Files
joke-app/apps/web/src/net/api.ts
EJClaw 19c163f4d2 시간 제한 없음은 정말 무제한, 대기실 설정은 바꾸면 바로 적용, 모르는 사람 기다리기(빠른 시작·공개 방) 제거
- 오목에서 [시간 제한 없음]을 골라도 30초가 흐르던 문제: 대기실 설정이 [설정 저장]을 눌러야
  서버에 반영되어, 누르지 않고 [게임 시작]하면 기본값(한 수 30초)으로 시작했다. 이제 설정은
  바꾸는 즉시 서버에 보낸다(저장 버튼 없음, 휴대폰 시트는 [닫기]만). 빠르게 여러 번 눌러도
  먼저 온 방 정보가 새 값을 되돌리지 않게 lib/option-sync.ts로 맞춘다.
- 시간 제한 없음(오목·체스·장기·오델로·바둑·쿼리도): 무응답 한도(idleLimitSec, 5~10분 뒤 시간패)와
  그 카운트다운을 없앴다. 체스는 첫 수 제한도 피셔일 때만. 연결이 끊긴 사람의 접속 유예는 그대로.
- 홈의 [지금 열린 방] 탭, 게임 소개의 [아무나와 빠른 시작]·기다리는 방 목록, 대기실의 방 공개
  설정을 뺐다(방은 모두 친구만). 서버 API는 남겨 두고 화면에서만 부르지 않는다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 20:42:46 +09:00

90 lines
3.3 KiB
TypeScript

import type { CatalogEntry, MyGameSummary, PublicUser, ReplayData } from '@bg/shared/lite';
export class ApiError extends Error {
constructor(
message: string,
readonly status: number,
) {
super(message);
}
}
async function call<T>(method: string, path: string, body?: unknown): Promise<T> {
let res: Response;
try {
res = await fetch(path, {
method,
credentials: 'same-origin',
headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined,
body: body !== undefined ? JSON.stringify(body) : undefined,
});
} catch {
throw new ApiError('인터넷 연결을 확인해 주세요.', 0);
}
const data = (await res.json().catch(() => ({}))) as { error?: string } & T;
if (!res.ok) throw new ApiError(data.error ?? '잠시 후 다시 시도해 주세요.', res.status);
return data;
}
export interface Settings {
fontScale?: 'normal' | 'large' | 'xlarge';
theme?: 'auto' | 'light' | 'dark';
sound?: boolean;
vibrate?: boolean;
colorBlind?: boolean;
tapConfirm?: boolean;
}
export type Role = 'user' | 'admin' | 'superadmin';
export interface MeResponse {
me: PublicUser | null;
role?: Role;
settings?: Settings;
useAvatar?: boolean;
hasDiscordAvatar?: boolean;
activeRoom?: string | null;
}
export interface GameStat {
gameId: string;
played: number;
wins: number;
draws: number;
}
export interface RoomPreview {
code: string;
codeLabel: string;
gameId: string;
host: string;
seated: number;
maxPlayers: number;
status: string;
}
export const api = {
config: () =>
call<{ discord: boolean; catalog: (CatalogEntry & { notice?: string })[]; siteName: string; announcement: string; maintenance: boolean }>('GET', '/api/config'),
me: () => call<MeResponse>('GET', '/api/me'),
guest: (nickname: string) => call<{ me: PublicUser }>('POST', '/api/auth/guest', { nickname }),
updateMe: (patch: { nickname?: string; useAvatar?: boolean; settings?: Settings }) => call<{ me: PublicUser }>('PATCH', '/api/me', patch),
logout: () => call<{ ok: true }>('POST', '/api/auth/logout', {}),
logoutAll: () => call<{ ok: true }>('POST', '/api/auth/logout-all', {}),
deleteMe: () => call<{ ok: true }>('DELETE', '/api/me', { confirm: '탈퇴' }),
stats: () => call<{ stats: { gameId: string; played: number; wins: number; draws: number }[] }>('GET', '/api/me/stats'),
/** Another player's record (room player card). */
userStats: (id: string) => call<{ user: PublicUser; stats: GameStat[] }>('GET', `/api/users/${encodeURIComponent(id)}/stats`),
myGames: (before?: string | null) =>
call<{ games: MyGameSummary[]; next: string | null }>('GET', `/api/me/games${before ? `?before=${encodeURIComponent(before)}` : ''}`),
replay: (gamePk: string) => call<ReplayData>('GET', `/api/replays/${encodeURIComponent(gamePk)}`),
createRoom: (gameId: string) => call<{ code: string }>('POST', '/api/rooms', { gameId }),
/** 컴퓨터와 연습: a private room with enough computer players to start (docs/06 §12). */
practice: (gameId: string) => call<{ code: string }>('POST', '/api/rooms', { gameId, practice: true }),
room: (code: string) => call<RoomPreview>('GET', `/api/rooms/${code}`),
};
export { call as apiCall };
export const discordLoginUrl = (next: string) => `/api/auth/discord/start?next=${encodeURIComponent(next)}`;