init
This commit is contained in:
commit
c19b910c36
80 changed files with 16134 additions and 0 deletions
146
app/components/AddToPlanControl.tsx
Normal file
146
app/components/AddToPlanControl.tsx
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
"use client";
|
||||
|
||||
import { ArrowRight, CalendarPlus, Check, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { DAY_NAMES, mealLabel, slotKey, visibleMeals } from "@/app/lib/plan";
|
||||
import { RECIPES_BY_ID } from "@/app/lib/recipes";
|
||||
import { DEFAULT_STATE, sanitizeState } from "@/app/lib/sanitize";
|
||||
import type { MealPlanState } from "@/app/types";
|
||||
|
||||
const STORAGE_KEY = "budget-meal-planner:v2";
|
||||
|
||||
function readPlan(): MealPlanState {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
return raw ? sanitizeState(JSON.parse(raw)) : DEFAULT_STATE;
|
||||
} catch {
|
||||
return DEFAULT_STATE;
|
||||
}
|
||||
}
|
||||
|
||||
export function AddToPlanControl({ recipeId, recipeTitle }: { recipeId: string; recipeTitle: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [plan, setPlan] = useState<MealPlanState>(DEFAULT_STATE);
|
||||
const [addedLabel, setAddedLabel] = useState<string | null>(null);
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
if (open && !dialog.open) dialog.showModal();
|
||||
if (!open && dialog.open) dialog.close();
|
||||
return () => {
|
||||
if (dialog.open) dialog.close();
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
function assignSlot(day: number, meal: number) {
|
||||
const latest = readPlan();
|
||||
const key = slotKey(day, meal);
|
||||
const updated: MealPlanState = {
|
||||
...latest,
|
||||
slots: { ...latest.slots, [key]: recipeId },
|
||||
};
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
|
||||
setPlan(updated);
|
||||
setAddedLabel(`${DAY_NAMES[day]} · ${mealLabel(meal)}`);
|
||||
setOpen(false);
|
||||
} catch {
|
||||
// Blocked storage
|
||||
}
|
||||
}
|
||||
|
||||
const meals = visibleMeals(plan.mealsPerDay);
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setPlan(readPlan());
|
||||
setOpen(true);
|
||||
}}
|
||||
className="trim-cap inline-flex min-h-10 items-center gap-2 rounded-lg bg-brand-600 px-4 py-2 text-sm font-semibold text-on-accent shadow-2xs transition-colors hover:bg-brand-500"
|
||||
>
|
||||
<CalendarPlus className="size-4" strokeWidth={2} aria-hidden />
|
||||
Add to meal plan
|
||||
</button>
|
||||
|
||||
{addedLabel && (
|
||||
<span className="inline-flex flex-wrap items-center gap-2 rounded-lg border border-status-ok-line bg-status-ok-bg px-3 py-1.5 text-xs font-medium text-status-ok">
|
||||
<Check className="size-3.5 shrink-0" strokeWidth={2.5} aria-hidden />
|
||||
Added to {addedLabel}
|
||||
<Link href="/plan" className="ml-1 inline-flex items-center gap-1 font-semibold underline hover:opacity-85">
|
||||
Open planner
|
||||
<ArrowRight className="size-3" aria-hidden />
|
||||
</Link>
|
||||
</span>
|
||||
)}
|
||||
|
||||
<dialog
|
||||
ref={dialogRef}
|
||||
onClose={() => setOpen(false)}
|
||||
onClick={(e) => e.target === dialogRef.current && setOpen(false)}
|
||||
aria-labelledby="add-to-plan-title"
|
||||
className="m-0 mt-auto max-h-[85dvh] w-full max-w-none overflow-y-auto rounded-t-2xl border border-line bg-surface p-0 text-ink shadow-2xl sm:m-auto sm:max-w-lg sm:rounded-2xl"
|
||||
>
|
||||
<div className="p-5 sm:p-6">
|
||||
<div className="flex items-start justify-between gap-4 border-b border-line pb-3">
|
||||
<div>
|
||||
<p className="font-mono text-xs tracking-wider text-ink-faint uppercase">Assign slot</p>
|
||||
<h2 id="add-to-plan-title" className="font-serif text-xl font-semibold">
|
||||
Add “{recipeTitle}”
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
aria-label="Close"
|
||||
className="grid size-10 shrink-0 place-items-center rounded-lg text-ink-muted hover:bg-raised hover:text-ink"
|
||||
>
|
||||
<X className="size-5" aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
{Array.from({ length: plan.daysCount }, (_, day) => (
|
||||
<div key={day} className="rounded-lg border border-line bg-canvas p-3">
|
||||
<p className="font-serif text-sm font-semibold text-ink">{DAY_NAMES[day]}</p>
|
||||
<div className="mt-2 grid gap-2 sm:grid-cols-3">
|
||||
{meals.map((meal) => {
|
||||
const key = slotKey(day, meal);
|
||||
const existingId = plan.slots[key];
|
||||
const existingTitle = existingId ? RECIPES_BY_ID.get(existingId)?.title : null;
|
||||
const isSame = existingId === recipeId;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => assignSlot(day, meal)}
|
||||
className={`min-h-12 rounded-lg border px-3 py-2 text-left text-xs transition-colors ${
|
||||
isSame
|
||||
? "border-status-ok-line bg-status-ok-bg text-status-ok"
|
||||
: "border-line-strong bg-surface hover:border-brand-600 hover:bg-brand-950"
|
||||
}`}
|
||||
>
|
||||
<span className="block font-mono text-[10px] tracking-wider text-ink-faint uppercase">
|
||||
{mealLabel(meal)}
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate font-medium text-ink">
|
||||
{existingTitle ? existingTitle : "+ Empty slot"}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
40
app/components/BudgetMeter.tsx
Normal file
40
app/components/BudgetMeter.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { formatUsd } from "@/app/lib/units";
|
||||
|
||||
/** Herb sage under 85%, saffron amber up to 100%, chili red over budget. */
|
||||
function budgetTone(spent: number, budget: number) {
|
||||
const ratio = budget > 0 ? spent / budget : 0;
|
||||
if (ratio > 1) return { bar: "bg-status-danger", text: "text-status-danger", label: "Over budget" };
|
||||
if (ratio > 0.85) return { bar: "bg-status-warn", text: "text-status-warn", label: "Close to budget" };
|
||||
return { bar: "bg-status-ok", text: "text-status-ok", label: "Under budget" };
|
||||
}
|
||||
|
||||
interface BudgetMeterProps {
|
||||
spent: number;
|
||||
budget: number;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export function BudgetMeter({ spent, budget, compact = false }: BudgetMeterProps) {
|
||||
const tone = budgetTone(spent, budget);
|
||||
const percent = budget > 0 ? Math.min(100, (spent / budget) * 100) : 0;
|
||||
|
||||
return (
|
||||
<div className={compact ? "w-full" : "w-full min-w-48"}>
|
||||
<div className="flex items-baseline justify-between gap-2 text-xs">
|
||||
<span className={`font-mono text-sm font-semibold tabular-nums ${tone.text}`}>{formatUsd(spent)}</span>
|
||||
<span className="font-mono text-ink-muted tabular-nums">/ {formatUsd(budget)}</span>
|
||||
</div>
|
||||
<div
|
||||
className="mt-1.5 h-2 w-full overflow-hidden rounded-full border border-line-strong/40 bg-raised"
|
||||
role="meter"
|
||||
aria-label="Budget used"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={budget}
|
||||
aria-valuenow={Math.round(spent * 100) / 100}
|
||||
aria-valuetext={`${formatUsd(spent)} of ${formatUsd(budget)}, ${tone.label.toLowerCase()}`}
|
||||
>
|
||||
<div className={`h-full rounded-full transition-[width] duration-300 ${tone.bar}`} style={{ width: `${percent}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
app/components/Chip.tsx
Normal file
38
app/components/Chip.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { Check } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
// A pill-shaped on/off toggle button for filters (cuisines, styles, appliances…).
|
||||
// Includes a visible Check icon when pressed so state never relies on color alone.
|
||||
|
||||
interface ChipProps {
|
||||
pressed: boolean;
|
||||
onClick: () => void;
|
||||
children: ReactNode;
|
||||
/** "sm" for dense filter rows, "md" for settings panels. */
|
||||
size?: "sm" | "md";
|
||||
/** Dashed outline when off: tells a second filter row apart from the first. */
|
||||
dashed?: boolean;
|
||||
}
|
||||
|
||||
const SIZES = {
|
||||
sm: "min-h-8 px-2.5 py-1 text-xs gap-1.5",
|
||||
md: "min-h-9 px-3.5 py-1.5 text-sm gap-1.5",
|
||||
};
|
||||
|
||||
export function Chip({ pressed, onClick, children, size = "md", dashed = false }: ChipProps) {
|
||||
const look = pressed
|
||||
? "border-selected-line bg-selected font-medium text-ink shadow-2xs"
|
||||
: `border-line-strong bg-surface text-ink-muted hover:border-ink-muted hover:bg-raised hover:text-ink ${dashed ? "border-dashed" : ""}`;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={pressed}
|
||||
onClick={onClick}
|
||||
className={`trim-cap inline-flex items-center rounded-full border transition-colors ${SIZES[size]} ${look}`}
|
||||
>
|
||||
{pressed && <Check className="size-3.5 shrink-0 text-brand-300" strokeWidth={2.5} aria-hidden />}
|
||||
<span className="inline-flex items-center gap-1.5">{children}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
272
app/components/ConfigPanel.tsx
Normal file
272
app/components/ConfigPanel.tsx
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
"use client";
|
||||
|
||||
import { ChevronDown, RotateCcw, Shuffle, SlidersHorizontal, Wand2 } from "lucide-react";
|
||||
import { useState, type Dispatch } from "react";
|
||||
import { APPLIANCES, CUISINES, STYLES, activeRecipeIds, mealLabel, visibleMeals } from "@/app/lib/plan";
|
||||
import type { PlanAction } from "@/app/lib/usePlanState";
|
||||
import type { FillMode, MealPlanState } from "@/app/types";
|
||||
import { Chip } from "./Chip";
|
||||
import { ApplianceIcon, StyleIcon } from "./Icons";
|
||||
import { RadioGroup } from "./RadioGroup";
|
||||
|
||||
interface ConfigPanelProps {
|
||||
state: MealPlanState;
|
||||
dispatch: Dispatch<PlanAction>;
|
||||
onGenerate: (mode: FillMode) => void;
|
||||
onClear: () => void;
|
||||
isGenerating: boolean;
|
||||
}
|
||||
|
||||
const MEALS_OPTIONS = [
|
||||
{ value: 1, label: "1" },
|
||||
{ value: 2, label: "2" },
|
||||
{ value: 3, label: "3" },
|
||||
] as const;
|
||||
|
||||
const PEOPLE_OPTIONS = [
|
||||
{ value: 1, label: "1" },
|
||||
{ value: 2, label: "2" },
|
||||
{ value: 3, label: "3" },
|
||||
{ value: 4, label: "4" },
|
||||
{ value: 5, label: "5" },
|
||||
{ value: 6, label: "6" },
|
||||
] as const;
|
||||
|
||||
export function ConfigPanel({ state, dispatch, onGenerate, onClear, isGenerating }: ConfigPanelProps) {
|
||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
|
||||
const filledCount = activeRecipeIds(state).length;
|
||||
const mealNames = visibleMeals(state.mealsPerDay).map(mealLabel).join(" · ");
|
||||
const activeFilterCount =
|
||||
(APPLIANCES.length - state.selectedAppliances.length > 0 ? 1 : 0) +
|
||||
state.preferredCuisines.length +
|
||||
state.preferredStyles.length;
|
||||
|
||||
const filterSummary = [
|
||||
`${state.selectedAppliances.length}/${APPLIANCES.length} appliances`,
|
||||
state.preferredCuisines.length > 0 ? `${state.preferredCuisines.length} cuisines` : "Any cuisine",
|
||||
state.preferredStyles.length > 0 ? `${state.preferredStyles.length} styles` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
|
||||
return (
|
||||
<section aria-labelledby="config-heading" className="rounded-xl border border-line bg-surface p-4 sm:p-5">
|
||||
<h2 id="config-heading" className="sr-only">
|
||||
Plan parameters
|
||||
</h2>
|
||||
|
||||
{/* --- Compact Top Strip: Core Week Parameters ---------------------- */}
|
||||
<div className="grid gap-x-6 gap-y-4 sm:grid-cols-2">
|
||||
{/* Budget */}
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<label htmlFor="budget" className="text-xs font-semibold tracking-wide text-ink-muted uppercase">
|
||||
Weekly budget
|
||||
</label>
|
||||
<span className="text-xs text-ink-faint">At checkout</span>
|
||||
</div>
|
||||
<div className="mt-1.5 flex min-h-9 items-center gap-2.5">
|
||||
<input
|
||||
id="budget-range"
|
||||
type="range"
|
||||
min={20}
|
||||
max={300}
|
||||
step={5}
|
||||
value={state.targetBudget}
|
||||
onChange={(e) => dispatch({ type: "setBudget", value: Number(e.target.value) })}
|
||||
className="min-w-0 flex-1"
|
||||
aria-label="Weekly budget slider"
|
||||
/>
|
||||
<div className="relative shrink-0">
|
||||
<span className="pointer-events-none absolute top-1/2 left-2.5 -translate-y-1/2 font-mono text-xs text-ink-muted">$</span>
|
||||
<input
|
||||
id="budget"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={10}
|
||||
max={1000}
|
||||
value={state.targetBudget}
|
||||
onChange={(e) => dispatch({ type: "setBudget", value: Math.max(0, Number(e.target.value) || 0) })}
|
||||
className="min-h-9 w-22 rounded-lg border border-line-strong py-1 pr-2.5 pl-6 text-right font-mono text-sm font-semibold tabular-nums"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Days */}
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<label htmlFor="days" className="text-xs font-semibold tracking-wide text-ink-muted uppercase">
|
||||
Days
|
||||
</label>
|
||||
<span className="font-mono text-xs font-semibold text-ink tabular-nums">
|
||||
{state.daysCount} {state.daysCount === 1 ? "day" : "days"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1.5 flex min-h-9 items-center">
|
||||
<input
|
||||
id="days"
|
||||
type="range"
|
||||
min={1}
|
||||
max={7}
|
||||
value={state.daysCount}
|
||||
onChange={(e) => dispatch({ type: "setDays", value: Number(e.target.value) })}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Meals per day */}
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="shrink-0 text-xs font-semibold tracking-wide text-ink-muted uppercase">Meals / day</span>
|
||||
<span className="truncate text-xs text-ink-faint">{mealNames}</span>
|
||||
</div>
|
||||
<div className="mt-1.5">
|
||||
<RadioGroup
|
||||
name="Meals per day"
|
||||
options={MEALS_OPTIONS}
|
||||
value={state.mealsPerDay}
|
||||
onChange={(value) => dispatch({ type: "setMealsPerDay", value })}
|
||||
fullWidth
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Household size */}
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="shrink-0 text-xs font-semibold tracking-wide text-ink-muted uppercase">Household</span>
|
||||
<span className="truncate text-xs text-ink-faint">Scales amounts</span>
|
||||
</div>
|
||||
<div className="mt-1.5">
|
||||
<RadioGroup
|
||||
name="People eating"
|
||||
options={PEOPLE_OPTIONS}
|
||||
value={state.householdSize}
|
||||
onChange={(value) => dispatch({ type: "setHouseholdSize", value })}
|
||||
fullWidth
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* --- Action Toolbar + Progressive Disclosure Toggle --------------- */}
|
||||
<div className="mt-4 flex flex-wrap items-center justify-between gap-2 border-t border-line pt-3.5">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onGenerate("empty")}
|
||||
disabled={isGenerating}
|
||||
className="trim-cap inline-flex min-h-10 items-center gap-2 rounded-lg bg-brand-600 px-4 py-2 text-sm font-semibold text-on-accent shadow-2xs transition-colors hover:bg-brand-500 disabled:opacity-60"
|
||||
>
|
||||
<Wand2 className="size-4 shrink-0" strokeWidth={2} aria-hidden />
|
||||
{isGenerating ? "Planning…" : "Auto-fill empty"}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onGenerate("all")}
|
||||
disabled={isGenerating}
|
||||
className="trim-cap inline-flex min-h-10 items-center gap-1.5 rounded-lg border border-line-strong bg-surface px-3.5 py-2 text-sm font-medium text-ink transition-colors hover:bg-raised disabled:opacity-60"
|
||||
>
|
||||
<Shuffle className="size-4 shrink-0 text-ink-muted" strokeWidth={1.75} aria-hidden />
|
||||
Shuffle plan
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClear}
|
||||
disabled={isGenerating || filledCount === 0}
|
||||
className="trim-cap inline-flex min-h-10 items-center gap-1.5 rounded-lg px-3 py-2 text-sm font-medium text-ink-muted transition-colors hover:bg-raised hover:text-ink disabled:opacity-40"
|
||||
>
|
||||
<RotateCcw className="size-3.5 shrink-0" strokeWidth={1.75} aria-hidden />
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={filtersOpen}
|
||||
aria-controls="kitchen-filters-drawer"
|
||||
onClick={() => setFiltersOpen((o) => !o)}
|
||||
className={`trim-cap inline-flex min-h-10 items-center gap-2 rounded-lg border px-3.5 py-2 text-sm font-medium transition-colors ${
|
||||
filtersOpen || activeFilterCount > 0
|
||||
? "border-selected-line bg-selected text-ink"
|
||||
: "border-line-strong bg-surface text-ink-muted hover:bg-raised hover:text-ink"
|
||||
}`}
|
||||
>
|
||||
<SlidersHorizontal className="size-4 shrink-0" strokeWidth={1.75} aria-hidden />
|
||||
<span>Kitchen & preferences</span>
|
||||
<span className="hidden text-xs text-ink-faint sm:inline">({filterSummary})</span>
|
||||
<ChevronDown
|
||||
className={`size-4 shrink-0 text-ink-muted transition-transform ${filtersOpen ? "rotate-180" : ""}`}
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* --- Collapsible Appliances, Cuisines & Styles -------------------- */}
|
||||
{filtersOpen && (
|
||||
<div id="kitchen-filters-drawer" className="mt-4 space-y-5 border-t border-line pt-4">
|
||||
<fieldset>
|
||||
<legend className="text-sm font-semibold text-ink">
|
||||
Appliances you own <span className="font-normal text-ink-muted">· recipes needing anything else stay hidden</span>
|
||||
</legend>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{APPLIANCES.map((a) => (
|
||||
<Chip
|
||||
key={a.id}
|
||||
size="sm"
|
||||
pressed={state.selectedAppliances.includes(a.id)}
|
||||
onClick={() => dispatch({ type: "toggleAppliance", value: a.id })}
|
||||
>
|
||||
<ApplianceIcon id={a.id} className="size-3.5" />
|
||||
{a.label}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend className="text-sm font-semibold text-ink">
|
||||
Preferred cuisines <span className="font-normal text-ink-muted">· none selected = any</span>
|
||||
</legend>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{CUISINES.map((c) => (
|
||||
<Chip
|
||||
key={c.id}
|
||||
size="sm"
|
||||
pressed={state.preferredCuisines.includes(c.id)}
|
||||
onClick={() => dispatch({ type: "toggleCuisine", value: c.id })}
|
||||
>
|
||||
{c.label}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend className="text-sm font-semibold text-ink">
|
||||
Cooking style <span className="font-normal text-ink-muted">· matches any selected</span>
|
||||
</legend>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{STYLES.map((s) => (
|
||||
<Chip
|
||||
key={s.id}
|
||||
size="sm"
|
||||
pressed={state.preferredStyles.includes(s.id)}
|
||||
onClick={() => dispatch({ type: "toggleStyle", value: s.id })}
|
||||
>
|
||||
<StyleIcon id={s.id} className="size-3.5" />
|
||||
{s.label}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
221
app/components/GroceryListDrawer.tsx
Normal file
221
app/components/GroceryListDrawer.tsx
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
"use client";
|
||||
|
||||
import { Check, Copy, Repeat2, Square, SquareCheckBig, X } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { formatAmount, formatUsd } from "@/app/lib/units";
|
||||
import type { Aisle, GroceryLine, GrocerySummary, ItemId } from "@/app/types";
|
||||
import { BudgetMeter } from "./BudgetMeter";
|
||||
|
||||
interface PanelProps {
|
||||
summary: GrocerySummary;
|
||||
budget: number;
|
||||
priceSourceLabel: string;
|
||||
onToggleOnHand: (itemId: ItemId, onHand: boolean) => void;
|
||||
}
|
||||
|
||||
export function GrocerySummaryPanel({ summary, budget, priceSourceLabel, onToggleOnHand }: PanelProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const byAisle = groupByAisle(summary.lines);
|
||||
const remaining = budget - summary.checkoutTotal;
|
||||
|
||||
async function copyList() {
|
||||
const text = summary.lines
|
||||
.filter((l) => !l.onHand)
|
||||
.map((l) => `☐ ${l.packages} × ${l.item.name} (${l.item.packageLabel})`)
|
||||
.join("\n");
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// Clipboard can be blocked; list remains visible on screen.
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* --- Tactile Market Receipt Header -------------------------------- */}
|
||||
<div className="paper-grain rounded-xl border border-line bg-canvas p-4">
|
||||
<BudgetMeter spent={summary.checkoutTotal} budget={budget} />
|
||||
|
||||
<dl className="receipt-rule mt-4 grid grid-cols-2 gap-3 pt-3.5 text-xs">
|
||||
<Stat label="At checkout" value={formatUsd(summary.checkoutTotal)} />
|
||||
<Stat
|
||||
label={remaining >= 0 ? "Left in budget" : "Over budget"}
|
||||
value={formatUsd(Math.abs(remaining))}
|
||||
tone={remaining >= 0 ? "text-status-ok" : "text-status-danger"}
|
||||
/>
|
||||
<Stat label="Eaten this week" value={formatUsd(summary.usedTotal)} />
|
||||
<Stat label="Pantry carryover" value={formatUsd(summary.carryoverValue)} hint="Leftover package value kept" />
|
||||
</dl>
|
||||
|
||||
{summary.sharedItemCount > 0 && (
|
||||
<p className="mt-3 flex items-center gap-1.5 border-t border-line pt-2.5 text-xs font-medium text-status-ok">
|
||||
<Repeat2 className="size-3.5 shrink-0" strokeWidth={2} aria-hidden />
|
||||
{summary.sharedItemCount} store item{summary.sharedItemCount > 1 ? "s" : ""} shared across meals
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* --- Shopping List Header ----------------------------------------- */}
|
||||
<div className="mt-5 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-serif text-base font-semibold">Shopping list</h3>
|
||||
<p className="text-xs text-ink-faint">Tap items you already have on hand</p>
|
||||
</div>
|
||||
{summary.lines.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyList}
|
||||
className="trim-cap inline-flex min-h-9 items-center gap-1.5 rounded-lg border border-line-strong bg-surface px-3 py-1.5 text-xs font-semibold text-ink transition-colors hover:bg-raised"
|
||||
>
|
||||
{copied ? <Check className="size-3.5 text-status-ok" aria-hidden /> : <Copy className="size-3.5 text-ink-muted" aria-hidden />}
|
||||
{copied ? "Copied" : "Copy list"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{summary.lines.length === 0 ? (
|
||||
<p className="mt-6 rounded-lg border border-dashed border-line-strong p-6 text-center text-sm text-ink-muted">
|
||||
Add meals to build your aisle-sorted grocery receipt.
|
||||
</p>
|
||||
) : (
|
||||
<div className="mt-3 space-y-5">
|
||||
{byAisle.map(([aisle, lines]) => (
|
||||
<section key={aisle}>
|
||||
<h4 className="border-b border-line pb-1 font-mono text-[11px] font-semibold tracking-wider text-ink-muted uppercase">
|
||||
{aisle}
|
||||
</h4>
|
||||
<ul className="divide-y divide-line">
|
||||
{lines.map((line) => (
|
||||
<GroceryRow key={line.item.id} line={line} onToggleOnHand={onToggleOnHand} />
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="mt-6 text-xs text-ink-faint">{priceSourceLabel}. Actual store prices vary by location.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GroceryRow({ line, onToggleOnHand }: { line: GroceryLine; onToggleOnHand: PanelProps["onToggleOnHand"] }) {
|
||||
const { item } = line;
|
||||
return (
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggleOnHand(item.id, !line.onHand)}
|
||||
aria-pressed={line.onHand}
|
||||
aria-label={line.onHand ? `Mark ${item.name} as needed at the store` : `Mark ${item.name} as already on hand`}
|
||||
className={`flex min-h-12 w-full items-start justify-between gap-3 py-2.5 text-left text-sm transition-colors hover:bg-raised/50 ${
|
||||
line.onHand ? "text-ink-faint" : "text-ink"
|
||||
}`}
|
||||
>
|
||||
<span className="flex min-w-0 items-start gap-2.5">
|
||||
<span className="mt-0.5 shrink-0 text-ink-muted">
|
||||
{line.onHand ? (
|
||||
<SquareCheckBig className="size-4 text-status-ok" strokeWidth={2} aria-hidden />
|
||||
) : (
|
||||
<Square className="size-4 text-line-strong" strokeWidth={1.75} aria-hidden />
|
||||
)}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className={`block leading-snug ${line.onHand ? "line-through" : "font-medium"}`}>
|
||||
{line.packages > 1 && <span className="font-mono tabular-nums">{line.packages} × </span>}
|
||||
{item.name}
|
||||
</span>
|
||||
<span className="mt-0.5 block text-xs text-ink-muted">
|
||||
{item.packageLabel} · need {formatAmount(line.neededAmount, item.baseUnit)}
|
||||
{line.onHand && " · on hand"}
|
||||
</span>
|
||||
{line.usedBy.length > 1 && (
|
||||
<span className="mt-0.5 inline-flex items-center gap-1 text-xs text-ink-faint" title={line.usedBy.join(", ")}>
|
||||
<Repeat2 className="size-3 shrink-0" aria-hidden />
|
||||
Used in {line.usedBy.length} meals
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span className="shrink-0 text-right font-mono text-sm font-medium tabular-nums">
|
||||
{line.onHand ? "$0.00" : formatUsd(line.checkoutCost)}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value, hint, tone = "text-ink" }: { label: string; value: string; hint?: string; tone?: string }) {
|
||||
return (
|
||||
<div>
|
||||
<dt className="text-xs text-ink-faint" title={hint}>
|
||||
{label}
|
||||
</dt>
|
||||
<dd className={`mt-0.5 font-mono text-sm font-semibold tabular-nums ${tone}`}>{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function groupByAisle(lines: GroceryLine[]): [Aisle, GroceryLine[]][] {
|
||||
const groups = new Map<Aisle, GroceryLine[]>();
|
||||
for (const line of lines) {
|
||||
const group = groups.get(line.item.aisle) ?? [];
|
||||
group.push(line);
|
||||
groups.set(line.item.aisle, group);
|
||||
}
|
||||
return [...groups];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GroceryListDrawer: Native <dialog> mobile bottom sheet with focus trapping,
|
||||
// scroll locking, and @starting-style spring transition.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface DrawerProps extends PanelProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function GroceryListDrawer({ open, onClose, ...panelProps }: DrawerProps) {
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
if (open && !dialog.open) dialog.showModal();
|
||||
if (!open && dialog.open) dialog.close();
|
||||
return () => {
|
||||
if (dialog.open) dialog.close();
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<dialog
|
||||
ref={dialogRef}
|
||||
onClose={onClose}
|
||||
onClick={(e) => e.target === dialogRef.current && onClose()}
|
||||
aria-labelledby="grocery-drawer-title"
|
||||
className="sheet-dialog m-0 mt-auto max-h-[88dvh] w-full max-w-none overflow-y-auto rounded-t-2xl border-t border-line bg-surface p-0 shadow-2xl lg:hidden"
|
||||
>
|
||||
<div className="p-4 pb-8 sm:px-6">
|
||||
<div className="mb-4 flex items-center justify-between border-b border-line pb-3">
|
||||
<h2 id="grocery-drawer-title" className="font-serif text-xl font-semibold">
|
||||
Market receipt & list
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close grocery list"
|
||||
className="grid size-10 place-items-center rounded-lg text-ink-muted transition-colors hover:bg-raised hover:text-ink"
|
||||
>
|
||||
<X className="size-5" aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
<GrocerySummaryPanel {...panelProps} />
|
||||
</div>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
58
app/components/Icons.tsx
Normal file
58
app/components/Icons.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import type { LucideProps } from "lucide-react";
|
||||
import {
|
||||
BookHeart,
|
||||
CookingPot,
|
||||
CupSoda,
|
||||
Fish,
|
||||
Flame,
|
||||
Heater,
|
||||
Leaf,
|
||||
PackageCheck,
|
||||
Salad,
|
||||
Soup,
|
||||
Timer,
|
||||
Utensils,
|
||||
Wind,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import type { Appliance, CookingStyle } from "@/app/types";
|
||||
|
||||
export function ApplianceIcon({ id, className = "size-4", ...props }: { id: Appliance } & LucideProps) {
|
||||
const shared = { className, strokeWidth: 1.75, "aria-hidden": true as const, ...props };
|
||||
switch (id) {
|
||||
case "stovetop":
|
||||
return <Flame {...shared} />;
|
||||
case "oven":
|
||||
return <Heater {...shared} />;
|
||||
case "air_fryer":
|
||||
return <Wind {...shared} />;
|
||||
case "slow_cooker":
|
||||
return <Soup {...shared} />;
|
||||
case "microwave":
|
||||
return <Zap {...shared} />;
|
||||
case "blender":
|
||||
return <CupSoda {...shared} />;
|
||||
case "rice_cooker":
|
||||
return <CookingPot {...shared} />;
|
||||
}
|
||||
}
|
||||
|
||||
export function StyleIcon({ id, className = "size-3.5", ...props }: { id: CookingStyle } & LucideProps) {
|
||||
const shared = { className, strokeWidth: 1.75, "aria-hidden": true as const, ...props };
|
||||
switch (id) {
|
||||
case "caydens_corner":
|
||||
return <BookHeart {...shared} />;
|
||||
case "seafood":
|
||||
return <Fish {...shared} />;
|
||||
case "quick":
|
||||
return <Timer {...shared} />;
|
||||
case "vegetarian":
|
||||
return <Leaf {...shared} />;
|
||||
case "healthy":
|
||||
return <Salad {...shared} />;
|
||||
case "meal_prep":
|
||||
return <PackageCheck {...shared} />;
|
||||
case "comfort":
|
||||
return <Utensils {...shared} />;
|
||||
}
|
||||
}
|
||||
199
app/components/IngredientPanel.tsx
Normal file
199
app/components/IngredientPanel.tsx
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
"use client";
|
||||
|
||||
import { ChevronDown, Search, X } from "lucide-react";
|
||||
import { useMemo, useState, type Dispatch } from "react";
|
||||
import { CATALOG, CATALOG_BY_ID, PANTRY_STAPLE_IDS } from "@/app/lib/catalog";
|
||||
import { RECIPES } from "@/app/lib/recipes";
|
||||
import type { PlanAction } from "@/app/lib/usePlanState";
|
||||
import type { Aisle, ItemId, ItemPreference, MealPlanState, StoreItem } from "@/app/types";
|
||||
import { RadioGroup, type RadioOption } from "./RadioGroup";
|
||||
|
||||
interface IngredientPanelProps {
|
||||
state: MealPlanState;
|
||||
dispatch: Dispatch<PlanAction>;
|
||||
}
|
||||
|
||||
type Filter = "all" | "avoid" | "have";
|
||||
|
||||
const RECIPE_COUNT: ReadonlyMap<ItemId, number> = (() => {
|
||||
const counts = new Map<ItemId, number>();
|
||||
for (const recipe of RECIPES) {
|
||||
for (const id of new Set(recipe.ingredients.map((i) => i.itemId))) counts.set(id, (counts.get(id) ?? 0) + 1);
|
||||
}
|
||||
return counts;
|
||||
})();
|
||||
|
||||
const FILTER_OPTIONS: readonly RadioOption<Filter>[] = [
|
||||
{ value: "all", label: "All" },
|
||||
{ value: "avoid", label: "Avoided" },
|
||||
{ value: "have", label: "On hand" },
|
||||
];
|
||||
|
||||
const PREF_OPTIONS: readonly RadioOption<ItemPreference>[] = [
|
||||
{ value: "avoid", label: "Avoid", activeClass: "border border-status-danger-line bg-status-danger-bg text-status-danger font-semibold" },
|
||||
{ value: "use", label: "Use", activeClass: "border border-selected-line/60 bg-surface text-ink font-semibold shadow-2xs" },
|
||||
{ value: "have", label: "Have it", activeClass: "border border-status-ok-line bg-status-ok-bg text-status-ok font-semibold" },
|
||||
];
|
||||
|
||||
export function IngredientPanel({ state, dispatch }: IngredientPanelProps) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [filter, setFilter] = useState<Filter>("all");
|
||||
|
||||
const preferenceOf = (id: ItemId): ItemPreference =>
|
||||
state.avoidedItems.includes(id) ? "avoid" : state.onHandItems.includes(id) ? "have" : "use";
|
||||
|
||||
const setPreference = (itemIds: ItemId[], preference: ItemPreference) =>
|
||||
dispatch({ type: "setItemPreference", itemIds, preference });
|
||||
|
||||
const needle = query.trim().toLowerCase();
|
||||
const groups = useMemo(() => {
|
||||
const visible = CATALOG.filter((item) => {
|
||||
if (needle && !item.name.toLowerCase().includes(needle)) return false;
|
||||
if (filter === "avoid") return state.avoidedItems.includes(item.id);
|
||||
if (filter === "have") return state.onHandItems.includes(item.id);
|
||||
return true;
|
||||
});
|
||||
const byAisle = new Map<Aisle, StoreItem[]>();
|
||||
for (const item of [...visible].sort((a, b) => a.name.localeCompare(b.name))) {
|
||||
byAisle.set(item.aisle, [...(byAisle.get(item.aisle) ?? []), item]);
|
||||
}
|
||||
return [...byAisle].sort(([a], [b]) => a.localeCompare(b));
|
||||
}, [needle, filter, state.avoidedItems, state.onHandItems]);
|
||||
|
||||
const staplesAllOnHand = PANTRY_STAPLE_IDS.every((id) => state.onHandItems.includes(id));
|
||||
|
||||
return (
|
||||
<section aria-labelledby="ingredients-heading" className="rounded-xl border border-line bg-surface">
|
||||
<details className="group">
|
||||
<summary className="flex cursor-pointer list-none items-center justify-between gap-4 p-4 sm:px-5 [&::-webkit-details-marker]:hidden">
|
||||
<div>
|
||||
<h2 id="ingredients-heading" className="font-serif text-lg font-semibold">
|
||||
Pantry & ingredient exclusions
|
||||
</h2>
|
||||
<p className="text-xs text-ink-muted sm:text-sm">
|
||||
<span className="font-mono font-medium text-ink">{state.onHandItems.length}</span> on hand ($0) ·{" "}
|
||||
<span className="font-mono font-medium text-ink">{state.avoidedItems.length}</span> avoided
|
||||
</p>
|
||||
</div>
|
||||
<span className="grid size-9 shrink-0 place-items-center rounded-lg border border-line bg-raised text-ink-muted transition-transform group-open:rotate-180">
|
||||
<ChevronDown className="size-4" aria-hidden />
|
||||
</span>
|
||||
</summary>
|
||||
|
||||
<div className="border-t border-line p-4 sm:px-5">
|
||||
{state.avoidedItems.length > 0 && (
|
||||
<div className="mb-4 flex flex-wrap gap-1.5" aria-label="Avoided ingredients">
|
||||
{state.avoidedItems.map((id) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => setPreference([id], "use")}
|
||||
className="trim-cap inline-flex min-h-8 items-center gap-1 rounded-full border border-status-danger-line bg-status-danger-bg px-3 py-1 text-xs font-medium text-status-danger transition-colors hover:opacity-85"
|
||||
aria-label={`Stop avoiding ${CATALOG_BY_ID.get(id)?.name}`}
|
||||
>
|
||||
{CATALOG_BY_ID.get(id)?.name}
|
||||
<X className="size-3" aria-hidden />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search + filter */}
|
||||
<div className="flex flex-wrap items-center gap-2.5">
|
||||
<div className="relative w-full sm:w-auto sm:min-w-0 sm:flex-1">
|
||||
<Search className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-ink-faint" aria-hidden />
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search store items…"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="min-h-10 w-full rounded-lg border border-line-strong py-1.5 pr-3 pl-9 text-sm"
|
||||
aria-label="Search store items"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<RadioGroup name="Filter ingredients" options={FILTER_OPTIONS} value={filter} onChange={setFilter} size="xs" />
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs sm:text-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPreference(PANTRY_STAPLE_IDS, staplesAllOnHand ? "use" : "have")}
|
||||
className="min-h-8 font-medium text-brand-300 hover:underline"
|
||||
>
|
||||
{staplesAllOnHand ? "Unmark pantry staples" : "Mark basic pantry staples as on hand"}
|
||||
</button>
|
||||
{(state.avoidedItems.length > 0 || state.onHandItems.length > 0) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPreference([...state.avoidedItems, ...state.onHandItems], "use")}
|
||||
className="min-h-8 text-ink-muted hover:text-ink hover:underline"
|
||||
>
|
||||
Reset all to “Use”
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 max-h-[28rem] space-y-5 overflow-y-auto pr-1">
|
||||
{groups.length === 0 && <p className="py-8 text-center text-sm text-ink-muted">No store items match your search.</p>}
|
||||
|
||||
{groups.map(([aisle, items]) => (
|
||||
<section key={aisle}>
|
||||
<div className="sticky top-0 z-10 flex items-center justify-between border-b border-line bg-surface py-1.5">
|
||||
<h3 className="font-mono text-xs font-semibold tracking-wider text-ink-muted uppercase">{aisle}</h3>
|
||||
<AisleBulkToggle itemIds={items.map((i) => i.id)} avoidedItems={state.avoidedItems} onSet={setPreference} />
|
||||
</div>
|
||||
<ul className="grid gap-x-6 sm:grid-cols-2">
|
||||
{items.map((item) => {
|
||||
const current = preferenceOf(item.id);
|
||||
const count = RECIPE_COUNT.get(item.id) ?? 0;
|
||||
return (
|
||||
<li key={item.id} className="flex min-w-0 items-center justify-between gap-3 border-b border-line py-2.5">
|
||||
<div className="min-w-0">
|
||||
<p className={`truncate text-sm font-medium ${current === "avoid" ? "text-ink-faint line-through" : "text-ink"}`}>
|
||||
{item.name}
|
||||
</p>
|
||||
<p className="text-xs text-ink-faint">
|
||||
Used in {count} recipe{count === 1 ? "" : "s"}
|
||||
</p>
|
||||
</div>
|
||||
<RadioGroup
|
||||
name={item.name}
|
||||
options={PREF_OPTIONS}
|
||||
value={current}
|
||||
onChange={(val) => setPreference([item.id], val)}
|
||||
size="xs"
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function AisleBulkToggle({
|
||||
itemIds,
|
||||
avoidedItems,
|
||||
onSet,
|
||||
}: {
|
||||
itemIds: ItemId[];
|
||||
avoidedItems: ItemId[];
|
||||
onSet: (itemIds: ItemId[], preference: ItemPreference) => void;
|
||||
}) {
|
||||
const allAvoided = itemIds.every((id) => avoidedItems.includes(id));
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSet(itemIds, allAvoided ? "use" : "avoid")}
|
||||
className="min-h-8 px-2 text-xs font-medium text-ink-muted transition-colors hover:text-status-danger"
|
||||
>
|
||||
{allAvoided ? "Allow aisle" : "Avoid entire aisle"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
231
app/components/MealGrid.tsx
Normal file
231
app/components/MealGrid.tsx
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
"use client";
|
||||
|
||||
import { AlertTriangle, ArrowLeftRight, Plus, X } from "lucide-react";
|
||||
import { useState, type Dispatch } from "react";
|
||||
import { APPLIANCES, DAY_NAMES, activeSlots, describeBlockers, isCookable, mealLabel, recipeBlockers, slotKey } from "@/app/lib/plan";
|
||||
import { RECIPES_BY_ID } from "@/app/lib/recipes";
|
||||
import { formatUsd } from "@/app/lib/units";
|
||||
import type { PlanAction } from "@/app/lib/usePlanState";
|
||||
import type { MealPlanState } from "@/app/types";
|
||||
import { ApplianceIcon } from "./Icons";
|
||||
|
||||
interface MealGridProps {
|
||||
state: MealPlanState;
|
||||
dispatch: Dispatch<PlanAction>;
|
||||
mealCosts: Map<string, number>;
|
||||
onOpenSlot: (key: string) => void;
|
||||
onViewSlot: (key: string) => void;
|
||||
onRemoveSlot: (key: string, recipeTitle: string) => void;
|
||||
}
|
||||
|
||||
const applianceLabel = (id: string) => APPLIANCES.find((a) => a.id === id)?.label ?? id;
|
||||
|
||||
export function MealGrid({ state, dispatch, mealCosts, onOpenSlot, onViewSlot, onRemoveSlot }: MealGridProps) {
|
||||
const [dragFrom, setDragFrom] = useState<string | null>(null);
|
||||
const [dragOver, setDragOver] = useState<string | null>(null);
|
||||
/** Mobile & keyboard slot-swap picker: holds the origin slot key currently choosing a destination. */
|
||||
const [moveSourceKey, setMoveSourceKey] = useState<string | null>(null);
|
||||
|
||||
const allSlots = activeSlots(state);
|
||||
const days = Array.from({ length: state.daysCount }, (_, day) => allSlots.filter((s) => s.dayIndex === day));
|
||||
|
||||
return (
|
||||
<section aria-labelledby="grid-heading">
|
||||
<div className="mb-3 flex flex-wrap items-baseline justify-between gap-2">
|
||||
<h2 id="grid-heading" className="font-serif text-xl font-semibold tracking-tight">
|
||||
Weekly menu
|
||||
</h2>
|
||||
<p className="text-xs text-ink-faint">
|
||||
<span className="hidden md:inline">Drag meals or use the swap icon to rearrange · </span>
|
||||
Tap any planned dish for its scaled recipe
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 2xl:grid-cols-3">
|
||||
{days.map((slots, day) => {
|
||||
const dayCost = slots.reduce((sum, s) => sum + (s.recipeId ? (mealCosts.get(s.recipeId) ?? 0) : 0), 0);
|
||||
const hasAnyMeal = slots.some((s) => s.recipeId !== null);
|
||||
|
||||
return (
|
||||
<article key={day} className="flex flex-col overflow-hidden rounded-xl border border-line bg-surface">
|
||||
{/* Architectural day header */}
|
||||
<div className="flex items-baseline justify-between border-b border-line bg-raised/65 px-4 py-2.5">
|
||||
<h3 className="font-serif text-base font-semibold tracking-tight text-ink">{DAY_NAMES[day]}</h3>
|
||||
{hasAnyMeal && (
|
||||
<span className="font-mono text-xs text-ink-muted tabular-nums">{formatUsd(dayCost)} eaten</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ul className="flex-1 divide-y divide-line">
|
||||
{slots.map((slot) => {
|
||||
const key = slotKey(slot.dayIndex, slot.mealNumber);
|
||||
const recipe = slot.recipeId ? RECIPES_BY_ID.get(slot.recipeId) : undefined;
|
||||
const blocked = recipe ? !isCookable(recipe, state) : false;
|
||||
const label = mealLabel(slot.mealNumber);
|
||||
const isDropTarget = dragOver === key && dragFrom !== key;
|
||||
const isMovingThis = moveSourceKey === key;
|
||||
|
||||
return (
|
||||
<li
|
||||
key={key}
|
||||
onDragOver={(e) => {
|
||||
if (!dragFrom) return;
|
||||
e.preventDefault();
|
||||
setDragOver(key);
|
||||
}}
|
||||
onDragLeave={() => setDragOver((current) => (current === key ? null : current))}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
if (dragFrom && dragFrom !== key) dispatch({ type: "swapSlots", from: dragFrom, to: key });
|
||||
setDragFrom(null);
|
||||
setDragOver(null);
|
||||
}}
|
||||
className={`transition-colors ${isDropTarget ? "bg-brand-950 ring-2 ring-inset ring-brand-600" : ""}`}
|
||||
>
|
||||
{recipe ? (
|
||||
<div
|
||||
draggable
|
||||
onDragStart={() => setDragFrom(key)}
|
||||
onDragEnd={() => {
|
||||
setDragFrom(null);
|
||||
setDragOver(null);
|
||||
}}
|
||||
className={`p-3.5 transition-colors ${
|
||||
blocked ? "bg-status-warn-bg/65" : "hover:bg-raised/45"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onViewSlot(key)}
|
||||
className="min-w-0 flex-1 text-left"
|
||||
aria-label={`View recipe for ${DAY_NAMES[day]} ${label}: ${recipe.title}`}
|
||||
>
|
||||
<span className="block font-mono text-[11px] font-medium tracking-wider text-ink-faint uppercase">
|
||||
{label}
|
||||
</span>
|
||||
<span className="mt-0.5 block leading-snug font-semibold text-ink hover:text-brand-300">
|
||||
{recipe.title}
|
||||
</span>
|
||||
|
||||
<span className="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-ink-muted">
|
||||
<span>{recipe.prepTime} min</span>
|
||||
<span aria-hidden>·</span>
|
||||
<span className="font-mono tabular-nums">{formatUsd(mealCosts.get(recipe.id) ?? 0)}/meal</span>
|
||||
{recipe.requiredAppliances.length > 0 && (
|
||||
<>
|
||||
<span aria-hidden>·</span>
|
||||
<span
|
||||
className="inline-flex items-center gap-1 text-ink-faint"
|
||||
title={`Requires: ${recipe.requiredAppliances.map(applianceLabel).join(", ")}`}
|
||||
>
|
||||
{recipe.requiredAppliances.map((a) => (
|
||||
<ApplianceIcon key={a} id={a} className="size-3.5" />
|
||||
))}
|
||||
<span className="sr-only">
|
||||
Requires {recipe.requiredAppliances.map(applianceLabel).join(", ")}
|
||||
</span>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{blocked && (
|
||||
<span className="mt-1.5 inline-flex items-center gap-1 text-xs font-medium text-status-warn">
|
||||
<AlertTriangle className="size-3.5 shrink-0" aria-hidden />
|
||||
{describeBlockers(recipeBlockers(recipe, state))}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Non-overlapping 36x36px touch & keyboard action controls */}
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
{allSlots.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={isMovingThis}
|
||||
onClick={() => setMoveSourceKey(isMovingThis ? null : key)}
|
||||
aria-label={`Move or swap ${recipe.title}`}
|
||||
title="Move or swap slot"
|
||||
className={`grid size-9 place-items-center rounded-lg transition-colors ${
|
||||
isMovingThis ? "bg-selected text-ink" : "text-ink-faint hover:bg-raised hover:text-ink"
|
||||
}`}
|
||||
>
|
||||
<ArrowLeftRight className="size-4" strokeWidth={1.75} aria-hidden />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (isMovingThis) setMoveSourceKey(null);
|
||||
onRemoveSlot(key, recipe.title);
|
||||
}}
|
||||
aria-label={`Remove ${recipe.title} from ${DAY_NAMES[day]} ${label}`}
|
||||
title="Remove meal"
|
||||
className="grid size-9 place-items-center rounded-lg text-ink-faint transition-colors hover:bg-status-danger-bg hover:text-status-danger"
|
||||
>
|
||||
<X className="size-4" strokeWidth={1.75} aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Touch & keyboard accessible Swap/Move picker */}
|
||||
{isMovingThis && (
|
||||
<div className="mt-3 rounded-lg border border-line-strong bg-raised p-2.5">
|
||||
<p className="text-xs font-medium text-ink-muted">Swap or move to another slot:</p>
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{allSlots
|
||||
.filter((target) => slotKey(target.dayIndex, target.mealNumber) !== key)
|
||||
.map((target) => {
|
||||
const targetKey = slotKey(target.dayIndex, target.mealNumber);
|
||||
const occupant = target.recipeId ? RECIPES_BY_ID.get(target.recipeId)?.title : null;
|
||||
return (
|
||||
<button
|
||||
key={targetKey}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
dispatch({ type: "swapSlots", from: key, to: targetKey });
|
||||
setMoveSourceKey(null);
|
||||
}}
|
||||
className="trim-cap min-h-8 rounded-md border border-line-strong bg-surface px-2.5 py-1 text-xs font-medium text-ink transition-colors hover:border-brand-600 hover:bg-brand-950"
|
||||
title={occupant ? `Swap with ${occupant}` : "Move to empty slot"}
|
||||
>
|
||||
{DAY_NAMES[target.dayIndex]} {mealLabel(target.mealNumber)}
|
||||
{occupant ? " ⇄" : ""}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenSlot(key)}
|
||||
className="group flex min-h-16 w-full items-center justify-between px-4 py-3 text-left transition-colors hover:bg-raised/65"
|
||||
>
|
||||
<span>
|
||||
<span className="block font-mono text-[11px] font-medium tracking-wider text-ink-faint uppercase">
|
||||
{label}
|
||||
</span>
|
||||
<span className="mt-0.5 block text-sm font-medium text-ink-muted group-hover:text-ink">
|
||||
Choose a meal
|
||||
</span>
|
||||
</span>
|
||||
<span className="grid size-8 place-items-center rounded-lg border border-dashed border-line-strong text-ink-muted transition-colors group-hover:border-brand-600 group-hover:bg-brand-950 group-hover:text-brand-300">
|
||||
<Plus className="size-4" strokeWidth={2} aria-hidden />
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
297
app/components/MealSelectorModal.tsx
Normal file
297
app/components/MealSelectorModal.tsx
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
"use client";
|
||||
|
||||
import { ChevronDown, ChevronUp, Repeat2, Search, SlidersHorizontal, X } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Cart } from "@/app/lib/grocery";
|
||||
import { CUISINES, DAY_NAMES, STYLES, activeRecipeIds, describeBlockers, mealKindFor, mealLabel, recipeBlockers, toggle } from "@/app/lib/plan";
|
||||
import { RECIPES, RECIPES_BY_ID } from "@/app/lib/recipes";
|
||||
import { formatUsd } from "@/app/lib/units";
|
||||
import type { CookingStyle, Cuisine, MealPlanState, Recipe } from "@/app/types";
|
||||
import { Chip } from "./Chip";
|
||||
import { StyleIcon } from "./Icons";
|
||||
import { RecipeDetail } from "./RecipeDetail";
|
||||
|
||||
interface MealSelectorModalProps {
|
||||
slotKey: string | null;
|
||||
state: MealPlanState;
|
||||
onPick: (recipeId: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function MealSelectorModal({ slotKey, state, onPick, onClose }: MealSelectorModalProps) {
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
if (slotKey && !dialog.open) dialog.showModal();
|
||||
if (!slotKey && dialog.open) dialog.close();
|
||||
return () => {
|
||||
if (dialog.open) dialog.close();
|
||||
};
|
||||
}, [slotKey]);
|
||||
|
||||
return (
|
||||
<dialog
|
||||
ref={dialogRef}
|
||||
onClose={onClose}
|
||||
onClick={(e) => e.target === dialogRef.current && onClose()}
|
||||
aria-labelledby="selector-title"
|
||||
className="m-0 mt-auto max-h-[90dvh] w-full max-w-none overflow-hidden rounded-t-2xl border border-line bg-surface p-0 shadow-2xl sm:m-auto sm:max-w-2xl sm:rounded-2xl"
|
||||
>
|
||||
{slotKey && <SelectorContent key={slotKey} slotKey={slotKey} state={state} onPick={onPick} onClose={onClose} />}
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
|
||||
interface Candidate {
|
||||
recipe: Recipe;
|
||||
addedCost: number;
|
||||
reusedCount: number;
|
||||
blockedReason: string;
|
||||
}
|
||||
|
||||
function SelectorContent({
|
||||
slotKey,
|
||||
state,
|
||||
onPick,
|
||||
onClose,
|
||||
}: {
|
||||
slotKey: string;
|
||||
state: MealPlanState;
|
||||
onPick: (id: string) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [dayIndex, mealNumber] = slotKey.split("-").map(Number);
|
||||
const slotKind = mealKindFor(mealNumber);
|
||||
const currentId = state.slots[slotKey];
|
||||
|
||||
const [query, setQuery] = useState("");
|
||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
const [cuisines, setCuisines] = useState<Cuisine[]>(state.preferredCuisines);
|
||||
const [styles, setStyles] = useState<CookingStyle[]>(state.preferredStyles);
|
||||
const [showAllKinds, setShowAllKinds] = useState(false);
|
||||
const [showIncompatible, setShowIncompatible] = useState(false);
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
|
||||
const candidates = useMemo<Candidate[]>(() => {
|
||||
const others = activeRecipeIds(state);
|
||||
if (currentId) others.splice(others.indexOf(currentId), 1);
|
||||
|
||||
const cart = new Cart({ householdSize: state.householdSize, onHandItems: state.onHandItems }, others);
|
||||
const itemsInCart = new Set(others.flatMap((id) => RECIPES_BY_ID.get(id)?.ingredients.map((i) => i.itemId) ?? []));
|
||||
|
||||
return RECIPES.map((recipe) => ({
|
||||
recipe,
|
||||
addedCost: cart.marginal(recipe.id).checkout,
|
||||
reusedCount: recipe.ingredients.filter((i) => itemsInCart.has(i.itemId)).length,
|
||||
blockedReason: describeBlockers(recipeBlockers(recipe, state)),
|
||||
})).sort((a, b) => a.addedCost - b.addedCost);
|
||||
}, [state, currentId]);
|
||||
|
||||
const needle = query.trim().toLowerCase();
|
||||
const visible = candidates.filter(
|
||||
({ recipe, blockedReason }) =>
|
||||
(showAllKinds || recipe.kind === slotKind) &&
|
||||
(showIncompatible || blockedReason === "") &&
|
||||
(cuisines.length === 0 || cuisines.includes(recipe.cuisine)) &&
|
||||
(styles.length === 0 || recipe.styles.some((s) => styles.includes(s))) &&
|
||||
(needle === "" || recipe.title.toLowerCase().includes(needle)),
|
||||
);
|
||||
|
||||
const activeFilterCount = cuisines.length + styles.length + (showAllKinds ? 1 : 0) + (showIncompatible ? 1 : 0);
|
||||
|
||||
return (
|
||||
<div className="flex max-h-[90dvh] flex-col">
|
||||
{/* Compact sticky header so 80%+ of mobile viewport shows recipes */}
|
||||
<div className="border-b border-line p-4 sm:px-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="font-mono text-xs tracking-wider text-ink-faint uppercase">
|
||||
{DAY_NAMES[dayIndex]} · {mealLabel(mealNumber)}
|
||||
</p>
|
||||
<h2 id="selector-title" className="font-serif text-xl font-semibold">
|
||||
Choose a dish
|
||||
</h2>
|
||||
<p className="text-xs text-ink-muted">Sorted by marginal cost added to your current grocery cart</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
className="grid size-10 shrink-0 place-items-center rounded-lg text-ink-muted transition-colors hover:bg-raised hover:text-ink"
|
||||
>
|
||||
<X className="size-5" aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<Search className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-ink-faint" aria-hidden />
|
||||
<input
|
||||
type="search"
|
||||
autoFocus
|
||||
placeholder="Search recipes…"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="min-h-10 w-full rounded-lg border border-line-strong py-2 pr-3 pl-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={filtersOpen}
|
||||
onClick={() => setFiltersOpen((o) => !o)}
|
||||
className={`trim-cap inline-flex min-h-10 shrink-0 items-center gap-1.5 rounded-lg border px-3 py-2 text-sm font-medium transition-colors ${
|
||||
filtersOpen || activeFilterCount > 0
|
||||
? "border-selected-line bg-selected text-ink"
|
||||
: "border-line-strong bg-surface text-ink-muted hover:bg-raised hover:text-ink"
|
||||
}`}
|
||||
>
|
||||
<SlidersHorizontal className="size-4" strokeWidth={1.75} aria-hidden />
|
||||
<span>Filters</span>
|
||||
{activeFilterCount > 0 && (
|
||||
<span className="rounded-full bg-brand-600 px-1.5 py-0.5 font-mono text-[11px] font-semibold text-on-accent">
|
||||
{activeFilterCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{filtersOpen && (
|
||||
<div className="mt-3 max-h-52 space-y-3 overflow-y-auto border-t border-line pt-3">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{CUISINES.map((c) => (
|
||||
<Chip key={c.id} size="sm" pressed={cuisines.includes(c.id)} onClick={() => setCuisines((list) => toggle(list, c.id))}>
|
||||
{c.label}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{STYLES.map((st) => (
|
||||
<Chip key={st.id} size="sm" dashed pressed={styles.includes(st.id)} onClick={() => setStyles((list) => toggle(list, st.id))}>
|
||||
<StyleIcon id={st.id} className="size-3.5" />
|
||||
{st.label}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-2 text-xs text-ink-muted sm:text-sm">
|
||||
<label className="flex min-h-8 cursor-pointer items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showAllKinds}
|
||||
onChange={(e) => setShowAllKinds(e.target.checked)}
|
||||
className="size-4 accent-brand-600"
|
||||
/>
|
||||
Include breakfasts, sides & desserts
|
||||
</label>
|
||||
<label className="flex min-h-8 cursor-pointer items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showIncompatible}
|
||||
onChange={(e) => setShowIncompatible(e.target.checked)}
|
||||
className="size-4 accent-brand-600"
|
||||
/>
|
||||
Show blocked recipes (appliances / avoided items)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<ul className="flex-1 space-y-2 overflow-y-auto p-4 sm:px-5">
|
||||
{visible.length === 0 && (
|
||||
<li className="py-10 text-center text-sm text-ink-muted">No recipes match these filters.</li>
|
||||
)}
|
||||
|
||||
{visible.map(({ recipe, addedCost, reusedCount, blockedReason }) => {
|
||||
const disabled = blockedReason !== "";
|
||||
const expanded = expandedId === recipe.id;
|
||||
return (
|
||||
<li
|
||||
key={recipe.id}
|
||||
className={`rounded-xl border transition-colors ${
|
||||
recipe.id === currentId ? "border-selected-line bg-raised" : "border-line bg-surface hover:border-line-strong"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3 p-3.5">
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onPick(recipe.id)}
|
||||
className="min-w-0 flex-1 text-left disabled:cursor-not-allowed disabled:opacity-55"
|
||||
aria-label={`Choose ${recipe.title}`}
|
||||
>
|
||||
<p className="font-semibold text-ink">
|
||||
{recipe.title}
|
||||
{recipe.id === currentId && (
|
||||
<span className="ml-2 rounded border border-selected-line bg-selected px-1.5 py-0.5 font-mono text-[11px] font-medium text-ink">
|
||||
current
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="mt-0.5 text-sm text-ink-muted">{recipe.description}</p>
|
||||
<p className="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-ink-faint">
|
||||
<span>{CUISINES.find((c) => c.id === recipe.cuisine)?.label}</span>
|
||||
<span aria-hidden>·</span>
|
||||
<span>{recipe.prepTime} min</span>
|
||||
{recipe.requiredAppliances.length === 0 && (
|
||||
<>
|
||||
<span aria-hidden>·</span>
|
||||
<span>No cooking</span>
|
||||
</>
|
||||
)}
|
||||
{reusedCount > 0 && (
|
||||
<span className="inline-flex items-center gap-1 font-medium text-status-ok">
|
||||
<Repeat2 className="size-3" aria-hidden />
|
||||
Reuses {reusedCount} item{reusedCount > 1 ? "s" : ""} in cart
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
{disabled && <p className="mt-1.5 text-xs font-medium text-status-warn">{blockedReason}</p>}
|
||||
</button>
|
||||
|
||||
<div className="flex shrink-0 flex-col items-end gap-1.5 text-right">
|
||||
<div>
|
||||
<p className={`font-mono text-sm font-semibold tabular-nums ${addedCost < 0.01 ? "text-status-ok" : "text-ink"}`}>
|
||||
+{formatUsd(Math.max(0, addedCost))}
|
||||
</p>
|
||||
<p className="text-[11px] text-ink-faint">{addedCost < 0.01 ? "in cart" : "to cart"}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpandedId(expanded ? null : recipe.id)}
|
||||
aria-expanded={expanded}
|
||||
aria-controls={`recipe-${recipe.id}`}
|
||||
className="trim-cap inline-flex min-h-8 items-center gap-1 rounded-lg border border-line-strong bg-surface px-2.5 py-1 text-xs font-medium text-ink-muted transition-colors hover:bg-raised hover:text-ink"
|
||||
>
|
||||
{expanded ? "Hide" : "Recipe"}
|
||||
{expanded ? <ChevronUp className="size-3.5" aria-hidden /> : <ChevronDown className="size-3.5" aria-hidden />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div id={`recipe-${recipe.id}`} className="border-t border-line bg-canvas/50 px-3.5 py-4">
|
||||
<RecipeDetail recipe={recipe} servings={state.householdSize} showMeta={false} compact />
|
||||
{!disabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPick(recipe.id)}
|
||||
className="trim-cap mt-4 min-h-10 w-full rounded-lg bg-brand-600 py-2 text-sm font-semibold text-on-accent transition-colors hover:bg-brand-500"
|
||||
>
|
||||
Add to {DAY_NAMES[dayIndex]} {mealLabel(mealNumber).toLowerCase()}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
36
app/components/Navbar.tsx
Normal file
36
app/components/Navbar.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import type { SyncStatus } from "@/app/lib/usePlanState";
|
||||
import { BudgetMeter } from "./BudgetMeter";
|
||||
import { SiteHeader } from "./SiteHeader";
|
||||
|
||||
interface NavbarProps {
|
||||
checkoutTotal: number;
|
||||
budget: number;
|
||||
syncStatus: SyncStatus;
|
||||
isGenerating: boolean;
|
||||
}
|
||||
|
||||
const SYNC_LABEL: Record<SyncStatus, { text: string; dot: string }> = {
|
||||
saved: { text: "Saved on device", dot: "bg-status-ok" },
|
||||
error: { text: "Storage blocked", dot: "bg-status-danger" },
|
||||
};
|
||||
|
||||
export function Navbar({ checkoutTotal, budget, syncStatus, isGenerating }: NavbarProps) {
|
||||
const sync = isGenerating ? { text: "Building plan…", dot: "bg-status-warn animate-pulse" } : SYNC_LABEL[syncStatus];
|
||||
|
||||
return (
|
||||
<SiteHeader
|
||||
active="plan"
|
||||
subtitle={
|
||||
<span className="inline-flex items-center gap-1.5 text-xs text-ink-muted" aria-live="polite" title={sync.text}>
|
||||
<span className={`size-1.5 rounded-full ${sync.dot}`} />
|
||||
<span className="sr-only sm:not-sr-only">{sync.text}</span>
|
||||
</span>
|
||||
}
|
||||
rightSlot={
|
||||
<div className="hidden w-60 border-l border-line pl-4 sm:block lg:w-68">
|
||||
<BudgetMeter spent={checkoutTotal} budget={budget} />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
149
app/components/OverlapExplorer.tsx
Normal file
149
app/components/OverlapExplorer.tsx
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
"use client";
|
||||
|
||||
import { Check, Plus, Repeat2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
export interface OverlapDish {
|
||||
id: string;
|
||||
title: string;
|
||||
cuisine: string;
|
||||
standaloneCheckout: number;
|
||||
}
|
||||
|
||||
export interface OverlapComboSnapshot {
|
||||
checkoutTotal: number;
|
||||
usedTotal: number;
|
||||
carryoverValue: number;
|
||||
sharedNames: string[];
|
||||
packageCount: number;
|
||||
}
|
||||
|
||||
interface OverlapExplorerProps {
|
||||
dishes: OverlapDish[];
|
||||
/** Keyed by sorted comma-joined dish IDs; precomputed on the server so zero catalog data ships to the landing bundle. */
|
||||
combos: Record<string, OverlapComboSnapshot>;
|
||||
}
|
||||
|
||||
const fmt = (n: number) => `$${n.toFixed(2)}`;
|
||||
|
||||
export function OverlapExplorer({ dishes, combos }: OverlapExplorerProps) {
|
||||
const [selected, setSelected] = useState<string[]>(() => dishes.slice(0, 2).map((d) => d.id));
|
||||
|
||||
function toggleDish(id: string) {
|
||||
setSelected((prev) => {
|
||||
if (prev.includes(id)) {
|
||||
return prev.length > 1 ? prev.filter((x) => x !== id) : prev; // keep at least 1 selected
|
||||
}
|
||||
return [...prev, id];
|
||||
});
|
||||
}
|
||||
|
||||
const comboKey = [...selected].sort().join(",");
|
||||
const current = combos[comboKey];
|
||||
const naiveSum = dishes.filter((d) => selected.includes(d.id)).reduce((acc, d) => acc + d.standaloneCheckout, 0);
|
||||
const saved = Math.max(0, naiveSum - (current?.checkoutTotal ?? 0));
|
||||
|
||||
return (
|
||||
<div className="grid gap-6 rounded-xl border border-line-strong bg-surface p-5 sm:p-7 lg:grid-cols-[1.15fr_1fr]">
|
||||
<div>
|
||||
<p className="font-mono text-xs tracking-wider text-brand-300 uppercase">Interactive checkout math · 2 people</p>
|
||||
<h3 className="mt-1 font-serif text-2xl font-semibold tracking-tight text-balance">
|
||||
Toggle dishes to watch shared packages drop the bill
|
||||
</h3>
|
||||
<p className="mt-2 text-sm leading-relaxed text-ink-muted">
|
||||
Grocery stores sell whole bags of onions, rice, and spices—not single tablespoons. Selecting meals that reuse open
|
||||
packages slashes what each extra dinner adds at the register.
|
||||
</p>
|
||||
|
||||
<ul className="mt-5 space-y-2" aria-label="Sample dishes">
|
||||
{dishes.map((dish) => {
|
||||
const active = selected.includes(dish.id);
|
||||
return (
|
||||
<li key={dish.id}>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
onClick={() => toggleDish(dish.id)}
|
||||
className={`flex min-h-12 w-full items-center justify-between gap-3 rounded-lg border px-3.5 py-2.5 text-left transition-colors ${
|
||||
active
|
||||
? "border-selected-line bg-selected text-ink"
|
||||
: "border-line bg-canvas text-ink-muted hover:border-line-strong hover:text-ink"
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-3 min-w-0">
|
||||
<span
|
||||
className={`grid size-5 shrink-0 place-items-center rounded border ${
|
||||
active ? "border-brand-600 bg-brand-600 text-on-accent" : "border-line-strong bg-surface"
|
||||
}`}
|
||||
>
|
||||
{active ? <Check className="size-3.5" strokeWidth={2.5} aria-hidden /> : <Plus className="size-3 text-ink-faint" aria-hidden />}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-sm font-semibold text-ink">{dish.title}</span>
|
||||
<span className="block text-xs text-ink-faint">{dish.cuisine}</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className="shrink-0 font-mono text-xs text-ink-muted tabular-nums">
|
||||
{fmt(dish.standaloneCheckout)} alone
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Simulated paper receipt */}
|
||||
<div className="paper-grain flex flex-col justify-between rounded-lg border border-line bg-canvas p-5 font-mono text-sm">
|
||||
<div>
|
||||
<div className="flex items-center justify-between border-b border-line pb-3 text-xs text-ink-faint uppercase">
|
||||
<span>Checkout Receipt</span>
|
||||
<span>{selected.length} {selected.length === 1 ? "meal" : "meals"}</span>
|
||||
</div>
|
||||
|
||||
<dl className="mt-4 space-y-2.5 text-xs sm:text-sm">
|
||||
<div className="flex justify-between text-ink-muted">
|
||||
<dt>Buying each meal separately</dt>
|
||||
<dd className="line-through tabular-nums">{fmt(naiveSum)}</dd>
|
||||
</div>
|
||||
{saved > 0.01 && (
|
||||
<div className="flex justify-between font-semibold text-status-ok">
|
||||
<dt>Shared-package savings</dt>
|
||||
<dd className="tabular-nums">−{fmt(saved)}</dd>
|
||||
</div>
|
||||
)}
|
||||
<div className="receipt-rule flex items-baseline justify-between pt-3 text-base font-bold text-ink">
|
||||
<dt className="font-sans text-sm font-semibold">Combined register total</dt>
|
||||
<dd className="text-lg tabular-nums">{fmt(current?.checkoutTotal ?? 0)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<dl className="mt-4 grid grid-cols-2 gap-3 rounded-md border border-line bg-surface p-3 font-sans text-xs">
|
||||
<div>
|
||||
<dt className="text-ink-faint">Food eaten</dt>
|
||||
<dd className="mt-0.5 font-mono text-sm font-semibold text-ink tabular-nums">{fmt(current?.usedTotal ?? 0)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-ink-faint">Kept in pantry</dt>
|
||||
<dd className="mt-0.5 font-mono text-sm font-semibold text-ink tabular-nums">{fmt(current?.carryoverValue ?? 0)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 border-t border-line pt-3 font-sans text-xs text-ink-muted">
|
||||
{current && current.sharedNames.length > 0 ? (
|
||||
<p className="flex items-start gap-1.5">
|
||||
<Repeat2 className="mt-0.5 size-3.5 shrink-0 text-status-ok" strokeWidth={2} aria-hidden />
|
||||
<span>
|
||||
<strong className="font-semibold text-ink">Reused across meals:</strong> {current.sharedNames.slice(0, 5).join(", ")}
|
||||
{current.sharedNames.length > 5 ? ` +${current.sharedNames.length - 5} more` : ""}
|
||||
</span>
|
||||
</p>
|
||||
) : (
|
||||
<p>Select a second dish to see which store packages overlap in your cart.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
246
app/components/PlannerDashboard.tsx
Normal file
246
app/components/PlannerDashboard.tsx
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
"use client";
|
||||
|
||||
import { AlertCircle, RotateCcw, ShoppingBag, X } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState, useTransition } from "react";
|
||||
import { generateMealPlanAction } from "@/app/actions";
|
||||
import { Cart, buildGroceryList } from "@/app/lib/grocery";
|
||||
import { activeRecipeIds } from "@/app/lib/plan";
|
||||
import { averageGroceryProvider } from "@/app/lib/pricing";
|
||||
import { RECIPES } from "@/app/lib/recipes";
|
||||
import { usePlanState } from "@/app/lib/usePlanState";
|
||||
import type { FillMode } from "@/app/types";
|
||||
import { BudgetMeter } from "./BudgetMeter";
|
||||
import { ConfigPanel } from "./ConfigPanel";
|
||||
import { GroceryListDrawer, GrocerySummaryPanel } from "./GroceryListDrawer";
|
||||
import { IngredientPanel } from "./IngredientPanel";
|
||||
import { MealGrid } from "./MealGrid";
|
||||
import { MealSelectorModal } from "./MealSelectorModal";
|
||||
import { Navbar } from "./Navbar";
|
||||
import { RecipeDialog } from "./RecipeDialog";
|
||||
|
||||
interface UndoSnapshot {
|
||||
message: string;
|
||||
previousSlots: Record<string, string>;
|
||||
}
|
||||
|
||||
export default function PlannerDashboard() {
|
||||
const { state, dispatch, syncStatus } = usePlanState();
|
||||
const [isGenerating, startGenerating] = useTransition();
|
||||
const [notes, setNotes] = useState<string[]>([]);
|
||||
const [openSlot, setOpenSlot] = useState<string | null>(null);
|
||||
const [viewSlot, setViewSlot] = useState<string | null>(null);
|
||||
const [groceriesOpen, setGroceriesOpen] = useState(false);
|
||||
const [undo, setUndo] = useState<UndoSnapshot | null>(null);
|
||||
|
||||
// Auto-dismiss undo toast after 6 seconds
|
||||
useEffect(() => {
|
||||
if (!undo) return;
|
||||
const timer = window.setTimeout(() => setUndo(null), 6000);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [undo]);
|
||||
|
||||
const summary = useMemo(
|
||||
() => buildGroceryList(activeRecipeIds(state), { householdSize: state.householdSize, onHandItems: state.onHandItems }),
|
||||
[state],
|
||||
);
|
||||
|
||||
const mealCosts = useMemo(() => {
|
||||
const emptyCart = new Cart({ householdSize: state.householdSize, onHandItems: state.onHandItems });
|
||||
return new Map(RECIPES.map((r) => [r.id, emptyCart.marginal(r.id).eaten]));
|
||||
}, [state.householdSize, state.onHandItems]);
|
||||
|
||||
function handleGenerate(fillMode: FillMode) {
|
||||
startGenerating(async () => {
|
||||
try {
|
||||
const result = await generateMealPlanAction({ state, fillMode, seed: Date.now() });
|
||||
dispatch({ type: "replaceSlots", slots: result.slots });
|
||||
setNotes(result.notes);
|
||||
setUndo(null);
|
||||
} catch {
|
||||
setNotes(["Couldn't reach the planner. Check your connection and try again."]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const handleRemoveSlot = useCallback(
|
||||
(key: string, recipeTitle: string) => {
|
||||
setUndo({ message: `Removed "${recipeTitle}"`, previousSlots: { ...state.slots } });
|
||||
dispatch({ type: "setSlot", key, recipeId: null });
|
||||
},
|
||||
[dispatch, state.slots],
|
||||
);
|
||||
|
||||
const handleClearWeek = useCallback(() => {
|
||||
const count = activeRecipeIds(state).length;
|
||||
if (count === 0) return;
|
||||
setUndo({ message: `Cleared ${count} planned meal${count === 1 ? "" : "s"}`, previousSlots: { ...state.slots } });
|
||||
dispatch({ type: "clearSlots" });
|
||||
}, [dispatch, state]);
|
||||
|
||||
const handleUndo = useCallback(() => {
|
||||
if (!undo) return;
|
||||
dispatch({ type: "replaceSlots", slots: undo.previousSlots });
|
||||
setUndo(null);
|
||||
}, [dispatch, undo]);
|
||||
|
||||
const closeSelector = useCallback(() => setOpenSlot(null), []);
|
||||
const closeRecipe = useCallback(() => setViewSlot(null), []);
|
||||
const closeGroceries = useCallback(() => setGroceriesOpen(false), []);
|
||||
const toggleOnHand = useCallback(
|
||||
(itemId: string, onHand: boolean) => dispatch({ type: "setItemPreference", itemIds: [itemId], preference: onHand ? "have" : "use" }),
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const mealCount = activeRecipeIds(state).length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Navbar checkoutTotal={summary.checkoutTotal} budget={state.targetBudget} syncStatus={syncStatus} isGenerating={isGenerating} />
|
||||
|
||||
<main className="mx-auto max-w-7xl px-4 pt-6 pb-28 sm:px-6 lg:pb-12">
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
{/* Left: compact control bar -> immediate MealGrid -> Pantry below */}
|
||||
<div className="space-y-6 lg:col-span-2">
|
||||
<ConfigPanel
|
||||
state={state}
|
||||
dispatch={dispatch}
|
||||
onGenerate={handleGenerate}
|
||||
onClear={handleClearWeek}
|
||||
isGenerating={isGenerating}
|
||||
/>
|
||||
|
||||
{notes.length > 0 && (
|
||||
<div
|
||||
role="status"
|
||||
className="flex items-start justify-between gap-3 rounded-xl border border-status-warn-line bg-status-warn-bg p-3.5 text-sm text-status-warn"
|
||||
>
|
||||
<div className="flex items-start gap-2.5">
|
||||
<AlertCircle className="mt-0.5 size-4 shrink-0" aria-hidden />
|
||||
<ul className="space-y-1">
|
||||
{notes.map((note) => (
|
||||
<li key={note}>{note}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setNotes([])}
|
||||
aria-label="Dismiss notice"
|
||||
className="grid size-8 shrink-0 place-items-center rounded-lg hover:bg-black/5"
|
||||
>
|
||||
<X className="size-4" aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={isGenerating ? "pointer-events-none opacity-60 transition-opacity" : "transition-opacity"}>
|
||||
<MealGrid
|
||||
state={state}
|
||||
dispatch={dispatch}
|
||||
mealCosts={mealCosts}
|
||||
onOpenSlot={setOpenSlot}
|
||||
onViewSlot={setViewSlot}
|
||||
onRemoveSlot={handleRemoveSlot}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<IngredientPanel state={state} dispatch={dispatch} />
|
||||
</div>
|
||||
|
||||
{/* Right: persistent Market Receipt summary (desktop) */}
|
||||
<aside className="hidden lg:block">
|
||||
<div className="sticky top-20 max-h-[calc(100dvh-6rem)] overflow-y-auto rounded-xl border border-line bg-surface p-5">
|
||||
<h2 className="mb-3 font-serif text-xl font-semibold">Market receipt</h2>
|
||||
<GrocerySummaryPanel
|
||||
summary={summary}
|
||||
budget={state.targetBudget}
|
||||
priceSourceLabel={averageGroceryProvider.label}
|
||||
onToggleOnHand={toggleOnHand}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Undo Toast for accidental meal removal or Clear */}
|
||||
{undo && (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="fixed bottom-20 left-1/2 z-40 flex -translate-x-1/2 items-center gap-3 rounded-xl border border-line-strong bg-surface px-4 py-2.5 text-sm shadow-lg lg:bottom-6"
|
||||
>
|
||||
<span className="text-ink">{undo.message}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleUndo}
|
||||
className="trim-cap inline-flex min-h-8 items-center gap-1.5 rounded-lg bg-brand-600 px-3 py-1 text-xs font-semibold text-on-accent hover:bg-brand-500"
|
||||
>
|
||||
<RotateCcw className="size-3.5" aria-hidden />
|
||||
Undo
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setUndo(null)}
|
||||
aria-label="Dismiss undo"
|
||||
className="grid size-7 place-items-center rounded text-ink-faint hover:text-ink"
|
||||
>
|
||||
<X className="size-3.5" aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile: sticky bottom bar */}
|
||||
<div className="fixed inset-x-0 bottom-0 z-30 border-t border-line bg-surface/95 px-4 py-3 backdrop-blur-md lg:hidden">
|
||||
<div className="mx-auto flex max-w-7xl items-center gap-4">
|
||||
<div className="flex-1">
|
||||
<BudgetMeter spent={summary.checkoutTotal} budget={state.targetBudget} compact />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setGroceriesOpen(true)}
|
||||
className="trim-cap inline-flex min-h-10 shrink-0 items-center gap-2 rounded-lg bg-brand-600 px-4 py-2 text-sm font-semibold text-on-accent shadow-2xs hover:bg-brand-500"
|
||||
>
|
||||
<ShoppingBag className="size-4" strokeWidth={2} aria-hidden />
|
||||
Receipt ({summary.lines.length})
|
||||
</button>
|
||||
</div>
|
||||
<p className="sr-only" aria-live="polite">
|
||||
{mealCount} meals planned, estimated checkout {summary.checkoutTotal.toFixed(2)} dollars
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<GroceryListDrawer
|
||||
open={groceriesOpen}
|
||||
onClose={closeGroceries}
|
||||
summary={summary}
|
||||
budget={state.targetBudget}
|
||||
priceSourceLabel={averageGroceryProvider.label}
|
||||
onToggleOnHand={toggleOnHand}
|
||||
/>
|
||||
|
||||
<RecipeDialog
|
||||
slotKey={viewSlot}
|
||||
state={state}
|
||||
onClose={closeRecipe}
|
||||
onSwap={(key) => {
|
||||
setViewSlot(null);
|
||||
setOpenSlot(key);
|
||||
}}
|
||||
onRemove={(key, title) => {
|
||||
handleRemoveSlot(key, title);
|
||||
setViewSlot(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
<MealSelectorModal
|
||||
slotKey={openSlot}
|
||||
state={state}
|
||||
onClose={closeSelector}
|
||||
onPick={(recipeId) => {
|
||||
if (openSlot) dispatch({ type: "setSlot", key: openSlot, recipeId });
|
||||
setOpenSlot(null);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
31
app/components/PlannerLoader.tsx
Normal file
31
app/components/PlannerLoader.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
const PlannerDashboard = dynamic(() => import("./PlannerDashboard"), {
|
||||
ssr: false,
|
||||
loading: () => <DashboardSkeleton />,
|
||||
});
|
||||
|
||||
export function PlannerLoader() {
|
||||
return <PlannerDashboard />;
|
||||
}
|
||||
|
||||
function DashboardSkeleton() {
|
||||
return (
|
||||
<div className="animate-pulse" aria-busy="true" aria-label="Loading planner">
|
||||
<div className="h-15 border-b border-line bg-surface" />
|
||||
<div className="mx-auto grid max-w-7xl grid-cols-1 gap-6 px-4 pt-6 sm:px-6 lg:grid-cols-3">
|
||||
<div className="space-y-6 lg:col-span-2">
|
||||
<div className="h-36 rounded-xl bg-raised" />
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
{Array.from({ length: 4 }, (_, i) => (
|
||||
<div key={i} className="h-44 rounded-xl bg-raised" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden h-[32rem] rounded-xl bg-raised lg:block" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
95
app/components/RadioGroup.tsx
Normal file
95
app/components/RadioGroup.tsx
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
"use client";
|
||||
|
||||
import { useRef, type KeyboardEvent, type ReactNode } from "react";
|
||||
|
||||
export interface RadioOption<T extends string | number> {
|
||||
value: T;
|
||||
label: ReactNode;
|
||||
activeClass?: string;
|
||||
}
|
||||
|
||||
interface RadioGroupProps<T extends string | number> {
|
||||
name: string;
|
||||
options: readonly RadioOption<T>[];
|
||||
value: T;
|
||||
onChange: (value: T) => void;
|
||||
size?: "xs" | "sm";
|
||||
fullWidth?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Accessible segmented control implementing WAI-ARIA `radiogroup` roving tabIndex
|
||||
* and arrow/Home/End keyboard navigation (1 Tab stop per group instead of N).
|
||||
*/
|
||||
export function RadioGroup<T extends string | number>({
|
||||
name,
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
size = "sm",
|
||||
fullWidth = false,
|
||||
}: RadioGroupProps<T>) {
|
||||
const buttonsRef = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent<HTMLDivElement>) {
|
||||
const currentIndex = Math.max(0, options.findIndex((o) => o.value === value));
|
||||
let nextIndex = -1;
|
||||
|
||||
if (e.key === "ArrowRight" || e.key === "ArrowDown") {
|
||||
nextIndex = (currentIndex + 1) % options.length;
|
||||
} else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
|
||||
nextIndex = (currentIndex - 1 + options.length) % options.length;
|
||||
} else if (e.key === "Home") {
|
||||
nextIndex = 0;
|
||||
} else if (e.key === "End") {
|
||||
nextIndex = options.length - 1;
|
||||
}
|
||||
|
||||
if (nextIndex !== -1) {
|
||||
e.preventDefault();
|
||||
onChange(options[nextIndex].value);
|
||||
buttonsRef.current[nextIndex]?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
const pad =
|
||||
size === "xs"
|
||||
? fullWidth
|
||||
? "min-h-8 flex-1 min-w-0 px-1.5 py-1 text-xs"
|
||||
: "min-h-8 px-2.5 py-1 text-xs"
|
||||
: fullWidth
|
||||
? "min-h-9 flex-1 min-w-0 px-2 py-1.5 text-sm"
|
||||
: "min-h-9 min-w-9 px-3 py-1.5 text-sm";
|
||||
|
||||
return (
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label={name}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={`${fullWidth ? "flex w-full" : "inline-flex shrink-0"} max-w-full rounded-lg border border-line-strong bg-raised p-0.5`}
|
||||
>
|
||||
{options.map((option, i) => {
|
||||
const checked = option.value === value;
|
||||
const activeStyle = option.activeClass ?? "bg-surface text-ink border border-selected-line/60 shadow-2xs";
|
||||
return (
|
||||
<button
|
||||
key={String(option.value)}
|
||||
ref={(el) => {
|
||||
buttonsRef.current[i] = el;
|
||||
}}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={checked}
|
||||
tabIndex={checked ? 0 : -1}
|
||||
onClick={() => onChange(option.value)}
|
||||
className={`trim-cap inline-flex items-center justify-center rounded-md font-medium tabular-nums transition-colors ${pad} ${
|
||||
checked ? activeStyle : "border border-transparent text-ink-muted hover:text-ink"
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
231
app/components/RecipeBrowser.tsx
Normal file
231
app/components/RecipeBrowser.tsx
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
"use client";
|
||||
|
||||
import { BookHeart, Search, SlidersHorizontal } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { ViewTransition, useMemo, useState, type ReactNode } from "react";
|
||||
import { APPLIANCES, CUISINES, MEAL_KIND_LABELS, STYLES, toggle } from "@/app/lib/plan";
|
||||
import type { Appliance, CookingStyle, Cuisine, MealKind } from "@/app/types";
|
||||
import { Chip } from "./Chip";
|
||||
import { ApplianceIcon, StyleIcon } from "./Icons";
|
||||
|
||||
export interface RecipeSummary {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
cuisine: Cuisine;
|
||||
kind: MealKind;
|
||||
styles: CookingStyle[];
|
||||
appliances: Appliance[];
|
||||
prepTime: number;
|
||||
ingredientText: string;
|
||||
}
|
||||
|
||||
type KindFilter = "all" | MealKind;
|
||||
type SortOrder = "az" | "quickest";
|
||||
|
||||
export function RecipeBrowser({ recipes }: { recipes: RecipeSummary[] }) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [kind, setKind] = useState<KindFilter>("all");
|
||||
const [cuisines, setCuisines] = useState<Cuisine[]>([]);
|
||||
const [styles, setStyles] = useState<CookingStyle[]>([]);
|
||||
const [kitchen, setKitchen] = useState<Appliance[]>([]);
|
||||
const [sort, setSort] = useState<SortOrder>("az");
|
||||
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false);
|
||||
|
||||
const visible = useMemo(() => {
|
||||
const needle = query.trim().toLowerCase();
|
||||
return recipes
|
||||
.filter((r) => kind === "all" || r.kind === kind)
|
||||
.filter((r) => cuisines.length === 0 || cuisines.includes(r.cuisine))
|
||||
.filter((r) => styles.length === 0 || r.styles.some((s) => styles.includes(s)))
|
||||
.filter((r) => kitchen.length === 0 || r.appliances.every((a) => kitchen.includes(a)))
|
||||
.filter((r) => !needle || r.title.toLowerCase().includes(needle) || r.ingredientText.includes(needle))
|
||||
.sort((a, b) => (sort === "quickest" ? a.prepTime - b.prepTime : 0) || a.title.localeCompare(b.title));
|
||||
}, [recipes, query, kind, cuisines, styles, kitchen, sort]);
|
||||
|
||||
const activeChipCount = (kind !== "all" ? 1 : 0) + cuisines.length + styles.length + kitchen.length;
|
||||
const filtersActive = Boolean(query) || activeChipCount > 0;
|
||||
|
||||
const clearAll = () => {
|
||||
setQuery("");
|
||||
setKind("all");
|
||||
setCuisines([]);
|
||||
setStyles([]);
|
||||
setKitchen([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid gap-8 lg:grid-cols-[18.5rem_1fr]">
|
||||
{/* --- Search + Collapsible Mobile Filters / Sticky Desktop Sidebar --- */}
|
||||
<aside className="space-y-4 lg:sticky lg:top-20 lg:self-start">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<Search className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-ink-faint" aria-hidden />
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search dishes or ingredients…"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="min-h-10 w-full rounded-lg border border-line-strong py-2 pr-3 pl-9 text-sm"
|
||||
aria-label="Search recipes or ingredients"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={mobileFiltersOpen}
|
||||
onClick={() => setMobileFiltersOpen((o) => !o)}
|
||||
className={`trim-cap inline-flex min-h-10 shrink-0 items-center gap-1.5 rounded-lg border px-3 py-2 text-sm font-medium lg:hidden ${
|
||||
mobileFiltersOpen || activeChipCount > 0
|
||||
? "border-selected-line bg-selected text-ink"
|
||||
: "border-line-strong bg-surface text-ink-muted"
|
||||
}`}
|
||||
>
|
||||
<SlidersHorizontal className="size-4" aria-hidden />
|
||||
Filters
|
||||
{activeChipCount > 0 && (
|
||||
<span className="rounded-full bg-brand-600 px-1.5 py-0.5 font-mono text-[11px] font-semibold text-on-accent">
|
||||
{activeChipCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={`space-y-5 ${mobileFiltersOpen ? "block rounded-xl border border-line bg-surface p-4" : "hidden"} lg:block lg:border-0 lg:bg-transparent lg:p-0`}>
|
||||
<FilterGroup label="Course">
|
||||
{(
|
||||
[
|
||||
["all", "All"],
|
||||
["breakfast", "Breakfast"],
|
||||
["main", "Lunch & dinner"],
|
||||
["side", "Sides"],
|
||||
["dessert", "Desserts"],
|
||||
] as const
|
||||
).map(([value, label]) => (
|
||||
<Chip size="sm" key={value} pressed={kind === value} onClick={() => setKind(value)}>
|
||||
{label}
|
||||
</Chip>
|
||||
))}
|
||||
</FilterGroup>
|
||||
|
||||
<FilterGroup label="Cuisine">
|
||||
{CUISINES.map((c) => (
|
||||
<Chip size="sm" key={c.id} pressed={cuisines.includes(c.id)} onClick={() => setCuisines((l) => toggle(l, c.id))}>
|
||||
{c.label}
|
||||
</Chip>
|
||||
))}
|
||||
</FilterGroup>
|
||||
|
||||
<FilterGroup label="Style">
|
||||
{STYLES.map((s) => (
|
||||
<Chip size="sm" key={s.id} pressed={styles.includes(s.id)} onClick={() => setStyles((l) => toggle(l, s.id))}>
|
||||
<StyleIcon id={s.id} className="size-3.5" />
|
||||
{s.label}
|
||||
</Chip>
|
||||
))}
|
||||
</FilterGroup>
|
||||
|
||||
<FilterGroup label="Available appliances" hint="Hides recipes needing anything else">
|
||||
{APPLIANCES.map((a) => (
|
||||
<Chip size="sm" key={a.id} pressed={kitchen.includes(a.id)} onClick={() => setKitchen((l) => toggle(l, a.id))}>
|
||||
<ApplianceIcon id={a.id} className="size-3.5" />
|
||||
{a.label}
|
||||
</Chip>
|
||||
))}
|
||||
</FilterGroup>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* --- Results -------------------------------------------------------- */}
|
||||
<section aria-label="Recipes">
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3 border-b border-line pb-3">
|
||||
<p className="text-sm text-ink-muted" aria-live="polite">
|
||||
Showing <span className="font-mono font-semibold text-ink">{visible.length}</span> of{" "}
|
||||
<span className="font-mono">{recipes.length}</span> recipes
|
||||
{filtersActive && (
|
||||
<button type="button" onClick={clearAll} className="ml-3 font-medium text-brand-300 hover:underline">
|
||||
Reset filters
|
||||
</button>
|
||||
)}
|
||||
</p>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm text-ink-muted">
|
||||
Sort
|
||||
<select
|
||||
value={sort}
|
||||
onChange={(e) => setSort(e.target.value as SortOrder)}
|
||||
className="min-h-9 rounded-lg border border-line-strong bg-surface px-2.5 py-1 text-sm font-medium text-ink"
|
||||
>
|
||||
<option value="az">A–Z</option>
|
||||
<option value="quickest">Quickest first</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{visible.length === 0 ? (
|
||||
<p className="rounded-xl border border-dashed border-line-strong p-10 text-center text-ink-muted">
|
||||
No recipes match these filters. Try relaxing an appliance or cuisine filter.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="grid gap-3.5 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{visible.map((r) => {
|
||||
const applianceNames = r.appliances.map((a) => APPLIANCES.find((x) => x.id === a)?.label).join(", ") || "No cooking";
|
||||
return (
|
||||
<li key={r.id}>
|
||||
<Link
|
||||
href={`/recipes/${r.id}`}
|
||||
className="group flex h-full flex-col rounded-xl border border-line bg-surface p-4 transition-colors hover:border-line-strong hover:bg-raised/60"
|
||||
>
|
||||
<span className="flex items-center gap-1.5 font-mono text-[11px] tracking-wide text-ink-faint uppercase">
|
||||
{r.styles.includes("caydens_corner") && (
|
||||
<BookHeart className="size-3.5 shrink-0 text-brand-300" strokeWidth={2} aria-hidden />
|
||||
)}
|
||||
<span>
|
||||
{CUISINES.find((c) => c.id === r.cuisine)?.label} · {MEAL_KIND_LABELS[r.kind]}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<ViewTransition name={`recipe-title-${r.id}`}>
|
||||
<span className="mt-1 font-serif text-lg leading-snug font-semibold text-ink group-hover:text-brand-300">
|
||||
{r.title}
|
||||
</span>
|
||||
</ViewTransition>
|
||||
|
||||
<span className="mt-1.5 line-clamp-2 text-sm leading-relaxed text-ink-muted">{r.description}</span>
|
||||
|
||||
<span className="mt-auto flex items-center justify-between border-t border-line pt-3 mt-4 text-xs text-ink-faint">
|
||||
<span className="font-mono tabular-nums">{r.prepTime} min</span>
|
||||
<span className="inline-flex items-center gap-1.5" title={applianceNames}>
|
||||
{r.appliances.length > 0 ? (
|
||||
<>
|
||||
{r.appliances.map((a) => (
|
||||
<ApplianceIcon key={a} id={a} className="size-3.5 text-ink-muted" />
|
||||
))}
|
||||
<span className="sr-only">Requires {applianceNames}</span>
|
||||
</>
|
||||
) : (
|
||||
<span>No cooking</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterGroup({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) {
|
||||
return (
|
||||
<fieldset>
|
||||
<legend className="text-xs font-semibold tracking-wider text-ink-muted uppercase">
|
||||
{label} {hint && <span className="font-sans font-normal tracking-normal text-ink-faint normal-case">· {hint}</span>}
|
||||
</legend>
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">{children}</div>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
229
app/components/RecipeDetail.tsx
Normal file
229
app/components/RecipeDetail.tsx
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
"use client";
|
||||
|
||||
import { Check, Lightbulb } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { ingredientLines } from "@/app/lib/display";
|
||||
import { APPLIANCES, CUISINES, MEAL_KIND_LABELS, STYLES } from "@/app/lib/plan";
|
||||
import type { Recipe } from "@/app/types";
|
||||
import { ApplianceIcon, StyleIcon } from "./Icons";
|
||||
|
||||
interface RecipeDetailProps {
|
||||
recipe: Recipe;
|
||||
/** People to scale ingredient amounts for. */
|
||||
servings: number;
|
||||
/** Hide the cuisine/time/appliance chips when surrounding UI already shows them. */
|
||||
showMeta?: boolean;
|
||||
/** Smaller type for use inside lists. */
|
||||
compact?: boolean;
|
||||
/** Allow tapping ingredients & steps to check them off while cooking. */
|
||||
interactive?: boolean;
|
||||
}
|
||||
|
||||
export function RecipeDetail({
|
||||
recipe,
|
||||
servings,
|
||||
showMeta = true,
|
||||
compact = false,
|
||||
interactive = false,
|
||||
}: RecipeDetailProps) {
|
||||
const lines = ingredientLines(recipe, servings);
|
||||
const text = compact ? "text-sm" : "text-base";
|
||||
const [checkedIngredients, setCheckedIngredients] = useState<Set<number>>(() => new Set());
|
||||
const [checkedSteps, setCheckedSteps] = useState<Set<number>>(() => new Set());
|
||||
|
||||
function toggleSet(setter: React.Dispatch<React.SetStateAction<Set<number>>>, index: number) {
|
||||
setter((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(index)) next.delete(index);
|
||||
else next.add(index);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={compact ? "space-y-4" : "space-y-8"}>
|
||||
{showMeta && <RecipeMeta recipe={recipe} />}
|
||||
|
||||
<div className={compact ? "grid gap-5 md:grid-cols-[2fr_3fr]" : "grid gap-8 md:grid-cols-[2fr_3fr]"}>
|
||||
{/* --- Ingredients ------------------------------------------------- */}
|
||||
<section>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<h3 className={`font-serif font-semibold ${compact ? "text-base" : "text-xl"}`}>
|
||||
Ingredients{" "}
|
||||
<span className="font-sans text-sm font-normal text-ink-muted">
|
||||
· {servings} serving{servings === 1 ? "" : "s"}
|
||||
</span>
|
||||
</h3>
|
||||
{interactive && checkedIngredients.size > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCheckedIngredients(new Set())}
|
||||
className="text-xs font-medium text-brand-300 hover:underline"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ul className={`mt-3 divide-y divide-line ${text}`}>
|
||||
{lines.map((line, i) => {
|
||||
const done = interactive && checkedIngredients.has(i);
|
||||
const content = (
|
||||
<>
|
||||
<span className="w-20 shrink-0 text-right font-mono text-sm font-semibold text-ink tabular-nums">
|
||||
{line.quantity} {line.unit}
|
||||
</span>
|
||||
<span className={done ? "text-ink-faint line-through" : "text-ink"}>
|
||||
{line.name}
|
||||
{line.detail && <span className="text-ink-muted"> ({line.detail})</span>}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<li key={i}>
|
||||
{interactive ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={done}
|
||||
onClick={() => toggleSet(setCheckedIngredients, i)}
|
||||
className="flex min-h-10 w-full items-baseline gap-3 py-2 text-left transition-colors hover:bg-raised/50"
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex items-baseline gap-3 py-1.5">{content}</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{/* --- Instructions ------------------------------------------------ */}
|
||||
<section>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<h3 className={`font-serif font-semibold ${compact ? "text-base" : "text-xl"}`}>Instructions</h3>
|
||||
{interactive && checkedSteps.size > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCheckedSteps(new Set())}
|
||||
className="text-xs font-medium text-brand-300 hover:underline"
|
||||
>
|
||||
Reset steps
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{servings !== recipe.servings && (
|
||||
<p className="mt-2.5 rounded-lg border border-line-strong/50 bg-raised px-3 py-2 text-xs text-ink-muted">
|
||||
Ingredient quantities on the left are scaled to <strong className="font-semibold text-ink">{servings}</strong>.
|
||||
Step prose is written for the original {recipe.servings}-serving batch—scale pan sizes or liquid mentions the same way.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<ol className={`mt-3 space-y-3 ${text}`}>
|
||||
{recipe.steps.map((step, i) => {
|
||||
const done = interactive && checkedSteps.has(i);
|
||||
const badge = (
|
||||
<span
|
||||
className={`grid size-6 shrink-0 place-items-center rounded-full font-mono text-xs font-semibold transition-colors ${
|
||||
done
|
||||
? "bg-status-ok text-canvas"
|
||||
: "border border-selected-line/50 bg-selected text-ink"
|
||||
}`}
|
||||
>
|
||||
{done ? <Check className="size-3.5" strokeWidth={2.5} aria-hidden /> : i + 1}
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<li key={i}>
|
||||
{interactive ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={done}
|
||||
onClick={() => toggleSet(setCheckedSteps, i)}
|
||||
className="flex w-full items-start gap-3 rounded-lg p-1.5 -m-1.5 text-left transition-colors hover:bg-raised/50"
|
||||
>
|
||||
{badge}
|
||||
<span className={`pt-0.5 leading-relaxed ${done ? "text-ink-faint line-through" : "text-ink"}`}>{step}</span>
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex items-start gap-3">
|
||||
{badge}
|
||||
<span className="pt-0.5 leading-relaxed">{step}</span>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
|
||||
{recipe.tip && (
|
||||
<div className="mt-5 flex items-start gap-2.5 rounded-lg border border-brand-border bg-brand-950 px-3.5 py-3 text-sm text-ink">
|
||||
<Lightbulb className="mt-0.5 size-4 shrink-0 text-brand-300" strokeWidth={2} aria-hidden />
|
||||
<p>
|
||||
<span className="font-semibold text-brand-300">Cook's note:</span> {recipe.tip}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RecipeMeta({ recipe }: { recipe: Recipe }) {
|
||||
const cuisineLabel = CUISINES.find((c) => c.id === recipe.cuisine)?.label;
|
||||
|
||||
return (
|
||||
<ul className="flex flex-wrap gap-1.5">
|
||||
{cuisineLabel && (
|
||||
<li className="rounded-full border border-line-strong bg-raised px-3 py-1 text-xs font-medium text-ink">
|
||||
{cuisineLabel}
|
||||
</li>
|
||||
)}
|
||||
<li className="rounded-full border border-line-strong bg-raised px-3 py-1 text-xs text-ink-muted">
|
||||
{MEAL_KIND_LABELS[recipe.kind]}
|
||||
</li>
|
||||
<li className="rounded-full border border-line-strong bg-raised px-3 py-1 font-mono text-xs text-ink-muted">
|
||||
{recipe.prepTime} min
|
||||
</li>
|
||||
<li className="rounded-full border border-line-strong bg-raised px-3 py-1 text-xs text-ink-muted">
|
||||
Serves {recipe.servings}
|
||||
</li>
|
||||
|
||||
{recipe.requiredAppliances.length === 0 ? (
|
||||
<li className="rounded-full border border-line-strong bg-raised px-3 py-1 text-xs text-ink-muted">No cooking</li>
|
||||
) : (
|
||||
recipe.requiredAppliances.map((a) => {
|
||||
const label = APPLIANCES.find((x) => x.id === a)?.label ?? a;
|
||||
return (
|
||||
<li
|
||||
key={a}
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-line-strong bg-raised px-3 py-1 text-xs text-ink-muted"
|
||||
>
|
||||
<ApplianceIcon id={a} className="size-3.5 text-ink" />
|
||||
{label}
|
||||
</li>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
{recipe.styles.map((s) => {
|
||||
const label = STYLES.find((x) => x.id === s)?.label;
|
||||
if (!label) return null;
|
||||
return (
|
||||
<li
|
||||
key={s}
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-line bg-surface px-3 py-1 text-xs text-ink-muted"
|
||||
>
|
||||
<StyleIcon id={s} className="size-3.5 text-brand-300" />
|
||||
{label}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
111
app/components/RecipeDialog.tsx
Normal file
111
app/components/RecipeDialog.tsx
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
"use client";
|
||||
|
||||
import { AlertTriangle, ArrowRight, ArrowRightLeft, Trash2, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { DAY_NAMES, describeBlockers, isCookable, mealLabel, recipeBlockers } from "@/app/lib/plan";
|
||||
import { RECIPES_BY_ID } from "@/app/lib/recipes";
|
||||
import type { MealPlanState } from "@/app/types";
|
||||
import { RecipeDetail, RecipeMeta } from "./RecipeDetail";
|
||||
|
||||
interface RecipeDialogProps {
|
||||
slotKey: string | null;
|
||||
state: MealPlanState;
|
||||
onClose: () => void;
|
||||
onSwap: (slotKey: string) => void;
|
||||
onRemove: (slotKey: string, recipeTitle: string) => void;
|
||||
}
|
||||
|
||||
export function RecipeDialog({ slotKey, state, onClose, onSwap, onRemove }: RecipeDialogProps) {
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
const recipe = slotKey ? RECIPES_BY_ID.get(state.slots[slotKey]) : undefined;
|
||||
const open = Boolean(slotKey && recipe);
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
if (open && !dialog.open) dialog.showModal();
|
||||
if (!open && dialog.open) dialog.close();
|
||||
return () => {
|
||||
if (dialog.open) dialog.close();
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const [dayIndex, mealNumber] = (slotKey ?? "0-1").split("-").map(Number);
|
||||
|
||||
return (
|
||||
<dialog
|
||||
ref={dialogRef}
|
||||
onClose={onClose}
|
||||
onClick={(e) => e.target === dialogRef.current && onClose()}
|
||||
aria-labelledby="recipe-dialog-title"
|
||||
className="m-0 mt-auto max-h-[92dvh] w-full max-w-none overflow-y-auto rounded-t-2xl border border-line bg-surface p-0 text-ink shadow-2xl sm:m-auto sm:max-w-3xl sm:rounded-2xl"
|
||||
>
|
||||
{open && recipe && slotKey && (
|
||||
<div className="p-5 sm:p-7">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="font-mono text-xs tracking-wider text-ink-faint uppercase">
|
||||
{DAY_NAMES[dayIndex]} · {mealLabel(mealNumber)}
|
||||
</p>
|
||||
<h2 id="recipe-dialog-title" className="mt-0.5 font-serif text-2xl font-semibold tracking-tight sm:text-3xl">
|
||||
{recipe.title}
|
||||
</h2>
|
||||
<p className="mt-1.5 text-ink-muted">{recipe.description}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
className="grid size-10 shrink-0 place-items-center rounded-lg text-ink-muted transition-colors hover:bg-raised hover:text-ink"
|
||||
>
|
||||
<X className="size-5" aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<RecipeMeta recipe={recipe} />
|
||||
</div>
|
||||
|
||||
{!isCookable(recipe, state) && (
|
||||
<p className="mt-4 flex items-center gap-2 rounded-lg border border-status-warn-line bg-status-warn-bg px-3.5 py-2.5 text-sm text-status-warn">
|
||||
<AlertTriangle className="size-4 shrink-0" aria-hidden />
|
||||
<span>{describeBlockers(recipeBlockers(recipe, state))}. Consider swapping this meal.</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-6">
|
||||
<RecipeDetail recipe={recipe} servings={state.householdSize} showMeta={false} interactive />
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex flex-wrap items-center gap-2.5 border-t border-line pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSwap(slotKey)}
|
||||
className="trim-cap inline-flex min-h-10 items-center gap-2 rounded-lg bg-brand-600 px-4 py-2 text-sm font-semibold text-on-accent transition-colors hover:bg-brand-500"
|
||||
>
|
||||
<ArrowRightLeft className="size-4" aria-hidden />
|
||||
Replace dish
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemove(slotKey, recipe.title)}
|
||||
className="trim-cap inline-flex min-h-10 items-center gap-1.5 rounded-lg px-3.5 py-2 text-sm font-medium text-ink-muted transition-colors hover:bg-status-danger-bg hover:text-status-danger"
|
||||
>
|
||||
<Trash2 className="size-4" aria-hidden />
|
||||
Remove from plan
|
||||
</button>
|
||||
<Link
|
||||
href={`/recipes/${recipe.id}`}
|
||||
onClick={onClose}
|
||||
className="ml-auto inline-flex min-h-10 items-center gap-1.5 text-sm font-medium text-brand-300 hover:underline"
|
||||
>
|
||||
Full recipe & cook mode
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
101
app/components/RecipeServings.tsx
Normal file
101
app/components/RecipeServings.tsx
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
"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>
|
||||
);
|
||||
}
|
||||
123
app/components/RotatingPreview.tsx
Normal file
123
app/components/RotatingPreview.tsx
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
"use client";
|
||||
|
||||
import { Pause, Play, Repeat2, ShoppingBag } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export interface PreviewSet {
|
||||
theme: string;
|
||||
blurb: string;
|
||||
meals: { slot: string; title: string; cost: string }[];
|
||||
total: string;
|
||||
sharedItems: number;
|
||||
itemCount: number;
|
||||
}
|
||||
|
||||
const INTERVAL_MS = 5000;
|
||||
|
||||
export function RotatingPreview({ sets }: { sets: PreviewSet[] }) {
|
||||
const [index, setIndex] = useState(0);
|
||||
const [paused, setPaused] = useState(false);
|
||||
const [hovering, setHovering] = useState(false);
|
||||
const running = !paused && !hovering;
|
||||
|
||||
useEffect(() => {
|
||||
if (!running || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
|
||||
const timer = window.setInterval(() => setIndex((i) => (i + 1) % sets.length), INTERVAL_MS);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [running, index, sets.length]);
|
||||
|
||||
const set = sets[index];
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label="Example meal plans"
|
||||
className="relative"
|
||||
onMouseEnter={() => setHovering(true)}
|
||||
onMouseLeave={() => setHovering(false)}
|
||||
onFocus={() => setHovering(true)}
|
||||
onBlur={() => setHovering(false)}
|
||||
>
|
||||
{/* Architectural editorial frame — crisp 1px border & tactile offset shadow instead of blurred glow blobs */}
|
||||
<div className="rounded-xl border border-line-strong bg-surface shadow-[6px_6px_0_0_var(--color-line)]">
|
||||
{/* Top progress rule */}
|
||||
<div className="h-1 overflow-hidden rounded-t-xl bg-raised">
|
||||
<div
|
||||
key={index}
|
||||
className="h-full origin-left animate-preview-timer bg-brand-600"
|
||||
style={{ animationDuration: `${INTERVAL_MS}ms`, animationPlayState: running ? "running" : "paused" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="p-5 sm:p-6">
|
||||
<div key={set.theme} className="animate-fade-in">
|
||||
<div className="flex items-baseline justify-between gap-3 border-b border-line pb-3">
|
||||
<div>
|
||||
<p className="font-mono text-[11px] tracking-wider text-ink-faint uppercase">Sample menu · 2 people</p>
|
||||
<h2 className="mt-0.5 font-serif text-xl font-semibold tracking-tight text-ink">{set.theme}</h2>
|
||||
</div>
|
||||
<span className="rounded-md border border-line bg-raised px-2.5 py-1 text-xs font-medium text-ink-muted">
|
||||
{set.blurb}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ul className="divide-y divide-line">
|
||||
{set.meals.map((m) => (
|
||||
<li key={m.slot} className="flex items-baseline justify-between gap-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<p className="font-mono text-[11px] tracking-wide text-ink-faint uppercase">{m.slot}</p>
|
||||
<p className="truncate font-medium text-ink">{m.title}</p>
|
||||
</div>
|
||||
<p className="shrink-0 font-mono text-xs text-ink-muted tabular-nums">{m.cost}/meal</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{/* Receipt tear-off footer */}
|
||||
<div className="receipt-rule mt-2 pt-3.5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 rounded-lg bg-raised px-3.5 py-2.5 text-xs">
|
||||
<span className="inline-flex items-center gap-1.5 font-medium text-status-ok">
|
||||
<Repeat2 className="size-3.5 shrink-0" strokeWidth={2} aria-hidden />
|
||||
{set.sharedItems} shared store items
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5 font-mono font-semibold text-ink tabular-nums">
|
||||
<ShoppingBag className="size-3.5 shrink-0 text-ink-muted" strokeWidth={1.75} aria-hidden />
|
||||
{set.itemCount} pkgs · {set.total}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Accessible 40x40px pagination & playback controls */}
|
||||
<div className="mt-3 flex items-center justify-between border-t border-line pt-2">
|
||||
<div className="flex items-center">
|
||||
{sets.map((s, i) => (
|
||||
<button
|
||||
key={s.theme}
|
||||
type="button"
|
||||
onClick={() => setIndex(i)}
|
||||
aria-label={`Show example: ${s.theme}`}
|
||||
aria-current={i === index}
|
||||
className="grid size-9 place-items-center rounded-md hover:bg-raised"
|
||||
>
|
||||
<span
|
||||
className={`block h-2 rounded-full transition-all ${
|
||||
i === index ? "w-5 bg-brand-600" : "w-2 bg-line-strong"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPaused((p) => !p)}
|
||||
className="trim-cap inline-flex min-h-9 items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium text-ink-muted transition-colors hover:bg-raised hover:text-ink"
|
||||
>
|
||||
{paused ? <Play className="size-3.5" aria-hidden /> : <Pause className="size-3.5" aria-hidden />}
|
||||
{paused ? "Play" : "Pause"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
109
app/components/SiteHeader.tsx
Normal file
109
app/components/SiteHeader.tsx
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import { BookHeart, BookOpen, CalendarRange, UtensilsCrossed } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import type { ReactNode } from "react";
|
||||
import { ThemeToggle } from "./ThemeToggle";
|
||||
|
||||
export type ActiveNav = "home" | "recipes" | "caydens-corner" | "plan";
|
||||
|
||||
export function Logo({ small = false, subtitle }: { small?: boolean; subtitle?: ReactNode }) {
|
||||
return (
|
||||
<div className="flex shrink-0 items-center gap-2.5">
|
||||
<Link
|
||||
href="/"
|
||||
aria-label="Plan my Meals home"
|
||||
className={`grid shrink-0 place-items-center rounded-lg border border-brand-border bg-brand-950 text-brand-300 transition-colors hover:bg-selected ${
|
||||
small ? "size-7" : "size-9"
|
||||
}`}
|
||||
>
|
||||
<UtensilsCrossed className={small ? "size-3.5" : "size-4"} strokeWidth={2} aria-hidden />
|
||||
</Link>
|
||||
<div className="min-w-0">
|
||||
<Link
|
||||
href="/"
|
||||
className={`block font-serif leading-tight font-semibold tracking-tight whitespace-nowrap hover:text-brand-300 ${
|
||||
small ? "text-sm text-ink-muted" : "text-lg text-ink"
|
||||
}`}
|
||||
>
|
||||
Plan my Meals
|
||||
</Link>
|
||||
<div className="flex items-center gap-1.5 text-xs leading-tight text-ink-faint whitespace-nowrap">
|
||||
<span>
|
||||
by{" "}
|
||||
<a
|
||||
href="https://caydens.dev"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium text-ink-muted transition-colors hover:text-brand-300 hover:underline"
|
||||
>
|
||||
Cayden Codes
|
||||
</a>
|
||||
</span>
|
||||
{subtitle && (
|
||||
<>
|
||||
<span aria-hidden>·</span>
|
||||
{subtitle}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SiteHeaderProps {
|
||||
active?: ActiveNav;
|
||||
subtitle?: ReactNode;
|
||||
rightSlot?: ReactNode;
|
||||
}
|
||||
|
||||
export function SiteHeader({ active = "home", subtitle, rightSlot }: SiteHeaderProps) {
|
||||
const linkClass = (isCurrent: boolean) =>
|
||||
`trim-cap inline-flex min-h-10 items-center gap-1.5 rounded-lg px-2.5 py-2 text-sm font-medium transition-colors sm:px-3 ${
|
||||
isCurrent ? "bg-selected text-ink" : "text-ink-muted hover:bg-raised hover:text-ink"
|
||||
}`;
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-30 border-b border-line bg-surface/90 backdrop-blur-md">
|
||||
<div className="mx-auto flex max-w-7xl items-center gap-2 px-4 py-2.5 sm:gap-4 sm:px-6">
|
||||
<Logo subtitle={subtitle} />
|
||||
|
||||
<nav aria-label="Primary" className="ml-auto flex items-center gap-1 whitespace-nowrap">
|
||||
<Link
|
||||
href="/recipes"
|
||||
aria-current={active === "recipes" ? "page" : undefined}
|
||||
className={linkClass(active === "recipes")}
|
||||
title="Recipe library"
|
||||
>
|
||||
<BookOpen className="size-4 shrink-0" strokeWidth={1.75} aria-hidden />
|
||||
<span className="sr-only md:not-sr-only">Recipes</span>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/caydens-corner"
|
||||
aria-current={active === "caydens-corner" ? "page" : undefined}
|
||||
className={linkClass(active === "caydens-corner")}
|
||||
title="Cayden's Corner"
|
||||
>
|
||||
<BookHeart className="size-4 shrink-0" strokeWidth={1.75} aria-hidden />
|
||||
<span className="sr-only md:not-sr-only">Cayden's Corner</span>
|
||||
</Link>
|
||||
|
||||
{active !== "plan" && (
|
||||
<Link
|
||||
href="/plan"
|
||||
className="trim-cap ml-1 inline-flex min-h-10 items-center gap-1.5 rounded-lg bg-brand-600 px-3 py-2 text-sm font-semibold text-on-accent shadow-2xs transition-colors hover:bg-brand-500 sm:px-4"
|
||||
>
|
||||
<CalendarRange className="size-4 shrink-0" strokeWidth={2} aria-hidden />
|
||||
<span className="sm:hidden">Planner</span>
|
||||
<span className="hidden sm:inline">Open planner</span>
|
||||
</Link>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{rightSlot}
|
||||
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
53
app/components/ThemeToggle.tsx
Normal file
53
app/components/ThemeToggle.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
"use client";
|
||||
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
const STORAGE_KEY = "plan-my-meals:theme";
|
||||
const EVENT_NAME = "plan-my-meals:theme-change";
|
||||
|
||||
type ResolvedTheme = "light" | "dark";
|
||||
|
||||
function getSnapshot(): ResolvedTheme {
|
||||
const explicit = document.documentElement.dataset.theme;
|
||||
if (explicit === "light" || explicit === "dark") return explicit;
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
}
|
||||
|
||||
function subscribe(callback: () => void): () => void {
|
||||
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
mq.addEventListener("change", callback);
|
||||
window.addEventListener(EVENT_NAME, callback);
|
||||
return () => {
|
||||
mq.removeEventListener("change", callback);
|
||||
window.removeEventListener(EVENT_NAME, callback);
|
||||
};
|
||||
}
|
||||
|
||||
export function ThemeToggle() {
|
||||
// Server snapshot returns null so hydration never mismatches; button renders a stable shell first.
|
||||
const theme = useSyncExternalStore<ResolvedTheme | null>(subscribe, getSnapshot, () => null);
|
||||
|
||||
function toggleTheme() {
|
||||
const next: ResolvedTheme = (theme ?? getSnapshot()) === "dark" ? "light" : "dark";
|
||||
document.documentElement.dataset.theme = next;
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, next);
|
||||
} catch {
|
||||
// Ignore blocked storage; dataset.theme still updates the active session.
|
||||
}
|
||||
window.dispatchEvent(new Event(EVENT_NAME));
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleTheme}
|
||||
aria-label={theme === "dark" ? "Switch to light paper theme" : "Switch to dark slate theme"}
|
||||
title={theme === "dark" ? "Switch to light mode" : "Switch to dark mode"}
|
||||
className="grid size-10 shrink-0 place-items-center rounded-lg border border-line bg-surface text-ink-muted transition-colors hover:border-line-strong hover:bg-raised hover:text-ink"
|
||||
>
|
||||
{theme === "dark" ? <Sun className="size-4" strokeWidth={1.75} aria-hidden /> : <Moon className="size-4" strokeWidth={1.75} aria-hidden />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue