diff --git a/frontend/src/api.ts b/frontend/src/api.ts new file mode 100644 index 0000000..54fd3bb --- /dev/null +++ b/frontend/src/api.ts @@ -0,0 +1,1118 @@ +import type { ClientPuzzle, GenerateResponse } from './types' + +const BASE = '/api' + +export interface ThemeInfo { + id: string + name: string + word_count: number +} + +export interface MeInfo { + display_name: string | null + is_new: boolean + has_passkey: boolean + preferred_preset: string | null + streak: { current: number; best: number; last_date: string | null } + streak_freezes: number +} + +export interface ValidateResult { + correct: boolean + cells: string[][] + rank: number | null + percentile: number | null + total_solvers: number | null + already_solved: boolean + solve_time: number + solve_hints: number + cell_results: string | null + new_achievements: string[] + flagged: boolean + receipt: ReceiptPayload | null +} + +export interface HintLetter { + row: number + col: number + letter: string +} + +export interface HintResult { + is_correct: boolean + row: number + col: number + letter: string + letters: HintLetter[] +} + +export interface LeaderboardEntry { + name: string | null + time: number + hints: number + date: string | null + count: number + current: number + best: number + instance: string +} + +export interface LeaderboardResult { + entries: LeaderboardEntry[] +} + +export type ServerSettings = Record + +export interface HistoryEntry { + date: string + preset: string | null + time: number + hints: number + short_id: string | null +} + +export interface RatingResult { + average: number + count: number + my_rating: number | null +} + +export type DailyRatings = Record + +export interface CommunityClueItem { + id: number + clue: string + upvotes: number + downvotes: number + status: string + my_vote: number + origin_domain: string | null + created_at: string +} + +export interface ClueSubmissionItem { + id: number + word: string + clue: string + upvotes: number + downvotes: number + status: string +} + +export interface EditorLockedCell { + row: number + col: number + letter: string +} + +export interface EditorGeneratedClue { + number: number + direction: string + text: string + answer: string +} + +export interface EditorAutofillResult { + ok: boolean + error: string + letters: (string | null)[][] + clues: EditorGeneratedClue[] +} + +export interface EditorLoadedClue { + number: number + direction: string + text: string + answer: string + startRow?: number + startCol?: number +} + +export interface EditorLoadedPuzzle { + id: string + title: string + width: number + height: number + grid: string[][] + clues: EditorLoadedClue[] + puzzle: { + grid?: { cells?: { Letter?: string }[][] | null } + } | null + status: string +} + +export interface SavedPuzzleItem { + id: string + title: string + width: number + height: number + status: string + updated_at: string +} + +export interface CommunityPuzzleItem { + id: string + title: string + author: string | null + origin: string | null + low_reputation: boolean + origin_reputation: number | null + federated?: boolean + width: number + height: number + updated_at: string +} + +export interface CommunityPlayResult { + id: string + short_id: string + title: string + community: boolean + puzzle: ClientPuzzle +} + +export interface ConstructorAnalytics { + total_solvers: number + total_attempts: number + avg_time: number + completion_rate: number + total_hints: number + width: number + height: number + hint_heatmap?: number[][] + max_hints?: number +} + +export interface ArchiveEntry { + date: string + preset: string + short_id: string + width: number + height: number + solved: boolean +} + +export interface PasskeyItem { + id: string + name: string + created_at: string +} + +export interface PackClue { + text: string + difficulty?: number +} + +export interface PackWord { + word: string + clues: PackClue[] +} + +export interface PackItem { + id: string + name: string + description: string | null + author: string | null + tags: string[] + word_count: number + downloads: number + rating: number | null + rating_count: number + created_at: string + words?: PackWord[] +} + +export interface PackActionResult { + id: string + word_count: number +} + +export interface ImportResult { + imported: number + skipped: string[] +} + +export interface MigrateResult { + ok: boolean + target: string + export: unknown +} + +export interface ReportResult { + id: number +} + +export interface RoomPlayer { + id: string + name: string + color: string + cursor: [number, number] | null + direction: string + order: number +} + +export interface RoomInfo { + code: string + mode: string + puzzle_id: string | null + players: RoomPlayer[] + player_count: number + max_players: number + started: boolean + current_turn: string | null + party: { + phase: string + round: number + total_rounds: number + sub_mode: string + } | null +} + +export interface ReceiptBody { + receipt_id: string + puzzle_hash: string + solve_time_secs: number + hints_used: number + solve_date: string + instance_url: string + player_name: string + room_code: string | null + mode: string | null + players: string[] + created_at: string + expires_at: string +} + +export interface ReceiptPayload { + receipt: ReceiptBody + signature: string + key_id: string + receipt_url?: string + receipt_id?: string +} + +export interface ClaimReceiptResult { + claimed: boolean + solve_time: number + hints: number +} + +export interface TrainerClueResult { + clue: string + length: number + pattern: string + difficulty: string +} + +export interface AnalyticsSummary { + total_solves: number + clean_solves: number + total_time_secs: number + avg_time_secs: number + avg_hints: number + current_streak: number + best_streak: number + first_solve: string | null + puzzles_rated: number + clues_submitted: number +} + +export interface SolveEntry { + date: string + preset: string + time: number + hints: number +} + +export interface TrendEntry { + date: string + avg_time?: number + avg_hints?: number +} + +export interface PresetStats { + count: number + best: number + avg: number + clean: number +} + +export interface CleanRateEntry { + date: string + rate: number +} + +export interface MonthlyEntry { + month: string + count: number +} + +export interface DiffEntry { + date: string + avg_difficulty: number +} + +export interface FastestRecord { + time: number + date: string +} + +export interface Analytics { + summary: AnalyticsSummary + by_preset: Record + solve_history: SolveEntry[] + activity_calendar: Record + by_hour: number[] + by_day_of_week: number[] + time_trend: TrendEntry[] + hints_trend: TrendEntry[] + improvement_rate: number | null + clean_rate_trend: CleanRateEntry[] + fastest_by_preset: Record + solve_time_distribution: number[] + monthly_totals: MonthlyEntry[] + completion_rate: number + words_encountered: number + avg_time_by_hour: Record + difficulty_progression: DiffEntry[] +} + +export interface WordOfTheDayResult { + word: string + definition: string + clue: string + length: number +} + +export type ChallengeCompletionMap = Record + +export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue } +export type ImportPayload = Record + +async function getJson(res: Response): Promise { + // SAFETY: every caller passes its endpoint's documented JSON shape and decodes at the call site + return res.json() as T +} + +interface PasskeyParam { + type: 'public-key' + alg: number +} + +interface RegistrationStartOptions { + challenge: string + rp: { id: string; name: string } + user: { id: string; name: string; displayName: string } + pubKeyCredParams: PasskeyParam[] + timeout: number + authenticatorSelection: AuthenticatorSelectionCriteria + attestation: AttestationConveyancePreference +} + +interface AuthenticationStartOptions { + challenge: string + rp_id: string + timeout: number + allowCredentials: { type: 'public-key'; id: string }[] +} + +interface ValidateBody { + cells: (string | null)[][] + elapsed: number + hints: number + local_date: string + room_code?: string + cell_results?: string +} + +interface CreateRoomBody { + puzzle_id: string + mode: string + party_sub?: string +} + +export async function fetchThemes(): Promise { + const res = await fetch(`${BASE}/themes`) + if (!res.ok) return [] + const data = await getJson<{ themes?: ThemeInfo[] }>(res) + return data.themes || [] +} + +export async function generatePuzzle( + difficulty?: string, + size?: number, + density?: number, + clue_difficulty?: number, + theme?: string, + cryptic?: boolean, + pack?: string, +): Promise { + const res = await fetch(`${BASE}/puzzles/generate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ difficulty, size, density, clue_difficulty, theme, cryptic, pack }), + }) + if (!res.ok) throw new Error(await res.text()) + return res.json() +} + +export async function fetchDaily(date: string, preset: string): Promise { + const res = await fetch(`${BASE}/puzzles/daily/${date}/${preset}`) + if (!res.ok) throw new Error(await res.text()) + return res.json() +} + +export async function fetchPuzzleByShortId(shortId: string): Promise { + const res = await fetch(`${BASE}/puzzles/s/${shortId}`) + if (!res.ok) throw new Error(await res.text()) + return res.json() +} + +export async function fetchPuzzleById(id: string): Promise { + const res = await fetch(`${BASE}/puzzles/${id}`) + if (!res.ok) throw new Error(await res.text()) + return res.json() +} + +export async function validateSolution( + id: string, + cells: (string | null)[][], + elapsed?: number, + hints?: number, + roomCode?: string | null, + cellResults?: string, +): Promise { + const localDate = new Date().toISOString().split('T')[0] + const body: ValidateBody = { cells, elapsed: elapsed ?? 0, hints: hints ?? 0, local_date: localDate } + if (roomCode) body.room_code = roomCode + if (cellResults) body.cell_results = cellResults + const res = await fetch(`${BASE}/puzzles/${id}/validate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + if (!res.ok) throw new Error(await res.text()) + return res.json() +} + +export async function requestHint( + id: string, + kind: string, + row: number, + col: number, + direction?: string, + letter?: string, +): Promise { + const res = await fetch(`${BASE}/puzzles/${id}/hint`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ kind, row, col, direction, letter }), + }) + if (!res.ok) throw new Error(await res.text()) + return res.json() +} + +export async function fetchMe(): Promise { + const res = await fetch(`${BASE}/me`) + if (!res.ok) throw new Error(await res.text()) + return res.json() +} + +export async function updateMe(data: { display_name?: string }): Promise { + await fetch(`${BASE}/me`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }) +} + +export async function saveProgress(id: string, cells: (string | null)[][]): Promise { + await fetch(`${BASE}/puzzles/${id}/progress`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ cells }), + }) +} + +export async function fetchLeaderboard(puzzleId: string): Promise { + const res = await fetch(`${BASE}/leaderboards/${puzzleId}`) + if (!res.ok) throw new Error(await res.text()) + return res.json() +} + +export async function fetchDailyLeaderboard(date: string, preset: string): Promise { + const res = await fetch(`${BASE}/leaderboards/daily/${date}/${preset}`) + if (!res.ok) throw new Error(await res.text()) + return res.json() +} + +export async function fetchFederatedLeaderboard(date: string, preset: string): Promise { + const res = await fetch(`${BASE}/leaderboards/federated/${date}/${preset}`) + if (!res.ok) throw new Error(await res.text()) + return res.json() +} + +export async function fetchAlltimeLeaderboard(preset: string): Promise { + const res = await fetch(`${BASE}/leaderboards/alltime/${preset}`) + if (!res.ok) throw new Error(await res.text()) + return res.json() +} + +export async function fetchWeeklyLeaderboard(preset: string): Promise { + const res = await fetch(`${BASE}/leaderboards/weekly/${preset}`) + if (!res.ok) throw new Error(await res.text()) + return res.json() +} + +export async function fetchStreakLeaderboard(): Promise { + const res = await fetch(`${BASE}/leaderboards/streaks`) + if (!res.ok) throw new Error(await res.text()) + return res.json() +} + +export async function fetchCompletionistLeaderboard(): Promise { + const res = await fetch(`${BASE}/leaderboards/completionist`) + if (!res.ok) throw new Error(await res.text()) + return res.json() +} + +export async function fetchCleanLeaderboard(date: string, preset: string): Promise { + const res = await fetch(`${BASE}/leaderboards/clean/${date}/${preset}`) + if (!res.ok) throw new Error(await res.text()) + return res.json() +} + +export async function fetchAnalytics(): Promise { + const res = await fetch(`${BASE}/me/analytics`) + if (!res.ok) throw new Error(await res.text()) + return res.json() +} + +export async function fetchSettings(): Promise { + const res = await fetch(`${BASE}/me/settings`) + if (!res.ok) return {} + return res.json() +} + +export async function saveSettings(settings: ServerSettings): Promise { + await fetch(`${BASE}/me/settings`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(settings), + }) +} + +// passkey / WebAuthn helpers + +function bufToBase64url(buf: ArrayBuffer): string { + return btoa(String.fromCharCode(...new Uint8Array(buf))) + .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} + +function base64urlToBuf(s: string): ArrayBuffer { + const padded = s.replace(/-/g, '+').replace(/_/g, '/') + '=='.slice(0, (4 - s.length % 4) % 4) + const binary = atob(padded) + const buf = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i++) buf[i] = binary.charCodeAt(i) + return buf.buffer +} + +export async function registerPasskey(): Promise { + const res = await fetch(`${BASE}/passkey/register/start`, { method: 'POST' }) + if (!res.ok) throw new Error(await res.text()) + const { options } = await getJson<{ options: RegistrationStartOptions }>(res) + + const publicKey: PublicKeyCredentialCreationOptions = { + challenge: base64urlToBuf(options.challenge), + rp: options.rp, + user: { + id: base64urlToBuf(options.user.id), + name: options.user.name, + displayName: options.user.displayName, + }, + pubKeyCredParams: options.pubKeyCredParams, + timeout: options.timeout, + authenticatorSelection: options.authenticatorSelection, + attestation: options.attestation, + } + + const credential = await navigator.credentials.create({ publicKey }) + if (!(credential instanceof PublicKeyCredential)) return false + + const attestation = credential.response + if (!(attestation instanceof AuthenticatorAttestationResponse)) return false + const finish = await fetch(`${BASE}/passkey/register/finish`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + id: credential.id, + clientDataJSON: bufToBase64url(attestation.clientDataJSON), + attestationObject: bufToBase64url(attestation.attestationObject), + }), + }) + if (!finish.ok) throw new Error(await finish.text()) + return true +} + +export async function authenticatePasskey(): Promise<{ ok: boolean; display_name: string | null } | null> { + const res = await fetch(`${BASE}/passkey/authenticate/start`, { method: 'POST' }) + if (!res.ok) throw new Error(await res.text()) + const { options } = await getJson<{ options: AuthenticationStartOptions }>(res) + + const publicKey: PublicKeyCredentialRequestOptions = { + challenge: base64urlToBuf(options.challenge), + rpId: options.rp_id, + timeout: options.timeout, + allowCredentials: options.allowCredentials.map(c => ({ + type: c.type, + id: base64urlToBuf(c.id), + })), + } + + const assertion = await navigator.credentials.get({ publicKey }) + if (!(assertion instanceof PublicKeyCredential)) return null + + const resp = assertion.response + if (!(resp instanceof AuthenticatorAssertionResponse)) return null + const finish = await fetch(`${BASE}/passkey/authenticate/finish`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + id: assertion.id, + clientDataJSON: bufToBase64url(resp.clientDataJSON), + authenticatorData: bufToBase64url(resp.authenticatorData), + signature: bufToBase64url(resp.signature), + }), + }) + if (!finish.ok) throw new Error(await finish.text()) + return finish.json() +} + +// history + +export async function fetchHistory(): Promise { + const res = await fetch(`${BASE}/me/history`) + if (!res.ok) return [] + const data = await getJson<{ history?: HistoryEntry[] }>(res) + return data.history || [] +} + +// puzzle ratings + +export async function ratePuzzle(id: string, rating: number): Promise { + const res = await fetch(`${BASE}/puzzles/${id}/rate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ rating }), + }) + if (!res.ok) throw new Error(await res.text()) + return res.json() +} + +export async function fetchRating(id: string): Promise { + const res = await fetch(`${BASE}/puzzles/${id}/rating`) + if (!res.ok) return { average: 0, count: 0, my_rating: null } + return res.json() +} + +export async function fetchDailyRatings(date: string): Promise { + const res = await fetch(`${BASE}/puzzles/daily/${date}/ratings`) + if (!res.ok) return {} + return res.json() +} + +// community clues + +export async function submitClue(word: string, clue: string): Promise<{ id: number }> { + const res = await fetch(`${BASE}/clues/community`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ word, clue }), + }) + if (!res.ok) throw new Error(await res.text()) + return res.json() +} + +export async function fetchCommunityClues(word: string): Promise<{ clues: CommunityClueItem[] }> { + const res = await fetch(`${BASE}/clues/community/${word.toUpperCase()}`) + if (!res.ok) return { clues: [] } + return res.json() +} + +export async function voteClue(id: number, vote: number): Promise { + await fetch(`${BASE}/clues/community/vote/${id}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ vote }), + }) +} + +export async function deleteSubmission(id: number): Promise { + await fetch(`${BASE}/me/submissions/${id}`, { method: 'DELETE' }) +} + +export async function fetchMySubmissions(): Promise { + const res = await fetch(`${BASE}/me/submissions`) + if (!res.ok) return [] + const data = await getJson<{ submissions?: ClueSubmissionItem[] }>(res) + return data.submissions || [] +} + +// puzzle import (.puz / .ipuz) + +export async function importPuzzle(file: File): Promise { + const buf = await file.arrayBuffer() + const res = await fetch(`${BASE}/puzzles/import`, { + method: 'POST', + headers: { 'Content-Type': 'application/octet-stream' }, + body: buf, + }) + if (!res.ok) throw new Error(await res.text()) + return res.json() +} + +// editor + +export async function editorAutofill(width: number, height: number, cells: string[][], locked?: EditorLockedCell[]): Promise { + const res = await fetch(`${BASE}/editor/autofill`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ width, height, cells, locked }), + }) + if (!res.ok) throw new Error(await res.text()) + return res.json() +} + +export async function editorClueSuggestions(word: string): Promise { + const res = await fetch(`${BASE}/editor/clue-suggestions/${word.toUpperCase()}`) + if (!res.ok) return [] + const data = await getJson<{ suggestions?: string[] }>(res) + return data.suggestions || [] +} + +export async function editorCheckWords(words: string[]): Promise<{ blocked: string[] }> { + const res = await fetch(`${BASE}/editor/check-words`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ words }), + }) + if (!res.ok) return { blocked: [] } + return res.json() +} + +export async function editorSave(data: { + id?: string + title: string + width: number + height: number + grid: string[][] + clues?: EditorLoadedClue[] + puzzle?: { grid: { cells: ({ Letter: string } | null)[][] } } + status?: string +}): Promise<{ id: string }> { + const res = await fetch(`${BASE}/editor/puzzles`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }) + if (!res.ok) throw new Error(await res.text()) + return res.json() +} + +export async function editorLoad(id: string): Promise { + const res = await fetch(`${BASE}/editor/puzzles/${id}`) + if (!res.ok) throw new Error(await res.text()) + return res.json() +} + +export async function editorList(): Promise { + const res = await fetch(`${BASE}/editor/puzzles`) + if (!res.ok) return [] + const data = await getJson<{ puzzles?: SavedPuzzleItem[] }>(res) + return data.puzzles || [] +} + +export async function editorDelete(id: string): Promise { + await fetch(`${BASE}/editor/puzzles/${id}`, { method: 'DELETE' }) +} + +export async function fetchCommunityPuzzles(): Promise { + const res = await fetch(`${BASE}/community-puzzles`) + if (!res.ok) return [] + const data = await getJson<{ puzzles?: CommunityPuzzleItem[] }>(res) + return data.puzzles || [] +} + +export async function fetchFederatedPuzzles(): Promise<{ puzzles: CommunityPuzzleItem[] }> { + const res = await fetch(`${BASE}/federated-puzzles`) + if (!res.ok) throw new Error(await res.text()) + return res.json() +} + +export async function loadCommunityPuzzle(id: string): Promise { + const res = await fetch(`${BASE}/community-puzzles/${id}`) + if (!res.ok) throw new Error(await res.text()) + return res.json() +} + +export async function editorExport(id: string): Promise { + const res = await fetch(`${BASE}/editor/puzzles/${id}/export`) + if (!res.ok) throw new Error(await res.text()) + const blob = await res.blob() + const cd = res.headers.get('content-disposition') || '' + const match = cd.match(/filename="(.+)"/) + const filename = match ? match[1] : 'puzzle.ipuz' + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url; a.download = filename; a.click() + URL.revokeObjectURL(url) +} + +export async function editorExportAll(): Promise { + const res = await fetch(`${BASE}/editor/export-all`) + if (!res.ok) throw new Error(await res.text()) + const blob = await res.blob() + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url; a.download = 'cruciverb-puzzles-export.json'; a.click() + URL.revokeObjectURL(url) +} + +export async function editorImport(data: ImportPayload): Promise { + const res = await fetch(`${BASE}/editor/import`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }) + if (!res.ok) throw new Error(await res.text()) + return res.json() +} + +export async function editorPublish(id: string): Promise<{ ok: boolean }> { + const res = await fetch(`${BASE}/editor/puzzles/${id}/publish`, { method: 'POST' }) + if (!res.ok) throw new Error(await res.text()) + return res.json() +} + +export async function fetchPuzzleAnalytics(id: string): Promise { + const res = await fetch(`${BASE}/editor/puzzles/${id}/analytics`) + if (!res.ok) throw new Error(await res.text()) + return res.json() +} + +export async function fetchArchive(preset?: string, page?: number): Promise<{ entries: ArchiveEntry[] }> { + const params = new URLSearchParams() + if (preset && preset !== 'all') params.set('preset', preset) + if (page) params.set('page', String(page)) + const qs = params.toString() + const res = await fetch(`${BASE}/puzzles/archive${qs ? '?' + qs : ''}`) + if (!res.ok) throw new Error(await res.text()) + return res.json() +} + +export async function fetchPasskeys(): Promise { + const res = await fetch(`${BASE}/me/passkeys`) + if (!res.ok) return [] + const data = await getJson<{ passkeys?: PasskeyItem[] }>(res) + return data.passkeys || [] +} + +// privacy / account + +export async function deleteAccount(): Promise { + await fetch(`${BASE}/me/account`, { method: 'DELETE' }) +} + +export async function exportData(): Promise { + const res = await fetch(`${BASE}/me/export`) + if (!res.ok) throw new Error(await res.text()) + const blob = await res.blob() + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url; a.download = 'cruciverb-export.json'; a.click() + URL.revokeObjectURL(url) +} + +export async function importData(data: ImportPayload): Promise { + const res = await fetch(`${BASE}/me/import`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }) + if (!res.ok) throw new Error(await res.text()) + return res.json() +} + +// user migration + +export async function migrateAccount(targetDomain: string): Promise { + const res = await fetch(`${BASE}/me/migrate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ target_domain: targetDomain }), + }) + if (!res.ok) throw new Error(await res.text()) + return res.json() +} + +// user blocklist import/export + +export async function exportUserBlocklist(): Promise { + const res = await fetch(`${BASE}/me/blocklist`) + if (!res.ok) throw new Error(await res.text()) + const blob = await res.blob() + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url; a.download = 'my-blocklist.json'; a.click() + URL.revokeObjectURL(url) +} + +export async function importUserBlocklist(data: ImportPayload): Promise { + const res = await fetch(`${BASE}/me/blocklist/import`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }) + if (!res.ok) throw new Error(await res.text()) + return res.json() +} + +// word of the day + +export async function fetchWordOfTheDay(): Promise { + const res = await fetch(`${BASE}/word-of-the-day`) + if (!res.ok) return { word: '', definition: '', clue: '', length: 0 } + return res.json() +} + +// clue trainer + +export async function fetchTrainerClue(difficulty: string): Promise { + const res = await fetch(`${BASE}/trainer/clue?difficulty=${difficulty}`) + if (!res.ok) throw new Error(await res.text()) + return res.json() +} + +export async function checkTrainerAnswer(clue: string, answer: string): Promise<{ correct: boolean; answer: string }> { + const res = await fetch(`${BASE}/trainer/check`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ clue, answer }), + }) + if (!res.ok) throw new Error(await res.text()) + return res.json() +} + +// content reporting + +export async function submitReport(contentType: string, contentId: string, reason?: string): Promise { + const res = await fetch(`${BASE}/report`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ content_type: contentType, content_id: contentId, reason }), + }) + if (!res.ok) throw new Error(await res.text()) + return res.json() +} + +// multiplayer rooms + +export async function createRoom(puzzleId: string, mode: string, partySub?: string): Promise<{ code: string }> { + const body: CreateRoomBody = { puzzle_id: puzzleId, mode } + if (partySub) body.party_sub = partySub + const res = await fetch(`${BASE}/rooms`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + if (!res.ok) throw new Error(await res.text()) + return res.json() +} + +export async function getRoomInfo(code: string): Promise { + const res = await fetch(`${BASE}/rooms/${code}`) + if (!res.ok) throw new Error(await res.text()) + return res.json() +} + +export function connectRoom(code: string): WebSocket { + const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:' + return new WebSocket(`${proto}//${window.location.host}/api/rooms/${code}/ws`) +} + +// wordlist packs + +export async function fetchPacks(search?: string): Promise<{ packs: PackItem[] }> { + const params = search ? `?search=${encodeURIComponent(search)}` : '' + const res = await fetch(`${BASE}/packs${params}`) + return res.json() +} + +export async function createPack(pack: { + name: string + description?: string + tags?: string[] + words: PackWord[] +}): Promise { + const res = await fetch(`${BASE}/packs`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(pack), + }) + if (!res.ok) throw new Error(await res.text()) + return res.json() +} + +// admin setup + +export async function fetchAdminStatus(): Promise<{ configured: boolean }> { + const res = await fetch(`${BASE}/admin/status`) + if (!res.ok) return { configured: true } + return res.json() +} + +export async function setupAdmin(username: string, password: string): Promise<{ ok: boolean }> { + const res = await fetch(`${BASE}/admin/setup`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }), + }) + if (!res.ok) throw new Error(await res.text()) + return res.json() +} + +// daily challenges + +export async function fetchDailyChallenge(date: string, preset: string): Promise<{ challenge: string; completed: boolean }> { + const res = await fetch(`${BASE}/puzzles/daily/${date}/${preset}/challenge`) + if (!res.ok) return { challenge: '', completed: false } + return res.json() +} + +export async function completeDailyChallenge(date: string, preset: string, challengeType: string): Promise { + await fetch(`${BASE}/puzzles/daily/${date}/${preset}/challenge/complete`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ challenge_type: challengeType }), + }) +} + +export async function fetchTodayChallenges(): Promise { + const res = await fetch(`${BASE}/me/challenges/today`) + if (!res.ok) return {} + return res.json() +} + +// receipts + +export async function getReceipt(id: string): Promise { + const res = await fetch(`${BASE}/receipts/${id}`) + if (!res.ok) throw new Error(await res.text()) + return res.json() +} + +export async function claimReceipt(receipt: ReceiptPayload): Promise { + const res = await fetch(`${BASE}/receipts/claim`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(receipt), + }) + if (!res.ok) throw new Error(await res.text()) + return res.json() +} \ No newline at end of file