diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..807fb59 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +node_modules +.next +.git +.vscode +data +*.log +.env +.env.local +.env.*.local +.heroui-docs +.claude diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0aae781 --- /dev/null +++ b/Dockerfile @@ -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" diff --git a/bun.lock b/bun.lock index b7d96b8..c8b9086 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..57bf7ca --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,9 @@ +services: + app: + build: . + ports: + - "${HOST_PORT:-3000}:3000" + volumes: + - ./data:/app/data + env_file: .env + restart: unless-stopped diff --git a/drizzle/0000_chunky_shiver_man.sql b/drizzle/0000_chunky_shiver_man.sql new file mode 100644 index 0000000..b0ab54d --- /dev/null +++ b/drizzle/0000_chunky_shiver_man.sql @@ -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 +); diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..fca4509 --- /dev/null +++ b/drizzle/meta/0000_snapshot.json @@ -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": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json new file mode 100644 index 0000000..6857646 --- /dev/null +++ b/drizzle/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1777764516703, + "tag": "0000_chunky_shiver_man", + "breakpoints": true + } + ] +} diff --git a/package.json b/package.json index 60e9fb9..9a12752 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/migrate.ts b/scripts/migrate.ts new file mode 100644 index 0000000..cbebc52 --- /dev/null +++ b/scripts/migrate.ts @@ -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."); diff --git a/src/app/about/page.tsx b/src/app/about/page.tsx new file mode 100644 index 0000000..36917ea --- /dev/null +++ b/src/app/about/page.tsx @@ -0,0 +1,91 @@ +import Link from "next/link"; + +export default function AboutPage() { + return ( +
+

About

+ +

+ Rate My Shots 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. +

+ +

+ 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{" "} + + bate-estacas.xyz + + . +

+ +

+ Click a photo to vote for it, or use A /{" "} + for the left photo and{" "} + D / 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. +

+ +

+ Every vote counts immediately in the{" "} + provisional ranking, which is what you see on the stats + page. After I review them for abuse, votes also count in the{" "} + verified ranking, which is what feeds my personal site. +

+ +

+ All photos are pulled from the portfolio album of my{" "} + + Immich + {" "} + instance. They're proxied through this server so the Immich API key + stays private. +

+ +

+ 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. +

+ +

+ 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{" "} + + gitea.bate-estacas.xyz/luis/rate-my-shots + + . +

+ +

+ Made by{" "} + + luisdralves + + . +

+
+ ); +} diff --git a/src/app/api/asset/[id]/matches/route.ts b/src/app/api/assets/[id]/matches/route.ts similarity index 100% rename from src/app/api/asset/[id]/matches/route.ts rename to src/app/api/assets/[id]/matches/route.ts diff --git a/src/app/asset/[id]/page.tsx b/src/app/assets/[id]/page.tsx similarity index 100% rename from src/app/asset/[id]/page.tsx rename to src/app/assets/[id]/page.tsx diff --git a/src/app/error.tsx b/src/app/error.tsx new file mode 100644 index 0000000..1276d82 --- /dev/null +++ b/src/app/error.tsx @@ -0,0 +1,23 @@ +"use client"; + +import { Button } from "@heroui/react"; + +export default function ErrorPage({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + return ( +
+

Something went wrong

+

+ {error.message || "An unexpected error occurred."} +

+ +
+ ); +} diff --git a/src/app/loading.tsx b/src/app/loading.tsx new file mode 100644 index 0000000..3e4b413 --- /dev/null +++ b/src/app/loading.tsx @@ -0,0 +1,9 @@ +import { Spinner } from "@heroui/react"; + +export default function Loading() { + return ( +
+ +
+ ); +} diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx new file mode 100644 index 0000000..70fe736 --- /dev/null +++ b/src/app/not-found.tsx @@ -0,0 +1,13 @@ +import Link from "next/link"; + +export default function NotFound() { + return ( +
+

404

+

This page doesn't exist.

+ + Back to voting + +
+ ); +} diff --git a/src/components/admin/pending-matches.tsx b/src/components/admin/pending-matches.tsx index ff0a582..a54f7a9 100644 --- a/src/components/admin/pending-matches.tsx +++ b/src/components/admin/pending-matches.tsx @@ -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() { + {asset.lensModel} - + )} ); diff --git a/src/components/asset/elo-chart.tsx b/src/components/asset/elo-chart.tsx index 474583d..d95fa7b 100644 --- a/src/components/asset/elo-chart.tsx +++ b/src/components/asset/elo-chart.tsx @@ -57,7 +57,13 @@ export function EloChart({
{width > 0 && ( - + + ELO over time {/* Grid lines */} {[0, 0.25, 0.5, 0.75, 1].map((pct) => { const y = PAD_Y + chartH - pct * chartH; diff --git a/src/components/asset/head-to-head.tsx b/src/components/asset/head-to-head.tsx index 0a7257c..07c339b 100644 --- a/src/components/asset/head-to-head.tsx +++ b/src/components/asset/head-to-head.tsx @@ -15,7 +15,7 @@ export function HeadToHead({ data }: { data: HeadToHeadEntry[] }) { {data.map((h2h) => ( `/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({ ( - {lensModel && {lensModel}} + {lensModel && ( + + {lensModel} + + )} {exifParts.length > 0 && {exifParts.join(" \u2022 ")}}
); diff --git a/src/components/gallery-photo-tile.tsx b/src/components/gallery-photo-tile.tsx index 1ddd5f1..475c6e4 100644 --- a/src/components/gallery-photo-tile.tsx +++ b/src/components/gallery-photo-tile.tsx @@ -18,7 +18,7 @@ export function GalleryPhotoTile({ photo }: { photo: ExportPhoto }) { return ( (
diff --git a/src/components/stats/leaderboard-table.tsx b/src/components/stats/leaderboard-table.tsx index 685d958..293b4a8 100644 --- a/src/components/stats/leaderboard-table.tsx +++ b/src/components/stats/leaderboard-table.tsx @@ -39,7 +39,7 @@ function LeaderboardRows({ entries }: { entries: LeaderboardEntry[] }) { {i + 1} - + - {entry.lensModel ?? "—"} + {entry.lensModel ? ( + + {entry.lensModel} + + ) : ( + "—" + )} {formatFocalLength(entry.focalLength)} diff --git a/src/components/stats/lens-table.tsx b/src/components/stats/lens-table.tsx index 8fbf378..4913326 100644 --- a/src/components/stats/lens-table.tsx +++ b/src/components/stats/lens-table.tsx @@ -49,7 +49,7 @@ export function LensTable({ data }: { data: LensStats[] }) { {/* Thumbnails at the edges */} - + - +