collection drawer ui fix

This commit is contained in:
gosangam
2026-05-31 11:41:18 +05:30
parent 8c1f3a1b7b
commit 7d76afe102
6 changed files with 104 additions and 91 deletions
+1 -1
View File
@@ -79,7 +79,7 @@ export default function CollectionDetailPage(props: {
open={editOpen} open={editOpen}
onClose={() => setEditOpen(false)} onClose={() => setEditOpen(false)}
collection={c} collection={c}
currentPostIds={posts.map((p) => p.id)} currentPosts={posts}
onSaved={({ collection, posts: rows }) => { onSaved={({ collection, posts: rows }) => {
setC(collection); setC(collection);
setPosts(rows); setPosts(rows);
@@ -33,6 +33,31 @@ import { useSharePost } from "@/components/social/share-post-provider";
const MAX_IMAGES = 5; const MAX_IMAGES = 5;
const PRESS_IMAGE_TYPES = "image/png,image/jpeg,image/webp,image/gif"; const PRESS_IMAGE_TYPES = "image/png,image/jpeg,image/webp,image/gif";
// Lazy-init singleton for the success chime. The user clicked "Post"
// to get here, so we're inside an active user-gesture context — modern
// browsers (incl. iOS Safari) honour audio playback on that activation.
// Anything that goes wrong (autoplay policy, decode failure, the file
// not yet cached on first run) falls through silently; the visible
// toast already communicates success, the sound is icing.
let successAudio: HTMLAudioElement | null = null;
function playSuccessSound() {
if (typeof window === "undefined") return;
try {
if (!successAudio) {
successAudio = new Audio("/audio/success.mp3");
successAudio.preload = "auto";
}
// Reset so rapid posts get a fresh chime instead of being
// ignored as "already playing".
successAudio.currentTime = 0;
void successAudio.play().catch(() => {
// Silent — see comment above.
});
} catch {
// Audio constructor unavailable or quota'd — silent.
}
}
export function ComposePost({ export function ComposePost({
open, open,
onClose, onClose,
@@ -128,6 +153,7 @@ export function ComposePost({
onPosted?.(); onPosted?.();
onClose(); onClose();
router.refresh(); router.refresh();
playSuccessSound();
// Success feedback + offer to externally share. The BFF's Create // Success feedback + offer to externally share. The BFF's Create
// response doesn't include author info (no JOIN with users), but // response doesn't include author info (no JOIN with users), but
// SharePostSheet falls back to the authed user's handle when // SharePostSheet falls back to the authed user's handle when
+19 -1
View File
@@ -10,6 +10,7 @@
* Hidden on md+ — desktop uses SiteSidebar. * Hidden on md+ — desktop uses SiteSidebar.
*/ */
import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { Home, Search, User as UserIcon } from "lucide-react"; import { Home, Search, User as UserIcon } from "lucide-react";
@@ -58,7 +59,24 @@ export function SiteBottomNav() {
{ {
href: user ? "/u/me" : "/login", href: user ? "/u/me" : "/login",
label: user ? "You" : "Sign in", label: user ? "You" : "Sign in",
icon: <UserIcon className="size-5" />, // Show the user's uploaded avatar when available so the nav
// feels personalized. Falls back to the generic person glyph
// for anonymous viewers and signed-in users who haven't
// uploaded a picture yet. The avatar circle is slightly larger
// (size-6 vs the size-5 icons elsewhere) so it reads as an
// identifying glyph rather than fading in with the other tabs.
icon: user?.avatar_url ? (
<Image
src={user.avatar_url}
alt=""
width={28}
height={28}
className="size-7 rounded-full object-cover ring-1 ring-foreground/10"
unoptimized
/>
) : (
<UserIcon className="size-5" />
),
matchPrefixes: ["/u/me", "/login", "/signup"], matchPrefixes: ["/u/me", "/login", "/signup"],
}, },
]; ];
+18
View File
@@ -25,6 +25,7 @@
*/ */
import { useState } from "react"; import { useState } from "react";
import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { import {
@@ -149,6 +150,7 @@ export function SiteSidebar() {
authed={!!user} authed={!!user}
handle={user?.handle ?? null} handle={user?.handle ?? null}
name={user?.name ?? null} name={user?.name ?? null}
avatarURL={user?.avatar_url ?? null}
active={pathname.startsWith("/u/me")} active={pathname.startsWith("/u/me")}
expandedOverride={readingOpen} expandedOverride={readingOpen}
/> />
@@ -242,12 +244,17 @@ function SidebarProfile({
authed, authed,
handle, handle,
name, name,
avatarURL,
active, active,
expandedOverride, expandedOverride,
}: { }: {
authed: boolean; authed: boolean;
handle: string | null; handle: string | null;
name: string | null; name: string | null;
/** Uploaded avatar URL — when set, the rail glyph becomes a small
* circular picture instead of the generic person icon so the user
* recognises their seat in the nav at a glance. */
avatarURL?: string | null;
active: boolean; active: boolean;
expandedOverride?: boolean; expandedOverride?: boolean;
}) { }) {
@@ -289,7 +296,18 @@ function SidebarProfile({
: "text-muted-foreground hover:text-foreground hover:bg-muted", : "text-muted-foreground hover:text-foreground hover:bg-muted",
)} )}
> >
{avatarURL ? (
<Image
src={avatarURL}
alt=""
width={20}
height={20}
className="size-5 rounded-full object-cover ring-1 ring-foreground/10 shrink-0"
unoptimized
/>
) : (
<UserIcon className="size-5 shrink-0" /> <UserIcon className="size-5 shrink-0" />
)}
<span <span
className={cn( className={cn(
"min-w-0 truncate transition-opacity duration-150", "min-w-0 truncate transition-opacity duration-150",
+39 -88
View File
@@ -1,13 +1,18 @@
"use client"; "use client";
/** /**
* EditCollectionDrawer — single-stop editor for a collection. * EditCollectionDrawer — edit the contents and metadata of a collection.
* *
* • Title, description, public/private toggle * • Title, description, public/private toggle
* • Cover image upload (signed-URL PUT to R2) * • 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) * • 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; * Right-side panel on `md+`, bottom drawer on mobile. The body scrolls;
* header and footer stay pinned so action buttons are always reachable. * 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 { useMediaQuery } from "@/components/selection-toolbar/shared";
import { import {
collections, collections,
posts as postsApi,
SocialError, SocialError,
type Collection, type Collection,
type Post, type Post,
@@ -58,17 +62,18 @@ export function EditCollectionDrawer({
open, open,
onClose, onClose,
collection, collection,
currentPostIds, currentPosts,
onSaved, onSaved,
onDeleted, onDeleted,
}: { }: {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
collection: Collection; collection: Collection;
/** Post ids currently in the collection, in display order. Used to /** Posts currently in this collection, in display order. The drawer
* seed the drag-sortable list and to diff against the user's * scopes its UI to this list — it does NOT pull the user's full
* selection + new ordering on save. */ * timeline. Adding new posts is done elsewhere (PostActionsFooter →
currentPostIds: string[]; * CollectionsDrawer). */
currentPosts: Post[];
onSaved: (next: SavedShape) => void; onSaved: (next: SavedShape) => void;
onDeleted: () => void; onDeleted: () => void;
}) { }) {
@@ -81,14 +86,13 @@ export function EditCollectionDrawer({
const [isPublic, setIsPublic] = useState(collection.is_public); const [isPublic, setIsPublic] = useState(collection.is_public);
const [coverURL, setCoverURL] = useState<string | null>(collection.cover_url); const [coverURL, setCoverURL] = useState<string | null>(collection.cover_url);
// Source of truth for the in-collection set + order: a plain array of // Source of truth for the in-collection order: a plain array of post
// post ids. We push/pull from this when toggling, and reorder it on // ids. Reorder updates it; toggling a checkbox removes the id. On
// drag. On save we diff against `currentPostIds` to find adds/removes // save we diff against the seed (`currentPosts`) to compute removes
// and push the final order to /items/order. // and push the new ordering to /items/order.
const [orderedIds, setOrderedIds] = useState<string[]>(currentPostIds); const [orderedIds, setOrderedIds] = useState<string[]>(() =>
currentPosts.map((p) => p.id),
const [allPosts, setAllPosts] = useState<Post[] | null>(null); );
const [loadingPosts, setLoadingPosts] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [uploadingCover, setUploadingCover] = useState(false); const [uploadingCover, setUploadingCover] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -100,38 +104,19 @@ export function EditCollectionDrawer({
setDescription(collection.description ?? ""); setDescription(collection.description ?? "");
setIsPublic(collection.is_public); setIsPublic(collection.is_public);
setCoverURL(collection.cover_url); setCoverURL(collection.cover_url);
setOrderedIds(currentPostIds); setOrderedIds(currentPosts.map((p) => p.id));
setError(null); setError(null);
}, [open, collection, currentPostIds]); }, [open, collection, currentPosts]);
useEffect(() => { // Ordered post objects to render in the sortable list. Indexed by id
if (!open || allPosts !== null) return; // since `orderedIds` is the live order and currentPosts is the data
setLoadingPosts(true); // table.
postsApi const inCollection = useMemo<Post[]>(() => {
.mine() const byId = new Map(currentPosts.map((p) => [p.id, p]));
.then((rows) => setAllPosts(rows ?? [])) return orderedIds
.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)) .map((id) => byId.get(id))
.filter((p): p is Post => !!p); .filter((p): p is Post => !!p);
const available = allPosts.filter((p) => !selected.has(p.id)); }, [currentPosts, orderedIds]);
return { inCollection, available };
}, [allPosts, orderedIds]);
function addToCollection(id: string) {
setOrderedIds((prev) => (prev.includes(id) ? prev : [...prev, id]));
}
function removeFromCollection(id: string) { function removeFromCollection(id: string) {
setOrderedIds((prev) => prev.filter((x) => x !== id)); setOrderedIds((prev) => prev.filter((x) => x !== id));
@@ -163,10 +148,13 @@ export function EditCollectionDrawer({
setSaving(true); setSaving(true);
setError(null); setError(null);
try { 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 after = new Set(orderedIds);
const toAdd = orderedIds.filter((id) => !before.has(id)); const toRemove = currentPosts
const toRemove = currentPostIds.filter((id) => !after.has(id)); .map((p) => p.id)
.filter((id) => !after.has(id));
await collections.update(collection.id, { await collections.update(collection.id, {
title, title,
@@ -175,11 +163,9 @@ export function EditCollectionDrawer({
cover_url: coverURL ?? "", cover_url: coverURL ?? "",
}); });
// Sequential mutations — the count trigger races on parallel calls. // 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) for (const id of toRemove)
await collections.removeItem(collection.id, id); await collections.removeItem(collection.id, id);
// Final position pass — overwrites whatever positions addItem // Final position pass — pushes the drag order to the server.
// assigned (MAX+1) so the user's drag order wins.
if (orderedIds.length > 0) { if (orderedIds.length > 0) {
await collections.reorderItems(collection.id, orderedIds); await collections.reorderItems(collection.id, orderedIds);
} }
@@ -342,11 +328,10 @@ export function EditCollectionDrawer({
{orderedIds.length} {orderedIds.length}
</span> </span>
</div> </div>
{loadingPosts ? ( {inCollection.length === 0 ? (
<p className="text-sm text-muted-foreground">Loading…</p>
) : inCollection.length === 0 ? (
<p className="text-sm text-muted-foreground"> <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
&ldquo;Save to collection&rdquo; button on any post.
</p> </p>
) : ( ) : (
<Reorder.Group <Reorder.Group
@@ -367,24 +352,6 @@ export function EditCollectionDrawer({
)} )}
</section> </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 ? ( {error ? (
<p className="text-sm text-destructive">{error}</p> <p className="text-sm text-destructive">{error}</p>
) : null} ) : 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 }) { function PostSummary({ post }: { post: Post }) {
return ( return (
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
Binary file not shown.