infra
This commit is contained in:
11
.dockerignore
Normal file
11
.dockerignore
Normal file
@@ -0,0 +1,11 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
.vscode
|
||||
data
|
||||
*.log
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
.heroui-docs
|
||||
.claude
|
||||
35
Dockerfile
Normal file
35
Dockerfile
Normal 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"
|
||||
5
bun.lock
5
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=="],
|
||||
|
||||
9
docker-compose.yml
Normal file
9
docker-compose.yml
Normal file
@@ -0,0 +1,9 @@
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
ports:
|
||||
- "${HOST_PORT:-3000}:3000"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
env_file: .env
|
||||
restart: unless-stopped
|
||||
64
drizzle/0000_chunky_shiver_man.sql
Normal file
64
drizzle/0000_chunky_shiver_man.sql
Normal 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
|
||||
);
|
||||
443
drizzle/meta/0000_snapshot.json
Normal file
443
drizzle/meta/0000_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
13
drizzle/meta/_journal.json
Normal file
13
drizzle/meta/_journal.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1777764516703,
|
||||
"tag": "0000_chunky_shiver_man",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
15
scripts/migrate.ts
Normal 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
91
src/app/about/page.tsx
Normal 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
23
src/app/error.tsx
Normal 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
9
src/app/loading.tsx
Normal 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
13
src/app/not-found.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=""
|
||||
|
||||
Reference in New Issue
Block a user