availableLabelOptions and availableAlbumOptions become computeds that
hide anything already in labelItems / albumItems (matched by
$util.normalizeTitle so case and punctuation variants collapse) and
sort the survivors via locale-aware localeCompare. Raw cache results
live on cachedLabelOptions / cachedAlbumOptions so resolveLabelFromText
can still find canonical matches for items already in the chip list.
Brings the Batch Edit dialog in line with the Edit Dialog labels tab
and the lightbox sidebar — every label/album typeahead now filters
already-assigned items out of the dropdown and sorts alphabetically.
The shared typeahead cache returns the cap-bounded result in whatever
order the backend's search?order=name produced, which in practice is
not reliably alphabetical (and would mis-sort Hebrew or Cyrillic
strings under naive byte-order anyway). loadChipOptions now applies a
locale-aware localeCompare with sensitivity:base + numeric:true after
the cache resolves, so the dropdown reads naturally in any language.
Two related dedup fixes for the Edit Dialog labels combobox:
addLabel now bails out without calling the backend when the typed
name resolves (via $util.normalizeTitle) to a label already on the
photo. The backend treats a re-add as an update and was emitting
"Label updated" + "added <name>" toast pairs whenever a user picked
an existing chip from the dropdown.
labelOptions becomes a computed that filters the cached label list by
the same normalized-name comparison and sorts the survivors via
locale-aware localeCompare. The dropdown now only surfaces actionable
suggestions, in alphabetical order — assigned labels disappear from
the list reactively the moment view.model.Labels updates.
Anchors the v-combobox menu at "bottom start" with minWidth:0 so the
dropdown's start edge tracks the input's start edge in both LTR and
RTL — Vuetify's default "bottom" + wider min-width otherwise pushes
the menu leftward into the photo-thumbnail column under the table.
Also adds density="compact" on the combobox itself to match the
lightbox sidebar's typography. Verified in browser at sub-pixel
alignment for English (delta 0.16 px) and Hebrew (delta 0.16 px).
Adds menu-icon="" to suppress the v-combobox dropdown chevron in
the Edit Dialog labels tab — the comfortable-density row positions
the chevron visibly below the input baseline, since the auto-open on
focus is the discovery affordance instead. Wires v-model:menu plus a
suppressMenuOpen debounce so committing a selection no longer re-pops
the dropdown via Vuetify's search-changed watcher when the bound
model is cleared. Mirrors the chip-selector pattern.
Replaces the bare <v-text-field> for new-label entry with a
<v-combobox> mirroring the sidebar pattern. The combobox sources its
suggestions from the shared typeahead cache and applies the same
$util.normalizeTitle canonical-match dedup as the sidebar's
addLabelImmediate (typing 'Hello Cat' resolves to an existing
'hello-cat' label instead of creating a duplicate). autofocus is
explicitly omitted because v-combobox auto-opens its menu on focus
and would lay out the dropdown before the tab geometry settles.
Replaces the per-mount Promise.all([Album.search, Label.search]) in
fetchAvailableOptions with typeaheadCache.getAlbums / getLabels.
Opening the batch-edit dialog while the lightbox sidebar is also
editable now costs zero extra round-trips, and a label or album
created elsewhere shows up next time the dialog opens via the cache's
WS-driven invalidation.
Drops the pencil-to-edit gesture for the lightbox info sidebar's chip
sections. The combobox/autocomplete row now stays mounted whenever the
section is editable, chip × icons are visible whenever isEditable, and
the toolbar ✓ only appears when there are pending REMOVALS. Per-field
input/search/key/options state lives under chipState.<field> so the
two comboboxes don't share scratch refs anymore. cancelEditing is
scoped to inline-text rollback; chip state clears via resetInlineEdits
on discard-pending. The sidebar consumes typeahead suggestions through
the new typeahead-cache module and reloads on combobox @focus to pick
up post-eviction freshness.
Adds frontend/src/common/typeahead-cache.js as a module-scope cache
that dedupes Label.search and Album.search across the sidebar info
panel, the batch-edit dialog, and the edit-dialog labels tab. WS
events (labels.updated/deleted, albums.updated/deleted, config.updated
for album deletes) evict the cache; concurrent callers share the same
in-flight promise, and session.logout clears both lists. Cap warning
behavior moves out of the sidebar into the cache implementation.
Sidebar inline dropdowns (Add label, Add to album, marker name
combobox) now structurally match every other Vuetify v-select /
v-autocomplete menu in the app:
- Drops the custom 1px-solid border + opaque .v-overlay__content
background that no other menu draws. Only the inner .v-list is
given a background; the standard MD elevation shadow alone draws
the edge.
- Lifts the inner .v-list to secondary-light (#1e1e1e) so it sits
visibly above the combobox input's surface-bright (#141417) bg
instead of merging into it.
- Forwards { density: "compact" } via :list-props on each
combobox/autocomplete (the documented Vuetify API for sizing the
menu items, separate from the input field's own density prop).
Title font drops to 0.8125rem so the menu typography matches the
sidebar's other inline-edit text.
Polishes related chip styling:
- The chip :focus-visible ring now uses rgba(--v-theme-on-surface,
0.5) instead of bright --v-theme-primary (#ebebeb), so the ring
is clearly more prominent than the chip's 12%-alpha resting
border without reading as a near-white halo on the dark sidebar
and without reusing a notification token.
- Removes the unreachable .meta-chip--pending-add rule (additions
go through the instant-save path now and never enter chipState).
The original lightbox theme was sized for a basic image viewer with
no editable controls. The info sidebar now embeds dialogs, inline
editors, autocomplete dropdowns, and chip selectors — surfaces that
bind theme tokens which were previously invisible. Three values
were too bright on the near-black lightbox surface:
- highlight: #9c9c9c -> #3d3f40 (PConfirmDialog confirm button —
was a near-white block, now a credible dark-grey button)
- selected: #9c9c9c -> #3d3f40 (Vuetify-internal selection
surfaces: active list item, autocomplete-active row, and the
on-selected fallback in views.css — same fix as highlight)
- hover-opacity: 0.019 -> 0.06,
focus-opacity: 0.022 -> 0.08
(lightbox was inheriting the dark-base defaults, which over a
near-white overlay color left dropdown hover and keyboard-focus
states almost invisible)
Scoped to the lightbox theme — every other theme keeps its current
highlight/selected values. The lightbox primary, info, and on-
surface tokens were left alone since they show up in many places
(focus rings, button text, captions) and would cascade unexpectedly.
Drops the icon="" / confirm-color="info" overrides on the discard-
unsaved-changes dialog so it inherits the same icon-left-of-title
layout and bg-highlight confirm button that every other call site
of <p-confirm-dialog> uses (see album/toolbar.vue, photo/toolbar
.vue, settings/apps.vue, etc.). Picks mdi-alert-circle-outline for
the icon — neutral warning, fits the "you may lose unsaved input"
question without implying permanent deletion.
Replaces the staged "type -> green pending chip -> click toolbar
check" flow for label and album additions with an instant-save
path. Removals stay batched under the toolbar check icon so users
can still toggle a chip back into place without an Undo.
- onLabelEnter / onLabelSelected fire Photo.addLabel(name) right
away. The model's setValues(r.data) chain repopulates this.photo
.Labels with the backend-canonical name, so the chip appears as
a real meta-chip--primary with no transient pending-add state.
- onAlbumSelected fires Photo.addToAlbum(albumUID) right away.
onAlbumEnter for brand-new titles still runs Album.save() first
and chains addToAlbum once a UID exists.
- onAlbumEnter now tries a normalized exact match against
albumOptions (via $util.normalizeTitle) before falling back to
the existing fuzzy startsWith / includes lookup, so typed
variants like "hello-cat" resolve to an existing "Hello Cat"
album instead of silently creating a near-duplicate.
- addLabelImmediate cross-checks labelOptions and uses the
canonical existing-label name when normalized-equal, so typed
"Hello Cat" reuses an existing "hello-cat" label and the user
sees the saved name immediately.
- startChipEditing bumps the typeahead cap from 1000 to 5000 and
emits a console.warn when the response equals the cap (a strong
signal it was truncated; not surfaced via $notify because the
typical user can't act on it).
- chipState drops the additions arrays - additions never enter
chipState anymore - and confirmAlbums migrates from raw
$api.delete/post to Photo.removeFromAlbum, so all album-
membership writes share one model code path.
Failure paths surface $notify.error("Failed to save changes") and
do not leave a transient chip behind. Discard-pending dialog still
fires for typed-but-uncommitted text (chipSearch) and for batched
removals.
The helper is reused for album titles in upcoming sidebar work, so
the label-only name no longer fits. Renames the static method on
$util plus the matching wrapper (normalizeLabelTitleForCompare ->
normalizeTitleForCompare) in batch-edit.vue, the component prop
contract stays the same. No behavior change.
Adds two model methods on Photo for album-membership writes that
mirror the backend album-photo endpoints:
- Photo.addToAlbum(albumUID): POST /albums/<uid>/photos with
{ photos: [this.UID] }, then evict the LRU cache and refetch the
canonical photo so this.Albums reflects the saved state.
- Photo.removeFromAlbum(albumUID): DELETE /albums/<uid>/photos with
the same evict + refind.
The explicit refind is necessary because the backend publishes
albums.updated (not photos.updated) for membership changes, so the
photos.updated WS subscriber doesn't auto-evict here.
The Photo-level method names overlap with Thumb.addToAlbum /
removeFromAlbum on purpose: those operate at the photo-grid layer
and flip a Removed flag for lightbox menu visibility (see
lightbox.vue onRemoveFromAlbum). Both contracts are pinned in
their respective tests so a future "consolidation" PR doesn't
collapse them.
Replace the unconditional @keydown.tab.stop on the v-dialog with
@keydown.tab="onTabKey" plus a runtime guard: stopPropagation now
fires only when document.activeElement is inside this.$refs.container
(or .content as a fallback). PhotoSwipe's _focusRoot() Tab trap stays
suppressed for the typical case (focus inside the lightbox tree, user
tabbing through chips and pencils), and a Tab event that bubbles up
through the dialog from a teleported autocomplete menu or any other
DOM context outside the lightbox tree can still reach PhotoSwipe.
Adds five unit tests covering the inside / outside / fallback / no-ref
/ no-event branches.
hasPendingEdit() was missing two signals, so swiping or pressing the
arrow keys silently dropped typed-but-uncommitted text in the labels
combobox / albums autocomplete and the open Add-name marker prompt.
Add both: typed text in chipSearch while editingField is 'labels' or
'albums', and addNameDialog.visible. resetInlineEdits() also dismisses
an open Add-name dialog so the Discard path leaves the sidebar fully
clean. Existing wrapPswpNavGuards / onChange post-facto rollback already
route through this method, so the discard confirmation now appears for
the missed cases without any new wiring.
Add .stop to every @keydown handler on sidebar editors and the three
nested dialogs (datetime, camera, location). The Vuetify v-dialog above
the lightbox listens for @keydown.esc.exact.stop=close, and PhotoSwipe's
document handler still consumes a few printable keys. Without .stop,
Esc on a textarea closed the editor AND the lightbox, Esc on a nested
dialog closed both modals at once, and Esc on the labels combobox /
albums autocomplete closed the lightbox without ever cancelling the
chip edit (Vuetify's default escape doesn't stop propagation).
Also: add @keydown.escape.stop.prevent=cancelEditing to the labels
combobox and albums autocomplete (their template had no escape
handler), and call clearChipInput() inside cancelEditing so typed-but-
uncommitted chip text doesn't survive a cancel.
Move Tab interception from the PhotoSwipe-event bridge (onPswpKeyDown)
to a generic v-dialog @keydown.tab.stop handler. PhotoSwipe's Tab path
only calls _focusRoot() — a focus trap that Vuetify's v-dialog and
PhotoPrism's $view trap (common/view.js) already provide more
generically — so stopping the bubble at the dialog root is enough,
no PhotoSwipe-specific hook required. onPswpKeyDown stays for the
"z" / printable-key case where we still need to defeat the
toggleZoom shortcut while a textarea or input is focused.
Also: shift the .meta-chip:focus outline to outline-offset: -2px so
the focus ring renders inside the chip area instead of being clipped
when chips wrap against the sidebar edge.
PhotoSwipe's _onKeyDown listens on document and consumes "z" for
toggleZoom and Tab for _focusRoot, so plain typing into the info
sidebar's textareas dropped "z" and Tab snapped focus back to the
lightbox root, leaving sidebar chips and pencils unreachable by
keyboard. Add an onPswpKeyDown bridge that listens via the lightbox's
own keydown event and calls preventDefault on the dispatched event so
PhotoSwipe's switch returns early (per its own defaultPrevented gate).
Two cases: Tab while the sidebar is open (regardless of focus), and
any key when an INPUT, TEXTAREA, or contentEditable element inside
the sidebar is focused. Registered once via this.lightbox.on, which
PhotoSwipe Lightbox replays onto each pswp instance on open.
Make label and album chips focusable (tabindex=0) and bind Enter +
Delete/Backspace via two new helpers: onChipActivate covers click and
Enter (navigates outside edit mode, toggles pending removal inside),
onChipDelete covers Delete and Backspace (no-op outside edit mode,
toggles inside). Vue 3's .delete keydown modifier captures both Delete
and Backspace, so one binding handles both keys.
Also: tighten the cache-freshness comment in confirmLabels to name the
.then((r) => this.setValues(r.data)) chain on Photo.addLabel /
removeLabel, so the next reader can verify the local-patch contract
without grepping model/photo.js. Resolves the documentation half of
proposal item L5 (the asymmetry with confirmAlbums is intentional and
already justified).
Replace four flat arrays (pendingLabel{Additions,Removals},
pendingAlbum{Additions,Removals}) with one chipState object keyed by
field, plus four generic helpers (isChipPendingRemoval,
togglePendingChipRemoval, removePendingChipAdd, resetChipState) that
take field + key. addPendingLabel / addPendingAlbum stay specialized
because their validation differs (string vs object input).
Also: route the chip-section header check/pencil through the existing
PSidebarInlineToolbar; add meta-labels / meta-albums classes for
parity with the per-field hooks; surface a notify.error on
confirmLabels / confirmAlbums rejection (was silent); document the
local-patch vs evict-and-refind asymmetry between addLabel/removeLabel
and the raw album-membership API; suppress the empty .metadata__chips
container when entering edit mode with zero existing chips and zero
pending additions, and hide the redundant 'Add label/album' prompt
while editing. Tests updated to the new state shape and method
signatures (140 sidebar/info tests still pass).
Replace four near-identical Subject/Artist/Copyright/License blocks
and the parallel Keywords/Notes blocks with two v-for loops over a
shared fieldRegistry computed (key, label, icon, read/write, display
mode, optional sanitized-html computed pointer). The registry is also
the lookup table for getFieldValue/setFieldValue, so each field's
label, model path, and rendering rule live in exactly one place.
Also: extract the check/pencil icon pair into PSidebarInlineToolbar,
swap the shared template ref for a function ref so the focus path
keeps working when the editors live inside v-for, and add
showDetailsSection/shouldShowFieldRow computed to replace the 6-term
OR gating expression. Net diff: -272 / +181 lines, no behavior change.
startEditing() used querySelector(.meta-inline-edit input) which matched
the first such element in the DOM. The People combobox shares that class
and renders above subject/artist/copyright/license/keywords/notes, so
clicking any of those add-prompts focused a marker input instead of the
clicked field. Replace the DOM query with a shared template ref on the
mutually-exclusive inline editors and add meta-<field> / meta-inline-<field>
hooks to all rows for consistent styling and test selectors.
AGENTS.md: add frontend/ to package-doc paths and point at
frontend/README.md for pin/override/dep policy.
frontend/AGENTS.md: new "Dependencies & Pins" section briefs agents
on the pins-are-intentional rule, the workspace npm install location,
and the orphan-audit pattern.
frontend/CODEMAP.md: trim the duplicated Vuetify pin paragraph and
link to frontend/README.md as the canonical rationale; keep the
upstream-issue caveats since they relate to component bindings.
Refresh Last Updated dates on all four files.
frontend-rules.md: new "Frontend Dependencies & Pins" section
points at frontend/README.md as the canonical pin doc, restates
the pins-are-intentional rule, and documents the workspace npm
install location plus the orphan-audit pattern.
commit-and-docs-style.md: explicit no-specs/-in-public rule with
the AGENTS.md/CLAUDE.md exception; note that gh issue create lacks
a --type flag so issue type must be set via the web UI.
sources-of-truth.md: add frontend/README.md to the package-README
list and call out its pin/override/orphan content directly.
The "minimatch@~3.0": "^3.1.3" override was added on 2026-04-27 to
force-patch the ReDoS chain when postcss-url's locked minimatch@3.0.8
was the active vulnerability. With postcss-url removed, no consumer
in the tree pins to ~3.0.x anymore — every remaining minimatch
request (^3.0.4 from babel-plugin-istanbul/test-exclude and
eslint-plugin-node, ^3.1.5 from the eslint family, ^9.0.4 / ^10.1.1
from modern tooling) resolves naturally to a patched version.
Verification: removing the override leaves the lockfile byte-identical
("up to date in 932ms"), npm audit reports zero vulnerabilities, and
all installed minimatch versions are 3.1.5 / 5.1.x / 9.0.9 / 10.2.5
(no <3.1.4 entry). Build and 1055 vitest tests pass.
Both were added during the Vitest framework integration (#4990) but
never referenced in any source file or test config — npm ls confirms
zero transitive consumers. PhotoPrism is Vue, so the React testing
library is not applicable; @testing-library/jest-dom (the actually
used helper) stays. The TS-paths resolver was speculatively added
when the Vite config was first stubbed and was never wired up.
README's "Known Unused" table is replaced with a short "Auditing for
Orphaned Dependencies" section so the sweep pattern (rg + npm ls)
is documented for future cleanups.
Cheerio was originally pulled in alongside easygettext@2.17.0 in May
2022 (commit d600d5faf), where easygettext declared cheerio@^1.0.0-rc.3
as a runtime dep for HTML parsing in the gettext-extract flow. When
easygettext was replaced by vue3-gettext / vue-gettext-extract in
October 2025 (commit ab9a0a969 / #1152), cheerio was overlooked and
left as a top-level dep with no consumers.
The exact pin to 1.0.0-rc.12 was set in September 2024 (commit
ab9e156c9, bundled into an unrelated UX commit) after an August 2024
bump to ^1.0.0 broke easygettext's ^1.0.0-rc.3 peer constraint. That
rationale evaporated when easygettext was removed; the lockfile
reverse-search confirms zero current consumers.
npm ls cheerio --all returns only the direct top-level entry; no
source files import it. make audit, make build-js, make test-js
(1055 passed / 13 skipped) all stay clean after removal.
Sweeps top-level CODEMAP/GLOSSARY, package READMEs under internal/,
and a handful of source-comment references that linked to the private
specs/ subrepo. AGENTS.md hints stay (agent-instruction document) and
the agent-skill linking targets in Makefile are guarded with
directory checks.
Captures why specific packages are pinned (vuetify, axios, cheerio),
which transitive overrides exist and why, and which major upgrades
are currently blocked by CJS config files. Includes add/remove/bump
checklists so future contributors can avoid duplicate research.
The supply-chain compromise that drove the original 1.14.0 quarantine
(malicious 1.14.1/0.30.4 from a hijacked maintainer account) was fully
remediated upstream weeks ago, so 1.16.0 is safe. Keeps the exact-pin
style recommended for high-risk packages.
Removes two stale dependencies that have no code references in any
frontend/, plus/frontend/, pro/frontend/, or portal/frontend/ tree.
postcss-url was a 2019 leftover and is the only consumer of the
unpatched minimatch <=3.1.3 ReDoS chain (GHSA-3ppc-4f35-3m26,
GHSA-7r86-cg39-jmmj, GHSA-23c5-xmqv-rm74); npm audit now reports
zero vulnerabilities.