Files
cruciverb/frontend/src/api.ts
T
2025-11-27 17:42:45 +02:00

1118 lines
31 KiB
TypeScript

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<string, string | number | boolean | null | string[]>
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<string, { average: number; count: number }>
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<string, PresetStats>
solve_history: SolveEntry[]
activity_calendar: Record<string, number>
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<string, FastestRecord>
solve_time_distribution: number[]
monthly_totals: MonthlyEntry[]
completion_rate: number
words_encountered: number
avg_time_by_hour: Record<string, number>
difficulty_progression: DiffEntry[]
}
export interface WordOfTheDayResult {
word: string
definition: string
clue: string
length: number
}
export type ChallengeCompletionMap = Record<string, boolean>
export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }
export type ImportPayload = Record<string, JsonValue>
async function getJson<T>(res: Response): Promise<T> {
// 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<ThemeInfo[]> {
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<GenerateResponse> {
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<GenerateResponse> {
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<GenerateResponse> {
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<GenerateResponse> {
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<ValidateResult> {
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<HintResult> {
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<MeInfo> {
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<void> {
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<void> {
await fetch(`${BASE}/puzzles/${id}/progress`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cells }),
})
}
export async function fetchLeaderboard(puzzleId: string): Promise<LeaderboardResult> {
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<LeaderboardResult> {
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<LeaderboardResult> {
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<LeaderboardResult> {
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<LeaderboardResult> {
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<LeaderboardResult> {
const res = await fetch(`${BASE}/leaderboards/streaks`)
if (!res.ok) throw new Error(await res.text())
return res.json()
}
export async function fetchCompletionistLeaderboard(): Promise<LeaderboardResult> {
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<LeaderboardResult> {
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<Analytics> {
const res = await fetch(`${BASE}/me/analytics`)
if (!res.ok) throw new Error(await res.text())
return res.json()
}
export async function fetchSettings(): Promise<ServerSettings> {
const res = await fetch(`${BASE}/me/settings`)
if (!res.ok) return {}
return res.json()
}
export async function saveSettings(settings: ServerSettings): Promise<void> {
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<boolean> {
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<HistoryEntry[]> {
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<RatingResult> {
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<RatingResult> {
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<DailyRatings> {
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<void> {
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<void> {
await fetch(`${BASE}/me/submissions/${id}`, { method: 'DELETE' })
}
export async function fetchMySubmissions(): Promise<ClueSubmissionItem[]> {
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<GenerateResponse> {
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<EditorAutofillResult> {
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<string[]> {
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<EditorLoadedPuzzle> {
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<SavedPuzzleItem[]> {
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<void> {
await fetch(`${BASE}/editor/puzzles/${id}`, { method: 'DELETE' })
}
export async function fetchCommunityPuzzles(): Promise<CommunityPuzzleItem[]> {
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<CommunityPlayResult> {
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<void> {
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<void> {
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<ImportResult> {
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<ConstructorAnalytics> {
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<PasskeyItem[]> {
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<void> {
await fetch(`${BASE}/me/account`, { method: 'DELETE' })
}
export async function exportData(): Promise<void> {
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<ImportResult> {
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<MigrateResult> {
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<void> {
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<ImportResult> {
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<WordOfTheDayResult> {
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<TrainerClueResult> {
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<ReportResult> {
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<RoomInfo> {
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<PackActionResult> {
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<void> {
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<ChallengeCompletionMap> {
const res = await fetch(`${BASE}/me/challenges/today`)
if (!res.ok) return {}
return res.json()
}
// receipts
export async function getReceipt(id: string): Promise<ReceiptPayload> {
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<ClaimReceiptResult> {
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()
}