Compare commits

...

7 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
18 changed files with 2689 additions and 58 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
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"]

100
README.md
View File

@@ -2,7 +2,7 @@
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
@@ -16,11 +16,51 @@ The site is based on the HTML5 UP Lens template and currently ships as a plain s
- `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/check.js`: validates data, image assets, and generated pages
- `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
## 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
Gallery entries live in `data/meals.json`, and the build generates both `index.html` and `rankings.html` from the template and data files.
@@ -61,6 +101,59 @@ npm run build:thumbs:force
`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
- Full-size images and thumbnails share the same numeric ID
@@ -91,8 +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.
## Planned Features
1. A pairwise voting page that shows two food images at a time and updates Elo rankings based on the selected winner.
2. General cleanup and history cleanup once the bigger structural changes are in place.

View File

@@ -42,6 +42,186 @@ body.rankings-page #giftwo {
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;
}
@@ -138,6 +318,7 @@ body.rankings-page #giftwo {
}
#rankings-summary,
#voting,
#rankings,
body.rankings-page #header,
body.rankings-page #footer {
@@ -145,6 +326,14 @@ body.rankings-page #giftwo {
padding-right: 1.25rem;
}
.voting-panel {
padding: 1.25rem;
}
.duel-grid {
grid-template-columns: 1fr;
}
.ranking-card {
grid-template-columns: 1fr;
}

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);
});
})();

View File

