Files
Epicure/apps/web/components/recipe/recipes-header.tsx
T
Arnaud e5d1080fb9 feat: implement remaining TODO.md feature ideas + fix mobile headers
Implements the six previously-unscoped feature ideas plus a mobile
layout fix reported via screenshot:

- Mobile: Recipes/Collections/Pantry/Meal Plan/Shopping Lists headers
  now stack and wrap instead of clipping buttons on narrow viewports.
- Recipe diff/compare view: word/list diff against any past version,
  next to Restore in version history.
- Shared meal plans & shopping lists: new shoppingListMembers/
  mealPlanMembers tables (viewer/editor roles, mirrors
  collectionMembers), share dialogs, membership-checked routes.
- PDF cookbook export: /print/collection/[id] renders a whole
  collection with page breaks, using the existing print-CSS pattern
  instead of adding a PDF rendering dependency.
- Grocery delivery handoff: shopping lists can copy-as-text (works
  today) or send to Instacart once INSTACART_API_KEY is configured
  (stub adapter — real API needs a partner agreement).
- Personalized "For You" feed tab: ranks public recipes by tag/
  dietary overlap with the user's favorited/highly-rated history.
- PWA: added manifest.json + icons on top of the existing service
  worker so the app is installable; cook-mode pages were already
  cached for offline use.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-02 12:13:00 +02:00

