2026-10-31 20:44:29 -05:00
|
|
|
"use client";
|
|
|
|
|
|
Move UI to BoardUI with slate + salmon theme; add PDF plan export
- Adopt BoardUI (Pro init): theme tokens, Inter, .dark mode, Button,
SegmentedControl, Meter, Skeleton and ThemeToggle; remap the app's
color aliases onto BoardUI tokens and swap lucide for Remix icons
- Retint BoardUI's neutral ramp to cool slate with a salmon accent;
drop the dotted background; raise muted text contrast
- Simplify the landing page and remove the overlap explorer
- Planner: explicit "I have this" toggle on grocery lines, pantry panel
above the menu with the staples list spelled out, sticky aisle
header fix, and a "Download PDF" plan export (jsPDF, lazy-loaded)
- Recipes: replace the family cabbage soup with Cade's Cabbage Soup
(adds Bone-In Ham Steak to the catalog); remove Ham, Bean & Cabbage Soup
- Remove unused BoardUI components, dependencies and exports
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 16:36:33 -06:00
|
|
|
import { RiFireLine as Flame, RiSubtractLine as Minus, RiAddLine as Plus } from "@remixicon/react";
|
2026-10-31 20:44:29 -05:00
|
|
|
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>
|
|
|
|
|
);
|
|
|
|
|
}
|