Compare commits

...

10 Commits

Author SHA1 Message Date
ac52daa454 add: go back button in rankings
All checks were successful
Deploy on push / deploy (push) Has been skipped
2026-03-23 03:05:44 -07:00
dc1dce1120 add: docker compose code
All checks were successful
Deploy on push / deploy (push) Has been skipped
2026-03-23 02:50:30 -07:00
b1403da70d add: dockerfile and docker ignore rules
All checks were successful
Deploy on push / deploy (push) Has been skipped
2026-03-23 02:46:53 -07:00
a72e4d21d5 change: removed .html in url
All checks were successful
Deploy on push / deploy (push) Has been skipped
2026-03-23 02:40:49 -07:00
9f60ab3cca add: persistant storage of elo ranking
All checks were successful
Deploy on push / deploy (push) Has been skipped
2026-03-22 21:18:49 -07:00
614a3d1eff refactor: clean up gallery tooling and document the workflow
All checks were successful
Deploy on push / deploy (push) Has been skipped
2026-03-22 20:33:29 -07:00
b3a8368bab add: pairwise elo voting workflow 2026-03-22 20:25:41 -07:00
26adbe617f add: elo data model and static rankings page
All checks were successful
Deploy on push / deploy (push) Has been skipped
2026-03-22 20:18:28 -07:00
8f9a7eda2f add: meal ingestion CLI for images and metadata
All checks were successful
Deploy on push / deploy (push) Has been skipped
2026-03-22 20:09:23 -07:00
21c3a0c4b2 add: harden thumbnail generation and validate image assets
All checks were successful
Deploy on push / deploy (push) Has been skipped
2026-03-22 20:04:26 -07:00
22 changed files with 4465 additions and 37 deletions

4
.dockerignore Normal file
View File

@@ -0,0 +1,4 @@
.DS_Store
.git
.runtime
node_modules

1
.gitignore vendored
View File

@@ -1,2 +1,3 @@
.DS_Store .DS_Store
node_modules/ node_modules/
.runtime/

15
Dockerfile Normal file
View File

@@ -0,0 +1,15 @@
FROM node:22-bookworm-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
ENV HOST=0.0.0.0
ENV PORT=80
EXPOSE 80
CMD ["npm", "start"]

133
README.md
View File

@@ -2,23 +2,68 @@
Static photo gallery for logging meals and food memories. Static photo gallery for logging meals and food memories.
The site is based on the HTML5 UP Lens template and currently ships as a plain static site: HTML, CSS, JavaScript, and local image assets. The site is based on the HTML5 UP Lens template. The front-end remains a mostly static HTML/CSS/JavaScript site, and the local Node server now also exposes a tiny rankings sync API for shared Elo persistence.
## Repo Layout ## Repo Layout
- `templates/index.html`: source template for the main gallery page - `templates/index.html`: source template for the main gallery page
- `templates/rankings.html`: source template for the rankings page
- `index.html`: generated static gallery page - `index.html`: generated static gallery page
- `rankings.html`: generated static rankings page
- `assets/`: site CSS, JavaScript, fonts, and audio - `assets/`: site CSS, JavaScript, fonts, and audio
- `images/fulls/`: full-size gallery images - `images/fulls/`: full-size gallery images
- `images/thumbs/`: gallery thumbnails - `images/thumbs/`: gallery thumbnails
- `data/meals.json`: source of truth for gallery entries - `data/meals.json`: source of truth for gallery entries
- `data/elo.json`: Elo ratings, record totals, and ranking settings
- `scripts/build.js`: renders static pages from templates and data - `scripts/build.js`: renders static pages from templates and data
- `scripts/check.js`: validates data, image assets, and generated pages
- `scripts/generate-thumbnails.js`: regenerates thumbnails from the full-size images - `scripts/generate-thumbnails.js`: regenerates thumbnails from the full-size images
- `scripts/ingest-meal.js`: ingests a new meal image and metadata in one command
- `scripts/serve.js`: serves the generated site and the rankings sync API
- `scripts/lib/elo.js`: validates and syncs Elo data against the meal list
- `scripts/lib/rankings-state.js`: normalizes and persists the shared rankings state
- `package.json`: minimal Node build entrypoint - `package.json`: minimal Node build entrypoint
## Run Locally
Install dependencies:
```sh
npm install
```
Build the site and validate the generated output:
```sh
npm run build
```
Serve it locally:
```sh
npm run serve
```
Then open `http://127.0.0.1:4321`.
By default, rankings sync state is written to `.runtime/rankings-state.json`.
Override that path with `RANKINGS_STATE_PATH=/absolute/path/to/rankings-state.json`.
If you want a single command that builds and serves, run:
```sh
npm start
```
To validate the repo state without rebuilding thumbnails or pages, run:
```sh
npm run check
```
## Content Workflow ## Content Workflow
Gallery entries live in `data/meals.json`, and `index.html` is generated from `templates/index.html`. Gallery entries live in `data/meals.json`, and the build generates both `index.html` and `rankings.html` from the template and data files.
After editing content or templates, rebuild the site with: After editing content or templates, rebuild the site with:
@@ -26,7 +71,18 @@ After editing content or templates, rebuild the site with:
npm run build npm run build
``` ```
The build currently renders the main page without changing the existing Lens gallery structure, so the current client-side viewer code continues to work. The gallery build keeps the existing Lens thumbnail markup intact, so the current client-side viewer code continues to work.
To ingest a new meal image and update the site in one command, run:
```sh
npm run ingest -- --image /path/to/photo.jpg --title "meal title" --description "notes"
```
Optional ingestion flags:
- `--position "left center"` sets the viewer image alignment
- `--focus-x 0.35 --focus-y 0.45` sets the thumbnail crop focal point
If you only need to regenerate thumbnails, run: If you only need to regenerate thumbnails, run:
@@ -34,6 +90,70 @@ If you only need to regenerate thumbnails, run:
npm run build:thumbs npm run build:thumbs
``` ```
To force a full thumbnail rebuild, run:
```sh
npm run build:thumbs:force
```
## Rankings Data
`data/elo.json` stores the seed rating, Elo `kFactor`, and a win-loss record for each meal.
The page build keeps this file aligned with `data/meals.json`, so new meals automatically appear in `rankings.html` with the default seed rating.
The interactive voting flow on `rankings.html` now prefers the same-origin API exposed by `scripts/serve.js`:
- `GET /api/rankings`: load the shared rankings state
- `POST /api/rankings/vote`: apply one head-to-head result on the server
- `POST /api/rankings/reset`: reset the shared board back to the seeded state
The server persists the shared board to `.runtime/rankings-state.json` by default, or to `RANKINGS_STATE_PATH` if you set it.
That makes rankings persist across reloads, sessions, browsers, and devices as long as they are hitting the same deployed site.
If the API is unavailable, the page falls back to browser `localStorage`.
In that fallback mode, votes still persist across reloads in the same browser profile, but they do not sync across browsers or devices.
Use the reset button on the rankings page if you want to clear the current saved board and go back to the seeded state.
## Deployment Notes
For Docker/VPS deployment, mount a persistent volume and point `RANKINGS_STATE_PATH` at it so rankings survive container rebuilds and restarts.
Example:
```sh
RANKINGS_STATE_PATH=/data/rankings-state.json npm start
```
In a containerized setup, mount `/data` as a named volume or bind mount.
If you reverse-proxy the app through Caddy on the same domain, the rankings page will use the shared API automatically with no extra CORS setup.
The current server deployment lives one directory up from this repo in `~/docker/websites/docker-compose.yml` and uses this `gallery` service definition:
```yaml
services:
gallery:
build:
context: ./gallery-src
container_name: gallery
environment:
HOST: 0.0.0.0
PORT: 80
RANKINGS_STATE_PATH: /data/rankings-state.json
volumes:
- gallery-rankings:/data
restart: unless-stopped
networks:
- web
networks:
web:
external: true
name: web
volumes:
gallery-rankings:
```
## Image Conventions ## Image Conventions
- Full-size images and thumbnails share the same numeric ID - Full-size images and thumbnails share the same numeric ID
@@ -45,6 +165,7 @@ npm run build:thumbs
## Thumbnail Focus ## Thumbnail Focus
Thumbnails are generated from `images/fulls` with `sharp` at `240x320`. Thumbnails are generated from `images/fulls` with `sharp` at `240x320`.
The generator auto-rotates images using EXIF orientation, skips unchanged files by default, and removes stale thumbnail `.jpg` files that no longer map to a meal entry.
For images that should crop away from the center, add optional thumbnail focus metadata to the meal entry: For images that should crop away from the center, add optional thumbnail focus metadata to the meal entry:
@@ -63,9 +184,3 @@ For images that should crop away from the center, add optional thumbnail focus m
``` ```
The `x` and `y` values are normalized from `0` to `1`, where `0.5, 0.5` is the center of the image. The `x` and `y` values are normalized from `0` to `1`, where `0.5, 0.5` is the center of the image.
## Planned Features
1. Automatic image ingestion, potentially with a stronger data model if the static workflow becomes too limiting.
2. An Elo-style ranking page that shows two food images at a time and updates rankings automatically based on the selected winner.
3. General cleanup and history cleanup once the bigger structural changes are in place.

View File

@@ -3,6 +3,7 @@
width: 100px; width: 100px;
height: auto; height: auto;
} }
#giftwo { #giftwo {
position: fixed; position: fixed;
bottom: 0; bottom: 0;
@@ -11,3 +12,24 @@
width: 100px; width: 100px;
height: auto; height: auto;
} }
.page-links {
font-size: 0.8rem;
letter-spacing: 0.1em;
margin-top: 1rem;
text-transform: uppercase;
}
.page-links a {
border-bottom: 0;
}
.page-links a[aria-current="page"] {
color: #00d3b7;
}
.page-links__separator {
color: #d0d0d0;
display: inline-block;
margin: 0 0.5rem;
}

349
assets/css/rankings.css Normal file
View File

@@ -0,0 +1,349 @@
html.rankings-html,
body.rankings-page {
background:
radial-gradient(circle at top, rgba(0, 211, 183, 0.18), transparent 28rem),
linear-gradient(180deg, #f5f7fb 0%, #ffffff 100%);
overflow-x: hidden;
overflow-y: auto;
}
body.rankings-page {
color: #7a7a7a;
}
body.rankings-page #main {
height: auto;
left: auto;
margin: 2rem auto;
max-width: 72rem;
overflow: visible;
position: relative;
text-align: left;
width: min(72rem, calc(100% - 3rem));
}
body.rankings-page #header,
body.rankings-page #footer {
text-align: left;
}
body.rankings-page #header {
padding-bottom: 1.25rem;
}
body.rankings-page #gifone {
display: block;
margin-bottom: 1rem;
}
body.rankings-page #giftwo {
left: auto;
position: static;
transform: none;
}
#voting {
padding: 0 2.25rem 1.75rem 2.25rem;
}
.voting-panel {
background:
linear-gradient(140deg, rgba(255, 255, 255, 0.97), rgba(240, 248, 247, 0.94));
border: 1px solid rgba(16, 16, 16, 0.08);
border-radius: 1.5rem;
box-shadow: 0 1.75rem 3.5rem rgba(16, 16, 16, 0.08);
padding: 1.5rem;
}
.voting-panel__intro h2,
.voting-panel__intro p {
margin: 0;
}
.voting-panel__eyebrow {
color: #00a892;
font-size: 0.8rem;
letter-spacing: 0.12em;
margin-bottom: 0.75rem;
text-transform: uppercase;
}
.voting-panel__intro h2 {
color: #333;
font-size: 2rem;
}
.vote-status {
color: #666;
margin-top: 0.5rem;
}
.voting-panel__actions {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-top: 1.25rem;
}
.vote-message {
color: #333;
font-size: 0.95rem;
margin: 1rem 0 0 0;
}
.duel-grid {
display: grid;
gap: 1rem;
grid-template-columns: repeat(2, minmax(0, 1fr));
margin-top: 1.25rem;
}
.duel-placeholder {
color: #666;
grid-column: 1 / -1;
margin: 0;
}
.duel-card {
background: #ffffff;
border: 1px solid rgba(16, 16, 16, 0.08);
border-radius: 1.25rem;
box-shadow: 0 1rem 2.5rem rgba(16, 16, 16, 0.08);
display: flex;
flex-direction: column;
overflow: hidden;
}
.duel-card__button {
background: transparent;
border: 0;
color: inherit;
cursor: pointer;
display: block;
padding: 0;
text-align: left;
white-space: normal;
width: 100%;
}
.duel-card__button:hover,
.duel-card__button:focus-visible {
color: inherit;
}
.duel-card__button:focus-visible {
outline: 3px solid rgba(0, 211, 183, 0.55);
outline-offset: -3px;
}
.duel-card__button:hover .duel-card__media img,
.duel-card__button:focus-visible .duel-card__media img {
transform: scale(1.02);
}
.duel-card__media {
background: #eff3f8;
overflow: hidden;
}
.duel-card__media img {
aspect-ratio: 3 / 4;
display: block;
object-fit: cover;
transition: transform 0.2s ease;
width: 100%;
}
.duel-card__body {
display: flex;
flex-direction: column;
gap: 0.7rem;
padding: 1.25rem;
}
.duel-card__label,
.duel-card__meta,
.duel-card__description,
.duel-card__body h3 {
margin: 0;
}
.duel-card__label,
.duel-card__meta {
text-transform: uppercase;
}
.duel-card__label {
color: #00a892;
font-size: 0.78rem;
letter-spacing: 0.12em;
}
.duel-card__title {
color: #333;
font-size: 1.5rem;
}
.duel-card__meta {
color: #666;
font-size: 0.78rem;
letter-spacing: 0.08em;
}
.duel-card__description {
color: #777;
}
.duel-card__cta {
color: #101010;
display: inline-flex;
font-size: 0.92rem;
font-weight: 700;
margin-top: 0.35rem;
}
.duel-card__open {
align-self: flex-start;
border-bottom: 0;
color: #666;
font-size: 0.82rem;
letter-spacing: 0.08em;
margin: 0 1.25rem 1.25rem 1.25rem;
text-transform: uppercase;
}
.duel-card__open:hover {
color: #00a892;
}
.vote-hint {
color: #666;
font-size: 0.85rem;
margin: 1rem 0 0 0;
}
#rankings-summary {
padding: 0 2.25rem 1.25rem 2.25rem;
}
.ranking-summary {
color: #666;
font-size: 0.95rem;
letter-spacing: 0.04em;
margin: 0;
text-transform: uppercase;
}
#rankings {
display: grid;
gap: 1.25rem;
padding: 0 2.25rem 2.25rem 2.25rem;
}
.ranking-card {
background: #f9fbfd;
border: 1px solid rgba(16, 16, 16, 0.08);
border-radius: 1rem;
box-shadow: 0 1.5rem 3rem rgba(16, 16, 16, 0.08);
display: grid;
gap: 1.25rem;
grid-template-columns: minmax(10rem, 14rem) minmax(0, 1fr);
overflow: hidden;
position: relative;
}
.ranking-card__placement {
background: #101010;
border-radius: 999px;
color: #ffffff;
font-size: 0.8rem;
left: 1rem;
letter-spacing: 0.08em;
margin: 0;
padding: 0.45rem 0.8rem;
position: absolute;
text-transform: uppercase;
top: 1rem;
z-index: 1;
}
.ranking-card__thumbnail {
border-bottom: 0;
display: block;
}
.ranking-card__thumbnail img {
display: block;
height: 100%;
object-fit: cover;
width: 100%;
}
.ranking-card__body {
display: flex;
flex-direction: column;
gap: 0.85rem;
justify-content: center;
min-width: 0;
padding: 1.5rem 1.5rem 1.5rem 0;
}
.ranking-card__body h2,
.ranking-card__body p {
margin: 0;
}
.ranking-card__body h2 {
color: #333;
font-size: 1.5rem;
}
.ranking-card__meta {
color: #00a892;
font-size: 0.8rem;
letter-spacing: 0.1em;
text-transform: uppercase;
}
@media screen and (max-width: 980px) {
body.rankings-page #main {
background: rgba(255, 255, 255, 0.96);
}
}
@media screen and (max-width: 736px) {
body.rankings-page #main {
margin: 0.75rem auto;
width: calc(100% - 1.5rem);
}
#rankings-summary,
#voting,
#rankings,
body.rankings-page #header,
body.rankings-page #footer {
padding-left: 1.25rem;
padding-right: 1.25rem;
}
.voting-panel {
padding: 1.25rem;
}
.duel-grid {
grid-template-columns: 1fr;
}
.ranking-card {
grid-template-columns: 1fr;
}
.ranking-card__placement {
left: auto;
right: 1rem;
}
.ranking-card__body {
padding: 0 1.25rem 1.25rem 1.25rem;
}
}

