M1 웹 + 배포: 홈·코드 입장·대기실·오목 화면·결과·계정, E2E, Docker/Caddy

- 웹(React+Vite): 홈(큰 버튼 2개·하던 게임 돌아가기), 6자리 코드 입력(붙여넣기 감지·자동 입장),
  링크 입장(닉네임 한 칸), 대기실(코드/링크 복사·공유·QR·자리·준비·방장 메뉴·설정),
  오목(SVG 판·두 번 눌러 확정·금수/위험 표시·시계·무르기·무승부·기권·키보드 조작·스크린리더 안내),
  결과(한 판 더 투표·방 전적·공정성 확인), 채팅·반응, 계정(닉네임 변경·글자 크기·테마·소리·진동)
- WS 클라이언트: 지수 백오프+지터 재연결, 화면 복귀/온라인 즉시 재연결, seq 순서 처리,
  ping/pong 지연·시계 보정, 서버 재시작 안내, 다른 창 접속 처리
- @bg/shared/lite: 브라우저용 진입점(zod 제외) → 첫 화면 JS 약 90KB gzip
- e2e/omok.e2e.ts: 브라우저 두 개(모바일·PC)로 방 만들기→링크 입장→대국→새로고침 복구→승리→한 판 더
- deploy: Dockerfile(운영 의존성만, 267MB), docker-compose(app+Caddy), Caddyfile(보안 헤더·CSP), .env.example
- 프록시 뒤에서만 X-Forwarded-For 신뢰(TRUST_PROXY=1)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
EJClaw
2026-10-04 04:37:33 +09:00
parent b1f3d7c500
commit 17b1d0fcda
46 changed files with 3590 additions and 90 deletions

View File

@@ -0,0 +1,206 @@
/** 계정 페이지 (docs/05 §7). */
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router';
import { catalogById, nicknameError, normalizeNickname } from '@bg/shared/lite';
import { Avatar, ConfirmSheet, TopBar } from '../components/ui';
import { NicknameForm } from '../components/NicknameForm';
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 } = 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 (
<>
<TopBar title="내 정보" onBack={() => nav('/')} />
<main className="page">
<section className="card stack">
<h2>시작하기</h2>
<NicknameForm next="/me" />
</section>
{display}
</main>
</>
);
}
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>
{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>
<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();
await useSession.getState().refresh();
nav('/');
}}
onClose={() => setConfirm(null)}
/>
)}
{confirm === 'logoutAll' && (
<ConfirmSheet
title="모든 기기에서 로그아웃할까요?"
confirm="모두 로그아웃"
onConfirm={async () => {
await api.logoutAll();
await useSession.getState().refresh();
nav('/');
}}
onClose={() => setConfirm(null)}
/>
)}
{confirm === 'delete' && (
<ConfirmSheet
title="계정을 삭제할까요?"
body="닉네임과 연결 정보가 지워지고 되돌릴 수 없어요."
confirm="삭제하기"
danger
onConfirm={async () => {
await api.deleteMe();
await useSession.getState().refresh();
nav('/');
}}
onClose={() => setConfirm(null)}
/>
)}
</main>
</>
);
}