Initial commit

This commit is contained in:
admin
2026-05-26 22:46:00 +02:00
commit 7e2c2ff89c
256 changed files with 51523 additions and 0 deletions
+166
View File
@@ -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>
);
}
+56
View File
@@ -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}
/>
);
}
+96
View File
@@ -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} />
</>
);
}
+131
View File
@@ -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>
);
}
+86
View File
@@ -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>
);
}
+420
View File
@@ -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,
);
+541
View File
@@ -0,0 +1,541 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState, useTransition } from "react";
import { createPortal } from "react-dom";
import { Tag as TagIcon, FolderHeart, Trash2, Search, Gem, Star, Eye, Package, Loader2, ChevronRight, ListVideo } from "lucide-react";
import { useRouter } from "next/navigation";
import { fetchImageContextData } from "@/app/actions/imageMeta";
import { addTagToImage, removeTagFromImage } from "@/app/actions/tags";
import { addImageToCollection, removeImageFromCollection, createCollection } from "@/app/actions/collections";
import { setCoverVip, setCoverFavorite, setWatched, setCoverOwned } from "@/app/actions/coverMeta";
import { bulkSetMark, bulkSetWatched, bulkSetOwned, deleteImages } from "@/app/actions/bulk";
import { useClickOutside } from "@/lib/hooks/useClickOutside";
import { useUndoDeleteToast } from "@/components/select/UndoDeleteToast";
import { useSettings } from "@/components/settings/SettingsProvider";
import { useWatchQueue } from "@/components/queue/WatchQueueProvider";
import { dispatchQueueRemove } from "@/components/queue/watchQueueEvents";
import { cn } from "@/lib/utils";
import type { ContextData, ContextTagOption, ContextCollectionOption } from "@/lib/db/queries";
type SubmenuKey = "tags" | "collections" | null;
export function ImageContextMenu({
imageIds,
x,
y,
onClose,
}: {
imageIds: number[];
/** Single-cover convenience link href; only relevant in 1-cover mode. */
singleHref?: string | null;
x: number;
y: number;
onClose: () => void;
}) {
const isBulk = imageIds.length > 1;
const ref = useRef<HTMLDivElement>(null);
const [data, setData] = useState<ContextData | null>(null);
const [submenu, setSubmenu] = useState<SubmenuKey>(null);
const [, start] = useTransition();
const router = useRouter();
const { show: showUndo } = useUndoDeleteToast();
const { settings } = useSettings();
const queue = useWatchQueue();
const queuedCount = imageIds.reduce((acc, id) => acc + (queue.has(id) ? 1 : 0), 0);
const allQueued = queuedCount === imageIds.length && imageIds.length > 0;
useClickOutside(ref, onClose);
useEffect(() => {
let live = true;
fetchImageContextData(imageIds).then((d) => { if (live) setData(d); });
return () => { live = false; };
}, [imageIds]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
if (submenu) setSubmenu(null);
else onClose();
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose, submenu]);
// Header — derive from cover info when available.
const header = useMemo(() => {
if (!data) return { title: isBulk ? `${imageIds.length} covers` : `Image #${imageIds[0]}`, sub: null as string | null };
if (isBulk) {
return { title: `${imageIds.length} covers selected`, sub: null };
}
const c = data.covers[0];
if (!c) return { title: `Image #${imageIds[0]}`, sub: null };
const title = c.code ?? `Image #${c.id}`;
const sub = c.actresses.length > 0
? c.actresses.join(", ")
: (c.title ?? null);
return { title, sub };
}, [data, imageIds, isBulk]);
// Mark state — for single, read straight from data; for bulk, all-true means
// every cover has it.
const marks = useMemo(() => {
if (!data || data.covers.length === 0) {
return { vip: false, favorite: false, watched: false, owned: false };
}
const all = (pred: (c: ContextData["covers"][number]) => boolean) => data.covers.every(pred);
return {
vip: all((c) => c.isVip),
favorite: all((c) => c.isFavorite),
watched: all((c) => c.isWatched),
owned: all((c) => c.isOwned),
};
}, [data]);
const setMark = useCallback((kind: "vip" | "favorite" | "watched" | "owned") => {
const wasOn = marks[kind];
setData((d) => d && {
...d,
covers: d.covers.map((c) => {
if (kind === "vip") return { ...c, isVip: !wasOn, isFavorite: !wasOn ? false : c.isFavorite };
if (kind === "favorite") return { ...c, isFavorite: !wasOn, isVip: !wasOn ? false : c.isVip };
if (kind === "watched") return { ...c, isWatched: !wasOn };
return { ...c, isOwned: !wasOn };
}),
});
start(async () => {
if (isBulk) {
if (kind === "vip") await bulkSetMark(imageIds, !wasOn ? "vip" : "unmarked");
else if (kind === "favorite") await bulkSetMark(imageIds, !wasOn ? "favorite" : "unmarked");
else if (kind === "watched") await bulkSetWatched(imageIds, !wasOn);
else await bulkSetOwned(imageIds, !wasOn);
} else {
const id = imageIds[0];
if (kind === "vip") await setCoverVip(id, !wasOn);
else if (kind === "favorite") await setCoverFavorite(id, !wasOn);
else if (kind === "watched") await setWatched(id, !wasOn);
else await setCoverOwned(id, !wasOn);
}
if (kind === "watched" && !wasOn) dispatchQueueRemove(imageIds);
router.refresh();
});
}, [imageIds, isBulk, marks, router]);
const toggleTag = useCallback((tag: { id: number; name: string }, on: boolean) => {
setData((d) => d && {
...d,
tags: d.tags.map((t) =>
t.id === tag.id ? { ...t, count: on ? imageIds.length : 0 } : t,
),
});
start(async () => {
await Promise.all(imageIds.map((id) => on ? addTagToImage(id, tag.name) : removeTagFromImage(id, tag.id)));
router.refresh();
});
}, [imageIds, router]);
const toggleCollection = useCallback((coll: { id: number; name: string }, on: boolean) => {
setData((d) => d && {
...d,
collections: d.collections.map((c) =>
c.id === coll.id ? { ...c, count: on ? imageIds.length : 0 } : c,
),
});
start(async () => {
await Promise.all(imageIds.map((id) => on ? addImageToCollection(coll.id, id) : removeImageFromCollection(coll.id, id)));
router.refresh();
});
}, [imageIds, router]);
const onDelete = (e: React.MouseEvent) => {
// Honor the same Shift-for-permanent / useRecycleBin semantics as
// SelectionBar so right-click delete doesn't bypass the user's
// recycle-bin preference.
const permanent = e.shiftKey || !settings.useRecycleBin;
const target = [...imageIds];
onClose();
start(async () => {
await deleteImages(target, permanent ? { permanent: true } : undefined);
router.refresh();
if (!permanent) showUndo(target);
});
};
// Compute viewport-clamped position. We render via a portal to
// document.body so a transformed ancestor (the page's fade-in
// wrapper) can't hijack our fixed-positioning containing block.
// Horizontal: center on the cursor so the menu sits symmetrically
// around the click — keeps right-edge clicks from spilling offscreen.
// Vertical: top of the menu sits at the cursor, expanding down.
const margin = 8;
const menuW = 380;
const menuH = 320;
const [coords, setCoords] = useState({ left: x, top: y });
useEffect(() => {
if (typeof window === "undefined") return;
setCoords({
left: Math.max(margin, Math.min(x - menuW / 2, window.innerWidth - menuW - margin)),
top: Math.max(margin, Math.min(y + 20, window.innerHeight - menuH - margin)),
});
}, [x, y]);
if (typeof document === "undefined") return null;
return createPortal(
<div
ref={ref}
onClick={(e) => e.stopPropagation()}
onContextMenu={(e) => e.preventDefault()}
className="fixed z-[80] flex items-start gap-2"
style={{ left: coords.left, top: coords.top }}
>
<div
className="w-[380px] rounded-xl shadow-2xl border border-[var(--color-glass-border-strong)] backdrop-blur-2xl overflow-hidden text-sm"
style={{ background: "color-mix(in oklch, var(--color-bg-0) 96%, transparent)" }}
>
{/* Header */}
<div className="px-3 py-2.5 border-b border-[var(--color-glass-border)]">
<div className="text-[13px] font-mono font-semibold text-[var(--color-cyan)] truncate">
{header.title}
</div>
{header.sub && (
<div className="text-[11px] text-[var(--color-fg-dim)] truncate mt-0.5">{header.sub}</div>
)}
{isBulk && (
<div className="text-[10px] uppercase tracking-wider font-mono text-[var(--color-cyan)] mt-1">
Applying to {imageIds.length} covers
</div>
)}
</div>
{/* Quick marks: VIP, Fav, Watched, Owned */}
<div className="flex gap-1.5 px-3 py-2.5 border-b border-[var(--color-glass-border)]">
<QBtn icon={Gem} label="VIP" active={marks.vip} onClick={() => setMark("vip")} />
<QBtn icon={Star} label="Fav" active={marks.favorite} onClick={() => setMark("favorite")} accent="amber" />
<QBtn icon={Eye} label="Watched" active={marks.watched} onClick={() => setMark("watched")} accent="mint" />
<QBtn icon={Package} label="Owned" active={marks.owned} onClick={() => setMark("owned")} accent="violet" />
</div>
{/* Submenu trigger rows — open on hover (saves a click). Click
still toggles, in case you want to close one without moving
the cursor. */}
<SubmenuRow
icon={TagIcon}
label="Tags"
countLabel={data ? formatCountLabel(data.tags, data.selectedCount, isBulk) : "…"}
open={submenu === "tags"}
onHover={() => setSubmenu("tags")}
onClick={() => setSubmenu(submenu === "tags" ? null : "tags")}
/>
<SubmenuRow
icon={FolderHeart}
label="Collections"
countLabel={data ? formatCountLabel(data.collections, data.selectedCount, isBulk) : "…"}
open={submenu === "collections"}
onHover={() => setSubmenu("collections")}
onClick={() => setSubmenu(submenu === "collections" ? null : "collections")}
/>
{/* Watch queue */}
<button
onClick={() => {
if (allQueued) queue.removeMany(imageIds);
else queue.addMany(imageIds);
onClose();
}}
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-[var(--color-glass)] border-t border-[var(--color-glass-border)]"
>
<ListVideo className={cn("w-3.5 h-3.5", allQueued ? "text-[var(--color-cyan)]" : "text-[var(--color-fg-dim)]")} />
<span className="text-sm">
{allQueued
? (isBulk ? `Remove ${imageIds.length} from Watch Queue` : "Remove from Watch Queue")
: isBulk
? (queuedCount > 0 ? `Add ${imageIds.length - queuedCount} to Watch Queue` : `Add ${imageIds.length} to Watch Queue`)
: "Add to Watch Queue"}
</span>
</button>
{/* Delete */}
<button
onClick={onDelete}
className="w-full flex items-center gap-2 px-3 py-2 text-[var(--color-coral)] hover:bg-[var(--color-coral)]/10 border-t border-[var(--color-glass-border)]"
>
<Trash2 className="w-3.5 h-3.5" />
{isBulk ? `Delete ${imageIds.length} images` : "Delete image"}
</button>
</div>
{/* Flyout */}
{submenu === "tags" && data && (
<Flyout
title="Tags"
options={data.tags}
recent={data.recentTags.map((t) => ({ id: t.id, name: t.name, color: t.color, count: 0 }))}
selectedCount={data.selectedCount}
isBulk={isBulk}
onToggle={(opt, on) => toggleTag(opt as ContextTagOption, on)}
onCreate={async (name) => {
for (const id of imageIds) await addTagToImage(id, name);
const fresh = await fetchImageContextData(imageIds);
setData(fresh);
router.refresh();
}}
/>
)}
{submenu === "collections" && data && (
<Flyout
title="Collections"
options={data.collections.map((c) => ({ id: c.id, name: c.name, color: null, count: c.count }))}
recent={data.recentCollections.map((c) => ({ id: c.id, name: c.name, color: null, count: 0 }))}
selectedCount={data.selectedCount}
isBulk={isBulk}
onToggle={(opt, on) => toggleCollection(opt as ContextCollectionOption, on)}
onCreate={async (name) => {
const created = await createCollection(name);
if (!created) return;
for (const id of imageIds) await addImageToCollection(created.id, id);
const fresh = await fetchImageContextData(imageIds);
setData(fresh);
router.refresh();
}}
/>
)}
</div>,
document.body,
);
}
/* ---------- helpers ---------- */
function formatCountLabel(opts: Array<{ count: number }>, selected: number, isBulk: boolean): string {
const fullyApplied = opts.filter((o) => o.count === selected && o.count > 0).length;
if (!isBulk) return String(fullyApplied);
const partial = opts.filter((o) => o.count > 0 && o.count < selected).length;
if (partial > 0) return "mixed";
return String(fullyApplied);
}
function QBtn({
icon: Icon,
label,
active,
accent = "cyan",
onClick,
}: {
icon: React.ComponentType<{ className?: string }>;
label: string;
active: boolean;
accent?: "cyan" | "amber" | "mint" | "violet";
onClick: () => void;
}) {
const palette: Record<string, string> = {
cyan: "bg-[var(--color-cyan)]/18 text-[var(--color-cyan)]",
amber: "bg-amber-400/18 text-amber-300",
mint: "bg-[var(--color-mint)]/18 text-[var(--color-mint)]",
violet: "bg-[var(--color-violet)]/18 text-[var(--color-violet)]",
};
return (
<button
type="button"
onClick={onClick}
className={cn(
"flex-1 min-w-0 flex items-center justify-center gap-1.5 px-3 py-2 rounded-md text-xs font-medium whitespace-nowrap transition-colors",
active
? palette[accent]
: "bg-[var(--color-glass)] text-[var(--color-fg-dim)] hover:bg-[var(--color-glass-strong)] hover:text-[var(--color-fg)]",
)}
>
<Icon className="w-3.5 h-3.5 shrink-0" />
{label}
</button>
);
}
function SubmenuRow({
icon: Icon,
label,
countLabel,
open,
onHover,
onClick,
}: {
icon: React.ComponentType<{ className?: string }>;
label: string;
countLabel: string;
open: boolean;
onHover?: () => void;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
onMouseEnter={onHover}
onFocus={onHover}
className={cn(
"w-full flex items-center gap-2 px-3 py-2 transition-colors text-left",
open ? "bg-[var(--color-glass-strong)]" : "hover:bg-[var(--color-glass)]",
)}
>
<Icon className={cn("w-3.5 h-3.5 shrink-0", open ? "text-[var(--color-cyan)]" : "text-[var(--color-fg-dim)]")} />
<span className="flex-1 text-[var(--color-fg)]">{label}</span>
<span
className={cn(
"text-[10px] font-mono px-1.5 py-0.5 rounded",
open
? "bg-[var(--color-cyan)]/15 text-[var(--color-cyan)]"
: "bg-[var(--color-glass)] text-[var(--color-fg-muted)]",
)}
>
{countLabel}
</span>
<ChevronRight className={cn("w-3.5 h-3.5", open ? "text-[var(--color-cyan)]" : "text-[var(--color-fg-muted)]")} />
</button>
);
}
interface FlyoutOption {
id: number;
name: string;
color: string | null;
count: number;
}
function Flyout({
title,
options,
recent,
selectedCount,
isBulk,
onToggle,
onCreate,
}: {
title: string;
options: FlyoutOption[];
recent: FlyoutOption[];
selectedCount: number;
isBulk: boolean;
onToggle: (opt: FlyoutOption, on: boolean) => void;
onCreate: (name: string) => Promise<void>;
}) {
const [query, setQuery] = useState("");
const [creating, start] = useTransition();
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return options;
return options.filter((o) => o.name.toLowerCase().includes(q));
}, [options, query]);
const trimmed = query.trim();
const exact = trimmed
? options.find((o) => o.name.toLowerCase() === trimmed.toLowerCase())
: undefined;
const showCreate = trimmed.length > 0 && !exact;
const submitCreate = () => {
if (!showCreate || creating) return;
const name = trimmed;
setQuery("");
start(async () => { await onCreate(name); });
};
return (
<div
className="w-[300px] rounded-xl shadow-2xl border border-[var(--color-glass-border-strong)] backdrop-blur-2xl overflow-hidden text-sm relative"
style={{ background: "color-mix(in oklch, var(--color-bg-0) 96%, transparent)" }}
>
<div className="px-3 py-2 border-b border-[var(--color-glass-border)] flex items-center justify-between">
<span className="text-[10px] uppercase tracking-wider font-mono text-[var(--color-fg-muted)]">{title}</span>
{isBulk && <span className="text-[9px] font-mono text-[var(--color-fg-muted)]">N / {selectedCount}</span>}
</div>
<div className="p-2 space-y-2">
{/* Filter or Create */}
<div className="relative">
<Search className="w-3 h-3 absolute left-2 top-1/2 -translate-y-1/2 text-[var(--color-fg-muted)] pointer-events-none" />
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") submitCreate(); }}
placeholder={`Filter or add new ${title.toLowerCase()}`}
className="w-full bg-[var(--color-bg-1)]/60 text-xs pl-7 pr-[60px] py-1.5 rounded-md border border-[var(--color-glass-border)] outline-none focus:border-[var(--color-cyan)]"
/>
{showCreate && (
<button
type="button"
onClick={submitCreate}
disabled={creating}
className="absolute right-1 top-1/2 -translate-y-1/2 text-[10px] font-mono px-1.5 py-1 rounded bg-[var(--color-cyan)]/15 text-[var(--color-cyan)] border border-[var(--color-cyan)]/30 hover:bg-[var(--color-cyan)]/25 disabled:opacity-50"
>
{creating ? <Loader2 className="w-3 h-3 animate-spin" /> : "+ Create"}
</button>
)}
</div>
{/* Recent strip — only when no filter active */}
{recent.length > 0 && !query && (
<div>
<div className="text-[9px] uppercase tracking-wider font-mono text-[var(--color-fg-muted)] px-1 mb-1">Recent</div>
<div className="flex flex-wrap gap-1">
{recent.map((r) => (
<button
key={r.id}
type="button"
onClick={() => onToggle(r, true)}
className="text-[11px] px-2 py-0.5 rounded-full bg-[var(--color-violet)]/12 text-[var(--color-violet)] hover:bg-[var(--color-violet)]/20"
>
+ {r.name}
</button>
))}
</div>
</div>
)}
{/* Scrollable list — caps at ~5 rows */}
<div>
<div className="flex items-center justify-between px-1 mb-1">
<span className="text-[9px] uppercase tracking-wider font-mono text-[var(--color-fg-muted)]">All</span>
<span className="text-[9px] uppercase tracking-wider font-mono text-[var(--color-fg-muted)]"> scroll</span>
</div>
{filtered.length === 0 ? (
<div className="text-xs text-[var(--color-fg-muted)] italic px-2 py-2">
{query ? "No matches" : `No ${title.toLowerCase()} yet`}
</div>
) : (
<div className="max-h-[150px] overflow-y-auto -mx-1 px-1">
{filtered.map((o) => {
const state = stateFor(o.count, selectedCount);
return (
<button
key={o.id}
type="button"
onClick={() => onToggle(o, state !== "all")}
className={cn(
"w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-left text-sm transition-colors",
state === "all" && "text-[var(--color-violet)]",
state === "partial" && "text-amber-300",
state === "none" && "text-[var(--color-fg-dim)] hover:text-[var(--color-fg)]",
"hover:bg-[var(--color-glass)]",
)}
>
<span className="w-3 inline-flex justify-center text-xs">
{state === "all" ? "✓" : state === "partial" ? "" : ""}
</span>
<span className="flex-1 truncate">{o.name}</span>
<span className="text-[10px] font-mono text-[var(--color-fg-muted)]">
{isBulk ? `${o.count} / ${selectedCount}` : (o.count > 0 ? "✓" : "")}
</span>
</button>
);
})}
</div>
)}
</div>
</div>
</div>
);
}
function stateFor(count: number, total: number): "all" | "partial" | "none" {
if (count === 0) return "none";
if (count >= total) return "all";
return "partial";
}
+68
View File
@@ -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>
);
}
+80
View File
@@ -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>
);
}
+443
View File
@@ -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 17 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
// 2N 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,
)}
</>
);
}
+133
View File
@@ -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>
);
}
+165
View File
@@ -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>
);
}
+302
View File
@@ -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>
)}
</>
);
}
+277
View File
@@ -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>
);
}
+246
View File
@@ -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;
+162
View File
@@ -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>
);
}
+90
View File
@@ -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>
);
}
+57
View File
@@ -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>
);
}