1118 lines
31 KiB
TypeScript
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()
|
|
} |