Files
hidden11/app/page.tsx
T
alexandrev-tibco fa04b20e30 Initial hidden11 MVP
2026-05-02 20:50:36 +02:00

378 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}