diff --git a/.agents/.gitignore b/.agents/.gitignore new file mode 100644 index 000000000..c96a04f00 --- /dev/null +++ b/.agents/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore \ No newline at end of file diff --git a/.claude/.gitignore b/.claude/.gitignore new file mode 100644 index 000000000..f09ddd2f8 --- /dev/null +++ b/.claude/.gitignore @@ -0,0 +1,8 @@ +* +!.gitignore +!CLAUDE.md +!rules +!rules/*.md +!agents +!agents/*.md +!settings.json \ No newline at end of file diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 000000000..fe2232a13 --- /dev/null +++ b/.claude/CLAUDE.md @@ -0,0 +1,148 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. Detailed rules are in `.claude/rules/*.md` files organized by topic. + +## Build Commands + +Run `make help` to list all available targets. Key commands: + +**Backend (Go):** +- `make build-go` — build the `photoprism` binary (develop mode) +- `make build-all` — build backend + frontend +- `go build ./...` — compile all Go packages + +**Frontend (Vue 3):** +- `make build-js` — production build of the frontend +- `make watch-js` — watch mode for frontend development (Ctrl+C to stop) + +**Dependencies:** +- `make dep` — install all dependencies (TensorFlow models, ONNX models, JS packages) +- `make dep-js` — install JS dependencies only (`npm ci`). The `photoprism/develop` image and the repo `Makefile` both set `NPM_CONFIG_IGNORE_SCRIPTS=true`, so install scripts are skipped automatically; when running npm directly in an env without that default, pass `--ignore-scripts`. Rebuild native addons with `npm rebuild --ignore-scripts=false ` — a bare `npm rebuild` no-ops wherever the env default is active. + +**Docker dev environment:** +- `make docker-build` — build local Docker image +- `docker compose up` — start dev environment (app at http://localhost:2342/) +- `make terminal` — open shell in dev container + +## Testing + +**Run all tests:** +- `make test` — runs both JS and Go tests +- `make test-go` — all Go tests (slow, ~20 min) +- `make test-js` — frontend unit tests (Vitest) +- `make test-short` — short Go tests in parallel (~5 min) + +**Run targeted Go tests:** +```bash +go test ./internal/api -run 'TestFunctionName' -count=1 +go test ./internal/photoprism -run 'TestMediaFile_' -count=1 +go test ./internal/entity/... -count=1 -tags="slow,develop" +``` + +**Run targeted JS tests:** +- `make vitest-watch` — Vitest in watch mode +- `make vitest-coverage` — Vitest with coverage report + +**Reset test databases before running Go tests:** +- `make reset-testdb` — clears SQLite test DBs and MariaDB testdb + +**Subset targets:** `make test-pkg`, `make test-api`, `make test-entity`, `make test-commands`, `make test-photoprism`, `make test-ai` + +## Formatting & Linting + +Available targets: `make fmt` (everything), `make fmt-go`, `make fmt-js`, `make fmt-swag` / `make swag` (Swagger), `make lint-go`, `make lint-js`. Detailed conventions live in `.claude/rules/go-code-style.md` and `.claude/rules/frontend-rules.md`. + +When creating or editing shell scripts, run `shellcheck ` and resolve warnings. When editing Markdown files that contain tables, format them with `npx --yes markdown-table-formatter `. + +## Schema Migrations + +If a change touches database schema, check migrations: +```bash +go run cmd/photoprism/photoprism.go migrations ls +go run cmd/photoprism/photoprism.go migrations run +# or via Makefile: +make migrate +``` + +Migration files live in `internal/entity/migrate/`. + +## Architecture Overview + +PhotoPrism is a self-hosted photo management app. The backend is Go, the frontend is Vue 3 + Vuetify 3, and the database is MariaDB or SQLite (via GORM). + +### Backend (`internal/`, `pkg/`, `cmd/`) + +| Package | Purpose | +|-------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `internal/photoprism` | **Core application logic**: indexing originals, metadata extraction, thumbnail generation, import/stacking, converter orchestration (FFmpeg/ImageMagick/ExifTool). Entry point for workers and CLI. | +| `internal/entity` | **Database models** (GORM): Photo, File, Album, Label, Face, User, Session, etc. Contains fixtures for tests and migration helpers. | +| `internal/entity/query` | Database query helpers used by the API and core packages. | +| `internal/api` | **REST API handlers** (Gin): thin handlers that validate input, enforce ACL/auth, delegate to services. Annotated with Swagger comments. | +| `internal/server` | HTTP server setup, routing (Gin engine), WebDAV, static assets, middleware wiring. Routes are registered in `routes.go`. | +| `internal/config` | Application configuration: CLI flags, env vars, client config sent to the frontend. | +| `internal/workers` | Background workers: indexing scheduler, metadata sync, sharing, backup, vision jobs. | +| `internal/commands` | CLI command implementations (`github.com/urfave/cli/v2`). | +| `internal/auth` | Authentication: ACL (`auth/acl`), JWT (`auth/jwt`), OIDC (`auth/oidc`), session management. | +| `internal/form` | Request form/binding structs for the API layer. | +| `internal/meta` | Metadata extraction from EXIF, XMP, JSON sidecars. | +| `internal/ffmpeg` | FFmpeg/transcoding helpers. | +| `internal/thumb` | Thumbnail generation helpers. | +| `internal/ai` | AI/vision model integration (TensorFlow, ONNX). | +| `internal/service` | Services: maps geocoding, hub (membership), cluster, WebDAV client, CIDR helpers. | +| `internal/event` | Event bus for structured logging and audit events. | +| `pkg/` | Standalone, reusable packages: `fs`, `geo`, `media`, `txt`, `clean`, `rnd`, `i18n`, `http`, `time`, etc. No dependency on `internal/`. | + +**Request flow:** HTTP request → Gin middleware (auth, rate limiting) → `internal/api` handler → `internal/photoprism` or `internal/entity` → response. + +**Audit logging convention** (`event.AuditInfo/Warn/Err`): slices must follow the pattern **Who → What → Outcome**: +- Who: `ClientIP(c)` + actor context (`"session %s"`, `"user %s"`) +- What: resource constant + action segments +- Outcome: single token like `status.Succeeded`, `status.Failed`, `status.Denied`, `status.Error(err)` + +### Frontend (`frontend/`) + +Vue 3 app using the Options API and Vuetify 3. + +| Directory | Purpose | +|---------------------------|-------------------------------------------------------------------------------------------------| +| `frontend/src/model/` | Client-side models mirroring API responses (Photo, Album, File, User, etc.) | +| `frontend/src/app/` | App bootstrap, routing (`routes.js`), and the `$session` reactive singleton | +| `frontend/src/page/` | Page-level components | +| `frontend/src/component/` | Reusable UI components | +| `frontend/src/common/` | Shared utilities, the API client (`$api`), and reactive singletons (`$config`, `$view`, `$log`) | +| `frontend/src/locales/` | i18n translation files | +| `frontend/tests/` | Vitest unit tests + TestCafe acceptance tests | + +State management uses reactive singleton modules in `src/common/` and `src/app/`, not Vuex or Pinia. Frontend code-style, formatting, testing, translation, and Playwright rules live in `.claude/rules/frontend-rules.md`. + +### API Conventions + +- REST API v1 base path: `/api/v1/` (configured via `conf.BaseUri()`) +- Authentication: Bearer token (`Authorization` header) or `X-Auth-Token` header +- Pagination: `count`, `offset`, `limit` parameters (default 100, max 1000) +- After adding/changing API handlers, regenerate Swagger docs: `make fmt-go swag-fmt swag` +- New routes must be registered in `internal/server/routes.go` + +### Config & Flags + +Verify config option names before using them: +```bash +./photoprism --help +./photoprism show config-options +./photoprism show config-yaml +``` + +### Verify Before Propagating + +Before promoting a claim from `CLAUDE.md`, `AGENTS.md`, a memory entry, or another spec into a new rule, spec, code comment, or commit message, verify it against the current code (grep imports, list directories, read the cited file). Stale documentation silently turns into stale rules and stale specs that future sessions will trust. When the claim names a package, function, file, or framework, the cost of one grep is much smaller than the cost of repeating an error across multiple files. + +### Detailed Rules + +Topic-specific conventions live under `.claude/rules/` and are loaded alongside this file: + +- `code-comments.md` — shared JS/Go doc comment rules (length cap, what to omit); referenced by both style files. +- `go-code-style.md`, `go-testing.md` — Go style, package boundaries, test patterns, fixtures. +- `frontend-rules.md` — JS/Vue code style, formatting, dependencies, tests, Playwright, translations. +- `commit-and-docs-style.md` — commit-message format, GitHub issue templates, spec heading style. +- `safety-and-security.md` — Git/data safety, destructive commands, file I/O and archive-extraction policies, HTTP download helpers. +- `api-and-config.md`, `cluster-operations.md`, `import-index-download.md`, `build-and-runtime.md`, `sources-of-truth.md` — domain-specific guidance. diff --git a/.claude/agents/ui-tester.md b/.claude/agents/ui-tester.md new file mode 100644 index 000000000..b523df4a6 --- /dev/null +++ b/.claude/agents/ui-tester.md @@ -0,0 +1,41 @@ +--- +name: ui-tester +description: Drives the Playwright MCP browser to exercise UI flows, verify behavior in a real Chromium, and report findings concisely. Use for any task that needs to navigate pages, click through flows, fill forms, capture console errors, check network requests, or validate UI state. Returns a short verdict + evidence so the parent context isn't filled with raw snapshots and console logs. +tools: Bash, Read, Grep, Glob, WebFetch, ToolSearch, mcp__playwright__browser_navigate, mcp__playwright__browser_navigate_back, mcp__playwright__browser_snapshot, mcp__playwright__browser_take_screenshot, mcp__playwright__browser_click, mcp__playwright__browser_hover, mcp__playwright__browser_drag, mcp__playwright__browser_drop, mcp__playwright__browser_type, mcp__playwright__browser_fill_form, mcp__playwright__browser_select_option, mcp__playwright__browser_press_key, mcp__playwright__browser_file_upload, mcp__playwright__browser_handle_dialog, mcp__playwright__browser_wait_for, mcp__playwright__browser_console_messages, mcp__playwright__browser_network_requests, mcp__playwright__browser_evaluate, mcp__playwright__browser_run_code, mcp__playwright__browser_resize, mcp__playwright__browser_tabs, mcp__playwright__browser_close +--- + +You are a focused UI test driver. The parent agent has delegated browser-driven work to you so its context stays clean. Your output is the only thing it sees — make it short, structured, and decision-ready. + +## Loading tool schemas + +The Playwright MCP tools above appear in your toolset by name but their schemas are deferred. Before you can call any of them, run `ToolSearch` once with `select:` to load the schemas for the specific tools you need (e.g. `select:mcp__playwright__browser_navigate,mcp__playwright__browser_snapshot,mcp__playwright__browser_click`). Don't load all of them — just the handful this task actually needs. + +## Browser environment + +- The MCP server runs **one shared Chromium instance** with a **persistent profile**. Cookies and `localStorage` from earlier sessions persist. Assume state may already exist — log in fresh if a flow requires a known account. +- This profile is **separate** from the user's real Chrome. You won't see their personal logins or extensions. +- There is no parallelism — only you should be driving the browser during your run. + +## Workflow + +1. **Plan first.** Decide the minimum flow that proves or disproves the parent's question. Don't explore; execute. +2. **Run.** Navigate, interact, observe. Prefer `browser_snapshot` (accessibility tree) over `browser_take_screenshot` — it's much smaller and works for assertions. Reach for screenshots only when the parent explicitly wants visual evidence. +3. **Capture signals.** Pull `browser_console_messages` and, if relevant, `browser_network_requests` near the end of the flow — they're often the actual answer. +4. **Clean up before returning.** Call `browser_close` so the next subagent starts with no open tabs. The persistent profile keeps cookies, but tabs/state shouldn't leak across runs. + +## Reporting format + +Default to **under 300 words** unless the parent asked for more. Structure: + +- **Verdict:** one sentence — pass / fail / partial, and what was tested. +- **Evidence:** 3–6 bullet points with the concrete observations (URLs visited, elements clicked, console errors verbatim, network failures with status codes). Quote error messages exactly; don't paraphrase. +- **Notes (optional):** anything the parent should know that wasn't asked for but matters (e.g. "noticed an unrelated 404 on `/api/foo`"). + +Don't paste full snapshots, full console logs, or screenshot data unless the parent specifically requested them. The parent does not need to see your tool-call play-by-play — just the conclusion and the evidence supporting it. + +## What not to do + +- Don't open new browsers, install Playwright, or modify the MCP server config. +- Don't edit application code unless the parent explicitly told you to. Your job is to test what's there. +- Don't run long-form Go/JS test suites (that's `make test` territory) — you're the manual-QA-in-a-browser agent, not a CI runner. +- Don't summarize what you just did at the end ("I navigated to X, then clicked Y, then..."). The verdict + evidence is the summary. \ No newline at end of file diff --git a/.claude/rules/api-and-config.md b/.claude/rules/api-and-config.md new file mode 100644 index 000000000..bf64a8c9e --- /dev/null +++ b/.claude/rules/api-and-config.md @@ -0,0 +1,42 @@ +## API & Config Changes + +- Respect precedence: `options.yml` overrides CLI/env values, which override defaults. +- Adding a new option: update `internal/config/options.go` (yaml/flag tags), register in `internal/config/flags.go`, expose a getter, surface it in `*config.Report()`, and write generated values back to `options.yml`. Use `CliTestContext` in `internal/config/test.go` to exercise new flags. +- Adding a `customize.FeatureSettings` flag: a new field defaults to `true` via reflection (`features_default.go`) and is operator-disableable through `PHOTOPRISM_DISABLE_FEATURES` — no new CLI option needed. It cascades: update the full-struct literals in `internal/config/customize/{acl,scope}_test.go` and `internal/config/client_config_test.go` (the longest field name re-aligns every literal via gofmt), and `testdata/settings.yml` self-updates via `TestSettings_Save`. If the flag is only meaningful for accounts/roles, gate it per-session in `customize.Settings.ApplyACL` / `ApplyScope` (e.g. `Account`/`AppPasswords` require `ResourcePassword`/`ActionUpdate`); that shapes the Web UI client config only — enforce server-side behavior on the global flag via a `Config.DisableX()` helper. +- Identify an app-password credential by its session, not its token: `(*entity.Session).IsApplication()` (auth provider `application`) covers every grant that mints one (`password`/`session`/`cli`). The token format and grant type vary, so don't gate on `rnd.IsAppPassword` or `GrantType`. +- For `options.yml` writes, prefer config-owned persistence helpers: `Config.SaveOptionsPatch(...)` for generic merges, `Config.SaveClusterOptionsUpdate(...)` for cluster-managed metadata. +- Use `pkg/fs.ConfigFilePath` for config filenames so existing `.yml` files stay valid and new installs can adopt `.yaml` transparently. +- Use the public accessors on `*config.Config` (e.g. `JWKSUrl()`, `SetJWKSUrl()`) instead of mutating `Config.Options()` directly; reserve raw option tweaks for test fixtures. +- New metadata sources (e.g. `SrcOllama`, `SrcOpenAI`) must be defined in both `internal/entity/src.go` and the frontend lookup tables (`frontend/src/common/util.js`). +- Config init order: load `options.yml` (`c.initSettings()`), run `EarlyExt().InitEarly(c)`, connect/register the DB, then `Ext().Init(c)`. +- Favor explicit CLI flags: check `c.cliCtx.IsSet("")` before overriding user-supplied values. +- Database helpers: reuse `conf.Db()` / `conf.Database*()`, avoid GORM `WithContext`, quote MySQL identifiers, and reject unsupported drivers early. + +## Handler Conventions + +- Reuse limiter stacks (`limiter.Auth`, `limiter.Login`) and `limiter.AbortJSON` for 429s. Lean on `api.ClientIP`, `header.BearerToken`, and `Abort*` helpers. +- Compare secrets with constant-time checks; set `Cache-Control: no-store` on sensitive responses. +- Register routes in `internal/server/routes.go`. New list endpoints default `count=100` (max 1000) and `offset≥0`; document parameters explicitly. +- Set portal mode via `PHOTOPRISM_NODE_ROLE=portal` plus `PHOTOPRISM_JOIN_TOKEN` when needed. + +## API Shape Checklist + +When renaming or adding fields: +- Field casing: **TitleCase** (`UUID`, `Name`, `SiteUrl`) for fields backed by a DB entity (mirror the entity/model), **camelCase** (`storageNamespace`, `redirectUri`) for generated/artificial payloads (client config, session, action/RPC bodies). A filtered/computed entity projection stays TitleCase; an action payload stays camelCase but MAY TitleCase its single entity-identity field (e.g. `UUID`). See `specs/common/field-casing.md`. +- Update DTOs in `internal/service/cluster/response.go` and any mappers. +- Update handlers and regenerate Swagger: `make fmt-go swag-fmt swag`. +- Update tests (search/replace old field names) and examples in `specs/`. +- Quick grep: `rg -n 'oldField|newField' -S` across code, tests, and specs. + +## Testing Helpers + +- Isolate config paths with `t.TempDir()`; reuse `NewConfig`, `CliTestContext`, and `NewApiTest()` harnesses. +- Authenticate via `AuthenticateAdmin`, `AuthenticateUser`, or `OAuthToken`. Toggle auth with `conf.SetAuthMode(config.AuthModePasswd)`. +- Prefer OAuth client tokens over non-admin fixtures for negative permission checks. + +## Roles & ACL + +- Map roles via the shared tables: users through `acl.ParseRole(s)` / `acl.UserRoles[...]`, clients through `acl.ClientRoles[...]`. +- Treat `RoleAliasNone` ("none") and an empty string as `RoleNone`; default unknown client roles to `RoleClient`. +- Build CLI role help from the registered role map (never hand-maintained literals) so each edition lists exactly the roles it accepts: `commands.UserRoleUsageFor()` / `Roles.CliUsageString()`, passing CE `acl.UserRoles` or an edition's own static `auth.UserRoles` (reference the edition map directly, not the runtime-reassigned `acl.UserRoles`, to avoid the init-order trap; Portal's map includes `cluster_admin`). For federatable / cluster-instance contexts (LDAP, OIDC group→role, cluster grants) use `acl.ClusterInstanceRolesCliUsageString()` — it excludes `cluster_admin`/`visitor`. `pkg/txt.JoinOr` renders the "a, b, or c" style. +- For JWT/client scope checks, use the shared helpers (`acl.ScopePermits` / `acl.ScopeAttrPermits`). diff --git a/.claude/rules/build-and-runtime.md b/.claude/rules/build-and-runtime.md new file mode 100644 index 000000000..c9fd9d390 --- /dev/null +++ b/.claude/rules/build-and-runtime.md @@ -0,0 +1,46 @@ +## Agent Runtime (Host vs Container) + +Agents MAY run either inside the Development Environment container (recommended) or on the host. + +### Detecting the Environment + +- **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. + +### Host Mode + +- Build local dev image (once): `make docker-build` +- Start services: `docker compose up` (add `-d` for background) +- Follow live app logs: `docker compose logs -f --tail=100 photoprism` +- Execute a single command in the app container: `docker compose exec photoprism ` + - Run as non-root to avoid root-owned files: `docker compose exec -u "$(id -u):$(id -g)" photoprism ` +- Open a terminal session: `make terminal` +- Stop everything: `docker compose --profile=all down --remove-orphans` (`make down`) + +### Container Mode + +- Install deps: `make dep` +- Build frontend/backend: `make build-js` and `make build-go` +- Watch frontend changes: `make watch-js` +- Start PhotoPrism server: `./photoprism start` + - HTTP: http://localhost:2342/ + - HTTPS: https://app.localssl.dev/ (via Traefik, if running) +- Admin Login: Default credentials are `admin` / `photoprism`; check `compose.yaml` for `PHOTOPRISM_ADMIN_USER` and `PHOTOPRISM_ADMIN_PASSWORD` if they differ. +- Do not use the Docker CLI inside the container; starting/stopping services requires host Docker access. + +### Operating Systems & Architectures + +- Guides and command examples assume Linux/Unix shell on 64-bit AMD64 or ARM64. +- For Windows-specifics, see the Developer Guide FAQ: https://docs.photoprism.app/developer-guide/faq/#can-your-development-environment-be-used-under-windows + +### CLI Binary Names + +The CLI name is `photoprism` in production and public docs. Other binary names (`photoprism-plus`, `photoprism-pro`, `photoprism-portal`) are only used in development builds for side-by-side comparisons. + +## Container Image Builds + +- **Pin the dev `Dockerfile` `FROM` to a dated `photoprism/develop` tag, never the floating codename.** Use `photoprism/develop:-` (e.g. `photoprism/develop:260520-resolute`), not `photoprism/develop:resolute` / `:latest` / `:ubuntu`. The dated tag pins to a known-good build so a drive-by `docker pull` doesn't silently swap in a fresh base mid-session, and bumping the date in a commit gives developers + reviewers a visible signal that a new base image is available. The `buildx-multi.sh` script publishes both `:` and `:-` on every build — the dated one is the durable pointer. When you bump the base image, also update `Dockerfile`, the host `compose.yaml` (if it references the tag explicitly), and any docs that quote the current dev image. +- **Never mix Debian and Ubuntu `apt` repositories in the same image:** + - Don't add a Debian source to an Ubuntu base (or vice versa) to install a single missing package — the transitive deps drift, apt's solver pulls newer libraries from the foreign distro, and other build steps in the same `RUN` (e.g. `install-libheif.sh` running `apt-get install libavcodec-dev`) silently link against the wrong soname. + - Symptoms surface much later as `dlopen: libfoo.so.N: cannot open shared object file` at image runtime, with the binary referencing a soname that exists only in the foreign distro. + - If a package isn't available in the host distro's repos, prefer (a) a same-distro PPA / backports source, (b) a vendor-supplied .deb (e.g. Google Chrome from `dl.google.com`), or (c) a from-source build pinned to a known version. diff --git a/.claude/rules/cluster-operations.md b/.claude/rules/cluster-operations.md new file mode 100644 index 000000000..a1641dc22 --- /dev/null +++ b/.claude/rules/cluster-operations.md @@ -0,0 +1,41 @@ +## Cluster Operations + +- Keep bootstrap decoupled: avoid importing `internal/service/cluster/node/*` from `internal/config` or the cluster root; nodes talk to the Portal over HTTP(S) using constants from `internal/service/cluster/const.go`. +- Bootstrap refreshes node OAuth credentials on 401/403 (rotate secret + retry, info-level log). If the secret file can't be written, the rotated value stays cached in memory so the current process continues. +- Portal validation accepts HTTP advertise URLs only for loopback or cluster-internal domains (`*.svc`, `*.cluster.local`, `*.internal`); everything else must use HTTPS. +- Theme endpoint: `GET /api/v1/cluster/theme` streams a zip from `conf.ThemePath()`; reinstall only when `app.js` is missing and always use the header helpers in `pkg/http/header`. +- Registration flow: send `rotate=true` only for MySQL/MariaDB nodes without credentials; treat 401/403/404 as terminal; include `ClientID` + `ClientSecret` when renaming an existing node; persist only newly generated secrets or DB settings. + +### Registry & DTOs + +- Use the client-backed registry (`NewClientRegistryWithConfig`) — the file-backed version is legacy. +- Nodes are keyed by UUID v7 (`/api/v1/cluster/nodes/{uuid}`); registry interface is UUID-first (`Get`, `FindByNodeUUID`, `FindByClientID`, `RotateSecret`, `DeleteAllByUUID`). CLI lookups resolve `uuid → ClientID → name`. +- DTOs normalize `Database.{Name,User,Driver,RotatedAt}`; `ClientSecret` is exposed only during creation/rotation. `nodes rm --all-ids` cleans duplicate client rows. `ClientData` no longer stores `NodeUUID`. +- Admin responses may include `AdvertiseUrl`/`Database`; client/user sessions stay redacted. +- Registry files live under `conf.PortalConfigPath()/nodes/` (mode 0600). + +### Provisioner & DSN + +- Database/user names use UUID-based HMACs (`d`, `u`; prefix defaults to `cluster_`). +- `BuildDSN` accepts a `driver` but falls back to MySQL format with a warning when unsupported. For Postgres, extend `BuildDSN` and `provisioner.DatabaseDriver` handling, add validations, and return `driver=postgres` consistently in API and CLI output. + +### Sessions & Redaction + +- Admin session (full view): `AuthenticateAdmin(app, router)`. +- User session: create a non-admin test user (role=guest), set a password, then `AuthenticateUser`. +- Client session (redacted internal fields; `SiteUrl` visible): + ```go + s, _ := entity.AddClientSession("test-client", conf.SessionMaxAge(), "cluster", authn.GrantClientCredentials, nil) + token := s.AuthToken() + r := AuthenticatedRequest(app, http.MethodGet, "/api/v1/cluster/nodes", token) + ``` +- Admins see `AdvertiseUrl` and `Database`; client/user sessions don't. `SiteUrl` is safe for all roles. Client config includes `storageNamespace` (SHA-256 of `SiteUrl`) for browser storage scoping. + +### Preflight Checklist + +- `go build ./...` +- `make fmt-go swag-fmt swag` +- `go test ./internal/service/cluster/registry -count=1` +- `go test ./internal/api -run 'Cluster' -count=1` +- `go test ./internal/commands -run 'ClusterRegister|ClusterNodesRotate' -count=1` +- Tooling: `make swag` may fetch modules — confirm network access before running. diff --git a/.claude/rules/code-comments.md b/.claude/rules/code-comments.md new file mode 100644 index 000000000..97cde32ff --- /dev/null +++ b/.claude/rules/code-comments.md @@ -0,0 +1,12 @@ +## JS/Go Code Comment Rules + +A doc comment is **required** for every function (including unexported helpers), as well as for every non-trivial Vue `methods:` / `computed:` / watcher: +- Keep comments **compact** and default to one line for "what" in the format `// Name does X.`. Skip trivial getters (`isOpen: () => this.open`). +- Add 1-2 follow-up lines (`// …`) **only** if the "why" is non-obvious: a hidden invariant, a workaround that would otherwise be undone by a future cleanup, a contract a reader can't infer from the code. If readers can infer the "why" from the function body or a nearby line, then omit it. +- Multi-paragraph explanations belong in `specs/`, package `README.md` files, or GitHub issues — never in the source itself. + +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. For short examples in comments, indent code instead of using backticks. + +Use US English spelling in all code comments (`parameterized`, `behavior`, `color`, `serialize`, `normalize`, `optimize`, …) — not the British `-ised`/`-our`/`-re` variants. + +> **Don't include in code comments:** Issue / PR numbers, "previously…" history, alternatives considered, what the function used to do, references to old commits, names of subsequent reviewers, or any narrative that names the change rather than the steady-state behavior. That context belongs in commit messages, specs, or handover notes. diff --git a/.claude/rules/commit-and-docs-style.md b/.claude/rules/commit-and-docs-style.md new file mode 100644 index 000000000..284073b74 --- /dev/null +++ b/.claude/rules/commit-and-docs-style.md @@ -0,0 +1,57 @@ +## Commit Messages + +Use concise, imperative subjects with a one-word prefix indicating the scope or topic: + +- `Config: Add tests for "darktable-cli" path detection` + +If the commit relates to specific issues or pull requests, reference their IDs in the message: + +- `Docker: Use two stage build to reduce image size #123 #5632` + +Commit messages must not exceed 80 characters in length. + +Do not add `Co-Authored-By: Claude …` trailers (or any other AI-authorship trailer) to commit messages. + +## GitHub Issues + +Issue titles MUST be concise, use the imperative mood, and start with a single capitalized prefix followed by a colon and a space, e.g. `Search: Add filter for RAW image formats`. + +Issue descriptions MUST begin with a one-sentence **User Story** in the format: `**As a , I want , so that .**` +Use level-3 Markdown headings for sections within issue descriptions, for example `### Acceptance Criteria`. +Follow the User Story with a clear summary of the expected behavior, rationale, technical considerations, and constraints. + +Descriptions MUST conclude with a checklist of **Acceptance Criteria**: +- Use GitHub checklist formatting: `- [ ]` +- Criteria MUST be clear, testable, and unambiguous. +- Each item MUST use one of the following requirement-level keywords: + - `MUST` — required for the issue to be considered complete + - `SHOULD` — strongly recommended but not strictly required + - `MAY` — optional enhancement +- Keep the checklist current: once the work for a criterion is implemented **and verified**, mark it done (`- [x]`). +- Leave items that are unverified, not yet implemented, or skipped optional (`MAY`) enhancements unchecked. +- An issue is complete only when every `MUST` is checked; never tick a box on the strength of a plan alone or an unrun test. +- When referencing an issue from a commit that fulfills some of its criteria, update the matching boxes first. + +> Agents MUST create, edit, close, reopen, relabel, or otherwise modify GitHub issues only when explicitly requested by the user. + +The repo's issue templates use the new GitHub `type:` property (`Bug`, `Feature`) instead of `bug`/`idea` labels. `gh issue create` does not yet accept a `--type` flag, so when filing issues programmatically use `--label` only and tell the user to set the issue type via the web UI. + +## Specifications & Documentation + +- Document headings use a **Chicago-style title case**, with additional code- and path-aware normalization rules (see below). Always spell the product name as `PhotoPrism`. +- When writing CLI examples or scripts, place option flags before positional arguments unless the command requires a different order. +- Use RFC 3339 UTC timestamps in request and response examples, and valid ID, UID and UUID examples in docs and tests. +- Technical specifications in the nested `specs/` subrepository may not be present in every clone or environment. Do not add `Makefile` targets in the main project that depend on `specs/` paths. + - Auto-generated configuration and command references live under `specs/generated/`. Agents MUST NOT read, analyze, or modify anything in this directory. + - Nested Git repositories may appear to be ignored; if so, change directories before staging or committing updates. +- **Never reference `specs/` paths from public artifacts** — issue bodies, PR descriptions, package READMEs (`frontend/README.md`, `internal/*/README.md`, etc.), top-level `CODEMAP.md`/`GLOSSARY.md`, code comments outside `specs/`. External readers see a 404 and the private subrepo's existence is leaked. Hints in `AGENTS.md` and `CLAUDE.md` files are the documented exception. Quick check: `grep -n "specs/" ` should return no matches before saving any public-facing file. + +> **Title Case** rules (Chicago-style headline capitalization, with code- and path-aware normalization): +> - Capitalize the first word, the first word after a colon, dash, or end punctuation, and all major words, including the second part of a hyphenated major word. +> - Lowercase only articles, short conjunctions, and short prepositions of three letters or fewer when they are not in one of those positions. +> - Preserve known acronyms (for example, API, CLI, HTTP, JSON) and slash-separated acronym groups (for example, CSV/TSV) as uppercase. +> - Preserve RFC 2119 / RFC 8174 normative keywords (MUST, SHOULD, MAY, SHALL, REQUIRED, RECOMMENDED, OPTIONAL) as uppercase when used in their normative sense. +> - Preserve inline code spans (`` `foo` ``), file paths (e.g. `docs/foo-bar.md`), and slash commands (e.g. `/grill-me`) verbatim; do not recase their contents. +> - Use `&` instead of `And`/`Or` in headings. + +> Refresh the `**Last Updated:**` date at the top of documents whenever you make changes to their contents, using the format `January 20, 2026` (without time); leave it as-is for simple formatting or whitespace-only edits. diff --git a/.claude/rules/frontend-rules.md b/.claude/rules/frontend-rules.md new file mode 100644 index 000000000..982f2e9f7 --- /dev/null +++ b/.claude/rules/frontend-rules.md @@ -0,0 +1,77 @@ +## Frontend Code Style & Test Coverage + +- **Comments:** Follow the code comment rules in `code-comments.md`. +- **Tests:** Test new JS functions (including helpers) and new Vue components whenever practical; update existing tests when behavior changes. When a unit test is impractical (DOM-heavy flows, third-party widget integration), the doc comment is still mandatory — it's the minimum bar. +- **State:** Shared state lives in reactive singleton modules under `src/common/` and `src/app/` (e.g., `app/session.js`, `common/config.js`, `common/clipboard.js`, `common/log.js`) that export a `reactive()` / `ref()` object directly; components access them via `import` or via the globally-installed `$config` / `$session` plugins. Do not introduce Vuex, Pinia, or new ad-hoc stores — extend an existing singleton or add a new one alongside its peers in `common/` or `app/`. +- **Vue/Vuetify:** Use the Options API in Vue components (consistent with the rest of the codebase); do not introduce Composition API or ` diff --git a/frontend/src/component/components.js b/frontend/src/component/components.js index 59023c6a8..e14b30310 100644 --- a/frontend/src/component/components.js +++ b/frontend/src/component/components.js @@ -6,7 +6,7 @@ import PUpdate from "component/update.vue"; import PLoading from "component/loading.vue"; import PLoadingBar from "component/loading-bar.vue"; import PLightboxMenu from "component/lightbox/menu.vue"; -import PSidebarInfo from "component/sidebar/info.vue"; +import PLightboxSidebar from "component/lightbox/sidebar.vue"; import PMap from "component/map.vue"; import PLightbox from "component/lightbox.vue"; @@ -68,6 +68,9 @@ import PPhotoArchiveDialog from "component/photo/archive/dialog.vue"; import PPhotoAlbumDialog from "component/photo/album/dialog.vue"; import PPhotoEditDialog from "component/photo/edit/dialog.vue"; +// Meta. +import PMetaFaceMarkers from "component/meta/face/markers.vue"; + // Upload. import PUploadDialog from "component/upload/dialog.vue"; @@ -86,7 +89,7 @@ export function install(app) { app.component("PLoading", PLoading); app.component("PLoadingBar", PLoadingBar); app.component("PLightboxMenu", PLightboxMenu); - app.component("PSidebarInfo", PSidebarInfo); + app.component("PLightboxSidebar", PLightboxSidebar); app.component("PMap", PMap); app.component("PLightbox", PLightbox); @@ -134,6 +137,9 @@ export function install(app) { app.component("PPhotoArchiveDialog", PPhotoArchiveDialog); app.component("PPhotoAlbumDialog", PPhotoAlbumDialog); app.component("PPhotoEditDialog", PPhotoEditDialog); + + app.component("PMetaFaceMarkers", PMetaFaceMarkers); + app.component("PUploadDialog", PUploadDialog); app.component("PServiceAdd", PServiceAdd); diff --git a/frontend/src/component/confirm/dialog.vue b/frontend/src/component/confirm/dialog.vue index ea74addf2..3c9f3d835 100644 --- a/frontend/src/component/confirm/dialog.vue +++ b/frontend/src/component/confirm/dialog.vue @@ -15,14 +15,14 @@ > - +
{{ text ? text : $gettext(`Are you sure?`) }}
{{ $gettext(`Cancel`) }} - + {{ action ? action : $gettext(`Yes`) }} @@ -53,6 +53,10 @@ export default { type: String, default: "", }, + confirmColor: { + type: String, + default: "highlight", + }, }, emits: ["close", "confirm"], data() { diff --git a/frontend/src/component/confirm/sponsor.vue b/frontend/src/component/confirm/sponsor.vue index 42fd42bef..de106e59b 100644 --- a/frontend/src/component/confirm/sponsor.vue +++ b/frontend/src/component/confirm/sponsor.vue @@ -58,6 +58,7 @@ export default { default: false, }, }, + emits: ["close"], data() { return { links, diff --git a/frontend/src/component/defaults.js b/frontend/src/component/defaults.js index 3e63e64a2..0fe136e3d 100644 --- a/frontend/src/component/defaults.js +++ b/frontend/src/component/defaults.js @@ -1,3 +1,5 @@ +import $util from "common/util"; + export default { global: { ripple: false, @@ -83,6 +85,12 @@ export default { validateOn: "invalid-input", hideDetails: "auto", }, + VTooltip: { + openDelay: 0, + closeDelay: 0, + disabled: $util.isMobile(), + transition: false, + }, VMenu: { origin: "auto", location: "bottom end", @@ -176,7 +184,8 @@ export default { ripple: false, }, VDataTable: { - color: "background", + // Forwarded to the footer only as the "items per page" select's active-item color (not the table). + color: "surface-variant", itemsPerPage: -1, hover: true, }, diff --git a/frontend/src/component/file/clipboard.vue b/frontend/src/component/file/clipboard.vue index a13a0bacf..03a330e00 100644 --- a/frontend/src/component/file/clipboard.vue +++ b/frontend/src/component/file/clipboard.vue @@ -120,7 +120,7 @@ export default { Promise.all(uniqueAlbumUids.map((uid) => $api.post(`albums/${uid}/photos`, body))) .then(() => this.onAdded()) - .catch((error) => { + .catch(() => { $notify.error(this.$gettext("Some albums could not be updated")); }); }, diff --git a/frontend/src/component/file/delete/dialog.vue b/frontend/src/component/file/delete/dialog.vue index 494ef0038..fb524a7d6 100644 --- a/frontend/src/component/file/delete/dialog.vue +++ b/frontend/src/component/file/delete/dialog.vue @@ -34,6 +34,7 @@ export default { default: false, }, }, + emits: ["close", "confirm"], data() { return {}; }, diff --git a/frontend/src/component/input/chip-selector.vue b/frontend/src/component/input/chip-selector.vue index 2dd8a6d99..f20a80d8d 100644 --- a/frontend/src/component/input/chip-selector.vue +++ b/frontend/src/component/input/chip-selector.vue @@ -6,7 +6,6 @@ :key="item.value || item.title" :text="getChipTooltip(item)" location="top" - :disabled="$vuetify?.display?.mobile === true" > @@ -51,7 +50,6 @@ class="chip-selector__input" @click:control="focusInput" @keydown.enter.prevent="onEnter" - @blur="addNewItem" @update:model-value="onComboboxChange" @update:menu="onMenuUpdate" > @@ -107,6 +105,10 @@ export default { type: Function, default: null, }, + maxLength: { + type: Number, + default: 0, + }, }, emits: ["update:items"], data() { @@ -142,7 +144,7 @@ export default { if (typeof this.normalizeTitleForCompare === "function") { try { return this.normalizeTitleForCompare(input); - } catch (e) { + } catch { return input.toLowerCase(); } } @@ -158,22 +160,28 @@ export default { } if (item.action === "add") { - classes.push(item.mixed ? `${baseClass}--green-light` : `${baseClass}--green`); + classes.push(item.mixed ? `${baseClass}--add-mixed` : `${baseClass}--add`); } else if (item.action === "remove") { - classes.push(item.mixed ? `${baseClass}--red-light` : `${baseClass}--red`); + classes.push(item.mixed ? `${baseClass}--remove-mixed` : `${baseClass}--remove`); } else if (item.mixed) { - classes.push(`${baseClass}--gray-light`); + classes.push(`${baseClass}--default-mixed`); } else { - classes.push(`${baseClass}--gray`); + classes.push(`${baseClass}--default`); } 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"; + if (item.action === "add") { + return "mdi-plus"; + } + if (item.action === "remove") { + return "mdi-minus"; + } + if (item.mixed) { + return "mdi-circle-half-full"; + } return null; }, @@ -189,7 +197,9 @@ export default { }, handleChipClick(item) { - if (this.loading || this.disabled) return; + if (this.loading || this.disabled) { + return; + } let newAction; @@ -256,14 +266,28 @@ export default { return; } - if (!title) return; + if (!title) { + return; + } + + // Block the create path when the typed title exceeds the configured cap — + // otherwise the backend setter clips with an ellipsis and the user is + // shown a green success against a renamed entity they didn't intend. + if (this.maxLength > 0 && title.length > this.maxLength) { + this.$notify.error(this.$gettext("%{s} is too long", { s: this.$gettext("Name") })); + 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; + if (resolved.title) { + title = resolved.title; + } + if (resolved.value) { + value = resolved.value; + } resolvedApplied = true; } } diff --git a/frontend/src/component/label/clipboard.vue b/frontend/src/component/label/clipboard.vue index bcb5a2c92..8ba81c07f 100644 --- a/frontend/src/component/label/clipboard.vue +++ b/frontend/src/component/label/clipboard.vue @@ -111,7 +111,7 @@ export default { Promise.all(uniqueAlbumUids.map((uid) => $api.post(`albums/${uid}/photos`, body))) .then(() => this.onAdded()) - .catch((error) => { + .catch(() => { $notify.error(this.$gettext("Some albums could not be updated")); }); }, diff --git a/frontend/src/component/label/delete/dialog.vue b/frontend/src/component/label/delete/dialog.vue index 97bf7104c..b0e2c1408 100644 --- a/frontend/src/component/label/delete/dialog.vue +++ b/frontend/src/component/label/delete/dialog.vue @@ -34,6 +34,7 @@ export default { default: false, }, }, + emits: ["close", "confirm"], data() { return {}; }, diff --git a/frontend/src/component/label/edit/dialog.vue b/frontend/src/component/label/edit/dialog.vue index fd40c055a..3f26ba2d6 100644 --- a/frontend/src/component/label/edit/dialog.vue +++ b/frontend/src/component/label/edit/dialog.vue @@ -13,18 +13,21 @@ > - - mdi-label -
{{ $gettext(`Edit %{s}`, { s: model.modelName() }) }}
-
+ + + {{ $gettext(`Edit %{s}`, { s: model.modelName() }) }} + + + mdi-close + + diff --git a/frontend/src/component/lightbox/sidebar/toolbar.vue b/frontend/src/component/lightbox/sidebar/toolbar.vue new file mode 100644 index 000000000..b9f4d497e --- /dev/null +++ b/frontend/src/component/lightbox/sidebar/toolbar.vue @@ -0,0 +1,71 @@ + + + diff --git a/frontend/src/component/loading-bar.vue b/frontend/src/component/loading-bar.vue index 891ee6c51..c24f646b7 100644 --- a/frontend/src/component/loading-bar.vue +++ b/frontend/src/component/loading-bar.vue @@ -168,7 +168,7 @@ export default { }, methods: { - beforeEnter(el) { + beforeEnter() { this.opacity = 0; this.progress = 0; this.width = 0; @@ -179,7 +179,7 @@ export default { done(); }, - afterEnter(el) { + afterEnter() { this._runStart(); }, diff --git a/frontend/src/component/map.vue b/frontend/src/component/map.vue index 38f13b154..a6d361a9a 100644 --- a/frontend/src/component/map.vue +++ b/frontend/src/component/map.vue @@ -1,5 +1,5 @@ diff --git a/frontend/src/component/meta/datetime/dialog.vue b/frontend/src/component/meta/datetime/dialog.vue new file mode 100644 index 000000000..4106308cd --- /dev/null +++ b/frontend/src/component/meta/datetime/dialog.vue @@ -0,0 +1,331 @@ + + + diff --git a/frontend/src/component/meta/face/markers.vue b/frontend/src/component/meta/face/markers.vue new file mode 100644 index 000000000..70e9b038b --- /dev/null +++ b/frontend/src/component/meta/face/markers.vue @@ -0,0 +1,1086 @@ + + + diff --git a/frontend/src/component/location/dialog.vue b/frontend/src/component/meta/location/dialog.vue similarity index 92% rename from frontend/src/component/location/dialog.vue rename to frontend/src/component/meta/location/dialog.vue index 2ebb00b46..c48f35602 100644 --- a/frontend/src/component/location/dialog.vue +++ b/frontend/src/component/meta/location/dialog.vue @@ -7,24 +7,20 @@ persistent scrim scrollable - class="p-location-dialog" - @keydown.esc.exact="close" + class="p-meta-location-dialog" + @keydown.esc.exact.stop="close" @after-enter="afterEnter" @after-leave="afterLeave" > - - - mdi-close - + {{ $gettext("Adjust Location") }} + + mdi-close + - - mdi-map-marker -
{{ $gettext("Adjust Location") }}
-
@@ -69,8 +65,9 @@ clearable autocomplete="off" no-filter - menu-icon="" + :menu-icon="null" :menu-props="{ maxHeight: 300 }" + :list-props="{ density: 'compact' }" @update:search="onSearchQueryChange" @update:model-value="onPlaceSelected" @click:clear="clearSearch" @@ -78,12 +75,12 @@ @@ -101,7 +98,7 @@
- + >
@@ -118,9 +115,10 @@ {{ $gettext("Cancel") }} diff --git a/frontend/src/component/upload/dialog.vue b/frontend/src/component/upload/dialog.vue index 17ed416a2..ac01fb71f 100644 --- a/frontend/src/component/upload/dialog.vue +++ b/frontend/src/component/upload/dialog.vue @@ -12,20 +12,15 @@ @keydown.esc.exact="onClose" > - - - - mdi-close - + {{ title }} + + mdi-close + - - mdi-cloud-upload -
{{ title }}
-
@@ -35,27 +30,42 @@ {{ $gettext(`Upload complete. Indexing…`) }} {{ $gettext(`Done.`) }} - {{ $gettext(`Insufficient storage.`) }} {{ $gettext(`Increase storage size or delete files to continue.`) }} - {{ $gettext(`Select the files to upload…`) }} + {{ $gettext(`Select the files to upload…`) }} + {{ $gettext(`Select or drop files to upload…`) }}
+ @@ -108,8 +118,14 @@ {{ $gettext(`Close`) }} - - {{ $gettext(`Browse`) }} + + {{ $gettext(`Upload`) }} @@ -121,6 +137,7 @@ import $api from "common/api"; import $notify from "common/notify"; import Album from "model/album"; import { createAlbumSelectionWatcher } from "common/albums"; +import { $gettext } from "common/gettext"; import { Duration } from "luxon"; export default { @@ -150,7 +167,7 @@ export default { loading: false, indexing: false, failed: false, - filesQuotaReached: this.$config.filesQuotaReached(), + insufficientStorage: this.$config.insufficientStorage(), current: 0, total: 0, totalSize: 0, @@ -172,6 +189,9 @@ export default { title() { return this.$gettext(`Upload`); }, + hasFiles() { + return Array.isArray(this.selected) && this.selected.length > 0; + }, }, watch: { visible: function (show) { @@ -287,8 +307,33 @@ export default { this.albumsMenu = false; this.suppressAlbumsMenuOpen = false; }, - onUploadDialog() { - this.$refs.upload.click(); + onFilesSelected(newFiles) { + const newArr = Array.isArray(newFiles) ? newFiles : newFiles ? [newFiles] : []; + const existing = Array.isArray(this.selected) ? this.selected : []; + + // Clear: empty array from the clearable button or a reset. + if (newArr.length === 0) { + this.selected = []; + return; + } + + // Remove: every file in the new set is already present by reference → + // this is a single-item removal emitted by VFileUploadItem's × button. + if (newArr.every((f) => existing.includes(f))) { + this.selected = newArr; + return; + } + + // Browse / drop: merge with existing selection, skip duplicates + // identified by name + size + lastModified so re-selecting the same + // file on a second browse pass does not add a second entry. + const merged = [...existing]; + for (const f of newArr) { + if (!merged.some((e) => e.name === f.name && e.size === f.size && e.lastModified === f.lastModified)) { + merged.push(f); + } + } + this.selected = merged; }, onUploadProgress(ev) { if (!ev || !ev.loaded || !ev.total) { @@ -335,25 +380,19 @@ export default { return; } - const files = this.$refs.upload.files; - // Too many files selected for upload? - if (this.isDemo && files && files.length > this.fileLimit) { + if (this.isDemo && this.selected && this.selected.length > this.fileLimit) { $notify.error(this.$gettext("Too many files selected")); return; } - this.selected = files; - this.total = files.length; - // No files selected? - if (!this.selected || this.total < 1) { + if (!this.selected || this.selected.length < 1) { return; } this.uploads = []; this.token = this.$util.generateToken(); - this.selected = this.$refs.upload.files; this.busy = true; this.indexing = false; this.failed = false; @@ -437,12 +476,12 @@ export default { }) .then(() => { ctx.reset(); - $notify.success(ctx.$gettext("Upload complete")); + $notify.success($gettext("Upload complete")); ctx.$emit("confirm"); }) .catch(() => { ctx.reset(); - $notify.error(ctx.$gettext("Upload failed")); + $notify.error($gettext("Upload failed")); }); }); }, diff --git a/frontend/src/css/app.css b/frontend/src/css/app.css index 04f5f5a59..dfbf9086b 100644 --- a/frontend/src/css/app.css +++ b/frontend/src/css/app.css @@ -1,6 +1,6 @@ /* -Copyright (c) 2018 - 2025 PhotoPrism UG. All rights reserved. +Copyright (c) 2018 - 2026 PhotoPrism UG. All rights reserved. This program is free software: you can redistribute it and/or modify it under Version 3 of the GNU Affero General Public License (the "AGPL"): @@ -13,7 +13,7 @@ Copyright (c) 2018 - 2025 PhotoPrism UG. All rights reserved. The AGPL is supplemented by our Trademark and Brand Guidelines, which describe how our Brand Assets may be used: - + Feel free to send an email to hello@photoprism.app if you have questions, want to support our work, or just want to say hello. @@ -28,6 +28,7 @@ Additional information can be found in our Developer Guide: @import url("root.css"); @import url("splash.css"); @import url("text.css"); +@import url("meta.css"); @import url("lightbox.css"); @import url("controls.css"); @import url("wallpapers.css"); diff --git a/frontend/src/css/auth.css b/frontend/src/css/auth.css index cfc13e2a4..861bd661f 100644 --- a/frontend/src/css/auth.css +++ b/frontend/src/css/auth.css @@ -16,46 +16,18 @@ main .auth-login { /* Auth Form Colors */ -main .auth-login .accent { - background-color: #05dde1 !important; -} - -main .auth-login .accent--text { - caret-color: #05dde1 !important; - color: #05dde1 !important; -} - -main .auth-login .primary { - background-color: #00a6a9 !important; -} - -main .auth-login .primary--text { - caret-color: #00a6a9 !important; - color: #00a6a9 !important; -} - -main .auth-login .secondary { - background-color: #505050 !important; -} - -main .auth-login .secondary--text { - caret-color: #505050 !important; - color: #505050 !important; -} - -main .auth-login .link { - background-color: #c8e3e7 !important; -} - -main .auth-login .link--text { - caret-color: #c8e3e7 !important; - color: #c8e3e7 !important; -} - main .auth-actions .auth-links { min-height: 24px; } +main .auth-actions__options { + opacity: 0.95; + padding-bottom: 4px; + align-items: center; + justify-content: center; + display: flex; +} + main .auth-login a, main .auth-login .clickable { color: rgb(var(--v-theme-secondary, #c8e3e7)); @@ -176,6 +148,45 @@ main .auth-login .text-subtitle-2, margin: 0; } +/* Instance Chooser */ + +main .auth-login .instance-results.v-table { + background: transparent; +} + +main .auth-login .instance-results .v-table__wrapper > table > thead > tr > th { + background: transparent !important; + border-bottom: 1px solid rgba(255, 255, 255, 0.16) !important; + font-size: 0.72rem; + letter-spacing: 0.04em; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.6) !important; +} + +main .auth-login .instance-results .v-table__wrapper > table > tbody > tr > td { + background: transparent !important; + border-bottom: 1px solid rgba(255, 255, 255, 0.08) !important; + color: #ffffff; +} + +main .auth-login .instance-results .v-table__wrapper > table > tbody > tr:last-child > td { + border-bottom: none !important; +} + +main .auth-login .instance-chooser-row { + cursor: pointer; +} + +main .auth-login .instance-results .action-open-instance { + font-weight: 500; + color: rgb(var(--v-theme-secondary, #c8e3e7)); + cursor: pointer; +} + +main .auth-login .instance-results .instance-url { + color: rgba(255, 255, 255, 0.6); +} + /* Account Page */ .user-avatar { diff --git a/frontend/src/css/chip-selector.css b/frontend/src/css/chip-selector.css index a8875e165..8bc8acd45 100644 --- a/frontend/src/css/chip-selector.css +++ b/frontend/src/css/chip-selector.css @@ -1,112 +1,130 @@ /* Chip Selector Styles */ .chip-selector { - display: flex; - flex-direction: column; - gap: 12px; + display: flex; + flex-direction: column; + gap: 12px; } .chip-selector__title { - font-weight: 500; - font-size: 14px; - color: rgba(var(--v-theme-on-surface), 0.87); - margin-bottom: 8px; + font-weight: 500; + font-size: 14px; + color: rgba(var(--v-theme-on-surface), 0.87); + margin-bottom: 8px; } .chip-selector__chips { - display: flex; - flex-wrap: wrap; - gap: 8px; - min-height: 40px; - align-items: flex-start; + display: flex; + flex-wrap: wrap; + gap: 8px; + min-height: 40px; + align-items: flex-start; } .chip-selector__input-container { - margin-top: 8px; + margin-top: 8px; } .chip-selector__input { - width: 100%; + width: 100%; } /* Chip Styles */ .chip { - display: inline-flex; - align-items: center; - padding: 6px 12px; - border-radius: 16px; - font-size: 13px; - font-weight: 500; - cursor: pointer; - user-select: none; - min-height: 32px; - transition: all 0.2s ease; + display: inline-flex; + align-items: center; + padding: 6px 12px; + border-radius: 16px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + user-select: none; + min-height: 32px; + max-width: 100%; + min-width: 0; + transition: all 0.2s ease; } .chip__content { - display: flex; - align-items: center; - gap: 6px; + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + max-width: 100%; } .chip__icon { - font-size: 16px; - opacity: 0.9; + font-size: 16px; + opacity: 0.9; + flex-shrink: 0; } -/* Chip States */ -.chip--gray { - background-color: rgb(var(--v-theme-secondary-light, var(--v-theme-surface-bright, var(--v-theme-surface)))); - color: rgb(var(--v-theme-on-surface)); - box-shadow: inset 0 0 0 1px rgba(var(--v-theme-on-surface), 0.12); +/* Allow the label to shrink within the chip's inline-flex layout so + Vuetify's `text-truncate` (overflow + ellipsis + nowrap) can engage on + single-word titles like `WWWWWWW…`; the chip itself is constrained by + `max-width: 100%` against its flex-wrap parent. */ +.chip__text { + min-width: 0; } -.chip--gray-light { - background-color: rgba(var(--v-theme-secondary-light, var(--v-theme-surface-bright, var(--v-theme-surface))), 0.55); - color: rgb(var(--v-theme-on-surface)); - box-shadow: inset 0 0 0 1px rgba(var(--v-theme-on-surface), 0.12); +/* Chip States — class names mirror the `item.action` value the chip-selector + emits (`add` / `remove` / default), and `--mixed` is the partial-selection + variant (action applies to only some of the selected items). Selectors are + doubled (`.chip.chip--*`) so the `color` rules out-rank the UA-level + `[role="button"] { color: inherit }` reset, which would otherwise strip the + on-token foreground from a solid chip rendered as a button. */ +.chip.chip--default { + background-color: rgb(var(--v-theme-secondary-light, var(--v-theme-surface-bright, var(--v-theme-surface)))); + color: rgb(var(--v-theme-on-surface)); + box-shadow: inset 0 0 0 1px rgba(var(--v-theme-on-surface), 0.12); } -.chip--green { - background-color: rgb(var(--v-theme-download)); - color: rgb(var(--v-theme-on-download)); +.chip.chip--default-mixed { + background-color: rgba(var(--v-theme-secondary-light, var(--v-theme-surface-bright, var(--v-theme-surface))), 0.55); + color: rgb(var(--v-theme-on-surface)); + box-shadow: inset 0 0 0 1px rgba(var(--v-theme-on-surface), 0.12); } -.chip--green-light { - background-color: rgba(var(--v-theme-download), 0.3); - color: rgb(var(--v-theme-download)); +.chip.chip--add { + background-color: rgba(var(--v-theme-add), 0.8); + color: rgb(var(--v-theme-on-add)); } -.chip--red { - background-color: rgb(var(--v-theme-error)); - color: rgb(var(--v-theme-on-error)); +.chip.chip--add-mixed { + background-color: rgba(var(--v-theme-add-darken-1), 0.45); + color: rgb(var(--v-theme-on-add)); } -.chip--red-light { - background-color: rgba(var(--v-theme-error), 0.3); - color: rgb(var(--v-theme-error)); +.chip.chip--remove { + background-color: rgba(var(--v-theme-remove), 0.8); + color: rgb(var(--v-theme-on-remove)); +} + +.chip.chip--remove-mixed { + background-color: rgba(var(--v-theme-remove-darken-1), 0.45); + color: rgb(var(--v-theme-on-remove)); } /* Empty state */ .chip-selector__empty { - padding: 16px; - text-align: center; - color: rgba(var(--v-theme-on-surface), 0.6); - font-style: italic; + padding: 16px; + text-align: center; + color: rgba(var(--v-theme-on-surface), 0.6); + font-style: italic; } /* Mobile optimizations */ @media (max-width: 600px) { - .chip-selector__chips { - gap: 6px; - } - - .chip { - padding: 4px 8px; - font-size: 12px; - min-height: 28px; - } - - .chip__icon { - font-size: 14px; - } + .chip-selector__chips { + gap: 6px; + } + + .chip { + padding: 4px 8px; + font-size: 12px; + min-height: 28px; + } + + .chip__icon { + font-size: 14px; + } } diff --git a/frontend/src/css/controls.css b/frontend/src/css/controls.css index d038e8ba6..452cce89a 100644 --- a/frontend/src/css/controls.css +++ b/frontend/src/css/controls.css @@ -256,6 +256,12 @@ table td > .action-buttons { padding: 8px; } +.nav-user-menu__icon { + width: 24px; + height: 24px; + object-fit: contain; +} + .v-menu.action-menu .action-menu__list .action-menu__shortcut { margin-inline-start: 0.75rem; opacity: 0.5; @@ -281,3 +287,15 @@ table .table-actions { gap: 12px; } } + +/* Portal Cluster Access: the label-less in-table role selector. Pin it to the + standard label-less input height (it would otherwise collapse to the text + line) and inset the selected value so it is not flush against the cell edge. */ + +.instance-role-select .v-field { + min-height: 40px; +} + +.instance-role-select .v-field__input { + padding-inline-start: 12px !important; +} diff --git a/frontend/src/css/lightbox.css b/frontend/src/css/lightbox.css index 5245f6266..1d6c6dc80 100644 --- a/frontend/src/css/lightbox.css +++ b/frontend/src/css/lightbox.css @@ -1,4 +1,4 @@ -/* Media Viewer Styles */ +/* Lightbox Styles */ .p-lightbox { position: fixed; @@ -67,7 +67,14 @@ z-index: 1; } -/* Sidebar Info */ +/* Lightbox Tooltips */ + +.v-tooltip.v-theme--lightbox > .v-overlay__content { + background: rgba(var(--v-theme-surface), var(--v-medium-emphasis-opacity)); + color: rgb(var(--v-theme-on-surface)); +} + +/* Lightbox Sidebar */ .p-lightbox__container > .p-lightbox__sidebar { position: relative; @@ -114,23 +121,314 @@ user-select: text; } +/* Reset Vuetify's `v-list nav` cursor:pointer on non-clickable rows so the + browser picks context-appropriate cursors (text I-beam, link pointer). */ +.p-lightbox__container > .p-lightbox__sidebar .metadata__item:not(.clickable) { + cursor: auto; +} + +.p-lightbox__container > .p-lightbox__sidebar .metadata__item .v-list-item-title, +.p-lightbox__container > .p-lightbox__sidebar .metadata__item .v-list-item-subtitle { + white-space: normal; + overflow: visible; + text-overflow: unset; +} + .p-lightbox__container > .p-lightbox__sidebar .meta-title { font-weight: 700; word-wrap: normal; word-break: break-word; - text-align: start; hyphens: auto; } -.p-lightbox__container > .p-lightbox__sidebar .meta-caption { +.p-lightbox__container > .p-lightbox__sidebar .meta-caption, +.p-lightbox__container > .p-lightbox__sidebar .meta-notes { white-space: pre-wrap; overflow-wrap: normal; word-wrap: normal; word-break: break-word; - text-align: start; hyphens: auto; } +.p-lightbox__container > .p-lightbox__sidebar .meta-scrollable { + max-height: 120px; + overflow-y: auto; + overflow-x: hidden; + scrollbar-width: thin; + scrollbar-color: rgba(var(--v-theme-on-surface), 0.2) transparent; +} + +.p-lightbox__container > .p-lightbox__sidebar .meta-scrollable.meta-caption { + max-height: 320px; +} + +.p-lightbox__container > .p-lightbox__sidebar .meta-scrollable::-webkit-scrollbar { + width: 4px; +} + +.p-lightbox__container > .p-lightbox__sidebar .meta-scrollable::-webkit-scrollbar-track { + background: transparent; +} + +.p-lightbox__container > .p-lightbox__sidebar .meta-scrollable::-webkit-scrollbar-thumb { + background: rgba(var(--v-theme-on-surface), 0.2); + border-radius: 2px; +} + +.p-lightbox__container > .p-lightbox__sidebar .meta-scrollable::-webkit-scrollbar-thumb:hover { + background: rgba(var(--v-theme-on-surface), 0.4); +} + +.p-lightbox__container > .p-lightbox__sidebar .meta-keywords { + word-break: break-word; + text-align: start; +} + +.p-lightbox__container > .p-lightbox__sidebar .meta-chip { + display: inline-flex; + align-items: center; + padding: 6px 12px; + border-radius: 16px; + font-size: 13px; + font-weight: 500; + line-height: 1.4; + cursor: pointer; + user-select: none; + min-height: 32px; + max-width: 100%; + min-width: 0; + transition: all 0.2s ease; + background-color: rgb(var(--v-theme-secondary-light, var(--v-theme-surface-bright, var(--v-theme-surface)))); + color: rgb(var(--v-theme-on-surface)); + box-shadow: inset 0 0 0 1px rgba(var(--v-theme-on-surface), 0.12); +} + +/* Allow the label to shrink within the chip's inline-flex layout so + Vuetify's `text-truncate` (overflow + ellipsis + nowrap) can engage on + single-word titles like `WWWWWWW…`; the chip itself is constrained by + `max-width: 100%` against its flex-wrap parent. */ +.p-lightbox__container > .p-lightbox__sidebar .meta-chip__label { + min-width: 0; +} + +.p-lightbox__container > .p-lightbox__sidebar .meta-chip:hover { + background-color: rgba(var(--v-theme-secondary-light, var(--v-theme-surface-bright, var(--v-theme-surface))), 0.7); +} + +/* Keyboard focus ring drawn inside the chip (negative offset) so it isn't + clipped when chips wrap against the sidebar edge. */ +.p-lightbox__container > .p-lightbox__sidebar .meta-chip:focus { + outline: 2px solid rgba(var(--v-theme-on-surface), 0.5); + outline-offset: -2px; +} + +/* Suppress the ring on mouse focus where supported; click on a chip already + navigates or toggles, so a transient outline there is just visual noise. */ +.p-lightbox__container > .p-lightbox__sidebar .meta-chip:focus:not(:focus-visible) { + outline: none; +} + +.p-lightbox__container > .p-lightbox__sidebar .meta-chip--primary { + background-color: rgb(var(--v-theme-secondary-light, var(--v-theme-surface-bright, var(--v-theme-surface)))); + color: rgb(var(--v-theme-on-surface)); + box-shadow: inset 0 0 0 1px rgba(var(--v-theme-on-surface), 0.12); +} + +.p-lightbox__container > .p-lightbox__sidebar .meta-chip--primary:hover { + background-color: rgba(var(--v-theme-secondary-light, var(--v-theme-surface-bright, var(--v-theme-surface))), 0.7); +} + +.p-lightbox__container > .p-lightbox__sidebar .meta-chip .v-icon { + opacity: 0.7; + cursor: pointer; + flex-shrink: 0; +} + +.p-lightbox__container > .p-lightbox__sidebar .meta-chip .v-icon:hover { + opacity: 1; +} + +/* Lightweight native icon buttons (B1: clickable controls are real buttons + for keyboard activation + focus ring + button role) used inside bespoke + chrome where Vuetify's box model would fight the compact layout. */ +.p-lightbox__container .meta-icon-btn { + -webkit-appearance: none; + appearance: none; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + margin: 0; + padding: 0; + border: 0; + background: transparent; + color: inherit; + font: inherit; + line-height: 0; + cursor: pointer; +} + +.p-lightbox__container .meta-icon-btn:focus-visible { + outline: 2px solid currentColor; + outline-offset: 2px; + border-radius: 50%; +} + +.p-lightbox__container .meta-icon-btn:disabled { + cursor: default; + opacity: 0.5; +} + +.p-lightbox__container > .p-lightbox__sidebar .meta-chip__remove:hover .v-icon { + opacity: 1; +} + +/* Person row in sidebar */ +.p-lightbox__container > .p-lightbox__sidebar .metadata__person-row { + min-height: 44px; +} + +.p-lightbox__container > .p-lightbox__sidebar .meta-person__avatar { + width: 40px; + height: 40px; + border-radius: 8px; + object-fit: cover; + margin-right: 12px; +} + +.p-lightbox__container > .p-lightbox__sidebar .meta-person__name { + font-size: 14px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Inline edit fields in sidebar */ +.p-lightbox__container > .p-lightbox__sidebar .meta-inline-edit { + margin: 0; +} + +.p-lightbox__container > .p-lightbox__sidebar .v-input--density-compact { + /* Sets the v-text-field control height and the floor for auto-grown + textareas. Do not add `min-height` on .v-field__input — it would + shadow Vuetify's auto-grow inheritance and freeze textareas at 36 px. */ + --v-input-control-height: 36px; +} + +.p-lightbox__container > .p-lightbox__sidebar .v-input--density-compact .v-field { + --v-field-input-padding-top: 6px; + --v-field-input-padding-bottom: 6px; + --v-field-padding-start: 10px; + --v-field-padding-end: 10px; + --v-field-padding-after: 0; + --v-field-padding-top: 6px; + --v-field-padding-bottom: 6px; + padding-inline-end: 4px; +} + +.p-lightbox__container > .p-lightbox__sidebar .v-input--density-compact .v-field .v-field__input { + font-size: 0.875rem; +} + +/* Thin themed scrollbar for textareas; legible against the dark surface on + Firefox/Chromium. Do not unset min-height — see the comment above. */ +.p-lightbox__container > .p-lightbox__sidebar .meta-inline-edit textarea.v-field__input { + scrollbar-width: thin; + scrollbar-color: rgba(var(--v-theme-on-surface), 0.2) transparent; +} + +.p-lightbox__container > .p-lightbox__sidebar .meta-inline-edit textarea.v-field__input::-webkit-scrollbar { + width: 4px; +} + +.p-lightbox__container > .p-lightbox__sidebar .meta-inline-edit textarea.v-field__input::-webkit-scrollbar-thumb { + background: rgba(var(--v-theme-on-surface), 0.2); + border-radius: 2px; +} + +.p-lightbox__container > .p-lightbox__sidebar .meta-inline-title .v-field__input { + font-weight: 700; +} + +.p-lightbox__container > .p-lightbox__sidebar .meta-inline-caption .v-field__input { + white-space: pre-wrap; + overflow-wrap: normal; + word-wrap: normal; + word-break: break-word; +} + +/* Drop Vuetify's top fade-mask on auto-grown inline textareas — without + a scroll viewport, the mask just dims the first line of text. */ +.p-lightbox__container > .p-lightbox__sidebar .meta-inline-edit .v-field__input, +.p-lightbox__container > .p-lightbox__sidebar .meta-inline-edit .v-textarea__sizer { + mask-image: none; +} + +/* Pencil button: hidden by default, revealed on list-item hover. */ +.p-lightbox__container > .p-lightbox__sidebar .meta-inline-pencil { + display: none; +} + +.p-lightbox__container > .p-lightbox__sidebar .metadata__item:hover > .v-list-item__append .meta-inline-pencil { + display: inline-flex; +} + +/* `.hide-edit-pencils` on the sidebar root hides every pencil even on hover; + row clicks and chip-toolbar Save/Undo remain reachable. */ +.p-lightbox__container > .p-lightbox__sidebar > .p-lightbox-sidebar.hide-edit-pencils .metadata__item:hover > .v-list-item__append .meta-inline-pencil, +.p-lightbox__container > .p-lightbox__sidebar > .p-lightbox-sidebar.hide-edit-pencils .meta-inline-pencil { + display: none; +} + +/* Hide inline-text Undo/Save (keyboard covers commit/cancel/revert); chip + toolbars opt out via `meta-chip-*`. Keep `:not()` on the hide rule itself + so the minifier doesn't collapse it into `:is()` and break the combinator. */ +.p-lightbox__container > .p-lightbox__sidebar > .p-lightbox-sidebar.hide-edit-undo .meta-inline-undo:not(.meta-chip-undo) { + display: none; +} + +.p-lightbox__container > .p-lightbox__sidebar > .p-lightbox-sidebar.hide-edit-save .meta-inline-confirm:not(.meta-chip-confirm) { + display: none; +} + +/* Add prompt for empty editable fields */ +.p-lightbox__container > .p-lightbox__sidebar .meta-add-prompt { + color: rgba(var(--v-theme-on-surface), 0.35); + font-size: 0.875rem; + cursor: pointer; + transition: color 0.15s ease; +} + +.p-lightbox__container > .p-lightbox__sidebar .meta-add-prompt:hover { + color: rgba(var(--v-theme-on-surface), 0.6); +} + +/* Per-row marker buttons (× reject, ⏏ Unassign) — hidden by default, revealed + on row hover so a non-hovered row has no nested focusable targets. */ +.p-lightbox__container > .p-lightbox__sidebar .meta-marker-remove, +.p-lightbox__container > .p-lightbox__sidebar .meta-marker-clear-subject { + display: none; +} + +.p-lightbox__container > .p-lightbox__sidebar .metadata__person-row:hover .meta-marker-remove, +.p-lightbox__container > .p-lightbox__sidebar .metadata__person-row:hover .meta-marker-clear-subject { + display: inline-flex; +} + +.p-lightbox__container > .p-lightbox__sidebar .meta-inline-edit .v-combobox__selection, +.p-lightbox__container > .p-lightbox__sidebar .meta-inline-edit .v-autocomplete__selection, +.p-lightbox__container > .p-lightbox__sidebar .meta-inline-edit .v-select__selection, +.p-lightbox__container > .p-lightbox__sidebar .meta-inline-edit .v-field--focused .v-label.v-field-label { + color: rgb(var(--v-theme-on-surface)) !important; +} + +/* density="compact" leaves the v-list title at 16 px; trim it so the menu + matches the sidebar's 0.875 rem inline editors. */ +.meta-inline-menu .v-list-item-title { + font-size: 0.8125rem; + line-height: 1.125rem; +} + /* Media Content */ .pswp__content { @@ -255,6 +553,33 @@ visibility: visible; } +.p-lightbox__content.face-marker-mode .pswp__media.pswp__media--video .pswp__video, +.p-lightbox__content.face-marker-mode .pswp__media.pswp__media--live .pswp__video, +.p-lightbox__content.face-marker-mode .pswp__media.pswp__media--animated .pswp__video, +.p-lightbox__content.face-marker-mode .pswp__media.pswp__media--video .pswp__play, +.p-lightbox__content.face-marker-mode .pswp__media.pswp__media--live .pswp__play, +.p-lightbox__content.face-marker-mode .pswp__media.pswp__media--animated .pswp__play { + visibility: hidden !important; +} + +.p-lightbox__content.face-marker-mode .pswp__media.pswp__media--video .pswp__image, +.p-lightbox__content.face-marker-mode .pswp__media.pswp__media--live .pswp__image, +.p-lightbox__content.face-marker-mode .pswp__media.pswp__media--animated .pswp__image { + visibility: visible !important; +} + +/* Hide every chrome element while face-marker mode is active; the overlay's + Back button (.p-meta-face-markers__btn--back) is the sole exit affordance. + Sidebar chrome lives in a different region and stays reachable. */ +.p-lightbox__content.face-marker-mode .pswp__top-bar .pswp__button, +.p-lightbox__content.face-marker-mode .pswp__button--arrow, +.p-lightbox__content.face-marker-mode .pswp__dynamic-caption, +.p-lightbox__content.face-marker-mode .pswp__counter, +.p-lightbox__content.face-marker-mode .p-lightbox__controls { + visibility: hidden !important; + pointer-events: none !important; +} + .pswp__img { -webkit-touch-callout: default; } @@ -368,20 +693,6 @@ touch-action: none; } -.pswp__dynamic-caption a { - color: inherit; - text-decoration: underline; - white-space: normal; - overflow-wrap: normal; - word-wrap: normal; - word-break: break-word; - text-align: start; - text-overflow: ellipsis; - hyphens: auto; - pointer-events: auto; - touch-action: auto; -} - .pswp__dynamic-caption h4 { font-weight: 600; word-wrap: normal; @@ -398,6 +709,13 @@ white-space: pre-wrap; } +/* User-toggled caption visibility (Ctrl+H, #5580). */ + +.p-lightbox__container > .p-lightbox__content.hide-caption .pswp__dynamic-caption { + display: none !important; + transition: none !important; +} + /* Top Bar Controls */ .pswp__top-bar { @@ -501,6 +819,11 @@ padding-inline-end: 8px; } +.p-lightbox__controls .video-btn:hover, +.p-lightbox__controls .video-btn:focus-visible { + color: #ffffff; +} + .p-lightbox__controls .v-slider .v-slider-thumb { color: #ffffff; } @@ -621,3 +944,14 @@ .p-lightbox__container > .p-lightbox__content > .pswp--touch .pswp__button--arrow .pswp__icn { display: none; } + +/* On 360° sphere slides, swipe is captured for panning the sphere instead of switching + photos, so the navigation arrows must stay visible on touch devices — where PhotoSwipe + hides them with `visibility: hidden` because a swipe normally navigates. The + `pswp--sphere` marker is set on the PhotoSwipe root only while a 360° slide is active. + Only `visibility` is overridden (not `display`) so disabled arrows at the first/last + photo stay hidden via PhotoSwipe's `:disabled { display: none }` rule. */ + +.pswp--touch.pswp--sphere .pswp__button--arrow { + visibility: visible; +} diff --git a/frontend/src/css/meta.css b/frontend/src/css/meta.css new file mode 100644 index 000000000..6a2cbe9e8 --- /dev/null +++ b/frontend/src/css/meta.css @@ -0,0 +1,166 @@ +/* Metadata Styles */ + +/* Camera & Datetime Dialogs */ + +/* Keep labels and selection text readable when fields are focused. + Vuetify 3 applies the focused color via an inline style, so overriding + it requires !important. */ +.p-meta-camera-dialog .v-field--focused .v-label.v-field-label, +.p-meta-camera-dialog .v-field--focused .v-label.v-field-label--floating, +.p-meta-datetime-dialog .v-field--focused .v-label.v-field-label, +.p-meta-datetime-dialog .v-field--focused .v-label.v-field-label--floating { + color: rgb(var(--v-theme-on-surface)) !important; + opacity: var(--v-medium-emphasis-opacity, 0.7); +} + +/* Face Markers */ + +.p-meta-face-markers { + position: absolute; + inset: 0; + z-index: 2; + user-select: none; + /* Stays transparent so PhotoSwipe pan/zoom keeps working; .is-edit + captures the pointer. */ + pointer-events: none; + touch-action: auto; + cursor: default; +} + +.p-meta-face-markers.is-edit { + pointer-events: auto; + touch-action: none; + cursor: crosshair; +} + +.p-meta-face-markers__svg { + pointer-events: none; + overflow: visible; +} + +.p-meta-face-markers__rect { + fill: rgba(var(--v-theme-on-surface), 0.05); + stroke: rgba(var(--v-theme-on-surface), 0.9); + stroke-width: 2px; + vector-effect: non-scaling-stroke; + pointer-events: none; +} + +.p-meta-face-markers__rect--named { + stroke: rgba(var(--v-theme-primary), 0.98); /* rgba(167, 139, 250, 0.98);*/ + cursor: default; +} + +.p-meta-face-markers__rect--draft { + stroke: rgba(var(--v-theme-accent), 1); + stroke-dasharray: 6 4; + fill: rgba(var(--v-theme-accent), 0.1); + pointer-events: none; + z-index: 3; +} + +/* Visual emphasis on a marker that the user has clicked for removal — + the inline remove-confirm pill is anchored below it. Red stroke + plus a faint red fill makes the destructive intent obvious. */ +.p-meta-face-markers__rect--removing { + stroke: rgba(var(--v-theme-remove), 0.95); + fill: rgba(var(--v-theme-remove), 0.16); + stroke-width: 2.5px; +} + +/* Hover highlight driven by sidebar People-row hover. Listed after --named + so it overrides the named-marker stroke color too. */ +.p-meta-face-markers__rect--hovered { + stroke: rgba(var(--v-theme-primary-darken-1), 1); + stroke-width: 4px; + fill: none; + filter: drop-shadow(0 0 4px rgba(var(--v-theme-primary-darken-1), 0.5)); +} + +.p-meta-face-markers__handle { + fill: rgba(var(--v-theme-primary-darken-1), 1); /* rgba(167, 139, 250, 1); */ + stroke: rgba(var(--v-theme-primary), 0.95); /* rgba(70, 40, 140, 0.95); */ + stroke-width: 1.5px; + vector-effect: non-scaling-stroke; + pointer-events: none; +} + +.p-meta-face-markers__label { + font-size: 13px; + font-weight: 600; + fill: #fff; + stroke: rgba(0, 0, 0, 0.85); + stroke-width: 3px; + paint-order: stroke fill; + pointer-events: none; + user-select: none; + dominant-baseline: hanging; +} + +.p-meta-face-markers__confirm, +.p-meta-face-markers__remove-confirm { + display: flex; + gap: 10px; + pointer-events: auto; + z-index: 3; +} + +.p-meta-face-markers__btn { + width: 32px; + height: 32px; + border-radius: 50%; + border: none; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.45); + color: rgb(var(--v-theme-on-surface)); + transition: transform 0.08s ease-out; +} + +.p-meta-face-markers__btn:hover { + transform: scale(1.08); +} + +.p-meta-face-markers__btn--confirm { + background: rgba(var(--v-theme-primary-darken-2), 0.95); +} + +.p-meta-face-markers__btn--cancel { + background: rgba(var(--v-theme-highlight-lighten-1), 0.95); +} + +.p-meta-face-markers__btn--remove { + background: rgba(var(--v-theme-remove), 0.95); +} + +/* Back button — anchored top-left in LTR / top-right in RTL with its own + pointer-events so it works regardless of the overlay root's pointer state. */ +.p-meta-face-markers__btn--back { + position: absolute; + top: 12px; + background: rgba(var(--v-theme-surface), 0.7); + backdrop-filter: blur(4px); + pointer-events: auto; + z-index: 4; +} + +.is-ltr .p-meta-face-markers__btn--back { + left: 12px; +} + +.is-rtl .p-meta-face-markers__btn--back { + right: 12px; +} + +.p-meta-face-markers__btn--back:hover { + background: rgba(var(--v-theme-surface), 0.85); +} + +.p-meta-face-markers__btn.is-disabled, +.p-meta-face-markers__btn[disabled] { + cursor: default; + opacity: 0.6; + transform: none; +} diff --git a/frontend/src/css/navigation.css b/frontend/src/css/navigation.css index 83e3d5580..23bdc27fd 100644 --- a/frontend/src/css/navigation.css +++ b/frontend/src/css/navigation.css @@ -20,6 +20,10 @@ nav .v-list__item__title.title { color: #ffffff77; } +.v-avatar.nav-logo { + border-radius: 0; +} + #p-navigation .nav-logo a, #p-navigation .nav-toolbar-title a, #p-navigation .navigation-home a { @@ -54,6 +58,33 @@ nav .v-list__item__title.title { margin-right: auto; } +#p-navigation .nav-user-activator { + display: flex; + align-items: center; + flex-grow: 1; + gap: 8px; + min-width: 0; + padding: 2px 4px; + border-radius: 8px; + cursor: pointer; +} + +#p-navigation .nav-user-activator .nav-user-text { + min-width: 0; + overflow: hidden; +} + +#p-navigation .nav-user-activator .nav-user-text p { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.nav-sidebar.v-navigation-drawer--rail .nav-container > .nav-info > .nav-user-activator { + flex-grow: 0; + justify-content: center; +} + #p-navigation .nav-sidebar .nav-logo { width: 40px; text-align: inherit; diff --git a/frontend/src/css/places.css b/frontend/src/css/places.css index 42e509980..6b8af3585 100644 --- a/frontend/src/css/places.css +++ b/frontend/src/css/places.css @@ -239,7 +239,20 @@ overflow: hidden; } -.p-location-dialog .p-map { +/* MapLibre renders the default marker as a
diff --git a/frontend/src/page/about/license.vue b/frontend/src/page/about/license.vue index a6f931bc6..15c40ca7a 100644 --- a/frontend/src/page/about/license.vue +++ b/frontend/src/page/about/license.vue @@ -547,7 +547,7 @@ (a) PhotoPrism’s Brand Assets — including trademarks, logos, icons, fonts, corporate design, product and service names, and any other brand features and elements, whether registered or unregistered („Brand Assets“) — are proprietary assets owned exclusively by PhotoPrism UG („PhotoPrism“). We reserve the right to object to any use or misuse in any jurisdiction worldwide. Visit - photoprism.app/trademark + photoprism.app/trademark to learn more.

diff --git a/frontend/src/page/admin.vue b/frontend/src/page/admin.vue index 7d963ba69..2e76493d5 100644 --- a/frontend/src/page/admin.vue +++ b/frontend/src/page/admin.vue @@ -1,17 +1,13 @@ - diff --git a/frontend/src/page/auth/login.vue b/frontend/src/page/auth/login.vue index 032c8a3e6..c2481e865 100644 --- a/frontend/src/page/auth/login.vue +++ b/frontend/src/page/auth/login.vue @@ -115,7 +115,7 @@ -

+
+
+ +
-