"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 { setActressPortraitTransform, clearActressPortrait } from "@/app/actions/actressPortrait"; import type { ActressAllPortraits, PortraitSlotKey } from "@/lib/db/queries"; import { portraitUrl } from "@/lib/assetUrls"; interface Props { actressId: number; actressName: string; initial: ActressAllPortraits; initialSlot?: PortraitSlotKey; onClose: () => void; } const PHI = 1.618; const FRAME_H = 360; const PORTRAIT_H = FRAME_H; const PORTRAIT_W = Math.round(FRAME_H / PHI); const HORIZ_H = FRAME_H; const HORIZ_W = Math.round(FRAME_H * PHI); const SLOT_LABELS: Record = { "1": "P1", "2": "P2", "3": "P3", "4": "P4", "h": "L" }; const SLOT_KEYS: PortraitSlotKey[] = ["1", "2", "3", "4", "h"]; function slotKey(s: PortraitSlotKey): keyof ActressAllPortraits { return s === "h" ? "ph" : (`p${s}` as "p1" | "p2" | "p3" | "p4"); } export function ActressPortraitEditor({ actressId, actressName, initial, initialSlot = "1", 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(); const dragRef = useRef<{ x: number; y: number; ox: number; oy: number } | null>(null); const cur = slots[slotKey(slot)]; const isHorizontal = slot === "h"; const W = isHorizontal ? HORIZ_W : PORTRAIT_W; const H = isHorizontal ? HORIZ_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, [slotKey(slot)]: { ...s[slotKey(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/actress-portrait/${actressId}?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.portraitPath, 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 setActressPortraitTransform(actressId, 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 removePortrait() { if (!confirm(`Remove ${SLOT_LABELS[slot]}?`)) return; setBusy(true); try { await clearActressPortrait(actressId, 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(); }} >
Portraits
{actressName}
{SLOT_KEYS.map((k) => { const active = k === slot; const has = !!slots[slotKey(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, ); }