- JavaScript 45.7%
- Python 36.3%
- CSS 11%
- HTML 6.8%
- Dockerfile 0.2%
| .forgejo/workflows | ||
| backend | ||
| frontend | ||
| .dockerignore | ||
| .env.example | ||
| .gitignore | ||
| CLAUDE.md | ||
| docker-compose.portainer.test.yml | ||
| docker-compose.portainer.yml | ||
| docker-compose.yml | ||
| Dockerfile | ||
| README.md | ||
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.filescope — 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
- Go to https://console.cloud.google.com/apis/credentials.
- Create credentials → OAuth client ID → Web application.
- Add an Authorized redirect URI:
http://localhost:8000/api/auth/google/callback - Enable the Google Drive API for the project (APIs & Services → Library).
- On the OAuth consent screen, add the scope
https://www.googleapis.com/auth/drive.fileand add your Google account as a test user (while the app is in "Testing" mode). - Copy
.env.exampleto.envand fill inGOOGLE_CLIENT_IDandGOOGLE_CLIENT_SECRET(and set a long randomJWT_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.
2. Run everything in Docker (recommended)
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
.venvis Python 3.14, which lacks prebuilt wheels for some deps. Ifpip installtries to compile from source, add--only-binary=:all:. The Docker image avoids this by using Python 3.13.
3. Use it
- Open http://localhost:8000 and click Sign in with Google.
- Create a notebook, then + Add page (pick lined / graph / blank).
- 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.
- Reload the page — your strokes and text persist (server storage).
- Back on the notebooks screen, open ⚙ Backups:
- Google Drive — click Back up to Drive to export everything to a
journal-backup.jsonin 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.jsonover 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.
- Restore — Restore from Drive / Restore from Nextcloud downloads your
journal-backup.jsonand 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
keepaliverequest 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.
- Google Drive — click Back up to Drive to export everything to a
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:
- test — installs deps and imports the app (smoke test).
- build — builds the root
Dockerfileand pushesgit.thenymans.com/brian/journal:latest(and a:<sha>tag) to the registry. - deploy — tells Portainer to pull the new image and redeploys the stack,
sending it the contents of
docker-compose.portainer.yml.
One-time setup
-
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). -
In the Forgejo repo, add these Actions secrets (just these):
Secret Purpose REGISTRY_USER/REGISTRY_PASSWORDPush images to git.thenymans.comPORTAINER_TOKENPortainer API key ( X-API-Key)PORTAINER_STACK_IDNumeric id of the stack created in step 1 PORTAINER_ENDPOINT_IDPortainer environment id (e.g. 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.