215 lines
7.7 KiB
TypeScript
215 lines
7.7 KiB
TypeScript
"use client";
|
|
|
|
import Link from "next/link";
|
|
import { useEffect, useState } from "react";
|
|
import { todayISO } from "@/lib/rng";
|
|
import { GAME_META, GAMES, GameId } from "@/lib/levels";
|
|
import { allStats, GameStats } from "@/lib/progress";
|
|
|
|
const GAME_SYMBOL: Record<GameId, string> = {
|
|
queens: "♛",
|
|
tango: "☀",
|
|
zip: "∞",
|
|
sudoku: "#",
|
|
patches: "▦",
|
|
};
|
|
|
|
// Check if today's daily puzzle is already solved from localStorage
|
|
const DAILY_STORAGE_KEY: Record<GameId, (d: string) => string> = {
|
|
queens: d => `queens-${d}`,
|
|
tango: d => `tango-${d}`,
|
|
zip: d => `zip-${d}`,
|
|
sudoku: d => `sudoku-${d}`,
|
|
patches: d => `patches-${d}`,
|
|
};
|
|
|
|
function isDailySolved(game: GameId, date: string): boolean {
|
|
if (typeof window === "undefined") return false;
|
|
try {
|
|
const s = localStorage.getItem(DAILY_STORAGE_KEY[game](date));
|
|
if (!s) return false;
|
|
// Check stats — if lastDate matches today, it was solved
|
|
const stats = localStorage.getItem(`stats-${game}`);
|
|
if (stats) {
|
|
const parsed = JSON.parse(stats);
|
|
if (parsed.lastDate === date) return true;
|
|
}
|
|
return false;
|
|
} catch { return false; }
|
|
}
|
|
|
|
function fmt(s: number): string {
|
|
return `${String(Math.floor(s / 60)).padStart(2, "0")}:${String(s % 60).padStart(2, "0")}`;
|
|
}
|
|
|
|
function ProgressBar({ pct, color }: { pct: number; color: string }) {
|
|
return (
|
|
<div className="progress-track">
|
|
<div className="progress-fill" style={{ width: `${pct}%`, background: color }} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SkeletonCard() {
|
|
return <div className="skeleton h-[88px] rounded-2xl" />;
|
|
}
|
|
|
|
export default function Home() {
|
|
const today = todayISO();
|
|
const [stats, setStats] = useState<Record<GameId, GameStats> | null>(null);
|
|
const [solvedToday, setSolvedToday] = useState<Record<GameId, boolean>>({} as Record<GameId, boolean>);
|
|
|
|
useEffect(() => {
|
|
const refresh = () => {
|
|
setStats(allStats());
|
|
const solved = {} as Record<GameId, boolean>;
|
|
for (const g of GAMES) solved[g] = isDailySolved(g, today);
|
|
setSolvedToday(solved);
|
|
};
|
|
refresh();
|
|
window.addEventListener("focus", refresh);
|
|
document.addEventListener("visibilitychange", refresh);
|
|
return () => {
|
|
window.removeEventListener("focus", refresh);
|
|
document.removeEventListener("visibilitychange", refresh);
|
|
};
|
|
}, [today]);
|
|
|
|
const dateLabel = new Date(today + "T00:00:00").toLocaleDateString("fr-FR", {
|
|
weekday: "long", day: "numeric", month: "long",
|
|
});
|
|
|
|
const allSolvedToday = GAMES.every(g => solvedToday[g]);
|
|
const totalSolvedToday = GAMES.filter(g => solvedToday[g]).length;
|
|
|
|
return (
|
|
<div className="flex flex-col gap-10 max-w-xl mx-auto py-4 px-4">
|
|
|
|
{/* Header */}
|
|
<div className="flex items-start justify-between">
|
|
<div>
|
|
<h1 className="text-3xl font-bold text-gray-900 tracking-tight">Puzzle Trainer</h1>
|
|
<p className="text-gray-400 mt-0.5 text-sm capitalize">{dateLabel}</p>
|
|
</div>
|
|
{totalSolvedToday > 0 && (
|
|
<div className="flex flex-col items-end gap-1 mt-1">
|
|
<span className="text-xs font-semibold text-gray-500 bg-gray-100 px-2.5 py-1 rounded-full">
|
|
{totalSolvedToday}/5 aujourd'hui
|
|
</span>
|
|
{allSolvedToday && (
|
|
<span className="text-[11px] text-green-600 font-medium">✓ Tous faits !</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Daily puzzles */}
|
|
<section>
|
|
<h2 className="text-[11px] font-semibold text-gray-400 uppercase tracking-widest mb-3">
|
|
Puzzle du jour
|
|
</h2>
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2.5">
|
|
{GAMES.map((game) => {
|
|
const { name, accent, desc } = GAME_META[game];
|
|
const solved = solvedToday[game];
|
|
return (
|
|
<Link
|
|
key={game}
|
|
href={`/${game}`}
|
|
className={`group relative flex flex-col gap-2 p-4 bg-white rounded-2xl border transition-all ${
|
|
solved
|
|
? "border-green-200 shadow-sm"
|
|
: "border-gray-100 hover:border-gray-200 hover:shadow-sm"
|
|
}`}
|
|
style={{ borderTop: `3px solid ${solved ? "#16a34a" : accent}` }}
|
|
>
|
|
{/* Solved badge */}
|
|
{solved && (
|
|
<span className="absolute top-2.5 right-2.5 text-[10px] font-bold text-green-600 bg-green-50 px-1.5 py-0.5 rounded-full">
|
|
✓
|
|
</span>
|
|
)}
|
|
<div className="flex items-center justify-between pr-4">
|
|
<span className={`text-sm font-semibold ${solved ? "text-green-800" : "text-gray-800 group-hover:text-gray-600"}`}>
|
|
{name}
|
|
</span>
|
|
<span
|
|
style={{ color: solved ? "#16a34a" : accent, fontSize: 15, lineHeight: 1, fontWeight: 700 }}
|
|
aria-hidden
|
|
>
|
|
{GAME_SYMBOL[game]}
|
|
</span>
|
|
</div>
|
|
<p className="text-xs text-gray-400 leading-snug">{desc}</p>
|
|
{solved && (
|
|
<span className="text-[10px] text-green-600 font-medium -mt-0.5">Résolu aujourd'hui</span>
|
|
)}
|
|
</Link>
|
|
);
|
|
})}
|
|
</div>
|
|
</section>
|
|
|
|
{/* Progression */}
|
|
<section>
|
|
<div className="flex items-baseline justify-between mb-3">
|
|
<h2 className="text-[11px] font-semibold text-gray-400 uppercase tracking-widest">
|
|
Progression
|
|
</h2>
|
|
<span className="text-xs text-gray-300">100 niveaux par jeu</span>
|
|
</div>
|
|
<div className="flex flex-col gap-2">
|
|
{GAMES.map((game) => {
|
|
const { name, accent } = GAME_META[game];
|
|
const s = stats?.[game];
|
|
const pct = s?.pct ?? 0;
|
|
const label = !s || s.completed === 0 ? "Commencer" : `Niv. ${s.nextLevel}`;
|
|
return (
|
|
<Link
|
|
key={game}
|
|
href={`/${game}/levels`}
|
|
className="group flex items-center gap-4 p-4 bg-white rounded-2xl border border-gray-100 hover:border-gray-200 hover:shadow-sm transition-all"
|
|
>
|
|
<div className="w-[68px] shrink-0">
|
|
<span className="text-sm font-semibold text-gray-800 group-hover:text-gray-600">
|
|
{name}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="flex-1 min-w-0 flex flex-col gap-1.5">
|
|
{s ? (
|
|
<>
|
|
<ProgressBar pct={pct} color={accent} />
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-[11px] text-gray-400">{s.completed} / 100</span>
|
|
{s.bestTime > 0 && (
|
|
<span className="text-[11px] text-gray-300 timer-mono">⏱ {fmt(s.bestTime)}</span>
|
|
)}
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className="skeleton h-4 rounded" />
|
|
)}
|
|
</div>
|
|
|
|
<div
|
|
className="shrink-0 text-xs font-semibold px-3 py-1.5 rounded-full whitespace-nowrap transition-colors"
|
|
style={{ background: `${accent}18`, color: accent }}
|
|
>
|
|
{label}
|
|
</div>
|
|
</Link>
|
|
);
|
|
})}
|
|
</div>
|
|
</section>
|
|
|
|
<Link
|
|
href="/archive"
|
|
className="text-xs text-gray-300 hover:text-gray-500 transition-colors self-start"
|
|
>
|
|
Archives des puzzles quotidiens →
|
|
</Link>
|
|
</div>
|
|
);
|
|
}
|