956
assets/js/rankings.js Normal file
View File

@@ -0,0 +1,956 @@
(function () {
const STORAGE_KEY = "gallery.rankings.v1";
const STORAGE_TEST_KEY = `${STORAGE_KEY}.probe`;
const STATE_VERSION = 1;
const CLOSE_MATCH_COUNT = 6;
const REMOTE_RANKINGS_URL = "/api/rankings";
const REMOTE_RANKINGS_VOTE_URL = "/api/rankings/vote";
const REMOTE_RANKINGS_RESET_URL = "/api/rankings/reset";
const REMOTE_RANKINGS_UNDO_URL = "/api/rankings/undo";
function $(id) {
return document.getElementById(id);
}
function escapeHtml(value) {
return String(value)
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function formatRating(rating) {
return new Intl.NumberFormat("en-US", {
maximumFractionDigits: 0,
}).format(rating);
}
function pluralize(count, singular, plural) {
return count === 1 ? singular : plural;
}
function createPairKey(leftId, rightId) {
return [leftId, rightId].sort().join(":");
}
function isValidMealId(id) {
return typeof id === "string" && /^\d+$/.test(id);
}
function createDefaultEntry(id, defaultRating) {
return {
id,
rating: defaultRating,
wins: 0,
losses: 0,
};
}
function cloneEntry(entry) {
return {
id: entry.id,
rating: entry.rating,
wins: entry.wins,
losses: entry.losses,
};
}
function roundRating(rating) {
return Math.round(rating * 1000) / 1000;
}
function pickRandom(items) {
return items[Math.floor(Math.random() * items.length)];
}
function parseSeedData() {
const seedElement = $("rankings-seed-data");
if (!seedElement) {
throw new Error("Missing rankings seed data");
}
const seedData = JSON.parse(seedElement.textContent);
if (!seedData || !Array.isArray(seedData.meals) || !seedData.elo) {
throw new Error("Invalid rankings seed data");
}
return seedData;
}
async function requestJson(url, options) {
const response = await fetch(url, {
cache: "no-store",
headers: {
Accept: "application/json",
...(options && options.body ? { "Content-Type": "application/json" } : {}),
},
...options,
});
if (!response.ok) {
throw new Error(`Request failed with status ${response.status}`);
}
return response.json();
}
function createLocalPersistence() {
let available = false;
try {
localStorage.setItem(STORAGE_TEST_KEY, "1");
localStorage.removeItem(STORAGE_TEST_KEY);
available = true;
} catch (error) {
available = false;
}
return {
get available() {
return available;
},
load() {
if (!available) {
return null;
}
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? JSON.parse(raw) : null;
} catch (error) {
return null;
}
},
save(state) {
if (!available) {
return;
}
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch (error) {
available = false;
}
},
clear() {
if (!available) {
return;
}
try {
localStorage.removeItem(STORAGE_KEY);
} catch (error) {
available = false;
}
},
};
}
function createPersistence() {
const local = createLocalPersistence();
let mode = "memory";
let pendingNotice = null;
function setMode(nextMode, nextNotice) {
if (mode !== nextMode && nextNotice) {
pendingNotice = nextNotice;
}
mode = nextMode;
}
function getFallbackMode() {
return local.available ? "local" : "memory";
}
return {
get mode() {
return mode;
},
async load(seedData) {
if (typeof fetch === "function") {
try {
const remoteState = await requestJson(REMOTE_RANKINGS_URL, { method: "GET" });
setMode("server");
return syncStoredState(seedData, remoteState);
} catch (error) {
setMode(getFallbackMode());
}
} else {
setMode(getFallbackMode());
}
return syncStoredState(seedData, local.load());
},
async save(state) {
if (mode === "local") {
local.save(state);
}
return state;
},
async submitVote(seedData, state, winnerId, loserId, pairKey, leftId, rightId) {
if (mode === "server" && typeof fetch === "function") {
try {
const remoteState = await requestJson(REMOTE_RANKINGS_VOTE_URL, {
method: "POST",
body: JSON.stringify({
winnerId,
loserId,
pairKey,
leftId,
rightId,
}),
});
return syncStoredState(seedData, remoteState);
} catch (error) {
setMode(
getFallbackMode(),
local.available
? "Server sync failed, so votes are now saved only in this browser."
: "Server sync failed, so votes will reset when you reload."
);
}
}
const nextState = applyVote(
seedData,
state,
winnerId,
loserId,
pairKey,
leftId,
rightId
);
if (mode === "local") {
local.save(nextState);
}
return nextState;
},
async undo(seedData, state) {
if (mode === "server" && typeof fetch === "function") {
try {
const remoteState = await requestJson(REMOTE_RANKINGS_UNDO_URL, {
method: "POST",
});
return syncStoredState(seedData, remoteState);
} catch (error) {
setMode(
getFallbackMode(),
local.available
? "Server sync failed, so go back now applies only in this browser."
: "Server sync failed, so go back lasts only until you reload."
);
}
}
const nextState = undoLastVote(seedData, state);
if (mode === "local") {
local.save(nextState);
}
return nextState;
},
async reset(seedData) {
if (mode === "server" && typeof fetch === "function") {
try {
const remoteState = await requestJson(REMOTE_RANKINGS_RESET_URL, {
method: "POST",
});
return syncStoredState(seedData, remoteState);
} catch (error) {
setMode(
getFallbackMode(),
local.available
? "Server sync failed, so resets now apply only in this browser."
: "Server sync failed, so resets last only until you reload."
);
}
}
const nextState = createSeedState(seedData);
if (mode === "local") {
local.clear();
local.save(nextState);
}
return nextState;
},
consumeNotice() {
const notice = pendingNotice;
pendingNotice = null;
return notice;
},
};
}
function isValidStoredEntry(entry) {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
return false;
}
if (typeof entry.id !== "string" || !/^\d+$/.test(entry.id)) {
return false;
}
if (typeof entry.rating !== "number" || !Number.isFinite(entry.rating) || entry.rating <= 0) {
return false;
}
return ["wins", "losses"].every(
(field) => Number.isInteger(entry[field]) && entry[field] >= 0
);
}
function createSeedState(seedData) {
return {
version: STATE_VERSION,
voteCount: 0,
lastPairKey: null,
updatedAt: null,
undo: null,
elo: {
defaultRating: seedData.elo.defaultRating,
kFactor: seedData.elo.kFactor,
entries: seedData.meals.map((meal) => {
const seedEntry =
seedData.elo.entries.find((entry) => entry.id === meal.id) ||
createDefaultEntry(meal.id, seedData.elo.defaultRating);
return cloneEntry(seedEntry);
}),
},
};
}
function syncStateCore(seedData, storedState) {
if (!storedState || typeof storedState !== "object" || Array.isArray(storedState)) {
return createSeedState(seedData);
}
const storedEntries = Array.isArray(storedState.elo?.entries)
? storedState.elo.entries.filter(isValidStoredEntry)
: [];
const storedEntryById = new Map(storedEntries.map((entry) => [entry.id, entry]));
const seedEntryById = new Map(seedData.elo.entries.map((entry) => [entry.id, entry]));
const entries = seedData.meals.map((meal) => {
const storedEntry = storedEntryById.get(meal.id);
if (storedEntry) {
return cloneEntry(storedEntry);
}
const seedEntry =
seedEntryById.get(meal.id) || createDefaultEntry(meal.id, seedData.elo.defaultRating);
return cloneEntry(seedEntry);
});
const derivedVoteCount = entries.reduce((sum, entry) => sum + entry.wins, 0);
return {
version: STATE_VERSION,
voteCount:
Number.isInteger(storedState.voteCount) && storedState.voteCount >= derivedVoteCount
? storedState.voteCount
: derivedVoteCount,
lastPairKey: typeof storedState.lastPairKey === "string" ? storedState.lastPairKey : null,
updatedAt: typeof storedState.updatedAt === "string" ? storedState.updatedAt : null,
undo: null,
elo: {
defaultRating: seedData.elo.defaultRating,
kFactor: seedData.elo.kFactor,
entries,
},
};
}
function createUndoSnapshot(state) {
return {
voteCount: state.voteCount,
lastPairKey: state.lastPairKey,
updatedAt: state.updatedAt,
elo: {
defaultRating: state.elo.defaultRating,
kFactor: state.elo.kFactor,
entries: state.elo.entries.map(cloneEntry),
},
};
}
function resolveUndoPairIds(winnerId, loserId, leftId, rightId) {
const validPair =
isValidMealId(leftId) &&
isValidMealId(rightId) &&
leftId !== rightId &&
[leftId, rightId].includes(winnerId) &&
[leftId, rightId].includes(loserId);
if (validPair) {
return { leftId, rightId };
}
return { leftId: winnerId, rightId: loserId };
}
function syncUndo(seedData, storedUndo) {
if (!storedUndo || typeof storedUndo !== "object" || Array.isArray(storedUndo)) {
return null;
}
const { leftId, rightId, winnerId, loserId, snapshot } = storedUndo;
if (
!isValidMealId(leftId) ||
!isValidMealId(rightId) ||
!isValidMealId(winnerId) ||
!isValidMealId(loserId) ||
leftId === rightId ||
winnerId === loserId
) {
return null;
}
const pairIds = new Set([leftId, rightId]);
const mealIds = new Set(seedData.meals.map((meal) => meal.id));
if (
!pairIds.has(winnerId) ||
!pairIds.has(loserId) ||
![leftId, rightId, winnerId, loserId].every((id) => mealIds.has(id))
) {
return null;
}
return {
pairKey: createPairKey(leftId, rightId),
leftId,
rightId,
winnerId,
loserId,
snapshot: createUndoSnapshot(syncStateCore(seedData, snapshot)),
};
}
function syncStoredState(seedData, storedState) {
const nextState = syncStateCore(seedData, storedState);
return {
...nextState,
undo: syncUndo(seedData, storedState?.undo),
};
}
function restoreUndoSnapshot(seedData, snapshot) {
return {
...syncStateCore(seedData, snapshot),
undo: null,
};
}
function compareRankedMeals(left, right) {
const leftMatches = left.wins + left.losses;
const rightMatches = right.wins + right.losses;
if (right.rating !== left.rating) {
return right.rating - left.rating;
}
if (rightMatches !== leftMatches) {
return rightMatches - leftMatches;
}
return Number.parseInt(left.id, 10) - Number.parseInt(right.id, 10);
}
function getRankedMeals(meals, eloData) {
const entryById = new Map(eloData.entries.map((entry) => [entry.id, entry]));
return meals
.map((meal) => {
const entry =
entryById.get(meal.id) || createDefaultEntry(meal.id, eloData.defaultRating);
return {
...meal,
rating: entry.rating,
wins: entry.wins,
losses: entry.losses,
matches: entry.wins + entry.losses,
};
})
.sort(compareRankedMeals);
}
function getRankingMeta(rankedMeal) {
const ratingText = `Elo ${formatRating(rankedMeal.rating)}`;
if (rankedMeal.matches === 0) {
return `${ratingText} | no votes yet`;
}
return `${ratingText} | ${rankedMeal.wins}-${rankedMeal.losses} record across ${
rankedMeal.matches
} ${pluralize(rankedMeal.matches, "match", "matches")}`;
}
function expectedScore(rating, opponentRating) {
return 1 / (1 + Math.pow(10, (opponentRating - rating) / 400));
}
function applyVote(seedData, state, winnerId, loserId, pairKey, leftId, rightId) {
const entryById = new Map(
state.elo.entries.map((entry) => [entry.id, cloneEntry(entry)])
);
const winner = entryById.get(winnerId);
const loser = entryById.get(loserId);
const undoPair = resolveUndoPairIds(winnerId, loserId, leftId, rightId);
const resolvedPairKey =
typeof pairKey === "string" && pairKey.length > 0
? pairKey
: createPairKey(undoPair.leftId, undoPair.rightId);
if (!winner || !loser) {
return state;
}
const winnerExpected = expectedScore(winner.rating, loser.rating);
const loserExpected = expectedScore(loser.rating, winner.rating);
winner.rating = roundRating(winner.rating + state.elo.kFactor * (1 - winnerExpected));
loser.rating = roundRating(loser.rating + state.elo.kFactor * (0 - loserExpected));
winner.wins += 1;
loser.losses += 1;
return {
...state,
voteCount: state.voteCount + 1,
lastPairKey: resolvedPairKey,
updatedAt: new Date().toISOString(),
undo: {
pairKey: createPairKey(undoPair.leftId, undoPair.rightId),
leftId: undoPair.leftId,
rightId: undoPair.rightId,
winnerId,
loserId,
snapshot: createUndoSnapshot(state),
},
elo: {
...state.elo,
entries: seedData.meals.map((meal) => entryById.get(meal.id)),
},
};
}
function undoLastVote(seedData, state) {
if (!state.undo) {
return state;
}
return restoreUndoSnapshot(seedData, state.undo.snapshot);
}
function choosePair(rankedMeals, avoidedPairKeys) {
if (rankedMeals.length < 2) {
return null;
}
const avoided = new Set(avoidedPairKeys.filter(Boolean));
const rankedOrder = new Map(rankedMeals.map((meal, index) => [meal.id, index]));
for (let attempt = 0; attempt < 20; attempt += 1) {
const baseMeal = rankedMeals[Math.floor(Math.random() * rankedMeals.length)];
const candidates = rankedMeals
.filter((meal) => meal.id !== baseMeal.id)
.sort((left, right) => {
const ratingGap = Math.abs(left.rating - baseMeal.rating) - Math.abs(right.rating - baseMeal.rating);
if (ratingGap !== 0) {
return ratingGap;
}
return (
Math.abs(rankedOrder.get(left.id) - rankedOrder.get(baseMeal.id)) -
Math.abs(rankedOrder.get(right.id) - rankedOrder.get(baseMeal.id))
);
});
const closeCandidates = candidates.slice(0, Math.min(CLOSE_MATCH_COUNT, candidates.length));
const filteredCandidates = closeCandidates.filter(
(meal) => !avoided.has(createPairKey(baseMeal.id, meal.id))
);
const candidatePool =
filteredCandidates.length > 0
? filteredCandidates
: candidates.filter((meal) => !avoided.has(createPairKey(baseMeal.id, meal.id)));
const fallbackPool = candidatePool.length > 0 ? candidatePool : candidates;
const opponent = pickRandom(
fallbackPool.slice(0, Math.min(CLOSE_MATCH_COUNT, fallbackPool.length))
);
if (opponent) {
return Math.random() < 0.5
? { left: baseMeal, right: opponent }
: { left: opponent, right: baseMeal };
}
}
return {
left: rankedMeals[0],
right: rankedMeals[1],
};
}
function renderDuelCard(meal, sideLabel, disabled) {
const disabledAttributes = disabled ? ' disabled aria-disabled="true"' : "";
return `<article class="duel-card">
<button class="duel-card__button" type="button" data-meal-id="${meal.id}"${disabledAttributes}>
<div class="duel-card__media">
<img src="images/fulls/${meal.id}.jpg" alt="${escapeHtml(`${meal.title} photo`)}" />
</div>
<div class="duel-card__body">
<p class="duel-card__label">${escapeHtml(sideLabel)}</p>
<h3 class="duel-card__title">${escapeHtml(meal.title)}</h3>
<p class="duel-card__meta">${escapeHtml(getRankingMeta(meal))}</p>
<p class="duel-card__description">${escapeHtml(meal.description)}</p>
<span class="duel-card__cta">Choose this meal</span>
</div>
</button>
<a class="duel-card__open" href="images/fulls/${meal.id}.jpg" target="_blank" rel="noreferrer">open full image</a>
</article>`;
}
function renderRankingCard(meal, placement) {
return `<article class="ranking-card">
<p class="ranking-card__placement">#${placement}</p>
<a class="ranking-card__thumbnail" href="images/fulls/${meal.id}.jpg">
<img src="images/thumbs/${meal.id}.jpg" alt="${escapeHtml(`${meal.title} thumbnail`)}" loading="lazy" />
</a>
<div class="ranking-card__body">
<h2>${escapeHtml(meal.title)}</h2>
<p class="ranking-card__meta">${escapeHtml(getRankingMeta(meal))}</p>
<p>${escapeHtml(meal.description)}</p>
</div>
</article>`;
}
function getSummaryText(seedData, state, persistence) {
const voteText = `${state.voteCount} ${pluralize(state.voteCount, "vote", "votes")}`;
if (persistence.mode === "server") {
return `${seedData.meals.length} meals ranked. ${voteText} saved on the server.`;
}
if (persistence.mode === "local") {
return `${seedData.meals.length} meals ranked. ${voteText} saved only in this browser.`;
}
return `${seedData.meals.length} meals ranked. ${voteText} active for this session only.`;
}
function getStatusText(persistence) {
if (persistence.mode === "server") {
return "Votes are saved on the server and shared across browsers.";
}
if (persistence.mode === "local") {
return "Server sync is unavailable, so votes are saved only in this browser.";
}
return "Browser storage is unavailable, so votes reset when you reload.";
}
async function init() {
let seedData;
try {
seedData = parseSeedData();
} catch (error) {
console.error(error);
return;
}
const elements = {
duelCards: $("duel-cards"),
rankings: $("rankings"),
rankingSummary: document.querySelector("#rankings-summary .ranking-summary"),
voteStatus: $("vote-status"),
voteMessage: $("vote-message"),
skipPair: $("skip-pair"),
undoVote: $("undo-vote"),
resetRankings: $("reset-rankings"),
};
if (
!elements.duelCards ||
!elements.rankings ||
!elements.rankingSummary ||
!elements.voteStatus ||
!elements.voteMessage ||
!elements.skipPair ||
!elements.undoVote ||
!elements.resetRankings
) {
return;
}
elements.voteMessage.textContent = "Loading saved rankings...";
const persistence = createPersistence();
const mealById = new Map(seedData.meals.map((meal) => [meal.id, meal]));
let state = await persistence.load(seedData);
let currentPair = null;
let currentPairKey = null;
let pendingPairIds = null;
let lastMessage = "Choose the better meal to start ranking.";
let busy = false;
await persistence.save(state);
function queueNextPair(rankedMeals) {
if (pendingPairIds) {
const rankedMealById = new Map(rankedMeals.map((meal) => [meal.id, meal]));
const left = rankedMealById.get(pendingPairIds.leftId);
const right = rankedMealById.get(pendingPairIds.rightId);
pendingPairIds = null;
if (left && right && left.id !== right.id) {
currentPair = { left, right };
currentPairKey = createPairKey(left.id, right.id);
return;
}
}
currentPair = choosePair(rankedMeals, [currentPairKey, state.lastPairKey]);
currentPairKey = currentPair
? createPairKey(currentPair.left.id, currentPair.right.id)
: null;
}
function render(message) {
const rankedMeals = getRankedMeals(seedData.meals, state.elo);
if (message) {
lastMessage = message;
}
if (
!currentPair ||
!rankedMeals.some((meal) => meal.id === currentPair.left.id) ||
!rankedMeals.some((meal) => meal.id === currentPair.right.id)
) {
queueNextPair(rankedMeals);
}
elements.voteStatus.textContent = getStatusText(persistence);
elements.voteMessage.textContent = lastMessage;
elements.rankingSummary.textContent = getSummaryText(seedData, state, persistence);
elements.undoVote.disabled = busy || !state.undo;
elements.resetRankings.disabled = busy;
elements.rankings.innerHTML = rankedMeals
.map((meal, index) => renderRankingCard(meal, index + 1))
.join("");
if (!currentPair) {
elements.duelCards.innerHTML =
'<p class="duel-placeholder">Add at least two meals before starting head-to-head voting.</p>';
elements.skipPair.disabled = true;
return;
}
elements.skipPair.disabled = busy;
elements.duelCards.innerHTML = [
renderDuelCard(currentPair.left, "Left Pick", busy),
renderDuelCard(currentPair.right, "Right Pick", busy),
].join("");
}
async function handleVote(winnerId) {
if (!currentPair || busy) {
return;
}
const loserId = currentPair.left.id === winnerId ? currentPair.right.id : currentPair.left.id;
const winnerTitle =
currentPair.left.id === winnerId ? currentPair.left.title : currentPair.right.title;
const loserTitle =
currentPair.left.id === winnerId ? currentPair.right.title : currentPair.left.title;
let nextMessage = `Picked ${winnerTitle} over ${loserTitle}.`;
busy = true;
render(`Saving ${winnerTitle} over ${loserTitle}...`);
try {
state = await persistence.submitVote(
seedData,
state,
winnerId,
loserId,
currentPairKey,
currentPair.left.id,
currentPair.right.id
);
currentPair = null;
const notice = persistence.consumeNotice();
if (notice) {
nextMessage = `${nextMessage} ${notice}`;
}
} catch (error) {
console.error(error);
nextMessage = "Failed to save that vote.";
} finally {
busy = false;
render(nextMessage);
}
}
async function handleUndo() {
if (!state.undo || busy) {
return;
}
const undoInfo = state.undo;
const winnerTitle = mealById.get(undoInfo.winnerId)?.title || "that meal";
const loserTitle = mealById.get(undoInfo.loserId)?.title || "the other meal";
let nextMessage = `Went back before ${winnerTitle} over ${loserTitle}. Pick again.`;
busy = true;
render(`Going back before ${winnerTitle} over ${loserTitle}...`);
try {
state = await persistence.undo(seedData, state);
currentPair = null;
currentPairKey = null;
pendingPairIds = {
leftId: undoInfo.leftId,
rightId: undoInfo.rightId,
};
const notice = persistence.consumeNotice();
if (notice) {
nextMessage = `${nextMessage} ${notice}`;
}
} catch (error) {
console.error(error);
nextMessage = "Failed to go back to the previous vote.";
} finally {
busy = false;
render(nextMessage);
}
}
elements.duelCards.addEventListener("click", async (event) => {
const button = event.target.closest("[data-meal-id]");
if (!button) {
return;
}
await handleVote(button.getAttribute("data-meal-id"));
});
elements.skipPair.addEventListener("click", () => {
if (busy) {
return;
}
const rankedMeals = getRankedMeals(seedData.meals, state.elo);
queueNextPair(rankedMeals);
render("Skipped that pair.");
});
elements.undoVote.addEventListener("click", async () => {
await handleUndo();
});
elements.resetRankings.addEventListener("click", async () => {
if (busy) {
return;
}
if (!window.confirm("Reset the saved rankings back to the seeded board?")) {
return;
}
let nextMessage = "Saved rankings cleared. Back to the seeded board.";
busy = true;
render("Resetting saved rankings...");
try {
state = await persistence.reset(seedData);
currentPair = null;
currentPairKey = null;
const notice = persistence.consumeNotice();
if (notice) {
nextMessage = `${nextMessage} ${notice}`;
}
} catch (error) {
console.error(error);
nextMessage = "Failed to reset rankings.";
} finally {
busy = false;
render(nextMessage);
}
});
document.addEventListener("keydown", async (event) => {
const target = event.target;
if (
target &&
["INPUT", "TEXTAREA", "SELECT", "BUTTON"].includes(target.tagName)
) {
return;
}
if (busy) {
return;
}
if (event.key.toLowerCase() === "z" && state.undo) {
event.preventDefault();
await handleUndo();
return;
}
if (!currentPair) {
return;
}
if (event.key === "ArrowLeft") {
event.preventDefault();
await handleVote(currentPair.left.id);
} else if (event.key === "ArrowRight") {
event.preventDefault();
await handleVote(currentPair.right.id);
}
});
render();
}
init().catch((error) => {
console.error(error);
});
})();

