feat(recipes): full recipe CRUD with photos, print, version history

List, detail, new, edit pages. Server-side pagination, dietary tags, difficulty.
Photo upload to S3-compatible storage. Version history. Multi-select grid with
bulk delete/visibility. Print view. Delete confirmation dialog.
This commit is contained in:
Arnaud
2026-07-01 08:10:11 +02:00
parent fa2d797918
commit 84d6cfeb07
45 changed files with 5300 additions and 0 deletions
@@ -0,0 +1,84 @@
"use client";
import { useState, useTransition } from "react";
import Link from "next/link";
import { useRouter, usePathname } from "next/navigation";
import { PlusCircle, Sparkles, Link2, Search, X } from "lucide-react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { buttonVariants } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { AiGenerateDialog } from "./ai-generate-dialog";
import { UrlImportDialog } from "./url-import-dialog";
import { cn } from "@/lib/utils";
export function RecipesHeader({ count, initialQuery = "" }: { count: number; initialQuery?: 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 handleSearch(value: string) {
setQuery(value);
startTransition(() => {
const params = new URLSearchParams();
if (value.trim()) params.set("q", value.trim());
router.push(`${pathname}?${params.toString()}`);
});
}
return (
<>
<div className="space-y-4">
<div className="flex items-center 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 items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setUrlOpen(true)}>
<Link2 className="h-4 w-4" />
{t("importUrl")}
</Button>
<Button variant="outline" 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="relative 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>
</div>
<AiGenerateDialog open={aiOpen} onOpenChange={setAiOpen} />
<UrlImportDialog open={urlOpen} onOpenChange={setUrlOpen} />
</>
);
}