Initial commit

This commit is contained in:
admin
2026-05-26 22:46:00 +02:00
commit 7e2c2ff89c
256 changed files with 51523 additions and 0 deletions
+541
View File
@@ -0,0 +1,541 @@
"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";
}