Add lens pages

This commit is contained in:
2026-05-02 22:37:42 +01:00
parent b54d22b2f1
commit 95b7f72c3e
17 changed files with 829 additions and 13 deletions

View 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);
}

View 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
View 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>
);
}

View File

@@ -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>
);

View 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>
);
}

View File

@@ -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 ")}

View File

@@ -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 (

View 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>
);
}

View File

@@ -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() {

View 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>
);
}

View File

@@ -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}

View 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>
);
}

View File

@@ -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")

View File

@@ -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
View 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
View 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;
}

View File

@@ -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,