mirror of
https://github.com/photoprism/photoprism.git
synced 2026-01-23 02:24:24 +00:00
Merge tag '251130-b3068414c' into PostgreSQL
This commit is contained in:
commit
b731d949f5
979 changed files with 48550 additions and 24493 deletions
|
|
@ -59,7 +59,7 @@ compose.*.yaml
|
|||
__pycache__
|
||||
venv
|
||||
.venv
|
||||
.env
|
||||
.env*
|
||||
.tmp
|
||||
.nv
|
||||
.eslintcache
|
||||
|
|
|
|||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -27,7 +27,7 @@
|
|||
__pycache__
|
||||
venv
|
||||
.venv
|
||||
.env
|
||||
.env*
|
||||
.tmp
|
||||
.nv
|
||||
.eslintcache
|
||||
|
|
|
|||
74
.golangci.yml
Normal file
74
.golangci.yml
Normal 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
21
.prettierignore
Normal 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
101
AGENTS.md
|
|
@ -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 per‑suite DSN like `.<pkg>.db`. Don’t assert an empty DSN for SQLite.
|
||||
- Clean up any per‑suite 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 ad‑hoc `net/http` code:
|
||||
|
|
|
|||
11
CODEMAP.md
11
CODEMAP.md
|
|
@ -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.
|
||||
- Don’t 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 (non‑mobile) 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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
64
Makefile
64
Makefile
|
|
@ -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
18
NOTICE
|
|
@ -9,7 +9,7 @@ The following 3rd-party software packages may be used by or distributed with
|
|||
PhotoPrism. Any information relevant to third-party vendors listed below are
|
||||
collected using common, reasonable means.
|
||||
|
||||
Date generated: 2025-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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
122
assets/profiles/icc/NOTICE
Normal 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
BIN
assets/profiles/icc/a98.icc
Normal file
Binary file not shown.
BIN
assets/profiles/icc/adobecompat-v2.icc
Normal file
BIN
assets/profiles/icc/adobecompat-v2.icc
Normal file
Binary file not shown.
BIN
assets/profiles/icc/adobecompat-v4.icc
Normal file
BIN
assets/profiles/icc/adobecompat-v4.icc
Normal file
Binary file not shown.
BIN
assets/profiles/icc/applecompat-v2.icc
Normal file
BIN
assets/profiles/icc/applecompat-v2.icc
Normal file
Binary file not shown.
BIN
assets/profiles/icc/applecompat-v4.icc
Normal file
BIN
assets/profiles/icc/applecompat-v4.icc
Normal file
Binary file not shown.
BIN
assets/profiles/icc/cgats001compat-v2-micro.icc
Normal file
BIN
assets/profiles/icc/cgats001compat-v2-micro.icc
Normal file
Binary file not shown.
BIN
assets/profiles/icc/colormatchcompat-v2.icc
Normal file
BIN
assets/profiles/icc/colormatchcompat-v2.icc
Normal file
Binary file not shown.
BIN
assets/profiles/icc/colormatchcompat-v4.icc
Normal file
BIN
assets/profiles/icc/colormatchcompat-v4.icc
Normal file
Binary file not shown.
BIN
assets/profiles/icc/dci-p3-v4.icc
Normal file
BIN
assets/profiles/icc/dci-p3-v4.icc
Normal file
Binary file not shown.
BIN
assets/profiles/icc/displayp3-v2-magic.icc
Normal file
BIN
assets/profiles/icc/displayp3-v2-magic.icc
Normal file
Binary file not shown.
BIN
assets/profiles/icc/displayp3-v2-micro.icc
Normal file
BIN
assets/profiles/icc/displayp3-v2-micro.icc
Normal file
Binary file not shown.
BIN
assets/profiles/icc/displayp3-v4.icc
Normal file
BIN
assets/profiles/icc/displayp3-v4.icc
Normal file
Binary file not shown.
BIN
assets/profiles/icc/displayp3compat-v2-magic.icc
Normal file
BIN
assets/profiles/icc/displayp3compat-v2-magic.icc
Normal file
Binary file not shown.
BIN
assets/profiles/icc/displayp3compat-v2-micro.icc
Normal file
BIN
assets/profiles/icc/displayp3compat-v2-micro.icc
Normal file
Binary file not shown.
BIN
assets/profiles/icc/displayp3compat-v4.icc
Normal file
BIN
assets/profiles/icc/displayp3compat-v4.icc
Normal file
Binary file not shown.
BIN
assets/profiles/icc/prophoto-v2-magic.icc
Normal file
BIN
assets/profiles/icc/prophoto-v2-magic.icc
Normal file
Binary file not shown.
BIN
assets/profiles/icc/prophoto-v2-micro.icc
Normal file
BIN
assets/profiles/icc/prophoto-v2-micro.icc
Normal file
Binary file not shown.
BIN
assets/profiles/icc/prophoto-v4.icc
Normal file
BIN
assets/profiles/icc/prophoto-v4.icc
Normal file
Binary file not shown.
BIN
assets/profiles/icc/rec2020-g24-v4.icc
Normal file
BIN
assets/profiles/icc/rec2020-g24-v4.icc
Normal file
Binary file not shown.
BIN
assets/profiles/icc/rec2020-v2-magic.icc
Normal file
BIN
assets/profiles/icc/rec2020-v2-magic.icc
Normal file
Binary file not shown.
BIN
assets/profiles/icc/rec2020-v2-micro.icc
Normal file
BIN
assets/profiles/icc/rec2020-v2-micro.icc
Normal file
Binary file not shown.
BIN
assets/profiles/icc/rec2020-v4.icc
Normal file
BIN
assets/profiles/icc/rec2020-v4.icc
Normal file
Binary file not shown.
BIN
assets/profiles/icc/rec2020compat-v2-magic.icc
Normal file
BIN
assets/profiles/icc/rec2020compat-v2-magic.icc
Normal file
Binary file not shown.
BIN
assets/profiles/icc/rec2020compat-v2-micro.icc
Normal file
BIN
assets/profiles/icc/rec2020compat-v2-micro.icc
Normal file
Binary file not shown.
BIN
assets/profiles/icc/rec2020compat-v4.icc
Normal file
BIN
assets/profiles/icc/rec2020compat-v4.icc
Normal file
Binary file not shown.
BIN
assets/profiles/icc/rec601ntsc-v2-magic.icc
Normal file
BIN
assets/profiles/icc/rec601ntsc-v2-magic.icc
Normal file
Binary file not shown.
BIN
assets/profiles/icc/rec601ntsc-v2-micro.icc
Normal file
BIN
assets/profiles/icc/rec601ntsc-v2-micro.icc
Normal file
Binary file not shown.
BIN
assets/profiles/icc/rec601ntsc-v4.icc
Normal file
BIN
assets/profiles/icc/rec601ntsc-v4.icc
Normal file
Binary file not shown.
BIN
assets/profiles/icc/rec601pal-v2-magic.icc
Normal file
BIN
assets/profiles/icc/rec601pal-v2-magic.icc
Normal file
Binary file not shown.
BIN
assets/profiles/icc/rec601pal-v2-micro.icc
Normal file
BIN
assets/profiles/icc/rec601pal-v2-micro.icc
Normal file
Binary file not shown.
BIN
assets/profiles/icc/rec601pal-v4.icc
Normal file
BIN
assets/profiles/icc/rec601pal-v4.icc
Normal file
Binary file not shown.
BIN
assets/profiles/icc/rec709-v2-magic.icc
Normal file
BIN
assets/profiles/icc/rec709-v2-magic.icc
Normal file
Binary file not shown.
BIN
assets/profiles/icc/rec709-v2-micro.icc
Normal file
BIN
assets/profiles/icc/rec709-v2-micro.icc
Normal file
Binary file not shown.
BIN
assets/profiles/icc/rec709-v4.icc
Normal file
BIN
assets/profiles/icc/rec709-v4.icc
Normal file
Binary file not shown.
BIN
assets/profiles/icc/scrgb-v2.icc
Normal file
BIN
assets/profiles/icc/scrgb-v2.icc
Normal file
Binary file not shown.
BIN
assets/profiles/icc/sgrey-v2-magic.icc
Normal file
BIN
assets/profiles/icc/sgrey-v2-magic.icc
Normal file
Binary file not shown.
BIN
assets/profiles/icc/sgrey-v2-micro.icc
Normal file
BIN
assets/profiles/icc/sgrey-v2-micro.icc
Normal file
Binary file not shown.
BIN
assets/profiles/icc/sgrey-v2-nano.icc
Normal file
BIN
assets/profiles/icc/sgrey-v2-nano.icc
Normal file
Binary file not shown.
BIN
assets/profiles/icc/sgrey-v4.icc
Normal file
BIN
assets/profiles/icc/sgrey-v4.icc
Normal file
Binary file not shown.
BIN
assets/profiles/icc/srgb-v2-magic.icc
Normal file
BIN
assets/profiles/icc/srgb-v2-magic.icc
Normal file
Binary file not shown.
BIN
assets/profiles/icc/srgb-v2-micro.icc
Normal file
BIN
assets/profiles/icc/srgb-v2-micro.icc
Normal file
Binary file not shown.
BIN
assets/profiles/icc/srgb-v2-nano.icc
Normal file
BIN
assets/profiles/icc/srgb-v2-nano.icc
Normal file
Binary file not shown.
BIN
assets/profiles/icc/srgb-v4.icc
Normal file
BIN
assets/profiles/icc/srgb-v4.icc
Normal file
Binary file not shown.
BIN
assets/profiles/icc/widegamutcompat-v2.icc
Normal file
BIN
assets/profiles/icc/widegamutcompat-v2.icc
Normal file
Binary file not shown.
BIN
assets/profiles/icc/widegamutcompat-v4.icc
Normal file
BIN
assets/profiles/icc/widegamutcompat-v4.icc
Normal file
Binary file not shown.
18
compose.yaml
18
compose.yaml
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 && \
|
||||
|
|
|
|||
|
|
@ -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 \
|
||||
|
|
|
|||
|
|
@ -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 && \
|
||||
|
|
|
|||
|
|
@ -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 \
|
||||
|
|
|
|||
|
|
@ -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 && \
|
||||
|
|
|
|||
|
|
@ -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 \
|
||||
|
|
|
|||
|
|
@ -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 && \
|
||||
|
|
|
|||
|
|
@ -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 \
|
||||
|
|
|
|||
|
|
@ -4,8 +4,12 @@ tests/screenshots/
|
|||
tests/acceptance/screenshots/
|
||||
tests/upload-files/
|
||||
*.html
|
||||
*.md
|
||||
.*
|
||||
.idea
|
||||
.codex
|
||||
.local
|
||||
.config
|
||||
.github
|
||||
.tmp
|
||||
.local
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"printWidth": 120,
|
||||
"printWidth": 160,
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"trailingComma": "es5",
|
||||
|
|
|
|||
|
|
@ -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 don’t 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 shouldn’t 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 don’t 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 don’t 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`
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ watch:
|
|||
build:
|
||||
npm run build
|
||||
lint:
|
||||
npm run lint
|
||||
npm run lint || true
|
||||
fmt:
|
||||
npm run fmt
|
||||
test:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
1354
frontend/package-lock.json
generated
1354
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
text = text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
||||
|
||||
// Make URLs clickable.
|
||||
text = text.replace(linkRegex, linkFunc);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -156,7 +156,6 @@ export default {
|
|||
color: "on-surface",
|
||||
bgColor: "secondary",
|
||||
baseColor: "secondary",
|
||||
sliderColor: "surface-variant",
|
||||
},
|
||||
VTable: {
|
||||
density: "comfortable",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
383
frontend/src/component/input/chip-selector.vue
Normal file
383
frontend/src/component/input/chip-selector.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue