147 lines
6.0 KiB
TypeScript
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>
|
|
);
|
|
}
|