alpha01 filetti: web app, crossword service and tor batch
This commit is contained in:
37
webapp/components/clue-list.tsx
Normal file
37
webapp/components/clue-list.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { CrosswordResponse } from "@/lib/types";
|
||||
|
||||
type ClueListProps = {
|
||||
crossword: CrosswordResponse;
|
||||
labels: {
|
||||
across: string;
|
||||
down: string;
|
||||
};
|
||||
};
|
||||
|
||||
export function ClueList({ crossword, labels }: ClueListProps) {
|
||||
return (
|
||||
<div className="clue-list">
|
||||
<section className="clue-section">
|
||||
<h3>{labels.across}</h3>
|
||||
<ul className="clue-section__list">
|
||||
{crossword.clues.across.map((clue) => (
|
||||
<li className="clue-item" key={clue.entry_id}>
|
||||
<strong>{clue.number}.</strong> {clue.text} <span className="muted">({clue.enumeration})</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="clue-section">
|
||||
<h3>{labels.down}</h3>
|
||||
<ul className="clue-section__list">
|
||||
{crossword.clues.down.map((clue) => (
|
||||
<li className="clue-item" key={clue.entry_id}>
|
||||
<strong>{clue.number}.</strong> {clue.text} <span className="muted">({clue.enumeration})</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
50
webapp/components/crossword-answer-key.tsx
Normal file
50
webapp/components/crossword-answer-key.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { CrosswordResponse } from "@/lib/types";
|
||||
|
||||
type CrosswordAnswerKeyProps = {
|
||||
crossword: CrosswordResponse;
|
||||
labels: {
|
||||
across: string;
|
||||
down: string;
|
||||
answer: string;
|
||||
clue: string;
|
||||
};
|
||||
};
|
||||
|
||||
export function CrosswordAnswerKey({ crossword, labels }: CrosswordAnswerKeyProps) {
|
||||
const acrossEntries = crossword.entries.filter((entry) => entry.direction === "across");
|
||||
const downEntries = crossword.entries.filter((entry) => entry.direction === "down");
|
||||
|
||||
function renderEntries(entries: typeof acrossEntries) {
|
||||
return (
|
||||
<ol className="answer-key__list">
|
||||
{entries.map((entry) => (
|
||||
<li className="answer-key__item" key={entry.entry_id}>
|
||||
<div className="answer-key__heading">
|
||||
<strong>{entry.number}.</strong>
|
||||
<span className="muted">{labels.answer}:</span>
|
||||
<span className="answer-key__answer">{entry.answer}</span>
|
||||
<span className="muted">({entry.answer_length})</span>
|
||||
</div>
|
||||
<p>
|
||||
<strong>{labels.clue}:</strong> {entry.clue}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="answer-key">
|
||||
<section className="answer-key__section">
|
||||
<h3>{labels.across}</h3>
|
||||
{renderEntries(acrossEntries)}
|
||||
</section>
|
||||
|
||||
<section className="answer-key__section">
|
||||
<h3>{labels.down}</h3>
|
||||
{renderEntries(downEntries)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
183
webapp/components/crossword-config-form.tsx
Normal file
183
webapp/components/crossword-config-form.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
"use client";
|
||||
|
||||
import { startTransition, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { Locale } from "@/lib/i18n";
|
||||
import type { CrosswordRequest } from "@/lib/types";
|
||||
|
||||
const DEFAULTS = {
|
||||
topic: "transport",
|
||||
difficulty: "medium",
|
||||
initialWordCount: 19,
|
||||
themedFillCount: 10,
|
||||
targetEmptyRatio: 0.1667,
|
||||
seed: 2,
|
||||
lexiconFile: "lexicon_it_curated_llm_aggressive.json",
|
||||
};
|
||||
|
||||
type CrosswordConfigFormProps = {
|
||||
locale: Locale;
|
||||
dict: {
|
||||
topic: string;
|
||||
difficulty: string;
|
||||
initialWords: string;
|
||||
themedFillCount: string;
|
||||
targetEmptyRatio: string;
|
||||
seed: string;
|
||||
lexiconFile: string;
|
||||
submit: string;
|
||||
submitting: string;
|
||||
note: string;
|
||||
};
|
||||
};
|
||||
|
||||
export function CrosswordConfigForm({ locale, dict }: CrosswordConfigFormProps) {
|
||||
const router = useRouter();
|
||||
const [topic, setTopic] = useState(DEFAULTS.topic);
|
||||
const [difficulty, setDifficulty] = useState(DEFAULTS.difficulty);
|
||||
const [initialWordCount, setInitialWordCount] = useState(DEFAULTS.initialWordCount);
|
||||
const [themedFillCount, setThemedFillCount] = useState(DEFAULTS.themedFillCount);
|
||||
const [targetEmptyRatio, setTargetEmptyRatio] = useState(DEFAULTS.targetEmptyRatio);
|
||||
const [seed, setSeed] = useState(DEFAULTS.seed);
|
||||
const [lexiconFile, setLexiconFile] = useState(DEFAULTS.lexiconFile);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
|
||||
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
setErrorMessage("");
|
||||
|
||||
const requestPayload: CrosswordRequest = {
|
||||
schema_version: "1.0",
|
||||
request_id: `req-${Date.now()}`,
|
||||
requested_at: new Date().toISOString(),
|
||||
generator: {
|
||||
topic: topic
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean),
|
||||
difficulty,
|
||||
seed,
|
||||
initial_word_count: initialWordCount,
|
||||
themed_fill_count: themedFillCount,
|
||||
target_empty_ratio: targetEmptyRatio,
|
||||
diffxy: 7,
|
||||
time_limit_seconds: 8,
|
||||
max_candidates_per_word: 12,
|
||||
lexicon_file: lexiconFile,
|
||||
definitions_enabled: true,
|
||||
definition_style: "classic",
|
||||
preferred_output_language: locale,
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/crosswords/generate", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(requestPayload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorPayload = await response.json().catch(() => ({}));
|
||||
throw new Error(errorPayload.message || "Unable to generate crossword");
|
||||
}
|
||||
|
||||
const generated = await response.json();
|
||||
|
||||
startTransition(() => {
|
||||
router.push(`/${locale}/crosswords/${generated.crossword_id}`);
|
||||
});
|
||||
} catch (error) {
|
||||
setErrorMessage(error instanceof Error ? error.message : "Unexpected error");
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="config-form" onSubmit={handleSubmit}>
|
||||
<div className="config-form__grid">
|
||||
<div className="field">
|
||||
<label htmlFor="topic">{dict.topic}</label>
|
||||
<input id="topic" value={topic} onChange={(event) => setTopic(event.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label htmlFor="difficulty">{dict.difficulty}</label>
|
||||
<select
|
||||
id="difficulty"
|
||||
value={difficulty}
|
||||
onChange={(event) => setDifficulty(event.target.value)}
|
||||
>
|
||||
<option value="easy">easy</option>
|
||||
<option value="medium">medium</option>
|
||||
<option value="hard">hard</option>
|
||||
<option value="expert">expert</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label htmlFor="initialWordCount">{dict.initialWords}</label>
|
||||
<input
|
||||
id="initialWordCount"
|
||||
type="number"
|
||||
value={initialWordCount}
|
||||
onChange={(event) => setInitialWordCount(Number(event.target.value))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label htmlFor="themedFillCount">{dict.themedFillCount}</label>
|
||||
<input
|
||||
id="themedFillCount"
|
||||
type="number"
|
||||
value={themedFillCount}
|
||||
onChange={(event) => setThemedFillCount(Number(event.target.value))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label htmlFor="targetEmptyRatio">{dict.targetEmptyRatio}</label>
|
||||
<input
|
||||
id="targetEmptyRatio"
|
||||
type="number"
|
||||
step="0.0001"
|
||||
value={targetEmptyRatio}
|
||||
onChange={(event) => setTargetEmptyRatio(Number(event.target.value))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label htmlFor="seed">{dict.seed}</label>
|
||||
<input
|
||||
id="seed"
|
||||
type="number"
|
||||
value={seed}
|
||||
onChange={(event) => setSeed(Number(event.target.value))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label htmlFor="lexiconFile">{dict.lexiconFile}</label>
|
||||
<input
|
||||
id="lexiconFile"
|
||||
value={lexiconFile}
|
||||
onChange={(event) => setLexiconFile(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="actions">
|
||||
<button className="button" type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? dict.submitting : dict.submit}
|
||||
</button>
|
||||
<span className="muted">{dict.note}</span>
|
||||
</div>
|
||||
|
||||
{errorMessage ? <p className="form-error">{errorMessage}</p> : null}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
113
webapp/components/crossword-player.tsx
Normal file
113
webapp/components/crossword-player.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import type { CrosswordResponse } from "@/lib/types";
|
||||
|
||||
type CrosswordPlayerProps = {
|
||||
crossword: CrosswordResponse;
|
||||
labels: {
|
||||
topic: string;
|
||||
difficulty: string;
|
||||
lexicon: string;
|
||||
};
|
||||
readOnly?: boolean;
|
||||
revealSolution?: boolean;
|
||||
};
|
||||
|
||||
type CellState = Record<string, string>;
|
||||
|
||||
function cellKey(row: number, col: number) {
|
||||
return `${row}:${col}`;
|
||||
}
|
||||
|
||||
export function CrosswordPlayer({
|
||||
crossword,
|
||||
labels,
|
||||
readOnly = false,
|
||||
revealSolution = false,
|
||||
}: CrosswordPlayerProps) {
|
||||
const [activeCell, setActiveCell] = useState<string | null>(null);
|
||||
const [letters, setLetters] = useState<CellState>({});
|
||||
|
||||
useEffect(() => {
|
||||
const firstCell = crossword.grid.cells.find((cell) => cell.kind === "letter");
|
||||
if (firstCell) {
|
||||
setActiveCell(cellKey(firstCell.row, firstCell.col));
|
||||
}
|
||||
}, [crossword]);
|
||||
|
||||
function writeLetter(row: number, col: number, value: string) {
|
||||
const normalized = value.slice(-1).toUpperCase();
|
||||
setLetters((current) => ({
|
||||
...current,
|
||||
[cellKey(row, col)]: normalized,
|
||||
}));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="player-shell">
|
||||
<div className="chip-row">
|
||||
<span className="chip">{labels.topic}: {crossword.generator.topic.join(", ")}</span>
|
||||
<span className="chip">{labels.difficulty}: {crossword.generator.difficulty}</span>
|
||||
<span className="chip">{labels.lexicon}: {crossword.generator.runtime_lexicon}</span>
|
||||
</div>
|
||||
|
||||
<div className="grid-board-wrap">
|
||||
<div
|
||||
className={`grid-board ${revealSolution ? "grid-board--solution" : ""}`}
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${crossword.grid.cols}, minmax(0, 1fr))`,
|
||||
}}
|
||||
>
|
||||
{crossword.grid.cells.map((cell) => {
|
||||
const key = cellKey(cell.row, cell.col);
|
||||
if (cell.kind === "block") {
|
||||
return <div className="grid-cell grid-cell--block" key={key} />;
|
||||
}
|
||||
|
||||
const currentValue = revealSolution ? cell.solution ?? "" : letters[key] ?? "";
|
||||
const isActive = activeCell === key;
|
||||
const className = [
|
||||
"grid-cell",
|
||||
isActive && !readOnly ? "grid-cell--active" : "",
|
||||
currentValue ? "grid-cell--filled" : "",
|
||||
revealSolution ? "grid-cell--revealed" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
if (readOnly) {
|
||||
return (
|
||||
<div className={className} key={key}>
|
||||
{cell.number ? <span className="grid-cell__number">{cell.number}</span> : null}
|
||||
{currentValue}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
aria-label={`Cell ${cell.row + 1}, ${cell.col + 1}`}
|
||||
className={className}
|
||||
key={key}
|
||||
onClick={() => setActiveCell(key)}
|
||||
onKeyDown={(event) => {
|
||||
if (/^[a-zA-Z]$/.test(event.key)) {
|
||||
writeLetter(cell.row, cell.col, event.key);
|
||||
}
|
||||
if (event.key === "Backspace") {
|
||||
writeLetter(cell.row, cell.col, "");
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{cell.number ? <span className="grid-cell__number">{cell.number}</span> : null}
|
||||
{currentValue}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
110
webapp/components/crossword-runtime-page.tsx
Normal file
110
webapp/components/crossword-runtime-page.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { ClueList } from "@/components/clue-list";
|
||||
import { CrosswordPlayer } from "@/components/crossword-player";
|
||||
import { useCrosswordData } from "@/components/use-crossword-data";
|
||||
import type { Locale } from "@/lib/i18n";
|
||||
|
||||
type CrosswordRuntimePageProps = {
|
||||
id: string;
|
||||
locale: Locale;
|
||||
labels: {
|
||||
player: {
|
||||
topic: string;
|
||||
difficulty: string;
|
||||
lexicon: string;
|
||||
};
|
||||
clues: {
|
||||
across: string;
|
||||
down: string;
|
||||
};
|
||||
play: {
|
||||
overviewTitle: string;
|
||||
boardTitle: string;
|
||||
cluesTitle: string;
|
||||
instructions: string;
|
||||
actions: {
|
||||
newCrossword: string;
|
||||
viewSolution: string;
|
||||
};
|
||||
stats: {
|
||||
words: string;
|
||||
intersections: string;
|
||||
filledCells: string;
|
||||
topicWords: string;
|
||||
};
|
||||
};
|
||||
loading: string;
|
||||
errorPrefix: string;
|
||||
};
|
||||
};
|
||||
|
||||
export function CrosswordRuntimePage({ id, locale, labels }: CrosswordRuntimePageProps) {
|
||||
const { crossword, errorMessage, isLoading } = useCrosswordData(id, locale);
|
||||
|
||||
if (errorMessage) {
|
||||
return <p className="form-error">{labels.errorPrefix}: {errorMessage}</p>;
|
||||
}
|
||||
|
||||
if (isLoading || !crossword) {
|
||||
return <p className="muted">{labels.loading}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="stack">
|
||||
<div className="card play-overview">
|
||||
<div className="play-overview__header">
|
||||
<div>
|
||||
<span className="hero__badge hero__badge--soft">{crossword.summary.title}</span>
|
||||
<h2>{labels.play.overviewTitle}</h2>
|
||||
<p className="muted">{labels.play.instructions}</p>
|
||||
</div>
|
||||
<div className="play-overview__actions">
|
||||
<Link className="button" href={`/${locale}/new`}>
|
||||
{labels.play.actions.newCrossword}
|
||||
</Link>
|
||||
<Link className="button button--secondary" href={`/${locale}/crosswords/${id}/solution`}>
|
||||
{labels.play.actions.viewSolution}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="kpi-grid kpi-grid--play">
|
||||
<div className="kpi">
|
||||
<span className="muted">{labels.play.stats.words}</span>
|
||||
<strong>{crossword.summary.total_words}</strong>
|
||||
</div>
|
||||
<div className="kpi">
|
||||
<span className="muted">{labels.play.stats.intersections}</span>
|
||||
<strong>{crossword.summary.intersections}</strong>
|
||||
</div>
|
||||
<div className="kpi">
|
||||
<span className="muted">{labels.play.stats.filledCells}</span>
|
||||
<strong>{crossword.diagnostics.filled_cells}</strong>
|
||||
</div>
|
||||
<div className="kpi">
|
||||
<span className="muted">{labels.play.stats.topicWords}</span>
|
||||
<strong>{crossword.diagnostics.topic_words}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card play-stage">
|
||||
<div className="play-stage__header">
|
||||
<h2>{labels.play.boardTitle}</h2>
|
||||
<p className="muted">{crossword.summary.subtitle}</p>
|
||||
</div>
|
||||
<CrosswordPlayer crossword={crossword} labels={labels.player} />
|
||||
</div>
|
||||
|
||||
<section className="card play-sidebar play-sidebar--bottom">
|
||||
<div className="play-stage__header play-stage__header--tight">
|
||||
<h2>{labels.play.cluesTitle}</h2>
|
||||
<p className="muted">{crossword.summary.title}</p>
|
||||
</div>
|
||||
<ClueList crossword={crossword} labels={labels.clues} />
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
87
webapp/components/crossword-solution-runtime-page.tsx
Normal file
87
webapp/components/crossword-solution-runtime-page.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { CrosswordAnswerKey } from "@/components/crossword-answer-key";
|
||||
import { CrosswordPlayer } from "@/components/crossword-player";
|
||||
import { useCrosswordData } from "@/components/use-crossword-data";
|
||||
import type { Locale } from "@/lib/i18n";
|
||||
|
||||
type CrosswordSolutionRuntimePageProps = {
|
||||
id: string;
|
||||
locale: Locale;
|
||||
labels: {
|
||||
player: {
|
||||
topic: string;
|
||||
difficulty: string;
|
||||
lexicon: string;
|
||||
};
|
||||
clues: {
|
||||
across: string;
|
||||
down: string;
|
||||
};
|
||||
solution: {
|
||||
solvedGridTitle: string;
|
||||
answersTitle: string;
|
||||
backToGame: string;
|
||||
answer: string;
|
||||
clue: string;
|
||||
};
|
||||
loading: string;
|
||||
errorPrefix: string;
|
||||
};
|
||||
};
|
||||
|
||||
export function CrosswordSolutionRuntimePage({
|
||||
id,
|
||||
locale,
|
||||
labels,
|
||||
}: CrosswordSolutionRuntimePageProps) {
|
||||
const { crossword, errorMessage, isLoading } = useCrosswordData(id, locale);
|
||||
|
||||
if (errorMessage) {
|
||||
return <p className="form-error">{labels.errorPrefix}: {errorMessage}</p>;
|
||||
}
|
||||
|
||||
if (isLoading || !crossword) {
|
||||
return <p className="muted">{labels.loading}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="stack">
|
||||
<div className="card solution-topbar">
|
||||
<Link className="button button--secondary" href={`/${locale}/crosswords/${id}`}>
|
||||
{labels.solution.backToGame}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="card play-stage">
|
||||
<div className="play-stage__header">
|
||||
<h2>{labels.solution.solvedGridTitle}</h2>
|
||||
<p className="muted">{crossword.summary.title}</p>
|
||||
</div>
|
||||
<CrosswordPlayer
|
||||
crossword={crossword}
|
||||
labels={labels.player}
|
||||
readOnly
|
||||
revealSolution
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section className="card play-sidebar play-sidebar--bottom">
|
||||
<div className="play-stage__header play-stage__header--tight">
|
||||
<h2>{labels.solution.answersTitle}</h2>
|
||||
<p className="muted">{crossword.summary.subtitle}</p>
|
||||
</div>
|
||||
<CrosswordAnswerKey
|
||||
crossword={crossword}
|
||||
labels={{
|
||||
across: labels.clues.across,
|
||||
down: labels.clues.down,
|
||||
answer: labels.solution.answer,
|
||||
clue: labels.solution.clue,
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
29
webapp/components/language-switcher.tsx
Normal file
29
webapp/components/language-switcher.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import Link from "next/link";
|
||||
import { LOCALES, type Locale } from "@/lib/i18n";
|
||||
|
||||
type LanguageSwitcherProps = {
|
||||
locale: Locale;
|
||||
path?: string;
|
||||
};
|
||||
|
||||
const LABELS: Record<Locale, string> = {
|
||||
it: "Italiano",
|
||||
en: "English",
|
||||
es: "Espanol",
|
||||
};
|
||||
|
||||
export function LanguageSwitcher({ locale, path = "" }: LanguageSwitcherProps) {
|
||||
return (
|
||||
<nav aria-label="Language switcher" className="lang-switcher">
|
||||
{LOCALES.map((item) => (
|
||||
<Link
|
||||
className={`lang-switcher__link ${item === locale ? "lang-switcher__link--active" : ""}`}
|
||||
href={`/${item}${path}`}
|
||||
key={item}
|
||||
>
|
||||
{LABELS[item]}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
61
webapp/components/use-crossword-data.ts
Normal file
61
webapp/components/use-crossword-data.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { getMockCrosswordResponse } from "@/lib/mock-crossword";
|
||||
import type { Locale } from "@/lib/i18n";
|
||||
import type { CrosswordResponse } from "@/lib/types";
|
||||
|
||||
type UseCrosswordDataResult = {
|
||||
crossword: CrosswordResponse | null;
|
||||
errorMessage: string;
|
||||
isLoading: boolean;
|
||||
};
|
||||
|
||||
export function useCrosswordData(id: string, locale: Locale): UseCrosswordDataResult {
|
||||
const [crossword, setCrossword] = useState<CrosswordResponse | null>(
|
||||
id === "demo-transport" ? getMockCrosswordResponse(locale) : null,
|
||||
);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(id !== "demo-transport");
|
||||
|
||||
useEffect(() => {
|
||||
if (id === "demo-transport") {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setIsLoading(true);
|
||||
setErrorMessage("");
|
||||
|
||||
fetch(`/api/crosswords/${id}`)
|
||||
.then(async (response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then((payload: CrosswordResponse) => {
|
||||
if (!cancelled) {
|
||||
setCrossword(payload);
|
||||
setIsLoading(false);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!cancelled) {
|
||||
setErrorMessage(error instanceof Error ? error.message : "Unexpected error");
|
||||
setIsLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [id, locale]);
|
||||
|
||||
return {
|
||||
crossword,
|
||||
errorMessage,
|
||||
isLoading,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user