No description
  • TypeScript 93.7%
  • CSS 3.9%
  • JavaScript 2.2%
  • Dockerfile 0.1%
  • Shell 0.1%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
victor 22810232ed A member with no display name is called by their address instead
Authentik sends an unset name as '', which slipped past the nullish
fallback and stored one member as nobody, slugged "user". Sign-in now
derives a blank name from the email's local part, and Anonymous only
when there is no address either. A boot-time repair renames the rows
already stored blank the same way and reslugs the fallback slugs, since
nothing has linked to them.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01HD9Fssb2bBMupYYBmEgUVH
2026-09-04 13:54:18 -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 A member with no display name is called by their address instead 2026-09-04 13:54:18 -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
vitest.config.ts The whole week now runs under test, and the pool can't be gate-crashed 2026-08-07 00:18:20 -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