collection drawer ui fix
This commit is contained in:
@@ -1,13 +1,18 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* EditCollectionDrawer — single-stop editor for a collection.
|
||||
* 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, toggle posts in/out of the collection
|
||||
* • 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.
|
||||
*
|
||||
@@ -42,7 +47,6 @@ import { ConfirmModal } from "@/components/ui/confirm-modal";
|
||||
import { useMediaQuery } from "@/components/selection-toolbar/shared";
|
||||
import {
|
||||
collections,
|
||||
posts as postsApi,
|
||||
SocialError,
|
||||
type Collection,
|
||||
type Post,
|
||||
@@ -58,17 +62,18 @@ export function EditCollectionDrawer({
|
||||
open,
|
||||
onClose,
|
||||
collection,
|
||||
currentPostIds,
|
||||
currentPosts,
|
||||
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[];
|
||||
/** 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;
|
||||
}) {
|
||||
@@ -81,14 +86,13 @@ export function EditCollectionDrawer({
|
||||
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);
|
||||
// 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<string[]>(() =>
|
||||
currentPosts.map((p) => p.id),
|
||||
);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [uploadingCover, setUploadingCover] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -100,38 +104,19 @@ export function EditCollectionDrawer({
|
||||
setDescription(collection.description ?? "");
|
||||
setIsPublic(collection.is_public);
|
||||
setCoverURL(collection.cover_url);
|
||||
setOrderedIds(currentPostIds);
|
||||
setOrderedIds(currentPosts.map((p) => p.id));
|
||||
setError(null);
|
||||
}, [open, collection, currentPostIds]);
|
||||
}, [open, collection, currentPosts]);
|
||||
|
||||
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
|
||||
// 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<Post[]>(() => {
|
||||
const byId = new Map(currentPosts.map((p) => [p.id, p]));
|
||||
return 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]));
|
||||
}
|
||||
}, [currentPosts, orderedIds]);
|
||||
|
||||
function removeFromCollection(id: string) {
|
||||
setOrderedIds((prev) => prev.filter((x) => x !== id));
|
||||
@@ -163,10 +148,13 @@ export function EditCollectionDrawer({
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const before = new Set(currentPostIds);
|
||||
// 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 toAdd = orderedIds.filter((id) => !before.has(id));
|
||||
const toRemove = currentPostIds.filter((id) => !after.has(id));
|
||||
const toRemove = currentPosts
|
||||
.map((p) => p.id)
|
||||
.filter((id) => !after.has(id));
|
||||
|
||||
await collections.update(collection.id, {
|
||||
title,
|
||||
@@ -175,11 +163,9 @@ export function EditCollectionDrawer({
|
||||
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.
|
||||
// Final position pass — pushes the drag order to the server.
|
||||
if (orderedIds.length > 0) {
|
||||
await collections.reorderItems(collection.id, orderedIds);
|
||||
}
|
||||
@@ -342,11 +328,10 @@ export function EditCollectionDrawer({
|
||||
{orderedIds.length}
|
||||
</span>
|
||||
</div>
|
||||
{loadingPosts ? (
|
||||
<p className="text-sm text-muted-foreground">Loading…</p>
|
||||
) : inCollection.length === 0 ? (
|
||||
{inCollection.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No posts in this collection yet — pick one from below.
|
||||
No posts in this collection yet. Add posts via the
|
||||
“Save to collection” button on any post.
|
||||
</p>
|
||||
) : (
|
||||
<Reorder.Group
|
||||
@@ -367,24 +352,6 @@ export function EditCollectionDrawer({
|
||||
)}
|
||||
</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}
|
||||
@@ -474,22 +441,6 @@ function SortableRow({
|
||||
);
|
||||
}
|
||||
|
||||
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">
|
||||
|
||||
Reference in New Issue
Block a user