mirror of
https://github.com/photoprism/photoprism.git
synced 2026-01-23 02:24:24 +00:00
Merge remote-tracking branch 'origin/develop' into SQLiteOIDC#4951
This commit is contained in:
commit
0672e58232
199 changed files with 27661 additions and 24804 deletions
11
AGENTS.md
11
AGENTS.md
|
|
@ -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 Vuetify’s 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`.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
70
NOTICE
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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 "Ο αποθηκευτικός χώρος είναι γεμάτος"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 How‑Tos
|
|||
- 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 Vuetify’s 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`
|
||||
|
|
|
|||
740
frontend/package-lock.json
generated
740
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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",
|
||||
|
|
|
|||
232
frontend/src/common/README.md
Normal file
232
frontend/src/common/README.md
Normal 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"`. Vuetify’s 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 Vuetify’s 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 Vuetify’s 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 browser’s 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 one’s trap.
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ export default {
|
|||
},
|
||||
tabindex: {
|
||||
type: Number,
|
||||
default: 3,
|
||||
default: 0,
|
||||
},
|
||||
listClass: {
|
||||
type: String,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<template>
|
||||
<v-dialog
|
||||
ref="dialog"
|
||||
:model-value="visible"
|
||||
persistent
|
||||
max-width="500"
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<template>
|
||||
<v-dialog
|
||||
ref="dialog"
|
||||
:model-value="visible"
|
||||
persistent
|
||||
max-width="575"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
autocorrect="off"
|
||||
autocapitalize="none"
|
||||
class="input-coordinates"
|
||||
@keydown.enter="applyCoordinates"
|
||||
@keydown.enter.stop="applyCoordinates"
|
||||
@update:model-value="onCoordinateInputChange"
|
||||
@paste="pastePosition"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<template>
|
||||
<v-dialog
|
||||
ref="dialog"
|
||||
:model-value="visible"
|
||||
persistent
|
||||
max-width="350"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<template>
|
||||
<v-dialog
|
||||
ref="dialog"
|
||||
:model-value="visible"
|
||||
persistent
|
||||
max-width="580"
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<template>
|
||||
<v-dialog
|
||||
ref="dialog"
|
||||
:model-value="visible"
|
||||
persistent
|
||||
max-width="400"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
114
frontend/src/css/root.css
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue