"use client"; /** * EditCollectionDrawer — single-stop editor for a collection. * * • Title, description, public/private toggle * • Cover image upload (signed-URL PUT to R2) * • Reorder posts via drag-and-drop, toggle posts in/out of the collection * • Delete button at the bottom (with confirm modal) * * Right-side panel on `md+`, bottom drawer on mobile. The body scrolls; * header and footer stay pinned so action buttons are always reachable. * * Drag-and-drop is powered by framer-motion's Reorder primitives so we * get touch + mouse support without an extra dependency. Each row has a * dedicated grip handle (Reorder.Item with `dragListener={false}` plus * `dragControls`) so the rest of the row stays clickable for toggling. */ import { useEffect, useMemo, useRef, useState } from "react"; import Image from "next/image"; import { Reorder, useDragControls } from "framer-motion"; import { Globe, GripVertical, ImagePlus, Loader2, Lock, Trash2, X, } from "lucide-react"; import { toast } from "sonner"; import { Drawer, DrawerClose, DrawerContent, DrawerHeader, DrawerTitle, } from "@/components/ui/drawer"; import { Checkbox } from "@/components/ui/checkbox"; import { ConfirmModal } from "@/components/ui/confirm-modal"; import { useMediaQuery } from "@/components/selection-toolbar/shared"; import { collections, posts as postsApi, SocialError, type Collection, type Post, } from "@/lib/social"; type SavedShape = { collection: Collection; posts: Post[]; }; export function EditCollectionDrawer({ open, onClose, collection, currentPostIds, onSaved, onDeleted, }: { open: boolean; onClose: () => void; collection: Collection; /** Post ids currently in the collection, in display order. Used to * seed the drag-sortable list and to diff against the user's * selection + new ordering on save. */ currentPostIds: string[]; onSaved: (next: SavedShape) => void; onDeleted: () => void; }) { const isMobile = useMediaQuery("(max-width: 767px)"); const direction: "bottom" | "right" = isMobile ? "bottom" : "right"; const fileInput = useRef(null); const [title, setTitle] = useState(collection.title); const [description, setDescription] = useState(collection.description ?? ""); const [isPublic, setIsPublic] = useState(collection.is_public); const [coverURL, setCoverURL] = useState(collection.cover_url); // Source of truth for the in-collection set + order: a plain array of // post ids. We push/pull from this when toggling, and reorder it on // drag. On save we diff against `currentPostIds` to find adds/removes // and push the final order to /items/order. const [orderedIds, setOrderedIds] = useState(currentPostIds); const [allPosts, setAllPosts] = useState(null); const [loadingPosts, setLoadingPosts] = useState(false); const [saving, setSaving] = useState(false); const [uploadingCover, setUploadingCover] = useState(false); const [error, setError] = useState(null); const [confirmDelete, setConfirmDelete] = useState(false); useEffect(() => { if (!open) return; setTitle(collection.title); setDescription(collection.description ?? ""); setIsPublic(collection.is_public); setCoverURL(collection.cover_url); setOrderedIds(currentPostIds); setError(null); }, [open, collection, currentPostIds]); useEffect(() => { if (!open || allPosts !== null) return; setLoadingPosts(true); postsApi .mine() .then((rows) => setAllPosts(rows ?? [])) .catch((err) => setError(err instanceof SocialError ? err.message : "Could not load posts."), ) .finally(() => setLoadingPosts(false)); }, [open, allPosts]); // Partition the user's posts into "in the collection" (ordered by // `orderedIds`) and "available to add" (everything else, stable). const { inCollection, available } = useMemo(() => { if (!allPosts) return { inCollection: [] as Post[], available: [] as Post[] }; const byId = new Map(allPosts.map((p) => [p.id, p])); const selected = new Set(orderedIds); const inCollection = orderedIds .map((id) => byId.get(id)) .filter((p): p is Post => !!p); const available = allPosts.filter((p) => !selected.has(p.id)); return { inCollection, available }; }, [allPosts, orderedIds]); function addToCollection(id: string) { setOrderedIds((prev) => (prev.includes(id) ? prev : [...prev, id])); } function removeFromCollection(id: string) { setOrderedIds((prev) => prev.filter((x) => x !== id)); } async function onCoverChange(e: React.ChangeEvent) { const f = e.target.files?.[0]; if (fileInput.current) fileInput.current.value = ""; if (!f) return; setUploadingCover(true); try { const url = await collections.uploadCover(f); setCoverURL(url); } catch (err) { toast.error( err instanceof SocialError ? err.message : "Cover upload failed.", ); } finally { setUploadingCover(false); } } function removeCover() { setCoverURL(null); } async function save() { setSaving(true); setError(null); try { const before = new Set(currentPostIds); const after = new Set(orderedIds); const toAdd = orderedIds.filter((id) => !before.has(id)); const toRemove = currentPostIds.filter((id) => !after.has(id)); await collections.update(collection.id, { title, description, is_public: isPublic, cover_url: coverURL ?? "", }); // Sequential mutations — the count trigger races on parallel calls. for (const id of toAdd) await collections.addItem(collection.id, id); for (const id of toRemove) await collections.removeItem(collection.id, id); // Final position pass — overwrites whatever positions addItem // assigned (MAX+1) so the user's drag order wins. if (orderedIds.length > 0) { await collections.reorderItems(collection.id, orderedIds); } const fresh = await collections.get(collection.id); onSaved({ collection: fresh.collection, posts: fresh.posts ?? [] }); toast.success("Collection updated"); onClose(); } catch (err) { setError(err instanceof SocialError ? err.message : "Save failed."); } finally { setSaving(false); } } async function performDelete() { try { await collections.delete(collection.id); onDeleted(); } catch (err) { throw new Error( err instanceof SocialError ? err.message : "Could not delete.", ); } } return ( <> !o && !saving && onClose()} direction={direction} shouldScaleBackground={direction === "bottom"} > Edit collection
{/* Cover */}
{coverURL ? ( ) : ( No cover )}
{coverURL ? ( ) : null}
{/* Title */}
setTitle(e.target.value)} maxLength={120} className="w-full bg-background text-foreground text-base rounded-md border border-border px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-primary/40" />
{/* Description */}