Files
joke-app/apps/web/src/pages/MePage.tsx
EJClaw 39223bacdd 컴퓨터 방 자동 정리 + 게스트 계정 삭제(로그아웃·하루 미접속) + 테스트 10분→1분
- 방 화면을 벗어나면(홈·규칙) 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>
2026-10-07 17:10:52 +09:00

283 lines
11 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** 설정(계정) 페이지 (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>
);
}