"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 = { 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(null); const [slot, setSlot] = useState(initialSlot); const [slots, setSlots] = useState(initial); const [busy, setBusy] = useState(false); const [error, setError] = useState(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) { 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(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(
{ if (e.target === e.currentTarget) onClose(); }} >
Collection Cover
{collectionName}
{SLOT_KEYS.map((k) => { const active = k === slot; const has = !!slots[k].path; return ( ); })}
{cur.path ? (
{/* eslint-disable-next-line @next/next/no-img-element */}
drag · scroll
) : ( )}
patchSlot({ zoom: Number(e.target.value) })} className="flex-1 accent-[var(--color-cyan)] min-w-0" disabled={!cur.path} />
{cur.zoom.toFixed(2)}x
{cur.path && ( )} { const f = e.target.files?.[0]; if (f) uploadFile(f); e.target.value = ""; }} />
{error &&
{error}
}
, document.body, ); }