204
data/elo.json Normal file
View File

@@ -0,0 +1,204 @@
{
"defaultRating": 1000,
"kFactor": 32,
"entries": [
{
"id": "01",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "02",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "03",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "04",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "05",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "06",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "07",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "08",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "09",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "10",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "11",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "12",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "13",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "14",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "15",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "16",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "17",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "18",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "19",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "20",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "21",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "22",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "23",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "24",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "25",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "26",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "27",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "28",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "29",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "30",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "31",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "32",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "33",
"rating": 1000,
"wins": 0,
"losses": 0
}
]
}

View File

@@ -0,0 +1,308 @@
{
"meal:01": {
"version": 1,
"width": 240,
"height": 320,
"quality": 82,
"mtimeMs": 1774257676921.0168,
"size": 1052830,
"focus": {
"x": 0.35,
"y": 0.5
}
},
"meal:02": {
"version": 1,
"width": 240,
"height": 320,
"quality": 82,
"mtimeMs": 1774257676923.4688,
"size": 835360,
"focus": null
},
"meal:03": {
"version": 1,
"width": 240,
"height": 320,
"quality": 82,
"mtimeMs": 1774257676926.1216,
"size": 1034158,
"focus": {
"x": 0.5,
"y": 0.35
}
},
"meal:04": {
"version": 1,
"width": 240,
"height": 320,
"quality": 82,
"mtimeMs": 1774257676928.9744,
"size": 1090215,
"focus": null
},
"meal:05": {
"version": 1,
"width": 240,
"height": 320,
"quality": 82,
"mtimeMs": 1774257676931.93,
"size": 1122236,
"focus": {
"x": 0.5,
"y": 0.35
}
},
"meal:06": {
"version": 1,
"width": 240,
"height": 320,
"quality": 82,
"mtimeMs": 1774257676932.7878,
"size": 676787,
"focus": null
},
"meal:07": {
"version": 1,
"width": 240,
"height": 320,
"quality": 82,
"mtimeMs": 1774257676935.0764,
"size": 872024,
"focus": null
},
"meal:08": {
"version": 1,
"width": 240,
"height": 320,
"quality": 82,
"mtimeMs": 1774257676936.637,
"size": 618276,
"focus": null
},
"meal:09": {
"version": 1,
"width": 240,
"height": 320,
"quality": 82,
"mtimeMs": 1774257676938.8577,
"size": 1349804,
"focus": null
},
"meal:10": {
"version": 1,
"width": 240,
"height": 320,
"quality": 82,
"mtimeMs": 1774257676940.0383,
"size": 1071870,
"focus": null
},
"meal:11": {
"version": 1,
"width": 240,
"height": 320,
"quality": 82,
"mtimeMs": 1774257676941.5466,
"size": 764329,
"focus": null
},
"meal:12": {
"version": 1,
"width": 240,
"height": 320,
"quality": 82,
"mtimeMs": 1774257676943.8735,
"size": 1172905,
"focus": null
},
"meal:13": {
"version": 1,
"width": 240,
"height": 320,
"quality": 82,
"mtimeMs": 1774257676945.2588,
"size": 1099540,
"focus": null
},
"meal:14": {
"version": 1,
"width": 240,
"height": 320,
"quality": 82,
"mtimeMs": 1774257676946.2732,
"size": 1052362,
"focus": null
},
"meal:15": {
"version": 1,
"width": 240,
"height": 320,
"quality": 82,
"mtimeMs": 1774257676947.5552,
"size": 1227608,
"focus": null
},
"meal:16": {
"version": 1,
"width": 240,
"height": 320,
"quality": 82,
"mtimeMs": 1774257676949.5437,
"size": 840466,
"focus": null
},
"meal:17": {
"version": 1,
"width": 240,
"height": 320,
"quality": 82,
"mtimeMs": 1774257676951.916,
"size": 1136990,
"focus": null
},
"meal:18": {
"version": 1,
"width": 240,
"height": 320,
"quality": 82,
"mtimeMs": 1774257676954.4558,
"size": 1261294,
"focus": null
},
"meal:19": {
"version": 1,
"width": 240,
"height": 320,
"quality": 82,
"mtimeMs": 1774257676956.7886,
"size": 1119498,
"focus": null
},
"meal:20": {
"version": 1,
"width": 240,
"height": 320,
"quality": 82,
"mtimeMs": 1774257676958.691,
"size": 868085,
"focus": null
},
"meal:21": {
"version": 1,
"width": 240,
"height": 320,
"quality": 82,
"mtimeMs": 1774257676961.0393,
"size": 1057896,
"focus": null
},
"meal:22": {
"version": 1,
"width": 240,
"height": 320,
"quality": 82,
"mtimeMs": 1774257676963.4336,
"size": 1088795,
"focus": null
},
"meal:23": {
"version": 1,
"width": 240,
"height": 320,
"quality": 82,
"mtimeMs": 1774257676965.364,
"size": 852307,
"focus": null
},
"meal:24": {
"version": 1,
"width": 240,
"height": 320,
"quality": 82,
"mtimeMs": 1774257676967.8005,
"size": 1149955,
"focus": null
},
"meal:25": {
"version": 1,
"width": 240,
"height": 320,
"quality": 82,
"mtimeMs": 1774257676970.2761,
"size": 1242099,
"focus": null
},
"meal:26": {
"version": 1,
"width": 240,
"height": 320,
"quality": 82,
"mtimeMs": 1774257676971.9075,
"size": 1414024,
"focus": null
},
"meal:27": {
"version": 1,
"width": 240,
"height": 320,
"quality": 82,
"mtimeMs": 1774257676973.2812,
"size": 1022877,
"focus": null
},
"meal:28": {
"version": 1,
"width": 240,
"height": 320,
"quality": 82,
"mtimeMs": 1774257676974.2112,
"size": 1018868,
"focus": null
},
"meal:29": {
"version": 1,
"width": 240,
"height": 320,
"quality": 82,
"mtimeMs": 1774257676975.1504,
"size": 1233602,
"focus": null
},
"meal:30": {
"version": 1,
"width": 240,
"height": 320,
"quality": 82,
"mtimeMs": 1774257676976.6533,
"size": 739786,
"focus": null
},
"meal:31": {
"version": 1,
"width": 240,
"height": 320,
"quality": 82,
"mtimeMs": 1774257676977.1958,
"size": 1069693,
"focus": null
},
"meal:32": {
"version": 1,
"width": 240,
"height": 320,
"quality": 82,
"mtimeMs": 1774257676979.063,
"size": 995282,
"focus": null
},
"meal:33": {
"version": 1,
"width": 240,
"height": 320,
"quality": 82,
"mtimeMs": 1774257676980.472,
"size": 729224,
"focus": null
}
}

