Initial commit
This commit is contained in:
@@ -0,0 +1,336 @@
|
||||
"use client";
|
||||
import { useEffect, useRef, useState, useTransition } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { X, Upload, Trash2, Loader2, Move, ZoomIn, ZoomOut, RotateCcw } from "lucide-react";
|
||||
import { setCollectionCoverTransform, clearCollectionCover, type CollectionCoverSlot } from "@/app/actions/collectionCover";
|
||||
import { collectionCoverUrl } from "@/lib/assetUrls";
|
||||
|
||||
interface CoverState {
|
||||
path: string | null;
|
||||
zoom: number;
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
collectionId: number;
|
||||
collectionName: string;
|
||||
initial: { portrait: CoverState; landscape: CoverState };
|
||||
initialSlot?: CollectionCoverSlot;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const PHI = 1.618;
|
||||
const FRAME_H = 360;
|
||||
const PORTRAIT_H = FRAME_H;
|
||||
const PORTRAIT_W = Math.round(FRAME_H / PHI);
|
||||
const LANDSCAPE_H = FRAME_H;
|
||||
const LANDSCAPE_W = Math.round(FRAME_H * PHI);
|
||||
|
||||
const SLOT_LABELS: Record<CollectionCoverSlot, string> = { portrait: "P", landscape: "L" };
|
||||
const SLOT_KEYS: CollectionCoverSlot[] = ["portrait", "landscape"];
|
||||
|
||||
export function CollectionCoverEditor({ collectionId, collectionName, initial, initialSlot = "portrait", onClose }: Props) {
|
||||
const router = useRouter();
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [slot, setSlot] = useState<CollectionCoverSlot>(initialSlot);
|
||||
const [slots, setSlots] = useState(initial);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [, _start] = useTransition();
|
||||
void _start;
|
||||
const dragRef = useRef<{ x: number; y: number; ox: number; oy: number } | null>(null);
|
||||
|
||||
const cur = slots[slot];
|
||||
const isLandscape = slot === "landscape";
|
||||
const W = isLandscape ? LANDSCAPE_W : PORTRAIT_W;
|
||||
const H = isLandscape ? LANDSCAPE_H : PORTRAIT_H;
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
const ACCEPTED = new Set(["image/jpeg", "image/png", "image/webp"]);
|
||||
const onPaste = (e: ClipboardEvent) => {
|
||||
if (busy) return;
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items) return;
|
||||
let imageItem: DataTransferItem | null = null;
|
||||
let unsupported: string | null = null;
|
||||
for (const it of items) {
|
||||
if (it.kind !== "file") continue;
|
||||
if (ACCEPTED.has(it.type)) { imageItem = it; break; }
|
||||
if (it.type.startsWith("image/")) unsupported = it.type;
|
||||
}
|
||||
if (imageItem) {
|
||||
e.preventDefault();
|
||||
const file = imageItem.getAsFile();
|
||||
if (file) uploadFile(file);
|
||||
} else if (unsupported) {
|
||||
e.preventDefault();
|
||||
setError("Unsupported image format — paste JPEG, PNG, or WebP");
|
||||
}
|
||||
};
|
||||
window.addEventListener("paste", onPaste);
|
||||
return () => window.removeEventListener("paste", onPaste);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [busy, slot]);
|
||||
|
||||
function patchSlot(patch: Partial<CoverState>) {
|
||||
setSlots((s) => ({ ...s, [slot]: { ...s[slot], ...patch } }));
|
||||
}
|
||||
|
||||
async function uploadFile(file: File) {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
const res = await fetch(`/api/collection-cover/${collectionId}?slot=${slot}`, { method: "POST", body: fd });
|
||||
if (!res.ok) {
|
||||
const j = await res.json().catch(() => ({}));
|
||||
throw new Error(j.error ?? `upload failed (${res.status})`);
|
||||
}
|
||||
const j = await res.json();
|
||||
patchSlot({ path: j.coverPath, zoom: 1, offsetX: 0, offsetY: 0 });
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerDown(e: React.PointerEvent) {
|
||||
if (!cur.path) return;
|
||||
(e.target as Element).setPointerCapture(e.pointerId);
|
||||
dragRef.current = { x: e.clientX, y: e.clientY, ox: cur.offsetX, oy: cur.offsetY };
|
||||
}
|
||||
function onPointerMove(e: React.PointerEvent) {
|
||||
if (!dragRef.current) return;
|
||||
const dx = e.clientX - dragRef.current.x;
|
||||
const dy = e.clientY - dragRef.current.y;
|
||||
patchSlot({ offsetX: dragRef.current.ox + dx, offsetY: dragRef.current.oy + dy });
|
||||
}
|
||||
function onPointerUp() { dragRef.current = null; }
|
||||
|
||||
const previewRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
const el = previewRef.current;
|
||||
if (!el) return;
|
||||
const handler = (e: WheelEvent) => {
|
||||
if (!cur.path) return;
|
||||
e.preventDefault();
|
||||
const factor = e.deltaY < 0 ? 1.08 : 1 / 1.08;
|
||||
patchSlot({ zoom: Math.max(0.5, Math.min(5, cur.zoom * factor)) });
|
||||
};
|
||||
el.addEventListener("wheel", handler, { passive: false });
|
||||
return () => el.removeEventListener("wheel", handler);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [cur.path, cur.zoom, slot]);
|
||||
|
||||
async function save() {
|
||||
setBusy(true);
|
||||
try {
|
||||
await setCollectionCoverTransform(collectionId, slot, {
|
||||
zoom: cur.zoom, offsetX: cur.offsetX, offsetY: cur.offsetY,
|
||||
});
|
||||
router.refresh();
|
||||
onClose();
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function reset() { patchSlot({ zoom: 1, offsetX: 0, offsetY: 0 }); }
|
||||
|
||||
async function removeCover() {
|
||||
if (!confirm(`Remove ${SLOT_LABELS[slot]} cover for "${collectionName}"?`)) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await clearCollectionCover(collectionId, slot);
|
||||
patchSlot({ path: null, zoom: 1, offsetX: 0, offsetY: 0 });
|
||||
router.refresh();
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof document === "undefined") return null;
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-x-0 bottom-0 top-16 z-50 flex items-center justify-center px-4 py-4 fade-in"
|
||||
onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
>
|
||||
<div
|
||||
className="bg-[var(--color-bg-0)] rounded-2xl border border-[var(--color-glass-border)] shadow-2xl p-4"
|
||||
style={{ width: `min(${isLandscape ? 800 : 480}px, calc(100vw - 32px))` }}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[10px] uppercase tracking-wider font-mono text-[var(--color-fg-muted)]">Collection Cover</div>
|
||||
<div className="text-base font-medium truncate">{collectionName}</div>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-[var(--color-fg-muted)] hover:text-[var(--color-fg)] shrink-0">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 mb-3">
|
||||
{SLOT_KEYS.map((k) => {
|
||||
const active = k === slot;
|
||||
const has = !!slots[k].path;
|
||||
return (
|
||||
<button
|
||||
key={k}
|
||||
type="button"
|
||||
onClick={() => setSlot(k)}
|
||||
className={`flex items-center gap-1.5 text-xs font-mono font-semibold px-3 py-1.5 rounded-lg border transition-colors ${
|
||||
active ? "bg-[var(--color-cyan)] text-black border-transparent" : "glass glass-hover"
|
||||
}`}
|
||||
>
|
||||
{SLOT_LABELS[k]}
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${has ? "bg-emerald-400" : "bg-white/20"}`} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 items-stretch">
|
||||
{cur.path ? (
|
||||
<div
|
||||
ref={previewRef}
|
||||
className="relative shrink-0 rounded-xl overflow-hidden bg-black/30 select-none border-2 border-dashed"
|
||||
style={{
|
||||
width: W,
|
||||
height: H,
|
||||
cursor: "grab",
|
||||
containerType: "inline-size",
|
||||
borderColor: "color-mix(in oklch, var(--color-violet) 60%, transparent)",
|
||||
}}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={onPointerUp}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={collectionCoverUrl(cur.path)}
|
||||
alt=""
|
||||
draggable={false}
|
||||
className="absolute top-1/2 left-1/2 max-w-none origin-center pointer-events-none border-2 border-dashed box-border"
|
||||
style={{
|
||||
// cqw makes the offset scale with frame width so the
|
||||
// grid card mirrors this preview exactly, regardless of
|
||||
// its column width.
|
||||
transform: `translate(-50%, -50%) translate(${cur.offsetX / W * 100}cqw, ${cur.offsetY / W * 100}cqw) scale(${cur.zoom})`,
|
||||
width: "100cqw",
|
||||
height: "auto",
|
||||
borderColor: "color-mix(in oklch, var(--color-cyan) 70%, transparent)",
|
||||
}}
|
||||
/>
|
||||
<div className="absolute bottom-1.5 left-1/2 -translate-x-1/2 text-[9px] uppercase tracking-wider font-mono text-white flex items-center gap-1 pointer-events-none whitespace-nowrap px-1.5 py-0.5 rounded-md bg-black/70 backdrop-blur-sm">
|
||||
<Move className="w-3 h-3" /> drag · scroll
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
className="shrink-0 rounded-xl border border-dashed border-[var(--color-glass-border-strong)] text-center text-sm text-[var(--color-fg-muted)] hover:border-[var(--color-cyan)] hover:text-[var(--color-fg-dim)] transition-colors flex flex-col items-center justify-center gap-2"
|
||||
style={{ width: W, height: H }}
|
||||
>
|
||||
<Upload className="w-6 h-6" />
|
||||
Click Or Paste Image
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0 flex flex-col justify-between">
|
||||
<div className="space-y-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<ZoomOut className="w-4 h-4 text-[var(--color-fg-muted)] shrink-0" />
|
||||
<input
|
||||
type="range"
|
||||
min={0.5}
|
||||
max={5}
|
||||
step={0.01}
|
||||
value={cur.zoom}
|
||||
onChange={(e) => patchSlot({ zoom: Number(e.target.value) })}
|
||||
className="flex-1 accent-[var(--color-cyan)] min-w-0"
|
||||
disabled={!cur.path}
|
||||
/>
|
||||
<ZoomIn className="w-4 h-4 text-[var(--color-fg-muted)] shrink-0" />
|
||||
</div>
|
||||
<div className="text-[10px] font-mono text-[var(--color-fg-dim)] tabular-nums text-right -mt-1">
|
||||
{cur.zoom.toFixed(2)}x
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
disabled={busy}
|
||||
className="flex-1 flex items-center justify-center gap-1.5 text-xs px-2 py-1.5 rounded-lg glass glass-hover disabled:opacity-50"
|
||||
>
|
||||
{busy ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Upload className="w-3.5 h-3.5" />}
|
||||
{cur.path ? "Replace" : "Upload"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={reset}
|
||||
disabled={!cur.path || busy}
|
||||
title="Reset zoom & position"
|
||||
className="flex items-center justify-center gap-1.5 text-xs px-2 py-1.5 rounded-lg glass glass-hover disabled:opacity-40"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
{cur.path && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={removeCover}
|
||||
disabled={busy}
|
||||
title="Remove"
|
||||
className="flex items-center justify-center gap-1.5 text-xs px-2 py-1.5 rounded-lg border border-red-500/30 text-red-300 hover:bg-red-500/10 disabled:opacity-40"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
hidden
|
||||
onChange={(e) => { const f = e.target.files?.[0]; if (f) uploadFile(f); e.target.value = ""; }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <div className="text-xs text-red-400">{error}</div>}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 pt-3 border-t border-[var(--color-glass-border)]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 text-xs px-3 py-1.5 rounded-lg glass glass-hover"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={save}
|
||||
disabled={busy || !cur.path}
|
||||
className="flex-1 flex items-center justify-center gap-1.5 text-xs px-3 py-1.5 rounded-lg bg-[var(--color-cyan)] text-black font-medium disabled:opacity-50"
|
||||
>
|
||||
{busy ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : null} Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
"use client";
|
||||
import { useState, useTransition } from "react";
|
||||
import Link from "next/link";
|
||||
import { FolderHeart, X, Plus } from "lucide-react";
|
||||
import { addImageToCollection, removeImageFromCollection, createCollection } from "@/app/actions/collections";
|
||||
|
||||
interface PickedCollection { id: number; name: string; slug?: string }
|
||||
|
||||
export function CollectionPicker({
|
||||
imageId,
|
||||
current,
|
||||
available,
|
||||
}: {
|
||||
imageId: number;
|
||||
current: PickedCollection[];
|
||||
available: PickedCollection[];
|
||||
}) {
|
||||
const [picked, setPicked] = useState(current);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [pool, setPool] = useState(available);
|
||||
const [, start] = useTransition();
|
||||
|
||||
const add = (c: PickedCollection) => {
|
||||
if (picked.some(p => p.id === c.id)) return;
|
||||
setPicked((cur) => [...cur, c]);
|
||||
start(async () => { await addImageToCollection(c.id, imageId); });
|
||||
};
|
||||
|
||||
const remove = (id: number) => {
|
||||
setPicked((cur) => cur.filter(c => c.id !== id));
|
||||
start(async () => { await removeImageFromCollection(id, imageId); });
|
||||
};
|
||||
|
||||
const createNew = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const name = draft.trim();
|
||||
if (!name) return;
|
||||
setDraft("");
|
||||
start(async () => {
|
||||
const created = await createCollection(name);
|
||||
if (created) {
|
||||
setPool((cur) => [...cur, { id: created.id, name, slug: created.slug }]);
|
||||
setPicked((cur) => [...cur, { id: created.id, name, slug: created.slug }]);
|
||||
await addImageToCollection(created.id, imageId);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const remaining = pool.filter(p => !picked.some(pp => pp.id === p.id));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-wider font-mono text-[var(--color-fg-muted)] mb-2">
|
||||
Collections
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{picked.map((c) => (
|
||||
<span
|
||||
key={c.id}
|
||||
className="group flex items-center gap-1 px-2 py-1 rounded-full text-xs glass border-[var(--color-cyan)]/30 text-[var(--color-cyan)] bg-[color-mix(in_oklch,var(--color-cyan)_10%,transparent)]"
|
||||
>
|
||||
<FolderHeart className="w-3 h-3" />
|
||||
{c.slug ? (
|
||||
<Link
|
||||
href={`/collection/${c.slug}`}
|
||||
className="hover:underline"
|
||||
>
|
||||
{c.name}
|
||||
</Link>
|
||||
) : (
|
||||
c.name
|
||||
)}
|
||||
<button onClick={() => remove(c.id)} className="opacity-50 hover:opacity-100">
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<button
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded-full text-xs border border-dashed border-[var(--color-glass-border)] text-[var(--color-fg-dim)] hover:text-[var(--color-cyan)] hover:border-[var(--color-cyan)]"
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
Add to collection
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div className="mt-2 glass rounded-xl p-3 space-y-2">
|
||||
{remaining.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{remaining.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
onClick={() => add(c)}
|
||||
className="text-xs px-2 py-1 rounded-full glass-strong hover:text-[var(--color-cyan)]"
|
||||
>
|
||||
{c.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={createNew} className="flex gap-1.5">
|
||||
<input
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
placeholder="New collection…"
|
||||
className="flex-1 bg-transparent text-xs px-2 py-1 rounded-md border border-[var(--color-glass-border)] outline-none focus:border-[var(--color-cyan)]"
|
||||
/>
|
||||
<button type="submit" className="text-xs px-2 py-1 rounded-md bg-[var(--color-cyan)] text-black font-medium">
|
||||
Create
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
import { createContext, useCallback, useContext, useMemo, useState } from "react";
|
||||
|
||||
type Ctx = {
|
||||
ids: Set<number>;
|
||||
has: (id: number) => boolean;
|
||||
toggle: (id: number) => void;
|
||||
setMany: (ids: number[]) => void;
|
||||
clear: () => void;
|
||||
};
|
||||
|
||||
const CollectionSelectCtx = createContext<Ctx | null>(null);
|
||||
|
||||
export function CollectionSelectionProvider({ children }: { children: React.ReactNode }) {
|
||||
const [ids, setIds] = useState<Set<number>>(new Set());
|
||||
|
||||
const toggle = useCallback((id: number) => {
|
||||
setIds((cur) => {
|
||||
const next = new Set(cur);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setMany = useCallback((newIds: number[]) => setIds(new Set(newIds)), []);
|
||||
const clear = useCallback(() => setIds(new Set()), []);
|
||||
|
||||
const value = useMemo<Ctx>(() => ({
|
||||
ids,
|
||||
has: (id) => ids.has(id),
|
||||
toggle,
|
||||
setMany,
|
||||
clear,
|
||||
}), [ids, toggle, setMany, clear]);
|
||||
|
||||
return <CollectionSelectCtx.Provider value={value}>{children}</CollectionSelectCtx.Provider>;
|
||||
}
|
||||
|
||||
export function useCollectionSelection() {
|
||||
const ctx = useContext(CollectionSelectCtx);
|
||||
if (!ctx) throw new Error("useCollectionSelection must be used within CollectionSelectionProvider");
|
||||
return ctx;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
"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 (
|
||||
<CollectionSelectionProvider>
|
||||
<Inner items={items} view={view} />
|
||||
</CollectionSelectionProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function Inner({ items: initial, view }: { items: CollectionListItem[]; view: "portrait" | "landscape" }) {
|
||||
const sel = useCollectionSelection();
|
||||
const [items, setItems] = useState(initial);
|
||||
const [draggingId, setDraggingId] = useState<number | null>(null);
|
||||
const [dropBeforeId, setDropBeforeId] = useState<number | "end" | null>(null);
|
||||
const [editing, setEditing] = useState<CollectionListItem | null>(null);
|
||||
const [renamingId, setRenamingId] = useState<number | null>(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 (
|
||||
<>
|
||||
<div
|
||||
key={view}
|
||||
className={cn("fade-in", gridCls)}
|
||||
onDragOver={(e) => { 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 (
|
||||
<div
|
||||
key={c.id}
|
||||
draggable={renamingId !== c.id}
|
||||
onDragStart={(e) => 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",
|
||||
)}
|
||||
>
|
||||
<Link
|
||||
href={`/collection/${c.slug}`}
|
||||
draggable={false}
|
||||
onClick={(e) => {
|
||||
if (anySelected) {
|
||||
e.preventDefault();
|
||||
sel.toggle(c.id);
|
||||
}
|
||||
}}
|
||||
className="group glass glass-hover rounded-2xl overflow-hidden block"
|
||||
>
|
||||
<div
|
||||
className="relative bg-[var(--color-bg-1)] overflow-hidden"
|
||||
style={{
|
||||
aspectRatio: isLandscape ? `${PHI} / 1` : `1 / ${PHI}`,
|
||||
containerType: "inline-size",
|
||||
}}
|
||||
>
|
||||
{customPath ? (
|
||||
/* eslint-disable-next-line @next/next/no-img-element */
|
||||
<img
|
||||
src={collectionCoverUrl(customPath)}
|
||||
alt=""
|
||||
draggable={false}
|
||||
className="absolute top-1/2 left-1/2 max-w-none origin-center pointer-events-none"
|
||||
style={{
|
||||
transform: `translate(-50%, -50%) translate(${customOffsetX / (isLandscape ? CANONICAL_LANDSCAPE_W : CANONICAL_PORTRAIT_W) * 100}cqw, ${customOffsetY / (isLandscape ? CANONICAL_LANDSCAPE_W : CANONICAL_PORTRAIT_W) * 100}cqw) scale(${customZoom})`,
|
||||
width: "100%",
|
||||
height: "auto",
|
||||
}}
|
||||
/>
|
||||
) : fallbackThumb ? (
|
||||
/* eslint-disable-next-line @next/next/no-img-element */
|
||||
<img
|
||||
src={thumbUrl({ thumbPath: fallbackThumb, code: c.name })}
|
||||
alt=""
|
||||
draggable={false}
|
||||
className="absolute inset-0 w-full h-full object-cover group-hover:scale-[1.02] transition-transform"
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 grid place-items-center">
|
||||
<FolderHeart className="w-10 h-10 text-[var(--color-fg-muted)]" />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="absolute top-3 left-3 text-[10px] uppercase tracking-wider font-mono font-semibold px-2 py-0.5 rounded-full bg-black/80 backdrop-blur-md text-[var(--color-cyan)] shadow-md"
|
||||
style={{
|
||||
border: "1px solid rgba(34,211,238,0.4)",
|
||||
textShadow: "0 1px 2px rgba(0,0,0,0.9)",
|
||||
}}
|
||||
>
|
||||
{c.count} Item{c.count === 1 ? "" : "s"}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
draggable={false}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
sel.toggle(c.id);
|
||||
}}
|
||||
aria-label={selected ? "Deselect" : "Select"}
|
||||
className={cn(
|
||||
"absolute top-3 right-3 w-8 h-8 rounded-md grid place-items-center transition-all backdrop-blur-md border-2",
|
||||
selected
|
||||
? "bg-[var(--color-cyan)] border-[var(--color-cyan)] text-black shadow-[var(--shadow-glow-cyan)]"
|
||||
: "bg-black/40 border-white/50 text-transparent",
|
||||
!selected && !anySelected && "opacity-0 group-hover/drag:opacity-100",
|
||||
)}
|
||||
>
|
||||
<Check className="w-4 h-4" strokeWidth={3} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
draggable={false}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setEditing(c);
|
||||
}}
|
||||
aria-label="Edit cover"
|
||||
title="Edit cover"
|
||||
className={cn(
|
||||
"absolute bottom-3 right-3 w-8 h-8 rounded-md grid place-items-center transition-all backdrop-blur-md border-2",
|
||||
"bg-black/60 border-white/30 text-white hover:bg-[var(--color-cyan)]/30 hover:border-[var(--color-cyan)] hover:text-[var(--color-cyan)]",
|
||||
"opacity-0 group-hover/drag:opacity-100",
|
||||
)}
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
{renamingId === c.id ? (
|
||||
<div
|
||||
className="flex items-center gap-1 h-7"
|
||||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<input
|
||||
autoFocus
|
||||
value={draftName}
|
||||
onChange={(e) => 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)]"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); commitRename(c); }}
|
||||
disabled={renamePending || !draftName.trim() || draftName.trim() === c.name}
|
||||
className="flex items-center justify-center w-6 h-6 rounded bg-[var(--color-cyan)] text-black disabled:opacity-40"
|
||||
title="Save"
|
||||
>
|
||||
<Check className="w-3 h-3" strokeWidth={3} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); cancelRename(); }}
|
||||
disabled={renamePending}
|
||||
className="flex items-center justify-center w-6 h-6 rounded glass text-[var(--color-fg-muted)] hover:text-[var(--color-fg)]"
|
||||
title="Cancel"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5 h-7 min-w-0">
|
||||
<div className="font-medium truncate flex-1 min-w-0">{c.name}</div>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); startRename(c); }}
|
||||
title="Rename"
|
||||
aria-label="Rename collection"
|
||||
className="flex-shrink-0 w-6 h-6 grid place-items-center rounded text-[var(--color-fg-muted)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)] opacity-0 group-hover/drag:opacity-100 transition-opacity"
|
||||
>
|
||||
<Type className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{c.description && (
|
||||
<div className="text-xs text-[var(--color-fg-muted)] truncate mt-0.5">{c.description}</div>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div
|
||||
onDragOver={onDragOverEnd}
|
||||
className={cn(
|
||||
"min-h-[120px] 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>
|
||||
|
||||
{editing && (
|
||||
<CollectionCoverEditor
|
||||
collectionId={editing.id}
|
||||
collectionName={editing.name}
|
||||
initialSlot={view}
|
||||
initial={{
|
||||
portrait: {
|
||||
path: editing.coverPortraitPath,
|
||||
zoom: editing.coverPortraitZoom,
|
||||
offsetX: editing.coverPortraitOffsetX,
|
||||
offsetY: editing.coverPortraitOffsetY,
|
||||
},
|
||||
landscape: {
|
||||
path: editing.coverLandscapePath,
|
||||
zoom: editing.coverLandscapeZoom,
|
||||
offsetX: editing.coverLandscapeOffsetX,
|
||||
offsetY: editing.coverLandscapeOffsetY,
|
||||
},
|
||||
}}
|
||||
onClose={() => setEditing(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user