khannurien 5fd1d364da
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 44s
v3: the header stops cropping itself and the search stops losing its place — a review of the header, search bar and presence row turned up tabs cut off at both ends, avatars that could only be all there or gone, and a search that forgot what it was showing. Between 860px and about 1000px, and again just past 1150px where the username and Playlists links arrive, presence plus tabs ran wider than the header's centre column; the slot couldn't shrink, so the column clipped it on both sides, and logged in FOLLOWED went missing with one person online. The container queries meant to shed presence under 460px and tabs under 280px never fired, overridden by later rules of the same weight, and no fixed width would have been right anyway: the tabs run from 314px (nyt, English) to 485px (melange, French). The slot can now shrink, and the presence row sizes itself through a new useRoomBeside, which measures the room the tabs leave it in the header and in the row under it: as many avatars as fit, at most six seats, the rest folded into a "+N" counter (square in brutalist), and never fewer than an avatar and the counter — below that the tabs scroll instead. So presence shows at every width, phones included, where a 512px rule used to hide it. When the header's tabs do scroll, they get the app's right-edge fade in the header's own colour, only while they overflow, and the active tab is scrolled clear of it. The avatars fan out on hover only where there's a pointer, so a tap doesn't leave them spread, and the label-less "+" button squares up with the icons beside it on small screens. Search: the ✕ on results opened directly ran navigate(-1) out of the app and now goes home; after several searches it stepped back one query at a time and now returns to where the search began in one step, carried in the entry's state, which useTabParam now keeps across result tabs; those tabs (/search/~/users) weren't recognised as the results page at all. On results the bar stays open holding the current query, including after a new search or Back, where it used to collapse and empty. The input no longer grabs focus on a results page, so a phone's keyboard doesn't cover the results, and submitting puts it away. Escape closes an opened search and returns focus to its button, or on results reverts an edited query, overriding the search input's own clear-on-Escape. The two search bars share one draft, so crossing the breakpoint keeps it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 10:00:15 +00:00
v3: the frontend stops trusting what it last saw — a review of src/ turned up counts that fell to zero, stale copies winning over fresh ones, sessions that tabs trampled on each other, and a player queue that outlived its listener, and this closes them. Voting or liking something with no live count yet started from 0, so a dump with five votes read 1 on the click and, with the socket down, 0 for good once the undo fired; the buttons now pass the count they show, which the optimistic update and its undo start from. After a reconnect the chat re-read its history as the truth, but the merge let the live copy of a message win, so an edit made while the socket was down never showed; the resync now replaces live copies with what it fetched. Every dump_updated added the dump to the live list if it wasn't there, so retitling a months-old dump put it at the top of New, Hot and Journal for everyone, while one that had arrived live kept its old title; an edit now refreshes a live entry in place and adds nothing, and the server announces a dump going public as a dump_created, which is the one case that is an arrival. The new-dump modal let a slow preview for the previous link land on the new one, title suggestion included; changing the link now aborts both lookups. Markdown found mentions by rewriting the source text with a rule that, unlike the server's, didn't skip an @ after a word character, so an email address linked its domain, a URL like mastodon.social/@bob was split, and @ in code came out as literal link syntax; mentions are now found by a remark step over text nodes only, by the server's rule. Changing the password in one tab stored fresh tokens that any other open tab wiped the moment its old token drew a 401; the session now lives behind utils/authStorage.ts, tabs follow each other through storage events (so a logout also signs out every tab), a 401 only forgets the stored session if it still holds that token, and in-place session updates go through a new updateSession() rather than re-reading localStorage. The persisted player queue carries media-token URLs yet survived logout for the next person to sign in; it is now stamped with its owner, not restored for anyone else, cleared on logout or account switch (a guest's queue survives signing in), and older unstamped queues holding a file are dropped. Playlist checkboxes in the new-dump and add-to-playlist modals flipped whether or not the server agreed and now wait for it. CommentThread and the Dump page's loads use authFetch like the rest, so a 401 there signs out consistently; isTokenExpired has one home; and the useScrollLock imports carry their extension so deno check passes.
2026-09-24 20:24:38 +00:00
2026-06-29 20:25:10 +00:00
v3: the header stops cropping itself and the search stops losing its place — a review of the header, search bar and presence row turned up tabs cut off at both ends, avatars that could only be all there or gone, and a search that forgot what it was showing. Between 860px and about 1000px, and again just past 1150px where the username and Playlists links arrive, presence plus tabs ran wider than the header's centre column; the slot couldn't shrink, so the column clipped it on both sides, and logged in FOLLOWED went missing with one person online. The container queries meant to shed presence under 460px and tabs under 280px never fired, overridden by later rules of the same weight, and no fixed width would have been right anyway: the tabs run from 314px (nyt, English) to 485px (melange, French). The slot can now shrink, and the presence row sizes itself through a new useRoomBeside, which measures the room the tabs leave it in the header and in the row under it: as many avatars as fit, at most six seats, the rest folded into a "+N" counter (square in brutalist), and never fewer than an avatar and the counter — below that the tabs scroll instead. So presence shows at every width, phones included, where a 512px rule used to hide it. When the header's tabs do scroll, they get the app's right-edge fade in the header's own colour, only while they overflow, and the active tab is scrolled clear of it. The avatars fan out on hover only where there's a pointer, so a tap doesn't leave them spread, and the label-less "+" button squares up with the icons beside it on small screens. Search: the ✕ on results opened directly ran navigate(-1) out of the app and now goes home; after several searches it stepped back one query at a time and now returns to where the search began in one step, carried in the entry's state, which useTabParam now keeps across result tabs; those tabs (/search/~/users) weren't recognised as the results page at all. On results the bar stays open holding the current query, including after a new search or Back, where it used to collapse and empty. The input no longer grabs focus on a results page, so a phone's keyboard doesn't cover the results, and submitting puts it away. Escape closes an opened search and returns focus to its button, or on results reverts an edited query, overriding the search input's own clear-on-Escape. The two search bars share one draft, so crossing the breakpoint keeps it.
2026-09-27 10:00:15 +00:00
2026-06-27 10:42:31 +00:00
2026-03-15 17:15:46 +00:00

gerbeur

A small invite-only social platform for sharing links and files. Users can post URLs and media (YouTube, SoundCloud, Bandcamp, images, audio, video, …), vote, comment, follow each other, build playlists, and search content. A real-time WebSocket layer handles live presence, vote counts, and notifications. The UI is localized (English and French) and ships multiple visual themes.

Stack

Layer Technology
Runtime Deno 2.x
API Oak (HTTP + WebSocket)
Database SQLite via node:sqlite
Frontend React 19 + Vite 8

Development

Prerequisites

  • Deno 2.x

Setup

cp .env.example .env
# Edit .env: set GERBEUR_JWT_SECRET to the output of: openssl rand -hex 32

Run

deno task dev

This starts both the API server (port 8000, with file watching) and the Vite dev server (port 3000) concurrently.

Open http://localhost:3000. On first run a default admin / admin account is created — change the password immediately.

Environment variables

See .env.example for the full list with descriptions. Key variables:

Variable Description Default
GERBEUR_JWT_SECRET JWT signing secret — required, generate with openssl rand -hex 32 —
GERBEUR_PUBLIC_URL Public-facing URL of the server (no trailing slash) — used for CORS, WebSocket origin, email links, OG URLs http://localhost:GERBEUR_PORT
GERBEUR_PORT Internal port Oak listens on 8000
GERBEUR_LISTEN_HOST Network interface Oak binds to; use 127.0.0.1 to restrict to loopback 0.0.0.0
GERBEUR_FRONTEND_URL Frontend base URL for email links and CORS; auto-added to allowed origins — the only variable needed when the frontend runs on a separate host GERBEUR_PUBLIC_URL
GERBEUR_ALLOWED_ORIGINS Comma-separated extra origins for CORS/WebSocket; PUBLIC_URL and FRONTEND_URL are always included — typically only needed in dev for the Vite server "" (empty)
GERBEUR_SITE_NAME Site name used in the browser tab, app header, OG meta tags, and emails gerbeur
GERBEUR_SMTPS_URL SMTPS connection URL for outgoing email (smtps://user:pass@host:465) unset
GERBEUR_FROM_EMAIL Sender address for outgoing emails — required when GERBEUR_SMTPS_URL is set unset
GERBEUR_WELCOME_EMAIL_BODY Markdown body for the account-creation welcome email; supports {{username}} and {{site_name}} built-in template
VITE_API_PROTOCOL API protocol baked into the frontend bundle (see Production) http
VITE_API_HOSTNAME API hostname baked into the frontend bundle localhost
VITE_API_PORT API port baked into the frontend bundle 8000

Production

The standard deployment runs API and frontend in a single container. The API server (Oak) serves the compiled frontend as static files, so both share the same origin — no VITE_API_* build args needed. Set GERBEUR_PUBLIC_URL to the externally-visible URL; it is automatically allowed for HTTP/WebSocket requests.

docker build -t gerbeur .

docker build -t gerbeur .

docker run -d \
  -p 8000:8000 \
  -v gerbeur-db:/app/api/sql \
  -v gerbeur-uploads:/app/api/uploads \
  -e GERBEUR_JWT_SECRET=$(openssl rand -hex 32) \
  -e GERBEUR_PUBLIC_URL=https://example.com \
  -e GERBEUR_SITE_NAME=mysite \
  --name gerbeur \
  gerbeur

The two volumes are required for persistence:

  • gerbeur-db — SQLite database (api/sql/gerbeur.db), initialized automatically on first run
  • gerbeur-uploads — user-uploaded files (api/uploads/)

Separate API and frontend (optional)

If you need to run the API on a different host than the frontend, pass the API location as build args so it gets baked into the frontend bundle. In that setup, add the frontend origin to GERBEUR_ALLOWED_ORIGINS so cross-origin HTTP/WebSocket requests are accepted:

docker build \
  --build-arg VITE_API_PROTOCOL=https \
  --build-arg VITE_API_HOSTNAME=api.example.com \
  --build-arg VITE_API_PORT=443 \
  -t gerbeur .

Reverse proxy

Put a reverse proxy (nginx, Caddy, …) in front of the container to handle TLS. Forward everything to port 8000. Example Caddyfile:

example.com {
    reverse_proxy localhost:8000
}

Project structure

api/
  main.ts          # Entry point — Oak application, middleware, routes
  config.ts        # Environment variables
  middleware/      # errorMiddleware, authMiddleware
  routes/          # HTTP routes + WebSocket
  services/        # Business logic
    providers/     # Link metadata providers (YouTube, SoundCloud, Bandcamp, …)
  model/           # Row types, type guards
  lib/             # JWT, pagination, slugify, upload, static helpers, …
  db/
    schema.sql     # Database schema
    init.ts        # First-run database initialisation
    migrate.ts     # Migration runner
    migrations/    # Versioned schema migrations
  sql/
    gerbeur.db     # SQLite database (not committed)
  uploads/         # User-uploaded files — avatars, dumps, thumbnails, … (not committed)
src/               # React frontend (Vite)
  config/          # API base URL, validation constants, feed tabs, upload limits
  pages/           # Route-level components
    index/         # Feed views (hot, new, followed, journal)
  components/      # Shared UI components
    form/          # Reusable form controls
  contexts/        # Auth, WebSocket, player, follows, theme
  hooks/           # Data fetching and UI hooks
  utils/           # Formatting, URL, hot-score, waveform, … helpers
  locales/         # Lingui message catalogues (en, fr)
  themes/          # Per-theme CSS files
  model.ts         # Shared frontend types
  i18n.ts          # Lingui runtime setup
public/            # Static assets (favicon, manifest, service worker)
Description
🚚 tiny community engine for sharing bookmarks
https://pub.sqrt.fr/vincent/gerbeur/
Readme MIT 11 MiB
Languages
TypeScript 87.9%
CSS 10.4%
JavaScript 1.5%