M1.5 관리자 사이트: 슈퍼어드민/어드민 권한, 게임·사이트 설정, 방·사용자 관리, 기록
- 권한: 디스코드 ID로 요청마다 계산. 슈퍼어드민 293719842274541579(SUPERADMIN_DISCORD_IDS), 어드민은 슈퍼어드민이 디스코드 ID로 추가(로그인 전 미리 등록 가능), 고정 슈퍼어드민은 삭제 불가 - 게임 설정: 사용 여부, 표시 이름·설명·안내문, 원작 범위 안 인원, 모든 규칙 옵션 기본값, 옵션별 방장 변경 금지(잠금), 초기화. 입력 화면은 옵션 스키마(zod→JSON Schema)로 자동 생성 - 오목 옵션 전체에 한국어 제목·선택지 메타, 엔진 고정값(자동 착수 허용 횟수, 무승부 재제안 간격, 한 수 제한 초)을 옵션으로 꺼냄 - 사이트 설정: 이름, 공지, 점검 모드, 금지어, 방 수 제한, 연결 유예, 채팅 기본값 - 방 관리(목록·닫기), 사용자 관리(검색·닉네임 변경·이용 제한·강제 로그아웃·삭제, 슈퍼어드민 전용), 관리자 작업 기록(전/후 값) - 이용 제한: 세션 삭제 + 접속 종료 + 디스코드 재로그인 거부 - 테스트: 관리자 API 7개(권한·설정 반영·잠금·점검·금지어·방 닫기·이용 제한·기록), 오목 옵션 2개, e2e/admin.e2e.ts(슈퍼어드민 로그인→규칙 변경·잠금→어드민 추가→게스트 새 방 반영) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -11,7 +11,8 @@ import { GAME_ICON } from '../lib/catalog-ui';
|
||||
|
||||
export default function GamePage() {
|
||||
const { gameId = '' } = useParams();
|
||||
const g = catalogById(gameId);
|
||||
const catalog = useSession((s) => s.catalog);
|
||||
const g = catalog.find((c) => c.id === gameId) ?? catalogById(gameId);
|
||||
const nav = useNavigate();
|
||||
const me = useSession((s) => s.me);
|
||||
const [rooms, setRooms] = useState<{ code: string; host: string; seated: number; max: number }[]>([]);
|
||||
|
||||
@@ -11,7 +11,7 @@ import { formatRoomCode } from '@bg/shared/lite';
|
||||
import { GAME_ICON, UPCOMING } from '../lib/catalog-ui';
|
||||
|
||||
export function Home() {
|
||||
const { me, activeRoom } = useSession();
|
||||
const { me, activeRoom, siteName } = useSession();
|
||||
const catalog = useSession((s) => (s.catalog.length ? s.catalog : CATALOG));
|
||||
const nav = useNavigate();
|
||||
const [picker, setPicker] = useState(false);
|
||||
@@ -37,7 +37,7 @@ export function Home() {
|
||||
return (
|
||||
<main className="page">
|
||||
<header className="row" style={{ justifyContent: 'space-between' }}>
|
||||
<h1>같이 놀자 보드게임</h1>
|
||||
<h1>{siteName}</h1>
|
||||
<Link to="/me" className="btn btn-ghost" aria-label="내 정보">
|
||||
{me ? <Avatar user={me} size={36} /> : '👤'} <span className="small">{me ? me.nickname : '내 정보'}</span>
|
||||
</Link>
|
||||
|
||||
@@ -25,7 +25,7 @@ function Seg<T extends string>({ value, options, onChange, label }: { value: T;
|
||||
|
||||
export default function MePage() {
|
||||
const nav = useNavigate();
|
||||
const { me, settings, discordEnabled, useAvatar, hasDiscordAvatar, updateSettings } = useSession();
|
||||
const { me, settings, discordEnabled, useAvatar, hasDiscordAvatar, updateSettings, role } = useSession();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [nick, setNick] = useState(me?.nickname ?? '');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -128,6 +128,12 @@ export default function MePage() {
|
||||
)}
|
||||
</section>
|
||||
|
||||
{role !== 'user' && (
|
||||
<button className="btn btn-soft btn-block" onClick={() => nav('/admin')}>
|
||||
🛠 관리자 페이지 ({role === 'superadmin' ? '슈퍼어드민' : '어드민'})
|
||||
</button>
|
||||
)}
|
||||
|
||||
{display}
|
||||
|
||||
<section className="card stack">
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { create } from 'zustand';
|
||||
import { useNavigate, useParams } from 'react-router';
|
||||
import { CHAT_MAX, EMOTE_LABEL, EMOTES, catalogById, formatRoomCode, type PublicUser, type RoomView } from '@bg/shared/lite';
|
||||
import { CHAT_MAX, EMOTE_LABEL, EMOTES, formatRoomCode, type PublicUser, type RoomView } from '@bg/shared/lite';
|
||||
import { Avatar, CodeLabel, ConfirmSheet, CopyButton, ShareButton, Sheet, TopBar } from '../components/ui';
|
||||
import { NicknameForm } from '../components/NicknameForm';
|
||||
import { GameNotice, gameName } from '../components/GameNotice';
|
||||
import { QrModal } from '../components/QrModal';
|
||||
import { api, type RoomPreview } from '../net/api';
|
||||
import { socket, useRoom } from '../net/socket';
|
||||
@@ -44,7 +45,7 @@ export default function RoomPage() {
|
||||
{preview ? (
|
||||
<>
|
||||
<h1>
|
||||
{catalogById(preview.gameId)?.nameKo ?? '게임'} 방에 초대받았어요
|
||||
{gameName(preview.gameId)} 방에 초대받았어요
|
||||
</h1>
|
||||
<p className="muted">
|
||||
방장 {preview.host} · {preview.seated}/{preview.maxPlayers}명 · 코드 {preview.codeLabel}
|
||||
@@ -132,7 +133,7 @@ function Room({ code, me }: { code: string; me: PublicUser }) {
|
||||
);
|
||||
}
|
||||
|
||||
const g = catalogById(room.gameId);
|
||||
const g = { nameKo: gameName(room.gameId) };
|
||||
const inGame = room.status === 'playing' || room.status === 'paused' || (room.status === 'finished' && !!game);
|
||||
const isHost = room.hostId === me.id;
|
||||
const doLeave = () => {
|
||||
@@ -241,9 +242,9 @@ function Lobby({ room, ui, me }: { room: RoomView; ui: GameUI; me: PublicUser })
|
||||
const seated = room.seats.filter(Boolean).length;
|
||||
const need = Math.max(0, room.minPlayers - seated);
|
||||
const url = `${location.origin}/r/${room.code}`;
|
||||
const gameName = catalogById(room.gameId)?.nameKo ?? '게임';
|
||||
const gName = gameName(room.gameId);
|
||||
const host = room.seats.find((s) => s?.user.id === room.hostId)?.user.nickname ?? me.nickname;
|
||||
const shareText = `${host}님이 ${gameName} 방에 초대했어요! 코드 ${formatRoomCode(room.code)}`;
|
||||
const shareText = `${host}님이 ${gName} 방에 초대했어요! 코드 ${formatRoomCode(room.code)}`;
|
||||
const notReady = room.seats.filter((s) => s && !s.ready && s.user.id !== room.hostId).length;
|
||||
const [askStart, setAskStart] = useState(false);
|
||||
|
||||
@@ -257,7 +258,7 @@ function Lobby({ room, ui, me }: { room: RoomView; ui: GameUI; me: PublicUser })
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
|
||||
<CopyButton text={room.code} label="🔢 코드 복사" />
|
||||
<CopyButton text={url} label="🔗 링크 복사" />
|
||||
<ShareButton title={`${gameName} 같이 해요`} text={shareText} url={url} />
|
||||
<ShareButton title={`${gName} 같이 해요`} text={shareText} url={url} />
|
||||
<button className="btn btn-secondary" onClick={() => setQr(true)}>
|
||||
📷 QR 보기
|
||||
</button>
|
||||
@@ -315,6 +316,8 @@ function Lobby({ room, ui, me }: { room: RoomView; ui: GameUI; me: PublicUser })
|
||||
<p className="muted small">
|
||||
{room.visibility === 'public' ? '공개 방 (누구나 들어올 수 있어요)' : '친구만 (코드·링크로만 들어와요)'}
|
||||
</p>
|
||||
{room.lockedOptions.length > 0 && <p className="muted small">🔒 일부 규칙은 관리자가 정해 두어서 바꿀 수 없어요.</p>}
|
||||
<GameNotice gameId={room.gameId} />
|
||||
</section>
|
||||
|
||||
<div className="bottom-bar">
|
||||
@@ -406,6 +409,7 @@ function SettingsSheet({ room, ui, onClose }: { room: RoomView; ui: GameUI; onCl
|
||||
return (
|
||||
<Sheet onClose={onClose} label="게임 설정">
|
||||
<h2>게임 설정</h2>
|
||||
{room.lockedOptions.length > 0 && <p className="muted small">🔒 관리자가 고정한 규칙은 바꿔도 저장되지 않아요.</p>}
|
||||
<ui.Options value={options} onChange={setOptions} />
|
||||
<hr style={{ border: 0, borderTop: '1px solid var(--border)', width: '100%' }} />
|
||||
<strong>방 공개</strong>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router';
|
||||
import { catalogById } from '@bg/shared/lite';
|
||||
import { TopBar } from '../components/ui';
|
||||
import { loadGameUI } from '../games/registry';
|
||||
import type { GameUI } from '../games/types';
|
||||
import { GameNotice, gameName } from '../components/GameNotice';
|
||||
|
||||
export default function RulesPage() {
|
||||
const { gameId = '' } = useParams();
|
||||
@@ -12,11 +12,12 @@ export default function RulesPage() {
|
||||
useEffect(() => {
|
||||
loadGameUI(gameId).then(setUi);
|
||||
}, [gameId]);
|
||||
const g = catalogById(gameId);
|
||||
const g = { nameKo: gameName(gameId) };
|
||||
return (
|
||||
<>
|
||||
<TopBar title={`${g?.nameKo ?? ''} 규칙`} onBack={() => nav(-1)} />
|
||||
<main className="page">
|
||||
<GameNotice gameId={gameId} />
|
||||
{ui === undefined ? <p className="muted">불러오는 중…</p> : ui ? <ui.Rules /> : <p>아직 준비 중인 게임이에요.</p>}
|
||||
</main>
|
||||
</>
|
||||
|
||||
657
apps/web/src/pages/admin/AdminApp.tsx
Normal file
657
apps/web/src/pages/admin/AdminApp.tsx
Normal file
@@ -0,0 +1,657 @@
|
||||
/** 관리자 사이트 (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, TopBar } 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: <T,>(p: string) => apiCall<T>('GET', `/api/admin${p}`),
|
||||
put: <T,>(p: string, b: unknown) => apiCall<T>('PUT', `/api/admin${p}`, b),
|
||||
post: <T,>(p: string, b: unknown = {}) => apiCall<T>('POST', `/api/admin${p}`, b),
|
||||
patch: <T,>(p: string, b: unknown) => apiCall<T>('PATCH', `/api/admin${p}`, b),
|
||||
del: <T,>(p: string) => apiCall<T>('DELETE', `/api/admin${p}`),
|
||||
};
|
||||
|
||||
function useLoad<T>(path: string): [T | null, () => void, string | null] {
|
||||
const [data, setData] = useState<T | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const load = useCallback(() => {
|
||||
a.get<T>(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' }) : '-');
|
||||
|
||||
export default function AdminApp() {
|
||||
const nav = useNavigate();
|
||||
const [role, setRole] = useState<Role | null | 'denied'>(null);
|
||||
useEffect(() => {
|
||||
a.get<{ role: Role }>('/me').then(
|
||||
(r) => setRole(r.role),
|
||||
() => setRole('denied'),
|
||||
);
|
||||
}, []);
|
||||
if (role === null) return <main className="page muted">확인 중…</main>;
|
||||
if (role === 'denied' || role === 'user') return <Denied />;
|
||||
const sup = role === 'superadmin';
|
||||
const tabs: [string, string, boolean][] = [
|
||||
['', '대시보드', true],
|
||||
['games', '게임', true],
|
||||
['rooms', '방', true],
|
||||
['site', '사이트', true],
|
||||
['users', '사용자', sup],
|
||||
['admins', '관리자', sup],
|
||||
['audit', '기록', true],
|
||||
];
|
||||
return (
|
||||
<>
|
||||
<TopBar title={`관리자 · ${sup ? '슈퍼어드민' : '어드민'}`} onBack={() => nav('/me')} />
|
||||
<nav className="row" style={{ padding: '8px 12px', gap: 6, overflowX: 'auto', flexWrap: 'nowrap', borderBottom: '1px solid var(--border)' }} aria-label="관리자 메뉴">
|
||||
{tabs
|
||||
.filter((t) => t[2])
|
||||
.map(([to, label]) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to ? `/admin/${to}` : "/admin"}
|
||||
end={to === ''}
|
||||
className={({ isActive }) => `btn btn-sm ${isActive ? 'btn-primary' : 'btn-secondary'}`}
|
||||
style={{ whiteSpace: 'nowrap' }}
|
||||
>
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
<main className="page">
|
||||
<Routes>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="games" element={<Games />} />
|
||||
<Route path="games/:id" element={<GameEditor />} />
|
||||
<Route path="rooms" element={<Rooms />} />
|
||||
<Route path="site" element={<Site />} />
|
||||
{sup && <Route path="users" element={<Users />} />}
|
||||
{sup && <Route path="admins" element={<Admins />} />}
|
||||
<Route path="audit" element={<Audit />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Denied() {
|
||||
const me = useSession((s) => s.me);
|
||||
const discord = useSession((s) => s.discordEnabled);
|
||||
return (
|
||||
<main className="page">
|
||||
<section className="card stack">
|
||||
<h1>권한이 없어요</h1>
|
||||
<p className="muted">관리자 페이지는 관리자로 등록된 디스코드 계정만 볼 수 있어요.</p>
|
||||
{discord && me?.kind !== 'member' && (
|
||||
<a className="btn btn-primary btn-block" href={discordLoginUrl('/admin')}>
|
||||
디스코드로 로그인
|
||||
</a>
|
||||
)}
|
||||
<a className="btn btn-secondary btn-block" href="/">
|
||||
홈으로
|
||||
</a>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value }: { label: string; value: ReactNode }) {
|
||||
return (
|
||||
<div className="card" style={{ padding: 14 }}>
|
||||
<div className="muted small">{label}</div>
|
||||
<div style={{ fontSize: '1.6rem', fontWeight: 800 }}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ dashboard
|
||||
|
||||
function Dashboard() {
|
||||
const [d] = useLoad<any>('/overview');
|
||||
if (!d) return <p className="muted">불러오는 중…</p>;
|
||||
return (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(150px, 1fr))', gap: 12 }}>
|
||||
<Stat label="지금 접속" value={d.connections} />
|
||||
<Stat label="열린 방" value={d.rooms.total} />
|
||||
<Stat label="게임 중인 방" value={d.rooms.playing} />
|
||||
<Stat label="오늘 시작한 판" value={d.games?.today ?? 0} />
|
||||
<Stat label="전체 판" value={d.games?.total ?? 0} />
|
||||
<Stat label="사용자" value={d.users.total} />
|
||||
<Stat label="게스트 / 디스코드" value={`${d.users.guests ?? 0} / ${d.users.members ?? 0}`} />
|
||||
<Stat label="이용 제한" value={d.users.banned ?? 0} />
|
||||
<Stat label="가동 시간" value={`${Math.floor(d.uptimeSec / 3600)}시간 ${Math.floor((d.uptimeSec % 3600) / 60)}분`} />
|
||||
<Stat label="메모리" value={`${d.memoryMb}MB`} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ games
|
||||
|
||||
interface AdminGame {
|
||||
id: string;
|
||||
codeDefaults: { nameKo: string; minPlayers: number; maxPlayers: number; defaultOptions: unknown };
|
||||
raw: Record<string, unknown>;
|
||||
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 <p className="muted">불러오는 중…</p>;
|
||||
return (
|
||||
<div className="stack">
|
||||
<p className="muted">설정을 바꾸면 새로 만드는 방과 새로 시작하는 판부터 적용돼요. 진행 중인 판은 그대로예요.</p>
|
||||
{d.games.map((g) => (
|
||||
<button key={g.id} className="card row" style={{ border: 0, cursor: 'pointer', textAlign: 'left', justifyContent: 'space-between' }} onClick={() => nav(`/admin/games/${g.id}`)}>
|
||||
<div>
|
||||
<strong>{g.effective.nameKo}</strong> <span className="muted small">({g.id})</span>
|
||||
<div className="muted small">
|
||||
{g.effective.minPlayers}~{g.effective.maxPlayers}명 · 잠긴 옵션 {g.effective.lockedOptions.length}개
|
||||
</div>
|
||||
</div>
|
||||
{g.effective.enabled ? <span className="badge success">사용 중</span> : <span className="badge warning">꺼짐</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<string> } | 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 <p className="muted">불러오는 중…</p>;
|
||||
if (!g || !form) return <p>없는 게임이에요.</p>;
|
||||
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 (
|
||||
<div className="stack" style={{ gap: 16 }}>
|
||||
<button className="btn btn-ghost" style={{ alignSelf: 'flex-start' }} onClick={() => nav('/admin/games')}>
|
||||
← 게임 목록
|
||||
</button>
|
||||
<h1>{g.effective.nameKo} 설정</h1>
|
||||
<section className="card stack">
|
||||
<h2>기본 정보</h2>
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>사용</strong>
|
||||
<div className="segmented">
|
||||
<button type="button" aria-pressed={form.enabled} onClick={() => setForm({ ...form, enabled: true })}>
|
||||
사용
|
||||
</button>
|
||||
<button type="button" aria-pressed={!form.enabled} onClick={() => setForm({ ...form, enabled: false })}>
|
||||
끄기(새 방 막기)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<label className="field">
|
||||
표시 이름 <span className="muted small">(원래: {cd.nameKo})</span>
|
||||
<input className="input" value={form.nameKo} maxLength={30} onChange={(e) => setForm({ ...form, nameKo: e.target.value })} />
|
||||
</label>
|
||||
<label className="field">
|
||||
한 줄 설명
|
||||
<input className="input" value={form.blurb} maxLength={80} onChange={(e) => setForm({ ...form, blurb: e.target.value })} />
|
||||
</label>
|
||||
<label className="field">
|
||||
안내문(규칙 화면 위에 표시)
|
||||
<textarea className="input" style={{ minHeight: 90, padding: 12 }} value={form.notice} maxLength={500} onChange={(e) => setForm({ ...form, notice: e.target.value })} />
|
||||
</label>
|
||||
<div className="row">
|
||||
<label className="field" style={{ flex: 1 }}>
|
||||
최소 인원 ({cd.minPlayers}~{cd.maxPlayers})
|
||||
<input className="input" type="number" min={cd.minPlayers} max={cd.maxPlayers} value={form.minPlayers} onChange={(e) => setForm({ ...form, minPlayers: Number(e.target.value) })} />
|
||||
</label>
|
||||
<label className="field" style={{ flex: 1 }}>
|
||||
최대 인원 ({cd.minPlayers}~{cd.maxPlayers})
|
||||
<input className="input" type="number" min={cd.minPlayers} max={cd.maxPlayers} value={form.maxPlayers} onChange={(e) => setForm({ ...form, maxPlayers: Number(e.target.value) })} />
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
<section className="card stack">
|
||||
<h2>기본 규칙</h2>
|
||||
<p className="muted small">방을 만들 때 처음 들어가는 값이에요. 🔒을 켜면 방장이 바꿀 수 없어요.</p>
|
||||
<SchemaForm
|
||||
schema={g.schema}
|
||||
value={form.defaultOptions}
|
||||
onChange={(v) => setForm({ ...form, defaultOptions: v })}
|
||||
locked={form.locked}
|
||||
onToggleLock={(p) => {
|
||||
const next = new Set(form.locked);
|
||||
if (next.has(p)) next.delete(p);
|
||||
else next.add(p);
|
||||
setForm({ ...form, locked: next });
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
<div className="bottom-bar">
|
||||
<div className="bottom-bar-inner">
|
||||
<button className="btn btn-primary" onClick={save}>
|
||||
저장
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={() => setAsk(true)}>
|
||||
처음 값으로
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{ask && (
|
||||
<ConfirmSheet
|
||||
title="코드 기본값으로 되돌릴까요?"
|
||||
body="이 게임의 관리자 설정이 모두 지워져요."
|
||||
confirm="되돌리기"
|
||||
danger
|
||||
onConfirm={async () => {
|
||||
await a.post(`/games/${id}/reset`);
|
||||
toast('처음 값으로 되돌렸어요.');
|
||||
reload();
|
||||
void useSession.getState().refresh();
|
||||
}}
|
||||
onClose={() => setAsk(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ rooms
|
||||
|
||||
function Rooms() {
|
||||
const [d, reload] = useLoad<{ rooms: any[] }>('/rooms');
|
||||
const [closing, setClosing] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
const t = setInterval(reload, 5000);
|
||||
return () => clearInterval(t);
|
||||
}, [reload]);
|
||||
const catalog = useSession((s) => s.catalog);
|
||||
if (!d) return <p className="muted">불러오는 중…</p>;
|
||||
const STATUS: Record<string, string> = { lobby: '대기 중', playing: '게임 중', finished: '끝남', paused: '멈춤', broken: '복구 실패' };
|
||||
return (
|
||||
<div className="stack">
|
||||
{d.rooms.length === 0 && <p className="muted">열린 방이 없어요.</p>}
|
||||
{d.rooms.map((r) => (
|
||||
<div key={r.code} className="card stack" style={{ gap: 6 }}>
|
||||
<div className="row" style={{ justifyContent: 'space-between' }}>
|
||||
<strong>
|
||||
{catalog.find((c) => c.id === r.gameId)?.nameKo ?? r.gameId} · {formatRoomCode(r.code)}
|
||||
</strong>
|
||||
<span className="badge">{STATUS[r.status] ?? r.status}</span>
|
||||
</div>
|
||||
<div className="small">
|
||||
{r.seats.map((s: any, i: number) => (s ? `${s.nickname}${s.connected ? '' : '(끊김)'}` : `빈자리${i + 1}`)).join(', ')}
|
||||
</div>
|
||||
<div className="muted small">
|
||||
방장 {r.host ?? '-'} · 관전 {r.spectators} · 접속 {r.connections} · {r.visibility === 'public' ? '공개' : '비공개'} · {r.gameNo}판 · 마지막 활동 {time(r.lastActiveAt)}
|
||||
</div>
|
||||
<div className="row">
|
||||
<a className="btn btn-secondary btn-sm" href={`/r/${r.code}`}>
|
||||
들어가 보기
|
||||
</a>
|
||||
<button className="btn btn-danger btn-sm" onClick={() => setClosing(r.code)}>
|
||||
방 닫기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{closing && (
|
||||
<ConfirmSheet
|
||||
title={`방 ${formatRoomCode(closing)}을 닫을까요?`}
|
||||
body="진행 중인 판은 무효가 되고 모두 방에서 나가요."
|
||||
confirm="닫기"
|
||||
danger
|
||||
onConfirm={async () => {
|
||||
await a.post(`/rooms/${closing}/close`).catch((e) => toast((e as Error).message, 'error'));
|
||||
reload();
|
||||
}}
|
||||
onClose={() => setClosing(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ site
|
||||
|
||||
function Site() {
|
||||
const [d] = useLoad<{ site: any }>('/site');
|
||||
const [s, setS] = useState<any>(null);
|
||||
useEffect(() => {
|
||||
if (d) setS({ ...d.site, bannedText: d.site.bannedWords.join('\n') });
|
||||
}, [d]);
|
||||
if (!s) return <p className="muted">불러오는 중…</p>;
|
||||
const num = (k: string, label: string, min: number, max: number) => (
|
||||
<label className="field">
|
||||
{label} <span className="muted small">({min}~{max})</span>
|
||||
<input className="input" type="number" min={min} max={max} value={s[k]} onChange={(e) => setS({ ...s, [k]: Number(e.target.value) })} />
|
||||
</label>
|
||||
);
|
||||
const bool = (k: string, label: string, on = '켜기', off = '끄기') => (
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>{label}</strong>
|
||||
<div className="segmented">
|
||||
<button type="button" aria-pressed={s[k]} onClick={() => setS({ ...s, [k]: true })}>
|
||||
{on}
|
||||
</button>
|
||||
<button type="button" aria-pressed={!s[k]} onClick={() => setS({ ...s, [k]: false })}>
|
||||
{off}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
const save = async () => {
|
||||
const { bannedText, ...rest } = s;
|
||||
try {
|
||||
await a.put('/site', { ...rest, bannedWords: String(bannedText).split('\n').map((w: string) => w.trim()).filter(Boolean) });
|
||||
toast('저장했어요.');
|
||||
void useSession.getState().refresh();
|
||||
} catch (e) {
|
||||
toast((e as Error).message, 'error', 6000);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="stack" style={{ gap: 16 }}>
|
||||
<section className="card stack">
|
||||
<label className="field">
|
||||
사이트 이름
|
||||
<input className="input" value={s.siteName} maxLength={40} onChange={(e) => setS({ ...s, siteName: e.target.value })} />
|
||||
</label>
|
||||
<label className="field">
|
||||
공지(모든 화면 위에 표시, 비우면 숨김)
|
||||
<textarea className="input" style={{ minHeight: 80, padding: 12 }} maxLength={300} value={s.announcement} onChange={(e) => setS({ ...s, announcement: e.target.value })} />
|
||||
</label>
|
||||
{bool('maintenance', '점검 모드(새 방 만들기 막기)', '점검 중', '정상')}
|
||||
</section>
|
||||
<section className="card stack">
|
||||
{num('maxRoomsPerUser', '사용자당 동시에 만들 수 있는 방', 1, 50)}
|
||||
{num('maxRooms', '서버 전체 방 수 제한', 10, 20000)}
|
||||
{num('graceSec', '연결 끊김 유예(초, 새 방 기본값)', 10, 600)}
|
||||
{bool('chatEnabledDefault', '새 방 채팅 기본값', '켜기', '끄기')}
|
||||
{bool('chatFilterDefault', '새 방 욕설 필터 기본값', '켜기', '끄기')}
|
||||
</section>
|
||||
<section className="card stack">
|
||||
<label className="field">
|
||||
금지어(한 줄에 하나, 채팅에서 가리고 닉네임에 못 씀)
|
||||
<textarea className="input" style={{ minHeight: 140, padding: 12 }} value={s.bannedText} onChange={(e) => setS({ ...s, bannedText: e.target.value })} />
|
||||
</label>
|
||||
</section>
|
||||
<div className="bottom-bar">
|
||||
<div className="bottom-bar-inner">
|
||||
<button className="btn btn-primary" onClick={save}>
|
||||
저장
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ users (superadmin)
|
||||
|
||||
function Users() {
|
||||
const [q, setQ] = useState('');
|
||||
const [query, setQuery] = useState('');
|
||||
const [d, reload] = useLoad<{ total: number; users: any[] }>(`/users?q=${encodeURIComponent(query)}`);
|
||||
const [open, setOpen] = useState<string | null>(null);
|
||||
return (
|
||||
<div className="stack">
|
||||
<form
|
||||
className="row"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
setQuery(q.trim());
|
||||
}}
|
||||
>
|
||||
<input className="input" style={{ flex: 1 }} placeholder="닉네임, 사용자 ID, 디스코드 ID" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||
<button className="btn btn-primary">검색</button>
|
||||
</form>
|
||||
{d && <p className="muted small">전체 {d.total}명</p>}
|
||||
{d?.users.map((u) => (
|
||||
<button key={u.id} className="card row" style={{ border: 0, cursor: 'pointer', textAlign: 'left', justifyContent: 'space-between' }} onClick={() => setOpen(u.id)}>
|
||||
<div>
|
||||
<strong>{u.nickname}</strong> {u.role !== 'user' && <span className="badge">{u.role === 'superadmin' ? '슈퍼어드민' : '어드민'}</span>}
|
||||
<div className="muted small">
|
||||
{u.kind === 'member' ? `디스코드 ${u.discordName ?? ''} (${u.discordId})` : '게스트'} · 마지막 접속 {time(u.lastSeenAt)}
|
||||
</div>
|
||||
</div>
|
||||
{u.banned && <span className="badge warning">제한</span>}
|
||||
</button>
|
||||
))}
|
||||
{open && <UserSheet id={open} onClose={() => (setOpen(null), reload())} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UserSheet({ id, onClose }: { id: string; onClose: () => void }) {
|
||||
const [d, reload] = useLoad<{ user: any; stats: any[] }>(`/users/${id}`);
|
||||
const [nick, setNick] = useState('');
|
||||
const [reason, setReason] = useState('');
|
||||
const [ask, setAsk] = useState<null | 'delete' | 'logout'>(null);
|
||||
useEffect(() => {
|
||||
if (d) setNick(d.user.nickname);
|
||||
}, [d]);
|
||||
const run = async (p: Promise<unknown>, msg: string) => {
|
||||
try {
|
||||
await p;
|
||||
toast(msg);
|
||||
reload();
|
||||
} catch (e) {
|
||||
toast((e as Error).message, 'error');
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Sheet onClose={onClose} label="사용자 관리">
|
||||
{!d ? (
|
||||
<p className="muted">불러오는 중…</p>
|
||||
) : (
|
||||
<>
|
||||
<h2>{d.user.nickname}</h2>
|
||||
<p className="muted small">
|
||||
ID {d.user.id}
|
||||
<br />
|
||||
{d.user.discordId ? `디스코드 ID ${d.user.discordId}` : '게스트'} · 가입 {time(d.user.createdAt)} · 마지막 접속 {time(d.user.lastSeenAt)}
|
||||
{d.user.activeRoom && <> · 지금 방 {formatRoomCode(d.user.activeRoom)}</>}
|
||||
</p>
|
||||
<p className="small">{d.stats.length ? d.stats.map((s) => `${s.gameId} ${s.played}판 ${s.wins}승`).join(' · ') : '기록 없음'}</p>
|
||||
<div className="row">
|
||||
<input className="input" style={{ flex: 1 }} value={nick} onChange={(e) => setNick(e.target.value)} maxLength={20} aria-label="닉네임" />
|
||||
<button className="btn btn-secondary" onClick={() => run(a.patch(`/users/${id}`, { nickname: nick }), '닉네임을 바꿨어요.')}>
|
||||
닉네임 변경
|
||||
</button>
|
||||
</div>
|
||||
{d.user.banned ? (
|
||||
<div className="card stack" style={{ background: 'var(--warning-soft)' }}>
|
||||
<p>
|
||||
이용 제한 중: {d.user.banned.reason} ({time(d.user.banned.at)})
|
||||
</p>
|
||||
<button className="btn btn-primary" onClick={() => run(a.patch(`/users/${id}`, { ban: null }), '제한을 풀었어요.')}>
|
||||
제한 해제
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="row">
|
||||
<input className="input" style={{ flex: 1 }} placeholder="제한 사유" value={reason} onChange={(e) => setReason(e.target.value)} maxLength={200} />
|
||||
<button className="btn btn-danger" disabled={!reason.trim()} onClick={() => run(a.patch(`/users/${id}`, { ban: { reason } }), '이용을 제한했어요.')}>
|
||||
이용 제한
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<button className="btn btn-secondary btn-block" onClick={() => setAsk('logout')}>
|
||||
모든 기기에서 로그아웃시키기
|
||||
</button>
|
||||
<button className="btn btn-danger btn-block" onClick={() => setAsk('delete')}>
|
||||
계정 삭제
|
||||
</button>
|
||||
<button className="btn btn-ghost btn-block" onClick={onClose}>
|
||||
닫기
|
||||
</button>
|
||||
{ask === 'logout' && <ConfirmSheet title="로그아웃시킬까요?" confirm="로그아웃" onConfirm={() => run(a.post(`/users/${id}/logout`), '로그아웃시켰어요.')} onClose={() => setAsk(null)} />}
|
||||
{ask === 'delete' && (
|
||||
<ConfirmSheet
|
||||
title="계정을 삭제할까요?"
|
||||
body="되돌릴 수 없어요."
|
||||
confirm="삭제"
|
||||
danger
|
||||
onConfirm={async () => {
|
||||
await run(a.del(`/users/${id}`), '삭제했어요.');
|
||||
onClose();
|
||||
}}
|
||||
onClose={() => setAsk(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ admins (superadmin)
|
||||
|
||||
function Admins() {
|
||||
const [d, reload] = useLoad<{ admins: any[] }>('/admins');
|
||||
const [discordId, setDiscordId] = useState('');
|
||||
const [note, setNote] = useState('');
|
||||
const [removing, setRemoving] = useState<string | null>(null);
|
||||
const add = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await a.post('/admins', { discordId: discordId.trim(), note: note.trim() || undefined });
|
||||
toast('어드민을 추가했어요.');
|
||||
setDiscordId('');
|
||||
setNote('');
|
||||
reload();
|
||||
} catch (ex) {
|
||||
toast((ex as Error).message, 'error');
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="stack">
|
||||
<form className="card stack" onSubmit={add}>
|
||||
<h2>어드민 추가</h2>
|
||||
<label className="field">
|
||||
디스코드 ID(숫자)
|
||||
<input className="input" inputMode="numeric" value={discordId} onChange={(e) => setDiscordId(e.target.value)} placeholder="예: 293719842274541579" />
|
||||
</label>
|
||||
<label className="field">
|
||||
메모(선택)
|
||||
<input className="input" value={note} maxLength={100} onChange={(e) => setNote(e.target.value)} />
|
||||
</label>
|
||||
<p className="muted small">디스코드 설정 → 고급 → 개발자 모드를 켠 뒤, 프로필을 우클릭해 "사용자 ID 복사"로 얻을 수 있어요. 아직 사이트에 들어온 적 없는 사람도 미리 추가할 수 있어요.</p>
|
||||
<button className="btn btn-primary btn-block" disabled={!/^\d{17,20}$/.test(discordId.trim())}>
|
||||
추가
|
||||
</button>
|
||||
</form>
|
||||
{d?.admins.map((x) => (
|
||||
<div key={x.discordId} className="card row" style={{ justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<strong>{x.nickname ?? '(아직 로그인 안 함)'}</strong> <span className="badge">{x.fixed ? '슈퍼어드민' : '어드민'}</span>
|
||||
<div className="muted small">
|
||||
{x.discordId}
|
||||
{x.note ? ` · ${x.note}` : ''}
|
||||
{x.addedAt ? ` · ${time(x.addedAt)}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
{!x.fixed && (
|
||||
<button className="btn btn-danger btn-sm" onClick={() => setRemoving(x.discordId)}>
|
||||
삭제
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{removing && (
|
||||
<ConfirmSheet
|
||||
title="어드민에서 뺄까요?"
|
||||
body={`디스코드 ID ${removing}`}
|
||||
confirm="빼기"
|
||||
danger
|
||||
onConfirm={async () => {
|
||||
await a.del(`/admins/${removing}`).catch((e) => toast((e as Error).message, 'error'));
|
||||
reload();
|
||||
}}
|
||||
onClose={() => setRemoving(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ audit
|
||||
|
||||
const ACTION_KO: Record<string, string> = {
|
||||
'game.update': '게임 설정 변경',
|
||||
'game.reset': '게임 설정 초기화',
|
||||
'site.update': '사이트 설정 변경',
|
||||
'room.close': '방 닫기',
|
||||
'user.rename': '닉네임 변경',
|
||||
'user.ban': '이용 제한',
|
||||
'user.unban': '제한 해제',
|
||||
'user.logout': '강제 로그아웃',
|
||||
'user.delete': '계정 삭제',
|
||||
'admin.add': '어드민 추가',
|
||||
'admin.remove': '어드민 삭제',
|
||||
};
|
||||
|
||||
function Audit() {
|
||||
const [page, setPage] = useState(0);
|
||||
const [d] = useLoad<{ entries: any[] }>(`/audit?page=${page}`);
|
||||
return (
|
||||
<div className="stack">
|
||||
{d?.entries.length === 0 && <p className="muted">기록이 없어요.</p>}
|
||||
{d?.entries.map((e) => (
|
||||
<details key={e.id} className="card">
|
||||
<summary style={{ cursor: 'pointer' }}>
|
||||
<strong>{ACTION_KO[e.action] ?? e.action}</strong> {e.target && <span className="muted">· {e.target}</span>}
|
||||
<div className="muted small">
|
||||
{time(e.at)} · {e.actor ?? e.actorId}
|
||||
</div>
|
||||
</summary>
|
||||
<pre className="small" style={{ whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' }}>
|
||||
전: {JSON.stringify(e.before, null, 1)}
|
||||
{'\n'}후: {JSON.stringify(e.after, null, 1)}
|
||||
</pre>
|
||||
</details>
|
||||
))}
|
||||
<div className="row">
|
||||
<button className="btn btn-secondary btn-sm" disabled={page === 0} onClick={() => setPage(page - 1)}>
|
||||
← 최근
|
||||
</button>
|
||||
<button className="btn btn-secondary btn-sm" disabled={(d?.entries.length ?? 0) < 50} onClick={() => setPage(page + 1)}>
|
||||
이전 기록 →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
139
apps/web/src/pages/admin/SchemaForm.tsx
Normal file
139
apps/web/src/pages/admin/SchemaForm.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Renders a form from a zod-generated JSON Schema (docs/14-admin.md §4).
|
||||
* Every option of a game appears automatically; titles/labels come from `.meta()`.
|
||||
*/
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export interface JsonSchema {
|
||||
type?: string | string[];
|
||||
title?: string;
|
||||
description?: string;
|
||||
labels?: Record<string, string>;
|
||||
enum?: (string | number)[];
|
||||
const?: string | number | boolean;
|
||||
anyOf?: JsonSchema[];
|
||||
properties?: Record<string, JsonSchema>;
|
||||
minimum?: number;
|
||||
maximum?: number;
|
||||
default?: unknown;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
schema: JsonSchema;
|
||||
value: unknown;
|
||||
onChange: (v: unknown) => void;
|
||||
locked?: Set<string>;
|
||||
onToggleLock?: (path: string) => void;
|
||||
}
|
||||
|
||||
function choices(s: JsonSchema): (string | number | boolean)[] | null {
|
||||
if (s.enum) return s.enum;
|
||||
if (s.anyOf && s.anyOf.every((a) => a.const !== undefined)) return s.anyOf.map((a) => a.const!);
|
||||
return null;
|
||||
}
|
||||
|
||||
export function SchemaForm({ schema, value, onChange, locked, onToggleLock }: Props) {
|
||||
return <Field schema={schema} value={value} onChange={onChange} path="" locked={locked} onToggleLock={onToggleLock} root />;
|
||||
}
|
||||
|
||||
function Field({ schema, value, onChange, path, locked, onToggleLock, root }: Props & { path: string; root?: boolean }) {
|
||||
const title = schema.title ?? path.split('.').pop() ?? '';
|
||||
const lock = onToggleLock && path ? (
|
||||
<label className="row small" style={{ gap: 4, cursor: 'pointer', marginLeft: 'auto' }}>
|
||||
<input type="checkbox" checked={locked?.has(path) ?? false} onChange={() => onToggleLock(path)} style={{ width: 20, height: 20 }} />
|
||||
🔒 방장 변경 금지
|
||||
</label>
|
||||
) : null;
|
||||
|
||||
if (schema.type === 'object' && schema.properties) {
|
||||
const obj = (value ?? {}) as Record<string, unknown>;
|
||||
const inner = Object.entries(schema.properties).map(([k, sub]) => (
|
||||
<Field
|
||||
key={k}
|
||||
schema={sub}
|
||||
value={obj[k]}
|
||||
onChange={(v) => onChange({ ...obj, [k]: v })}
|
||||
path={path ? `${path}.${k}` : k}
|
||||
locked={locked}
|
||||
onToggleLock={onToggleLock}
|
||||
/>
|
||||
));
|
||||
if (root) return <div className="stack" style={{ gap: 14 }}>{inner}</div>;
|
||||
return (
|
||||
<fieldset className="stack" style={{ border: '2px solid var(--border)', borderRadius: 12, padding: 12, gap: 12 }}>
|
||||
<legend style={{ fontWeight: 700, padding: '0 6px' }}>{title}</legend>
|
||||
{lock}
|
||||
{inner}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
let control: ReactNode;
|
||||
const opts = choices(schema);
|
||||
if (opts) {
|
||||
const cur = value ?? schema.default;
|
||||
control = (
|
||||
<select
|
||||
className="input"
|
||||
value={String(cur)}
|
||||
onChange={(e) => {
|
||||
const picked = opts.find((o) => String(o) === e.target.value);
|
||||
onChange(picked);
|
||||
}}
|
||||
>
|
||||
{opts.map((o) => (
|
||||
<option key={String(o)} value={String(o)}>
|
||||
{schema.labels?.[String(o)] ?? String(o)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
} else if (schema.type === 'boolean') {
|
||||
const cur = (value ?? schema.default) === true;
|
||||
control = (
|
||||
<div className="segmented">
|
||||
<button type="button" aria-pressed={cur} onClick={() => onChange(true)}>
|
||||
켜기
|
||||
</button>
|
||||
<button type="button" aria-pressed={!cur} onClick={() => onChange(false)}>
|
||||
끄기
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
} else if (schema.type === 'integer' || schema.type === 'number') {
|
||||
const cur = (value ?? schema.default ?? '') as number | '';
|
||||
control = (
|
||||
<div className="row">
|
||||
<input
|
||||
className="input"
|
||||
style={{ maxWidth: 180 }}
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
value={cur}
|
||||
min={schema.minimum}
|
||||
max={schema.maximum}
|
||||
step={schema.type === 'integer' ? 1 : 'any'}
|
||||
onChange={(e) => onChange(e.target.value === '' ? undefined : Number(e.target.value))}
|
||||
/>
|
||||
{(schema.minimum !== undefined || schema.maximum !== undefined) && (
|
||||
<span className="muted small">
|
||||
{schema.minimum ?? ''} ~ {schema.maximum ?? ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
control = <input className="input" value={String(value ?? schema.default ?? '')} onChange={(e) => onChange(e.target.value)} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<div className="row" style={{ gap: 8 }}>
|
||||
<strong>{title}</strong>
|
||||
{lock}
|
||||
</div>
|
||||
{schema.description && <p className="muted small">{schema.description}</p>}
|
||||
{control}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user