Files
Epicure/apps/web/components/cooking-mode/cooking-mode.tsx
T
Arnaud 9d9dfb46c6 feat: misc — cooking mode, OpenAPI docs, email, storage, upload, homepage
Cooking mode step-by-step view. OpenAPI 3.1 spec auto-generated from Zod schemas.
Email lib (resend). Redis client. S3 storage helper. Photo upload endpoint.
Landing page. Short recipe URL redirect /r/[id]. API docs page.
2026-07-01 08:11:31 +02:00

497 lines
19 KiB
TypeScript

"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import { useRouter } from "next/navigation";
import { X, ChevronLeft, ChevronRight, List, Mic, MicOff, Play, Square, HelpCircle, Clock } from "lucide-react";
import { cn } from "@/lib/utils";
import { useTranslations } from "next-intl";
import { useLocale } from "@/lib/i18n/provider";
type Step = { id: string; instruction: string; timerSeconds: number | null };
type Ingredient = { rawName: string; quantity: string | null; unit: string | null };
type TimerState = {
remaining: number;
running: boolean;
initial: number;
};
const VOICE_COMMANDS: Record<string, Record<string, string[]>> = {
en: {
next: ["next", "continue"],
prev: ["previous", "back", "go back"],
timer: ["timer", "start"],
stop: ["stop", "pause"],
reset: ["reset"],
repeat: ["repeat", "again"],
},
fr: {
next: ["suivant", "continuer", "prochain"],
prev: ["précédent", "retour", "reculer"],
timer: ["minuteur", "démarrer", "lancer"],
stop: ["arrêter", "pause", "stop"],
reset: ["réinitialiser", "recommencer"],
repeat: ["répéter", "encore"],
},
};
const SPEECH_LANG: Record<string, string> = {
en: "en-US",
fr: "fr-FR",
};
function beep(freq = 880, duration = 0.15, vol = 0.3) {
try {
const ctx = new AudioContext();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(ctx.destination);
osc.frequency.value = freq;
gain.gain.setValueAtTime(vol, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + duration);
osc.start(ctx.currentTime);
osc.stop(ctx.currentTime + duration);
} catch {}
}
function formatTime(s: number) {
const m = Math.floor(s / 60);
const sec = s % 60;
return m > 0 ? `${m}:${sec.toString().padStart(2, "0")}` : `${sec}s`;
}
export function CookingMode({
recipeId,
recipeTitle,
steps,
ingredients,
}: {
recipeId: string;
recipeTitle: string;
steps: Step[];
ingredients: Ingredient[];
}) {
const router = useRouter();
const t = useTranslations("cookingMode");
const { locale } = useLocale();
const [current, setCurrent] = useState(0);
const [showIngredients, setShowIngredients] = useState(false);
const [voiceActive, setVoiceActive] = useState(false);
const [voiceStatus, setVoiceStatus] = useState("");
const [showHelp, setShowHelp] = useState(false);
// Parallel timers: keyed by step index
const [timers, setTimers] = useState<Map<number, TimerState>>(() => new Map());
const wakeLockRef = useRef<WakeLockSentinel | null>(null);
const recognitionRef = useRef<InstanceType<typeof window.SpeechRecognition> | null>(null);
const step = steps[current]!;
const isLast = current === steps.length - 1;
const isFirst = current === 0;
const currentTimer = timers.get(current);
const otherRunningTimers = [...timers.entries()].filter(([idx, t]) => idx !== current && t.running);
// Wake Lock
useEffect(() => {
navigator.wakeLock?.request("screen").then((lock) => { wakeLockRef.current = lock; }).catch(() => {});
return () => { wakeLockRef.current?.release().catch(() => {}); };
}, []);
// Master tick: decrement all running timers once per second
useEffect(() => {
const id = setInterval(() => {
setTimers((prev) => {
let changed = false;
const next = new Map(prev);
for (const [stepIdx, timer] of next) {
if (!timer.running) continue;
changed = true;
const rem = timer.remaining - 1;
if (rem <= 3 && rem > 0) beep(660 + (3 - rem) * 110, 0.1);
if (rem <= 0) {
next.set(stepIdx, { ...timer, remaining: 0, running: false });
beep(880, 0.3);
setTimeout(() => beep(1100, 0.3), 350);
setTimeout(() => beep(1320, 0.5), 700);
} else {
next.set(stepIdx, { ...timer, remaining: rem });
}
}
return changed ? next : prev;
});
}, 1000);
return () => clearInterval(id);
}, []);
function startTimer(stepIdx = current) {
const s = steps[stepIdx];
if (!s?.timerSeconds) return;
setTimers((prev) => {
const existing = prev.get(stepIdx);
const remaining = existing && existing.remaining > 0 ? existing.remaining : s.timerSeconds!;
if (existing?.running) return prev;
const next = new Map(prev);
next.set(stepIdx, { remaining, running: true, initial: s.timerSeconds! });
return next;
});
}
function stopTimer(stepIdx = current) {
setTimers((prev) => {
const t = prev.get(stepIdx);
if (!t || !t.running) return prev;
const next = new Map(prev);
next.set(stepIdx, { ...t, running: false });
return next;
});
}
function resetTimer(stepIdx = current) {
setTimers((prev) => {
const s = steps[stepIdx];
if (!s?.timerSeconds) return prev;
const next = new Map(prev);
next.delete(stepIdx);
return next;
});
}
function dismissTimer(stepIdx: number) {
setTimers((prev) => {
const next = new Map(prev);
next.delete(stepIdx);
return next;
});
}
const goNext = useCallback(() => {
if (!isLast) setCurrent((c) => c + 1);
}, [isLast]);
const goPrev = useCallback(() => {
if (!isFirst) setCurrent((c) => c - 1);
}, [isFirst]);
// Keyboard navigation
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.target !== document.body) return;
if (e.key === "?") { setShowHelp((v) => !v); return; }
if (e.key === "Escape") { if (showHelp) { setShowHelp(false); return; } router.push(`/recipes/${recipeId}`); }
if (showHelp) return;
if (e.key === "ArrowRight" || e.key === " ") { e.preventDefault(); goNext(); }
if (e.key === "ArrowLeft") goPrev();
if (e.key === "t" || e.key === "T") startTimer();
}
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [goNext, goPrev, recipeId, showHelp]);
// Voice recognition
function toggleVoice() {
const SR = window.SpeechRecognition ?? window.webkitSpeechRecognition;
if (!SR) { setVoiceStatus(t("voiceNotSupported")); return; }
if (voiceActive) {
recognitionRef.current?.stop();
recognitionRef.current = null;
setVoiceActive(false);
setVoiceStatus("");
return;
}
const rec = new SR();
rec.continuous = true;
rec.interimResults = false;
rec.lang = SPEECH_LANG[locale] ?? "en-US";
rec.onresult = (e: SpeechRecognitionEvent) => {
const transcript = e.results[e.results.length - 1]![0]!.transcript.toLowerCase().trim();
setVoiceStatus(`"${transcript}"`);
const cmds = VOICE_COMMANDS[locale] ?? VOICE_COMMANDS.en!;
if (cmds.next!.some(k => transcript.includes(k))) goNext();
else if (cmds.prev!.some(k => transcript.includes(k))) goPrev();
else if (cmds.timer!.some(k => transcript.includes(k))) startTimer();
else if (cmds.stop!.some(k => transcript.includes(k))) stopTimer();
else if (cmds.reset!.some(k => transcript.includes(k))) resetTimer();
else if (cmds.repeat!.some(k => transcript.includes(k))) setVoiceStatus(t("stepRepeatPrefix", { n: current + 1 }) + step.instruction.slice(0, 60) + "…");
setTimeout(() => setVoiceStatus(""), 3000);
};
rec.onerror = () => setVoiceStatus(t("voiceError"));
rec.onend = () => { if (voiceActive) rec.start(); };
rec.start();
recognitionRef.current = rec;
setVoiceActive(true);
}
return (
<div className="fixed inset-0 z-50 bg-background flex flex-col select-none">
{/* Top bar */}
<div className="flex items-center justify-between px-6 py-4 border-b shrink-0">
<div className="flex items-center gap-3">
<button
onClick={() => router.push(`/recipes/${recipeId}`)}
className="rounded-full p-2 hover:bg-muted transition-colors"
title="Exit (Esc)"
>
<X className="h-5 w-5" />
</button>
<div>
<p className="text-xs text-muted-foreground uppercase tracking-wider">{t("cooking")}</p>
<h1 className="font-semibold text-sm leading-tight">{recipeTitle}</h1>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={toggleVoice}
className={cn("rounded-full p-2 transition-colors", voiceActive ? "bg-primary text-primary-foreground" : "hover:bg-muted")}
title={voiceActive ? t("stopVoice") : t("enableVoice")}
>
{voiceActive ? <Mic className="h-5 w-5" /> : <MicOff className="h-5 w-5" />}
</button>
<button
onClick={() => setShowIngredients((v) => !v)}
className={cn("rounded-full p-2 transition-colors", showIngredients ? "bg-primary text-primary-foreground" : "hover:bg-muted")}
title={t("toggleIngredients")}
>
<List className="h-5 w-5" />
</button>
<button
onClick={() => setShowHelp((v) => !v)}
className={cn("rounded-full p-2 transition-colors", showHelp ? "bg-primary text-primary-foreground" : "hover:bg-muted")}
title="Shortcuts & voice commands (?)"
>
<HelpCircle className="h-5 w-5" />
</button>
</div>
</div>
{/* Progress bar */}
<div className="h-1 bg-muted shrink-0">
<div
className="h-full bg-primary transition-all duration-300"
style={{ width: `${((current + 1) / steps.length) * 100}%` }}
/>
</div>
{/* Main area */}
<div className="flex flex-1 overflow-hidden">
{/* Step content */}
<div className="flex-1 flex flex-col items-center justify-center px-8 py-12 gap-8">
{/* Step number */}
<div className="text-sm font-medium text-muted-foreground">
{t("stepOf", { current: current + 1, total: steps.length })}
</div>
{/* Instruction */}
<p className="text-2xl sm:text-3xl leading-relaxed text-center font-medium max-w-2xl">
{step.instruction}
</p>
{/* Timer for current step */}
{step.timerSeconds && (
<div className="flex flex-col items-center gap-3">
<div className={cn(
"text-5xl font-mono font-bold tabular-nums",
currentTimer?.running && currentTimer.remaining <= 10 && "text-destructive"
)}>
{formatTime(currentTimer?.remaining ?? step.timerSeconds)}
</div>
<div className="flex gap-2">
{!currentTimer?.running ? (
<button
onClick={() => startTimer()}
disabled={currentTimer?.remaining === 0}
className="flex items-center gap-2 rounded-full px-5 py-2.5 bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-40 transition-colors"
title="Start timer (T)"
>
<Play className="h-4 w-4" />
{currentTimer?.remaining === 0 ? t("timerDone") : t("startTimer")}
</button>
) : (
<button
onClick={() => stopTimer()}
className="flex items-center gap-2 rounded-full px-5 py-2.5 bg-muted text-sm font-medium hover:bg-muted/80 transition-colors"
>
<Square className="h-4 w-4" />
{t("pause")}
</button>
)}
{currentTimer && currentTimer.remaining !== step.timerSeconds && (
<button onClick={() => resetTimer()} className="rounded-full px-4 py-2.5 text-sm text-muted-foreground hover:bg-muted transition-colors">
{t("reset")}
</button>
)}
</div>
</div>
)}
{/* Voice status */}
{voiceStatus && (
<p className="text-sm text-muted-foreground italic">{voiceStatus}</p>
)}
</div>
{/* Ingredients drawer */}
{showIngredients && (
<aside className="w-72 border-l bg-muted/20 overflow-y-auto py-6 px-5 shrink-0">
<h2 className="font-semibold mb-4 text-sm uppercase tracking-wider text-muted-foreground">{t("ingredients")}</h2>
<ul className="space-y-2">
{ingredients.map((ing, i) => (
<li key={i} className="text-sm flex gap-2">
<span className="text-muted-foreground shrink-0 w-16 text-right">
{ing.quantity}{ing.unit ? ` ${ing.unit}` : ""}
</span>
<span>{ing.rawName}</span>
</li>
))}
</ul>
</aside>
)}
</div>
{/* Parallel timers dock */}
{otherRunningTimers.length > 0 && (
<div className="shrink-0 border-t bg-muted/30 px-4 py-3">
<div className="flex items-center gap-2 overflow-x-auto">
<Clock className="h-4 w-4 text-muted-foreground shrink-0" />
<div className="flex gap-3">
{otherRunningTimers.map(([stepIdx, timer]) => (
<button
key={stepIdx}
onClick={() => setCurrent(stepIdx)}
className="flex items-center gap-2 rounded-full bg-background border px-3 py-1.5 text-xs font-mono hover:bg-muted transition-colors shrink-0"
title={`Step ${stepIdx + 1}: ${steps[stepIdx]?.instruction.slice(0, 50)}`}
>
<span className={cn("font-bold tabular-nums", timer.remaining <= 10 && "text-destructive")}>
{formatTime(timer.remaining)}
</span>
<span className="text-muted-foreground">Step {stepIdx + 1}</span>
<button
onClick={(e) => { e.stopPropagation(); dismissTimer(stepIdx); }}
className="ml-1 text-muted-foreground hover:text-foreground"
>
<X className="h-3 w-3" />
</button>
</button>
))}
</div>
</div>
</div>
)}
{/* Help overlay */}
{showHelp && (
<div
className="absolute inset-0 z-10 bg-background/80 backdrop-blur-sm flex items-center justify-center p-6"
onClick={() => setShowHelp(false)}
>
<div
className="bg-popover border rounded-2xl shadow-xl w-full max-w-lg p-6 space-y-6"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between">
<h2 className="font-semibold text-lg">{t("shortcutsTitle")}</h2>
<button onClick={() => setShowHelp(false)} className="rounded-full p-1.5 hover:bg-muted transition-colors">
<X className="h-4 w-4" />
</button>
</div>
<div className="grid grid-cols-2 gap-6">
<div className="space-y-3">
<p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">{t("keyboardSection")}</p>
<div className="space-y-2 text-sm">
{[
["→ / Space", t("shortcuts.nextStep")],
["←", t("shortcuts.prevStep")],
["T", t("shortcuts.startTimer")],
["?", t("shortcuts.toggleHelp")],
["Esc", t("shortcuts.exitMode")],
].map(([key, label]) => (
<div key={key} className="flex items-center justify-between gap-4">
<kbd className="font-mono text-xs bg-muted px-2 py-0.5 rounded border border-border">{key}</kbd>
<span className="text-muted-foreground text-right">{label}</span>
</div>
))}
</div>
</div>
<div className="space-y-3">
<p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">{t("voiceSection")}</p>
<div className="space-y-2 text-sm">
{[
[t("voiceLabels.nextCmd"), t("voiceLabels.nextLabel")],
[t("voiceLabels.prevCmd"), t("voiceLabels.prevLabel")],
[t("voiceLabels.timerCmd"), t("voiceLabels.timerLabel")],
[t("voiceLabels.stopCmd"), t("voiceLabels.stopLabel")],
[t("voiceLabels.resetCmd"), t("voiceLabels.resetLabel")],
[t("voiceLabels.repeatCmd"), t("voiceLabels.repeatLabel")],
].map(([cmd, label]) => (
<div key={cmd} className="flex items-center justify-between gap-4">
<span className="font-mono text-xs bg-muted px-2 py-0.5 rounded border border-border whitespace-nowrap">&ldquo;{cmd}&rdquo;</span>
<span className="text-muted-foreground text-right">{label}</span>
</div>
))}
</div>
{!voiceActive && (
<p className="text-xs text-muted-foreground italic">{t("enableMicFirst")}</p>
)}
</div>
</div>
</div>
</div>
)}
{/* Bottom navigation */}
<div className="flex items-center justify-between px-8 py-6 border-t shrink-0">
<button
onClick={goPrev}
disabled={isFirst}
className="flex items-center gap-2 rounded-full px-5 py-3 border text-sm font-medium hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
title="Previous step (←)"
>
<ChevronLeft className="h-4 w-4" />
{t("previous")}
</button>
{/* Step dots */}
<div className="hidden sm:flex gap-1.5">
{steps.map((_, i) => (
<button
key={i}
onClick={() => setCurrent(i)}
className={cn(
"rounded-full transition-all",
i === current ? "w-6 h-2.5 bg-primary" : "w-2.5 h-2.5 bg-muted-foreground/30 hover:bg-muted-foreground/60"
)}
/>
))}
</div>
{isLast ? (
<button
onClick={() => router.push(`/recipes/${recipeId}`)}
className="flex items-center gap-2 rounded-full px-5 py-3 bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors"
>
{t("finish")}
</button>
) : (
<button
onClick={goNext}
className="flex items-center gap-2 rounded-full px-5 py-3 bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors"
title="Next step (→ or Space)"
>
{t("next")}
<ChevronRight className="h-4 w-4" />
</button>
)}
</div>
</div>
);
}