feat: collections overhaul — reorder, search, edit/delete, tags (v0.53.0)
Seven related improvements to collections:
- Drag-and-drop reorder (dnd-kit, same pattern as the shopping list) — new
collection_recipes.position column (migration 0049, backfilled from
existing added_at order so nothing jumps around on upgrade).
- Search collections by name/description (server-side, list page) and
search recipes within a collection (client-side filter, already loaded).
- Edit collection: name/description/tags/private notes via a new dialog;
new collections.notes + collections.tags columns.
- Delete collection with a choice to also delete its recipes — only ones
the deleting user actually owns, never recipes shared in by others.
- Collection detail (both owner and public view) now renders the same
RecipeGridCard used on /recipes, instead of the older, plainer RecipeCard.
- Collection list cards redesigned — photo-collage preview (first 4 recipe
covers/placeholders), tag badges, cleaner layout.
- Fixed the recipe count shown on a collection card: the query capped the
`recipes` relation at 1 for thumbnail purposes and then read `.length`
off that same capped array, so it never showed more than 1. Now a
proper grouped count query, separate from the thumbnail fetch.
New/changed endpoints documented in OpenAPI: PATCH /collections/{id}/reorder,
DELETE /collections/{id}?deleteRecipes, PUT /collections/{id}'s new
notes/tags fields.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,27 +1,86 @@
|
||||
"use client";
|
||||
import { useState, useTransition } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter, usePathname } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { FolderOpen, Flame } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { FolderOpen, Flame, Search } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { NewCollectionButton } from "@/components/social/new-collection-button";
|
||||
import { EmptyState } from "@/components/shared/empty-state";
|
||||
import { RecipeCoverPlaceholder } from "@/components/recipe/recipe-cover-placeholder";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Thumbnail = {
|
||||
recipeId: string;
|
||||
recipeType?: "dish" | "drink";
|
||||
coverIcon: string | null;
|
||||
coverColor: string | null;
|
||||
photoUrl: string | null;
|
||||
};
|
||||
|
||||
type Collection = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
tags: string[];
|
||||
isPublic: boolean;
|
||||
recipeCount: number;
|
||||
thumbnails: Thumbnail[];
|
||||
};
|
||||
|
||||
type Props = {
|
||||
collections: Collection[];
|
||||
query: string;
|
||||
};
|
||||
|
||||
export function CollectionsPageContent({ collections }: Props) {
|
||||
function CollectionThumbCollage({ thumbnails }: { thumbnails: Thumbnail[] }) {
|
||||
if (thumbnails.length === 0) {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center bg-muted text-muted-foreground">
|
||||
<FolderOpen className="h-8 w-8" strokeWidth={1.5} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 grid-rows-2 gap-0.5 w-full h-full">
|
||||
{Array.from({ length: 4 }).map((_, i) => {
|
||||
const thumb = thumbnails[i];
|
||||
return (
|
||||
<div key={i} className="relative overflow-hidden bg-muted">
|
||||
{thumb ? (
|
||||
thumb.photoUrl ? (
|
||||
<Image src={thumb.photoUrl} unoptimized alt="" fill className="object-cover" />
|
||||
) : (
|
||||
<RecipeCoverPlaceholder
|
||||
recipe={{ id: thumb.recipeId, recipeType: thumb.recipeType, coverIcon: thumb.coverIcon, coverColor: thumb.coverColor }}
|
||||
iconClassName="h-5 w-5"
|
||||
/>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CollectionsPageContent({ collections, query }: Props) {
|
||||
const t = useTranslations("collections");
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const [search, setSearch] = useState(query);
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
function handleSearch(value: string) {
|
||||
setSearch(value);
|
||||
const params = new URLSearchParams();
|
||||
if (value.trim()) params.set("q", value.trim());
|
||||
startTransition(() => router.push(`${pathname}?${params.toString()}`));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -39,25 +98,51 @@ export function CollectionsPageContent({ collections }: Props) {
|
||||
</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={search}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
placeholder={t("searchPlaceholder")}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{collections.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={FolderOpen}
|
||||
title={t("empty")}
|
||||
description={t("emptyDescription")}
|
||||
actionSlot={<NewCollectionButton />}
|
||||
title={query ? t("noSearchResults") : t("empty")}
|
||||
description={query ? undefined : t("emptyDescription")}
|
||||
actionSlot={!query ? <NewCollectionButton /> : undefined}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
|
||||
{collections.map((col) => (
|
||||
<Link key={col.id} href={`/collections/${col.id}`} className="group rounded-xl border p-4 hover:shadow-sm transition-shadow space-y-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h2 className="font-semibold group-hover:text-primary transition-colors line-clamp-1">{col.name}</h2>
|
||||
{col.isPublic && <Badge variant="secondary" className="text-xs shrink-0">{t("public")}</Badge>}
|
||||
<Link
|
||||
key={col.id}
|
||||
href={`/collections/${col.id}`}
|
||||
className="group rounded-xl border overflow-hidden hover:shadow-md hover:border-muted-foreground/30 transition-all duration-200"
|
||||
>
|
||||
<div className="aspect-[2/1]">
|
||||
<CollectionThumbCollage thumbnails={col.thumbnails} />
|
||||
</div>
|
||||
<div className="p-4 space-y-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h2 className="font-semibold group-hover:text-primary transition-colors line-clamp-1">{col.name}</h2>
|
||||
{col.isPublic && <Badge variant="secondary" className="text-xs shrink-0">{t("public")}</Badge>}
|
||||
</div>
|
||||
{col.description && <p className="text-sm text-muted-foreground line-clamp-2">{col.description}</p>}
|
||||
{col.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{col.tags.slice(0, 4).map((tag) => (
|
||||
<Badge key={tag} variant="outline" className="text-xs">{tag}</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{col.recipeCount !== 1 ? t("recipeCountPlural", { count: col.recipeCount }) : t("recipeCount", { count: col.recipeCount })}
|
||||
</p>
|
||||
</div>
|
||||
{col.description && <p className="text-sm text-muted-foreground line-clamp-2">{col.description}</p>}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{col.recipeCount !== 1 ? t("recipeCountPlural", { count: col.recipeCount }) : t("recipeCount", { count: col.recipeCount })}
|
||||
</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user