257908da7f
Search bar forced its own full-width row (w-full), pushing Sort/Filter to a second row below it — on mobile that's title + action buttons + search row + sort/filter row + toolbar row before any recipe appears. Sort/Filter now go icon-only below sm: (text labels hidden, matching the convention already used for compact-view badges), letting Search share one row with both instead of forcing its own. Also tightened the title size and inter-section spacing on mobile (unchanged at sm: and up). Verified at a real 390px mobile viewport: first recipe card now clears with room to spare, versus barely visible at the same viewport height before. Desktop screenshot confirms zero visual change there.
296 lines
11 KiB
TypeScript
296 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-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" />
|
|
{t("new")}
|
|
</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>
|
|
{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} />
|
|
</>
|
|
);
|
|
}
|