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