Files
Epicure/apps/web/components/recipe/recipes-header.tsx
T
Arnaud 01fdbb880b feat(i18n): translate hardcoded strings across print, public recipe, and recipe management UI
Wires dozens of components and server pages to next-intl instead of hardcoded
English text, and adds a lightweight getMessages/formatMessage helper for
server components (print pages, public recipe page, cook metadata) since
next-intl/server isn't wired into this app's routing. Backfills missing
en/fr message keys that existing code already referenced but fr.json lacked.
2026-07-02 07:58:18 +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 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="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} />
</>
);
}