Add asset page

This commit is contained in:
2026-05-02 20:00:00 +01:00
parent 8483d5b384
commit b54d22b2f1
11 changed files with 784 additions and 4 deletions

View File

@@ -0,0 +1,12 @@
import type { NextRequest } from "next/server";
import { getAssetMatchHistory } from "@/lib/asset-queries";
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
const page = Number(request.nextUrl.searchParams.get("page") ?? "0");
const data = await getAssetMatchHistory(id, page);
return Response.json(data);
}

View File

@@ -0,0 +1,99 @@
import { notFound } from "next/navigation";
import { EloChart } from "@/components/asset/elo-chart";
import { HeadToHead } from "@/components/asset/head-to-head";
import { AssetMatchHistory } from "@/components/asset/match-history";
import { EloNeighbors } from "@/components/asset/neighbors";
import { StatsBlock } from "@/components/asset/stats-block";
import {
getAssetById,
getAssetEloHistory,
getAssetMatchHistory,
getEloNeighbors,
getHeadToHead,
getWinTrend,
} from "@/lib/asset-queries";
export const revalidate = 60;
export default async function AssetPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const asset = await getAssetById(id);
if (!asset) notFound();
const [eloHistory, matchHistory, h2h, neighbors, winTrend] =
await Promise.all([
getAssetEloHistory(id),
getAssetMatchHistory(id, 0),
getHeadToHead(id),
getEloNeighbors(id, asset.eloProvisional),
getWinTrend(id),
]);
const isHorizontal = asset.aspectRatio >= 1;
const statsProps = {
eloProvisional: asset.eloProvisional,
winsProvisional: asset.winsProvisional,
matchesProvisional: asset.matchesProvisional,
lensModel: asset.lensModel,
focalLength: asset.focalLength,
fNumber: asset.fNumber,
exposureTime: asset.exposureTime,
iso: asset.iso,
winTrend,
};
return (
<div className="relative flex flex-col flex-1">
{/* Blurred background */}
<div className="absolute inset-0 overflow-hidden -z-10">
<img
src={`/img/${asset.id}`}
alt=""
className="w-full h-full object-cover blur-3xl opacity-20 scale-110"
/>
</div>
<div className="flex flex-col flex-1 max-w-7xl mx-auto w-full px-4 py-8 gap-10">
{/* Hero: image + stats */}
{isHorizontal ? (
<div className="flex flex-col gap-8">
<img
src={`/img/${asset.id}`}
alt=""
className="w-full rounded-lg object-contain"
style={{ aspectRatio: asset.aspectRatio }}
/>
<StatsBlock {...statsProps} layout="horizontal" />
</div>
) : (
<div className="flex flex-col md:flex-row gap-8 items-center md:items-start">
<img
src={`/img/${asset.id}`}
alt=""
className="max-h-[32rem] rounded-lg object-contain"
style={{ aspectRatio: asset.aspectRatio }}
/>
<StatsBlock {...statsProps} layout="vertical" />
</div>
)}
{/* ELO Chart */}
<EloChart data={eloHistory} initialElo={asset.eloProvisional} />
{/* Head to Head */}
<HeadToHead data={h2h} />
{/* Match History */}
<AssetMatchHistory assetId={id} initialData={matchHistory} />
{/* Neighbors */}
<EloNeighbors data={neighbors} />
</div>
</div>
);
}

View File

