Initial commit
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
"use client";
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { GripVertical } from "lucide-react";
|
||||
import { ImageCard, type CardImage } from "@/components/grid/ImageCard";
|
||||
import { reorderCollectionImage } from "@/app/actions/collections";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Drag-and-drop reorderable grid. Wraps each ImageCard in a draggable
|
||||
* container; on drop, computes which image should be placed before
|
||||
* which (or at the end) and calls the server action. The grid uses the
|
||||
* same column count as the regular MasonryGrid so visuals match.
|
||||
*/
|
||||
export function ReorderableCollectionGrid({
|
||||
images,
|
||||
collectionId,
|
||||
view = "landscape",
|
||||
}: {
|
||||
images: CardImage[];
|
||||
collectionId: number;
|
||||
view?: "portrait" | "landscape";
|
||||
}) {
|
||||
// Local state mirrors the prop so we can apply optimistic reordering
|
||||
// before the server round-trips. Drift is reconciled when the page
|
||||
// re-fetches via router.refresh().
|
||||
const [items, setItems] = useState(images);
|
||||
const [draggingId, setDraggingId] = useState<number | null>(null);
|
||||
const [dropBeforeId, setDropBeforeId] = useState<number | "end" | null>(null);
|
||||
const [, start] = useTransition();
|
||||
const router = useRouter();
|
||||
|
||||
if (images.length === 0) return null;
|
||||
|
||||
// Reconcile when the prop changes (e.g. after a refresh).
|
||||
if (items.length !== images.length || items.some((it, i) => it.id !== images[i]?.id)) {
|
||||
setItems(images);
|
||||
}
|
||||
|
||||
const cols = view === "portrait" ? "6" : "var(--grid-cols, 3)";
|
||||
|
||||
function onDragStart(id: number, e: React.DragEvent) {
|
||||
setDraggingId(id);
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
// Firefox needs setData to actually begin drag.
|
||||
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;
|
||||
}
|
||||
|
||||
// Optimistic local reorder.
|
||||
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 () => {
|
||||
await reorderCollectionImage(collectionId, movedId, beforeId);
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
function onDragEnd() {
|
||||
setDraggingId(null);
|
||||
setDropBeforeId(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="grid gap-5"
|
||||
style={{ gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))` }}
|
||||
onDragOver={(e) => {
|
||||
// Required for the drop event to fire on this container at all
|
||||
// — without preventDefault on dragover, browsers treat the area
|
||||
// as a non-drop-target.
|
||||
if (draggingId != null) e.preventDefault();
|
||||
}}
|
||||
onDrop={commitDrop}
|
||||
onDragEnd={onDragEnd}
|
||||
>
|
||||
{items.map((img) => (
|
||||
<div
|
||||
key={img.id}
|
||||
draggable
|
||||
onDragStart={(e) => onDragStart(img.id, e)}
|
||||
onDragOver={(e) => onDragOverCard(img.id, e)}
|
||||
onDragEnd={onDragEnd}
|
||||
className={cn(
|
||||
"relative group/drag",
|
||||
draggingId === img.id && "opacity-40",
|
||||
dropBeforeId === img.id && "ring-2 ring-[var(--color-cyan)] ring-offset-2 ring-offset-[var(--color-bg-0)] rounded-2xl",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className="absolute top-3 left-3 z-20 w-7 h-7 grid place-items-center rounded-md bg-black/60 backdrop-blur-md border border-white/15 text-white/80 cursor-grab active:cursor-grabbing opacity-0 group-hover/drag:opacity-100 transition-opacity pointer-events-none"
|
||||
aria-label="Drag handle"
|
||||
title="Drag to reorder"
|
||||
>
|
||||
<GripVertical className="w-3.5 h-3.5" />
|
||||
</span>
|
||||
<ImageCard image={img} view={view} />
|
||||
</div>
|
||||
))}
|
||||
{/* Sentinel drop zone for "end of list" */}
|
||||
<div
|
||||
onDragOver={onDragOverEnd}
|
||||
className={cn(
|
||||
"min-h-[100px] rounded-2xl border-2 border-dashed flex items-center justify-center text-xs text-[var(--color-fg-muted)] transition-colors",
|
||||
dropBeforeId === "end"
|
||||
? "border-[var(--color-cyan)] bg-[var(--color-cyan)]/5 text-[var(--color-cyan)]"
|
||||
: "border-[var(--color-glass-border)]",
|
||||
)}
|
||||
>
|
||||
{draggingId != null ? "Drop here for end of list" : ""}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user