View File

@@ -29,6 +29,11 @@
<img src="images/meow.gif" alt="meow" id="gifone"> <img src="images/meow.gif" alt="meow" id="gifone">
<h1>for vham :3</h1> <h1>for vham :3</h1>
<p id="haiku">Please enable javascript >.<</p> <p id="haiku">Please enable javascript >.<</p>
<p class="page-links">
<a href="./" aria-current="page">gallery</a>
<span class="page-links__separator">/</span>
<a href="rankings">rankings</a>
</p>
</header> </header>
<!-- Thumbnail --> <!-- Thumbnail -->
@@ -131,7 +136,7 @@
<article> <article>
<a class="thumbnail" href="images/fulls/20.jpg"><img src="images/thumbs/20.jpg" alt="" /></a> <a class="thumbnail" href="images/fulls/20.jpg"><img src="images/thumbs/20.jpg" alt="" /></a>
<h2>sul and beans</h2> <h2>sul and beans</h2>
<p>sweet treat -> claire dropping the most insane piece of information ever -> hti the yap</p> <p>sweet treat -&gt; claire dropping the most insane piece of information ever -&gt; hti the yap</p>
</article> </article>
<article> <article>
<a class="thumbnail" href="images/fulls/21.jpg"><img src="images/thumbs/21.jpg" alt="" /></a> <a class="thumbnail" href="images/fulls/21.jpg"><img src="images/thumbs/21.jpg" alt="" /></a>

View File

