542 lines
21 KiB
TypeScript
542 lines
21 KiB
TypeScript
"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";
|
||
}
|