collection drawer ui fix
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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"],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
“Save to collection” 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.
Reference in New Issue
Block a user