- TypeScript 92.9%
- CSS 4.3%
- JavaScript 2.5%
- Dockerfile 0.2%
- Shell 0.1%
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 |
||
|---|---|---|
| deploy | ||
| public | ||
| scripts | ||
| src | ||
| .dockerignore | ||
| .env.example | ||
| .gitignore | ||
| AGENTS.md | ||
| CLAUDE.md | ||
| compose.yaml | ||
| Dockerfile | ||
| eslint.config.mjs | ||
| next.config.ts | ||
| package-lock.json | ||
| package.json | ||
| postcss.config.mjs | ||
| README.md | ||
| tsconfig.json | ||
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)
- Authentik: create an OAuth2/OpenID provider —
redirect URI
https://<your-host>/api/auth/callback/authentik, scopesopenid profile email— plus an Application using it. The issuer URL looks likehttps://<authentik-host>/application/o/<app-slug>/. - 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/posterso the key stays server-side. - Copy
.env.exampleto.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