Update features and dependencies
This commit is contained in:
@@ -3,16 +3,76 @@
|
||||
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 { PlusCircle, Sparkles, Link2, Search, X, SlidersHorizontal, ArrowUpDown } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
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);
|
||||
return (
|
||||
<input
|
||||
value={local}
|
||||
onChange={(e) => setLocal(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") onChange(local); }}
|
||||
onBlur={() => onChange(local)}
|
||||
placeholder="Filter by tag…"
|
||||
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";
|
||||
|
||||
export function RecipesHeader({ count, initialQuery = "" }: { count: number; initialQuery?: string }) {
|
||||
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 A–Z",
|
||||
title_desc: "Title Z–A",
|
||||
};
|
||||
|
||||
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");
|
||||
@@ -21,15 +81,32 @@ export function RecipesHeader({ count, initialQuery = "" }: { count: number; ini
|
||||
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);
|
||||
startTransition(() => {
|
||||
const params = new URLSearchParams();
|
||||
if (value.trim()) params.set("q", value.trim());
|
||||
router.push(`${pathname}?${params.toString()}`);
|
||||
});
|
||||
pushParams({ q: value });
|
||||
}
|
||||
|
||||
const activeFilterCount = [initialVisibility, initialDifficulty, initialTag].filter(Boolean).length;
|
||||
const sortChanged = initialSort !== "updated_desc";
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
@@ -43,11 +120,11 @@ export function RecipesHeader({ count, initialQuery = "" }: { count: number; ini
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setUrlOpen(true)}>
|
||||
<Button variant="ghost" size="sm" onClick={() => setUrlOpen(true)}>
|
||||
<Link2 className="h-4 w-4" />
|
||||
{t("importUrl")}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setAiOpen(true)}>
|
||||
<Button variant="ghost" size="sm" onClick={() => setAiOpen(true)}>
|
||||
<Sparkles className="h-4 w-4" />
|
||||
{t("generate")}
|
||||
</Button>
|
||||
@@ -58,22 +135,121 @@ export function RecipesHeader({ count, initialQuery = "" }: { count: number; ini
|
||||
</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"
|
||||
<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"
|
||||
)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user