feat: implement MC domain filter proxy, API, dashboard
- proxy: asyncio TCP proxy with handshake parser, domain whitelist, transparent backend tunneling, SQLite logging, mtime hot reload - api: FastAPI routes for config/domains/logs/status + restart trigger - frontend: React + Vite NPM-style dashboard (dashboard/domains/logs/settings) - nginx: reverse proxy for /api -> api:8000 and / -> frontend:3000 - docker-compose: full stack with shared data volume - replace spec mc-domain-filter.md with README.md
This commit is contained in:
32
frontend/src/App.jsx
Normal file
32
frontend/src/App.jsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { NavLink, Route, Routes } from 'react-router-dom'
|
||||
import Dashboard from './pages/Dashboard.jsx'
|
||||
import Domains from './pages/Domains.jsx'
|
||||
import Logs from './pages/Logs.jsx'
|
||||
import Settings from './pages/Settings.jsx'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<div className="layout">
|
||||
<aside className="sidebar">
|
||||
<div className="logo">
|
||||
<span className="dot" /> MC Filter
|
||||
</div>
|
||||
<nav>
|
||||
<NavLink to="/" end>대시보드</NavLink>
|
||||
<NavLink to="/domains">허용 도메인</NavLink>
|
||||
<NavLink to="/logs">접속 로그</NavLink>
|
||||
<NavLink to="/settings">설정</NavLink>
|
||||
</nav>
|
||||
<div className="sidebar-footer">v0.1.0</div>
|
||||
</aside>
|
||||
<main className="content">
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/domains" element={<Domains />} />
|
||||
<Route path="/logs" element={<Logs />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
36
frontend/src/api.js
Normal file
36
frontend/src/api.js
Normal file
@@ -0,0 +1,36 @@
|
||||
const base = '/api'
|
||||
|
||||
async function req(path, opts = {}) {
|
||||
const res = await fetch(base + path, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
...opts,
|
||||
})
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '')
|
||||
throw new Error(`${res.status}: ${text || res.statusText}`)
|
||||
}
|
||||
if (res.status === 204) return null
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export const api = {
|
||||
status: () => req('/status'),
|
||||
config: () => req('/config'),
|
||||
putConfig: (cfg) => req('/config', { method: 'PUT', body: JSON.stringify(cfg) }),
|
||||
domains: () => req('/domains'),
|
||||
addDomain: (d) => req('/domains', { method: 'POST', body: JSON.stringify(d) }),
|
||||
deleteDomain: (name) =>
|
||||
req(`/domains/${encodeURIComponent(name)}`, { method: 'DELETE' }),
|
||||
patchDomain: (name, body) =>
|
||||
req(`/domains/${encodeURIComponent(name)}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
logs: (params = {}) => {
|
||||
const q = new URLSearchParams(
|
||||
Object.fromEntries(Object.entries(params).filter(([, v]) => v !== '' && v != null))
|
||||
).toString()
|
||||
return req('/logs' + (q ? `?${q}` : ''))
|
||||
},
|
||||
restart: () => req('/proxy/restart', { method: 'POST' }),
|
||||
}
|
||||
13
frontend/src/main.jsx
Normal file
13
frontend/src/main.jsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import App from './App.jsx'
|
||||
import './styles.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
83
frontend/src/pages/Dashboard.jsx
Normal file
83
frontend/src/pages/Dashboard.jsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api } from '../api.js'
|
||||
|
||||
export default function Dashboard() {
|
||||
const [status, setStatus] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
setStatus(await api.status())
|
||||
setError(null)
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
const id = setInterval(load, 5000)
|
||||
return () => clearInterval(id)
|
||||
}, [])
|
||||
|
||||
async function toggle() {
|
||||
const cfg = await api.config()
|
||||
cfg.proxy.enabled = !cfg.proxy.enabled
|
||||
await api.putConfig(cfg)
|
||||
await load()
|
||||
}
|
||||
|
||||
if (error) return <div className="error">에러: {error}</div>
|
||||
if (!status) return <div className="muted">로딩 중…</div>
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>대시보드</h1>
|
||||
<div className="cards">
|
||||
<div className="card">
|
||||
<div className="card-title">프록시 상태</div>
|
||||
<div className={`card-value ${status.proxy_enabled ? 'ok' : 'warn'}`}>
|
||||
{status.proxy_enabled ? '동작 중' : '중지됨'}
|
||||
</div>
|
||||
<button onClick={toggle} className="mt">
|
||||
{status.proxy_enabled ? '끄기' : '켜기'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="card-title">총 접속 시도</div>
|
||||
<div className="card-value">{status.stats.total.toLocaleString()}</div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="card-title">허용</div>
|
||||
<div className="card-value ok">{status.stats.allowed.toLocaleString()}</div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="card-title">차단</div>
|
||||
<div className="card-value warn">{status.stats.blocked.toLocaleString()}</div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="card-title">리스닝 포트</div>
|
||||
<div className="card-value small">:{status.listen_port}</div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="card-title">백엔드</div>
|
||||
<div className="card-value small">
|
||||
{status.backend?.host}:{status.backend?.port}
|
||||
</div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="card-title">허용 도메인 수</div>
|
||||
<div className="card-value">{status.domain_count}</div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="card-title">마지막 이벤트</div>
|
||||
<div className="card-value small">
|
||||
{status.stats.last_event_ts
|
||||
? new Date(status.stats.last_event_ts * 1000).toLocaleString('ko-KR')
|
||||
: '-'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
115
frontend/src/pages/Domains.jsx
Normal file
115
frontend/src/pages/Domains.jsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api } from '../api.js'
|
||||
|
||||
export default function Domains() {
|
||||
const [domains, setDomains] = useState([])
|
||||
const [newDomain, setNewDomain] = useState('')
|
||||
const [newNote, setNewNote] = useState('')
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
setDomains(await api.domains())
|
||||
setError(null)
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [])
|
||||
|
||||
async function add(e) {
|
||||
e.preventDefault()
|
||||
if (!newDomain.trim()) return
|
||||
try {
|
||||
await api.addDomain({ domain: newDomain.trim(), enabled: true, note: newNote })
|
||||
setNewDomain('')
|
||||
setNewNote('')
|
||||
setError(null)
|
||||
await load()
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
async function toggle(d) {
|
||||
try {
|
||||
await api.patchDomain(d.domain, { enabled: !d.enabled })
|
||||
await load()
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(d) {
|
||||
if (!confirm(`${d.domain} 을(를) 삭제할까요?`)) return
|
||||
try {
|
||||
await api.deleteDomain(d.domain)
|
||||
await load()
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>허용 도메인</h1>
|
||||
{error && <div className="error">{error}</div>}
|
||||
<form onSubmit={add} className="form-row card">
|
||||
<input
|
||||
placeholder="mc.example.com"
|
||||
value={newDomain}
|
||||
onChange={(e) => setNewDomain(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
placeholder="메모 (선택)"
|
||||
value={newNote}
|
||||
onChange={(e) => setNewNote(e.target.value)}
|
||||
/>
|
||||
<button type="submit">추가</button>
|
||||
</form>
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>도메인</th>
|
||||
<th>메모</th>
|
||||
<th style={{ width: 80 }}>활성</th>
|
||||
<th style={{ width: 90 }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{domains.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={4} className="muted" style={{ textAlign: 'center', padding: 24 }}>
|
||||
등록된 도메인이 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{domains.map((d) => (
|
||||
<tr key={d.domain}>
|
||||
<td>
|
||||
<code>{d.domain}</code>
|
||||
</td>
|
||||
<td>{d.note}</td>
|
||||
<td>
|
||||
<label className="switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={d.enabled}
|
||||
onChange={() => toggle(d)}
|
||||
/>
|
||||
<span />
|
||||
</label>
|
||||
</td>
|
||||
<td>
|
||||
<button className="danger" onClick={() => remove(d)}>삭제</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
88
frontend/src/pages/Logs.jsx
Normal file
88
frontend/src/pages/Logs.jsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api } from '../api.js'
|
||||
|
||||
function fmtTime(ts) {
|
||||
return new Date(ts * 1000).toLocaleString('ko-KR')
|
||||
}
|
||||
|
||||
export default function Logs() {
|
||||
const [data, setData] = useState({ total: 0, items: [] })
|
||||
const [filter, setFilter] = useState('')
|
||||
const [auto, setAuto] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const params = { limit: 100 }
|
||||
if (filter) params.action = filter
|
||||
setData(await api.logs(params))
|
||||
setError(null)
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
if (!auto) return
|
||||
const id = setInterval(load, 3000)
|
||||
return () => clearInterval(id)
|
||||
}, [filter, auto])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>접속 로그</h1>
|
||||
{error && <div className="error">{error}</div>}
|
||||
<div className="toolbar">
|
||||
<select value={filter} onChange={(e) => setFilter(e.target.value)}>
|
||||
<option value="">전체</option>
|
||||
<option value="allowed">허용</option>
|
||||
<option value="blocked">차단</option>
|
||||
<option value="error">에러</option>
|
||||
</select>
|
||||
<label className="inline">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={auto}
|
||||
onChange={(e) => setAuto(e.target.checked)}
|
||||
/>
|
||||
자동 갱신 (3초)
|
||||
</label>
|
||||
<span className="muted">총 {data.total.toLocaleString()}건</span>
|
||||
</div>
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 170 }}>시각</th>
|
||||
<th style={{ width: 130 }}>클라이언트 IP</th>
|
||||
<th>도메인</th>
|
||||
<th style={{ width: 80 }}>상태</th>
|
||||
<th>사유</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.items.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="muted" style={{ textAlign: 'center', padding: 24 }}>
|
||||
기록된 접속이 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{data.items.map((row) => (
|
||||
<tr key={row.id} className={`row-${row.action}`}>
|
||||
<td>{fmtTime(row.ts)}</td>
|
||||
<td>{row.client_ip}</td>
|
||||
<td>
|
||||
<code>{row.domain || '-'}</code>
|
||||
</td>
|
||||
<td>
|
||||
<span className={`pill pill-${row.action}`}>{row.action}</span>
|
||||
</td>
|
||||
<td>{row.reason || '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
90
frontend/src/pages/Settings.jsx
Normal file
90
frontend/src/pages/Settings.jsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api } from '../api.js'
|
||||
|
||||
export default function Settings() {
|
||||
const [cfg, setCfg] = useState(null)
|
||||
const [msg, setMsg] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
api.config().then(setCfg).catch((e) => setError(e.message))
|
||||
}, [])
|
||||
|
||||
if (error) return <div className="error">{error}</div>
|
||||
if (!cfg) return <div className="muted">로딩 중…</div>
|
||||
|
||||
async function save() {
|
||||
try {
|
||||
await api.putConfig(cfg)
|
||||
setMsg('저장됨 (프록시는 2초 안에 자동 반영)')
|
||||
setError(null)
|
||||
setTimeout(() => setMsg(null), 2500)
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>설정</h1>
|
||||
<section className="card">
|
||||
<h2>프록시</h2>
|
||||
<label>
|
||||
리스닝 포트
|
||||
<input
|
||||
type="number"
|
||||
value={cfg.proxy.listen_port}
|
||||
onChange={(e) =>
|
||||
setCfg({
|
||||
...cfg,
|
||||
proxy: { ...cfg.proxy, listen_port: +e.target.value },
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="inline">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={cfg.proxy.enabled}
|
||||
onChange={(e) =>
|
||||
setCfg({
|
||||
...cfg,
|
||||
proxy: { ...cfg.proxy, enabled: e.target.checked },
|
||||
})
|
||||
}
|
||||
/>
|
||||
프록시 활성화
|
||||
</label>
|
||||
</section>
|
||||
<section className="card">
|
||||
<h2>백엔드 (실제 MC 서버)</h2>
|
||||
<label>
|
||||
호스트 (IP 또는 hostname)
|
||||
<input
|
||||
value={cfg.backend.host}
|
||||
onChange={(e) =>
|
||||
setCfg({ ...cfg, backend: { ...cfg.backend, host: e.target.value } })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
포트
|
||||
<input
|
||||
type="number"
|
||||
value={cfg.backend.port}
|
||||
onChange={(e) =>
|
||||
setCfg({
|
||||
...cfg,
|
||||
backend: { ...cfg.backend, port: +e.target.value },
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</section>
|
||||
<div className="actions">
|
||||
<button onClick={save}>저장</button>
|
||||
{msg && <span className="ok">{msg}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
301
frontend/src/styles.css
Normal file
301
frontend/src/styles.css
Normal file
@@ -0,0 +1,301 @@
|
||||
:root {
|
||||
color-scheme: dark light;
|
||||
--bg: #0f1115;
|
||||
--panel: #161a22;
|
||||
--panel-2: #1d2230;
|
||||
--border: #2a2f3a;
|
||||
--text: #e6e8eb;
|
||||
--muted: #8a93a6;
|
||||
--accent: #4f8cff;
|
||||
--ok: #3fb950;
|
||||
--warn: #f0883e;
|
||||
--danger: #f85149;
|
||||
}
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
--bg: #f5f6f8;
|
||||
--panel: #ffffff;
|
||||
--panel-2: #f0f2f5;
|
||||
--border: #d8dde6;
|
||||
--text: #1d2230;
|
||||
--muted: #5b6478;
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
code {
|
||||
font-family: ui-monospace, "SFMono-Regular", Consolas, monospace;
|
||||
background: var(--panel-2);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: 220px 1fr;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.sidebar {
|
||||
background: var(--panel);
|
||||
border-right: 1px solid var(--border);
|
||||
padding: 24px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.logo {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 8px var(--accent);
|
||||
}
|
||||
.sidebar nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
}
|
||||
.sidebar nav a {
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
padding: 10px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.sidebar nav a.active {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
.sidebar nav a:hover {
|
||||
background: var(--panel-2);
|
||||
color: var(--text);
|
||||
}
|
||||
.sidebar-footer {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 32px 40px;
|
||||
max-width: 1200px;
|
||||
}
|
||||
h1 {
|
||||
margin: 0 0 24px;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
}
|
||||
h2 {
|
||||
margin: 0 0 16px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
.card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
}
|
||||
.card-title {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
font-weight: 600;
|
||||
}
|
||||
.card-value {
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.card-value.ok { color: var(--ok); }
|
||||
.card-value.warn { color: var(--warn); }
|
||||
.card-value.small { font-size: 16px; font-weight: 600; }
|
||||
|
||||
button {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 14px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
}
|
||||
button:hover { filter: brightness(1.1); }
|
||||
button.danger { background: var(--danger); }
|
||||
button.mt { margin-top: 12px; }
|
||||
|
||||
input,
|
||||
select {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 8px 10px;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
}
|
||||
input:focus,
|
||||
select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
.form-row input { flex: 1; }
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.table th,
|
||||
.table td {
|
||||
padding: 10px 14px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 14px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.table th {
|
||||
background: var(--panel-2);
|
||||
color: var(--muted);
|
||||
font-weight: 600;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.table tr:last-child td { border-bottom: none; }
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.muted { color: var(--muted); font-size: 13px; }
|
||||
.ok { color: var(--ok); font-size: 13px; }
|
||||
|
||||
.error {
|
||||
background: rgba(248, 81, 73, 0.1);
|
||||
border: 1px solid var(--danger);
|
||||
color: var(--danger);
|
||||
padding: 12px 14px;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.switch {
|
||||
display: inline-block;
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
position: relative;
|
||||
}
|
||||
.switch input { opacity: 0; width: 0; height: 0; }
|
||||
.switch span {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
inset: 0;
|
||||
background: var(--border);
|
||||
border-radius: 20px;
|
||||
transition: 0.2s;
|
||||
}
|
||||
.switch span:before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
left: 2px;
|
||||
bottom: 2px;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
transition: 0.2s;
|
||||
}
|
||||
.switch input:checked + span { background: var(--ok); }
|
||||
.switch input:checked + span:before { transform: translateX(16px); }
|
||||
|
||||
section.card { margin-bottom: 16px; }
|
||||
section.card label {
|
||||
display: block;
|
||||
margin-bottom: 12px;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
section.card label input {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
color: var(--text);
|
||||
}
|
||||
section.card label.inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
}
|
||||
section.card label.inline input { width: auto; margin-top: 0; }
|
||||
label.inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
label.inline input { margin: 0; }
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.pill {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.pill-allowed { background: rgba(63, 185, 80, 0.15); color: var(--ok); }
|
||||
.pill-blocked { background: rgba(248, 81, 73, 0.15); color: var(--danger); }
|
||||
.pill-error { background: rgba(240, 136, 62, 0.15); color: var(--warn); }
|
||||
Reference in New Issue
Block a user