Files
Epicure/apps/web/components/recipe/recipes-header.tsx
T
Arnaud 2682eba2be fix: presigned upload URLs, card badge alignment, filter menu UX, floating save
- Presigned upload URLs were signed against STORAGE_ENDPOINT (the
  internal docker hostname, e.g. http://minio:9000) instead of a
  browser-reachable URL — uploads/edits with photos were broken in
  prod. Now signed with a separate client bound to STORAGE_PUBLIC_URL,
  which also needed threading through as a Docker build arg (CSP
  headers are computed at build time) and into compose.prod.yml/
  .env.production (gitignored, not committed).
- recipe-form.tsx used the wrong i18n namespace for the visibility
  label (t("recipeForm") instead of t_recipe("recipe")), causing
  MISSING_MESSAGE in French.
- Compact recipe-list view: batch-cook rows showed no dish count, and
  the date/difficulty columns shifted per-row depending on whether a
  difficulty badge was present — reserved a fixed-width slot for it.
- All three card icon badges (batch-cook, favorite, visibility) now
  share the same muted color instead of the batch badge standing out.
- Recipes filter dropdown: clicking a filter option closed the whole
  menu, and the tag text input couldn't be typed into (the menu's
  roving-focus/type-ahead handling was intercepting keystrokes) — menu
  items now stay open on click, and the tag input stops event
  propagation so it behaves like a normal text field.
- Recipe form: added a floating save/cancel bar so long recipes don't
  require scrolling back down to save.

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

295 lines
11 KiB
TypeScript

"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) => {
// Stop the dropdown menu's own keydown handling (roving focus / type-ahead
// search) from intercepting keystrokes meant for this plain text input.
e.stopPropagation();
if (e.key === "Enter") onChange(local);
}}
onClick={(e) => e.stopPropagation()}
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_KEYS: Record<string, string> = {
updated_desc: "sortRecentlyUpdated",
updated_asc: "sortOldestUpdated",
created_desc: "sortNewestFirst",
created_asc: "sortOldestFirst",
title_asc: "sortTitleAZ",
title_desc: "sortTitleZA",
};
const VISIBILITY_KEYS: Record<string, string> = {
"": "all",
private: "private",
unlisted: "unlisted",
public: "public",
};
const DIFFICULTY_KEYS: Record<string, string> = {
"": "anyDifficulty",
easy: "easy",
medium: "medium",
hard: "hard",
};
const BATCH_COOK_KEYS: Record<string, string> = {
"": "all",
"1": "batchCookOnly",
"0": "batchCookExclude",
};
export function RecipesHeader({
count,
initialQuery = "",
initialSort = "updated_desc",
initialVisibility = "",
initialDifficulty = "",
initialTag = "",
initialBatchCook = "",
}: {
count: number;
initialQuery?: string;
initialSort?: string;
initialVisibility?: string;
initialDifficulty?: string;
initialTag?: string;
initialBatchCook?: string;
}) {
const router = useRouter();
const pathname = usePathname();
const t = useTranslations("recipes");
const tRecipe = useTranslations("recipe");
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,
batchCook: initialBatchCook,
...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());
if (current.batchCook) params.set("batchCook", current.batchCook);
startTransition(() => router.push(`${pathname}?${params.toString()}`));
}
function handleSearch(value: string) {
setQuery(value);
pushParams({ q: value });
}
const activeFilterCount = [initialVisibility, initialDifficulty, initialTag, initialBatchCook].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 size="sm" className="gap-1.5" onClick={() => setAiOpen(true)}>
<Sparkles className="h-4 w-4" />
{t("generate")}
</Button>
<Button variant="outline" size="sm" className="gap-1.5" onClick={() => setUrlOpen(true)}>
<Link2 className="h-4 w-4" />
{t("importUrl")}
</Button>
<Link href="/recipes/new" className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "gap-1.5")}>
<PlusCircle className="h-4 w-4" />
{t("new")}
</Link>
</div>
</div>
<div className="flex flex-wrap gap-2">
{/* Search */}
<div className="relative w-full sm:flex-1 sm:min-w-[200px] sm: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_KEYS[initialSort] ? t(SORT_KEYS[initialSort]) : t("sort")}
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuGroup>
<DropdownMenuLabel>{t("sortBy")}</DropdownMenuLabel>
<DropdownMenuSeparator />
{Object.entries(SORT_KEYS).map(([value, key]) => (
<DropdownMenuItem
key={value}
onClick={() => pushParams({ sort: value })}
className={cn(initialSort === value && "font-medium text-primary")}
>
{t(key)}
</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" />
{t("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>{t("visibilityLabel")}</DropdownMenuLabel>
{Object.entries(VISIBILITY_KEYS).map(([value, key]) => (
<DropdownMenuItem
key={value}
closeOnClick={false}
onClick={() => pushParams({ visibility: value })}
className={cn(initialVisibility === value && "font-medium text-primary")}
>
{value === "" ? t(key) : tRecipe(`visibility.${key}`)}
</DropdownMenuItem>
))}
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel>{t("difficultyLabel")}</DropdownMenuLabel>
{Object.entries(DIFFICULTY_KEYS).map(([value, key]) => (
<DropdownMenuItem
key={value}
closeOnClick={false}
onClick={() => pushParams({ difficulty: value })}
className={cn(initialDifficulty === value && "font-medium text-primary")}
>
{value === "" ? t(key) : tRecipe(`difficulty.${key}`)}
</DropdownMenuItem>
))}
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel>{t("tagLabel")}</DropdownMenuLabel>
<div className="px-2 py-1">
<TagFilterInput
value={initialTag}
onChange={(v) => pushParams({ tag: v })}
/>
</div>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel>{t("batchCookLabel")}</DropdownMenuLabel>
{Object.entries(BATCH_COOK_KEYS).map(([value, key]) => (
<DropdownMenuItem
key={value}
closeOnClick={false}
onClick={() => pushParams({ batchCook: value })}
className={cn(initialBatchCook === value && "font-medium text-primary")}
>
{t(key)}
</DropdownMenuItem>
))}
</DropdownMenuGroup>
{activeFilterCount > 0 && (
<>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem
onClick={() => pushParams({ visibility: "", difficulty: "", tag: "", batchCook: "" })}
className="text-muted-foreground"
>
<X className="h-3 w-3 mr-1.5" /> {t("clearFilters")}
</DropdownMenuItem>
</DropdownMenuGroup>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<AiGenerateDialog open={aiOpen} onOpenChange={setAiOpen} />
<UrlImportDialog open={urlOpen} onOpenChange={setUrlOpen} />
</>
);
}