"use client"; import { useEffect, useState, useTransition } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { Check, FolderHeart, Pencil, Type, X } from "lucide-react"; import { reorderCollection, renameCollection } from "@/app/actions/collections"; import { thumbUrl, collectionCoverUrl } from "@/lib/assetUrls"; import { cn } from "@/lib/utils"; import { CollectionSelectionProvider, useCollectionSelection } from "./CollectionSelectionProvider"; import { CollectionCoverEditor } from "./CollectionCoverEditor"; interface CollectionListItem { id: number; name: string; slug: string; description: string | null; cover_thumb: string | null; first_thumb: string | null; count: number; coverPortraitPath: string | null; coverPortraitZoom: number; coverPortraitOffsetX: number; coverPortraitOffsetY: number; coverLandscapePath: string | null; coverLandscapeZoom: number; coverLandscapeOffsetX: number; coverLandscapeOffsetY: number; } const PHI = 1.618; // Canonical frame widths used by the cover editor preview. The card // reproduces editor offsets via `cqw` so a 50px offset on a 583px-wide // editor preview scales correctly on a 430px-wide grid card. const FRAME_H = 360; const CANONICAL_PORTRAIT_W = Math.round(FRAME_H / PHI); const CANONICAL_LANDSCAPE_W = Math.round(FRAME_H * PHI); export function ReorderableCollectionsIndex({ items, view = "landscape", }: { items: CollectionListItem[]; view?: "portrait" | "landscape"; }) { return ( ); } function Inner({ items: initial, view }: { items: CollectionListItem[]; view: "portrait" | "landscape" }) { const sel = useCollectionSelection(); const [items, setItems] = useState(initial); const [draggingId, setDraggingId] = useState(null); const [dropBeforeId, setDropBeforeId] = useState(null); const [editing, setEditing] = useState(null); const [renamingId, setRenamingId] = useState(null); const [draftName, setDraftName] = useState(""); const [renamePending, setRenamePending] = useState(false); const [, start] = useTransition(); const router = useRouter(); function startRename(c: CollectionListItem) { setRenamingId(c.id); setDraftName(c.name); } function cancelRename() { setRenamingId(null); setDraftName(""); } async function commitRename(c: CollectionListItem) { const next = draftName.trim(); if (!next || next === c.name) { cancelRename(); return; } setRenamePending(true); await renameCollection(c.id, next); setRenamePending(false); setRenamingId(null); setDraftName(""); router.refresh(); } // Reconcile when the prop changes (after server refresh). Includes // cover-art fields so save-and-router.refresh() picks up new // transforms even when the row order is unchanged. Runs in an effect // (not during render) so we never call setState mid-render. useEffect(() => { setItems(initial); }, [initial]); function onDragStart(id: number, e: React.DragEvent) { setDraggingId(id); e.dataTransfer.effectAllowed = "move"; e.dataTransfer.setData("text/plain", String(id)); } function onDragOverCard(id: number, e: React.DragEvent) { if (draggingId == null) return; e.preventDefault(); setDropBeforeId(id); } function onDragOverEnd(e: React.DragEvent) { if (draggingId == null) return; e.preventDefault(); setDropBeforeId("end"); } function commitDrop() { if (draggingId == null || dropBeforeId == null) { setDraggingId(null); setDropBeforeId(null); return; } const movedId = draggingId; const beforeId = dropBeforeId === "end" ? null : dropBeforeId; if (movedId === beforeId) { setDraggingId(null); setDropBeforeId(null); return; } setItems((cur) => { const fromIdx = cur.findIndex((x) => x.id === movedId); if (fromIdx === -1) return cur; const moved = cur[fromIdx]; const without = [...cur.slice(0, fromIdx), ...cur.slice(fromIdx + 1)]; let toIdx = beforeId == null ? without.length : without.findIndex((x) => x.id === beforeId); if (toIdx === -1) toIdx = without.length; return [...without.slice(0, toIdx), moved, ...without.slice(toIdx)]; }); setDraggingId(null); setDropBeforeId(null); start(async () => { try { await reorderCollection(movedId, beforeId); router.refresh(); } catch (err) { // Revert the optimistic reorder so the UI doesn't lie about // the persisted order. console.error("[reorderCollection] failed:", err); setItems(initial); } }); } function onDragEnd() { setDraggingId(null); setDropBeforeId(null); } const isLandscape = view === "landscape"; const gridCls = isLandscape ? "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4" : "grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7 gap-4"; return ( <>
{ if (draggingId != null) e.preventDefault(); }} onDrop={commitDrop} onDragEnd={onDragEnd} > {items.map((c) => { const customPath = isLandscape ? c.coverLandscapePath : c.coverPortraitPath; const customZoom = isLandscape ? c.coverLandscapeZoom : c.coverPortraitZoom; const customOffsetX = isLandscape ? c.coverLandscapeOffsetX : c.coverPortraitOffsetX; const customOffsetY = isLandscape ? c.coverLandscapeOffsetY : c.coverPortraitOffsetY; const fallbackThumb = c.cover_thumb ?? c.first_thumb; const selected = sel.has(c.id); const anySelected = sel.ids.size > 0; return (
onDragStart(c.id, e)} onDragOver={(e) => onDragOverCard(c.id, e)} onDragEnd={onDragEnd} className={cn( "relative group/drag", draggingId === c.id && "opacity-40", dropBeforeId === c.id && "ring-2 ring-[var(--color-cyan)] ring-offset-2 ring-offset-[var(--color-bg-0)] rounded-2xl", selected && "ring-2 ring-[var(--color-cyan)] shadow-[var(--shadow-glow-cyan)] rounded-2xl", anySelected && !selected && "opacity-70 hover:opacity-100", )} > { if (anySelected) { e.preventDefault(); sel.toggle(c.id); } }} className="group glass glass-hover rounded-2xl overflow-hidden block" >
{customPath ? ( /* eslint-disable-next-line @next/next/no-img-element */ ) : fallbackThumb ? ( /* eslint-disable-next-line @next/next/no-img-element */ ) : (
)}
{c.count} Item{c.count === 1 ? "" : "s"}
{renamingId === c.id ? (
{ e.preventDefault(); e.stopPropagation(); }} onMouseDown={(e) => e.stopPropagation()} > setDraftName(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); commitRename(c); } if (e.key === "Escape") { e.preventDefault(); cancelRename(); } }} disabled={renamePending} maxLength={120} className="flex-1 min-w-0 h-full glass rounded-md px-2 text-sm outline-none focus:border-[var(--color-cyan)]" />
) : (
{c.name}
)} {c.description && (
{c.description}
)}
); })}
{draggingId != null ? "Drop here for end of list" : ""}
{editing && ( setEditing(null)} /> )} ); }