9d9dfb46c6
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.
497 lines
19 KiB
TypeScript
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">“{cmd}”</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>
|
|
);
|
|
}
|