Files
spsays-training/components/selection-toolbar/collections-drawer.tsx
T
2026-06-11 00:32:04 +05:30

353 lines
13 KiB
TypeScript

"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,
onSaved,
isMobile,
selectedText,
existingPostId,
}: {
open: boolean;
onClose: () => void;
/** Called instead of onClose after a successful save. Lets the selection
* toolbar end the flow (drop the selection) rather than restore it the
* way a cancel/dismiss does. Falls back to onClose when omitted. */
onSaved?: () => 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<Collection[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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<Set<string>>(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<Record<string, Status>>({});
const [rowMsg, setRowMsg] = useState<Record<string, string>>({});
// 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<unknown>[] = [
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,
),
);
// Leave the "Saved ✓" row state visible for a beat, then complete the
// flow — onSaved (don't restore the selection) when provided.
setTimeout(onSaved ?? 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 (
<Drawer
open={open}
onOpenChange={(o) => !o && onClose()}
direction={direction}
>
<DrawerContent
direction={direction}
className="bg-black dark:bg-[#1a1a1a] border-white/10"
>
<DrawerHeader className="text-left border-b border-white/10 pb-4">
<DrawerTitle className="text-white">Save to collection</DrawerTitle>
{selectedText ? (
<p className="text-xs text-white/50 mt-1 line-clamp-2">
“{selectedText.slice(0, 140)}
{selectedText.length > 140 ? "…" : ""}”
</p>
) : null}
</DrawerHeader>
<div className="overflow-y-auto flex-1 p-4 space-y-1">
{loading ? (
<div className="flex items-center justify-center py-10 text-white/60">
<Loader2 className="size-5 animate-spin" />
</div>
) : error ? (
<p className="text-sm text-amber-400 px-3 py-2">{error}</p>
) : null}
{!loading && !error && list.length === 0 ? (
<p className="text-sm text-white/50 px-3 py-2">
No collections yet — create one below.
</p>
) : 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 (
<button
key={c.id}
onClick={() => saveTo(c.id)}
disabled={locked}
aria-disabled={locked}
className="flex items-center justify-between gap-3 w-full px-3 py-3 rounded-lg text-left hover:bg-white/10 transition-colors disabled:hover:bg-transparent disabled:cursor-not-allowed disabled:opacity-60"
>
<div className="flex items-center gap-3 min-w-0">
{/* Cover thumb — falls back to the FolderPlus glyph when
the collection has no cover_url. `overflow-hidden`
so the Image's fill respects the rounded slot. */}
<div className="relative flex items-center justify-center size-9 rounded-lg bg-white/10 shrink-0 overflow-hidden">
{c.cover_url ? (
<Image
src={c.cover_url}
alt=""
fill
sizes="36px"
className="object-cover"
unoptimized
/>
) : (
<FolderPlus className="size-4 text-white/60" />
)}
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-white/90 truncate">
{c.title}
</p>
<p className="text-xs text-white/50 truncate">
{alreadySaved
? "Already in this collection"
: `${c.is_public ? "Public" : "Private"} · ${c.item_count} item${
c.item_count === 1 ? "" : "s"
}${rowMsg[c.id] ? ` · ${rowMsg[c.id]}` : ""}`}
</p>
</div>
</div>
<span className="shrink-0 text-xs text-white/50">
{st === "saving" ? (
<Loader2 className="size-4 animate-spin" />
) : alreadySaved || st === "saved" ? (
<Check className="size-4 text-emerald-400" />
) : null}
</span>
</button>
);
})}
{/* Inline "new collection" panel */}
{!loading && !error && !showCreate ? (
<button
onClick={() => setShowCreate(true)}
className="flex items-center gap-3 w-full px-3 py-3 rounded-lg text-left hover:bg-white/10 transition-colors mt-2 border border-dashed border-white/20"
>
<div className="flex items-center justify-center size-9 rounded-lg bg-amber-400/15">
<Plus className="size-4 text-amber-400" />
</div>
<span className="text-sm font-medium text-amber-400">
New collection
</span>
</button>
) : null}
{showCreate ? (
<div className="mt-2 rounded-lg border border-white/10 p-3 space-y-3">
<input
value={newTitle}
onChange={(e) => 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"
/>
<div className="flex items-center justify-between text-xs text-white/60">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={newIsPublic}
onChange={(e) => setNewIsPublic(e.target.checked)}
/>
Public
</label>
<div className="flex items-center gap-2">
<button
onClick={() => setShowCreate(false)}
className="px-3 py-1.5 rounded-md hover:bg-white/10"
>
Cancel
</button>
<button
onClick={createCollection}
disabled={creating || !newTitle.trim()}
className="px-3 py-1.5 rounded-md bg-primary text-primary-foreground disabled:opacity-60"
>
{creating ? "Creating…" : "Create & save"}
</button>
</div>
</div>
</div>
) : null}
</div>
</DrawerContent>
</Drawer>
);
}