Merge remote-tracking branch 'origin/develop' into SQLiteOIDC#4951

This commit is contained in:
Keith Martin 2025-11-17 18:48:47 +10:00
commit 0672e58232
199 changed files with 27661 additions and 24804 deletions

View file

@ -1,6 +1,6 @@
# PhotoPrism® Repository Guidelines
**Last Updated:** November 2, 2025
**Last Updated:** November 14, 2025
## Purpose
@ -17,6 +17,7 @@ Learn more: https://agents.md/
- REST API: https://docs.photoprism.dev/ (Swagger), https://docs.photoprism.app/developer-guide/api/ (Docs)
- Code Maps: [`CODEMAP.md`](CODEMAP.md) (Backend/Go), [`frontend/CODEMAP.md`](frontend/CODEMAP.md) (Frontend/JS)
- Face Detection & Embeddings Notes: [`internal/ai/face/README.md`](internal/ai/face/README.md)
- Vision Engine Guides: [`internal/ai/vision/openai/README.md`](internal/ai/vision/openai/README.md), [`internal/ai/vision/ollama/README.md`](internal/ai/vision/ollama/README.md)
> Quick Tip: to inspect GitHub issue details without leaving the terminal, run `curl -s https://api.github.com/repos/photoprism/photoprism/issues/<id>`.
@ -220,6 +221,14 @@ Note: Across our public documentation, official images, and in production, the c
> Remember to update the `**Last Updated:**` line at the top whenever you edit these guidelines or other files containing a timestamp.
### Frontend Focus Management
- Dialogs must follow the shared focus pattern documented in `frontend/src/common/README.md`.
- Always expose `ref="dialog"` on `<v-dialog>` overlays, call `$view.enter/leave` in `@after-enter` / `@after-leave`, and avoid positive `tabindex` values.
- Persistent dialogs (those with the `persistent` prop) must handle Escape via `@keydown.esc.exact` so Vuetifys default rejection animation is suppressed; keep other shortcuts on `@keyup` so inner inputs can cancel them first.
- Global shortcuts run through `onShortCut(ev)` in `common/view.js`; it only forwards Escape and `ctrl`/`meta` combinations, so do not rely on it for arbitrary keys.
- When a dialog opens nested menus (for example, combobox suggestion lists), ensure they work with the global trap; see the README for troubleshooting tips.
## Safety & Data
- Never commit secrets, local configurations, or cache files. Use environment variables or a local `.env`.

View file

@ -1,6 +1,6 @@
PhotoPrism — Backend CODEMAP
**Last Updated:** November 2, 2025
**Last Updated:** November 14, 2025
Purpose
- Give agents and contributors a fast, reliable map of where things live and how they fit together, so you can add features, fix bugs, and write tests without spelunking.
@ -35,6 +35,7 @@ High-Level Package Map (Go)
- `internal/config` — configuration, flags/env/options, client config, DB init/migrate
- `internal/entity` — GORM v1 models, queries, search helpers, migrations
- `internal/photoprism` — core domain logic (indexing, import, faces, thumbnails, cleanup)
- `internal/ai/vision` — multi-engine computer vision pipeline (models, adapters, schema). Adapter docs: [`internal/ai/vision/openai/README.md`](internal/ai/vision/openai/README.md) and [`internal/ai/vision/ollama/README.md`](internal/ai/vision/ollama/README.md).
- `internal/workers` — background schedulers (index, vision, sync, meta, backup)
- `internal/auth` — ACL, sessions, OIDC
- `internal/service` — cluster/portal, maps, hub, webdav

View file

@ -1,5 +1,5 @@
# Ubuntu 25.10 (Questing Quokka)
FROM photoprism/develop:251018-questing
FROM photoprism/develop:251113-questing
# Harden npm usage by default (applies to npm ci / install in dev container)
ENV NPM_CONFIG_IGNORE_SCRIPTS=true

70
NOTICE
View file

