feat: add user centric features
This commit is contained in:
@@ -0,0 +1,504 @@
|
||||
"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<HTMLInputElement>(null);
|
||||
const [title, setTitle] = useState(collection.title);
|
||||
const [description, setDescription] = useState(collection.description ?? "");
|
||||
const [isPublic, setIsPublic] = useState(collection.is_public);
|
||||
const [coverURL, setCoverURL] = useState<string | null>(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<string[]>(currentPostIds);
|
||||
|
||||
const [allPosts, setAllPosts] = useState<Post[] | null>(null);
|
||||
const [loadingPosts, setLoadingPosts] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [uploadingCover, setUploadingCover] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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<HTMLInputElement>) {
|
||||
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 (
|
||||
<>
|
||||
<Drawer
|
||||
open={open}
|
||||
onOpenChange={(o) => !o && !saving && onClose()}
|
||||
direction={direction}
|
||||
shouldScaleBackground={direction === "bottom"}
|
||||
>
|
||||
<DrawerContent
|
||||
direction={direction}
|
||||
className={
|
||||
direction === "bottom"
|
||||
? "max-h-[90vh]"
|
||||
: "max-w-md sm:max-w-lg"
|
||||
}
|
||||
>
|
||||
<DrawerHeader className="shrink-0 text-left flex items-center justify-between pb-2">
|
||||
<DrawerTitle>Edit collection</DrawerTitle>
|
||||
<DrawerClose asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close"
|
||||
disabled={saving}
|
||||
className="size-8 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted inline-flex items-center justify-center disabled:opacity-50"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</DrawerClose>
|
||||
</DrawerHeader>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-4 pb-4 space-y-5">
|
||||
{/* Cover */}
|
||||
<section className="flex items-start gap-4">
|
||||
<div className="relative size-24 rounded-lg overflow-hidden bg-muted ring-1 ring-foreground/10 shrink-0">
|
||||
{coverURL ? (
|
||||
<Image
|
||||
src={coverURL}
|
||||
alt=""
|
||||
fill
|
||||
sizes="96px"
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
<span className="absolute inset-0 flex items-center justify-center text-[0.6rem] uppercase tracking-[0.2em] text-muted-foreground text-center px-2">
|
||||
No cover
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col gap-2 min-w-0">
|
||||
<input
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp,image/gif"
|
||||
hidden
|
||||
onChange={onCoverChange}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInput.current?.click()}
|
||||
disabled={uploadingCover}
|
||||
className="inline-flex items-center gap-1.5 rounded-md px-3 h-9 text-sm text-foreground hover:bg-muted border border-border w-fit disabled:opacity-50"
|
||||
>
|
||||
{uploadingCover ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<ImagePlus className="size-4" />
|
||||
)}
|
||||
{coverURL ? "Change cover" : "Add cover"}
|
||||
</button>
|
||||
{coverURL ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={removeCover}
|
||||
className="text-xs text-muted-foreground hover:text-destructive w-fit"
|
||||
>
|
||||
Remove cover
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Title */}
|
||||
<section className="space-y-1">
|
||||
<label className="text-xs uppercase tracking-[0.18em] font-semibold text-muted-foreground">
|
||||
Title
|
||||
</label>
|
||||
<input
|
||||
value={title}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Description */}
|
||||
<section className="space-y-1">
|
||||
<label className="text-xs uppercase tracking-[0.18em] font-semibold text-muted-foreground">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={3}
|
||||
placeholder="Optional"
|
||||
className="w-full bg-background text-foreground text-sm rounded-md border border-border px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-primary/40 resize-none"
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Visibility */}
|
||||
<section>
|
||||
<label className="flex items-center gap-2.5 text-sm cursor-pointer text-foreground">
|
||||
<Checkbox
|
||||
checked={isPublic}
|
||||
onCheckedChange={setIsPublic}
|
||||
ariaLabel="Public collection"
|
||||
/>
|
||||
{isPublic ? (
|
||||
<Globe className="size-4 text-muted-foreground" />
|
||||
) : (
|
||||
<Lock className="size-4 text-muted-foreground" />
|
||||
)}
|
||||
Public — anyone with the link can view
|
||||
</label>
|
||||
</section>
|
||||
|
||||
{/* Posts in this collection (drag-sortable) */}
|
||||
<section className="space-y-2 pt-2">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<label className="text-xs uppercase tracking-[0.18em] font-semibold text-muted-foreground">
|
||||
In this collection
|
||||
</label>
|
||||
<span className="text-xs text-muted-foreground tabular-nums">
|
||||
{orderedIds.length}
|
||||
</span>
|
||||
</div>
|
||||
{loadingPosts ? (
|
||||
<p className="text-sm text-muted-foreground">Loading…</p>
|
||||
) : inCollection.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No posts in this collection yet — pick one from below.
|
||||
</p>
|
||||
) : (
|
||||
<Reorder.Group
|
||||
axis="y"
|
||||
values={orderedIds}
|
||||
onReorder={setOrderedIds}
|
||||
className="rounded-lg border border-border divide-y divide-border bg-background"
|
||||
as="ul"
|
||||
>
|
||||
{inCollection.map((p) => (
|
||||
<SortableRow
|
||||
key={p.id}
|
||||
post={p}
|
||||
onRemove={() => removeFromCollection(p.id)}
|
||||
/>
|
||||
))}
|
||||
</Reorder.Group>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Available posts */}
|
||||
{available.length > 0 ? (
|
||||
<section className="space-y-2">
|
||||
<label className="text-xs uppercase tracking-[0.18em] font-semibold text-muted-foreground">
|
||||
Add posts
|
||||
</label>
|
||||
<ul className="rounded-lg border border-border divide-y divide-border bg-background">
|
||||
{available.map((p) => (
|
||||
<AvailableRow
|
||||
key={p.id}
|
||||
post={p}
|
||||
onAdd={() => addToCollection(p.id)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Footer actions */}
|
||||
<div className="shrink-0 border-t border-border p-4 flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmDelete(true)}
|
||||
disabled={saving}
|
||||
className="inline-flex items-center gap-1.5 rounded-md px-3 h-10 text-sm text-destructive hover:bg-destructive/10 disabled:opacity-50"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
Delete
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={saving}
|
||||
className="inline-flex items-center rounded-md px-4 h-10 text-sm text-foreground hover:bg-muted disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={save}
|
||||
disabled={saving || uploadingCover || title.trim().length === 0}
|
||||
className="inline-flex items-center gap-1.5 rounded-md px-4 h-10 bg-primary text-primary-foreground text-sm hover:bg-primary/90 disabled:opacity-60"
|
||||
>
|
||||
{saving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
<ConfirmModal
|
||||
open={confirmDelete}
|
||||
onClose={() => setConfirmDelete(false)}
|
||||
title={`Delete "${collection.title}"?`}
|
||||
message="The collection will be removed permanently. The posts inside stay on your feed."
|
||||
confirmLabel="Delete collection"
|
||||
onConfirm={performDelete}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Row pieces ──────────────────────────────────────────────────────────
|
||||
|
||||
function SortableRow({
|
||||
post,
|
||||
onRemove,
|
||||
}: {
|
||||
post: Post;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
// dragListener={false} disables the default whole-item drag — only the
|
||||
// grip handle starts a drag via dragControls.start.
|
||||
const controls = useDragControls();
|
||||
return (
|
||||
<Reorder.Item
|
||||
value={post.id}
|
||||
dragListener={false}
|
||||
dragControls={controls}
|
||||
className="flex items-start gap-3 p-3 hover:bg-muted/40 transition-colors bg-background select-none"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onPointerDown={(e) => controls.start(e)}
|
||||
aria-label="Drag to reorder"
|
||||
title="Drag to reorder"
|
||||
className="mt-0.5 text-muted-foreground hover:text-foreground cursor-grab active:cursor-grabbing touch-none"
|
||||
>
|
||||
<GripVertical className="size-4" />
|
||||
</button>
|
||||
<Checkbox
|
||||
checked
|
||||
onCheckedChange={() => onRemove()}
|
||||
ariaLabel="Remove from collection"
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<PostSummary post={post} />
|
||||
</Reorder.Item>
|
||||
);
|
||||
}
|
||||
|
||||
function AvailableRow({ post, onAdd }: { post: Post; onAdd: () => void }) {
|
||||
return (
|
||||
<li>
|
||||
<label className="flex items-start gap-3 p-3 hover:bg-muted/40 transition-colors cursor-pointer">
|
||||
<Checkbox
|
||||
checked={false}
|
||||
onCheckedChange={() => onAdd()}
|
||||
ariaLabel="Add to collection"
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<PostSummary post={post} />
|
||||
</label>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function PostSummary({ post }: { post: Post }) {
|
||||
return (
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-sm text-foreground line-clamp-2 break-words">
|
||||
{post.caption}
|
||||
</span>
|
||||
{post.source_title ? (
|
||||
<span className="block text-[0.65rem] uppercase tracking-[0.18em] text-muted-foreground mt-1 truncate">
|
||||
{post.source_title}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user