commit c96a23dc123408503afd956f6fa90e5b9f2b6aa8 Author: Puechberty Arthur Date: Mon Mar 30 20:19:05 2026 +0200 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1f3f329 --- /dev/null +++ b/.gitignore @@ -0,0 +1,42 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem +.vscode/ + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/README.md b/README.md new file mode 100644 index 0000000..52af32b --- /dev/null +++ b/README.md @@ -0,0 +1,58 @@ +# Chrono + +Chrono est une application de **chronometre et minuteur en ligne** construite avec Next.js. + +Site en ligne: [https://chrono.arthurp.fr](https://chrono.arthurp.fr) + +## Fonctionnalites + +- Chronometre avec precision a la milliseconde +- Gestion des tours (laps) +- Minuteurs multiples +- Interface responsive desktop/mobile +- Donnees SEO (sitemap, robots, metadata) + +## Stack technique + +- Next.js (App Router) +- React +- TypeScript +- ESLint + +## Installation locale + +```bash +npm install +npm run dev +``` + +Application disponible sur [http://localhost:3000](http://localhost:3000). + +## Scripts utiles + +```bash +npm run dev +npm run build +npm run start +npm run lint +``` + +## Deploiement + +Le projet peut etre deploye sur Vercel, VPS Docker, ou toute plateforme Node.js compatible Next.js. + +Configuration recommandee avant mise en production: + +- Verifier les variables d'environnement +- Executer `npm run build` sans erreur +- Verifier les meta SEO et le domaine de production + +## Backlink + +Si vous citez cet outil, utilisez ce lien: + +- [Chrono - chronometre et minuteur en ligne](https://chrono.arthurp.fr) + +## Licence + +Projet prive / usage personnel (a ajuster selon votre choix). diff --git a/app/components/Stopwatch.tsx b/app/components/Stopwatch.tsx new file mode 100644 index 0000000..aa7e043 --- /dev/null +++ b/app/components/Stopwatch.tsx @@ -0,0 +1,297 @@ +"use client"; + +import React, { useEffect, useCallback } from "react"; +import { useStopwatch } from "../hooks/useStopwatch"; +import { formatTime, copyToClipboard } from "../lib/utils"; + +export default function Stopwatch({ + onCopyFeedback, +}: { + onCopyFeedback: () => void; +}) { + const { elapsed, isRunning, laps, toggle, reset, lap } = useStopwatch(); + const time = formatTime(elapsed); + + // Keyboard shortcuts + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return; + switch (e.code) { + case "Space": + e.preventDefault(); + toggle(); + break; + case "KeyR": + if (!e.ctrlKey && !e.metaKey) { + e.preventDefault(); + reset(); + } + break; + case "KeyL": + e.preventDefault(); + lap(); + break; + case "KeyC": + if (!e.ctrlKey && !e.metaKey) { + e.preventDefault(); + copyToClipboard(time.full).then((ok) => { + if (ok) onCopyFeedback(); + }); + } + break; + } + }, + [toggle, reset, lap, time.full, onCopyFeedback] + ); + + useEffect(() => { + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [handleKeyDown]); + + const lapDiffs = laps.map((lapTime, i) => { + const prev = i === 0 ? 0 : laps[i - 1]; + return lapTime - prev; + }); + + return ( +
+ {/* Time display */} +
+ + {/* Copy icon hint */} +
+ + + + +
+
+ + {/* Controls */} +
+ + + {isRunning && ( + + )} + + {(elapsed > 0 || laps.length > 0) && ( + + )} +
+ + {/* Laps */} + {laps.length > 0 && ( +
+

+ Tours ({laps.length}) +

+
+ + + + + + + + + + {[...laps] + .map((l, i) => ({ lap: l, diff: lapDiffs[i], index: i })) + .reverse() + .map(({ lap: l, diff, index }) => { + const best = Math.min(...lapDiffs); + const worst = Math.max(...lapDiffs); + const isBest = lapDiffs.length > 1 && diff === best; + const isWorst = lapDiffs.length > 1 && diff === worst; + + return ( + + + + + + ); + })} + +
# + Temps total + Delta
+ + {index + 1} + {isBest && ( + + BEST + + )} + {isWorst && ( + + SLOW + + )} + + + {formatTime(l).full} + + +{formatTime(diff).full} +
+
+
+ )} + + {/* Keyboard shortcuts */} +
+ + + Espace + {" "} + Start/Pause + + + + L + {" "} + Tour + + + + R + {" "} + Reset + + + + C + {" "} + Copier + +
+
+ ); +} diff --git a/app/components/ThemeProvider.tsx b/app/components/ThemeProvider.tsx new file mode 100644 index 0000000..f17059a --- /dev/null +++ b/app/components/ThemeProvider.tsx @@ -0,0 +1,59 @@ +"use client"; + +import React, { createContext, useContext, useEffect, useState } from "react"; + +type Theme = "light" | "dark"; + +interface ThemeContextType { + theme: Theme; + toggleTheme: () => void; +} + +const ThemeContext = createContext({ + theme: "dark", + toggleTheme: () => {}, +}); + +export function useTheme() { + return useContext(ThemeContext); +} + +export function ThemeProvider({ children }: { children: React.ReactNode }) { + const [theme, setTheme] = useState("dark"); + const [mounted, setMounted] = useState(false); + + useEffect(() => { + const stored = localStorage.getItem("chrono-theme") as Theme | null; + if (stored) { + setTheme(stored); + } else if (window.matchMedia("(prefers-color-scheme: light)").matches) { + setTheme("light"); + } + setMounted(true); + }, []); + + useEffect(() => { + if (!mounted) return; + const root = document.documentElement; + if (theme === "dark") { + root.classList.add("dark"); + } else { + root.classList.remove("dark"); + } + localStorage.setItem("chrono-theme", theme); + }, [theme, mounted]); + + const toggleTheme = () => { + setTheme((prev) => (prev === "dark" ? "light" : "dark")); + }; + + if (!mounted) { + return
{children}
; + } + + return ( + + {children} + + ); +} diff --git a/app/components/Timer.tsx b/app/components/Timer.tsx new file mode 100644 index 0000000..1477e6d --- /dev/null +++ b/app/components/Timer.tsx @@ -0,0 +1,408 @@ +"use client"; + +import React, { useState, useCallback } from "react"; +import { useTimers, TimerItem } from "../hooks/useTimers"; +import { formatTime, copyToClipboard } from "../lib/utils"; + +const PRESETS = [ + { label: "1 min", seconds: 60 }, + { label: "3 min", seconds: 180 }, + { label: "5 min", seconds: 300 }, + { label: "10 min", seconds: 600 }, + { label: "15 min", seconds: 900 }, + { label: "30 min", seconds: 1800 }, +]; + +const RADIUS = 54; +const CIRCUMFERENCE = 2 * Math.PI * RADIUS; + +function CircularProgress({ + progress, + finished, +}: { + progress: number; + finished: boolean; +}) { + const offset = CIRCUMFERENCE * (1 - Math.max(0, Math.min(1, progress))); + + return ( + + {/* Track */} + + {/* Progress */} + 0.25 + ? "stroke-sky-500 dark:stroke-cyan-400" + : progress > 0.1 + ? "stroke-amber-500 dark:stroke-amber-400" + : "stroke-red-500 dark:stroke-red-400" + }`} + /> + {/* Glow when close to finish */} + {progress <= 0.1 && progress > 0 && ( + + )} + + ); +} + +function TimerCard({ + timer, + onToggle, + onReset, + onDelete, + onCopyFeedback, +}: { + timer: TimerItem; + onToggle: () => void; + onReset: () => void; + onDelete: () => void; + onCopyFeedback: () => void; +}) { + const time = formatTime(timer.remaining); + const progress = timer.duration > 0 ? timer.remaining / timer.duration : 0; + + return ( +
+ {/* Delete button */} + + +
+ {/* Circular progress */} +
+ + {/* Percentage in center */} +
+ + {timer.finished ? "Fini !" : `${Math.round(progress * 100)}%`} + +
+
+ + {/* Time + controls */} +
+ {timer.label && ( +

+ {timer.label} +

+ )} + + + {/* Controls */} +
+ {!timer.finished && ( + + )} + +
+
+
+
+ ); +} + +export default function Timer({ + onCopyFeedback, +}: { + onCopyFeedback: () => void; +}) { + const { + timers, + addTimer, + startTimer, + pauseTimer, + resetTimer, + deleteTimer, + toggleTimer, + } = useTimers(); + + const [hours, setHours] = useState(0); + const [minutes, setMinutes] = useState(5); + const [seconds, setSeconds] = useState(0); + const [label, setLabel] = useState(""); + + const handleAdd = useCallback(() => { + const totalMs = (hours * 3600 + minutes * 60 + seconds) * 1000; + if (totalMs <= 0) return; + const id = addTimer(totalMs, label || `${hours > 0 ? hours + "h " : ""}${minutes > 0 ? minutes + "m " : ""}${seconds > 0 ? seconds + "s" : ""}`.trim()); + startTimer(id); + setLabel(""); + }, [hours, minutes, seconds, label, addTimer, startTimer]); + + const handlePreset = useCallback( + (totalSeconds: number) => { + const h = Math.floor(totalSeconds / 3600); + const m = Math.floor((totalSeconds % 3600) / 60); + const s = totalSeconds % 60; + setHours(h); + setMinutes(m); + setSeconds(s); + + const id = addTimer(totalSeconds * 1000, `${m > 0 ? m + " min" : ""}${s > 0 ? " " + s + "s" : ""}`); + startTimer(id); + }, + [addTimer, startTimer] + ); + + return ( +
+ {/* Timer creation */} +
+ {/* Presets */} +
+ {PRESETS.map((p) => ( + + ))} +
+ + {/* Time input */} +
+ + + : + + + + : + + +
+ + {/* Label input */} +
+ setLabel(e.target.value)} + placeholder="Nom du minuteur (optionnel)" + className="flex-1 px-3 py-2 rounded-xl border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-sm text-zinc-800 dark:text-zinc-200 placeholder-zinc-400 dark:placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-sky-500/40 transition-all" + onKeyDown={(e) => { + if (e.key === "Enter") handleAdd(); + }} + /> +
+ + {/* Add button */} + +
+ + {/* Active timers */} + {timers.length > 0 && ( +
+

+ Minuteurs actifs ({timers.length}) +

+ {timers.map((timer) => ( + toggleTimer(timer.id)} + onReset={() => resetTimer(timer.id)} + onDelete={() => deleteTimer(timer.id)} + onCopyFeedback={onCopyFeedback} + /> + ))} +
+ )} + + {/* Empty state */} + {timers.length === 0 && ( +
+ + + + +

+ Sélectionnez un preset ou réglez un temps personnalisé +

+
+ )} +
+ ); +} + +function TimeInput({ + value, + onChange, + max, + label, +}: { + value: number; + onChange: (v: number) => void; + max: number; + label: string; +}) { + const increment = () => onChange(Math.min(max, value + 1)); + const decrement = () => onChange(Math.max(0, value - 1)); + + return ( +
+ + { + const v = parseInt(e.target.value) || 0; + onChange(Math.max(0, Math.min(max, v))); + }} + className="w-16 sm:w-20 text-center text-3xl sm:text-4xl font-mono font-bold bg-zinc-100 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl py-2 text-zinc-900 dark:text-zinc-100 focus:outline-none focus:ring-2 focus:ring-sky-500/40 transition-all" + min={0} + max={max} + /> + + + {label} + +
+ ); +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..a3c93a1 --- /dev/null +++ b/app/globals.css @@ -0,0 +1,142 @@ +@import "tailwindcss"; + +@custom-variant dark (&:is(.dark *)); + +:root { + --background: #fafafa; + --foreground: #09090b; +} + +.dark { + --background: #0a0a0a; + --foreground: #fafafa; +} + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --font-sans: var(--font-geist-sans); + --font-mono: var(--font-geist-mono); +} + +body { + background: var(--background); + color: var(--foreground); + font-family: var(--font-geist-sans), system-ui, sans-serif; + transition: background-color 0.3s ease, color 0.3s ease; +} + +/* Scrollbar */ +::-webkit-scrollbar { + width: 6px; +} +::-webkit-scrollbar-track { + background: transparent; +} +::-webkit-scrollbar-thumb { + background: #a1a1aa; + border-radius: 3px; +} +.dark ::-webkit-scrollbar-thumb { + background: #3f3f46; +} + +/* Animations */ +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes slideIn { + from { + opacity: 0; + transform: translateX(-10px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes pulse-ring { + 0% { + opacity: 1; + } + 50% { + opacity: 0.3; + } + 100% { + opacity: 1; + } +} + +@keyframes scaleIn { + from { + opacity: 0; + transform: scale(0.95); + } + to { + opacity: 1; + transform: scale(1); + } +} + +.animate-fade-in { + animation: fadeIn 0.35s ease-out; +} + +.animate-slide-in { + animation: slideIn 0.3s ease-out; +} + +.animate-scale-in { + animation: scaleIn 0.2s ease-out; +} + +.animate-pulse-ring { + animation: pulse-ring 1.5s ease-in-out infinite; +} + +/* Number input hide arrows */ +input[type="number"]::-webkit-inner-spin-button, +input[type="number"]::-webkit-outer-spin-button { + -webkit-appearance: none; + margin: 0; +} +input[type="number"] { + -moz-appearance: textfield; +} + +/* Toast animation */ +@keyframes toastIn { + from { + opacity: 0; + transform: translateY(10px) scale(0.95); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} +@keyframes toastOut { + from { + opacity: 1; + transform: translateY(0) scale(1); + } + to { + opacity: 0; + transform: translateY(-10px) scale(0.95); + } +} +.toast-enter { + animation: toastIn 0.25s ease-out; +} +.toast-exit { + animation: toastOut 0.2s ease-in forwards; +} diff --git a/app/hooks/useStopwatch.ts b/app/hooks/useStopwatch.ts new file mode 100644 index 0000000..876dbc7 --- /dev/null +++ b/app/hooks/useStopwatch.ts @@ -0,0 +1,159 @@ +"use client"; + +import { useState, useRef, useCallback, useEffect } from "react"; + +const STORAGE_KEY = "chrono-stopwatch"; + +interface SavedState { + elapsed: number; + isRunning: boolean; + laps: number[]; + savedAt: number; +} + +export function useStopwatch() { + const [elapsed, setElapsed] = useState(0); + const [isRunning, setIsRunning] = useState(false); + const [laps, setLaps] = useState([]); + + const originRef = useRef(0); + const rafRef = useRef(0); + const isRunningRef = useRef(false); + const lapsRef = useRef([]); + const initializedRef = useRef(false); + + const saveNow = useCallback( + (e: number, running: boolean, l: number[]) => { + try { + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + elapsed: e, + isRunning: running, + laps: l, + savedAt: Date.now(), + } satisfies SavedState) + ); + } catch { + // Storage not available + } + }, + [] + ); + + const tick = useCallback(() => { + if (!isRunningRef.current) return; + setElapsed(Date.now() - originRef.current); + rafRef.current = requestAnimationFrame(tick); + }, []); + + // Initialize from localStorage + useEffect(() => { + if (initializedRef.current) return; + initializedRef.current = true; + + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return; + const saved: SavedState = JSON.parse(raw); + + let restoredElapsed = saved.elapsed || 0; + if (saved.isRunning && saved.savedAt) { + restoredElapsed += Date.now() - saved.savedAt; + } + + setElapsed(restoredElapsed); + setLaps(saved.laps || []); + lapsRef.current = saved.laps || []; + + if (saved.isRunning) { + originRef.current = Date.now() - restoredElapsed; + isRunningRef.current = true; + setIsRunning(true); + rafRef.current = requestAnimationFrame(tick); + } + } catch { + // Parse error + } + }, [tick]); + + // Background tab handling + useEffect(() => { + const handleVisibility = () => { + if (!isRunningRef.current) return; + if (!document.hidden) { + setElapsed(Date.now() - originRef.current); + cancelAnimationFrame(rafRef.current); + rafRef.current = requestAnimationFrame(tick); + } + }; + document.addEventListener("visibilitychange", handleVisibility); + return () => + document.removeEventListener("visibilitychange", handleVisibility); + }, [tick]); + + // Periodic save when running + useEffect(() => { + if (!isRunning) return; + const interval = setInterval(() => { + saveNow(Date.now() - originRef.current, true, lapsRef.current); + }, 2000); + return () => clearInterval(interval); + }, [isRunning, saveNow]); + + const start = useCallback(() => { + const currentElapsed = + isRunningRef.current ? Date.now() - originRef.current : elapsed; + originRef.current = Date.now() - currentElapsed; + isRunningRef.current = true; + setIsRunning(true); + rafRef.current = requestAnimationFrame(tick); + saveNow(currentElapsed, true, lapsRef.current); + }, [elapsed, tick, saveNow]); + + const pause = useCallback(() => { + isRunningRef.current = false; + setIsRunning(false); + cancelAnimationFrame(rafRef.current); + const currentElapsed = Date.now() - originRef.current; + setElapsed(currentElapsed); + saveNow(currentElapsed, false, lapsRef.current); + }, [saveNow]); + + const reset = useCallback(() => { + isRunningRef.current = false; + setIsRunning(false); + cancelAnimationFrame(rafRef.current); + setElapsed(0); + setLaps([]); + lapsRef.current = []; + originRef.current = 0; + saveNow(0, false, []); + }, [saveNow]); + + const lap = useCallback(() => { + if (!isRunningRef.current) return; + const currentElapsed = Date.now() - originRef.current; + const newLaps = [...lapsRef.current, currentElapsed]; + lapsRef.current = newLaps; + setLaps(newLaps); + saveNow(currentElapsed, true, newLaps); + }, [saveNow]); + + const toggle = useCallback(() => { + if (isRunningRef.current) { + pause(); + } else { + start(); + } + }, [start, pause]); + + // Cleanup + useEffect(() => { + return () => { + cancelAnimationFrame(rafRef.current); + }; + }, []); + + return { elapsed, isRunning, laps, start, pause, reset, lap, toggle }; +} diff --git a/app/hooks/useTimers.ts b/app/hooks/useTimers.ts new file mode 100644 index 0000000..1c66cdf --- /dev/null +++ b/app/hooks/useTimers.ts @@ -0,0 +1,276 @@ +"use client"; + +import { useState, useRef, useCallback, useEffect } from "react"; +import { playAlarm, sendNotification, requestNotificationPermission } from "../lib/utils"; + +const STORAGE_KEY = "chrono-timers"; + +export interface TimerItem { + id: string; + label: string; + duration: number; + remaining: number; + isRunning: boolean; + endTime: number; + finished: boolean; +} + +interface SavedTimers { + timers: TimerItem[]; + savedAt: number; +} + +export function useTimers() { + const [timers, setTimers] = useState([]); + const rafRef = useRef(0); + const notifiedRef = useRef(new Set()); + const timersRef = useRef([]); + const initializedRef = useRef(false); + + // Keep ref in sync + useEffect(() => { + timersRef.current = timers; + }, [timers]); + + const saveTimers = useCallback((t: TimerItem[]) => { + try { + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ timers: t, savedAt: Date.now() } satisfies SavedTimers) + ); + } catch { + // Storage unavailable + } + }, []); + + // Animation loop + const tick = useCallback(() => { + const now = Date.now(); + setTimers((prev) => { + let changed = false; + const updated = prev.map((t) => { + if (!t.isRunning || t.finished) return t; + const remaining = t.endTime - now; + if (remaining <= 0) { + changed = true; + return { ...t, remaining: 0, isRunning: false, finished: true }; + } + changed = true; + return { ...t, remaining }; + }); + return changed ? updated : prev; + }); + rafRef.current = requestAnimationFrame(tick); + }, []); + + // Detect finished timers - trigger alarm and notification + useEffect(() => { + timers.forEach((t) => { + if (t.finished && !notifiedRef.current.has(t.id)) { + notifiedRef.current.add(t.id); + playAlarm(); + sendNotification(t.label || "Minuteur"); + } + }); + }, [timers]); + + // Start/stop animation loop + useEffect(() => { + const hasRunning = timers.some((t) => t.isRunning && !t.finished); + if (hasRunning) { + rafRef.current = requestAnimationFrame(tick); + } else { + cancelAnimationFrame(rafRef.current); + } + return () => cancelAnimationFrame(rafRef.current); + }, [timers.some((t) => t.isRunning), tick]); // eslint-disable-line react-hooks/exhaustive-deps + + // Background tab handling + useEffect(() => { + const handleVisibility = () => { + if (!document.hidden) { + const hasRunning = timersRef.current.some( + (t) => t.isRunning && !t.finished + ); + if (hasRunning) { + // Force update + const now = Date.now(); + setTimers((prev) => + prev.map((t) => { + if (!t.isRunning || t.finished) return t; + const remaining = t.endTime - now; + if (remaining <= 0) { + return { ...t, remaining: 0, isRunning: false, finished: true }; + } + return { ...t, remaining }; + }) + ); + cancelAnimationFrame(rafRef.current); + rafRef.current = requestAnimationFrame(tick); + } + } + }; + document.addEventListener("visibilitychange", handleVisibility); + return () => + document.removeEventListener("visibilitychange", handleVisibility); + }, [tick]); + + // Load from localStorage + useEffect(() => { + if (initializedRef.current) return; + initializedRef.current = true; + requestNotificationPermission(); + + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return; + const saved: SavedTimers = JSON.parse(raw); + const elapsed = Date.now() - (saved.savedAt || Date.now()); + + const restored = saved.timers.map((t) => { + if (t.isRunning && !t.finished) { + const newRemaining = t.remaining - elapsed; + if (newRemaining <= 0) { + return { ...t, remaining: 0, isRunning: false, finished: true }; + } + return { + ...t, + remaining: newRemaining, + endTime: Date.now() + newRemaining, + }; + } + return t; + }); + + setTimers(restored); + } catch { + // Parse error + } + }, []); + + // Periodic save + useEffect(() => { + const interval = setInterval(() => { + saveTimers(timersRef.current); + }, 2000); + return () => clearInterval(interval); + }, [saveTimers]); + + const addTimer = useCallback( + (duration: number, label: string = "") => { + const timer: TimerItem = { + id: crypto.randomUUID(), + label, + duration, + remaining: duration, + isRunning: false, + endTime: 0, + finished: false, + }; + setTimers((prev) => { + const next = [...prev, timer]; + saveTimers(next); + return next; + }); + return timer.id; + }, + [saveTimers] + ); + + const startTimer = useCallback( + (id: string) => { + setTimers((prev) => { + const next = prev.map((t) => { + if (t.id !== id || t.finished) return t; + return { + ...t, + isRunning: true, + endTime: Date.now() + t.remaining, + }; + }); + saveTimers(next); + return next; + }); + }, + [saveTimers] + ); + + const pauseTimer = useCallback( + (id: string) => { + setTimers((prev) => { + const next = prev.map((t) => { + if (t.id !== id) return t; + return { + ...t, + isRunning: false, + remaining: Math.max(0, t.endTime - Date.now()), + }; + }); + saveTimers(next); + return next; + }); + }, + [saveTimers] + ); + + const resetTimer = useCallback( + (id: string) => { + notifiedRef.current.delete(id); + setTimers((prev) => { + const next = prev.map((t) => { + if (t.id !== id) return t; + return { + ...t, + remaining: t.duration, + isRunning: false, + endTime: 0, + finished: false, + }; + }); + saveTimers(next); + return next; + }); + }, + [saveTimers] + ); + + const deleteTimer = useCallback( + (id: string) => { + notifiedRef.current.delete(id); + setTimers((prev) => { + const next = prev.filter((t) => t.id !== id); + saveTimers(next); + return next; + }); + }, + [saveTimers] + ); + + const toggleTimer = useCallback( + (id: string) => { + const timer = timersRef.current.find((t) => t.id === id); + if (!timer || timer.finished) return; + if (timer.isRunning) { + pauseTimer(id); + } else { + startTimer(id); + } + }, + [startTimer, pauseTimer] + ); + + // Cleanup + useEffect(() => { + return () => cancelAnimationFrame(rafRef.current); + }, []); + + return { + timers, + addTimer, + startTimer, + pauseTimer, + resetTimer, + deleteTimer, + toggleTimer, + }; +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..c2dc5a6 --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,232 @@ +import type { Metadata, Viewport } from "next"; +import { Geist, Geist_Mono } from "next/font/google"; +import "./globals.css"; +import { ThemeProvider } from "./components/ThemeProvider"; + +const geistSans = Geist({ + variable: "--font-geist-sans", + subsets: ["latin"], +}); + +const geistMono = Geist_Mono({ + variable: "--font-geist-mono", + subsets: ["latin"], +}); + +const SITE_URL = "https://chrono.arthurp.fr"; +const SITE_NAME = "Chrono"; + +export const viewport: Viewport = { + themeColor: [ + { media: "(prefers-color-scheme: light)", color: "#fafafa" }, + { media: "(prefers-color-scheme: dark)", color: "#0a0a0a" }, + ], + width: "device-width", + initialScale: 1, + maximumScale: 5, +}; + +export const metadata: Metadata = { + metadataBase: new URL(SITE_URL), + title: { + default: "Chronomètre en ligne gratuit | Minuteur & Compte à rebours", + template: "%s | Chrono", + }, + description: + "Chronomètre en ligne gratuit et minuteur professionnel. Précision à la milliseconde, tours/laps, minuteurs multiples simultanés, thème sombre, raccourcis clavier. Rapide, moderne et sans publicité.", + keywords: [ + "chronomètre en ligne", + "chronomètre en ligne gratuit", + "chronomètre gratuit", + "minuteur en ligne", + "minuteur en ligne gratuit", + "timer en ligne", + "timer gratuit", + "stopwatch", + "stopwatch online", + "compte à rebours", + "compte à rebours en ligne", + "chrono en ligne", + "minuteur gratuit", + "chronomètre précis", + "chronomètre milliseconde", + "online timer", + "online stopwatch", + ], + authors: [{ name: SITE_NAME }], + creator: SITE_NAME, + publisher: SITE_NAME, + robots: { + index: true, + follow: true, + googleBot: { + index: true, + follow: true, + "max-video-preview": -1, + "max-image-preview": "large", + "max-snippet": -1, + }, + }, + alternates: { + canonical: SITE_URL, + languages: { + "fr-FR": SITE_URL, + }, + }, + openGraph: { + title: "Chronomètre en ligne gratuit | Minuteur & Compte à rebours", + description: + "Chronomètre et minuteur en ligne : précis, rapide, gratuit. Tours/laps, minuteurs multiples, thème sombre, raccourcis clavier.", + url: SITE_URL, + siteName: SITE_NAME, + locale: "fr_FR", + type: "website", + images: [ + { + url: "/icon-512.png", + width: 512, + height: 512, + alt: "Chronomètre en ligne gratuit", + }, + ], + }, + twitter: { + card: "summary_large_image", + title: "Chronomètre en ligne gratuit | Minuteur & Chrono", + description: + "Chronomètre et minuteur en ligne : précis, rapide, gratuit. Tours/laps, minuteurs multiples.", + images: ["/icon-512.png"], + }, + icons: { + icon: [ + { url: "/favicon.ico", sizes: "32x32" }, + { url: "/icon.svg", type: "image/svg+xml" }, + ], + apple: [{ url: "/apple-icon.png", sizes: "180x180" }], + }, + category: "utility", + classification: "Utility, Productivity", +}; + +// JSON-LD structured data +const jsonLd = { + "@context": "https://schema.org", + "@type": "WebApplication", + name: "Chronomètre en ligne gratuit", + alternateName: ["Chrono", "Minuteur en ligne", "Timer en ligne"], + description: + "Chronomètre en ligne gratuit et minuteur professionnel. Précision à la milliseconde, tours/laps, minuteurs multiples simultanés.", + url: SITE_URL, + applicationCategory: "UtilityApplication", + operatingSystem: "All", + browserRequirements: "Requires JavaScript", + offers: { + "@type": "Offer", + price: "0", + priceCurrency: "EUR", + }, + featureList: [ + "Chronomètre précis à la milliseconde", + "Fonction tours/laps avec historique", + "Minuteur avec compte à rebours", + "Minuteurs multiples simultanés", + "Alerte sonore en fin de minuteur", + "Thème clair et sombre", + "Raccourcis clavier", + "Plein écran", + "Copier le temps en un clic", + "Sauvegarde automatique", + "Fonctionne hors ligne", + ], + inLanguage: "fr", + isAccessibleForFree: true, + screenshot: `${SITE_URL}/icon-512.png`, +}; + +const faqJsonLd = { + "@context": "https://schema.org", + "@type": "FAQPage", + mainEntity: [ + { + "@type": "Question", + name: "Comment utiliser le chronomètre en ligne ?", + acceptedAnswer: { + "@type": "Answer", + text: "Cliquez sur le bouton Démarrer ou appuyez sur la barre Espace pour lancer le chronomètre. Utilisez les raccourcis clavier : Espace (start/pause), L (tour), R (reset), C (copier le temps).", + }, + }, + { + "@type": "Question", + name: "Le chronomètre est-il gratuit ?", + acceptedAnswer: { + "@type": "Answer", + text: "Oui, le chronomètre et le minuteur sont 100% gratuits, sans publicité et sans inscription. Ils fonctionnent directement dans votre navigateur.", + }, + }, + { + "@type": "Question", + name: "Puis-je utiliser plusieurs minuteurs en même temps ?", + acceptedAnswer: { + "@type": "Answer", + text: "Oui, vous pouvez créer et exécuter autant de minuteurs simultanés que vous le souhaitez. Chaque minuteur fonctionne de manière indépendante avec son propre compte à rebours.", + }, + }, + { + "@type": "Question", + name: "Le chronomètre fonctionne-t-il en arrière-plan ?", + acceptedAnswer: { + "@type": "Answer", + text: "Oui, le chronomètre et les minuteurs continuent de fonctionner même si vous changez d'onglet ou réduisez la fenêtre. Le temps est sauvegardé automatiquement.", + }, + }, + ], +}; + +const breadcrumbJsonLd = { + "@context": "https://schema.org", + "@type": "BreadcrumbList", + itemListElement: [ + { + "@type": "ListItem", + position: 1, + name: "Accueil", + item: SITE_URL, + }, + ], +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + +