- 방 화면을 벗어나면(홈·규칙) detach: 자리·접속 상태는 유지, 방 접속 수에서 빠짐 - 컴퓨터가 있는 방: 사람이 모두 [방 나가기]하면 바로 닫고, 아무도 안 보면 대기실·끝난 방 1분, 게임 중 30분 뒤 정리 - 게스트 로그아웃 = 계정 삭제(자리·전적·세션·소켓), 사이트를 닫고 24시간 지난 게스트도 1분 주기로 삭제 - users.last_seen_at을 요청·소켓 종료 때 갱신(이전엔 갱신 안 됨), 003 마이그레이션에서 기존 게스트 시계 초기화 - 테스트: 무작위 대국 시드를 playoutSeeds()로 평소 1/10, test:full은 전부. bun test --parallel=4, 테스트 서버 종료 대기 1s→50ms Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
283 lines
11 KiB
TypeScript
283 lines
11 KiB
TypeScript
/** 설정(계정) 페이지 (docs/05 §7). 헤더 계정 메뉴 → 설정으로 들어와요. */
|
||
import { useEffect, useState } from 'react';
|
||
import { Link, Navigate, useNavigate } from 'react-router';
|
||
import { catalogById, nicknameError, normalizeNickname, type MyGameSummary } from '@bg/shared/lite';
|
||
import { Avatar, ConfirmSheet, TopBar } from '../components/ui';
|
||
import { gameName } from '../components/GameNotice';
|
||
import { api, discordLoginUrl } from '../net/api';
|
||
import { useSession } from '../store/session';
|
||
import { toast } from '../store/toast';
|
||
|
||
function Seg<T extends string>({ value, options, onChange, label }: { value: T; options: [T, string][]; onChange: (v: T) => void; label: string }) {
|
||
return (
|
||
<div className="stack" style={{ gap: 6 }}>
|
||
<strong>{label}</strong>
|
||
<div className="segmented" role="group" aria-label={label}>
|
||
{options.map(([k, l]) => (
|
||
<button key={k} type="button" aria-pressed={value === k} onClick={() => onChange(k)}>
|
||
{l}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default function MePage() {
|
||
const nav = useNavigate();
|
||
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);
|
||
const [stats, setStats] = useState<{ gameId: string; played: number; wins: number; draws: number }[]>([]);
|
||
const [confirm, setConfirm] = useState<null | 'logout' | 'logoutAll' | 'delete'>(null);
|
||
|
||
useEffect(() => {
|
||
if (me) api.stats().then((r) => setStats(r.stats)).catch(() => {});
|
||
}, [me]);
|
||
|
||
const saveNick = async () => {
|
||
const n = normalizeNickname(nick);
|
||
const e = nicknameError(n);
|
||
if (e) return setError(e);
|
||
try {
|
||
const r = await api.updateMe({ nickname: n });
|
||
useSession.getState().setMe(r.me);
|
||
setEditing(false);
|
||
toast('닉네임을 바꿨어요.');
|
||
} catch (ex) {
|
||
setError((ex as Error).message);
|
||
}
|
||
};
|
||
|
||
const display = (
|
||
<section className="card stack">
|
||
<h2>화면 설정</h2>
|
||
<Seg label="글자 크기" value={settings.fontScale} options={[['normal', '보통'], ['large', '크게'], ['xlarge', '아주 크게']]} onChange={(fontScale) => updateSettings({ fontScale })} />
|
||
<Seg label="화면 색" value={settings.theme} options={[['auto', '자동'], ['light', '밝게'], ['dark', '어둡게']]} onChange={(theme) => updateSettings({ theme })} />
|
||
<Seg label="소리" value={settings.sound ? 'on' : 'off'} options={[['on', '켜기'], ['off', '끄기']]} onChange={(v) => updateSettings({ sound: v === 'on' })} />
|
||
<Seg label="진동" value={settings.vibrate ? 'on' : 'off'} options={[['on', '켜기'], ['off', '끄기']]} onChange={(v) => updateSettings({ vibrate: v === 'on' })} />
|
||
<Seg
|
||
label="돌·카드 놓기"
|
||
value={settings.tapConfirm ? 'on' : 'off'}
|
||
options={[['on', '두 번 눌러 확정(실수 방지)'], ['off', '한 번에 놓기']]}
|
||
onChange={(v) => updateSettings({ tapConfirm: v === 'on' })}
|
||
/>
|
||
</section>
|
||
);
|
||
|
||
// 로그인 안 했으면 로그인 페이지로 (로그인 페이지에는 로그인만 있어요).
|
||
if (!me) return <Navigate to="/login?next=/me" replace />;
|
||
|
||
return (
|
||
<>
|
||
<TopBar title="설정" onBack={() => nav('/')} />
|
||
<main className="page">
|
||
<section className="card stack" style={{ alignItems: 'center', textAlign: 'center' }}>
|
||
<Avatar user={me} size={88} />
|
||
{editing ? (
|
||
<div className="stack" style={{ width: '100%' }}>
|
||
<input className="input" value={nick} onChange={(e) => (setNick(e.target.value), setError(null))} maxLength={20} autoFocus aria-label="새 닉네임" />
|
||
{error && <p className="field-error">{error}</p>}
|
||
<div className="row">
|
||
<button className="btn btn-primary" style={{ flex: 1 }} onClick={saveNick}>
|
||
저장
|
||
</button>
|
||
<button className="btn btn-secondary" style={{ flex: 1 }} onClick={() => (setEditing(false), setNick(me.nickname), setError(null))}>
|
||
취소
|
||
</button>
|
||
</div>
|
||
<p className="muted small">게임 중에 바꾸면 그 게임이 끝난 뒤에 보여요.</p>
|
||
</div>
|
||
) : (
|
||
<>
|
||
<h1>{me.nickname}</h1>
|
||
<button className="btn btn-secondary" onClick={() => (setNick(me.nickname), setEditing(true))}>
|
||
✏️ 닉네임 바꾸기
|
||
</button>
|
||
</>
|
||
)}
|
||
<p className="muted">{me.kind === 'member' ? '디스코드로 로그인함' : '게스트(이 기기에만 저장돼요)'}</p>
|
||
{me.kind === 'guest' && discordEnabled && (
|
||
<a className="btn btn-soft btn-block" href={discordLoginUrl('/me')}>
|
||
디스코드 연결하기 (기록을 안전하게 보관)
|
||
</a>
|
||
)}
|
||
{me.kind === 'member' && hasDiscordAvatar && (
|
||
<button
|
||
className="btn btn-ghost"
|
||
onClick={async () => {
|
||
await api.updateMe({ useAvatar: !useAvatar });
|
||
await useSession.getState().refresh();
|
||
}}
|
||
>
|
||
{useAvatar ? '디스코드 프로필 사진 숨기기' : '디스코드 프로필 사진 쓰기'}
|
||
</button>
|
||
)}
|
||
</section>
|
||
|
||
{role !== 'user' && (
|
||
<button className="btn btn-soft btn-block" onClick={() => nav('/admin')}>
|
||
🛠 관리자 페이지 ({role === 'superadmin' ? '슈퍼어드민' : '어드민'})
|
||
</button>
|
||
)}
|
||
|
||
{display}
|
||
|
||
<section className="card stack">
|
||
<h2>내 기록</h2>
|
||
{stats.length === 0 ? (
|
||
<p className="muted">아직 끝까지 한 게임이 없어요.</p>
|
||
) : (
|
||
stats.map((s) => (
|
||
<div key={s.gameId} className="row" style={{ justifyContent: 'space-between' }}>
|
||
<strong>{catalogById(s.gameId)?.nameKo ?? s.gameId}</strong>
|
||
<span>
|
||
{s.played}판 {s.wins}승{s.draws ? ` ${s.draws}무` : ''}
|
||
</span>
|
||
</div>
|
||
))
|
||
)}
|
||
</section>
|
||
|
||
<MyGames />
|
||
|
||
<section className="stack">
|
||
<button className="btn btn-secondary btn-block" onClick={() => setConfirm('logout')}>
|
||
로그아웃
|
||
</button>
|
||
{me.kind === 'member' && (
|
||
<button className="btn btn-ghost btn-block" onClick={() => setConfirm('logoutAll')}>
|
||
모든 기기에서 로그아웃
|
||
</button>
|
||
)}
|
||
<button className="btn btn-ghost btn-block small muted" onClick={() => setConfirm('delete')}>
|
||
계정 삭제
|
||
</button>
|
||
</section>
|
||
|
||
{confirm === 'logout' && (
|
||
<ConfirmSheet
|
||
title="로그아웃할까요?"
|
||
body={me.kind === 'guest' ? '게스트 계정은 로그아웃하면 이름과 기록이 모두 지워져요.' : undefined}
|
||
confirm="로그아웃"
|
||
danger={me.kind === 'guest'}
|
||
onConfirm={async () => {
|
||
await api.logout();
|
||
nav('/');
|
||
await useSession.getState().refresh();
|
||
}}
|
||
onClose={() => setConfirm(null)}
|
||
/>
|
||
)}
|
||
{confirm === 'logoutAll' && (
|
||
<ConfirmSheet
|
||
title="모든 기기에서 로그아웃할까요?"
|
||
confirm="모두 로그아웃"
|
||
onConfirm={async () => {
|
||
await api.logoutAll();
|
||
nav('/');
|
||
await useSession.getState().refresh();
|
||
}}
|
||
onClose={() => setConfirm(null)}
|
||
/>
|
||
)}
|
||
{confirm === 'delete' && (
|
||
<ConfirmSheet
|
||
title="계정을 삭제할까요?"
|
||
body="닉네임과 연결 정보가 지워지고 되돌릴 수 없어요."
|
||
confirm="삭제하기"
|
||
danger
|
||
onConfirm={async () => {
|
||
await api.deleteMe();
|
||
nav('/');
|
||
await useSession.getState().refresh();
|
||
}}
|
||
onClose={() => setConfirm(null)}
|
||
/>
|
||
)}
|
||
</main>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function resultLabel(g: MyGameSummary): string {
|
||
if (g.endReason === 'void') return '무효';
|
||
if (g.myRank === null) return '끝남';
|
||
if (g.players.length === 1) return '혼자 했어요';
|
||
if (g.players.every((p) => p.rank === 1)) return '무승부';
|
||
if (g.players.length === 2) return g.myRank === 1 ? '이겼어요' : '졌어요';
|
||
return `${g.myRank}등`;
|
||
}
|
||
|
||
/** 내 게임 기록: finished games, newest first, with a 다시 보기 button (docs/09 §8). */
|
||
function MyGames() {
|
||
const [games, setGames] = useState<MyGameSummary[] | null>(null);
|
||
const [next, setNext] = useState<string | null>(null);
|
||
const [loading, setLoading] = useState(false);
|
||
const load = (before: string | null) => {
|
||
setLoading(true);
|
||
api
|
||
.myGames(before)
|
||
.then((r) => {
|
||
setGames((prev) => [...(before ? (prev ?? []) : []), ...r.games]);
|
||
setNext(r.next);
|
||
})
|
||
.catch((e) => toast((e as Error).message, 'error'))
|
||
.finally(() => setLoading(false));
|
||
};
|
||
useEffect(() => load(null), []);
|
||
|
||
return (
|
||
<section className="card stack" aria-labelledby="my-games-title">
|
||
<h2 id="my-games-title">내 게임 기록</h2>
|
||
{games === null ? (
|
||
<p className="muted">불러오는 중…</p>
|
||
) : games.length === 0 ? (
|
||
<p className="muted">아직 다시 볼 게임이 없어요.</p>
|
||
) : (
|
||
<ul className="my-games">
|
||
{games.map((g) => {
|
||
const label = resultLabel(g);
|
||
return (
|
||
<li key={g.gamePk} className="my-game">
|
||
<div className="stack" style={{ gap: 4, flex: 1, minWidth: 0 }}>
|
||
<div className="row" style={{ gap: 8 }}>
|
||
<strong>{gameName(g.gameId)}</strong>
|
||
<span className={`badge${label === '이겼어요' || label === '1등' ? ' success' : ''}`}>{label}</span>
|
||
</div>
|
||
<span className="muted small">
|
||
{new Date(g.startedAt).toLocaleString('ko-KR', { month: 'long', day: 'numeric', hour: 'numeric', minute: '2-digit' })}
|
||
</span>
|
||
<span className="small">
|
||
{g.players.map((p, i) => (
|
||
<span key={i}>
|
||
{i > 0 && ' · '}
|
||
{p.me ? <strong>나</strong> : p.nickname}
|
||
</span>
|
||
))}
|
||
</span>
|
||
</div>
|
||
{g.replayable ? (
|
||
<Link className="btn btn-soft" to={`/replay/${g.gamePk}`} aria-label={`${gameName(g.gameId)} ${label} 다시 보기`}>
|
||
▶ 다시 보기
|
||
</Link>
|
||
) : (
|
||
<span className="muted small" style={{ maxWidth: 110, textAlign: 'right' }}>
|
||
다시 볼 수 없어요
|
||
</span>
|
||
)}
|
||
</li>
|
||
);
|
||
})}
|
||
</ul>
|
||
)}
|
||
{next && (
|
||
<button className="btn btn-secondary btn-block" disabled={loading} onClick={() => load(next)}>
|
||
{loading ? '불러오는 중…' : '더 보기'}
|
||
</button>
|
||
)}
|
||
</section>
|
||
);
|
||
}
|