231 lines
9.9 KiB
TypeScript
231 lines
9.9 KiB
TypeScript
"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>
|
||
);
|
||
}
|