Photo Diary is a calendar-based photo gallery platform for self-hosting. Photos are arranged by the date they were shot, in calendar-based views, aimed at diaries and other date-based photography projects.
Live examples: dailybw.misaki.fi · papusama.misaki.fi
Key features:
- Calendar-based views (year, month, photo), with map from embedded GPS information
- Comprehensive photo statistics (time, gear, exposure settings, etc.) with stacked-area evolution charts
- Fast browsing — per-view fetch narrowed by the active filter, cached client-side so filter toggles update in place
- Admin UI for galleries, users, groups, photo bulk-edit, and per-instance settings
- Multi-instance deploy pattern: shared code + per-instance state + atomic upgrades
For visitors / evaluators:
For operators:
- Setup — install, dev mode, multi-instance deployment, day-to-day ops
- Photo pipeline
- Architecture
For contributors:
- Working in the repo — layout, commands, footguns, release flow
- react-app · server · converter · e2e
The live examples up top are the canonical "see it in action" — the calendar grid, photo modal, stats, and admin UI are best understood by clicking around. A static set captured from a local fixture lives in docs/screenshots.md.
- Photos segmented into galleries; one photo can belong to any number of them
- Real galleries — flat namespace, direct membership
- Virtual galleries — composed live without copying photo rows
- Hybrid: union of one or more real galleries
- Saved-filter: a filter snapshot baked onto a parent gallery, addressed at
/g/<id>like any other
- Hostname-based default + admin scope — a request to a hostname matching
gallery.hostnamepatterns narrows reads and admin writes to that gallery set
- Year / Month / Photo views — heat-mapped calendar grid, thumbnails grouped by date, full-screen photo modal with corner metadata panel (EXIF + location + date + map)
- Persistent title-bar map button (
m) that survives year / month / photo nav, scoped to the current view's photo set - Fast browse — per-view fetches narrowed by the active filter, TanStack Query cache, in-place refresh on filter toggles
- Arrow / swipe nav with prev/next prefetch; clickable breadcrumb (
🏠 › Gallery › 2024 › March › #1234) - SPA UI in
en/fi/ja(UserMenu language picker); per-photo metadata (title, description, place) in the same three locales - Reverse-geocoded country / state / city from Nominatim, in the visitor's locale
- 18 themes (light / dark / neutral / colored); per-gallery override or instance-wide default
- Per-gallery
hide_mapprivacy cascade — coordinates / map / location card can be suppressed per gallery, per user, or via the:guestbaseline; same MAX-merge logic as the access grants
- Inline strip with italic "Category: value" chunks (× to clear individually) opens a modal of per-category cards with faceted counts; a "Show all N" sub-modal walks the natural-sort universe
- Range filters for continuous variables (focal length, aperture, shutter speed, ISO, EV, LV, resolution)
- Date range filter; missing-field chips for the audit workflow
- Within-category values are additive (OR); across categories subtractive (AND)
- Filters apply to gallery and statistics views simultaneously
- Per-gallery
/g/<id>/stats; cross-gallery/sfor admins - General: summary (total photos, days, average per day), author, country, state (beta), city, location (map card)
- Time: year, year/month, month, weekday, hour
- Gear: camera make, camera, lens, camera-lens combo
- Exposure: focal length, aperture, shutter speed, ISO, EV, LV
- Image: resolution, orientation, aspect ratio
- Stacked-area Evolution chart for every trendable category with month / year granularity
- Map card with marker clustering; popup thumbnail links to the photo
- Click any value chip / chart segment to filter both stats and gallery to that subset
- Galleries / Users / Groups / Photos / Access / Instance management surfaces, each opening item routes as layered modals over their list page
- Photo browser with bulk multi-select (shift / long-press / drag-paint), bulk Edit / Regeocode / Set-private / Set-public; same filter strip + modal pattern as
/g/, plus audit chips (duplicates / country-mismatch / per-field missing) for the data-quality workflow - Per-photo Private flag; per-gallery
can_see_privategrants on users + groups; public Photo modal shows a Private badge to viewers who can see it - Editor-tier "Manage this photo" / "Set as gallery icon" buttons on the public Photo modal (gated by
user.isGalleryEditor); pencil opens the samePhotoDrawerin-place over the gallery - Per-language metadata inputs for title / description / place (en / fi / ja) on every item form; canonical column +
*_localizedoverlay rows - Virtual gallery editors — hybrid gallery's source picker, saved-filter gallery's filter builder (mounts the same
<Builder>the public viewer uses) - Gallery icon cropper — pick any photo, drop a square crop, written to
gallery-icons/<id>.jpg - Instance defaults (
defaultGallery,defaultTheme,defaultLanguage,initialGalleryView,firstWeekday, beta feature toggles, rendition ladder) editable from/m/instancewithout an.envround-trip
- JWT sessions (90-day default); user-rotatable secret invalidates every token issued to that user
- Per-user / per-group viewer / editor grants on galleries;
user.is_adminfor instance-wide admin :guestuser carries the public baseline; every user inherits its grants and individual user / group rows can only broaden them (access isMAXacross all matching rows, neverMIN)
- Multi-instance deploy pattern — shared code under
/opt/photo-diary/<version>/, per-instance state under/var/photo-diary/<name>/(own.env, DB, photos, nginx vhost), atomic symlink-flip upgrade viabin/instance.ts - CLI surface for everything the admin UI can do —
bin/{photo,gallery,user,group,access,meta,photo-geocode,photo-rerender}.ts; destructive subcommands default to dry-run, opt in with--apply - Inbox JSON sidecars for instance-wide default metadata on new intake
- Configurable rendition ladder via the
renditionsmeta key;bin/photo-rerender.tsregenerates display variants from on-disk originals
Opt-in surfaces that aren't part of the default UI. Per-visitor toggle in UserMenu → "Beta features", or per-instance lock via BETA_FEATURE_<NAME>=user|on|off in the instance's .env (user (default) shows the toggle, on/off forces it for every visitor).
regions(BETA_FEATURE_REGIONS) — adds a State row to the photo metadata's address line, a State filter category, a Stats State topic, and Summary "Top State" + "States variety" tiles. Backed by curatedsubdivisions/{en,fi,ja}.jsonkeyed by ISO 3166-2. Coverage: JP, FI, DE, MY, KR, AU, CA, US (en + ja); fi covers Finnish regions and falls back to en elsewhere.focalLengthEquiv(BETA_FEATURE_FOCAL_LENGTH_EQUIV) — adds a 35mm-equivalent focal length filter category and a matching Stats Settings category. Uses EXIFFocalLengthIn35mmFormatwhen present; falls back toreact-app/src/lib/crop-factors.jsonfor known-no-EXIF bodies (X100F, 5D Mark II, 30D, GX7, FinePix F50fd). The MetadataPanel Settings row also appends(N㎜ eq.)next to the raw focal length when the values differ.
See SETUP.md for the operator guide — basic setup, dev mode, multi-instance deployment (host prep, bootstrap, nginx, per-gallery vhost mapping, upgrades), and day-to-day operations.
End-to-end flow from a new JPG arriving on the host to it being browsable in the gallery:
- Drop into
inbox/<gallery>/. Copy/move a JPG into the instance'sphotos/inbox/<gallery>/directory (or justphotos/inbox/if you'd rather link to a gallery later). The converter watches this path (via chokidar) recursively and picks the file up immediately. - Converter processes the file. converter reads the EXIF, writes one entry per configured rendition (default: a single 1500-px size) at
photos/display/<maxDim>/<id>.jpgplus the fixed thumbnail atphotos/thumbnail/<id>.jpg, inserts aphoto_renditionrow per display variant so the SPA'ssrcsetpicks it up, moves the original tophotos/original/<id>.jpg, and inserts the photo row into the DB (id, originalFilename, EXIF-derived timestamp / camera / lens / exposure, dimensions). When the file came in underinbox/<gallery>/, it's auto-linked to that gallery ingallery_photoon intake. After this step the photo appears in the gallery on next page load. - Geocoding fills place / country / city. If the photo had EXIF coordinates, the converter (and the
bin/photo-geocode.tsbackfill daemon) resolves them through Nominatim and stores the result onphoto.geocoded_*andphoto_localized.*for the operator's extra languages. - (Optional) operator enrichment via JSON sidecar. Drop a
<name>.jsonalongside (or anywhere under)inbox/with the fields you want to overlay onto an existing row — title, description, operator-set country / place, etc. The converter matches it back to the row by id / originalFilename + timestamp and applies the overlay. The sidecar is archived underphotos/original/<id>.intake.jsonafter processing. - (Optional) operator enrichment via CLI. For one-off corrections,
./bin/photo.ts update <id> --title "…" --place "…"applies the same overrides directly../bin/photo.ts show <id>prints the current row;./bin/photo.ts delete <id>removes one. See./bin/photo.ts --help.
Three TypeScript workspaces, no network between them — the converter and server share the same SQLite DB and photos/ tree on the host filesystem; the SPA talks to the server over HTTP.
flowchart LR
operator["Operator: drop JPGs / JSON sidecars"]
visitor["Visitor browser"]
subgraph instance["Instance dir"]
inbox["photos/inbox/"]
photos["photos/{original,thumbnail,display}/"]
db[("db.sqlite3")]
end
operator -->|"copy"| inbox
inbox -->|"chokidar watch"| converter
converter -->|"sharp + exifr"| photos
converter -->|"insert photo row"| db
converter -.->|"reverse-geocode"| nominatim["Nominatim"]
visitor -->|"HTTPS"| nginx["nginx (TLS)"]
nginx -->|"/api/*"| server
nginx -.->|"photos/* directly"| photos
server -->|"read"| db
server -->|"static SPA bundle"| visitor
For more on the boundaries:
- react-app — Vite + React 19 SPA. Built into static files, served by the server. No backend of its own.
- server — Fastify + TypeBox + better-sqlite3. Owns
/api/v1and serves the SPA bundle. OpenAPI doc at/api/v1/docs. - converter — chokidar-watched intake daemon. EXIF extraction, configurable rendition ladder via sharp, optional Nominatim reverse-geocoding.
Per-instance state — the SQLite DB, the photos/ tree, .env — lives in a single directory outside the repo. Same code can run multiple instances; see Setup for the multi-instance layout.
Where the project is headed. Each bullet links the GitHub milestone for live status.
- 1.0 — shipped 2026-07-19; patches through
1.0.9(see the Version History entry below). - 1.1 — the API cleanup that accompanies the iOS companion app; breaking for API clients, of which the front end and the companion are the only two.
- 2.0 — Thin server, cloud-native direction (direction-setting, far out) — originals leave the server for client-side / cold storage, the converter's sharp pipeline becomes a bundled local uploader, all DB ops route through the API, storage backends behind a vendor-agnostic interface. Likely diverges from today's self-hosted-monolith shape enough that it may end up being a different product line.
Themes loosely held for later — full list lives as open issues without a milestone on GitHub.
- Filter & navigation UX — coordinate-radius filter (photos within N km of a point).
- Gallery shape — alternative renderers for galleries that aren't calendar-shaped.
Third structural take on a long-running personal photo-gallery side project — predecessors at pod.vlumi.net (2004, Perl/CGI then Ruby) and github.com/vlumi/gallery (2012, Ruby/eruby + Apache + SQLite). One-line themes per release; see CHANGELOG.md for the detail.
-
0.1–0.4 (Jul–Aug 2020) — Calendar views, auth/ACL, embedded map, per-gallery stats, photo property filters.
-
0.5 / 0.5.1 (Dec 2021 / May 2022) —
/api/v1versioned surface + instance metadata; aspect-ratio stats. Then a long pause. -
0.6 (May 2026) — Modernization sweep: Express 5, Node 26, ESM + TypeScript, better-sqlite3, React 19, Vite; Stats map with clustering; converter on sharp.
-
0.7 (May 2026) — Multi-instance deploy pattern (versioned code under
/opt/, per-instance dirs under/var/, atomic symlink-flip upgrades); privacyhide_mapcascade, helmet, npm workspaces. -
0.8 (May 2026) — Fastify + TypeBox + OpenAPI on the backend;
openapi-fetch, TanStack Query, Zustand on the frontend; Stats + Photo code-split out of the main bundle. -
0.9 (May 2026) — Privacy hardening (403/404 collapsed), JWT expiration, self-service password change, global 401 handling, toast notifications.
-
0.10 (May 2026) — Photo modal with swipe + controlled zoom, Stats Location card with map-in-modal, clickable breadcrumb, Day merged into Month, seven new themes.
-
0.11 (May 2026) — Reverse-geocoded place hierarchy: structured Nominatim data on intake, backfill daemon, operator-vs-geocoded audit; converter hardens around filename collisions.
-
0.12 (May 2026) — Geocoded surfaces across the app: per-language city / state / country in MetadataPanel, new filter categories and Stats topics, beta-gated 35mm-eq focal length.
-
0.13 (Jun 2026) — Admin frontend bundle (
/m/*) behinduser.is_admin: dashboard, Photos, Galleries, Users, Groups, Access; TypeBox-validated mutations, ACL groups, six new themes. -
0.14 (Jun 2026) — Admin UI polish: slug ids, bulk Edit / Regeocode, dashboard audit tiles, filter-sidebar timeline, gallery-icon cropper, gallery-editor tier, mobile pass.
-
0.15 (Jun 2026) — Composition + scale: hybrid galleries, saved filters as pseudo-galleries, per-language metadata, date-range filter, stats evolution chart; per-view
/query//counts//neighborsendpoints. -
0.16 (Jun 2026) — Filter & viewing UX polish: redesigned filter widget (inline strip + per-category modal cards with faceted counts), continuous-variable range filters, stacked-area evolution chart, virtual-gallery edit page + hybrid-source admin UI, persistent map modal.
-
0.17 (Jun 2026) — Admin UI shift to layered modals + Section card primitive; per-photo visibility + editor-tier admin actions on
/g/;/m/instancepage with runtime-overridablemetadefaults; configurable rendition ladder + collapsedphotos/display/<maxDim>/layout;/m/photosfilters move into a modal; Stats Evolution addsweekdayandhour; Finnish geocoding cleanup (state-code lvl fallthrough + script-rule address blob filter). -
0.18 (Jun 2026) — Cleanup + observability:
metatable is the only source for SPA runtime defaults (no.envfallback);/m/operationsadmin page surfaces converter activity, pending queues, and failures; tidiedbin/photo.tssurface; vitest coverage wired; frontend security audit pass; auth tokens move from localStorage to HttpOnly cookies. -
1.0 (Jul 2026) — Stable milestone. End of the JWT-cookie transition, CSP enable pass, cross-host SSO for the UserMenu virtual-host switcher (with federated login from non-main hosts), Playwright e2e suite, docs overhaul, session-state reconcile on boot + across tabs, session-hardening pass across six rcs.
1.0.1(Jul 26) is a dependency-refresh patch (better-sqlite3 13, @fastify/static 10, jest-dom 7, c8 12, plus in-range bumps across the tree). From here, further work ships as patch releases or moves onto the 2.0 direction. -
1.1 (Sep 2026) — An API fit for a second client, the iOS companion: device pairing (from
1.0.7); the OpenAPI document describes cookie auth, photos, galleries and meta instead of open objects, and a test holds each release to what the last one documented; viewers no longer receive raw EXIF, original filenames or serial numbers. Breaking cleanups: 404 instead of an empty success for a gallery out of reach, the single-gallery route without its embedded photos, camelCase grant lists, and absent rather than empty text.
See the Roadmap for what's in flight after 1.1.



