chore: code refactor

This commit is contained in:
gosangam
2026-06-13 02:24:42 +05:30
parent cc5633c37a
commit 439664a97d
151 changed files with 1387 additions and 1138 deletions
@@ -0,0 +1,88 @@
"use client";
/**
* CollectionGridCard — square collection card for the 2-per-row grid view.
*
* • With cover: cover fills the square; bottom-up overlay for legibility.
* • Without cover: the same flat palette-aware surface that post cards
* fall back to — pure white on light themes, palette-specific dark
* contrast on dark themes (see `--quote-card-bg`).
*
* Visibility badge (Globe/Lock) sits top-right. Title + count read
* bottom-centre in white over the cover image's dark overlay; on the
* flat fallback they use `text-foreground` so they invert cleanly with
* the theme.
*/
import Image from "next/image";
import Link from "next/link";
import { Lock } from "lucide-react";
import { cn } from "@/lib/utils";
import type { Collection } from "@/lib/social";
import { quoteCardBgStyle } from "../post/post-card-shared";
export function CollectionGridCard({
collection: c,
href,
}: {
collection: Collection;
href: string;
}) {
const hasCover = !!c.cover_url;
return (
<Link
href={href}
className="group relative block aspect-square rounded-2xl overflow-hidden ring-1 ring-foreground/10 hover:ring-foreground/25 hover:shadow-md transition-all select-none"
>
{hasCover ? (
<>
<Image
src={c.cover_url!}
alt=""
fill
sizes="(max-width: 640px) 50vw, 320px"
className="object-cover"
unoptimized
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-transparent" />
</>
) : (
<div className="absolute inset-0" style={quoteCardBgStyle()} />
)}
{/* Visibility badge — black pill works on both image overlay and
flat surface (white or dark). */}
{c.is_public ? null : (
<div className="absolute top-3 right-3 inline-flex items-center gap-1 rounded-full bg-black/40 backdrop-blur-sm px-2.5 py-1 text-[0.6rem] uppercase tracking-[0.18em] font-medium text-white">
<Lock className="size-3" />
{"Private"}
</div>
)}
{/* Title + count — text colour follows the surface beneath. */}
<div
className={cn(
"absolute inset-0 flex flex-col items-center justify-center text-center px-5 sm:px-7",
hasCover ? "text-white" : "text-foreground",
)}
>
<p
className={cn(
"font-[family-name:var(--font-title)] text-lg sm:text-2xl font-semibold leading-snug line-clamp-4 break-words",
hasCover && "drop-shadow-sm",
)}
>
{c.title}
</p>
<p
className={cn(
"mt-2 text-[0.65rem] uppercase tracking-[0.22em] font-medium",
hasCover ? "text-white/85" : "text-muted-foreground",
)}
>
{c.item_count} post{c.item_count === 1 ? "" : "s"}
</p>
</div>
</Link>
);
}
@@ -0,0 +1,336 @@
"use client";
/**
* CollectionScrollCard — Instagram-style horizontal carousel for one
* collection.
*
* • Slide 1 is the collection's "cover" — cover image (or themed
* gradient if none) overlaid with the title and item count.
* • Slides 2..N are PostGridCards for each post in the collection.
*
* Posts are fetched lazily: an IntersectionObserver triggers
* `collections.get(id)` the first time the carousel scrolls into view,
* so a feed of many collections doesn't blow N+1 requests on mount.
*
* The carousel uses native CSS scroll-snap (horizontal, mandatory) so
* touch swipes feel right on mobile. A page-dot indicator below tracks
* the current slide.
*/
import { useEffect, useRef, useState } from "react";
import Image from "next/image";
import Link from "next/link";
import {
ChevronLeft,
ChevronRight,
Globe,
Loader2,
Lock,
Pencil,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { collections, type Collection, type Post } from "@/lib/social";
import { PostGridCard } from "../post/post-grid-card";
import { quoteCardBgStyle } from "../post/post-card-shared";
type FetchState = "idle" | "loading" | "loaded" | "error";
export function CollectionScrollCard({
collection: c,
href,
initialPosts,
onEdit,
}: {
collection: Collection;
/** Link target for the "Open" footer + cover slide click-through. */
href: string;
/** Pre-loaded posts — skip the lazy fetch when the parent already has
* them (e.g. the single-collection page renders one card after its
* own collections.get call). */
initialPosts?: Post[];
/** When provided, shows a compact Edit chip in the cover slide's
* bottom-right corner. Owner-only — caller decides whether to pass it. */
onEdit?: () => void;
}) {
const scrollRef = useRef<HTMLDivElement>(null);
const cardRef = useRef<HTMLElement>(null);
// `fetched` only holds posts pulled by the lazy IntersectionObserver
// fetch. When the parent supplies `initialPosts`, we derive `posts`
// from it directly — no mirror-into-state effect required, which means
// fresh data from the parent (e.g. after an edit) shows up on the very
// next render without a setState-cascade.
const [fetched, setFetched] = useState<Post[]>([]);
const [fetchState, setFetchState] = useState<FetchState>(
initialPosts ? "loaded" : "idle",
);
const [activeIndex, setActiveIndex] = useState(0);
const posts = initialPosts ?? fetched;
// Fetch posts the first time the carousel enters the viewport (with a
// 200px rootMargin so we kick it off just before it's visible). Skipped
// entirely when `initialPosts` was provided (fetchState starts at
// "loaded" in that case so the early-return below fires).
useEffect(() => {
if (fetchState !== "idle") return;
const el = cardRef.current;
if (!el) return;
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
observer.disconnect();
setFetchState("loading");
collections
.get(c.id)
.then(({ posts: rows }) => {
setFetched(rows ?? []);
setFetchState("loaded");
})
.catch(() => setFetchState("error"));
return;
}
}
},
{ rootMargin: "200px" },
);
observer.observe(el);
return () => observer.disconnect();
}, [c.id, fetchState]);
// Track which slide is currently snapped. We use scrollLeft / slideWidth
// rather than another observer because the slides are full-width siblings
// — much cheaper to compute.
function onScroll() {
const el = scrollRef.current;
if (!el) return;
const slideWidth = el.offsetWidth;
if (slideWidth === 0) return;
setActiveIndex(Math.round(el.scrollLeft / slideWidth));
}
function scrollByOne(direction: 1 | -1) {
const el = scrollRef.current;
if (!el) return;
el.scrollBy({ left: el.offsetWidth * direction, behavior: "smooth" });
}
const slideCount = 1 + posts.length;
const showArrows = slideCount > 1;
return (
<article ref={cardRef} className="space-y-3 select-none">
<div className="relative max-w-xl mx-auto">
<div
ref={scrollRef}
onScroll={onScroll}
className={cn(
"flex overflow-x-auto snap-x snap-mandatory rounded-2xl",
// Hide scrollbar — the carousel is gesture-driven; the dot
// indicator below tracks position.
"[scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
)}
>
{/* Cover slide */}
<CoverSlide collection={c} href={href} onEdit={onEdit} />
{/* Post slides (or placeholders while loading) */}
{fetchState === "loaded" &&
posts.map((p) => (
<div
key={p.id}
className="w-full shrink-0 snap-start p-px"
aria-roledescription="slide"
>
<PostGridCard post={p} />
</div>
))}
{fetchState === "loading" ? (
<div className="w-full shrink-0 snap-start aspect-square rounded-2xl bg-card ring-1 ring-foreground/10 flex items-center justify-center text-muted-foreground">
<Loader2 className="size-5 animate-spin" />
</div>
) : null}
</div>
{showArrows ? (
<>
<CarouselArrow
direction="prev"
onClick={() => scrollByOne(-1)}
disabled={activeIndex === 0}
/>
<CarouselArrow
direction="next"
onClick={() => scrollByOne(1)}
disabled={activeIndex >= slideCount - 1}
/>
</>
) : null}
</div>
{/* Dot indicator */}
{slideCount > 1 ? (
<div className="flex items-center justify-center gap-1.5">
{Array.from({ length: slideCount }).map((_, i) => (
<span
key={i}
className={cn(
"rounded-full transition-all",
i === activeIndex
? "size-1.5 bg-foreground"
: "size-1.5 bg-muted-foreground/40",
)}
aria-hidden
/>
))}
</div>
) : null}
</article>
);
}
// ─── Slide pieces ────────────────────────────────────────────────────────
function CoverSlide({
collection: c,
href,
onEdit,
}: {
collection: Collection;
href: string;
onEdit?: () => void;
}) {
// The slide wrapper hosts the Link AND any owner-action chip as
// siblings. Nesting a button inside the Link is an a11y anti-pattern
// (interactive nested in interactive), so we keep them peers and let
// DOM z-order put the chip on top of the Link's bottom-right corner.
return (
<div className="w-full shrink-0 snap-start relative aspect-square">
<Link
href={href}
className="absolute inset-0 rounded-2xl overflow-hidden ring-1 ring-foreground/10 select-none"
aria-label={`Open collection ${c.title}`}
>
{c.cover_url ? (
<>
<Image
src={c.cover_url}
alt=""
fill
sizes="(max-width: 768px) 100vw, 640px"
className="object-cover"
unoptimized
/>
{/* Light scrim — enough darkness at the bottom for the title
and post-count line to read, but the top stays close to
the original image so the cover photo isn't muted into a
near-black plate. */}
<div className="absolute inset-0 bg-linear-to-t from-black/75 via-black/40 to-black/15" />
</>
) : (
<div className="absolute inset-0" style={quoteCardBgStyle()} />
)}
<div className="absolute top-4 right-4 inline-flex items-center gap-1.5 rounded-full bg-black/40 backdrop-blur-sm px-3 py-1 text-[0.6rem] uppercase tracking-[0.18em] font-medium text-white">
{c.is_public ? (
<Globe className="size-3" />
) : (
<Lock className="size-3" />
)}
Collection
</div>
<div
className={cn(
"absolute inset-0 flex flex-col p-6 sm:p-8",
// Text colour follows the surface underneath: white over the
// image's dark overlay, theme foreground over the flat
// fallback so it inverts cleanly with the palette.
c.cover_url ? "text-white" : "text-foreground",
c.cover_url
? "justify-end"
: "justify-center items-center text-center",
)}
>
<p
className={cn(
"font-(family-name:--font-title) text-2xl sm:text-3xl font-semibold leading-tight line-clamp-3 wrap-break-words",
c.cover_url && "drop-shadow-md",
)}
>
{c.title}
</p>
{c.description ? (
<p
className={cn(
"mt-2 text-sm line-clamp-2",
c.cover_url
? "text-white/85 max-w-md"
: "text-muted-foreground max-w-md mx-auto",
)}
>
{c.description}
</p>
) : null}
<p
className={cn(
"mt-3 text-[0.7rem] uppercase tracking-[0.22em] font-semibold",
c.cover_url ? "text-white/75" : "text-muted-foreground",
)}
>
{c.item_count} post{c.item_count === 1 ? "" : "s"} · Swipe to browse
→
</p>
</div>
</Link>
{onEdit ? (
<button
type="button"
onClick={onEdit}
aria-label="Edit collection"
title="Edit collection"
className={cn(
"absolute bottom-3 right-3 z-10 inline-flex items-center gap-1",
"rounded-full bg-black/50 backdrop-blur-sm hover:bg-black/70",
"text-white text-[0.7rem] font-medium uppercase tracking-[0.16em]",
"px-2.5 py-1 transition-colors",
)}
>
<Pencil className="size-3" />
Edit
</button>
) : null}
</div>
);
}
function CarouselArrow({
direction,
onClick,
disabled,
}: {
direction: "prev" | "next";
onClick: () => void;
disabled: boolean;
}) {
const Icon = direction === "prev" ? ChevronLeft : ChevronRight;
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
aria-label={direction === "prev" ? "Previous slide" : "Next slide"}
className={cn(
"hidden md:inline-flex absolute top-1/2 -translate-y-1/2 size-9 rounded-full",
"bg-black/40 text-white items-center justify-center backdrop-blur-sm",
"hover:bg-black/60 transition-all",
"disabled:opacity-0 disabled:pointer-events-none",
direction === "prev" ? "left-3" : "right-3",
)}
>
<Icon className="size-4" />
</button>
);
}
@@ -0,0 +1,38 @@
"use client";
/**
* CollectionViews — grid-only renderer for collection lists.
*
* `hrefFor(c)` chooses where each card links: the owner page sends users
* to /u/me/collections/<id>; the public profile sends them to /c/<id>.
* The carousel "scroll view" is reached by clicking through to the
* collection detail page.
*/
import type { Collection } from "@/lib/social";
import { CollectionGridCard } from "./collection-grid-card";
export function CollectionViews({
collections,
hrefFor,
emptyMessage,
}: {
collections: Collection[];
hrefFor: (c: Collection) => string;
emptyMessage?: string;
}) {
if (collections.length === 0) {
return (
<p className="text-sm text-muted-foreground">
{emptyMessage ?? "No collections yet."}
</p>
);
}
return (
<div className="grid grid-cols-2 gap-3 sm:gap-4">
{collections.map((c) => (
<CollectionGridCard key={c.id} collection={c} href={hrefFor(c)} />
))}
</div>
);
}
@@ -0,0 +1,457 @@
"use client";
/**
* 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, 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.
*
* Drag-and-drop is powered by framer-motion's Reorder primitives so we
* get touch + mouse support without an extra dependency. Each row has a
* dedicated grip handle (Reorder.Item with `dragListener={false}` plus
* `dragControls`) so the rest of the row stays clickable for toggling.
*/
import { useEffect, useMemo, useRef, useState } from "react";
import Image from "next/image";
import { Reorder, useDragControls } from "framer-motion";
import {
Globe,
GripVertical,
ImagePlus,
Loader2,
Lock,
Trash2,
X,
} from "lucide-react";
import { toast } from "sonner";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerTitle,
} from "@/components/ui/drawer";
import { Checkbox } from "@/components/ui/checkbox";
import { ConfirmModal } from "@/components/ui/confirm-modal";
import { useMediaQuery } from "@/components/selection-toolbar/shared";
import {
collections,
SocialError,
type Collection,
type Post,
} from "@/lib/social";
import { compressImage } from "@/lib/compress-image";
type SavedShape = {
collection: Collection;
posts: Post[];
};
export function EditCollectionDrawer({
open,
onClose,
collection,
currentPosts,
onSaved,
onDeleted,
}: {
open: boolean;
onClose: () => void;
collection: Collection;
/** 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;
}) {
const isMobile = useMediaQuery("(max-width: 767px)");
const direction: "bottom" | "right" = isMobile ? "bottom" : "right";
const fileInput = useRef<HTMLInputElement>(null);
const [title, setTitle] = useState(collection.title);
const [description, setDescription] = useState(collection.description ?? "");
const [isPublic, setIsPublic] = useState(collection.is_public);
const [coverURL, setCoverURL] = useState<string | null>(collection.cover_url);
// 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);
const [confirmDelete, setConfirmDelete] = useState(false);
useEffect(() => {
if (!open) return;
setTitle(collection.title);
setDescription(collection.description ?? "");
setIsPublic(collection.is_public);
setCoverURL(collection.cover_url);
setOrderedIds(currentPosts.map((p) => p.id));
setError(null);
}, [open, collection, currentPosts]);
// 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);
}, [currentPosts, orderedIds]);
function removeFromCollection(id: string) {
setOrderedIds((prev) => prev.filter((x) => x !== id));
}
async function onCoverChange(e: React.ChangeEvent<HTMLInputElement>) {
const f = e.target.files?.[0];
if (fileInput.current) fileInput.current.value = "";
if (!f) return;
setUploadingCover(true);
try {
const compressed = await compressImage(f);
const url = await collections.uploadCover(compressed);
setCoverURL(url);
} catch (err) {
toast.error(
err instanceof SocialError ? err.message : "Cover upload failed.",
);
} finally {
setUploadingCover(false);
}
}
function removeCover() {
setCoverURL(null);
}
async function save() {
setSaving(true);
setError(null);
try {
// 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 toRemove = currentPosts
.map((p) => p.id)
.filter((id) => !after.has(id));
await collections.update(collection.id, {
title,
description,
is_public: isPublic,
cover_url: coverURL ?? "",
});
// Sequential mutations — the count trigger races on parallel calls.
for (const id of toRemove)
await collections.removeItem(collection.id, id);
// Final position pass — pushes the drag order to the server.
if (orderedIds.length > 0) {
await collections.reorderItems(collection.id, orderedIds);
}
const fresh = await collections.get(collection.id);
onSaved({ collection: fresh.collection, posts: fresh.posts ?? [] });
toast.success("Collection updated");
onClose();
} catch (err) {
setError(err instanceof SocialError ? err.message : "Save failed.");
} finally {
setSaving(false);
}
}
async function performDelete() {
try {
await collections.delete(collection.id);
onDeleted();
} catch (err) {
throw new Error(
err instanceof SocialError ? err.message : "Could not delete.",
);
}
}
return (
<>
<Drawer
open={open}
onOpenChange={(o) => !o && !saving && onClose()}
direction={direction}
shouldScaleBackground={direction === "bottom"}
>
<DrawerContent
direction={direction}
className={
direction === "bottom"
? "max-h-[90vh]"
: "max-w-md sm:max-w-lg"
}
>
<DrawerHeader className="shrink-0 text-left flex items-center justify-between pb-2 select-none">
<DrawerTitle>Edit collection</DrawerTitle>
<DrawerClose asChild>
<button
type="button"
aria-label="Close"
disabled={saving}
className="size-8 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted inline-flex items-center justify-center disabled:opacity-50"
>
<X className="size-4" />
</button>
</DrawerClose>
</DrawerHeader>
<div className="flex-1 min-h-0 overflow-y-auto px-4 pb-4 space-y-5 select-none">
{/* Cover */}
<section className="flex items-start gap-4">
<div className="relative size-24 rounded-lg overflow-hidden bg-muted ring-1 ring-foreground/10 shrink-0">
{coverURL ? (
<Image
src={coverURL}
alt=""
fill
sizes="96px"
className="object-cover"
unoptimized
/>
) : (
<span className="absolute inset-0 flex items-center justify-center text-[0.6rem] uppercase tracking-[0.2em] text-muted-foreground text-center px-2">
No cover
</span>
)}
</div>
<div className="flex-1 flex flex-col gap-2 min-w-0">
<input
ref={fileInput}
type="file"
accept="image/png,image/jpeg,image/webp,image/gif"
hidden
onChange={onCoverChange}
/>
<button
type="button"
onClick={() => fileInput.current?.click()}
disabled={uploadingCover}
className="inline-flex items-center gap-1.5 rounded-md px-3 h-9 text-sm text-foreground hover:bg-muted border border-border w-fit disabled:opacity-50"
>
{uploadingCover ? (
<Loader2 className="size-4 animate-spin" />
) : (
<ImagePlus className="size-4" />
)}
{coverURL ? "Change cover" : "Add cover"}
</button>
{coverURL ? (
<button
type="button"
onClick={removeCover}
className="text-xs text-muted-foreground hover:text-destructive w-fit"
>
Remove cover
</button>
) : null}
</div>
</section>
{/* Title */}
<section className="space-y-1">
<label className="text-xs uppercase tracking-[0.18em] font-semibold text-muted-foreground">
Title
</label>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
maxLength={120}
className="w-full bg-background text-foreground text-base rounded-md border border-border px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-primary/40 select-text"
/>
</section>
{/* Description */}
<section className="space-y-1">
<label className="text-xs uppercase tracking-[0.18em] font-semibold text-muted-foreground">
Description
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
placeholder="Optional"
className="w-full bg-background text-foreground text-sm rounded-md border border-border px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-primary/40 resize-none select-text"
/>
</section>
{/* Visibility */}
<section>
<label className="flex items-center gap-2.5 text-sm cursor-pointer text-foreground">
<Checkbox
checked={isPublic}
onCheckedChange={setIsPublic}
ariaLabel="Public collection"
/>
{isPublic ? (
<Globe className="size-4 text-muted-foreground" />
) : (
<Lock className="size-4 text-muted-foreground" />
)}
Public — anyone with the link can view
</label>
</section>
{/* Posts in this collection (drag-sortable) */}
<section className="space-y-2 pt-2">
<div className="flex items-baseline justify-between">
<label className="text-xs uppercase tracking-[0.18em] font-semibold text-muted-foreground">
In this collection
</label>
<span className="text-xs text-muted-foreground tabular-nums">
{orderedIds.length}
</span>
</div>
{inCollection.length === 0 ? (
<p className="text-sm text-muted-foreground">
No posts in this collection yet. Add posts via the
&ldquo;Save to collection&rdquo; button on any post.
</p>
) : (
<Reorder.Group
axis="y"
values={orderedIds}
onReorder={setOrderedIds}
className="rounded-lg border border-border divide-y divide-border bg-background"
as="ul"
>
{inCollection.map((p) => (
<SortableRow
key={p.id}
post={p}
onRemove={() => removeFromCollection(p.id)}
/>
))}
</Reorder.Group>
)}
</section>
{error ? (
<p className="text-sm text-destructive">{error}</p>
) : null}
</div>
{/* Footer actions */}
<div className="shrink-0 border-t border-border p-4 flex items-center justify-between gap-2">
<button
type="button"
onClick={() => setConfirmDelete(true)}
disabled={saving}
className="inline-flex items-center gap-1.5 rounded-md px-3 h-10 text-sm text-destructive hover:bg-destructive/10 disabled:opacity-50"
>
<Trash2 className="size-4" />
Delete
</button>
<div className="flex items-center gap-2">
<button
type="button"
onClick={onClose}
disabled={saving}
className="inline-flex items-center rounded-md px-4 h-10 text-sm text-foreground hover:bg-muted disabled:opacity-50"
>
Cancel
</button>
<button
type="button"
onClick={save}
disabled={saving || uploadingCover || title.trim().length === 0}
className="inline-flex items-center gap-1.5 rounded-md px-4 h-10 bg-primary text-primary-foreground text-sm hover:bg-primary/90 disabled:opacity-60"
>
{saving ? "Saving…" : "Save"}
</button>
</div>
</div>
</DrawerContent>
</Drawer>
<ConfirmModal
open={confirmDelete}
onClose={() => setConfirmDelete(false)}
title={`Delete "${collection.title}"?`}
message="The collection will be removed permanently. The posts inside stay on your feed."
confirmLabel="Delete collection"
onConfirm={performDelete}
/>
</>
);
}
// ─── Row pieces ──────────────────────────────────────────────────────────
function SortableRow({
post,
onRemove,
}: {
post: Post;
onRemove: () => void;
}) {
// dragListener={false} disables the default whole-item drag — only the
// grip handle starts a drag via dragControls.start.
const controls = useDragControls();
return (
<Reorder.Item
value={post.id}
dragListener={false}
dragControls={controls}
className="flex items-start gap-3 p-3 hover:bg-muted/40 transition-colors bg-background select-none"
>
<button
type="button"
onPointerDown={(e) => controls.start(e)}
aria-label="Drag to reorder"
title="Drag to reorder"
className="mt-0.5 text-muted-foreground hover:text-foreground cursor-grab active:cursor-grabbing touch-none"
>
<GripVertical className="size-4" />
</button>
<Checkbox
checked
onCheckedChange={() => onRemove()}
ariaLabel="Remove from collection"
className="mt-0.5"
/>
<PostSummary post={post} />
</Reorder.Item>
);
}
function PostSummary({ post }: { post: Post }) {
return (
<span className="min-w-0 flex-1">
<span className="block text-sm text-foreground line-clamp-2 break-words">
{post.caption}
</span>
{post.source_title ? (
<span className="block text-[0.65rem] uppercase tracking-[0.18em] text-muted-foreground mt-1 truncate">
{post.source_title}
</span>
) : null}
</span>
);
}