mirror of
https://github.com/arthur-pbty/chrono.git
synced 2026-06-03 15:07:21 +02:00
first commit
This commit is contained in:
@@ -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 (
|
||||
<div className="flex flex-col items-center gap-6 sm:gap-8 animate-fade-in">
|
||||
{/* Time display */}
|
||||
<div className="relative group">
|
||||
<button
|
||||
onClick={() =>
|
||||
copyToClipboard(time.full).then((ok) => {
|
||||
if (ok) onCopyFeedback();
|
||||
})
|
||||
}
|
||||
className="cursor-pointer select-none focus:outline-none"
|
||||
title="Copier le temps"
|
||||
aria-label="Copier le temps"
|
||||
>
|
||||
<div className="font-mono text-5xl sm:text-7xl md:text-8xl lg:text-9xl font-bold tracking-tight tabular-nums transition-all duration-100">
|
||||
<span className="text-zinc-500 dark:text-zinc-500">
|
||||
{time.hours}
|
||||
</span>
|
||||
<span className="text-zinc-400 dark:text-zinc-600">:</span>
|
||||
<span className="text-zinc-900 dark:text-zinc-100">
|
||||
{time.minutes}
|
||||
</span>
|
||||
<span className="text-zinc-400 dark:text-zinc-600">:</span>
|
||||
<span className="text-zinc-900 dark:text-zinc-100">
|
||||
{time.seconds}
|
||||
</span>
|
||||
<span className="text-zinc-400 dark:text-zinc-600">.</span>
|
||||
<span className="text-zinc-400 dark:text-zinc-500 text-3xl sm:text-5xl md:text-6xl lg:text-7xl">
|
||||
{time.milliseconds}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
{/* Copy icon hint */}
|
||||
<div className="absolute -right-8 top-1/2 -translate-y-1/2 opacity-0 group-hover:opacity-60 transition-opacity">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
|
||||
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex items-center gap-3 sm:gap-4">
|
||||
<button
|
||||
onClick={toggle}
|
||||
className={`px-6 sm:px-8 py-3 sm:py-4 rounded-2xl font-semibold text-base sm:text-lg transition-all duration-200 active:scale-95 cursor-pointer ${
|
||||
isRunning
|
||||
? "bg-amber-500/15 text-amber-600 dark:text-amber-400 hover:bg-amber-500/25 border border-amber-500/30"
|
||||
: "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 hover:bg-emerald-500/25 border border-emerald-500/30"
|
||||
}`}
|
||||
>
|
||||
{isRunning ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<rect x="6" y="4" width="4" height="16" rx="1" />
|
||||
<rect x="14" y="4" width="4" height="16" rx="1" />
|
||||
</svg>
|
||||
Pause
|
||||
</span>
|
||||
) : elapsed > 0 ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<polygon points="5 3 19 12 5 21 5 3" />
|
||||
</svg>
|
||||
Reprendre
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-2">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<polygon points="5 3 19 12 5 21 5 3" />
|
||||
</svg>
|
||||
Démarrer
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isRunning && (
|
||||
<button
|
||||
onClick={lap}
|
||||
className="px-5 sm:px-6 py-3 sm:py-4 rounded-2xl font-semibold text-base sm:text-lg bg-sky-500/15 text-sky-600 dark:text-sky-400 hover:bg-sky-500/25 border border-sky-500/30 transition-all duration-200 active:scale-95 animate-scale-in cursor-pointer"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z" />
|
||||
<line x1="4" x2="4" y1="22" y2="15" />
|
||||
</svg>
|
||||
Tour
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{(elapsed > 0 || laps.length > 0) && (
|
||||
<button
|
||||
onClick={reset}
|
||||
className="px-5 sm:px-6 py-3 sm:py-4 rounded-2xl font-semibold text-base sm:text-lg bg-red-500/10 text-red-500 dark:text-red-400 hover:bg-red-500/20 border border-red-500/20 transition-all duration-200 active:scale-95 animate-scale-in cursor-pointer"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
|
||||
<path d="M3 3v5h5" />
|
||||
</svg>
|
||||
Reset
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Laps */}
|
||||
{laps.length > 0 && (
|
||||
<div className="w-full max-w-lg mt-2 animate-fade-in">
|
||||
<h3 className="text-sm font-medium text-zinc-500 dark:text-zinc-400 mb-3 uppercase tracking-wider">
|
||||
Tours ({laps.length})
|
||||
</h3>
|
||||
<div className="max-h-64 overflow-y-auto rounded-xl border border-zinc-200 dark:border-zinc-800 bg-white/50 dark:bg-zinc-900/50 backdrop-blur-sm">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-200 dark:border-zinc-800 text-zinc-500 dark:text-zinc-400">
|
||||
<th className="py-2.5 px-4 text-left font-medium">#</th>
|
||||
<th className="py-2.5 px-4 text-right font-medium">
|
||||
Temps total
|
||||
</th>
|
||||
<th className="py-2.5 px-4 text-right font-medium">Delta</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{[...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 (
|
||||
<tr
|
||||
key={index}
|
||||
className={`border-b border-zinc-100 dark:border-zinc-800/50 last:border-0 transition-colors ${
|
||||
isBest
|
||||
? "bg-emerald-500/5"
|
||||
: isWorst
|
||||
? "bg-red-500/5"
|
||||
: "hover:bg-zinc-50 dark:hover:bg-zinc-800/30"
|
||||
}`}
|
||||
>
|
||||
<td className="py-2.5 px-4 font-medium text-zinc-600 dark:text-zinc-300">
|
||||
<span className="flex items-center gap-1.5">
|
||||
{index + 1}
|
||||
{isBest && (
|
||||
<span className="text-[10px] bg-emerald-500/20 text-emerald-600 dark:text-emerald-400 px-1.5 py-0.5 rounded-full font-semibold">
|
||||
BEST
|
||||
</span>
|
||||
)}
|
||||
{isWorst && (
|
||||
<span className="text-[10px] bg-red-500/20 text-red-500 dark:text-red-400 px-1.5 py-0.5 rounded-full font-semibold">
|
||||
SLOW
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2.5 px-4 text-right font-mono text-zinc-800 dark:text-zinc-200">
|
||||
{formatTime(l).full}
|
||||
</td>
|
||||
<td
|
||||
className={`py-2.5 px-4 text-right font-mono ${
|
||||
isBest
|
||||
? "text-emerald-600 dark:text-emerald-400"
|
||||
: isWorst
|
||||
? "text-red-500 dark:text-red-400"
|
||||
: "text-zinc-500 dark:text-zinc-400"
|
||||
}`}
|
||||
>
|
||||
+{formatTime(diff).full}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Keyboard shortcuts */}
|
||||
<div className="flex flex-wrap justify-center gap-x-4 gap-y-1 text-xs text-zinc-400 dark:text-zinc-600 mt-4">
|
||||
<span>
|
||||
<kbd className="px-1.5 py-0.5 bg-zinc-200 dark:bg-zinc-800 rounded text-[10px] font-mono">
|
||||
Espace
|
||||
</kbd>{" "}
|
||||
Start/Pause
|
||||
</span>
|
||||
<span>
|
||||
<kbd className="px-1.5 py-0.5 bg-zinc-200 dark:bg-zinc-800 rounded text-[10px] font-mono">
|
||||
L
|
||||
</kbd>{" "}
|
||||
Tour
|
||||
</span>
|
||||
<span>
|
||||
<kbd className="px-1.5 py-0.5 bg-zinc-200 dark:bg-zinc-800 rounded text-[10px] font-mono">
|
||||
R
|
||||
</kbd>{" "}
|
||||
Reset
|
||||
</span>
|
||||
<span>
|
||||
<kbd className="px-1.5 py-0.5 bg-zinc-200 dark:bg-zinc-800 rounded text-[10px] font-mono">
|
||||
C
|
||||
</kbd>{" "}
|
||||
Copier
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<ThemeContextType>({
|
||||
theme: "dark",
|
||||
toggleTheme: () => {},
|
||||
});
|
||||
|
||||
export function useTheme() {
|
||||
return useContext(ThemeContext);
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
const [theme, setTheme] = useState<Theme>("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 <div style={{ visibility: "hidden" }}>{children}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, toggleTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<svg viewBox="0 0 120 120" className="w-full h-full">
|
||||
{/* Track */}
|
||||
<circle
|
||||
cx="60"
|
||||
cy="60"
|
||||
r={RADIUS}
|
||||
fill="none"
|
||||
strokeWidth="5"
|
||||
className="stroke-zinc-200 dark:stroke-zinc-800"
|
||||
/>
|
||||
{/* Progress */}
|
||||
<circle
|
||||
cx="60"
|
||||
cy="60"
|
||||
r={RADIUS}
|
||||
fill="none"
|
||||
strokeWidth="5"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={CIRCUMFERENCE}
|
||||
strokeDashoffset={offset}
|
||||
transform="rotate(-90 60 60)"
|
||||
className={`transition-[stroke-dashoffset] duration-100 ${
|
||||
finished
|
||||
? "stroke-red-500 dark:stroke-red-400"
|
||||
: 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 && (
|
||||
<circle
|
||||
cx="60"
|
||||
cy="60"
|
||||
r={RADIUS}
|
||||
fill="none"
|
||||
strokeWidth="8"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={CIRCUMFERENCE}
|
||||
strokeDashoffset={offset}
|
||||
transform="rotate(-90 60 60)"
|
||||
className="stroke-red-500/20 dark:stroke-red-400/20 animate-pulse-ring"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div
|
||||
className={`relative rounded-2xl border p-4 sm:p-5 transition-all duration-300 animate-scale-in ${
|
||||
timer.finished
|
||||
? "border-red-500/40 bg-red-500/5 dark:bg-red-500/5 shadow-lg shadow-red-500/10"
|
||||
: "border-zinc-200 dark:border-zinc-800 bg-white/60 dark:bg-zinc-900/60 hover:border-zinc-300 dark:hover:border-zinc-700"
|
||||
} backdrop-blur-sm`}
|
||||
>
|
||||
{/* Delete button */}
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="absolute top-3 right-3 p-1 rounded-lg text-zinc-400 hover:text-red-500 hover:bg-red-500/10 transition-colors cursor-pointer"
|
||||
aria-label="Supprimer"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-4 sm:gap-5">
|
||||
{/* Circular progress */}
|
||||
<div className="w-20 h-20 sm:w-24 sm:h-24 shrink-0 relative">
|
||||
<CircularProgress progress={progress} finished={timer.finished} />
|
||||
{/* Percentage in center */}
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span
|
||||
className={`text-xs sm:text-sm font-semibold ${
|
||||
timer.finished
|
||||
? "text-red-500 dark:text-red-400"
|
||||
: "text-zinc-600 dark:text-zinc-300"
|
||||
}`}
|
||||
>
|
||||
{timer.finished ? "Fini !" : `${Math.round(progress * 100)}%`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Time + controls */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{timer.label && (
|
||||
<p className="text-sm text-zinc-500 dark:text-zinc-400 mb-1 truncate">
|
||||
{timer.label}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
onClick={() =>
|
||||
copyToClipboard(time.full).then((ok) => {
|
||||
if (ok) onCopyFeedback();
|
||||
})
|
||||
}
|
||||
className="cursor-pointer"
|
||||
title="Copier le temps"
|
||||
>
|
||||
<p
|
||||
className={`font-mono text-2xl sm:text-3xl font-bold tabular-nums ${
|
||||
timer.finished
|
||||
? "text-red-500 dark:text-red-400 animate-pulse"
|
||||
: "text-zinc-900 dark:text-zinc-100"
|
||||
}`}
|
||||
>
|
||||
{time.hours !== "00" && (
|
||||
<>
|
||||
{time.hours}
|
||||
<span className="text-zinc-400 dark:text-zinc-600">:</span>
|
||||
</>
|
||||
)}
|
||||
{time.minutes}
|
||||
<span className="text-zinc-400 dark:text-zinc-600">:</span>
|
||||
{time.seconds}
|
||||
</p>
|
||||
</button>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex items-center gap-2 mt-2.5">
|
||||
{!timer.finished && (
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className={`px-3.5 py-1.5 rounded-xl text-sm font-semibold transition-all duration-200 active:scale-95 cursor-pointer ${
|
||||
timer.isRunning
|
||||
? "bg-amber-500/15 text-amber-600 dark:text-amber-400 hover:bg-amber-500/25 border border-amber-500/30"
|
||||
: "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 hover:bg-emerald-500/25 border border-emerald-500/30"
|
||||
}`}
|
||||
>
|
||||
{timer.isRunning ? "⏸ Pause" : "▶ Start"}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onReset}
|
||||
className="px-3.5 py-1.5 rounded-xl text-sm font-semibold bg-zinc-200/60 dark:bg-zinc-800/60 text-zinc-600 dark:text-zinc-300 hover:bg-zinc-300/60 dark:hover:bg-zinc-700/60 border border-zinc-300/50 dark:border-zinc-700/50 transition-all duration-200 active:scale-95 cursor-pointer"
|
||||
>
|
||||
↻ Reset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex flex-col items-center gap-6 sm:gap-8 animate-fade-in w-full max-w-xl mx-auto">
|
||||
{/* Timer creation */}
|
||||
<div className="w-full rounded-2xl border border-zinc-200 dark:border-zinc-800 bg-white/60 dark:bg-zinc-900/60 backdrop-blur-sm p-5 sm:p-6">
|
||||
{/* Presets */}
|
||||
<div className="flex flex-wrap justify-center gap-2 mb-5">
|
||||
{PRESETS.map((p) => (
|
||||
<button
|
||||
key={p.seconds}
|
||||
onClick={() => handlePreset(p.seconds)}
|
||||
className="px-3.5 py-1.5 rounded-xl text-sm font-medium bg-zinc-100 dark:bg-zinc-800 text-zinc-700 dark:text-zinc-300 hover:bg-sky-500/15 hover:text-sky-600 dark:hover:text-sky-400 hover:border-sky-500/30 border border-zinc-200 dark:border-zinc-700 transition-all duration-200 active:scale-95 cursor-pointer"
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Time input */}
|
||||
<div className="flex items-center justify-center gap-1 sm:gap-2 mb-4">
|
||||
<TimeInput
|
||||
value={hours}
|
||||
onChange={setHours}
|
||||
max={99}
|
||||
label="Heures"
|
||||
/>
|
||||
<span className="text-2xl sm:text-3xl font-bold text-zinc-400 dark:text-zinc-600 mt-5">
|
||||
:
|
||||
</span>
|
||||
<TimeInput
|
||||
value={minutes}
|
||||
onChange={setMinutes}
|
||||
max={59}
|
||||
label="Minutes"
|
||||
/>
|
||||
<span className="text-2xl sm:text-3xl font-bold text-zinc-400 dark:text-zinc-600 mt-5">
|
||||
:
|
||||
</span>
|
||||
<TimeInput
|
||||
value={seconds}
|
||||
onChange={setSeconds}
|
||||
max={59}
|
||||
label="Secondes"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Label input */}
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<input
|
||||
type="text"
|
||||
value={label}
|
||||
onChange={(e) => 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();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Add button */}
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
disabled={hours === 0 && minutes === 0 && seconds === 0}
|
||||
className="w-full py-3 rounded-2xl font-semibold text-base bg-sky-500/15 text-sky-600 dark:text-sky-400 hover:bg-sky-500/25 border border-sky-500/30 transition-all duration-200 active:scale-[0.98] disabled:opacity-30 disabled:cursor-not-allowed cursor-pointer"
|
||||
>
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
Ajouter un minuteur
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Active timers */}
|
||||
{timers.length > 0 && (
|
||||
<div className="w-full space-y-3">
|
||||
<h3 className="text-sm font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">
|
||||
Minuteurs actifs ({timers.length})
|
||||
</h3>
|
||||
{timers.map((timer) => (
|
||||
<TimerCard
|
||||
key={timer.id}
|
||||
timer={timer}
|
||||
onToggle={() => toggleTimer(timer.id)}
|
||||
onReset={() => resetTimer(timer.id)}
|
||||
onDelete={() => deleteTimer(timer.id)}
|
||||
onCopyFeedback={onCopyFeedback}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{timers.length === 0 && (
|
||||
<div className="text-center py-8 text-zinc-400 dark:text-zinc-600">
|
||||
<svg
|
||||
className="mx-auto mb-3 opacity-40"
|
||||
width="48"
|
||||
height="48"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polyline points="12 6 12 12 16 14" />
|
||||
</svg>
|
||||
<p className="text-sm">
|
||||
Sélectionnez un preset ou réglez un temps personnalisé
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex flex-col items-center">
|
||||
<button
|
||||
onClick={increment}
|
||||
className="p-1 text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-200 transition-colors cursor-pointer"
|
||||
aria-label={`Augmenter ${label}`}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="18 15 12 9 6 15" />
|
||||
</svg>
|
||||
</button>
|
||||
<input
|
||||
type="number"
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
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}
|
||||
/>
|
||||
<button
|
||||
onClick={decrement}
|
||||
className="p-1 text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-200 transition-colors cursor-pointer"
|
||||
aria-label={`Diminuer ${label}`}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</button>
|
||||
<span className="text-[10px] text-zinc-400 dark:text-zinc-500 uppercase tracking-wider mt-0.5">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+142
@@ -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;
|
||||
}
|
||||
@@ -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<number[]>([]);
|
||||
|
||||
const originRef = useRef(0);
|
||||
const rafRef = useRef(0);
|
||||
const isRunningRef = useRef(false);
|
||||
const lapsRef = useRef<number[]>([]);
|
||||
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 };
|
||||
}
|
||||
@@ -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<TimerItem[]>([]);
|
||||
const rafRef = useRef(0);
|
||||
const notifiedRef = useRef(new Set<string>());
|
||||
const timersRef = useRef<TimerItem[]>([]);
|
||||
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,
|
||||
};
|
||||
}
|
||||
+232
@@ -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 (
|
||||
<html lang="fr" dir="ltr" suppressHydrationWarning>
|
||||
<head>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(faqJsonLd) }}
|
||||
/>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }}
|
||||
/>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link
|
||||
rel="preconnect"
|
||||
href="https://fonts.gstatic.com"
|
||||
crossOrigin="anonymous"
|
||||
/>
|
||||
</head>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
<ThemeProvider>{children}</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
export function formatTime(ms: number) {
|
||||
const abs = Math.abs(ms);
|
||||
const totalSeconds = Math.floor(abs / 1000);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
const milliseconds = Math.floor(abs % 1000);
|
||||
|
||||
return {
|
||||
hours: hours.toString().padStart(2, "0"),
|
||||
minutes: minutes.toString().padStart(2, "0"),
|
||||
seconds: seconds.toString().padStart(2, "0"),
|
||||
milliseconds: milliseconds.toString().padStart(3, "0"),
|
||||
full: `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}.${milliseconds.toString().padStart(3, "0")}`,
|
||||
};
|
||||
}
|
||||
|
||||
export function playAlarm() {
|
||||
try {
|
||||
const ctx = new (window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext)();
|
||||
const playBeep = (freq: number, startTime: number, duration: number) => {
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
osc.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
osc.frequency.value = freq;
|
||||
osc.type = "sine";
|
||||
gain.gain.setValueAtTime(0.3, startTime);
|
||||
gain.gain.exponentialRampToValueAtTime(0.01, startTime + duration);
|
||||
osc.start(startTime);
|
||||
osc.stop(startTime + duration);
|
||||
};
|
||||
const now = ctx.currentTime;
|
||||
playBeep(880, now, 0.15);
|
||||
playBeep(880, now + 0.25, 0.15);
|
||||
playBeep(1100, now + 0.5, 0.15);
|
||||
playBeep(880, now + 0.75, 0.15);
|
||||
playBeep(880, now + 1.0, 0.15);
|
||||
playBeep(1100, now + 1.25, 0.3);
|
||||
setTimeout(() => ctx.close(), 3000);
|
||||
} catch {
|
||||
// Audio not available
|
||||
}
|
||||
}
|
||||
|
||||
export function requestNotificationPermission() {
|
||||
if (typeof window !== "undefined" && "Notification" in window) {
|
||||
if (Notification.permission === "default") {
|
||||
Notification.requestPermission();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function sendNotification(label: string) {
|
||||
if (typeof window !== "undefined" && "Notification" in window) {
|
||||
if (Notification.permission === "granted") {
|
||||
new Notification("⏰ Minuteur terminé", {
|
||||
body: label ? `${label} est terminé !` : "Le minuteur est terminé !",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function toggleFullscreen() {
|
||||
if (!document.fullscreenElement) {
|
||||
document.documentElement.requestFullscreen().catch(() => {});
|
||||
} else {
|
||||
document.exitFullscreen().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
export function copyToClipboard(text: string): Promise<boolean> {
|
||||
return navigator.clipboard
|
||||
.writeText(text)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
|
||||
export default function manifest(): MetadataRoute.Manifest {
|
||||
return {
|
||||
name: "Chronomètre en ligne gratuit - Minuteur & Chrono",
|
||||
short_name: "Chrono",
|
||||
description:
|
||||
"Chronomètre en ligne gratuit et minuteur professionnel. Précision à la milliseconde, tours/laps, minuteurs multiples.",
|
||||
start_url: "/",
|
||||
display: "standalone",
|
||||
background_color: "#0a0a0a",
|
||||
theme_color: "#0ea5e9",
|
||||
orientation: "any",
|
||||
categories: ["utilities", "productivity"],
|
||||
icons: [
|
||||
{
|
||||
src: "/icon-192.png",
|
||||
sizes: "192x192",
|
||||
type: "image/png",
|
||||
},
|
||||
{
|
||||
src: "/icon-512.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
},
|
||||
{
|
||||
src: "/icon-512.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
purpose: "maskable",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
+347
@@ -0,0 +1,347 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { useTheme } from "./components/ThemeProvider";
|
||||
import { toggleFullscreen } from "./lib/utils";
|
||||
import Stopwatch from "./components/Stopwatch";
|
||||
import Timer from "./components/Timer";
|
||||
|
||||
type Mode = "stopwatch" | "timer";
|
||||
|
||||
export default function Home() {
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const [mode, setMode] = useState<Mode>("stopwatch");
|
||||
const [toast, setToast] = useState<string | null>(null);
|
||||
const [toastExiting, setToastExiting] = useState(false);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
|
||||
// Load saved mode
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem("chrono-mode") as Mode | null;
|
||||
if (saved === "stopwatch" || saved === "timer") {
|
||||
setMode(saved);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Save mode
|
||||
useEffect(() => {
|
||||
localStorage.setItem("chrono-mode", mode);
|
||||
}, [mode]);
|
||||
|
||||
// Track fullscreen state
|
||||
useEffect(() => {
|
||||
const handler = () => setIsFullscreen(!!document.fullscreenElement);
|
||||
document.addEventListener("fullscreenchange", handler);
|
||||
return () => document.removeEventListener("fullscreenchange", handler);
|
||||
}, []);
|
||||
|
||||
const showToast = useCallback((msg: string) => {
|
||||
setToastExiting(false);
|
||||
setToast(msg);
|
||||
setTimeout(() => {
|
||||
setToastExiting(true);
|
||||
setTimeout(() => setToast(null), 200);
|
||||
}, 1500);
|
||||
}, []);
|
||||
|
||||
const handleCopyFeedback = useCallback(() => {
|
||||
showToast("Temps copié !");
|
||||
}, [showToast]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
{/* Header */}
|
||||
<header className="sticky top-0 z-50 backdrop-blur-xl bg-white/70 dark:bg-zinc-950/70 border-b border-zinc-200/60 dark:border-zinc-800/60">
|
||||
<div className="max-w-3xl mx-auto px-4 sm:px-6 py-3 flex items-center justify-between">
|
||||
{/* Logo */}
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-8 h-8 rounded-xl bg-linear-to-br from-sky-500 to-cyan-400 dark:from-cyan-400 dark:to-sky-500 flex items-center justify-center">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="white"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polyline points="12 6 12 12 16 14" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-lg font-bold tracking-tight text-zinc-900 dark:text-zinc-100 hidden sm:block">
|
||||
<span aria-hidden="true">Chrono</span>
|
||||
<span className="sr-only">
|
||||
Chronomètre en ligne gratuit et minuteur
|
||||
</span>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Mode switcher */}
|
||||
<div className="flex items-center bg-zinc-100 dark:bg-zinc-800/80 rounded-xl p-0.5">
|
||||
<button
|
||||
onClick={() => setMode("stopwatch")}
|
||||
className={`px-3 sm:px-4 py-1.5 rounded-lg text-sm font-medium transition-all duration-200 cursor-pointer ${
|
||||
mode === "stopwatch"
|
||||
? "bg-white dark:bg-zinc-700 text-zinc-900 dark:text-zinc-100 shadow-sm"
|
||||
: "text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-300"
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="13" r="8" />
|
||||
<path d="M12 9v4l2 2" />
|
||||
<path d="M5 3 2 6" />
|
||||
<path d="m22 6-3-3" />
|
||||
<path d="M6.38 18.7 4 21" />
|
||||
<path d="M17.64 18.67 20 21" />
|
||||
</svg>
|
||||
<span className="hidden xs:inline">Chrono</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMode("timer")}
|
||||
className={`px-3 sm:px-4 py-1.5 rounded-lg text-sm font-medium transition-all duration-200 cursor-pointer ${
|
||||
mode === "timer"
|
||||
? "bg-white dark:bg-zinc-700 text-zinc-900 dark:text-zinc-100 shadow-sm"
|
||||
: "text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-300"
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M5 22h14" />
|
||||
<path d="M5 2h14" />
|
||||
<path d="M17 22v-4.172a2 2 0 0 0-.586-1.414L12 12l-4.414 4.414A2 2 0 0 0 7 17.828V22" />
|
||||
<path d="M7 2v4.172a2 2 0 0 0 .586 1.414L12 12l4.414-4.414A2 2 0 0 0 17 6.172V2" />
|
||||
</svg>
|
||||
<span className="hidden xs:inline">Minuteur</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Theme toggle */}
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="p-2 rounded-xl text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-zinc-200 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-all duration-200 cursor-pointer"
|
||||
aria-label="Changer de thème"
|
||||
title={theme === "dark" ? "Mode clair" : "Mode sombre"}
|
||||
>
|
||||
{theme === "dark" ? (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path d="M12 2v2" />
|
||||
<path d="M12 20v2" />
|
||||
<path d="m4.93 4.93 1.41 1.41" />
|
||||
<path d="m17.66 17.66 1.41 1.41" />
|
||||
<path d="M2 12h2" />
|
||||
<path d="M20 12h2" />
|
||||
<path d="m6.34 17.66-1.41 1.41" />
|
||||
<path d="m19.07 4.93-1.41 1.41" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Fullscreen toggle */}
|
||||
<button
|
||||
onClick={toggleFullscreen}
|
||||
className="p-2 rounded-xl text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-zinc-200 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-all duration-200 cursor-pointer hidden sm:flex"
|
||||
aria-label="Plein écran"
|
||||
title={isFullscreen ? "Quitter le plein écran" : "Plein écran"}
|
||||
>
|
||||
{isFullscreen ? (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M8 3v3a2 2 0 0 1-2 2H3" />
|
||||
<path d="M21 8h-3a2 2 0 0 1-2-2V3" />
|
||||
<path d="M3 16h3a2 2 0 0 1 2 2v3" />
|
||||
<path d="M16 21v-3a2 2 0 0 1 2-2h3" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M8 3H5a2 2 0 0 0-2 2v3" />
|
||||
<path d="M21 8V5a2 2 0 0 0-2-2h-3" />
|
||||
<path d="M3 16v3a2 2 0 0 0 2 2h3" />
|
||||
<path d="M16 21h3a2 2 0 0 0 2-2v-3" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 flex flex-col items-center justify-center px-4 sm:px-6 py-8 sm:py-12">
|
||||
<div className="w-full max-w-2xl">
|
||||
<section
|
||||
aria-label={
|
||||
mode === "stopwatch"
|
||||
? "Chronomètre en ligne"
|
||||
: "Minuteur en ligne"
|
||||
}
|
||||
>
|
||||
<h2 className="sr-only">
|
||||
{mode === "stopwatch"
|
||||
? "Chronomètre en ligne gratuit — Précision à la milliseconde"
|
||||
: "Minuteur en ligne gratuit — Compte à rebours avec alertes"}
|
||||
</h2>
|
||||
{mode === "stopwatch" ? (
|
||||
<Stopwatch onCopyFeedback={handleCopyFeedback} />
|
||||
) : (
|
||||
<Timer onCopyFeedback={handleCopyFeedback} />
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* SEO content — visible et utile, mais discret */}
|
||||
<section className="max-w-3xl mx-auto px-4 sm:px-6 pb-12 pt-4">
|
||||
<article className="prose prose-sm prose-zinc dark:prose-invert max-w-none">
|
||||
<h2 className="text-base font-semibold text-zinc-700 dark:text-zinc-300 mb-3">
|
||||
Chronomètre en ligne gratuit et minuteur professionnel
|
||||
</h2>
|
||||
<p className="text-sm text-zinc-500 dark:text-zinc-400 leading-relaxed mb-4">
|
||||
Bienvenue sur Chrono, votre <strong>chronomètre en ligne gratuit</strong> et{" "}
|
||||
<strong>minuteur professionnel</strong>. Mesurez le temps avec une précision à la
|
||||
milliseconde, enregistrez vos tours (laps), créez plusieurs minuteurs simultanés et
|
||||
profitez d'une interface moderne et rapide. Aucune inscription requise, aucune
|
||||
publicité, fonctionne directement dans votre navigateur.
|
||||
</p>
|
||||
|
||||
<div className="grid sm:grid-cols-2 gap-4 text-sm text-zinc-500 dark:text-zinc-400 mb-6">
|
||||
<div>
|
||||
<h3 className="font-medium text-zinc-700 dark:text-zinc-300 mb-1.5 text-sm">
|
||||
⏱️ Chronomètre
|
||||
</h3>
|
||||
<ul className="space-y-1 list-disc list-inside">
|
||||
<li>Démarrer, pause et remise à zéro</li>
|
||||
<li>Affichage heures, minutes, secondes, millisecondes</li>
|
||||
<li>Tours/laps avec historique et meilleur/pire temps</li>
|
||||
<li>Raccourcis clavier (Espace, L, R, C)</li>
|
||||
<li>Copie du temps en un clic</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-zinc-700 dark:text-zinc-300 mb-1.5 text-sm">
|
||||
⏳ Minuteur
|
||||
</h3>
|
||||
<ul className="space-y-1 list-disc list-inside">
|
||||
<li>Réglage heures, minutes, secondes</li>
|
||||
<li>Presets rapides (1, 3, 5, 10, 15, 30 min)</li>
|
||||
<li>Plusieurs minuteurs simultanés</li>
|
||||
<li>Alerte sonore et notification navigateur</li>
|
||||
<li>Animation visuelle de progression</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* FAQ SEO */}
|
||||
<details className="group text-sm">
|
||||
<summary className="font-medium text-zinc-700 dark:text-zinc-300 cursor-pointer hover:text-zinc-900 dark:hover:text-zinc-100 transition-colors">
|
||||
Questions fréquentes (FAQ)
|
||||
</summary>
|
||||
<div className="mt-3 space-y-3 text-zinc-500 dark:text-zinc-400">
|
||||
<div>
|
||||
<h4 className="font-medium text-zinc-600 dark:text-zinc-300">
|
||||
Comment utiliser le chronomètre en ligne ?
|
||||
</h4>
|
||||
<p>
|
||||
Cliquez sur « Démarrer » ou appuyez sur la barre Espace pour lancer le
|
||||
chronomètre. Utilisez L pour marquer un tour, R pour réinitialiser et C pour
|
||||
copier le temps.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-zinc-600 dark:text-zinc-300">
|
||||
Le chronomètre est-il vraiment gratuit ?
|
||||
</h4>
|
||||
<p>
|
||||
Oui, 100% gratuit, sans publicité et sans inscription. Il fonctionne directement
|
||||
dans votre navigateur web.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-zinc-600 dark:text-zinc-300">
|
||||
Puis-je utiliser plusieurs minuteurs en même temps ?
|
||||
</h4>
|
||||
<p>
|
||||
Oui, vous pouvez créer autant de minuteurs simultanés que vous le souhaitez.
|
||||
Chacun fonctionne de manière indépendante.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-zinc-600 dark:text-zinc-300">
|
||||
Le chronomètre fonctionne-t-il en arrière-plan ?
|
||||
</h4>
|
||||
<p>
|
||||
Oui, le chronomètre et les minuteurs continuent même si vous changez d'onglet.
|
||||
Le temps est sauvegardé automatiquement dans votre navigateur.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-zinc-600 dark:text-zinc-300">
|
||||
Le chronomètre fonctionne-t-il sur mobile ?
|
||||
</h4>
|
||||
<p>
|
||||
Oui, l'interface est entièrement responsive et optimisée pour smartphones et
|
||||
tablettes. Vous pouvez aussi l'installer comme application (PWA).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="py-4 text-center text-xs text-zinc-400 dark:text-zinc-600 border-t border-zinc-100 dark:border-zinc-900">
|
||||
<p>Chronomètre en ligne gratuit — rapide, précis, sans pub</p>
|
||||
<nav className="mt-1 flex justify-center gap-3" aria-label="Liens utiles">
|
||||
<span>Chronomètre</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>Minuteur</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>Compte à rebours</span>
|
||||
</nav>
|
||||
</footer>
|
||||
|
||||
{/* Toast notification */}
|
||||
{toast && (
|
||||
<div
|
||||
className={`fixed bottom-6 left-1/2 -translate-x-1/2 z-50 px-4 py-2.5 rounded-xl bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 text-sm font-medium shadow-lg ${
|
||||
toastExiting ? "toast-exit" : "toast-enter"
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
{toast}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
rules: [
|
||||
{
|
||||
userAgent: "*",
|
||||
allow: "/",
|
||||
},
|
||||
],
|
||||
sitemap: "https://chrono.arthurp.fr/sitemap.xml",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
const baseUrl = "https://chrono.arthurp.fr";
|
||||
|
||||
return [
|
||||
{
|
||||
url: baseUrl,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "monthly",
|
||||
priority: 1.0,
|
||||
},
|
||||
{
|
||||
url: `${baseUrl}/#chronometre`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "monthly",
|
||||
priority: 0.9,
|
||||
},
|
||||
{
|
||||
url: `${baseUrl}/#minuteur`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "monthly",
|
||||
priority: 0.9,
|
||||
},
|
||||
];
|
||||
}
|
||||
Reference in New Issue
Block a user