@@ -4,7 +4,7 @@
"width": 240,
"height": 320,
"quality": 82,
"mtimeMs": 1770500129000,
"mtimeMs": 1774257676921.0168,
"size": 1052830,
"focus": {
"x": 0.35,
@@ -16,7 +16,7 @@
"width": 240,
"height": 320,
"quality": 82,
"mtimeMs": 1770500128000,
"mtimeMs": 1774257676923.4688,
"size": 835360,
"focus": null
},
@@ -25,7 +25,7 @@
"width": 240,
"height": 320,
"quality": 82,
"mtimeMs": 1770500128000,
"mtimeMs": 1774257676926.1216,
"size": 1034158,
"focus": {
"x": 0.5,
@@ -37,7 +37,7 @@
"width": 240,
"height": 320,
"quality": 82,
"mtimeMs": 1770500128000,
"mtimeMs": 1774257676928.9744,
"size": 1090215,
"focus": null
},
@@ -46,7 +46,7 @@
"width": 240,
"height": 320,
"quality": 82,
"mtimeMs": 1770500128000,
"mtimeMs": 1774257676931.93,
"size": 1122236,
"focus": {
"x": 0.5,
@@ -58,7 +58,7 @@
"width": 240,
"height": 320,
"quality": 82,
"mtimeMs": 1770444049000,
"mtimeMs": 1774257676932.7878,
"size": 676787,
"focus": null
},
@@ -67,7 +67,7 @@
"width": 240,
"height": 320,
"quality": 82,
"mtimeMs": 1770500128000,
"mtimeMs": 1774257676935.0764,
"size": 872024,
"focus": null
},
@@ -76,7 +76,7 @@
"width": 240,
"height": 320,
"quality": 82,
"mtimeMs": 1770500128000,
"mtimeMs": 1774257676936.637,
"size": 618276,
"focus": null
},
@@ -85,7 +85,7 @@
"width": 240,
"height": 320,
"quality": 82,
"mtimeMs": 1770500127000,
"mtimeMs": 1774257676938.8577,
"size": 1349804,
"focus": null
},
@@ -94,7 +94,7 @@
"width": 240,
"height": 320,
"quality": 82,
"mtimeMs": 1770500128000,
"mtimeMs": 1774257676940.0383,
"size": 1071870,
"focus": null
},
@@ -103,7 +103,7 @@
"width": 240,
"height": 320,
"quality": 82,
"mtimeMs": 1770500128000,
"mtimeMs": 1774257676941.5466,
"size": 764329,
"focus": null
},
@@ -112,7 +112,7 @@
"width": 240,
"height": 320,
"quality": 82,
"mtimeMs": 1770500128000,
"mtimeMs": 1774257676943.8735,
"size": 1172905,
"focus": null
},
@@ -121,7 +121,7 @@
"width": 240,
"height": 320,
"quality": 82,
"mtimeMs": 1770500129000,
"mtimeMs": 1774257676945.2588,
"size": 1099540,
"focus": null
},
@@ -130,7 +130,7 @@
"width": 240,
"height": 320,
"quality": 82,
"mtimeMs": 1770500128000,
"mtimeMs": 1774257676946.2732,
"size": 1052362,
"focus": null
},
@@ -139,7 +139,7 @@
"width": 240,
"height": 320,
"quality": 82,
"mtimeMs": 1770500128000,
"mtimeMs": 1774257676947.5552,
"size": 1227608,
"focus": null
},
@@ -148,7 +148,7 @@
"width": 240,
"height": 320,
"quality": 82,
"mtimeMs": 1770500127000,
"mtimeMs": 1774257676949.5437,
"size": 840466,
"focus": null
},
@@ -157,7 +157,7 @@
"width": 240,
"height": 320,
"quality": 82,
"mtimeMs": 1770500128000,
"mtimeMs": 1774257676951.916,
"size": 1136990,
"focus": null
},
@@ -166,7 +166,7 @@
"width": 240,
"height": 320,
"quality": 82,
"mtimeMs": 1770500127000,
"mtimeMs": 1774257676954.4558,
"size": 1261294,
"focus": null
},
@@ -175,7 +175,7 @@
"width": 240,
"height": 320,
"quality": 82,
"mtimeMs": 1770500128000,
"mtimeMs": 1774257676956.7886,
"size": 1119498,
"focus": null
},
@@ -184,7 +184,7 @@
"width": 240,
"height": 320,
"quality": 82,
"mtimeMs": 1770500128000,
"mtimeMs": 1774257676958.691,
"size": 868085,
"focus": null
},
@@ -193,7 +193,7 @@
"width": 240,
"height": 320,
"quality": 82,
"mtimeMs": 1770500128000,
"mtimeMs": 1774257676961.0393,
"size": 1057896,
"focus": null
},
@@ -202,7 +202,7 @@
"width": 240,
"height": 320,
"quality": 82,
"mtimeMs": 1770500128000,
"mtimeMs": 1774257676963.4336,
"size": 1088795,
"focus": null
},
@@ -211,7 +211,7 @@
"width": 240,
"height": 320,
"quality": 82,
"mtimeMs": 1770500129000,
"mtimeMs": 1774257676965.364,
"size": 852307,
"focus": null
},
@@ -220,7 +220,7 @@
"width": 240,
"height": 320,
"quality": 82,
"mtimeMs": 1770500129000,
"mtimeMs": 1774257676967.8005,
"size": 1149955,
"focus": null
},
@@ -229,7 +229,7 @@
"width": 240,
"height": 320,
"quality": 82,
"mtimeMs": 1770500129000,
"mtimeMs": 1774257676970.2761,
"size": 1242099,
"focus": null
},
@@ -238,7 +238,7 @@
"width": 240,
"height": 320,
"quality": 82,
"mtimeMs": 1770500128000,
"mtimeMs": 1774257676971.9075,
"size": 1414024,
"focus": null
},
@@ -247,7 +247,7 @@
"width": 240,
"height": 320,
"quality": 82,
"mtimeMs": 1770500128000,
"mtimeMs": 1774257676973.2812,
"size": 1022877,
"focus": null
},
@@ -256,7 +256,7 @@
"width": 240,
"height": 320,
"quality": 82,
"mtimeMs": 1770500129000,
"mtimeMs": 1774257676974.2112,
"size": 1018868,
"focus": null
},
@@ -265,7 +265,7 @@
"width": 240,
"height": 320,
"quality": 82,
"mtimeMs": 1770500128000,
"mtimeMs": 1774257676975.1504,
"size": 1233602,
"focus": null
},
@@ -274,7 +274,7 @@
"width": 240,
"height": 320,
"quality": 82,
"mtimeMs": 1770500129000,
"mtimeMs": 1774257676976.6533,
"size": 739786,
"focus": null
},
@@ -283,7 +283,7 @@
"width": 240,
"height": 320,
"quality": 82,
"mtimeMs": 1771126436000,
"mtimeMs": 1774257676977.1958,
"size": 1069693,
"focus": null
},
@@ -292,7 +292,7 @@
"width": 240,
"height": 320,
"quality": 82,
"mtimeMs": 1771226059000,
"mtimeMs": 1774257676979.063,
"size": 995282,
"focus": null
},
@@ -301,7 +301,7 @@
"width": 240,
"height": 320,
"quality": 82,
"mtimeMs": 1771226144000,
"mtimeMs": 1774257676980.472,
"size": 729224,
"focus": null
}

View File

@@ -30,9 +30,9 @@
<h1>for vham :3</h1>
<p id="haiku">Please enable javascript >.<</p>
<p class="page-links">
<a href="index.html" aria-current="page">gallery</a>
<a href="./" aria-current="page">gallery</a>
<span class="page-links__separator">/</span>
<a href="rankings.html">rankings</a>
<a href="rankings">rankings</a>
</p>
</header>

View File

