PHOTOPRISM_OIDC_REDIRECT only fired for the root path; deep links under
/library/* fell through to the SPA bootstrap, which served the local
login page with a manual "Continue with OIDC" button. The deep-link
URL was also dropped on the OIDC roundtrip because $session.loginRedirect
lived only in memory, which the IdP bounce wipes.
The login page now auto-triggers OIDC on mount when the flag is on, and
$session persists the redirect target in namespaced storage under a key
(login.next) that survives auth.gohtml's session.* clear. A one-shot
login.logout flag set on signOut suppresses one auto-trigger so an
explicit logout shows the login form once.
Also adds a /logout SPA route so direct visits behave like the navigation
menu action. The new $session.signOut() snapshots the auth token, fires
the server DELETE with it (so the response is 200 and no 401 echo
re-raises the logout flag), then resets client state synchronously.
AccPass and AccKey are tagged json:"-" on the backend so they're absent
from GET responses. The base Model only tracked keys present in the
response, so getValues(true) silently dropped credential edits and the
PUT body never carried them.
Adds a generic getWriteOnly() hook on the base Model (parallel to
getDefaults()): subclasses declare write-only fields and the constructor
seeds them into __originalValues so the existing change-diff machinery
detects user edits. Service overrides it for AccPass and AccKey.
Refreshes `#:` source-path references after the sidebar relocation
from component/sidebar/ to component/lightbox/sidebar(.vue|/toolbar.vue).
No string changes.
Renames the sidebar component to reflect its only mount point: the
lightbox. Bundles file moves, name renames, template tags, imports,
CSS class renames, and acceptance + vitest selector sweeps so the
build never lands half-renamed.
- frontend/src/component/sidebar/info.vue -> lightbox/sidebar.vue
- frontend/src/component/sidebar/inline-toolbar.vue -> lightbox/sidebar/toolbar.vue
- PSidebarInfo -> PLightboxSidebar; PSidebarInlineToolbar -> PLightboxSidebarToolbar
- .p-sidebar-info -> .p-lightbox-sidebar (and the toolbar variant)
- vitest files relocated; gettext catalog regen deferred to next pass.
Camera popover (component/meta/camera/dialog.vue): wraps the inputs in
<v-form ref="form" v-model="valid">, seeds validate() in afterEnter, and
gates confirm() on $refs.form.validate() with a "Changes could not be
saved" toast on failure. Save button binds :disabled="!valid" so the
disabled state mirrors the datetime popover's :disabled="invalidDate"
pattern and the user sees the gate before clicking.
Datetime popover (component/meta/datetime/dialog.vue): setTime() now
flips invalidDate when the time field is malformed (previously
short-circuited and left the flag stale, so Confirm stayed enabled
against bad input). Time field gains :error="invalidDate" for visual
consistency with the Day/Month/Year siblings.
Both popovers rename the primary action from "Confirm" to "Save" with a
matching aria-label, aligning with the regular Edit Dialog. The
action-confirm CSS class stays for selector compatibility with the
acceptance tests.
Sidebar (component/sidebar/info.vue): formatTime() now delegates to
photo.getDateString(true) when the full Photo is loaded, so Day/Month/
Year=-1 (Unknown) values render via the model's existing fallback
(Unknown / year / month + year) instead of the padded "Jan 1, 2024"
shape. The Thumb model from viewer.Result doesn't carry Year/Month/
Day, so the fallback to the Thumb's plain TakenAtLocal format still
covers the brief pre-load window.
ISO, F Number, and Focal Length on the Edit Dialog and Camera popover
previously capped at 1048576, much wider than the backend's SetExposure
clamps (ISO and FocalLength <= 128000, FNumber <= 256). The looser caps
let the rule pass values the server then quietly rejects. Tightens each
to match the backend, adds Exposure: 64 to PhotoMaxLength, and routes
the Exposure rule through PhotoMaxLength.Exposure instead of a hardcoded
literal.
Drops the bare hide-details attribute from Country, Altitude, ISO,
Exposure, F Number, and Focal Length on both surfaces so the rule's
inline error renders below the input on overflow. Date/time inputs
(Day/Month/Year/Time autocompletes + the standalone DateTime dialog)
stay as-is: autocompletes can only emit valid items, the Time field
combines rules.time() with the :error="invalidDate" visual cue and the
form-validate save gate.
Adds per-entity MaxLength constants on model/{photo,label,subject,album}.js
mirroring the backend VARCHAR widths, and routes every inline-text input
in the Edit Dialog, Batch Edit, sidebar inline editor, Photo Labels /
People tabs, New People page, and the Label / Album / People edit dialogs
through the shared rules.text(...) factory in common/form.js. Deletes the
$config.get('clip') = 160 anti-pattern that rejected valid 200-char titles
and 1024-char rights fields, and drops the bare `hide-details` attribute
that masked the inline error slot.
Adds a $refs.form.validate() gate to the Edit Dialog and Batch Edit save
handlers, mirroring page/settings/account.vue, so an overlength field
blocks the save with $notify.error("Changes could not be saved") instead
of firing photo.update() against invalid input. For the sidebar inline
editor (no parent v-form) confirmField() now checks the field's
fieldRegistry.maxLength imperatively and keeps the editor open on
overflow; navigation arrows then route through confirmDiscardPending,
which opens the existing discard dialog with a "Discard invalid changes?"
label so the user explicitly chooses to revert or stay.
Includes focused vitest cases for the new caps, the form-validate gate
on both dialogs, the sidebar imperative check, and the new discard-dialog
overflow branch.
Hides the Labels and Albums sections in the Batch Edit dialog when
the matching deployment feature is disabled or the session lacks
the `labels:search` / `albums:search` grant, mirroring the gate
pattern already in `sidebar/info.vue`. Skips the typeahead prefetch
for any disabled section so a deployment with both features off
spends no round-trip on data the dialog never reads. Also moves the
Albums row above Labels so the order matches the lightbox sidebar.
Drops shouldShowEditButton() from the <p-face-marker-overlay> v-if so
non-editable users (Plus / Pro viewer role, anyone reaching the eye
toggle in the sidebar) actually see marker rectangles AND the
in-overlay Back button when they activate display mode. The previous
gate required edit permission, so viewers entered face-marker mode
(chrome hidden), saw nothing, and had no way to exit. Edit gestures
stay gated separately by the overlay's isEditMode flag and the
lightbox-side onCreateFaceMarker / onRemoveFaceMarker /
toggleFaceMarkerEdit handlers that re-check shouldShowEditButton().
Pre-existing bug; surfaced while testing the recent ACL-driven
sidebar refactor (#4966).
Replaces the static SidebarRestrictedRoles array ("guest", "visitor",
"contributor") and the User/Session isSidebarRestricted helpers with
per-resource $config.allow(resource, perm) checks. The frontend now
mirrors the backend's authority (internal/auth/acl/rules.go) instead
of duplicating policy: each sidebar section reads the same ACL grant
the server uses to redact the JSON response. Drift fix on the way —
"contributor" wasn't a CE role and the static list silently allowed
the "viewer" role full sidebar access without any spec saying so.
New per-section computeds in sidebar/info.vue:
- canViewLibrary -> $config.allow("photos", "access_library")
Gates camera, lens, file name, and the rights / notes cluster
(photographer-EXIF privacy fields).
- canViewPeople -> $config.allow("people", "search")
- canViewLabels -> $config.allow("labels", "search")
- canViewAlbums -> $config.allow("albums", "search")
- canViewPlaces -> $config.allow("places", "view")
lightbox.vue's fetchPhoto / preloadNextPhoto guards switch from
$session.isSidebarRestricted() to $config.deny("photos",
"access_library") so sessions without library access still skip
the extra GET /photos/:uid calls.
PhotoSwipe Dynamic Caption overlay can now be hidden via Ctrl+H so
the photo takes the full canvas without leaving the lightbox or
opening the sidebar. State persists to localStorage as
`lightbox.caption` (mirrors the existing `lightbox.info` flag), so
the user's choice survives slide nav, lightbox close/reopen, and
page reload. The toggle gates the forked dynamic-caption plugin's
panAreaSize adjustment via a new `enabled` option and routes the
layout recompute through this.resize(true) after $nextTick (the
.hide-caption class binding is reactive), so the photo actually
reclaims the caption's reserved vertical space when not width-
bound by aspect ratio. Shortcut sits inside the existing input-
focus gate, is inert in face-marker mode, and is a no-op when the
sidebar is open (captions render in the sidebar there). Also adds
mouse-driven Show/Hide Caption entries to the lightbox dropdown
menu, mirroring the Archive/Restore mutually-exclusive pattern.
Picks up the "Edit Faces" / "Remove Name" → "Edit" / "Unassign"
renames from the previous commit. "Edit" was already broadly
translated; "Unassign" is freshly active and needs new translations
across locales. "Remove Name" demoted to obsolete (#~) with prior
translations preserved.
Renames the People-section toggle from "Edit Faces" to "Edit" — the
mdi-pencil icon plus surrounding section context already convey the
scope, and the shorter label aligns with the rest of the inline-toolbar
pencil affordances. Renames the marker-name eject tooltip from "Remove
Name" to "Unassign", which more accurately describes the action (the
subject assignment is removed, not the name as text). Same string now
covers both edit-dialog and sidebar surfaces. Also reorders a couple
of v-btn attributes for consistency.
Notes uses `icon: null` so Vuetify's default `'end center'` tooltip
location anchors against an empty prepend slot, putting the tooltip
in a visually inconsistent place vs sibling rows. Gate the tooltip
on the row having an icon so Notes (and any future no-icon row in
detailsFields) skips the tooltip entirely — matches Title and
Caption above which never had one. The "Notes" / placeholder text
is already visible inline as the add-prompt, so no information is
lost.
Hovering a person row in the lightbox sidebar's People section now
sets a thicker purple stroke + glow on the matching face-marker rect
on the image, making the visual link between the row and the box
unambiguous in both display and edit modes. Implementation routes
through a new hoveredMarkerUid field on the $faceMarkers singleton
that the lightbox forwards as a prop to the overlay. Reverse
direction (rect hover → sidebar row) isn't wired since rects carry
pointer-events: none — recorded for future work in the singleton +
CSS comments. Also drops two redundant text-align: start declarations
on .meta-title / .meta-caption (defaults already match).
Adds an input/textarea/contenteditable focus gate at the top of the
lightbox onShortCut handler so Ctrl+A select-all, Ctrl+C copy, etc.
no longer trigger global lightbox actions when a sidebar inline
editor or edit-dialog field has focus. Migrates the Archive/Restore
binding from KeyA to KeyX (Gmail/Outlook-style mnemonic) so the
canonical select-all chord stays free for text editing in addition
to the focus gate. Updates the matching face-marker disabled-shortcut
list and acceptance + vitest test cases.
Unifies Subject / Artist / Copyright / License / Keywords / Notes
into a single icon-prepended row layout (Option A from the proposal),
adds per-field maxLength sourced from new PhotoMaxLength constants
mirroring the backend VARCHAR caps, fixes a pre-existing textarea
auto-grow regression caused by a static min-height override in
lightbox.css, wires Enter-to-commit on the single-line fields via a
commitOnEnter registry flag, and adds an Undo affordance + new
hideEditUndo / hideEditSave toggles for A/B testing the inline
toolbar chrome.
common/event.js now exports six action-verb constants (ACTION_CREATED,
ACTION_UPDATED, ACTION_DELETED, ACTION_ARCHIVED, ACTION_RESTORED,
ACTION_EDITED) and composes ENTITY_MUTATIONS from them so there is one
source of truth. Every onUpdate(ev, data) switch in the frontend now
references the symbols instead of the literal strings — eight cache /
page-level handlers across albums, labels, library/browse, photos,
people, the album-photos view, and the photo edit dialog.
The motivation is navigation: grep / IDE find-references on
"updated" or "created" is overwhelmed by Vue lifecycle hooks, Date
methods, prose in comments, and unrelated occurrences of the same
English word. ACTION_UPDATED finds only the event-handling sites.
The count.* subtype switch in common/config.js stays on string
literals because those are count category names, not entity-mutation
verbs.