196 lines
6 KiB
TypeScript
196 lines
6 KiB
TypeScript
"use client";
|
|
|
|
import Link from "next/link";
|
|
import { GameId, TOTAL_LEVELS, levelMeta, GAME_META } from "@/lib/levels";
|
|
import { GameProgress } from "@/lib/progress";
|
|
|
|
interface Props {
|
|
game: GameId;
|
|
progress: GameProgress;
|
|
currentLevel?: number;
|
|
}
|
|
|
|
const DIFF_COLORS: Record<number, { bg: string; border: string; label: string; color: string }> = {
|
|
1: { bg: "#f0fdf4", border: "#86efac", label: "Facile", color: "#16a34a" },
|
|
2: { bg: "#fefce8", border: "#fde047", label: "Normal", color: "#ca8a04" },
|
|
3: { bg: "#fff7ed", border: "#fdba74", label: "Intermédiaire", color: "#ea580c" },
|
|
4: { bg: "#fef2f2", border: "#fca5a5", label: "Difficile", color: "#dc2626" },
|
|
5: { bg: "#faf5ff", border: "#d8b4fe", label: "Expert", color: "#9333ea" },
|
|
};
|
|
|
|
function fmt(s: number): string {
|
|
return `${String(Math.floor(s / 60)).padStart(2, "0")}:${String(s % 60).padStart(2, "0")}`;
|
|
}
|
|
|
|
function LevelCell({
|
|
game,
|
|
level,
|
|
done,
|
|
isCurrent,
|
|
accent,
|
|
diffInfo,
|
|
bestTime,
|
|
}: {
|
|
game: GameId;
|
|
level: number;
|
|
done: boolean;
|
|
isCurrent: boolean;
|
|
accent: string;
|
|
diffInfo: typeof DIFF_COLORS[1];
|
|
bestTime: number;
|
|
}) {
|
|
return (
|
|
<Link
|
|
href={`/${game}/level/${level}`}
|
|
title={`Niveau ${level} — ${diffInfo.label}${done ? ` · ${fmt(bestTime)}` : ""}`}
|
|
style={{
|
|
aspectRatio: "1",
|
|
borderRadius: 6,
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
fontSize: 11,
|
|
fontWeight: 600,
|
|
textDecoration: "none",
|
|
position: "relative",
|
|
...(done
|
|
? {
|
|
background: accent,
|
|
color: "#fff",
|
|
border: `1.5px solid ${accent}`,
|
|
}
|
|
: isCurrent
|
|
? {
|
|
background: diffInfo.bg,
|
|
color: accent,
|
|
border: `2px solid ${accent}`,
|
|
boxShadow: `0 0 0 3px ${accent}22`,
|
|
}
|
|
: {
|
|
background: diffInfo.bg,
|
|
color: "#9ca3af",
|
|
border: `1.5px solid ${diffInfo.border}`,
|
|
}),
|
|
}}
|
|
className={`hover:scale-105 hover:shadow-md transition-transform ${isCurrent ? "level-current" : ""}`}
|
|
>
|
|
{done ? (
|
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={3} strokeLinecap="round" strokeLinejoin="round">
|
|
<polyline points="20 6 9 17 4 12"/>
|
|
</svg>
|
|
) : (
|
|
level
|
|
)}
|
|
</Link>
|
|
);
|
|
}
|
|
|
|
export default function LevelGrid({ game, progress, currentLevel }: Props) {
|
|
const accent = GAME_META[game].accent;
|
|
const levels = Array.from({ length: TOTAL_LEVELS }, (_, i) => i + 1);
|
|
|
|
// Group levels by difficulty
|
|
const groups: { diff: number; levels: number[] }[] = [];
|
|
let currentDiff = -1;
|
|
for (const level of levels) {
|
|
const d = levelMeta(game, level).difficulty;
|
|
if (d !== currentDiff) {
|
|
groups.push({ diff: d, levels: [level] });
|
|
currentDiff = d;
|
|
} else {
|
|
groups[groups.length - 1].levels.push(level);
|
|
}
|
|
}
|
|
|
|
// Completion stats
|
|
const completedCount = Object.keys(progress).length;
|
|
|
|
return (
|
|
<div className="w-full max-w-lg">
|
|
{/* Difficulty legend */}
|
|
<div className="flex gap-3 flex-wrap mb-5">
|
|
{[1, 2, 3, 4, 5].map(d => {
|
|
const { bg, border, label, color } = DIFF_COLORS[d];
|
|
return (
|
|
<div key={d} className="flex items-center gap-1.5 text-xs" style={{ color }}>
|
|
<span style={{
|
|
width: 12,
|
|
height: 12,
|
|
borderRadius: 3,
|
|
display: "inline-block",
|
|
background: bg,
|
|
border: `1.5px solid ${border}`,
|
|
}} />
|
|
{label}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Difficulty groups */}
|
|
<div className="flex flex-col gap-5">
|
|
{groups.map(({ diff, levels: groupLevels }) => {
|
|
const diffInfo = DIFF_COLORS[diff];
|
|
const groupDone = groupLevels.filter(l => !!progress[l]).length;
|
|
|
|
return (
|
|
<div key={diff}>
|
|
{/* Group header */}
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<span
|
|
className="text-xs font-semibold uppercase tracking-wide"
|
|
style={{ color: diffInfo.color }}
|
|
>
|
|
{diffInfo.label}
|
|
</span>
|
|
<span className="text-[10px] text-gray-300 font-medium tabular-nums">
|
|
{groupDone}/{groupLevels.length}
|
|
</span>
|
|
<div className="flex-1 h-px bg-gray-100" />
|
|
</div>
|
|
|
|
{/* Grid for this difficulty group */}
|
|
<div
|
|
style={{
|
|
display: "grid",
|
|
gridTemplateColumns: "repeat(10, 1fr)",
|
|
gap: 5,
|
|
}}
|
|
>
|
|
{groupLevels.map(level => {
|
|
const record = progress[level];
|
|
const done = !!record;
|
|
const isCurrent = level === currentLevel;
|
|
const meta = levelMeta(game, level);
|
|
|
|
return (
|
|
<LevelCell
|
|
key={level}
|
|
game={game}
|
|
level={level}
|
|
done={done}
|
|
isCurrent={isCurrent}
|
|
accent={accent}
|
|
diffInfo={DIFF_COLORS[meta.difficulty]}
|
|
bestTime={record?.bestTime ?? 0}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Footer stats */}
|
|
<p className="text-xs text-gray-400 mt-5 text-center tabular-nums">
|
|
{completedCount} / {TOTAL_LEVELS} niveaux complétés
|
|
{completedCount > 0 && (
|
|
<span className="ml-2 text-gray-300">
|
|
({Math.round((completedCount / TOTAL_LEVELS) * 100)}%)
|
|
</span>
|
|
)}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|