262 lines
9.2 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 { useState, useTransition } from "react";
import Link from "next/link";
import { useRouter, usePathname } from "next/navigation";
import { PlusCircle, Sparkles, Link2, Search, X, SlidersHorizontal, ArrowUpDown } from "lucide-react";
import { useTranslations } from "next-intl";
import { Button, buttonVariants } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { AiGenerateDialog } from "./ai-generate-dialog";
import { UrlImportDialog } from "./url-import-dialog";
function TagFilterInput({ value, onChange }: { value: string; onChange: (v: string) => void }) {
const [local, setLocal] = useState(value);
const t = useTranslations("recipes");
return (
<input
value={local}
onChange={(e) => setLocal(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") onChange(local); }}
onBlur={() => onChange(local)}
placeholder={t("filterByTag")}
className="w-full h-7 px-2 text-sm rounded-md border border-input bg-background focus:outline-none focus:ring-1 focus:ring-ring"
/>
);
}
import { cn } from "@/lib/utils";
const SORT_LABELS: Record<string, string> = {
updated_desc: "Recently updated",
updated_asc: "Oldest updated",
created_desc: "Newest first",
created_asc: "Oldest first",
title_asc: "Title AZ",
title_desc: "Title ZA",
};
const VISIBILITY_LABELS: Record<string, string> = {
"": "All",
private: "Private",
unlisted: "Unlisted",
public: "Public",
};
const DIFFICULTY_LABELS: Record<string, string> = {
"": "Any difficulty",
easy: "Easy",
medium: "Medium",
hard: "Hard",
};
export function RecipesHeader({
count,
initialQuery = "",
initialSort = "updated_desc",
initialVisibility = "",
initialDifficulty = "",
initialTag = "",
}: {
count: number;
initialQuery?: string;
initialSort?: string;
initialVisibility?: string;
initialDifficulty?: string;
initialTag?: string;
}) {
const router = useRouter();
const pathname = usePathname();
const t = useTranslations("recipes");
const [aiOpen, setAiOpen] = useState(false);
const [urlOpen, setUrlOpen] = useState(false);
const [query, setQuery] = useState(initialQuery);
const [, startTransition] = useTransition();
function pushParams(overrides: Record<string, string>) {
const params = new URLSearchParams();
const current = {
q: query,
sort: initialSort,
visibility: initialVisibility,
difficulty: initialDifficulty,
tag: initialTag,
...overrides,
};
if (current.q?.trim()) params.set("q", current.q.trim());
if (current.sort && current.sort !== "updated_desc") params.set("sort", current.sort);
if (current.visibility) params.set("visibility", current.visibility);
if (current.difficulty) params.set("difficulty", current.difficulty);
if (current.tag?.trim()) params.set("tag", current.tag.trim());
startTransition(() => router.push(`${pathname}?${params.toString()}`));
}
function handleSearch(value: string) {
setQuery(value);
pushParams({ q: value });
}
const activeFilterCount = [initialVisibility, initialDifficulty, initialTag].filter(Boolean).length;
const sortChanged = initialSort !== "updated_desc";
return (
<>
<div className="space-y-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">{t("title")}</h1>
<p className="text-muted-foreground mt-1">
{count === 0
? t("subtitle")
: t(count !== 1 ? "resultPlural" : "resultSingular", { count })}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button variant="ghost" size="sm" onClick={() => setUrlOpen(true)}>
<Link2 className="h-4 w-4" />
{t("importUrl")}
</Button>
<Button variant="ghost" size="sm" onClick={() => setAiOpen(true)}>
<Sparkles className="h-4 w-4" />
{t("generate")}
</Button>
<Link href="/recipes/new" className={cn(buttonVariants({ size: "sm" }))}>
<PlusCircle className="h-4 w-4" />
{t("new")}
</Link>
</div>
</div>
<div className="flex flex-wrap gap-2">
{/* Search */}
<div className="relative flex-1 min-w-[200px] max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
<Input
value={query}
onChange={(e) => handleSearch(e.target.value)}
placeholder={t("search")}
className="pl-9 pr-9"
/>
{query && (
<button
onClick={() => handleSearch("")}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
)}
</div>
{/* Sort */}
<DropdownMenu>
<DropdownMenuTrigger
className={cn(
buttonVariants({ variant: sortChanged ? "secondary" : "outline", size: "sm" }),
"gap-1.5"
)}
>
<ArrowUpDown className="h-4 w-4" />
{SORT_LABELS[initialSort] ?? "Sort"}
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuGroup>
<DropdownMenuLabel>Sort by</DropdownMenuLabel>
<DropdownMenuSeparator />
{Object.entries(SORT_LABELS).map(([value, label]) => (
<DropdownMenuItem
key={value}
onClick={() => pushParams({ sort: value })}
className={cn(initialSort === value && "font-medium text-primary")}
>
{label}
</DropdownMenuItem>
))}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
{/* Filters */}
<DropdownMenu>
<DropdownMenuTrigger
className={cn(
buttonVariants({ variant: activeFilterCount > 0 ? "secondary" : "outline", size: "sm" }),
"gap-1.5"
)}
>
<SlidersHorizontal className="h-4 w-4" />
Filter
{activeFilterCount > 0 && (
<Badge variant="secondary" className="ml-1 h-4 min-w-4 px-1 text-xs">
{activeFilterCount}
</Badge>
)}
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-48">
<DropdownMenuGroup>
<DropdownMenuLabel>Visibility</DropdownMenuLabel>
{Object.entries(VISIBILITY_LABELS).map(([value, label]) => (
<DropdownMenuItem
key={value}
onClick={() => pushParams({ visibility: value })}
className={cn(initialVisibility === value && "font-medium text-primary")}
>
{label}
</DropdownMenuItem>
))}
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel>Difficulty</DropdownMenuLabel>
{Object.entries(DIFFICULTY_LABELS).map(([value, label]) => (
<DropdownMenuItem
key={value}
onClick={() => pushParams({ difficulty: value })}
className={cn(initialDifficulty === value && "font-medium text-primary")}
>
{label}
</DropdownMenuItem>
))}
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel>Tag</DropdownMenuLabel>
<div className="px-2 py-1">
<TagFilterInput
value={initialTag}
onChange={(v) => pushParams({ tag: v })}
/>
</div>
</DropdownMenuGroup>
{activeFilterCount > 0 && (
<>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem
onClick={() => pushParams({ visibility: "", difficulty: "", tag: "" })}
className="text-muted-foreground"
>
<X className="h-3 w-3 mr-1.5" /> Clear filters
</DropdownMenuItem>
</DropdownMenuGroup>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<AiGenerateDialog open={aiOpen} onOpenChange={setAiOpen} />
<UrlImportDialog open={urlOpen} onOpenChange={setUrlOpen} />
</>
);
}