@@ -2,9 +2,14 @@
"name": "gallery", "name": "gallery",
"private": true, "private": true,
"scripts": { "scripts": {
"build": "npm run build:thumbs && npm run build:pages", "build": "npm run build:thumbs && npm run build:pages && npm run check",
"ingest": "node scripts/ingest-meal.js",
"check": "node scripts/check.js",
"build:pages": "node scripts/build.js", "build:pages": "node scripts/build.js",
"build:thumbs": "node scripts/generate-thumbnails.js" "build:thumbs": "node scripts/generate-thumbnails.js",
"build:thumbs:force": "node scripts/generate-thumbnails.js --force",
"serve": "node scripts/serve.js",
"start": "npm run build && npm run serve"
}, },
"dependencies": { "dependencies": {
"sharp": "^0.34.5" "sharp": "^0.34.5"

773
rankings.html Normal file
View File

@@ -0,0 +1,773 @@
<!DOCTYPE HTML>
<!--
Lens by HTML5 UP
html5up.net | @ajlkn
Free for personal and commercial use under the CCA 3.0 license (html5up.net/license)
-->
<html class="rankings-html">
<head>
<title>food rankings</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" />
<link rel="stylesheet" href="assets/css/main.css" />
<link rel="stylesheet" href="assets/css/nyaa.css" />
<link rel="stylesheet" href="assets/css/rankings.css" />
<noscript><link rel="stylesheet" href="assets/css/noscript.css" /></noscript>
<link rel="apple-touch-icon" sizes="180x180" href="../favicon/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="../favicon/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="../favicon/favicon-16x16.png">
<link rel="manifest" href="../favicon/site.webmanifest">
</head>
<body class="rankings-page">
<!-- Main -->
<div id="main">
<!-- Header -->
<header id="header">
<img src="images/meow.gif" alt="meow" id="gifone">
<h1>food power rankings</h1>
<p>pick the better meal, one pair at a time, and the board updates live in this browser.</p>
<p class="page-links">
<a href="./">gallery</a>
<span class="page-links__separator">/</span>
<a href="rankings" aria-current="page">rankings</a>
</p>
</header>
<!-- Voting -->
<section id="voting">
<div class="voting-panel">
<div class="voting-panel__intro">
<p class="voting-panel__eyebrow">Head-To-Head Voting</p>
<h2>Pick the winner.</h2>
<p class="vote-status" id="vote-status" aria-live="polite">Enable JavaScript to load saved rankings.</p>
</div>
<div class="voting-panel__actions">
<button class="button small" id="skip-pair" type="button">skip pair</button>
<button class="button small" id="undo-vote" type="button">go back</button>
<button class="button small" id="reset-rankings" type="button">reset saved rankings</button>
</div>
<p class="vote-message" id="vote-message" aria-live="polite">Enable JavaScript to load head-to-head voting.</p>
<div class="duel-grid" id="duel-cards">
<p class="duel-placeholder">Enable JavaScript to compare meals here.</p>
</div>
<p class="vote-hint">Tip: use the left and right arrow keys to vote faster, or press Z to go back.</p>
</div>
</section>
<!-- Rankings Summary -->
<section id="rankings-summary">
<p class="ranking-summary">33 meals seeded at Elo 1,000. Enable JavaScript to vote and reorder them.</p>
</section>
<!-- Rankings -->
<section id="rankings">
<article class="ranking-card">
<p class="ranking-card__placement">#1</p>
<a class="ranking-card__thumbnail" href="images/fulls/01.jpg"><img src="images/thumbs/01.jpg" alt="sf on $10 thumbnail" /></a>
<div class="ranking-card__body">
<h2>sf on $10</h2>
<p class="ranking-card__meta">Elo 1,000 | no votes yet</p>
<p>this was so not real i can't believe technically u paid for our first meal back. calmluh 3 years after. first hang !!!! pork buns were yummy 7/10</p>
</div>
</article>
<article class="ranking-card">
<p class="ranking-card__placement">#2</p>
<a class="ranking-card__thumbnail" href="images/fulls/02.jpg"><img src="images/thumbs/02.jpg" alt="honey butter chicken thumbnail" /></a>
<div class="ranking-card__body">
<h2>honey butter chicken</h2>
<p class="ranking-card__meta">Elo 1,000 | no votes yet</p>
<p>the first thing you cooked for me ! so yum 10/10</p>
</div>
</article>
<article class="ranking-card">
<p class="ranking-card__placement">#3</p>
<a class="ranking-card__thumbnail" href="images/fulls/03.jpg"><img src="images/thumbs/03.jpg" alt="aloha fresh thumbnail" /></a>
<div class="ranking-card__body">
<h2>aloha fresh</h2>
<p class="ranking-card__meta">Elo 1,000 | no votes yet</p>
<p>we fucking love this place 10/10 i love poke i should have never quit pokehouse</p>
</div>
</article>
<article class="ranking-card">
<p class="ranking-card__placement">#4</p>
<a class="ranking-card__thumbnail" href="images/fulls/04.jpg"><img src="images/thumbs/04.jpg" alt="mad yolks thumbnail" /></a>
<div class="ranking-card__body">
<h2>mad yolks</h2>
<p class="ranking-card__meta">Elo 1,000 | no votes yet</p>
<p>for our santa cruz hang! u in my city now. so so good but lwk so so tax 9/10</p>
</div>
</article>
<article class="ranking-card">
<p class="ranking-card__placement">#5</p>
<a class="ranking-card__thumbnail" href="images/fulls/05.jpg"><img src="images/thumbs/05.jpg" alt="sizzling lunch thumbnail" /></a>
<div class="ranking-card__body">
<h2>sizzling lunch</h2>
<p class="ranking-card__meta">Elo 1,000 | no votes yet</p>
<p>better than pepper lunch. server was being a little bitchy but i would be too if i was the only one working the front. 8/10</p>
</div>
</article>
<article class="ranking-card">
<p class="ranking-card__placement">#6</p>
<a class="ranking-card__thumbnail" href="images/fulls/06.jpg"><img src="images/thumbs/06.jpg" alt="braised pork belly thumbnail" /></a>
<div class="ranking-card__body">
<h2>braised pork belly</h2>
<p class="ranking-card__meta">Elo 1,000 | no votes yet</p>
<p>omfg this is my favorite thing uve made 100/10</p>
</div>
</article>
<article class="ranking-card">
<p class="ranking-card__placement">#7</p>
<a class="ranking-card__thumbnail" href="images/fulls/07.jpg"><img src="images/thumbs/07.jpg" alt="sushi w/ claire! thumbnail" /></a>
<div class="ranking-card__body">
<h2>sushi w/ claire!</h2>
<p class="ranking-card__meta">Elo 1,000 | no votes yet</p>
<p>and then we played bananagrams. sushi 8/10 thanks for paying mommy</p>
</div>
</article>
<article class="ranking-card">
<p class="ranking-card__placement">#8</p>
<a class="ranking-card__thumbnail" href="images/fulls/08.jpg"><img src="images/thumbs/08.jpg" alt="myungrang hot dog thumbnail" /></a>
<div class="ranking-card__body">
<h2>myungrang hot dog</h2>
<p class="ranking-card__meta">Elo 1,000 | no votes yet</p>
<p>main street tino nothing special 7/10</p>
</div>
</article>
<article class="ranking-card">
<p class="ranking-card__placement">#9</p>
<a class="ranking-card__thumbnail" href="images/fulls/09.jpg"><img src="images/thumbs/09.jpg" alt="liangs village thumbnail" /></a>
<div class="ranking-card__body">
<h2>liangs village</h2>
<p class="ranking-card__meta">Elo 1,000 | no votes yet</p>
<p>my peoples food. 9/10</p>
</div>
</article>
<article class="ranking-card">
<p class="ranking-card__placement">#10</p>
<a class="ranking-card__thumbnail" href="images/fulls/10.jpg"><img src="images/thumbs/10.jpg" alt="cabonara thumbnail" /></a>
<div class="ranking-card__body">
<h2>cabonara</h2>
<p class="ranking-card__meta">Elo 1,000 | no votes yet</p>
<p>insane safeway hang 9/10</p>
</div>
</article>
<article class="ranking-card">
<p class="ranking-card__placement">#11</p>
<a class="ranking-card__thumbnail" href="images/fulls/11.jpg"><img src="images/thumbs/11.jpg" alt="heytea thumbnail" /></a>
<div class="ranking-card__body">
<h2>heytea</h2>
<p class="ranking-card__meta">Elo 1,000 | no votes yet</p>
<p>this fuckass blue drink</p>
</div>
</article>
<article class="ranking-card">
<p class="ranking-card__placement">#12</p>
<a class="ranking-card__thumbnail" href="images/fulls/12.jpg"><img src="images/thumbs/12.jpg" alt="sparcos thumbnail" /></a>
<div class="ranking-card__body">
<h2>sparcos</h2>
<p class="ranking-card__meta">Elo 1,000 | no votes yet</p>
<p>one of many.. 100/10</p>
</div>
</article>
<article class="ranking-card">
<p class="ranking-card__placement">#13</p>
<a class="ranking-card__thumbnail" href="images/fulls/13.jpg"><img src="images/thumbs/13.jpg" alt="noahs bagels thumbnail" /></a>
<div class="ranking-card__body">
<h2>noahs bagels</h2>
<p class="ranking-card__meta">Elo 1,000 | no votes yet</p>
<p>this is the plaza where i used to go to all the time before school 9/10</p>
</div>
</article>
<article class="ranking-card">
<p class="ranking-card__placement">#14</p>
<a class="ranking-card__thumbnail" href="images/fulls/14.jpg"><img src="images/thumbs/14.jpg" alt="homeroom thumbnail" /></a>
<div class="ranking-card__body">
<h2>homeroom</h2>
<p class="ranking-card__meta">Elo 1,000 | no votes yet</p>
<p>mac and cheese was gas. 10/10. you know its my fav comfort food. free the girl crying in the corner tho</p>
</div>
</article>
<article class="ranking-card">
<p class="ranking-card__placement">#15</p>
<a class="ranking-card__thumbnail" href="images/fulls/15.jpg"><img src="images/thumbs/15.jpg" alt="sparcos x2 thumbnail" /></a>
<div class="ranking-card__body">
<h2>sparcos x2</h2>
<p class="ranking-card__meta">Elo 1,000 | no votes yet</p>
<p>spartan tacos</p>
</div>
</article>
<article class="ranking-card">
<p class="ranking-card__placement">#16</p>
<a class="ranking-card__thumbnail" href="images/fulls/16.jpg"><img src="images/thumbs/16.jpg" alt="sparcos x3 thumbnail" /></a>
<div class="ranking-card__body">
<h2>sparcos x3</h2>
<p class="ranking-card__meta">Elo 1,000 | no votes yet</p>
<p>okay damn no way we got this b2b</p>
</div>
</article>
<article class="ranking-card">
<p class="ranking-card__placement">#17</p>
<a class="ranking-card__thumbnail" href="images/fulls/17.jpg"><img src="images/thumbs/17.jpg" alt="aloha fresh thumbnail" /></a>
<div class="ranking-card__body">
<h2>aloha fresh</h2>
<p class="ranking-card__meta">Elo 1,000 | no votes yet</p>
<p>this is lowkey the spot poke always hits so fucking good</p>
</div>
</article>
<article class="ranking-card">
<p class="ranking-card__placement">#18</p>
<a class="ranking-card__thumbnail" href="images/fulls/18.jpg"><img src="images/thumbs/18.jpg" alt="house of bagels thumbnail" /></a>
<div class="ranking-card__body">
<h2>house of bagels</h2>
<p class="ranking-card__meta">Elo 1,000 | no votes yet</p>
<p>hobags ughgmmfmfm im such a fucking ho for hobags 100/10</p>
</div>
</article>
<article class="ranking-card">
<p class="ranking-card__placement">#19</p>
<a class="ranking-card__thumbnail" href="images/fulls/19.jpg"><img src="images/thumbs/19.jpg" alt="toro sushi thumbnail" /></a>
<div class="ranking-card__body">
<h2>toro sushi</h2>
<p class="ranking-card__meta">Elo 1,000 | no votes yet</p>
<p>carmel by the sea! we love sushi but tax 8/10</p>
</div>
</article>
<article class="ranking-card">
<p class="ranking-card__placement">#20</p>
<a class="ranking-card__thumbnail" href="images/fulls/20.jpg"><img src="images/thumbs/20.jpg" alt="sul and beans thumbnail" /></a>
<div class="ranking-card__body">
<h2>sul and beans</h2>
<p class="ranking-card__meta">Elo 1,000 | no votes yet</p>
<p>sweet treat -&gt; claire dropping the most insane piece of information ever -&gt; hti the yap</p>
</div>
</article>
<article class="ranking-card">
<p class="ranking-card__placement">#21</p>
<a class="ranking-card__thumbnail" href="images/fulls/21.jpg"><img src="images/thumbs/21.jpg" alt="highland hand pulled noodles thumbnail" /></a>
<div class="ranking-card__body">
<h2>highland hand pulled noodles</h2>
<p class="ranking-card__meta">Elo 1,000 | no votes yet</p>
<p>so good and soooo filling 10/10. also my peoples food.</p>
</div>
</article>
<article class="ranking-card">
<p class="ranking-card__placement">#22</p>
<a class="ranking-card__thumbnail" href="images/fulls/22.jpg"><img src="images/thumbs/22.jpg" alt="bloom thumbnail" /></a>
<div class="ranking-card__body">
<h2>bloom</h2>
<p class="ranking-card__meta">Elo 1,000 | no votes yet</p>
<p>even when its rich white people breakfast im getting salmon nox 10/10</p>
</div>
</article>
<article class="ranking-card">
<p class="ranking-card__placement">#23</p>
<a class="ranking-card__thumbnail" href="images/fulls/23.jpg"><img src="images/thumbs/23.jpg" alt="happy donuts thumbnail" /></a>
<div class="ranking-card__body">
<h2>happy donuts</h2>
<p class="ranking-card__meta">Elo 1,000 | no votes yet</p>
<p>1k cal meal 0 protein 10/10</p>
</div>
</article>
<article class="ranking-card">
<p class="ranking-card__placement">#24</p>
<a class="ranking-card__thumbnail" href="images/fulls/24.jpg"><img src="images/thumbs/24.jpg" alt="marugame thumbnail" /></a>
<div class="ranking-card__body">
<h2>marugame</h2>
<p class="ranking-card__meta">Elo 1,000 | no votes yet</p>
<p>i dont want to talk about this. 9/10</p>
</div>
</article>
<article class="ranking-card">
<p class="ranking-card__placement">#25</p>
<a class="ranking-card__thumbnail" href="images/fulls/25.jpg"><img src="images/thumbs/25.jpg" alt="siam station! thumbnail" /></a>
<div class="ranking-card__body">
<h2>siam station!</h2>
<p class="ranking-card__meta">Elo 1,000 | no votes yet</p>
<p>i can't believe u didnt eat ur leftovers. 10/10</p>
</div>
</article>
<article class="ranking-card">
<p class="ranking-card__placement">#26</p>
<a class="ranking-card__thumbnail" href="images/fulls/26.jpg"><img src="images/thumbs/26.jpg" alt="muukata 6395 thumbnail" /></a>
<div class="ranking-card__body">
<h2>muukata 6395</h2>
<p class="ranking-card__meta">Elo 1,000 | no votes yet</p>
<p>for my birthday!! i love eating meat and i love you so perfect combination 1000/10</p>
</div>
</article>
<article class="ranking-card">
<p class="ranking-card__placement">#27</p>
<a class="ranking-card__thumbnail" href="images/fulls/27.jpg"><img src="images/thumbs/27.jpg" alt="bambu thumbnail" /></a>
<div class="ranking-card__body">
<h2>bambu</h2>
<p class="ranking-card__meta">Elo 1,000 | no votes yet</p>
<p>why was the store so nice. i wonder about the 4 sisters</p>
</div>
</article>
<article class="ranking-card">
<p class="ranking-card__placement">#28</p>
<a class="ranking-card__thumbnail" href="images/fulls/28.jpg"><img src="images/thumbs/28.jpg" alt="porridge at julias thumbnail" /></a>
<div class="ranking-card__body">
<h2>porridge at julias</h2>
<p class="ranking-card__meta">Elo 1,000 | no votes yet</p>
<p>her boyfriend is so not real hes so stupid and funny. porridge was gas too i love eating free at julias 10/10</p>
</div>
</article>
<article class="ranking-card">
<p class="ranking-card__placement">#29</p>
<a class="ranking-card__thumbnail" href="images/fulls/29.jpg"><img src="images/thumbs/29.jpg" alt="sparcos x4 thumbnail" /></a>
<div class="ranking-card__body">
<h2>sparcos x4</h2>
<p class="ranking-card__meta">Elo 1,000 | no votes yet</p>
<p>spartan tacos. i was moody lol.</p>
</div>
</article>
<article class="ranking-card">
<p class="ranking-card__placement">#30</p>
<a class="ranking-card__thumbnail" href="images/fulls/30.jpg"><img src="images/thumbs/30.jpg" alt="wonton udon thumbnail" /></a>
<div class="ranking-card__body">
<h2>wonton udon</h2>
<p class="ranking-card__meta">Elo 1,000 | no votes yet</p>
<p>i helped wrap the wontons w/ u !!! super fun and super yummy 10/10</p>
</div>
</article>
<article class="ranking-card">
<p class="ranking-card__placement">#31</p>
<a class="ranking-card__thumbnail" href="images/fulls/31.jpg"><img src="images/thumbs/31.jpg" alt="steak dinna for vday thumbnail" /></a>
<div class="ranking-card__body">
<h2>steak dinna for vday</h2>
<p class="ranking-card__meta">Elo 1,000 | no votes yet</p>
<p>marry me? yes. 100/10 best valentines day ever</p>
</div>
</article>
<article class="ranking-card">
<p class="ranking-card__placement">#32</p>
<a class="ranking-card__thumbnail" href="images/fulls/32.jpg"><img src="images/thumbs/32.jpg" alt="poke house thumbnail" /></a>
<div class="ranking-card__body">
<h2>poke house</h2>
<p class="ranking-card__meta">Elo 1,000 | no votes yet</p>
<p>poke house 3 years later 9/10 but +1 point bc its basically free</p>
</div>
</article>
<article class="ranking-card">
<p class="ranking-card__placement">#33</p>
<a class="ranking-card__thumbnail" href="images/fulls/33.jpg"><img src="images/thumbs/33.jpg" alt="hey tea thumbnail" /></a>
<div class="ranking-card__body">
<h2>hey tea</h2>
<p class="ranking-card__meta">Elo 1,000 | no votes yet</p>
<p>mochi yinje black milk tea ts was actually so buss 10/10 only boba i've ever wanted to get again myself</p>
</div>
</article>
</section>
<!-- Footer -->
<footer id="footer">
<ul class="copyright">
<li>&copy; Ryan Chou. 2026.</li>
</ul>
<img src="images/nyaa.gif" alt="nyaa" id="giftwo">
</footer>
</div>
<script id="rankings-seed-data" type="application/json">
{
"meals": [
{
"id": "01",
"thumbnail": {
"focus": {
"x": 0.35,
"y": 0.5
}
},
"position": "left center",
"title": "sf on $10",
"description": "this was so not real i can't believe technically u paid for our first meal back. calmluh 3 years after. first hang !!!! pork buns were yummy 7/10"
},
{
"id": "02",
"title": "honey butter chicken",
"description": "the first thing you cooked for me ! so yum 10/10"
},
{
"id": "03",
"thumbnail": {
"focus": {
"x": 0.5,
"y": 0.35
}
},
"position": "top center",
"title": "aloha fresh",
"description": "we fucking love this place 10/10 i love poke i should have never quit pokehouse"
},
{
"id": "04",
"title": "mad yolks",
"description": "for our santa cruz hang! u in my city now. so so good but lwk so so tax 9/10"
},
{
"id": "05",
"thumbnail": {
"focus": {
"x": 0.5,
"y": 0.35
}
},
"position": "top center",
"title": "sizzling lunch",
"description": "better than pepper lunch. server was being a little bitchy but i would be too if i was the only one working the front. 8/10"
},
{
"id": "06",
"title": "braised pork belly",
"description": "omfg this is my favorite thing uve made 100/10"
},
{
"id": "07",
"title": "sushi w/ claire!",
"description": "and then we played bananagrams. sushi 8/10 thanks for paying mommy"
},
{
"id": "08",
"title": "myungrang hot dog",
"description": "main street tino nothing special 7/10"
},
{
"id": "09",
"title": "liangs village",
"description": "my peoples food. 9/10"
},
{
"id": "10",
"title": "cabonara",
"description": "insane safeway hang 9/10"
},
{
"id": "11",
"title": "heytea",
"description": "this fuckass blue drink"
},
{
"id": "12",
"title": "sparcos",
"description": "one of many.. 100/10"
},
{
"id": "13",
"title": "noahs bagels",
"description": "this is the plaza where i used to go to all the time before school 9/10"
},
{
"id": "14",
"title": "homeroom",
"description": "mac and cheese was gas. 10/10. you know its my fav comfort food. free the girl crying in the corner tho"
},
{
"id": "15",
"title": "sparcos x2",
"description": "spartan tacos"
},
{
"id": "16",
"title": "sparcos x3",
"description": "okay damn no way we got this b2b"
},
{
"id": "17",
"title": "aloha fresh",
"description": "this is lowkey the spot poke always hits so fucking good"
},
{
"id": "18",
"title": "house of bagels",
"description": "hobags ughgmmfmfm im such a fucking ho for hobags 100/10"
},
{
"id": "19",
"title": "toro sushi",
"description": "carmel by the sea! we love sushi but tax 8/10"
},
{
"id": "20",
"title": "sul and beans",
"description": "sweet treat -> claire dropping the most insane piece of information ever -> hti the yap"
},
{
"id": "21",
"title": "highland hand pulled noodles",
"description": "so good and soooo filling 10/10. also my peoples food."
},
{
"id": "22",
"title": "bloom",
"description": "even when its rich white people breakfast im getting salmon nox 10/10"
},
{
"id": "23",
"title": "happy donuts",
"description": "1k cal meal 0 protein 10/10"
},
{
"id": "24",
"title": "marugame",
"description": "i dont want to talk about this. 9/10"
},
{
"id": "25",
"title": "siam station!",
"description": "i can't believe u didnt eat ur leftovers. 10/10"
},
{
"id": "26",
"title": "muukata 6395",
"description": "for my birthday!! i love eating meat and i love you so perfect combination 1000/10"
},
{
"id": "27",
"title": "bambu",
"description": "why was the store so nice. i wonder about the 4 sisters"
},
{
"id": "28",
"title": "porridge at julias",
"description": "her boyfriend is so not real hes so stupid and funny. porridge was gas too i love eating free at julias 10/10"
},
{
"id": "29",
"title": "sparcos x4",
"description": "spartan tacos. i was moody lol."
},
{
"id": "30",
"title": "wonton udon",
"description": "i helped wrap the wontons w/ u !!! super fun and super yummy 10/10"
},
{
"id": "31",
"title": "steak dinna for vday",
"description": "marry me? yes. 100/10 best valentines day ever"
},
{
"id": "32",
"title": "poke house",
"description": "poke house 3 years later 9/10 but +1 point bc its basically free"
},
{
"id": "33",
"title": "hey tea",
"description": "mochi yinje black milk tea ts was actually so buss 10/10 only boba i've ever wanted to get again myself"
}
],
"elo": {
"defaultRating": 1000,
"kFactor": 32,
"entries": [
{
"id": "01",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "02",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "03",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "04",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "05",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "06",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "07",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "08",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "09",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "10",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "11",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "12",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "13",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "14",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "15",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "16",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "17",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "18",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "19",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "20",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "21",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "22",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "23",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "24",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "25",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "26",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "27",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "28",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "29",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "30",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "31",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "32",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "33",
"rating": 1000,
"wins": 0,
"losses": 0
}
]
}
}
</script>
<script src="assets/js/rankings.js"></script>
</body>
</html>

View File

@@ -1,22 +1,43 @@
const fs = require("fs"); const fs = require("fs");
const path = require("path"); const path = require("path");
const { loadMeals, repoRoot } = require("./lib/meals"); const { getRankedMeals, syncEloWithMeals } = require("./lib/elo");
const { loadMeals, repoRoot, validateMealAssets } = require("./lib/meals");
const indexTemplatePath = path.join(repoRoot, "templates", "index.html"); const indexTemplatePath = path.join(repoRoot, "templates", "index.html");
const indexOutputPath = path.join(repoRoot, "index.html"); const indexOutputPath = path.join(repoRoot, "index.html");
const rankingsTemplatePath = path.join(repoRoot, "templates", "rankings.html");
const rankingsOutputPath = path.join(repoRoot, "rankings.html");
const ratingFormatter = new Intl.NumberFormat("en-US", {
maximumFractionDigits: 0,
});
function detectEol(text) { function detectEol(text) {
return text.includes("\r\n") ? "\r\n" : "\n"; return text.includes("\r\n") ? "\r\n" : "\n";
} }
function escapeHtml(value) { function escapeHtml(value) {
return value return String(value)
.replace(/&/g, "&amp;") .replace(/&/g, "&amp;")
.replace(/>/g, "&gt;")
.replace(/</g, "&lt;") .replace(/</g, "&lt;")
.replace(/"/g, "&quot;"); .replace(/"/g, "&quot;");
} }
function serializeJsonForHtml(value) {
return JSON.stringify(value, null, 2)
.replace(/</g, "\\u003c")
.replace(/\u2028/g, "\\u2028")
.replace(/\u2029/g, "\\u2029");
}
function indentBlock(text, indent) {
return text
.split("\n")
.map((line) => `${indent}${line}`)
.join("\n");
}
function renderGalleryItem(meal, eol) { function renderGalleryItem(meal, eol) {
const attrs = [`class="thumbnail"`, `href="images/fulls/${meal.id}.jpg"`]; const attrs = [`class="thumbnail"`, `href="images/fulls/${meal.id}.jpg"`];
@@ -37,6 +58,63 @@ function renderGallery(meals, eol) {
return meals.map((meal) => renderGalleryItem(meal, eol)).join(eol); return meals.map((meal) => renderGalleryItem(meal, eol)).join(eol);
} }
function formatRating(rating) {
return ratingFormatter.format(rating);
}
function renderRankingSummary(meals, eloData) {
const mealLabel = meals.length === 1 ? "meal" : "meals";
return `\t\t\t\t<p class="ranking-summary">${meals.length} ${mealLabel} seeded at Elo ${formatRating(
eloData.defaultRating
)}. Enable JavaScript to vote and reorder them.</p>`;
}
function renderRankingMeta(rankedMeal) {
const ratingText = `Elo ${formatRating(rankedMeal.rating)}`;
if (rankedMeal.matches === 0) {
return `${ratingText} | no votes yet`;
}
const matchLabel = rankedMeal.matches === 1 ? "match" : "matches";
return `${ratingText} | ${rankedMeal.wins}-${rankedMeal.losses} record across ${
rankedMeal.matches
} ${matchLabel}`;
}
function renderRankingItem(rankedMeal, placement, eol) {
return [
'\t\t\t\t<article class="ranking-card">',
`\t\t\t\t\t<p class="ranking-card__placement">#${placement}</p>`,
`\t\t\t\t\t<a class="ranking-card__thumbnail" href="images/fulls/${rankedMeal.id}.jpg"><img src="images/thumbs/${rankedMeal.id}.jpg" alt="${escapeHtml(
`${rankedMeal.title} thumbnail`
)}" /></a>`,
'\t\t\t\t\t<div class="ranking-card__body">',
`\t\t\t\t\t\t<h2>${escapeHtml(rankedMeal.title)}</h2>`,
`\t\t\t\t\t\t<p class="ranking-card__meta">${escapeHtml(renderRankingMeta(rankedMeal))}</p>`,
`\t\t\t\t\t\t<p>${escapeHtml(rankedMeal.description)}</p>`,
"\t\t\t\t\t</div>",
"\t\t\t\t</article>",
].join(eol);
}
function renderRankings(rankedMeals, eol) {
return rankedMeals
.map((rankedMeal, index) => renderRankingItem(rankedMeal, index + 1, eol))
.join(eol);
}
function renderRankingsSeedData(meals, eloData) {
return indentBlock(
serializeJsonForHtml({
meals,
elo: eloData,
}),
"\t\t\t"
);
}
function replaceBlock(template, token, replacement) { function replaceBlock(template, token, replacement) {
const pattern = new RegExp(`^[\\t ]*\\{\\{${token}\\}\\}$`, "m"); const pattern = new RegExp(`^[\\t ]*\\{\\{${token}\\}\\}$`, "m");
@@ -47,20 +125,58 @@ function replaceBlock(template, token, replacement) {
return template.replace(pattern, () => replacement); return template.replace(pattern, () => replacement);
} }
function buildIndex() { function buildIndex(meals = loadMeals()) {
validateMealAssets(meals);
const template = fs.readFileSync(indexTemplatePath, "utf8"); const template = fs.readFileSync(indexTemplatePath, "utf8");
const eol = detectEol(template); const eol = detectEol(template);
const meals = loadMeals();
return replaceBlock(template, "gallery_items", renderGallery(meals, eol)); return replaceBlock(template, "gallery_items", renderGallery(meals, eol));
} }
function buildRankings(
meals = loadMeals(),
eloData = syncEloWithMeals(meals)
) {
validateMealAssets(meals);
const template = fs.readFileSync(rankingsTemplatePath, "utf8");
const eol = detectEol(template);
const rankedMeals = getRankedMeals(meals, eloData);
const withSummary = replaceBlock(
template,
"ranking_summary",
renderRankingSummary(meals, eloData)
);
const withSeedData = replaceBlock(
withSummary,
"rankings_seed_data",
renderRankingsSeedData(meals, eloData)
);
return replaceBlock(withSeedData, "ranking_items", renderRankings(rankedMeals, eol));
}
function writeFile(filePath, contents) { function writeFile(filePath, contents) {
fs.writeFileSync(filePath, contents); fs.writeFileSync(filePath, contents);
} }
function main() { function main() {
writeFile(indexOutputPath, buildIndex()); const meals = loadMeals();
const eloData = syncEloWithMeals(meals);
writeFile(indexOutputPath, buildIndex(meals));
writeFile(rankingsOutputPath, buildRankings(meals, eloData));
} }
main(); function buildPages() {
main();
}
if (require.main === module) {
buildPages();
}
module.exports = {
buildPages,
buildIndex,
buildRankings,
};

151
scripts/check.js Normal file
View File

@@ -0,0 +1,151 @@
const fs = require("fs");
const path = require("path");
const { getEloAlignmentReport, loadEloData } = require("./lib/elo");
const {
fullsDir,
loadMeals,
repoRoot,
thumbsDir,
validateMealAssets,
} = require("./lib/meals");
const indexPath = path.join(repoRoot, "index.html");
const rankingsPath = path.join(repoRoot, "rankings.html");
function listJpgIds(directoryPath) {
if (!fs.existsSync(directoryPath)) {
return [];
}
return fs
.readdirSync(directoryPath, { withFileTypes: true })
.filter(
(entry) => entry.isFile() && path.extname(entry.name).toLowerCase() === ".jpg"
)
.map((entry) => path.basename(entry.name, ".jpg"))
.sort((left, right) => left.localeCompare(right, undefined, { numeric: true }));
}
function getUnexpectedIds(directoryPath, expectedIds) {
return listJpgIds(directoryPath).filter((id) => !expectedIds.has(id));
}
function countMatches(text, pattern) {
return (text.match(pattern) || []).length;
}
function parseRankingsSeedData(rankingsHtml) {
const match = rankingsHtml.match(
/<script id="rankings-seed-data" type="application\/json">([\s\S]*?)<\/script>/
);
if (!match) {
throw new Error("Generated rankings.html is missing embedded rankings seed data");
}
return JSON.parse(match[1]);
}
function validateGeneratedPages(meals, eloData) {
if (!fs.existsSync(indexPath)) {
throw new Error("Generated index.html is missing; run npm run build");
}
if (!fs.existsSync(rankingsPath)) {
throw new Error("Generated rankings.html is missing; run npm run build");
}
const indexHtml = fs.readFileSync(indexPath, "utf8");
const rankingsHtml = fs.readFileSync(rankingsPath, "utf8");
const galleryArticleCount = countMatches(indexHtml, /<article>/g);
const rankingCardCount = countMatches(rankingsHtml, /class="ranking-card"/g);
if (galleryArticleCount !== meals.length) {
throw new Error(
`Generated index.html is out of sync: expected ${meals.length} gallery entries, found ${galleryArticleCount}`
);
}
if (rankingCardCount !== meals.length) {
throw new Error(
`Generated rankings.html is out of sync: expected ${meals.length} ranking cards, found ${rankingCardCount}`
);
}
if (!rankingsHtml.includes('src="assets/js/rankings.js"')) {
throw new Error("Generated rankings.html is missing the interactive rankings script");
}
const seedData = parseRankingsSeedData(rankingsHtml);
if (!Array.isArray(seedData.meals) || seedData.meals.length !== meals.length) {
throw new Error("Generated rankings.html has stale embedded meal seed data");
}
if (
!seedData.elo ||
!Array.isArray(seedData.elo.entries) ||
seedData.elo.entries.length !== eloData.entries.length
) {
throw new Error("Generated rankings.html has stale embedded Elo seed data");
}
}
function main() {
const meals = loadMeals();
const eloData = loadEloData();
const expectedIds = new Set(meals.map((meal) => meal.id));
const alignment = getEloAlignmentReport(meals, eloData);
validateMealAssets(meals);
if (alignment.missingEntryIds.length > 0 || alignment.unexpectedEntryIds.length > 0) {
const messages = [];
if (alignment.missingEntryIds.length > 0) {
messages.push(
`Missing Elo entries for meal ids: ${alignment.missingEntryIds.join(", ")}`
);
}
if (alignment.unexpectedEntryIds.length > 0) {
messages.push(
`Unexpected Elo entries with no meal: ${alignment.unexpectedEntryIds.join(", ")}`
);
}
throw new Error(messages.join("\n"));
}
const unexpectedFulls = getUnexpectedIds(fullsDir, expectedIds);
if (unexpectedFulls.length > 0) {
throw new Error(
`Unexpected full-size image files with no meal entry: ${unexpectedFulls.join(", ")}`
);
}
const unexpectedThumbs = getUnexpectedIds(thumbsDir, expectedIds);
if (unexpectedThumbs.length > 0) {
throw new Error(
`Unexpected thumbnail files with no meal entry: ${unexpectedThumbs.join(", ")}`
);
}
validateGeneratedPages(meals, eloData);
console.log(
`Validation passed: ${meals.length} meals, ${eloData.entries.length} Elo entries, generated pages and image assets are in sync.`
);
}
if (require.main === module) {
try {
main();
} catch (error) {
console.error(error.message);
process.exitCode = 1;
}
}

View File

@@ -6,7 +6,9 @@ const { loadMeals, repoRoot } = require("./lib/meals");
const fullsDir = path.join(repoRoot, "images", "fulls"); const fullsDir = path.join(repoRoot, "images", "fulls");
const thumbsDir = path.join(repoRoot, "images", "thumbs"); const thumbsDir = path.join(repoRoot, "images", "thumbs");
const manifestPath = path.join(thumbsDir, ".thumbs-manifest.json");
const THUMB_VERSION = 1;
const THUMB_WIDTH = 240; const THUMB_WIDTH = 240;
const THUMB_HEIGHT = 320; const THUMB_HEIGHT = 320;
const JPEG_QUALITY = 82; const JPEG_QUALITY = 82;
@@ -15,6 +17,16 @@ function clamp(value, min, max) {
return Math.min(Math.max(value, min), max); return Math.min(Math.max(value, min), max);
} }
function parseArgs(argv) {
return {
force: argv.includes("--force"),
};
}
function getManifestKey(mealId) {
return `meal:${mealId}`;
}
function getThumbnailPaths(meal) { function getThumbnailPaths(meal) {
return { return {
fullPath: path.join(fullsDir, `${meal.id}.jpg`), fullPath: path.join(fullsDir, `${meal.id}.jpg`),
@@ -22,6 +34,24 @@ function getThumbnailPaths(meal) {
}; };
} }
function getOrientedDimensions(metadata) {
if (!metadata.width || !metadata.height) {
throw new Error("Could not determine image dimensions");
}
if ([5, 6, 7, 8].includes(metadata.orientation)) {
return {
width: metadata.height,
height: metadata.width,
};
}
return {
width: metadata.width,
height: metadata.height,
};
}
function getCropArea(width, height, focus) { function getCropArea(width, height, focus) {
const targetRatio = THUMB_WIDTH / THUMB_HEIGHT; const targetRatio = THUMB_WIDTH / THUMB_HEIGHT;
const sourceRatio = width / height; const sourceRatio = width / height;
@@ -48,46 +78,161 @@ function getCropArea(width, height, focus) {
}; };
} }
async function generateThumbnail(meal) { function loadManifest() {
if (!fs.existsSync(manifestPath)) {
return {};
}
return JSON.parse(fs.readFileSync(manifestPath, "utf8"));
}
function getThumbSignature(meal, sourceStats) {
return {
version: THUMB_VERSION,
width: THUMB_WIDTH,
height: THUMB_HEIGHT,
quality: JPEG_QUALITY,
mtimeMs: sourceStats.mtimeMs,
size: sourceStats.size,
focus: meal.thumbnail?.focus ?? null,
};
}
function manifestEntryMatches(currentEntry, nextEntry) {
return JSON.stringify(currentEntry) === JSON.stringify(nextEntry);
}
async function removeStaleThumbnails(expectedIds) {
if (!fs.existsSync(thumbsDir)) {
return 0;
}
const entries = await fs.promises.readdir(thumbsDir, { withFileTypes: true });
let removed = 0;
for (const entry of entries) {
if (!entry.isFile() || path.extname(entry.name).toLowerCase() !== ".jpg") {
continue;
}
const id = path.basename(entry.name, path.extname(entry.name));
if (expectedIds.has(id)) {
continue;
}
await fs.promises.unlink(path.join(thumbsDir, entry.name));
removed += 1;
}
return removed;
}
async function generateThumbnail(meal, manifest, options) {
const { fullPath, thumbPath } = getThumbnailPaths(meal); const { fullPath, thumbPath } = getThumbnailPaths(meal);
if (!fs.existsSync(fullPath)) { if (!fs.existsSync(fullPath)) {
throw new Error(`Missing full-size image for meal ${meal.id}: ${fullPath}`); throw new Error(`Missing full-size image for meal ${meal.id}: ${fullPath}`);
} }
const image = sharp(fullPath); const sourceStats = await fs.promises.stat(fullPath);
const metadata = await image.metadata(); const signature = getThumbSignature(meal, sourceStats);
const thumbExists = fs.existsSync(thumbPath);
if (!metadata.width || !metadata.height) { if (
throw new Error(`Could not read image dimensions for ${fullPath}`); !options.force &&
thumbExists &&
manifestEntryMatches(manifest[getManifestKey(meal.id)], signature)
) {
return {
mealId: meal.id,
changed: false,
manifestEntry: signature,
};
} }
const cropArea = getCropArea( const image = sharp(fullPath);
metadata.width, const metadata = await image.metadata();
metadata.height, const { width, height } = getOrientedDimensions(metadata);
meal.thumbnail?.focus
); const cropArea = getCropArea(width, height, meal.thumbnail?.focus);
await image await image
.rotate()
.extract(cropArea) .extract(cropArea)
.resize(THUMB_WIDTH, THUMB_HEIGHT) .resize(THUMB_WIDTH, THUMB_HEIGHT)
.jpeg({ quality: JPEG_QUALITY, mozjpeg: true }) .jpeg({ quality: JPEG_QUALITY, mozjpeg: true })
.toFile(thumbPath); .toFile(thumbPath);
return {
mealId: meal.id,
changed: true,
manifestEntry: signature,
};
}
function writeManifest(manifest) {
const sortedEntries = Object.entries(manifest).sort(([left], [right]) =>
left.localeCompare(right, undefined, { numeric: true })
);
const orderedManifest = Object.fromEntries(sortedEntries);
fs.writeFileSync(manifestPath, `${JSON.stringify(orderedManifest, null, 2)}\n`);
} }
async function main() { async function main() {
const options = parseArgs(process.argv.slice(2));
const summary = await buildThumbnails(options);
console.log(
`Thumbnail build complete: ${summary.generated} generated, ${summary.skipped} skipped, ${summary.removed} removed`
);
}
async function buildThumbnails(options = {}) {
const settings = {
force: false,
...options,
};
const meals = loadMeals(); const meals = loadMeals();
const manifest = loadManifest();
const nextManifest = {};
const expectedIds = new Set(meals.map((meal) => meal.id));
await fs.promises.mkdir(thumbsDir, { recursive: true }); await fs.promises.mkdir(thumbsDir, { recursive: true });
const removed = await removeStaleThumbnails(expectedIds);
let generated = 0;
let skipped = 0;
for (const meal of meals) { for (const meal of meals) {
await generateThumbnail(meal); const result = await generateThumbnail(meal, manifest, settings);
nextManifest[getManifestKey(meal.id)] = result.manifestEntry;
if (result.changed) {
generated += 1;
} else {
skipped += 1;
}
} }
console.log(`Generated ${meals.length} thumbnails in images/thumbs`); writeManifest(nextManifest);
return {
generated,
removed,
skipped,
total: meals.length,
};
} }
main().catch((error) => { if (require.main === module) {
main().catch((error) => {
console.error(error.message); console.error(error.message);
process.exitCode = 1; process.exitCode = 1;
}); });
}
module.exports = {
buildThumbnails,
};

246
scripts/ingest-meal.js Normal file
View File

@@ -0,0 +1,246 @@
const fs = require("fs");
const path = require("path");
const sharp = require("sharp");
const { buildPages } = require("./build");
const { buildThumbnails } = require("./generate-thumbnails");
const { eloPath } = require("./lib/elo");
const {
getNextMealId,
loadMeals,
mealsPath,
repoRoot,
saveMeals,
} = require("./lib/meals");
const fullsDir = path.join(repoRoot, "images", "fulls");
const thumbsDir = path.join(repoRoot, "images", "thumbs");
const indexPath = path.join(repoRoot, "index.html");
const rankingsPath = path.join(repoRoot, "rankings.html");
const manifestPath = path.join(thumbsDir, ".thumbs-manifest.json");
const FULL_IMAGE_QUALITY = 90;
function printHelp() {
console.log(`Usage:
npm run ingest -- --image <path> --title <title> --description <text> [options]
Required:
--image <path> Source image to ingest
--title <title> Meal title
--description <text> Meal description
Optional:
--position <value> Viewer background position, e.g. "left center"
--focus-x <0..1> Thumbnail crop focal point x coordinate
--focus-y <0..1> Thumbnail crop focal point y coordinate
--help Show this help message
`);
}
function parseArgs(argv) {
const options = {};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--help" || arg === "-h") {
options.help = true;
continue;
}
if (!arg.startsWith("--")) {
throw new Error(`Unexpected argument "${arg}"`);
}
const key = arg.slice(2);
const value = argv[index + 1];
if (value === undefined || value.startsWith("--")) {
throw new Error(`Missing value for "${arg}"`);
}
options[key] = value;
index += 1;
}
return options;
}
function parseFocusValue(value, axis) {
const parsed = Number.parseFloat(value);
if (!Number.isFinite(parsed) || parsed < 0 || parsed > 1) {
throw new Error(`--focus-${axis} must be a number between 0 and 1`);
}
return parsed;
}
function buildMealFromOptions(id, options) {
const meal = {
id,
title: options.title,
description: options.description,
};
if (options.position) {
meal.position = options.position;
}
if (options["focus-x"] !== undefined || options["focus-y"] !== undefined) {
if (options["focus-x"] === undefined || options["focus-y"] === undefined) {
throw new Error("Both --focus-x and --focus-y must be provided together");
}
meal.thumbnail = {
focus: {
x: parseFocusValue(options["focus-x"], "x"),
y: parseFocusValue(options["focus-y"], "y"),
},
};
}
return meal;
}
function getResolvedSourcePath(imageArg) {
const resolvedPath = path.resolve(process.cwd(), imageArg);
if (!fs.existsSync(resolvedPath)) {
throw new Error(`Source image not found: ${resolvedPath}`);
}
if (!fs.statSync(resolvedPath).isFile()) {
throw new Error(`Source image is not a file: ${resolvedPath}`);
}
return resolvedPath;
}
async function writeFullImage(sourcePath, destinationPath) {
await sharp(sourcePath)
.rotate()
.jpeg({ quality: FULL_IMAGE_QUALITY, mozjpeg: true })
.toFile(destinationPath);
}
function readOptionalFile(filePath) {
return fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8") : null;
}
async function restoreOptionalFile(filePath, previousContents) {
if (previousContents === null) {
if (fs.existsSync(filePath)) {
await fs.promises.unlink(filePath);
}
return;
}
if (previousContents !== undefined) {
fs.writeFileSync(filePath, previousContents);
}
}
async function rollback({
createdFullPath,
createdThumbPath,
previousElo,
previousIndex,
previousManifest,
previousMeals,
previousRankings,
}) {
if (previousMeals !== undefined) {
fs.writeFileSync(mealsPath, previousMeals);
}
await restoreOptionalFile(eloPath, previousElo);
if (previousIndex !== undefined) {
fs.writeFileSync(indexPath, previousIndex);
}
await restoreOptionalFile(rankingsPath, previousRankings);
await restoreOptionalFile(manifestPath, previousManifest);
if (createdThumbPath && fs.existsSync(createdThumbPath)) {
await fs.promises.unlink(createdThumbPath);
}
if (createdFullPath && fs.existsSync(createdFullPath)) {
await fs.promises.unlink(createdFullPath);
}
}
async function ingestMeal(options) {
const sourcePath = getResolvedSourcePath(options.image);
const meals = loadMeals();
const nextId = getNextMealId(meals);
const meal = buildMealFromOptions(nextId, options);
const fullPath = path.join(fullsDir, `${nextId}.jpg`);
const thumbPath = path.join(thumbsDir, `${nextId}.jpg`);
if (fs.existsSync(fullPath) || fs.existsSync(thumbPath)) {
throw new Error(`Meal id ${nextId} already has generated image files`);
}
const previousMeals = fs.readFileSync(mealsPath, "utf8");
const previousElo = readOptionalFile(eloPath);
const previousIndex = fs.readFileSync(indexPath, "utf8");
const previousRankings = readOptionalFile(rankingsPath);
const previousManifest = readOptionalFile(manifestPath);
await fs.promises.mkdir(fullsDir, { recursive: true });
try {
await writeFullImage(sourcePath, fullPath);
saveMeals([...meals, meal]);
await buildThumbnails();
buildPages();
} catch (error) {
await rollback({
createdFullPath: fullPath,
createdThumbPath: thumbPath,
previousElo,
previousIndex,
previousManifest,
previousMeals,
previousRankings,
});
throw error;
}
return meal;
}
async function main() {
const options = parseArgs(process.argv.slice(2));
if (options.help) {
printHelp();
return;
}
for (const field of ["image", "title", "description"]) {
if (!options[field]) {
throw new Error(`Missing required option "--${field}"`);
}
}
const meal = await ingestMeal(options);
console.log(`Ingested meal ${meal.id}: ${meal.title}`);
}
if (require.main === module) {
main().catch((error) => {
console.error(error.message);
process.exitCode = 1;
});
}
module.exports = {
ingestMeal,
};

154
scripts/lib/elo.js Normal file
View File

@@ -0,0 +1,154 @@
const fs = require("fs");
const path = require("path");
const { repoRoot } = require("./meals");
const eloPath = path.join(repoRoot, "data", "elo.json");
function validateEloData(eloData) {
if (!eloData || typeof eloData !== "object" || Array.isArray(eloData)) {
throw new Error("data/elo.json must contain an object");
}
for (const field of ["defaultRating", "kFactor"]) {
if (
typeof eloData[field] !== "number" ||
!Number.isFinite(eloData[field]) ||
eloData[field] <= 0
) {
throw new Error(`data/elo.json "${field}" must be a positive number`);
}
}
if (!Array.isArray(eloData.entries)) {
throw new Error('data/elo.json "entries" must be an array');
}
const ids = new Set();
for (const [index, entry] of eloData.entries.entries()) {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
throw new Error(`Elo entry ${index} must be an object`);
}
if (typeof entry.id !== "string" || !/^\d+$/.test(entry.id)) {
throw new Error(`Elo entry ${index} has an invalid id`);
}
if (ids.has(entry.id)) {
throw new Error(`Duplicate Elo entry id "${entry.id}" found in data/elo.json`);
}
ids.add(entry.id);
if (
typeof entry.rating !== "number" ||
!Number.isFinite(entry.rating) ||
entry.rating <= 0
) {
throw new Error(`Elo entry ${index} must have a positive numeric rating`);
}
for (const field of ["wins", "losses"]) {
if (!Number.isInteger(entry[field]) || entry[field] < 0) {
throw new Error(`Elo entry ${index} field "${field}" must be a non-negative integer`);
}
}
}
}
function loadEloData() {
const eloData = JSON.parse(fs.readFileSync(eloPath, "utf8"));
validateEloData(eloData);
return eloData;
}
function saveEloData(eloData) {
validateEloData(eloData);
fs.writeFileSync(eloPath, `${JSON.stringify(eloData, null, 2)}\n`);
}
function createDefaultEntry(id, defaultRating) {
return {
id,
rating: defaultRating,
wins: 0,
losses: 0,
};
}
function syncEloWithMeals(meals) {
const eloData = loadEloData();
const entryById = new Map(eloData.entries.map((entry) => [entry.id, entry]));
const syncedData = {
...eloData,
entries: meals.map((meal) => {
const existingEntry = entryById.get(meal.id);
return existingEntry
? { ...existingEntry }
: createDefaultEntry(meal.id, eloData.defaultRating);
}),
};
if (JSON.stringify(syncedData) !== JSON.stringify(eloData)) {
saveEloData(syncedData);
}
return syncedData;
}
function getEloAlignmentReport(meals, eloData) {
const mealIds = new Set(meals.map((meal) => meal.id));
const eloIds = new Set(eloData.entries.map((entry) => entry.id));
return {
missingEntryIds: meals
.map((meal) => meal.id)
.filter((mealId) => !eloIds.has(mealId)),
unexpectedEntryIds: eloData.entries
.map((entry) => entry.id)
.filter((entryId) => !mealIds.has(entryId)),
};
}
function compareRankedMeals(left, right) {
if (right.rating !== left.rating) {
return right.rating - left.rating;
}
if (right.matches !== left.matches) {
return right.matches - left.matches;
}
return Number.parseInt(left.id, 10) - Number.parseInt(right.id, 10);
}
function getRankedMeals(meals, eloData) {
const entryById = new Map(eloData.entries.map((entry) => [entry.id, entry]));
return meals
.map((meal) => {
const entry = entryById.get(meal.id) || createDefaultEntry(meal.id, eloData.defaultRating);
const matches = entry.wins + entry.losses;
return {
...meal,
rating: entry.rating,
wins: entry.wins,
losses: entry.losses,
matches,
};
})
.sort(compareRankedMeals);
}
module.exports = {
eloPath,
getEloAlignmentReport,
getRankedMeals,
loadEloData,
saveEloData,
syncEloWithMeals,
};

