ee7946316c
- sourceUrl was already saved on URL import but never displayed — render it as a link (hostname only) under the description. - Extract cook-mode's wake lock into a reusable useWakeLock hook, add visibility-change re-acquisition (a wake lock releases when the tab goes background and won't come back on its own), and use it on the regular recipe view too, not just cook mode. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
494 lines
19 KiB
TypeScript
494 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";
|
|
import { hasQuantity } from "@/lib/fractions";
|
|
import { useWakeLock } from "@/lib/hooks/use-wake-lock";
|
|
|
|
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 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);
|
|
|
|
useWakeLock();
|
|
|
|
// 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-col sm:flex-row flex-1 overflow-hidden">
|
|
{/* Step content */}
|
|
<div className="flex-1 flex flex-col items-center justify-center px-4 sm:px-8 py-8 sm:py-12 gap-8 overflow-y-auto">
|
|
{/* 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 — stacks above the step on mobile instead of squeezing it horizontally */}
|
|
{showIngredients && (
|
|
<aside className="order-first sm:order-none w-full sm:w-72 max-h-40 sm:max-h-none border-b sm:border-b-0 sm:border-l bg-muted/20 overflow-y-auto py-4 sm: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">
|
|
{hasQuantity(ing.quantity) ? 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>
|
|
);
|
|
}
|