/** 관리자 사이트 (docs/14-admin.md). Downloaded only when /admin is opened. */ import { useCallback, useEffect, useState, type ReactNode } from 'react'; import { NavLink, Route, Routes, useNavigate, useParams } from 'react-router'; import { formatRoomCode } from '@bg/shared/lite'; import { ConfirmSheet, Sheet } from '../../components/ui'; import { apiCall, discordLoginUrl, type Role } from '../../net/api'; import { useSession } from '../../store/session'; import { toast } from '../../store/toast'; import { SchemaForm, type JsonSchema } from './SchemaForm'; const a = { get: (p: string) => apiCall('GET', `/api/admin${p}`), put: (p: string, b: unknown) => apiCall('PUT', `/api/admin${p}`, b), post: (p: string, b: unknown = {}) => apiCall('POST', `/api/admin${p}`, b), patch: (p: string, b: unknown) => apiCall('PATCH', `/api/admin${p}`, b), del: (p: string) => apiCall('DELETE', `/api/admin${p}`), }; function useLoad(path: string): [T | null, () => void, string | null] { const [data, setData] = useState(null); const [err, setErr] = useState(null); const load = useCallback(() => { a.get(path).then( (d) => (setData(d), setErr(null)), (e) => setErr((e as Error).message), ); }, [path]); useEffect(load, [load]); return [data, load, err]; } const time = (ms: number) => (ms ? new Date(ms).toLocaleString('ko-KR', { dateStyle: 'short', timeStyle: 'short' }) : '-'); /** Idle guests are deleted a day after they leave the site (docs/05 §5). */ const guestDeletion = (u: { kind: string; deleteAt: number | null }) => u.kind !== 'guest' ? null : u.deleteAt ? `삭제 예정 ${time(u.deleteAt)}` : '사이트나 방에 남아 있어 삭제 보류'; export default function AdminApp() { const nav = useNavigate(); const [role, setRole] = useState(null); useEffect(() => { a.get<{ role: Role }>('/me').then( (r) => setRole(r.role), () => setRole('denied'), ); }, []); if (role === null) return
확인 중…
; if (role === 'denied' || role === 'user') return ; const sup = role === 'superadmin'; const tabs: [string, string, boolean][] = [ ['', '대시보드', true], ['games', '게임', true], ['rooms', '방', true], ['site', '사이트', true], ['users', '사용자', sup], ['admins', '관리자', sup], ['audit', '기록', true], ]; return (
} /> } /> } /> } /> } /> {sup && } />} {sup && } />} } />
); } function Denied() { const me = useSession((s) => s.me); const discord = useSession((s) => s.discordEnabled); return (

권한이 없어요

관리자 페이지는 관리자로 등록된 디스코드 계정만 볼 수 있어요.

{discord && me?.kind !== 'member' && ( 디스코드로 로그인 )} 홈으로
); } function Stat({ label, value }: { label: string; value: ReactNode }) { return (
{label}
{value}
); } // ------------------------------------------------------------------ dashboard function Dashboard() { const [d] = useLoad('/overview'); if (!d) return

불러오는 중…

; return (
); } // ------------------------------------------------------------------ games interface AdminGame { id: string; codeDefaults: { nameKo: string; minPlayers: number; maxPlayers: number; defaultOptions: unknown }; raw: Record; effective: { enabled: boolean; nameKo: string; blurb: string; notice: string; minPlayers: number; maxPlayers: number; defaultOptions: unknown; lockedOptions: string[] }; schema: JsonSchema; } function Games() { const [d] = useLoad<{ games: AdminGame[] }>('/games'); const nav = useNavigate(); if (!d) return

불러오는 중…

; return (

설정을 바꾸면 새로 만드는 방과 새로 시작하는 판부터 적용돼요. 진행 중인 판은 그대로예요.

{d.games.map((g) => ( ))}
); } function GameEditor() { const { id = '' } = useParams(); const nav = useNavigate(); const [d, reload] = useLoad<{ games: AdminGame[] }>('/games'); const g = d?.games.find((x) => x.id === id); const [form, setForm] = useState<{ enabled: boolean; nameKo: string; blurb: string; notice: string; minPlayers: number; maxPlayers: number; defaultOptions: unknown; locked: Set } | null>(null); const [ask, setAsk] = useState(false); useEffect(() => { if (g) setForm({ ...g.effective, locked: new Set(g.effective.lockedOptions) }); }, [g?.id, d]); // eslint-disable-line react-hooks/exhaustive-deps if (!d) return

불러오는 중…

; if (!g || !form) return

없는 게임이에요.

; const cd = g.codeDefaults; const save = async () => { try { await a.put(`/games/${id}`, { enabled: form.enabled, nameKo: form.nameKo, blurb: form.blurb, notice: form.notice, minPlayers: form.minPlayers, maxPlayers: form.maxPlayers, defaultOptions: form.defaultOptions, lockedOptions: [...form.locked], }); toast('저장했어요.'); reload(); void useSession.getState().refresh(); } catch (e) { toast((e as Error).message, 'error', 6000); } }; return (

{g.effective.nameKo} 설정

기본 정보

사용