View File

@@ -3,6 +3,12 @@ const path = require("path");
const repoRoot = path.resolve(__dirname, "..", ".."); const repoRoot = path.resolve(__dirname, "..", "..");
const mealsPath = path.join(repoRoot, "data", "meals.json"); const mealsPath = path.join(repoRoot, "data", "meals.json");
const fullsDir = path.join(repoRoot, "images", "fulls");
const thumbsDir = path.join(repoRoot, "images", "thumbs");
function isNonEmptyString(value) {
return typeof value === "string" && value.trim().length > 0;
}
function validateThumbnail(meal, index) { function validateThumbnail(meal, index) {
if (meal.thumbnail === undefined) { if (meal.thumbnail === undefined) {
@@ -47,21 +53,33 @@ function validateMeals(meals) {
throw new Error("data/meals.json must contain an array"); throw new Error("data/meals.json must contain an array");
} }
const ids = new Set();
for (const [index, meal] of meals.entries()) { for (const [index, meal] of meals.entries()) {
if (!meal || typeof meal !== "object") { if (!meal || typeof meal !== "object") {
throw new Error(`Meal at index ${index} must be an object`); throw new Error(`Meal at index ${index} must be an object`);
} }
for (const field of ["id", "title", "description"]) { for (const field of ["id", "title", "description"]) {
if (typeof meal[field] !== "string" || meal[field].length === 0) { if (!isNonEmptyString(meal[field])) {
throw new Error(`Meal ${index} is missing required string field "${field}"`); throw new Error(`Meal ${index} is missing required string field "${field}"`);
} }
} }
if (meal.position !== undefined && typeof meal.position !== "string") { if (!/^\d+$/.test(meal.id)) {
throw new Error(`Meal ${index} has a non-string "position" value`); throw new Error(`Meal ${index} has a non-numeric id "${meal.id}"`);
} }
if (meal.position !== undefined && !isNonEmptyString(meal.position)) {
throw new Error(`Meal ${index} has an invalid "position" value`);
}
if (ids.has(meal.id)) {
throw new Error(`Duplicate meal id "${meal.id}" found in data/meals.json`);
}
ids.add(meal.id);
validateThumbnail(meal, index); validateThumbnail(meal, index);
} }
} }
@@ -74,8 +92,78 @@ function loadMeals() {
return meals; return meals;
} }
function saveMeals(meals) {
validateMeals(meals);
fs.writeFileSync(mealsPath, `${JSON.stringify(meals, null, 2)}\n`);
}
function getMealImagePaths(mealOrId) {
const mealId =
typeof mealOrId === "string" ? mealOrId : mealOrId && typeof mealOrId.id === "string" ? mealOrId.id : null;
if (!mealId) {
throw new Error("Expected a meal object or meal id string");
}
return {
fullPath: path.join(fullsDir, `${mealId}.jpg`),
thumbPath: path.join(thumbsDir, `${mealId}.jpg`),
};
}
function validateMealAssets(meals, options = {}) {
const settings = {
requireFull: true,
requireThumb: true,
...options,
};
const missingAssets = [];
for (const meal of meals) {
const { fullPath, thumbPath } = getMealImagePaths(meal);
if (settings.requireFull && !fs.existsSync(fullPath)) {
missingAssets.push(
`Meal ${meal.id} is missing full-size image: ${path.relative(repoRoot, fullPath)}`
);
}
if (settings.requireThumb && !fs.existsSync(thumbPath)) {
missingAssets.push(
`Meal ${meal.id} is missing thumbnail image: ${path.relative(repoRoot, thumbPath)}`
);
}
}
if (missingAssets.length > 0) {
throw new Error(`Missing image assets:\n${missingAssets.join("\n")}`);
}
}
function getNextMealId(meals) {
if (meals.length === 0) {
return "01";
}
const nextNumber =
Math.max(...meals.map((meal) => Number.parseInt(meal.id, 10))) + 1;
const idWidth = Math.max(
2,
...meals.map((meal) => meal.id.length),
String(nextNumber).length
);
return String(nextNumber).padStart(idWidth, "0");
}
module.exports = { module.exports = {
fullsDir,
getNextMealId,
getMealImagePaths,
loadMeals, loadMeals,
mealsPath, mealsPath,
repoRoot, repoRoot,
saveMeals,
thumbsDir,
validateMealAssets,
}; };

