planmymeal/app/components/RecipeBrowser.tsx

232 lines
9.9 KiB
TypeScript
Raw Normal View History

2026-10-31 20:44:29 -05:00
"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>
);
}