This commit is contained in:
+4
-407
@@ -1,410 +1,7 @@
|
||||
"use client";
|
||||
import HomeClient from "@/components/HomeClient";
|
||||
|
||||
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 { buildShareText, buildShareUrl } from "@/lib/share";
|
||||
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 const dynamic = "force-dynamic";
|
||||
|
||||
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]);
|
||||
const shareUrl = useMemo(
|
||||
() => buildShareUrl(dateKey, progress, typeof window === "undefined" ? "https://hidden11.app" : window.location.origin),
|
||||
[dateKey, progress],
|
||||
);
|
||||
const shareText = useMemo(() => buildShareText(dateKey, progress, t, shareUrl), [dateKey, progress, shareUrl, t]);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
async function shareCurrentResult() {
|
||||
if (navigator.share) {
|
||||
await navigator.share({
|
||||
title: "hidden11",
|
||||
text: shareText,
|
||||
url: shareUrl,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
window.open(
|
||||
`https://twitter.com/intent/tweet?text=${encodeURIComponent(shareText)}&url=${encodeURIComponent(shareUrl)}`,
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
);
|
||||
}
|
||||
|
||||
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}
|
||||
difficulty={activeDifficulty}
|
||||
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>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={shareCurrentResult}
|
||||
className="h-9 rounded-full bg-emerald-400 px-4 text-xs font-black uppercase tracking-wide text-pitch-950 transition hover:bg-emerald-300"
|
||||
>
|
||||
{t.shareNow}
|
||||
</button>
|
||||
<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>
|
||||
</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>
|
||||
);
|
||||
export default function Page() {
|
||||
return <HomeClient />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
"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 { buildShareText, buildShareUrl } from "@/lib/share";
|
||||
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]);
|
||||
const shareUrl = useMemo(
|
||||
() => buildShareUrl(dateKey, progress, typeof window === "undefined" ? "https://hidden11.app" : window.location.origin),
|
||||
[dateKey, progress],
|
||||
);
|
||||
const shareText = useMemo(() => buildShareText(dateKey, progress, t, shareUrl), [dateKey, progress, shareUrl, t]);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
async function shareCurrentResult() {
|
||||
if (navigator.share) {
|
||||
await navigator.share({
|
||||
title: "hidden11",
|
||||
text: shareText,
|
||||
url: shareUrl,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
window.open(
|
||||
`https://twitter.com/intent/tweet?text=${encodeURIComponent(shareText)}&url=${encodeURIComponent(shareUrl)}`,
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
);
|
||||
}
|
||||
|
||||
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}
|
||||
difficulty={activeDifficulty}
|
||||
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>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={shareCurrentResult}
|
||||
className="h-9 rounded-full bg-emerald-400 px-4 text-xs font-black uppercase tracking-wide text-pitch-950 transition hover:bg-emerald-300"
|
||||
>
|
||||
{t.shareNow}
|
||||
</button>
|
||||
<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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user