Compare commits
14 Commits
2756bf754e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ac52daa454 | |||
| dc1dce1120 | |||
| b1403da70d | |||
| a72e4d21d5 | |||
| 9f60ab3cca | |||
| 614a3d1eff | |||
| b3a8368bab | |||
| 26adbe617f | |||
| 8f9a7eda2f | |||
| 21c3a0c4b2 | |||
| 3439fc834f | |||
| c5f525bb03 | |||
| 6c3ee112f9 | |||
| b60452a1f9 |
4
.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
.DS_Store
|
||||||
|
.git
|
||||||
|
.runtime
|
||||||
|
node_modules
|
||||||
2
.gitignore
vendored
@@ -1 +1,3 @@
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
|
node_modules/
|
||||||
|
.runtime/
|
||||||
|
|||||||
15
Dockerfile
Normal 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"]
|
||||||
172
README.md
@@ -2,39 +2,185 @@
|
|||||||
|
|
||||||
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
|
||||||
|
|
||||||
- `index.html`: 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
|
||||||
|
- `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
|
||||||
- `scripts/render-gallery.js`: regenerates the gallery markup in `index.html` from `data/meals.json`
|
- `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
|
## Content Workflow
|
||||||
|
|
||||||
Gallery entries now live in `data/meals.json`.
|
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 that file, regenerate the static markup with:
|
After editing content or templates, rebuild the site with:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
node scripts/render-gallery.js
|
npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
The script updates only the generated gallery block inside `index.html`.
|
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:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
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
|
||||||
- Full-size images live at `images/fulls/<id>.jpg`
|
- Full-size images live at `images/fulls/<id>.jpg`
|
||||||
- Thumbnails live at `images/thumbs/<id>.jpg`
|
- Thumbnails live at `images/thumbs/<id>.jpg`
|
||||||
- Optional thumbnail focal positioning is stored per entry as `position`
|
- `position` controls the full-screen viewer image alignment
|
||||||
|
- `thumbnail.focus` optionally overrides the default center crop for generated thumbnails
|
||||||
|
|
||||||
## Planned Features
|
## Thumbnail Focus
|
||||||
|
|
||||||
1. Better thumbnail implementation, either a small script to automatically convert full-size images into thumbnails and a build system, or another simpler approach.
|
Thumbnails are generated from `images/fulls` with `sharp` at `240x320`.
|
||||||
2. Automatic image ingestion, potentially with a stronger data model if the static workflow becomes too limiting.
|
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.
|
||||||
3. An Elo-style ranking page that shows two food images at a time and updates rankings automatically based on the selected winner.
|
|
||||||
4. General cleanup and history cleanup once the bigger structural changes are in place.
|
For images that should crop away from the center, add optional thumbnail focus metadata to the meal entry:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "34",
|
||||||
|
"title": "example",
|
||||||
|
"description": "example",
|
||||||
|
"thumbnail": {
|
||||||
|
"focus": {
|
||||||
|
"x": 0.35,
|
||||||
|
"y": 0.45
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `x` and `y` values are normalized from `0` to `1`, where `0.5, 0.5` is the center of the image.
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
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
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
[
|
[
|
||||||
{
|
{
|
||||||
"id": "01",
|
"id": "01",
|
||||||
|
"thumbnail": {
|
||||||
|
"focus": {
|
||||||
|
"x": 0.35,
|
||||||
|
"y": 0.5
|
||||||
|
}
|
||||||
|
},
|
||||||
"position": "left center",
|
"position": "left center",
|
||||||
"title": "sf on $10",
|
"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"
|
"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"
|
||||||
@@ -12,6 +18,12 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "03",
|
"id": "03",
|
||||||
|
"thumbnail": {
|
||||||
|
"focus": {
|
||||||
|
"x": 0.5,
|
||||||
|
"y": 0.35
|
||||||
|
}
|
||||||
|
},
|
||||||
"position": "top center",
|
"position": "top center",
|
||||||
"title": "aloha fresh",
|
"title": "aloha fresh",
|
||||||
"description": "we fucking love this place 10/10 i love poke i should have never quit pokehouse"
|
"description": "we fucking love this place 10/10 i love poke i should have never quit pokehouse"
|
||||||
@@ -23,6 +35,12 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "05",
|
"id": "05",
|
||||||
|
"thumbnail": {
|
||||||
|
"focus": {
|
||||||
|
"x": 0.5,
|
||||||
|
"y": 0.35
|
||||||
|
}
|
||||||
|
},
|
||||||
"position": "top center",
|
"position": "top center",
|
||||||
"title": "sizzling lunch",
|
"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"
|
"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"
|
||||||
|
|||||||
308
images/thumbs/.thumbs-manifest.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 13 KiB |
@@ -29,11 +29,15 @@
|
|||||||
<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 -->
|
||||||
<section id="thumbnails">
|
<section id="thumbnails">
|
||||||
<!-- Generated gallery items: start -->
|
|
||||||
<article>
|
<article>
|
||||||
<a class="thumbnail" href="images/fulls/01.jpg" data-position="left center"><img src="images/thumbs/01.jpg" alt="" /></a>
|
<a class="thumbnail" href="images/fulls/01.jpg" data-position="left center"><img src="images/thumbs/01.jpg" alt="" /></a>
|
||||||
<h2>sf on $10</h2>
|
<h2>sf on $10</h2>
|
||||||
@@ -132,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 -> claire dropping the most insane piece of information ever -> 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>
|
||||||
@@ -199,7 +203,6 @@
|
|||||||
<h2>hey tea</h2>
|
<h2>hey tea</h2>
|
||||||
<p>mochi yinje black milk tea ts was actually so buss 10/10 only boba i've ever wanted to get again myself</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>
|
||||||
</article>
|
</article>
|
||||||
<!-- Generated gallery items: end -->
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
|
|||||||
608
package-lock.json
generated
Normal file
@@ -0,0 +1,608 @@
|
|||||||
|
{
|
||||||
|
"name": "gallery",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "gallery",
|
||||||
|
"dependencies": {
|
||||||
|
"sharp": "^0.34.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@emnapi/runtime": {
|
||||||
|
"version": "1.9.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz",
|
||||||
|
"integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/colour": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-darwin-arm64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-darwin-arm64": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-darwin-x64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-darwin-x64": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-darwin-arm64": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-darwin-x64": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-arm": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-arm64": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-ppc64": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-riscv64": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-s390x": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-x64": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-arm": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-arm": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-arm64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-arm64": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-ppc64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-ppc64": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-riscv64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-riscv64": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-s390x": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-s390x": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-x64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-x64": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linuxmusl-arm64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linuxmusl-x64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linuxmusl-x64": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-wasm32": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
|
||||||
|
"cpu": [
|
||||||
|
"wasm32"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@emnapi/runtime": "^1.7.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-win32-arm64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-win32-ia32": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-win32-x64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/detect-libc": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/semver": {
|
||||||
|
"version": "7.7.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||||
|
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"bin": {
|
||||||
|
"semver": "bin/semver.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/sharp": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@img/colour": "^1.0.0",
|
||||||
|
"detect-libc": "^2.1.2",
|
||||||
|
"semver": "^7.7.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-darwin-arm64": "0.34.5",
|
||||||
|
"@img/sharp-darwin-x64": "0.34.5",
|
||||||
|
"@img/sharp-libvips-darwin-arm64": "1.2.4",
|
||||||
|
"@img/sharp-libvips-darwin-x64": "1.2.4",
|
||||||
|
"@img/sharp-libvips-linux-arm": "1.2.4",
|
||||||
|
"@img/sharp-libvips-linux-arm64": "1.2.4",
|
||||||
|
"@img/sharp-libvips-linux-ppc64": "1.2.4",
|
||||||
|
"@img/sharp-libvips-linux-riscv64": "1.2.4",
|
||||||
|
"@img/sharp-libvips-linux-s390x": "1.2.4",
|
||||||
|
"@img/sharp-libvips-linux-x64": "1.2.4",
|
||||||
|
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
|
||||||
|
"@img/sharp-libvips-linuxmusl-x64": "1.2.4",
|
||||||
|
"@img/sharp-linux-arm": "0.34.5",
|
||||||
|
"@img/sharp-linux-arm64": "0.34.5",
|
||||||
|
"@img/sharp-linux-ppc64": "0.34.5",
|
||||||
|
"@img/sharp-linux-riscv64": "0.34.5",
|
||||||
|
"@img/sharp-linux-s390x": "0.34.5",
|
||||||
|
"@img/sharp-linux-x64": "0.34.5",
|
||||||
|
"@img/sharp-linuxmusl-arm64": "0.34.5",
|
||||||
|
"@img/sharp-linuxmusl-x64": "0.34.5",
|
||||||
|
"@img/sharp-wasm32": "0.34.5",
|
||||||
|
"@img/sharp-win32-arm64": "0.34.5",
|
||||||
|
"@img/sharp-win32-ia32": "0.34.5",
|
||||||
|
"@img/sharp-win32-x64": "0.34.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tslib": {
|
||||||
|
"version": "2.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
|
"license": "0BSD",
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
17
package.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"name": "gallery",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"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",
|
||||||
|
"serve": "node scripts/serve.js",
|
||||||
|
"start": "npm run build && npm run serve"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"sharp": "^0.34.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
773
rankings.html
Normal 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 -> claire dropping the most insane piece of information ever -> 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>© 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>
|
||||||
182
scripts/build.js
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const { getRankedMeals, syncEloWithMeals } = require("./lib/elo");
|
||||||
|
const { loadMeals, repoRoot, validateMealAssets } = require("./lib/meals");
|
||||||
|
|
||||||
|
const indexTemplatePath = path.join(repoRoot, "templates", "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) {
|
||||||
|
return text.includes("\r\n") ? "\r\n" : "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value)
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
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"`];
|
||||||
|
|
||||||
|
if (meal.position) {
|
||||||
|
attrs.push(`data-position="${escapeHtml(meal.position)}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
"\t\t\t\t<article>",
|
||||||
|
`\t\t\t\t\t<a ${attrs.join(" ")}><img src="images/thumbs/${meal.id}.jpg" alt="" /></a>`,
|
||||||
|
`\t\t\t\t\t<h2>${escapeHtml(meal.title)}</h2>`,
|
||||||
|
`\t\t\t\t\t<p>${escapeHtml(meal.description)}</p>`,
|
||||||
|
"\t\t\t\t</article>",
|
||||||
|
].join(eol);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGallery(meals, 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) {
|
||||||
|
const pattern = new RegExp(`^[\\t ]*\\{\\{${token}\\}\\}$`, "m");
|
||||||
|
|
||||||
|
if (!pattern.test(template)) {
|
||||||
|
throw new Error(`Template is missing required block token "{{${token}}}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return template.replace(pattern, () => replacement);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildIndex(meals = loadMeals()) {
|
||||||
|
validateMealAssets(meals);
|
||||||
|
const template = fs.readFileSync(indexTemplatePath, "utf8");
|
||||||
|
const eol = detectEol(template);
|
||||||
|
|
||||||
|
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) {
|
||||||
|
fs.writeFileSync(filePath, contents);
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const meals = loadMeals();
|
||||||
|
const eloData = syncEloWithMeals(meals);
|
||||||
|
|
||||||
|
writeFile(indexOutputPath, buildIndex(meals));
|
||||||
|
writeFile(rankingsOutputPath, buildRankings(meals, eloData));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPages() {
|
||||||
|
main();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
buildPages();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
buildPages,
|
||||||
|
buildIndex,
|
||||||
|
buildRankings,
|
||||||
|
};
|
||||||
151
scripts/check.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
238
scripts/generate-thumbnails.js
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const sharp = require("sharp");
|
||||||
|
|
||||||
|
const { loadMeals, repoRoot } = require("./lib/meals");
|
||||||
|
|
||||||
|
const fullsDir = path.join(repoRoot, "images", "fulls");
|
||||||
|
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_HEIGHT = 320;
|
||||||
|
const JPEG_QUALITY = 82;
|
||||||
|
|
||||||
|
function clamp(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) {
|
||||||
|
return {
|
||||||
|
fullPath: path.join(fullsDir, `${meal.id}.jpg`),
|
||||||
|
thumbPath: path.join(thumbsDir, `${meal.id}.jpg`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
const targetRatio = THUMB_WIDTH / THUMB_HEIGHT;
|
||||||
|
const sourceRatio = width / height;
|
||||||
|
|
||||||
|
let cropWidth = width;
|
||||||
|
let cropHeight = height;
|
||||||
|
|
||||||
|
if (sourceRatio > targetRatio) {
|
||||||
|
cropWidth = Math.round(height * targetRatio);
|
||||||
|
} else if (sourceRatio < targetRatio) {
|
||||||
|
cropHeight = Math.round(width / targetRatio);
|
||||||
|
}
|
||||||
|
|
||||||
|
const centerX = (focus?.x ?? 0.5) * width;
|
||||||
|
const centerY = (focus?.y ?? 0.5) * height;
|
||||||
|
const left = Math.round(centerX - cropWidth / 2);
|
||||||
|
const top = Math.round(centerY - cropHeight / 2);
|
||||||
|
|
||||||
|
return {
|
||||||
|
left: clamp(left, 0, width - cropWidth),
|
||||||
|
top: clamp(top, 0, height - cropHeight),
|
||||||
|
width: cropWidth,
|
||||||
|
height: cropHeight,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
if (!fs.existsSync(fullPath)) {
|
||||||
|
throw new Error(`Missing full-size image for meal ${meal.id}: ${fullPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceStats = await fs.promises.stat(fullPath);
|
||||||
|
const signature = getThumbSignature(meal, sourceStats);
|
||||||
|
const thumbExists = fs.existsSync(thumbPath);
|
||||||
|
|
||||||
|
if (
|
||||||
|
!options.force &&
|
||||||
|
thumbExists &&
|
||||||
|
manifestEntryMatches(manifest[getManifestKey(meal.id)], signature)
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
mealId: meal.id,
|
||||||
|
changed: false,
|
||||||
|
manifestEntry: signature,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const image = sharp(fullPath);
|
||||||
|
const metadata = await image.metadata();
|
||||||
|
const { width, height } = getOrientedDimensions(metadata);
|
||||||
|
|
||||||
|
const cropArea = getCropArea(width, height, meal.thumbnail?.focus);
|
||||||
|
|
||||||
|
await image
|
||||||
|
.rotate()
|
||||||
|
.extract(cropArea)
|
||||||
|
.resize(THUMB_WIDTH, THUMB_HEIGHT)
|
||||||
|
.jpeg({ quality: JPEG_QUALITY, mozjpeg: true })
|
||||||
|
.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() {
|
||||||
|
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 manifest = loadManifest();
|
||||||
|
const nextManifest = {};
|
||||||
|
const expectedIds = new Set(meals.map((meal) => meal.id));
|
||||||
|
|
||||||
|
await fs.promises.mkdir(thumbsDir, { recursive: true });
|
||||||
|
|
||||||
|
const removed = await removeStaleThumbnails(expectedIds);
|
||||||
|
let generated = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
|
||||||
|
for (const meal of meals) {
|
||||||
|
const result = await generateThumbnail(meal, manifest, settings);
|
||||||
|
nextManifest[getManifestKey(meal.id)] = result.manifestEntry;
|
||||||
|
|
||||||
|
if (result.changed) {
|
||||||
|
generated += 1;
|
||||||
|
} else {
|
||||||
|
skipped += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writeManifest(nextManifest);
|
||||||
|
|
||||||
|
return {
|
||||||
|
generated,
|
||||||
|
removed,
|
||||||
|
skipped,
|
||||||
|
total: meals.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error.message);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
buildThumbnails,
|
||||||
|
};
|
||||||
246
scripts/ingest-meal.js
Normal 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
@@ -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,
|
||||||
|
};
|
||||||
169
scripts/lib/meals.js
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
const fs = require("fs");
|
||||||
|
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) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!meal.thumbnail ||
|
||||||
|
typeof meal.thumbnail !== "object" ||
|
||||||
|
Array.isArray(meal.thumbnail)
|
||||||
|
) {
|
||||||
|
throw new Error(`Meal ${index} has an invalid "thumbnail" object`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (meal.thumbnail.focus === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { focus } = meal.thumbnail;
|
||||||
|
|
||||||
|
if (!focus || typeof focus !== "object" || Array.isArray(focus)) {
|
||||||
|
throw new Error(`Meal ${index} has an invalid "thumbnail.focus" object`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const axis of ["x", "y"]) {
|
||||||
|
if (typeof focus[axis] !== "number" || !Number.isFinite(focus[axis])) {
|
||||||
|
throw new Error(
|
||||||
|
`Meal ${index} has a non-numeric thumbnail focus value for "${axis}"`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (focus[axis] < 0 || focus[axis] > 1) {
|
||||||
|
throw new Error(
|
||||||
|
`Meal ${index} thumbnail focus "${axis}" must be between 0 and 1`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateMeals(meals) {
|
||||||
|
if (!Array.isArray(meals)) {
|
||||||
|
throw new Error("data/meals.json must contain an array");
|
||||||
|
}
|
||||||
|
|
||||||
|
const ids = new Set();
|
||||||
|
|
||||||
|
for (const [index, meal] of meals.entries()) {
|
||||||
|
if (!meal || typeof meal !== "object") {
|
||||||
|
throw new Error(`Meal at index ${index} must be an object`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const field of ["id", "title", "description"]) {
|
||||||
|
if (!isNonEmptyString(meal[field])) {
|
||||||
|
throw new Error(`Meal ${index} is missing required string field "${field}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/^\d+$/.test(meal.id)) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadMeals() {
|
||||||
|
const meals = JSON.parse(fs.readFileSync(mealsPath, "utf8"));
|
||||||
|
|
||||||
|
validateMeals(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 = {
|
||||||
|
fullsDir,
|
||||||
|
getNextMealId,
|
||||||
|
getMealImagePaths,
|
||||||
|
loadMeals,
|
||||||
|
mealsPath,
|
||||||
|
repoRoot,
|
||||||
|
saveMeals,
|
||||||
|
thumbsDir,
|
||||||
|
validateMealAssets,
|
||||||
|
};
|
||||||
380
scripts/lib/rankings-state.js
Normal 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,
|
||||||
|
};
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
const fs = require("fs");
|
|
||||||
const path = require("path");
|
|
||||||
|
|
||||||
const repoRoot = path.resolve(__dirname, "..");
|
|
||||||
const dataPath = path.join(repoRoot, "data", "meals.json");
|
|
||||||
const indexPath = path.join(repoRoot, "index.html");
|
|
||||||
|
|
||||||
const startMarker = "<!-- Generated gallery items: start -->";
|
|
||||||
const endMarker = "<!-- Generated gallery items: end -->";
|
|
||||||
|
|
||||||
function escapeHtml(value) {
|
|
||||||
return value
|
|
||||||
.replace(/&/g, "&")
|
|
||||||
.replace(/</g, "<")
|
|
||||||
.replace(/\"/g, """);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderArticle(entry, eol) {
|
|
||||||
const attrs = [`class="thumbnail"`, `href="images/fulls/${entry.id}.jpg"`];
|
|
||||||
|
|
||||||
if (entry.position) {
|
|
||||||
attrs.push(`data-position="${escapeHtml(entry.position)}"`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
"\t\t\t\t<article>",
|
|
||||||
`\t\t\t\t\t<a ${attrs.join(" ")}><img src="images/thumbs/${entry.id}.jpg" alt="" /></a>`,
|
|
||||||
`\t\t\t\t\t<h2>${escapeHtml(entry.title)}</h2>`,
|
|
||||||
`\t\t\t\t\t<p>${escapeHtml(entry.description)}</p>`,
|
|
||||||
"\t\t\t\t</article>",
|
|
||||||
].join(eol);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderGallery(entries, eol) {
|
|
||||||
return entries.map((entry) => renderArticle(entry, eol)).join(eol);
|
|
||||||
}
|
|
||||||
|
|
||||||
function main() {
|
|
||||||
const entries = JSON.parse(fs.readFileSync(dataPath, "utf8"));
|
|
||||||
const indexHtml = fs.readFileSync(indexPath, "utf8");
|
|
||||||
const eol = indexHtml.includes("\r\n") ? "\r\n" : "\n";
|
|
||||||
const galleryMarkup = renderGallery(entries, eol);
|
|
||||||
const replacement = [
|
|
||||||
`\t\t\t\t${startMarker}`,
|
|
||||||
galleryMarkup,
|
|
||||||
`\t\t\t\t${endMarker}`,
|
|
||||||
].join(eol);
|
|
||||||
|
|
||||||
const markerPattern = new RegExp(
|
|
||||||
`^[\\t ]*${startMarker}[\\s\\S]*?^[\\t ]*${endMarker}`,
|
|
||||||
"m"
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!markerPattern.test(indexHtml)) {
|
|
||||||
throw new Error("Could not find gallery markers in index.html");
|
|
||||||
}
|
|
||||||
|
|
||||||
fs.writeFileSync(indexPath, indexHtml.replace(markerPattern, replacement));
|
|
||||||
}
|
|
||||||
|
|
||||||
main();
|
|
||||||
302
scripts/serve.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
61
templates/index.html
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
<!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>
|
||||||
|
<head>
|
||||||
|
<title>vicky n pham fanpage</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" />
|
||||||
|
<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="is-preload-0 is-preload-1 is-preload-2">
|
||||||
|
|
||||||
|
<!-- Main -->
|
||||||
|
<div id="main">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<header id="header">
|
||||||
|
<img src="images/meow.gif" alt="meow" id="gifone">
|
||||||
|
<h1>for vham :3</h1>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- Thumbnail -->
|
||||||
|
<section id="thumbnails">
|
||||||
|
{{gallery_items}}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<footer id="footer">
|
||||||
|
<ul class="copyright">
|
||||||
|
<li>© Ryan Chou. 2026.</li>
|
||||||
|
</ul>
|
||||||
|
<img src="images/nyaa.gif" alt="nyaa" id="giftwo">
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Scripts -->
|
||||||
|
<script src="assets/js/jquery.min.js"></script>
|
||||||
|
<script src="assets/js/browser.min.js"></script>
|
||||||
|
<script src="assets/js/breakpoints.min.js"></script>
|
||||||
|
<script src="assets/js/haiku.js"></script>
|
||||||
|
<script src="assets/js/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
84
templates/rankings.html
Normal 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>© 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>
|
||||||