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