Merge tag '251130-b3068414c' into PostgreSQL

This commit is contained in:
Keith Martin 2025-12-01 17:16:33 +10:00
commit b731d949f5
979 changed files with 48550 additions and 24493 deletions

View file

@ -59,7 +59,7 @@ compose.*.yaml
__pycache__
venv
.venv
.env
.env*
.tmp
.nv
.eslintcache

2
.gitignore vendored
View file

@ -27,7 +27,7 @@
__pycache__
venv
.venv
.env
.env*
.tmp
.nv
.eslintcache

74
.golangci.yml Normal file
View file

@ -0,0 +1,74 @@
version: "2"
linters:
default: standard
# Extra linters that are usually worth the cost even on a big codebase.
enable:
- revive
- gosec
- gocritic # good generic bug/quality suggestions
- misspell # catch obvious spelling mistakes in identifiers/comments
# Linters that tend to fight with established code style or structure:
disable:
- funlen
- gocyclo
- wsl
- lll # don't enforce a strict max line length
- mnd # don't go after every "magic number"
- depguard # only useful once you define strict dependency rules
- gomodguard
- ineffassign
- errcheck
settings:
revive:
# Only enable the revive rules you actually care about.
rules:
- name: blank-imports
- name: error-strings
- name: error-return
- name: error-naming
- name: exported
- name: if-return
- name: var-naming
severity: warning
disabled: false
exclude: [""]
arguments:
- ["ID", "Id", "id", "UID", "Uid", "uid", "URI", "Uri", "uri", "URL", "Url", "url"] # AllowList
- [] # DenyList
- - skip-initialism-name-checks: true
upper-case-const: true
skip-package-name-checks: true
- name: var-declaration
- name: increment-decrement
- name: range
- name: receiver-naming
- name: time-naming
- name: unexported-return
misspell:
# Correct spellings using locale preferences for US or UK.
# Setting locale to US will correct the British spelling of 'colour' to 'color'.
# Default is to use a neutral variety of English.
locale: US
# Typos to ignore.
# Should be in lower case.
# Default: []
ignore-rules:
- nolint
- gosec
# Extra word corrections.
# `typo` and `correction` should only contain letters.
# The words are case-insensitive.
# Default: []
extra-words:
- typo: "Photoprism"
correction: "PhotoPrism"
# Mode of the analysis:
# - default: checks all the file content.
# - restricted: checks only comments.
# Default: ""
mode: restricted

21
.prettierignore Normal file
View file

@ -0,0 +1,21 @@
coverage/
node_modules/
screenshots/
acceptance/
build/
dist/
bin/
tests/upload-files/
*.html
*.md
.*
.idea
.codex
.local
.config
.github
.tmp
.local
.cache
.gocache
.var

101
AGENTS.md
View file

