"use client"; import { useCallback, useEffect, useMemo, useRef, useState, useTransition } from "react"; import { createPortal } from "react-dom"; import { Tag as TagIcon, FolderHeart, Trash2, Search, Gem, Star, Eye, Package, Loader2, ChevronRight, ListVideo } from "lucide-react"; import { useRouter } from "next/navigation"; import { fetchImageContextData } from "@/app/actions/imageMeta"; import { addTagToImage, removeTagFromImage } from "@/app/actions/tags"; import { addImageToCollection, removeImageFromCollection, createCollection } from "@/app/actions/collections"; import { setCoverVip, setCoverFavorite, setWatched, setCoverOwned } from "@/app/actions/coverMeta"; import { bulkSetMark, bulkSetWatched, bulkSetOwned, deleteImages } from "@/app/actions/bulk"; import { useClickOutside } from "@/lib/hooks/useClickOutside"; import { useUndoDeleteToast } from "@/components/select/UndoDeleteToast"; import { useSettings } from "@/components/settings/SettingsProvider"; import { useWatchQueue } from "@/components/queue/WatchQueueProvider"; import { dispatchQueueRemove } from "@/components/queue/watchQueueEvents"; import { cn } from "@/lib/utils"; import type { ContextData, ContextTagOption, ContextCollectionOption } from "@/lib/db/queries"; type SubmenuKey = "tags" | "collections" | null; export function ImageContextMenu({ imageIds, x, y, onClose, }: { imageIds: number[]; /** Single-cover convenience link href; only relevant in 1-cover mode. */ singleHref?: string | null; x: number; y: number; onClose: () => void; }) { const isBulk = imageIds.length > 1; const ref = useRef(null); const [data, setData] = useState(null); const [submenu, setSubmenu] = useState(null); const [, start] = useTransition(); const router = useRouter(); const { show: showUndo } = useUndoDeleteToast(); const { settings } = useSettings(); const queue = useWatchQueue(); const queuedCount = imageIds.reduce((acc, id) => acc + (queue.has(id) ? 1 : 0), 0); const allQueued = queuedCount === imageIds.length && imageIds.length > 0; useClickOutside(ref, onClose); useEffect(() => { let live = true; fetchImageContextData(imageIds).then((d) => { if (live) setData(d); }); return () => { live = false; }; }, [imageIds]); useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") { if (submenu) setSubmenu(null); else onClose(); } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [onClose, submenu]); // Header — derive from cover info when available. const header = useMemo(() => { if (!data) return { title: isBulk ? `${imageIds.length} covers` : `Image #${imageIds[0]}`, sub: null as string | null }; if (isBulk) { return { title: `${imageIds.length} covers selected`, sub: null }; } const c = data.covers[0]; if (!c) return { title: `Image #${imageIds[0]}`, sub: null }; const title = c.code ?? `Image #${c.id}`; const sub = c.actresses.length > 0 ? c.actresses.join(", ") : (c.title ?? null); return { title, sub }; }, [data, imageIds, isBulk]); // Mark state — for single, read straight from data; for bulk, all-true means // every cover has it. const marks = useMemo(() => { if (!data || data.covers.length === 0) { return { vip: false, favorite: false, watched: false, owned: false }; } const all = (pred: (c: ContextData["covers"][number]) => boolean) => data.covers.every(pred); return { vip: all((c) => c.isVip), favorite: all((c) => c.isFavorite), watched: all((c) => c.isWatched), owned: all((c) => c.isOwned), }; }, [data]); const setMark = useCallback((kind: "vip" | "favorite" | "watched" | "owned") => { const wasOn = marks[kind]; setData((d) => d && { ...d, covers: d.covers.map((c) => { if (kind === "vip") return { ...c, isVip: !wasOn, isFavorite: !wasOn ? false : c.isFavorite }; if (kind === "favorite") return { ...c, isFavorite: !wasOn, isVip: !wasOn ? false : c.isVip }; if (kind === "watched") return { ...c, isWatched: !wasOn }; return { ...c, isOwned: !wasOn }; }), }); start(async () => { if (isBulk) { if (kind === "vip") await bulkSetMark(imageIds, !wasOn ? "vip" : "unmarked"); else if (kind === "favorite") await bulkSetMark(imageIds, !wasOn ? "favorite" : "unmarked"); else if (kind === "watched") await bulkSetWatched(imageIds, !wasOn); else await bulkSetOwned(imageIds, !wasOn); } else { const id = imageIds[0]; if (kind === "vip") await setCoverVip(id, !wasOn); else if (kind === "favorite") await setCoverFavorite(id, !wasOn); else if (kind === "watched") await setWatched(id, !wasOn); else await setCoverOwned(id, !wasOn); } if (kind === "watched" && !wasOn) dispatchQueueRemove(imageIds); router.refresh(); }); }, [imageIds, isBulk, marks, router]); const toggleTag = useCallback((tag: { id: number; name: string }, on: boolean) => { setData((d) => d && { ...d, tags: d.tags.map((t) => t.id === tag.id ? { ...t, count: on ? imageIds.length : 0 } : t, ), }); start(async () => { await Promise.all(imageIds.map((id) => on ? addTagToImage(id, tag.name) : removeTagFromImage(id, tag.id))); router.refresh(); }); }, [imageIds, router]); const toggleCollection = useCallback((coll: { id: number; name: string }, on: boolean) => { setData((d) => d && { ...d, collections: d.collections.map((c) => c.id === coll.id ? { ...c, count: on ? imageIds.length : 0 } : c, ), }); start(async () => { await Promise.all(imageIds.map((id) => on ? addImageToCollection(coll.id, id) : removeImageFromCollection(coll.id, id))); router.refresh(); }); }, [imageIds, router]); const onDelete = (e: React.MouseEvent) => { // Honor the same Shift-for-permanent / useRecycleBin semantics as // SelectionBar so right-click delete doesn't bypass the user's // recycle-bin preference. const permanent = e.shiftKey || !settings.useRecycleBin; const target = [...imageIds]; onClose(); start(async () => { await deleteImages(target, permanent ? { permanent: true } : undefined); router.refresh(); if (!permanent) showUndo(target); }); }; // Compute viewport-clamped position. We render via a portal to // document.body so a transformed ancestor (the page's fade-in // wrapper) can't hijack our fixed-positioning containing block. // Horizontal: center on the cursor so the menu sits symmetrically // around the click — keeps right-edge clicks from spilling offscreen. // Vertical: top of the menu sits at the cursor, expanding down. const margin = 8; const menuW = 380; const menuH = 320; const [coords, setCoords] = useState({ left: x, top: y }); useEffect(() => { if (typeof window === "undefined") return; setCoords({ left: Math.max(margin, Math.min(x - menuW / 2, window.innerWidth - menuW - margin)), top: Math.max(margin, Math.min(y + 20, window.innerHeight - menuH - margin)), }); }, [x, y]); if (typeof document === "undefined") return null; return createPortal(
e.stopPropagation()} onContextMenu={(e) => e.preventDefault()} className="fixed z-[80] flex items-start gap-2" style={{ left: coords.left, top: coords.top }} >
{/* Header */}
{header.title}
{header.sub && (
{header.sub}
)} {isBulk && (
⚡ Applying to {imageIds.length} covers
)}
{/* Quick marks: VIP, Fav, Watched, Owned */}
setMark("vip")} /> setMark("favorite")} accent="amber" /> setMark("watched")} accent="mint" /> setMark("owned")} accent="violet" />
{/* Submenu trigger rows — open on hover (saves a click). Click still toggles, in case you want to close one without moving the cursor. */} setSubmenu("tags")} onClick={() => setSubmenu(submenu === "tags" ? null : "tags")} /> setSubmenu("collections")} onClick={() => setSubmenu(submenu === "collections" ? null : "collections")} /> {/* Watch queue */} {/* Delete */}
{/* Flyout */} {submenu === "tags" && data && ( ({ id: t.id, name: t.name, color: t.color, count: 0 }))} selectedCount={data.selectedCount} isBulk={isBulk} onToggle={(opt, on) => toggleTag(opt as ContextTagOption, on)} onCreate={async (name) => { for (const id of imageIds) await addTagToImage(id, name); const fresh = await fetchImageContextData(imageIds); setData(fresh); router.refresh(); }} /> )} {submenu === "collections" && data && ( ({ id: c.id, name: c.name, color: null, count: c.count }))} recent={data.recentCollections.map((c) => ({ id: c.id, name: c.name, color: null, count: 0 }))} selectedCount={data.selectedCount} isBulk={isBulk} onToggle={(opt, on) => toggleCollection(opt as ContextCollectionOption, on)} onCreate={async (name) => { const created = await createCollection(name); if (!created) return; for (const id of imageIds) await addImageToCollection(created.id, id); const fresh = await fetchImageContextData(imageIds); setData(fresh); router.refresh(); }} /> )}
, document.body, ); } /* ---------- helpers ---------- */ function formatCountLabel(opts: Array<{ count: number }>, selected: number, isBulk: boolean): string { const fullyApplied = opts.filter((o) => o.count === selected && o.count > 0).length; if (!isBulk) return String(fullyApplied); const partial = opts.filter((o) => o.count > 0 && o.count < selected).length; if (partial > 0) return "mixed"; return String(fullyApplied); } function QBtn({ icon: Icon, label, active, accent = "cyan", onClick, }: { icon: React.ComponentType<{ className?: string }>; label: string; active: boolean; accent?: "cyan" | "amber" | "mint" | "violet"; onClick: () => void; }) { const palette: Record = { cyan: "bg-[var(--color-cyan)]/18 text-[var(--color-cyan)]", amber: "bg-amber-400/18 text-amber-300", mint: "bg-[var(--color-mint)]/18 text-[var(--color-mint)]", violet: "bg-[var(--color-violet)]/18 text-[var(--color-violet)]", }; return ( ); } function SubmenuRow({ icon: Icon, label, countLabel, open, onHover, onClick, }: { icon: React.ComponentType<{ className?: string }>; label: string; countLabel: string; open: boolean; onHover?: () => void; onClick: () => void; }) { return ( ); } interface FlyoutOption { id: number; name: string; color: string | null; count: number; } function Flyout({ title, options, recent, selectedCount, isBulk, onToggle, onCreate, }: { title: string; options: FlyoutOption[]; recent: FlyoutOption[]; selectedCount: number; isBulk: boolean; onToggle: (opt: FlyoutOption, on: boolean) => void; onCreate: (name: string) => Promise; }) { const [query, setQuery] = useState(""); const [creating, start] = useTransition(); const filtered = useMemo(() => { const q = query.trim().toLowerCase(); if (!q) return options; return options.filter((o) => o.name.toLowerCase().includes(q)); }, [options, query]); const trimmed = query.trim(); const exact = trimmed ? options.find((o) => o.name.toLowerCase() === trimmed.toLowerCase()) : undefined; const showCreate = trimmed.length > 0 && !exact; const submitCreate = () => { if (!showCreate || creating) return; const name = trimmed; setQuery(""); start(async () => { await onCreate(name); }); }; return (
{title} {isBulk && N / {selectedCount}}
{/* Filter or Create */}
setQuery(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") submitCreate(); }} placeholder={`Filter or add new ${title.toLowerCase()}…`} className="w-full bg-[var(--color-bg-1)]/60 text-xs pl-7 pr-[60px] py-1.5 rounded-md border border-[var(--color-glass-border)] outline-none focus:border-[var(--color-cyan)]" /> {showCreate && ( )}
{/* Recent strip — only when no filter active */} {recent.length > 0 && !query && (
Recent
{recent.map((r) => ( ))}
)} {/* Scrollable list — caps at ~5 rows */}
All ↕ scroll
{filtered.length === 0 ? (
{query ? "No matches" : `No ${title.toLowerCase()} yet`}
) : (
{filtered.map((o) => { const state = stateFor(o.count, selectedCount); return ( ); })}
)}
); } function stateFor(count: number, total: number): "all" | "partial" | "none" { if (count === 0) return "none"; if (count >= total) return "all"; return "partial"; }