Initial commit

This commit is contained in:
admin
2026-05-26 22:46:00 +02:00
commit 7e2c2ff89c
256 changed files with 51523 additions and 0 deletions
@@ -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>
);
}