@ -1,6 +1,6 @@
# PhotoPrism® Repository Guidelines
# PhotoPrism® Repository Guidelines
**Last Updated:** November 14, 2025
**Last Updated:** November 25, 2025
## Purpose
@ -16,32 +16,44 @@ Learn more: https://agents.md/
- Security: https://github.com/photoprism/photoprism/blob/develop/SECURITY.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)
- Packages: `README.md` files under `internal/`, `pkg/`, and `frontend/src/`, e.g. [`internal/photoprism/README.md`](internal/photoprism/README.md), [`internal/photoprism/batch/README.md`](internal/photoprism/batch/README.md), [`internal/config/README.md`](internal/config/README.md), [`internal/server/README.md`](internal/server/README.md), [`internal/api/README.md`](internal/api/README.md), [`internal/thumb/README.md`](internal/thumb/README.md), [`internal/ffmpeg/README.md`](internal/ffmpeg/README.md), and [`frontend/src/common/README.md`](frontend/src/common/README.md).
- Face Detection & Embeddings: [`internal/ai/face/README.md`](internal/ai/face/README.md)
- Vision Config & Engines: [`internal/ai/vision/README.md`](internal/ai/vision/README.md), [`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>`.
### Specifications (Versioning & Usage)
### Specifications, Versioning, & Writing Style
- In the main repo, `specs/` appears ignored because it is managed as a nested Git repository; change into `specs/` before staging or committing spec updates.
- Availability: The `specs/` repository is private and is not guaranteed to be present in every clone or environment. Do not add `Makefile` targets in the main project that depend on `specs/` paths. When `specs/` is available, run its tools directly (e.g., `bash specs/scripts/lint-status.sh`).
- If available, always use the latest spec version for a topic (highest `-vN`), as linked from `specs/README.md`.
- Testing Guides: `specs/dev/backend-testing.md` (Backend/Go), `specs/dev/frontend-testing.md` (Frontend/JS)
- Whenever the Change Management instructions for a document require it, publish changes as a new file with an incremented version suffix (e.g., `*-v3.md`) rather than overwriting the original file.
- Older spec versions remain in the repo for historical reference but are not linked from the main TOC. Do not base new work on superseded files (e.g., `*-v1.md` when `*-v2.md` exists).
- Auto-generated configuration and command references live under `specs/generated/`. Agents MUST NOT read, analyse, or modify anything in this directory; refer humans to `specs/generated/README.md` if regeneration is required.
- Regenerate NOTICE files with `make notice` when dependencies change. Do not edit `NOTICE` or `frontend/NOTICE` manually.
- In the main repo, `specs/` and other directories may appear to be ignored because they are nested Git repositories; if so, change directories before staging or committing updates.
- Availability: The `specs/` repository is private and is not guaranteed to be present in every clone or environment. Do not add `Makefile` targets in the main project that depend on `specs/` paths. When `specs/` is available, you MAY run its tools manually (e.g., `bash specs/scripts/lint-status.sh`), but the main repo must remain buildable without `specs/`.
- If available, always use the latest spec version for a topic (highest `-vN`), as linked from `specs/README.md`.
- Testing Guides: `specs/dev/backend-testing.md` (Backend/Go), `specs/dev/frontend-testing.md` (Frontend/JS)
- Whenever the Change Management instructions for a document require it, publish changes as a new file with an incremented version suffix (e.g., `*-v3.md`) rather than overwriting the original file.
- Older spec versions remain in the repo for historical reference but are not linked from the main TOC. Do not base new work on superseded files (e.g., `*-v1.md` when `*-v2.md` exists).
- Auto-generated configuration and command references live under `specs/generated/`. Agents MUST NOT read, analyse, or modify anything in this directory; refer humans to `specs/generated/README.md` if regeneration is required.
- Regenerate `NOTICE` files with `make notice` when dependencies change (e.g., updates to `go.mod`, `go.sum`, `package-lock.json`, or other lockfiles). Do not edit `NOTICE` or `frontend/NOTICE` manually.
- When writing CLI examples or scripts, place option flags before positional arguments unless the command requires a different order.
**Style note:** Document headings must use Title Case (capitalize words ≥4 letters in AP-style) across Markdown files to keep generated navigation and changelogs consistent.
> Document headings must use **Title Case** (in APA or AP style) across Markdown files to keep generated navigation and changelogs consistent. Always spell the product name as `PhotoPrism`; this proper noun is an exception to generic naming rules.
**CLI note:** When writing CLI examples or scripts, place option flags before positional arguments unless the command requires a different order.
## Safety & Data
- If `git status` shows unexpected changes, assume a human might be editing; if you think you caused them, ask for permission before using reset commands like `git checkout` or `git reset`.
- Do not run `git config` (global or repo-level); changing Git configuration is prohibited for agents.
- Do not run destructive commands against production data. Prefer ephemeral volumes and test fixtures for acceptance tests.
- Never commit secrets, local configurations, or cache files. Use environment variables or a local `.env`.
- Ensure `.env`, `.config`, `.local`, `.codex`, and `.gocache` are ignored in `.gitignore` and `.dockerignore`.
- Prefer using existing caches, workers, and batching strategies referenced in code and `Makefile`.
- Consider memory/CPU impact of changes; only suggest benchmarks or profiling when justified.
> If anything in this file conflicts with the `Makefile` or Sources of Truth, **ask** for clarification before proceeding.
## Project Structure & Languages
- Backend: Go (`internal/`, `pkg/`, `cmd/`) + MariaDB/SQLite
- Package boundaries: Code in `pkg/*` MUST NOT import from `internal/*`.
- If you need access to config/entity/DB, put new code in a package under `internal/` instead of `pkg/`.
- GORM field naming: When adding struct fields that include uppercase abbreviations (e.g., `LabelNSFW`), set an explicit `gorm:"column:<name>"` tag so column names stay consistent (`label_nsfw` instead of `label_n_s_f_w`).
- GORM field naming: When adding struct fields that include uppercase abbreviations (e.g., `LabelNSFW`, `UserID`, `URLHash`), set an explicit `gorm:"column:<name>"` tag so column names stay consistent (`label_nsfw`, `user_id`, `url_hash` instead of split-letter variants).
- Frontend: Vue 3 + Vuetify 3 (`frontend/`)
- Docker/compose for dev/CI; Traefik is used for local TLS (`*.localssl.dev`)
@ -64,16 +76,15 @@ Agents MAY run either:
Agents SHOULD detect the runtime and choose commands accordingly:
- **Inside container if** one of the following is true:
- File exists: `/.dockerenv`
- Project path equals (or is a direct child of): `/go/src/github.com/photoprism/photoprism`
- **Inside container if** `/.dockerenv` exists (authoritative signal).
- Path hint: when the project path is `/go/src/github.com/photoprism/photoprism` *and* `/.dockerenv` is absent, assume you are on the host with a bind mount; treat it as host mode and prefer host-side Docker commands.
#### Examples
Bash:
```bash
if [ -f "/.dockerenv" ] || [ -d "/go/src/github.com/photoprism/photoprism/.git" ]; then
if [ -f "/.dockerenv" ]; then
echo "container"
else
echo "host"
@ -85,8 +96,7 @@ Node.js:
```js
const fs = require("fs");
const inContainer = fs.existsSync("/.dockerenv");
const inDevPath = fs.existsSync("/go/src/github.com/photoprism/photoprism/.git");
console.log(inContainer || inDevPath ? "container" : "host");
console.log(inContainer ? "container" : "host");
```
### Agent installation and invocation
@ -132,15 +142,35 @@ console.log(inContainer || inDevPath ? "container" : "host");
- Only if Traefik is running and the dev compose labels are active
- Labels for `*.localssl.dev` are defined in the dev compose files, e.g. https://github.com/photoprism/photoprism/blob/develop/compose.yaml
- Admin Login: Local compose files set `PHOTOPRISM_ADMIN_USER=admin` and `PHOTOPRISM_ADMIN_PASSWORD=photoprism`; if the credentials differ, inspect `compose.yaml` (or the active environment) for these variables before logging in.
- Do not use the Docker CLI inside the container; starting/stopping services requires host Docker access.
- Do not use the Docker CLI inside the container; starting/stopping services requires host Docker access. If you need to manage compose while inside the dev container, switch to host mode (or ask a human) instead of running `docker compose` there.
Note: Across our public documentation, official images, and in production, the command-line interface (CLI) name is `photoprism`. Other PhotoPrism binary names are only used in development builds for side-by-side comparisons of the Community Edition (CE) with PhotoPrism Plus (`photoprism-plus`) and PhotoPrism Pro (`photoprism-pro`).
### Operating Systems & Architectures
- Our guides and command examples generally assume the use of a Linux/Unix shell on a 64-bit AMD64 or ARM64 system.
- For Windows-specifics, see the Developer Guide FAQ: https://docs.photoprism.app/developer-guide/faq/#can-your-development-environment-be-used-under-windows
## Code Style & Lint
- Go: run `make fmt-go swag-fmt` to reformat the backend code + Swagger annotations (see `Makefile` for additional targets)
- Run `make lint-go` (golangci-lint) after Go changes; prefer `golangci-lint run ./internal/<pkg>/...` for focused edits.
- Doc comments for packages and exported identifiers must be complete sentences that begin with the name of the thing being described and end with a period.
- All newly added functions, including unexported helpers, must have a concise doc comment that explains their behavior.
- For short examples inside comments, indent code rather than using backticks; godoc treats indented blocks as preformatted.
- Branding: Always spell the product name as `PhotoPrism`; this proper noun is an exception to generic naming rules.
- Every Go package must contain a `<package>.go` file in its root (for example, `internal/auth/jwt/jwt.go`) with the standard license header and a short package description comment explaining its purpose.
- JS/Vue: use the lint/format scripts in `frontend/package.json` (ESLint + Prettier)
- All added code and tests **must** be formatted according to our standards.
> Remember to update the `**Last Updated:**` line at the top whenever you edit these guidelines or other files containing a timestamp.
## Tests
- From within the Development Environment:
- Full unit test suite: `make test` (runs backend and frontend tests)
- Test frontend/backend: `make test-js` and `make test-go`
- Linting: `make lint` (all), `make lint-go` (golangci-lint with `.golangci.yml`, prints findings without failing due to `--issues-exit-code 0`), `make lint-js` (ESLint/Prettier)
- Go packages: `go test` (all tests) or `go test -run <name>` (specific tests only)
- Need to inspect the MariaDB data while iterating? Connect directly inside the dev shell with `mariadb -D photoprism` and run SQL without rebuilding Go code.
- Go tests live beside sources: for `path/to/pkg/<file>.go`, add tests in `path/to/pkg/<file>_test.go` (create if missing). For the same function, group related cases as `t.Run(...)` sub-tests (table-driven where helpful) and use **PascalCase** for subtest names (for example, `t.Run("Success", ...)`).
@ -208,19 +238,6 @@ Note: Across our public documentation, official images, and in production, the c
- `config.NewTestConfig("<pkg>")` defaults to SQLite with a persuite DSN like `.<pkg>.db`. Dont assert an empty DSN for SQLite.
- Clean up any persuite SQLite files in tests with `t.Cleanup(func(){ _ = os.Remove(dsn) })` if you capture the DSN.
## Code Style & Lint
- Go: run `make fmt-go swag-fmt` to reformat the backend code + Swagger annotations (see `Makefile` for additional targets)
- Doc comments for packages and exported identifiers must be complete sentences that begin with the name of the thing being described and end with a period.
- All newly added functions, including unexported helpers, must have a concise doc comment that explains their behavior.
- For short examples inside comments, indent code rather than using backticks; godoc treats indented blocks as preformatted.
- Branding: Always spell the product name as `PhotoPrism`; this proper noun is an exception to generic naming rules.
- Every Go package must contain a `<package>.go` file in its root (for example, `internal/auth/jwt/jwt.go`) with the standard license header and a short package description comment explaining its purpose.
- JS/Vue: use the lint/format scripts in `frontend/package.json` (ESLint + Prettier)
- All added code and tests **must** be formatted according to our standards.
> 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`.
@ -229,15 +246,6 @@ Note: Across our public documentation, official images, and in production, the c
- 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`.
- Ensure `.env` and `.local` are ignored in `.gitignore` and `.dockerignore`.
- Prefer using existing caches, workers, and batching strategies referenced in code and `Makefile`. Consider memory/CPU impact; suggest benchmarks or profiling only when justified.
- Do not run destructive commands against production data. Prefer ephemeral volumes and test fixtures when running acceptance tests.
> If anything in this file conflicts with the `Makefile` or the Developer Guide, the `Makefile` and the documentation win. When unsure, **ask** for clarification before proceeding.
### Filesystem Permissions & io/fs Aliasing (Go)
- Always use our shared permission variables from `pkg/fs` when creating files/directories:
@ -277,9 +285,6 @@ Note: Across our public documentation, official images, and in production, the c
- `..` traversal skipped; `__MACOSX` skipped.
- Per-file and total size limits enforced; directory entries created; nested paths extracted safely.
- Examples assume a Linux/Unix shell. For Windows specifics, see the Developer Guide FAQ:
https://docs.photoprism.app/developer-guide/faq/#can-your-development-environment-be-used-under-windows
### HTTP Download — Security Checklist
- Use the shared safe HTTP helper instead of adhoc `net/http` code:

View file

@ -1,6 +1,6 @@
PhotoPrism — Backend CODEMAP
**Last Updated:** November 14, 2025
**Last Updated:** November 22, 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.
@ -10,6 +10,7 @@ Quick Start
- Inside dev container (recommended):
- Install deps: `make dep`
- Build backend: `make build-go`
- Lint Go (golangci-lint): `make lint-go` (uses `.golangci.yml`; prints findings without failing) or run both stacks with `make lint`
- Run server: `./photoprism start`
- Open: http://localhost:2342/ or https://app.localssl.dev/ (Traefik required)
- On host (manages Docker):
@ -39,8 +40,8 @@ High-Level Package Map (Go)
- `internal/workers` — background schedulers (index, vision, sync, meta, backup)
- `internal/auth` — ACL, sessions, OIDC
- `internal/service` — cluster/portal, maps, hub, webdav
- `internal/event` — logging, pub/sub, audit; canonical outcome tokens live in `pkg/log/status` (use helpers like `status.Error(err)` when the sanitized message should be the outcome)
- `internal/ffmpeg`, `internal/thumb`, `internal/meta`, `internal/form`, `internal/mutex` — media, thumbs, metadata, forms, coordination
- `internal/event` — logging, pub/sub, audit; canonical outcome tokens live in `pkg/log/status` (use helpers like `status.Error(err)` when the sanitized message should be the outcome). Docs: `internal/event/README.md`.
- `internal/ffmpeg`, `internal/thumb`, `internal/meta`, `internal/form`, `internal/mutex` — media, thumbs, metadata, forms, coordination. Docs: `internal/ffmpeg/README.md`, `internal/meta/README.md`.
- `pkg/*` — reusable utilities (must never import from `internal/*`), e.g. `pkg/clean`, `pkg/enum`, `pkg/fs`, `pkg/txt`, `pkg/http/header`
Templates & Static Assets
@ -80,6 +81,10 @@ Configuration & Flags
- ACL/mode aware: Values are filtered by user/session and may differ for public vs. authenticated users.
- Dont expose secrets: Treat it as client-visible; avoid sensitive data. To add fields, extend client values via `config.Register` rather than exposing Options directly.
- Refresh cadence: The web UI (nonmobile) also polls for updates every 10 minutes via `$config.update()` in `frontend/src/app.js`, complementing the websocket push.
- OIDC Groups (Pro-Only)
- Config options (tagged `pro`, flags hidden in CE): `oidc-group-claim` (default `groups`), `oidc-group` (required membership list), `oidc-group-role` (mapping `GROUP=ROLE`).
- Parsing/helpers: `internal/auth/oidc/groups.go` normalizes IDs, detects Entra `_claim_names` overage, maps groups→roles, and enforces required membership in `internal/api/oidc_redirect.go`.
- Overage: if `_claim_names.groups` is present and no groups are returned, login fails when required groups are configured; Graph fetch is not implemented yet.
Database & Migrations
- Driver: GORM v1 (`github.com/jinzhu/gorm`). No `WithContext`. Use `db.Raw(stmt).Scan(&nop)` for raw SQL.

View file

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

View file

@ -117,7 +117,7 @@ show-rev:
show-build:
@echo "$(BUILD_TAG)"
test-all: test acceptance-run-chromium
fmt: fmt-js fmt-go swag-fmt
fmt: fmt-js fmt-go fmt-swag
clean-local: clean-local-config clean-local-cache
upgrade: dep-upgrade-js dep-upgrade
devtools: install-go dep-npm
@ -416,20 +416,20 @@ test-js:
(cd frontend && npm run test)
acceptance:
$(info Running public-mode tests in Chrome...)
(cd frontend && find ./tests/acceptance -type f -name "*.js" | xargs -i perl -0777 -ne 'while(/(?:mode: \"auth[^,]*\,)|(Multi-Window\:[A-Za-z 0-9\-_]*)/g){print "$$1\n" if ($$1);}' {} | xargs -I testname bash -c 'npm run testcafe -- "chrome --headless=new" --experimental-multiple-windows --test-meta mode=public --config-file ./testcaferc.json --test "testname" "tests/acceptance"')
(cd frontend && npm run testcafe -- "chrome --headless=new" --test-grep "^(Common|Core)\:*" --test-meta mode=public --config-file ./testcaferc.json "tests/acceptance")
(cd frontend && find ./tests/acceptance -type f -name "*.js" | xargs -i perl -0777 -ne 'while(/(?:mode: \"auth[^,]*\,)|(Multi-Window\:[A-Za-z 0-9\-_]*)/g){print "$$1\n" if ($$1);}' {} | xargs -I testname bash -c 'npm run testcafe -- "chrome --headless=new --disable-features=LocalNetworkAccessChecks" --experimental-multiple-windows --test-meta mode=public --config-file ./testcaferc.json --test "testname" "tests/acceptance"')
(cd frontend && npm run testcafe -- "chrome --headless=new --disable-features=LocalNetworkAccessChecks" --test-grep "^(Common|Core)\:*" --test-meta mode=public --config-file ./testcaferc.json "tests/acceptance")
acceptance-short:
$(info Running JS acceptance tests in Chrome...)
(cd frontend && find ./tests/acceptance -type f -name "*.js" | xargs -i perl -0777 -ne 'while(/(?:mode: \"auth[^,]*\,)|(Multi-Window\:[A-Za-z 0-9\-_]*)/g){print "$$1\n" if ($$1);}' {} | xargs -I testname bash -c 'npm run testcafe -- "chrome --headless=new" --experimental-multiple-windows --test-meta mode=public,type=short --config-file ./testcaferc.json --test "testname" "tests/acceptance"')
(cd frontend && npm run testcafe -- "chrome --headless=new" --test-grep "^(Common|Core)\:*" --test-meta mode=public,type=short --config-file ./testcaferc.json "tests/acceptance")
(cd frontend && find ./tests/acceptance -type f -name "*.js" | xargs -i perl -0777 -ne 'while(/(?:mode: \"auth[^,]*\,)|(Multi-Window\:[A-Za-z 0-9\-_]*)/g){print "$$1\n" if ($$1);}' {} | xargs -I testname bash -c 'npm run testcafe -- "chrome --headless=new --disable-features=LocalNetworkAccessChecks" --experimental-multiple-windows --test-meta mode=public,type=short --config-file ./testcaferc.json --test "testname" "tests/acceptance"')
(cd frontend && npm run testcafe -- "chrome --headless=new --disable-features=LocalNetworkAccessChecks" --test-grep "^(Common|Core)\:*" --test-meta mode=public,type=short --config-file ./testcaferc.json "tests/acceptance")
acceptance-auth:
$(info Running JS acceptance-auth tests in Chrome...)
(cd frontend && find ./tests/acceptance -type f -name "*.js" | xargs -i perl -0777 -ne 'while(/(?:mode: \"public[^,]*\,)|(Multi-Window\:[A-Za-z 0-9\-_]*)/g){print "$$1\n" if ($$1);}' {} | xargs -I testname bash -c 'npm run testcafe -- "chrome --headless=new" --experimental-multiple-windows --test-meta mode=auth --config-file ./testcaferc.json --test "testname" "tests/acceptance"')
(cd frontend && npm run testcafe -- "chrome --headless=new" --test-grep "^(Common|Core)\:*" --test-meta mode=auth --config-file ./testcaferc.json "tests/acceptance")
(cd frontend && find ./tests/acceptance -type f -name "*.js" | xargs -i perl -0777 -ne 'while(/(?:mode: \"public[^,]*\,)|(Multi-Window\:[A-Za-z 0-9\-_]*)/g){print "$$1\n" if ($$1);}' {} | xargs -I testname bash -c 'npm run testcafe -- "chrome --headless=new --disable-features=LocalNetworkAccessChecks" --experimental-multiple-windows --test-meta mode=auth --config-file ./testcaferc.json --test "testname" "tests/acceptance"')
(cd frontend && npm run testcafe -- "chrome --headless=new --disable-features=LocalNetworkAccessChecks" --test-grep "^(Common|Core)\:*" --test-meta mode=auth --config-file ./testcaferc.json "tests/acceptance")
acceptance-auth-short:
$(info Running JS acceptance-auth tests in Chrome...)
(cd frontend && find ./tests/acceptance -type f -name "*.js" | xargs -i perl -0777 -ne 'while(/(?:mode: \"public[^,]*\,)|(Multi-Window\:[A-Za-z 0-9\-_]*)/g){print "$$1\n" if ($$1);}' {} | xargs -I testname bash -c 'npm run testcafe -- "chrome --headless=new" --experimental-multiple-windows --test-meta mode=auth,type=short --config-file ./testcaferc.json --test "testname" "tests/acceptance"')
(cd frontend && npm run testcafe -- "chrome --headless=new" --test-grep "^(Common|Core)\:*" --test-meta mode=auth,type=short --config-file ./testcaferc.json "tests/acceptance")
(cd frontend && find ./tests/acceptance -type f -name "*.js" | xargs -i perl -0777 -ne 'while(/(?:mode: \"public[^,]*\,)|(Multi-Window\:[A-Za-z 0-9\-_]*)/g){print "$$1\n" if ($$1);}' {} | xargs -I testname bash -c 'npm run testcafe -- "chrome --headless=new --disable-features=LocalNetworkAccessChecks" --experimental-multiple-windows --test-meta mode=auth,type=short --config-file ./testcaferc.json --test "testname" "tests/acceptance"')
(cd frontend && npm run testcafe -- "chrome --headless=new --disable-features=LocalNetworkAccessChecks" --test-grep "^(Common|Core)\:*" --test-meta mode=auth,type=short --config-file ./testcaferc.json "tests/acceptance")
vitest-watch:
$(info Running Vitest unit tests in watch mode...)
(cd frontend && npm run test-watch)
@ -482,10 +482,10 @@ reset-sqlite:
find ./internal -type f \( -iname '.*.db' -o -iname '.*.db-journal' -o -iname '.test.*' \) -delete
run-test-short:
$(info Running short Go tests in parallel mode...)
$(GOTEST) -parallel 2 -count 1 -cpu 2 -short -timeout 5m ./pkg/... ./internal/...
$(GOTEST) -parallel 2 -count 1 -cpu 2 -short -timeout 5m ./pkg/... ./internal/... ./.../internal/...
run-test-go:
$(info Running all Go tests...)
$(GOTEST) -parallel 1 -count 1 -cpu 1 -tags="slow,develop" -timeout 20m ./pkg/... ./internal/...
$(GOTEST) -parallel 1 -count 1 -cpu 1 -tags="slow,develop" -timeout 20m ./pkg/... ./internal/... ./.../internal/...
run-test-hub:
$(info Running all Go tests with hub requests...)
env PHOTOPRISM_TEST_HUB="true" $(GOTEST) -parallel 1 -count 1 -cpu 1 -tags="slow,develop,debug" -timeout 20m ./pkg/... ./internal/...
@ -521,16 +521,16 @@ run-test-photoprism:
$(GOTEST) -parallel 2 -count 1 -cpu 2 -tags="slow,develop" -timeout 20m ./internal/photoprism/...
test-parallel:
$(info Running all Go tests in parallel mode...)
$(GOTEST) -parallel 2 -count 1 -cpu 2 -tags="slow,develop" -timeout 20m ./pkg/... ./internal/...
$(GOTEST) -parallel 2 -count 1 -cpu 2 -tags="slow,develop" -timeout 20m ./pkg/... ./internal/... ./.../internal/...
test-verbose:
$(info Running all Go tests in verbose mode...)
$(GOTEST) -parallel 1 -count 1 -cpu 1 -tags="slow,develop" -timeout 20m -v ./pkg/... ./internal/...
$(GOTEST) -parallel 1 -count 1 -cpu 1 -tags="slow,develop" -timeout 20m -v ./pkg/... ./internal/... ./.../internal/...
test-race:
$(info Running all Go tests with race detection in verbose mode...)
$(GOTEST) -tags="slow,develop" -race -timeout 60m -v ./pkg/... ./internal/...
$(GOTEST) -tags="slow,develop" -race -timeout 60m -v ./pkg/... ./internal/... ./.../internal/...
test-coverage:
$(info Running all Go tests with code coverage report...)
go test -parallel 1 -count 1 -cpu 1 -failfast -tags="slow,develop" -timeout 30m -coverprofile coverage.txt -covermode atomic ./pkg/... ./internal/...
go test -parallel 1 -count 1 -cpu 1 -failfast -tags="slow,develop" -timeout 30m -coverprofile coverage.txt -covermode atomic ./pkg/... ./internal/... ./.../internal/...
go tool cover -html=coverage.txt -o coverage.html
go tool cover -func coverage.txt | grep total:
git-pull:
@ -769,18 +769,18 @@ docker-preview-oracular:
docker pull --platform=arm64 photoprism/develop:oracular
docker pull --platform=arm64 photoprism/develop:oracular-slim
scripts/docker/buildx-multi.sh photoprism linux/amd64,linux/arm64 preview-ce /oracular
docker-preview-questing:
docker pull --platform=amd64 photoprism/develop:questing
docker pull --platform=amd64 photoprism/develop:questing-slim
docker pull --platform=arm64 photoprism/develop:questing
docker pull --platform=arm64 photoprism/develop:questing-slim
scripts/docker/buildx-multi.sh photoprism linux/amd64,linux/arm64 preview-ce /questing "-t photoprism/photoprism:preview -t photoprism/photoprism:ubuntu"
docker-preview-plucky:
docker pull --platform=amd64 photoprism/develop:plucky
docker pull --platform=amd64 photoprism/develop:plucky-slim
docker pull --platform=arm64 photoprism/develop:plucky
docker pull --platform=arm64 photoprism/develop:plucky-slim
scripts/docker/buildx-multi.sh photoprism linux/amd64,linux/arm64 preview-ce /plucky
docker-preview-questing:
docker pull --platform=amd64 photoprism/develop:questing
docker pull --platform=amd64 photoprism/develop:questing-slim
docker pull --platform=arm64 photoprism/develop:questing
docker pull --platform=arm64 photoprism/develop:questing-slim
scripts/docker/buildx-multi.sh photoprism linux/amd64,linux/arm64 preview-ce /questing
release: docker-release
docker-release: docker-release-latest
docker-release-all: docker-release-latest docker-release-other
@ -851,18 +851,18 @@ docker-release-oracular:
docker pull --platform=arm64 photoprism/develop:oracular
docker pull --platform=arm64 photoprism/develop:oracular-slim
scripts/docker/buildx-multi.sh photoprism linux/amd64,linux/arm64 ce /oracular
docker-release-questing:
docker pull --platform=amd64 photoprism/develop:questing
docker pull --platform=amd64 photoprism/develop:questing-slim
docker pull --platform=arm64 photoprism/develop:questing
docker pull --platform=arm64 photoprism/develop:questing-slim
scripts/docker/buildx-multi.sh photoprism linux/amd64,linux/arm64 ce /questing "-t photoprism/photoprism:latest -t photoprism/photoprism:ubuntu"
docker-release-plucky:
docker pull --platform=amd64 photoprism/develop:plucky
docker pull --platform=amd64 photoprism/develop:plucky-slim
docker pull --platform=arm64 photoprism/develop:plucky
docker pull --platform=arm64 photoprism/develop:plucky-slim
scripts/docker/buildx-multi.sh photoprism linux/amd64,linux/arm64 ce /plucky
docker-release-questing:
docker pull --platform=amd64 photoprism/develop:questing
docker pull --platform=amd64 photoprism/develop:questing-slim
docker pull --platform=arm64 photoprism/develop:questing
docker pull --platform=arm64 photoprism/develop:questing-slim
scripts/docker/buildx-multi.sh photoprism linux/amd64,linux/arm64 ce /questing
start-traefik:
$(DOCKER_COMPOSE) up -d --wait traefik
stop-traefik:
@ -1035,14 +1035,20 @@ docker-dummy-oidc:
packer-digitalocean:
$(info Buildinng DigitalOcean marketplace image...)
(cd ./setup/docker/cloud && packer build digitalocean.json)
lint: lint-js lint-go
lint-js:
(cd frontend && npm run lint)
$(info Linting JS code...)
$(MAKE) -C frontend lint
lint-go:
$(info Linting Go code...)
golangci-lint run --issues-exit-code 0 ./pkg/... ./internal/... ./.../internal/...
fmt-js:
(cd frontend && npm run fmt)
fmt-go:
go fmt ./pkg/... ./internal/... ./cmd/...
go fmt ./pkg/... ./internal/... ./cmd/... ./.../internal/...
gofmt -w -s pkg internal cmd
goimports -w -local "github.com/photoprism" pkg internal cmd
fmt-swag: swag-fmt
tidy:
go mod tidy
users:

18
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-11-12
Date generated: 2025-11-29
================================================================================
@ -1206,8 +1206,8 @@ THE SOFTWARE.
--------------------------------------------------------------------------------
Package: github.com/go-co-op/gocron/v2
Version: v2.18.0
License: MIT (https://github.com/go-co-op/gocron/blob/v2.18.0/LICENSE)
Version: v2.18.2
License: MIT (https://github.com/go-co-op/gocron/blob/v2.18.2/LICENSE)
MIT License
@ -2443,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-20251111181513-e7f3a1a58fb3
License: Apache-2.0 (https://github.com/golang/geo/blob/e7f3a1a58fb3/LICENSE)
Version: v0.0.0-20251125140653-09e2dd3603dd
License: Apache-2.0 (https://github.com/golang/geo/blob/09e2dd3603dd/LICENSE)
Apache License
@ -5325,8 +5325,8 @@ License: Apache-2.0 (https://github.com/prometheus/client_model/blob/v0.6.2/LICE
--------------------------------------------------------------------------------
Package: github.com/prometheus/common
Version: v0.67.2
License: Apache-2.0 (https://github.com/prometheus/common/blob/v0.67.2/LICENSE)
Version: v0.67.4
License: Apache-2.0 (https://github.com/prometheus/common/blob/v0.67.4/LICENSE)
Apache License
Version 2.0, January 2004
@ -8188,8 +8188,8 @@ License: Apache-2.0 (https://github.com/go4org/go4/blob/214862532bf5/LICENSE)
--------------------------------------------------------------------------------
Package: golang.org/x/crypto
Version: v0.44.0
License: BSD-3-Clause (https://cs.opensource.google/go/x/crypto/+/v0.44.0:LICENSE)
Version: v0.45.0
License: BSD-3-Clause (https://cs.opensource.google/go/x/crypto/+/v0.45.0:LICENSE)
Copyright 2009 The Go Authors.

View file

@ -3,7 +3,7 @@ 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-11-12 07:40+0000\n"
"PO-Revision-Date: 2025-11-19 10:23+0000\n"
"Last-Translator: dtsolakis <dtsola@eranet.gr>\n"
"Language-Team: none\n"
"Language: el\n"
@ -108,7 +108,7 @@ msgstr "Μη διαθέσιμο στην κατάσταση \"μόνο ανάγ
#: messages.go:127
msgid "Please log in to your account"
msgstr "Παρακαλούμε συνδεθείτε και δοκιμάστε ξανά"
msgstr "Συνδεθείτε στο λογαριασμό σας"
#: messages.go:128
msgid "Permission denied"
@ -156,7 +156,7 @@ msgstr "Μη έγκυρος κωδικός πρόσβασης, δοκιμάστ
#: messages.go:139
msgid "Feature disabled"
msgstr "Απενεργοποιημένη δυνατότητα"
msgstr "Απενεργοποιημένη λειτουργία"
#: messages.go:140
msgid "No labels selected"
@ -271,7 +271,7 @@ msgstr "Ο λογαριασμός δημιουργήθηκε"
#: messages.go:168
msgid "Account saved"
msgstr "Ο λογαριασμός αποθηκεύθηκε"
msgstr "Ο λογαριασμός αποθηκεύτηκε"
#: messages.go:169
msgid "Account deleted"
@ -345,7 +345,7 @@ msgstr "Το θέμα διαγράφηκε"
#: messages.go:185
msgid "Person saved"
msgstr "Το άτομο αποθηκεύθηκε"
msgstr "Το άτομο αποθηκεύτηκε"
#: messages.go:186
msgid "Person deleted"

122
assets/profiles/icc/NOTICE Normal file
View file

@ -0,0 +1,122 @@
================================================================================
================================================================================
Third-Party ICC Profiles for PhotoPrism
--------------------------------------------------------------------------------
The following 3rd-party ICC profiles 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-11-23
--------------------------------------------------------------------------------
Files: a98.icc (compatibleWithAdobeRGB1998.icc)
Source: OpenICC "compatibleWithAdobeRGB1998.icc" via Debian icc-profiles-free
URL: https://salsa.debian.org/debian/icc-profiles-free/-/blob/a7a3c11b8a6d3bc2937447183b87dc89de9d2388/icc-profiles-openicc/default_profiles/base/compatibleWithAdobeRGB1998.icc
License: zlib/libpng
Checksum (md5): 826a1e13374e3dc34f9872f31ec028c8
The zlib/libpng License
Copyright (c) Graeme Gill <graeme@argyllcms.com>
This software is provided 'as-is', without any express or implied
warranty. In no event will the authors be held liable for any damages
arising from the use of this software.
Permission is granted to anyone to use this software for any purpose,
including commercial applications, and to alter it and redistribute it
freely, subject to the following restrictions:
1. The origin of this software must not be misrepresented; you must not
claim that you wrote the original software. If you use this software
in a product, an acknowledgment in the product documentation would be
appreciated but is not required.
2. Altered source versions must be plainly marked as such, and must not be
misrepresented as being the original software.
3. This notice may not be removed or altered from any source
distribution.
The provided ICC Profiles in the package are called DATA in the following
statement:
NO WARRANTY
BECAUSE THE DATA IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
FOR THE DATA, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
PROVIDE THE DATA "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
TO THE QUALITY AND PERFORMANCE OF THE DATA IS WITH YOU. SHOULD THE
DATA PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
REPAIR OR CORRECTION.
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
REDISTRIBUTE THE DATA AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
OUT OF THE USE OR INABILITY TO USE THE DATA (INCLUDING BUT NOT LIMITED
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
YOU OR THIRD PARTIES OR A FAILURE OF THE DATA TO OPERATE WITH ANY OTHER
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
POSSIBILITY OF SUCH DAMAGES.
--------------------------------------------------------------------------------
Files: adobecompat-v2.icc, adobecompat-v4.icc, applecompat-v2.icc, applecompat-v4.icc, cgats001compat-v2-micro.icc, colormatchcompat-v2.icc, colormatchcompat-v4.icc, dci-p3-v4.icc, displayp3-v2-magic.icc, displayp3-v2-micro.icc, displayp3-v4.icc, displayp3compat-v2-magic.icc, displayp3compat-v2-micro.icc, displayp3compat-v4.icc, prophoto-v2-magic.icc, prophoto-v2-micro.icc, prophoto-v4.icc, rec2020-g24-v4.icc, rec2020-v2-magic.icc, rec2020-v2-micro.icc, rec2020-v4.icc, rec2020compat-v2-magic.icc, rec2020compat-v2-micro.icc, rec2020compat-v4.icc, rec601ntsc-v2-magic.icc, rec601ntsc-v2-micro.icc, rec601ntsc-v4.icc, rec601pal-v2-magic.icc, rec601pal-v2-micro.icc, rec601pal-v4.icc, rec709-v2-magic.icc, rec709-v2-micro.icc, rec709-v4.icc, scrgb-v2.icc, sgrey-v2-magic.icc, sgrey-v2-micro.icc, sgrey-v2-nano.icc, sgrey-v4.icc, srgb-v2-magic.icc, srgb-v2-micro.icc, srgb-v2-nano.icc, srgb-v4.icc, widegamutcompat-v2.icc, widegamutcompat-v4.icc
Source: Compact-ICC-Profiles via Debian icc-profiles-free
URL: https://salsa.debian.org/debian/icc-profiles-free/-/tree/a7a3c11b8a6d3bc2937447183b87dc89de9d2388/Compact-ICC-Profiles/profiles
License: CC0-1.0 (Public Domain Dedication)
Checksums (md5):
adobecompat-v2.icc 08220aa4b4e4259ec3c446a35197d89b
adobecompat-v4.icc fbf912760a8d14e496ff389c29c3132d
applecompat-v2.icc 21453c734d9364abceacc7ab837019ec
applecompat-v4.icc fc399558e27a0d53748820cff2a98a2b
cgats001compat-v2-micro.icc 56a85233ee08fa7527875be36cf426d6
colormatchcompat-v2.icc 9f2a755b4b3069f46f4eaef11e24926d
colormatchcompat-v4.icc 9e119efb0abaa31e955f8a30eeabc58c
dci-p3-v4.icc bdedf9e7ad0b93ed8f85f8c3ebdc4223
displayp3-v2-magic.icc 6748fcfd56d38770a02c023bbd6d0529
displayp3-v2-micro.icc 2615293123ddc4366af3da39455c3d7a
displayp3-v4.icc 32dc35d6a113b86cbc31bd1281e3baed
displayp3compat-v2-magic.icc 05fb82a702e27438ecec47a2f120cdcd
displayp3compat-v2-micro.icc 2ef6c295ac5d05760c1a4cf1668951a3
displayp3compat-v4.icc c34c90451326b183916f05b3ae41d920
prophoto-v2-magic.icc 15a31d407cf35662fbe4513f6204bfdf
prophoto-v2-micro.icc 445c1a3f3f1a20aab68e76838d3ed334
prophoto-v4.icc 8f4523255234753cd2d3111d8f09b184
rec2020-g24-v4.icc 3c7afbfff612d10a10775c167d36b51e
rec2020-v2-magic.icc 3b5846fb69faa53ebdfd29061f17b0e3
rec2020-v2-micro.icc 13d1e96875c35f7d5ebaf0f6a3d16357
rec2020-v4.icc 297c644758a979abe62349ca1ff416d5
rec2020compat-v2-magic.icc 9aa85534e81c275bc0627badf157580a
rec2020compat-v2-micro.icc fa39fc95daece7dcaff18e7265082368
rec2020compat-v4.icc 66839229639bb55bc443b490fe302364
rec601ntsc-v2-magic.icc 53ee12707ac87a8e3b3452af4a325e29
rec601ntsc-v2-micro.icc af05afec2146917a67445ac6cb5ca61d
rec601ntsc-v4.icc cc57dd6fa3d6f08e43e0f70b5376c00a
rec601pal-v2-magic.icc f706fd528cedcd1c88dac50073112f94
rec601pal-v2-micro.icc d193e01949eb5347b0d16d2b3ccdabcf
rec601pal-v4.icc dd7cd61d6ee14a521c9fb5afa2803e8e
rec709-v2-magic.icc 47f09046656a2f0d66117a9c1b15e137
rec709-v2-micro.icc f91edc9f3ff1390c842bd9e8759688b0
rec709-v4.icc 0339e2a70940aefd9237311889d065e6
scrgb-v2.icc a841263101bdf48fb9b81486f5451f2d
sgrey-v2-magic.icc ef6221686b517e4665480639202dacd5
sgrey-v2-micro.icc ca08451dba57ca1e910330cda37515ad
sgrey-v2-nano.icc 57d72e3f6437d65c618ebcdb1f6fa1bd
sgrey-v4.icc b93b1e31e75243ea08fbdab9e82f7cbe
srgb-v2-magic.icc 5967f401f9a54913a283942710cef93c
srgb-v2-micro.icc e8de3a5b44d70610306b0a20225701d1
srgb-v2-nano.icc e060a57b7a057f7f0bdb859b68db60e9
srgb-v4.icc 3c6a277ddee033ad090ba22b8323dcaf
widegamutcompat-v2.icc 63c0165987ee94c8c7f2c68749e0418d
widegamutcompat-v4.icc 4ccb168ae2f7daa51d3cef7df6fa6f16
--------------------------------------------------------------------------------

BIN
assets/profiles/icc/a98.icc Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -176,7 +176,7 @@ services:
## Release Notes: https://mariadb.com/kb/en/changes-improvements-in-mariadb-1011/
mariadb:
image: mariadb:11
stop_grace_period: 15s
stop_grace_period: 10s
security_opt: # see https://github.com/MariaDB/mariadb-docker/issues/434#issuecomment-1136151239
- seccomp:unconfined
- apparmor:unconfined
@ -203,7 +203,7 @@ services:
qdrant:
image: qdrant/qdrant:latest
profiles: [ "all", "qdrant" ]
stop_grace_period: 15s
stop_grace_period: 10s
links:
- "traefik:localssl.dev"
- "traefik:app.localssl.dev"
@ -357,8 +357,8 @@ services:
## Dummy WebDAV Server
dummy-webdav:
image: photoprism/dummy-webdav:240627
stop_grace_period: 30s
image: photoprism/dummy-webdav:251124
stop_grace_period: 10s
environment:
WEBDAV_USERNAME: admin
WEBDAV_PASSWORD: photoprism
@ -374,8 +374,8 @@ services:
## Dummy OIDC Identity Provider
dummy-oidc:
image: photoprism/dummy-oidc:240627
stop_grace_period: 30s
image: photoprism/dummy-oidc:251124
stop_grace_period: 5s
labels:
- "traefik.enable=true"
- "traefik.http.services.dummy-oidc.loadbalancer.server.port=9998"
@ -390,7 +390,7 @@ services:
## Docs: https://glauth.github.io/docs/
dummy-ldap:
image: glauth/glauth-plugins:latest
stop_grace_period: 15s
stop_grace_period: 5s
ports:
- "127.0.0.1:389:389"
labels:
@ -410,7 +410,7 @@ services:
## Login with "user / photoprism" and "admin / photoprism".
keycloak:
image: quay.io/keycloak/keycloak:25.0
stop_grace_period: 20s
stop_grace_period: 10s
profiles: [ "all", "auth", "keycloak" ]
command: "start-dev" # development mode, do not use this in production!
links:
@ -443,7 +443,7 @@ services:
## ./photoprism client add --id=cs5cpu17n6gj2qo5 --secret=xcCbOrw6I0vcoXzhnOmXhjpVSyFq0l0e -s metrics -n Prometheus -e 60 -t 1
prometheus:
image: prom/prometheus:latest
stop_grace_period: 15s
stop_grace_period: 10s
profiles: [ "all", "auth", "prometheus" ]
labels:
- "traefik.enable=true"

View file

@ -53,7 +53,7 @@ RUN echo 'APT::Acquire::Retries "3";' > /etc/apt/apt.conf.d/80retries && \
exiftool darktable rawtherapee libheif-examples librsvg2-bin librav1e-dev \
ffmpeg libavcodec-extra libdav1d-dev x264 x265 libvpx-dev libwebm-dev \
libmatroska-dev libdvdread-dev libebml5 libgav1-bin libatomic1 \
iputils-ping dnsutils \
iputils-ping dnsutils binutils binutils-gold \
&& \
echo 'alias ll="ls -alh"' >> /etc/skel/.bashrc && \
echo 'export PS1="\u@$DOCKER_TAG:\w\$ "' >> /etc/skel/.bashrc && \

View file

@ -59,7 +59,7 @@ RUN echo 'APT::Acquire::Retries "3";' > /etc/apt/apt.conf.d/80retries && \
exiftool rawtherapee libheif-examples librsvg2-bin librav1e-dev \
ffmpeg libavcodec-extra libdav1d-dev x264 x265 libvpx-dev libwebm-dev \
libmatroska-dev libdvdread-dev libebml5 libgav1-bin libatomic1 \
iputils-ping dnsutils \
iputils-ping dnsutils binutils binutils-gold \
&& \
apt-get -qq install \
apt-utils pkg-config software-properties-common \

View file

@ -53,7 +53,7 @@ RUN echo 'APT::Acquire::Retries "3";' > /etc/apt/apt.conf.d/80retries && \
xz-utils exiftool sqlite3 postgresql-client tzdata gpg make zip unzip wget curl rsync \
imagemagick libvips-dev rawtherapee ffmpeg libavcodec-extra x264 x265 libde265-dev \
libaom-dev libvpx-dev libwebm-dev libjpeg-dev libmatroska-dev libdvdread-dev libebml5 libgav1-bin libatomic1 \
va-driver-all libva2 iputils-ping dnsutils libmagic-mgc \
va-driver-all libva2 iputils-ping dnsutils libmagic-mgc binutils binutils-gold \
&& \
/scripts/install-mariadb.sh mariadb-client && \
/scripts/install-postgresql.sh postgresql-client && \

View file

@ -59,7 +59,7 @@ RUN echo 'APT::Acquire::Retries "3";' > /etc/apt/apt.conf.d/80retries && \
xz-utils exiftool sqlite3 postgresql-client tzdata gpg make zip unzip wget curl rsync \
imagemagick libvips-dev rawtherapee ffmpeg libavcodec-extra x264 x265 libde265-dev \
libaom-dev libvpx-dev libwebm-dev libjpeg-dev libmatroska-dev libdvdread-dev libebml5 libgav1-bin libatomic1 \
va-driver-all libva2 iputils-ping dnsutils libmagic-mgc \
va-driver-all libva2 iputils-ping dnsutils libmagic-mgc binutils binutils-gold \
&& \
apt-get -qq install \
build-essential software-properties-common pkg-config apt-utils \

View file

@ -51,7 +51,7 @@ RUN echo 'APT::Acquire::Retries "3";' > /etc/apt/apt.conf.d/80retries && \
ffmpeg imagemagick libvips-dev rawtherapee libjxl-dev libjxl-tools libffmpeg-nvenc-dev librav1e-dev \
libswscale-dev libavfilter-extra libavformat-extra libavcodec-extra x264 x265 libde265-dev libaom-dev \
libvpx-dev libwebm-dev libjpeg-dev libmatroska-dev libdvdread-dev libdav1d-dev libsharpyuv0 \
iputils-ping dnsutils \
iputils-ping dnsutils binutils binutils-gold \
&& \
/scripts/install-mariadb.sh mariadb-client && \
/scripts/install-postgresql.sh postgresql-client && \

View file

@ -57,7 +57,7 @@ RUN echo 'APT::Acquire::Retries "3";' > /etc/apt/apt.conf.d/80retries && \
ffmpeg imagemagick libvips-dev rawtherapee libjxl-dev libjxl-tools libffmpeg-nvenc-dev librav1e-dev \
libswscale-dev libavfilter-extra libavformat-extra libavcodec-extra x264 x265 libde265-dev libaom-dev \
libvpx-dev libwebm-dev libjpeg-dev libmatroska-dev libdvdread-dev libdav1d-dev libsharpyuv0 \
iputils-ping dnsutils \
iputils-ping dnsutils binutils binutils-gold \
&& \
apt-get -qq install \
software-properties-common pkg-config apt-utils ripgrep fd-find bat eza \

View file

@ -54,7 +54,7 @@ RUN echo 'APT::Acquire::Retries "3";' > /etc/apt/apt.conf.d/80retries && \
ffmpeg imagemagick libvips-dev rawtherapee libjxl-dev libjxl-tools libffmpeg-nvenc-dev librav1e-dev \
libswscale-dev libavfilter-extra libavformat-extra libavcodec-extra x264 x265 libde265-dev libaom-dev \
libvpx-dev libwebm-dev libjpeg-dev libmatroska-dev libdvdread-dev libdav1d-dev libsharpyuv0 \
va-driver-all libva2 iputils-ping dnsutils libmagic-mgc \
va-driver-all libva2 iputils-ping dnsutils libmagic-mgc binutils binutils-gold \
&& \
/scripts/install-mariadb.sh mariadb-client && \
/scripts/install-darktable.sh && \

View file

@ -60,7 +60,7 @@ RUN echo 'APT::Acquire::Retries "3";' > /etc/apt/apt.conf.d/80retries && \
ffmpeg imagemagick libvips-dev rawtherapee libjxl-dev libjxl-tools libffmpeg-nvenc-dev librav1e-dev \
libswscale-dev libavfilter-extra libavformat-extra libavcodec-extra x264 x265 libde265-dev libaom-dev \
libvpx-dev libwebm-dev libjpeg-dev libmatroska-dev libdvdread-dev libdav1d-dev libsharpyuv0 \
va-driver-all libva2 iputils-ping dnsutils libmagic-mgc \
va-driver-all libva2 iputils-ping dnsutils libmagic-mgc binutils binutils-gold \
&& \
apt-get -qq install \
build-essential software-properties-common pkg-config apt-utils htop \

View file

@ -4,8 +4,12 @@ tests/screenshots/
tests/acceptance/screenshots/
tests/upload-files/
*.html
*.md
.*
.idea
.codex
.local
.config
.github
.tmp
.local

View file

@ -1,5 +1,5 @@
{
"printWidth": 120,
"printWidth": 160,
"semi": true,
"singleQuote": false,
"trailingComma": "es5",

View file

@ -1,6 +1,6 @@
PhotoPrism — Frontend CODEMAP
**Last Updated:** November 12, 2025
**Last Updated:** November 21, 2025
Purpose
- Help agents and contributors navigate the Vue 3 + Vuetify 3 app quickly and make safe changes.
@ -43,6 +43,12 @@ Runtime & Plugins
- PWA: Workbox registers a service worker after config load (see `src/app.js`); scope and registration URL derive from `$config.baseUri` so non-root deployments work. Workbox precache rules live in `frontend/webpack.config.js` (see the `GenerateSW` plugin); locale chunks and non-woff2 font variants are excluded there so we dont force every user to download those assets on first visit.
- WebSocket: `src/common/websocket.js` publishes `websocket.*` events, used by `$session` for client info
Lightbox Integration
- Shared entry points live in `src/common/lightbox.js`; `$lightbox.open(options)` fires a `lightbox.open` event consumed by `component/lightbox.vue`.
- Prefer `$lightbox.openView(this, index)` when a component or dialog already has the photos in memory. Implement `getLightboxContext(index)` on the view and return `{ models, index, context, allowEdit?, allowSelect? }` so the lightbox can build slides without requerying.
- Set `allowEdit: false` when the caller shouldnt expose inline editing (the edit button and `KeyE` shortcut are disabled automatically). Set `allowSelect: false` to hide the selection toggle and block the `.` shortcut so batch-edit dialogs dont mutate the global clipboard.
- Legacy `$lightbox.openModels(models, index, collection)` still accepts raw thumb arrays, but it cannot express the context flags—only use it when you truly dont have a backing view.
HTTP Client
- Axios instance: `src/common/api.js`
- Base URL: `window.__CONFIG__.apiUri` (or `/api/v1` in tests)
@ -77,7 +83,7 @@ Testing
Build & Tooling
- Webpack is used for bundling; scripts in `frontend/package.json`:
- `npm run build` (prod), `npm run build-dev` (dev), `npm run watch`
- Lint/format: `npm run lint`, `npm run fmt`
- Lint/format: `npm run lint` or `make lint-js`; repo root `make lint` runs both backend (golangci-lint via `.golangci.yml`) and frontend linters
- Security scan: `npm run security:scan` (checks `--ignore-scripts` and forbids `v-html`)
- Licensing: run `make notice` from the repo root to regenerate `NOTICE` files after dependency changes—never edit them manually.
- Make targets (from repo root): `make build-js`, `make watch-js`, `make test-js`

View file

@ -60,7 +60,7 @@ watch:
build:
npm run build
lint:
npm run lint
npm run lint || true
fmt:
npm run fmt
test:

View file

@ -76,7 +76,8 @@ export default defineConfig([
},
},
rules: {
"indent": ["error", 2, { SwitchCase: 1 }],
// Defer indentation to Prettier so we don't get conflicting expectations.
"indent": "off",
"linebreak-style": ["error", "unix"],
"quotes": [
"off",
@ -127,7 +128,7 @@ export default defineConfig([
"prettier/prettier": [
"warn",
{
printWidth: 120,
printWidth: 160,
semi: true,
singleQuote: false,
bracketSpacing: true,

File diff suppressed because it is too large Load diff

View file

@ -27,7 +27,7 @@
"security:scan": "npm run -s security:scan-installs && npm run -s security:scan-xss",
"security:scan-installs": "sh -lc 'set -e; MATCHES=\"$(rg -n --hidden --glob !**/.git/** -S \"npm (ci|install|update)\" ./Makefile ./package.json 2>/dev/null || true)\"; if [ -z \"$MATCHES\" ]; then echo \"No npm install/update/ci commands found in frontend/\"; exit 0; fi; VIOLATIONS=\"$(printf %s \"$MATCHES\" | rg -v -e \"ignore-scripts\" -e \"install .* -g npm\" -e \"update .* -g npm\" -e \":[0-9]+:\\s*#\" -e \"install-npm\" || true)\"; if [ -n \"$VIOLATIONS\" ]; then echo \"ERROR: npm install/update/ci without --ignore-scripts (exceptions excluded)\"; printf %s\\n \"$VIOLATIONS\"; exit 1; fi; echo \"OK: All frontend installs/updates use --ignore-scripts or are allowed exceptions.\"'",
"security:scan-xss": "sh -lc 'set -e; if rg -n --glob \"src/**\" -S \"v-html=\\\"\" src >/dev/null; then echo \"ERROR: v-html usage detected; prefer v-sanitize or $util.sanitizeHtml()\"; rg -n --glob \"src/**\" -S \"v-html=\\\"\" src; exit 1; else echo \"OK: No v-html usage detected.\"; fi'",
"watch": "webpack --watch"
"watch": "cross-env BUILD_ENV=development NODE_ENV=production webpack --watch"
},
"browserslist": [
">0.25% and last 2 years"
@ -39,20 +39,20 @@
"@babel/preset-env": "^7.28.5",
"@babel/register": "^7.28.3",
"@babel/runtime": "^7.28.4",
"@eslint/eslintrc": "^3.3.1",
"@eslint/eslintrc": "^3.3.3",
"@eslint/js": "^9.33.0",
"@mdi/font": "^7.4.47",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
"@vitejs/plugin-react": "^5.1.1",
"@vitejs/plugin-vue": "^6.0.1",
"@vitejs/plugin-vue": "^6.0.2",
"@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.3",
"@vue/language-server": "^3.1.5",
"@vue/test-utils": "^2.4.6",
"@vvo/tzdb": "^6.193.0",
"@vvo/tzdb": "^6.197.0",
"axios": "^1.13.2",
"axios-mock-adapter": "^2.1.0",
"babel-loader": "^10.0.0",
@ -60,7 +60,7 @@
"babel-plugin-polyfill-corejs3": "^0.13.0",
"browserslist": "^4.28.0",
"cheerio": "1.0.0-rc.12",
"core-js": "^3.46.0",
"core-js": "^3.47.0",
"cross-env": "^7.0.3",
"css-loader": "^7.1.2",
"cssnano": "^7.1.2",
@ -72,7 +72,7 @@
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-prettier": "^5.5.4",
"eslint-plugin-vue": "^10.5.1",
"eslint-plugin-vue": "^10.6.2",
"eslint-plugin-vuetify": "^2.5.3",
"eslint-webpack-plugin": "^5.0.2",
"eventsource-polyfill": "^0.9.6",
@ -80,30 +80,30 @@
"file-saver": "^2.0.5",
"floating-vue": "^5.2.2",
"globals": "^16.5.0",
"hls.js": "^1.6.14",
"hls.js": "^1.6.15",
"i": "^0.3.7",
"jsdom": "^26.1.0",
"luxon": "^3.7.2",
"maplibre-gl": "^5.12.0",
"maplibre-gl": "^5.13.0",
"memoize-one": "^6.0.0",
"mini-css-extract-plugin": "^2.9.4",
"minimist": "^1.2.8",
"node-storage-shim": "^2.0.1",
"passive-events-support": "^1.1.0",
"photoswipe": "^5.4.4",
"playwright": "^1.56.1",
"playwright": "^1.57.0",
"postcss": "^8.5.6",
"postcss-import": "^16.1.1",
"postcss-loader": "^8.2.0",
"postcss-preset-env": "^10.4.0",
"postcss-reporter": "^7.1.0",
"postcss-url": "^10.1.3",
"prettier": "^3.6.2",
"prettier": "^3.7.2",
"pubsub-js": "^1.9.5",
"regenerator-runtime": "^0.14.1",
"resolve-url-loader": "^5.0.0",
"sanitize-html": "^2.17.0",
"sass": "^1.94.0",
"sass": "^1.94.2",
"sass-loader": "^16.0.6",
"sockette": "^2.0.6",
"style-loader": "^4.0.0",
@ -122,8 +122,8 @@
"vue-sanitize-directive": "^0.2.1",
"vue-style-loader": "^4.1.3",
"vue3-gettext": "^2.4.0",
"vuetify": "^3.10.10",
"webpack": "^5.102.1",
"vuetify": "^3.11.0",
"webpack": "^5.103.0",
"webpack-bundle-analyzer": "^4.10.2",
"webpack-cli": "^6.0.1",
"webpack-hot-middleware": "^2.26.1",
@ -131,7 +131,7 @@
"webpack-md5-hash": "^0.0.6",
"webpack-merge": "^6.0.1",
"webpack-plugin-vuetify": "^3.1.2",
"workbox-webpack-plugin": "^7.3.0"
"workbox-webpack-plugin": "^7.4.0"
},
"engines": {
"node": ">= 18.0.0",

View file

@ -12,9 +12,7 @@ export function processAlbumSelection(selectedAlbums, availableAlbums) {
selectedAlbums.forEach((item) => {
// If it's a string, try to match it with existing albums
if (typeof item === "string" && item.trim().length > 0) {
const matchedAlbum = availableAlbums.find(
(album) => album.Title && album.Title.toLowerCase() === item.trim().toLowerCase()
);
const matchedAlbum = availableAlbums.find((album) => album.Title && album.Title.toLowerCase() === item.trim().toLowerCase());
if (matchedAlbum && !seenUids.has(matchedAlbum.UID)) {
// Replace string with actual album object
@ -37,7 +35,7 @@ export function processAlbumSelection(selectedAlbums, availableAlbums) {
return {
processed,
changed: changed || processed.length !== selectedAlbums.length
changed: changed || processed.length !== selectedAlbums.length,
};
}
@ -52,9 +50,9 @@ export function createAlbumSelectionWatcher(albumsProperty) {
this.$nextTick(() => {
this.selectedAlbums = processed;
}).catch((error) => {
console.error('Error updating selectedAlbums:', error);
console.error("Error updating selectedAlbums:", error);
});
}
}
},
};
}

View file

@ -125,13 +125,12 @@ export class Clipboard {
const id = model.getId();
this.addId(id);
return this.addId(id);
}
addId(id) {
this.updateDom(id, true);
if (this.hasId(id)) {
this.lastId = id;
return true;
}
@ -145,6 +144,7 @@ export class Clipboard {
this.lastId = id;
this.saveToStorage();
this.updateDom(id, true);
return true;
}
@ -170,12 +170,19 @@ export class Clipboard {
rangeEnd = newEnd;
}
let added = 0;
for (let i = rangeStart; i <= rangeEnd; i++) {
this.add(models[i], false);
this.updateDom(models[i].getId(), true);
const result = this.add(models[i]);
if (!result) {
break;
}
added++;
}
return rangeEnd - rangeStart + 1;
return added;
}
has(model) {

View file

@ -241,12 +241,7 @@ export default class Config {
.filter((m) => m.UID === values.UID)
.forEach((m) => {
for (let key in values) {
if (
key !== "UID" &&
values.hasOwnProperty(key) &&
values[key] != null &&
typeof values[key] !== "object"
) {
if (key !== "UID" && values.hasOwnProperty(key) && values[key] != null && typeof values[key] !== "object") {
m[key] = values[key];
}
}

View file

@ -102,11 +102,7 @@ export class Form {
// getOptions resolves the options array for select-style fields.
getOptions(fieldName) {
if (
this.definition &&
this.definition.hasOwnProperty(fieldName) &&
this.definition[fieldName].hasOwnProperty("options")
) {
if (this.definition && this.definition.hasOwnProperty(fieldName) && this.definition[fieldName].hasOwnProperty("options")) {
return this.definition[fieldName].options;
}
@ -209,9 +205,7 @@ export class rules {
return false;
}
return /^[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+@[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*$/.test(
v
);
return /^[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+@[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*$/.test(v);
}
// isUrl validates strings by length and URL parsing.
@ -260,10 +254,7 @@ export class rules {
// email returns Vuetify rule callbacks for email validation.
static email(required) {
if (required) {
return [
(v) => !!v || $gettext("This field is required"),
(v) => !v || this.isEmail(v) || $gettext("Invalid address"),
];
return [(v) => !!v || $gettext("This field is required"), (v) => !v || this.isEmail(v) || $gettext("Invalid address")];
} else {
return [(v) => !v || this.isEmail(v) || $gettext("Invalid address")];
}
@ -352,20 +343,14 @@ export class rules {
(v) => this.maxLen(v, 2) || $gettext("Invalid country"),
];
} else {
return [
(v) => this.minLen(v, 2) || $gettext("Invalid country"),
(v) => this.maxLen(v, 2) || $gettext("Invalid country"),
];
return [(v) => this.minLen(v, 2) || $gettext("Invalid country"), (v) => this.maxLen(v, 2) || $gettext("Invalid country")];
}
}
// day validates day-of-month values between 1 and 31.
static day(required) {
if (required) {
return [
(v) => !!v || Number(v) < -1 || $gettext("This field is required"),
(v) => this.isNumberRange(v, 1, 31) || $gettext("Invalid"),
];
return [(v) => !!v || Number(v) < -1 || $gettext("This field is required"), (v) => this.isNumberRange(v, 1, 31) || $gettext("Invalid")];
} else {
return [(v) => this.isNumberRange(v, 1, 31) || $gettext("Invalid")];
}
@ -374,10 +359,7 @@ export class rules {
// month validates month values between 1 and 12.
static month(required) {
if (required) {
return [
(v) => !!v || Number(v) < -1 || $gettext("This field is required"),
(v) => this.isNumberRange(v, 1, 12) || $gettext("Invalid"),
];
return [(v) => !!v || Number(v) < -1 || $gettext("This field is required"), (v) => this.isNumberRange(v, 1, 12) || $gettext("Invalid")];
} else {
return [(v) => this.isNumberRange(v, 1, 12) || $gettext("Invalid")];
}
@ -394,10 +376,7 @@ export class rules {
}
if (required) {
return [
(v) => !!v || Number(v) < -1 || $gettext("This field is required"),
(v) => this.isNumberRange(v, min, max) || $gettext("Invalid"),
];
return [(v) => !!v || Number(v) < -1 || $gettext("This field is required"), (v) => this.isNumberRange(v, min, max) || $gettext("Invalid")];
} else {
return [(v) => this.isNumberRange(v, min, max) || $gettext("Invalid")];
}

View file

@ -1,14 +1,31 @@
import { createGettext as vue3Gettext } from "vue3-gettext";
function interpolate(message, params = {}) {
if (message === null || message === undefined) {
return "";
}
const text = String(message);
if (!params || typeof params !== "object") {
return text;
}
return text.replace(/%\{(\w+)\}/g, (_, key) => {
if (!Object.prototype.hasOwnProperty.call(params, key)) {
return "";
}
const value = params[key];
return value === undefined || value === null ? "" : String(value);
});
}
export let gettext = {
$gettext: (msgid) => msgid,
$ngettext: (msgid, plural, n) => {
return n > 1 ? plural : msgid;
},
$pgettext: (context, msgid) => msgid,
$npgettext: (domain, context, msgid, plural, n) => {
return n > 1 ? plural : msgid;
},
$gettext: (msgid, params) => interpolate(msgid, params),
$ngettext: (msgid, plural, n, params) => interpolate(n > 1 ? plural : msgid, params),
$pgettext: (context, msgid, params) => interpolate(msgid, params),
$npgettext: (domain, context, msgid, plural, n, params) => interpolate(n > 1 ? plural : msgid, params),
};
export function T(msgid, params) {

View file

@ -75,10 +75,7 @@ export class Input {
return InputInvalid;
}
if (
Math.abs(this.touches[0].screenX - ev.changedTouches[0].screenX) > 4 ||
Math.abs(this.touches[0].screenY - ev.changedTouches[0].screenY) > 4
) {
if (Math.abs(this.touches[0].screenX - ev.changedTouches[0].screenX) > 4 || Math.abs(this.touches[0].screenY - ev.changedTouches[0].screenY) > 4) {
return InputInvalid;
}
}

View file

@ -15,13 +15,7 @@ const langFallbackDecorate = function (style, cfg) {
for (let i = layers.length - 1; i >= 0; i--) {
let layer = layers[i];
if (
!(
lf[0] === "in" &&
lfProp === "layout.text-field" &&
layer.layout &&
layer.layout["text-field"] &&
lfValues.indexOf(layer.layout["text-field"]) >= 0
)
!(lf[0] === "in" && lfProp === "layout.text-field" && layer.layout && layer.layout["text-field"] && lfValues.indexOf(layer.layout["text-field"]) >= 0)
) {
continue;
}
@ -95,27 +89,7 @@ maplibregl.Map.prototype.setLanguage = function (language, noAlt) {
return;
}
let isNonlatin =
[
"ar",
"hy",
"be",
"bg",
"zh",
"ka",
"el",
"he",
"ja",
"ja_kana",
"kn",
"kk",
"ko",
"mk",
"ru",
"sr",
"th",
"uk",
].indexOf(language) >= 0;
let isNonlatin = ["ar", "hy", "be", "bg", "zh", "ka", "el", "he", "ja", "ja_kana", "kn", "kk", "ko", "mk", "ru", "sr", "th", "uk"].indexOf(language) >= 0;
let style = JSON.parse(JSON.stringify(this.styleUndecorated));
let langCfg = {
@ -131,15 +105,12 @@ maplibregl.Map.prototype.setLanguage = function (language, noAlt) {
],
"decorators": [
{
"layout.text-field": isNonlatin
? "{name:nonlatin}" + (noAlt ? "" : "\n{name:latin}")
: "{name:latin}" + (noAlt ? "" : "\n{name:nonlatin}"),
"layout.text-field": isNonlatin ? "{name:nonlatin}" + (noAlt ? "" : "\n{name:latin}") : "{name:latin}" + (noAlt ? "" : "\n{name:nonlatin}"),
"filter-all-part": ["!has", "name:" + language],
},
{
"layer-name-postfix": language,
"layout.text-field":
"{name:" + language + "}" + (noAlt ? "" : "\n{name:" + (isNonlatin ? "latin" : "nonlatin") + "}"),
"layout.text-field": "{name:" + language + "}" + (noAlt ? "" : "\n{name:" + (isNonlatin ? "latin" : "nonlatin") + "}"),
"filter-all-part": ["has", "name:" + language],
},
],

View file

@ -89,10 +89,7 @@ export default class Session {
}
// Restore authentication from session storage.
if (
this.applyAuthToken(this.storage.getItem(this.storageKey + ".token")) &&
this.applyId(this.storage.getItem(this.storageKey + ".id"))
) {
if (this.applyAuthToken(this.storage.getItem(this.storageKey + ".token")) && this.applyId(this.storage.getItem(this.storageKey + ".id"))) {
const dataJson = this.storage.getItem(this.storageKey + ".data");
if (dataJson && dataJson !== "undefined") {
this.data = JSON.parse(dataJson);

View file

@ -49,6 +49,26 @@ export const tokenLength = 7;
const debug = window.__CONFIG__?.debug || window.__CONFIG__?.trace;
export default class $util {
static normalizeLabelTitle(s) {
if (s === null || s === undefined) return "";
return String(s)
.toLowerCase()
.replace(/&/g, "and")
.replace(/[+_\-]+/g, " ")
.replace(/[^a-z0-9 ]+/g, "")
.replace(/\s+/g, " ")
.trim();
}
static slugifyLabelTitle(s) {
if (s === null || s === undefined) return "";
return String(s)
.toLowerCase()
.replace(/&/g, "and")
.replace(/[^a-z0-9]+/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
}
// formatBytes returns a human-readable size string for a byte count.
static formatBytes(b) {
if (!b) {
@ -333,12 +353,7 @@ export default class $util {
}
// Escape HTML control characters.
text = text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
text = text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
// Make URLs clickable.
text = text.replace(linkRegex, linkFunc);

View file

@ -521,10 +521,7 @@ export class View {
const scope = this.scopes.map((s) => `${s?.$options?.name} #${s?.$?.uid.toString()}`).join(" ");
// To make them easy to recognize, the collapsed view logs are displayed
// in the browser console with bold white text on a purple background.
console.groupCollapsed(
`%c${scope}`,
"background: #502A85; color: white; padding: 3px 5px; border-radius: 8px; font-weight: bold;"
);
console.groupCollapsed(`%c${scope}`, "background: #502A85; color: white; padding: 3px 5px; border-radius: 8px; font-weight: bold;");
console.log("data:", toRaw(c?.$data));
}
@ -744,11 +741,7 @@ export class View {
ev.preventDefault();
const target =
(fallback && fallback.isConnected && root.contains(fallback) && fallback) ||
resolveFocusTarget(root) ||
findFocusElement(component) ||
root;
const target = (fallback && fallback.isConnected && root.contains(fallback) && fallback) || resolveFocusTarget(root) || findFocusElement(component) || root;
if (!target) {
return;
@ -1019,10 +1012,7 @@ export class View {
const current = typeof this.navigation.currentPosition === "number" ? this.navigation.currentPosition : nextPos;
if (
this.navigation.direction !== NavigationDirection.Back &&
this.navigation.direction !== NavigationDirection.Forward
) {
if (this.navigation.direction !== NavigationDirection.Back && this.navigation.direction !== NavigationDirection.Forward) {
if (nextPos < current) {
this.navigation.direction = NavigationDirection.Back;
} else if (nextPos > current) {

View file

@ -6,26 +6,14 @@
</strong>
<span :title="version" class="body-link text-selectable">
<span class="cursor-copy" @click.stop.prevent="$util.copyText(about, version)">Build</span>
<a
href="https://docs.photoprism.app/release-notes/"
target="_blank"
rel="noopener"
class="body-link text-truncate"
>{{ build }}</a
>
<a href="https://docs.photoprism.app/release-notes/" target="_blank" rel="noopener" class="body-link text-truncate">{{ build }}</a>
</span>
</div>
<div class="hidden-xs text-sm-end">
<a
href="https://raw.githubusercontent.com/photoprism/photoprism/develop/NOTICE"
target="_blank"
rel="noopener"
class="text-link"
<a href="https://raw.githubusercontent.com/photoprism/photoprism/develop/NOTICE" target="_blank" rel="noopener" class="text-link"
>3rd-party software packages</a
>
<a href="https://www.photoprism.app/about/team/" target="_blank" rel="noopener" class="body-link"
>© 2018-2025 PhotoPrism UG</a
>
<a href="https://www.photoprism.app/about/team/" target="_blank" rel="noopener" class="body-link">© 2018-2025 PhotoPrism UG</a>
</div>
</footer>
</template>

View file

@ -1,20 +1,8 @@
<template>
<div class="p-action-menu">
<v-menu
:model-value="visible"
:open-on-hover="openOnHover"
class="action-menu action-menu--default"
@update:model-value="onMenu"
>
<v-menu :model-value="visible" :open-on-hover="openOnHover" class="action-menu action-menu--default" @update:model-value="onMenu">
<template #activator="{ props }">
<v-btn
v-bind="props"
density="comfortable"
:icon="buttonIcon"
:tabindex="tabindex"
class="action-menu__btn"
:class="buttonClass"
></v-btn>
<v-btn v-bind="props" density="comfortable" :icon="buttonIcon" :tabindex="tabindex" class="action-menu__btn" :class="buttonClass"></v-btn>
</template>
<v-list slim nav density="compact" bg-color="navigation" class="action-menu__list" :class="listClass">

View file

@ -13,15 +13,7 @@
offset="12"
>
<template #activator="{ props }">
<v-btn
v-bind="props"
icon
size="52"
color="highlight"
variant="elevated"
density="comfortable"
class="action-menu opacity-95 ma-5"
>
<v-btn v-bind="props" icon size="52" color="highlight" variant="elevated" density="comfortable" class="action-menu opacity-95 ma-5">
<span class="count-clipboard">{{ selection.length }}</span>
</v-btn>
</template>
@ -81,26 +73,11 @@
class="action-delete"
@click.stop="dialog.delete = true"
></v-btn>
<v-btn
key="action-close"
icon="mdi-close"
color="grey-darken-2"
density="comfortable"
class="action-clear"
@click.stop="clearClipboard()"
></v-btn>
<v-btn key="action-close" icon="mdi-close" color="grey-darken-2" density="comfortable" class="action-clear" @click.stop="clearClipboard()"></v-btn>
</v-speed-dial>
</div>
<p-photo-album-dialog
:visible="dialog.album"
@close="dialog.album = false"
@confirm="cloneAlbums"
></p-photo-album-dialog>
<p-album-delete-dialog
:visible="dialog.delete"
@close="dialog.delete = false"
@confirm="batchDelete"
></p-album-delete-dialog>
<p-photo-album-dialog :visible="dialog.album" @close="dialog.album = false" @confirm="cloneAlbums"></p-photo-album-dialog>
<p-album-delete-dialog :visible="dialog.delete" @close="dialog.delete = false" @confirm="batchDelete"></p-album-delete-dialog>
</div>
</template>
<script>
@ -143,8 +120,7 @@ export default {
return {
canDelete: this.$config.allow("albums", "delete"),
canDownload:
this.$config.allow("albums", "download") && features.download && !settings?.albums?.download?.disabled,
canDownload: this.$config.allow("albums", "download") && features.download && !settings?.albums?.download?.disabled,
canShare: this.$config.allow("albums", "share") && features.share,
canManage: this.$config.allow("albums", "manage"),
deletable: ["album", "moment", "state"],

View file

@ -10,13 +10,7 @@
@after-enter="afterEnter"
@after-leave="afterLeave"
>
<v-form
ref="form"
validate-on="invalid-input"
class="form-album-edit"
accept-charset="UTF-8"
@submit.prevent="confirm"
>
<v-form ref="form" validate-on="invalid-input" class="form-album-edit" accept-charset="UTF-8" @submit.prevent="confirm">
<v-card>
<v-card-title class="d-flex justify-start align-center ga-3">
<v-icon size="28" color="primary">mdi-bookmark</v-icon>
@ -40,13 +34,7 @@
></v-text-field>
</v-col>
<v-col cols="12">
<v-text-field
v-model="model.Location"
hide-details
:label="$gettext('Location')"
:disabled="disabled"
class="input-location"
></v-text-field>
<v-text-field v-model="model.Location" hide-details :label="$gettext('Location')" :disabled="disabled" class="input-location"></v-text-field>
</v-col>
<v-col cols="12">
<v-textarea
@ -85,22 +73,10 @@
></v-select>
</v-col>
<v-col sm="3">
<v-checkbox
v-model="model.Favorite"
:disabled="disabled"
:label="$gettext('Favorite')"
density="comfortable"
hide-details
></v-checkbox>
<v-checkbox v-model="model.Favorite" :disabled="disabled" :label="$gettext('Favorite')" density="comfortable" hide-details></v-checkbox>
</v-col>
<v-col v-if="experimental && featPrivate" sm="3">
<v-checkbox
v-model="model.Private"
:disabled="disabled"
:label="$gettext('Private')"
density="comfortable"
hide-details
></v-checkbox>
<v-checkbox v-model="model.Private" :disabled="disabled" :label="$gettext('Private')" density="comfortable" hide-details></v-checkbox>
</v-col>
</v-row>
</v-card-text>

View file

@ -7,12 +7,7 @@
accept-charset="UTF-8"
@submit.prevent="updateQuery()"
>
<v-toolbar
flat
:density="$vuetify.display.smAndDown ? 'compact' : 'default'"
class="page-toolbar"
color="secondary"
>
<v-toolbar flat :density="$vuetify.display.smAndDown ? 'compact' : 'default'" class="page-toolbar" color="secondary">
<v-toolbar-title :title="album.Title" class="page__title">
<router-link :to="{ name: collectionRoute }" class="hidden-xs">
{{ T(collectionTitle) }}
@ -36,13 +31,7 @@
class="ms-1"
>
<v-btn value="cards" icon="mdi-view-column" class="ps-1 action-view-cards" @click="setView('cards')"></v-btn>
<v-btn
v-if="listView"
value="list"
icon="mdi-view-list"
class="action-view-list"
@click="setView('list')"
></v-btn>
<v-btn v-if="listView" value="list" icon="mdi-view-list" class="action-view-list" @click="setView('list')"></v-btn>
<v-btn value="mosaic" icon="mdi-view-comfy" class="pe-1 action-view-mosaic" @click="setView('mosaic')"></v-btn>
</v-btn-toggle>
@ -53,12 +42,7 @@
{{ album.Description }}
</div>
<p-share-dialog
:visible="dialog.share"
:model="album"
@upload="webdavUpload"
@close="dialog.share = false"
></p-share-dialog>
<p-share-dialog :visible="dialog.share" :model="album" @upload="webdavUpload" @close="dialog.share = false"></p-share-dialog>
<p-service-upload
:visible="dialog.upload"
:items="{ albums: album.getId() }"
@ -124,8 +108,7 @@ export default {
return {
expanded: false,
canUpload: this.$config.allow("files", "upload") && features.upload,
canDownload:
this.$config.allow("albums", "download") && features.download && !settings?.albums?.download?.disabled,
canDownload: this.$config.allow("albums", "download") && features.download && !settings?.albums?.download?.disabled,
canShare: this.$config.allow("albums", "share") && features.share,
canManage: this.$config.allow("albums", "manage"),
canDelete: this.$config.allow("albums", "delete"),

View file

@ -10,6 +10,9 @@ import PSidebarInfo from "component/sidebar/info.vue";
import PMap from "component/map.vue";
import PLightbox from "component/lightbox.vue";
// Inputs.
import PInputChipSelector from "component/input/chip-selector.vue";
// Icons.
import IconLivePhoto from "component/icon/live-photo.vue";
import IconSponsor from "component/icon/sponsor.vue";
@ -79,13 +82,15 @@ export function install(app) {
app.component("PNotify", PNotify);
app.component("PScroll", PScroll);
app.component("PNavigation", PNavigation);
app.component("PUpdate", PUpdate);
app.component("PLoading", PLoading);
app.component("PLoadingBar", PLoadingBar);
app.component("PLightboxMenu", PLightboxMenu);
app.component("PSidebarInfo", PSidebarInfo);
app.component("PMap", PMap);
app.component("PLightbox", PLightbox);
app.component("PUpdate", PUpdate);
app.component("PInputChipSelector", PInputChipSelector);
app.component("IconLivePhoto", IconLivePhoto);
app.component("IconSponsor", IconSponsor);

View file

@ -15,9 +15,7 @@
<h6 class="text-h6">{{ $gettext(`Support Our Mission`) }}</h6>
</v-card-title>
<v-card-text class="text-subtitle-2">{{
$gettext(
`Your continued support helps us provide regular updates and remain independent, so we can fulfill our mission and protect your privacy.`
)
$gettext(`Your continued support helps us provide regular updates and remain independent, so we can fulfill our mission and protect your privacy.`)
}}</v-card-text>
<v-card-text class="text-body-2">{{
$gettext(

View file

@ -156,7 +156,6 @@ export default {
color: "on-surface",
bgColor: "secondary",
baseColor: "secondary",
sliderColor: "surface-variant",
},
VTable: {
density: "comfortable",

View file

@ -8,12 +8,8 @@
:tab="edit.tab"
@close="closeEditDialog"
></p-photo-edit-dialog>
<p-upload-dialog
:visible="upload.visible"
:data="upload.data"
@close="closeUploadDialog"
@confirm="closeUploadDialog"
></p-upload-dialog>
<p-photo-batch-edit :visible="batchEdit.visible" :selection="batchEdit.selection" @close="closeBatchEdit"></p-photo-batch-edit>
<p-upload-dialog :visible="upload.visible" :data="upload.data" @close="closeUploadDialog" @confirm="closeUploadDialog"></p-upload-dialog>
<p-update :visible="update.visible" @close="closeUpdateDialog"></p-update>
<p-lightbox @enter="onLightboxEnter" @leave="onLightboxLeave"></p-lightbox>
</div>
@ -22,6 +18,7 @@
import Album from "model/album";
import PPhotoEditDialog from "component/photo/edit/dialog.vue";
import PPhotoBatchEdit from "component/photo/batch-edit.vue";
import PUploadDialog from "component/upload/dialog.vue";
import PUpdate from "component/update.vue";
import PLightbox from "component/lightbox.vue";
@ -30,6 +27,7 @@ export default {
name: "PDialogs",
components: {
PPhotoEditDialog,
PPhotoBatchEdit,
PUploadDialog,
PUpdate,
PLightbox,
@ -43,6 +41,10 @@ export default {
index: 0,
tab: "",
},
batchEdit: {
visible: false,
selection: [],
},
upload: {
visible: false,
data: {},
@ -57,13 +59,20 @@ export default {
};
},
created() {
// Opens the photo edit dialog.
// Opens the photo edit dialog (when 1 image is selected).
this.subscriptions.push(
this.$event.subscribe("dialog.edit", (ev, data) => {
this.onEdit(data);
})
);
// Opens the photo edit dialog (when more than 1 image are selected).
this.subscriptions.push(
this.$event.subscribe("dialog.batchedit", (ev, data) => {
this.onBatchEdit(data);
})
);
// Opens the web upload dialog.
this.subscriptions.push(
this.$event.subscribe("dialog.upload", (ev, data) => {
@ -106,6 +115,19 @@ export default {
this.edit.visible = false;
}
},
onBatchEdit(data) {
if (this.batchEdit.visible || !this.hasAuth()) {
return;
}
this.batchEdit.selection = data.selection;
this.batchEdit.visible = true;
},
closeBatchEdit() {
if (this.batchEdit.visible) {
this.batchEdit.visible = false;
}
},
onUpload(data) {
if (this.upload.visible || !this.hasAuth() || this.isReadOnly() || !this.$config.feature("upload")) {
return;

View file

@ -13,15 +13,7 @@
offset="12"
>
<template #activator="{ props }">
<v-btn
v-bind="props"
icon
size="52"
color="highlight"
variant="elevated"
density="comfortable"
class="action-menu opacity-95 ma-5"
>
<v-btn v-bind="props" icon size="52" color="highlight" variant="elevated" density="comfortable" class="action-menu opacity-95 ma-5">
<span class="count-clipboard">{{ selection.length }}</span>
</v-btn>
</template>
@ -61,11 +53,7 @@
></v-btn>
</v-speed-dial>
</div>
<p-photo-album-dialog
:visible="dialog.album"
@close="dialog.album = false"
@confirm="addToAlbum"
></p-photo-album-dialog>
<p-photo-album-dialog :visible="dialog.album" @close="dialog.album = false" @confirm="addToAlbum"></p-photo-album-dialog>
</div>
</template>
<script>

View file

@ -1,12 +1,5 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
enable-background="new 0 0 24 24"
height="24px"
viewBox="0 0 24 24"
width="24px"
fill="currentColor"
>
<svg xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 24 24" height="24px" viewBox="0 0 24 24" width="24px" fill="currentColor">
<rect fill="none" height="24" width="24" />
<path
d="M9.68,13.69L12,11.93l2.31,1.76l-0.88-2.85L15.75,9h-2.84L12,6.19L11.09,9H8.25l2.31,1.84L9.68,13.69z M20,10 c0-4.42-3.58-8-8-8s-8,3.58-8,8c0,2.03,0.76,3.87,2,5.28V23l6-2l6,2v-7.72C19.24,13.87,20,12.03,20,10z M12,4c3.31,0,6,2.69,6,6 s-2.69,6-6,6s-6-2.69-6-6S8.69,4,12,4z"

View file

@ -0,0 +1,383 @@
<template>
<div class="p-input-chip-selector chip-selector">
<div v-if="shouldRenderChips" class="chip-selector__chips">
<v-tooltip
v-for="item in processedItems"
:key="item.value || item.title"
:text="getChipTooltip(item)"
location="top"
:disabled="$vuetify?.display?.mobile === true"
>
<template #activator="{ props }">
<div
v-bind="props"
:class="getChipClasses(item)"
:aria-pressed="item.selected"
:tabindex="0"
role="button"
@click="handleChipClick(item)"
@keydown.enter="handleChipClick(item)"
@keydown.space.prevent="handleChipClick(item)"
>
<div class="chip__content">
<v-icon v-if="getChipIcon(item)" class="chip__icon">
{{ getChipIcon(item) }}
</v-icon>
<span class="chip__text">{{ item.title }}</span>
</div>
</div>
</template>
</v-tooltip>
<div v-if="processedItems.length === 0 && !showInput" class="chip-selector__empty">
{{ emptyText }}
</div>
</div>
<div v-if="allowCreate" class="chip-selector__input-container">
<v-combobox
ref="inputField"
v-model="newItemTitle"
v-model:menu="menuOpen"
:placeholder="computedInputPlaceholder"
:persistent-placeholder="true"
:items="availableItems"
item-title="title"
item-value="value"
density="comfortable"
hide-details
hide-no-data
return-object
class="chip-selector__input"
@click:control="focusInput"
@keydown.enter.prevent="onEnter"
@blur="addNewItem"
@update:model-value="onComboboxChange"
@update:menu="onMenuUpdate"
>
<template #no-data>
<v-list-item>
<v-list-item-title>
{{ $gettext("Press enter to create new item") }}
</v-list-item-title>
</v-list-item>
</template>
</v-combobox>
</div>
</div>
</template>
<script>
export default {
name: "PInputChipSelector",
props: {
items: {
type: Array,
default: () => [],
},
normalizeTitleForCompare: {
type: Function,
default: null,
},
availableItems: {
type: Array,
default: () => [],
},
allowCreate: {
type: Boolean,
default: true,
},
emptyText: {
type: String,
default: "",
},
inputPlaceholder: {
type: String,
default: "",
},
loading: {
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
resolveItemFromText: {
type: Function,
default: null,
},
},
emits: ["update:items"],
data() {
return {
newItemTitle: null,
menuOpen: false,
suppressMenuOpen: false,
};
},
computed: {
processedItems() {
return this.items.map((item) => ({
...item,
// Ensure action is always a string, never null/undefined
action: item.action || "none",
selected: item.action === "add" || item.action === "remove",
}));
},
computedInputPlaceholder() {
return this.inputPlaceholder || this.$gettext("Enter item name...");
},
showInput() {
return this.allowCreate;
},
shouldRenderChips() {
// Render chips container only when there are chips
return this.processedItems.length > 0 || !this.showInput;
},
},
methods: {
normalizeTitle(text) {
const input = text == null ? "" : String(text);
if (typeof this.normalizeTitleForCompare === "function") {
try {
return this.normalizeTitleForCompare(input);
} catch (e) {
return input.toLowerCase();
}
}
return input.toLowerCase();
},
getChipClasses(item) {
const baseClass = "chip";
const classes = [baseClass];
if (this.loading || this.disabled) {
classes.push(`${baseClass}--loading`);
}
if (item.action === "add") {
classes.push(item.mixed ? `${baseClass}--green-light` : `${baseClass}--green`);
} else if (item.action === "remove") {
classes.push(item.mixed ? `${baseClass}--red-light` : `${baseClass}--red`);
} else if (item.mixed) {
classes.push(`${baseClass}--gray-light`);
} else {
classes.push(`${baseClass}--gray`);
}
return classes;
},
getChipIcon(item) {
if (item.action === "add") return "mdi-plus";
if (item.action === "remove") return "mdi-minus";
if (item.mixed) return "mdi-circle-half-full";
return null;
},
getChipTooltip(item) {
if (item.action === "add") {
return item.mixed ? this.$gettext("Add to all selected photos") : this.$gettext("Add to all");
} else if (item.action === "remove") {
return item.mixed ? this.$gettext("Remove from all selected photos") : this.$gettext("Remove from all");
} else if (item.mixed) {
return this.$gettext("Part of some selected photos");
}
return this.$gettext("Part of all selected photos");
},
handleChipClick(item) {
if (this.loading || this.disabled) return;
let newAction;
if (item.mixed) {
// Handle mixed state cycling
switch (item.action) {
case "none":
newAction = "add";
break;
case "add":
newAction = "remove";
break;
case "remove":
newAction = "none";
break;
}
} else {
// Handle normal state cycling
if (item.isNew) {
newAction = item.action === "add" ? "remove" : "add";
} else {
newAction = item.action === "remove" ? "none" : "remove";
}
}
this.updateItemAction(item, newAction);
},
updateItemAction(itemToUpdate, action) {
// Special case: remove new items completely
if (itemToUpdate.isNew && action === "remove") {
const updatedItems = this.items.filter((item) => (item.value || item.title) !== (itemToUpdate.value || itemToUpdate.title));
this.$emit("update:items", updatedItems);
return;
}
// Update action for existing item
const updatedItems = this.items.map((item) => ((item.value || item.title) === (itemToUpdate.value || itemToUpdate.title) ? { ...item, action } : item));
this.$emit("update:items", updatedItems);
},
onComboboxChange(value) {
if (value && typeof value === "object" && value.title) {
this.newItemTitle = value;
this.temporarilyCloseMenu();
this.addNewItem();
} else {
this.newItemTitle = value;
}
},
addNewItem() {
// Extract title and value from input
let title, value;
if (typeof this.newItemTitle === "string") {
title = this.newItemTitle.trim();
value = "";
} else if (this.newItemTitle && typeof this.newItemTitle === "object") {
title = this.newItemTitle.title;
value = this.newItemTitle.value;
} else {
return;
}
if (!title) return;
let resolvedApplied = false;
if (typeof this.resolveItemFromText === "function") {
const resolved = this.resolveItemFromText(title);
if (resolved && typeof resolved === "object") {
if (resolved.title) title = resolved.title;
if (resolved.value) value = resolved.value;
resolvedApplied = true;
}
}
const normalizedTitle = this.normalizeTitle(title);
const existingItem = this.items.find((item) => (item.value && value && item.value === value) || this.normalizeTitle(item.title) === normalizedTitle);
if (existingItem) {
let changed = false;
const updatedItems = this.items.map((item) => {
const isSame = (item.value && value && item.value === value) || this.normalizeTitle(item.title) === normalizedTitle;
if (!isSame) {
return item;
}
const next = { ...item };
let itemChanged = false;
if (resolvedApplied) {
if (value && item.value !== value) {
next.value = value;
itemChanged = true;
}
if (title && item.title !== title) {
next.title = title;
itemChanged = true;
}
}
if (item.mixed && item.action !== "add") {
next.action = "add";
next.mixed = false;
itemChanged = true;
}
if (itemChanged) {
changed = true;
return next;
}
return item;
});
if (changed) {
this.$emit("update:items", updatedItems);
}
this.resetInputField();
return;
}
const newItem = {
value: value || "",
title,
mixed: false,
action: "add",
isNew: true,
};
this.$emit("update:items", [...this.items, newItem]);
this.temporarilyCloseMenu();
this.clearAndOptionallyRefocus(true);
},
onEnter() {
this.temporarilyCloseMenu();
this.addNewItem();
},
onMenuUpdate(val) {
if (val && this.suppressMenuOpen) {
this.menuOpen = false;
return;
}
this.menuOpen = val;
},
focusInput() {
if (this.$refs.inputField && typeof this.$refs.inputField.focus === "function") {
this.$refs.inputField.focus();
}
},
temporarilyCloseMenu() {
this.menuOpen = false;
this.suppressMenuOpen = true;
window.setTimeout(() => {
this.suppressMenuOpen = false;
}, 200);
},
clearAndOptionallyRefocus(shouldRefocus = false) {
this.$nextTick(() => {
this.newItemTitle = "";
if (this.$refs.inputField) {
this.$refs.inputField.blur();
setTimeout(() => {
this.newItemTitle = null;
if (shouldRefocus) {
this.focusInput();
}
}, 10);
} else {
this.newItemTitle = null;
}
});
},
resetInputField() {
this.temporarilyCloseMenu();
this.clearAndOptionallyRefocus(false);
},
},
};
</script>
<style src="../../css/chip-selector.css"></style>

View file

@ -13,15 +13,7 @@
offset="12"
>
<template #activator="{ props }">
<v-btn
v-bind="props"
icon
size="52"
color="highlight"
variant="elevated"
density="comfortable"
class="action-menu opacity-95 ma-5"
>
<v-btn v-bind="props" icon size="52" color="highlight" variant="elevated" density="comfortable" class="action-menu opacity-95 ma-5">
<span class="count-clipboard">{{ selection.length }}</span>
</v-btn>
</template>
@ -47,26 +39,11 @@
class="action-delete"
@click.stop="dialog.delete = true"
></v-btn>
<v-btn
key="action-close"
icon="mdi-close"
color="grey-darken-2"
density="comfortable"
class="action-clear"
@click.stop="clearClipboard()"
></v-btn>
<v-btn key="action-close" icon="mdi-close" color="grey-darken-2" density="comfortable" class="action-clear" @click.stop="clearClipboard()"></v-btn>
</v-speed-dial>
</div>
<p-photo-album-dialog
:visible="dialog.album"
@close="dialog.album = false"
@confirm="addToAlbum"
></p-photo-album-dialog>
<p-label-delete-dialog
:visible="dialog.delete"
@close="dialog.delete = false"
@confirm="batchDelete"
></p-label-delete-dialog>
<p-photo-album-dialog :visible="dialog.album" @close="dialog.album = false" @confirm="addToAlbum"></p-photo-album-dialog>
<p-label-delete-dialog :visible="dialog.delete" @close="dialog.delete = false" @confirm="batchDelete"></p-label-delete-dialog>
</div>
</template>
<script>

View file

@ -11,14 +11,7 @@
@after-enter="afterEnter"
@after-leave="afterLeave"
>
<v-form
ref="form"
validate-on="invalid-input"
class="form-label-edit"
accept-charset="UTF-8"
tabindex="-1"
@submit.prevent="confirm"
>
<v-form ref="form" validate-on="invalid-input" class="form-label-edit" 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 size="28" color="primary">mdi-label</v-icon>
@ -38,14 +31,7 @@
></v-text-field>
</v-col>
<v-col sm="4">
<v-checkbox
v-model="model.Favorite"
:disabled="disabled"
:label="$gettext('Favorite')"
density="comfortable"
hide-details
>
</v-checkbox>
<v-checkbox v-model="model.Favorite" :disabled="disabled" :label="$gettext('Favorite')" density="comfortable" hide-details> </v-checkbox>
</v-col>
</v-row>
</v-card-text>

View file

@ -40,22 +40,11 @@
}"
>
<div ref="lightbox" tabindex="-1" class="p-lightbox__pswp no-transition"></div>
<div
v-show="video.controls && controlsShown !== 0"
ref="controls"
tabindex="-1"
class="p-lightbox__controls"
@click.stop.prevent
>
<div v-show="video.controls && controlsShown !== 0" ref="controls" tabindex="-1" class="p-lightbox__controls" @click.stop.prevent>
<div :title="video.error" class="video-control video-control--play">
<v-icon v-if="video.error || video.errorCode > 0" icon="mdi-alert"></v-icon>
<v-icon v-else-if="video.seeking || video.waiting" icon="mdi-loading" class="animate-loading"></v-icon>
<v-icon
v-else-if="video.playing"
icon="mdi-pause"
class="clickable"
@pointerdown.stop.prevent="toggleVideo"
></v-icon>
<v-icon v-else-if="video.playing" icon="mdi-pause" class="clickable" @pointerdown.stop.prevent="toggleVideo"></v-icon>
<v-icon v-else icon="mdi-play" class="clickable" @pointerdown.stop.prevent="toggleVideo"></v-icon>
</div>
<div class="video-control video-control--time text-body-2">
@ -80,19 +69,8 @@
{{ $util.formatRemainingSeconds(video.time, video.duration) }}
</div>
<div v-if="featExperimental && video.castable" class="video-control video-control--cast">
<v-icon
v-if="video.casting"
icon="mdi-cast-connected"
class="clickable"
@pointerdown.stop.prevent="toggleVideoRemote"
></v-icon>
<v-icon
v-else
icon="mdi-cast"
:disabled="video.remote === 'connecting'"
class="clickable"
@pointerdown.stop.prevent="toggleVideoRemote"
></v-icon>
<v-icon v-if="video.casting" icon="mdi-cast-connected" class="clickable" @pointerdown.stop.prevent="toggleVideoRemote"></v-icon>
<v-icon v-else icon="mdi-cast" :disabled="video.remote === 'connecting'" class="clickable" @pointerdown.stop.prevent="toggleVideoRemote"></v-icon>
</div>
</div>
</div>
@ -122,6 +100,7 @@ import Collection from "model/collection";
import { Photo } from "model/photo";
import { Album } from "model/album";
import * as media from "common/media";
import * as contexts from "options/contexts";
const VIDEO_EVENT_TYPES = [
"loadstart",
@ -189,10 +168,12 @@ export default {
selection: this.$clipboard.selection,
config: this.$config.values,
collection: null,
context: "",
context: contexts.Default,
model: new Thumb(), // Current slide.
models: [], // Slide models.
index: 0, // Current slide index in models.
contextAllowsEdit: true,
contextAllowsSelect: true,
subscriptions: [], // Event subscriptions.
// Video properties for rendering the controls.
video: {
@ -331,11 +312,7 @@ export default {
this.$emit("leave");
},
focusContent(ev) {
if (
this.$refs.content &&
this.$refs.content instanceof HTMLElement &&
document.activeElement !== this.$refs.content
) {
if (this.$refs.content && this.$refs.content instanceof HTMLElement && document.activeElement !== this.$refs.content) {
this.$refs.content.focus();
if (this.debug && ev) {
@ -413,12 +390,26 @@ export default {
errorMsg: this.$gettext("Error"),
};
},
// Updates lightbox permissions and capabilities (e.g., batch edit disables selecting and editing).
applyContext(ctx = {}) {
this.contextAllowsSelect = ctx?.allowSelect !== false;
this.contextAllowsEdit = ctx?.allowEdit !== false;
this.canEdit = this.$config.allow("photos", "update") && this.$config.feature("edit");
this.canLike = this.$config.allow("photos", "manage") && this.$config.feature("favorites");
this.canDownload = this.$config.allow("photos", "download") && this.$config.feature("download");
this.canArchive = this.$config.allow("photos", "delete") && this.$config.feature("archive");
this.canManageAlbums = this.$config.allow("albums", "manage");
},
// Displays the thumbnail images and/or videos that belong to the specified models in the lightbox.
showThumbs(models, index = 0, ctx = {}) {
if (this.isBusy("show thumbs")) {
return Promise.reject();
}
// Update permissions and capabilities.
this.applyContext(ctx);
// Check if at least one model was passed, as otherwise no content can be displayed.
if (!Array.isArray(models) || models.length === 0 || index >= models.length) {
this.log("model list passed to lightbox is empty:", models);
@ -447,7 +438,19 @@ export default {
return Promise.reject();
}
if (view.loading || !view.listen || view.lightbox.loading || !view.results[index]) {
if (view && typeof view.getLightboxContext === "function") {
const ctx = view.getLightboxContext(index);
if (!ctx || !Array.isArray(ctx.models) || ctx.models.length === 0) {
return Promise.reject();
}
const targetIndex = this.normalizeIndex(typeof ctx.index === "number" ? ctx.index : typeof index === "number" ? index : 0, ctx.models.length);
return this.showThumbs(ctx.models, targetIndex, ctx);
}
if (!view || view.loading || !view.listen || view.lightbox?.loading || !Array.isArray(view.results) || !view.results[index]) {
return Promise.reject();
}
@ -526,6 +529,28 @@ export default {
view.lightbox.loading = false;
});
},
// Keeps the requested slide index within the available bounds before opening the lightbox.
normalizeIndex(idx, length) {
let target = Number.isFinite(idx) ? idx : 0;
if (target < 0) {
target = 0;
}
const maxIndex = Math.max(length - 1, 0);
if (target > maxIndex) {
target = maxIndex;
}
return target;
},
shouldShowEditButton() {
return this.canEdit && this.contextAllowsEdit;
},
shouldShowSelectionToggle() {
return this.contextAllowsSelect;
},
getNumItems() {
return this.models.length;
},
@ -557,9 +582,7 @@ export default {
*/
// Check the duration so that short videos can be looped, unless a slideshow is playing.
const isShort = model?.Duration
? model.Duration > 0 && model.Duration <= this.shortVideoDuration * 1000000000
: false;
const isShort = model?.Duration ? model.Duration > 0 && model.Duration <= this.shortVideoDuration * 1000000000 : false;
// Set the slide data needed to render and play the video.
const video = {
@ -689,12 +712,7 @@ export default {
});
// Create and append video source elements, depending on file format support.
if (
format !== media.FormatAvc &&
model?.Mime &&
model.Mime !== media.ContentTypeMp4AvcMain &&
video.canPlayType(model.Mime)
) {
if (format !== media.FormatAvc && model?.Mime && model.Mime !== media.ContentTypeMp4AvcMain && video.canPlayType(model.Mime)) {
const nativeSource = document.createElement("source");
nativeSource.type = model.Mime;
nativeSource.src = this.$util.videoFormatUrl(model.Hash, format);
@ -721,9 +739,7 @@ export default {
if (this.featExperimental && video.remote && video.remote instanceof RemotePlayback) {
if (!this.video.castable) {
const cancel = () => {
video.remote
.cancelWatchAvailability?.(this.videoAvailabilityListener)
.catch(this.trace ? this.log : () => {});
video.remote.cancelWatchAvailability?.(this.videoAvailabilityListener).catch(this.trace ? this.log : () => {});
};
ctrl.signal.addEventListener("abort", cancel, { once: true });
@ -816,8 +832,7 @@ export default {
this.resetVideo();
}
let isPlaying =
video.readyState && !video.paused && !video.ended && !video.waiting && (!video.error || video.error.code === 0);
let isPlaying = video.readyState && !video.paused && !video.ended && !video.waiting && (!video.error || video.error.code === 0);
if (ev && ev.type) {
switch (ev.type) {
@ -867,20 +882,14 @@ export default {
this.video.src = video.src;
// Loop short videos of 5 seconds or less, even if the server does not know the duration.
if (
!data.loop &&
video.duration &&
video.duration <= this.shortVideoDuration &&
data.model?.Type !== media.Live
) {
if (!data.loop && video.duration && video.duration <= this.shortVideoDuration && data.model?.Type !== media.Live) {
data.loop = true;
video.loop = data.loop && !this.slideshow.active;
}
// Do not display video controls if a slideshow is running,
// or the video belongs to an animation or live photo.
this.video.controls =
!this.slideshow.active && data.model?.Type !== media.Animated && data.model?.Type !== media.Live;
this.video.controls = !this.slideshow.active && data.model?.Type !== media.Animated && data.model?.Type !== media.Live;
// Get video playback error, if any:
// https://developer.mozilla.org/de/docs/Web/API/HTMLMediaElement/error
@ -1293,23 +1302,25 @@ export default {
}
// Add selection toggle control.
lightbox.pswp.ui.registerElement({
name: "select-toggle",
className: "pswp__button--select-toggle pswp__button--mdi", // Sets the icon style/size in lightbox.css.
title: this.$gettext("Select"),
ariaLabel: this.$gettext("Select"),
order: 10,
isButton: true,
html: {
isCustomSVG: true,
inner: `<use class="pswp__icn-shadow pswp__icn-select-on" xlink:href="#pswp__icn-select-on"></use><path d="M12 2C6.5 2 2 6.5 2 12S6.5 22 12 22 22 17.5 22 12 17.5 2 12 2M10 17L5 12L6.41 10.59L10 14.17L17.59 6.58L19 8L10 17Z" id="pswp__icn-select-on" class="pswp__icn-select-on" /><use class="pswp__icn-shadow pswp__icn-select-off" xlink:href="#pswp__icn-select-off"></use><path d="M12,20A8,8 0 0,1 4,12A8,8 0 0,1 12,4A8,8 0 0,1 20,12A8,8 0 0,1 12,20M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z" id="pswp__icn-select-off" class="pswp__icn-select-off" />`,
size: 24, // Depends on the original SVG viewBox, e.g. use 24 for viewBox="0 0 24 24".
},
onClick: (ev) => this.onControlClick(ev, this.toggleSelect),
});
if (this.shouldShowSelectionToggle()) {
lightbox.pswp.ui.registerElement({
name: "select-toggle",
className: "pswp__button--select-toggle pswp__button--mdi", // Sets the icon style/size in lightbox.css.
title: this.$gettext("Select"),
ariaLabel: this.$gettext("Select"),
order: 10,
isButton: true,
html: {
isCustomSVG: true,
inner: `<use class="pswp__icn-shadow pswp__icn-select-on" xlink:href="#pswp__icn-select-on"></use><path d="M12 2C6.5 2 2 6.5 2 12S6.5 22 12 22 22 17.5 22 12 17.5 2 12 2M10 17L5 12L6.41 10.59L10 14.17L17.59 6.58L19 8L10 17Z" id="pswp__icn-select-on" class="pswp__icn-select-on" /><use class="pswp__icn-shadow pswp__icn-select-off" xlink:href="#pswp__icn-select-off"></use><path d="M12,20A8,8 0 0,1 4,12A8,8 0 0,1 12,4A8,8 0 0,1 20,12A8,8 0 0,1 12,20M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z" id="pswp__icn-select-off" class="pswp__icn-select-off" />`,
size: 24, // Depends on the original SVG viewBox, e.g. use 24 for viewBox="0 0 24 24".
},
onClick: (ev) => this.onControlClick(ev, this.toggleSelect),
});
}
// Add edit button control if user has permission to use it.
if (this.canEdit) {
if (this.shouldShowEditButton()) {
lightbox.pswp.ui.registerElement({
name: "edit-button",
className: "pswp__button--edit-button pswp__button--mdi hidden-shared-only", // Sets the icon style/size in lightbox.css.
@ -1356,12 +1367,7 @@ export default {
icon: "mdi-image-album",
text: this.$gettext("Set as Album Cover"),
disabled: !this.model,
visible:
this.canManageAlbums &&
this.collection &&
this.collection instanceof Collection &&
!this.model?.Removed &&
!this.model?.Archived,
visible: this.canManageAlbums && this.collection && this.collection instanceof Collection && !this.model?.Removed && !this.model?.Archived,
click: () => {
this.onSetCollectionCover();
},
@ -1389,8 +1395,9 @@ export default {
disabled: !this.model,
visible:
this.canArchive &&
this.context !== "hidden" &&
((this.context !== "archive" && !this.model?.Archived) || this.model?.Archived === false),
this.context !== contexts.Hidden &&
this.context !== contexts.BatchEdit &&
((this.context !== contexts.Archive && !this.model?.Archived) || this.model?.Archived === false),
click: () => {
this.onArchive();
},
@ -1403,8 +1410,9 @@ export default {
disabled: !this.model,
visible:
this.canArchive &&
this.context !== "hidden" &&
(this.model?.Archived || (this.context === "archive" && this.model?.Archived !== false)),
this.context !== contexts.Hidden &&
this.context !== contexts.BatchEdit &&
(this.model?.Archived || (this.context === contexts.Archive && this.model?.Archived !== false)),
click: () => {
this.onRestore();
},
@ -1533,6 +1541,8 @@ export default {
onReset() {
this.resetControls();
this.resetModels();
this.contextAllowsEdit = true;
this.contextAllowsSelect = true;
},
// Resets the state of the lightbox controls.
resetControls() {
@ -1541,7 +1551,7 @@ export default {
// Reset the lightbox models and index.
resetModels() {
this.collection = null;
this.context = "";
this.context = contexts.Default;
this.model = new Thumb();
this.models = [];
this.index = 0;
@ -1738,8 +1748,7 @@ export default {
// Handle the click and touch events on custom content.
if (
ev.target instanceof HTMLMediaElement ||
(ev.target instanceof HTMLElement &&
(ev.target.classList.contains("pswp__image") || ev.target.classList.contains("pswp__play")))
(ev.target instanceof HTMLElement && (ev.target.classList.contains("pswp__image") || ev.target.classList.contains("pswp__play")))
) {
// Always stop slideshow after user interaction with the content.
if (this.slideshow.active) {
@ -1843,6 +1852,9 @@ export default {
},
// Toggles the selection of the current picture in the global photo clipboard.
toggleSelect() {
if (!this.contextAllowsSelect) {
return;
}
this.$clipboard.toggle(this.model);
},
// Returns the active HTMLMediaElement element in the lightbox, if any.
@ -1952,12 +1964,15 @@ export default {
this.close();
return true;
case "Period":
if (!this.contextAllowsSelect) {
return false;
}
this.onShowMenu();
this.toggleSelect();
return true;
case "KeyA":
if (this.canArchive && this.context !== "hidden") {
if (this.model.Archived || (this.context === "archive" && this.model?.Archived !== false)) {
if (this.canArchive && this.context !== contexts.Hidden && this.context !== contexts.BatchEdit) {
if (this.model.Archived || (this.context === contexts.Archive && this.model?.Archived !== false)) {
this.onRestore();
} else {
this.onArchive();
@ -1970,7 +1985,7 @@ export default {
}
return true;
case "KeyE":
if (this.canEdit) {
if (this.canEdit && this.contextAllowsEdit) {
this.onEdit();
}
return true;
@ -2002,10 +2017,7 @@ export default {
return;
}
if (
this.info &&
(document.activeElement instanceof HTMLInputElement || document.activeElement instanceof HTMLTextAreaElement)
) {
if (this.info && (document.activeElement instanceof HTMLInputElement || document.activeElement instanceof HTMLTextAreaElement)) {
return;
}

View file

@ -1,12 +1,5 @@
<template>
<v-progress-circular
size="112"
:width="5"
color="surface-variant"
indeterminate
tabindex="-1"
class="opacity-60"
></v-progress-circular>
<v-progress-circular size="112" :width="5" color="surface-variant" indeterminate tabindex="-1" class="opacity-60"></v-progress-circular>
</template>
<script>

View file

@ -63,7 +63,7 @@
variant="outlined"
:placeholder="$gettext(`Search`)"
item-title="name"
item-value="id"
item-value="__key"
return-object
auto-select-first
clearable
@ -83,9 +83,7 @@
</v-list-item>
</template>
<template #no-data>
<v-list-item
v-if="searchQuery && searchQuery.length >= 2 && !searchLoading && searchResults.length === 0"
>
<v-list-item v-if="searchQuery && searchQuery.length >= 2 && !searchLoading && searchResults.length === 0">
<v-list-item-title>{{ $gettext("No results") }}</v-list-item-title>
</v-list-item>
</template>
@ -295,38 +293,40 @@ export default {
this.performPlaceSearch(query);
}, 300); // 300ms delay after user stops typing
},
async performPlaceSearch(query) {
performPlaceSearch(query) {
if (!query || query.length < 2) {
this.searchLoading = false;
return;
return Promise.resolve();
}
try {
const response = await this.$api.get("places/search", {
return this.$api
.get("places/search", {
params: {
q: query,
count: 10,
locale: this.$config.getLanguageLocale() || "en",
},
});
if (this.searchQuery === query) {
if (response.data && Array.isArray(response.data)) {
this.searchResults = response.data;
} else {
})
.then((response) => {
if (this.searchQuery === query) {
if (response.data && Array.isArray(response.data)) {
this.searchResults = this.normalizeSearchResults(response.data);
} else {
this.searchResults = [];
}
}
})
.catch((error) => {
console.error("Place search error:", error);
if (this.searchQuery === query) {
this.searchResults = [];
}
}
} catch (error) {
console.error("Place search error:", error);
if (this.searchQuery === query) {
this.searchResults = [];
}
} finally {
if (this.searchQuery === query) {
this.searchLoading = false;
}
}
})
.finally(() => {
if (this.searchQuery === query) {
this.searchLoading = false;
}
});
},
onPlaceSelected(place) {
if (place && place.lat && place.lng) {
@ -340,6 +340,20 @@ export default {
clearSearch() {
this.resetSearchState();
},
normalizeSearchResults(results) {
const seen = new Map();
return results.map((item, index) => {
const base = item.id || `result-${index}`;
const occurrence = seen.get(base) || 0;
seen.set(base, occurrence + 1);
return {
...item,
__key: occurrence === 0 ? base : `${base}-${occurrence}`,
};
});
},
},
};
</script>

View file

@ -5,6 +5,7 @@
:hide-details="hideDetails"
:label="label"
:placeholder="placeholder"
:persistent-placeholder="persistentPlaceholder"
:density="density"
:validate-on="validateOn"
:rules="[() => !coordinateInput || isValidCoordinateInput]"
@ -30,20 +31,10 @@
<v-icon v-else variant="plain" :icon="icon" class="text-disabled"> </v-icon>
</template>
<template #append-inner>
<v-icon
v-if="showUndoButton"
variant="plain"
icon="mdi-undo"
class="action-undo"
@click.stop="undoClear"
></v-icon>
<v-icon
v-else-if="coordinateInput"
variant="plain"
icon="mdi-close-circle"
class="action-clear"
@click.stop="clearCoordinates"
></v-icon>
<v-icon v-if="isDeleted" variant="plain" icon="mdi-undo" class="action-undo" @click.stop="$emit('undo')"></v-icon>
<v-icon v-else-if="isMixed" :icon="iconClear" variant="plain" class="action-delete" @click.stop="$emit('delete')"></v-icon>
<v-icon v-else-if="showUndoButton" variant="plain" :icon="iconUndo" class="action-undo" @click.stop="undoClear"></v-icon>
<v-icon v-else-if="coordinateInput" :icon="iconClear" variant="plain" class="action-delete" @click.stop="clearCoordinates"></v-icon>
</template>
</v-text-field>
</template>
@ -52,6 +43,14 @@
export default {
name: "PLocationInput",
props: {
isMixed: {
type: Boolean,
default: false,
},
isDeleted: {
type: Boolean,
default: false,
},
latlng: {
type: Array,
default: () => [null, null],
@ -73,6 +72,10 @@ export default {
type: String,
default: "37.75267, -122.543",
},
persistentPlaceholder: {
type: Boolean,
default: false,
},
density: {
type: String,
default: "comfortable",
@ -99,7 +102,7 @@ export default {
},
enableUndo: {
type: Boolean,
default: false,
default: true,
},
autoApply: {
type: Boolean,
@ -110,9 +113,11 @@ export default {
default: 1000,
},
},
emits: ["update:latlng", "changed", "cleared", "open-map"],
emits: ["update:latlng", "changed", "cleared", "open-map", "delete", "undo"],
data() {
return {
iconClear: "mdi-close-circle",
iconUndo: "mdi-undo",
coordinateInput: "",
inputTimeout: null,
wasCleared: false,

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