101 lines
3.9 KiB
TypeScript
101 lines
3.9 KiB
TypeScript
"use client";
|
|
|
|
import { Flame, Minus, Plus } from "lucide-react";
|
|
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
|
|
import type { Recipe } from "@/app/types";
|
|
import { RecipeDetail } from "./RecipeDetail";
|
|
|
|
const noopSubscribe = () => () => {};
|
|
|
|
/** RecipeDetail with an accessible servings stepper + Screen Wake Lock ("Cook mode") toggle. */
|
|
export function RecipeServings({ recipe }: { recipe: Recipe }) {
|
|
const [servings, setServings] = useState(recipe.servings);
|
|
const [cookMode, setCookMode] = useState(false);
|
|
const wakeSupported = useSyncExternalStore(noopSubscribe, () => "wakeLock" in navigator, () => false);
|
|
const wakeLockRef = useRef<WakeLockSentinel | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (!cookMode || !wakeSupported) {
|
|
wakeLockRef.current?.release().catch(() => {});
|
|
wakeLockRef.current = null;
|
|
return;
|
|
}
|
|
|
|
let cancelled = false;
|
|
async function acquire() {
|
|
try {
|
|
const sentinel = await navigator.wakeLock.request("screen");
|
|
if (cancelled) {
|
|
await sentinel.release();
|
|
return;
|
|
}
|
|
wakeLockRef.current = sentinel;
|
|
} catch {
|
|
// Battery saver or background tab can refuse wakeLock; fail gracefully.
|
|
}
|
|
}
|
|
|
|
acquire();
|
|
const onVisibility = () => {
|
|
if (document.visibilityState === "visible" && cookMode) acquire();
|
|
};
|
|
document.addEventListener("visibilitychange", onVisibility);
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
document.removeEventListener("visibilitychange", onVisibility);
|
|
wakeLockRef.current?.release().catch(() => {});
|
|
wakeLockRef.current = null;
|
|
};
|
|
}, [cookMode, wakeSupported]);
|
|
|
|
return (
|
|
<div>
|
|
<div className="mb-6 flex flex-wrap items-center justify-between gap-3 border-b border-line pb-4">
|
|
<div className="flex items-center gap-3">
|
|
<span className="text-sm font-medium text-ink-muted">Scale servings</span>
|
|
<div className="inline-flex items-center rounded-lg border border-line-strong bg-raised p-0.5">
|
|
<button
|
|
type="button"
|
|
onClick={() => setServings((s) => Math.max(1, s - 1))}
|
|
className="grid size-9 place-items-center rounded-md text-ink-muted transition-colors hover:bg-surface hover:text-ink"
|
|
aria-label="Fewer servings"
|
|
>
|
|
<Minus className="size-4" aria-hidden />
|
|
</button>
|
|
<span className="min-w-9 text-center font-mono text-sm font-semibold tabular-nums" aria-live="polite">
|
|
{servings}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => setServings((s) => Math.min(12, s + 1))}
|
|
className="grid size-9 place-items-center rounded-md text-ink-muted transition-colors hover:bg-surface hover:text-ink"
|
|
aria-label="More servings"
|
|
>
|
|
<Plus className="size-4" aria-hidden />
|
|
</button>
|
|
</div>
|
|
<span className="hidden text-xs text-ink-faint sm:inline">Tap any ingredient or step below to check it off</span>
|
|
</div>
|
|
|
|
{wakeSupported && (
|
|
<button
|
|
type="button"
|
|
aria-pressed={cookMode}
|
|
onClick={() => setCookMode((c) => !c)}
|
|
className={`trim-cap inline-flex min-h-9 items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs font-semibold transition-colors ${
|
|
cookMode
|
|
? "border-status-ok-line bg-status-ok-bg text-status-ok"
|
|
: "border-line-strong bg-surface text-ink-muted hover:bg-raised hover:text-ink"
|
|
}`}
|
|
>
|
|
<Flame className="size-3.5" aria-hidden />
|
|
{cookMode ? "Screen kept awake" : "Keep screen awake"}
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
<RecipeDetail recipe={recipe} servings={servings} showMeta={false} interactive />
|
|
</div>
|
|
);
|
|
}
|