This commit is contained in:
2026-05-03 17:07:05 +01:00
parent 3b658b1e86
commit 9a3b12e1c4
31 changed files with 782 additions and 22 deletions

11
.dockerignore Normal file
View File

@@ -0,0 +1,11 @@
node_modules
.next
.git
.vscode
data
*.log
.env
.env.local
.env.*.local
.heroui-docs
.claude

35
Dockerfile Normal file
View File

@@ -0,0 +1,35 @@
FROM oven/bun:1-alpine AS deps
WORKDIR /app
RUN apk add --no-cache python3 make g++
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile
FROM oven/bun:1-alpine AS builder
WORKDIR /app
RUN apk add --no-cache python3 make g++
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN bun run build
FROM oven/bun:1-alpine AS runner
WORKDIR /app
RUN apk add --no-cache nodejs
ENV NODE_ENV=production
ENV HOSTNAME=0.0.0.0
ENV PORT=3000
ENV DATABASE_PATH=/app/data/rate-my-shots.db
# Standalone server bundle (Node-compatible)
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
# Migration assets (run via bun)
COPY --from=builder /app/drizzle ./drizzle
COPY --from=builder /app/scripts ./scripts
COPY --from=builder /app/node_modules/drizzle-orm ./node_modules/drizzle-orm
EXPOSE 3000
CMD sh -c "bun run scripts/migrate.ts && node server.js"

View File

@@ -20,6 +20,7 @@
"@biomejs/biome": "2.2.0",
"@tailwindcss/postcss": "^4",
"@types/better-sqlite3": "^7.6.13",
"@types/bun": "^1.3.13",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
@@ -487,6 +488,8 @@
"@types/better-sqlite3": ["@types/better-sqlite3@7.6.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA=="],
"@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="],
"@types/node": ["@types/node@20.19.39", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw=="],
"@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
@@ -509,6 +512,8 @@
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
"bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="],
"caniuse-lite": ["caniuse-lite@1.0.30001784", "", {}, "sha512-WU346nBTklUV9YfUl60fqRbU5ZqyXlqvo1SgigE1OAXK5bFL8LL9q1K7aap3N739l4BvNqnkm3YrGHiY9sfUQw=="],
"chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="],

9
docker-compose.yml Normal file
View File

@@ -0,0 +1,9 @@
services:
app:
build: .
ports:
- "${HOST_PORT:-3000}:3000"
volumes:
- ./data:/app/data
env_file: .env
restart: unless-stopped

View File

@@ -0,0 +1,64 @@
CREATE TABLE `assets` (
`id` text PRIMARY KEY NOT NULL,
`elo_provisional` real DEFAULT 1500 NOT NULL,
`elo_verified` real DEFAULT 1500 NOT NULL,
`matches_provisional` integer DEFAULT 0 NOT NULL,
`matches_verified` integer DEFAULT 0 NOT NULL,
`wins_provisional` integer DEFAULT 0 NOT NULL,
`wins_verified` integer DEFAULT 0 NOT NULL,
`thumbhash` text,
`lens_model` text,
`focal_length` real,
`f_number` real,
`exposure_time` real,
`iso` integer,
`width` integer NOT NULL,
`height` integer NOT NULL,
`aspect_ratio` real NOT NULL,
`original_file_name` text,
`taken_at` text,
`active` integer DEFAULT true NOT NULL,
`created_at` text NOT NULL
);
--> statement-breakpoint
CREATE TABLE `elo_history` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`asset_id` text NOT NULL,
`elo` real NOT NULL,
`elo_type` text NOT NULL,
`match_id` integer NOT NULL,
`created_at` text NOT NULL,
FOREIGN KEY (`asset_id`) REFERENCES `assets`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`match_id`) REFERENCES `matches`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `lens_portraits` (
`lens_model` text PRIMARY KEY NOT NULL,
`asset_id` text NOT NULL
);
--> statement-breakpoint
CREATE TABLE `matches` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`pairing_uuid` text NOT NULL,
`winner_id` text NOT NULL,
`loser_id` text NOT NULL,
`winner_elo_before` real NOT NULL,
`loser_elo_before` real NOT NULL,
`verified` integer DEFAULT false NOT NULL,
`created_at` text NOT NULL,
FOREIGN KEY (`pairing_uuid`) REFERENCES `pairings`(`uuid`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`winner_id`) REFERENCES `assets`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`loser_id`) REFERENCES `assets`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `pairings` (
`uuid` text PRIMARY KEY NOT NULL,
`asset_a_id` text NOT NULL,
`asset_b_id` text NOT NULL,
`voter_ip` text NOT NULL,
`created_at` text NOT NULL,
`expires_at` text NOT NULL,
`voted_at` text,
FOREIGN KEY (`asset_a_id`) REFERENCES `assets`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`asset_b_id`) REFERENCES `assets`(`id`) ON UPDATE no action ON DELETE no action
);

