Add lens pages
This commit is contained in:
22
src/app/api/lens/[name]/assets/route.ts
Normal file
22
src/app/api/lens/[name]/assets/route.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import {
|
||||
getLensAssets,
|
||||
type LensAssetSort,
|
||||
type SortDirection,
|
||||
} from "@/lib/lens-queries";
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ name: string }> },
|
||||
) {
|
||||
const { name } = await params;
|
||||
const lensModel = decodeURIComponent(name);
|
||||
|
||||
const sp = request.nextUrl.searchParams;
|
||||
const page = Number(sp.get("page") ?? "0");
|
||||
const sortBy = (sp.get("sortBy") ?? "elo") as LensAssetSort;
|
||||
const direction = (sp.get("direction") ?? "desc") as SortDirection;
|
||||
|
||||
const data = await getLensAssets(lensModel, sortBy, direction, page);
|
||||
return Response.json(data);
|
||||
}
|
||||
101
src/app/lens/[name]/page.tsx
Normal file
101
src/app/lens/[name]/page.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { LensAssetMasonry } from "@/components/lens-asset-masonry";
|
||||
import { formatElo } from "@/lib/format";
|
||||
import { getLensAssets, getLensDetail, getLensPortrait } from "@/lib/lens-queries";
|
||||
|
||||
export const revalidate = 60;
|
||||
|
||||
export default async function LensPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ name: string }>;
|
||||
}) {
|
||||
const { name } = await params;
|
||||
const lensModel = decodeURIComponent(name);
|
||||
|
||||
const [detail, firstPage, portrait] = await Promise.all([
|
||||
getLensDetail(lensModel),
|
||||
getLensAssets(lensModel),
|
||||
getLensPortrait(lensModel),
|
||||
]);
|
||||
|
||||
if (!detail) notFound();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 max-w-7xl mx-auto w-full px-4 py-8 gap-10">
|
||||
<header className="flex flex-col gap-2">
|
||||
<Link href="/lens" className="text-sm text-muted hover:text-foreground">
|
||||
← All lenses
|
||||
</Link>
|
||||
<h1 className="text-3xl font-bold">{detail.lensModel}</h1>
|
||||
</header>
|
||||
|
||||
<div className="flex flex-col md:flex-row gap-8 items-start">
|
||||
{portrait && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<img
|
||||
src={`/img/${portrait.assetId}`}
|
||||
alt={`Portrait of ${detail.lensModel}`}
|
||||
className="max-h-96 rounded-lg object-contain"
|
||||
style={{ aspectRatio: portrait.aspectRatio }}
|
||||
/>
|
||||
{portrait.takenWithLens && (
|
||||
<span className="text-xs text-muted">
|
||||
Photographed with{" "}
|
||||
<Link
|
||||
href={`/lens/${encodeURIComponent(portrait.takenWithLens)}`}
|
||||
className="hover:underline"
|
||||
>
|
||||
{portrait.takenWithLens}
|
||||
</Link>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-x-8 gap-y-6">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-3xl font-bold tabular-nums">
|
||||
{formatElo(detail.avgElo)}
|
||||
</span>
|
||||
<span className="text-xs text-muted uppercase tracking-widest">
|
||||
Avg ELO
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-3xl font-bold tabular-nums">
|
||||
{detail.assetCount}
|
||||
</span>
|
||||
<span className="text-xs text-muted uppercase tracking-widest">
|
||||
{detail.assetCount === 1 ? "Asset" : "Assets"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-3xl font-bold tabular-nums">
|
||||
{detail.totalMatches}
|
||||
</span>
|
||||
<span className="text-xs text-muted uppercase tracking-widest">
|
||||
Matches
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-3xl font-bold tabular-nums">
|
||||
{(detail.winRate * 100).toFixed(1)}%
|
||||
</span>
|
||||
<span className="text-xs text-muted uppercase tracking-widest">
|
||||
Win rate
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="flex flex-col gap-4">
|
||||
<h2 className="text-sm font-semibold text-muted uppercase tracking-widest">
|
||||
All assets
|
||||
</h2>
|
||||
<LensAssetMasonry lensModel={lensModel} initialData={firstPage} />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
86
src/app/lens/page.tsx
Normal file
86
src/app/lens/page.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import Link from "next/link";
|
||||
import { formatElo } from "@/lib/format";
|
||||
import { getLensPortraits } from "@/lib/lens-queries";
|
||||
import { getLensStats } from "@/lib/stats";
|
||||
|
||||
export const revalidate = 60;
|
||||
|
||||
export default async function LensIndexPage() {
|
||||
const lenses = await getLensStats();
|
||||
const portraits = await getLensPortraits(lenses.map((l) => l.lensModel));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 max-w-7xl mx-auto w-full px-4 py-8 gap-8">
|
||||
<h1 className="text-3xl font-bold">Lenses</h1>
|
||||
|
||||
{lenses.length === 0 ? (
|
||||
<p className="text-muted text-sm">No lens data yet.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{lenses.map((lens) => {
|
||||
const portrait = portraits.get(lens.lensModel);
|
||||
return (
|
||||
<Link
|
||||
key={lens.lensModel}
|
||||
href={`/lens/${encodeURIComponent(lens.lensModel)}`}
|
||||
className="flex flex-col gap-3 p-4 rounded-lg bg-surface hover:bg-surface-hover transition-colors"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{portrait ? (
|
||||
<img
|
||||
src={`/img/${portrait.assetId}`}
|
||||
alt=""
|
||||
className="h-20 rounded object-contain shrink-0"
|
||||
style={{ aspectRatio: portrait.aspectRatio }}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-20 w-20 rounded bg-default shrink-0 flex items-center justify-center">
|
||||
<span className="text-[10px] text-muted text-center px-2">
|
||||
No portrait
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col flex-1 min-w-0">
|
||||
<h2 className="text-sm font-semibold truncate">
|
||||
{lens.lensModel}
|
||||
</h2>
|
||||
<span className="text-xs text-muted">
|
||||
{lens.assetCount}{" "}
|
||||
{lens.assetCount === 1 ? "asset" : "assets"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 text-center">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-lg font-bold tabular-nums">
|
||||
{formatElo(lens.avgElo)}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted uppercase tracking-widest">
|
||||
Avg ELO
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-lg font-bold tabular-nums">
|
||||
{lens.totalMatches}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted uppercase tracking-widest">
|
||||
Matches
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-lg font-bold tabular-nums">
|
||||
{(lens.winRate * 100).toFixed(0)}%
|
||||
</span>
|
||||
<span className="text-[10px] text-muted uppercase tracking-widest">
|
||||
Win rate
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { LeaderboardTable } from "@/components/stats/leaderboard-table";
|
||||
import { Podium } from "@/components/stats/podium";
|
||||
import { getLeaderboard } from "@/lib/stats";
|
||||
|
||||
export const revalidate = 60;
|
||||
@@ -9,6 +10,7 @@ export default async function LeaderboardPage() {
|
||||
return (
|
||||
<div className="flex flex-col flex-1 max-w-7xl mx-auto w-full px-4 py-8 gap-8">
|
||||
<h1 className="text-3xl font-bold">Leaderboard</h1>
|
||||
<Podium data={leaderboard} />
|
||||
<LeaderboardTable initialData={leaderboard} />
|
||||
</div>
|
||||
);
|
||||
|
||||
74
src/components/asset-photo-tile.tsx
Normal file
74
src/components/asset-photo-tile.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import Link from "next/link";
|
||||
import {
|
||||
formatElo,
|
||||
formatExposureTime,
|
||||
formatFNumber,
|
||||
formatFocalLength,
|
||||
formatIso,
|
||||
} from "@/lib/format";
|
||||
|
||||
interface AssetPhotoTileProps {
|
||||
id: string;
|
||||
width: number;
|
||||
height: number;
|
||||
eloProvisional: number;
|
||||
winsProvisional: number;
|
||||
matchesProvisional: number;
|
||||
focalLength: number | null;
|
||||
fNumber: number | null;
|
||||
exposureTime: number | null;
|
||||
iso: number | null;
|
||||
}
|
||||
|
||||
export function AssetPhotoTile({
|
||||
id,
|
||||
width,
|
||||
height,
|
||||
eloProvisional,
|
||||
winsProvisional,
|
||||
matchesProvisional,
|
||||
focalLength,
|
||||
fNumber,
|
||||
exposureTime,
|
||||
iso,
|
||||
}: AssetPhotoTileProps) {
|
||||
const losses = matchesProvisional - winsProvisional;
|
||||
|
||||
const exifParts = [
|
||||
focalLength && formatFocalLength(focalLength),
|
||||
fNumber && formatFNumber(fNumber),
|
||||
exposureTime && formatExposureTime(exposureTime),
|
||||
iso && formatIso(iso),
|
||||
].filter(Boolean);
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/asset/${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/${id}`}
|
||||
alt=""
|
||||
width={width}
|
||||
height={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(eloProvisional)}
|
||||
</span>
|
||||
<span className="text-xs tabular-nums text-white/80">
|
||||
{winsProvisional}W {losses}L
|
||||
</span>
|
||||
</div>
|
||||
{exifParts.length > 0 && (
|
||||
<span className="font-mono text-[11px] text-white/70">
|
||||
{exifParts.join(" • ")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -33,10 +33,10 @@ export function AssetStats({ asset }: { asset: PairingAsset }) {
|
||||
{/* EXIF row */}
|
||||
<div className="text-xs font-mono text-muted">
|
||||
{[
|
||||
asset.focalLength != null && formatFocalLength(asset.focalLength),
|
||||
asset.fNumber != null && formatFNumber(asset.fNumber),
|
||||
asset.focalLength && formatFocalLength(asset.focalLength),
|
||||
asset.fNumber && formatFNumber(asset.fNumber),
|
||||
asset.exposureTime && formatExposureTime(asset.exposureTime),
|
||||
asset.iso != null && formatIso(asset.iso),
|
||||
asset.iso && formatIso(asset.iso),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" \u2022 ")}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { WinTrendDisplay } from "@/components/asset/win-trend";
|
||||
import type { WinTrend } from "@/lib/asset-queries";
|
||||
import {
|
||||
formatElo,
|
||||
formatExposureTime,
|
||||
@@ -6,7 +7,6 @@ import {
|
||||
formatFocalLength,
|
||||
formatIso,
|
||||
} from "@/lib/format";
|
||||
import type { WinTrend } from "@/lib/asset-queries";
|
||||
|
||||
interface StatsBlockProps {
|
||||
eloProvisional: number;
|
||||
@@ -32,10 +32,10 @@ function ExifLine({
|
||||
"lensModel" | "focalLength" | "fNumber" | "exposureTime" | "iso"
|
||||
>) {
|
||||
const exifParts = [
|
||||
focalLength != null && formatFocalLength(focalLength),
|
||||
fNumber != null && formatFNumber(fNumber),
|
||||
exposureTime != null && formatExposureTime(exposureTime),
|
||||
iso != null && formatIso(iso),
|
||||
focalLength && formatFocalLength(focalLength),
|
||||
fNumber && formatFNumber(fNumber),
|
||||
exposureTime && formatExposureTime(exposureTime),
|
||||
iso && formatIso(iso),
|
||||
].filter(Boolean);
|
||||
|
||||
return (
|
||||
|
||||
108
src/components/lens-asset-masonry.tsx
Normal file
108
src/components/lens-asset-masonry.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
"use client";
|
||||
|
||||
import { ToggleButton, ToggleButtonGroup } from "@heroui/react";
|
||||
import { useState } from "react";
|
||||
import useSWRInfinite from "swr/infinite";
|
||||
import { AssetPhotoTile } from "@/components/asset-photo-tile";
|
||||
import { PhotoMasonry } from "@/components/photo-masonry";
|
||||
import {
|
||||
LENS_ASSETS_PAGE_SIZE,
|
||||
type LensAsset,
|
||||
type LensAssetSort,
|
||||
type SortDirection,
|
||||
} from "@/lib/lens-types";
|
||||
|
||||
const fetcher = (url: string): Promise<LensAsset[]> =>
|
||||
fetch(url).then((r) => r.json());
|
||||
|
||||
export function LensAssetMasonry({
|
||||
lensModel,
|
||||
initialData,
|
||||
}: {
|
||||
lensModel: string;
|
||||
initialData: LensAsset[];
|
||||
}) {
|
||||
const [sortBy, setSortBy] = useState<LensAssetSort>("elo");
|
||||
const [direction, setDirection] = useState<SortDirection>("desc");
|
||||
|
||||
const isInitialSort = sortBy === "elo" && direction === "desc";
|
||||
|
||||
const { data, size, setSize, isValidating } = useSWRInfinite<LensAsset[]>(
|
||||
(pageIndex, previousPageData) => {
|
||||
if (previousPageData && previousPageData.length < LENS_ASSETS_PAGE_SIZE)
|
||||
return null;
|
||||
return `/api/lens/${encodeURIComponent(
|
||||
lensModel,
|
||||
)}/assets?page=${pageIndex}&sortBy=${sortBy}&direction=${direction}`;
|
||||
},
|
||||
fetcher,
|
||||
{
|
||||
// Only seed initial data when sort matches what was server-rendered
|
||||
fallbackData: isInitialSort ? [initialData] : undefined,
|
||||
revalidateFirstPage: false,
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
keepPreviousData: true,
|
||||
},
|
||||
);
|
||||
|
||||
const items = data ? data.flat() : [];
|
||||
const lastPage = data?.[data.length - 1];
|
||||
const hasMore = lastPage ? lastPage.length === LENS_ASSETS_PAGE_SIZE : false;
|
||||
const isLoadingMore =
|
||||
isValidating && data !== undefined && typeof data[size - 1] === "undefined";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<ToggleButtonGroup
|
||||
aria-label="Sort by"
|
||||
selectionMode="single"
|
||||
selectedKeys={[sortBy]}
|
||||
onSelectionChange={(keys) => {
|
||||
const next = [...keys][0] as LensAssetSort | undefined;
|
||||
if (next) setSortBy(next);
|
||||
}}
|
||||
>
|
||||
<ToggleButton id="elo">ELO</ToggleButton>
|
||||
<ToggleButton id="takenAt">Date taken</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
|
||||
<ToggleButtonGroup
|
||||
aria-label="Direction"
|
||||
selectionMode="single"
|
||||
selectedKeys={[direction]}
|
||||
onSelectionChange={(keys) => {
|
||||
const next = [...keys][0] as SortDirection | undefined;
|
||||
if (next) setDirection(next);
|
||||
}}
|
||||
>
|
||||
<ToggleButton id="desc">Desc</ToggleButton>
|
||||
<ToggleButton id="asc">Asc</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
</div>
|
||||
|
||||
<PhotoMasonry
|
||||
key={`${sortBy}-${direction}`}
|
||||
items={items}
|
||||
renderItem={(asset) => (
|
||||
<AssetPhotoTile
|
||||
id={asset.id}
|
||||
width={asset.width}
|
||||
height={asset.height}
|
||||
eloProvisional={asset.eloProvisional}
|
||||
winsProvisional={asset.winsProvisional}
|
||||
matchesProvisional={asset.matchesProvisional}
|
||||
focalLength={asset.focalLength}
|
||||
fNumber={asset.fNumber}
|
||||
exposureTime={asset.exposureTime}
|
||||
iso={asset.iso}
|
||||
/>
|
||||
)}
|
||||
hasMore={hasMore}
|
||||
isLoading={isLoadingMore}
|
||||
onLoadMore={() => setSize(size + 1)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import Link from "next/link";
|
||||
const links = [
|
||||
{ href: "/", label: "Vote" },
|
||||
{ href: "/stats", label: "Stats" },
|
||||
{ href: "/lens", label: "Lenses" },
|
||||
] as const;
|
||||
|
||||
export function Nav() {
|
||||
|
||||
170
src/components/photo-masonry.tsx
Normal file
170
src/components/photo-masonry.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
const GAP = 16;
|
||||
|
||||
function getColumnCount(width: number) {
|
||||
if (width >= 1024) return 4;
|
||||
if (width >= 768) return 3;
|
||||
if (width >= 640) return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
export interface MasonryItem {
|
||||
id: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
type Column<T extends MasonryItem> = {
|
||||
items: T[];
|
||||
height: number;
|
||||
};
|
||||
|
||||
type ColumnCache<T extends MasonryItem> = {
|
||||
columns: Column<T>[];
|
||||
itemCount: number;
|
||||
};
|
||||
|
||||
function assignToColumns<T extends MasonryItem>(
|
||||
cache: ColumnCache<T> | null,
|
||||
items: T[],
|
||||
columnCount: number,
|
||||
columnWidth: number,
|
||||
): ColumnCache<T> {
|
||||
const needsReset = !cache || cache.columns.length !== columnCount;
|
||||
|
||||
if (needsReset || columnWidth <= 0) {
|
||||
const columns: Column<T>[] = Array.from({ length: columnCount }, () => ({
|
||||
items: [],
|
||||
height: 0,
|
||||
}));
|
||||
|
||||
if (columnWidth > 0) {
|
||||
for (const item of items) {
|
||||
if (item.width <= 0 || item.height <= 0) continue;
|
||||
const shortest = columns.reduce((min, col) =>
|
||||
col.height < min.height ? col : min,
|
||||
);
|
||||
shortest.items.push(item);
|
||||
shortest.height += (item.height / item.width) * columnWidth + GAP;
|
||||
}
|
||||
}
|
||||
|
||||
// If columnWidth was 0 we didn't actually distribute, so report itemCount: 0
|
||||
// to ensure the next call (with a real width) re-runs the distribution.
|
||||
return { columns, itemCount: columnWidth > 0 ? items.length : 0 };
|
||||
}
|
||||
|
||||
if (items.length > cache.itemCount) {
|
||||
const columns = cache.columns.map((col) => ({
|
||||
...col,
|
||||
items: [...col.items],
|
||||
}));
|
||||
|
||||
for (const item of items.slice(cache.itemCount)) {
|
||||
if (item.width <= 0 || item.height <= 0) continue;
|
||||
const shortest = columns.reduce((min, col) =>
|
||||
col.height < min.height ? col : min,
|
||||
);
|
||||
shortest.items.push(item);
|
||||
shortest.height += (item.height / item.width) * columnWidth + GAP;
|
||||
}
|
||||
|
||||
return { columns, itemCount: items.length };
|
||||
}
|
||||
|
||||
return cache;
|
||||
}
|
||||
|
||||
interface PhotoMasonryProps<T extends MasonryItem> {
|
||||
items: T[];
|
||||
renderItem: (item: T) => ReactNode;
|
||||
/** Called when the user has scrolled near the bottom and more items should be loaded. */
|
||||
onLoadMore?: () => void;
|
||||
/** Whether more items are available to load. */
|
||||
hasMore?: boolean;
|
||||
/** Whether a load is currently in progress (prevents duplicate triggers). */
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function PhotoMasonry<T extends MasonryItem>({
|
||||
items,
|
||||
renderItem,
|
||||
onLoadMore,
|
||||
hasMore = false,
|
||||
isLoading = false,
|
||||
}: PhotoMasonryProps<T>) {
|
||||
const gridRef = useRef<HTMLDivElement>(null);
|
||||
const loadMoreRef = useRef<HTMLDivElement>(null);
|
||||
const [columnCount, setColumnCount] = useState(4);
|
||||
const [columnWidth, setColumnWidth] = useState(0);
|
||||
const cacheRef = useRef<ColumnCache<T> | null>(null);
|
||||
|
||||
const columns = useMemo(() => {
|
||||
const result = assignToColumns(
|
||||
cacheRef.current,
|
||||
items,
|
||||
columnCount,
|
||||
columnWidth,
|
||||
);
|
||||
cacheRef.current = result;
|
||||
return result.columns;
|
||||
}, [items, columnCount, columnWidth]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!gridRef.current) return;
|
||||
|
||||
const measure = () => {
|
||||
if (!gridRef.current) return;
|
||||
const count = getColumnCount(window.innerWidth);
|
||||
setColumnCount(count);
|
||||
const gridWidth = gridRef.current.offsetWidth;
|
||||
const width = (gridWidth - (count - 1) * GAP) / count;
|
||||
setColumnWidth(width);
|
||||
};
|
||||
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(gridRef.current);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loadMoreRef.current || !onLoadMore) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting && hasMore && !isLoading) {
|
||||
onLoadMore();
|
||||
}
|
||||
},
|
||||
{ rootMargin: "100%" },
|
||||
);
|
||||
|
||||
observer.observe(loadMoreRef.current);
|
||||
return () => observer.disconnect();
|
||||
}, [hasMore, isLoading, onLoadMore]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={gridRef}
|
||||
className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4"
|
||||
style={{ gap: GAP }}
|
||||
>
|
||||
{columns.map((column, colIdx) => (
|
||||
<div
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: column indices are stable
|
||||
key={colIdx}
|
||||
className="flex flex-col"
|
||||
style={{ gap: GAP }}
|
||||
>
|
||||
{column.items.map((item) => (
|
||||
<div key={item.id}>{renderItem(item)}</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
<div ref={loadMoreRef} className="col-span-full h-1" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -28,7 +28,12 @@ export function LensTable({ data }: { data: LensStats[] }) {
|
||||
{data.map((lens) => (
|
||||
<Table.Row key={lens.lensModel}>
|
||||
<Table.Cell className="font-medium text-sm">
|
||||
{lens.lensModel}
|
||||
<Link
|
||||
href={`/lens/${encodeURIComponent(lens.lensModel)}`}
|
||||
className="hover:underline"
|
||||
>
|
||||
{lens.lensModel}
|
||||
</Link>
|
||||
</Table.Cell>
|
||||
<Table.Cell className="tabular-nums">
|
||||
{lens.assetCount}
|
||||
|
||||
77
src/components/stats/podium.tsx
Normal file
77
src/components/stats/podium.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import Link from "next/link";
|
||||
import { formatElo } from "@/lib/format";
|
||||
import type { LeaderboardEntry } from "@/lib/stats";
|
||||
|
||||
const PLACES = [
|
||||
{
|
||||
rank: 2,
|
||||
label: "2nd",
|
||||
height: "h-32",
|
||||
order: "order-1",
|
||||
bg: "bg-zinc-400",
|
||||
border: "border-zinc-600",
|
||||
text: "text-zinc-700",
|
||||
},
|
||||
{
|
||||
rank: 1,
|
||||
label: "1st",
|
||||
height: "h-44",
|
||||
order: "order-2",
|
||||
bg: "bg-yellow-400",
|
||||
border: "border-yellow-600",
|
||||
text: "text-yellow-700",
|
||||
},
|
||||
{
|
||||
rank: 3,
|
||||
label: "3rd",
|
||||
height: "h-24",
|
||||
order: "order-3",
|
||||
bg: "bg-amber-600",
|
||||
border: "border-amber-800",
|
||||
text: "text-amber-900",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export function Podium({ data }: { data: LeaderboardEntry[] }) {
|
||||
if (data.length < 3) return null;
|
||||
|
||||
const top3 = [data[0], data[1], data[2]];
|
||||
|
||||
return (
|
||||
<div className="flex items-end justify-center gap-4 md:gap-8 py-8">
|
||||
{PLACES.map((place) => {
|
||||
const entry = top3[place.rank - 1];
|
||||
return (
|
||||
<div
|
||||
key={place.rank}
|
||||
className={`flex flex-col items-center gap-2 flex-1 max-w-xs ${place.order}`}
|
||||
>
|
||||
<Link
|
||||
href={`/asset/${entry.id}`}
|
||||
className="flex flex-col items-center gap-2 hover:opacity-90 transition-opacity"
|
||||
>
|
||||
<img
|
||||
src={entry.thumbnailUrl}
|
||||
alt=""
|
||||
className="max-h-48 rounded-lg object-contain"
|
||||
style={{ aspectRatio: entry.aspectRatio }}
|
||||
/>
|
||||
<span className="text-xl font-bold tabular-nums">
|
||||
{formatElo(entry.eloProvisional)}
|
||||
</span>
|
||||
</Link>
|
||||
<div
|
||||
className={`flex items-end justify-center w-full ${place.height} ${place.bg} border-x-4 border-t-4 ${place.border} rounded-t-lg`}
|
||||
>
|
||||
<span
|
||||
className={`text-3xl font-extrabold ${place.text} pb-2 tracking-tight`}
|
||||
>
|
||||
{place.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -60,6 +60,11 @@ export const matches = sqliteTable("matches", {
|
||||
.$defaultFn(() => new Date().toISOString()),
|
||||
});
|
||||
|
||||
export const lensPortraits = sqliteTable("lens_portraits", {
|
||||
lensModel: text("lens_model").primaryKey(),
|
||||
assetId: text("asset_id").notNull(),
|
||||
});
|
||||
|
||||
export const eloHistory = sqliteTable("elo_history", {
|
||||
id: integer().primaryKey({ autoIncrement: true }),
|
||||
assetId: text("asset_id")
|
||||
|
||||
@@ -48,6 +48,10 @@ export async function fetchAlbumAssets(): Promise<ImmichAsset[]> {
|
||||
return album.assets;
|
||||
}
|
||||
|
||||
export async function fetchAsset(assetId: string): Promise<ImmichAsset> {
|
||||
return immichFetch(`/assets/${assetId}`);
|
||||
}
|
||||
|
||||
export async function updateAssetRating(
|
||||
assetId: string,
|
||||
rating: number,
|
||||
|
||||
143
src/lib/lens-queries.ts
Normal file
143
src/lib/lens-queries.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { asc, desc, eq, sql } from "drizzle-orm";
|
||||
import { db } from "@/db";
|
||||
import { assets, lensPortraits } from "@/db/schema";
|
||||
import { fetchAsset } from "./immich";
|
||||
import {
|
||||
LENS_ASSETS_PAGE_SIZE,
|
||||
type LensAsset,
|
||||
type LensAssetSort,
|
||||
type SortDirection,
|
||||
} from "./lens-types";
|
||||
|
||||
export type { LensAsset, LensAssetSort, SortDirection };
|
||||
export { LENS_ASSETS_PAGE_SIZE };
|
||||
|
||||
export interface LensPortrait {
|
||||
assetId: string;
|
||||
aspectRatio: number;
|
||||
/** The lens that took the portrait photo (the lens behind the camera). */
|
||||
takenWithLens: string | null;
|
||||
}
|
||||
|
||||
async function hydratePortrait(assetId: string): Promise<LensPortrait | null> {
|
||||
try {
|
||||
const asset = await fetchAsset(assetId);
|
||||
return {
|
||||
assetId,
|
||||
aspectRatio: asset.width / asset.height,
|
||||
takenWithLens: asset.exifInfo.lensModel,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Get the configured portrait asset for a lens, if any. */
|
||||
export async function getLensPortrait(
|
||||
lensModel: string,
|
||||
): Promise<LensPortrait | null> {
|
||||
const [row] = await db
|
||||
.select({ assetId: lensPortraits.assetId })
|
||||
.from(lensPortraits)
|
||||
.where(eq(lensPortraits.lensModel, lensModel));
|
||||
|
||||
if (!row) return null;
|
||||
return hydratePortrait(row.assetId);
|
||||
}
|
||||
|
||||
/** Get portraits for many lens models. Issues one Immich call per portrait, in parallel. */
|
||||
export async function getLensPortraits(
|
||||
lensModels: string[],
|
||||
): Promise<Map<string, LensPortrait>> {
|
||||
if (lensModels.length === 0) return new Map();
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
lensModel: lensPortraits.lensModel,
|
||||
assetId: lensPortraits.assetId,
|
||||
})
|
||||
.from(lensPortraits)
|
||||
.where(
|
||||
sql`${lensPortraits.lensModel} IN (${sql.join(
|
||||
lensModels.map((m) => sql`${m}`),
|
||||
sql`, `,
|
||||
)})`,
|
||||
);
|
||||
|
||||
const hydrated = await Promise.all(
|
||||
rows.map(async (r) => [r.lensModel, await hydratePortrait(r.assetId)] as const),
|
||||
);
|
||||
|
||||
return new Map(
|
||||
hydrated.filter((entry): entry is [string, LensPortrait] => entry[1] !== null),
|
||||
);
|
||||
}
|
||||
|
||||
export interface LensDetail {
|
||||
lensModel: string;
|
||||
assetCount: number;
|
||||
avgElo: number;
|
||||
totalMatches: number;
|
||||
totalWins: number;
|
||||
winRate: number;
|
||||
}
|
||||
|
||||
export async function getLensDetail(
|
||||
lensModel: string,
|
||||
): Promise<LensDetail | null> {
|
||||
const [agg] = await db
|
||||
.select({
|
||||
lensModel: assets.lensModel,
|
||||
assetCount: sql<number>`COUNT(*)`,
|
||||
avgElo: sql<number>`AVG(${assets.eloProvisional})`,
|
||||
totalMatches: sql<number>`SUM(${assets.matchesProvisional})`,
|
||||
totalWins: sql<number>`SUM(${assets.winsProvisional})`,
|
||||
})
|
||||
.from(assets)
|
||||
.where(eq(assets.lensModel, lensModel))
|
||||
.groupBy(assets.lensModel);
|
||||
|
||||
if (!agg || !agg.lensModel) return null;
|
||||
|
||||
return {
|
||||
lensModel: agg.lensModel,
|
||||
assetCount: agg.assetCount,
|
||||
avgElo: agg.avgElo,
|
||||
totalMatches: agg.totalMatches,
|
||||
totalWins: agg.totalWins,
|
||||
winRate: agg.totalMatches > 0 ? agg.totalWins / agg.totalMatches : 0,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getLensAssets(
|
||||
lensModel: string,
|
||||
sortBy: LensAssetSort = "elo",
|
||||
direction: SortDirection = "desc",
|
||||
page = 0,
|
||||
limit = LENS_ASSETS_PAGE_SIZE,
|
||||
): Promise<LensAsset[]> {
|
||||
const sortColumn =
|
||||
sortBy === "takenAt" ? assets.takenAt : assets.eloProvisional;
|
||||
const orderFn = direction === "asc" ? asc : desc;
|
||||
|
||||
return db
|
||||
.select({
|
||||
id: assets.id,
|
||||
width: assets.width,
|
||||
height: assets.height,
|
||||
aspectRatio: assets.aspectRatio,
|
||||
eloProvisional: assets.eloProvisional,
|
||||
matchesProvisional: assets.matchesProvisional,
|
||||
winsProvisional: assets.winsProvisional,
|
||||
focalLength: assets.focalLength,
|
||||
fNumber: assets.fNumber,
|
||||
exposureTime: assets.exposureTime,
|
||||
iso: assets.iso,
|
||||
})
|
||||
.from(assets)
|
||||
.where(eq(assets.lensModel, lensModel))
|
||||
.orderBy(orderFn(sortColumn))
|
||||
.limit(limit)
|
||||
.offset(page * limit);
|
||||
}
|
||||
|
||||
18
src/lib/lens-types.ts
Normal file
18
src/lib/lens-types.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export type LensAssetSort = "elo" | "takenAt";
|
||||
export type SortDirection = "asc" | "desc";
|
||||
|
||||
export const LENS_ASSETS_PAGE_SIZE = 24;
|
||||
|
||||
export interface LensAsset {
|
||||
id: string;
|
||||
width: number;
|
||||
height: number;
|
||||
aspectRatio: number;
|
||||
eloProvisional: number;
|
||||
matchesProvisional: number;
|
||||
winsProvisional: number;
|
||||
focalLength: number | null;
|
||||
fNumber: number | null;
|
||||
exposureTime: number | null;
|
||||
iso: number | null;
|
||||
}
|
||||
@@ -30,10 +30,10 @@ function assetToRow(asset: ImmichAsset) {
|
||||
eloVerified: initialElo,
|
||||
thumbhash: asset.thumbhash,
|
||||
lensModel: exif.lensModel,
|
||||
focalLength: exif.focalLength,
|
||||
fNumber: exif.fNumber,
|
||||
exposureTime: parseExposureTime(exif.exposureTime),
|
||||
iso: exif.iso,
|
||||
focalLength: exif.focalLength || null,
|
||||
fNumber: exif.fNumber || null,
|
||||
exposureTime: parseExposureTime(exif.exposureTime) || null,
|
||||
iso: exif.iso || null,
|
||||
width: asset.width,
|
||||
height: asset.height,
|
||||
aspectRatio: asset.width / asset.height,
|
||||
|
||||
Reference in New Issue
Block a user