Add gallery page

This commit is contained in:
2026-05-03 00:21:02 +01:00
parent a80b2d83db
commit 3b658b1e86
6 changed files with 199 additions and 0 deletions

View File

@@ -0,0 +1,11 @@
import type { NextRequest } from "next/server";
import { getExportPhotos } from "@/lib/export-queries";
export async function GET(request: NextRequest) {
const sp = request.nextUrl.searchParams;
const seed = sp.get("seed") ?? "default";
const page = Number(sp.get("page") ?? "0");
const data = await getExportPhotos(seed, page);
return Response.json(data);
}

10
src/app/gallery/page.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { GalleryMasonry } from "@/components/gallery-masonry";
export default function GalleryPage() {
return (
<div className="flex flex-col flex-1 max-w-7xl mx-auto w-full px-4 py-8 gap-6">
<h1 className="text-3xl font-bold">Gallery</h1>
<GalleryMasonry />
</div>
);
}

View File

@@ -0,0 +1,41 @@
"use client";
import { useMemo } from "react";
import useSWRInfinite from "swr/infinite";
import { GalleryPhotoTile } from "@/components/gallery-photo-tile";
import { PhotoMasonry } from "@/components/photo-masonry";
import type { ExportPhoto } from "@/lib/export-queries";
interface ExportResponse {
photos: ExportPhoto[];
hasMore: boolean;
}
const fetcher = (url: string): Promise<ExportResponse> =>
fetch(url).then((r) => r.json());
export function GalleryMasonry() {
const seed = useMemo(() => Math.random().toString(36).slice(2), []);
const { data, size, setSize, isValidating } = useSWRInfinite<ExportResponse>(
(pageIndex, previous) => {
if (previous && !previous.hasMore) return null;
return `/api/export?seed=${seed}&page=${pageIndex}`;
},
fetcher,
{ revalidateFirstPage: false, revalidateOnFocus: false },
);
const photos = data?.flatMap((p) => p.photos) ?? [];
const hasMore = data?.[data.length - 1]?.hasMore ?? true;
return (
<PhotoMasonry
items={photos}
renderItem={(photo) => <GalleryPhotoTile photo={photo} />}
hasMore={hasMore}
isLoading={isValidating}
onLoadMore={() => setSize(size + 1)}
/>
);
}

View File

@@ -0,0 +1,51 @@
import Link from "next/link";
import type { ExportPhoto } from "@/lib/export-queries";
import {
formatElo,
formatExposureTime,
formatFNumber,
formatFocalLength,
formatIso,
} from "@/lib/format";
export function GalleryPhotoTile({ photo }: { photo: ExportPhoto }) {
const exifParts = [
photo.exif.focalLength && formatFocalLength(photo.exif.focalLength),
photo.exif.fNumber && formatFNumber(photo.exif.fNumber),
photo.exif.exposureTime && formatExposureTime(photo.exif.exposureTime),
photo.exif.iso && formatIso(photo.exif.iso),
].filter(Boolean);
return (
<Link
href={`/asset/${photo.id}`}
className="group relative block overflow-hidden rounded-lg shadow-sm transition-shadow duration-200 hover:shadow-2xl hover:shadow-black/30"
>
<img
src={`/img/${photo.id}`}
alt=""
width={photo.width}
height={photo.height}
loading="lazy"
className="w-full"
/>
<div className="pointer-events-none absolute inset-0 flex flex-col justify-end gap-1 bg-linear-to-t from-black/80 via-black/30 to-transparent p-3 opacity-0 transition-opacity duration-200 group-hover:opacity-100">
<div className="flex items-baseline justify-between">
<span className="text-base font-bold tabular-nums text-white">
{formatElo(photo.eloVerified)}
</span>
{photo.exif.lensModel && (
<span className="text-xs text-white/70 truncate max-w-[60%]">
{photo.exif.lensModel}
</span>
)}
</div>
{exifParts.length > 0 && (
<span className="font-mono text-[11px] text-white/70">
{exifParts.join(" • ")}
</span>
)}
</div>
</Link>
);
}

View File

@@ -4,6 +4,7 @@ const links = [
{ href: "/", label: "Vote" },
{ href: "/stats", label: "Stats" },
{ href: "/lenses", label: "Lenses" },
{ href: "/gallery", label: "Gallery" },
] as const;
export function Nav() {

85
src/lib/export-queries.ts Normal file
View File

@@ -0,0 +1,85 @@
import { eq } from "drizzle-orm";
import { db } from "@/db";
import { assets } from "@/db/schema";
export const EXPORT_PAGE_SIZE = 48;
const ELO_NOISE = 20;
export interface ExportPhoto {
id: string;
width: number;
height: number;
eloVerified: number;
exif: {
lensModel: string | null;
focalLength: number | null;
fNumber: number | null;
exposureTime: number | null;
iso: number | null;
};
}
function seededRandom(seed: string) {
let hash = 0;
for (let i = 0; i < seed.length; i++) {
hash = (hash << 5) - hash + seed.charCodeAt(i);
hash = hash & hash;
}
return () => {
hash = Math.imul(hash ^ (hash >>> 15), hash | 1);
hash ^= hash + Math.imul(hash ^ (hash >>> 7), hash | 61);
return ((hash ^ (hash >>> 14)) >>> 0) / 4294967296;
};
}
export async function getExportPhotos(
seed: string,
page = 0,
limit = EXPORT_PAGE_SIZE,
): Promise<{ photos: ExportPhoto[]; hasMore: boolean }> {
const rows = await db
.select({
id: assets.id,
width: assets.width,
height: assets.height,
eloVerified: assets.eloVerified,
lensModel: assets.lensModel,
focalLength: assets.focalLength,
fNumber: assets.fNumber,
exposureTime: assets.exposureTime,
iso: assets.iso,
})
.from(assets)
.where(eq(assets.active, true));
// Add seeded noise to ELO and sort. This shuffles within similar-ELO ranges
// while keeping the overall ordering intact.
const rand = seededRandom(seed);
const jittered = rows
.map((r) => ({
row: r,
score: r.eloVerified + (rand() * 2 - 1) * ELO_NOISE,
}))
.sort((a, b) => b.score - a.score);
const start = page * limit;
const end = start + limit;
const slice = jittered.slice(start, end);
return {
photos: slice.map(({ row }) => ({
id: row.id,
width: row.width,
height: row.height,
eloVerified: row.eloVerified,
exif: {
lensModel: row.lensModel,
focalLength: row.focalLength,
fNumber: row.fNumber,
exposureTime: row.exposureTime,
iso: row.iso,
},
})),
hasMore: end < jittered.length,
};
}