No description
  • JavaScript 45.7%
  • Python 36.3%
  • CSS 11%
  • HTML 6.8%
  • Dockerfile 0.2%
Find a file
brian 8e8d5edc59
All checks were successful
Build and Deploy / test (push) Successful in 24s
Build and Deploy / build (push) Successful in 7s
Build and Deploy / deploy (push) Has been skipped
Build and Deploy / deploy-main (push) Successful in 13s
Merge pull request 'feat: visual & flow refresh for the frontend' (#4) from test into main
Reviewed-on: #4
2026-07-21 21:02:02 +00:00
.forgejo/workflows fix: re-apply branch-aware deploy lost in merge conflict 2026-07-04 14:34:48 -06:00
backend feat: configurable per-user timezone for journal dates 2026-07-21 12:27:45 -06:00
frontend feat: visual & flow refresh for the frontend 2026-07-21 14:52:53 -06:00
.dockerignore Initial commit: stylus journaling app (FastAPI + Postgres + PWA) 2026-06-07 22:18:03 -06:00
.env.example Move transcription config to an admin UI panel (no env vars) 2026-06-17 00:48:07 -06:00
.gitignore chore: ignore .aider files 2026-07-04 14:37:21 -06:00
CLAUDE.md Auto-stamp service-worker cache name from frontend content hash 2026-06-29 09:48:53 -06:00
docker-compose.portainer.test.yml Fix for deploy.yml 2026-07-04 10:45:54 -06:00
docker-compose.portainer.yml Multi-provider OIDC login; decouple Google Drive backup from login 2026-06-09 13:43:22 -06:00
docker-compose.yml Multi-provider OIDC login; decouple Google Drive backup from login 2026-06-09 13:43:22 -06:00
Dockerfile Transcription: add Wyoming protocol support + a Test button 2026-06-17 01:23:27 -06:00
README.md Add per-page word-processor mode (type & format, draw over background) 2026-06-26 13:39:43 -06:00

Journal

A journaling web app (PWA) for writing/drawing with a stylus and typing, with multiple users, multiple notebooks per user, and several page backgrounds (lined, graph, blank). Data is stored on a central server. Users sign in with Google or any OpenID Connect provider, and can back up their notebooks to their own Google Drive or their own Nextcloud (configured per user under ⚙ Backups).

  • Backend: FastAPI + async SQLAlchemy + Postgres
  • Frontend: vanilla JS PWA (no build step), HTML canvas + Pointer Events (pressure-sensitive stylus support), served by the same FastAPI server
  • Auth: OpenID Connect (Authorization Code flow) — Google built-in, plus any number of additional OIDC providers (Authentik, Keycloak, Auth0, …) configured via OIDC_PROVIDERS. The app issues its own session token after login.
  • Backup: exports all of a user's notebooks/pages as JSON to Google Drive (least-privilege drive.file scope — the app only touches files it creates).

Page content model

Pages store vector content (not images), so they stay small and re-render crisply at any zoom:

{ "width": 1240, "height": 1754,
  "strokes": [ { "color": "#1a1a1a", "size": 2, "points": [[x, y, pressure], ...] } ],
  "texts":   [ { "x": 120, "y": 80, "size": 18, "color": "#1a1a1a", "text": "..." } ],
  "doc": "<p>rich-text HTML for word-processor mode</p>", "wordMode": false }

Each page can also act as a word processor: flip the ✎ toggle on a page to type and format flowing rich text (bold/italic/underline, fonts, sizes, headings, colors, alignment, and lists), stored as sanitized HTML in doc. Flip it off and that text becomes a background you can draw over with the stylus. The page grows taller to fit the typed document; strokes are vectors, so re-rendering at the new height stays lossless.

Prerequisites

  • Docker + Docker Compose (runs the whole app — web + Postgres).
  • (Optional) Python 3.12+ if you want to run the backend on the host instead of in a container.

1. Configure Google OAuth

  1. Go to https://console.cloud.google.com/apis/credentials.
  2. Create credentials → OAuth client ID → Web application.
  3. Add an Authorized redirect URI: http://localhost:8000/api/auth/google/callback
  4. Enable the Google Drive API for the project (APIs & Services → Library).
  5. On the OAuth consent screen, add the scope https://www.googleapis.com/auth/drive.file and add your Google account as a test user (while the app is in "Testing" mode).
  6. Copy .env.example to .env and fill in GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET (and set a long random JWT_SECRET).
cp .env.example .env
# edit .env

Other OIDC providers (optional)

Google is optional. To offer other login providers, set OIDC_PROVIDERS to a JSON array — endpoints are discovered from each issuer's /.well-known/openid-configuration:

OIDC_PROVIDERS='[{"name":"authentik","display_name":"Authentik","client_id":"…","client_secret":"…","issuer":"https://auth.example.com/application/o/journal/"}]'

Register the redirect URI {BASE_URL}/api/auth/oidc/<name>/callback (e.g. http://localhost:8000/api/auth/oidc/authentik/callback) with each provider.

Using Google for Drive backup but not for login: keep GOOGLE_CLIENT_ID/ GOOGLE_CLIENT_SECRET set but add GOOGLE_LOGIN_ENABLED=false. The Google button disappears from the login screen; signed-in users can still Connect Google Drive from ⚙ Backups.

docker compose up --build -d

This builds the app image and starts two containers: web (FastAPI serving the API and the frontend) and db (Postgres). Compose substitutes the values from your .env into the web service, and web waits for the database to be healthy before starting. Tables are created automatically on first boot.

The app is now at http://localhost:8000 (API docs at http://localhost:8000/docs).

docker compose logs -f web   # follow app logs
docker compose down          # stop (add -v to also wipe the database volume)

Alternative: run the backend on the host

Postgres is still exposed on localhost:5432, so you can run just the DB in Docker and the app on your machine:

docker compose up -d db
.venv/bin/pip install -r backend/requirements.txt
.venv/bin/uvicorn app.main:app --reload --app-dir backend

Note: this project's .venv is Python 3.14, which lacks prebuilt wheels for some deps. If pip install tries to compile from source, add --only-binary=:all:. The Docker image avoids this by using Python 3.13.

3. Use it

  1. Open http://localhost:8000 and click Sign in with Google.
  2. Create a notebook, then + Add page (pick lined / graph / blank).
  3. Draw with a stylus or mouse (stylus pressure varies stroke width), switch to the eraser or text tool, change color/size. The eraser rubs out the part of a line you touch (it splits the stroke) rather than deleting the whole line. On a touchscreen, pinch with two fingers to zoom/pan; resets the view. Changes autosave every few seconds; Save forces a save.
  4. Reload the page — your strokes and text persist (server storage).
  5. Back on the notebooks screen, open ⚙ Backups:
    • Google Drive — click Back up to Drive to export everything to a journal-backup.json in your Drive.
    • Nextcloud — enter your server URL, username, and a Nextcloud app password (Settings → Security → Devices & sessions), pick a folder, and Save & verify. Then Back up now uploads journal-backup.json over WebDAV.
    • Automatic backups — enable a schedule (15 min / hourly / 6 h / daily / weekly) and tick Drive and/or Nextcloud. A server-side scheduler runs due backups even when the app is closed.
    • RestoreRestore from Drive / Restore from Nextcloud downloads your journal-backup.json and replaces your current notebooks with it (confirmation required, since it overwrites).
    • Back up on close — tick "back up when I close the app" to fire a backup to every configured location when you close/leave the app. It uploads the local export (including not-yet-synced edits) via a keepalive request that survives unload; for large journals over the ~64 KB keepalive limit it falls back to a server-side backup of synced data. Throttled to once a minute; needs a connection.

Each user only sees their own notebooks.

Project layout

docker-compose.yml            # local dev: builds web + db
docker-compose.portainer.yml  # production stack for Portainer (pulls prebuilt image)
.forgejo/workflows/deploy.yml # CI: test -> build/push image -> redeploy in Portainer
Dockerfile                    # app image (Python 3.13, serves API + frontend)
.dockerignore
.env.example                  # config template
backend/app/
  main.py               # FastAPI app; serves API + static frontend
  config.py models.py schemas.py database.py security.py deps.py google.py
  routers/auth.py notebooks.py pages.py backup.py
frontend/
  index.html app.js style.css manifest.webmanifest sw.js icon.svg

Journal notebooks (calendar)

When creating a notebook you can choose its type:

  • Notebook — a flat ordered list of pages (the default).
  • Journal — organized by a calendar. Opening it shows a month grid with a dot on days that have content; tap a day to view/add that day's pages (same drawing/typing editor) and to upload photos/files for that day.

Files are stored on a server-side volume (DATA_DIR, mounted at /data in Docker) and served via an authenticated endpoint. File upload/view needs a connection (pages-per-day still work offline). Backups include attachments — each file is gzipped and uploaded to Drive/Nextcloud incrementally (named by content hash, so unchanged files are skipped); restore re-downloads them.

Offline support

The app is offline-first. All notebook/page reads and writes go to a local IndexedDB store first (frontend/db.js + frontend/data.js), so you can view, create, draw, type, and delete with no connection. A small badge (bottom-left) shows Synced / Syncing / N pending / Offline.

A sync engine pushes local changes and pulls server changes whenever you're online (on load, on reconnect, on tab focus, and every 30 s). The transport is idempotent, client-UUID-keyed upserts (PUT /api/notebooks/{id}, PUT /api/pages/{id}) plus idempotent deletes, so a queued change can be retried safely. Conflicts use last-write-wins at the page level (the vector page model makes this clean); a locally-edited record is never overwritten by a pull until it's synced.

Notes / limits (this iteration):

  • Sync happens while the app is open (foreground). True background sync while the app is closed isn't done — fine on Android/desktop where the app syncs on reopen; the server-side auto-backup scheduler is unaffected.
  • Remote deletions propagate to a device on its next pull (clean local copies are pruned); locally-unsynced edits are always kept.
  • Login itself needs network once (Google OAuth); after that the session works offline using the cached profile until the token expires.

Deployment (Forgejo Actions → Portainer)

Pushing to main runs .forgejo/workflows/deploy.yml, which:

  1. test — installs deps and imports the app (smoke test).
  2. build — builds the root Dockerfile and pushes git.thenymans.com/brian/journal:latest (and a :<sha> tag) to the registry.
  3. deploy — tells Portainer to pull the new image and redeploys the stack, sending it the contents of docker-compose.portainer.yml.

One-time setup

  1. Create the stack in Portainer from docker-compose.portainer.yml, filling in its environment variables (POSTGRES_PASSWORD, JWT_SECRET, GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, GOOGLE_REDIRECT_URI, FRONTEND_URL). These live in Portainer — the pipeline reads the stack's existing env and re-sends it unchanged on each deploy, so you manage/update them in Portainer, not in CI. Note the stack's numeric id (from the URL).

  2. In the Forgejo repo, add these Actions secrets (just these):

    Secret Purpose
    REGISTRY_USER / REGISTRY_PASSWORD Push images to git.thenymans.com
    PORTAINER_TOKEN Portainer API key (X-API-Key)
    PORTAINER_STACK_ID Numeric id of the stack created in step 1
    PORTAINER_ENDPOINT_ID Portainer environment id (e.g. 3)
  3. Add the public GOOGLE_REDIRECT_URI (set in Portainer) to your Google OAuth client's Authorized redirect URIs (the localhost one only works for local dev).

The web service publishes port 8000; put your reverse proxy in front of it and point FRONTEND_URL / GOOGLE_REDIRECT_URI at the public hostname.

Notes / next steps

Deferred from this first slice: Alembic migrations (tables are auto-created on startup), page reorder drag-and-drop, undo/redo, stroke smoothing, image insertion, PDF/PNG export, real-time multi-device sync, and notebook sharing.