alpha01 filetti: web app, crossword service and tor batch

This commit is contained in:
2026-06-05 16:22:17 +02:00
parent 47d8957e15
commit 9cb8a5aa8f
29 changed files with 8590 additions and 0 deletions

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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,
};
}