@@ -0,0 +1,114 @@
"use client";
import { useEffect, useRef, useState } from "react";
import type { EloHistoryEntry } from "@/lib/asset-queries";
const HEIGHT = 200;
const PAD_Y = 20;
const PAD_LEFT = 40;
const PAD_RIGHT = 10;
export function EloChart({
data,
initialElo,
}: {
data: EloHistoryEntry[];
initialElo: number;
}) {
const containerRef = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(0);
useEffect(() => {
if (!containerRef.current) return;
const observer = new ResizeObserver((entries) => {
setWidth(entries[0].contentRect.width);
});
observer.observe(containerRef.current);
return () => observer.disconnect();
}, []);
if (data.length === 0) {
return <p className="text-muted text-sm">No match history yet.</p>;
}
const points = [{ elo: initialElo, createdAt: "" }, ...data];
const elos = points.map((d) => d.elo);
const min = Math.min(...elos);
const max = Math.max(...elos);
const range = max - min || 1;
const chartW = width - PAD_LEFT - PAD_RIGHT;
const chartH = HEIGHT - PAD_Y * 2;
const xStep = points.length > 1 ? chartW / (points.length - 1) : 0;
const pathPoints = points.map((p, i) => {
const x = PAD_LEFT + i * xStep;
const y = PAD_Y + chartH - ((p.elo - min) / range) * chartH;
return `${x},${y}`;
});
const polyline = pathPoints.join(" ");
return (
<div className="flex flex-col gap-2">
<h3 className="text-sm font-semibold text-muted uppercase tracking-widest">
ELO Over Time
</h3>
<div ref={containerRef} className="w-full">
{width > 0 && (
<svg width={width} height={HEIGHT}>
{/* Grid lines */}
{[0, 0.25, 0.5, 0.75, 1].map((pct) => {
const y = PAD_Y + chartH - pct * chartH;
const label = Math.round(min + pct * range);
return (
<g key={pct}>
<line
x1={PAD_LEFT}
y1={y}
x2={width - PAD_RIGHT}
y2={y}
stroke="var(--separator)"
strokeWidth={0.5}
/>
<text
x={PAD_LEFT - 4}
y={y + 4}
textAnchor="end"
className="fill-muted"
fontSize={11}
>
{label}
</text>
</g>
);
})}
{/* Line */}
<polyline
points={polyline}
fill="none"
stroke="var(--accent)"
strokeWidth={2}
strokeLinejoin="round"
strokeLinecap="round"
/>
{/* Last point marker */}
{points.length > 0 && (
<circle
cx={PAD_LEFT + (points.length - 1) * xStep}
cy={
PAD_Y +
chartH -
((points[points.length - 1].elo - min) / range) * chartH
}
r={3}
className="fill-accent"
/>
)}
</svg>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,36 @@
import Link from "next/link";
import type { HeadToHeadEntry } from "@/lib/asset-queries";
export function HeadToHead({ data }: { data: HeadToHeadEntry[] }) {
if (data.length === 0) {
return <p className="text-muted text-sm">No opponents yet.</p>;
}
return (
<div className="flex flex-col gap-2">
<h3 className="text-sm font-semibold text-muted uppercase tracking-widest">
Head to Head
</h3>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
{data.map((h2h) => (
<Link
key={h2h.opponentId}
href={`/asset/${h2h.opponentId}`}
className="flex flex-col items-center gap-1.5 p-2 rounded-lg hover:bg-default transition-colors"
>
<img
src={`/img/${h2h.opponentId}`}
alt=""
className="h-16 rounded object-contain"
style={{ aspectRatio: h2h.opponentAspectRatio }}
/>
<div className="flex items-center gap-1.5 text-sm font-semibold tabular-nums">
<span className="text-success">{h2h.wins}W</span>
<span className="text-danger">{h2h.losses}L</span>
</div>
</Link>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,102 @@
"use client";
import { Button, Table } from "@heroui/react";
import Link from "next/link";
import useSWRInfinite from "swr/infinite";
import { TimeAgo } from "@/components/time-ago";
import type { AssetMatchEntry } from "@/lib/asset-queries";
import { formatElo } from "@/lib/format";
const fetcher = (url: string) => fetch(url).then((r) => r.json());
export function AssetMatchHistory({
assetId,
initialData,
}: {
assetId: string;
initialData: AssetMatchEntry[];
}) {
const { data, size, setSize, isValidating } = useSWRInfinite(
(index) => `/api/asset/${assetId}/matches?page=${index}`,
fetcher,
{ fallbackData: [initialData], revalidateFirstPage: false },
);
const allMatches: AssetMatchEntry[] = data ? data.flat() : [];
const hasMore = data ? data[data.length - 1]?.length === 20 : false;
const isLoadingMore =
isValidating && data && typeof data[size - 1] === "undefined";
return (
<div className="flex flex-col gap-4">
<h3 className="text-sm font-semibold text-muted uppercase tracking-widest">
Match History
</h3>
<Table>
<Table.ScrollContainer>
<Table.Content aria-label="Match history">
<Table.Header>
<Table.Column isRowHeader>Result</Table.Column>
<Table.Column className="text-center">Opponent</Table.Column>
<Table.Column>ELO</Table.Column>
<Table.Column>Delta</Table.Column>
<Table.Column>When</Table.Column>
</Table.Header>
<Table.Body>
{allMatches.map((match) => (
<Table.Row key={match.id}>
<Table.Cell>
<span
className={`text-sm font-bold ${match.won ? "text-success" : "text-danger"}`}
>
{match.won ? "W" : "L"}
</span>
</Table.Cell>
<Table.Cell className="flex justify-center">
<Link
href={`/asset/${match.opponentId}`}
className="inline-flex justify-center"
>
<img
src={`/img/${match.opponentId}`}
alt=""
className="h-8 rounded object-contain"
style={{ aspectRatio: match.opponentAspectRatio }}
/>
</Link>
</Table.Cell>
<Table.Cell className="tabular-nums text-xs">
{formatElo(match.assetEloBefore)} {" "}
{formatElo(match.assetEloAfter)}
</Table.Cell>
<Table.Cell>
<span
className={`tabular-nums text-xs font-semibold ${match.won ? "text-success" : "text-danger"}`}
>
{match.won ? "+" : ""}
{match.eloDelta.toFixed(1)}
</span>
</Table.Cell>
<Table.Cell>
<TimeAgo date={match.createdAt} />
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table.Content>
</Table.ScrollContainer>
</Table>
{hasMore && (
<div className="flex justify-center">
<Button
variant="secondary"
onPress={() => setSize(size + 1)}
isDisabled={isLoadingMore}
>
{isLoadingMore ? "Loading..." : "Load more"}
</Button>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,34 @@
import Link from "next/link";
import type { EloNeighbor } from "@/lib/asset-queries";
import { formatElo } from "@/lib/format";
export function EloNeighbors({ data }: { data: EloNeighbor[] }) {
if (data.length === 0) return null;
return (
<div className="flex flex-col gap-2">
<h3 className="text-sm font-semibold text-muted uppercase tracking-widest">
Similar ELO
</h3>
<div className="flex gap-3">
{data.map((n) => (
<Link
key={n.id}
href={`/asset/${n.id}`}
className="flex flex-col items-center gap-1 p-2 rounded-lg hover:bg-default transition-colors"
>
<img
src={`/img/${n.id}`}
alt=""
className="h-16 rounded object-contain"
style={{ aspectRatio: n.aspectRatio }}
/>
<span className="text-xs tabular-nums font-semibold">
{formatElo(n.eloProvisional)}
</span>
</Link>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,125 @@
import { WinTrendDisplay } from "@/components/asset/win-trend";
import {
formatElo,
formatExposureTime,
formatFNumber,
formatFocalLength,
formatIso,
} from "@/lib/format";
import type { WinTrend } from "@/lib/asset-queries";
interface StatsBlockProps {
eloProvisional: number;
winsProvisional: number;
matchesProvisional: number;
lensModel: string | null;
focalLength: number | null;
fNumber: number | null;
exposureTime: number | null;
iso: number | null;
winTrend: WinTrend;
layout: "vertical" | "horizontal";
}
function ExifLine({
lensModel,
focalLength,
fNumber,
exposureTime,
iso,
}: Pick<
StatsBlockProps,
"lensModel" | "focalLength" | "fNumber" | "exposureTime" | "iso"
>) {
const exifParts = [
focalLength != null && formatFocalLength(focalLength),
fNumber != null && formatFNumber(fNumber),
exposureTime != null && formatExposureTime(exposureTime),
iso != null && formatIso(iso),
].filter(Boolean);
return (
<div className="flex flex-col gap-1 text-sm font-mono text-muted">
{lensModel && <span>{lensModel}</span>}
{exifParts.length > 0 && <span>{exifParts.join(" \u2022 ")}</span>}
</div>
);
}
export function StatsBlock(props: StatsBlockProps) {
const losses = props.matchesProvisional - props.winsProvisional;
if (props.layout === "horizontal") {
return (
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex items-baseline gap-3">
<span className="text-5xl font-bold tabular-nums tracking-tight">
{formatElo(props.eloProvisional)}
</span>
<span className="text-sm text-muted uppercase tracking-widest">
ELO
</span>
</div>
<div className="flex items-center gap-1.5">
<span className="text-success text-lg font-semibold tabular-nums">
{props.winsProvisional}W
</span>
<span className="text-danger text-lg font-semibold tabular-nums">
{losses}L
</span>
<span className="text-muted text-sm ml-2">
{props.matchesProvisional} played
</span>
</div>
<ExifLine
lensModel={props.lensModel}
focalLength={props.focalLength}
fNumber={props.fNumber}
exposureTime={props.exposureTime}
iso={props.iso}
/>
<WinTrendDisplay data={props.winTrend} />
</div>
);
}
return (
<div className="flex flex-col gap-4">
<div className="flex items-baseline gap-3">
<span className="text-5xl font-bold tabular-nums tracking-tight">
{formatElo(props.eloProvisional)}
</span>
<span className="text-sm text-muted uppercase tracking-widest">
ELO
</span>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-1.5">
<span className="text-success text-lg font-semibold tabular-nums">
{props.winsProvisional}W
</span>
<span className="text-danger text-lg font-semibold tabular-nums">
{losses}L
</span>
</div>
<span className="text-muted text-sm">
{props.matchesProvisional} played
</span>
</div>
<ExifLine
lensModel={props.lensModel}
focalLength={props.focalLength}
fNumber={props.fNumber}
exposureTime={props.exposureTime}
iso={props.iso}
/>
<WinTrendDisplay data={props.winTrend} />
</div>
);
}

View File

@@ -0,0 +1,27 @@
import type { WinTrend } from "@/lib/asset-queries";
export function WinTrendDisplay({ data }: { data: WinTrend }) {
const items = [
{ label: "Last 10", value: data.last10 },
{ label: "Last 20", value: data.last20 },
{ label: "All time", value: data.allTime },
];
return (
<div className="flex flex-col gap-2">
<h3 className="text-sm font-semibold text-muted uppercase tracking-widest">
Win Rate Trend
</h3>
<div className="flex gap-6">
{items.map((item) => (
<div key={item.label} className="flex flex-col items-center">
<span className="text-2xl font-bold tabular-nums">
{(item.value * 100).toFixed(1)}%
</span>
<span className="text-xs text-muted">{item.label}</span>
</div>
))}
</div>
</div>
);
}

View File

@@ -9,6 +9,7 @@ import { thumbhashToUrl } from "@/lib/thumbhash-url";
import type { VoteResult } from "@/lib/vote";
import { AssetStats } from "./asset-stats";
const isDev = process.env.NODE_ENV === "development";
const GAP = 32; // px between images
const STATS_HEIGHT = 120; // height reserved for kbd + stats below images
const PADDING = 32; // vertical padding
@@ -109,7 +110,7 @@ export function VotingArena({
// Keyboard controls
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (isMutating || !bothLoaded) return;
if (isMutating || (!isDev && !bothLoaded)) return;
if (e.key === "ArrowLeft" || e.key === "a" || e.key === "A") {
trigger({ pairingUuid: pairing.uuid, winnerId: pairing.assetA.id });
} else if (e.key === "ArrowRight" || e.key === "d" || e.key === "D") {
@@ -128,7 +129,7 @@ export function VotingArena({
]);
function vote(winnerId: string) {
if (isMutating || !bothLoaded) return;
if (isMutating || (!isDev && !bothLoaded)) return;
trigger({ pairingUuid: pairing.uuid, winnerId });
}
@@ -139,7 +140,7 @@ export function VotingArena({
<button
type="button"
onClick={() => vote(pairing.assetA.id)}
disabled={isMutating || !bothLoaded}
disabled={isMutating || (!isDev && !bothLoaded)}
className="relative cursor-pointer transition-opacity hover:opacity-90 disabled:cursor-wait"
>
{placeholderA && !bothLoaded && (
@@ -167,7 +168,7 @@ export function VotingArena({
<button
type="button"
onClick={() => vote(pairing.assetB.id)}
disabled={isMutating || !bothLoaded}
disabled={isMutating || (!isDev && !bothLoaded)}
className="relative cursor-pointer transition-opacity hover:opacity-90 disabled:cursor-wait"
>
{placeholderB && !bothLoaded && (

226
src/lib/asset-queries.ts Normal file
View File

@@ -0,0 +1,226 @@
import { and, desc, eq, or, sql } from "drizzle-orm";
import { db } from "@/db";
import { assets, eloHistory, matches } from "@/db/schema";
import { calculateElo } from "./elo";
// ---------- Asset by ID ----------
export async function getAssetById(id: string) {
const [asset] = await db.select().from(assets).where(eq(assets.id, id));
return asset ?? null;
}
// ---------- Match History ----------
export interface AssetMatchEntry {
id: number;
opponentId: string;
opponentAspectRatio: number;
won: boolean;
assetEloBefore: number;
assetEloAfter: number;
opponentEloBefore: number;
opponentEloAfter: number;
eloDelta: number;
createdAt: string;
}
export async function getAssetMatchHistory(
assetId: string,
page = 0,
limit = 20,
): Promise<AssetMatchEntry[]> {
const rows = await db
.select()
.from(matches)
.where(or(eq(matches.winnerId, assetId), eq(matches.loserId, assetId)))
.orderBy(desc(matches.createdAt))
.limit(limit)
.offset(page * limit);
// Fetch opponent aspect ratios
const opponentIds = rows.map((r) =>
r.winnerId === assetId ? r.loserId : r.winnerId,
);
const opponents = await db
.select({ id: assets.id, aspectRatio: assets.aspectRatio })
.from(assets)
.where(
sql`${assets.id} IN (${sql.join(
opponentIds.map((id) => sql`${id}`),
sql`, `,
)})`,
);
const arMap = new Map(opponents.map((o) => [o.id, o.aspectRatio]));
return rows.map((r) => {
const won = r.winnerId === assetId;
const { newWinnerElo, newLoserElo } = calculateElo(
r.winnerEloBefore,
r.loserEloBefore,
);
return {
id: r.id,
opponentId: won ? r.loserId : r.winnerId,
opponentAspectRatio: arMap.get(won ? r.loserId : r.winnerId) ?? 1,
won,
assetEloBefore: won ? r.winnerEloBefore : r.loserEloBefore,
assetEloAfter: won ? newWinnerElo : newLoserElo,
opponentEloBefore: won ? r.loserEloBefore : r.winnerEloBefore,
opponentEloAfter: won ? newLoserElo : newWinnerElo,
eloDelta: won
? newWinnerElo - r.winnerEloBefore
: newLoserElo - r.loserEloBefore,
createdAt: r.createdAt,
};
});
}
// ---------- ELO History ----------
export interface EloHistoryEntry {
elo: number;
createdAt: string;
}
export async function getAssetEloHistory(
assetId: string,
): Promise<EloHistoryEntry[]> {
return db
.select({ elo: eloHistory.elo, createdAt: eloHistory.createdAt })
.from(eloHistory)
.where(
and(
eq(eloHistory.assetId, assetId),
eq(eloHistory.eloType, "provisional"),
),
)
.orderBy(eloHistory.createdAt);
}
// ---------- Head to Head ----------
export interface HeadToHeadEntry {
opponentId: string;
opponentAspectRatio: number;
wins: number;
losses: number;
total: number;
}
export async function getHeadToHead(
assetId: string,
): Promise<HeadToHeadEntry[]> {
// Get all matches involving this asset
const allMatches = await db
.select({
winnerId: matches.winnerId,
loserId: matches.loserId,
})
.from(matches)
.where(or(eq(matches.winnerId, assetId), eq(matches.loserId, assetId)));
// Tally per opponent
const tally = new Map<string, { wins: number; losses: number }>();
for (const m of allMatches) {
const won = m.winnerId === assetId;
const opponentId = won ? m.loserId : m.winnerId;
const entry = tally.get(opponentId) ?? { wins: 0, losses: 0 };
if (won) entry.wins++;
else entry.losses++;
tally.set(opponentId, entry);
}
// Fetch opponent aspect ratios
const opponentIds = [...tally.keys()];
if (opponentIds.length === 0) return [];
const opponents = await db
.select({ id: assets.id, aspectRatio: assets.aspectRatio })
.from(assets)
.where(
sql`${assets.id} IN (${sql.join(
opponentIds.map((id) => sql`${id}`),
sql`, `,
)})`,
);
const arMap = new Map(opponents.map((o) => [o.id, o.aspectRatio]));
return [...tally.entries()]
.map(([opponentId, { wins, losses }]) => ({
opponentId,
opponentAspectRatio: arMap.get(opponentId) ?? 1,
wins,
losses,
total: wins + losses,
}))
.sort((a, b) => b.total - a.total);
}
// ---------- ELO Neighbors ----------
export interface EloNeighbor {
id: string;
aspectRatio: number;
eloProvisional: number;
}
export async function getEloNeighbors(
assetId: string,
elo: number,
limit = 4,
): Promise<EloNeighbor[]> {
const rows = await db
.select({
id: assets.id,
aspectRatio: assets.aspectRatio,
eloProvisional: assets.eloProvisional,
})
.from(assets)
.where(sql`${assets.id} != ${assetId}`)
.orderBy(sql`ABS(${assets.eloProvisional} - ${elo})`)
.limit(limit);
return rows;
}
// ---------- Win Trend ----------
export interface WinTrend {
last10: number;
last20: number;
allTime: number;
}
export async function getWinTrend(assetId: string): Promise<WinTrend> {
const recentMatches = await db
.select({ winnerId: matches.winnerId })
.from(matches)
.where(or(eq(matches.winnerId, assetId), eq(matches.loserId, assetId)))
.orderBy(desc(matches.createdAt))
.limit(20);
const last10 = recentMatches.slice(0, 10);
const last20 = recentMatches;
const winRate = (list: typeof recentMatches) => {
if (list.length === 0) return 0;
return list.filter((m) => m.winnerId === assetId).length / list.length;
};
// All time from asset stats (faster than counting all matches)
const [asset] = await db
.select({
matches: assets.matchesProvisional,
wins: assets.winsProvisional,
})
.from(assets)
.where(eq(assets.id, assetId));
return {
last10: winRate(last10),
last20: winRate(last20),
allTime: asset.matches > 0 ? asset.wins / asset.matches : 0,
};
}

View File

@@ -18,6 +18,10 @@ export function checkRateLimit(ip: string): {
allowed: boolean;
retryAfterMs: number;
} {
if (process.env.NODE_ENV === "development") {
return { allowed: true, retryAfterMs: 0 };
}
const now = Date.now();
const entry = store.get(ip);