diff --git a/CHANGELOG.md b/CHANGELOG.md index 474a8f9..dd0d5f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to Epicure are documented here. This file is mirrored in-app at `/changelog` (and in the admin dashboard) via `apps/web/lib/changelog.ts` — update both together. +## 0.53.0 — 2026-07-19 15:20 + +### Added +- Collections got a big upgrade: drag-and-drop reorder recipes, search collections and search within a collection, edit name/description/tags/private notes, delete with a choice to also delete the recipes (only ones you own), and tags/labels on collections themselves. + +### Fixed +- Collection cards showed the wrong recipe count (capped at 1) — now a real count. Cards also got a visual refresh (photo collage preview) and collection detail pages now use the same recipe card as the main Recipes page. + ## 0.52.1 — 2026-07-19 14:10 ### Fixed diff --git a/apps/web/app/(app)/collections/[id]/page.tsx b/apps/web/app/(app)/collections/[id]/page.tsx index 1e028c5..83424dc 100644 --- a/apps/web/app/(app)/collections/[id]/page.tsx +++ b/apps/web/app/(app)/collections/[id]/page.tsx @@ -2,14 +2,17 @@ import type { Metadata } from "next"; import { notFound } from "next/navigation"; import { headers } from "next/headers"; import Link from "next/link"; -import { Printer, UtensilsCrossed } from "lucide-react"; +import { Printer, UtensilsCrossed, StickyNote } from "lucide-react"; import { auth } from "@/lib/auth/server"; import { db, collections, eq, and, or } from "@epicure/db"; -import { RecipeCard } from "@/components/recipe/recipe-card"; +import { RecipeGridCard } from "@/components/recipe/recipe-grid-card"; import { CollectionRecipesGrid } from "@/components/collections/collection-recipes-grid"; import { ForkCollectionButton } from "@/components/collections/fork-collection-button"; import { ShareCollectionButton } from "@/components/collections/share-collection-button"; import { GenerateMealDialog } from "@/components/collections/generate-meal-dialog"; +import { EditCollectionDialog } from "@/components/collections/edit-collection-dialog"; +import { DeleteCollectionDialog } from "@/components/collections/delete-collection-dialog"; +import { Badge } from "@/components/ui/badge"; import { buttonVariants } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import { ExportMarkdownButton } from "@/components/shared/export-markdown-button"; @@ -32,12 +35,18 @@ export default async function CollectionPage({ params }: Params) { eq(collections.id, id), or(eq(collections.userId, session.user.id), eq(collections.isPublic, true)) ), - with: { recipes: { with: { recipe: { with: { photos: true } } } } }, + with: { + recipes: { + orderBy: (t, { asc }) => asc(t.position), + with: { recipe: { with: { photos: true } } }, + }, + }, }); if (!col) notFound(); const isOwner = col.userId === session.user.id; + const recipeList = col.recipes.flatMap((r) => (r.recipe ? [r.recipe] : [])); return (
@@ -45,12 +54,25 @@ export default async function CollectionPage({ params }: Params) {

{col.name}

{col.description &&

{col.description}

} + {col.tags.length > 0 && ( +
+ {col.tags.map((tag) => ( + {tag} + ))} +
+ )}

- {col.recipes.length} recipe{col.recipes.length !== 1 ? "s" : ""} · {col.isPublic ? "Public" : "Private"} + {recipeList.length} recipe{recipeList.length !== 1 ? "s" : ""} · {col.isPublic ? "Public" : "Private"}

+ {isOwner && col.notes && ( +
+ +

{col.notes}

+
+ )}
- {col.recipes.length > 0 && ( + {recipeList.length > 0 && ( <> @@ -60,7 +82,7 @@ export default async function CollectionPage({ params }: Params) { markdown={collectionToMarkdown({ name: col.name, description: col.description, - recipes: col.recipes.flatMap((r) => (r.recipe ? [r.recipe] : [])), + recipes: recipeList, })} filename={col.name} /> @@ -68,23 +90,33 @@ export default async function CollectionPage({ params }: Params) { )} {isOwner && } {isOwner && } + {isOwner && ( + + )} + {isOwner && } {!isOwner && col.isPublic && ( )}
- {col.recipes.length === 0 ? ( + {recipeList.length === 0 ? ( ) : isOwner ? ( - (r.recipe ? [r.recipe] : []))} - /> + ) : (
- {col.recipes.map(({ recipe }) => ( - recipe && + {recipeList.map((recipe) => ( + + + ))}
)} diff --git a/apps/web/app/(app)/collections/page.tsx b/apps/web/app/(app)/collections/page.tsx index ff744e3..3248239 100644 --- a/apps/web/app/(app)/collections/page.tsx +++ b/apps/web/app/(app)/collections/page.tsx @@ -1,29 +1,73 @@ import type { Metadata } from "next"; import { headers } from "next/headers"; import { auth } from "@/lib/auth/server"; -import { db, collections, eq, desc } from "@epicure/db"; +import { db, collections, collectionRecipes, eq, and, or, ilike, sql } from "@epicure/db"; import { CollectionsPageContent } from "@/components/collections/collections-page-content"; +import { getPublicUrl } from "@/lib/storage"; export const metadata: Metadata = {}; -export default async function CollectionsPage() { +export default async function CollectionsPage({ + searchParams, +}: { + searchParams: Promise<{ q?: string }>; +}) { const session = await auth.api.getSession({ headers: await headers() }); if (!session) return null; - const userCollections = await db.query.collections.findMany({ - where: eq(collections.userId, session.user.id), - orderBy: desc(collections.updatedAt), - with: { recipes: { limit: 1 } }, - }); + const { q } = await searchParams; + const query = q?.trim(); + + const where = query + ? and(eq(collections.userId, session.user.id), or(ilike(collections.name, `%${query}%`), ilike(collections.description, `%${query}%`))) + : eq(collections.userId, session.user.id); + + const [userCollections, countRows] = await Promise.all([ + db.query.collections.findMany({ + where, + orderBy: (t, { desc }) => desc(t.updatedAt), + with: { + recipes: { + limit: 4, + orderBy: (t, { asc }) => asc(t.position), + with: { recipe: { with: { photos: true } } }, + }, + }, + }), + // Separate grouped count — the `with: { recipes: { limit: 4 } }` above is + // capped for thumbnail previews, so `.recipes.length` off that relation + // would only ever report up to 4, never the real total. + db + .select({ collectionId: collectionRecipes.collectionId, count: sql`count(*)::int` }) + .from(collectionRecipes) + .innerJoin(collections, eq(collectionRecipes.collectionId, collections.id)) + .where(eq(collections.userId, session.user.id)) + .groupBy(collectionRecipes.collectionId), + ]); + + const countByCollection = new Map(countRows.map((r) => [r.collectionId, r.count])); return ( ({ id: col.id, name: col.name, description: col.description, + tags: col.tags, isPublic: col.isPublic, - recipeCount: col.recipes.length, + recipeCount: countByCollection.get(col.id) ?? 0, + thumbnails: col.recipes.flatMap((r) => { + if (!r.recipe) return []; + const cover = r.recipe.photos.find((p) => p.isCover) ?? r.recipe.photos[0]; + return [{ + recipeId: r.recipe.id, + recipeType: r.recipe.recipeType, + coverIcon: r.recipe.coverIcon, + coverColor: r.recipe.coverColor, + photoUrl: cover ? getPublicUrl(cover.storageKey) : null, + }]; + }), }))} /> ); diff --git a/apps/web/app/api/v1/collections/[id]/reorder/route.ts b/apps/web/app/api/v1/collections/[id]/reorder/route.ts new file mode 100644 index 0000000..6847671 --- /dev/null +++ b/apps/web/app/api/v1/collections/[id]/reorder/route.ts @@ -0,0 +1,44 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { db, collections, collectionRecipes, eq, and, inArray } from "@epicure/db"; +import { requireSessionOrApiKey } from "@/lib/api-auth"; + +type Params = { params: Promise<{ id: string }> }; + +const Schema = z.object({ + recipeIds: z.array(z.string()).min(1).max(500), +}); + +export async function PATCH(req: NextRequest, { params }: Params) { + const { session, response } = await requireSessionOrApiKey(req); + if (response) return response; + const { id } = await params; + + const existing = await db.query.collections.findFirst({ + where: and(eq(collections.id, id), eq(collections.userId, session!.user.id)), + }); + if (!existing) return NextResponse.json({ error: "Not found" }, { status: 404 }); + + const body = await req.json() as unknown; + const parsed = Schema.safeParse(body); + if (!parsed.success) return NextResponse.json({ error: "Validation error", issues: parsed.error.issues }, { status: 400 }); + + const current = await db.query.collectionRecipes.findMany({ + where: eq(collectionRecipes.collectionId, id), + columns: { recipeId: true }, + }); + const currentIds = new Set(current.map((r) => r.recipeId)); + const requestedIds = parsed.data.recipeIds.filter((rid) => currentIds.has(rid)); + if (requestedIds.length === 0) return NextResponse.json({ error: "No matching recipes in this collection" }, { status: 400 }); + + await db.transaction(async (tx) => { + for (let i = 0; i < requestedIds.length; i++) { + await tx + .update(collectionRecipes) + .set({ position: i }) + .where(and(eq(collectionRecipes.collectionId, id), inArray(collectionRecipes.recipeId, [requestedIds[i]!]))); + } + }); + + return NextResponse.json({ ok: true }); +} diff --git a/apps/web/app/api/v1/collections/[id]/route.ts b/apps/web/app/api/v1/collections/[id]/route.ts index 925e71a..a4ba5d3 100644 --- a/apps/web/app/api/v1/collections/[id]/route.ts +++ b/apps/web/app/api/v1/collections/[id]/route.ts @@ -1,7 +1,8 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; -import { db, collections, collectionRecipes, recipes, eq, and, or, ne, inArray } from "@epicure/db"; +import { db, collections, collectionRecipes, recipes, ratings, eq, and, or, ne, inArray, isNotNull, sql } from "@epicure/db"; import { requireSessionOrApiKey } from "@/lib/api-auth"; +import { deleteObject } from "@/lib/storage"; type Params = { params: Promise<{ id: string }> }; @@ -32,7 +33,9 @@ export async function PUT(req: NextRequest, { params }: Params) { const body = await req.json() as unknown; const parsed = z.object({ name: z.string().min(1).max(100).optional(), - description: z.string().max(500).optional(), + description: z.string().max(500).nullable().optional(), + notes: z.string().max(2000).nullable().optional(), + tags: z.array(z.string().min(1).max(50)).max(20).optional(), isPublic: z.boolean().optional(), addRecipeId: z.string().optional(), addRecipeIds: z.array(z.string()).max(200).optional(), @@ -42,10 +45,12 @@ export async function PUT(req: NextRequest, { params }: Params) { if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 }); const data = parsed.data; - if (data.name || data.description !== undefined || data.isPublic !== undefined) { + if (data.name || data.description !== undefined || data.notes !== undefined || data.tags !== undefined || data.isPublic !== undefined) { await db.update(collections).set({ ...(data.name && { name: data.name }), ...(data.description !== undefined && { description: data.description }), + ...(data.notes !== undefined && { notes: data.notes }), + ...(data.tags !== undefined && { tags: data.tags }), ...(data.isPublic !== undefined && { isPublic: data.isPublic }), updatedAt: new Date(), }).where(eq(collections.id, id)); @@ -61,8 +66,13 @@ export async function PUT(req: NextRequest, { params }: Params) { columns: { id: true }, }); if (owned.length > 0) { + const [maxPositionRow] = await db + .select({ max: sql`max(${collectionRecipes.position})` }) + .from(collectionRecipes) + .where(eq(collectionRecipes.collectionId, id)); + let nextPosition = (maxPositionRow?.max ?? -1) + 1; await db.insert(collectionRecipes) - .values(owned.map((r) => ({ collectionId: id, recipeId: r.id }))) + .values(owned.map((r) => ({ collectionId: id, recipeId: r.id, position: nextPosition++ }))) .onConflictDoNothing(); } } @@ -87,6 +97,41 @@ export async function DELETE(req: NextRequest, { params }: Params) { }); if (!existing) return NextResponse.json({ error: "Not found" }, { status: 404 }); + const deleteRecipes = req.nextUrl.searchParams.get("deleteRecipes") === "true"; + + if (deleteRecipes) { + // Only recipes this user actually owns — a collection can contain other + // people's (non-private) recipes added via sharing/collab, and those + // must never be deleted just because this user deletes their collection. + const owned = await db.query.collectionRecipes.findMany({ + where: eq(collectionRecipes.collectionId, id), + with: { recipe: { columns: { id: true, authorId: true }, with: { photos: true } } }, + }); + const ownRecipes = owned.flatMap((r) => (r.recipe && r.recipe.authorId === session!.user.id ? [r.recipe] : [])); + + if (ownRecipes.length > 0) { + const recipeIds = ownRecipes.map((r) => r.id); + const reviewPhotos = await db + .select({ photoKey: ratings.photoKey }) + .from(ratings) + .where(and(inArray(ratings.recipeId, recipeIds), isNotNull(ratings.photoKey))); + const storageKeys = [ + ...ownRecipes.flatMap((r) => r.photos.map((p) => p.storageKey)), + ...reviewPhotos.map((r) => r.photoKey).filter((k): k is string => k !== null), + ]; + + await db.delete(recipes).where(inArray(recipes.id, recipeIds)); + + for (const key of storageKeys) { + try { + await deleteObject(key); + } catch (err) { + console.error(`Failed to delete storage object ${key} while deleting collection ${id}`, err); + } + } + } + } + await db.delete(collections).where(eq(collections.id, id)); return new NextResponse(null, { status: 204 }); } diff --git a/apps/web/components/collections/collection-recipes-grid.tsx b/apps/web/components/collections/collection-recipes-grid.tsx index 7047f93..fc4537c 100644 --- a/apps/web/components/collections/collection-recipes-grid.tsx +++ b/apps/web/components/collections/collection-recipes-grid.tsx @@ -1,11 +1,28 @@ "use client"; -import { useState, useCallback } from "react"; +import { useState, useCallback, useMemo } from "react"; +import Link from "next/link"; import { useTranslations } from "next-intl"; import { toast } from "sonner"; -import { ListChecks, X, FolderInput, FolderMinus, Check } from "lucide-react"; +import { ListChecks, X, FolderInput, FolderMinus, Check, GripVertical, Search } from "lucide-react"; +import { + DndContext, + type DragEndEvent, + PointerSensor, + useSensor, + useSensors, + closestCenter, +} from "@dnd-kit/core"; +import { + SortableContext, + useSortable, + verticalListSortingStrategy, + arrayMove, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; import { Button, buttonVariants } from "@/components/ui/button"; -import { RecipeCard } from "@/components/recipe/recipe-card"; +import { Input } from "@/components/ui/input"; +import { RecipeGridCard, type GridCardRecipe } from "@/components/recipe/recipe-grid-card"; import { AddToCollectionDialog } from "@/components/recipe/add-to-collection-dialog"; import { AlertDialog, @@ -19,21 +36,69 @@ import { } from "@/components/ui/alert-dialog"; import { cn } from "@/lib/utils"; -type Recipe = { - id: string; - title: string; - description: string | null; - baseServings: number; - prepMins: number | null; - cookMins: number | null; - difficulty: "easy" | "medium" | "hard" | null; - visibility: "private" | "unlisted" | "public" | "followers"; - updatedAt: Date; - photos?: Array<{ storageKey: string; isCover: boolean }>; - sourceUrl?: string | null; -}; +function SortableRecipeCard({ + recipe, + selectMode, + selected, + onToggle, + dragDisabled, +}: { + recipe: GridCardRecipe; + selectMode: boolean; + selected: boolean; + onToggle: () => void; + dragDisabled: boolean; +}) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: recipe.id, + disabled: dragDisabled, + }); -export function CollectionRecipesGrid({ collectionId, recipes: initialRecipes }: { collectionId: string; recipes: Recipe[] }) { + const style = { transform: CSS.Transform.toString(transform), transition }; + + return ( +
+ {selectMode && ( +
+ {selected && } +
+ )} + {!selectMode && !dragDisabled && ( + + )} +
+ {selectMode ? ( + + ) : ( + + + + )} +
+
+ ); +} + +export function CollectionRecipesGrid({ collectionId, recipes: initialRecipes }: { collectionId: string; recipes: GridCardRecipe[] }) { const t = useTranslations("collections"); const tCommon = useTranslations("common"); const [recipes, setRecipes] = useState(initialRecipes); @@ -42,6 +107,9 @@ export function CollectionRecipesGrid({ collectionId, recipes: initialRecipes }: const [moveOpen, setMoveOpen] = useState(false); const [removeConfirmOpen, setRemoveConfirmOpen] = useState(false); const [busy, setBusy] = useState(false); + const [search, setSearch] = useState(""); + + const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } })); const toggleSelect = useCallback((id: string) => { setSelected((prev) => { @@ -75,16 +143,57 @@ export function CollectionRecipesGrid({ collectionId, recipes: initialRecipes }: } } + async function persistOrder(ordered: GridCardRecipe[]) { + try { + const res = await fetch(`/api/v1/collections/${collectionId}/reorder`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ recipeIds: ordered.map((r) => r.id) }), + }); + if (!res.ok) throw new Error(); + } catch { + toast.error(t("reorderFailed")); + } + } + + function handleDragEnd(event: DragEndEvent) { + const { active, over } = event; + if (!over || active.id === over.id) return; + const oldIndex = recipes.findIndex((r) => r.id === active.id); + const newIndex = recipes.findIndex((r) => r.id === over.id); + if (oldIndex === -1 || newIndex === -1) return; + const reordered = arrayMove(recipes, oldIndex, newIndex); + setRecipes(reordered); + void persistOrder(reordered); + } + + const filtered = useMemo(() => { + const q = search.trim().toLowerCase(); + if (!q) return recipes; + return recipes.filter((r) => r.title.toLowerCase().includes(q)); + }, [recipes, search]); + + const searchActive = search.trim().length > 0; + if (recipes.length === 0) return null; return (
-
+
+
+ + setSearch(e.target.value)} + placeholder={t("searchRecipesPlaceholder")} + className="pl-9" + /> +
-
- {recipes.map((recipe) => ( -
toggleSelect(recipe.id) : undefined}> - {selectMode && ( -
- {selected.has(recipe.id) && } -
- )} -
- + {filtered.length === 0 ? ( +

{t("noRecipeSearchResults")}

+ ) : ( + + r.id)} strategy={verticalListSortingStrategy}> +
+ {filtered.map((recipe) => ( + toggleSelect(recipe.id)} + dragDisabled={selectMode || searchActive} + /> + ))}
-
- ))} -
+ + + )} {selectMode && selected.size > 0 && (
diff --git a/apps/web/components/collections/collections-page-content.tsx b/apps/web/components/collections/collections-page-content.tsx index 3c9a326..98d6f7f 100644 --- a/apps/web/components/collections/collections-page-content.tsx +++ b/apps/web/components/collections/collections-page-content.tsx @@ -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 ( +
+ +
+ ); + } + + return ( +
+ {Array.from({ length: 4 }).map((_, i) => { + const thumb = thumbnails[i]; + return ( +
+ {thumb ? ( + thumb.photoUrl ? ( + + ) : ( + + ) + ) : null} +
+ ); + })} +
+ ); +} + +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 (
@@ -39,25 +98,51 @@ export function CollectionsPageContent({ collections }: Props) {
+
+ + handleSearch(e.target.value)} + placeholder={t("searchPlaceholder")} + className="pl-9" + /> +
+ {collections.length === 0 ? ( } + title={query ? t("noSearchResults") : t("empty")} + description={query ? undefined : t("emptyDescription")} + actionSlot={!query ? : undefined} /> ) : (
{collections.map((col) => ( - -
-

{col.name}

- {col.isPublic && {t("public")}} + +
+ +
+
+
+

{col.name}

+ {col.isPublic && {t("public")}} +
+ {col.description &&

{col.description}

} + {col.tags.length > 0 && ( +
+ {col.tags.slice(0, 4).map((tag) => ( + {tag} + ))} +
+ )} +

+ {col.recipeCount !== 1 ? t("recipeCountPlural", { count: col.recipeCount }) : t("recipeCount", { count: col.recipeCount })} +

- {col.description &&

{col.description}

} -

- {col.recipeCount !== 1 ? t("recipeCountPlural", { count: col.recipeCount }) : t("recipeCount", { count: col.recipeCount })} -

))}
diff --git a/apps/web/components/collections/delete-collection-dialog.tsx b/apps/web/components/collections/delete-collection-dialog.tsx new file mode 100644 index 0000000..62dde2b --- /dev/null +++ b/apps/web/components/collections/delete-collection-dialog.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { toast } from "sonner"; +import { Trash2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; + +export function DeleteCollectionDialog({ collectionId }: { collectionId: string }) { + const t = useTranslations("collections"); + const tCommon = useTranslations("common"); + const router = useRouter(); + const [open, setOpen] = useState(false); + const [deleteRecipes, setDeleteRecipes] = useState(false); + const [deleting, setDeleting] = useState(false); + + async function handleDelete() { + setDeleting(true); + try { + const res = await fetch(`/api/v1/collections/${collectionId}?deleteRecipes=${deleteRecipes}`, { method: "DELETE" }); + if (!res.ok) throw new Error(); + toast.success(t("deleteSuccess")); + router.push("/collections"); + router.refresh(); + } catch { + toast.error(t("deleteFailed")); + setDeleting(false); + } + } + + return ( + <> + + + + + + {t("deleteConfirmTitle")} + {t("deleteConfirmDescription")} + + + + + + {tCommon("cancel")} + { e.preventDefault(); void handleDelete(); }} + disabled={deleting} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + {deleting ? t("deleting") : tCommon("delete")} + + + + + + ); +} diff --git a/apps/web/components/collections/edit-collection-dialog.tsx b/apps/web/components/collections/edit-collection-dialog.tsx new file mode 100644 index 0000000..a339dd8 --- /dev/null +++ b/apps/web/components/collections/edit-collection-dialog.tsx @@ -0,0 +1,163 @@ +"use client"; + +import { useRef, useState, type KeyboardEvent } from "react"; +import { useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { toast } from "sonner"; +import { Pencil, Tag, X } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Label } from "@/components/ui/label"; +import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; + +export function EditCollectionDialog({ + collectionId, + initialName, + initialDescription, + initialNotes, + initialTags, + initialIsPublic, +}: { + collectionId: string; + initialName: string; + initialDescription: string | null; + initialNotes: string | null; + initialTags: string[]; + initialIsPublic: boolean; +}) { + const t = useTranslations("collections"); + const tRecipeForm = useTranslations("recipeForm"); + const tCommon = useTranslations("common"); + const router = useRouter(); + const [open, setOpen] = useState(false); + const [name, setName] = useState(initialName); + const [description, setDescription] = useState(initialDescription ?? ""); + const [notes, setNotes] = useState(initialNotes ?? ""); + const [tags, setTags] = useState(initialTags); + const [tagInput, setTagInput] = useState(""); + const [isPublic, setIsPublic] = useState(initialIsPublic); + const [saving, setSaving] = useState(false); + const tagInputRef = useRef(null); + + function addTag(raw: string) { + const tag = raw.trim().toLowerCase().slice(0, 50); + if (!tag || tags.includes(tag) || tags.length >= 20) return; + setTags((prev) => [...prev, tag]); + setTagInput(""); + } + + function removeTag(tag: string) { + setTags((prev) => prev.filter((tg) => tg !== tag)); + } + + function handleTagKeyDown(e: KeyboardEvent) { + if (e.key === "Enter") { + e.preventDefault(); + addTag(tagInput); + } else if (e.key === "Backspace" && !tagInput && tags.length > 0) { + setTags((prev) => prev.slice(0, -1)); + } + } + + async function handleSave() { + if (!name.trim()) return; + setSaving(true); + try { + const res = await fetch(`/api/v1/collections/${collectionId}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: name.trim(), + description: description.trim() || null, + notes: notes.trim() || null, + tags, + isPublic, + }), + }); + if (!res.ok) throw new Error(); + toast.success(t("editSuccess")); + setOpen(false); + router.refresh(); + } catch { + toast.error(t("editFailed")); + } finally { + setSaving(false); + } + } + + return ( + <> + + + + + + {t("editTitle")} + + +
+
+ + setName(e.target.value)} placeholder={t("namePlaceholder")} maxLength={100} /> +
+ +
+ +