337 lines
13 KiB
TypeScript
337 lines
13 KiB
TypeScript
"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,
|
|
);
|
|
}
|