353 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|