View File

@@ -0,0 +1,443 @@
{
"version": "6",
"dialect": "sqlite",
"id": "aa65299e-c9ba-4d23-9189-438a771f863e",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"assets": {
"name": "assets",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"elo_provisional": {
"name": "elo_provisional",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1500
},
"elo_verified": {
"name": "elo_verified",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1500
},
"matches_provisional": {
"name": "matches_provisional",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"matches_verified": {
"name": "matches_verified",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"wins_provisional": {
"name": "wins_provisional",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"wins_verified": {
"name": "wins_verified",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"thumbhash": {
"name": "thumbhash",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"lens_model": {
"name": "lens_model",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"focal_length": {
"name": "focal_length",
"type": "real",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"f_number": {
"name": "f_number",
"type": "real",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"exposure_time": {
"name": "exposure_time",
"type": "real",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"iso": {
"name": "iso",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"width": {
"name": "width",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"height": {
"name": "height",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"aspect_ratio": {
"name": "aspect_ratio",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"original_file_name": {
"name": "original_file_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"taken_at": {
"name": "taken_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"active": {
"name": "active",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"elo_history": {
"name": "elo_history",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"asset_id": {
"name": "asset_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"elo": {
"name": "elo",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"elo_type": {
"name": "elo_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"match_id": {
"name": "match_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"elo_history_asset_id_assets_id_fk": {
"name": "elo_history_asset_id_assets_id_fk",
"tableFrom": "elo_history",
"tableTo": "assets",
"columnsFrom": ["asset_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
},
"elo_history_match_id_matches_id_fk": {
"name": "elo_history_match_id_matches_id_fk",
"tableFrom": "elo_history",
"tableTo": "matches",
"columnsFrom": ["match_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"lens_portraits": {
"name": "lens_portraits",
"columns": {
"lens_model": {
"name": "lens_model",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"asset_id": {
"name": "asset_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"matches": {
"name": "matches",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"pairing_uuid": {
"name": "pairing_uuid",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"winner_id": {
"name": "winner_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"loser_id": {
"name": "loser_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"winner_elo_before": {
"name": "winner_elo_before",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"loser_elo_before": {
"name": "loser_elo_before",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"verified": {
"name": "verified",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"matches_pairing_uuid_pairings_uuid_fk": {
"name": "matches_pairing_uuid_pairings_uuid_fk",
"tableFrom": "matches",
"tableTo": "pairings",
"columnsFrom": ["pairing_uuid"],
"columnsTo": ["uuid"],
"onDelete": "no action",
"onUpdate": "no action"
},
"matches_winner_id_assets_id_fk": {
"name": "matches_winner_id_assets_id_fk",
"tableFrom": "matches",
"tableTo": "assets",
"columnsFrom": ["winner_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
},
"matches_loser_id_assets_id_fk": {
"name": "matches_loser_id_assets_id_fk",
"tableFrom": "matches",
"tableTo": "assets",
"columnsFrom": ["loser_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"pairings": {
"name": "pairings",
"columns": {
"uuid": {
"name": "uuid",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"asset_a_id": {
"name": "asset_a_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"asset_b_id": {
"name": "asset_b_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"voter_ip": {
"name": "voter_ip",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"voted_at": {
"name": "voted_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"pairings_asset_a_id_assets_id_fk": {
"name": "pairings_asset_a_id_assets_id_fk",
"tableFrom": "pairings",
"tableTo": "assets",
"columnsFrom": ["asset_a_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
},
"pairings_asset_b_id_assets_id_fk": {
"name": "pairings_asset_b_id_assets_id_fk",
"tableFrom": "pairings",
"tableTo": "assets",
"columnsFrom": ["asset_b_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1777764516703,
"tag": "0000_chunky_shiver_man",
"breakpoints": true
}
]
}

View File

@@ -25,6 +25,7 @@
"@biomejs/biome": "2.2.0",
"@tailwindcss/postcss": "^4",
"@types/better-sqlite3": "^7.6.13",
"@types/bun": "^1.3.13",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",

15
scripts/migrate.ts Normal file
View File

@@ -0,0 +1,15 @@
import { Database } from "bun:sqlite";
import { drizzle } from "drizzle-orm/bun-sqlite";
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
const sqlite = new Database(
process.env.DATABASE_PATH ?? "data/rate-my-shots.db",
);
sqlite.exec("PRAGMA journal_mode = WAL");
sqlite.exec("PRAGMA foreign_keys = ON");
const db = drizzle(sqlite);
migrate(db, { migrationsFolder: "drizzle" });
sqlite.close();
console.log("Migrations applied.");

91
src/app/about/page.tsx Normal file
View File

@@ -0,0 +1,91 @@
import Link from "next/link";
export default function AboutPage() {
return (
<div className="flex flex-col flex-1 max-w-4xl mx-auto w-full px-4 py-12 gap-5 text-sm leading-relaxed">
<h1 className="text-3xl font-bold mb-4">About</h1>
<p>
<strong>Rate My Shots</strong> is a public ELO tournament for my
photography. Two random photos are shown side by side, and you pick the
one you prefer. Over time, the photos accumulate ratings based on how
often they win matchups.
</p>
<p>
The goal is to replace the static star ratings on my personal site with
crowd-sourced rankings. The top-rated photos here will eventually decide
the order in which photos appear on{" "}
<a
href="https://bate-estacas.xyz"
target="_blank"
rel="noopener noreferrer"
className="link"
>
bate-estacas.xyz
</a>
.
</p>
<p>
Click a photo to vote for it, or use <kbd className="kbd">A</kbd> /{" "}
<kbd className="kbd"></kbd> for the left photo and{" "}
<kbd className="kbd">D</kbd> / <kbd className="kbd"></kbd> for the
right. The next pair loads immediately. Both photos are sized to have
the same effective area on screen, regardless of aspect ratio, so
neither side gets an unfair visual advantage. You only get one vote per
pair.
</p>
<p>
Every vote counts immediately in the{" "}
<strong>provisional ranking</strong>, which is what you see on the stats
page. After I review them for abuse, votes also count in the{" "}
<strong>verified ranking</strong>, which is what feeds my personal site.
</p>
<p>
All photos are pulled from the portfolio album of my{" "}
<a
href="https://immich.app"
target="_blank"
rel="noopener noreferrer"
className="link"
>
Immich
</a>{" "}
instance. They're proxied through this server so the Immich API key
stays private.
</p>
<p>
Your IP address is stored alongside each vote so I can detect bad
actors. That's the only personal data collected. No analytics, no
third-party tracking, no cookies.
</p>
<p>
Built with Next.js, React, TypeScript, Tailwind CSS, HeroUI, SQLite,
Drizzle ORM, and Bun. Self-hosted in a small Docker container. No
third-party services. Code is open at{" "}
<a
href="https://gitea.bate-estacas.xyz/luis/rate-my-shots"
target="_blank"
rel="noopener noreferrer"
className="link font-mono text-xs"
>
gitea.bate-estacas.xyz/luis/rate-my-shots
</a>
.
</p>
<p>
Made by{" "}
<Link href="https://bate-estacas.xyz" className="link">
luisdralves
</Link>
.
</p>
</div>
);
}

23
src/app/error.tsx Normal file
View File

@@ -0,0 +1,23 @@
"use client";
import { Button } from "@heroui/react";
export default function ErrorPage({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-4 px-4 text-center">
<h1 className="text-2xl font-bold">Something went wrong</h1>
<p className="text-sm text-muted max-w-md">
{error.message || "An unexpected error occurred."}
</p>
<Button variant="primary" onPress={reset}>
Try again
</Button>
</div>
);
}

9
src/app/loading.tsx Normal file
View File

@@ -0,0 +1,9 @@
import { Spinner } from "@heroui/react";
export default function Loading() {
return (
<div className="flex flex-1 items-center justify-center">
<Spinner />
</div>
);
}

13
src/app/not-found.tsx Normal file
View File

@@ -0,0 +1,13 @@
import Link from "next/link";
export default function NotFound() {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-4 px-4 text-center">
<h1 className="text-3xl font-bold">404</h1>
<p className="text-muted">This page doesn't exist.</p>
<Link href="/" className="link text-sm">
Back to voting
</Link>
</div>
);
}

View File

@@ -45,7 +45,7 @@ function PendingMatchesInner() {
const total = data?.count ?? 0;
const isFirstPage = page === 0;
// Reset selection on page change
// biome-ignore lint/correctness/useExhaustiveDependencies: page is the trigger
useEffect(() => {
setSelectedKeys(new Set());
}, [page]);
@@ -209,7 +209,7 @@ function PendingMatchesInner() {
</Table.Cell>
<Table.Cell className="text-center">
<Link
href={`/asset/${m.leftId}`}
href={`/assets/${m.leftId}`}
className="inline-flex justify-center"
>
<img
@@ -235,7 +235,7 @@ function PendingMatchesInner() {
</Table.Cell>
<Table.Cell className="text-center">
<Link
href={`/asset/${m.rightId}`}
href={`/assets/${m.rightId}`}
className="inline-flex justify-center"
>
<img

View File

@@ -43,7 +43,7 @@ export function AssetPhotoTile({
return (
<Link
href={`/asset/${id}`}
href={`/assets/${id}`}
className="group relative block overflow-hidden rounded-lg shadow-sm transition-shadow duration-200 hover:shadow-2xl hover:shadow-black/30"
>
<img

View File

@@ -1,3 +1,4 @@
import Link from "next/link";
import {
formatElo,
formatExposureTime,
@@ -44,9 +45,12 @@ export function AssetStats({ asset }: { asset: PairingAsset }) {
{/* Lens */}
{asset.lensModel && (
<span className="text-xs text-muted/60 truncate max-w-full">
<Link
href={`/lenses/${encodeURIComponent(asset.lensModel)}`}
className="text-xs text-muted/60 truncate max-w-full hover:text-muted hover:underline"
>
{asset.lensModel}
</span>
</Link>
)}
</div>
);

View File

@@ -57,7 +57,13 @@ export function EloChart({
</h3>
<div ref={containerRef} className="w-full">
{width > 0 && (
<svg width={width} height={HEIGHT}>
<svg
width={width}
height={HEIGHT}
role="img"
aria-label="ELO over time"
>
<title>ELO over time</title>
{/* Grid lines */}
{[0, 0.25, 0.5, 0.75, 1].map((pct) => {
const y = PAD_Y + chartH - pct * chartH;

View File

@@ -15,7 +15,7 @@ export function HeadToHead({ data }: { data: HeadToHeadEntry[] }) {
{data.map((h2h) => (
<Link
key={h2h.opponentId}
href={`/asset/${h2h.opponentId}`}
href={`/assets/${h2h.opponentId}`}
className="flex flex-col items-center gap-1.5 p-2 rounded-lg hover:bg-default transition-colors"
>
<img

View File

@@ -17,7 +17,7 @@ export function AssetMatchHistory({
initialData: AssetMatchEntry[];
}) {
const { data, size, setSize, isValidating } = useSWRInfinite(
(index) => `/api/asset/${assetId}/matches?page=${index}`,
(index) => `/api/assets/${assetId}/matches?page=${index}`,
fetcher,
{ fallbackData: [initialData], revalidateFirstPage: false },
);
@@ -54,7 +54,7 @@ export function AssetMatchHistory({
</Table.Cell>
<Table.Cell className="flex justify-center">
<Link
href={`/asset/${match.opponentId}`}
href={`/assets/${match.opponentId}`}
className="inline-flex justify-center"
>
<img

View File

@@ -14,7 +14,7 @@ export function EloNeighbors({ data }: { data: EloNeighbor[] }) {
{data.map((n) => (
<Link
key={n.id}
href={`/asset/${n.id}`}
href={`/assets/${n.id}`}
className="flex flex-col items-center gap-1 p-2 rounded-lg hover:bg-default transition-colors"
>
<img

View File

@@ -1,3 +1,4 @@
import Link from "next/link";
import { WinTrendDisplay } from "@/components/asset/win-trend";
import type { WinTrend } from "@/lib/asset-queries";
import {
@@ -40,7 +41,14 @@ function ExifLine({
return (
<div className="flex flex-col gap-1 text-sm font-mono text-muted">
{lensModel && <span>{lensModel}</span>}
{lensModel && (
<Link
href={`/lenses/${encodeURIComponent(lensModel)}`}
className="hover:underline w-fit"
>
{lensModel}
</Link>
)}
{exifParts.length > 0 && <span>{exifParts.join(" \u2022 ")}</span>}
</div>
);

View File

@@ -18,7 +18,7 @@ export function GalleryPhotoTile({ photo }: { photo: ExportPhoto }) {
return (
<Link
href={`/asset/${photo.id}`}
href={`/assets/${photo.id}`}
className="group relative block overflow-hidden rounded-lg shadow-sm transition-shadow duration-200 hover:shadow-2xl hover:shadow-black/30"
>
<img

View File

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

View File

@@ -16,7 +16,7 @@ export function ControversialList({ data }: { data: ControversialEntry[] }) {
{data.map((entry) => (
<Link
key={entry.id}
href={`/asset/${entry.id}`}
href={`/assets/${entry.id}`}
className="flex flex-col items-center gap-1.5 p-2 rounded-lg hover:bg-default transition-colors"
>
<div className="flex items-center justify-center w-full h-24">

View File

@@ -39,7 +39,7 @@ function LeaderboardRows({ entries }: { entries: LeaderboardEntry[] }) {
<Table.Row key={entry.id}>
<Table.Cell className="tabular-nums">{i + 1}</Table.Cell>
<Table.Cell className="flex justify-center">
<Link href={`/asset/${entry.id}`}>
<Link href={`/assets/${entry.id}`}>
<img
src={entry.thumbnailUrl}
alt=""
@@ -64,7 +64,16 @@ function LeaderboardRows({ entries }: { entries: LeaderboardEntry[] }) {
{(entry.winRate * 100).toFixed(1)}%
</Table.Cell>
<Table.Cell className="text-xs text-muted">
{entry.lensModel ?? "—"}
{entry.lensModel ? (
<Link
href={`/lenses/${encodeURIComponent(entry.lensModel)}`}
className="hover:underline"
>
{entry.lensModel}
</Link>
) : (
"—"
)}
</Table.Cell>
<Table.Cell className="tabular-nums text-xs">
{formatFocalLength(entry.focalLength)}

View File

@@ -49,7 +49,7 @@ export function LensTable({ data }: { data: LensStats[] }) {
</Table.Cell>
<Table.Cell className="flex justify-center">
<Link
href={`/asset/${lens.bestAssetId}`}
href={`/assets/${lens.bestAssetId}`}
className="inline-flex justify-center"
>
<img

View File

@@ -17,7 +17,7 @@ function MatchRows({ matches }: { matches: MatchEntry[] }) {
<Table.Row key={match.id}>
<Table.Cell className="text-center">
<Link
href={`/asset/${match.leftId}`}
href={`/assets/${match.leftId}`}
className="inline-flex justify-center"
>
<img
@@ -43,7 +43,7 @@ function MatchRows({ matches }: { matches: MatchEntry[] }) {
</Table.Cell>
<Table.Cell className="text-center">
<Link
href={`/asset/${match.rightId}`}
href={`/assets/${match.rightId}`}
className="inline-flex justify-center"
>
<img

View File

@@ -47,7 +47,7 @@ export function Podium({ data }: { data: LeaderboardEntry[] }) {
className={`flex flex-col items-center gap-2 flex-1 max-w-xs ${place.order}`}
>
<Link
href={`/asset/${entry.id}`}
href={`/assets/${entry.id}`}
className="flex flex-col items-center gap-2 hover:opacity-90 transition-opacity"
>
<img

View File

@@ -19,7 +19,7 @@ export function UpsetsList({ data }: { data: UpsetEntry[] }) {
className="relative flex items-center justify-between p-3 rounded-lg bg-surface"
>
{/* Thumbnails at the edges */}
<Link href={`/asset/${upset.winnerId}`} className="inline-flex">
<Link href={`/assets/${upset.winnerId}`} className="inline-flex">
<img
src={`/img/${upset.winnerId}`}
alt=""
@@ -27,7 +27,7 @@ export function UpsetsList({ data }: { data: UpsetEntry[] }) {
style={{ aspectRatio: upset.winnerAspectRatio }}
/>
</Link>
<Link href={`/asset/${upset.loserId}`} className="inline-flex">
<Link href={`/assets/${upset.loserId}`} className="inline-flex">
<img
src={`/img/${upset.loserId}`}
alt=""