"use client"; /** * EditCollectionDrawer — edit the contents and metadata of a collection. * * • Title, description, public/private toggle * • Cover image upload (signed-URL PUT to R2) * • Reorder posts via drag-and-drop, remove posts from the collection * • Delete button at the bottom (with confirm modal) * * Scope is intentionally narrow to **posts already in this collection** — * the user's full timeline doesn't appear here. Adding new posts happens * via the FolderPlus action in PostActionsFooter (which mounts the * CollectionsDrawer); this surface manages reordering + removal only. * * 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, SocialError, type Collection, type Post, } from "@/lib/social"; import { compressImage } from "@/lib/compress-image"; type SavedShape = { collection: Collection; posts: Post[]; }; export function EditCollectionDrawer({ open, onClose, collection, currentPosts, onSaved, onDeleted, }: { open: boolean; onClose: () => void; collection: Collection; /** Posts currently in this collection, in display order. The drawer * scopes its UI to this list — it does NOT pull the user's full * timeline. Adding new posts is done elsewhere (PostActionsFooter → * CollectionsDrawer). */ currentPosts: Post[]; 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 order: a plain array of post // ids. Reorder updates it; toggling a checkbox removes the id. On // save we diff against the seed (`currentPosts`) to compute removes // and push the new ordering to /items/order. const [orderedIds, setOrderedIds] = useState(() => currentPosts.map((p) => p.id), ); 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(currentPosts.map((p) => p.id)); setError(null); }, [open, collection, currentPosts]); // Ordered post objects to render in the sortable list. Indexed by id // since `orderedIds` is the live order and currentPosts is the data // table. const inCollection = useMemo(() => { const byId = new Map(currentPosts.map((p) => [p.id, p])); return orderedIds .map((id) => byId.get(id)) .filter((p): p is Post => !!p); }, [currentPosts, orderedIds]); 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 compressed = await compressImage(f); const url = await collections.uploadCover(compressed); 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 { // This drawer only supports removing + reordering, so the diff // collapses to "what got dropped". Anything no longer in // orderedIds needs a /items DELETE. const after = new Set(orderedIds); const toRemove = currentPosts .map((p) => p.id) .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 toRemove) await collections.removeItem(collection.id, id); // Final position pass — pushes the drag order to the server. 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 */}