frontend/CODEMAP.md
PhotoPrism — Frontend CODEMAP
Last Updated: March 8, 2026
Purpose
frontend/package.json as sources of truth.Quick Start
make -C frontend buildmake watch-js from repo root, orcd frontend && npm run watchmake vitest-watch / make vitest-coverage or cd frontend && npm run testDirectory Map (src)
src/app.vue — root component; UI shellsrc/app.js — app bootstrap: creates Vue app, installs Vuetify + plugins, configures router, mounts to #appsrc/app/routes.js — all route definitions (guards, titles, meta)src/app/session.js — $config and $session singletons wired from server-provided window.__CONFIG__ and storagesrc/common/* — framework-agnostic helpers: $api (Axios), $notify, $view, $event (PubSub), i18n (gettext), util, fullscreen, map utils, websocketsrc/component/* — Vue components; src/component/components.js registers global componentssrc/page/* — route views (Albums, Photos, Places, Settings, Admin, Discover, Help, Login, etc.)src/model/* — REST models; base Rest class (model/rest.js) wraps Axios CRUD for collections and entitiessrc/options/* — UI/theme options, formats, auth optionssrc/css/* — styles loaded by Webpacksrc/locales/* — gettext catalogs; extraction/compile scripts in package.jsonStartup Templates & Splash Screen
assets/templates/index.gohtml (and pro/assets/templates/index.gohtml / plus/... / portal/...). Each template includes app.gohtml for the splash markup and app.js.gohtml to inject the bundle.assets/static/js/browser-check.js and is included via app.js.gohtml; it performs capability checks (Promise, fetch, AbortController, script.noModule, etc.) before the main bundle executes. Update the same files in private repos whenever the loader logic changes, and keep the script order so the check runs first..splash-warning fallback banner, live in frontend/src/css/splash.css. Keep styling changes there so public and private editions stay aligned.app.js.gohtml and the CSS message accordingly.createVideoElement wires listeners through an AbortController stored in content.data.events; contentDestroy aborts it so video and RemotePlayback handlers vanish with the slide.Runtime & Plugins
createVuetify) with MDI icons; themes from src/options/themes.js$config.frontendUri (default /library for CE/Plus/Pro and /portal/admin for Portal)vue3-gettext via common/gettext.js; canonical extraction via root make gettext-extract (scans frontend/src plus available overlays in plus/frontend, pro/frontend, and portal/frontend), compile with npm run gettext-compilevue-3-sanitize + vue-sanitize-directivefloating-vuewindow.Hlssrc/common/pwa.js and src/app.js); scope and registration URL derive from $config.baseUri so non-root deployments work. In Portal mode we intentionally skip root-scope (/) registration to avoid shared-domain cache interference with instance scopes under /i/<name>/. Instance clients under /i/<name>/ also try to unregister legacy root-scope registrations before registering their scoped worker, so upgrades from older shared-domain setups can recover without manual browser cleanup. Workbox precache rules live in frontend/webpack.config.js (see the GenerateSW plugin); locale chunks and non-woff2 font variants are excluded there so we don’t force every user to download those assets on first visit.frontend/src/sw-scope-cleanup.js provides strict same-scope precache cleanup. cleanupOutdatedCaches is disabled in GenerateSW to avoid broad cross-scope cache deletion on shared origins.src/common/websocket.js publishes websocket.* events, used by $session for client infoLightbox Integration
src/common/lightbox.js; $lightbox.open(options) fires a lightbox.open event consumed by component/lightbox.vue.$lightbox.openView(this, index) when a component or dialog already has the photos in memory. Implement getLightboxContext(index) on the view and return { models, index, context, allowEdit?, allowSelect? } so the lightbox can build slides without requerying.allowEdit: false when the caller shouldn’t expose inline editing (the edit button and KeyE shortcut are disabled automatically). Set allowSelect: false to hide the selection toggle and block the . shortcut so batch-edit dialogs don’t mutate the global clipboard.$lightbox.openModels(models, index, collection) still accepts raw thumb arrays, but it cannot express the context flags—only use it when you truly don’t have a backing view.HTTP Client
src/common/api.js
window.__CONFIG__.apiUri (or /api/v1 in tests)X-Auth-Token, X-Client-Uri, X-Client-VersionX-Auth-Token from app-local namespaced storage (getAppStorage().getItem("session.token"))X-Preview-Token/X-Download-TokenAuth, Session, and Config
$session: src/common/session.js — restores and persists namespaced browser session state (session.token, session.id, user/provider/scope/data), selects localStorage vs sessionStorage from the namespaced session preference flag, resolves storageNamespace from the actual client config payload, and provides guards/default routessrc/common/storage.js — applies the pp:<storageNamespace>: prefix, supports legacy key migration, and exposes app-local wrappers for localStorage and sessionStorage$config: src/common/config.js — reactive view of server config and user settings; sets theme, language, limits; exposes deny() for feature flagssrc/app.js (router beforeEach/afterEach) and use $session + $config$view: src/common/view.js — manages focus/scroll helpers; use saveWindowScrollPos() / restoreWindowScrollPos() when navigating so infinite-scroll pages land back where users left them; behaviour is covered by tests/vitest/common/view.test.jssrc/page/auth/login.vue — password + OIDC entrypoint; the Stay signed in on this device toggle maps to persistent namespaced localStorage when checked and ephemeral namespaced sessionStorage when unchecked, initializing from the current session storage modeModels (REST)
src/model/rest.js provides search, find, save, update, remove for concrete models (photo, album, label, subject, etc.)src/model/collection.js adds shared behaviors (for example setCover) used by collection-types such as albums and labels.X-Count, X-Limit, X-OffsetHidden Error Reasons
src/model/photo.js via Photo.getHiddenReason(), which prefers FileError from search results and falls back to Files[*].Error (primary file first).src/component/photo/view/cards.vuesrc/component/photo/view/list.vuesrc/component/photo/edit/files.vue with an outlined alert (mdi-alert-circle-outline), so this visual style can differ from result-view metadata icons.Routing Conventions
src/page/<area>/... and import them in src/app/routes.jsmeta.requiresAuth, meta.admin, and meta.settings as neededmeta.title for translated titles; router.afterEach updates document.titleTheming & UI
src/options/themes.js registered in Vuetify; default comes from $config.values.settings.ui.themesrc/component/components.js when they are broadly reusedTesting
frontend/vitest.config.js (Vue plugin, alias map to src/*), tests/vitest/**/*cd frontend && npm run test (or make test-js from repo root)frontend/tests/acceptance; run against a live serverfrontend/tests/README.mdsrc/common/session.js, cover both direct config.storageNamespace access and the real Config shape where the namespace is supplied via config.values.storageNamespaceBuild & Tooling
frontend/package.json:
npm run build (prod), npm run build-dev (dev), npm run watchnpm run lint or make lint-js; repo root make lint runs both backend (golangci-lint via .golangci.yml) and frontend lintersnpm run security:scan (checks --ignore-scripts and forbids v-html)frontend/tests/README.md.make notice from the repo root to regenerate NOTICE files after dependency changes—never edit them manually.make build-js, make watch-js, make test-jsAGENTS.md under “Playwright MCP Usage”; use those directions when agents need to script UI checks or capture screenshots.Common How‑Tos
Add a page
src/page/<name>.vue (or nested directory)src/app/routes.js with name, path, component, and meta$api for data, $notify for UX, $session for guardsupdateQuery(props) helpers should return a boolean indicating whether a navigation was scheduled (recently standardised across pages); callers can bail early when falseAdd a REST model
src/model/<thing>.js extending Rest and implement static getCollectionResource() + static getModelName()Call a backend endpoint
$api.get/post/put/delete from src/common/api.js$session.setAuthToken(token) sets header; router guards redirect to login when neededAdd translations
$gettext(...) / $pgettext(...)$gettext("—"))make gettext-extract from repo root (or CE-only fallback: cd frontend && npm run gettext-extract); compile: npm run gettext-compileRestore scroll state on back navigation
$view.saveRestoreState(key, { count, offset, scrollTop }) when unloads happen and $view.consumeRestoreState(key) on popstate to preload prior batches (Albums, Labels already supply examples).key from route + filter params and cap eager loads with Rest.restoreCap(Model.batchSize()) (defaults to 10× the batch size).$view.wasBackwardNavigation() when deciding whether to reuse stored state; src/app.js wires the router guards that keep the history direction in sync so no globals like window.backwardsNavigationDetected are needed.Handle dialog shortcuts
persistent prop) must listen for Escape on @keydown.esc.exact to override Vuetify’s rejection animation; keep Enter and other actions on @keyup so child inputs can intercept them first.onShortCut(ev) in common/view.js. It only forwards Escape and ctrl/meta combinations, so do not depend on it for plain character keys.Conventions & Safety
v-html; use v-sanitize or $util.sanitizeHtml() (build enforces this)src/pagevitest.config.js when importing (app, common, component, model, options, page)Frequently Touched Files
src/app.js, src/app.vuesrc/app/routes.jssrc/common/api.jssrc/common/session.js, src/common/config.jssrc/model/rest.js and concrete models (photo.js, album.js, ...)src/component/components.jsSee Also
CODEMAP.md) for API and server internals