View File

@@ -0,0 +1,380 @@
const fs = require("fs");
const path = require("path");
const { loadEloData } = require("./elo");
const { loadMeals, repoRoot } = require("./meals");
const STATE_VERSION = 1;
const defaultStatePath = path.join(repoRoot, ".runtime", "rankings-state.json");
function isValidMealId(id) {
return typeof id === "string" && /^\d+$/.test(id);
}
function createDefaultEntry(id, defaultRating) {
return {
id,
rating: defaultRating,
wins: 0,
losses: 0,
};
}
function cloneEntry(entry) {
return {
id: entry.id,
rating: entry.rating,
wins: entry.wins,
losses: entry.losses,
};
}
function roundRating(rating) {
return Math.round(rating * 1000) / 1000;
}
function expectedScore(rating, opponentRating) {
return 1 / (1 + Math.pow(10, (opponentRating - rating) / 400));
}
function isValidStoredEntry(entry) {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
return false;
}
if (typeof entry.id !== "string" || !/^\d+$/.test(entry.id)) {
return false;
}
if (typeof entry.rating !== "number" || !Number.isFinite(entry.rating) || entry.rating <= 0) {
return false;
}
return ["wins", "losses"].every(
(field) => Number.isInteger(entry[field]) && entry[field] >= 0
);
}
function createSeedState(seedData) {
return {
version: STATE_VERSION,
voteCount: 0,
lastPairKey: null,
updatedAt: null,
undo: null,
elo: {
defaultRating: seedData.elo.defaultRating,
kFactor: seedData.elo.kFactor,
entries: seedData.meals.map((meal) => {
const seedEntry =
seedData.elo.entries.find((entry) => entry.id === meal.id) ||
createDefaultEntry(meal.id, seedData.elo.defaultRating);
return cloneEntry(seedEntry);
}),
},
};
}
function syncStateCore(seedData, storedState) {
if (!storedState || typeof storedState !== "object" || Array.isArray(storedState)) {
return createSeedState(seedData);
}
const storedEntries = Array.isArray(storedState.elo?.entries)
? storedState.elo.entries.filter(isValidStoredEntry)
: [];
const storedEntryById = new Map(storedEntries.map((entry) => [entry.id, entry]));
const seedEntryById = new Map(seedData.elo.entries.map((entry) => [entry.id, entry]));
const entries = seedData.meals.map((meal) => {
const storedEntry = storedEntryById.get(meal.id);
if (storedEntry) {
return cloneEntry(storedEntry);
}
const seedEntry =
seedEntryById.get(meal.id) || createDefaultEntry(meal.id, seedData.elo.defaultRating);
return cloneEntry(seedEntry);
});
const derivedVoteCount = entries.reduce((sum, entry) => sum + entry.wins, 0);
return {
version: STATE_VERSION,
voteCount:
Number.isInteger(storedState.voteCount) && storedState.voteCount >= derivedVoteCount
? storedState.voteCount
: derivedVoteCount,
lastPairKey: typeof storedState.lastPairKey === "string" ? storedState.lastPairKey : null,
updatedAt: typeof storedState.updatedAt === "string" ? storedState.updatedAt : null,
undo: null,
elo: {
defaultRating: seedData.elo.defaultRating,
kFactor: seedData.elo.kFactor,
entries,
},
};
}
function createPairKey(leftId, rightId) {
return [leftId, rightId].sort().join(":");
}
function createUndoSnapshot(state) {
return {
voteCount: state.voteCount,
lastPairKey: state.lastPairKey,
updatedAt: state.updatedAt,
elo: {
defaultRating: state.elo.defaultRating,
kFactor: state.elo.kFactor,
entries: state.elo.entries.map(cloneEntry),
},
};
}
function resolveUndoPairIds(winnerId, loserId, leftId, rightId) {
const validPair =
isValidMealId(leftId) &&
isValidMealId(rightId) &&
leftId !== rightId &&
[leftId, rightId].includes(winnerId) &&
[leftId, rightId].includes(loserId);
if (validPair) {
return { leftId, rightId };
}
return { leftId: winnerId, rightId: loserId };
}
function syncUndo(seedData, storedUndo) {
if (!storedUndo || typeof storedUndo !== "object" || Array.isArray(storedUndo)) {
return null;
}
const { leftId, rightId, winnerId, loserId, snapshot } = storedUndo;
if (
!isValidMealId(leftId) ||
!isValidMealId(rightId) ||
!isValidMealId(winnerId) ||
!isValidMealId(loserId) ||
leftId === rightId ||
winnerId === loserId
) {
return null;
}
const pairIds = new Set([leftId, rightId]);
const mealIds = new Set(seedData.meals.map((meal) => meal.id));
if (
!pairIds.has(winnerId) ||
!pairIds.has(loserId) ||
![leftId, rightId, winnerId, loserId].every((id) => mealIds.has(id))
) {
return null;
}
return {
pairKey: createPairKey(leftId, rightId),
leftId,
rightId,
winnerId,
loserId,
snapshot: createUndoSnapshot(syncStateCore(seedData, snapshot)),
};
}
function syncStoredState(seedData, storedState) {
const nextState = syncStateCore(seedData, storedState);
return {
...nextState,
undo: syncUndo(seedData, storedState?.undo),
};
}
function restoreUndoSnapshot(seedData, snapshot) {
return {
...syncStateCore(seedData, snapshot),
undo: null,
};
}
function applyVote(seedData, state, vote) {
const { winnerId, loserId, pairKey, leftId, rightId } = vote;
const entryById = new Map(state.elo.entries.map((entry) => [entry.id, cloneEntry(entry)]));
const winner = entryById.get(winnerId);
const loser = entryById.get(loserId);
const undoPair = resolveUndoPairIds(winnerId, loserId, leftId, rightId);
const resolvedPairKey =
typeof pairKey === "string" && pairKey.length > 0
? pairKey
: createPairKey(undoPair.leftId, undoPair.rightId);
if (!winner || !loser) {
throw new Error("Vote referenced an unknown meal id");
}
const winnerExpected = expectedScore(winner.rating, loser.rating);
const loserExpected = expectedScore(loser.rating, winner.rating);
winner.rating = roundRating(winner.rating + state.elo.kFactor * (1 - winnerExpected));
loser.rating = roundRating(loser.rating + state.elo.kFactor * (0 - loserExpected));
winner.wins += 1;
loser.losses += 1;
return {
...state,
voteCount: state.voteCount + 1,
lastPairKey: resolvedPairKey,
updatedAt: new Date().toISOString(),
undo: {
pairKey: createPairKey(undoPair.leftId, undoPair.rightId),
leftId: undoPair.leftId,
rightId: undoPair.rightId,
winnerId,
loserId,
snapshot: createUndoSnapshot(state),
},
elo: {
...state.elo,
entries: seedData.meals.map((meal) => entryById.get(meal.id)),
},
};
}
function undoVote(seedData, state) {
if (!state.undo) {
return state;
}
return restoreUndoSnapshot(seedData, state.undo.snapshot);
}
function resolveStatePath(statePath) {
return statePath ? path.resolve(statePath) : defaultStatePath;
}
function loadSeedData() {
return {
meals: loadMeals(),
elo: loadEloData(),
};
}
function readPersistedState(statePath) {
if (!fs.existsSync(statePath)) {
return null;
}
return JSON.parse(fs.readFileSync(statePath, "utf8"));
}
function writePersistedState(statePath, state) {
fs.mkdirSync(path.dirname(statePath), { recursive: true });
const nextState = `${JSON.stringify(state, null, 2)}\n`;
const tempPath = `${statePath}.tmp`;
fs.writeFileSync(tempPath, nextState);
fs.renameSync(tempPath, statePath);
}
function loadRankingsState(options = {}) {
const statePath = resolveStatePath(options.statePath);
const seedData = loadSeedData();
const storedState = readPersistedState(statePath);
return syncStoredState(seedData, storedState);
}
function saveRankingsState(state, options = {}) {
const statePath = resolveStatePath(options.statePath);
const seedData = loadSeedData();
const nextState = syncStoredState(seedData, state);
writePersistedState(statePath, nextState);
return nextState;
}
function recordVote(vote, options = {}) {
if (!vote || typeof vote !== "object" || Array.isArray(vote)) {
throw new Error("Vote payload must be an object");
}
const { winnerId, loserId, pairKey, leftId, rightId } = vote;
if (!isValidMealId(winnerId)) {
throw new Error("Vote payload is missing a valid winnerId");
}
if (!isValidMealId(loserId)) {
throw new Error("Vote payload is missing a valid loserId");
}
if (winnerId === loserId) {
throw new Error("winnerId and loserId must be different");
}
if (leftId !== undefined || rightId !== undefined) {
if (!isValidMealId(leftId) || !isValidMealId(rightId) || leftId === rightId) {
throw new Error("Vote payload is missing a valid left/right pair");
}
if (![leftId, rightId].includes(winnerId) || ![leftId, rightId].includes(loserId)) {
throw new Error("Vote payload leftId/rightId must match winnerId/loserId");
}
}
const statePath = resolveStatePath(options.statePath);
const seedData = loadSeedData();
const storedState = syncStoredState(seedData, readPersistedState(statePath));
const nextState = applyVote(seedData, storedState, {
winnerId,
loserId,
pairKey,
leftId,
rightId,
});
writePersistedState(statePath, nextState);
return nextState;
}
function undoLastRankingsVote(options = {}) {
const statePath = resolveStatePath(options.statePath);
const seedData = loadSeedData();
const storedState = syncStoredState(seedData, readPersistedState(statePath));
const nextState = undoVote(seedData, storedState);
writePersistedState(statePath, nextState);
return nextState;
}
function resetRankingsState(options = {}) {
const statePath = resolveStatePath(options.statePath);
const seedData = loadSeedData();
const nextState = createSeedState(seedData);
writePersistedState(statePath, nextState);
return nextState;
}
module.exports = {
createSeedState,
defaultStatePath,
loadRankingsState,
recordVote,
resetRankingsState,
saveRankingsState,
syncStoredState,
undoLastRankingsVote,
};

