planmymeal/app/components/RecipeBrowser.tsx
Cayden Schwechten df5dd3a70e Move UI to BoardUI with slate + salmon theme; add PDF plan export
- Adopt BoardUI (Pro init): theme tokens, Inter, .dark mode, Button,
  SegmentedControl, Meter, Skeleton and ThemeToggle; remap the app's
  color aliases onto BoardUI tokens and swap lucide for Remix icons
- Retint BoardUI's neutral ramp to cool slate with a salmon accent;
  drop the dotted background; raise muted text contrast
- Simplify the landing page and remove the overlap explorer
- Planner: explicit "I have this" toggle on grocery lines, pantry panel
  above the menu with the staples list spelled out, sticky aisle
  header fix, and a "Download PDF" plan export (jsPDF, lazy-loaded)
- Recipes: replace the family cabbage soup with Cade's Cabbage Soup
  (adds Bone-In Ham Steak to the catalog); remove Ham, Bean & Cabbage Soup
- Remove unused BoardUI components, dependencies and exports

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 16:36:33 -06:00

231 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { RiHeart3Line as BookHeart, RiSearchLine as Search, RiEqualizerLine as SlidersHorizontal } from "@remixicon/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-3xl border border-border-button-default 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-3xl border border-border-button-default 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>
);
}