"use client"; /** * CollectionsDrawer — "Save to Collection" picker, reused in two flows: * * • Selection toolbar — caller passes `selectedText`. On row click we * POST a new public post first, then add it to the chosen collection. * * • Post card footer — caller passes `existingPostId` (the post is * already saved). We skip the create step and only call addItem. * * Exactly one of `selectedText` or `existingPostId` is meaningful per * mount. Per the product spec: collections always hold posts, every * post is public on the user's feed, and bookmarks are the private save. */ import { useEffect, useState } from "react"; import Image from "next/image"; import { FolderPlus, Check, Plus, Loader2 } from "lucide-react"; import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, } from "@/components/ui/drawer"; import { collections, posts as postsApi, SocialError, type Collection, } from "@/lib/social"; import { buildHighlightUrl } from "@/lib/text-highlight"; import { getSourceReference } from "@/lib/source-reference"; type Status = "idle" | "saving" | "saved" | "auth" | "limit" | "error"; export function CollectionsDrawer({ open, onClose, isMobile, selectedText, existingPostId, }: { open: boolean; onClose: () => void; isMobile: boolean; /** Provided by the selection toolbar — the drawer will create a fresh * post from this text before adding it to a collection. */ selectedText?: string; /** Provided by post-card footers — the drawer skips post creation and * attaches the existing post id to the chosen collection. */ existingPostId?: string; }) { const [list, setList] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); // For the existing-post mode: which collections already contain the // post, so we can disable those rows. Empty for the toolbar mode // (a brand-new post can't belong to anything yet). const [alreadyIn, setAlreadyIn] = useState>(new Set()); // Per-row save status — keyed by collection id so each row can show its // own "saving / saved" state without blocking the others. const [rowStatus, setRowStatus] = useState>({}); const [rowMsg, setRowMsg] = useState>({}); // Inline "new collection" form state. const [showCreate, setShowCreate] = useState(false); const [newTitle, setNewTitle] = useState(""); const [newIsPublic, setNewIsPublic] = useState(true); const [creating, setCreating] = useState(false); useEffect(() => { if (!open) return; setLoading(true); setError(null); // Fetch the user's collections in parallel with the post→collection // membership lookup so the rows render with the disabled state set // on first paint (no flicker from "Save" → "Saved"). const tasks: Promise[] = [ collections .mine() .then((rows) => setList(rows ?? [])) .catch((err) => { if (err instanceof SocialError && err.status === 401) { setError("Log in to use collections."); } else { setError( err instanceof SocialError ? err.message : "Could not load collections.", ); } }), ]; if (existingPostId) { tasks.push( collections .containingPost(existingPostId) .then((ids) => setAlreadyIn(new Set(ids))) .catch(() => { /* Non-fatal — drawer still works without the disabled hints. */ }), ); } else { setAlreadyIn(new Set()); } Promise.all(tasks).finally(() => setLoading(false)); }, [open, existingPostId]); // Reset transient UI state when drawer closes. useEffect(() => { if (!open) { setRowStatus({}); setRowMsg({}); setShowCreate(false); setNewTitle(""); setAlreadyIn(new Set()); } }, [open]); async function saveTo(collectionID: string) { setRowStatus((s) => ({ ...s, [collectionID]: "saving" })); setRowMsg((m) => ({ ...m, [collectionID]: "" })); try { // Two paths: // - existingPostId provided → just attach. // - selectedText provided → create a post first, then attach. let postID = existingPostId ?? null; if (!postID) { const sourceURL = buildHighlightUrl(selectedText ?? ""); const ref = getSourceReference(); const sourceTitle = ref ? ref.detail ? `${ref.title} — ${ref.detail}` : ref.title : typeof document !== "undefined" ? document.title : ""; const post = await postsApi.create({ caption: selectedText ?? "", source_url: sourceURL, source_title: sourceTitle, }); postID = post.id; } await collections.addItem(collectionID, postID); setRowStatus((s) => ({ ...s, [collectionID]: "saved" })); // Bump the local item_count so the user sees the change without a refetch. setList((rows) => rows.map((c) => c.id === collectionID ? { ...c, item_count: c.item_count + 1 } : c, ), ); setTimeout(onClose, 800); } catch (err) { if (err instanceof SocialError) { const tone: Status = err.status === 401 ? "auth" : err.status === 402 ? "limit" : "error"; setRowStatus((s) => ({ ...s, [collectionID]: tone })); setRowMsg((m) => ({ ...m, [collectionID]: err.message })); } else { setRowStatus((s) => ({ ...s, [collectionID]: "error" })); setRowMsg((m) => ({ ...m, [collectionID]: "Save failed." })); } } } async function createCollection() { const title = newTitle.trim(); if (!title) return; setCreating(true); setError(null); try { const c = await collections.create({ title, is_public: newIsPublic }); setList((rows) => [c, ...rows]); setNewTitle(""); setShowCreate(false); // Auto-save the quote into the new collection so the user gets to the // payoff in one step. void saveTo(c.id); } catch (err) { if (err instanceof SocialError && err.status === 402) { setError(err.message); } else if (err instanceof SocialError && err.status === 401) { setError("Log in to create a collection."); } else { setError( err instanceof SocialError ? err.message : "Could not create collection.", ); } } finally { setCreating(false); } } const direction = isMobile ? "bottom" : "right"; return ( !o && onClose()} direction={direction} > Save to collection {selectedText ? (

“{selectedText.slice(0, 140)} {selectedText.length > 140 ? "…" : ""}”

) : null}
{loading ? (
) : error ? (

{error}

) : null} {!loading && !error && list.length === 0 ? (

No collections yet — create one below.

) : null} {list.map((c) => { const st = rowStatus[c.id] ?? "idle"; // A row is "already saved" if the BFF told us this post is // already in this collection. We treat that the same as // st === "saved" (button locked, check mark shown), just // with a different sub-line for the user. const alreadySaved = alreadyIn.has(c.id); const locked = alreadySaved || st === "saving" || st === "saved"; return ( ); })} {/* Inline "new collection" panel */} {!loading && !error && !showCreate ? ( ) : null} {showCreate ? (
setNewTitle(e.target.value)} placeholder="Collection title" maxLength={120} className="w-full bg-white/5 text-white text-sm rounded-md px-3 py-2 placeholder:text-white/30 outline-none focus:bg-white/10" />
) : null}
); }