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.
This commit is contained in:
@@ -0,0 +1,496 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user