웹: PC 중심 화면으로 재구성
- 공통 상단 메뉴(사이트 이름·홈·게임 목록·관리자·어디서나 코드 입력·내 정보) - 홈: 방 만들기 + 코드 입력 카드, 게임 격자, 지금 열린 공개 방 목록(/api/public-rooms) - 대기실: 두 칸(초대·설정 바로 수정·규칙 요약 / 자리·시작 버튼·채팅 항상 보임) - 게임 화면: 판(화면 높이에 맞춤, 좌표 표시) + 오른쪽 패널(상태·선수·시계·버튼·기보·채팅) - 오목: 마우스 올리면 미리 보기 돌, PC는 한 번 클릭으로 착수(두 번 눌러 확정은 터치 기기 기본값, 기기별 설정), GameUI에 Side 패널 추가 - 채팅: PC는 패널로 항상 표시(Enter로 바로 입력), 휴대폰은 시트 - 관리자: 왼쪽 사이드바, 사용자 표, 규칙 입력 두 칸 격자, 저장 바 고정 - 좁은 화면은 같은 화면을 한 칸으로 접어서 지원(방 상단 줄 줄바꿈 수정) - E2E: PC 1440×900 기준(방장 PC, 친구 휴대폰), 관리자 E2E도 PC Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
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 { ConfirmSheet, Sheet } from '../../components/ui';
|
||||
import { apiCall, discordLoginUrl, type Role } from '../../net/api';
|
||||
import { useSession } from '../../store/session';
|
||||
import { toast } from '../../store/toast';
|
||||
@@ -53,24 +53,23 @@ export default function AdminApp() {
|
||||
['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="관리자 메뉴">
|
||||
<div className="admin-layout">
|
||||
<nav className="admin-side" aria-label="관리자 메뉴">
|
||||
<div className="muted small" style={{ padding: '0 16px 8px', fontWeight: 700 }}>
|
||||
관리자 · {sup ? '슈퍼어드민' : '어드민'}
|
||||
</div>
|
||||
{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' }}
|
||||
>
|
||||
<NavLink key={to} to={to ? `/admin/${to}` : '/admin'} end={to === ''}>
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
<button className="btn btn-ghost btn-sm" style={{ justifyContent: 'flex-start', marginTop: 8 }} onClick={() => nav('/')}>
|
||||
← 사이트로
|
||||
</button>
|
||||
</nav>
|
||||
<main className="page">
|
||||
<main className="stack" style={{ gap: 20, minWidth: 0, paddingBottom: 96 }}>
|
||||
<Routes>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="games" element={<Games />} />
|
||||
@@ -82,7 +81,7 @@ export default function AdminApp() {
|
||||
<Route path="audit" element={<Audit />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -259,7 +258,7 @@ function GameEditor() {
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
<div className="bottom-bar">
|
||||
<div className="bottom-bar sticky-save">
|
||||
<div className="bottom-bar-inner">
|
||||
<button className="btn btn-primary" onClick={save}>
|
||||
저장
|
||||
@@ -408,7 +407,7 @@ function Site() {
|
||||
<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 sticky-save">
|
||||
<div className="bottom-bar-inner">
|
||||
<button className="btn btn-primary" onClick={save}>
|
||||
저장
|
||||
@@ -439,17 +438,32 @@ function Users() {
|
||||
<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>
|
||||
))}
|
||||
{d && (
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>닉네임</th>
|
||||
<th className="wide-only">로그인</th>
|
||||
<th>권한</th>
|
||||
<th className="wide-only">마지막 접속</th>
|
||||
<th>상태</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{d.users.map((u) => (
|
||||
<tr key={u.id} className="clickable" onClick={() => setOpen(u.id)} tabIndex={0} onKeyDown={(e) => e.key === 'Enter' && setOpen(u.id)}>
|
||||
<td>
|
||||
<strong>{u.nickname}</strong>
|
||||
</td>
|
||||
<td className="wide-only small">{u.kind === 'member' ? `디스코드 ${u.discordName ?? ''} (${u.discordId})` : '게스트'}</td>
|
||||
<td>{u.role === 'superadmin' ? '슈퍼어드민' : u.role === 'admin' ? '어드민' : '-'}</td>
|
||||
<td className="wide-only small">{time(u.lastSeenAt)}</td>
|
||||
<td>{u.banned ? <span className="badge warning">제한</span> : <span className="badge success">정상</span>}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{open && <UserSheet id={open} onClose={() => (setOpen(null), reload())} />}
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user