planmymeal/app/components/RecipeBrowser.tsx
Cayden Schwechten c19b910c36 init
2026-10-31 20:44:29 -05:00

231 lines
9.9 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 { 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>
);
}