@ -9,7 +9,7 @@ The following 3rd-party software packages may be used by or distributed with
PhotoPrism. Any information relevant to third-party vendors listed below are
collected using common, reasonable means.
Date generated: 2025-10-31
Date generated: 2025-11-12
================================================================================
@ -405,11 +405,39 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
--------------------------------------------------------------------------------
Package: github.com/clipperhouse/stringish
Version: v0.1.1
License: MIT (https://github.com/clipperhouse/stringish/blob/v0.1.1/LICENSE)
MIT License
Copyright (c) 2025 Matt Sherman
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
--------------------------------------------------------------------------------
Package: github.com/clipperhouse/uax29/v2
Version: v2.2.0
License: MIT (https://github.com/clipperhouse/uax29/blob/v2.2.0/LICENSE)
Version: v2.3.0
License: MIT (https://github.com/clipperhouse/uax29/blob/v2.3.0/LICENSE)
MIT License
@ -1178,8 +1206,8 @@ THE SOFTWARE.
--------------------------------------------------------------------------------
Package: github.com/go-co-op/gocron/v2
Version: v2.17.0
License: MIT (https://github.com/go-co-op/gocron/blob/v2.17.0/LICENSE)
Version: v2.18.0
License: MIT (https://github.com/go-co-op/gocron/blob/v2.18.0/LICENSE)
MIT License
@ -2415,8 +2443,8 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI
--------------------------------------------------------------------------------
Package: github.com/golang/geo
Version: v0.0.0-20251030142647-5906ab3d21fa
License: Apache-2.0 (https://github.com/golang/geo/blob/5906ab3d21fa/LICENSE)
Version: v0.0.0-20251111181513-e7f3a1a58fb3
License: Apache-2.0 (https://github.com/golang/geo/blob/e7f3a1a58fb3/LICENSE)
Apache License
@ -8160,8 +8188,8 @@ License: Apache-2.0 (https://github.com/go4org/go4/blob/214862532bf5/LICENSE)
--------------------------------------------------------------------------------
Package: golang.org/x/crypto
Version: v0.43.0
License: BSD-3-Clause (https://cs.opensource.google/go/x/crypto/+/v0.43.0:LICENSE)
Version: v0.44.0
License: BSD-3-Clause (https://cs.opensource.google/go/x/crypto/+/v0.44.0:LICENSE)
Copyright 2009 The Go Authors.
@ -8194,8 +8222,8 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
--------------------------------------------------------------------------------
Package: golang.org/x/image
Version: v0.32.0
License: BSD-3-Clause (https://cs.opensource.google/go/x/image/+/v0.32.0:LICENSE)
Version: v0.33.0
License: BSD-3-Clause (https://cs.opensource.google/go/x/image/+/v0.33.0:LICENSE)
Copyright 2009 The Go Authors.
@ -8228,8 +8256,8 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
--------------------------------------------------------------------------------
Package: golang.org/x/mod/semver
Version: v0.29.0
License: BSD-3-Clause (https://cs.opensource.google/go/x/mod/+/v0.29.0:LICENSE)
Version: v0.30.0
License: BSD-3-Clause (https://cs.opensource.google/go/x/mod/+/v0.30.0:LICENSE)
Copyright 2009 The Go Authors.
@ -8262,8 +8290,8 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
--------------------------------------------------------------------------------
Package: golang.org/x/net
Version: v0.46.0
License: BSD-3-Clause (https://cs.opensource.google/go/x/net/+/v0.46.0:LICENSE)
Version: v0.47.0
License: BSD-3-Clause (https://cs.opensource.google/go/x/net/+/v0.47.0:LICENSE)
Copyright 2009 The Go Authors.
@ -8330,8 +8358,8 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
--------------------------------------------------------------------------------
Package: golang.org/x/sync/errgroup
Version: v0.17.0
License: BSD-3-Clause (https://cs.opensource.google/go/x/sync/+/v0.17.0:LICENSE)
Version: v0.18.0
License: BSD-3-Clause (https://cs.opensource.google/go/x/sync/+/v0.18.0:LICENSE)
Copyright 2009 The Go Authors.
@ -8364,8 +8392,8 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
--------------------------------------------------------------------------------
Package: golang.org/x/sys
Version: v0.37.0
License: BSD-3-Clause (https://cs.opensource.google/go/x/sys/+/v0.37.0:LICENSE)
Version: v0.38.0
License: BSD-3-Clause (https://cs.opensource.google/go/x/sys/+/v0.38.0:LICENSE)
Copyright 2009 The Go Authors.
@ -8398,8 +8426,8 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
--------------------------------------------------------------------------------
Package: golang.org/x/text
Version: v0.30.0
License: BSD-3-Clause (https://cs.opensource.google/go/x/text/+/v0.30.0:LICENSE)
Version: v0.31.0
License: BSD-3-Clause (https://cs.opensource.google/go/x/text/+/v0.31.0:LICENSE)
Copyright 2009 The Go Authors.

View file

@ -3,8 +3,8 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-17 17:32+0000\n"
"PO-Revision-Date: 2025-10-22 08:25+0000\n"
"Last-Translator: DeepL <noreply-mt-deepl@weblate.org>\n"
"PO-Revision-Date: 2025-11-12 07:40+0000\n"
"Last-Translator: dtsolakis <dtsola@eranet.gr>\n"
"Language-Team: none\n"
"Language: el\n"
"MIME-Version: 1.0\n"
@ -23,11 +23,11 @@ msgstr "Αυτό δεν είναι εφικτό"
#: messages.go:106
msgid "Changes could not be saved"
msgstr "Οι αλλαγές δεν μπόρεσαν να αποθηκευτούν"
msgstr "Οι αλλαγές δεν ήταν δυνατό να αποθηκευτούν"
#: messages.go:107
msgid "Could not be deleted"
msgstr "Δεν μπόρεσε να διαγραφεί"
msgstr "Δεν ήταν εφικτή η διαγραφή"
#: messages.go:108
#, c-format
@ -48,7 +48,7 @@ msgstr "Πολύ μεγάλο αρχείο"
#: messages.go:112
msgid "Unsupported"
msgstr "Ανυποστήρικτος"
msgstr "Δεν υποστηρίζεται"
#: messages.go:113
msgid "Unsupported type"
@ -56,11 +56,11 @@ msgstr "Μη υποστηριζόμενος τύπος"
#: messages.go:114
msgid "Unsupported format"
msgstr "Μη υποστηριζόμενη μορφή"
msgstr "Μη υποστηριζόμενος μορφότυπος"
#: messages.go:115
msgid "Originals folder is empty"
msgstr "Ο φάκελος Πρωτότυπα είναι άδειος"
msgstr "Ο φάκελος πρωτότυπων είναι άδειος"
#: messages.go:116
msgid "Selection not found"
@ -84,19 +84,19 @@ msgstr "Η ετικέτα δεν βρέθηκε"
#: messages.go:121
msgid "Album not found"
msgstr "Η Συλλογή δεν βρέθηκε"
msgstr "Το άλμπουμ δεν βρέθηκε"
#: messages.go:122
msgid "Subject not found"
msgstr "Το Θέμα δεν βρέθηκε"
msgstr "Το θέμα δεν βρέθηκε"
#: messages.go:123
msgid "Person not found"
msgstr "Το Άτομο δεν βρέθηκε"
msgstr "Το άτομο δεν βρέθηκε"
#: messages.go:124
msgid "Face not found"
msgstr "Το Πρόσωπο δεν βρέθηκε"
msgstr "Το πρόσωπο δεν βρέθηκε"
#: messages.go:125
msgid "Not available in public mode"
@ -104,7 +104,7 @@ msgstr "Μη διαθέσιμο κατά τη δημόσια λειτουργί
#: messages.go:126
msgid "Not available in read-only mode"
msgstr "μη διαθέσιμο στην κατάσταση \"μόνο για ανάγνωση\""
msgstr "Μη διαθέσιμο στην κατάσταση \"μόνο ανάγνωση\""
#: messages.go:127
msgid "Please log in to your account"
@ -112,7 +112,7 @@ msgstr "Παρακαλούμε συνδεθείτε και δοκιμάστε ξ
#: messages.go:128
msgid "Permission denied"
msgstr "Το Άτομο διαγράφηκε"
msgstr "Δέν δόθηκε άδεια"
#: messages.go:129
msgid "Payment required"
@ -120,31 +120,31 @@ msgstr "Απαιτείται πληρωμή"
#: messages.go:130
msgid "Upload might be offensive"
msgstr "Η φόρτωση μπορεί να είναι προσβλητική"
msgstr "Το ανέβασμα μπορεί να είναι προσβλητικό"
#: messages.go:131
msgid "Upload failed"
msgstr "Αποτυχία αποστολής"
msgstr "Αποτυχία ανεβάσματος"
#: messages.go:132
msgid "No items selected"
msgstr "Δεν έχουν επιλεγεί αντικείμενα"
msgstr "Δεν έχουν επιλεγεί στοιχεία"
#: messages.go:133
msgid "Failed creating file, please check permissions"
msgstr "Απέτυχε η δημιουργία αρχείου, παρακαλούμε ελέγξτε τα δικαιώματα"
msgstr "Απέτυχε η δημιουργία αρχείου, ελέγξτε τα δικαιώματα"
#: messages.go:134
msgid "Failed creating folder, please check permissions"
msgstr "Απέτυχε η δημιουργία φακέλου, παρακαλούμε ελέγξτε τα δικαιώματα"
msgstr "Απέτυχε η δημιουργία φακέλου, ελέγξτε τα δικαιώματα"
#: messages.go:135
msgid "Could not connect, please try again"
msgstr "Δεν ήταν δυνατή η σύνδεση, παρακαλώ δοκιμάστε ξανά"
msgstr "Δεν ήταν δυνατή η σύνδεση, δοκιμάστε ξανά"
#: messages.go:136
msgid "Enter verification code"
msgstr "βάλτε κωδικό επιβεβαίωσης"
msgstr "Εισάγετε τον κωδικό επαλήθευσης"
#: messages.go:137
msgid "Invalid verification code, please try again"
@ -152,11 +152,11 @@ msgstr "Μη έγκυρος κωδικός επαλήθευσης, δοκιμά
#: messages.go:138
msgid "Invalid password, please try again"
msgstr "Μη έγκυρος κωδικός πρόσβασης, παρακαλώ δοκιμάστε ξανά"
msgstr "Μη έγκυρος κωδικός πρόσβασης, δοκιμάστε ξανά"
#: messages.go:139
msgid "Feature disabled"
msgstr "Λειτουργία απενεργοποιημένη"
msgstr "Απενεργοποιημένη δυνατότητα"
#: messages.go:140
msgid "No labels selected"
@ -164,7 +164,7 @@ msgstr "Δεν έχουν επιλεγεί ετικέτες"
#: messages.go:141
msgid "No albums selected"
msgstr "Δεν έχουν επιλεγεί συλλογές"
msgstr "Δεν έχουν επιλεγεί άλμπουμ"
#: messages.go:142
msgid "No files available for download"
@ -188,7 +188,7 @@ msgstr "Μη έγκυρο όνομα"
#: messages.go:147
msgid "Busy, please try again later"
msgstr "Απασχολημένος, προσπαθήστε ξανά αργότερα"
msgstr "Το σύστημα είναι απασχολημένο, προσπαθήστε ξανά αργότερα"
#: messages.go:148
#, c-format
@ -197,7 +197,7 @@ msgstr "Το διάστημα αφύπνισης είναι %s, αλλά πρέ
#: messages.go:149
msgid "Your account could not be connected"
msgstr "Ο λογαριασμός σας δεν μπόρεσε να συνδεθεί"
msgstr "Ο λογαριασμός σας δεν ήταν δυνατό να συνδεθεί"
#: messages.go:150
msgid "Too many requests"
@ -205,11 +205,11 @@ msgstr "Πάρα πολλά αιτήματα"
#: messages.go:151
msgid "Insufficient storage"
msgstr "Ανεπαρκής αποθήκευση"
msgstr "Ανεπαρκής χώρος"
#: messages.go:152
msgid "Quota exceeded"
msgstr "Υπέρβαση ποσόστωσης"
msgstr "Υπέρβαση ορίου"
#: messages.go:155
msgid "Changes successfully saved"
@ -217,20 +217,20 @@ msgstr "Οι αλλαγές αποθηκεύτηκαν επιτυχώς"
#: messages.go:156
msgid "Album created"
msgstr "Η Συλλογή δημιουργήθηκε"
msgstr "Το άλμπουμ δημιουργήθηκε"
#: messages.go:157
msgid "Album saved"
msgstr "Η Συλλογή αποθηκεύθηκε"
msgstr "Το άλμπουμ αποθηκεύθηκε"
#: messages.go:158
#, c-format
msgid "Album %s deleted"
msgstr "Η Συλλογή %s διαγράφηκε"
msgstr "Το άλμπουμ %s διαγράφηκε"
#: messages.go:159
msgid "Album contents cloned"
msgstr "Τα περιεχόμενα της Συλλογής αντιγράφηκαν"
msgstr "Τα περιεχόμενα του άλμπουμ αντιγράφηκαν"
#: messages.go:160
msgid "File removed from stack"
@ -267,15 +267,15 @@ msgstr "%d καταχωρήσεις αφαιρέθηκαν από %s"
#: messages.go:167
msgid "Account created"
msgstr "Ο Λογαριασμός δημιουργήθηκε"
msgstr "Ο λογαριασμός δημιουργήθηκε"
#: messages.go:168
msgid "Account saved"
msgstr "Ο Λογαριασμός αποθηκεύθηκε"
msgstr "Ο λογαριασμός αποθηκεύθηκε"
#: messages.go:169
msgid "Account deleted"
msgstr "Ο Λογαριασμός διαγράφηκε"
msgstr "Ο λογαριασμός διαγράφηκε"
#: messages.go:170
msgid "Settings saved"
@ -297,7 +297,7 @@ msgstr "Η εισαγωγή ακυρώθηκε"
#: messages.go:174
#, c-format
msgid "Indexing completed in %d s"
msgstr "Η δημιουργία ευρετηρίου σε %d s"
msgstr "Η ευρετηρίαση ολοκληρώθηκε σε %d s"
#: messages.go:175
msgid "Indexing originals..."
@ -329,27 +329,27 @@ msgstr "Αντιγραφή αρχείων από %s"
#: messages.go:181
msgid "Labels deleted"
msgstr "Οι Ετικέτες διαγράφηκαν"
msgstr "Οι ετικέτες διαγράφηκαν"
#: messages.go:182
msgid "Label saved"
msgstr "Η Ετικέτα αποθηκεύτηκε"
msgstr "Η ετικέτα αποθηκεύτηκε"
#: messages.go:183
msgid "Subject saved"
msgstr "Το Θέμα αποθηκεύθηκε"
msgstr "Το θέμα αποθηκεύθηκε"
#: messages.go:184
msgid "Subject deleted"
msgstr "Το Θέμα διαγράφηκε"
msgstr "Το θέμα διαγράφηκε"
#: messages.go:185
msgid "Person saved"
msgstr "Το Άτομο αποθηκεύθηκε"
msgstr "Το άτομο αποθηκεύθηκε"
#: messages.go:186
msgid "Person deleted"
msgstr "Το Άτομο διαγράφηκε"
msgstr "Το άτομο διαγράφηκε"
#: messages.go:187
msgid "File uploaded"
@ -358,15 +358,15 @@ msgstr "Το αρχείο διαγράφηκε"
#: messages.go:188
#, c-format
msgid "%d files uploaded in %d s"
msgstr "%d αρχεία μεταφορτώθηκαν σε %d s"
msgstr "%d αρχεία ανεβάστηκαν σε %d s"
#: messages.go:189
msgid "Processing upload..."
msgstr "Επεξεργασία μεταφόρτωσης..."
msgstr "Επεξεργασία ανεβάσματος..."
#: messages.go:190
msgid "Upload has been processed"
msgstr "Η φόρτωση έχει ολοκληρωθεί"
msgstr "Το ανέβασμα έχει ολοκληρωθεί"
#: messages.go:191
msgid "Selection approved"
@ -382,16 +382,16 @@ msgstr "Η επιλογή αποκαταστάθηκε"
#: messages.go:194
msgid "Selection marked as private"
msgstr "Η επιλογή χαρακτηρίστηκε ως ιδιωτική"
msgstr "Η επιλογή μαρκαρίστηκε ως ιδιωτική"
#: messages.go:195
msgid "Albums deleted"
msgstr "Οι Συλλογές διαγράφηκαν"
msgstr "Διαγραμμένα άλμπουμ"
#: messages.go:196
#, c-format
msgid "Zip created in %d s"
msgstr "Το αρχείο συμπίεσης δημιουργήθηκε σε %d s"
msgstr "Το αρχείο zip δημιουργήθηκε σε %d s"
#: messages.go:197
msgid "Permanently deleted"
@ -404,11 +404,11 @@ msgstr "%s έχει αποκατασταθεί"
#: messages.go:199
msgid "Successfully verified"
msgstr "Επαληθεύτηκε με επιτυχία"
msgstr "Επιτυχής επαλήθευση"
#: messages.go:200
msgid "Successfully activated"
msgstr "Ενεργοποιήθηκε με επιτυχία"
msgstr "Επιτυχής ενεργοποίηση"
#~ msgid "Storage is full"
#~ msgstr "Ο αποθηκευτικός χώρος είναι γεμάτος"

View file

@ -388,7 +388,8 @@ services:
## Login with "user / photoprism" and "admin / photoprism".
keycloak:
image: quay.io/keycloak/keycloak:25.0
stop_grace_period: 30s
stop_grace_period: 20s
profiles: [ "all", "auth", "keycloak" ]
command: "start-dev" # development mode, do not use this in production!
links:
- "traefik:localssl.dev"

View file

@ -27,7 +27,7 @@ services:
traefik:
restart: always
image: traefik:v3.5
image: traefik:v3.6
container_name: traefik
ports:
- "80:80"
@ -50,7 +50,7 @@ services:
watchtower:
restart: always
image: containrrr/watchtower
image: nickfedor/watchtower
container_name: watchtower
environment:
WATCHTOWER_CLEANUP: "true"

View file

@ -1,6 +1,6 @@
PhotoPrism — Frontend CODEMAP
**Last Updated:** October 13, 2025
**Last Updated:** November 12, 2025
Purpose
- Help agents and contributors navigate the Vue 3 + Vuetify 3 app quickly and make safe changes.
@ -107,6 +107,10 @@ Common HowTos
- Compute `key` from route + filter params and cap eager loads with `Rest.restoreCap(Model.batchSize())` (defaults to 10× the batch size).
- Check `$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 dialogs (`persistent` prop) must listen for Escape on `@keydown.esc.exact` to override Vuetifys rejection animation; keep Enter and other actions on `@keyup` so child inputs can intercept them first.
- Global shortcuts go through `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
- Avoid `v-html`; use `v-sanitize` or `$util.sanitizeHtml()` (build enforces this)
- Keep big components lazy if needed; split views logically under `src/page`

File diff suppressed because it is too large Load diff

View file

@ -44,28 +44,28 @@
"@mdi/font": "^7.4.47",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
"@vitejs/plugin-react": "^5.1.0",
"@vitejs/plugin-react": "^5.1.1",
"@vitejs/plugin-vue": "^6.0.1",
"@vitest/browser": "^3.2.4",
"@vitest/coverage-v8": "^3.2.4",
"@vitest/ui": "^3.2.4",
"@vue/compiler-sfc": "^3.5.18",
"@vue/language-server": "^3.1.2",
"@vue/language-server": "^3.1.3",
"@vue/test-utils": "^2.4.6",
"@vvo/tzdb": "^6.190.0",
"axios": "^1.13.1",
"@vvo/tzdb": "^6.193.0",
"axios": "^1.13.2",
"axios-mock-adapter": "^2.1.0",
"babel-loader": "^10.0.0",
"babel-plugin-istanbul": "^7.0.1",
"babel-plugin-polyfill-corejs3": "^0.13.0",
"browserslist": "^4.27.0",
"browserslist": "^4.28.0",
"cheerio": "1.0.0-rc.12",
"core-js": "^3.46.0",
"cross-env": "^7.0.3",
"css-loader": "^7.1.2",
"cssnano": "^7.1.2",
"escape-string-regexp": "^4.0.0",
"eslint": "^9.39.0",
"eslint": "^9.39.1",
"eslint-config-prettier": "^10.1.8",
"eslint-formatter-pretty": "^6.0.1",
"eslint-plugin-html": "^8.1.3",
@ -84,7 +84,7 @@
"i": "^0.3.7",
"jsdom": "^26.1.0",
"luxon": "^3.7.2",
"maplibre-gl": "^5.10.0",
"maplibre-gl": "^5.12.0",
"memoize-one": "^6.0.0",
"mini-css-extract-plugin": "^2.9.4",
"minimist": "^1.2.8",
@ -103,7 +103,7 @@
"regenerator-runtime": "^0.14.1",
"resolve-url-loader": "^5.0.0",
"sanitize-html": "^2.17.0",
"sass": "^1.93.3",
"sass": "^1.94.0",
"sass-loader": "^16.0.6",
"sockette": "^2.0.6",
"style-loader": "^4.0.0",
@ -122,7 +122,7 @@
"vue-sanitize-directive": "^0.2.1",
"vue-style-loader": "^4.1.3",
"vue3-gettext": "^2.4.0",
"vuetify": "^3.10.8",
"vuetify": "^3.10.10",
"webpack": "^5.102.1",
"webpack-bundle-analyzer": "^4.10.2",
"webpack-cli": "^6.0.1",

View file

@ -0,0 +1,232 @@
# View Helper Guidelines
**Last Updated:** November 12, 2025
## Focus Management
PhotoPrism uses a shared view helper to maintain predictable focus across pages and dialogs:
- [`frontend/src/common/view.js`](https://github.com/photoprism/photoprism/blob/develop/frontend/src/common/view.js)
This helper tracks the currently active component, applies focus when views change, and traps focus inside open dialogs, ensuring that tabbing never leaks into the page behind an overlay. The following guidelines explain how to work with the helper when building UI functionality.
### Tabindex Cheat Sheet
| Value | When to use it | Effect |
|------------|---------------------------------------------------------|------------------------------------------------------------------------------------------------|
| `0` | Interactive controls in the natural tab order | Element participates in sequential keyboard focus |
| `-1` | Programmatic focus targets (dialog wrappers, sentinels) | Element can receive focus via script but is skipped while tabbing |
| *positive* | **Avoid** | Custom tab order becomes hard to maintain; the view helper no longer knows the “first” element |
**Tips**
- Root page containers (`<div class="p-page ...">`) should use `tabindex="-1"` so the view helper can focus them when a route becomes active, then immediately move focus to the first interactive control.
- Leave buttons, inputs, and links at the default `tabindex="0"` (or no attribute) so the browser controls the natural order.
### Dialog Implementation Checklist
Vuetify dialogs are teleported to the overlay container, so consistent refs and lifecycle hooks are essential.
1. **Add refs and focus hooks**
```vue
<v-dialog
ref="dialog"
:model-value="visible"
persistent
max-width="350"
@after-enter="afterEnter"
@after-leave="afterLeave"
>
<v-card ref="content" tabindex="-1">
<!-- dialog body -->
</v-card>
</v-dialog>
```
```js
export default {
methods: {
afterEnter() {
this.$view.enter(this);
},
afterLeave() {
this.$view.leave(this);
},
},
};
```
- `ref="dialog"` lets the view helper grab the teleported overlay via `ref.contentEl`.
- The `$view.enter/leave` calls are mandatory so the helper knows when to trap or release focus.
2. **Keep the first focusable control at `tabindex="0"`**
```vue
<v-card-actions class="action-buttons">
<v-btn variant="flat" color="button"
class="action-cancel" @click.stop="close">
{{ $gettext(`Cancel`) }}
</v-btn>
<v-btn variant="flat" color="highlight"
class="action-confirm" @click.stop="confirm">
{{ $gettext(`Delete`) }}
</v-btn>
</v-card-actions>
```
The view helper resolves the first tabbable element (`Cancel` in this case) as the fallback when tabbing inside the dialog.
3. **Avoid per-dialog traps unless necessary**
Only add local `@focusout` handlers if a dialog needs custom behaviour. If you do, always call `ev.preventDefault()` when you redirect focus so you do not fight the global handler.
### Keyboard Event Handling
Dialogs and page shells often react to keyboard shortcuts (Escape to close, Enter to confirm, etc.). To keep those handlers compatible with text inputs and other interactive children:
- Attach listeners to the focusable container that the view helper manages the page wrapper with `tabindex="-1"` or the dialog root (`<v-dialog ref="dialog">`).
- Prefer `@keyup` (for example, `@keyup.enter.exact="confirm"`) so elements inside the container receive `keydown` events first and can call `event.stopPropagation()` when they need to keep the key (such as pressing Enter inside a form field).
- **Persistent dialogs (`persistent` attribute)** must handle the Escape key with `@keydown.esc.exact="close"`. Vuetifys built-in Escape handler plays a “rejection” shake animation when the dialog refuses to close; attaching a direct keydown listener overrides the built-in handler and suppresses the animation while still allowing inner inputs to cancel the event.
- Combine modifiers like `.exact` and `.stop` intentionally. Use `.stop` only when the handler fully resolves the action; otherwise allow events to bubble to ancestor traps.
- If a component must react on `keydown`, scope the listener to the specific control instead of the container, and document why the early trigger is required.
- When emitting from reusable components, forward the native event (`close(event)`) so parents can inspect `event.defaultPrevented` or `event.key` before acting.
Note: To override Vuetifys built-in `<v-dialog>` Escape handler (and stop the “rejection” animation on persistent dialogs), attach a direct `@keydown.esc.exact="close"` listener; the global `onShortCut(ev)` hook is not sufficient on its own.
Example dialog wiring:
```vue
<v-dialog
ref="dialog"
persistent
@keydown.esc.exact="close"
@keyup.enter.exact="confirm"
>
<v-card ref="content" tabindex="-1">
<!-- dialog body -->
</v-card>
</v-dialog>
```
Example page container:
```vue
<template>
<div class="p-page p-settings" tabindex="-1" @keyup.esc.exact="maybeClose">
<!-- page content -->
</div>
</template>
```
Both snippets allow focused inputs to veto shortcuts by calling `event.stopPropagation()` or `event.preventDefault()` before the key reaches the container listener, keeping focus management predictable across the app.
#### Global Shortcut Forwarding
`common/view.js` registers a single `keydown` listener that forwards shortcut keys to the active component:
```js
// onKeyDown forwards global shortcuts (Escape, Ctrl/⌘ combos)
// to the active component when supported.
onKeyDown(ev) {
if (!this.current || !ev || !(ev instanceof KeyboardEvent) || !ev.code) {
return;
} else if (!ev.ctrlKey && !ev.metaKey && ev.code !== "Escape") {
return;
} else if (typeof this.current?.onShortCut !== "function") {
return;
}
if (this.current.onShortCut(ev)) {
ev.preventDefault();
}
}
```
- Implement `onShortCut(ev)` on pages or dialogs when you need to react to Ctrl / ⌘ combinations or global Escape handling. The helper only forwards events where `ev.ctrlKey` or `ev.metaKey` is `true`, or the Escape key is pressed, so it cannot be repurposed for arbitrary keys.
- Persistent dialogs that must suppress Vuetifys rejection animation should still attach a direct `@keydown.esc.exact` handler; `onShortCut(ev)` alone does not override the built-in dialog behaviour.
- Return `true` from `onShortCut(ev)` after handling a shortcut to signal `preventDefault()`. Return `false` to fall back to the browsers native behaviour.
### Example: Delete Confirmation Dialog
```vue
<template>
<v-dialog
ref="dialog"
:model-value="visible"
persistent
max-width="350"
class="p-dialog p-file-delete-dialog"
@keydown.esc.exact="close"
@keyup.enter.exact="confirm"
@after-enter="afterEnter"
@after-leave="afterLeave"
>
<v-card ref="content" tabindex="-1">
<v-card-title class="d-flex justify-start align-center ga-3">
<v-icon icon="mdi-delete-outline" size="54" color="primary"></v-icon>
<p class="text-subtitle-1">{{ $gettext(`Are you sure?`) }}</p>
</v-card-title>
<v-card-actions class="action-buttons mt-1">
<v-btn variant="flat" color="button"
class="action-cancel" @click.stop="close">
{{ $gettext(`Cancel`) }}
</v-btn>
<v-btn color="highlight" variant="flat"
class="action-confirm" @click.stop="confirm">
{{ $gettext(`Delete`) }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script>
export default {
name: "PFileDeleteDialog",
props: {
visible: Boolean,
},
emits: ["close", "confirm"],
methods: {
afterEnter() {
this.$view.enter(this);
},
afterLeave() {
this.$view.leave(this);
},
close() {
this.$emit("close");
},
confirm() {
this.$emit("confirm");
},
},
};
</script>
```
This pattern ensures:
- The dialog registers with the view helper as soon as it appears (`afterEnter`).
- Focus defaults to the `Cancel` button (first tabbable control).
- Tabbing continues to cycle between `Cancel` and `Delete` until the dialog closes.
### Troubleshooting Checklist
**Focus escapes the dialog when tabbing**
- Verify the dialog calls `$view.enter(this)` / `$view.leave(this)`.
- Confirm the dialog template has `ref="dialog"`; if you teleport manually, expose `contentEl`.
- Ensure there is at least one control with `tabindex="0"` inside the card. Pure static content cannot trap focus.
**Focus lands on the overlay instead of a button**
- Check for stray `tabindex="-1"` on child elements. Only the outer container should use `-1`.
- Use the browser console with `trace` logging enabled (`this.$config.get("trace")`) to see which elements receive `document.focusin/out`.
**Custom focusout handler keeps fighting the trap**
- Make sure the handler checks `this.$view.isActive(this)` and calls `ev.preventDefault()` when redirecting focus.
- Consider removing the custom handler if the global trap already matches the desired behaviour.
**Nested dialogs (dialog inside dialog)**
- Each dialog must have `ref="dialog"` so the helper can distinguish them.
- The helper chooses the currently active component (`this.$view.getCurrent()`) as the trap owner, so opening a second dialog automatically pauses the first ones trap.

View file

@ -188,65 +188,125 @@ const encodeRestoreKey = (key) => {
return restoreNamespace + encodeURIComponent(key);
};
// resolveFocusTarget returns the most appropriate element inside root for initial focus.
function resolveFocusTarget(root) {
if (!root) {
return null;
}
let el = getHTMLElement(root);
if (!(el instanceof HTMLElement)) {
return null;
}
if (el.hasAttribute("autofocus")) {
return el;
}
let candidate = null;
if (el.getAttribute("tabindex") === "-1") {
candidate = el;
}
try {
const autofocus = el.querySelector("[autofocus]");
if (autofocus instanceof HTMLElement) {
return autofocus;
}
const sentinel = el.querySelector('[tabindex="-1"]');
if (sentinel instanceof HTMLElement) {
return sentinel;
}
} catch {
// Ignore.
}
return candidate;
}
// getHTMLElement normalizes Vue component refs or DOM nodes to a concrete HTMLElement.
function getHTMLElement(ref) {
if (!ref) {
return null;
}
if (ref instanceof HTMLElement) {
return ref;
} else if (ref.contentEl && ref.contentEl instanceof HTMLElement) {
return ref.contentEl;
} else if (ref.$el && ref.$el instanceof HTMLElement) {
return ref.$el;
}
return null;
}
// resolveFocusScope determines the container and fallback focus element for trapping focus within a component.
function resolveFocusScope(component) {
if (!component || !component.$refs) {
return null;
}
const root = getHTMLElement(component.$refs?.dialog);
if (!root) {
return null;
}
const fallback = resolveFocusTarget(root);
if (fallback && root.contains(fallback)) {
return {
root,
fallback,
};
}
return {
root,
fallback: root,
};
}
// Returns the most likely focus element for the given component, or null if none exists.
export function findFocusElement(c) {
if (!c) {
return null;
}
let el, ref;
const candidates = [];
if (c.$refs && c.$refs instanceof Object) {
focusRefs.forEach((r) => {
if (c.$refs[r] && c.$refs[r] instanceof Object) {
if (c.$refs[r].$el instanceof HTMLElement && c.$refs[r].$el.getAttribute("tabindex") !== null) {
ref = c.$refs[r].$el;
} else if (c.$refs[r] instanceof HTMLElement && c.$refs[r].getAttribute("tabindex") !== null) {
ref = c.$refs[r];
if (c.$refs[r]) {
const el = getHTMLElement(c.$refs[r]);
if (el) {
candidates.push(el);
}
}
});
}
if (!ref || !(ref instanceof Object) || typeof ref.getAttribute !== "function") {
ref = null;
} else if (ref.getAttribute("tabindex") === null) {
ref = null;
const el = getHTMLElement(c);
if (el) {
candidates.push(el);
}
if (!ref && c.$el && c.$el instanceof Object) {
if (c.$el instanceof HTMLElement) {
ref = c.$el;
} else if (c.$el.parentElement && c.$el.parentElement instanceof HTMLElement) {
ref = c.$el.parentElement;
}
}
for (let i = 0; i < candidates.length; i++) {
const target = resolveFocusTarget(candidates[i]);
if (ref) {
if (ref.$el && ref.$el instanceof HTMLElement) {
ref = ref.$el;
}
if (ref instanceof HTMLElement) {
if (ref.getAttribute("tabindex") !== null) {
return ref;
}
if (!window.$isMobile) {
try {
el = ref.querySelector('input[tabindex="1"]');
if (el && el instanceof HTMLElement) {
return el;
}
} catch {
// Ignore.
}
}
if (target) {
return target;
}
}
if (c.$refs?.dialog) {
return document.querySelector(".v-overlay-container .v-overlay__content");
return getHTMLElement(c.$refs.dialog);
}
return null;
@ -334,6 +394,7 @@ export class View {
this.scopes = [];
this.hideScrollbar = false;
this.preventNavigation = false;
this.focusScopes = new Map();
// Tracks the most recent history position and derived navigation direction so components can
// determine whether a transition was triggered by browser back/forward buttons.
@ -348,6 +409,10 @@ export class View {
this._onKeyDownListener = this.onKeyDown.bind(this);
addEventListener("keydown", this._onKeyDownListener);
// Register a single document-level focus handler, so dialogs can keep keyboard focus inside their scope.
this._onFocusOutListener = this.onDocumentFocusOut.bind(this);
document.addEventListener("focusout", this._onFocusOutListener);
// Options used when preventing navigation touch gestures; keep a stable
// object reference so add/removeEventListener calls can match on all browsers.
this._preventNavOptions = { passive: false };
@ -357,15 +422,22 @@ export class View {
this._traceFocusIn = (ev) => {
console.log("%cdocument.focusin", "color: #B2EBF2;", ev.target);
};
this._traceFocusOut = (ev) => {
console.log("%cdocument.focusout", "color: #B2EBF2;", ev.target);
};
document.addEventListener("focusin", this._traceFocusIn);
document.addEventListener("focusout", this._traceFocusOut);
}
}
// destroy unregisters the global listeners so the view helper can be garbage-collected safely.
destroy() {
removeEventListener("keydown", this._onKeyDownListener);
document.removeEventListener("focusout", this._onFocusOutListener);
if (this._traceFocusIn) {
document.removeEventListener("focusin", this._traceFocusIn);
}
}
// onKeyDown forwards global shortcuts (Escape, Ctrl/⌘ combos) to the active component when supported.
onKeyDown(ev) {
if (!this.current || !ev || !(ev instanceof KeyboardEvent) || !ev.code) {
return;
@ -421,6 +493,9 @@ export class View {
this.apply(this.current);
}
// Remove any stale focus scope once the component leaves the stack.
this.focusScopes.delete(c);
return this.scopes.length;
}
@ -453,7 +528,7 @@ export class View {
console.log("data:", toRaw(c?.$data));
}
// Automatically focus the active component if its element tabindex attribute is set to "1":
// Automatically focus the active component based on autofocus markers or tabindex sentinels:
// https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/tabindex
if (focusElement) {
setFocus(focusElement, focusSelector, false);
@ -461,6 +536,9 @@ export class View {
setFocus(findFocusElement(c), false, false);
}
// Capture the most recent focusable root so we can trap focus if this component opens a dialog.
this.recordFocusScope(c);
// Return, as it should not be necessary to apply the same state twice.
if (this.uid === uid) {
if (debug) {
@ -584,6 +662,107 @@ export class View {
return true;
}
// recordFocusScope caches the DOM boundary used to keep focus inside the active component.
recordFocusScope(component) {
if (!component) {
return;
}
const scope = resolveFocusScope(component);
// Clear existing traps when we cannot resolve a focus container (e.g., simple pages).
if (!scope) {
this.focusScopes.delete(component);
return;
}
const { root } = scope;
// Ensure the focus container can receive focus, which some Vuetify overlays require explicitly.
if (root && !root.hasAttribute("tabindex")) {
root.setAttribute("tabindex", "-1");
}
// Remember the trapping metadata so onDocumentFocusOut can redirect focus if needed.
this.focusScopes.set(component, scope);
}
// onDocumentFocusOut re-focuses the current dialog when keyboard focus attempts to leave its scope.
onDocumentFocusOut(ev) {
if (trace) {
console.log("%cdocument.focusout", "color: #B2EBF2;", ev?.target);
}
if (!this.current || !ev || !(ev instanceof FocusEvent)) {
return;
}
const component = this.getCurrent();
if (!component) {
return;
}
// Look up the trap associated with the currently active component.
const scope = this.focusScopes.get(component);
if (!scope) {
return;
}
const { root, fallback } = scope;
// Drop the trap when the underlying DOM node vanished (dialog closed).
if (!root || !root.isConnected) {
this.focusScopes.delete(component);
return;
}
const next = ev.relatedTarget;
if (next instanceof HTMLElement && root.contains(next)) {
return;
}
const dialogOverlay = root.closest(".v-overlay");
const menuOverlayContent = next instanceof HTMLElement ? next.closest(".v-overlay__content") : null;
if (dialogOverlay && menuOverlayContent && menuOverlayContent instanceof HTMLElement) {
const menuOverlay = menuOverlayContent.closest(".v-overlay");
if (
menuOverlay &&
menuOverlay.classList.contains("v-menu") &&
menuOverlay.parentElement === dialogOverlay.parentElement &&
menuOverlay.style.display !== "none" &&
menuOverlayContent.contains(next)
) {
// Allow focus to move into sibling menu overlays (e.g., combobox suggestions)
return;
}
}
ev.preventDefault();
const target =
(fallback && fallback.isConnected && root.contains(fallback) && fallback) ||
resolveFocusTarget(root) ||
findFocusElement(component) ||
root;
if (!target) {
return;
}
this.focusScopes.set(component, { root, fallback: target });
ev.preventDefault();
setTimeout(() => {
setFocus(target, false, false);
}, 0);
}
// Returns the number of views currently registered.
len() {
return this.scopes?.length ? this.scopes.length : 0;

View file

@ -54,7 +54,7 @@ export default {
},
tabindex: {
type: Number,
default: 3,
default: 0,
},
listClass: {
type: String,

View file

@ -1,10 +1,13 @@
<template>
<v-dialog
ref="dialog"
:model-value="visible"
persistent
max-width="350"
class="p-dialog p-album-delete-dialog"
@keydown.esc.exact="close"
@after-enter="afterEnter"
@after-leave="afterLeave"
>
<v-card>
<v-card-title class="d-flex justify-start align-center ga-3">
@ -36,6 +39,12 @@ export default {
return {};
},
methods: {
afterEnter() {
this.$view.enter(this);
},
afterLeave() {
this.$view.leave(this);
},
close() {
this.$emit("close");
},

View file

@ -1,5 +1,6 @@
<template>
<v-dialog
ref="dialog"
:model-value="visible"
persistent
max-width="500"

View file

@ -1,12 +1,19 @@
<template>
<v-dialog
ref="dialog"
:model-value="visible"
:close-delay="0"
:open-delay="0"
persistent
scrim
max-width="360"
class="p-dialog p-confirm-dialog"
@keydown.esc.exact="close"
@keyup.enter.exact="confirm"
@after-enter="afterEnter"
@after-leave="afterLeave"
>
<v-card>
<v-card ref="content" tabindex="-1">
<v-card-title class="d-flex justify-start align-center ga-3">
<v-icon :icon="icon" :size="iconSize" color="primary"></v-icon>
<div class="text-subtitle-1">{{ text ? text : $gettext(`Are you sure?`) }}</div>
@ -47,10 +54,17 @@ export default {
default: "",
},
},
emits: ["close", "confirm"],
data() {
return {};
},
methods: {
afterEnter() {
this.$view.enter(this);
},
afterLeave() {
this.$view.leave(this);
},
close() {
this.$emit("close");
},

View file

@ -1,5 +1,6 @@
<template>
<v-dialog
ref="dialog"
:model-value="visible"
persistent
max-width="575"

View file

@ -55,6 +55,15 @@ export default {
validateOn: "invalid-input",
autofocus: false,
},
VChip: {
closable: true,
rounded: true,
flat: true,
ripple: false,
variant: "flat",
color: "highlight",
density: "default",
},
VAutocomplete: {
flat: true,
variant: "solo-filled",
@ -74,15 +83,6 @@ export default {
validateOn: "invalid-input",
hideDetails: "auto",
},
VChip: {
closable: true,
rounded: true,
flat: true,
ripple: false,
variant: "flat",
color: "highlight",
density: "default",
},
VMenu: {
origin: "auto",
location: "bottom end",
@ -95,6 +95,7 @@ export default {
closeOnContentClick: true,
persistent: false,
scrim: false,
opacity: 0,
zIndex: 1,
},
VSnackbar: {
@ -192,6 +193,7 @@ export default {
openDelay: 0,
closeDelay: 0,
attach: document.body,
tabindex: -1,
},
VOverlay: {
transition: false,

View file

@ -1,12 +1,15 @@
<template>
<v-dialog
ref="dialog"
:model-value="visible"
persistent
max-width="350"
class="p-dialog p-file-delete-dialog"
@keydown.esc.exact="close"
@after-enter="afterEnter"
@after-leave="afterLeave"
>
<v-card>
<v-card ref="content" tabindex="-1">
<v-card-title class="d-flex justify-start align-center ga-3">
<v-icon size="54" color="primary">mdi-delete-outline</v-icon>
<p class="text-subtitle-1">{{ $gettext(`Are you sure you want to permanently delete this file?`) }}</p>
@ -35,6 +38,12 @@ export default {
return {};
},
methods: {
afterEnter() {
this.$view.enter(this);
},
afterLeave() {
this.$view.leave(this);
},
close() {
this.$emit("close");
},

View file

@ -1,10 +1,13 @@
<template>
<v-dialog
ref="dialog"
:model-value="visible"
persistent
max-width="350"
class="p-dialog p-label-delete-dialog"
@keydown.esc.exact="close"
@after-enter="afterEnter"
@after-leave="afterLeave"
>
<v-card>
<v-card-title class="d-flex justify-start align-center ga-3">
@ -35,6 +38,12 @@ export default {
return {};
},
methods: {
afterEnter() {
this.$view.enter(this);
},
afterLeave() {
this.$view.leave(this);
},
close() {
this.$emit("close");
},

View file

@ -1,11 +1,13 @@
<template>
<v-dialog
ref="dialog"
:model-value="visible"
persistent
max-width="500"
class="p-dialog dialog-label-edit"
color="background"
@keydown.esc.exact="close"
@keyup.enter.exact="confirm"
@after-enter="afterEnter"
@after-leave="afterLeave"
>
@ -14,7 +16,7 @@
validate-on="invalid-input"
class="form-label-edit"
accept-charset="UTF-8"
tabindex="1"
tabindex="-1"
@submit.prevent="confirm"
>
<v-card>
@ -33,7 +35,6 @@
:label="$gettext('Name')"
:disabled="disabled"
class="input-title"
@keyup.enter="confirm"
></v-text-field>
</v-col>
<v-col sm="4">

View file

@ -14,11 +14,10 @@
class="p-dialog p-lightbox v-dialog--lightbox no-transition"
@after-enter="afterEnter"
@after-leave="afterLeave"
@focusout="onFocusOut"
@keydown.space.exact="onKeyDown"
@keydown.left.exact="onKeyDown"
@keydown.right.exact="onKeyDown"
@keydown.esc.stop="close"
@keydown.esc.exact.stop="close"
@click.capture="captureDialogClick"
@pointerdown.capture="captureDialogPointerDown"
>
@ -26,7 +25,7 @@
<div ref="container" class="p-lightbox__container no-transition">
<div
ref="content"
tabindex="1"
tabindex="-1"
class="p-lightbox__content no-transition"
:class="{
'sidebar-visible': info,
@ -40,11 +39,11 @@
'is-selected': $clipboard.has(model),
}"
>
<div ref="lightbox" tabindex="2" class="p-lightbox__pswp no-transition"></div>
<div ref="lightbox" tabindex="-1" class="p-lightbox__pswp no-transition"></div>
<div
v-show="video.controls && controlsShown !== 0"
ref="controls"
tabindex="3"
tabindex="-1"
class="p-lightbox__controls"
@click.stop.prevent
>
@ -331,30 +330,6 @@ export default {
this.$event.publish("lightbox.leave");
this.$emit("leave");
},
// Traps the focus inside the lightbox dialog.
onFocusOut(ev) {
if (this.debug) {
this.log(`dialog.${ev.type}`, { ev });
}
if (!this.$view.isActive(this)) {
return;
}
// Keep content element focused.
if (this.$refs.content && this.$refs.content instanceof HTMLElement) {
if (
(ev.target &&
ev.target instanceof HTMLElement &&
(!ev.target.closest(".v-dialog--lightbox") || ev.target?.tabIndex < 0 || ev.target.disabled)) ||
(ev.relatedTarget &&
ev.relatedTarget instanceof HTMLElement &&
(!ev.relatedTarget.closest(".v-dialog--lightbox") || ev.relatedTarget.tabIndex < 0))
) {
this.focusContent(ev);
}
}
},
focusContent(ev) {
if (
this.$refs.content &&

View file

@ -1,5 +1,6 @@
<template>
<v-dialog
ref="dialog"
:model-value="visible"
:max-width="900"
:fullscreen="$vuetify.display.xs"
@ -7,11 +8,11 @@
scrim
scrollable
class="p-location-dialog"
@keydown.esc="close"
@keydown.esc.exact="close"
@after-enter="afterEnter"
@after-leave="afterLeave"
>
<v-card :tile="$vuetify.display.xs">
<v-card ref="content" tabindex="-1" :tile="$vuetify.display.xs">
<v-toolbar v-if="$vuetify.display.xs" flat color="navigation" class="mb-4" density="compact">
<v-btn icon @click.stop="close">
<v-icon>mdi-close</v-icon>
@ -192,6 +193,18 @@ export default {
},
},
methods: {
afterEnter() {
this.$view.enter(this);
if (this.currentLat && this.currentLng && !(this.currentLat === 0 && this.currentLng === 0)) {
this.fetchLocationInfo(this.currentLat, this.currentLng);
}
},
afterLeave() {
this.location = null;
this.locationLoading = false;
this.resetSearchState();
this.$view.leave(this);
},
close() {
this.$emit("close");
},
@ -205,16 +218,6 @@ export default {
});
}
},
afterEnter() {
if (this.currentLat && this.currentLng && !(this.currentLat === 0 && this.currentLng === 0)) {
this.fetchLocationInfo(this.currentLat, this.currentLng);
}
},
afterLeave() {
this.location = null;
this.locationLoading = false;
this.resetSearchState();
},
onMarkerMoved(event) {
this.setPositionAndFetchInfo(event.lat, event.lng);
},

View file

@ -12,7 +12,7 @@
autocorrect="off"
autocapitalize="none"
class="input-coordinates"
@keydown.enter="applyCoordinates"
@keydown.enter.stop="applyCoordinates"
@update:model-value="onCoordinateInputChange"
@paste="pastePosition"
>

View file

@ -1,11 +1,13 @@
<template>
<v-dialog
ref="dialog"
:model-value="visible"
persistent
max-width="500"
class="dialog-person-edit"
color="background"
@keydown.esc.exact="close"
@keyup.enter.exact="confirm"
@after-enter="afterEnter"
@after-leave="afterLeave"
>
@ -14,7 +16,7 @@
validate-on="invalid-input"
class="form-person-edit"
accept-charset="UTF-8"
tabindex="1"
tabindex="-1"
@submit.prevent="confirm"
>
<v-card>
@ -33,7 +35,6 @@
:label="$gettext('Name')"
:disabled="disabled"
class="input-title"
@keyup.enter="confirm"
></v-text-field>
</v-col>
<v-col sm="4">

View file

@ -1,10 +1,13 @@
<template>
<v-dialog
ref="dialog"
:model-value="visible"
persistent
max-width="350"
class="p-dialog p-people-merge-dialog"
@keydown.esc.exact="close"
@after-enter="afterEnter"
@after-leave="afterLeave"
>
<v-card>
<v-card-title class="d-flex justify-start align-center ga-3">
@ -41,6 +44,7 @@ export default {
default: new Subject(),
},
},
emits: ["close", "confirm"],
data() {
return {};
},
@ -57,6 +61,12 @@ export default {
},
},
methods: {
afterEnter() {
this.$view.enter(this);
},
afterLeave() {
this.$view.leave(this);
},
close() {
this.$emit("close");
},

View file

@ -1,5 +1,6 @@
<template>
<v-dialog
ref="dialog"
:model-value="visible"
persistent
max-width="500"
@ -8,7 +9,7 @@
@after-enter="afterEnter"
@after-leave="afterLeave"
>
<v-form ref="form" validate-on="invalid-input" accept-charset="UTF-8" tabindex="1" @submit.prevent="confirm">
<v-form ref="form" validate-on="invalid-input" accept-charset="UTF-8" tabindex="-1" @submit.prevent="confirm">
<v-card>
<v-card-title class="d-flex justify-start align-center ga-3">
<v-icon icon="mdi-bookmark" size="28" color="primary"></v-icon>

View file

@ -1,10 +1,13 @@
<template>
<v-dialog
ref="dialog"
:model-value="visible"
persistent
max-width="350"
class="p-dialog p-photo-archive-dialog"
@keydown.esc.exact="close"
@after-enter="afterEnter"
@after-leave="afterLeave"
>
<v-card>
<v-card-title class="d-flex justify-start align-center ga-3">
@ -35,6 +38,12 @@ export default {
return {};
},
methods: {
afterEnter() {
this.$view.enter(this);
},
afterLeave() {
this.$view.leave(this);
},
close() {
this.$emit("close");
},

View file

@ -5,7 +5,7 @@
validate-on="invalid-input"
class="p-form p-form-photo-details-meta"
accept-charset="UTF-8"
tabindex="1"
tabindex="-1"
@submit.prevent="save"
>
<div class="form-body">

View file

@ -1,6 +1,7 @@
<template>
<v-dialog
ref="dialog"
tabindex="-1"
:model-value="visible"
:fullscreen="$vuetify.display.smAndDown"
scrim
@ -11,10 +12,9 @@
@after-leave="afterLeave"
@keydown.left.exact="onKeyLeft"
@keydown.right.exact="onKeyRight"
@keydown.esc.stop="onClose"
@focusout="onFocusOut"
@keydown.esc.exact.stop="onClose"
>
<v-card ref="content" tabindex="1" :tile="$vuetify.display.smAndDown">
<v-card ref="content" tabindex="-1" :tile="$vuetify.display.smAndDown">
<v-toolbar flat color="navigation" :density="$vuetify.display.smAndDown ? 'compact' : 'comfortable'">
<v-btn icon class="action-close" @click.stop="onClose">
<v-icon>mdi-close</v-icon>
@ -196,27 +196,13 @@ export default {
},
methods: {
afterEnter() {
this.$view.enter(this);
this.$view.enter(this, this.$refs.content);
this.ready = true;
},
afterLeave() {
this.ready = false;
this.$view.leave(this);
},
onFocusOut(ev) {
if (!this.$view.isActive(this)) {
return;
}
if (ev.target && ev.target instanceof HTMLElement && this.$refs.content?.$el instanceof HTMLElement) {
if (
document.activeElement !== this.$refs.content.$el &&
(!ev.target.closest(".p-photo-edit-dialog") || ev.target?.disabled)
) {
this.$refs.content?.$el.focus();
}
}
},
onUpdate(ev, data) {
if (!data || !data.entities || !Array.isArray(data.entities) || this.loading || !this.model || !this.model.UID) {
return;

View file

@ -4,7 +4,7 @@
<v-expansion-panel
v-for="file in view.model.fileModels().filter((f) => !f.Missing)"
:key="file.UID"
tabindex="1"
tabindex="0"
style="margin-top: 1px"
class="pa-0 elevation-0"
>

View file

@ -1,6 +1,6 @@
<template>
<div class="p-tab p-tab-photo-advanced">
<v-form ref="form" validate-on="invalid-input" accept-charset="UTF-8" tabindex="1" @submit.prevent>
<v-form ref="form" validate-on="invalid-input" accept-charset="UTF-8" tabindex="-1" @submit.prevent>
<div class="v-table__overflow">
<v-table tile hover density="compact" class="bg-table">
<tbody>

View file

@ -5,7 +5,7 @@
class="p-form p-form--table p-form-photo-labels"
validate-on="invalid-input"
accept-charset="UTF-8"
tabindex="1"
tabindex="-1"
@submit.prevent
>
<div class="form-body">

View file

@ -18,7 +18,7 @@
</v-alert>
<div v-else class="v-row search-results face-results cards-view d-flex">
<div v-for="m in markers" :key="m.UID" class="v-col-12 v-col-sm-6 v-col-md-4 v-col-lg-3 d-flex">
<v-card :data-id="m.UID" :class="m.classes()" class="result not-selectable flex-grow-1" tabindex="1">
<v-card :data-id="m.UID" :class="m.classes()" class="result not-selectable flex-grow-1" tabindex="0">
<v-img :src="m.thumbnailUrl('tile_320')" aspect-ratio="1" class="card">
<v-btn
v-if="!m.SubjUID && !m.Invalid"
@ -79,19 +79,19 @@
item-title="Name"
item-value="Name"
:disabled="busy"
:menu-props="menuProps"
return-object
hide-no-data
:menu-props="menuProps"
hide-details
single-line
open-on-clear
append-icon=""
prepend-inner-icon="mdi-account-plus"
density="comfortable"
class="input-name pa-0 ma-0"
@blur="onSetName(m)"
class="input-name pa-0 ma-0 text-selectable"
@update:model-value="(person) => onSetPerson(m, person)"
@keyup.enter.native="onSetName(m)"
@blur="(ev) => onSetName(m, ev)"
@keyup.enter="(ev) => onSetName(m, ev)"
>
</v-combobox>
</v-card-actions>
@ -140,11 +140,21 @@ export default {
text: this.$gettext("Add person?"),
},
menuProps: {
closeOnClick: false,
closeOnContentClick: true,
openOnClick: false,
openOnFocus: true,
closeOnBack: true,
closeOnContentClick: true,
disableInitialFocus: true,
persistent: false,
scrim: true,
openDelay: 0,
closeDelay: 0,
opacity: 0,
density: "compact",
maxHeight: 300,
locationStrategy: "connected",
scrollStrategy: "reposition",
origin: "auto",
},
textRule: (v) => {
if (!v || !v.length) {
@ -322,11 +332,16 @@ export default {
return true;
},
onSetName(model) {
onSetName(model, ev) {
if (this.busy || !model) {
return;
}
// If there's a pending confirmation for a different face, don't process new input
if (this.confirm.visible && this.confirm.model && this.confirm.model.UID !== model.UID) {
return;
}
const name = model?.Name;
if (!name) {
@ -343,14 +358,21 @@ export default {
if (found) {
model.Name = found.Name;
model.SubjUID = found.UID;
this.setName(model);
if (model.wasChanged()) {
this.setName(model);
}
return;
}
}
model.Name = name;
model.SubjUID = "";
this.confirm.visible = true;
if (ev && ev.key === "Enter" && !ev.isComposing && !ev.repeat) {
this.setName(model);
} else {
this.confirm.visible = true;
}
},
onConfirmSetName() {
if (!this.confirm?.model?.Name) {
@ -360,6 +382,10 @@ export default {
this.setName(this.confirm.model);
},
onCancelSetName() {
if (this.confirm && this.confirm.model) {
this.confirm.model.Name = "";
this.confirm.model.SubjUID = "";
}
this.confirm.visible = false;
},
setName(model) {

View file

@ -18,7 +18,6 @@
<v-text-field
:model-value="filter.q"
:density="density"
tabindex="1"
hide-details
clearable
single-line
@ -65,7 +64,6 @@
>
<v-btn
value="cards"
tabindex="2"
icon="mdi-view-column"
class="ps-1 action-view-cards"
@click="setView('cards')"
@ -73,14 +71,12 @@
<v-btn
v-if="listView"
value="list"
tabindex="2"
icon="mdi-view-list"
class="action-view-list"
@click="setView('list')"
></v-btn>
<v-btn
value="mosaic"
tabindex="2"
icon="mdi-view-comfy"
class="pe-1 action-view-mosaic"
@click="setView('mosaic')"
@ -91,7 +87,6 @@
v-if="canDelete && context === 'archive' && config.count.archived > 0"
:title="$gettext('Delete All')"
icon="mdi-delete-sweep"
tabindex="3"
class="action-delete-all ms-1"
@click.stop="deleteAll"
>
@ -100,7 +95,6 @@
<p-action-menu
v-if="$vuetify.display.mdAndUp"
:items="menuActions"
:tabindex="3"
button-class="ms-1"
></p-action-menu>
</template>
@ -125,7 +119,6 @@
:model-value="filter.country"
:label="$gettext('Country')"
:menu-props="{ maxHeight: 346 }"
tabindex="4"
single-line
hide-details
variant="solo-filled"
@ -147,7 +140,6 @@
:model-value="filter.camera"
:label="$gettext('Camera')"
:menu-props="{ maxHeight: 346 }"
tabindex="5"
single-line
hide-details
variant="solo-filled"
@ -168,7 +160,6 @@
id="viewSelect"
:model-value="settings.view"
:label="$gettext('View')"
tabindex="6"
single-line
hide-details
variant="solo-filled"
@ -189,7 +180,6 @@
:model-value="filter.order"
:label="$gettext('Sort Order')"
:menu-props="{ maxHeight: 400 }"
tabindex="7"
single-line
variant="solo-filled"
:density="density"
@ -209,7 +199,6 @@
:model-value="filter.year"
:label="$gettext('Year')"
:menu-props="{ maxHeight: 346 }"
tabindex="8"
single-line
variant="solo-filled"
:density="density"
@ -229,7 +218,6 @@
:model-value="filter.month"
:label="$gettext('Month')"
:menu-props="{ maxHeight: 346 }"
tabindex="9"
single-line
variant="solo-filled"
:density="density"
@ -263,7 +251,6 @@
:model-value="filter.color"
:label="$gettext('Color')"
:menu-props="{ maxHeight: 346 }"
tabindex="10"
single-line
hide-details
variant="solo-filled"
@ -284,7 +271,6 @@
:model-value="filter.label"
:label="$gettext('Category')"
:menu-props="{ maxHeight: 346 }"
tabindex="11"
single-line
hide-details
variant="solo-filled"

View file

@ -1,5 +1,6 @@
<template>
<v-dialog
ref="dialog"
:model-value="visible"
persistent
max-width="500"
@ -8,7 +9,7 @@
@after-enter="afterEnter"
@after-leave="afterLeave"
>
<v-form ref="form" validate-on="invalid-input" accept-charset="UTF-8" tabindex="1" @submit.prevent>
<v-form ref="form" validate-on="invalid-input" accept-charset="UTF-8" tabindex="-1" @submit.prevent>
<v-card>
<v-card-title class="d-flex justify-start align-center ga-3">
<v-icon size="28" color="primary">mdi-swap-horizontal</v-icon>

View file

@ -1,5 +1,6 @@
<template>
<v-dialog
ref="dialog"
:model-value="visible"
persistent
max-width="500"
@ -8,7 +9,7 @@
@after-enter="afterEnter"
@after-leave="afterLeave"
>
<v-form ref="form" validate-on="invalid-input" accept-charset="UTF-8" tabindex="1" @submit.prevent>
<v-form ref="form" validate-on="invalid-input" accept-charset="UTF-8" tabindex="-1" @submit.prevent>
<v-card>
<v-card-title v-if="scope === 'sharing'" class="d-flex justify-space-between align-center ga-3">
<h6 class="text-h6">

View file

@ -1,5 +1,6 @@
<template>
<v-dialog
ref="dialog"
:model-value="visible"
persistent
max-width="350"

View file

@ -1,5 +1,6 @@
<template>
<v-dialog
ref="dialog"
:model-value="visible"
persistent
max-width="400"
@ -8,7 +9,7 @@
@after-enter="afterEnter"
@after-leave="afterLeave"
>
<v-form ref="form" validate-on="invalid-input" accept-charset="UTF-8" tabindex="1" @submit.prevent>
<v-form ref="form" validate-on="invalid-input" accept-charset="UTF-8" tabindex="-1" @submit.prevent>
<v-card>
<v-card-title class="d-flex justify-start align-center ga-3">
<v-icon size="28" color="primary">mdi-cloud</v-icon>

View file

@ -1,5 +1,6 @@
<template>
<v-dialog
ref="dialog"
:model-value="visible"
persistent
max-width="610"
@ -13,7 +14,7 @@
validate-on="invalid-input"
class="form-password"
accept-charset="UTF-8"
tabindex="1"
tabindex="-1"
@submit.prevent
>
<v-card>

View file

@ -1,5 +1,6 @@
<template>
<v-dialog
ref="dialog"
:model-value="visible"
persistent
max-width="500"
@ -13,7 +14,7 @@
validate-on="invalid-input"
accept-charset="UTF-8"
class="form-password"
tabindex="1"
tabindex="-1"
@submit.prevent
>
<v-card>
@ -40,7 +41,7 @@
:disabled="busy"
:type="showPassword ? 'text' : 'password'"
:placeholder="$gettext('Password')"
tabindex="1"
autofocus
name="password"
hide-details
autocorrect="off"
@ -211,7 +212,7 @@
:disabled="busy"
:type="showPassword ? 'text' : 'password'"
:placeholder="$gettext('Password')"
tabindex="1"
autofocus
name="password"
hide-details
autocorrect="off"

View file

@ -1,5 +1,6 @@
<template>
<v-dialog
ref="dialog"
:model-value="visible"
persistent
max-width="500"
@ -28,7 +29,7 @@
:maxlength="maxLength"
:append-inner-icon="showPassword ? 'mdi-eye-off' : 'mdi-eye'"
:label="$gettext('Current Password')"
tabindex="1"
:autofocus="oldRequired"
hide-details
autocorrect="off"
autocapitalize="none"
@ -46,7 +47,7 @@
:maxlength="maxLength"
:label="$gettext('New Password')"
:hint="$gettextInterpolate($gettext('Must have at least %{n} characters.'), { n: minLength })"
tabindex="2"
:autofocus="!oldRequired"
counter
persistent-hint
type="password"
@ -65,7 +66,6 @@
:maxlength="maxLength"
:label="$gettext('Retype Password')"
:hint="$gettext('Please confirm your new password.')"
tabindex="3"
counter
persistent-hint
type="password"
@ -79,11 +79,10 @@
</v-row>
</v-card-text>
<v-card-actions class="action-buttons">
<v-btn tabindex="5" variant="flat" color="button" class="action-cancel" @click.stop="close">
<v-btn variant="flat" color="button" class="action-cancel" @click.stop="close">
{{ $gettext(`Cancel`) }}
</v-btn>
<v-btn
tabindex="4"
variant="flat"
color="highlight"
class="action-confirm"

View file

@ -1,5 +1,6 @@
<template>
<v-dialog
ref="dialog"
:model-value="visible"
persistent
max-width="580"

View file

@ -1,5 +1,6 @@
<template>
<v-dialog
ref="dialog"
:model-value="visible"
persistent
max-width="540"
@ -26,7 +27,7 @@
variant="accordion"
density="compact"
rounded="6"
tabindex="1"
tabindex="0"
class="elevation-0"
>
<v-expansion-panel v-for="(link, index) in links" :key="link.UID" color="secondary" class="pa-0 elevation-0">

View file

@ -1,5 +1,6 @@
<template>
<v-dialog
ref="dialog"
:model-value="visible"
persistent
max-width="400"

View file

@ -1,5 +1,6 @@
<template>
<v-dialog
ref="dialog"
:model-value="visible"
:fullscreen="$vuetify.display.mdAndDown"
scrim
@ -9,9 +10,8 @@
@after-enter="afterEnter"
@after-leave="afterLeave"
@keydown.esc.exact="onClose"
@focusout="onFocusOut"
>
<v-form ref="form" class="p-photo-upload" validate-on="invalid-input" tabindex="1" @submit.prevent="onSubmit">
<v-form ref="form" class="p-photo-upload" validate-on="invalid-input" tabindex="-1" @submit.prevent="onSubmit">
<input ref="upload" type="file" multiple :accept="accept" class="d-none input-upload" @change.stop="onUpload()" />
<v-card :tile="$vuetify.display.mdAndDown">
<v-toolbar
@ -52,18 +52,18 @@
<v-combobox
v-model="selectedAlbums"
v-model:menu="albumsMenu"
@update:menu="onAlbumsMenuUpdate"
:disabled="busy || loading || total > 0 || filesQuotaReached"
hide-details
chips
closable-chips
return-object
multiple
class="input-albums"
:items="albums"
item-title="Title"
item-value="UID"
:placeholder="$gettext('Select or create albums')"
return-object
@update:menu="onAlbumsMenuUpdate"
@keydown.enter.stop="onAlbumsEnter"
>
<template #no-data>
@ -221,20 +221,6 @@ export default {
afterLeave() {
this.$view.leave(this);
},
onFocusOut(ev) {
if (!this.$view.isActive(this)) {
return;
}
if (ev.target && ev.target instanceof HTMLElement && this.$refs.form?.$el instanceof HTMLElement) {
if (
document.activeElement !== this.$refs.form.$el &&
(!ev.target.closest(".p-upload-dialog") || ev.target?.disabled)
) {
this.$refs.form?.$el.focus();
}
}
},
removeSelection(index) {
this.selectedAlbums.splice(index, 1);
},

View file

@ -25,8 +25,8 @@ Additional information can be found in our Developer Guide:
@import url("../../node_modules/photoswipe/dist/photoswipe.css");
@import url("../../node_modules/maplibre-gl/dist/maplibre-gl.css");
@import url("root.css");
@import url("splash.css");
@import url("body.css");
@import url("text.css");
@import url("lightbox.css");
@import url("controls.css");

View file

@ -1,58 +0,0 @@
/* Browser Scrollbar, see https://css-tricks.com/custom-scrollbars-in-webkit/#aa-the-different-pieces */
#p-navigation ::-webkit-scrollbar {
/* Hides scrollbar in sidebar navigation to save space */
width: 0;
background: transparent;
}
body.dark-theme {
color-scheme: dark !important;
}
/* Scrollbar Styles for Mozilla Firefox: */
body.firefox.dark-theme {
scrollbar-color: dark !important;
}
/* Scrollbar Styles for Google Chrome (and compatible browsers): */
::-webkit-scrollbar {
height: 7px;
width: 7px;
overflow: visible;
}
::-webkit-scrollbar-button {
height: 0;
width: 0;
}
@media only screen and (min-width: 600px) {
::-webkit-scrollbar {
height: 11px;
width: 11px;
overflow: visible;
}
}
::-webkit-scrollbar-corner {
background: transparent;
}
::-webkit-scrollbar-track {
background-color: rgba(var(--v-theme-secondary), 0.2);
border: solid transparent;
border-width: 0 0 0 4px;
}
::-webkit-scrollbar-thumb {
background-color: rgba(var(--v-theme-on-surface), 0.28);
border-style: solid;
border-color: rgba(var(--v-theme-secondary), 0.56);
border-width: 1px;
border-radius: 6px;
min-height: 28px;
padding: 100px 0 0;
}

View file

@ -10,7 +10,7 @@
display: block;
text-align: left;
font-size: 0.825rem;
font-family: monospace;
font-family: var(--v-font-family-mono);
white-space: normal;
color: rgb(var(--v-theme-on-surface));
padding: 4px;

View file

@ -5,6 +5,13 @@
user-select: none;
}
#p-navigation ::-webkit-scrollbar {
/* Hides scrollbar in sidebar navigation to save space */
/* see https://css-tricks.com/custom-scrollbars-in-webkit/#aa-the-different-pieces */
width: 0;
background: transparent;
}
nav .v-list__item__title.title {
line-height: normal !important;
}

114
frontend/src/css/root.css Normal file
View file

@ -0,0 +1,114 @@
:root {
--v-font-family: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji";
--v-font-family-mono: ui-monospace, "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace;
}
/* HTML Body: Fonts, Color Schemes, Loading, and Scrollbar Styles */
html {
font-size: 16px;
}
html,
body,
body #app {
/* Use the system fonts defined above for faster rendering and a native look */
font-family: var(--v-font-family);
letter-spacing: normal !important;
line-height: normal;
text-justify: inter-word;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
-moz-osx-font-smoothing: grayscale;
font-feature-settings: "kern", "liga";
}
body {
color: #333333;
font-size: 17px;
}
[data-color-mode="light"][data-light-theme*="dark"],
[data-color-mode="dark"][data-dark-theme*="dark"] {
color-scheme: dark;
}
:root,
[data-color-mode="light"][data-light-theme*="light"],
[data-color-mode="dark"][data-dark-theme*="light"] {
color-scheme: light;
}
body.dark-theme {
color-scheme: dark !important;
}
html.loading {
overflow-y: hidden !important;
scrollbar-width: none;
}
html.hide-scrollbar {
scrollbar-width: none;
}
body.nojs::-webkit-scrollbar,
body.hide-scrollbar::-webkit-scrollbar,
html.hide-scrollbar ::-webkit-scrollbar {
width: 0;
background: transparent;
z-index: -1000;
}
body.nojs,
body.hide-scrollbar,
html.hide-scrollbar body {
-ms-overflow-style: none;
}
/* Mozilla Firefox */
body.firefox.dark-theme {
scrollbar-color: dark !important;
}
/* Chrome, Chromium, and compatible browsers */
::-webkit-scrollbar {
height: 7px;
width: 7px;
overflow: visible;
}
::-webkit-scrollbar-button {
height: 0;
width: 0;
}
@media only screen and (min-width: 600px) {
::-webkit-scrollbar {
height: 11px;
width: 11px;
overflow: visible;
}
}
::-webkit-scrollbar-corner {
background: transparent;
}
::-webkit-scrollbar-track {
background-color: rgba(var(--v-theme-secondary), 0.2);
border: solid transparent;
border-width: 0 0 0 4px;
}
::-webkit-scrollbar-thumb {
background-color: rgba(var(--v-theme-on-surface), 0.28);
border-style: solid;
border-color: rgba(var(--v-theme-secondary), 0.56);
border-width: 1px;
border-radius: 6px;
min-height: 28px;
padding: 100px 0 0;
}

View file

@ -1,68 +1,4 @@
/* Inline <HTML>, <Body>, and Splash Screen Styles */
html.loading {
overflow-y: hidden !important;
scrollbar-width: none;
}
html.hide-scrollbar {
scrollbar-width: none;
}
[data-color-mode="light"][data-light-theme*="dark"],
[data-color-mode="dark"][data-dark-theme*="dark"] {
color-scheme: dark;
}
:root,
[data-color-mode="light"][data-light-theme*="light"],
[data-color-mode="dark"][data-dark-theme*="light"] {
color-scheme: light;
}
body {
color: #333333;
font-size: 17px;
}
html,
body {
/* Use a system font for faster rendering and a native look */
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
/* Text rendering defaults. */
letter-spacing: normal !important;
line-height: normal;
text-justify: inter-word;
/* Support for all WebKit browsers. */
-webkit-font-smoothing: antialiased;
/* Support for Safari and Chrome. */
text-rendering: optimizeLegibility;
/* Support for Firefox. */
-moz-osx-font-smoothing: grayscale;
/* Enable font features. */
font-feature-settings: "kern", "liga";
}
body.nojs::-webkit-scrollbar,
body.hide-scrollbar::-webkit-scrollbar,
html.hide-scrollbar ::-webkit-scrollbar {
width: 0;
background: transparent;
z-index: -1000;
}
body.nojs,
body.hide-scrollbar,
html.hide-scrollbar body {
-ms-overflow-style: none;
}
/* Loading Animation Styles */
/* Splash Screen Styles */
#busy-overlay {
display: none;

View file

@ -1,44 +1,29 @@
/* UI Font Presets */
/* Text Styles */
body #app,
.text-h1,
.text-h2,
.text-h3,
.text-h4,
.text-h5,
.text-h6,
.text-subtitle-1,
.text-subtitle-2,
.text-body-1,
.text-body-2,
.text-caption,
.text-button,
.text-overline {
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol" !important;
font-family: inherit;
}
body #app,
.text-subtitle-1,
.text-subtitle-2,
.text-body-1,
.text-body-2,
.text-subtitle-1,
.text-subtitle-2,
.text-caption {
/* Text rendering defaults. */
/* Use the system fonts defined in root.css for faster rendering and a native look */
font-family: inherit;
letter-spacing: normal !important;
line-height: normal;
text-justify: inter-word;
/* Support for all WebKit browsers. */
-webkit-font-smoothing: antialiased;
/* Support for Safari and Chrome. */
text-rendering: optimizeLegibility;
/* Support for Firefox. */
-moz-osx-font-smoothing: grayscale;
/* Enable font features. */
font-feature-settings: "kern", "liga";
}
@ -113,41 +98,20 @@ body #app,
white-space: nowrap;
}
pre,
code,
.text-monospace {
font-family: var(--v-font-family-mono);
}
.text-monospace {
white-space: pre;
font-family: monospace;
font-size: 0.875rem;
font-weight: 400;
line-height: 1.425;
letter-spacing: 0.0178571429em;
}
/* Line Height Styles */
.lh-15 {
line-height: 1.5rem !important;
}
.lh-16 {
line-height: 1.6rem !important;
}
.lh-17 {
line-height: 1.7rem !important;
}
.lh-18 {
line-height: 1.8rem !important;
}
.lh-19 {
line-height: 1.9rem !important;
}
.lh-20 {
line-height: 2rem !important;
}
/* Headings */
.p-page h2 {
@ -179,6 +143,11 @@ body #app,
margin-bottom: 0.85rem;
}
.footer p {
margin: 0;
padding: 0;
}
/* Links */
.text-link,
@ -194,9 +163,28 @@ p a.text-link {
text-decoration: none;
}
/* Footers */
/* Custom Line Heights */
.footer p {
margin: 0;
padding: 0;
.lh-15 {
line-height: 1.5rem !important;
}
.lh-16 {
line-height: 1.6rem !important;
}
.lh-17 {
line-height: 1.7rem !important;
}
.lh-18 {
line-height: 1.8rem !important;
}
.lh-19 {
line-height: 1.9rem !important;
}
.lh-20 {
line-height: 2rem !important;
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,5 @@
<template>
<div class="p-page p-page-about" tabindex="1">
<div class="p-page p-page-about" tabindex="-1">
<v-toolbar
flat
:density="$vuetify.display.smAndDown ? 'compact' : 'default'"

Some files were not shown because too many files have changed in this diff Show more