Add gallery page
This commit is contained in:
11
src/app/api/export/route.ts
Normal file
11
src/app/api/export/route.ts
Normal 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
10
src/app/gallery/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
41
src/components/gallery-masonry.tsx
Normal file
41
src/components/gallery-masonry.tsx
Normal 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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
51
src/components/gallery-photo-tile.tsx
Normal file
51
src/components/gallery-photo-tile.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
85
src/lib/export-queries.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user