@@ -2,11 +2,14 @@
"name": "gallery",
"private": true,
"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:thumbs": "node scripts/generate-thumbnails.js",
"build:thumbs:force": "node scripts/generate-thumbnails.js --force"
"build:thumbs:force": "node scripts/generate-thumbnails.js --force",
"serve": "node scripts/serve.js",
"start": "npm run build && npm run serve"
},
"dependencies": {
"sharp": "^0.34.5"

View File

@@ -28,17 +28,38 @@
<header id="header">
<img src="images/meow.gif" alt="meow" id="gifone">
<h1>food power rankings</h1>
<p>static Elo seeds for every meal before the head-to-head voting page exists.</p>
<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="index.html">gallery</a>
<a href="./">gallery</a>
<span class="page-links__separator">/</span>
<a href="rankings.html" aria-current="page">rankings</a>
<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 until head-to-head voting starts.</p>
<p class="ranking-summary">33 meals seeded at Elo 1,000. Enable JavaScript to vote and reorder them.</p>
</section>
<!-- Rankings -->
@@ -351,5 +372,402 @@
</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

@@ -2,7 +2,7 @@ const fs = require("fs");
const path = require("path");
const { getRankedMeals, syncEloWithMeals } = require("./lib/elo");
const { loadMeals, repoRoot } = require("./lib/meals");
const { loadMeals, repoRoot, validateMealAssets } = require("./lib/meals");
const indexTemplatePath = path.join(repoRoot, "templates", "index.html");
const indexOutputPath = path.join(repoRoot, "index.html");
@@ -24,6 +24,20 @@ function escapeHtml(value) {
.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) {
const attrs = [`class="thumbnail"`, `href="images/fulls/${meal.id}.jpg"`];
@@ -53,7 +67,7 @@ function renderRankingSummary(meals, eloData) {
return `\t\t\t\t<p class="ranking-summary">${meals.length} ${mealLabel} seeded at Elo ${formatRating(
eloData.defaultRating
)} until head-to-head voting starts.</p>`;
)}. Enable JavaScript to vote and reorder them.</p>`;
}
function renderRankingMeta(rankedMeal) {
@@ -91,6 +105,16 @@ function renderRankings(rankedMeals, eol) {
.join(eol);
}
function renderRankingsSeedData(meals, eloData) {
return indentBlock(
serializeJsonForHtml({
meals,
elo: eloData,
}),
"\t\t\t"
);
}
function replaceBlock(template, token, replacement) {
const pattern = new RegExp(`^[\\t ]*\\{\\{${token}\\}\\}$`, "m");
@@ -102,6 +126,7 @@ function replaceBlock(template, token, replacement) {
}
function buildIndex(meals = loadMeals()) {
validateMealAssets(meals);
const template = fs.readFileSync(indexTemplatePath, "utf8");
const eol = detectEol(template);
@@ -112,6 +137,7 @@ function buildRankings(
meals = loadMeals(),
eloData = syncEloWithMeals(meals)
) {
validateMealAssets(meals);
const template = fs.readFileSync(rankingsTemplatePath, "utf8");
const eol = detectEol(template);
const rankedMeals = getRankedMeals(meals, eloData);
@@ -120,8 +146,13 @@ function buildRankings(
"ranking_summary",
renderRankingSummary(meals, eloData)
);
const withSeedData = replaceBlock(
withSummary,
"rankings_seed_data",
renderRankingsSeedData(meals, eloData)
);
return replaceBlock(withSummary, "ranking_items", renderRankings(rankedMeals, eol));
return replaceBlock(withSeedData, "ranking_items", renderRankings(rankedMeals, eol));
}
function writeFile(filePath, contents) {

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

@@ -99,6 +99,20 @@ function syncEloWithMeals(meals) {
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;
@@ -132,6 +146,7 @@ function getRankedMeals(meals, eloData) {
module.exports = {
eloPath,
getEloAlignmentReport,
getRankedMeals,
loadEloData,
saveEloData,

View File

@@ -3,6 +3,12 @@ const path = require("path");
const repoRoot = path.resolve(__dirname, "..", "..");
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) {
if (meal.thumbnail === undefined) {
@@ -55,7 +61,7 @@ function validateMeals(meals) {
}
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}"`);
}
}
@@ -64,8 +70,8 @@ function validateMeals(meals) {
throw new Error(`Meal ${index} has a non-numeric id "${meal.id}"`);
}
if (meal.position !== undefined && typeof meal.position !== "string") {
throw new Error(`Meal ${index} has a non-string "position" value`);
if (meal.position !== undefined && !isNonEmptyString(meal.position)) {
throw new Error(`Meal ${index} has an invalid "position" value`);
}
if (ids.has(meal.id)) {
@@ -91,6 +97,49 @@ function saveMeals(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";
@@ -108,9 +157,13 @@ function getNextMealId(meals) {
}
module.exports = {
fullsDir,
getNextMealId,
getMealImagePaths,
loadMeals,
mealsPath,
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

@@ -30,9 +30,9 @@
<h1>for vham :3</h1>
<p id="haiku">Please enable javascript >.<</p>
<p class="page-links">
<a href="index.html" aria-current="page">gallery</a>
<a href="./" aria-current="page">gallery</a>
<span class="page-links__separator">/</span>
<a href="rankings.html">rankings</a>
<a href="rankings">rankings</a>
</p>
</header>

View File

@@ -28,14 +28,35 @@
<header id="header">
<img src="images/meow.gif" alt="meow" id="gifone">
<h1>food power rankings</h1>
<p>static Elo seeds for every meal before the head-to-head voting page exists.</p>
<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="index.html">gallery</a>
<a href="./">gallery</a>
<span class="page-links__separator">/</span>
<a href="rankings.html" aria-current="page">rankings</a>
<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}}
@@ -55,5 +76,9 @@
</footer>
</div>
<script id="rankings-seed-data" type="application/json">
{{rankings_seed_data}}
</script>
<script src="assets/js/rankings.js"></script>
</body>
</html>