Initial commit
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { FilterCriteria, FilterTabKey, StatusAxisKey } from "@/lib/filters";
|
||||
import { anyActive, EMPTY_STATUS } from "@/lib/filters";
|
||||
import { FILTER_TABS, type FilterOption } from "./MultiFilterPopover";
|
||||
import { useSelection } from "@/components/select/SelectionProvider";
|
||||
|
||||
const WATCHED_LABELS: Record<string, string> = { watched: "Watched", unwatched: "Unwatched" };
|
||||
const RATED_LABELS: Record<string, string> = { rated: "Rated", unrated: "No Rating" };
|
||||
const PRESENCE_LABELS: Record<string, string> = { has: "Has", missing: "No" };
|
||||
|
||||
function pillFor(axis: StatusAxisKey, value: string): string | null {
|
||||
if (value === "all") return null;
|
||||
if (axis === "watched") return WATCHED_LABELS[value] ?? null;
|
||||
if (axis === "rated") return RATED_LABELS[value] ?? null;
|
||||
if (axis === "collection") return value === "has" ? "Has Collection" : "No Collection";
|
||||
if (axis === "tags") return value === "has" ? "Has Tags" : "No Tags";
|
||||
if (axis === "video") return value === "has" ? "Has Video" : "No Video";
|
||||
return null;
|
||||
}
|
||||
const STATUS_AXES: StatusAxisKey[] = ["watched", "rated", "collection", "tags", "video"];
|
||||
|
||||
export function ActiveCriteriaStrip({
|
||||
criteria,
|
||||
options,
|
||||
onChange,
|
||||
}: {
|
||||
criteria: FilterCriteria;
|
||||
options: Record<FilterTabKey, FilterOption[]>;
|
||||
onChange: (next: FilterCriteria) => void;
|
||||
}) {
|
||||
// SSR has no document, so defer the portal until after mount.
|
||||
const [mounted, setMounted] = useState(false);
|
||||
useEffect(() => { setMounted(true); }, []);
|
||||
const sel = useSelection();
|
||||
const selectionActive = sel.ids.size > 0;
|
||||
if (!anyActive(criteria)) return null;
|
||||
if (!mounted) return null;
|
||||
|
||||
const tabSections: Array<{ key: FilterTabKey; label: string; Icon: React.ComponentType<{ className?: string }>; pills: FilterOption[] }> = [];
|
||||
for (const t of FILTER_TABS) {
|
||||
const ids = criteria.ids[t.key];
|
||||
if (ids.length === 0) continue;
|
||||
const optionMap = new Map(options[t.key].map((o) => [o.id, o]));
|
||||
const pills = ids.map((id) => optionMap.get(id)).filter((o): o is FilterOption => !!o);
|
||||
if (pills.length > 0) tabSections.push({ key: t.key, label: t.label, Icon: t.Icon, pills });
|
||||
}
|
||||
|
||||
function removeId(tab: FilterTabKey, id: number) {
|
||||
onChange({ ...criteria, ids: { ...criteria.ids, [tab]: criteria.ids[tab].filter((x) => x !== id) } });
|
||||
}
|
||||
|
||||
function resetAxis(key: StatusAxisKey) {
|
||||
onChange({ ...criteria, status: { ...criteria.status, [key]: "all" } as typeof criteria.status });
|
||||
}
|
||||
|
||||
function removeMark(m: typeof criteria.marks[number]) {
|
||||
onChange({ ...criteria, marks: criteria.marks.filter((x) => x !== m) });
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
onChange({
|
||||
ids: { actresses: [], studios: [], series: [], genres: [], collections: [], tags: [], categories: [] },
|
||||
mode: criteria.mode,
|
||||
status: { ...EMPTY_STATUS },
|
||||
marks: [],
|
||||
});
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed left-1/2 -translate-x-1/2 z-40 flex items-center gap-1.5 flex-wrap py-2 px-3 rounded-2xl border border-[var(--color-glass-border-strong)] shadow-2xl backdrop-blur-2xl"
|
||||
style={{
|
||||
bottom: selectionActive ? "76px" : "20px",
|
||||
background: "color-mix(in oklch, var(--color-bg-0) 88%, transparent)",
|
||||
width: "max-content",
|
||||
maxWidth: "min(96vw, 1600px)",
|
||||
}}
|
||||
>
|
||||
<span className="text-[10px] uppercase tracking-wider font-mono text-[var(--color-fg-muted)] mr-1">Active Filters</span>
|
||||
|
||||
{tabSections.map((section, sIdx) => (
|
||||
<span key={section.key} className="inline-flex items-center gap-1 flex-wrap">
|
||||
{section.pills.map((p, i) => (
|
||||
<span key={p.id} className="inline-flex items-center gap-1">
|
||||
<Pill kind="cyan" Icon={section.Icon} label={p.name} onRemove={() => removeId(section.key, p.id)} />
|
||||
{i < section.pills.length - 1 && (
|
||||
<span className="text-[10px] font-mono text-[var(--color-fg-muted)] px-0.5">
|
||||
{criteria.mode[section.key].toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
{sIdx < tabSections.length - 1 && (
|
||||
<span className="text-[10px] font-mono text-[var(--color-violet)] px-1">AND</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
|
||||
{STATUS_AXES.map((axis) => {
|
||||
const label = pillFor(axis, criteria.status[axis]);
|
||||
if (!label) return null;
|
||||
return <Pill key={axis} kind="coral" label={label} onRemove={() => resetAxis(axis)} />;
|
||||
})}
|
||||
|
||||
{criteria.marks.map((m) => (
|
||||
<Pill
|
||||
key={m}
|
||||
kind={m === "vip" ? "cyan" : m === "favorite" ? "amber" : m === "owned" ? "violet" : "coral"}
|
||||
label={m === "vip" ? "VIP" : m === "favorite" ? "Favorite" : m === "owned" ? "Owned" : "Unmarked"}
|
||||
onRemove={() => removeMark(m)}
|
||||
/>
|
||||
))}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearAll}
|
||||
className="ml-auto text-[11px] text-[var(--color-fg-muted)] hover:text-[var(--color-coral)] underline"
|
||||
>
|
||||
Clear All
|
||||
</button>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
function Pill({
|
||||
kind,
|
||||
label,
|
||||
Icon,
|
||||
onRemove,
|
||||
}: {
|
||||
kind: "cyan" | "coral" | "amber" | "violet";
|
||||
label: string;
|
||||
Icon?: React.ComponentType<{ className?: string }>;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const cls =
|
||||
kind === "cyan"
|
||||
? "bg-[var(--color-cyan)]/12 border-[var(--color-cyan)]/40 text-[var(--color-cyan)]"
|
||||
: kind === "amber"
|
||||
? "border-amber-400/40 text-amber-200"
|
||||
: kind === "violet"
|
||||
? "bg-[var(--color-violet)]/12 border-[var(--color-violet)]/40 text-[var(--color-violet)]"
|
||||
: "bg-[var(--color-coral)]/12 border-[var(--color-coral)]/40 text-[var(--color-coral)]";
|
||||
return (
|
||||
<span
|
||||
className={cn("inline-flex items-center gap-1.5 pl-2.5 pr-1 py-0.5 rounded-full border text-xs font-mono", cls)}
|
||||
style={kind === "amber" ? { background: "rgba(251,191,36,0.12)" } : undefined}
|
||||
>
|
||||
{Icon && <Icon className="w-3 h-3 opacity-70" />}
|
||||
{label}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
className="w-4 h-4 grid place-items-center rounded-full bg-black/30 hover:bg-[var(--color-coral)]/40 hover:text-white"
|
||||
aria-label={`Remove ${label}`}
|
||||
>
|
||||
<X className="w-2.5 h-2.5" />
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { listAllTags, listAllCollections, listAllActresses, listAllStudios, listAllSeries, listAllGenres, listAllTagCategories } from "@/lib/db/queries";
|
||||
import { FilterBarClient } from "./FilterBarClient";
|
||||
import type { FilterTabKey, FilterCriteria } from "@/lib/filters";
|
||||
import type { FilterOption } from "./MultiFilterPopover";
|
||||
import type { SortKey } from "@/lib/sort";
|
||||
import type { LibraryView } from "./ViewToggle";
|
||||
|
||||
export type FilterContext =
|
||||
| { kind: "all" }
|
||||
| { kind: "tag"; name: string }
|
||||
| { kind: "collection"; id: number; name: string }
|
||||
| { kind: "actress"; name: string }
|
||||
| { kind: "studio"; name: string }
|
||||
| { kind: "series"; name: string }
|
||||
| { kind: "genre"; name: string }
|
||||
| { kind: "label"; name: string }
|
||||
| { kind: "category"; name: string }
|
||||
| { kind: "search"; query: string };
|
||||
|
||||
export interface FilterBarProps {
|
||||
current?: FilterContext;
|
||||
criteria: FilterCriteria;
|
||||
sort?: SortKey;
|
||||
view?: LibraryView;
|
||||
}
|
||||
|
||||
export function FilterBar({ current = { kind: "all" }, criteria, sort, view }: FilterBarProps) {
|
||||
const actresses = listAllActresses();
|
||||
const studios = listAllStudios();
|
||||
const seriesList = listAllSeries();
|
||||
const genres = listAllGenres();
|
||||
const collections = listAllCollections();
|
||||
const tags = listAllTags();
|
||||
const categories = listAllTagCategories();
|
||||
|
||||
const options: Record<FilterTabKey, FilterOption[]> = {
|
||||
actresses: actresses.map((a) => ({ id: a.id, name: a.name, count: a.count })),
|
||||
studios: studios.map((s) => ({ id: s.id, name: s.name, count: s.count })),
|
||||
series: seriesList.map((s) => ({ id: s.id, name: s.name, count: s.count })),
|
||||
genres: genres.map((g) => ({ id: g.id, name: g.name, count: g.count })),
|
||||
collections: collections.map((c) => ({ id: c.id, name: c.name, count: c.count })),
|
||||
tags: tags.map((t) => ({ id: t.id, name: t.name, count: t.count })),
|
||||
categories: categories.map((c) => ({ id: c.id, name: c.name, count: c.imageCount })),
|
||||
};
|
||||
|
||||
return (
|
||||
<FilterBarClient
|
||||
criteria={criteria}
|
||||
options={options}
|
||||
isHome={current.kind === "all"}
|
||||
showSort={!!sort}
|
||||
sort={sort}
|
||||
view={view}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useTransition } from "react";
|
||||
import Link from "next/link";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { FilterCriteria, FilterTabKey } from "@/lib/filters";
|
||||
import { writeFilterCriteria, anyActive, EMPTY_STATUS } from "@/lib/filters";
|
||||
import { MultiFilterPopover, type FilterOption } from "./MultiFilterPopover";
|
||||
import { MergedFilterPopover } from "./MergedFilterPopover";
|
||||
import { MarkActionPopover } from "./MarkActionPopover";
|
||||
import { ActiveCriteriaStrip } from "./ActiveCriteriaStrip";
|
||||
import { GridSearchInput } from "./GridSearchInput";
|
||||
import { SortMenu } from "./SortMenu";
|
||||
import { ViewToggle, type LibraryView } from "./ViewToggle";
|
||||
import { InfiniteScrollToggle } from "./InfiniteScrollToggle";
|
||||
import type { SortKey } from "@/lib/sort";
|
||||
|
||||
export function FilterBarClient({
|
||||
criteria,
|
||||
options,
|
||||
isHome,
|
||||
showSort,
|
||||
sort,
|
||||
view,
|
||||
}: {
|
||||
criteria: FilterCriteria;
|
||||
options: Record<FilterTabKey, FilterOption[]>;
|
||||
isHome: boolean;
|
||||
showSort: boolean;
|
||||
sort?: SortKey;
|
||||
view?: LibraryView;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const params = useSearchParams();
|
||||
const [, start] = useTransition();
|
||||
|
||||
function pushCriteria(next: FilterCriteria) {
|
||||
const sp = new URLSearchParams(params.toString());
|
||||
writeFilterCriteria(sp, next);
|
||||
const y = typeof window !== "undefined" ? window.scrollY : 0;
|
||||
start(() => {
|
||||
router.push(`?${sp.toString()}`, { scroll: false });
|
||||
// Defensive: restore scroll on the next two frames in case Next still resets.
|
||||
requestAnimationFrame(() => window.scrollTo({ top: y, left: 0, behavior: "instant" as ScrollBehavior }));
|
||||
requestAnimationFrame(() => window.scrollTo({ top: y, left: 0, behavior: "instant" as ScrollBehavior }));
|
||||
});
|
||||
}
|
||||
|
||||
const active = anyActive(criteria);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2 mb-3 flex-wrap">
|
||||
{isHome ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => pushCriteria({
|
||||
ids: { actresses: [], studios: [], series: [], genres: [], collections: [], tags: [], categories: [] },
|
||||
mode: criteria.mode,
|
||||
status: { ...EMPTY_STATUS },
|
||||
marks: [],
|
||||
})}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-3 py-1.5 rounded-full border text-sm transition-colors",
|
||||
!active
|
||||
? "bg-[var(--color-cyan)]/15 border-[var(--color-cyan)]/40 text-[var(--color-cyan)]"
|
||||
: "glass glass-hover text-[var(--color-fg-dim)]",
|
||||
)}
|
||||
>
|
||||
ALL
|
||||
</button>
|
||||
) : (
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-full border text-sm glass glass-hover text-[var(--color-fg-dim)]"
|
||||
>
|
||||
ALL
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<MultiFilterPopover criteria={criteria} options={options} onChange={pushCriteria} />
|
||||
<MergedFilterPopover criteria={criteria} onChange={pushCriteria} />
|
||||
<MarkActionPopover />
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<GridSearchInput />
|
||||
{showSort && sort && <SortMenu activeSort={sort} />}
|
||||
{isHome && <InfiniteScrollToggle />}
|
||||
{view && <ViewToggle current={view} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ActiveCriteriaStrip criteria={criteria} options={options} onChange={pushCriteria} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
"use client";
|
||||
import { useMemo, useRef, useState, useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
import { ChevronDown, Search, Tag, FolderHeart, Users, Building2, Film, Hash, X } from "lucide-react";
|
||||
import { useClickOutside } from "@/lib/hooks/useClickOutside";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface FilterOption {
|
||||
id: string | number;
|
||||
label: string;
|
||||
href: string;
|
||||
count?: number;
|
||||
}
|
||||
|
||||
const ICONS = {
|
||||
tag: Tag,
|
||||
folder: FolderHeart,
|
||||
actress: Users,
|
||||
studio: Building2,
|
||||
series: Film,
|
||||
genre: Hash,
|
||||
} as const;
|
||||
|
||||
export type FilterIconKey = keyof typeof ICONS;
|
||||
|
||||
export function FilterDropdown({
|
||||
label,
|
||||
iconKey,
|
||||
options,
|
||||
emptyMsg = "Nothing here yet",
|
||||
align = "left",
|
||||
activeLabel,
|
||||
clearHref,
|
||||
}: {
|
||||
label: string;
|
||||
iconKey?: FilterIconKey;
|
||||
options: FilterOption[];
|
||||
emptyMsg?: string;
|
||||
align?: "left" | "right";
|
||||
activeLabel?: string;
|
||||
clearHref?: string;
|
||||
}) {
|
||||
const Icon = iconKey ? ICONS[iconKey] : null;
|
||||
const [open, setOpen] = useState(false);
|
||||
const [filter, setFilter] = useState("");
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useClickOutside(ref, useCallback(() => setOpen(false), []), open);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = filter.trim().toLowerCase();
|
||||
return q ? options.filter((o) => o.label.toLowerCase().includes(q)) : options;
|
||||
}, [options, filter]);
|
||||
|
||||
const isActive = activeLabel != null;
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center rounded-full border text-sm transition-colors",
|
||||
isActive
|
||||
? "bg-[var(--color-cyan)]/15 border-[var(--color-cyan)]/40 text-[var(--color-cyan)]"
|
||||
: "glass glass-hover text-[var(--color-fg-dim)]",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="flex items-center gap-1.5 pl-3 pr-2 py-1.5"
|
||||
>
|
||||
{Icon && <Icon className="w-3.5 h-3.5" />}
|
||||
<span>{label}{isActive ? `: ${activeLabel}` : ""}</span>
|
||||
<ChevronDown className={cn("w-3 h-3 opacity-60 transition-transform", open && "rotate-180")} />
|
||||
</button>
|
||||
{isActive && clearHref && (
|
||||
<Link
|
||||
href={clearHref}
|
||||
aria-label="Clear filter"
|
||||
className="pr-2 pl-1 py-1.5 hover:opacity-70"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-full mt-2 z-30 w-64 rounded-xl shadow-2xl border border-[var(--color-glass-border-strong)] backdrop-blur-2xl overflow-hidden",
|
||||
align === "right" ? "right-0" : "left-0",
|
||||
)}
|
||||
style={{ background: "color-mix(in oklch, var(--color-bg-0) 96%, transparent)" }}
|
||||
>
|
||||
<div className="relative p-2 border-b border-[var(--color-glass-border)]">
|
||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-[var(--color-fg-muted)] pointer-events-none" />
|
||||
<input
|
||||
autoFocus
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
placeholder={`Filter ${label.toLowerCase()}…`}
|
||||
className="w-full bg-[var(--color-bg-1)]/60 text-xs pl-7 pr-2 py-1.5 rounded-md border border-[var(--color-glass-border)] outline-none focus:border-[var(--color-cyan)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<div className="px-3 py-4 text-xs text-[var(--color-fg-muted)] italic text-center">
|
||||
{filter ? "No matches" : emptyMsg}
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-72 overflow-y-auto py-1">
|
||||
{filtered.map((o) => (
|
||||
<Link
|
||||
key={o.id}
|
||||
href={o.href}
|
||||
onClick={() => setOpen(false)}
|
||||
className="flex items-center justify-between gap-2 px-3 py-1.5 text-sm hover:bg-[var(--color-glass)]"
|
||||
>
|
||||
<span className="truncate">{o.label}</span>
|
||||
{o.count != null && (
|
||||
<span className="text-xs font-mono text-[var(--color-fg-muted)] tabular-nums flex-shrink-0">
|
||||
{o.count}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Search, X } from "lucide-react";
|
||||
|
||||
export function GridSearchInput() {
|
||||
const router = useRouter();
|
||||
const params = useSearchParams();
|
||||
const initial = params.get("q") ?? "";
|
||||
const [value, setValue] = useState(initial);
|
||||
const debounce = useRef<number | null>(null);
|
||||
const lastApplied = useRef(initial);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (debounce.current) window.clearTimeout(debounce.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Sync state from URL (e.g. when navigating, "All" link clears it).
|
||||
useEffect(() => {
|
||||
const fromUrl = params.get("q") ?? "";
|
||||
if (fromUrl !== lastApplied.current) {
|
||||
lastApplied.current = fromUrl;
|
||||
setValue(fromUrl);
|
||||
}
|
||||
}, [params]);
|
||||
|
||||
function apply(next: string) {
|
||||
if (next === lastApplied.current) return;
|
||||
lastApplied.current = next;
|
||||
const sp = new URLSearchParams(params.toString());
|
||||
if (next.trim()) {
|
||||
sp.set("q", next.trim());
|
||||
// Activating search clears the letter filter so the user sees all matches.
|
||||
sp.delete("letter");
|
||||
} else {
|
||||
sp.delete("q");
|
||||
}
|
||||
router.push(`?${sp.toString()}`, { scroll: false });
|
||||
}
|
||||
|
||||
function onChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const next = e.target.value;
|
||||
setValue(next);
|
||||
if (debounce.current) window.clearTimeout(debounce.current);
|
||||
debounce.current = window.setTimeout(() => apply(next), 300);
|
||||
}
|
||||
|
||||
function clear() {
|
||||
setValue("");
|
||||
if (debounce.current) window.clearTimeout(debounce.current);
|
||||
apply("");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<Search className="w-3.5 h-3.5 absolute left-3 top-1/2 -translate-y-1/2 text-[var(--color-fg-muted)] pointer-events-none" />
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
if (debounce.current) window.clearTimeout(debounce.current);
|
||||
apply(value);
|
||||
} else if (e.key === "Escape") {
|
||||
clear();
|
||||
}
|
||||
}}
|
||||
placeholder="Search Code, Title, Notes…"
|
||||
className="glass rounded-lg pl-8 pr-7 py-1.5 text-sm outline-none focus:border-[var(--color-cyan)] w-56"
|
||||
/>
|
||||
{value && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={clear}
|
||||
aria-label="Clear search"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-[var(--color-fg-muted)] hover:text-[var(--color-fg)]"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
"use client";
|
||||
import Link from "next/link";
|
||||
import { memo, useEffect, useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Check, Star, Eye, EyeOff, Gem, Package, Play, Captions } from "lucide-react";
|
||||
import { useSelection } from "@/components/select/SelectionProvider";
|
||||
import { ImageContextMenu } from "@/components/grid/ImageContextMenu";
|
||||
import { cn, coverHref } from "@/lib/utils";
|
||||
import { thumbUrl } from "@/lib/assetUrls";
|
||||
import { setWatched, setCoverVip, setCoverFavorite, setCoverOwned } from "@/app/actions/coverMeta";
|
||||
import { dispatchQueueRemove } from "@/components/queue/watchQueueEvents";
|
||||
import { useVideoIndex } from "@/components/video/VideoIndexProvider";
|
||||
import { VideoPlayerModal } from "@/components/video/VideoPlayerModal";
|
||||
|
||||
export interface CardImage {
|
||||
id: number;
|
||||
thumbPath: string;
|
||||
width: number;
|
||||
height: number;
|
||||
code: string | null;
|
||||
title: string | null;
|
||||
rating: number | null;
|
||||
watched: boolean;
|
||||
isVip: boolean;
|
||||
isFavorite: boolean;
|
||||
isOwned: boolean;
|
||||
studioName: string | null;
|
||||
actresses: Array<{ id: number; name: string; slug: string }>;
|
||||
/** Mirror of images.has_video — server-rendered fallback so the
|
||||
* play button shows correctly before the client-side video index
|
||||
* provider hydrates. */
|
||||
hasVideo?: boolean;
|
||||
/** Mirror of images.has_subtitle. Same reason as hasVideo. */
|
||||
hasSubtitle?: boolean;
|
||||
}
|
||||
|
||||
function ImageCardImpl({ image, view = "landscape" }: { image: CardImage; view?: "portrait" | "landscape" }) {
|
||||
const sel = useSelection();
|
||||
const router = useRouter();
|
||||
const selected = sel.has(image.id);
|
||||
const anySelected = sel.ids.size > 0;
|
||||
// Snapshot imageIds at the moment the context menu opens. Otherwise the
|
||||
// prop array reference changes on every parent re-render, retriggering
|
||||
// the menu's data-fetch effect and risking the menu acting on a
|
||||
// selection that drifted between right-click and click.
|
||||
const [menuPos, setMenuPos] = useState<{ x: number; y: number; ids: number[] } | null>(null);
|
||||
const [watched, setLocalWatched] = useState(image.watched);
|
||||
const [vip, setLocalVip] = useState(image.isVip);
|
||||
const [favorite, setLocalFavorite] = useState(image.isFavorite);
|
||||
const [owned, setLocalOwned] = useState(image.isOwned);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const videoIdx = useVideoIndex();
|
||||
// Provider is the live source of truth once it has scanned. Until
|
||||
// then (cold boot of the server before /api/video-status responds)
|
||||
// fall back to the SSR'd flags from the DB so play buttons / CC
|
||||
// chips don't flicker in.
|
||||
const providerReady = videoIdx.lastScannedAt > 0;
|
||||
const hasVideo = providerReady ? videoIdx.hasVideo(image.code) : !!image.hasVideo;
|
||||
const hasSubtitle = providerReady ? videoIdx.hasSubtitle(image.code) : !!image.hasSubtitle;
|
||||
const [, startMutate] = useTransition();
|
||||
|
||||
useEffect(() => { setLocalWatched(image.watched); }, [image.watched]);
|
||||
useEffect(() => { setLocalVip(image.isVip); }, [image.isVip]);
|
||||
useEffect(() => { setLocalFavorite(image.isFavorite); }, [image.isFavorite]);
|
||||
useEffect(() => { setLocalOwned(image.isOwned); }, [image.isOwned]);
|
||||
|
||||
const toggleWatched = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const next = !watched;
|
||||
const prev = watched;
|
||||
setLocalWatched(next);
|
||||
startMutate(async () => {
|
||||
try {
|
||||
await setWatched(image.id, next);
|
||||
if (next) dispatchQueueRemove(image.id);
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
setLocalWatched(prev);
|
||||
console.error("[toggleWatched] failed:", err);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const toggleVip = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const next = !vip;
|
||||
const prevVip = vip;
|
||||
const prevFav = favorite;
|
||||
setLocalVip(next);
|
||||
if (next) setLocalFavorite(false); // mutually exclusive
|
||||
startMutate(async () => {
|
||||
try {
|
||||
await setCoverVip(image.id, next);
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
setLocalVip(prevVip);
|
||||
setLocalFavorite(prevFav);
|
||||
console.error("[toggleVip] failed:", err);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const toggleFavorite = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const next = !favorite;
|
||||
const prevFav = favorite;
|
||||
const prevVip = vip;
|
||||
setLocalFavorite(next);
|
||||
if (next) setLocalVip(false); // mutually exclusive
|
||||
startMutate(async () => {
|
||||
try {
|
||||
await setCoverFavorite(image.id, next);
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
setLocalFavorite(prevFav);
|
||||
setLocalVip(prevVip);
|
||||
console.error("[toggleFavorite] failed:", err);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const toggleOwned = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const next = !owned;
|
||||
const prev = owned;
|
||||
setLocalOwned(next);
|
||||
startMutate(async () => {
|
||||
try {
|
||||
await setCoverOwned(image.id, next);
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
setLocalOwned(prev);
|
||||
console.error("[toggleOwned] failed:", err);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleCheckbox = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
sel.toggle(image.id);
|
||||
};
|
||||
|
||||
const handleCardClick = (e: React.MouseEvent) => {
|
||||
if (anySelected) {
|
||||
e.preventDefault();
|
||||
sel.toggle(image.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
const ids = sel.has(image.id) ? Array.from(sel.ids) : [image.id];
|
||||
setMenuPos({ x: e.clientX, y: e.clientY, ids });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Link
|
||||
href={coverHref(image)}
|
||||
onClick={handleCardClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
draggable={false}
|
||||
className={cn(
|
||||
// No `glass` here — backdrop-filter on every card kills scroll
|
||||
// FPS, and the card root is fully covered by the cover image
|
||||
// anyway. Inner overlays (badges, pills) keep their blurs.
|
||||
"cover-hero-frame group relative flex flex-col justify-end rounded-2xl overflow-hidden bg-[var(--color-glass)] border border-[var(--color-glass-border)] cursor-default transition-shadow hover:border-[var(--color-glass-border-strong)]",
|
||||
selected && "ring-2 ring-[var(--color-cyan)] shadow-[var(--shadow-glow-cyan)]",
|
||||
anySelected && !selected && "opacity-70 hover:opacity-100",
|
||||
)}
|
||||
style={{ breakInside: "avoid" } as React.CSSProperties}
|
||||
>
|
||||
<div className="cover-hero-hover relative">
|
||||
{view === "portrait" ? (
|
||||
// JAV covers are composite back+spine+front, ~800×538 with the
|
||||
// front taking the rightmost ~373×538. We crop to that aspect
|
||||
// by anchoring the thumb to the right and scaling to fit
|
||||
// height — pure CSS, no extra fetch.
|
||||
<div
|
||||
className="w-full block transition-transform duration-500 group-hover:scale-[1.02]"
|
||||
style={{
|
||||
aspectRatio: "373 / 538",
|
||||
backgroundImage: `url(${thumbUrl({ thumbPath: image.thumbPath, code: image.code, id: image.id })})`,
|
||||
backgroundSize: "auto 100%",
|
||||
backgroundPosition: "right center",
|
||||
backgroundRepeat: "no-repeat",
|
||||
}}
|
||||
role="img"
|
||||
aria-label={image.title ?? image.code ?? ""}
|
||||
/>
|
||||
) : (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={thumbUrl({ thumbPath: image.thumbPath, code: image.code, id: image.id })}
|
||||
alt={image.title ?? image.code ?? ""}
|
||||
loading="lazy"
|
||||
draggable={false}
|
||||
width={image.width}
|
||||
height={image.height}
|
||||
className="w-full h-auto block transition-transform duration-500 group-hover:scale-[1.02]"
|
||||
/>
|
||||
)}
|
||||
|
||||
{hasVideo && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); setPlaying(true); }}
|
||||
aria-label="Play video"
|
||||
title="Play video"
|
||||
className={cn(
|
||||
"absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-20 inline-flex items-center justify-center backdrop-blur-md text-white/95 cursor-pointer transition-colors hover:text-[var(--color-cyan)] hover:[animation:play-pulse_1.2s_ease-out_infinite] active:scale-95",
|
||||
view === "portrait" ? "w-16 h-11 rounded-md" : "w-20 h-14 rounded-lg",
|
||||
)}
|
||||
style={{
|
||||
background: "rgba(20,20,28,0.75)",
|
||||
border: 0,
|
||||
boxShadow: "0 6px 16px rgba(0,0,0,0.55), 0 1px 2px rgba(0,0,0,0.5)",
|
||||
}}
|
||||
>
|
||||
<Play className={view === "portrait" ? "w-[18px] h-[18px]" : "w-6 h-6"} style={{ fill: "currentColor" }} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleCheckbox}
|
||||
aria-label={selected ? "Deselect" : "Select"}
|
||||
className={cn(
|
||||
"absolute top-3 right-3 z-20 w-8 h-8 rounded-md grid place-items-center transition-all backdrop-blur-md border-2",
|
||||
selected
|
||||
? "bg-[var(--color-cyan)] border-[var(--color-cyan)] text-black shadow-[var(--shadow-glow-cyan)]"
|
||||
: "bg-black/40 border-white/50 text-transparent",
|
||||
!selected && !anySelected && "opacity-0 group-hover:opacity-100",
|
||||
)}
|
||||
>
|
||||
<Check className="w-4 h-4" strokeWidth={3} />
|
||||
</button>
|
||||
|
||||
{hasVideo && !vip && !favorite && (
|
||||
<span
|
||||
className="absolute top-3 left-3 z-10 flex items-center gap-1 text-[11px] uppercase tracking-wider font-mono font-semibold px-2.5 py-0.5 rounded-full bg-black/80 shadow-md"
|
||||
style={{
|
||||
color: "var(--color-cyan)",
|
||||
border: "1px solid color-mix(in oklch, var(--color-cyan) 60%, transparent)",
|
||||
textShadow: "0 1px 2px rgba(0,0,0,0.9)",
|
||||
}}
|
||||
title="Has playable video"
|
||||
>
|
||||
<Play className="w-3 h-3" style={{ fill: "currentColor" }} /> Video
|
||||
</span>
|
||||
)}
|
||||
{(vip || favorite) && (
|
||||
<span
|
||||
className="absolute top-3 left-3 z-10 flex items-center gap-1 text-[11px] uppercase tracking-wider font-mono font-semibold px-2.5 py-0.5 rounded-full bg-black/80 backdrop-blur-md shadow-md"
|
||||
style={{
|
||||
color: vip ? "var(--color-cyan)" : "#fbbf24",
|
||||
border: `1px solid ${vip ? "var(--color-cyan)" : "#fbbf24"}aa`,
|
||||
textShadow: "0 1px 2px rgba(0,0,0,0.9)",
|
||||
}}
|
||||
>
|
||||
{vip ? <Gem className="w-3 h-3" /> : <Star className="w-3 h-3" style={{ fill: "#fbbf24" }} />}
|
||||
{vip ? "VIP" : "Favorite"}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"absolute right-3 z-20 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity",
|
||||
view === "portrait"
|
||||
? "bottom-7 flex-col items-end"
|
||||
: "bottom-3 flex-row items-center",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleVip}
|
||||
title={vip ? "Unmark VIP" : "Mark VIP"}
|
||||
className={cn(
|
||||
"w-8 h-8 grid place-items-center rounded-md cursor-pointer transition-all duration-150",
|
||||
"hover:scale-110 hover:ring-2 hover:ring-cyan-300 hover:shadow-lg active:scale-95",
|
||||
vip
|
||||
? "bg-cyan-400/40 text-cyan-200 hover:bg-cyan-400/60"
|
||||
: "bg-black/70 text-white hover:bg-cyan-400/30 hover:text-cyan-200",
|
||||
)}
|
||||
>
|
||||
<Gem className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleFavorite}
|
||||
title={favorite ? "Unmark Favorite" : "Mark Favorite"}
|
||||
className={cn(
|
||||
"w-8 h-8 grid place-items-center rounded-md cursor-pointer transition-all duration-150",
|
||||
"hover:scale-110 hover:ring-2 hover:ring-amber-300 hover:shadow-lg active:scale-95",
|
||||
favorite
|
||||
? "bg-amber-400/40 text-amber-200 hover:bg-amber-400/60"
|
||||
: "bg-black/70 text-white hover:bg-amber-400/30 hover:text-amber-200",
|
||||
)}
|
||||
>
|
||||
<Star className={cn("w-4 h-4", favorite && "fill-amber-200")} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleWatched}
|
||||
title={watched ? "Mark as not watched" : "Mark as watched"}
|
||||
className={cn(
|
||||
"w-8 h-8 grid place-items-center rounded-md cursor-pointer transition-all duration-150",
|
||||
"hover:scale-110 hover:ring-2 hover:ring-emerald-300 hover:shadow-lg active:scale-95",
|
||||
watched
|
||||
? "bg-emerald-400/40 text-emerald-200 hover:bg-emerald-400/60"
|
||||
: "bg-black/70 text-white hover:bg-emerald-400/30 hover:text-emerald-200",
|
||||
)}
|
||||
>
|
||||
{watched ? <Eye className="w-4 h-4" /> : <EyeOff className="w-4 h-4" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleOwned}
|
||||
title={owned ? "Unmark Owned" : "Mark Owned"}
|
||||
className={cn(
|
||||
"w-8 h-8 grid place-items-center rounded-md cursor-pointer transition-all duration-150",
|
||||
"hover:scale-110 hover:ring-2 hover:ring-violet-300 hover:shadow-lg active:scale-95",
|
||||
owned
|
||||
? "bg-violet-400/40 text-violet-200 hover:bg-violet-400/60"
|
||||
: "bg-black/70 text-white hover:bg-violet-400/30 hover:text-violet-200",
|
||||
)}
|
||||
>
|
||||
<Package className={cn("w-4 h-4", owned && "fill-violet-200/20")} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-x-0 bottom-0 z-10 p-3 pt-12 bg-gradient-to-t from-black/90 via-black/60 to-transparent">
|
||||
{image.code && (
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span
|
||||
className="text-base uppercase tracking-wider font-mono font-bold text-[var(--color-cyan)] truncate"
|
||||
style={{ textShadow: "0 1px 3px rgba(0,0,0,0.9)" }}
|
||||
>
|
||||
{image.code}
|
||||
</span>
|
||||
{hasSubtitle && (
|
||||
<span
|
||||
title={hasVideo ? "Has playable video and subtitles" : "Has subtitle file"}
|
||||
className="inline-flex items-center gap-1 text-[10px] uppercase tracking-wider font-mono font-semibold px-1.5 py-0.5 rounded border border-[var(--color-mint)]/50 bg-black/60 backdrop-blur-md text-[var(--color-mint)] shrink-0"
|
||||
style={{ textShadow: "0 1px 2px rgba(0,0,0,0.9)" }}
|
||||
>
|
||||
<Captions className="w-3 h-3" /> CC
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{image.actresses.length > 0 && (
|
||||
<div
|
||||
className="text-xs text-white/75 truncate mt-0.5"
|
||||
style={{ textShadow: "0 1px 3px rgba(0,0,0,0.9)" }}
|
||||
>
|
||||
{image.actresses.map((a, i) => (
|
||||
<span key={a.id}>
|
||||
{i > 0 && <span className="text-white/40">, </span>}
|
||||
{/* Programmatic navigation rather than <Link>: HTML
|
||||
forbids nested <a> elements, and the cover card
|
||||
is wrapped in a Link of its own. */}
|
||||
<span
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
router.push(`/actress/${a.slug}`);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
router.push(`/actress/${a.slug}`);
|
||||
}
|
||||
}}
|
||||
className="cursor-pointer hover:text-[var(--color-violet)] hover:underline underline-offset-2"
|
||||
>
|
||||
{a.name}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
{menuPos && (
|
||||
<ImageContextMenu
|
||||
imageIds={menuPos.ids}
|
||||
singleHref={menuPos.ids.length > 1 ? null : coverHref(image)}
|
||||
x={menuPos.x}
|
||||
y={menuPos.y}
|
||||
onClose={() => setMenuPos(null)}
|
||||
/>
|
||||
)}
|
||||
{playing && image.code && (
|
||||
<VideoPlayerModal
|
||||
code={image.code}
|
||||
actresses={image.actresses}
|
||||
onClose={() => setPlaying(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Memoized so cards don't re-render on every Virtuoso scroll tick or
|
||||
// parent state change. Equality is shallow on `image` + `view` — the
|
||||
// card's mutable state (selection, watched, vip, etc.) is held inside
|
||||
// the component itself, so a stable `image` reference + same `view`
|
||||
// safely skip the re-render.
|
||||
export const ImageCard = memo(ImageCardImpl, (a, b) =>
|
||||
a.view === b.view && a.image === b.image,
|
||||
);
|
||||
@@ -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";
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Infinity, FileText } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const STORAGE_KEY = "pinkudex.infiniteScroll";
|
||||
const EVENT_NAME = "pinkudex:infinite-scroll-toggled";
|
||||
|
||||
export function readInfiniteScrollEnabled(): boolean {
|
||||
if (typeof window === "undefined") return true;
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (raw === "0") return false;
|
||||
return true;
|
||||
} catch { return true; }
|
||||
}
|
||||
|
||||
function writeInfiniteScrollEnabled(value: boolean): void {
|
||||
try { localStorage.setItem(STORAGE_KEY, value ? "1" : "0"); } catch { /* ignore */ }
|
||||
try { window.dispatchEvent(new CustomEvent(EVENT_NAME, { detail: value })); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to toggle changes from anywhere in the app. Returns the
|
||||
* current value. Updates synchronously when the toggle is flipped.
|
||||
*/
|
||||
export function useInfiniteScrollEnabled(): boolean {
|
||||
const [enabled, setEnabled] = useState<boolean>(true);
|
||||
useEffect(() => {
|
||||
setEnabled(readInfiniteScrollEnabled());
|
||||
const onChange = (e: Event) => {
|
||||
const next = (e as CustomEvent<boolean>).detail;
|
||||
setEnabled(next);
|
||||
};
|
||||
window.addEventListener(EVENT_NAME, onChange);
|
||||
// Cross-tab updates via the storage event.
|
||||
const onStorage = (e: StorageEvent) => {
|
||||
if (e.key === STORAGE_KEY) setEnabled(readInfiniteScrollEnabled());
|
||||
};
|
||||
window.addEventListener("storage", onStorage);
|
||||
return () => {
|
||||
window.removeEventListener(EVENT_NAME, onChange);
|
||||
window.removeEventListener("storage", onStorage);
|
||||
};
|
||||
}, []);
|
||||
return enabled;
|
||||
}
|
||||
|
||||
export function InfiniteScrollToggle() {
|
||||
const enabled = useInfiniteScrollEnabled();
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => writeInfiniteScrollEnabled(!enabled)}
|
||||
title={enabled ? "Infinite scroll on — click to disable (paginated only)" : "Paginated only — click to enable infinite scroll"}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center w-9 h-9 rounded-lg border transition-colors cursor-pointer",
|
||||
enabled
|
||||
? "border-[var(--color-cyan)]/50 bg-[var(--color-cyan)]/10 text-[var(--color-cyan)]"
|
||||
: "border-[var(--color-glass-border)] bg-[var(--color-glass)] text-[var(--color-fg-dim)] hover:text-[var(--color-fg)]",
|
||||
)}
|
||||
aria-pressed={enabled}
|
||||
aria-label={enabled ? "Disable infinite scroll" : "Enable infinite scroll"}
|
||||
>
|
||||
{enabled ? <Infinity className="w-4 h-4" /> : <FileText className="w-4 h-4" />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useTransition } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".split("");
|
||||
const NON_LATIN = "#";
|
||||
|
||||
export function LetterBar({
|
||||
active,
|
||||
counts,
|
||||
}: {
|
||||
active: string | null;
|
||||
counts: Record<string, number>;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const params = useSearchParams();
|
||||
const [, start] = useTransition();
|
||||
const total = counts[""] ?? 0;
|
||||
const hasSearch = !!(params.get("q") ?? "").trim();
|
||||
|
||||
function go(letter: string | null) {
|
||||
const next = new URLSearchParams(params.toString());
|
||||
if (letter == null) next.delete("letter");
|
||||
else next.set("letter", letter);
|
||||
start(() => router.push(`?${next.toString()}`, { scroll: false }));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-stretch gap-1 w-full">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => go(null)}
|
||||
className={cn(
|
||||
"flex-1 flex flex-col items-center justify-center font-mono py-2 rounded-lg border transition-colors",
|
||||
active === null
|
||||
? "bg-[var(--color-cyan)] text-black border-transparent"
|
||||
: "glass glass-hover",
|
||||
)}
|
||||
>
|
||||
<span className="text-base font-semibold leading-none">ALL</span>
|
||||
<span className={cn(
|
||||
"text-[10px] font-semibold tabular-nums mt-0.5",
|
||||
active === null ? "text-black/70" : "text-[var(--color-fg-muted)]",
|
||||
)}>
|
||||
{total}
|
||||
</span>
|
||||
</button>
|
||||
{[...LETTERS, NON_LATIN].map((L) => {
|
||||
const n = counts[L] ?? 0;
|
||||
const enabled = n > 0 && !hasSearch;
|
||||
const isActive = active === L;
|
||||
return (
|
||||
<button
|
||||
key={L}
|
||||
type="button"
|
||||
disabled={!enabled}
|
||||
onClick={() => go(isActive ? null : L)}
|
||||
className={cn(
|
||||
"flex-1 flex flex-col items-center justify-center font-mono py-2 rounded-lg border transition-colors",
|
||||
isActive
|
||||
? "bg-[var(--color-cyan)] text-black border-transparent"
|
||||
: enabled
|
||||
? "glass glass-hover"
|
||||
: "border-transparent text-[var(--color-fg-muted)]/40 cursor-not-allowed",
|
||||
)}
|
||||
>
|
||||
<span className="text-base font-semibold leading-none">{L}</span>
|
||||
<span className={cn(
|
||||
"text-[10px] font-semibold tabular-nums mt-0.5",
|
||||
isActive ? "text-black/70" : enabled ? "text-[var(--color-fg-muted)]" : "text-transparent",
|
||||
)}>
|
||||
{enabled ? n : 0}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
"use client";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import type { VirtuosoHandle } from "react-virtuoso";
|
||||
import { MasonryGrid } from "./MasonryGrid";
|
||||
import { PaginationBar } from "./PaginationBar";
|
||||
import { useInfiniteScrollEnabled } from "./InfiniteScrollToggle";
|
||||
import { useSettings } from "@/components/settings/SettingsProvider";
|
||||
import type { CardImage } from "./ImageCard";
|
||||
import type { LibraryView } from "./ViewToggle";
|
||||
|
||||
interface Props {
|
||||
initialItems: CardImage[];
|
||||
initialPage: number;
|
||||
totalPages: number;
|
||||
totalCount: number;
|
||||
view: LibraryView;
|
||||
infiniteScrollEnabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper that owns the "loaded pages" state for the cover grid. The
|
||||
* top + bottom pagination bars and the grid itself all read from here
|
||||
* so the bottom bar can show "Pages 1–7 of 7" once the user has
|
||||
* scroll-appended through the whole result set.
|
||||
*
|
||||
* Top bar stays anchored at `initialPage` — that's where the user
|
||||
* landed via URL. Bottom bar reflects `loadedEnd`, the highest page
|
||||
* currently appended.
|
||||
*/
|
||||
export function LibraryGrid({
|
||||
initialItems,
|
||||
initialPage,
|
||||
totalPages,
|
||||
totalCount,
|
||||
view,
|
||||
infiniteScrollEnabled: infiniteFromProp = true,
|
||||
}: Props) {
|
||||
const sp = useSearchParams();
|
||||
const pathname = usePathname();
|
||||
// The toggle in the FilterBar persists per-user-tab via localStorage.
|
||||
// The prop is the page-level "is this surface ever allowed to
|
||||
// infinite-scroll?" gate; AND with the user's preference.
|
||||
const userInfinite = useInfiniteScrollEnabled();
|
||||
const infiniteScrollEnabled = infiniteFromProp && userInfinite;
|
||||
const { settings } = useSettings();
|
||||
const fadeMs = settings.fadeTransitions ? Math.max(0, settings.fadeDurationMs ?? 400) : 0;
|
||||
|
||||
// Loaded items split into two buckets: the SSR-rendered initial
|
||||
// page (kept stable for hydration) and any appended-by-fetch pages.
|
||||
const [extra, setExtra] = useState<CardImage[]>([]);
|
||||
const [loadedEnd, setLoadedEnd] = useState<number>(initialPage);
|
||||
// The page currently in the viewport, derived from Virtuoso's
|
||||
// first-visible-row index. Used solely for the "Page X of Y" label
|
||||
// — navigation still keys off `initialPage` (URL anchor) and
|
||||
// `loadedEnd`. Defaults to initialPage so SSR matches.
|
||||
const [visiblePage, setVisiblePage] = useState<number>(initialPage);
|
||||
const pageSize = Math.max(25, Math.min(500, settings.coverPageSize || 100));
|
||||
// Per-batch fade controller. Each appended page is a "batch"; when
|
||||
// any row of that batch first intersects the viewport, every row in
|
||||
// the same batch fades in together (rather than one-row-at-a-time
|
||||
// as the user scrolls past). Rows subscribe to their batch's trigger
|
||||
// so they all flip to "animated" at once.
|
||||
const fadeController = useMemo(() => {
|
||||
let seq = 0;
|
||||
const itemBatch = new Map<number, number>();
|
||||
const batchIds = new Map<number, number[]>();
|
||||
const triggered = new Set<number>();
|
||||
const subs = new Map<number, Set<() => void>>();
|
||||
return {
|
||||
addBatch(ids: number[]): number {
|
||||
seq += 1;
|
||||
const id = seq;
|
||||
batchIds.set(id, ids);
|
||||
for (const it of ids) itemBatch.set(it, id);
|
||||
return id;
|
||||
},
|
||||
batchIdOf(itemId: number): number | null {
|
||||
return itemBatch.get(itemId) ?? null;
|
||||
},
|
||||
isTriggered(batchId: number): boolean {
|
||||
return triggered.has(batchId);
|
||||
},
|
||||
trigger(batchId: number) {
|
||||
if (triggered.has(batchId)) return;
|
||||
triggered.add(batchId);
|
||||
const set = subs.get(batchId);
|
||||
if (set) for (const cb of set) cb();
|
||||
},
|
||||
subscribe(batchId: number, cb: () => void): () => void {
|
||||
let set = subs.get(batchId);
|
||||
if (!set) { set = new Set(); subs.set(batchId, set); }
|
||||
set.add(cb);
|
||||
return () => { set?.delete(cb); };
|
||||
},
|
||||
// Drop a batch entirely — items no longer count as pending so a
|
||||
// future remount won't replay the keyframe.
|
||||
expire(batchId: number) {
|
||||
const ids = batchIds.get(batchId);
|
||||
if (ids) for (const it of ids) itemBatch.delete(it);
|
||||
batchIds.delete(batchId);
|
||||
triggered.delete(batchId);
|
||||
subs.delete(batchId);
|
||||
},
|
||||
reset() {
|
||||
seq = 0;
|
||||
itemBatch.clear();
|
||||
batchIds.clear();
|
||||
triggered.clear();
|
||||
subs.clear();
|
||||
},
|
||||
};
|
||||
}, []);
|
||||
const fetchInFlightRef = useRef<boolean>(false);
|
||||
// Page number currently being fetched (or just resolved). Combined
|
||||
// with fetchInFlightRef it dedupes simultaneous requests for the same
|
||||
// page — strict-mode double-invoke and Virtuoso's onEndReached firing
|
||||
// twice in rapid succession both hit this.
|
||||
const lastFetchTargetRef = useRef<number>(0);
|
||||
// Auto-fetch suppression: after appendNextPage resolves we set this
|
||||
// true. Virtuoso's onEndReached path checks it and bails. The user
|
||||
// scrolling at least one full viewport flips it back to false so the
|
||||
// next bottom-trigger can append. Without this, mounting on a page
|
||||
// whose SSR rows are shorter than the viewport causes onEndReached
|
||||
// to fire repeatedly, chaining 3-4 page appends instantly and
|
||||
// dragging visiblePage way past the URL anchor.
|
||||
const autoFetchPausedRef = useRef<boolean>(false);
|
||||
const [isFetching, setIsFetching] = useState(false);
|
||||
|
||||
// Reset when the SSR-anchor changes (filter/sort/page nav). Page
|
||||
// remount via key in app/page.tsx already handles most of this.
|
||||
useEffect(() => {
|
||||
setExtra([]);
|
||||
setLoadedEnd(initialPage);
|
||||
fadeController.reset();
|
||||
}, [initialPage, initialItems, fadeController]);
|
||||
|
||||
const allItems = useMemo(() => [...initialItems, ...extra], [initialItems, extra]);
|
||||
|
||||
// Mirror loadedEnd into a ref so the save closure (registered once
|
||||
// on mount) always reads the current value, without rebinding.
|
||||
const loadedEndRef = useRef(loadedEnd);
|
||||
useEffect(() => { loadedEndRef.current = loadedEnd; }, [loadedEnd]);
|
||||
|
||||
// Reflect the visible page in the URL with `replaceState` (no
|
||||
// history push, so the back button still goes to the previous
|
||||
// route, not through every scroll position). Debounced via
|
||||
// visiblePage state which only updates when the bottom-most-visible
|
||||
// row changes pages — already throttled at the source.
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const usp = new URLSearchParams(sp.toString());
|
||||
if (visiblePage > 1) usp.set("page", String(visiblePage));
|
||||
else usp.delete("page");
|
||||
const qs = usp.toString();
|
||||
const target = qs ? `${pathname}?${qs}` : pathname;
|
||||
if (window.location.pathname + window.location.search !== target) {
|
||||
window.history.replaceState(window.history.state, "", target);
|
||||
}
|
||||
}, [visiblePage, sp, pathname]);
|
||||
|
||||
// Track when we're mounted on the client so the portal can target
|
||||
// document.body without breaking SSR.
|
||||
const [mounted, setMounted] = useState(false);
|
||||
useEffect(() => { setMounted(true); }, []);
|
||||
|
||||
// Imperative handle on Virtuoso so we can call scrollToIndex even
|
||||
// when the target row has been unmounted from DOM (it's outside the
|
||||
// overscan window).
|
||||
const virtuosoHandleRef = useRef<VirtuosoHandle | null>(null);
|
||||
|
||||
|
||||
// Scroll + appended-page restoration. The page is `force-dynamic` so
|
||||
// Next.js re-renders fresh on back-nav (Router Cache doesn't apply),
|
||||
// which means scroll is *not* preserved automatically.
|
||||
//
|
||||
// Strategy: save scrollY + loadedEnd to sessionStorage on every
|
||||
// scroll (debounced) and on cleanup. On mount, if a snapshot exists,
|
||||
// re-fetch any pages the user had appended, then scrollTo with a
|
||||
// retry loop because Virtuoso lazily measures rows and the page may
|
||||
// briefly be too short for the saved scrollY to be valid.
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const key = `pinkudex:scroll:${pathname}?${sp.toString()}`;
|
||||
// Skip restore when arriving via an internal Prev/Next click
|
||||
// (PaginationBar sets this marker before pushing). Otherwise the
|
||||
// user clicking Prev all the way back to / would replay a snapshot
|
||||
// saved during their previous scroll session, re-fetching pages
|
||||
// 2–N and re-creating the visiblePage drift loop.
|
||||
const internalMarker = sessionStorage.getItem("pinkudex:nav-internal");
|
||||
if (internalMarker) {
|
||||
sessionStorage.removeItem("pinkudex:nav-internal");
|
||||
// Also clear the snapshot for this URL so subsequent scrolls
|
||||
// capture fresh state instead of compounding on the old one.
|
||||
try { sessionStorage.removeItem(key); } catch { /* ignore */ }
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const restore = async () => {
|
||||
let snap: { scrollY: number; loadedEnd: number } | null = null;
|
||||
try {
|
||||
const raw = sessionStorage.getItem(key);
|
||||
if (raw) snap = JSON.parse(raw);
|
||||
} catch { /* corrupt — ignore */ }
|
||||
if (!snap || snap.scrollY <= 0) return;
|
||||
|
||||
// Refetch missing appended pages so the document is tall enough
|
||||
// for the saved scrollY to land somewhere meaningful.
|
||||
if (snap.loadedEnd > initialPage && infiniteScrollEnabled) {
|
||||
const collected: CardImage[] = [];
|
||||
for (let p = initialPage + 1; p <= snap.loadedEnd && p <= totalPages; p++) {
|
||||
if (cancelled) return;
|
||||
const usp = new URLSearchParams(sp.toString());
|
||||
usp.set("page", String(p));
|
||||
try {
|
||||
const r = await fetch(`/api/covers?${usp.toString()}`, { cache: "no-store" });
|
||||
if (!r.ok) break;
|
||||
const data = (await r.json()) as { items: CardImage[]; page: number };
|
||||
if (!Array.isArray(data.items)) break;
|
||||
collected.push(...data.items);
|
||||
} catch { break; }
|
||||
}
|
||||
if (cancelled) return;
|
||||
if (collected.length > 0) {
|
||||
setExtra(collected);
|
||||
setLoadedEnd(snap.loadedEnd);
|
||||
}
|
||||
}
|
||||
|
||||
// Retry scrollTo for up to ~1s. Stops once position settles
|
||||
// within a couple of pixels of target. Necessary because Next.js
|
||||
// and Virtuoso both touch scroll/layout shortly after mount.
|
||||
const target = snap.scrollY;
|
||||
let attempts = 0;
|
||||
const maxAttempts = 60;
|
||||
const tryScroll = () => {
|
||||
if (cancelled) return;
|
||||
window.scrollTo(0, target);
|
||||
attempts += 1;
|
||||
if (Math.abs(window.scrollY - target) <= 2 || attempts >= maxAttempts) return;
|
||||
requestAnimationFrame(tryScroll);
|
||||
};
|
||||
requestAnimationFrame(tryScroll);
|
||||
};
|
||||
restore();
|
||||
|
||||
// Save scroll + loadedEnd. No restoredRef gate — we want every
|
||||
// scroll captured, and re-saving a stale value before restore is
|
||||
// harmless (restore reads once, before it loops).
|
||||
let t: ReturnType<typeof setTimeout> | null = null;
|
||||
const save = () => {
|
||||
try {
|
||||
const payload = JSON.stringify({ scrollY: window.scrollY, loadedEnd: loadedEndRef.current });
|
||||
sessionStorage.setItem(key, payload);
|
||||
} catch { /* quota / private mode */ }
|
||||
};
|
||||
const onScroll = () => {
|
||||
if (t) clearTimeout(t);
|
||||
t = setTimeout(save, 100);
|
||||
};
|
||||
window.addEventListener("scroll", onScroll, { passive: true });
|
||||
window.addEventListener("pagehide", save);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
// Intentionally NOT calling save() here. By the time cleanup
|
||||
// runs on back-nav, Next.js has already reset window.scrollY=0,
|
||||
// and saving that would clobber the snapshot.
|
||||
window.removeEventListener("scroll", onScroll);
|
||||
window.removeEventListener("pagehide", save);
|
||||
if (t) clearTimeout(t);
|
||||
};
|
||||
// Run once per LibraryGrid mount. Filter changes already remount
|
||||
// via the page-level key in app/page.tsx.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Scroll to the first row of `targetPage` once it's in the buffer.
|
||||
// Pure DOM operation; caller is responsible for ensuring the target
|
||||
// page has been loaded.
|
||||
const scrollToLoadedPage = useCallback((targetPage: number): boolean => {
|
||||
const cols = view === "portrait"
|
||||
? Math.max(4, Math.min(10, settings.gridColumnsPortrait || 6))
|
||||
: Math.max(2, Math.min(4, settings.gridColumns || 3));
|
||||
const itemIdx = (targetPage - initialPage) * pageSize;
|
||||
const rowIdx = Math.floor(itemIdx / cols);
|
||||
const handle = virtuosoHandleRef.current;
|
||||
if (!handle) return false;
|
||||
handle.scrollToIndex({ index: rowIdx, align: "start", behavior: "smooth" });
|
||||
return true;
|
||||
}, [initialPage, pageSize, view, settings.gridColumns, settings.gridColumnsPortrait]);
|
||||
|
||||
// Append the page just past loadedEnd. Returns true on success, false
|
||||
// if there's nothing more to load or the request fails. Shared between
|
||||
// the infinite-scroll auto-fetch path, the explicit Load-More button,
|
||||
// and the scroll-mode prefetch loop in scrollToPage.
|
||||
const appendNextPage = useCallback(async (): Promise<boolean> => {
|
||||
if (loadedEnd >= totalPages) return false;
|
||||
const next = loadedEnd + 1;
|
||||
// Dedupe: a second invocation for the same target while the first
|
||||
// is in flight is a no-op. This catches strict-mode double-invoke
|
||||
// in dev and Virtuoso firing onEndReached twice for one bottom hit.
|
||||
if (fetchInFlightRef.current && lastFetchTargetRef.current === next) {
|
||||
return false;
|
||||
}
|
||||
if (fetchInFlightRef.current) return false;
|
||||
fetchInFlightRef.current = true;
|
||||
lastFetchTargetRef.current = next;
|
||||
setIsFetching(true);
|
||||
try {
|
||||
const usp = new URLSearchParams(sp.toString());
|
||||
usp.set("page", String(next));
|
||||
const r = await fetch(`/api/covers?${usp.toString()}`, { cache: "no-store" });
|
||||
if (!r.ok) return false;
|
||||
const data = (await r.json()) as { items: CardImage[]; page: number; hasMore: boolean };
|
||||
if (!Array.isArray(data.items) || data.items.length === 0) return false;
|
||||
if (fadeMs > 0) {
|
||||
fadeController.addBatch(data.items.map((it) => it.id));
|
||||
}
|
||||
setExtra((cur) => [...cur, ...data.items]);
|
||||
setLoadedEnd(data.page);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
fetchInFlightRef.current = false;
|
||||
setIsFetching(false);
|
||||
}
|
||||
}, [loadedEnd, sp, totalPages, fadeMs, fadeController]);
|
||||
|
||||
// Auto-fetch path used by Virtuoso's onEndReached. Gated on the
|
||||
// infinite-scroll preference. After each successful append we pause
|
||||
// until the user scrolls — that breaks the chain where a freshly
|
||||
// mounted SSR page has the bottom row near the viewport, causing
|
||||
// onEndReached to fire repeatedly and append 3-4 pages back-to-back.
|
||||
// The explicit Load-More / prefetch paths bypass this guard via
|
||||
// appendNextPage directly.
|
||||
const fetchNextPage = useCallback(async () => {
|
||||
if (!infiniteScrollEnabled) return;
|
||||
if (autoFetchPausedRef.current) return;
|
||||
const ok = await appendNextPage();
|
||||
if (ok) autoFetchPausedRef.current = true;
|
||||
}, [infiniteScrollEnabled, appendNextPage]);
|
||||
|
||||
// Release the auto-fetch pause only on a real user gesture — wheel,
|
||||
// touchmove, or a scroll-direction key. The earlier window-scroll
|
||||
// listener was too sensitive: programmatic scroll-restoration and
|
||||
// browser overflow-anchor adjustments fire scroll events and were
|
||||
// bypassing the pause, which let the auto-fetch chain still run
|
||||
// 3-4 pages deep on initial mount and after URL nav back to /.
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const release = () => { autoFetchPausedRef.current = false; };
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "PageDown" || e.key === "ArrowDown" || e.key === "End" || e.key === " " || e.key === "Spacebar") {
|
||||
autoFetchPausedRef.current = false;
|
||||
}
|
||||
};
|
||||
window.addEventListener("wheel", release, { passive: true });
|
||||
window.addEventListener("touchmove", release, { passive: true });
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
window.removeEventListener("wheel", release);
|
||||
window.removeEventListener("touchmove", release);
|
||||
window.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Build the scroll-mode entry point. Backward across the SSR anchor
|
||||
// returns false → URL nav. Forward past loadedEnd prefetches one
|
||||
// page at a time until the target lands inside the buffer, then
|
||||
// scrolls to it. Each loop iteration awaits a real network round-trip
|
||||
// — this is the "always scroll, prefetch when needed" behavior.
|
||||
const scrollToPageScrollMode = useCallback(async (targetPage: number): Promise<boolean> => {
|
||||
if (targetPage < initialPage) return false;
|
||||
if (targetPage > totalPages) return false;
|
||||
while (targetPage > loadedEndRef.current) {
|
||||
const ok = await appendNextPage();
|
||||
if (!ok) return false;
|
||||
}
|
||||
return scrollToLoadedPage(targetPage);
|
||||
}, [initialPage, totalPages, appendNextPage, scrollToLoadedPage]);
|
||||
|
||||
// Pick the right Prev/Next handler based on the user's preference.
|
||||
// - "url" → no callback; bar always URL-navs.
|
||||
// - "scroll" → prefetch + smooth scroll, URL fallback only on backward.
|
||||
const onScrollToPageProp = settings.paginationMode === "scroll" ? scrollToPageScrollMode : undefined;
|
||||
|
||||
// Same-URL nav handler — fires when the bar would push to the page
|
||||
// we're already at (e.g. clicking Prev at "Page 5 (scrolled from
|
||||
// URL=/)" wants to land on page 1). Resets the appended buffer +
|
||||
// scrolls to top so the user sees a real change instead of nothing.
|
||||
const handleSamePageNav = useCallback(() => {
|
||||
setExtra([]);
|
||||
setLoadedEnd(initialPage);
|
||||
fadeController.reset();
|
||||
autoFetchPausedRef.current = true;
|
||||
if (typeof window !== "undefined") window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}, [initialPage, fadeController]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<MasonryGrid
|
||||
images={allItems}
|
||||
view={view}
|
||||
infiniteScrollEnabled={infiniteScrollEnabled}
|
||||
loadedEnd={loadedEnd}
|
||||
totalPages={totalPages}
|
||||
onEndReached={fetchNextPage}
|
||||
isFetching={isFetching}
|
||||
fadeController={fadeController}
|
||||
fadeMs={fadeMs}
|
||||
pageSize={pageSize}
|
||||
ssrAnchorPage={initialPage}
|
||||
onVisiblePageChange={setVisiblePage}
|
||||
virtuosoHandleRef={virtuosoHandleRef}
|
||||
/>
|
||||
|
||||
{/* Floating bar — portaled to <body> to escape any ancestor's
|
||||
backdrop-filter / transform, which would otherwise trap a
|
||||
`position: fixed` child to that ancestor's box. */}
|
||||
{mounted && createPortal(
|
||||
<div className="fixed inset-x-0 bottom-[12px] z-30 flex justify-center pointer-events-none">
|
||||
<div
|
||||
className="pointer-events-auto rounded-2xl shadow-2xl px-4 py-2.5 border border-[var(--color-glass-border-strong)] backdrop-blur-2xl"
|
||||
style={{ background: "color-mix(in oklch, var(--color-bg-0) 85%, transparent)" }}
|
||||
>
|
||||
<PaginationBar
|
||||
currentPage={visiblePage}
|
||||
totalPages={totalPages}
|
||||
totalCount={totalCount}
|
||||
onScrollToPage={onScrollToPageProp}
|
||||
onSamePageNav={handleSamePageNav}
|
||||
/>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
"use client";
|
||||
import { useEffect, useRef, useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Tag, ChevronDown, Eye, EyeOff, Gem, Star, Package, MinusCircle } from "lucide-react";
|
||||
import { useSelection } from "@/components/select/SelectionProvider";
|
||||
import { bulkSetMark, bulkSetWatched, bulkSetOwned } from "@/app/actions/bulk";
|
||||
import { dispatchQueueRemove } from "@/components/queue/watchQueueEvents";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Action = "watched" | "unwatched" | "vip" | "favorite" | "owned" | "unmark";
|
||||
|
||||
export function MarkActionPopover() {
|
||||
const sel = useSelection();
|
||||
const count = sel.ids.size;
|
||||
const enabled = count > 0;
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
const [pending, start] = useTransition();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDoc = (e: MouseEvent) => {
|
||||
if (!wrapRef.current?.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", onDoc);
|
||||
return () => document.removeEventListener("mousedown", onDoc);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setOpen(false); };
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [open]);
|
||||
|
||||
// If selection drains while menu is open, close it.
|
||||
useEffect(() => {
|
||||
if (!enabled && open) setOpen(false);
|
||||
}, [enabled, open]);
|
||||
|
||||
function pick(action: Action) {
|
||||
setOpen(false);
|
||||
const ids = Array.from(sel.ids);
|
||||
if (ids.length === 0) return;
|
||||
start(async () => {
|
||||
if (action === "watched") { await bulkSetWatched(ids, true); dispatchQueueRemove(ids); }
|
||||
else if (action === "unwatched") await bulkSetWatched(ids, false);
|
||||
else if (action === "vip") await bulkSetMark(ids, "vip");
|
||||
else if (action === "favorite") await bulkSetMark(ids, "favorite");
|
||||
else if (action === "owned") await bulkSetOwned(ids, true);
|
||||
else if (action === "unmark") await bulkSetMark(ids, "unmarked");
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative" ref={wrapRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => enabled && setOpen((v) => !v)}
|
||||
disabled={!enabled || pending}
|
||||
title={enabled ? `Mark ${count} selected cover${count === 1 ? "" : "s"}` : "Select covers first"}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-3 py-1.5 rounded-full border text-sm transition-colors min-w-[140px] justify-between",
|
||||
enabled
|
||||
? "bg-[var(--color-cyan)]/10 border-[var(--color-cyan)]/40 text-[var(--color-cyan)] hover:bg-[var(--color-cyan)]/20"
|
||||
: "glass text-[var(--color-fg-muted)] opacity-50 cursor-not-allowed",
|
||||
)}
|
||||
>
|
||||
<Tag className="w-3.5 h-3.5" />
|
||||
Mark As
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center min-w-[22px] h-4 px-1 rounded-full text-black text-[10px] font-mono font-bold tabular-nums bg-[var(--color-cyan)]",
|
||||
!enabled && "invisible",
|
||||
)}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
<ChevronDown className="w-3 h-3 opacity-70" />
|
||||
</button>
|
||||
|
||||
{open && enabled && (
|
||||
<div
|
||||
className="absolute left-0 top-[calc(100%+6px)] z-30 bg-[var(--color-bg-0)] border border-[var(--color-glass-border-strong)] rounded-xl shadow-2xl p-1 w-56"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Row icon={Eye} label="Watched" colorClass="text-[var(--color-mint)]" onClick={() => pick("watched")} />
|
||||
<Row icon={EyeOff} label="Unwatched" onClick={() => pick("unwatched")} />
|
||||
<Divider />
|
||||
<Row icon={Gem} label="VIP" colorClass="text-[var(--color-cyan)]" onClick={() => pick("vip")} />
|
||||
<Row icon={Star} label="Favorite" colorClass="text-amber-300" iconStyle={{ fill: "#fbbf24" }} onClick={() => pick("favorite")} />
|
||||
<Row icon={Package} label="Owned" colorClass="text-[var(--color-violet)]" onClick={() => pick("owned")} />
|
||||
<Row icon={MinusCircle} label="Unmark VIP/Fav" colorClass="text-[var(--color-fg-muted)]" onClick={() => pick("unmark")} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Divider() {
|
||||
return <div className="h-px bg-[var(--color-glass-border)] my-1" />;
|
||||
}
|
||||
|
||||
function Row({
|
||||
icon: Icon,
|
||||
label,
|
||||
onClick,
|
||||
colorClass,
|
||||
iconStyle,
|
||||
}: {
|
||||
icon: React.ComponentType<{ className?: string; style?: React.CSSProperties }>;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
colorClass?: string;
|
||||
iconStyle?: React.CSSProperties;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2.5 px-2 py-1.5 rounded-md text-sm text-left transition-colors hover:bg-[var(--color-glass)]",
|
||||
colorClass ?? "text-[var(--color-fg-dim)]",
|
||||
)}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5" style={iconStyle} />
|
||||
<span className="flex-1">{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
"use client";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Bookmark, ChevronDown, Gem, Star, MinusCircle, Package, Check } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { FilterCriteria, MarkOption } from "@/lib/filters";
|
||||
|
||||
const OPTIONS: Array<{
|
||||
value: MarkOption;
|
||||
label: string;
|
||||
Icon: React.ComponentType<{ className?: string }>;
|
||||
tint: "cyan" | "amber" | "violet" | "muted";
|
||||
}> = [
|
||||
{ value: "vip", label: "VIP", Icon: Gem, tint: "cyan" },
|
||||
{ value: "favorite", label: "Favorite", Icon: Star, tint: "amber" },
|
||||
{ value: "owned", label: "Owned", Icon: Package, tint: "violet" },
|
||||
{ value: "unmarked", label: "Unmarked", Icon: MinusCircle, tint: "muted" },
|
||||
];
|
||||
|
||||
export function MarkPopover({
|
||||
criteria,
|
||||
onChange,
|
||||
}: {
|
||||
criteria: FilterCriteria;
|
||||
onChange: (next: FilterCriteria) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
const active = criteria.marks.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDoc = (e: MouseEvent) => {
|
||||
if (!wrapRef.current?.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", onDoc);
|
||||
return () => document.removeEventListener("mousedown", onDoc);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setOpen(false); };
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [open]);
|
||||
|
||||
function toggle(value: MarkOption) {
|
||||
const has = criteria.marks.includes(value);
|
||||
const next = has ? criteria.marks.filter((m) => m !== value) : [...criteria.marks, value];
|
||||
onChange({ ...criteria, marks: next });
|
||||
}
|
||||
|
||||
function selectAll() { onChange({ ...criteria, marks: [] }); }
|
||||
|
||||
return (
|
||||
<div className="relative" ref={wrapRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-3 py-1.5 rounded-full border text-sm transition-colors min-w-[140px] justify-between",
|
||||
active
|
||||
? "bg-[var(--color-violet)]/12 border-[var(--color-violet)]/40 text-[var(--color-violet)]"
|
||||
: "glass glass-hover text-[var(--color-fg-dim)]",
|
||||
)}
|
||||
>
|
||||
<Bookmark className="w-3.5 h-3.5" />
|
||||
Filter
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-[var(--color-violet)] text-black text-[10px] font-mono font-bold tabular-nums",
|
||||
!active && "invisible",
|
||||
)}
|
||||
>
|
||||
{criteria.marks.length || 0}
|
||||
</span>
|
||||
<ChevronDown className="w-3 h-3 opacity-70" />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
className="absolute left-0 top-[calc(100%+6px)] z-30 bg-[var(--color-bg-0)] border border-[var(--color-glass-border-strong)] rounded-xl shadow-2xl p-2 w-60"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={selectAll}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2.5 px-2 py-1.5 rounded-md text-sm text-left transition-colors",
|
||||
!active
|
||||
? "bg-[var(--color-glass-strong)] text-[var(--color-fg)]"
|
||||
: "text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]",
|
||||
)}
|
||||
>
|
||||
<span className={cn(
|
||||
"w-4 h-4 rounded border flex items-center justify-center shrink-0",
|
||||
!active ? "bg-[var(--color-fg)]/20 border-[var(--color-fg-dim)]" : "border-[var(--color-glass-border-strong)]",
|
||||
)}>
|
||||
{!active && <span className="w-1.5 h-1.5 rounded-full bg-[var(--color-fg)]" />}
|
||||
</span>
|
||||
<span className="flex-1">All (clear filter)</span>
|
||||
</button>
|
||||
|
||||
<div className="h-px bg-[var(--color-glass-border)] my-1" />
|
||||
|
||||
{OPTIONS.map(({ value, label, Icon, tint }) => {
|
||||
const on = criteria.marks.includes(value);
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => toggle(value)}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2.5 px-2 py-1.5 rounded-md text-sm text-left transition-colors",
|
||||
on
|
||||
? tint === "cyan" ? "bg-[var(--color-cyan)]/15 text-[var(--color-cyan)]"
|
||||
: tint === "amber" ? "text-amber-200"
|
||||
: tint === "violet" ? "bg-[var(--color-violet)]/15 text-[var(--color-violet)]"
|
||||
: "bg-[var(--color-glass-strong)] text-[var(--color-fg)]"
|
||||
: "text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]",
|
||||
)}
|
||||
style={on && tint === "amber" ? { background: "rgba(251,191,36,0.15)" } : undefined}
|
||||
>
|
||||
<span className={cn(
|
||||
"w-4 h-4 rounded border flex items-center justify-center shrink-0",
|
||||
on
|
||||
? tint === "cyan" ? "bg-[var(--color-cyan)]/30 border-[var(--color-cyan)]"
|
||||
: tint === "amber" ? "border-amber-400"
|
||||
: tint === "violet" ? "bg-[var(--color-violet)]/30 border-[var(--color-violet)]"
|
||||
: "bg-[var(--color-fg-dim)]/30 border-[var(--color-fg-dim)]"
|
||||
: "border-[var(--color-glass-border-strong)]",
|
||||
)}
|
||||
style={on && tint === "amber" ? { background: "rgba(251,191,36,0.25)" } : undefined}>
|
||||
{on && (
|
||||
<Check
|
||||
className="w-3 h-3"
|
||||
strokeWidth={3}
|
||||
style={{
|
||||
color: tint === "cyan" ? "var(--color-cyan)"
|
||||
: tint === "amber" ? "#fbbf24"
|
||||
: tint === "violet" ? "var(--color-violet)"
|
||||
: "var(--color-fg)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className="inline-flex items-center"
|
||||
style={{
|
||||
color: tint === "cyan" ? "var(--color-cyan)"
|
||||
: tint === "amber" ? "#fbbf24"
|
||||
: tint === "violet" ? "var(--color-violet)"
|
||||
: "var(--color-fg-muted)",
|
||||
}}
|
||||
>
|
||||
<Icon className={cn("w-3.5 h-3.5", on && tint === "amber" && "fill-amber-300")} />
|
||||
</span>
|
||||
<span className="flex-1">{label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
"use client";
|
||||
import { Virtuoso, type VirtuosoHandle } from "react-virtuoso";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { ImageCard, type CardImage } from "./ImageCard";
|
||||
import type { LibraryView } from "./ViewToggle";
|
||||
import { useSettings } from "@/components/settings/SettingsProvider";
|
||||
|
||||
/**
|
||||
* Windowed grid: renders only visible rows, mounting/unmounting cards
|
||||
* as the user scrolls. Backed by react-virtuoso's window-scroll mode
|
||||
* so the page itself scrolls (not an inner div).
|
||||
*
|
||||
* Items are bucketed into rows of `cols` cards each — Virtuoso treats
|
||||
* each row as one item and measures its height dynamically. This keeps
|
||||
* the masonry-style CSS grid (equal columns, variable card heights)
|
||||
* working without forcing fixed-aspect cards.
|
||||
*
|
||||
* Below a small threshold we render a plain CSS grid — virtualization
|
||||
* overhead isn't worth it for short lists, and the simpler DOM tree
|
||||
* cooperates better with browser-native fade-in / scroll animations.
|
||||
*/
|
||||
interface Props {
|
||||
images: CardImage[];
|
||||
view?: LibraryView;
|
||||
/** Highest page number currently appended. Used to decide whether
|
||||
* to keep firing endReached. */
|
||||
loadedEnd?: number;
|
||||
/** Total pages for the current filter set. */
|
||||
totalPages?: number;
|
||||
/** When false, infinite-scroll appends are suppressed (filtered
|
||||
* views, user toggle, etc.). */
|
||||
infiniteScrollEnabled?: boolean;
|
||||
/** Called when Virtuoso detects we're near the end and more pages
|
||||
* exist. Parent fetches and grows `images`. */
|
||||
onEndReached?: () => void;
|
||||
/** True while the parent is fetching the next page — drives the
|
||||
* small "Loading next page..." footer beneath the grid. */
|
||||
isFetching?: boolean;
|
||||
/** Per-batch fade controller. Each row looks up which batch its
|
||||
* items belong to; the first row of that batch to intersect the
|
||||
* viewport triggers the batch, fading every row of the batch in
|
||||
* unison. */
|
||||
fadeController?: FadeController;
|
||||
/** Fade animation duration in ms; matches CSS `--fade-duration`. */
|
||||
fadeMs?: number;
|
||||
/** Items per logical "page" — used to derive the page currently
|
||||
* scrolled into view from the first-visible row index. */
|
||||
pageSize?: number;
|
||||
/** The page the SSR rendered from. Item index 0 corresponds to this
|
||||
* page, not page 1, when the user landed via ?page=N. */
|
||||
ssrAnchorPage?: number;
|
||||
/** Fires whenever the viewport's leading row changes pages. */
|
||||
onVisiblePageChange?: (page: number) => void;
|
||||
/** Receives Virtuoso's imperative handle so the parent can call
|
||||
* `scrollToIndex` on it (e.g. for Prev/Next navigation jumps). */
|
||||
virtuosoHandleRef?: React.MutableRefObject<VirtuosoHandle | null>;
|
||||
}
|
||||
|
||||
interface FadeController {
|
||||
batchIdOf(itemId: number): number | null;
|
||||
isTriggered(batchId: number): boolean;
|
||||
trigger(batchId: number): void;
|
||||
subscribe(batchId: number, cb: () => void): () => void;
|
||||
expire(batchId: number): void;
|
||||
}
|
||||
|
||||
function MasonryRow({
|
||||
rowIdx, row, cols, view, fadeController, fadeMs,
|
||||
}: {
|
||||
rowIdx: number;
|
||||
row: CardImage[];
|
||||
cols: number;
|
||||
view: LibraryView;
|
||||
fadeController?: FadeController;
|
||||
fadeMs?: number;
|
||||
}) {
|
||||
// Resolve this row's batch once on mount. If null, the row was part
|
||||
// of the SSR initial page (or its batch already expired) — no fade.
|
||||
const [batchId] = useState<number | null>(
|
||||
() => fadeController?.batchIdOf(row[0]?.id ?? -1) ?? null,
|
||||
);
|
||||
const isFresh = batchId != null;
|
||||
// `animated` flips when the batch's trigger fires. Initial value
|
||||
// honors a batch that was already triggered (e.g. row remounted via
|
||||
// Virtuoso unmount/remount mid-fade).
|
||||
const [animated, setAnimated] = useState<boolean>(
|
||||
() => batchId != null && !!fadeController?.isTriggered(batchId),
|
||||
);
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// Subscribe to batch trigger so every row flips at the same time.
|
||||
useEffect(() => {
|
||||
if (!isFresh || !fadeController || batchId == null) return;
|
||||
return fadeController.subscribe(batchId, () => setAnimated(true));
|
||||
}, [isFresh, fadeController, batchId]);
|
||||
|
||||
// First row to intersect triggers the batch. Virtuoso pre-renders
|
||||
// rows in its overscan window (well below the viewport), so without
|
||||
// this gate the animation would finish off-screen.
|
||||
useEffect(() => {
|
||||
if (!isFresh || animated || !fadeController || batchId == null) return;
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const io = new IntersectionObserver((entries) => {
|
||||
for (const e of entries) {
|
||||
if (e.isIntersecting) {
|
||||
fadeController.trigger(batchId);
|
||||
io.disconnect();
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
io.observe(el);
|
||||
return () => io.disconnect();
|
||||
}, [isFresh, animated, fadeController, batchId]);
|
||||
|
||||
// Once the animation has played, drop the batch so that any future
|
||||
// remount of these rows doesn't replay the keyframe.
|
||||
useEffect(() => {
|
||||
if (!animated || !fadeController || batchId == null) return;
|
||||
const t = setTimeout(() => fadeController.expire(batchId), (fadeMs ?? 0) + 50);
|
||||
return () => clearTimeout(t);
|
||||
}, [animated, fadeController, batchId, fadeMs]);
|
||||
|
||||
const showFade = isFresh && animated;
|
||||
const hiddenUntilSeen = isFresh && !animated;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
data-masonry-row={rowIdx}
|
||||
className={showFade ? "grid gap-5 pb-5 fade-in" : "grid gap-5 pb-5"}
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`,
|
||||
...(hiddenUntilSeen ? { opacity: 0 } : null),
|
||||
}}
|
||||
>
|
||||
{row.map((img) => (
|
||||
<ImageCard key={img.id} image={img} view={view} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MasonryGrid({
|
||||
images,
|
||||
view = "landscape",
|
||||
loadedEnd = 1,
|
||||
totalPages = 1,
|
||||
infiniteScrollEnabled = true,
|
||||
onEndReached,
|
||||
isFetching = false,
|
||||
fadeController,
|
||||
fadeMs,
|
||||
pageSize = 100,
|
||||
ssrAnchorPage = 1,
|
||||
onVisiblePageChange,
|
||||
virtuosoHandleRef,
|
||||
}: Props) {
|
||||
const { settings } = useSettings();
|
||||
const cols = view === "portrait"
|
||||
? Math.max(4, Math.min(10, settings.gridColumnsPortrait || 6))
|
||||
: Math.max(2, Math.min(4, settings.gridColumns || 3));
|
||||
|
||||
// CSS var is used by the simple-grid path so the column count is
|
||||
// correct on first paint regardless of what `settings.gridColumns`
|
||||
// resolves to client-side. Virtuoso path uses the JS number because
|
||||
// it bucket-rows items + only renders post-hydration anyway.
|
||||
const cssCols = view === "portrait" ? "var(--grid-cols-portrait, 6)" : "var(--grid-cols, 3)";
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const out: CardImage[][] = [];
|
||||
for (let i = 0; i < images.length; i += cols) {
|
||||
out.push(images.slice(i, i + cols));
|
||||
}
|
||||
return out;
|
||||
}, [images, cols]);
|
||||
|
||||
if (images.length === 0) return null;
|
||||
|
||||
// Threshold for short lists: skip virtualization and render plain
|
||||
// grid. ImageCard's lazy-loaded thumbnails handle the bytes side.
|
||||
// Above the threshold we go through Virtuoso *from first render* —
|
||||
// initialItemCount lets SSR emit a few rows so the simple-grid →
|
||||
// virtuoso swap (and its flash) is gone.
|
||||
const SIMPLE_THRESHOLD = 24;
|
||||
if (images.length <= SIMPLE_THRESHOLD) {
|
||||
return (
|
||||
<div
|
||||
className="grid gap-5"
|
||||
style={{ gridTemplateColumns: `repeat(${cssCols}, minmax(0, 1fr))` }}
|
||||
>
|
||||
{images.map((img) => (
|
||||
<ImageCard key={img.id} image={img} view={view} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const canLoadMore = infiniteScrollEnabled && loadedEnd < totalPages;
|
||||
|
||||
// Track which "page" sits at the top of the viewport. We scan
|
||||
// rendered rows on each scroll tick and pick the first one whose
|
||||
// bottom edge is past the viewport top — that's the row currently
|
||||
// crossing/just-below the top. Virtuoso's own rangeChanged reports
|
||||
// the *rendered* range (includes overscan above viewport) so it
|
||||
// lags by a row or two.
|
||||
const lastReportedPage = useRef<number>(ssrAnchorPage);
|
||||
const totalItemsRef = useRef<number>(images.length);
|
||||
useEffect(() => { totalItemsRef.current = images.length; }, [images.length]);
|
||||
useEffect(() => {
|
||||
if (!onVisiblePageChange) return;
|
||||
const update = () => {
|
||||
// Find the *bottom-most* row currently visible — i.e. the row
|
||||
// furthest along that has any pixel in the viewport. Combined
|
||||
// with last-item page derivation, the label flips the moment
|
||||
// the first card of the next page peeks in from the bottom of
|
||||
// the viewport.
|
||||
const vh = window.innerHeight;
|
||||
const els = document.querySelectorAll<HTMLElement>("[data-masonry-row]");
|
||||
let chosen: HTMLElement | null = null;
|
||||
for (const el of els) {
|
||||
const r = el.getBoundingClientRect();
|
||||
if (r.top < vh && r.bottom > 0) chosen = el; // keep updating
|
||||
}
|
||||
if (!chosen && els.length > 0) chosen = els[0];
|
||||
if (!chosen) return;
|
||||
const idx = Number(chosen.dataset.masonryRow ?? 0);
|
||||
// Last item of the bottom-most visible row drives the page
|
||||
// label. As soon as a row containing the first card of the
|
||||
// next page enters from the bottom, the label flips. Clamp to
|
||||
// the actual item count so a partially-filled trailing row
|
||||
// (e.g. only 1 of 3 slots populated) doesn't claim a page that
|
||||
// has no real content yet.
|
||||
const total = totalItemsRef.current;
|
||||
const itemIdx = Math.min(
|
||||
idx * cols + Math.max(0, cols - 1),
|
||||
Math.max(0, total - 1),
|
||||
);
|
||||
const page = ssrAnchorPage + Math.floor(itemIdx / pageSize);
|
||||
if (page !== lastReportedPage.current) {
|
||||
lastReportedPage.current = page;
|
||||
onVisiblePageChange(page);
|
||||
}
|
||||
};
|
||||
let raf: number | null = null;
|
||||
const onScroll = () => {
|
||||
if (raf != null) return;
|
||||
raf = requestAnimationFrame(() => { raf = null; update(); });
|
||||
};
|
||||
window.addEventListener("scroll", onScroll, { passive: true });
|
||||
update();
|
||||
return () => {
|
||||
window.removeEventListener("scroll", onScroll);
|
||||
if (raf != null) cancelAnimationFrame(raf);
|
||||
};
|
||||
}, [cols, pageSize, ssrAnchorPage, onVisiblePageChange]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Virtuoso
|
||||
ref={(h) => { if (virtuosoHandleRef) virtuosoHandleRef.current = h; }}
|
||||
useWindowScroll
|
||||
// `data` makes Virtuoso re-render rows whenever the array
|
||||
// reference changes (e.g. on append). Without it, item
|
||||
// closures freeze on the first render and updates to
|
||||
// fadeFromIndex / loadedEnd never propagate into rows.
|
||||
data={rows}
|
||||
initialItemCount={Math.min(rows.length, 8)}
|
||||
endReached={canLoadMore ? onEndReached : undefined}
|
||||
increaseViewportBy={600}
|
||||
itemContent={(rowIdx, row) => {
|
||||
if (!row) return null;
|
||||
return (
|
||||
<MasonryRow
|
||||
rowIdx={rowIdx}
|
||||
row={row}
|
||||
cols={cols}
|
||||
view={view}
|
||||
fadeController={fadeController}
|
||||
fadeMs={fadeMs}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
overscan={600}
|
||||
// Key by the first card's id, falling back to rowIdx for empty
|
||||
// rows. Joining every id in the row meant a partial trailing
|
||||
// row (e.g. 2 of 3 columns filled) re-keyed to a brand-new
|
||||
// identity once infinite-scroll filled the missing slots,
|
||||
// unmounting the row mid-scroll and blanking the in-flight
|
||||
// fade-in batch. The first id is stable across that fill.
|
||||
computeItemKey={(rowIdx, row) => (row && row[0] ? row[0].id : rowIdx)}
|
||||
/>
|
||||
{isFetching && (
|
||||
<div className="flex items-center justify-center gap-2 py-4 text-xs font-mono text-[var(--color-fg-muted)]">
|
||||
<Loader2 className="w-4 h-4 animate-spin" /> Loading next page...
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
"use client";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Bookmark, ChevronDown, Gem, Star, MinusCircle, Package, Check, Eye, FolderHeart, Tag, Play } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { FilterCriteria, FilterStatus, MarkOption, StatusAxisKey } from "@/lib/filters";
|
||||
import { totalStatusActive, EMPTY_STATUS } from "@/lib/filters";
|
||||
|
||||
const MARK_OPTIONS: Array<{
|
||||
value: MarkOption;
|
||||
label: string;
|
||||
Icon: React.ComponentType<{ className?: string; style?: React.CSSProperties }>;
|
||||
tint: "cyan" | "amber" | "violet" | "muted";
|
||||
}> = [
|
||||
{ value: "vip", label: "VIP", Icon: Gem, tint: "cyan" },
|
||||
{ value: "favorite", label: "Favorite", Icon: Star, tint: "amber" },
|
||||
{ value: "owned", label: "Owned", Icon: Package, tint: "violet" },
|
||||
{ value: "unmarked", label: "Unmarked", Icon: MinusCircle, tint: "muted" },
|
||||
];
|
||||
|
||||
type AxisOpt<V extends string> = { value: V; label: string };
|
||||
type AxisConfig = {
|
||||
key: StatusAxisKey;
|
||||
label: string;
|
||||
Icon: React.ComponentType<{ className?: string }>;
|
||||
options: Array<AxisOpt<string>>;
|
||||
};
|
||||
|
||||
const WATCH_AXES: AxisConfig[] = [
|
||||
{ key: "watched", label: "Watched", Icon: Eye, options: [
|
||||
{ value: "all", label: "ALL" },
|
||||
{ value: "watched", label: "Watched" },
|
||||
{ value: "unwatched", label: "Unwatched" },
|
||||
]},
|
||||
{ key: "rated", label: "Rated", Icon: Star, options: [
|
||||
{ value: "all", label: "ALL" },
|
||||
{ value: "rated", label: "Rated" },
|
||||
{ value: "unrated", label: "No Rating" },
|
||||
]},
|
||||
];
|
||||
|
||||
const HAS_AXES: AxisConfig[] = [
|
||||
{ key: "collection", label: "Collection", Icon: FolderHeart, options: [
|
||||
{ value: "all", label: "ALL" },
|
||||
{ value: "has", label: "Has" },
|
||||
{ value: "missing", label: "Missing" },
|
||||
]},
|
||||
{ key: "tags", label: "Tags", Icon: Tag, options: [
|
||||
{ value: "all", label: "ALL" },
|
||||
{ value: "has", label: "Has" },
|
||||
{ value: "missing", label: "Missing" },
|
||||
]},
|
||||
{ key: "video", label: "Video", Icon: Play, options: [
|
||||
{ value: "all", label: "ALL" },
|
||||
{ value: "has", label: "Has" },
|
||||
{ value: "missing", label: "Missing" },
|
||||
]},
|
||||
];
|
||||
|
||||
export function MergedFilterPopover({
|
||||
criteria,
|
||||
onChange,
|
||||
}: {
|
||||
criteria: FilterCriteria;
|
||||
onChange: (next: FilterCriteria) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const markCount = criteria.marks.length;
|
||||
const stateCount = totalStatusActive(criteria);
|
||||
const total = markCount + stateCount;
|
||||
const active = total > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDoc = (e: MouseEvent) => {
|
||||
if (!wrapRef.current?.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", onDoc);
|
||||
return () => document.removeEventListener("mousedown", onDoc);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setOpen(false); };
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [open]);
|
||||
|
||||
function toggleMark(value: MarkOption) {
|
||||
const has = criteria.marks.includes(value);
|
||||
const next = has ? criteria.marks.filter((m) => m !== value) : [...criteria.marks, value];
|
||||
onChange({ ...criteria, marks: next });
|
||||
}
|
||||
|
||||
function setAxis<K extends StatusAxisKey>(key: K, value: FilterStatus[K]) {
|
||||
onChange({ ...criteria, status: { ...criteria.status, [key]: value } });
|
||||
}
|
||||
|
||||
function resetAll() {
|
||||
onChange({ ...criteria, marks: [], status: { ...EMPTY_STATUS } });
|
||||
}
|
||||
|
||||
// Watch / Has section counts (used for the footer breakdown text only).
|
||||
const watchCount = (["watched", "rated"] as StatusAxisKey[]).filter((k) => criteria.status[k] !== "all").length;
|
||||
const hasCount = (["collection", "tags", "video"] as StatusAxisKey[]).filter((k) => criteria.status[k] !== "all").length;
|
||||
|
||||
return (
|
||||
<div className="relative" ref={wrapRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-3 py-1.5 rounded-full border text-sm transition-colors min-w-[140px] justify-between",
|
||||
active
|
||||
? "bg-[var(--color-cyan)]/10 border-[var(--color-cyan)]/40 text-[var(--color-cyan)]"
|
||||
: "glass glass-hover text-[var(--color-fg-dim)]",
|
||||
)}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Bookmark className="w-3.5 h-3.5" />
|
||||
Filter
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-[var(--color-cyan)] text-black text-[10px] font-mono font-bold tabular-nums",
|
||||
!active && "invisible",
|
||||
)}
|
||||
>
|
||||
{total || 0}
|
||||
</span>
|
||||
<ChevronDown className="w-3 h-3 opacity-70" />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
className="absolute left-0 top-[calc(100%+6px)] z-30 bg-[var(--color-bg-0)] border border-[var(--color-glass-border-strong)] rounded-2xl shadow-2xl overflow-hidden w-[460px]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Section 1 — Marks (multi-select OR) */}
|
||||
<div className="p-3 border-b border-[var(--color-glass-border)]">
|
||||
<div className="grid grid-cols-4 gap-1">
|
||||
{MARK_OPTIONS.map(({ value, label, Icon, tint }) => {
|
||||
const on = criteria.marks.includes(value);
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => toggleMark(value)}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-2 py-1.5 rounded-md text-xs text-left transition-colors whitespace-nowrap",
|
||||
on
|
||||
? tint === "cyan" ? "bg-[var(--color-cyan)]/15 text-[var(--color-cyan)]"
|
||||
: tint === "amber" ? "text-amber-200"
|
||||
: tint === "violet" ? "bg-[var(--color-violet)]/15 text-[var(--color-violet)]"
|
||||
: "bg-[var(--color-glass-strong)] text-[var(--color-fg)]"
|
||||
: "text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]",
|
||||
)}
|
||||
style={on && tint === "amber" ? { background: "rgba(251,191,36,0.15)" } : undefined}
|
||||
>
|
||||
<span className={cn(
|
||||
"w-4 h-4 rounded border flex items-center justify-center shrink-0",
|
||||
on
|
||||
? tint === "cyan" ? "bg-[var(--color-cyan)]/30 border-[var(--color-cyan)]"
|
||||
: tint === "amber" ? "border-amber-400"
|
||||
: tint === "violet" ? "bg-[var(--color-violet)]/30 border-[var(--color-violet)]"
|
||||
: "bg-[var(--color-fg-dim)]/30 border-[var(--color-fg-dim)]"
|
||||
: "border-[var(--color-glass-border-strong)]",
|
||||
)}
|
||||
style={on && tint === "amber" ? { background: "rgba(251,191,36,0.25)" } : undefined}>
|
||||
{on && (
|
||||
<Check
|
||||
className="w-3 h-3"
|
||||
strokeWidth={3}
|
||||
style={{
|
||||
color: tint === "cyan" ? "var(--color-cyan)"
|
||||
: tint === "amber" ? "#fbbf24"
|
||||
: tint === "violet" ? "var(--color-violet)"
|
||||
: "var(--color-fg)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<Icon
|
||||
className={cn("w-3.5 h-3.5", on && tint === "amber" && "fill-amber-300")}
|
||||
style={{
|
||||
color: tint === "cyan" ? "var(--color-cyan)"
|
||||
: tint === "amber" ? "#fbbf24"
|
||||
: tint === "violet" ? "var(--color-violet)"
|
||||
: "var(--color-fg-muted)",
|
||||
}}
|
||||
/>
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section 2 — Watch State */}
|
||||
<AxisSection axes={WATCH_AXES} status={criteria.status} onSet={setAxis} />
|
||||
|
||||
{/* Section 3 — Has… */}
|
||||
<AxisSection axes={HAS_AXES} status={criteria.status} onSet={setAxis} />
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between px-4 py-2.5 bg-[var(--color-bg-1)] border-t border-[var(--color-glass-border)] text-[11px] font-mono text-[var(--color-fg-muted)]">
|
||||
<span>
|
||||
{total === 0
|
||||
? "no filters set"
|
||||
: `${total} filter${total === 1 ? "" : "s"}` +
|
||||
(markCount > 0 ? ` · ${markCount} mark${markCount === 1 ? "" : "s"}` : "") +
|
||||
(watchCount > 0 ? ` · ${watchCount} watch` : "") +
|
||||
(hasCount > 0 ? ` · ${hasCount} has` : "")}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetAll}
|
||||
disabled={!active}
|
||||
className="text-[var(--color-cyan)] hover:underline disabled:opacity-40 disabled:no-underline"
|
||||
>
|
||||
Reset All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AxisSection({
|
||||
axes,
|
||||
status,
|
||||
onSet,
|
||||
}: {
|
||||
axes: AxisConfig[];
|
||||
status: FilterStatus;
|
||||
onSet: <K extends StatusAxisKey>(key: K, value: FilterStatus[K]) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="p-3 border-b border-[var(--color-glass-border)] last:border-b-0 space-y-2.5">
|
||||
{axes.map(({ key, label, Icon, options }) => {
|
||||
const current = status[key];
|
||||
return (
|
||||
<div key={key}>
|
||||
<div className="flex items-center gap-1.5 text-[10px] uppercase tracking-wider font-mono text-[var(--color-fg-muted)] mb-1">
|
||||
<Icon className="w-3 h-3" />
|
||||
{label}
|
||||
</div>
|
||||
<div className="flex border border-[var(--color-glass-border)] rounded-lg overflow-hidden">
|
||||
{options.map((o) => {
|
||||
const on = current === o.value;
|
||||
return (
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
onClick={() => onSet(key, o.value as FilterStatus[typeof key])}
|
||||
className={cn(
|
||||
"flex-1 text-center px-2 py-1.5 text-xs font-mono whitespace-nowrap transition-colors border-r border-[var(--color-glass-border)] last:border-r-0",
|
||||
on
|
||||
? "bg-[var(--color-cyan)]/20 text-[var(--color-cyan)] font-bold"
|
||||
: "text-[var(--color-fg-muted)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]",
|
||||
)}
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
"use client";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Search, ChevronDown, Users, Building2, Film, Hash, FolderHeart, Tag, X, Check, Layers } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { tabSupportsAnd, type FilterCriteria, type FilterTabKey } from "@/lib/filters";
|
||||
|
||||
export interface FilterOption {
|
||||
id: number;
|
||||
name: string;
|
||||
count?: number;
|
||||
}
|
||||
|
||||
const TAB_META: Array<{ key: FilterTabKey; label: string; Icon: React.ComponentType<{ className?: string }> }> = [
|
||||
{ key: "actresses", label: "Actresses", Icon: Users },
|
||||
{ key: "studios", label: "Studios", Icon: Building2 },
|
||||
{ key: "series", label: "Series", Icon: Film },
|
||||
{ key: "categories", label: "Categories", Icon: Layers },
|
||||
{ key: "tags", label: "Tags", Icon: Tag },
|
||||
{ key: "genres", label: "Genres", Icon: Hash },
|
||||
{ key: "collections", label: "Collections", Icon: FolderHeart },
|
||||
];
|
||||
|
||||
export function MultiFilterPopover({
|
||||
criteria,
|
||||
options,
|
||||
onChange,
|
||||
}: {
|
||||
criteria: FilterCriteria;
|
||||
options: Record<FilterTabKey, FilterOption[]>;
|
||||
onChange: (next: FilterCriteria) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<FilterTabKey>("actresses");
|
||||
const [search, setSearch] = useState("");
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDoc = (e: MouseEvent) => {
|
||||
if (!wrapRef.current?.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", onDoc);
|
||||
return () => document.removeEventListener("mousedown", onDoc);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setOpen(false); };
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [open]);
|
||||
|
||||
const total = useMemo(() => {
|
||||
let n = 0;
|
||||
for (const t of TAB_META) n += criteria.ids[t.key].length;
|
||||
return n;
|
||||
}, [criteria]);
|
||||
|
||||
function toggleId(tab: FilterTabKey, id: number) {
|
||||
const cur = criteria.ids[tab];
|
||||
const next = cur.includes(id) ? cur.filter((x) => x !== id) : [...cur, id];
|
||||
onChange({ ...criteria, ids: { ...criteria.ids, [tab]: next } });
|
||||
}
|
||||
|
||||
function setMode(tab: FilterTabKey, mode: "and" | "or") {
|
||||
onChange({ ...criteria, mode: { ...criteria.mode, [tab]: mode } });
|
||||
}
|
||||
|
||||
function clearTab(tab: FilterTabKey) {
|
||||
onChange({ ...criteria, ids: { ...criteria.ids, [tab]: [] } });
|
||||
}
|
||||
|
||||
function clearAllTabs() {
|
||||
onChange({
|
||||
...criteria,
|
||||
ids: { actresses: [], studios: [], series: [], genres: [], collections: [], tags: [], categories: [] },
|
||||
});
|
||||
}
|
||||
|
||||
const tabOptions = options[activeTab] ?? [];
|
||||
const q = search.trim().toLowerCase();
|
||||
const filteredOptions = q ? tabOptions.filter((o) => o.name.toLowerCase().includes(q)) : tabOptions;
|
||||
const supportsAnd = tabSupportsAnd(activeTab);
|
||||
|
||||
return (
|
||||
<div className="relative" ref={wrapRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-3 py-1.5 rounded-full border text-sm transition-colors min-w-[140px] justify-between",
|
||||
total > 0
|
||||
? "bg-[var(--color-cyan)]/15 border-[var(--color-cyan)]/40 text-[var(--color-cyan)]"
|
||||
: "glass glass-hover text-[var(--color-fg-dim)]",
|
||||
)}
|
||||
>
|
||||
Browse
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center w-[22px] h-4 rounded-full bg-[var(--color-cyan)] text-black text-[10px] font-mono font-bold tabular-nums",
|
||||
total === 0 && "invisible",
|
||||
)}
|
||||
>
|
||||
{total || 0}
|
||||
</span>
|
||||
<ChevronDown className="w-3 h-3 opacity-70" />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
className="absolute left-0 top-[calc(100%+6px)] z-30 bg-[var(--color-bg-0)] border border-[var(--color-glass-border-strong)] rounded-2xl shadow-2xl p-3 w-[720px] max-w-[calc(100vw-32px)]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex flex-wrap gap-1 border-b border-[var(--color-glass-border)] pb-2 mb-3">
|
||||
{TAB_META.map(({ key, label, Icon }) => {
|
||||
const count = criteria.ids[key].length;
|
||||
const isActive = key === activeTab;
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => { setActiveTab(key); setSearch(""); }}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs transition-colors",
|
||||
isActive
|
||||
? "bg-[var(--color-glass-strong)] text-[var(--color-cyan)]"
|
||||
: "text-[var(--color-fg-muted)] hover:bg-[var(--color-glass)] hover:text-[var(--color-fg-dim)]",
|
||||
)}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
{label}
|
||||
{count > 0 && (
|
||||
<span className="inline-flex items-center justify-center min-w-[14px] h-3.5 px-1 rounded-full bg-[var(--color-cyan)] text-black text-[9px] font-mono font-bold">
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="w-3.5 h-3.5 absolute left-2.5 top-1/2 -translate-y-1/2 text-[var(--color-fg-muted)] pointer-events-none" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={`Filter ${activeTab}…`}
|
||||
className="w-full glass rounded-lg pl-8 pr-2 py-1.5 text-sm outline-none focus:border-[var(--color-cyan)]"
|
||||
/>
|
||||
</div>
|
||||
{supportsAnd && (
|
||||
<div className="inline-flex border border-[var(--color-glass-border)] rounded-lg overflow-hidden text-[11px] font-mono">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode(activeTab, "and")}
|
||||
className={cn(
|
||||
"px-2.5 py-1 transition-colors",
|
||||
criteria.mode[activeTab] === "and"
|
||||
? "bg-[var(--color-cyan)] text-black font-bold"
|
||||
: "text-[var(--color-fg-muted)] hover:text-[var(--color-fg)]",
|
||||
)}
|
||||
>
|
||||
AND
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode(activeTab, "or")}
|
||||
className={cn(
|
||||
"px-2.5 py-1 transition-colors",
|
||||
criteria.mode[activeTab] === "or"
|
||||
? "bg-[var(--color-cyan)] text-black font-bold"
|
||||
: "text-[var(--color-fg-muted)] hover:text-[var(--color-fg)]",
|
||||
)}
|
||||
>
|
||||
OR
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="max-h-[260px] overflow-y-auto">
|
||||
{filteredOptions.length === 0 ? (
|
||||
<div className="text-xs text-[var(--color-fg-muted)] italic px-2 py-3">
|
||||
{q ? "No matches" : `No ${activeTab} yet`}
|
||||
</div>
|
||||
) : (
|
||||
filteredOptions.map((o) => {
|
||||
const checked = criteria.ids[activeTab].includes(o.id);
|
||||
return (
|
||||
<button
|
||||
key={o.id}
|
||||
type="button"
|
||||
onClick={() => toggleId(activeTab, o.id)}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2.5 px-2 py-1.5 rounded-md text-sm text-left transition-colors",
|
||||
checked ? "text-[var(--color-cyan)]" : "text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]",
|
||||
)}
|
||||
>
|
||||
<span className={cn(
|
||||
"w-3.5 h-3.5 rounded border flex items-center justify-center shrink-0",
|
||||
checked ? "bg-[var(--color-cyan)]/20 border-[var(--color-cyan)]" : "border-[var(--color-glass-border-strong)]",
|
||||
)}>
|
||||
{checked && <Check className="w-2.5 h-2.5" strokeWidth={3} />}
|
||||
</span>
|
||||
<span className="flex-1 truncate">{o.name}</span>
|
||||
{typeof o.count === "number" && (
|
||||
<span className="font-mono text-[11px] text-[var(--color-fg-muted)]">{o.count}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 pt-2 border-t border-[var(--color-glass-border)] flex items-center justify-between text-[11px] font-mono text-[var(--color-fg-muted)]">
|
||||
<span>
|
||||
{supportsAnd
|
||||
? "tap to toggle · AND = match all · OR = match any"
|
||||
: "tap to toggle"}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => clearTab(activeTab)}
|
||||
className="text-[var(--color-coral)] hover:underline"
|
||||
>
|
||||
Clear Tab
|
||||
</button>
|
||||
<span className="text-[var(--color-fg-muted)]">|</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearAllTabs}
|
||||
className="text-[var(--color-coral)] hover:underline"
|
||||
>
|
||||
Clear All Tabs
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const FILTER_TABS = TAB_META;
|
||||
@@ -0,0 +1,162 @@
|
||||
"use client";
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRouter, useSearchParams, usePathname } from "next/navigation";
|
||||
import { ChevronLeft, ChevronRight, ArrowRight } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface Props {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
totalCount: number;
|
||||
/** When provided, Prev/Next first try to scroll within the already-
|
||||
* loaded grid (or prefetch + scroll, depending on mode). If the
|
||||
* callback returns false (or resolves to false), the bar falls back
|
||||
* to a URL navigation. */
|
||||
onScrollToPage?: (targetPage: number) => boolean | Promise<boolean>;
|
||||
/** Called when the user clicks Prev/Next but the target equals the
|
||||
* current URL page (i.e. no router.push will happen). The grid uses
|
||||
* this to clear its appended buffer + scroll to top, so a Prev click
|
||||
* at "Page 5 (scrolled from URL=/)" snaps back to Page 1 instead of
|
||||
* silently doing nothing. */
|
||||
onSamePageNav?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bottom pagination bar. Preserves all existing query params (filters,
|
||||
* sort, view) when navigating between pages — only the `page` key
|
||||
* changes. `page=1` is dropped from the URL for a clean default.
|
||||
*/
|
||||
export function PaginationBar({ currentPage, totalPages, totalCount, onScrollToPage, onSamePageNav }: Props) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const sp = useSearchParams();
|
||||
const [, start] = useTransition();
|
||||
const [jump, setJump] = useState<string>("");
|
||||
|
||||
// The page anchored in the URL — independent of the visible-page label
|
||||
// (which drifts as the user scrolls into appended pages). Prev/Next nav
|
||||
// math reads from here so a click at "Page 4 (showing)" while URL=?page=2
|
||||
// walks back from 2, not from 4. Without this split, a buffer of
|
||||
// appended pages combined with onEndReached chaining makes Prev appear
|
||||
// to "stick" at page 1: each Prev pushes URL=/, the auto-fetch chain
|
||||
// re-drifts visiblePage forward, and the loop never escapes.
|
||||
const urlPageRaw = Number(sp.get("page") ?? "1");
|
||||
const urlPage = Number.isFinite(urlPageRaw) && urlPageRaw >= 1
|
||||
? Math.min(Math.floor(urlPageRaw), totalPages)
|
||||
: 1;
|
||||
|
||||
const urlNav = (page: number) => {
|
||||
const next = new URLSearchParams(sp.toString());
|
||||
if (page > 1) next.set("page", String(page));
|
||||
else next.delete("page");
|
||||
const qs = next.toString();
|
||||
// If the URL would not actually change (target page === urlPage),
|
||||
// a router.push is a no-op visually and the loop "click Prev → no
|
||||
// remount → drift returns" reappears. Hand off to onSamePageNav so
|
||||
// the grid can reset its appended buffer + scroll to top.
|
||||
if (page === urlPage && onSamePageNav) {
|
||||
onSamePageNav();
|
||||
return;
|
||||
}
|
||||
// Marker so the destination grid can distinguish an internal
|
||||
// Prev/Next click from a browser back/forward. Internal nav skips
|
||||
// scroll-restore (which otherwise replays a stale buffer snapshot
|
||||
// and re-creates the visiblePage drift).
|
||||
try { sessionStorage.setItem("pinkudex:nav-internal", "1"); } catch { /* ignore */ }
|
||||
start(() => {
|
||||
router.push(qs ? `${pathname}?${qs}` : pathname, { scroll: true });
|
||||
});
|
||||
};
|
||||
|
||||
const navTo = async (page: number) => {
|
||||
if (onScrollToPage) {
|
||||
const maybe = onScrollToPage(page);
|
||||
const ok = typeof maybe === "boolean" ? maybe : await maybe;
|
||||
if (ok) return;
|
||||
}
|
||||
urlNav(page);
|
||||
};
|
||||
|
||||
const goJump = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const target = Number(jump);
|
||||
if (!Number.isFinite(target) || target < 1) return;
|
||||
const clamped = Math.min(Math.max(1, Math.floor(target)), totalPages);
|
||||
setJump("");
|
||||
void navTo(clamped);
|
||||
};
|
||||
|
||||
// Prev/Next button math — relative to the displayed (visible) page so
|
||||
// clicks feel responsive to where the user is scrolled. The same-URL
|
||||
// case is handled by onSamePageNav (buffer reset), which prevents the
|
||||
// old "Prev does nothing" trap when target === urlPage.
|
||||
const prevTarget = Math.max(1, currentPage - 1);
|
||||
const nextTarget = Math.min(totalPages, currentPage + 1);
|
||||
const canPrev = currentPage > 1;
|
||||
const canNext = currentPage < totalPages;
|
||||
|
||||
const showJump = totalPages > 5;
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-2 flex-wrap">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canPrev}
|
||||
onClick={() => void navTo(prevTarget)}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 text-sm px-3 py-1.5 rounded-lg glass",
|
||||
!canPrev
|
||||
? "opacity-40 cursor-not-allowed"
|
||||
: "glass-hover cursor-pointer",
|
||||
)}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
Prev
|
||||
</button>
|
||||
|
||||
<div className="text-sm font-mono text-[var(--color-fg-dim)] px-2 text-center tabular-nums min-w-[240px]">
|
||||
Page <span className="text-[var(--color-cyan)]">{currentPage}</span> of {totalPages}
|
||||
<span className="opacity-50"> · </span>
|
||||
{totalCount.toLocaleString()} total
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canNext}
|
||||
onClick={() => void navTo(nextTarget)}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 text-sm px-3 py-1.5 rounded-lg glass",
|
||||
!canNext
|
||||
? "opacity-40 cursor-not-allowed"
|
||||
: "glass-hover cursor-pointer",
|
||||
)}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{showJump && (
|
||||
<form onSubmit={goJump} className="ml-3 inline-flex items-center gap-1.5">
|
||||
<span className="text-xs uppercase tracking-wider font-mono text-[var(--color-fg-muted)]">
|
||||
Jump to
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={jump}
|
||||
onChange={(e) => setJump(e.target.value.replace(/[^0-9]/g, ""))}
|
||||
placeholder={`1–${totalPages}`}
|
||||
className="w-20 glass rounded-md px-2 py-1 text-xs font-mono outline-none focus:border-[var(--color-cyan)] text-center"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!jump || Number(jump) < 1 || Number(jump) > totalPages}
|
||||
className="inline-flex items-center justify-center w-7 h-7 rounded-md glass glass-hover disabled:opacity-40 cursor-pointer"
|
||||
title="Jump"
|
||||
>
|
||||
<ArrowRight className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { ArrowDownAZ, ArrowDownUp, ArrowUpAZ, Check, ChevronDown, Clock, Hash } from "lucide-react";
|
||||
import { useClickOutside } from "@/lib/hooks/useClickOutside";
|
||||
import { SORT_OPTIONS, labelFor, type SortKey } from "@/lib/sort";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const ICONS: Record<SortKey, React.ComponentType<{ className?: string }>> = {
|
||||
newest: Clock,
|
||||
oldest: Clock,
|
||||
az: ArrowDownAZ,
|
||||
za: ArrowUpAZ,
|
||||
"code-az": Hash,
|
||||
"code-za": Hash,
|
||||
};
|
||||
|
||||
export function SortMenu({ activeSort }: { activeSort: SortKey }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useClickOutside(ref, useCallback(() => setOpen(false), []), open);
|
||||
|
||||
const pathname = usePathname();
|
||||
const params = useSearchParams();
|
||||
|
||||
const hrefFor = useMemo(() => (next: SortKey) => {
|
||||
const sp = new URLSearchParams(params);
|
||||
sp.set("sort", next);
|
||||
return `${pathname}?${sp.toString()}`;
|
||||
}, [pathname, params]);
|
||||
|
||||
const Icon = ICONS[activeSort] ?? ArrowDownUp;
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-full border text-sm transition-colors glass glass-hover text-[var(--color-fg-dim)]"
|
||||
title={`Sort: ${labelFor(activeSort)}`}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
<span className="hidden sm:inline">{labelFor(activeSort)}</span>
|
||||
<ChevronDown className={cn("w-3 h-3 opacity-60 transition-transform", open && "rotate-180")} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
className="absolute right-0 top-full mt-2 z-30 min-w-[200px] rounded-xl shadow-2xl border border-[var(--color-glass-border-strong)] backdrop-blur-2xl overflow-hidden"
|
||||
style={{ background: "color-mix(in oklch, var(--color-bg-0) 96%, transparent)" }}
|
||||
>
|
||||
<div className="p-1">
|
||||
{SORT_OPTIONS.map((o, i) => {
|
||||
const OptIcon = ICONS[o.value];
|
||||
const active = o.value === activeSort;
|
||||
const prev = SORT_OPTIONS[i - 1];
|
||||
// Group divider whenever the underlying sort dimension
|
||||
// changes (date → title → code). newest/oldest share the
|
||||
// date dimension; az/za and code-az/code-za each share theirs.
|
||||
const groupOf = (v: string) =>
|
||||
v === "newest" || v === "oldest"
|
||||
? "date"
|
||||
: v.replace(/-?(az|za)$/, "") || "title";
|
||||
const showDivider = prev && groupOf(prev.value) !== groupOf(o.value);
|
||||
return (
|
||||
<div key={o.value}>
|
||||
{showDivider && (
|
||||
<div className="my-1 mx-2 border-t border-[var(--color-glass-border)]" />
|
||||
)}
|
||||
<Link
|
||||
href={hrefFor(o.value)}
|
||||
onClick={() => setOpen(false)}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-3 py-1.5 rounded-md text-sm hover:bg-[var(--color-glass)]",
|
||||
active && "text-[var(--color-cyan)]"
|
||||
)}
|
||||
>
|
||||
<OptIcon className="w-3.5 h-3.5" />
|
||||
<span className="flex-1">{o.label}</span>
|
||||
{active && <Check className="w-3.5 h-3.5" />}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useTransition } from "react";
|
||||
import { RectangleVertical, RectangleHorizontal } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type LibraryView = "portrait" | "landscape";
|
||||
|
||||
export function ViewToggle({ current }: { current: LibraryView }) {
|
||||
const router = useRouter();
|
||||
const params = useSearchParams();
|
||||
const [, start] = useTransition();
|
||||
|
||||
function set(view: LibraryView) {
|
||||
if (view === current) return;
|
||||
const sp = new URLSearchParams(params.toString());
|
||||
if (view === "portrait") sp.set("view", "portrait");
|
||||
else sp.delete("view");
|
||||
const y = typeof window !== "undefined" ? window.scrollY : 0;
|
||||
start(() => {
|
||||
const qs = sp.toString();
|
||||
router.push(qs ? `?${qs}` : `?`, { scroll: false });
|
||||
requestAnimationFrame(() => window.scrollTo({ top: y, left: 0, behavior: "instant" as ScrollBehavior }));
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center rounded-lg glass overflow-hidden text-xs">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => set("landscape")}
|
||||
className={cn(
|
||||
"flex items-center gap-1 px-2.5 py-1.5",
|
||||
current === "landscape"
|
||||
? "bg-[var(--color-cyan)] text-black font-medium"
|
||||
: "text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]",
|
||||
)}
|
||||
title="Full cover (landscape)"
|
||||
>
|
||||
<RectangleHorizontal className="w-3.5 h-3.5" /> L
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => set("portrait")}
|
||||
className={cn(
|
||||
"flex items-center gap-1 px-2.5 py-1.5",
|
||||
current === "portrait"
|
||||
? "bg-[var(--color-cyan)] text-black font-medium"
|
||||
: "text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]",
|
||||
)}
|
||||
title="Front cover only (portrait)"
|
||||
>
|
||||
<RectangleVertical className="w-3.5 h-3.5" /> P
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user