Files
pinkudex/components/grid/ImageContextMenu.tsx
T
2026-05-26 22:46:00 +02:00

542 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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<HTMLDivElement>(null);
const [data, setData] = useState<ContextData | null>(null);
const [submenu, setSubmenu] = useState<SubmenuKey>(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(
<div
ref={ref}
onClick={(e) => e.stopPropagation()}
onContextMenu={(e) => e.preventDefault()}
className="fixed z-[80] flex items-start gap-2"
style={{ left: coords.left, top: coords.top }}
>
<div
className="w-[380px] rounded-xl shadow-2xl border border-[var(--color-glass-border-strong)] backdrop-blur-2xl overflow-hidden text-sm"
style={{ background: "color-mix(in oklch, var(--color-bg-0) 96%, transparent)" }}
>
{/* Header */}
<div className="px-3 py-2.5 border-b border-[var(--color-glass-border)]">
<div className="text-[13px] font-mono font-semibold text-[var(--color-cyan)] truncate">
{header.title}
</div>
{header.sub && (
<div className="text-[11px] text-[var(--color-fg-dim)] truncate mt-0.5">{header.sub}</div>
)}
{isBulk && (
<div className="text-[10px] uppercase tracking-wider font-mono text-[var(--color-cyan)] mt-1">
Applying to {imageIds.length} covers
</div>
)}
</div>
{/* Quick marks: VIP, Fav, Watched, Owned */}
<div className="flex gap-1.5 px-3 py-2.5 border-b border-[var(--color-glass-border)]">
<QBtn icon={Gem} label="VIP" active={marks.vip} onClick={() => setMark("vip")} />
<QBtn icon={Star} label="Fav" active={marks.favorite} onClick={() => setMark("favorite")} accent="amber" />
<QBtn icon={Eye} label="Watched" active={marks.watched} onClick={() => setMark("watched")} accent="mint" />
<QBtn icon={Package} label="Owned" active={marks.owned} onClick={() => setMark("owned")} accent="violet" />
</div>
{/* Submenu trigger rows — open on hover (saves a click). Click
still toggles, in case you want to close one without moving
the cursor. */}
<SubmenuRow
icon={TagIcon}
label="Tags"
countLabel={data ? formatCountLabel(data.tags, data.selectedCount, isBulk) : "…"}
open={submenu === "tags"}
onHover={() => setSubmenu("tags")}
onClick={() => setSubmenu(submenu === "tags" ? null : "tags")}
/>
<SubmenuRow
icon={FolderHeart}
label="Collections"
countLabel={data ? formatCountLabel(data.collections, data.selectedCount, isBulk) : "…"}
open={submenu === "collections"}
onHover={() => setSubmenu("collections")}
onClick={() => setSubmenu(submenu === "collections" ? null : "collections")}
/>
{/* Watch queue */}
<button
onClick={() => {
if (allQueued) queue.removeMany(imageIds);
else queue.addMany(imageIds);
onClose();
}}
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-[var(--color-glass)] border-t border-[var(--color-glass-border)]"
>
<ListVideo className={cn("w-3.5 h-3.5", allQueued ? "text-[var(--color-cyan)]" : "text-[var(--color-fg-dim)]")} />
<span className="text-sm">
{allQueued
? (isBulk ? `Remove ${imageIds.length} from Watch Queue` : "Remove from Watch Queue")
: isBulk
? (queuedCount > 0 ? `Add ${imageIds.length - queuedCount} to Watch Queue` : `Add ${imageIds.length} to Watch Queue`)
: "Add to Watch Queue"}
</span>
</button>
{/* Delete */}
<button
onClick={onDelete}
className="w-full flex items-center gap-2 px-3 py-2 text-[var(--color-coral)] hover:bg-[var(--color-coral)]/10 border-t border-[var(--color-glass-border)]"
>
<Trash2 className="w-3.5 h-3.5" />
{isBulk ? `Delete ${imageIds.length} images` : "Delete image"}
</button>
</div>
{/* Flyout */}
{submenu === "tags" && data && (
<Flyout
title="Tags"
options={data.tags}
recent={data.recentTags.map((t) => ({ 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 && (
<Flyout
title="Collections"
options={data.collections.map((c) => ({ 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();
}}
/>
)}
</div>,
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<string, string> = {
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 (
<button
type="button"
onClick={onClick}
className={cn(
"flex-1 min-w-0 flex items-center justify-center gap-1.5 px-3 py-2 rounded-md text-xs font-medium whitespace-nowrap transition-colors",
active
? palette[accent]
: "bg-[var(--color-glass)] text-[var(--color-fg-dim)] hover:bg-[var(--color-glass-strong)] hover:text-[var(--color-fg)]",
)}
>
<Icon className="w-3.5 h-3.5 shrink-0" />
{label}
</button>
);
}
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 (
<button
type="button"
onClick={onClick}
onMouseEnter={onHover}
onFocus={onHover}
className={cn(
"w-full flex items-center gap-2 px-3 py-2 transition-colors text-left",
open ? "bg-[var(--color-glass-strong)]" : "hover:bg-[var(--color-glass)]",
)}
>
<Icon className={cn("w-3.5 h-3.5 shrink-0", open ? "text-[var(--color-cyan)]" : "text-[var(--color-fg-dim)]")} />
<span className="flex-1 text-[var(--color-fg)]">{label}</span>
<span
className={cn(
"text-[10px] font-mono px-1.5 py-0.5 rounded",
open
? "bg-[var(--color-cyan)]/15 text-[var(--color-cyan)]"
: "bg-[var(--color-glass)] text-[var(--color-fg-muted)]",
)}
>
{countLabel}
</span>
<ChevronRight className={cn("w-3.5 h-3.5", open ? "text-[var(--color-cyan)]" : "text-[var(--color-fg-muted)]")} />
</button>
);
}
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<void>;
}) {
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 (
<div
className="w-[300px] rounded-xl shadow-2xl border border-[var(--color-glass-border-strong)] backdrop-blur-2xl overflow-hidden text-sm relative"
style={{ background: "color-mix(in oklch, var(--color-bg-0) 96%, transparent)" }}
>
<div className="px-3 py-2 border-b border-[var(--color-glass-border)] flex items-center justify-between">
<span className="text-[10px] uppercase tracking-wider font-mono text-[var(--color-fg-muted)]">{title}</span>
{isBulk && <span className="text-[9px] font-mono text-[var(--color-fg-muted)]">N / {selectedCount}</span>}
</div>
<div className="p-2 space-y-2">
{/* Filter or Create */}
<div className="relative">
<Search className="w-3 h-3 absolute left-2 top-1/2 -translate-y-1/2 text-[var(--color-fg-muted)] pointer-events-none" />
<input
value={query}
onChange={(e) => 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 && (
<button
type="button"
onClick={submitCreate}
disabled={creating}
className="absolute right-1 top-1/2 -translate-y-1/2 text-[10px] font-mono px-1.5 py-1 rounded bg-[var(--color-cyan)]/15 text-[var(--color-cyan)] border border-[var(--color-cyan)]/30 hover:bg-[var(--color-cyan)]/25 disabled:opacity-50"
>
{creating ? <Loader2 className="w-3 h-3 animate-spin" /> : "+ Create"}
</button>
)}
</div>
{/* Recent strip — only when no filter active */}
{recent.length > 0 && !query && (
<div>
<div className="text-[9px] uppercase tracking-wider font-mono text-[var(--color-fg-muted)] px-1 mb-1">Recent</div>
<div className="flex flex-wrap gap-1">
{recent.map((r) => (
<button
key={r.id}
type="button"
onClick={() => onToggle(r, true)}
className="text-[11px] px-2 py-0.5 rounded-full bg-[var(--color-violet)]/12 text-[var(--color-violet)] hover:bg-[var(--color-violet)]/20"
>
+ {r.name}
</button>
))}
</div>
</div>
)}
{/* Scrollable list — caps at ~5 rows */}
<div>
<div className="flex items-center justify-between px-1 mb-1">
<span className="text-[9px] uppercase tracking-wider font-mono text-[var(--color-fg-muted)]">All</span>
<span className="text-[9px] uppercase tracking-wider font-mono text-[var(--color-fg-muted)]"> scroll</span>
</div>
{filtered.length === 0 ? (
<div className="text-xs text-[var(--color-fg-muted)] italic px-2 py-2">
{query ? "No matches" : `No ${title.toLowerCase()} yet`}
</div>
) : (
<div className="max-h-[150px] overflow-y-auto -mx-1 px-1">
{filtered.map((o) => {
const state = stateFor(o.count, selectedCount);
return (
<button
key={o.id}
type="button"
onClick={() => onToggle(o, state !== "all")}
className={cn(
"w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-left text-sm transition-colors",
state === "all" && "text-[var(--color-violet)]",
state === "partial" && "text-amber-300",
state === "none" && "text-[var(--color-fg-dim)] hover:text-[var(--color-fg)]",
"hover:bg-[var(--color-glass)]",
)}
>
<span className="w-3 inline-flex justify-center text-xs">
{state === "all" ? "✓" : state === "partial" ? "" : ""}
</span>
<span className="flex-1 truncate">{o.name}</span>
<span className="text-[10px] font-mono text-[var(--color-fg-muted)]">
{isBulk ? `${o.count} / ${selectedCount}` : (o.count > 0 ? "✓" : "")}
</span>
</button>
);
})}
</div>
)}
</div>
</div>
</div>
);
}
function stateFor(count: number, total: number): "all" | "partial" | "none" {
if (count === 0) return "none";
if (count >= total) return "all";
return "partial";
}