No description
  • TypeScript 92.9%
  • CSS 4.3%
  • JavaScript 2.5%
  • Dockerfile 0.2%
  • Shell 0.1%
Find a file
Viscid c74ad31ef2 The wash is combed into scan lines that land on real pixels
Interlace over the poster wash, which suits a film site — but a stripe has
to be a whole number of *device* pixels or the leftover half falls
differently from line to line, and the comb turns into interference fringes.
Which CSS lengths are whole depends on the screen: at 125%, the ordinary
Windows setting, one device pixel is 0.8 CSS px, so the obvious 2px is two
and a half of them. So the pitch is chosen per display ratio, and every one
of them is exact — verified by reading the computed value back at 1x, 1.25,
1.5, 2 and 3.

Two things found on the way. The layer was scaled by transform, which
resamples the mask along with the element and rounded a 2px stripe to 2.12 —
it grows by layout now. And a screenshot rendered at 1x is resampled again
by a 125% display before anyone looks at it, which invents fringes that were
never in the page; that, not the CSS, was most of what we were chasing.

The wash runs hotter than the flat version to compensate for half its lines
being gone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014gjKv849ZnQ11UFfoMVoXj
2026-07-31 18:13:13 -04:00
deploy A poster is fetched once and then it is ours 2026-07-31 16:37:05 -04:00
public Ban The Room; generalize the interdiction 2026-07-26 02:38:46 -04:00
scripts Every film credit line gets a director: reel, winner hero, detail page 2026-07-27 14:10:01 -04:00
src The wash is combed into scan lines that land on real pixels 2026-07-31 18:13:13 -04:00
.dockerignore netherFilms: movie-night voting app 2026-07-25 18:21:05 -04:00
.env.example Link the Discord screening room from the marquee 2026-07-27 00:38:25 -04:00
.gitignore Track .env.example — the blanket .env* rule was hiding it 2026-07-26 13:45:17 -04:00
AGENTS.md A poster is fetched once and then it is ours 2026-07-31 16:37:05 -04:00
CLAUDE.md netherFilms: movie-night voting app 2026-07-25 18:21:05 -04:00
compose.yaml netherFilms: movie-night voting app 2026-07-25 18:21:05 -04:00
Dockerfile netherFilms: movie-night voting app 2026-07-25 18:21:05 -04:00
eslint.config.mjs netherFilms: movie-night voting app 2026-07-25 18:21:05 -04:00
next.config.ts A poster is fetched once and then it is ours 2026-07-31 16:37:05 -04:00
package-lock.json A poster is fetched once and then it is ours 2026-07-31 16:37:05 -04:00
package.json A poster is fetched once and then it is ours 2026-07-31 16:37:05 -04:00
postcss.config.mjs netherFilms: movie-night voting app 2026-07-25 18:21:05 -04:00
README.md A ballot is the films you would happily watch, picked off a deck 2026-07-29 01:09:11 -04:00
tsconfig.json netherFilms: movie-night voting app 2026-07-25 18:21:05 -04:00

netherFilms 🎬

A mobile-first movie-night app for your community: suggest movies (info pulled from OMDb) into a shared pool, then vote on it and see the winner crowned as Up Next. Voting is by approval: pick the films you'd happily watch — swipe up through a deck on a phone, click posters on a desktop — and the film on the most ballots takes the night.

Sign-in is via Authentik (OIDC). Strict black & white design (light and dark are inversions of each other); color appears only in the center of movie artwork.

How a week works

Everything runs on an automatic weekly cycle (times are configurable):

The week is built around the Thursday screening: it opens Friday, right after the credits roll.

Phase Default window What happens
Suggesting Fri 00:00 → Tue 00:00 Anyone can suggest movies — up to MAX_SUGGESTIONS_PER_USER (2) standing suggestions each, pool capped at MAX_SUGGESTIONS (20). Every suggestion is on the ballot.
Voting Tue 00:00 → Thu 18:00 The pool locks; everyone ranks all suggested films (drag, or double-tap to send to the top). Ballots can be updated until close.
Showtime Thu 18:00 → Fri 00:00 Instant-runoff picks the winner, shown as "Up Next". When the next round opens, the pool wipes — every week starts fresh.

A systemd timer (netherfilms-tick.timer) POSTs /api/tick every ten minutes, which advances the round and posts announcements to Telegram — by default when suggestions open, when voting opens, when a winner is crowned, and whenever someone suggests a film. TELEGRAM_EVENTS tunes that set; see .env.example.

The home page shows the whole week at a glance. Rounds advance lazily on page load — no cron or worker process needed. Admins (emails listed in ADMIN_EMAILS) get an /admin page to set the phase by hand, in either direction — going back out of showtime reopens the round and un-crowns the winner. The manual phase holds until the next scheduled boundary passes.

Local development

npm install
node scripts/seed.mjs   # optional: demo data
npm run dev

.env.local ships with AUTH_DEV=true, which enables a no-password dev sign-in (any name). Add OMDB_API_KEY=... to make search work. Never set AUTH_DEV in production — it also makes every user an admin.

Production (Docker)

  1. Authentik: create an OAuth2/OpenID provider — redirect URI https://<your-host>/api/auth/callback/authentik, scopes openid profile email — plus an Application using it. The issuer URL looks like https://<authentik-host>/application/o/<app-slug>/.
  2. OMDb: get an API key at omdbapi.com/apikey.aspx (free tier: 1,000 req/day). Becoming a patron raises the limit and unlocks the high-res Poster API, which the app proxies at /api/poster so the key stays server-side.
  3. Copy .env.example to .env, fill it in, then:
docker compose up -d --build

The SQLite database lives in the netherfilms-data volume (/data). Set TZ so the weekly schedule follows your local clock.

Schedule configuration

"Day HH:MM" strings, server-local time:

NOMINATIONS_OPEN="Fri 00:00"
VOTING_OPENS="Tue 00:00"
VOTING_CLOSES="Thu 18:00"
MAX_SUGGESTIONS=20
MAX_SUGGESTIONS_PER_USER=2

The screening slot (when you actually watch) is separate and timezone-aware: it's pinned to an IANA timezone on the server and displayed to every user in their local timezone:

SCREENING_AT="Thu 22:00"          # weekly "Day HH:MM" in SCREENING_TZ
SCREENING_TZ="America/New_York"   # IANA name — handles EST/EDT automatically

Stack

Next.js (App Router) · SQLite via better-sqlite3 · Auth.js v5 (Authentik OIDC) · Tailwind v4 · Motion (drag-to-rank ballot) · OMDb API