302
scripts/serve.js Normal file
View File

@@ -0,0 +1,302 @@
const fs = require("fs");
const http = require("http");
const path = require("path");
const { repoRoot } = require("./lib/meals");
const {
defaultStatePath,
loadRankingsState,
recordVote,
resetRankingsState,
undoLastRankingsVote,
} = require("./lib/rankings-state");
const DEFAULT_HOST = "127.0.0.1";
const DEFAULT_PORT = 4321;
const MAX_REQUEST_BODY_BYTES = 16 * 1024;
const MIME_TYPES = {
".css": "text/css; charset=utf-8",
".gif": "image/gif",
".html": "text/html; charset=utf-8",
".ico": "image/x-icon",
".jpg": "image/jpeg",
".js": "application/javascript; charset=utf-8",
".json": "application/json; charset=utf-8",
".map": "application/json; charset=utf-8",
".mp3": "audio/mpeg",
".png": "image/png",
".svg": "image/svg+xml",
".ttf": "font/ttf",
".txt": "text/plain; charset=utf-8",
".webmanifest": "application/manifest+json; charset=utf-8",
".woff": "font/woff",
".woff2": "font/woff2",
};
function parseArgs(argv) {
const options = {
host: process.env.HOST || DEFAULT_HOST,
port: Number.parseInt(process.env.PORT || String(DEFAULT_PORT), 10),
rankingsStatePath: path.resolve(process.env.RANKINGS_STATE_PATH || defaultStatePath),
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
const value = argv[index + 1];
if (arg === "--host" && value) {
options.host = value;
index += 1;
continue;
}
if (arg === "--port" && value) {
options.port = Number.parseInt(value, 10);
index += 1;
continue;
}
if (arg === "--rankings-state-path" && value) {
options.rankingsStatePath = path.resolve(value);
index += 1;
}
}
if (!Number.isInteger(options.port) || options.port <= 0 || options.port > 65535) {
throw new Error("Port must be an integer between 1 and 65535");
}
return options;
}
function getContentType(filePath) {
return MIME_TYPES[path.extname(filePath).toLowerCase()] || "application/octet-stream";
}
function hasHiddenSegment(pathname) {
return pathname.split("/").some((segment) => segment.startsWith(".") && segment.length > 1);
}
function resolveRequestPath(pathname) {
if (hasHiddenSegment(pathname)) {
return null;
}
const requestedPath = pathname === "/" ? "/index.html" : pathname;
const absolutePath = path.resolve(repoRoot, `.${requestedPath}`);
const withinRepo =
absolutePath === repoRoot || absolutePath.startsWith(`${repoRoot}${path.sep}`);
if (!withinRepo) {
return null;
}
if (fs.existsSync(absolutePath) && fs.statSync(absolutePath).isDirectory()) {
return path.join(absolutePath, "index.html");
}
if (
path.extname(absolutePath) === "" &&
!fs.existsSync(absolutePath) &&
fs.existsSync(`${absolutePath}.html`) &&
fs.statSync(`${absolutePath}.html`).isFile()
) {
return `${absolutePath}.html`;
}
return absolutePath;
}
function sendJson(response, statusCode, payload) {
response.writeHead(statusCode, {
"Cache-Control": "no-store",
"Content-Type": "application/json; charset=utf-8",
});
response.end(`${JSON.stringify(payload, null, 2)}\n`);
}
function sendText(response, statusCode, body) {
response.writeHead(statusCode, {
"Cache-Control": "no-store",
"Content-Type": "text/plain; charset=utf-8",
});
response.end(body);
}
function sendRedirect(response, location) {
response.writeHead(301, {
Location: location,
"Cache-Control": "no-store",
});
response.end();
}
function parseJsonBody(request) {
return new Promise((resolve, reject) => {
const chunks = [];
let totalBytes = 0;
request.on("data", (chunk) => {
totalBytes += chunk.length;
if (totalBytes > MAX_REQUEST_BODY_BYTES) {
reject(new Error("Request body is too large"));
request.destroy();
return;
}
chunks.push(chunk);
});
request.on("end", () => {
if (chunks.length === 0) {
resolve({});
return;
}
try {
resolve(JSON.parse(Buffer.concat(chunks).toString("utf8")));
} catch (error) {
reject(new Error("Request body must be valid JSON"));
}
});
request.on("error", reject);
});
}
function sendFile(response, filePath) {
const stream = fs.createReadStream(filePath);
response.writeHead(200, {
"Content-Type": getContentType(filePath),
"Cache-Control": "no-cache",
});
stream.pipe(response);
stream.on("error", () => {
sendText(response, 500, "Failed to read file");
});
}
async function handleApiRequest(request, response, pathname, options) {
if (pathname === "/api/rankings" && request.method === "GET") {
sendJson(response, 200, loadRankingsState({ statePath: options.rankingsStatePath }));
return true;
}
if (pathname === "/api/rankings/vote" && request.method === "POST") {
const body = await parseJsonBody(request);
sendJson(response, 200, recordVote(body, { statePath: options.rankingsStatePath }));
return true;
}
if (pathname === "/api/rankings/reset" && request.method === "POST") {
sendJson(response, 200, resetRankingsState({ statePath: options.rankingsStatePath }));
return true;
}
if (pathname === "/api/rankings/undo" && request.method === "POST") {
sendJson(response, 200, undoLastRankingsVote({ statePath: options.rankingsStatePath }));
return true;
}
if (
pathname === "/api/rankings" ||
pathname === "/api/rankings/vote" ||
pathname === "/api/rankings/reset" ||
pathname === "/api/rankings/undo"
) {
sendText(response, 405, "Method not allowed");
return true;
}
if (pathname.startsWith("/api/")) {
sendText(response, 404, "Not found");
return true;
}
return false;
}
function createServer(options) {
return http.createServer(async (request, response) => {
let pathname;
let search;
try {
const requestUrl = new URL(request.url || "/", "http://localhost");
pathname = decodeURIComponent(requestUrl.pathname);
search = requestUrl.search;
} catch (error) {
sendText(response, 400, "Bad request");
return;
}
if (request.method === "GET" || request.method === "HEAD") {
if (pathname === "/index.html") {
sendRedirect(response, `/${search}`);
return;
}
if (pathname.endsWith(".html") && pathname !== "/index.html") {
sendRedirect(response, `${pathname.slice(0, -5)}${search}`);
return;
}
}
try {
if (await handleApiRequest(request, response, pathname, options)) {
return;
}
} catch (error) {
const statusCode =
error.message === "Request body must be valid JSON" ||
error.message === "Request body is too large" ||
error.message.startsWith("Vote payload") ||
error.message === "winnerId and loserId must be different"
? 400
: 500;
if (statusCode >= 500) {
console.error(error);
}
sendJson(response, statusCode, { error: error.message });
return;
}
const filePath = resolveRequestPath(pathname);
if (!filePath) {
sendText(response, 403, "Forbidden");
return;
}
if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
sendText(response, 404, "Not found");
return;
}
sendFile(response, filePath);
});
}
function main() {
const options = parseArgs(process.argv.slice(2));
const server = createServer(options);
server.listen(options.port, options.host, () => {
console.log(`Serving ${repoRoot} at http://${options.host}:${options.port}`);
console.log(`Rankings state path: ${options.rankingsStatePath}`);
});
}
if (require.main === module) {
try {
main();
} catch (error) {
console.error(error.message);
process.exitCode = 1;
}
}

View File

@@ -29,6 +29,11 @@
<img src="images/meow.gif" alt="meow" id="gifone"> <img src="images/meow.gif" alt="meow" id="gifone">
<h1>for vham :3</h1> <h1>for vham :3</h1>
<p id="haiku">Please enable javascript >.<</p> <p id="haiku">Please enable javascript >.<</p>
<p class="page-links">
<a href="./" aria-current="page">gallery</a>
<span class="page-links__separator">/</span>
<a href="rankings">rankings</a>
</p>
</header> </header>
<!-- Thumbnail --> <!-- Thumbnail -->

84
templates/rankings.html Normal file
View File

@@ -0,0 +1,84 @@
<!DOCTYPE HTML>
<!--
Lens by HTML5 UP
html5up.net | @ajlkn
Free for personal and commercial use under the CCA 3.0 license (html5up.net/license)
-->
<html class="rankings-html">
<head>
<title>food rankings</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" />
<link rel="stylesheet" href="assets/css/main.css" />
<link rel="stylesheet" href="assets/css/nyaa.css" />
<link rel="stylesheet" href="assets/css/rankings.css" />
<noscript><link rel="stylesheet" href="assets/css/noscript.css" /></noscript>
<link rel="apple-touch-icon" sizes="180x180" href="../favicon/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="../favicon/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="../favicon/favicon-16x16.png">
<link rel="manifest" href="../favicon/site.webmanifest">
</head>
<body class="rankings-page">
<!-- Main -->
<div id="main">
<!-- Header -->
<header id="header">
<img src="images/meow.gif" alt="meow" id="gifone">
<h1>food power rankings</h1>
<p>pick the better meal, one pair at a time, and the board updates live in this browser.</p>
<p class="page-links">
<a href="./">gallery</a>
<span class="page-links__separator">/</span>
<a href="rankings" aria-current="page">rankings</a>
</p>
</header>
<!-- Voting -->
<section id="voting">
<div class="voting-panel">
<div class="voting-panel__intro">
<p class="voting-panel__eyebrow">Head-To-Head Voting</p>
<h2>Pick the winner.</h2>
<p class="vote-status" id="vote-status" aria-live="polite">Enable JavaScript to load saved rankings.</p>
</div>
<div class="voting-panel__actions">
<button class="button small" id="skip-pair" type="button">skip pair</button>
<button class="button small" id="undo-vote" type="button">go back</button>
<button class="button small" id="reset-rankings" type="button">reset saved rankings</button>
</div>
<p class="vote-message" id="vote-message" aria-live="polite">Enable JavaScript to load head-to-head voting.</p>
<div class="duel-grid" id="duel-cards">
<p class="duel-placeholder">Enable JavaScript to compare meals here.</p>
</div>
<p class="vote-hint">Tip: use the left and right arrow keys to vote faster, or press Z to go back.</p>
</div>
</section>
<!-- Rankings Summary -->
<section id="rankings-summary">
{{ranking_summary}}
</section>
<!-- Rankings -->
<section id="rankings">
{{ranking_items}}
</section>
<!-- Footer -->
<footer id="footer">
<ul class="copyright">
<li>&copy; Ryan Chou. 2026.</li>
</ul>
<img src="images/nyaa.gif" alt="nyaa" id="giftwo">
</footer>
</div>
<script id="rankings-seed-data" type="application/json">
{{rankings_seed_data}}
</script>
<script src="assets/js/rankings.js"></script>
</body>
</html>