378 lines
14 KiB
TypeScript
378 lines
14 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useMemo, useState } from "react";
|
||
import ClueList from "@/components/ClueList";
|
||
import Header from "@/components/Header";
|
||
import Pitch from "@/components/Pitch";
|
||
import PlayerSearch from "@/components/PlayerSearch";
|
||
import ShareResult from "@/components/ShareResult";
|
||
import StatsPanel from "@/components/StatsPanel";
|
||
import {
|
||
DIFFICULTIES,
|
||
MAX_ATTEMPTS,
|
||
POSITIONS,
|
||
getDailyPuzzles,
|
||
getFixedGuess,
|
||
getTodayKey,
|
||
isAttemptComplete,
|
||
isSolved,
|
||
players,
|
||
validateGuess,
|
||
} from "@/lib/game-engine";
|
||
import { SUPPORTED_LOCALES, detectLocale, getDictionary, type Locale } from "@/lib/i18n";
|
||
import { quoteForDay } from "@/lib/quotes";
|
||
import {
|
||
createInitialProgress,
|
||
createInitialStats,
|
||
loadProgress,
|
||
loadStats,
|
||
nextDifficulty,
|
||
recordDailyStats,
|
||
saveProgress,
|
||
} from "@/lib/storage";
|
||
import type { DailyProgress, Difficulty, GlobalStats, Position } from "@/lib/types";
|
||
|
||
export default function Home() {
|
||
const dateKey = useMemo(() => getTodayKey(), []);
|
||
const dailyPuzzles = useMemo(() => getDailyPuzzles(dateKey), [dateKey]);
|
||
const [locale, setLocale] = useState<Locale>("en");
|
||
const [progress, setProgress] = useState<DailyProgress>(() => createInitialProgress());
|
||
const [activeDifficulty, setActiveDifficulty] = useState<Difficulty>("easy");
|
||
const [cluesCollapsed, setCluesCollapsed] = useState(false);
|
||
const [fieldCollapsed, setFieldCollapsed] = useState(false);
|
||
const [shareOpen, setShareOpen] = useState(false);
|
||
const [statsOpen, setStatsOpen] = useState(false);
|
||
const [selectedPosition, setSelectedPosition] = useState<Position>("DEF");
|
||
const [stats, setStats] = useState<GlobalStats>(() => createInitialStats());
|
||
const [draft, setDraft] = useState<Record<Position, string | null>>(() => getFixedGuess(dailyPuzzles[0]));
|
||
const [message, setMessage] = useState("");
|
||
|
||
const activePuzzle = dailyPuzzles.find((puzzle) => puzzle.difficulty === activeDifficulty) ?? dailyPuzzles[0];
|
||
const activeProgress = progress[activeDifficulty];
|
||
const isLocked = activeProgress.locked;
|
||
const isFinished = activeProgress.solved || activeProgress.attempts.length >= MAX_ATTEMPTS;
|
||
const t = useMemo(() => getDictionary(locale), [locale]);
|
||
const quote = useMemo(() => quoteForDay(dateKey, locale), [dateKey, locale]);
|
||
|
||
useEffect(() => {
|
||
const savedLocale = window.localStorage.getItem("hidden11-locale") as Locale | null;
|
||
setLocale(savedLocale ?? detectLocale(window.navigator.language));
|
||
const loaded = loadProgress(dateKey);
|
||
setProgress(loaded);
|
||
setStats(loadStats());
|
||
const firstPlayable = DIFFICULTIES.find((difficulty) => !loaded[difficulty].locked) ?? "easy";
|
||
setActiveDifficulty(firstPlayable);
|
||
}, [dateKey]);
|
||
|
||
useEffect(() => {
|
||
setDraft(getFixedGuess(activePuzzle));
|
||
const firstEmpty = POSITIONS.find((position) => position !== activePuzzle.fixedPosition) ?? "GK";
|
||
setSelectedPosition(firstEmpty);
|
||
setMessage("");
|
||
}, [activePuzzle]);
|
||
|
||
useEffect(() => {
|
||
if (isLocked || isFinished) return;
|
||
|
||
const interval = window.setInterval(() => {
|
||
setProgress((current) => {
|
||
const next = structuredClone(current);
|
||
next[activeDifficulty].elapsedSeconds += 1;
|
||
saveProgress(dateKey, next);
|
||
return next;
|
||
});
|
||
}, 1000);
|
||
|
||
return () => window.clearInterval(interval);
|
||
}, [activeDifficulty, dateKey, isFinished, isLocked]);
|
||
|
||
useEffect(() => {
|
||
if (!isFinished) return;
|
||
const nextStats = recordDailyStats(dateKey, progress);
|
||
setStats(nextStats);
|
||
setShareOpen(true);
|
||
}, [activeDifficulty, dateKey, isFinished, progress]);
|
||
|
||
function updateProgress(next: DailyProgress) {
|
||
setProgress(next);
|
||
saveProgress(dateKey, next);
|
||
}
|
||
|
||
function selectDifficulty(difficulty: Difficulty) {
|
||
if (progress[difficulty].locked) return;
|
||
setActiveDifficulty(difficulty);
|
||
}
|
||
|
||
function placePlayer(playerId: string) {
|
||
if (isLocked || isFinished || selectedPosition === activePuzzle.fixedPosition) return;
|
||
setDraft((current) => ({ ...current, [selectedPosition]: playerId }));
|
||
}
|
||
|
||
function submitGuess() {
|
||
if (isLocked || isFinished) return;
|
||
if (!isAttemptComplete(draft)) {
|
||
setMessage(t.completeLineup);
|
||
return;
|
||
}
|
||
|
||
const guess = validateGuess(activePuzzle, draft);
|
||
const solved = isSolved(guess);
|
||
const next = structuredClone(progress);
|
||
next[activeDifficulty].attempts.push(guess);
|
||
next[activeDifficulty].solved = solved;
|
||
|
||
if (solved) {
|
||
const unlocked = nextDifficulty(activeDifficulty);
|
||
if (unlocked) next[unlocked].locked = false;
|
||
setMessage(unlocked ? t.unlocked(t.difficulty[unlocked]) : t.allCompleted);
|
||
if (unlocked) {
|
||
setActiveDifficulty(unlocked);
|
||
}
|
||
} else if (next[activeDifficulty].attempts.length >= MAX_ATTEMPTS) {
|
||
setMessage(t.noAttemptsLeft);
|
||
} else {
|
||
setMessage(t.attemptsRemaining(MAX_ATTEMPTS - next[activeDifficulty].attempts.length));
|
||
}
|
||
|
||
updateProgress(next);
|
||
}
|
||
|
||
function resetDraft() {
|
||
setDraft(getFixedGuess(activePuzzle));
|
||
setSelectedPosition(POSITIONS.find((position) => position !== activePuzzle.fixedPosition) ?? "GK");
|
||
}
|
||
|
||
function revealHint() {
|
||
if (isLocked || isFinished) return;
|
||
|
||
const positionToReveal = POSITIONS.find(
|
||
(position) => position !== activePuzzle.fixedPosition && draft[position] !== activePuzzle.solution[position],
|
||
);
|
||
|
||
if (!positionToReveal) {
|
||
setMessage(t.noHintAvailable);
|
||
return;
|
||
}
|
||
|
||
setDraft((current) => ({
|
||
...current,
|
||
[positionToReveal]: activePuzzle.solution[positionToReveal],
|
||
}));
|
||
setSelectedPosition(positionToReveal);
|
||
setMessage(t.hintRevealed(positionToReveal));
|
||
}
|
||
|
||
function changeLocale(nextLocale: Locale) {
|
||
setLocale(nextLocale);
|
||
window.localStorage.setItem("hidden11-locale", nextLocale);
|
||
}
|
||
|
||
const actionButtons = (
|
||
<div className="flex items-center gap-1.5">
|
||
<button
|
||
type="button"
|
||
onClick={revealHint}
|
||
disabled={isLocked || isFinished}
|
||
className="flex h-8 w-8 items-center justify-center rounded-full border border-yellow-300/40 bg-yellow-300/15 text-sm font-black text-yellow-100 transition hover:bg-yellow-300/25 disabled:cursor-not-allowed disabled:opacity-40"
|
||
aria-label={t.hint}
|
||
title={t.hint}
|
||
>
|
||
?
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={submitGuess}
|
||
disabled={isLocked || isFinished}
|
||
className="flex h-9 w-9 items-center justify-center rounded-full bg-white text-base font-black text-pitch-950 shadow-lg transition hover:bg-emerald-100 disabled:cursor-not-allowed disabled:opacity-40"
|
||
aria-label={t.validate}
|
||
title={t.validate}
|
||
>
|
||
✓
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={resetDraft}
|
||
disabled={isLocked || isFinished}
|
||
className="flex h-8 w-8 items-center justify-center rounded-full border border-white/15 bg-white/[0.06] text-sm font-black text-white transition hover:bg-white/10 disabled:cursor-not-allowed disabled:opacity-40"
|
||
aria-label={t.reset}
|
||
title={t.reset}
|
||
>
|
||
↺
|
||
</button>
|
||
</div>
|
||
);
|
||
|
||
return (
|
||
<main className="min-h-screen bg-[radial-gradient(circle_at_top,#115c39_0,#061b12_45%,#04110c_100%)]">
|
||
<div className="mx-auto flex min-h-screen w-full max-w-5xl flex-col px-4 py-5 sm:px-6 lg:px-8">
|
||
<Header
|
||
attemptsUsed={activeProgress.attempts.length}
|
||
dateKey={dateKey}
|
||
elapsedSeconds={activeProgress.elapsedSeconds}
|
||
solved={activeProgress.solved}
|
||
t={t}
|
||
/>
|
||
|
||
<div className="mt-3 flex justify-end">
|
||
<button
|
||
type="button"
|
||
onClick={() => setStatsOpen(true)}
|
||
className="rounded-full border border-white/10 bg-white/[0.06] px-3 py-1.5 text-xs font-black uppercase text-white/65 transition hover:bg-white/10 hover:text-white"
|
||
>
|
||
{t.stats}
|
||
</button>
|
||
</div>
|
||
|
||
<div className="mt-3 rounded-lg border border-white/10 bg-white/[0.04] px-3 py-2">
|
||
<p className="truncate text-xs font-semibold text-white/65">
|
||
“{quote.text[locale]}” <span className="text-white/35">— {quote.author}</span>
|
||
</p>
|
||
</div>
|
||
|
||
<section
|
||
className="mt-3 overflow-hidden rounded-lg border border-white/10 bg-white/[0.06]"
|
||
aria-label={t.puzzle}
|
||
>
|
||
<div className="flex items-stretch">
|
||
{DIFFICULTIES.map((difficulty) => (
|
||
<button
|
||
key={difficulty}
|
||
type="button"
|
||
onClick={() => selectDifficulty(difficulty)}
|
||
className={`relative flex min-h-10 flex-1 flex-col items-center justify-center px-3 py-1.5 text-center transition after:absolute after:right-[-8px] after:top-0 after:z-10 after:h-full after:w-4 after:skew-x-[-18deg] after:border-r after:border-white/10 after:content-[''] last:after:hidden ${
|
||
activeDifficulty === difficulty
|
||
? "bg-white text-pitch-950 after:bg-white"
|
||
: "text-white hover:bg-white/10 after:bg-white/[0.06]"
|
||
} ${progress[difficulty].locked ? "cursor-not-allowed opacity-45" : ""}`}
|
||
title={
|
||
progress[difficulty].locked
|
||
? `${t.difficulty[difficulty]} ${t.locked}`
|
||
: progress[difficulty].solved
|
||
? `${t.difficulty[difficulty]} ${t.solved}`
|
||
: `${t.difficulty[difficulty]} ${t.available}`
|
||
}
|
||
>
|
||
<span className="flex items-center justify-center gap-1 text-xs font-black uppercase sm:text-sm">
|
||
<span>{t.difficulty[difficulty]}</span>
|
||
{progress[difficulty].locked
|
||
? "🔒"
|
||
: progress[difficulty].solved
|
||
? "✓"
|
||
: null}
|
||
</span>
|
||
<span className="mt-0.5 block text-[10px] font-bold opacity-65 sm:text-xs">
|
||
{progress[difficulty].locked
|
||
? t.locked
|
||
: progress[difficulty].solved
|
||
? `${progress[difficulty].attempts.length}/${MAX_ATTEMPTS}`
|
||
: t.left(MAX_ATTEMPTS - progress[difficulty].attempts.length)}
|
||
</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{message ? <p className="border-t border-white/10 px-2 py-1.5 text-xs font-semibold text-emerald-200">{message}</p> : null}
|
||
</section>
|
||
|
||
<div className="mt-4">
|
||
<PlayerSearch
|
||
disabled={isLocked || isFinished}
|
||
fixedPosition={activePuzzle.fixedPosition}
|
||
players={players}
|
||
selectedPosition={selectedPosition}
|
||
t={t}
|
||
onSelectPosition={setSelectedPosition}
|
||
onSelect={placePlayer}
|
||
/>
|
||
</div>
|
||
|
||
<div className="mt-5 grid flex-1 gap-5 lg:grid-cols-[minmax(0,1.1fr)_minmax(320px,0.9fr)]">
|
||
<section className="min-w-0 lg:order-1">
|
||
<div className="mb-4 lg:hidden">
|
||
<ClueList
|
||
clues={activePuzzle.clues}
|
||
collapsed={cluesCollapsed}
|
||
onToggleCollapsed={() => setCluesCollapsed((current) => !current)}
|
||
t={t}
|
||
/>
|
||
</div>
|
||
|
||
<Pitch
|
||
actions={actionButtons}
|
||
collapsed={fieldCollapsed}
|
||
draft={draft}
|
||
fixedPosition={activePuzzle.fixedPosition}
|
||
lastGuess={activeProgress.attempts[activeProgress.attempts.length - 1]}
|
||
onToggleCollapsed={() => setFieldCollapsed((current) => !current)}
|
||
selectedPosition={selectedPosition}
|
||
t={t}
|
||
onSelectPosition={setSelectedPosition}
|
||
/>
|
||
</section>
|
||
|
||
<aside className="flex min-w-0 flex-col gap-4 lg:order-2">
|
||
<div className="hidden lg:block">
|
||
<ClueList
|
||
clues={activePuzzle.clues}
|
||
collapsed={cluesCollapsed}
|
||
onToggleCollapsed={() => setCluesCollapsed((current) => !current)}
|
||
t={t}
|
||
/>
|
||
</div>
|
||
|
||
</aside>
|
||
</div>
|
||
|
||
{isFinished && shareOpen ? (
|
||
<ShareResult
|
||
dateKey={dateKey}
|
||
locale={locale}
|
||
onClose={() => setShareOpen(false)}
|
||
progress={progress}
|
||
quote={quote}
|
||
stats={stats}
|
||
t={t}
|
||
/>
|
||
) : null}
|
||
|
||
{statsOpen ? (
|
||
<div className="fixed inset-0 z-50 flex items-end bg-black/70 p-3 backdrop-blur-sm sm:items-center sm:justify-center">
|
||
<div className="w-full rounded-lg border border-white/10 bg-pitch-950 p-4 shadow-2xl sm:max-w-lg">
|
||
<div className="mb-3 flex items-center justify-between">
|
||
<h2 className="text-lg font-black text-white">{t.stats}</h2>
|
||
<button
|
||
type="button"
|
||
onClick={() => setStatsOpen(false)}
|
||
className="h-9 w-9 rounded-full border border-white/15 text-lg font-black text-white transition hover:bg-white/10"
|
||
aria-label={t.closeModal}
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
<StatsPanel stats={stats} t={t} />
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
<footer className="mt-8 flex flex-wrap items-center justify-center gap-2 pb-4 text-xs text-white/45">
|
||
<span className="mr-1 font-semibold uppercase">{t.language}</span>
|
||
{SUPPORTED_LOCALES.map((item) => (
|
||
<button
|
||
key={item.code}
|
||
type="button"
|
||
onClick={() => changeLocale(item.code)}
|
||
className={`rounded px-2 py-1 font-bold transition ${
|
||
locale === item.code
|
||
? "bg-white text-pitch-950"
|
||
: "border border-white/10 text-white/60 hover:bg-white/10 hover:text-white"
|
||
}`}
|
||
>
|
||
{item.label}
|
||
</button>
|
||
))}
|
||
</footer>
|
||
</div>
|
||
</main>
|
||
);
|
||
}
|