diff --git a/app/u/me/collections/[id]/page.tsx b/app/u/me/collections/[id]/page.tsx index 2acf3870..04e4a45d 100644 --- a/app/u/me/collections/[id]/page.tsx +++ b/app/u/me/collections/[id]/page.tsx @@ -79,7 +79,7 @@ export default function CollectionDetailPage(props: { open={editOpen} onClose={() => setEditOpen(false)} collection={c} - currentPostIds={posts.map((p) => p.id)} + currentPosts={posts} onSaved={({ collection, posts: rows }) => { setC(collection); setPosts(rows); diff --git a/components/selection-toolbar/compose-post.tsx b/components/selection-toolbar/compose-post.tsx index 8d1acb26..27abe988 100644 --- a/components/selection-toolbar/compose-post.tsx +++ b/components/selection-toolbar/compose-post.tsx @@ -33,6 +33,31 @@ import { useSharePost } from "@/components/social/share-post-provider"; const MAX_IMAGES = 5; 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({ open, onClose, @@ -128,6 +153,7 @@ export function ComposePost({ onPosted?.(); onClose(); router.refresh(); + playSuccessSound(); // Success feedback + offer to externally share. The BFF's Create // response doesn't include author info (no JOIN with users), but // SharePostSheet falls back to the authed user's handle when diff --git a/components/site-bottom-nav.tsx b/components/site-bottom-nav.tsx index 4060d8cd..840de41e 100644 --- a/components/site-bottom-nav.tsx +++ b/components/site-bottom-nav.tsx @@ -10,6 +10,7 @@ * Hidden on md+ — desktop uses SiteSidebar. */ +import Image from "next/image"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { Home, Search, User as UserIcon } from "lucide-react"; @@ -58,7 +59,24 @@ export function SiteBottomNav() { { href: user ? "/u/me" : "/login", label: user ? "You" : "Sign in", - icon: , + // 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 ? ( + + ) : ( + + ), matchPrefixes: ["/u/me", "/login", "/signup"], }, ]; diff --git a/components/site-sidebar.tsx b/components/site-sidebar.tsx index b1565d79..b0e099f0 100644 --- a/components/site-sidebar.tsx +++ b/components/site-sidebar.tsx @@ -25,6 +25,7 @@ */ import { useState } from "react"; +import Image from "next/image"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { @@ -149,6 +150,7 @@ export function SiteSidebar() { authed={!!user} handle={user?.handle ?? null} name={user?.name ?? null} + avatarURL={user?.avatar_url ?? null} active={pathname.startsWith("/u/me")} expandedOverride={readingOpen} /> @@ -242,12 +244,17 @@ function SidebarProfile({ authed, handle, name, + avatarURL, active, expandedOverride, }: { authed: boolean; handle: 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; expandedOverride?: boolean; }) { @@ -289,7 +296,18 @@ function SidebarProfile({ : "text-muted-foreground hover:text-foreground hover:bg-muted", )} > - + {avatarURL ? ( + + ) : ( + + )} 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(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(currentPostIds); - - const [allPosts, setAllPosts] = useState(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(() => + currentPosts.map((p) => p.id), + ); const [saving, setSaving] = useState(false); const [uploadingCover, setUploadingCover] = useState(false); const [error, setError] = useState(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(() => { + 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} - {loadingPosts ? ( -

Loading…

- ) : inCollection.length === 0 ? ( + {inCollection.length === 0 ? (

- 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.

) : ( - {/* Available posts */} - {available.length > 0 ? ( -
- -
    - {available.map((p) => ( - addToCollection(p.id)} - /> - ))} -
-
- ) : null} - {error ? (

{error}

) : null} @@ -474,22 +441,6 @@ function SortableRow({ ); } -function AvailableRow({ post, onAdd }: { post: Post; onAdd: () => void }) { - return ( -
  • - -
  • - ); -} - function PostSummary({ post }: { post: Post }) { return ( diff --git a/public/audio/success.mp3 b/public/audio/success.mp3 new file mode 100644 index 00000000..d05e6d71 Binary files /dev/null and b/public/audio/success.mp3 differ