207 lines
8.8 KiB
TypeScript
207 lines
8.8 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
import { getPlayMode, savePlayMode, PlayMode } from "@/lib/session";
|
|
import { GAMES } from "@/lib/levels";
|
|
import { loadStats, getRitualStreak } from "@/lib/stats";
|
|
|
|
function todayISO() {
|
|
const d = new Date();
|
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
}
|
|
|
|
function formatTime(secs: number) {
|
|
if (!secs) return "—";
|
|
return `${Math.floor(secs / 60)}:${String(secs % 60).padStart(2, "0")}`;
|
|
}
|
|
|
|
export default function SettingsPage() {
|
|
const [mode, setMode] = useState<PlayMode>("free");
|
|
const [storageOk, setStorageOk] = useState<boolean | null>(null);
|
|
const [isIOS, setIsIOS] = useState(false);
|
|
const [isStandalone, setIsStandalone] = useState(false);
|
|
const [exportDone, setExportDone] = useState(false);
|
|
|
|
useEffect(() => {
|
|
setMode(getPlayMode());
|
|
setIsIOS(/iPhone|iPad|iPod/.test(navigator.userAgent));
|
|
setIsStandalone(window.matchMedia("(display-mode: standalone)").matches);
|
|
|
|
// Check storage persistence
|
|
if ("storage" in navigator && "persisted" in navigator.storage) {
|
|
navigator.storage.persisted().then(p => setStorageOk(p));
|
|
}
|
|
}, []);
|
|
|
|
function handleModeChange(m: PlayMode) {
|
|
setMode(m);
|
|
savePlayMode(m);
|
|
}
|
|
|
|
async function requestPersist() {
|
|
if ("storage" in navigator && "persist" in navigator.storage) {
|
|
const granted = await navigator.storage.persist();
|
|
setStorageOk(granted);
|
|
}
|
|
}
|
|
|
|
function exportData() {
|
|
const data: Record<string, unknown> = { exportDate: todayISO() };
|
|
GAMES.forEach(g => {
|
|
const raw = localStorage.getItem(`stats-${g}`);
|
|
if (raw) data[`stats-${g}`] = JSON.parse(raw);
|
|
const lvl = localStorage.getItem(`levels-${g}`);
|
|
if (lvl) data[`levels-${g}`] = JSON.parse(lvl);
|
|
});
|
|
data["pt-mode"] = localStorage.getItem("pt-mode");
|
|
data["ritual"] = localStorage.getItem("ritual-streak");
|
|
|
|
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = `puzzle-trainer-${todayISO()}.json`;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
setExportDone(true);
|
|
setTimeout(() => setExportDone(false), 2000);
|
|
}
|
|
|
|
const ritual = typeof window !== "undefined" ? getRitualStreak() : { streak: 0, lastDate: "" };
|
|
|
|
return (
|
|
<div className="px-4 pb-8">
|
|
{/* Header */}
|
|
<div className="pt-4 pb-5">
|
|
<h1 className="text-2xl font-bold text-gray-900">Réglages</h1>
|
|
</div>
|
|
|
|
{/* Stats summary */}
|
|
<section className="mb-5">
|
|
<h2 className="text-xs font-semibold uppercase tracking-wider text-gray-400 mb-2 px-1">Résumé</h2>
|
|
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm divide-y divide-gray-50">
|
|
<div className="flex items-center justify-between px-4 py-3">
|
|
<span className="text-sm text-gray-700">Série quotidienne</span>
|
|
<span className="text-sm font-bold text-gray-900">
|
|
{ritual.streak > 0 ? `🔥 ${ritual.streak} jour${ritual.streak > 1 ? "s" : ""}` : "—"}
|
|
</span>
|
|
</div>
|
|
{GAMES.map(g => {
|
|
const stats = typeof window !== "undefined" ? loadStats(g) : null;
|
|
return (
|
|
<div key={g} className="flex items-center justify-between px-4 py-3">
|
|
<span className="text-sm text-gray-700 capitalize">{g}</span>
|
|
<span className="text-sm text-gray-500">
|
|
{stats?.total ?? 0} résolus · meilleur {formatTime(stats?.bestTime ?? 0)}
|
|
</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</section>
|
|
|
|
{/* Mode de jeu */}
|
|
<section className="mb-5">
|
|
<h2 className="text-xs font-semibold uppercase tracking-wider text-gray-400 mb-2 px-1">Mode de jeu</h2>
|
|
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm">
|
|
<button
|
|
onClick={() => handleModeChange("free")}
|
|
className="w-full flex items-center justify-between px-4 py-3.5 text-left"
|
|
>
|
|
<div>
|
|
<p className="text-sm font-medium text-gray-900">Libre</p>
|
|
<p className="text-xs text-gray-400">Joue les puzzles dans n'importe quel ordre</p>
|
|
</div>
|
|
<span className={`w-5 h-5 rounded-full border-2 flex-shrink-0 ${mode === "free" ? "border-gray-900 bg-gray-900" : "border-gray-300"}`} />
|
|
</button>
|
|
<div className="h-px bg-gray-50 mx-4" />
|
|
<button
|
|
onClick={() => handleModeChange("session")}
|
|
className="w-full flex items-center justify-between px-4 py-3.5 text-left"
|
|
>
|
|
<div>
|
|
<p className="text-sm font-medium text-gray-900">Session — 5 à la suite</p>
|
|
<p className="text-xs text-gray-400">Enchaîne tous les jeux guidé, un après l'autre</p>
|
|
</div>
|
|
<span className={`w-5 h-5 rounded-full border-2 flex-shrink-0 ${mode === "session" ? "border-gray-900 bg-gray-900" : "border-gray-300"}`} />
|
|
</button>
|
|
</div>
|
|
</section>
|
|
|
|
{/* Données */}
|
|
<section className="mb-5">
|
|
<h2 className="text-xs font-semibold uppercase tracking-wider text-gray-400 mb-2 px-1">Données</h2>
|
|
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm divide-y divide-gray-50">
|
|
<div className="flex items-center justify-between px-4 py-3.5">
|
|
<div>
|
|
<p className="text-sm font-medium text-gray-900">Stockage persistant</p>
|
|
<p className="text-xs text-gray-400">Évite la suppression des données par Safari</p>
|
|
</div>
|
|
{storageOk === true ? (
|
|
<span className="text-xs text-green-600 font-medium">Activé</span>
|
|
) : storageOk === false ? (
|
|
<button
|
|
onClick={requestPersist}
|
|
className="text-xs font-medium text-blue-600 active:opacity-70"
|
|
>
|
|
Activer
|
|
</button>
|
|
) : (
|
|
<span className="text-xs text-gray-400">—</span>
|
|
)}
|
|
</div>
|
|
<button
|
|
onClick={exportData}
|
|
className="w-full flex items-center justify-between px-4 py-3.5 text-left active:opacity-70"
|
|
>
|
|
<div>
|
|
<p className="text-sm font-medium text-gray-900">Exporter mes données</p>
|
|
<p className="text-xs text-gray-400">Télécharge un fichier JSON de sauvegarde</p>
|
|
</div>
|
|
<span className="text-xs font-medium text-blue-600">
|
|
{exportDone ? "✓ Téléchargé" : "Télécharger"}
|
|
</span>
|
|
</button>
|
|
</div>
|
|
</section>
|
|
|
|
{/* iOS Install guide */}
|
|
{isIOS && !isStandalone && (
|
|
<section className="mb-5">
|
|
<h2 className="text-xs font-semibold uppercase tracking-wider text-gray-400 mb-2 px-1">Installer l'app</h2>
|
|
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-4">
|
|
<p className="text-sm font-medium text-gray-900 mb-3">Ajouter à l'écran d'accueil</p>
|
|
<div className="flex flex-col gap-3">
|
|
{[
|
|
{ n: 1, text: "Appuie sur l'icône Partager (carré avec flèche vers le haut) en bas de Safari" },
|
|
{ n: 2, text: "Fais défiler et appuie sur « Sur l'écran d'accueil »" },
|
|
{ n: 3, text: "Appuie sur « Ajouter » en haut à droite" },
|
|
].map(step => (
|
|
<div key={step.n} className="flex gap-3 items-start">
|
|
<span className="w-5 h-5 rounded-full bg-gray-900 text-white text-[10px] font-bold flex items-center justify-center flex-shrink-0 mt-0.5">{step.n}</span>
|
|
<p className="text-sm text-gray-600 leading-snug">{step.text}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<p className="text-xs text-gray-400 mt-3">L'app sera disponible hors connexion une fois installée.</p>
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
{/* À propos */}
|
|
<section>
|
|
<h2 className="text-xs font-semibold uppercase tracking-wider text-gray-400 mb-2 px-1">À propos</h2>
|
|
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm divide-y divide-gray-50">
|
|
<div className="flex items-center justify-between px-4 py-3">
|
|
<span className="text-sm text-gray-700">Version</span>
|
|
<span className="text-sm text-gray-500">1.0</span>
|
|
</div>
|
|
<div className="flex items-center justify-between px-4 py-3">
|
|
<span className="text-sm text-gray-700">Données stockées</span>
|
|
<span className="text-sm text-gray-500">100% sur votre appareil</span>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|