Files
pinkudex/app/page.tsx
T
2026-05-26 22:46:00 +02:00

147 lines
6.0 KiB
TypeScript

import { listImages, countImages, libraryStats, libraryLetterCounts } from "@/lib/db/queries";
import { LibraryGrid } from "@/components/grid/LibraryGrid";
import { getAppSetting } from "@/lib/db/appSettings";
import { UploadCard } from "@/components/ingest/UploadCard";
import { RegisterVisible } from "@/components/select/RegisterVisible";
import { FilterBar } from "@/components/grid/FilterBar";
import { LetterBar } from "@/components/grid/LetterBar";
import type { LibraryView } from "@/components/grid/ViewToggle";
import { resolveSort } from "@/lib/sortServer";
import { parseFilterCriteria, anyActive as hasAnyCriteria, statusToFlags } from "@/lib/filters";
import { Disc3 } from "lucide-react";
export const dynamic = "force-dynamic";
export default async function Home({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const sp = await searchParams;
const criteria = parseFilterCriteria(sp);
const sort = await resolveSort(typeof sp.sort === "string" ? sp.sort : undefined);
const rawLetter = (typeof sp.letter === "string" ? sp.letter : "").toUpperCase();
const letter = rawLetter === "#" ? "#" : (/^[A-Z]$/.test(rawLetter) ? rawLetter : null);
const search = (typeof sp.q === "string" ? sp.q.trim() : "") || undefined;
const view: LibraryView = sp.view === "portrait" ? "portrait" : "landscape";
const anyActive = hasAnyCriteria(criteria) || letter != null || !!search;
// URL pagination — `page` is the anchor (1-based). Page size from
// user settings (Settings → Appearance → Items Per Page). Negative
// / non-numeric params clamp to 1.
const PAGE_SIZE = Math.max(25, Math.min(500, getAppSetting("coverPageSize") ?? 100));
const rawPage = typeof sp.page === "string" ? Number(sp.page) : NaN;
const page = Number.isFinite(rawPage) && rawPage >= 1 ? Math.floor(rawPage) : 1;
const offset = (page - 1) * PAGE_SIZE;
const filterOpts = {
sort,
letter: letter ?? undefined,
search,
...statusToFlags(criteria.status),
marks: criteria.marks,
actressIds: criteria.ids.actresses,
actressMode: criteria.mode.actresses,
studioIds: criteria.ids.studios,
seriesIds: criteria.ids.series,
genreIds: criteria.ids.genres,
genreMode: criteria.mode.genres,
collectionIds: criteria.ids.collections,
collectionMode: criteria.mode.collections,
tagIds: criteria.ids.tags,
tagMode: criteria.mode.tags,
categoryIds: criteria.ids.categories,
categoryMode: criteria.mode.categories,
};
const totalCount = countImages(filterOpts);
const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE));
// Clamp out-of-range page back to last; cheap re-fetch since we only
// care about offset and we already have the count.
const effectivePage = Math.min(page, totalPages);
const effectiveOffset = (effectivePage - 1) * PAGE_SIZE;
const items = listImages({ ...filterOpts, limit: PAGE_SIZE, offset: effectiveOffset });
const stats = libraryStats();
const letterCounts = libraryLetterCounts({
...statusToFlags(criteria.status),
marks: criteria.marks,
actressIds: criteria.ids.actresses,
actressMode: criteria.mode.actresses,
studioIds: criteria.ids.studios,
seriesIds: criteria.ids.series,
genreIds: criteria.ids.genres,
genreMode: criteria.mode.genres,
collectionIds: criteria.ids.collections,
collectionMode: criteria.mode.collections,
tagIds: criteria.ids.tags,
tagMode: criteria.mode.tags,
categoryIds: criteria.ids.categories,
categoryMode: criteria.mode.categories,
search,
});
return (
<div className="max-w-[1600px] mx-auto px-6 py-8 fade-in">
<section className="mb-6 grid grid-cols-1 lg:grid-cols-[1fr_minmax(280px,360px)] gap-6 items-start">
<div>
<h1 className="text-4xl font-semibold tracking-tight">
Your <span className="text-gradient-accent">Cover Library</span>
</h1>
<p className="text-[var(--color-fg-dim)] mt-2 max-w-prose">
Drop cover images to import. Codes are parsed from filenames; metadata can be filled in
manually or seeded from a sibling <span className="font-mono">.nfo</span> file. Tag, collect,
rate, and search.
</p>
<div className="flex flex-wrap gap-6 mt-6 text-sm">
<Stat label="Covers" value={stats.images} />
<Stat label="Actresses" value={stats.actresses} />
<Stat label="Studios" value={stats.studios} />
<Stat label="Tags" value={stats.tags} />
<Stat label="Collections" value={stats.collections} />
</div>
</div>
<UploadCard />
</section>
<FilterBar current={{ kind: "all" }} criteria={criteria} sort={sort} view={view} />
<div className="my-6">
<LetterBar active={letter} counts={letterCounts} />
</div>
<RegisterVisible ids={items.map((i) => i.id)} />
{items.length === 0 ? (
<div className="glass rounded-2xl p-card text-center">
<Disc3 className="w-10 h-10 mx-auto text-[var(--color-cyan)] mb-label" />
<h2 className="text-xl font-medium">
{anyActive ? "Nothing Matches" : "Nothing Here Yet"}
</h2>
<p className="text-[var(--color-fg-dim)] mt-1">
{anyActive ? "All filtered out — switch back to All." : "Drag a few covers above to get started."}
</p>
</div>
) : (
<div key={`${view}-${letter ?? "all"}-${effectivePage}`} className="fade-in">
<LibraryGrid
initialItems={items}
initialPage={effectivePage}
totalPages={totalPages}
totalCount={totalCount}
view={view}
infiniteScrollEnabled
/>
</div>
)}
</div>
);
}
function Stat({ label, value }: { label: string; value: number }) {
return (
<div>
<div className="text-2xl font-mono font-semibold tabular-nums">{value}</div>
<div className="text-xs uppercase tracking-wider text-[var(--color-fg-muted)]">{label}</div>
</div>
);
}