6f11dc07fd
Add recipes.recipeType enum (dish|drink, default dish). Recipe form now shows a Type selector and hides food-only fields for drinks (prep/cook time, difficulty, batch-cook toggle) instead of forcing every cocktail through meal-oriented UI. Detail page skips the meal/drink pairing suggestion buttons on drink recipes (pairing a drink with a drink doesn't make sense). Recipes list gains a Type filter facet alongside difficulty/batch-cook. Scoped deliberately narrow per investigation: no ABV/glass-type/ garnish structured fields, no meal-plan schema changes, and the existing AI drink-pairing-suggestion feature (ephemeral, never saved as a recipe) is left as-is rather than wired into recipe creation — those suggestions are wine/beer/spirit recommendations without ingredients/steps, not really recipes to save. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
320 lines
12 KiB
TypeScript
320 lines
12 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",
|
|
};
|
|
|
|
const RECIPE_TYPE_KEYS: Record<string, string> = {
|
|
"": "all",
|
|
dish: "dish",
|
|
drink: "drink",
|
|
};
|
|
|
|
export function RecipesHeader({
|
|
count,
|
|
initialQuery = "",
|
|
initialSort = "updated_desc",
|
|
initialVisibility = "",
|
|
initialDifficulty = "",
|
|
initialTag = "",
|
|
initialBatchCook = "",
|
|
initialRecipeType = "",
|
|
}: {
|
|
count: number;
|
|
initialQuery?: string;
|
|
initialSort?: string;
|
|
initialVisibility?: string;
|
|
initialDifficulty?: string;
|
|
initialTag?: string;
|
|
initialBatchCook?: string;
|
|
initialRecipeType?: 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,
|
|
recipeType: initialRecipeType,
|
|
...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);
|
|
if (current.recipeType) params.set("recipeType", current.recipeType);
|
|
startTransition(() => router.push(`${pathname}?${params.toString()}`));
|
|
}
|
|
|
|
function handleSearch(value: string) {
|
|
setQuery(value);
|
|
pushParams({ q: value });
|
|
}
|
|
|
|
const activeFilterCount = [initialVisibility, initialDifficulty, initialTag, initialBatchCook, initialRecipeType].filter(Boolean).length;
|
|
const sortChanged = initialSort !== "updated_desc";
|
|
|
|
return (
|
|
<>
|
|
<div className="space-y-3 sm:space-y-4">
|
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
|
|
<div>
|
|
<h1 className="text-2xl sm:text-3xl font-bold tracking-tight">{t("title")}</h1>
|
|
<p className="text-muted-foreground text-sm sm:text-base mt-0.5 sm: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" />
|
|
<span className="hidden sm:inline">{t("new")}</span>
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap gap-2">
|
|
{/* Search — shares the row with Sort/Filter even on mobile now that
|
|
those are icon-only there, instead of forcing a whole row to itself. */}
|
|
<div className="relative flex-1 min-w-[120px] 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 shrink-0"
|
|
)}
|
|
>
|
|
<ArrowUpDown className="h-4 w-4" />
|
|
<span className="hidden sm:inline">{SORT_KEYS[initialSort] ? t(SORT_KEYS[initialSort]) : t("sort")}</span>
|
|
</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 shrink-0"
|
|
)}
|
|
>
|
|
<SlidersHorizontal className="h-4 w-4" />
|
|
<span className="hidden sm:inline">{t("filter")}</span>
|
|
{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>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuGroup>
|
|
<DropdownMenuLabel>{t("recipeTypeLabel")}</DropdownMenuLabel>
|
|
{Object.entries(RECIPE_TYPE_KEYS).map(([value, key]) => (
|
|
<DropdownMenuItem
|
|
key={value}
|
|
closeOnClick={false}
|
|
onClick={() => pushParams({ recipeType: value })}
|
|
className={cn(initialRecipeType === value && "font-medium text-primary")}
|
|
>
|
|
{value === "" ? t(key) : tRecipe(`recipeType.${key}`)}
|
|
</DropdownMenuItem>
|
|
))}
|
|
</DropdownMenuGroup>
|
|
{activeFilterCount > 0 && (
|
|
<>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuGroup>
|
|
<DropdownMenuItem
|
|
onClick={() => pushParams({ visibility: "", difficulty: "", tag: "", batchCook: "", recipeType: "" })}
|
|
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} />
|
|
</>
|
|
);
|
|
}
|