diff --git a/.env.photoprism.example b/.env.photoprism.example new file mode 100644 index 0000000..e165254 --- /dev/null +++ b/.env.photoprism.example @@ -0,0 +1,69 @@ +# PhotoPrism stack — example environment file. +# +# Copy to `.env.photoprism` and adjust. The compose file is invoked with +# `--env-file .env.photoprism` so this never collides with the legacy +# `.env` used by the mule-image stack. +# +# docker compose --env-file .env.photoprism -f docker-compose.photoprism.yml up -d + + +# ── REQUIRED ───────────────────────────────────────────────────────────────── + +# Host path to your photo library. PhotoPrism reads this in place and +# (post-M2) writes EXIF backwrites next to originals. Same path the legacy +# mule-image backend used. +PHOTO_DIRS=/mnt/library/homecloud/admin/files/ + +# Bootstrap admin password. The first PhotoPrism boot creates an `admin` +# account with this password. Rotate after first login from the UI. +PP_ADMIN_PASSWORD=please-change-me + +# MariaDB passwords. Generate with `openssl rand -hex 24`. +PP_DB_PASSWORD=please-change-me +PP_DB_ROOT_PASSWORD=please-change-me + + +# ── OPTIONAL ───────────────────────────────────────────────────────────────── + +# Host port for direct PhotoPrism UI access during M0–M3. Moves behind a +# Caddy reverse proxy at M4; keep this open through then for debugging. +PP_PORT=2342 + +# Site URL — used for share links, OIDC redirect URI, and reverse-proxy aware +# URL generation. Set to the public hostname once the proxy is in front. +PP_SITE_URL=http://localhost:2342/ + +# Auth mode — "password" for username/password (default), "public" for an +# unauthenticated kiosk mode (don't use this on a multi-user library). +PP_AUTH_MODE=password + +# Library mount mode. M0 = "ro" (safe initial validation). Flip to "rw" at M2 +# when the right-sidebar enables EXIF backwrite. Set in lockstep with +# PP_READONLY below. +PP_ORIGINALS_MODE=ro +PP_READONLY=true + +# UID/GID inside the PhotoPrism container. Set these to the host UID/GID that +# owns ${PHOTO_DIRS}. `id -u` and `id -g`. +PP_UID=1000 +PP_GID=1000 + + +# ── OIDC SSO (Authentik or equivalent) ─────────────────────────────────────── +# Leave blank to keep OIDC dormant. Fill in to enable the "Sign in with OIDC" +# button on the login page; per plan, OIDC_REGISTER=true auto-creates +# accounts at role `user`. + +# OIDC_PROVIDER_NAME=Authentik +# OIDC_ISSUER_URL=https://auth.example.com/application/o/photoprism/ +# OIDC_CLIENT_ID=... +# OIDC_CLIENT_SECRET=... +# OIDC_REDIRECT_URI=http://localhost:2342/api/v1/oidc/redirect +# OIDC_SCOPES=openid profile email +# OIDC_REGISTER=true +# OIDC_ROLE=user + + +# ── LOGGING ────────────────────────────────────────────────────────────────── + +PP_LOG_LEVEL=info diff --git a/.gitignore b/.gitignore index 2fe2140..66c8702 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,7 @@ dist-ssr/ .env .env.local .env.*.local +.env.photoprism # Database *.db @@ -61,6 +62,16 @@ build/ # Docker docker-compose.override.yml +# PhotoPrism state (sidecars, cache, thumbs, db backups) — regenerable. +/pp/storage/ +/pp/import/ + +# Local-dev sample photo library used for M0 PhotoPrism validation. +/photos-sample/ + +# Sidecar runtime state (per-user marks etc.) — generated, not seed data. +/sidecar/data/ + # Photos (for development) /photos/ diff --git a/docker-compose.photoprism.podman.yml b/docker-compose.photoprism.podman.yml new file mode 100644 index 0000000..8d7e5ea --- /dev/null +++ b/docker-compose.photoprism.podman.yml @@ -0,0 +1,28 @@ +# Podman-rootless overlay for the PhotoPrism stack. +# +# Apply alongside the base compose file: +# podman-compose --env-file .env.photoprism \ +# -f docker-compose.photoprism.yml \ +# -f docker-compose.photoprism.podman.yml \ +# up -d +# +# Adds the podman-specific bits that would break a vanilla docker compose run: +# - userns_mode: keep-id maps container UID to the invoking host UID, so +# PhotoPrism (running as PP_UID:PP_GID inside) can actually read the +# bind-mounted originals volume on the host (which is owned by the host +# user, not by uid 1000-in-the-container-namespace). +# - the explicit security_opt entries on the base file work in podman as-is. + +services: + # MariaDB writes to a named volume managed by podman; its in-container + # `mysql` user expects to own that volume. keep-id breaks this by mapping + # in-container UID 999 to a podman-subuid that doesn't own the volume, + # so let mariadb use the default userns mapping (root-in-namespace). + mariadb: + # No userns_mode override — use podman defaults. + init: true + + # PhotoPrism does need keep-id, so its container UID maps back to the + # host UID that owns the bind-mounted originals/. + photoprism: + userns_mode: keep-id diff --git a/docker-compose.photoprism.yml b/docker-compose.photoprism.yml new file mode 100644 index 0000000..b9af80f --- /dev/null +++ b/docker-compose.photoprism.yml @@ -0,0 +1,127 @@ +# PhotoPrism stack — replaces the legacy mule-image backend over the course of +# milestones M0–M5 (see /home/dtoro/.claude/plans/i-want-you-to-twinkly-galaxy.md). +# +# This compose file is intentionally separate from docker-compose.yml so the +# legacy mule-image stack keeps running side-by-side until M5 cutover, when +# data is migrated and the legacy backend is retired. +# +# docker compose -f docker-compose.photoprism.yml up -d +# +# M0 scope: mariadb + photoprism only. Library mounted READ-ONLY so initial +# indexing cannot mutate originals while we validate. Backwrite, sidecar +# service, web frontend, and reverse proxy land in later milestones. + +services: + mariadb: + # Fully-qualified for podman (which refuses short names by default). + # Docker resolves the same digest. + image: docker.io/library/mariadb:11 + container_name: pp-mariadb + restart: unless-stopped + command: + - --innodb-buffer-pool-size=512M + - --transaction-isolation=READ-COMMITTED + - --character-set-server=utf8mb4 + - --collation-server=utf8mb4_unicode_ci + - --max-connections=512 + - --innodb-rollback-on-timeout=OFF + - --innodb-lock-wait-timeout=120 + environment: + MARIADB_AUTO_UPGRADE: "1" + MARIADB_INITDB_SKIP_TZINFO: "1" + MARIADB_DATABASE: ${PP_DB_NAME:-photoprism} + MARIADB_USER: ${PP_DB_USER:-photoprism} + MARIADB_PASSWORD: ${PP_DB_PASSWORD:?set PP_DB_PASSWORD in .env.photoprism} + MARIADB_ROOT_PASSWORD: ${PP_DB_ROOT_PASSWORD:?set PP_DB_ROOT_PASSWORD in .env.photoprism} + volumes: + - pp_mariadb_data:/var/lib/mysql + # The init script creates the mule_sidecar database + user that the Go + # sidecar service will use in M4. Idempotent; no-op on subsequent boots. + # ":Z" is the SELinux private-relabel flag — needed on Fedora/RHEL hosts, + # silently no-op on Debian/Ubuntu and macOS Docker Desktop. + - ./mariadb/init:/docker-entrypoint-initdb.d:ro,Z + healthcheck: + test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] + interval: 10s + timeout: 5s + retries: 12 + start_period: 60s + networks: [photoprism-network] + + photoprism: + image: docker.io/photoprism/photoprism:latest + container_name: pp-app + restart: unless-stopped + depends_on: + mariadb: + condition: service_healthy + # PhotoPrism's container drops to a non-root user via PHOTOPRISM_UID / + # PHOTOPRISM_GID. Match the host user that owns ${PHOTO_DIRS} so the + # process can read originals (and later write sidecars). + user: "${PP_UID:-1000}:${PP_GID:-1000}" + security_opt: + - seccomp:unconfined + - apparmor:unconfined + ports: + - "${PP_PORT:-2342}:2342" + environment: + PHOTOPRISM_ADMIN_USER: ${PP_ADMIN_USER:-admin} + PHOTOPRISM_ADMIN_PASSWORD: ${PP_ADMIN_PASSWORD:?set PP_ADMIN_PASSWORD in .env.photoprism} + PHOTOPRISM_AUTH_MODE: ${PP_AUTH_MODE:-password} + PHOTOPRISM_SITE_URL: ${PP_SITE_URL:-http://localhost:2342/} + PHOTOPRISM_ORIGINALS_LIMIT: ${PP_ORIGINALS_LIMIT:-50000} + PHOTOPRISM_HTTP_COMPRESSION: gzip + PHOTOPRISM_LOG_LEVEL: ${PP_LOG_LEVEL:-info} + # M0 safety: keep originals read-only. Flip to "false" in M2 when the + # right-sidebar enables metadata edits and we want EXIF backwrite. + PHOTOPRISM_READONLY: ${PP_READONLY:-true} + PHOTOPRISM_EXPERIMENTAL: "false" + PHOTOPRISM_DISABLE_CHOWN: "true" + PHOTOPRISM_DISABLE_WEBDAV: ${PP_DISABLE_WEBDAV:-false} + PHOTOPRISM_DISABLE_SETTINGS: "false" + PHOTOPRISM_DISABLE_TLS: "true" + PHOTOPRISM_DEFAULT_TLS: "false" + # AI/vision pipeline back on — per plan we re-introduce TF labels + faces. + PHOTOPRISM_TENSORFLOW_OFF: "false" + PHOTOPRISM_DETECT_NSFW: "true" + PHOTOPRISM_UPLOAD_NSFW: "true" + # Database + PHOTOPRISM_DATABASE_DRIVER: mysql + PHOTOPRISM_DATABASE_SERVER: mariadb:3306 + PHOTOPRISM_DATABASE_NAME: ${PP_DB_NAME:-photoprism} + PHOTOPRISM_DATABASE_USER: ${PP_DB_USER:-photoprism} + PHOTOPRISM_DATABASE_PASSWORD: ${PP_DB_PASSWORD} + # Sidecars next to originals — read by the migrator at M5. + PHOTOPRISM_SIDECAR_PATH: "" + PHOTOPRISM_SIDECAR_YAML: "true" + # EXIF backwrite — disabled in M0 (READONLY blocks writes anyway). + # Re-enable in M2 by overriding in .env.photoprism: PP_BACKUP_DATABASE=true. + PHOTOPRISM_DISABLE_BACKUPS: "false" + PHOTOPRISM_BACKUP_DATABASE: ${PP_BACKUP_DATABASE:-true} + PHOTOPRISM_DISABLE_EXIFTOOL: "false" + # OIDC — set in .env.photoprism when the IdP (Authentik) is wired up. + # Empty values keep OIDC dormant; the username/password login still works. + PHOTOPRISM_OIDC_PROVIDER_NAME: ${OIDC_PROVIDER_NAME:-} + PHOTOPRISM_OIDC_ISSUER_URL: ${OIDC_ISSUER_URL:-} + PHOTOPRISM_OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-} + PHOTOPRISM_OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-} + PHOTOPRISM_OIDC_REDIRECT_URI: ${OIDC_REDIRECT_URI:-} + PHOTOPRISM_OIDC_SCOPES: ${OIDC_SCOPES:-openid profile email} + PHOTOPRISM_OIDC_REGISTER: ${OIDC_REGISTER:-true} + PHOTOPRISM_OIDC_ROLE: ${OIDC_ROLE:-user} + working_dir: /photoprism + volumes: + # Existing photo library — mounted read-only in M0; flip to :rw in M2 + # when the right-sidebar starts saving edits. ",Z" relabels for SELinux + # on Fedora/RHEL; silent no-op elsewhere. + - "${PHOTO_DIRS:?set PHOTO_DIRS in .env.photoprism}:/photoprism/originals:${PP_ORIGINALS_MODE:-ro},Z" + - "./pp/storage:/photoprism/storage:Z" + - "./pp/import:/photoprism/import:Z" + networks: [photoprism-network] + +networks: + photoprism-network: + driver: bridge + +volumes: + pp_mariadb_data: diff --git a/mariadb/init/01-sidecar.sql b/mariadb/init/01-sidecar.sql new file mode 100644 index 0000000..910013e --- /dev/null +++ b/mariadb/init/01-sidecar.sql @@ -0,0 +1,25 @@ +-- Bootstraps the `mule_sidecar` database + user used by the Go sidecar +-- service (per-user heap sharing + folder mutations, stood up in M4). +-- +-- MariaDB runs every .sql in /docker-entrypoint-initdb.d ONCE, on first +-- boot of a fresh data volume. Subsequent boots are no-ops. +-- +-- The sidecar's MariaDB user is intentionally scoped to `mule_sidecar.*` +-- only — it never has access to PhotoPrism's schema. + +CREATE DATABASE IF NOT EXISTS mule_sidecar + CHARACTER SET utf8mb4 + COLLATE utf8mb4_unicode_ci; + +-- The password here is substituted at compose build time via envsubst, +-- but MariaDB's init script doesn't expand vars in .sql files. So we use +-- a literal placeholder that the user replaces locally — or, simpler, +-- we let the M4 sidecar bring-up script create the user via SQL with the +-- env-var password. Keeping a placeholder here makes the schema visible +-- in source control without leaking creds. +-- +-- TODO (M4): replace this block with an entrypoint that templates the +-- password from $SIDECAR_DB_PASSWORD before MariaDB reads the file. +CREATE USER IF NOT EXISTS 'sidecar'@'%' IDENTIFIED BY 'replace-at-m4-bringup'; +GRANT ALL PRIVILEGES ON mule_sidecar.* TO 'sidecar'@'%'; +FLUSH PRIVILEGES; diff --git a/migrate/README.md b/migrate/README.md new file mode 100644 index 0000000..561797e --- /dev/null +++ b/migrate/README.md @@ -0,0 +1,97 @@ +# migrate — legacy mule-image → PhotoPrism + +Two-phase migration that lifts user metadata + heap memberships out of the +legacy mule-image Postgres into PhotoPrism without touching originals. + +```text +┌────────────────────┐ legacy_export.mjs ┌─────────────────────┐ +│ mule-image Postgres├───────────────────────►│ snapshot.json │ +└────────────────────┘ └──────────┬──────────┘ + │ run.mjs + ▼ + ┌─────────────────────────────┐ + │ PUT /api/v1/photos/:uid │ (Phase A) + │ POST /api/v1/albums + adds │ (Phase B) + └─────────────────────────────┘ +``` + +## Why two phases + +- **Phase A — metadata via PUT.** For each legacy photo, the migrator + hashes the file on disk, resolves the PhotoPrism `UID` via + `q=hash:`, and PUTs the merged metadata onto `/api/v1/photos/:uid`. + We tried writing YAML sidecars directly first but PhotoPrism's reindex + rewrites them from its DB state — PUT is the only authoritative path. +- **Phase B — heap memberships.** Once every legacy photo has a stable + UID, the heap → Album conversion creates manual Albums and bulk-adds + the resolved UIDs via `/albums/:uid/photos`. + +Both phases are idempotent. Phase A's PUT is deep-merged (top-level +spread plus `Details` shallow-merge), so re-running pulls in any new +fields from the snapshot without clobbering server-side state. Phase B +looks up heaps by title and only creates if absent. + +## Files + +- `legacy_export.mjs` — reads from legacy mule-image Postgres + (`DATABASE_URL` env), emits `snapshot.json`. Runs on the **homecloud server** + where the legacy backend lives. +- `run.mjs` — reads a snapshot file and walks both phases against the + PhotoPrism API at `PHOTOPRISM_BASE_URL`. Hashes files on disk under + `ORIGINALS_ROOT` to resolve UIDs. Runs **wherever the new PhotoPrism is + reachable and originals are mounted** (homecloud first, dev workstation + for the smoke test). +- `example-snapshot.json` — synthetic input that exercises Phase A + B + against the local sample library. Smoke test: + + ```sh + PHOTOPRISM_BASE_URL=http://localhost:2342 \ + PHOTOPRISM_USER=admin \ + PHOTOPRISM_PASSWORD=... \ + ORIGINALS_ROOT=$(pwd)/photos-sample \ + node migrate/run.mjs --snapshot migrate/example-snapshot.json + ``` + + Add `--phase=sidecars` or `--phase=heaps` to run just one phase, or + `--dry-run` to log without mutating. + +## Snapshot schema + +```json +{ + "photos": [ + { + "filepath": "Screenshot_20260510_110411.png", + "user_title": null, + "user_notes": "Trip to Paris", + "rating": 4, + "is_picked": true, + "is_discarded": false, + "is_hidden": false, + "taken_at": "2024-06-15T14:30:00Z", + "tags": ["paris", "trip"] + } + ], + "heaps": [ + { + "name": "Summer 2024", + "photo_filepaths": ["Screenshot_20260510_110411.png"] + } + ] +} +``` + +- `filepath` is relative to `ORIGINALS_ROOT`. +- `is_hidden` is **dropped** by the migrator per the merge plan. +- `tags` lands in `Details.Keywords` (comma-separated, `KeywordsSrc=manual`). +- Heaps reference photos by `filepath`; the migrator resolves these to + PhotoPrism UIDs after Phase A's reindex finishes. + +## What's NOT migrated + +- mule-image's `is_hidden` (per the planning round). +- Heap+folder sharing state (planned for the Go sidecar service). +- Undo history. +- Nextcloud per-user roots. +- The 5-star user rating (PhotoPrism's `Rating` field is server-managed in + this build; we map mule-image's `is_picked` boolean → `Favorite`). diff --git a/migrate/example-snapshot.json b/migrate/example-snapshot.json new file mode 100644 index 0000000..234fb8f --- /dev/null +++ b/migrate/example-snapshot.json @@ -0,0 +1,36 @@ +{ + "_comment": "Synthetic snapshot exercising the migrator against ./photos-sample. Run from repo root: node migrate/run.mjs --snapshot migrate/example-snapshot.json", + "photos": [ + { + "filepath": "Screenshot_20260507_204050.png", + "user_title": "Paris dawn", + "user_notes": "Migration smoke test caption", + "rating": 4, + "is_picked": true, + "is_discarded": false, + "is_hidden": false, + "taken_at": "2024-06-15T07:00:00Z", + "tags": ["paris", "migration-test"] + }, + { + "filepath": "Screenshot_20260508_000706.png", + "user_title": null, + "user_notes": "Another mule-image note", + "rating": null, + "is_picked": false, + "is_discarded": false, + "is_hidden": false, + "taken_at": "2024-07-04T18:30:00Z", + "tags": ["fireworks"] + } + ], + "heaps": [ + { + "name": "M5 migration test", + "photo_filepaths": [ + "Screenshot_20260507_204050.png", + "Screenshot_20260508_000706.png" + ] + } + ] +} diff --git a/migrate/legacy_export.mjs b/migrate/legacy_export.mjs new file mode 100644 index 0000000..86c1c28 --- /dev/null +++ b/migrate/legacy_export.mjs @@ -0,0 +1,90 @@ +#!/usr/bin/env node +// Dumps a legacy mule-image Postgres into the snapshot JSON shape `run.mjs` +// consumes. Intended to run on the homecloud server where the legacy stack +// lives (since `DATABASE_URL` points at the in-network Postgres there). +// +// DATABASE_URL=postgres://mulita:mulita@db:5432/mulita \ +// node migrate/legacy_export.mjs > snapshot.json +// +// Uses the `pg` npm package which the legacy mule-image already ships in +// its backend image; if you're running this from a separate workstation, +// `npm install pg` first. + +import pg from 'pg'; +import process from 'node:process'; + +const DATABASE_URL = process.env.DATABASE_URL; +if (!DATABASE_URL) { + console.error('DATABASE_URL required'); + process.exit(2); +} + +const client = new pg.Client({ connectionString: DATABASE_URL }); +await client.connect(); + +// ── photos ─────────────────────────────────────────────────────────────────── + +// Mule-image's photos table is the source of all per-file metadata. The +// migrator only reads fields that have a clean PhotoPrism mapping; the +// rest (auto-tags, ML-derived fields, etc.) are skipped per the merge plan. +const photoRows = await client.query(` + SELECT + p.filepath, + p.user_title, + p.user_notes, + p.rating, + p.is_picked, + p.is_discarded, + p.is_hidden, + p.taken_at, + COALESCE( + array_agg(t.name) FILTER (WHERE t.name IS NOT NULL), + '{}' + ) AS tags + FROM photos p + LEFT JOIN photo_tags pt ON pt.photo_id = p.id + LEFT JOIN tags t ON t.id = pt.tag_id + WHERE p.is_active IS NULL OR p.is_active = true + GROUP BY p.id +`); + +// ── heaps + memberships ───────────────────────────────────────────────────── + +const heapRows = await client.query(` + SELECT h.id, h.name FROM heaps h WHERE h.is_active = true +`); + +const heapMembers = await client.query(` + SELECT hp.heap_id, p.filepath + FROM heap_photos hp + JOIN photos p ON p.id = hp.photo_id +`); + +const membersByHeap = new Map(); +for (const r of heapMembers.rows) { + if (!membersByHeap.has(r.heap_id)) membersByHeap.set(r.heap_id, []); + membersByHeap.get(r.heap_id).push(r.filepath); +} + +// ── assemble ───────────────────────────────────────────────────────────────── + +const snapshot = { + photos: photoRows.rows.map((r) => ({ + filepath: r.filepath, + user_title: r.user_title, + user_notes: r.user_notes, + rating: r.rating, + is_picked: Boolean(r.is_picked), + is_discarded: Boolean(r.is_discarded), + is_hidden: Boolean(r.is_hidden), + taken_at: r.taken_at ? new Date(r.taken_at).toISOString() : null, + tags: r.tags ?? [] + })), + heaps: heapRows.rows.map((h) => ({ + name: h.name, + photo_filepaths: membersByHeap.get(h.id) ?? [] + })) +}; + +process.stdout.write(JSON.stringify(snapshot, null, 2)); +await client.end(); diff --git a/migrate/run.mjs b/migrate/run.mjs new file mode 100644 index 0000000..24d7dc2 --- /dev/null +++ b/migrate/run.mjs @@ -0,0 +1,257 @@ +#!/usr/bin/env node +// Two-phase migration: legacy snapshot → PhotoPrism (metadata + heaps). +// +// Phase A — for each legacy photo, hash the on-disk file, resolve the +// PhotoPrism UID via `q=hash:`, then PUT the merged metadata onto +// `/api/v1/photos/:uid`. We don't write sidecars directly because +// PhotoPrism's reindex rewrites them from its DB state — PUT is the only +// authoritative path. Slower (one API call per photo) but reliable. +// +// Phase B — creates a manual PhotoPrism Album for each legacy heap and +// bulk-adds the resolved UIDs via `/albums/:uid/photos`. +// +// Both phases are idempotent. Re-running Phase A merges the same fields +// (PhotoPrism's PUT does a deep-merge for nested Details). Phase B looks +// up heaps by title and only creates if absent. + +import crypto from 'node:crypto'; +import { readFile, stat } from 'node:fs/promises'; +import { createReadStream } from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; + +// ── env / cli ──────────────────────────────────────────────────────────────── + +const PP_BASE = process.env.PHOTOPRISM_BASE_URL ?? 'http://localhost:2342'; +const PP_USER = process.env.PHOTOPRISM_USER ?? 'admin'; +const PP_PW = process.env.PHOTOPRISM_PASSWORD; +const ORIGINALS_ROOT = path.resolve( + process.env.ORIGINALS_ROOT ?? '/photoprism/originals' +); +const STORAGE_ROOT = path.resolve( + process.env.STORAGE_ROOT ?? '/photoprism/storage' +); + +if (!PP_PW) { + console.error('PHOTOPRISM_PASSWORD is required'); + process.exit(2); +} + +const args = parseArgs(process.argv.slice(2)); +if (!args.snapshot) { + console.error('--snapshot required'); + process.exit(2); +} +const phase = args.phase ?? 'all'; +if (!['sidecars', 'heaps', 'all'].includes(phase)) { + console.error(`unknown --phase=${phase} (sidecars|heaps|all)`); + process.exit(2); +} + +function parseArgs(argv) { + const out = {}; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === '--snapshot') out.snapshot = argv[++i]; + else if (a === '--phase') out.phase = argv[++i]; + else if (a === '--dry-run') out.dryRun = true; + } + return out; +} + +// ── http helpers ───────────────────────────────────────────────────────────── + +let TOKEN = null; +async function pp(method, urlPath, body) { + const init = { + method, + headers: { + 'Content-Type': 'application/json', + ...(TOKEN ? { 'X-Auth-Token': TOKEN } : {}) + } + }; + if (body !== undefined) init.body = JSON.stringify(body); + const r = await fetch(new URL(urlPath, PP_BASE), init); + const text = await r.text(); + let data = null; + try { + data = text ? JSON.parse(text) : null; + } catch { + data = text; + } + if (!r.ok) { + const msg = data?.error ?? data?.message ?? `HTTP ${r.status}`; + throw new Error(`${method} ${urlPath} → ${msg}`); + } + return data; +} + +async function login() { + const data = await pp('POST', '/api/v1/session', { + username: PP_USER, + password: PP_PW + }); + TOKEN = data.access_token; + console.log(`logged in as ${data.user.Name}`); +} + +// ── hashing ────────────────────────────────────────────────────────────────── + +async function sha1(absPath) { + return new Promise((resolve, reject) => { + const h = crypto.createHash('sha1'); + const s = createReadStream(absPath); + s.on('data', (c) => h.update(c)); + s.on('end', () => resolve(h.digest('hex'))); + s.on('error', reject); + }); +} + +// ── Phase A: PUT merged metadata per photo ─────────────────────────────────── + +function buildPatch(photo) { + const patch = {}; + + if (photo.user_title) { + patch.Title = photo.user_title; + patch.TitleSrc = 'manual'; + } + if (photo.user_notes) { + patch.Caption = photo.user_notes; + patch.CaptionSrc = 'manual'; + } + // mule-image's is_picked (binary "starred") → PhotoPrism Favorite. + // is_discarded → Archived. Rating + is_hidden intentionally not + // migrated per the merge plan. + if (photo.is_picked) patch.Favorite = true; + if (photo.is_discarded) patch.Archived = true; + + if (photo.taken_at) { + const d = new Date(photo.taken_at); + if (!Number.isNaN(d.getTime())) { + const iso = d.toISOString().replace(/\.\d+Z$/, 'Z'); + patch.TakenAt = iso; + patch.TakenAtLocal = iso; + patch.TakenSrc = 'manual'; + patch.Year = d.getUTCFullYear(); + patch.Month = d.getUTCMonth() + 1; + patch.Day = d.getUTCDate(); + } + } + + if (Array.isArray(photo.tags) && photo.tags.length) { + patch.Details = { + Keywords: photo.tags.join(', '), + KeywordsSrc: 'manual' + }; + } + + return patch; +} + +async function resolveUid(filepath) { + const abs = path.join(ORIGINALS_ROOT, filepath); + const exists = await stat(abs).then(() => true, () => false); + if (!exists) return null; + const hash = await sha1(abs); + const r = await pp( + 'GET', + `/api/v1/photos?count=1&q=${encodeURIComponent(`hash:${hash}`)}` + ); + const match = Array.isArray(r) ? r[0] : null; + return match ? match.UID : null; +} + +async function runPhaseA(snapshot) { + console.log(`phase A — ${snapshot.photos.length} photos`); + let updated = 0; + let skipped = 0; + let missing = 0; + for (const p of snapshot.photos) { + const patch = buildPatch(p); + if (Object.keys(patch).length === 0) { + skipped++; + continue; + } + const uid = await resolveUid(p.filepath); + if (!uid) { + console.warn(` [skip] ${p.filepath} — no matching PhotoPrism photo`); + missing++; + continue; + } + if (args.dryRun) { + console.log(` [dry] ${p.filepath} → ${uid}`); + updated++; + continue; + } + // PhotoPrism PUT requires the full photo body for nested Details to + // stick — fetch first, deep-merge the patch in, then PUT. + const current = await pp('GET', `/api/v1/photos/${uid}`); + const merged = { ...current, ...patch }; + if (patch.Details) { + merged.Details = { ...(current.Details ?? {}), ...patch.Details }; + } + await pp('PUT', `/api/v1/photos/${uid}`, merged); + console.log(` patched ${p.filepath} (${uid})`); + updated++; + } + console.log(`phase A — updated ${updated}, no-data ${skipped}, missing ${missing}`); +} + +// ── Phase B: heaps ─────────────────────────────────────────────────────────── + +async function findHeapByTitle(title) { + const r = await pp( + 'GET', + `/api/v1/albums?type=album&count=500&q=${encodeURIComponent(title)}` + ); + const arr = Array.isArray(r) ? r : []; + return arr.find((a) => a.Title === title) ?? null; +} + +async function runPhaseB(snapshot) { + console.log(`phase B — ${snapshot.heaps.length} heaps`); + for (const heap of snapshot.heaps) { + const existing = await findHeapByTitle(heap.name); + let album; + if (existing) { + console.log(` heap "${heap.name}" exists (${existing.UID})`); + album = existing; + } else if (args.dryRun) { + console.log(` [dry] create heap "${heap.name}"`); + continue; + } else { + album = await pp('POST', '/api/v1/albums', { + Title: heap.name, + Type: 'album' + }); + console.log(` created heap "${heap.name}" (${album.UID})`); + } + + const uids = []; + for (const fp of heap.photo_filepaths) { + const uid = await resolveUid(fp); + if (uid) uids.push(uid); + else console.warn(` [skip] ${fp} — no matching PhotoPrism photo`); + } + if (!uids.length) { + console.log(` heap "${heap.name}" — nothing to add`); + continue; + } + if (args.dryRun) { + console.log(` [dry] add ${uids.length} to "${heap.name}"`); + continue; + } + const r = await pp('POST', `/api/v1/albums/${album.UID}/photos`, { photos: uids }); + console.log(` added ${uids.length} to "${heap.name}"`); + void r; + } +} + +// ── main ───────────────────────────────────────────────────────────────────── + +const snapshot = JSON.parse(await readFile(args.snapshot, 'utf8')); +await login(); +if (phase === 'sidecars' || phase === 'all') await runPhaseA(snapshot); +if (phase === 'heaps' || phase === 'all') await runPhaseB(snapshot); +console.log('done'); diff --git a/sidecar/README.md b/sidecar/README.md new file mode 100644 index 0000000..6f3c1c2 --- /dev/null +++ b/sidecar/README.md @@ -0,0 +1,43 @@ +# mule-sidecar + +Auxiliary service that handles operations PhotoPrism's REST API does not expose. + +## Why this exists + +Per the merge plan at `/home/dtoro/.claude/plans/i-want-you-to-twinkly-galaxy.md`, +a Go + Gin + GORM service (matching PhotoPrism's stack) will eventually own: + +- Per-user heap sharing with pending invitations +- Folder mutations under `originals/` (create / rename / delete / move) +- **File rename** on disk (PhotoPrism's `OriginalName` is a display-only rename) + +The plan picks Go for stack consistency and the option to upstream features. + +## What ships today + +A **Node.js prototype** (`server.mjs`) covering only the **file rename** endpoint. + +The decision to ship Node first is pragmatic — Go isn't installed on this dev +box and `sudo dnf install golang` needs a password. Node is already on PATH for +the SvelteKit dev server, so a single-file Node service unblocks the feature +without changing the host setup. + +The endpoint contract is stable: when M4 lands the proper Go service, the +SvelteKit client keeps calling the same paths. + +## Endpoints + +- `POST /api/sidecar/files/:photoUid/rename` `{ "newName": "newfile.png" }` + Renames the primary file of the photo on disk under `${ORIGINALS_ROOT}`, + then triggers a PhotoPrism reindex of the parent path. + +## Run + +``` +ORIGINALS_ROOT=/home/dtoro/projects/mule-image/photos-sample \ +PHOTOPRISM_BASE_URL=http://localhost:2342 \ +SIDECAR_PORT=8000 \ +node server.mjs +``` + +The SvelteKit dev server proxies `/api/sidecar/*` to `http://localhost:8000`. diff --git a/sidecar/server.mjs b/sidecar/server.mjs new file mode 100644 index 0000000..3bdde2f --- /dev/null +++ b/sidecar/server.mjs @@ -0,0 +1,845 @@ +// mule-sidecar — Node.js prototype. +// +// Endpoints PhotoPrism does not expose. Today: file rename on disk. +// Future Go rewrite (per plan) will keep the same wire contract. +// +// Auth: forwards the caller's `X-Auth-Token` to PhotoPrism's session check +// before doing anything destructive. The token belongs to the end user; the +// sidecar does not hold its own credentials in this prototype. + +import http from 'node:http'; +import { URL, fileURLToPath } from 'node:url'; +import { promises as fs } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { createReadStream } from 'node:fs'; +import path from 'node:path'; + +const ORIGINALS_ROOT = path.resolve( + process.env.ORIGINALS_ROOT ?? '/photoprism/originals' +); +const PHOTOPRISM_BASE_URL = + process.env.PHOTOPRISM_BASE_URL ?? 'http://localhost:2342'; +const PORT = Number(process.env.SIDECAR_PORT ?? 8000); + +// JSON-backed marks store. Holds the mule-image extras PhotoPrism doesn't: +// per-photo rating (0..5) and color label (red/orange/yellow/green/''). +// Lives next to server.mjs so the Go rewrite can migrate it into MariaDB +// without touching ORIGINALS_ROOT. +const SIDECAR_DIR = path.dirname(fileURLToPath(import.meta.url)); +const MARKS_FILE = path.join(SIDECAR_DIR, 'data', 'marks.json'); + +// ── helpers ────────────────────────────────────────────────────────────────── + +function json(res, status, body) { + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(body)); +} + +async function readJson(req) { + const chunks = []; + for await (const c of req) chunks.push(c); + const raw = Buffer.concat(chunks).toString('utf8'); + return raw ? JSON.parse(raw) : {}; +} + +async function pp(method, urlPath, token, body) { + const url = new URL(urlPath, PHOTOPRISM_BASE_URL); + const init = { + method, + headers: { + 'X-Auth-Token': token, + 'Content-Type': 'application/json' + } + }; + if (body !== undefined) init.body = JSON.stringify(body); + const r = await fetch(url, init); + const text = await r.text(); + let data = null; + try { + data = text ? JSON.parse(text) : null; + } catch { + data = text; + } + return { ok: r.ok, status: r.status, data }; +} + +async function validateSession(token) { + if (!token) return false; + // Cheapest auth probe: list 1 photo. 401 if the token is bad. + const r = await pp('GET', '/api/v1/photos?count=1', token); + return r.ok; +} + +/** + * Enforce: NewName is a bare filename (no path separators, no leading dot, + * no surprising chars). PhotoPrism's index works fine with most filename + * shapes, but we lock down the obvious dangerous ones. + */ +function sanitizeFilename(name) { + if (typeof name !== 'string') return null; + const trimmed = name.trim(); + if (!trimmed) return null; + if (trimmed.length > 240) return null; + if (trimmed.startsWith('.')) return null; + if (/[\\/\x00]/.test(trimmed)) return null; + if (trimmed === '..' || trimmed === '.') return null; + return trimmed; +} + +// ── Marks store (rating + color label) ─────────────────────────────────────── +// In-memory cache backed by an atomic JSON file write. Single-threaded Node +// means we don't need an external lock — sequential awaits serialize writes. + +/** @type {Record} */ +let MARKS_CACHE = null; +let marksLoadPromise = null; + +async function loadMarks() { + if (MARKS_CACHE !== null) return MARKS_CACHE; + if (marksLoadPromise) return marksLoadPromise; + marksLoadPromise = (async () => { + await fs.mkdir(path.dirname(MARKS_FILE), { recursive: true }); + try { + const raw = await fs.readFile(MARKS_FILE, 'utf8'); + MARKS_CACHE = JSON.parse(raw); + } catch (err) { + if (err.code === 'ENOENT') MARKS_CACHE = {}; + else throw err; + } + return MARKS_CACHE; + })(); + return marksLoadPromise; +} + +async function persistMarks() { + const tmp = MARKS_FILE + '.tmp'; + await fs.writeFile(tmp, JSON.stringify(MARKS_CACHE ?? {}, null, 2)); + await fs.rename(tmp, MARKS_FILE); +} + +/** Normalize partial input. Strips unknown fields, clamps rating to 0..5, + * whitelists colors to mule-image's four-color palette. */ +function sanitizeMarkPatch(patch) { + if (!patch || typeof patch !== 'object') return null; + const out = {}; + if ('rating' in patch) { + const r = Math.round(Number(patch.rating)); + if (!Number.isFinite(r) || r < 0 || r > 5) return null; + out.rating = r; + } + if ('color' in patch) { + const c = typeof patch.color === 'string' ? patch.color.toLowerCase() : ''; + if (c !== '' && !['red', 'orange', 'yellow', 'green'].includes(c)) return null; + out.color = c; + } + return out; +} + +/** Merge a patch onto an existing mark, dropping zero/empty so the JSON + * stays sparse — never write `rating: 0` or `color: ''` to disk, just + * delete the field. */ +function mergeMark(prev, patch) { + const merged = { ...(prev ?? {}) }; + if ('rating' in patch) { + if (patch.rating > 0) merged.rating = patch.rating; + else delete merged.rating; + } + if ('color' in patch) { + if (patch.color) merged.color = patch.color; + else delete merged.color; + } + const hasAny = 'rating' in merged || 'color' in merged; + if (!hasAny) return null; + merged.updatedAt = new Date().toISOString(); + return merged; +} + +async function ensureWithinOriginals(absPath) { + const real = await fs.realpath(path.dirname(absPath)).catch(() => null); + if (!real) return false; + return real === ORIGINALS_ROOT || real.startsWith(ORIGINALS_ROOT + path.sep); +} + +/** + * Resolve a user-supplied relative path under ORIGINALS_ROOT. + * Returns the absolute resolved path on success, or null if it escapes + * the root, contains traversal sequences, or its parent is missing. + * + * `mustExist=false` is used for the *target* of a rename/create where + * the path itself doesn't yet exist; we still ensure the parent does. + */ +async function resolveUnderRoot(rel, { mustExist = true } = {}) { + if (typeof rel !== 'string') return null; + const clean = rel.replace(/^\/+/, ''); + if (!clean || clean === '.' || clean.split('/').some((seg) => seg === '..' || seg === '')) { + return null; + } + const abs = path.resolve(ORIGINALS_ROOT, clean); + const parent = path.dirname(abs); + // Confirm both the abs and its parent resolve back under ORIGINALS_ROOT + // (defends against symlinks pointing out of the library). + const parentReal = await fs.realpath(parent).catch(() => null); + if (!parentReal) return null; + if ( + parentReal !== ORIGINALS_ROOT && + !parentReal.startsWith(ORIGINALS_ROOT + path.sep) + ) { + return null; + } + if (mustExist) { + const stat = await fs.stat(abs).catch(() => null); + if (!stat) return null; + } + return abs; +} + +// ── handlers ───────────────────────────────────────────────────────────────── + +async function handleRename(req, res, photoUid) { + const token = req.headers['x-auth-token']; + if (!token || Array.isArray(token)) return json(res, 401, { error: 'no token' }); + + const body = await readJson(req).catch(() => null); + if (!body) return json(res, 400, { error: 'invalid json' }); + + const newName = sanitizeFilename(body.newName); + if (!newName) return json(res, 400, { error: 'newName must be a plain filename' }); + + if (!(await validateSession(token))) return json(res, 401, { error: 'invalid session' }); + + // Fetch the photo to discover the file's path on disk. The single-photo + // endpoint nests Files[0] with Root + Name; the file lookup by UID + // (/api/v1/files/:uid) doesn't exist in this build. + const photoResp = await pp('GET', `/api/v1/photos/${photoUid}`, token); + if (!photoResp.ok) return json(res, photoResp.status, { error: 'photo not found' }); + + const photo = photoResp.data; + const file = + (photo.Files || []).find((f) => f.Primary) || (photo.Files || [])[0]; + if (!file) return json(res, 404, { error: 'no files on this photo' }); + + // Build the absolute current path. + const root = (file.Root && file.Root !== '/') ? file.Root : ''; + const relPath = path.posix.join(root, file.Name); + const oldAbs = path.resolve(path.join(ORIGINALS_ROOT, relPath)); + + if (!(await ensureWithinOriginals(oldAbs))) { + return json(res, 400, { error: 'path escapes originals root' }); + } + const stat = await fs.stat(oldAbs).catch(() => null); + if (!stat || !stat.isFile()) return json(res, 404, { error: 'file missing on disk' }); + + const newAbs = path.resolve(path.join(path.dirname(oldAbs), newName)); + if (!(await ensureWithinOriginals(newAbs))) { + return json(res, 400, { error: 'new path escapes originals root' }); + } + + // Refuse to clobber an existing file. + if (await fs.stat(newAbs).then(() => true, () => false)) { + return json(res, 409, { error: 'target filename already exists' }); + } + + const oldName = file.Name; + console.log(`[rename] ${relPath} -> ${path.posix.join(root, newName)}`); + await fs.rename(oldAbs, newAbs); + + // Tell PhotoPrism to re-index the parent so the DB picks up the new path. + // `cleanup: true` removes orphan rows for the old filename. + const indexResp = await pp( + 'POST', + '/api/v1/index', + token, + { path: root || '/', rescan: false, cleanup: true } + ); + if (!indexResp.ok) { + // Best-effort: file is renamed, index will catch up eventually. + console.warn('[rename] reindex returned', indexResp.status); + } + + json(res, 200, { + ok: true, + oldName, + newName, + oldRelPath: relPath, + newRelPath: path.posix.join(root, newName) + }); +} + +function handleHealth(_req, res) { + json(res, 200, { ok: true, originalsRoot: ORIGINALS_ROOT }); +} + +// ── Marks handlers (rating + color) ────────────────────────────────────────── + +async function handleMarksListAll(req, res) { + const token = req.headers['x-auth-token']; + if (!token || Array.isArray(token)) return json(res, 401, { error: 'no token' }); + if (!(await validateSession(token))) return json(res, 401, { error: 'invalid session' }); + const all = await loadMarks(); + json(res, 200, all); +} + +async function handleMarkGet(req, res, uid) { + const token = req.headers['x-auth-token']; + if (!token || Array.isArray(token)) return json(res, 401, { error: 'no token' }); + if (!(await validateSession(token))) return json(res, 401, { error: 'invalid session' }); + const all = await loadMarks(); + json(res, 200, all[uid] ?? {}); +} + +async function handleMarkPut(req, res, uid) { + const token = req.headers['x-auth-token']; + if (!token || Array.isArray(token)) return json(res, 401, { error: 'no token' }); + const body = await readJson(req).catch(() => null); + const patch = sanitizeMarkPatch(body); + if (patch === null) return json(res, 400, { error: 'invalid patch' }); + if (!(await validateSession(token))) return json(res, 401, { error: 'invalid session' }); + + const all = await loadMarks(); + const next = mergeMark(all[uid], patch); + if (next === null) delete all[uid]; + else all[uid] = next; + await persistMarks(); + json(res, 200, all[uid] ?? {}); +} + +async function handleMarkBulk(req, res) { + const token = req.headers['x-auth-token']; + if (!token || Array.isArray(token)) return json(res, 401, { error: 'no token' }); + const body = await readJson(req).catch(() => null); + if (!body || !Array.isArray(body.ids)) { + return json(res, 400, { error: 'ids[] required' }); + } + const patch = sanitizeMarkPatch(body.patch); + if (patch === null) return json(res, 400, { error: 'invalid patch' }); + if (!(await validateSession(token))) return json(res, 401, { error: 'invalid session' }); + + const all = await loadMarks(); + const applied = {}; + for (const uid of body.ids) { + if (typeof uid !== 'string' || !uid) continue; + const next = mergeMark(all[uid], patch); + if (next === null) delete all[uid]; + else all[uid] = next; + applied[uid] = all[uid] ?? {}; + } + await persistMarks(); + json(res, 200, { count: Object.keys(applied).length, marks: applied }); +} + +// ── Folder mutations ──────────────────────────────────────────────────────── +// +// Each endpoint operates on a relative path under ORIGINALS_ROOT, then +// triggers PhotoPrism's re-index of the parent so the DB picks up the +// change. PhotoPrism's "folder" concept is just a directory on disk — +// there's no DB-side folder entity to mutate. + +async function reindex(parentRel, token) { + const r = await pp( + 'POST', + '/api/v1/index', + token, + { path: parentRel || '/', rescan: false, cleanup: true } + ); + if (!r.ok) console.warn('[folder] reindex returned', r.status); +} + +async function handleFolderCreate(req, res) { + const token = req.headers['x-auth-token']; + if (!token || Array.isArray(token)) return json(res, 401, { error: 'no token' }); + const body = await readJson(req).catch(() => null); + if (!body) return json(res, 400, { error: 'invalid json' }); + if (typeof body.path !== 'string') return json(res, 400, { error: 'path required' }); + if (!(await validateSession(token))) return json(res, 401, { error: 'invalid session' }); + + const abs = await resolveUnderRoot(body.path, { mustExist: false }); + if (!abs) return json(res, 400, { error: 'invalid path' }); + if (await fs.stat(abs).then(() => true, () => false)) { + return json(res, 409, { error: 'already exists' }); + } + await fs.mkdir(abs, { recursive: false }); + const rel = path.relative(ORIGINALS_ROOT, abs); + console.log('[folder.create]', rel); + void reindex(path.posix.dirname('/' + rel), token); + json(res, 200, { ok: true, path: rel }); +} + +async function handleFolderRename(req, res, rel) { + const token = req.headers['x-auth-token']; + if (!token || Array.isArray(token)) return json(res, 401, { error: 'no token' }); + const body = await readJson(req).catch(() => null); + if (!body) return json(res, 400, { error: 'invalid json' }); + const newName = sanitizeFilename(body.newName); + if (!newName) return json(res, 400, { error: 'newName must be a plain dirname' }); + if (!(await validateSession(token))) return json(res, 401, { error: 'invalid session' }); + + const oldAbs = await resolveUnderRoot(rel); + if (!oldAbs) return json(res, 400, { error: 'invalid path' }); + const stat = await fs.stat(oldAbs); + if (!stat.isDirectory()) return json(res, 400, { error: 'not a directory' }); + + const newAbs = path.join(path.dirname(oldAbs), newName); + if (await fs.stat(newAbs).then(() => true, () => false)) { + return json(res, 409, { error: 'target already exists' }); + } + if ( + !newAbs.startsWith(ORIGINALS_ROOT + path.sep) && + newAbs !== ORIGINALS_ROOT + ) { + return json(res, 400, { error: 'target escapes root' }); + } + + await fs.rename(oldAbs, newAbs); + const oldRel = path.relative(ORIGINALS_ROOT, oldAbs); + const newRel = path.relative(ORIGINALS_ROOT, newAbs); + console.log('[folder.rename]', oldRel, '→', newRel); + void reindex(path.posix.dirname('/' + oldRel), token); + json(res, 200, { ok: true, oldPath: oldRel, newPath: newRel }); +} + +// ── Heap convert (move/copy heap photos into a folder) ───────────────────── +// PhotoPrism has no native "move all album photos into folder X" operation +// — it can't because the file-on-disk layout is its source of truth. The +// flow: list members via q=album:, fs.rename / fs.copyFile each primary +// file into the destination, optionally delete the album, then reindex +// both source and destination so PhotoPrism's DB catches up. + +/** Find a non-clobbering destination for `basename` inside `destDir`. If + * `foo.jpg` exists, try `foo-1.jpg`, `foo-2.jpg`, … up to a sane cap. */ +async function uniqueName(destDir, basename) { + const ext = path.extname(basename); + const stem = basename.slice(0, basename.length - ext.length); + for (let i = 0; i < 1000; i++) { + const candidate = i === 0 ? basename : `${stem}-${i}${ext}`; + const abs = path.join(destDir, candidate); + const exists = await fs.stat(abs).then(() => true, () => false); + if (!exists) return { abs, name: candidate }; + } + return null; +} + +async function handleHeapConvert(req, res, albumUid) { + const token = req.headers['x-auth-token']; + if (!token || Array.isArray(token)) return json(res, 401, { error: 'no token' }); + const body = await readJson(req).catch(() => null); + if (!body) return json(res, 400, { error: 'invalid json' }); + + const mode = body.mode === 'copy' ? 'copy' : 'move'; + const deleteHeap = mode === 'move' && body.deleteHeap === true; + const subfolderRaw = typeof body.subfolder === 'string' ? body.subfolder.trim() : ''; + const subfolder = subfolderRaw ? sanitizeFilename(subfolderRaw) : null; + if (subfolderRaw && !subfolder) { + return json(res, 400, { error: 'invalid subfolder name' }); + } + + if (!(await validateSession(token))) return json(res, 401, { error: 'invalid session' }); + + // Resolve target folder. Picker passes a relative path under ORIGINALS_ROOT. + const targetAbs = await resolveUnderRoot(body.targetFolder); + if (!targetAbs) return json(res, 400, { error: 'invalid targetFolder' }); + + // Create the optional subfolder (idempotent). + let destAbs = targetAbs; + if (subfolder) { + destAbs = path.join(targetAbs, subfolder); + await fs.mkdir(destAbs, { recursive: true }); + } + + // Fetch the album's photos. PhotoPrism's q-DSL lets us filter by album + // UID; merged=true expands to one row per file (we need every variant + // in a stack to follow the primary). 1000 covers any realistic heap. + const listResp = await pp( + 'GET', + `/api/v1/photos?q=${encodeURIComponent(`album:${albumUid}`)}&count=1000&merged=true`, + token + ); + if (!listResp.ok) return json(res, listResp.status, { error: 'list photos failed' }); + const photos = Array.isArray(listResp.data) ? listResp.data : []; + + // Track source parents so we know which paths to reindex once we're done. + const sourceParents = new Set(); + const errors = []; + let moved = 0; + let copied = 0; + + for (const photo of photos) { + const file = + (photo.Files || []).find((f) => f.Primary) || (photo.Files || [])[0]; + if (!file || typeof file.Name !== 'string') { + errors.push({ uid: photo.UID, reason: 'no primary file' }); + continue; + } + // PhotoPrism Files[].Name is already originals-relative. + const srcRel = file.Name; + const srcAbs = path.resolve(path.join(ORIGINALS_ROOT, srcRel)); + if (!srcAbs.startsWith(ORIGINALS_ROOT + path.sep) && srcAbs !== ORIGINALS_ROOT) { + errors.push({ uid: photo.UID, reason: 'path escapes originals' }); + continue; + } + const stat = await fs.stat(srcAbs).catch(() => null); + if (!stat || !stat.isFile()) { + errors.push({ uid: photo.UID, reason: 'file missing on disk' }); + continue; + } + // Avoid no-op moves (file already lives in dest). + if (path.dirname(srcAbs) === destAbs) { + errors.push({ uid: photo.UID, reason: 'already in target' }); + continue; + } + + const basename = path.basename(srcAbs); + const target = await uniqueName(destAbs, basename); + if (!target) { + errors.push({ uid: photo.UID, reason: 'too many collisions' }); + continue; + } + + try { + if (mode === 'move') { + await fs.rename(srcAbs, target.abs); + moved += 1; + } else { + await fs.copyFile(srcAbs, target.abs); + copied += 1; + } + sourceParents.add(path.dirname(srcRel)); + } catch (err) { + errors.push({ + uid: photo.UID, + reason: err instanceof Error ? err.message : String(err) + }); + } + } + + // Reindex destination + every distinct source parent so PhotoPrism's + // DB catches up to the new on-disk layout. + const destRel = path.relative(ORIGINALS_ROOT, destAbs); + const reindexPaths = new Set(sourceParents); + reindexPaths.add(destRel); + if (subfolder) reindexPaths.add(path.relative(ORIGINALS_ROOT, targetAbs)); + for (const p of reindexPaths) { + void reindex(p ? '/' + p : '/', token); + } + + // Optionally delete the album after a successful move. We don't gate on + // `errors.length === 0` — partial successes still warrant heap cleanup + // if the user explicitly opted in. + let heap_deleted = false; + if (deleteHeap) { + const delResp = await pp('DELETE', `/api/v1/albums/${albumUid}`, token); + heap_deleted = delResp.ok; + if (!delResp.ok) { + errors.push({ uid: albumUid, reason: `album delete: HTTP ${delResp.status}` }); + } + } + + console.log( + `[heap.convert] album=${albumUid} mode=${mode} moved=${moved} copied=${copied} errors=${errors.length} heap_deleted=${heap_deleted}` + ); + json(res, 200, { moved, copied, errors, heap_deleted }); +} + +async function handleFolderDelete(req, res, rel) { + const token = req.headers['x-auth-token']; + if (!token || Array.isArray(token)) return json(res, 401, { error: 'no token' }); + if (!(await validateSession(token))) return json(res, 401, { error: 'invalid session' }); + + const abs = await resolveUnderRoot(rel); + if (!abs) return json(res, 400, { error: 'invalid path' }); + if (abs === ORIGINALS_ROOT) return json(res, 400, { error: 'refuse to delete root' }); + const stat = await fs.stat(abs); + if (!stat.isDirectory()) return json(res, 400, { error: 'not a directory' }); + + // Safety: only remove if empty. Recursive rm is left to the user via shell + // until M5 lands proper file moves. + const entries = await fs.readdir(abs); + if (entries.length > 0) { + return json(res, 409, { error: 'directory not empty' }); + } + await fs.rmdir(abs); + console.log('[folder.delete]', rel); + void reindex(path.posix.dirname('/' + rel), token); + json(res, 200, { ok: true, path: rel }); +} + +// ── Cross-folder duplicate scan ───────────────────────────────────────────── +// +// PhotoPrism silently drops byte-identical files at index time, so duplicates +// across folders never enter its DB. This sidecar scans ORIGINALS_ROOT +// directly: walk every file, pre-filter by size (files with unique sizes +// can't be hash-duplicates so we skip hashing them), sha1 the rest, and +// return groups of ≥2 files that share a hash. +// +// Resolution archives the unwanted copies into a `.duplicates/` quarantine +// folder under ORIGINALS_ROOT — PhotoPrism's indexer ignores dotfile dirs, +// so the moved files stop appearing in the index. Recoverable by moving +// them back to a regular subfolder + reindex. + +const QUARANTINE_DIR = '.duplicates'; +const SUPPORTED_EXTS = new Set([ + '.jpg', '.jpeg', '.png', '.heic', '.heif', '.tiff', '.tif', + '.gif', '.bmp', '.webp', '.avif', + '.mov', '.mp4', '.m4v', '.avi', '.mkv', '.webm', + '.dng', '.cr2', '.cr3', '.nef', '.arw', '.orf', '.rw2', '.raw' +]); + +async function walkFiles(rootAbs) { + /** @type {{ relPath: string, absPath: string, size: number }[]} */ + const out = []; + async function recurse(dir) { + let entries; + try { + entries = await fs.readdir(dir, { withFileTypes: true }); + } catch { + return; + } + for (const ent of entries) { + // Skip dotfile/dotdir (matches PhotoPrism's indexer behaviour + // + our own quarantine dir). + if (ent.name.startsWith('.')) continue; + const abs = path.join(dir, ent.name); + if (ent.isDirectory()) { + await recurse(abs); + continue; + } + if (!ent.isFile()) continue; + const ext = path.extname(ent.name).toLowerCase(); + if (!SUPPORTED_EXTS.has(ext)) continue; + let st; + try { + st = await fs.stat(abs); + } catch { + continue; + } + out.push({ + relPath: path.relative(rootAbs, abs), + absPath: abs, + size: st.size + }); + } + } + await recurse(rootAbs); + return out; +} + +async function sha1File(absPath) { + return new Promise((resolve, reject) => { + const hash = createHash('sha1'); + createReadStream(absPath) + .on('data', (chunk) => hash.update(chunk)) + .on('end', () => resolve(hash.digest('hex'))) + .on('error', reject); + }); +} + +async function scanDuplicates(token) { + const all = await walkFiles(ORIGINALS_ROOT); + + // Group by size first; hashes only fire for size collisions. For a + // typical library this skips ~95% of the IO+CPU. + /** @type {Map} */ + const bySize = new Map(); + for (const f of all) { + const arr = bySize.get(f.size); + if (arr) arr.push(f); + else bySize.set(f.size, [f]); + } + + /** @type {Map} */ + const groups = new Map(); + for (const [size, group] of bySize) { + if (group.length < 2) continue; + // Concurrently hash each file in this size bucket. + const hashes = await Promise.all(group.map((f) => sha1File(f.absPath))); + for (let i = 0; i < group.length; i++) { + const h = hashes[i]; + const g = groups.get(h); + if (g) g.files.push(group[i]); + else groups.set(h, { hash: h, size, files: [group[i]] }); + } + } + + // Filter to groups with ≥2 files (size collisions where hashes differed + // produce singleton entries we drop here). For each surviving group, + // ask PhotoPrism which path it has indexed — that becomes the default + // "keep" candidate so the user doesn't accidentally archive the only + // indexed copy. + const out = []; + for (const g of groups.values()) { + if (g.files.length < 2) continue; + let indexedPath = null; + try { + const r = await pp( + 'GET', + `/api/v1/photos?q=hash:${g.hash}&count=1&merged=true`, + token + ); + if (r.ok && Array.isArray(r.data) && r.data[0]) { + const photo = r.data[0]; + const primary = + (photo.Files || []).find((f) => f.Primary) || + (photo.Files || [])[0]; + if (primary?.Name) indexedPath = primary.Name; + } + } catch { + /* swallow — best-effort hint */ + } + out.push({ + hash: g.hash, + size: g.size, + indexedPath, + files: g.files.map((f) => ({ path: f.relPath, size: f.size })) + }); + } + // Sort groups by size descending so the biggest disk wins float to top. + out.sort((a, b) => b.size * (b.files.length - 1) - a.size * (a.files.length - 1)); + return out; +} + +async function handleDupScan(req, res) { + const token = req.headers['x-auth-token']; + if (!token || Array.isArray(token)) return json(res, 401, { error: 'no token' }); + if (!(await validateSession(token))) return json(res, 401, { error: 'invalid session' }); + const started = Date.now(); + console.log('[dup.scan] starting walk under', ORIGINALS_ROOT); + const groups = await scanDuplicates(token); + const ms = Date.now() - started; + console.log(`[dup.scan] ${groups.length} groups in ${ms} ms`); + json(res, 200, { groups, scannedMs: ms }); +} + +async function handleDupArchive(req, res) { + const token = req.headers['x-auth-token']; + if (!token || Array.isArray(token)) return json(res, 401, { error: 'no token' }); + const body = await readJson(req).catch(() => null); + if (!body || !Array.isArray(body.paths)) { + return json(res, 400, { error: 'paths[] required' }); + } + if (!(await validateSession(token))) return json(res, 401, { error: 'invalid session' }); + + const moved = []; + const errors = []; + const stamp = new Date().toISOString().replace(/[:.]/g, '-'); + const targetDir = path.join(ORIGINALS_ROOT, QUARANTINE_DIR, stamp); + await fs.mkdir(targetDir, { recursive: true }); + + for (const rel of body.paths) { + try { + const abs = await resolveUnderRoot(rel); + if (!abs) { + errors.push({ path: rel, error: 'invalid path' }); + continue; + } + // Use the file's basename as the quarantine name; prepend a + // short slice of its parent dir if a collision would happen so + // two `IMG_0001.jpg` from different folders don't overwrite + // each other in the same quarantine batch. + let dest = path.join(targetDir, path.basename(abs)); + let i = 1; + while (await fs.stat(dest).then(() => true, () => false)) { + const parsed = path.parse(path.basename(abs)); + dest = path.join(targetDir, `${parsed.name}__${i}${parsed.ext}`); + i++; + } + await fs.rename(abs, dest); + moved.push({ + from: rel, + to: path.relative(ORIGINALS_ROOT, dest) + }); + console.log('[dup.archive]', rel, '→', path.relative(ORIGINALS_ROOT, dest)); + } catch (err) { + errors.push({ + path: rel, + error: err instanceof Error ? err.message : String(err) + }); + } + } + + // Trigger a cleanup reindex so PhotoPrism drops any photo entries whose + // underlying file is now in the quarantine dir (out of its scan path). + if (moved.length > 0) { + void pp('POST', '/api/v1/index', token, { + path: '/', + rescan: false, + cleanup: true + }); + } + + json(res, 200, { moved, errors }); +} + +// ── router ─────────────────────────────────────────────────────────────────── + +const RENAME_RE = /^\/api\/sidecar\/files\/([^/]+)\/rename$/; +const FOLDER_CREATE_RE = /^\/api\/sidecar\/folders$/; +const FOLDER_RENAME_RE = /^\/api\/sidecar\/folders\/(.+)\/rename$/; +const FOLDER_DELETE_RE = /^\/api\/sidecar\/folders\/(.+)$/; +const MARKS_BULK_RE = /^\/api\/sidecar\/photos\/marks\/bulk$/; +const MARKS_ALL_RE = /^\/api\/sidecar\/photos\/marks$/; +const MARKS_ONE_RE = /^\/api\/sidecar\/photos\/([^/]+)\/marks$/; +const HEAP_CONVERT_RE = /^\/api\/sidecar\/albums\/([^/]+)\/convert$/; +const DUP_SCAN_RE = /^\/api\/sidecar\/duplicates\/scan$/; +const DUP_ARCHIVE_RE = /^\/api\/sidecar\/duplicates\/archive$/; + +const server = http.createServer(async (req, res) => { + try { + const url = new URL(req.url ?? '/', 'http://localhost'); + const p = url.pathname; + + if (p === '/api/sidecar/healthz' && req.method === 'GET') { + return handleHealth(req, res); + } + // Cross-folder duplicate detection. + if (DUP_SCAN_RE.test(p) && req.method === 'GET') { + return handleDupScan(req, res); + } + if (DUP_ARCHIVE_RE.test(p) && req.method === 'POST') { + return handleDupArchive(req, res); + } + // Marks routes — bulk match first so "marks/bulk" doesn't get + // captured by the "{uid}/marks" pattern. + if (MARKS_BULK_RE.test(p) && req.method === 'POST') { + return handleMarkBulk(req, res); + } + if (MARKS_ALL_RE.test(p) && req.method === 'GET') { + return handleMarksListAll(req, res); + } + const markM = MARKS_ONE_RE.exec(p); + if (markM) { + if (req.method === 'GET') return handleMarkGet(req, res, markM[1]); + if (req.method === 'PUT') return handleMarkPut(req, res, markM[1]); + } + const fileM = RENAME_RE.exec(p); + if (fileM && req.method === 'POST') { + return handleRename(req, res, fileM[1]); + } + if (FOLDER_CREATE_RE.test(p) && req.method === 'POST') { + return handleFolderCreate(req, res); + } + const renameFolderM = FOLDER_RENAME_RE.exec(p); + if (renameFolderM && req.method === 'POST') { + return handleFolderRename(req, res, decodeURIComponent(renameFolderM[1])); + } + const deleteFolderM = FOLDER_DELETE_RE.exec(p); + if (deleteFolderM && req.method === 'DELETE') { + // Avoid matching the rename URL which has a trailing /rename. + if (!p.endsWith('/rename')) { + return handleFolderDelete(req, res, decodeURIComponent(deleteFolderM[1])); + } + } + const heapConvertM = HEAP_CONVERT_RE.exec(p); + if (heapConvertM && req.method === 'POST') { + return handleHeapConvert(req, res, heapConvertM[1]); + } + json(res, 404, { error: 'no route' }); + } catch (err) { + console.error(err); + json(res, 500, { error: err instanceof Error ? err.message : String(err) }); + } +}); + +server.listen(PORT, '127.0.0.1', () => { + console.log( + `mule-sidecar listening on http://127.0.0.1:${PORT} (originals=${ORIGINALS_ROOT})` + ); +}); diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..3b462cb --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,23 @@ +node_modules + +# Output +.output +.vercel +.netlify +.wrangler +/.svelte-kit +/build + +# OS +.DS_Store +Thumbs.db + +# Env +.env +.env.* +!.env.example +!.env.test + +# Vite +vite.config.js.timestamp-* +vite.config.ts.timestamp-* diff --git a/web/.npmrc b/web/.npmrc new file mode 100644 index 0000000..b6f27f1 --- /dev/null +++ b/web/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..8edb2a5 --- /dev/null +++ b/web/README.md @@ -0,0 +1,42 @@ +# sv + +Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli). + +## Creating a project + +If you're seeing this, you've probably already done this step. Congrats! + +```sh +# create a new project +npx sv create my-app +``` + +To recreate this project with the same configuration: + +```sh +# recreate this project +npx sv@0.15.3 create --template minimal --types ts --install npm web +``` + +## Developing + +Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: + +```sh +npm run dev + +# or start the server and open the app in a new browser tab +npm run dev -- --open +``` + +## Building + +To create a production version of your app: + +```sh +npm run build +``` + +You can preview the production build with `npm run preview`. + +> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment. diff --git a/web/components.json b/web/components.json new file mode 100644 index 0000000..36cb586 --- /dev/null +++ b/web/components.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://shadcn-svelte.com/schema.json", + "style": "default", + "tailwind": { + "css": "src/app.css", + "baseColor": "zinc" + }, + "aliases": { + "components": "$lib/components", + "utils": "$lib/utils", + "ui": "$lib/components/ui", + "hooks": "$lib/hooks", + "lib": "$lib" + }, + "typescript": true, + "registry": "https://shadcn-svelte.com/registry" +} diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..5726b89 --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,2621 @@ +{ + "name": "web", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "web", + "version": "0.0.1", + "dependencies": { + "@tanstack/svelte-query": "^6.1.29", + "@tanstack/svelte-virtual": "^3.13.24", + "axios": "^1.16.1", + "bits-ui": "^2.18.1", + "clsx": "^2.1.1", + "lucide-svelte": "^1.0.1", + "maplibre-gl": "^5.24.0", + "mode-watcher": "^1.1.0", + "svelte-sonner": "^1.1.1", + "tailwind-merge": "^3.6.0", + "tailwind-variants": "^3.2.2" + }, + "devDependencies": { + "@sveltejs/adapter-static": "^3.0.10", + "@sveltejs/kit": "^2.57.0", + "@sveltejs/vite-plugin-svelte": "^7.0.0", + "@tailwindcss/vite": "^4.3.0", + "@types/node": "^25.8.0", + "svelte": "^5.55.2", + "svelte-check": "^4.4.6", + "tailwindcss": "^4.3.0", + "typescript": "^6.0.2", + "vite": "^8.0.7" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, + "node_modules/@internationalized/date": { + "version": "3.12.1", + "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.1.tgz", + "integrity": "sha512-6IedsVWXyq4P9Tj+TxuU8WGWM70hYLl12nbYU8jkikVpa6WXapFazPUcHUMDMoWftIDE2ILDkFFte6W2nFCkRQ==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@swc/helpers": "^0.5.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mapbox/jsonlint-lines-primitives": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz", + "integrity": "sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@mapbox/point-geometry": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz", + "integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==", + "license": "ISC" + }, + "node_modules/@mapbox/tiny-sdf": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.2.0.tgz", + "integrity": "sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/unitbezier": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz", + "integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/vector-tile": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-2.0.4.tgz", + "integrity": "sha512-AkOLcbgGTdXScosBWwmmD7cDlvOjkg/DetGva26pIRiZPdeJYjYKarIlb4uxVzi6bwHO6EWH82eZ5Nuv4T5DUg==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/point-geometry": "~1.1.0", + "@types/geojson": "^7946.0.16", + "pbf": "^4.0.1" + } + }, + "node_modules/@mapbox/whoots-js": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz", + "integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==", + "license": "ISC", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@maplibre/geojson-vt": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-6.1.0.tgz", + "integrity": "sha512-2eIY4gZxeKIVOZVNkAMb+5NgXhgsMQpOveTQAvnp53LYqHGJZDidk7Ew0Tged9PThidpbS+NFTh0g4zivhPDzQ==", + "license": "ISC", + "dependencies": { + "kdbush": "^4.0.2" + } + }, + "node_modules/@maplibre/maplibre-gl-style-spec": { + "version": "24.8.5", + "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-24.8.5.tgz", + "integrity": "sha512-EzEJmMt6thioRH7GI9LWS7ahXTcAhAPGWCe6oTP2Ps4YnsXOOAfeqx854lZaiDnwURfHmcCKV1mr6oo0i23x6w==", + "license": "ISC", + "dependencies": { + "@mapbox/jsonlint-lines-primitives": "~2.0.2", + "@mapbox/unitbezier": "^0.0.1", + "json-stringify-pretty-compact": "^4.0.0", + "minimist": "^1.2.8", + "quickselect": "^3.0.0", + "tinyqueue": "^3.0.0" + }, + "bin": { + "gl-style-format": "dist/gl-style-format.mjs", + "gl-style-migrate": "dist/gl-style-migrate.mjs", + "gl-style-validate": "dist/gl-style-validate.mjs" + } + }, + "node_modules/@maplibre/mlt": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.1.9.tgz", + "integrity": "sha512-g/tD8EYJB97udq33ipuJ9a4Q7fcbZnTEnUrgnEc/tLMmEL+zaCbR+X5fkDBO2dgpaAMsLH179qE3UXg2N0Nc/g==", + "license": "(MIT OR Apache-2.0)", + "dependencies": { + "@mapbox/point-geometry": "^1.1.0" + } + }, + "node_modules/@maplibre/vt-pbf": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.3.0.tgz", + "integrity": "sha512-jIvp8F5hQCcreqOOpEt42TJMUlsrEcpf/kI1T2v85YrQRV6PPXUcEXUg5karKtH6oh47XJZ4kHu56pUkOuqA7w==", + "license": "MIT", + "dependencies": { + "@mapbox/point-geometry": "^1.1.0", + "@mapbox/vector-tile": "^2.0.4", + "@maplibre/geojson-vt": "^5.0.4", + "@types/geojson": "^7946.0.16", + "@types/supercluster": "^7.1.3", + "pbf": "^4.0.1", + "supercluster": "^8.0.1" + } + }, + "node_modules/@maplibre/vt-pbf/node_modules/@maplibre/geojson-vt": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-5.0.4.tgz", + "integrity": "sha512-KGg9sma45S+stfH9vPCJk1J0lSDLWZgCT9Y8u8qWZJyjFlP8MNP1WGTxIMYJZjDvVT3PDn05kN1C95Sut1HpgQ==", + "license": "ISC" + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.130.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.130.0.tgz", + "integrity": "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==", + "devOptional": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.1.tgz", + "integrity": "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.1.tgz", + "integrity": "sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.1.tgz", + "integrity": "sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.1.tgz", + "integrity": "sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.1.tgz", + "integrity": "sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.1.tgz", + "integrity": "sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.1.tgz", + "integrity": "sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.1.tgz", + "integrity": "sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.1.tgz", + "integrity": "sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.1.tgz", + "integrity": "sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.1.tgz", + "integrity": "sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.1.tgz", + "integrity": "sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.1.tgz", + "integrity": "sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.1.tgz", + "integrity": "sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.1.tgz", + "integrity": "sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz", + "integrity": "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==", + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/adapter-static": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.10.tgz", + "integrity": "sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@sveltejs/kit": "^2.0.0" + } + }, + "node_modules/@sveltejs/kit": { + "version": "2.60.1", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.60.1.tgz", + "integrity": "sha512-mQjlkNo+rJvpln7V2IGY2j99BqhcFbS4UN0AQNKNYfhBAFZTuCDAdW3a1sgf330mvtNvsBXn3HpAhcmvdJTcIQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@sveltejs/acorn-typescript": "^1.0.5", + "@types/cookie": "^0.6.0", + "acorn": "^8.14.1", + "cookie": "^0.6.0", + "devalue": "^5.8.1", + "esm-env": "^1.2.2", + "kleur": "^4.1.5", + "magic-string": "^0.30.5", + "mrmime": "^2.0.0", + "set-cookie-parser": "^3.0.0", + "sirv": "^3.0.0" + }, + "bin": { + "svelte-kit": "svelte-kit.js" + }, + "engines": { + "node": ">=18.13" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0", + "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": "^5.3.3 || ^6.0.0", + "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.1.2.tgz", + "integrity": "sha512-DrUBA2UXRfDmUX/ZTiEopd3X40yavsJF1FX2RygcuIScHL7o5YX1fMvoYnDhjeJQC4weCOklirpNWlcb2NiSeA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "deepmerge": "^4.3.1", + "magic-string": "^0.30.21", + "obug": "^2.1.0", + "vitefu": "^1.1.2" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "svelte": "^5.46.4", + "vite": "^8.0.0-beta.7 || ^8.0.0" + } + }, + "node_modules/@swc/helpers": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.21.tgz", + "integrity": "sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz", + "integrity": "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.21.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.0" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.0.tgz", + "integrity": "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-x64": "4.3.0", + "@tailwindcss/oxide-freebsd-x64": "4.3.0", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-x64-musl": "4.3.0", + "@tailwindcss/oxide-wasm32-wasi": "4.3.0", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.0.tgz", + "integrity": "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.0.tgz", + "integrity": "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.0.tgz", + "integrity": "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.0.tgz", + "integrity": "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.0.tgz", + "integrity": "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.0.tgz", + "integrity": "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.0.tgz", + "integrity": "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.0.tgz", + "integrity": "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.0.tgz", + "integrity": "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.0.tgz", + "integrity": "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.10.0", + "@emnapi/runtime": "^1.10.0", + "@emnapi/wasi-threads": "^1.2.1", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz", + "integrity": "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.0.tgz", + "integrity": "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.0.tgz", + "integrity": "sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.0", + "@tailwindcss/oxide": "4.3.0", + "tailwindcss": "4.3.0" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.100.10", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.10.tgz", + "integrity": "sha512-8UR0yJR+GiQ40m3lPhUr0xbfAupe6GSQiksSBSa9SM2NjezFyxXCIA69/lz8cSoNKZLrw1/PktIyQBJcVeMi3w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/svelte-query": { + "version": "6.1.29", + "resolved": "https://registry.npmjs.org/@tanstack/svelte-query/-/svelte-query-6.1.29.tgz", + "integrity": "sha512-qB6hv21JzGvUtKcqUFhbhYEp1qp2/x/PgBaeQVNCkz+BvygLCv/+OMnrzzi5hLoIDJgIbEtQSoX3xW85OYJ9JA==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.100.10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "svelte": "^5.25.0" + } + }, + "node_modules/@tanstack/svelte-virtual": { + "version": "3.13.24", + "resolved": "https://registry.npmjs.org/@tanstack/svelte-virtual/-/svelte-virtual-3.13.24.tgz", + "integrity": "sha512-Up3LOD5Cj+oJ3GuKfM1Li06jzzZMIZnRPmu3aik9rJQgk7jq7LgPo4yumfUw4+I4edjYfyPKSZnXGwZ9Vjlebw==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.14.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "svelte": "^3.48.0 || ^4.0.0 || ^5.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.14.0.tgz", + "integrity": "sha512-JLANqGy/D6k4Ujmh8Tr25lGimuOXNiaVyXaCAZS0W+1390sADdGnyUdSWNIfd49gebtIxGMij4IktRVzrdr12Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "license": "MIT" + }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.8.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.8.0.tgz", + "integrity": "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@types/supercluster": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz", + "integrity": "sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz", + "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/bits-ui": { + "version": "2.18.1", + "resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-2.18.1.tgz", + "integrity": "sha512-KkemzKFH4T3gt3H+P86JcnAWExjByv/6vlwjm/BoCwTPHu03yiCdxbghdJLvFReQTe0acCAiRcKfmixxD6XvlA==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.1", + "@floating-ui/dom": "^1.7.1", + "esm-env": "^1.1.2", + "runed": "^0.35.1", + "svelte-toolbelt": "^0.10.6", + "tabbable": "^6.2.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/huntabyte" + }, + "peerDependencies": { + "@internationalized/date": "^3.8.1", + "svelte": "^5.33.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "devOptional": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devalue": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz", + "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==", + "license": "MIT" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/earcut": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz", + "integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==", + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.21.3", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.3.tgz", + "integrity": "sha512-QyL119InA+XXEkNLNTPCXPugSvOfhwv0JOlGNzvxs0hZaiHLNvXSpudUWsOlsXGWJh8G6ckCScEkVHfX3kw/2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "license": "MIT" + }, + "node_modules/esrap": { + "version": "2.2.9", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.9.tgz", + "integrity": "sha512-4KijP+NxCWthMCUC3qHbE6n4vCjqgJS1uAYKhuT/GWfFTf1Qyive2TgOjep+gzbSzRfnNyaN/UU9YmdOt8Eg0A==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "peerDependencies": { + "@typescript-eslint/types": "^8.2.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/types": { + "optional": true + } + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gl-matrix": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz", + "integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==", + "license": "MIT" + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/json-stringify-pretty-compact": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz", + "integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==", + "license": "MIT" + }, + "node_modules/kdbush": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz", + "integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==", + "license": "ISC" + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "devOptional": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "license": "MIT" + }, + "node_modules/lucide-svelte": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lucide-svelte/-/lucide-svelte-1.0.1.tgz", + "integrity": "sha512-WvzZgk0pqzgda+AErLvgWxHkfg/+GgUwqKMRHvzt0IqyMdmyEDzDCk3Z+Wo/3y753oIgx8u9Q4eUbWkghFa8Jg==", + "deprecated": "Package deprecated. Please use @lucide/svelte instead.", + "license": "ISC", + "peerDependencies": { + "svelte": "^3 || ^4 || ^5.0.0-next.42" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/maplibre-gl": { + "version": "5.24.0", + "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.24.0.tgz", + "integrity": "sha512-ALyFxgtd5R+65UqZ/++lOqwWcC0SNho9c27fYSyLmG7AfnAul2o46F05aDJGPbFU57wos9dgcIySHs0Xe6ia3A==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/jsonlint-lines-primitives": "^2.0.2", + "@mapbox/point-geometry": "^1.1.0", + "@mapbox/tiny-sdf": "^2.1.0", + "@mapbox/unitbezier": "^0.0.1", + "@mapbox/vector-tile": "^2.0.4", + "@mapbox/whoots-js": "^3.1.0", + "@maplibre/geojson-vt": "^6.1.0", + "@maplibre/maplibre-gl-style-spec": "^24.8.1", + "@maplibre/mlt": "^1.1.8", + "@maplibre/vt-pbf": "^4.3.0", + "@types/geojson": "^7946.0.16", + "earcut": "^3.0.2", + "gl-matrix": "^3.4.4", + "kdbush": "^4.0.2", + "murmurhash-js": "^1.0.0", + "pbf": "^4.0.1", + "potpack": "^2.1.0", + "quickselect": "^3.0.0", + "tinyqueue": "^3.0.0" + }, + "engines": { + "node": ">=16.14.0", + "npm": ">=8.1.0" + }, + "funding": { + "url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mode-watcher": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/mode-watcher/-/mode-watcher-1.1.0.tgz", + "integrity": "sha512-mUT9RRGPDYenk59qJauN1rhsIMKBmWA3xMF+uRwE8MW/tjhaDSCCARqkSuDTq8vr4/2KcAxIGVjACxTjdk5C3g==", + "license": "MIT", + "dependencies": { + "runed": "^0.25.0", + "svelte-toolbelt": "^0.7.1" + }, + "peerDependencies": { + "svelte": "^5.27.0" + } + }, + "node_modules/mode-watcher/node_modules/runed": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/runed/-/runed-0.25.0.tgz", + "integrity": "sha512-7+ma4AG9FT2sWQEA0Egf6mb7PBT2vHyuHail1ie8ropfSjvZGtEAx8YTmUjv/APCsdRRxEVvArNjALk9zFSOrg==", + "funding": [ + "https://github.com/sponsors/huntabyte", + "https://github.com/sponsors/tglide" + ], + "dependencies": { + "esm-env": "^1.0.0" + }, + "peerDependencies": { + "svelte": "^5.7.0" + } + }, + "node_modules/mode-watcher/node_modules/svelte-toolbelt": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.7.1.tgz", + "integrity": "sha512-HcBOcR17Vx9bjaOceUvxkY3nGmbBmCBBbuWLLEWO6jtmWH8f/QoWmbyUfQZrpDINH39en1b8mptfPQT9VKQ1xQ==", + "funding": [ + "https://github.com/sponsors/huntabyte" + ], + "dependencies": { + "clsx": "^2.1.1", + "runed": "^0.23.2", + "style-to-object": "^1.0.8" + }, + "engines": { + "node": ">=18", + "pnpm": ">=8.7.0" + }, + "peerDependencies": { + "svelte": "^5.0.0" + } + }, + "node_modules/mode-watcher/node_modules/svelte-toolbelt/node_modules/runed": { + "version": "0.23.4", + "resolved": "https://registry.npmjs.org/runed/-/runed-0.23.4.tgz", + "integrity": "sha512-9q8oUiBYeXIDLWNK5DfCWlkL0EW3oGbk845VdKlPeia28l751VpfesaB/+7pI6rnbx1I6rqoZ2fZxptOJLxILA==", + "funding": [ + "https://github.com/sponsors/huntabyte", + "https://github.com/sponsors/tglide" + ], + "dependencies": { + "esm-env": "^1.0.0" + }, + "peerDependencies": { + "svelte": "^5.7.0" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/murmurhash-js": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz", + "integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "devOptional": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "devOptional": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/pbf": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.1.tgz", + "integrity": "sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==", + "license": "BSD-3-Clause", + "dependencies": { + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "devOptional": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "devOptional": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/potpack": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz", + "integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==", + "license": "ISC" + }, + "node_modules/protocol-buffers-schema": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz", + "integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==", + "license": "MIT" + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/quickselect": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz", + "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==", + "license": "ISC" + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/resolve-protobuf-schema": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz", + "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==", + "license": "MIT", + "dependencies": { + "protocol-buffers-schema": "^3.3.1" + } + }, + "node_modules/rolldown": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz", + "integrity": "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.130.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.1", + "@rolldown/binding-darwin-arm64": "1.0.1", + "@rolldown/binding-darwin-x64": "1.0.1", + "@rolldown/binding-freebsd-x64": "1.0.1", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.1", + "@rolldown/binding-linux-arm64-gnu": "1.0.1", + "@rolldown/binding-linux-arm64-musl": "1.0.1", + "@rolldown/binding-linux-ppc64-gnu": "1.0.1", + "@rolldown/binding-linux-s390x-gnu": "1.0.1", + "@rolldown/binding-linux-x64-gnu": "1.0.1", + "@rolldown/binding-linux-x64-musl": "1.0.1", + "@rolldown/binding-openharmony-arm64": "1.0.1", + "@rolldown/binding-wasm32-wasi": "1.0.1", + "@rolldown/binding-win32-arm64-msvc": "1.0.1", + "@rolldown/binding-win32-x64-msvc": "1.0.1" + } + }, + "node_modules/runed": { + "version": "0.35.1", + "resolved": "https://registry.npmjs.org/runed/-/runed-0.35.1.tgz", + "integrity": "sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q==", + "funding": [ + "https://github.com/sponsors/huntabyte", + "https://github.com/sponsors/tglide" + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3", + "esm-env": "^1.0.0", + "lz-string": "^1.5.0" + }, + "peerDependencies": { + "@sveltejs/kit": "^2.21.0", + "svelte": "^5.7.0" + }, + "peerDependenciesMeta": { + "@sveltejs/kit": { + "optional": true + } + } + }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/set-cookie-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz", + "integrity": "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "devOptional": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, + "node_modules/supercluster": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz", + "integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==", + "license": "ISC", + "dependencies": { + "kdbush": "^4.0.2" + } + }, + "node_modules/svelte": { + "version": "5.55.7", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.7.tgz", + "integrity": "sha512-ymI5ykLPwIHW839E053FQbI1G+jnRFJEw3Kv5Y4njixVWywQBx+NUFpkkKyk5LIb36Fg9DVXSYpqiGekLD0hyw==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.5", + "@types/estree": "^1.0.5", + "@types/trusted-types": "^2.0.7", + "acorn": "^8.12.1", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.8.1", + "esm-env": "^1.2.1", + "esrap": "^2.2.4", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte-check": { + "version": "4.4.8", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.4.8.tgz", + "integrity": "sha512-67adfgBox5eNSNIvIIwgFizKGdcRrGpiMoNO2obHcYuLz7iTa8Xgm/NGU3ntMFnNm8K1grFOIG6HhMLX/vcN8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", + "picocolors": "^1.0.0", + "sade": "^1.7.4" + }, + "bin": { + "svelte-check": "bin/svelte-check" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": ">=5.0.0" + } + }, + "node_modules/svelte-sonner": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/svelte-sonner/-/svelte-sonner-1.1.1.tgz", + "integrity": "sha512-5cd3p7wa4cq0NsqslMwdlPb7x1JglEZ/GKrLePWNr5bCxR1nagAVrY01FRFrXfUGs41miLt3C327+8XJo5BzZw==", + "license": "MIT", + "dependencies": { + "runed": "^0.28.0" + }, + "peerDependencies": { + "svelte": "^5.0.0" + } + }, + "node_modules/svelte-sonner/node_modules/runed": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/runed/-/runed-0.28.0.tgz", + "integrity": "sha512-k2xx7RuO9hWcdd9f+8JoBeqWtYrm5CALfgpkg2YDB80ds/QE4w0qqu34A7fqiAwiBBSBQOid7TLxwxVC27ymWQ==", + "funding": [ + "https://github.com/sponsors/huntabyte", + "https://github.com/sponsors/tglide" + ], + "license": "MIT", + "dependencies": { + "esm-env": "^1.0.0" + }, + "peerDependencies": { + "svelte": "^5.7.0" + } + }, + "node_modules/svelte-toolbelt": { + "version": "0.10.6", + "resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.10.6.tgz", + "integrity": "sha512-YWuX+RE+CnWYx09yseAe4ZVMM7e7GRFZM6OYWpBKOb++s+SQ8RBIMMe+Bs/CznBMc0QPLjr+vDBxTAkozXsFXQ==", + "funding": [ + "https://github.com/sponsors/huntabyte" + ], + "dependencies": { + "clsx": "^2.1.1", + "runed": "^0.35.1", + "style-to-object": "^1.0.8" + }, + "engines": { + "node": ">=18", + "pnpm": ">=8.7.0" + }, + "peerDependencies": { + "svelte": "^5.30.2" + } + }, + "node_modules/tabbable": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", + "integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==", + "license": "MIT" + }, + "node_modules/tailwind-merge": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", + "integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwind-variants": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/tailwind-variants/-/tailwind-variants-3.2.2.tgz", + "integrity": "sha512-Mi4kHeMTLvKlM98XPnK+7HoBPmf4gygdFmqQPaDivc3DpYS6aIY6KiG/PgThrGvii5YZJqRsPz0aPyhoFzmZgg==", + "license": "MIT", + "engines": { + "node": ">=16.x", + "pnpm": ">=7.x" + }, + "peerDependencies": { + "tailwind-merge": ">=3.0.0", + "tailwindcss": "*" + }, + "peerDependenciesMeta": { + "tailwind-merge": { + "optional": true + } + } + }, + "node_modules/tailwindcss": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", + "integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyqueue": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz", + "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==", + "license": "ISC" + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.0.13", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz", + "integrity": "sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.14", + "rolldown": "1.0.1", + "tinyglobby": "^0.2.16" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "devOptional": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "license": "MIT" + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..dfc4d5b --- /dev/null +++ b/web/package.json @@ -0,0 +1,39 @@ +{ + "name": "web", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "prepare": "svelte-kit sync || echo ''", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" + }, + "devDependencies": { + "@sveltejs/adapter-static": "^3.0.10", + "@sveltejs/kit": "^2.57.0", + "@sveltejs/vite-plugin-svelte": "^7.0.0", + "@tailwindcss/vite": "^4.3.0", + "@types/node": "^25.8.0", + "svelte": "^5.55.2", + "svelte-check": "^4.4.6", + "tailwindcss": "^4.3.0", + "typescript": "^6.0.2", + "vite": "^8.0.7" + }, + "dependencies": { + "@tanstack/svelte-query": "^6.1.29", + "@tanstack/svelte-virtual": "^3.13.24", + "axios": "^1.16.1", + "bits-ui": "^2.18.1", + "clsx": "^2.1.1", + "lucide-svelte": "^1.0.1", + "maplibre-gl": "^5.24.0", + "mode-watcher": "^1.1.0", + "svelte-sonner": "^1.1.1", + "tailwind-merge": "^3.6.0", + "tailwind-variants": "^3.2.2" + } +} diff --git a/web/src/app.css b/web/src/app.css new file mode 100644 index 0000000..1b5fedc --- /dev/null +++ b/web/src/app.css @@ -0,0 +1,87 @@ +@import "tailwindcss"; + +/* + * shadcn-svelte design tokens. Mirrors the canonical "new-york" preset. + * Both light + dark are declared so mode-watcher can flip between them. + */ +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 240 10% 3.9%; + --card: 0 0% 100%; + --card-foreground: 240 10% 3.9%; + --popover: 0 0% 100%; + --popover-foreground: 240 10% 3.9%; + --primary: 240 5.9% 10%; + --primary-foreground: 0 0% 98%; + --secondary: 240 4.8% 95.9%; + --secondary-foreground: 240 5.9% 10%; + --muted: 240 4.8% 95.9%; + --muted-foreground: 240 3.8% 46.1%; + --accent: 240 4.8% 95.9%; + --accent-foreground: 240 5.9% 10%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 0 0% 98%; + --border: 240 5.9% 90%; + --input: 240 5.9% 90%; + --ring: 240 5.9% 10%; + --radius: 0.5rem; + } + + .dark { + --background: 240 10% 3.9%; + --foreground: 0 0% 98%; + --card: 240 10% 3.9%; + --card-foreground: 0 0% 98%; + --popover: 240 10% 3.9%; + --popover-foreground: 0 0% 98%; + --primary: 0 0% 98%; + --primary-foreground: 240 5.9% 10%; + --secondary: 240 3.7% 15.9%; + --secondary-foreground: 0 0% 98%; + --muted: 240 3.7% 15.9%; + --muted-foreground: 240 5% 64.9%; + --accent: 240 3.7% 15.9%; + --accent-foreground: 0 0% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 0 0% 98%; + --border: 240 3.7% 15.9%; + --input: 240 3.7% 15.9%; + --ring: 240 4.9% 83.9%; + } +} + +@theme inline { + --color-background: hsl(var(--background)); + --color-foreground: hsl(var(--foreground)); + --color-card: hsl(var(--card)); + --color-card-foreground: hsl(var(--card-foreground)); + --color-popover: hsl(var(--popover)); + --color-popover-foreground: hsl(var(--popover-foreground)); + --color-primary: hsl(var(--primary)); + --color-primary-foreground: hsl(var(--primary-foreground)); + --color-secondary: hsl(var(--secondary)); + --color-secondary-foreground: hsl(var(--secondary-foreground)); + --color-muted: hsl(var(--muted)); + --color-muted-foreground: hsl(var(--muted-foreground)); + --color-accent: hsl(var(--accent)); + --color-accent-foreground: hsl(var(--accent-foreground)); + --color-destructive: hsl(var(--destructive)); + --color-destructive-foreground: hsl(var(--destructive-foreground)); + --color-border: hsl(var(--border)); + --color-input: hsl(var(--input)); + --color-ring: hsl(var(--ring)); + --radius-lg: var(--radius); + --radius-md: calc(var(--radius) - 2px); + --radius-sm: calc(var(--radius) - 4px); +} + +@layer base { + * { + border-color: hsl(var(--border)); + } + body { + background-color: hsl(var(--background)); + color: hsl(var(--foreground)); + } +} diff --git a/web/src/app.d.ts b/web/src/app.d.ts new file mode 100644 index 0000000..da08e6d --- /dev/null +++ b/web/src/app.d.ts @@ -0,0 +1,13 @@ +// See https://svelte.dev/docs/kit/types#app.d.ts +// for information about these interfaces +declare global { + namespace App { + // interface Error {} + // interface Locals {} + // interface PageData {} + // interface PageState {} + // interface Platform {} + } +} + +export {}; diff --git a/web/src/app.html b/web/src/app.html new file mode 100644 index 0000000..9c66b7d --- /dev/null +++ b/web/src/app.html @@ -0,0 +1,14 @@ + + + + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/web/src/lib/actions/gridKeyNav.ts b/web/src/lib/actions/gridKeyNav.ts new file mode 100644 index 0000000..80529eb --- /dev/null +++ b/web/src/lib/actions/gridKeyNav.ts @@ -0,0 +1,501 @@ +import { toast } from 'svelte-sonner'; +import { batchEdit } from '$lib/services/batch'; +import { patchTargets } from '$lib/services/bulk'; +import { + addToHeap, + likePhoto, + removeFromHeap, + unlikePhoto, + type PpAlbum +} from '$lib/services/photoprism'; +import { queryClient } from '$lib/queryClient'; +import { filters } from '$lib/stores/filters.svelte'; +import { closePreview, openPreview, preview } from '$lib/stores/preview.svelte'; +import { + clearSelection, + indexOf, + selectRange, + selection, + setAnchor, + setFocused, + toggle +} from '$lib/stores/selection.svelte'; +import { popAndRun, push as pushUndo } from '$lib/stores/undo.svelte'; +import { toggleLeftSidebar, toggleRightSidebar } from '$lib/stores/view.svelte'; +import type { PpPhoto } from '$lib/types/photoprism'; + +/** + * Optional parameters the host passes via `use:gridKeyNav={...}`. + * + * - `scrollToIndex`: invoked when the action's own arrow-nav lands on a + * tile that's currently windowed-out of the DOM. The host expands its + * render window and scrolls the now-mounted shell into view. + * - `onArrow`: when provided, the action delegates ALL arrow keys to the + * host instead of computing moves itself. Required for grids with + * interleaved non-tile rows (e.g. month headers): linear +/-cols math + * skips wrong because the column count of header rows is 1 (full-span), + * not the tile column count. The host owns the visual-row map and + * handles the (row, col) translation. Mirrors mule-image's + * `useGridKeyNav` pattern. + */ +export type ArrowKey = 'ArrowLeft' | 'ArrowRight' | 'ArrowUp' | 'ArrowDown'; + +export interface GridKeyNavParams { + scrollToIndex?: (i: number) => void; + onArrow?: (key: ArrowKey, extending: boolean) => void; +} + +/** + * Svelte `action` for the timeline grid. Owns: + * - Arrow-key focus navigation (with shift-extend) inside the visible grid + * - Click + shift/ctrl click selection mutations + * - Window-level shortcuts mirroring mule-image's keyboard layer: + * x archive-toggle, u restore, f favorite-toggle, s + (1–9) add to + * heap N (bare s adds to the currently-viewed heap), b/Tab toggles + * left sidebar, i toggles right sidebar, space/enter opens preview, + * esc clears, ⌘Z undoes, ⌘A selects all visible. + * Rating + color labels are mouse-driven via the metadata sidebar — no + * keyboard shortcuts. + * + * Archive / restore target a synthesized "cull target list" — in priority: + * 1. preview overlay uid (when open) — applies to the visible preview + * photo even if the grid still shows a stale selection + * 2. multi-selection set + * 3. focused tile + */ +export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) { + let scrollToIndex = params.scrollToIndex; + let onArrow = params.onArrow; + + /** Cached column count for the visible grid. Read from CSS + * (`grid-template-columns` resolves to a space-separated list of px + * sizes), invalidated by a `ResizeObserver` on the grid host. This + * keeps the read DOM-cheap regardless of how many tiles are mounted — + * critical once windowing renders only a slice of the order. */ + let cachedCols: number | null = null; + let gridEl: HTMLElement | null = null; + + function findGrid(): HTMLElement | null { + // The `[role="group"][aria-label="Photos"]` or simply the first + // element whose computed grid-template-columns has >1 track. The + // timeline grid sits inside `node` (the action target =
). + if (gridEl && node.contains(gridEl)) return gridEl; + const candidate = node.querySelector('[data-photo-grid]'); + if (candidate) { + gridEl = candidate; + return candidate; + } + // Fallback: the first descendant that's display: grid with ≥2 cols. + // Avoids a hard coupling on the data-attribute in case the host + // hasn't tagged it yet. + for (const el of node.querySelectorAll('*')) { + const cs = getComputedStyle(el); + if (cs.display === 'grid' && cs.gridTemplateColumns.split(' ').length > 1) { + gridEl = el; + return el; + } + } + return null; + } + + function tilesPerRow(): number { + if (cachedCols !== null) return cachedCols; + const grid = findGrid(); + if (!grid) return 1; + const cols = getComputedStyle(grid).gridTemplateColumns.split(' ').filter(Boolean).length; + cachedCols = Math.max(1, cols); + return cachedCols; + } + + const ro = new ResizeObserver(() => { + // Container width changed → column count likely changed too. + // Cheaper to invalidate than to recompute; tilesPerRow recomputes + // on next access (which is per keystroke at most). + cachedCols = null; + }); + ro.observe(node); + + function focusedIndex(): number { + return indexOf(selection.focused); + } + + /** Move the focus cursor by `delta` tiles. When the move is NOT a + * shift-extension, the anchor is bumped to the new focused tile so the + * next shift-click/arrow starts from the user's current cursor (the + * "starting photo") instead of a stale toggle/selectOnly anchor. + * + * Scroll-into-view tries the direct DOM lookup first (works pre- + * windowing AND post-windowing for tiles already in the visible + * window); if the tile isn't rendered (windowed out), defer to the + * host-provided `scrollToIndex` which expands the window. */ + function moveFocus(delta: number, extending: boolean) { + if (selection.order.length === 0) return; + const cur = focusedIndex(); + const next = + cur < 0 + ? delta > 0 + ? 0 + : selection.order.length - 1 + : Math.min(Math.max(0, cur + delta), selection.order.length - 1); + const nextUid = selection.order[next]; + setFocused(nextUid); + if (!extending) setAnchor(nextUid); + const tile = node.querySelector(`[data-uid="${nextUid}"]`); + if (tile) { + tile.scrollIntoView({ block: 'nearest', inline: 'nearest' }); + } else { + scrollToIndex?.(next); + } + } + + /** Synthesize a target list. Preview wins, then multi, then focused. */ + function cullTargets(): string[] { + if (preview.uid) return [preview.uid]; + if (selection.ids.size > 0) return Array.from(selection.ids); + if (selection.focused) return [selection.focused]; + return []; + } + + /** Look up a photo's current cached state without forcing a refetch. + * Walks every `['photos', …]` cache entry first, then the per-photo + * cache. Lets `x` decide "archive vs restore" based on the actual current + * state instead of always sending Archived=true. + * + * The `['photos', …]` namespace holds two shapes: a flat `PpPhoto[]` + * (e.g. ratings/colors pools) and TanStack's `InfiniteData` envelope + * (`{pages: PpPhoto[][], pageParams}`) used by the timeline's infinite + * scroll. Walk both — assuming a flat array on the timeline cache used + * to throw `list.find is not a function` and abort the F/X handlers. */ + function cachedPhoto(uid: string): PpPhoto | undefined { + const lists = queryClient.getQueriesData({ queryKey: ['photos'] }); + for (const [, data] of lists) { + if (!data) continue; + if (Array.isArray(data)) { + const hit = (data as PpPhoto[]).find((p) => p.UID === uid); + if (hit) return hit; + continue; + } + const pages = (data as { pages?: PpPhoto[][] }).pages; + if (!Array.isArray(pages)) continue; + for (const page of pages) { + const hit = page?.find?.((p) => p.UID === uid); + if (hit) return hit; + } + } + return queryClient.getQueryData(['photo', uid]); + } + + async function toggleArchive(direction: 'archive' | 'restore' | 'toggle') { + const ids = cullTargets(); + if (ids.length === 0) { + const verb = direction === 'restore' ? 'restore' : 'archive'; + toast.message(`Nothing to ${verb}`, { + description: 'Click a photo or select some first' + }); + return; + } + + let target: boolean; + if (direction === 'archive') target = true; + else if (direction === 'restore') target = false; + else { + const first = cachedPhoto(ids[0]); + target = !(first?.Archived ?? false); + } + + await patchTargets( + ids, + { Archived: target }, + target ? `Archived ${ids.length}` : `Restored ${ids.length}`, + (p) => ({ Archived: p.Archived ?? false }) + ); + } + + /** Flip the Favorite (heart) flag on cull targets. Reads the first + * target's cached `Favorite` to decide direction so a mixed selection + * resolves to "make them all favorited" when the first isn't, mirroring + * the way `toggleArchive('toggle')` works. */ + async function toggleFavoriteOnTargets() { + const ids = cullTargets(); + if (ids.length === 0) { + toast.message('Nothing to favorite', { + description: 'Click a photo or select some first' + }); + return; + } + const first = cachedPhoto(ids[0]); + const next = !(first?.Favorite ?? false); + const { updated, errors } = await batchEdit(ids, (id) => + next ? likePhoto(id) : unlikePhoto(id) + ); + void queryClient.invalidateQueries({ queryKey: ['photos'] }); + for (const id of ids) { + void queryClient.invalidateQueries({ queryKey: ['photo', id] }); + } + const verb = next ? 'Favorited' : 'Unfavorited'; + if (errors.length) { + // Surface the actual first error message — silent failures here are + // the #1 reason `f` "doesn't work" (e.g. permission, network, 404). + toast.error(`${verb} ${updated.length}; ${errors.length} failed`, { + description: errors[0].message + }); + return; + } + toast.success(`${verb} ${ids.length}`); + pushUndo(`${verb} ${ids.length}`, async () => { + await batchEdit(ids, (id) => (next ? unlikePhoto(id) : likePhoto(id))); + void queryClient.invalidateQueries({ queryKey: ['photos'] }); + for (const id of ids) { + void queryClient.invalidateQueries({ queryKey: ['photo', id] }); + } + }); + } + + // ── S chord (add-to-heap) ──────────────────────────────────────────── + // Press S: arm a short timer. A digit 1–9 within the window adds the + // cull targets to the Nth heap in the heap list. Any other key cancels + // the chord without firing. On timeout, fall back to the currently- + // viewed heap (i.e. when section==='heap'); otherwise show a hint toast. + let sChordTimer: number | null = null; + const S_CHORD_MS = 500; + + function clearSChord() { + if (sChordTimer !== null) { + window.clearTimeout(sChordTimer); + sChordTimer = null; + } + } + + async function addCullTargetsToHeap(heap: PpAlbum) { + const ids = cullTargets(); + if (ids.length === 0) { + toast.message('Nothing to add', { + description: 'Click a photo or select some first' + }); + return; + } + try { + await addToHeap(heap.UID, ids); + void queryClient.invalidateQueries({ queryKey: ['heaps'] }); + void queryClient.invalidateQueries({ queryKey: ['photos'] }); + toast.success(`Added ${ids.length} → ${heap.Title}`); + pushUndo(`Added ${ids.length} to ${heap.Title}`, async () => { + await removeFromHeap(heap.UID, ids); + void queryClient.invalidateQueries({ queryKey: ['heaps'] }); + void queryClient.invalidateQueries({ queryKey: ['photos'] }); + }); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Add-to-heap failed'); + } + } + + async function addCullTargetsToHeapByIndex(idx: number) { + const heaps = queryClient.getQueryData(['heaps']) ?? []; + if (idx < 1 || idx > heaps.length) { + toast.message(`No heap #${idx}`); + return; + } + await addCullTargetsToHeap(heaps[idx - 1]); + } + + async function addCullTargetsToActiveHeap() { + if (filters.section !== 'heap' || !filters.heapUid) { + toast.message('Press S then 1–9 to pick a heap'); + return; + } + const heaps = queryClient.getQueryData(['heaps']) ?? []; + const heap = heaps.find((h) => h.UID === filters.heapUid); + if (!heap) { + toast.message('Active heap not found'); + return; + } + await addCullTargetsToHeap(heap); + } + + function openPreviewFromGrid() { + const id = selection.focused ?? selection.order[0]; + if (!id) return; + openPreview(id, selection.order); + } + + function togglePreview() { + if (preview.uid) closePreview(); + else openPreviewFromGrid(); + } + + async function onKey(e: KeyboardEvent) { + // Don't hijack typing inside form fields. + const tag = (e.target as HTMLElement | null)?.tagName?.toLowerCase(); + if (tag === 'input' || tag === 'textarea' || tag === 'select') return; + + // S+digit chord. A digit 1–9 within the chord window consumes the key + // and fires add-to-heap-N. Any other key cancels the chord without + // firing the default active-heap action — the user switched intent — + // and falls through to normal handling for that key. + if (sChordTimer !== null) { + if (/^[1-9]$/.test(e.key)) { + e.preventDefault(); + clearSChord(); + void addCullTargetsToHeapByIndex(parseInt(e.key, 10)); + return; + } + clearSChord(); + } + + const meta = e.metaKey || e.ctrlKey; + const shift = e.shiftKey; + const inPreview = preview.uid !== null; + + // ── Grid-only nav keys (preview owns its own Arrow/Esc) ────────────── + if (!inPreview) { + switch (e.key) { + case 'ArrowLeft': + case 'ArrowRight': + case 'ArrowUp': + case 'ArrowDown': + e.preventDefault(); + if (onArrow) { + // Host owns the visual-row map (needed for grids with + // interleaved headers). The host calls setFocused + + // scrollToIndex + selectRange-on-shift itself. + onArrow(e.key, shift); + } else { + const delta = + e.key === 'ArrowLeft' + ? -1 + : e.key === 'ArrowRight' + ? 1 + : e.key === 'ArrowUp' + ? -tilesPerRow() + : tilesPerRow(); + moveFocus(delta, shift); + if (shift && selection.focused) selectRange(selection.focused); + } + return; + case 'Escape': + clearSelection(); + setFocused(null); + return; + } + } + + // ── Mode-aware shortcuts (work in grid AND preview) ────────────────── + switch (e.key) { + case ' ': + case 'Enter': + e.preventDefault(); + togglePreview(); + return; + case 'Tab': + // Tab in the grid context = mule-image's left-sidebar toggle. + // Browsers reserve Tab for focus traversal — preventDefault + // here is fine because the grid owns this surface. + e.preventDefault(); + toggleLeftSidebar(); + return; + case 'i': + case 'I': + if (!meta && !shift && !inPreview) { + e.preventDefault(); + toggleRightSidebar(); + } + return; + case 'b': + case 'B': + if (!meta && !shift) { + e.preventDefault(); + toggleLeftSidebar(); + } + return; + case 'z': + case 'Z': + if (meta) { + e.preventDefault(); + const entry = await popAndRun(); + if (entry) toast.success(`Undone: ${entry.label}`); + else toast.message('Nothing to undo'); + } + return; + case 'a': + case 'A': + if (meta && !inPreview) { + e.preventDefault(); + for (const id of selection.order) selection.ids.add(id); + } + return; + case 'x': + case 'X': + if (meta || shift) return; + e.preventDefault(); + void toggleArchive('toggle'); + return; + case 'u': + case 'U': + if (meta || shift) return; + e.preventDefault(); + void toggleArchive('restore'); + return; + case 'f': + case 'F': + if (meta || shift) return; + e.preventDefault(); + void toggleFavoriteOnTargets(); + return; + case 's': + case 'S': + if (meta || shift) return; + // Arm the chord. A digit 1–9 within S_CHORD_MS picks heap N; + // otherwise we fall back to the currently-viewed heap. + e.preventDefault(); + clearSChord(); + sChordTimer = window.setTimeout(() => { + sChordTimer = null; + void addCullTargetsToActiveHeap(); + }, S_CHORD_MS); + return; + } + } + + function onClick(e: MouseEvent) { + const tile = (e.target as HTMLElement | null)?.closest('[data-tile]'); + if (!tile) return; + const uid = tile.dataset.uid; + if (!uid) return; + if (e.shiftKey) { + e.preventDefault(); + selectRange(uid); + setFocused(uid); + } else if (e.metaKey || e.ctrlKey) { + e.preventDefault(); + toggle(uid); + setFocused(uid); + } else if (selection.ids.size > 0) { + // When a multi-selection is active, a plain click reduces it to + // just this tile (matches mule-image's "selection mode" behaviour). + e.preventDefault(); + selection.ids.clear(); + selection.ids.add(uid); + setFocused(uid); + } + } + + node.addEventListener('click', onClick); + // Keydown lives on the window so arrow keys, Esc, ⌘Z, etc. work + // immediately on page load regardless of which element holds focus. + // The filters inside `onKey` keep form-field typing and preview mode + // safe (preview owns its own Arrow/Esc). + window.addEventListener('keydown', onKey); + + return { + update(next: GridKeyNavParams = {}) { + scrollToIndex = next.scrollToIndex; + onArrow = next.onArrow; + }, + destroy() { + clearSChord(); + node.removeEventListener('click', onClick); + window.removeEventListener('keydown', onKey); + ro.disconnect(); + } + }; +} diff --git a/web/src/lib/actions/nearBottom.ts b/web/src/lib/actions/nearBottom.ts new file mode 100644 index 0000000..9a859d1 --- /dev/null +++ b/web/src/lib/actions/nearBottom.ts @@ -0,0 +1,70 @@ +/** + * Fires `onHit` whenever the attached element scrolls near the bottom of + * its scroll container. Mirrors PhotoPrism's infinite-scroll trigger from + * `frontend/src/page/photos.vue`: an IntersectionObserver on a sentinel + * div with a rootMargin equal to ~4 viewport heights, so the next page is + * fetched well before the user actually reaches the end. + * + * Usage: attach to a sentinel
placed at the bottom of the scroll + * area. The host gates calls via `enabled` (= `hasNextPage && !isFetching`). + * + *
+ */ +export interface NearBottomParams { + onHit: () => void; + /** When false the observer ignores intersections (use for the + * hasNextPage + !isFetchingNextPage gate). */ + enabled?: boolean; + /** Pre-load distance in pixels. PhotoPrism uses `innerHeight * 4`; + * we default to the same. Caller can pass a number for tests. */ + preloadPx?: number; + /** Optional scroll root (defaults to the viewport). Pass the + * scrolling ancestor when the page itself doesn't scroll, which is + * our case — the timeline scrolls inside `
`. */ + root?: Element | null; +} + +export function nearBottom(node: HTMLElement, params: NearBottomParams) { + let current: NearBottomParams = params; + let io: IntersectionObserver | null = null; + + function buildObserver(p: NearBottomParams) { + io?.disconnect(); + const preload = p.preloadPx ?? Math.max(800, window.innerHeight * 4); + io = new IntersectionObserver( + (entries) => { + if (!current.enabled) return; + for (const e of entries) { + if (e.isIntersecting) { + current.onHit(); + return; + } + } + }, + { + root: p.root ?? null, + // Inflate the root's bottom edge so we trip well before + // the sentinel actually enters the viewport. + rootMargin: `0px 0px ${preload}px 0px` + } + ); + io.observe(node); + } + + buildObserver(current); + + return { + update(next: NearBottomParams) { + const rootChanged = next.root !== current.root; + const preloadChanged = next.preloadPx !== current.preloadPx; + current = next; + // `enabled` and `onHit` are read live inside the callback, + // so they don't require rebuilding the observer. Root and + // preloadPx are baked in at construction. + if (rootChanged || preloadChanged) buildObserver(current); + }, + destroy() { + io?.disconnect(); + } + }; +} diff --git a/web/src/lib/actions/resizable.ts b/web/src/lib/actions/resizable.ts new file mode 100644 index 0000000..c0873ca --- /dev/null +++ b/web/src/lib/actions/resizable.ts @@ -0,0 +1,78 @@ +/** + * Drag-to-resize Svelte action. Attaches pointerdown to the host element + * (a thin handle on the inner edge of a sidebar) and writes the new width + * back via the supplied setter. The pointer is captured so the drag keeps + * tracking when the cursor leaves the handle. + * + * edge: 'right' — handle on the right edge of the panel; drag right widens + * edge: 'left' — handle on the left edge of the panel; drag left widens + * + * Usage: + *
view.leftSidebarWidth, setWidth: setLeftSidebarWidth }} /> + */ +export interface ResizableParams { + edge: 'right' | 'left'; + getWidth: () => number; + setWidth: (px: number) => void; +} + +export function resizable(node: HTMLElement, initial: ResizableParams) { + let params = initial; + let pointerId = -1; + let startX = 0; + let startWidth = 0; + + function onDown(e: PointerEvent) { + if (e.button !== 0) return; + pointerId = e.pointerId; + startX = e.clientX; + startWidth = params.getWidth(); + node.setPointerCapture(pointerId); + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + node.addEventListener('pointermove', onMove); + node.addEventListener('pointerup', onUp); + node.addEventListener('pointercancel', onUp); + } + + function onMove(e: PointerEvent) { + if (e.pointerId !== pointerId) return; + const dx = e.clientX - startX; + const delta = params.edge === 'right' ? dx : -dx; + params.setWidth(startWidth + delta); + } + + function onUp(e: PointerEvent) { + if (pointerId === -1) return; + try { + node.releasePointerCapture(pointerId); + } catch { + // Pointer may already be released; ignore. + } + pointerId = -1; + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + node.removeEventListener('pointermove', onMove); + node.removeEventListener('pointerup', onUp); + node.removeEventListener('pointercancel', onUp); + } + + function onDoubleClick() { + // Reset to the current default-ish midpoint. Callers can override by + // providing their own dblclick handler; we just stop pointer events + // from leaking up so the page underneath doesn't react. + } + + node.addEventListener('pointerdown', onDown); + node.addEventListener('dblclick', onDoubleClick); + + return { + update(next: ResizableParams) { + params = next; + }, + destroy() { + node.removeEventListener('pointerdown', onDown); + node.removeEventListener('dblclick', onDoubleClick); + } + }; +} diff --git a/web/src/lib/actions/visibleRange.ts b/web/src/lib/actions/visibleRange.ts new file mode 100644 index 0000000..b080391 --- /dev/null +++ b/web/src/lib/actions/visibleRange.ts @@ -0,0 +1,173 @@ +/** + * Tracks the first/last visible tile indices inside the attached scroll + * container so the host can render only `[first - BUFFER, last + BUFFER]` + * and leave the rest unmounted. Mirrors PhotoPrism's pattern in + * `frontend/src/component/photo/view/cards.vue`: + * + * - One IntersectionObserver on the scroll root. + * - Observes every Nth tile (sample, not every tile, to keep observer + * overhead flat as the order grows). + * - The host registers nodes as they mount via `register(el, index)` + * and unregisters via `unregister(el)`. + * - The action calls `onChange(first, last)` whenever the visible + * window updates. + * + * Use it like: + * + * const range = $state({ first: 0, last: 0 }); + *
{ range.first = f; range.last = l; }, + * sampleEvery: 5, + * }}> + * {#each photos as p, i} + * {#if i >= range.first - BUFFER && i <= range.last + BUFFER} + * + * {:else} + *
+ * {/if} + * {/each} + *
+ * + * The returned controller exposes `register`/`unregister`/`expand` so the + * host can plumb them through. + */ + +export interface VisibleRangeParams { + onChange: (first: number, last: number) => void; + /** Sample 1 in N tiles. Higher numbers reduce observer overhead at + * the cost of resolution. PhotoPrism uses 5. */ + sampleEvery?: number; + /** Optional override: trigger zone in px around the scroll root. + * Defaults to 0 (only counts the visible viewport). */ + rootMargin?: string; +} + +export interface VisibleRangeController { + register(el: HTMLElement, index: number): void; + unregister(el: HTMLElement): void; + /** Force the window to include `index` (used by keyboard navigation + * before scrollIntoView so the target tile actually exists). The + * host should expand its own `first`/`last` reactive state — the + * action only tracks observed intersections. */ +} + +/** Public handle the host attaches to each tile to enrol it in the + * visibility observer. Returned by setup() rather than created here so + * it closes over the active observer instance. */ +export interface VisibleRangeHandle { + register(el: HTMLElement, index: number): void; + unregister(el: HTMLElement): void; +} + +export function visibleRange(node: HTMLElement, params: VisibleRangeParams) { + let current = params; + const indexByEl = new WeakMap(); + const visibleIndices = new Set(); + let observer: IntersectionObserver | null = null; + let lastFirst = -1; + let lastLast = -1; + + function rebuild() { + observer?.disconnect(); + observer = new IntersectionObserver( + (entries) => { + let dirty = false; + for (const e of entries) { + const i = indexByEl.get(e.target); + if (i === undefined) continue; + const wasIn = visibleIndices.has(i); + if (e.isIntersecting && !wasIn) { + visibleIndices.add(i); + dirty = true; + } else if (!e.isIntersecting && wasIn) { + visibleIndices.delete(i); + dirty = true; + } + } + if (!dirty) return; + emit(); + }, + { + root: node, + rootMargin: current.rootMargin ?? '0px' + } + ); + } + + function emit() { + if (visibleIndices.size === 0) { + // Don't emit (0, 0) — the host's last known window stays valid + // and the user is likely between layout passes. Once a sample + // tile re-enters view, the next intersection fires and we + // update for real. + return; + } + let first = Number.POSITIVE_INFINITY; + let last = Number.NEGATIVE_INFINITY; + for (const i of visibleIndices) { + if (i < first) first = i; + if (i > last) last = i; + } + if (first === lastFirst && last === lastLast) return; + lastFirst = first; + lastLast = last; + current.onChange(first, last); + } + + rebuild(); + + const handle: VisibleRangeHandle = { + register(el, index) { + const every = current.sampleEvery ?? 5; + // Sample 1-in-N tiles. The host blindly calls register for + // every mounted tile; we only attach the observer to the + // sample subset to keep observer load O(n/N). + if (index % every !== 0) return; + indexByEl.set(el, index); + observer?.observe(el); + }, + unregister(el) { + if (!indexByEl.has(el)) return; + const i = indexByEl.get(el); + if (i !== undefined) visibleIndices.delete(i); + indexByEl.delete(el); + observer?.unobserve(el); + emit(); + } + }; + + // Stash the handle on the node so the host can grab it via the + // action's return. Svelte's action API only returns update/destroy, + // so we expose `getHandle` through a one-shot accessor on the host. + (node as HTMLElement & { __visibleRange?: VisibleRangeHandle }).__visibleRange = handle; + + return { + update(next: VisibleRangeParams) { + const sampleChanged = (next.sampleEvery ?? 5) !== (current.sampleEvery ?? 5); + const marginChanged = next.rootMargin !== current.rootMargin; + current = next; + if (sampleChanged || marginChanged) { + // Re-observe everything under the new config. Cheapest is + // to disconnect; the host's tile-mount effects will + // re-register on next paint when they read sampleEvery. + observer?.disconnect(); + indexByEl as unknown; // no-op; entries stay valid for the rebuild + visibleIndices.clear(); + lastFirst = -1; + lastLast = -1; + rebuild(); + } + }, + destroy() { + observer?.disconnect(); + delete (node as HTMLElement & { __visibleRange?: VisibleRangeHandle }).__visibleRange; + } + }; +} + +/** Read the handle the action stashed on the scroll-root node. Used by + * the host's per-tile register/unregister calls. */ +export function getVisibleRangeHandle(node: HTMLElement | undefined): VisibleRangeHandle | null { + if (!node) return null; + return (node as HTMLElement & { __visibleRange?: VisibleRangeHandle }).__visibleRange ?? null; +} diff --git a/web/src/lib/assets/favicon.svg b/web/src/lib/assets/favicon.svg new file mode 100644 index 0000000..cc5dc66 --- /dev/null +++ b/web/src/lib/assets/favicon.svg @@ -0,0 +1 @@ +svelte-logo \ No newline at end of file diff --git a/web/src/lib/components/duplicates/CrossFolderGroupCard.svelte b/web/src/lib/components/duplicates/CrossFolderGroupCard.svelte new file mode 100644 index 0000000..01a7d38 --- /dev/null +++ b/web/src/lib/components/duplicates/CrossFolderGroupCard.svelte @@ -0,0 +1,265 @@ + + + + + +
+
+
+
+ {group.files.length} copies · {sizeLabel(group.size)} each +
+
+ sha1 {group.hash.slice(0, 16)}… +
+
+ +
+ +
+ {#each group.files as file (file.path)} + {@const isKeep = file.path === keep} + {@const isIndexed = file.path === group.indexedPath} + + {/each} +
+
diff --git a/web/src/lib/components/duplicates/DuplicatesView.svelte b/web/src/lib/components/duplicates/DuplicatesView.svelte new file mode 100644 index 0000000..8b085f5 --- /dev/null +++ b/web/src/lib/components/duplicates/DuplicatesView.svelte @@ -0,0 +1,225 @@ + + + +
+ +
+ + +
+ + + {#if activeTab === 'stacks'} +
+ {#if pending} +

Loading stacks…

+ {:else if error} +

+ Could not load stacks: {error instanceof Error + ? error.message + : 'unknown error'} +

+ {:else if stackCount === 0} +
+

No stacks.

+

+ PhotoPrism stacks byte-identical (or EXIF-identical) files. If you + don't have any, this tab stays empty. Cross-folder copies that + PhotoPrism rejected at index time live under the + + tab. +

+
+ {:else} +
+ {#each groups as group, i (group.photo.UID)} + + {/each} +
+ {/if} +
+ {/if} + + + {#if activeTab === 'cross-folder'} +
+
+

+ Byte-identical files PhotoPrism dropped at index time. Found by + scanning the originals tree directly. +

+ +
+ + {#if !scanRequested} +

+ Click Scan filesystem to look for byte-identical files spread + across folders. Pre-filtered by size, so even large libraries finish + in a few seconds. +

+ {:else if crossQuery.isFetching && !crossQuery.data} +

+ Hashing files under originals… +

+ {:else if crossQuery.isError} +

+ Scan failed: {crossQuery.error instanceof Error + ? crossQuery.error.message + : 'unknown error'} +

+ {:else if crossCount === 0} +

+ No cross-folder duplicates found. + {#if crossQuery.data} + + (scanned in {crossQuery.data.scannedMs} ms) + + {/if} +

+ {:else} +
+ {#each crossQuery.data?.groups ?? [] as group, i (group.hash)} + + {/each} +
+ {/if} +
+ {/if} +
diff --git a/web/src/lib/components/duplicates/StackGroupCard.svelte b/web/src/lib/components/duplicates/StackGroupCard.svelte new file mode 100644 index 0000000..656e96e --- /dev/null +++ b/web/src/lib/components/duplicates/StackGroupCard.svelte @@ -0,0 +1,289 @@ + + + + + + + +
+
+
+
+ {group.files.length} files in this stack +
+
+ {group.photo.OriginalName ?? group.photo.FileName ?? group.photo.Name ?? ''} +
+
+ +
+ +
+ {#each group.files as file (file.UID)} + {@const isBest = file.UID === best} + {@const sizeStr = sizeLabel(file.Size)} + + {/each} +
+
diff --git a/web/src/lib/components/layout/FolderTree.svelte b/web/src/lib/components/layout/FolderTree.svelte new file mode 100644 index 0000000..df1781d --- /dev/null +++ b/web/src/lib/components/layout/FolderTree.svelte @@ -0,0 +1,190 @@ + + + + +
    + {#each nodes as node (node.path)} + {@const open = openSet.has(node.path)} + {@const active = isActive(node.path)} + {@const hasChildren = node.children.length > 0} +
  • + +
    + {#if hasChildren} + + {:else} + + {/if} + + {#if !readonly} + +
    + + onCreateChild?.(node.path)} + > + + New subfolder + + onRename?.(node.path)} + > + + Rename + + + onDelete?.(node.path)} + > + + Delete folder… + + +
    + {/if} +
    + {#if hasChildren && open} + + {/if} +
  • + {/each} +
diff --git a/web/src/lib/components/layout/HeapConvertDialog.svelte b/web/src/lib/components/layout/HeapConvertDialog.svelte new file mode 100644 index 0000000..e269c7b --- /dev/null +++ b/web/src/lib/components/layout/HeapConvertDialog.svelte @@ -0,0 +1,226 @@ + + + + { + if (!o) onClose(); + }} +> + + + +
+ +
+ + {mode === 'copy' ? 'Copy' : 'Move'} heap to folder + + + {heap?.Title ?? ''} · {heap?.PhotoCount ?? 0} photo{heap?.PhotoCount === 1 + ? '' + : 's'} + +
+
+ + +
+
+ Destination +
+
+ {#if foldersQuery.isPending} +

Loading folders…

+ {:else if (foldersQuery.data ?? []).length === 0} +

+ No folders. Create one from the sidebar first. +

+ {:else} + (pickedPath = p)} + selectedPath={pickedPath} + readonly + /> + {/if} +
+
+ + +
+
+ + +
+ + +
+ +
+ + +
+
+
+
diff --git a/web/src/lib/components/layout/KebabMenu.svelte b/web/src/lib/components/layout/KebabMenu.svelte new file mode 100644 index 0000000..f2844ff --- /dev/null +++ b/web/src/lib/components/layout/KebabMenu.svelte @@ -0,0 +1,56 @@ + + + + + + + e.stopPropagation()} + > + + + + + {@render children()} + + + diff --git a/web/src/lib/components/layout/LeftSidebar.svelte b/web/src/lib/components/layout/LeftSidebar.svelte new file mode 100644 index 0000000..071fef2 --- /dev/null +++ b/web/src/lib/components/layout/LeftSidebar.svelte @@ -0,0 +1,389 @@ + + + + + (convertingHeap = null)} /> diff --git a/web/src/lib/components/layout/Toolbar.svelte b/web/src/lib/components/layout/Toolbar.svelte new file mode 100644 index 0000000..90f16ec --- /dev/null +++ b/web/src/lib/components/layout/Toolbar.svelte @@ -0,0 +1,73 @@ + + + +
+ + +
+ {@render children?.()} +
+ +
+ {@render trailing?.()} +
+ + {#if showRightToggle} + + {/if} +
diff --git a/web/src/lib/components/mule/AnimatedMule.svelte b/web/src/lib/components/mule/AnimatedMule.svelte new file mode 100644 index 0000000..43841f1 --- /dev/null +++ b/web/src/lib/components/mule/AnimatedMule.svelte @@ -0,0 +1,99 @@ + + + +
+
+ +
{MULIMAGO_ASCII}
+
+ +
+ {@render children?.()} +
+
+ + diff --git a/web/src/lib/components/preview/PreviewOverlay.svelte b/web/src/lib/components/preview/PreviewOverlay.svelte new file mode 100644 index 0000000..98d40d4 --- /dev/null +++ b/web/src/lib/components/preview/PreviewOverlay.svelte @@ -0,0 +1,163 @@ + + +{#if preview.uid !== null} + +{/if} diff --git a/web/src/lib/components/sidebar/BulkMetadataSidebar.svelte b/web/src/lib/components/sidebar/BulkMetadataSidebar.svelte new file mode 100644 index 0000000..8084be6 --- /dev/null +++ b/web/src/lib/components/sidebar/BulkMetadataSidebar.svelte @@ -0,0 +1,341 @@ + + + + diff --git a/web/src/lib/components/sidebar/RightSidebar.svelte b/web/src/lib/components/sidebar/RightSidebar.svelte new file mode 100644 index 0000000..f1272f7 --- /dev/null +++ b/web/src/lib/components/sidebar/RightSidebar.svelte @@ -0,0 +1,614 @@ + + + + diff --git a/web/src/lib/components/timeline/BulkActionBar.svelte b/web/src/lib/components/timeline/BulkActionBar.svelte new file mode 100644 index 0000000..7c08e4e --- /dev/null +++ b/web/src/lib/components/timeline/BulkActionBar.svelte @@ -0,0 +1,289 @@ + + +{#if targetCount > 0} +
+
+ + {#if isBulk} + {targetCount} selected + {:else} + Focused photo + {/if} + + +
+
+ + {#if heapPickerOpen} +
+ {#if heapsQuery.isPending} +

Loading…

+ {:else if (heapsQuery.data ?? []).length === 0} +

No heaps yet

+ {:else} + {#each heapsQuery.data ?? [] as heap, i (heap.UID)} + + {/each} + {/if} +
+ {/if} +
+ + + + {#if filters.section === 'archive'} + + {/if} + + +
+
+
+{/if} diff --git a/web/src/lib/index.ts b/web/src/lib/index.ts new file mode 100644 index 0000000..856f2b6 --- /dev/null +++ b/web/src/lib/index.ts @@ -0,0 +1 @@ +// place files you want to import through the `$lib` alias in this folder. diff --git a/web/src/lib/queryClient.ts b/web/src/lib/queryClient.ts new file mode 100644 index 0000000..98c0603 --- /dev/null +++ b/web/src/lib/queryClient.ts @@ -0,0 +1,15 @@ +import { QueryClient } from '@tanstack/svelte-query'; + +/** + * App-wide QueryClient singleton. `+layout.svelte` wires it into the + * provider; non-component code (Svelte actions, keyboard handlers) imports + * it directly to read cached photo state and invalidate after mutations. + */ +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30_000, + retry: 1 + } + } +}); diff --git a/web/src/lib/services/adapters/duplicates.ts b/web/src/lib/services/adapters/duplicates.ts new file mode 100644 index 0000000..2638c10 --- /dev/null +++ b/web/src/lib/services/adapters/duplicates.ts @@ -0,0 +1,41 @@ +/** + * Adapter that shapes PhotoPrism's `q=stack:true` photo list into a + * `DuplicateGroup[]` the UI consumes. Mirrors mule-image's + * `DuplicateGroup` interface so the view code stays declarative. + * + * PhotoPrism's `/photos?merged=true` inlines `Files[]` on each row + * already — no follow-up GET per group needed (confirmed against + * photoprism/photoprism:latest, May 2026). + */ + +import { listPhotos } from '$lib/services/photoprism'; +import type { PpFile, PpPhoto } from '$lib/types/photoprism'; + +export interface DuplicateGroup { + /** The Photo record that owns this stack. Carries marks, keywords, + * taken_at — everything the metadata sidebar reads off of. */ + photo: PpPhoto; + /** The variants the user picks among. Always 2+ entries; groups with + * a single file are filtered out (PhotoPrism shouldn't return them + * for `stack:true` anyway, but the guard is cheap). */ + files: PpFile[]; + /** Pre-selected "keep this" file = the one PhotoPrism marks Primary. + * The view starts with this highlighted and updates it on click. */ + bestFileUid: string; +} + +export async function listDuplicateGroups(): Promise { + const photos = await listPhotos({ + q: 'stack:true', + count: 200, + merged: true, + order: 'newest' + }); + return photos + .filter((p) => (p.Files?.length ?? 0) > 1) + .map((p) => { + const files = p.Files ?? []; + const primary = files.find((f) => f.Primary) ?? files[0]; + return { photo: p, files, bestFileUid: primary.UID }; + }); +} diff --git a/web/src/lib/services/batch.ts b/web/src/lib/services/batch.ts new file mode 100644 index 0000000..164b798 --- /dev/null +++ b/web/src/lib/services/batch.ts @@ -0,0 +1,50 @@ +/** + * Fan-out helper used wherever PhotoPrism lacks a true batch endpoint. + * Bounded concurrency keeps the indexer happy on slow hosts; each item's + * result is collected and the aggregate `{updated, errors[]}` mirrors the + * shape the legacy mule-image bulk endpoint returned, so existing toast + * + undo plumbing slots in without changes. + */ + +export interface BatchResult { + updated: T[]; + errors: { id: string; message: string }[]; +} + +export interface BatchOptions { + concurrency?: number; + onProgress?: (done: number, total: number) => void; +} + +export async function batchEdit( + ids: string[], + fn: (id: string) => Promise, + opts: BatchOptions = {} +): Promise> { + const concurrency = Math.max(1, opts.concurrency ?? 8); + const updated: T[] = []; + const errors: { id: string; message: string }[] = []; + let i = 0; + let done = 0; + + async function worker() { + while (true) { + const idx = i++; + if (idx >= ids.length) return; + const id = ids[idx]; + try { + updated.push(await fn(id)); + } catch (err) { + errors.push({ id, message: err instanceof Error ? err.message : String(err) }); + } finally { + done++; + opts.onProgress?.(done, ids.length); + } + } + } + + await Promise.all( + Array.from({ length: Math.min(concurrency, ids.length) }, () => worker()) + ); + return { updated, errors }; +} diff --git a/web/src/lib/services/bulk.ts b/web/src/lib/services/bulk.ts new file mode 100644 index 0000000..97621bc --- /dev/null +++ b/web/src/lib/services/bulk.ts @@ -0,0 +1,87 @@ +import { toast } from 'svelte-sonner'; +import { batchEdit } from './batch'; +import { getPhoto, updatePhoto, type UpdatePhotoBody } from './photoprism'; +import { queryClient } from '$lib/queryClient'; +import { push as pushUndo } from '$lib/stores/undo.svelte'; +import type { PpPhoto } from '$lib/types/photoprism'; + +/** + * Shared helpers for bulk metadata mutations across the timeline. Three call + * sites: the keyboard culling layer (`gridKeyNav`), the `BulkActionBar` row, + * and the bulk metadata sidebar shown on multi-select. Each needs the same + * "fetch full photo body → deep-merge patch → PUT → invalidate" round-trip + * (PhotoPrism's PUT only persists nested fields when the body is whole), so + * the wiring lives here to keep the call sites declarative. + */ + +/** Fetch the freshest photo body, seeding the per-photo cache. PhotoPrism's + * PUT needs the full body to persist `Rating` / `Color` / `Details.*`; the + * cache lookup means the subsequent patch pass reuses this fetch. */ +export async function freshPhoto(uid: string): Promise { + const cached = queryClient.getQueryData(['photo', uid]); + if (cached) return cached; + const p = await getPhoto(uid); + queryClient.setQueryData(['photo', uid], p); + return p; +} + +export function invalidatePhotos(uids: string[]): void { + void queryClient.invalidateQueries({ queryKey: ['photos'] }); + for (const id of uids) { + void queryClient.invalidateQueries({ queryKey: ['photo', id] }); + } +} + +/** + * Apply a patch to every uid. The patch can be a static body or a per-photo + * function (used by keyword merges which need to read each photo's current + * Details.Keywords before extending it). When `inverseBuilder` is provided, + * an undo entry is registered that restores each photo's pre-patch state. + */ +export async function patchTargets( + ids: string[], + patch: UpdatePhotoBody | ((p: PpPhoto) => UpdatePhotoBody), + label: string, + inverseBuilder?: (photo: PpPhoto) => UpdatePhotoBody +): Promise { + if (ids.length === 0) return; + + const inverses = inverseBuilder + ? new Map( + await Promise.all( + ids.map(async (id) => { + const p = await freshPhoto(id); + return [id, inverseBuilder(p)] as const; + }) + ) + ) + : null; + + const { updated, errors } = await batchEdit(ids, async (id) => { + const p = await freshPhoto(id); + const body = typeof patch === 'function' ? patch(p) : patch; + // An empty body is a no-op signal — e.g. "keyword already present". + if (Object.keys(body).length === 0) return p; + return updatePhoto(p, body); + }); + + invalidatePhotos(ids); + + if (errors.length) { + toast.error(`${label} · ${updated.length} ok, ${errors.length} failed`); + } else { + toast.success(`${label} · ${ids.length}`); + } + + if (inverses) { + pushUndo(`${label} (${ids.length})`, async () => { + await batchEdit(ids, async (id) => { + const p = await freshPhoto(id); + const inv = inverses.get(id) ?? {}; + if (Object.keys(inv).length === 0) return p; + return updatePhoto(p, inv); + }); + invalidatePhotos(ids); + }); + } +} diff --git a/web/src/lib/services/photoprism.ts b/web/src/lib/services/photoprism.ts new file mode 100644 index 0000000..955da67 --- /dev/null +++ b/web/src/lib/services/photoprism.ts @@ -0,0 +1,607 @@ +import axios, { AxiosError, type AxiosInstance } from 'axios'; +import { browser } from '$app/environment'; +import { goto } from '$app/navigation'; +import { adoptSession, clearSession, session } from '$lib/stores/session.svelte'; +import type { + PpClientConfig, + PpPhoto, + PpSessionResponse, + PpUser +} from '$lib/types/photoprism'; + +/** + * Axios client pre-configured for PhotoPrism's /api/v1. Same-origin in dev + * (vite proxies /api → photoprism:2342), same-origin in prod (Caddy fronts + * both the SPA and PhotoPrism on one hostname). + */ +const http: AxiosInstance = axios.create({ + baseURL: '/api/v1', + headers: { 'Content-Type': 'application/json' } +}); + +http.interceptors.request.use((config) => { + if (session.accessToken) { + config.headers = config.headers ?? {}; + (config.headers as Record)['X-Auth-Token'] = session.accessToken; + } + return config; +}); + +http.interceptors.response.use( + (r) => r, + (err: AxiosError) => { + if (err.response?.status === 401 && browser) { + clearSession(); + // Avoid redirect loops if the request was a login probe. + const url = err.config?.url ?? ''; + if (!url.endsWith('/session')) { + void goto('/login', { replaceState: true }); + } + } + return Promise.reject(err); + } +); + +// ── Auth ───────────────────────────────────────────────────────────────────── + +export async function login(username: string, password: string): Promise { + const { data } = await http.post('/session', { username, password }); + adoptSession(data); + return data; +} + +export async function logout(): Promise { + if (session.id) { + try { + await http.delete(`/session/${session.id}`); + } catch { + // Best-effort: even if PhotoPrism rejects, drop the client state. + } + } + clearSession(); +} + +export async function fetchSession(id: string): Promise { + const { data } = await http.get(`/session/${id}`); + return data; +} + +export async function getConfig(): Promise { + const { data } = await http.get('/config'); + return data; +} + +// ── Photos ─────────────────────────────────────────────────────────────────── + +export interface ListPhotosParams { + q?: string; + count?: number; + offset?: number; + order?: 'newest' | 'oldest' | 'added' | 'edited' | 'name'; + merged?: boolean; +} + +export async function listPhotos(params: ListPhotosParams = {}): Promise { + const { data } = await http.get('/photos', { + params: { + count: 60, + offset: 0, + order: 'newest', + merged: true, + ...params + } + }); + return data; +} + +export async function getPhoto(uid: string): Promise { + const { data } = await http.get(`/photos/${uid}`); + return data; +} + +/** + * Patch payload accepted by `updatePhoto`. Top-level scalars merge in + * place; `Details` is shallow-merged onto the existing Details object so + * callers can patch a single Details field (Keywords, Subject, …) without + * clobbering siblings. + * + * **PhotoPrism quirk**: nested `Details.*` only persists when the PUT + * carries the FULL photo body — partial PUTs silently no-op for those + * fields. `updatePhoto` handles the fetch/merge/PUT round-trip so callers + * can stick to a partial shape. + */ +export interface UpdatePhotoBody { + OriginalName?: string; + Caption?: string; + CaptionSrc?: 'manual' | ''; + Favorite?: boolean; + Private?: boolean; + Archived?: boolean; + /** TakenAt + TakenAtLocal + Year/Month/Day must move in lockstep — + * use `buildTakenAtPatch` to assemble all five fields from one ISO. */ + TakenAt?: string; + TakenAtLocal?: string; + TakenSrc?: 'manual' | ''; + Year?: number; + Month?: number; + Day?: number; + TimeZone?: string; + Lat?: number; + Lng?: number; + Altitude?: number; + Country?: string; + CountrySrc?: 'manual' | ''; + Details?: Partial; +} + +export function buildTakenAtPatch(iso: string): UpdatePhotoBody { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return {}; + const utc = d.toISOString().replace(/\.\d+Z$/, 'Z'); + return { + TakenAt: utc, + TakenAtLocal: utc, + TakenSrc: 'manual', + Year: d.getUTCFullYear(), + Month: d.getUTCMonth() + 1, + Day: d.getUTCDate() + }; +} + +/** + * Merge a partial `UpdatePhotoBody` onto a full photo and PUT the result. + * Required for `Details.*` because PhotoPrism rejects partial bodies for + * nested fields. Top-level fields would work with a thinner body but we + * unify on full-body PUT to keep the call sites simple. + */ +export async function updatePhoto(photo: PpPhoto, patch: UpdatePhotoBody): Promise { + const merged: Record = { ...photo, ...patch }; + if (patch.Details) { + merged.Details = { ...(photo.Details ?? {}), ...patch.Details }; + } + const { data } = await http.put(`/photos/${photo.UID}`, merged); + return data; +} + +// ── Batch ──────────────────────────────────────────────────────────────────── + +interface BatchPhotosBody { + photos: string[]; +} + +function toBatchBody(uids: string[]): BatchPhotosBody { + // PhotoPrism's batch endpoints expect a flat array of UIDs, not + // `{UID: ...}` objects (verified against the bundled Vue client). + return { photos: uids }; +} + +export async function batchArchive(uids: string[]): Promise { + await http.post('/batch/photos/archive', toBatchBody(uids)); +} + +export async function batchRestore(uids: string[]): Promise { + await http.post('/batch/photos/restore', toBatchBody(uids)); +} + +/** + * Permanently delete photos. PhotoPrism only accepts UIDs that are already + * archived — calling on a live photo returns 4xx. Irreversible; no undo + * counterpart. + */ +export async function batchDelete(uids: string[]): Promise { + await http.post('/batch/photos/delete', toBatchBody(uids)); +} + +/** + * Toggle the heart/favorite flag. PhotoPrism has dedicated like/unlike + * routes that are atomic; preferred over PUT for this one field. + */ +export async function likePhoto(uid: string): Promise { + await http.post(`/photos/${uid}/like`); +} + +export async function unlikePhoto(uid: string): Promise { + await http.delete(`/photos/${uid}/like`); +} + +// ── Stack file operations ─────────────────────────────────────────────────── +// PhotoPrism's stacks pack multiple file variants (RAW + JPG + Live + …) into +// a single Photo entity. The duplicate-resolution flow needs two ops, both +// nested under the photo UID: +// - setPrimary: pick which file is the canonical/cover for the stack. +// - unstackFile: pull a file out of the stack so it becomes its own Photo +// record (which can then be archived via batchArchive). PhotoPrism returns +// the freshly-promoted parent photo body on success. +// +// PhotoPrism refuses to unstack auto-generated sidecar files (e.g. `.jpg` +// companions next to a RAW) and live-photo pairs — both return 4xx/5xx. The +// callers above must surface the failure rather than retry, hence the +// passthrough error from the axios layer. + +export async function setPrimary(photoUid: string, fileUid: string): Promise { + await http.post(`/photos/${photoUid}/files/${fileUid}/primary`); +} + +export async function unstackFile(photoUid: string, fileUid: string): Promise { + const { data } = await http.post(`/photos/${photoUid}/files/${fileUid}/unstack`); + return data; +} + +/** + * Remove a non-primary file from a stack. PhotoPrism cascades the delete + * through related variants in the same logical group (e.g. deleting one + * file of a Live Photo HEIC+MOV pair removes the whole pair). The file is + * NOT erased from disk; PhotoPrism renames the on-disk file with a hash + * suffix to take it out of the indexer's path. The response is the + * updated parent photo body. + * + * Used by the duplicate-resolution flow as the practical "discard rest" + * primitive because `/unstack` returns 5xx for live-photo and sidecar + * files (`only originals can be unstacked` / `Changes could not be saved`). + */ +export async function deleteFile(photoUid: string, fileUid: string): Promise { + const { data } = await http.delete(`/photos/${photoUid}/files/${fileUid}`); + return data; +} + +// ── Folders ────────────────────────────────────────────────────────────────── + +export interface PpFolder { + UID: string; + Path: string; + Root: string; + Title: string; + FileCount?: number; + Favorite?: boolean; + Private?: boolean; +} + +/** + * Recursive list of subfolders under originals/. `uncached=true` because + * PhotoPrism's folder cache lags new folders by a noticeable interval and + * mule-image's folder tree expects to surface mutations immediately. + */ +export async function listFolders(): Promise { + const { data } = await http.get<{ folders?: PpFolder[] }>( + '/folders/originals', + { params: { recursive: true, uncached: true, files: false } } + ); + return data.folders ?? []; +} + +// ── Geo ────────────────────────────────────────────────────────────────────── + +export interface PpGeoFeature { + type: 'Feature'; + id: string; + geometry: { type: 'Point'; coordinates: [number, number] }; + properties: { + UID: string; + Hash: string; + Title?: string; + TakenAt?: string; + FavId?: number; + }; +} + +export interface PpGeoCollection { + type: 'FeatureCollection'; + features: PpGeoFeature[]; + bbox?: number[]; +} + +export async function listGeo(q = ''): Promise { + // PhotoPrism's `/geo` returns a GeoJSON FeatureCollection of every + // matching geocoded photo. MapLibre's native clustering handles 50k+ + // points without breaking a sweat (PhotoPrism upstream documents + // 500k); we ask for a generous cap that covers realistic libraries. + const { data } = await http.get('/geo', { + params: { count: 50000, q: q || undefined } + }); + return data; +} + +// ── Labels ─────────────────────────────────────────────────────────────────── + +export interface PpLabel { + UID: string; + Slug: string; + CustomSlug?: string; + Name: string; + Favorite?: boolean; + Priority?: number; + Description?: string; + PhotoCount?: number; + Thumb?: string; +} + +export async function listLabels(): Promise { + // `all=true` includes labels PhotoPrism has soft-deleted (auto-hidden + // low-confidence classifier hits, manually-removed labels). They're + // still attached to photos in the DB and the `label:` query + // still resolves; without `all=true` the /labels endpoint filters + // them out and the tags page silently shows only ~40% of the user's + // real tag set. `count` bumped to 1000 so a moderately tagged library + // returns the full list in one round-trip. + const { data } = await http.get('/labels', { + params: { count: 1000, order: 'count', all: true } + }); + return data; +} + +// ── Albums = Heaps ─────────────────────────────────────────────────────────── + +export interface PpAlbum { + UID: string; + Slug?: string; + Type: string; + Title: string; + Description?: string; + Favorite?: boolean; + PhotoCount?: number; + CreatedAt?: string; + UpdatedAt?: string; + Thumb?: string; +} + +export async function listHeaps(): Promise { + const { data } = await http.get('/albums', { + params: { type: 'album', count: 500, order: 'newest' } + }); + return data; +} + +export async function getHeap(uid: string): Promise { + const { data } = await http.get(`/albums/${uid}`); + return data; +} + +export async function createHeap(title: string): Promise { + const { data } = await http.post('/albums', { + Title: title, + Type: 'album' + }); + return data; +} + +export async function renameHeap(uid: string, title: string): Promise { + const { data } = await http.put(`/albums/${uid}`, { Title: title }); + return data; +} + +export async function deleteHeap(uid: string): Promise { + await http.delete(`/albums/${uid}`); +} + +export async function addToHeap(uid: string, photos: string[]): Promise { + await http.post(`/albums/${uid}/photos`, { photos }); +} + +export async function removeFromHeap(uid: string, photos: string[]): Promise { + await http.delete(`/albums/${uid}/photos`, { data: { photos } }); +} + +/** + * Clone a heap. PhotoPrism has no native duplicate endpoint, so we fan out + * three round-trips: read the source title, list its members via the q-DSL + * (the same `album:` filter the timeline uses for the heap view), create + * a new "X (copy)" album, then add every member to it. Matches mule-image's + * backend `POST /heaps/{id}/duplicate` behaviour. + */ +export async function duplicateHeap(uid: string): Promise { + const source = await getHeap(uid); + const members = await listPhotos({ q: `album:${uid}`, count: 1000 }); + const copy = await createHeap(`${source.Title} (copy)`); + if (members.length > 0) { + await addToHeap( + copy.UID, + members.map((p) => p.UID) + ); + } + return copy; +} + +/** + * URL for PhotoPrism's album-as-zip download. The download token comes from + * the session config and is what authenticates the GET — no auth header + * needed, which is why the URL can be opened in a fresh window/tab. + */ +export function heapDownloadUrl(uid: string): string { + const t = session.downloadToken ?? ''; + return `/api/v1/albums/${uid}/dl?t=${encodeURIComponent(t)}`; +} + +/** + * Trigger a browser download by injecting a transient element and + * clicking it. Matches the pattern from mule-image's `downloads.trigger`. + * Uses target=_blank so PhotoPrism's zip response (which streams) doesn't + * navigate the current page away. + */ +export function triggerDownload(url: string): void { + const a = document.createElement('a'); + a.href = url; + a.rel = 'noopener'; + a.target = '_blank'; + document.body.appendChild(a); + a.click(); + a.remove(); +} + +// ── mule-sidecar (Node prototype today, Go in M4) ──────────────────────────── + +/** + * Rename the primary file of a photo on disk. PhotoPrism's `OriginalName` + * field only updates the display name; this calls the mule-sidecar service + * to issue an actual `os.Rename` under `originals/` and then trigger a + * PhotoPrism reindex of the parent path. + * + * Network: same-origin via the dev proxy entry `/api/sidecar/*`. + */ +export interface RenameResult { + ok: boolean; + oldName: string; + newName: string; + oldRelPath: string; + newRelPath: string; +} + +async function sidecar(method: string, urlPath: string, body?: unknown): Promise { + const res = await fetch(`/api/sidecar${urlPath}`, { + method, + headers: { + 'Content-Type': 'application/json', + 'X-Auth-Token': session.accessToken ?? '' + }, + body: body === undefined ? undefined : JSON.stringify(body) + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + const err = (data as { error?: string }).error ?? `HTTP ${res.status}`; + throw new Error(err); + } + return data; +} + +export async function createFolder(relPath: string): Promise<{ path: string }> { + return sidecar('POST', '/folders', { path: relPath }) as Promise<{ path: string }>; +} + +export async function renameFolder( + relPath: string, + newName: string +): Promise<{ oldPath: string; newPath: string }> { + return sidecar('POST', `/folders/${encodeURIComponent(relPath)}/rename`, { + newName + }) as Promise<{ oldPath: string; newPath: string }>; +} + +export async function deleteFolder(relPath: string): Promise<{ path: string }> { + return sidecar('DELETE', `/folders/${encodeURIComponent(relPath)}`) as Promise<{ + path: string; + }>; +} + +// ── Cross-folder duplicate detection (sidecar-driven) ─────────────────────── +// PhotoPrism silently drops byte-identical files at index time, so duplicates +// across folders never enter its DB. The sidecar walks the originals tree, +// pre-filters by size, sha1s the survivors, and returns the hash-collision +// groups. Resolution moves the unwanted copies into a `.duplicates/` +// quarantine folder PhotoPrism's indexer ignores. + +export interface DupFileEntry { + path: string; + size: number; +} + +export interface CrossFolderDuplicateGroup { + hash: string; + size: number; + /** Path of the file PhotoPrism currently has indexed for this hash, + * or null if none (the rare case of every copy being dropped). The + * UI uses this to default the "keep" pick. */ + indexedPath: string | null; + files: DupFileEntry[]; +} + +export interface CrossFolderScanResult { + groups: CrossFolderDuplicateGroup[]; + scannedMs: number; +} + +export async function scanCrossFolderDuplicates(): Promise { + return sidecar('GET', '/duplicates/scan') as Promise; +} + +export interface ArchiveDuplicatesResult { + moved: { from: string; to: string }[]; + errors: { path: string; error: string }[]; +} + +export async function archiveDuplicatePaths( + paths: string[] +): Promise { + return sidecar('POST', '/duplicates/archive', { paths }) as Promise; +} + +// ── Heap convert (move/copy heap photos to a folder) ──────────────────────── +// Lives on the sidecar because moving the underlying files is a filesystem +// operation PhotoPrism's API doesn't expose. The sidecar lists album members +// via PhotoPrism's q-DSL, fs.rename / fs.copyFile each primary file into the +// target folder, then triggers a PhotoPrism reindex. + +export interface HeapConvertBody { + /** Originals-relative target folder. Must exist. */ + targetFolder: string; + mode: 'move' | 'copy'; + /** Optional subfolder name to create under `targetFolder` and place + * files into. Lets the user keep a heap's worth of files grouped. */ + subfolder?: string | null; + /** Delete the album after a successful move. Ignored when mode='copy' + * (a copy doesn't change membership). */ + deleteHeap?: boolean; +} + +export interface HeapConvertResult { + moved: number; + copied: number; + errors: { uid: string; reason: string }[]; + heap_deleted: boolean; +} + +export async function convertHeap( + uid: string, + body: HeapConvertBody +): Promise { + return sidecar('POST', `/albums/${uid}/convert`, body) as Promise; +} + +// ── Photo marks (rating + color) ───────────────────────────────────────────── +// PhotoPrism's PUT silently drops Rating and Color (they're auto-computed +// internal fields). We store them in mule-sidecar instead. + +export interface PhotoMark { + rating?: number; + color?: string; + updatedAt?: string; +} + +export type PhotoMarksMap = Record; + +export async function getAllMarks(): Promise { + const data = await sidecar('GET', '/photos/marks'); + return (data ?? {}) as PhotoMarksMap; +} + +export async function setMark(photoUid: string, patch: PhotoMark): Promise { + return sidecar('PUT', `/photos/${photoUid}/marks`, patch) as Promise; +} + +export async function bulkSetMarks( + ids: string[], + patch: PhotoMark +): Promise<{ count: number; marks: PhotoMarksMap }> { + return sidecar('POST', '/photos/marks/bulk', { ids, patch }) as Promise<{ + count: number; + marks: PhotoMarksMap; + }>; +} + +export async function renameOnDisk(photoUid: string, newName: string): Promise { + // Bypass the axios client because the sidecar lives at /api/sidecar, not + // /api/v1 — http.baseURL would prepend the wrong prefix. + const res = await fetch(`/api/sidecar/files/${photoUid}/rename`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Auth-Token': session.accessToken ?? '' + }, + body: JSON.stringify({ newName }) + }); + const data = (await res.json()) as Partial & { error?: string }; + if (!res.ok) throw new Error(data.error ?? `Rename failed (${res.status})`); + return data as RenameResult; +} + +// ── Re-exports ─────────────────────────────────────────────────────────────── + +export type { PpClientConfig, PpPhoto, PpSessionResponse, PpUser }; diff --git a/web/src/lib/stores/filters.svelte.ts b/web/src/lib/stores/filters.svelte.ts new file mode 100644 index 0000000..50b992e --- /dev/null +++ b/web/src/lib/stores/filters.svelte.ts @@ -0,0 +1,107 @@ +/** + * Filter state for the timeline. One source of truth; routes read from it, + * the left sidebar writes to it, and `filtersToQ()` derives the search + * string PhotoPrism's `q` parameter accepts. + * + * Sections behave like saved searches: picking a section sets a default + * filter shape (favorites → `favorite:true`, archive → `archived:true`, + * etc.), and the search box on top stacks an additional `q` term. + */ + +export type Section = + | 'all-photos' + | 'favorites' + | 'archive' + | 'heap'; + +export interface FilterState { + section: Section; + /** Heap UID, used when section === 'heap'. */ + heapUid: string | null; + /** Relative folder path under originals/. Stacks with section terms. */ + folderPath: string | null; + /** Free-form search text, ANDed with section-derived terms. */ + search: string; +} + +export const filters = $state({ + section: 'all-photos', + heapUid: null, + folderPath: null, + search: '' +}); + +export function setSection(section: Section, heapUid: string | null = null): void { + filters.section = section; + filters.heapUid = section === 'heap' ? heapUid : null; +} + +export function setSearch(q: string): void { + filters.search = q; +} + +export function setFolderPath(path: string | null): void { + filters.folderPath = path; +} + +/** + * Quote a DSL term value when it contains characters that PhotoPrism's + * parser treats as boundaries (spaces, colons). We surround in double + * quotes; users can still type a raw `q=` for advanced search. + */ +function quoteIfNeeded(v: string): string { + if (!v) return ''; + if (/^[A-Za-z0-9_\-./]+$/.test(v)) return v; + return `"${v.replace(/"/g, '\\"')}"`; +} + +/** + * Build the PhotoPrism `q=` DSL string from the current filter state. + * Returns "" when nothing's restricting (the timeline default). + */ +export function filtersToQ(f: FilterState = filters): string { + const parts: string[] = []; + switch (f.section) { + case 'favorites': + parts.push('favorite:true'); + break; + case 'archive': + parts.push('archived:true'); + break; + case 'heap': + if (f.heapUid) parts.push(`album:${f.heapUid}`); + break; + case 'all-photos': + default: + break; + } + if (f.folderPath) parts.push(`path:${quoteIfNeeded(f.folderPath)}`); + if (f.search) parts.push(quoteIfNeeded(f.search)); + return parts.join(' '); +} + +/** Inverse of filtersToQ for URL hydration. Returns the parsed filter state. */ +export function parseUrlParams(params: URLSearchParams): Partial { + const sectionRaw = params.get('section') as Section | null; + const section: Section = + sectionRaw && ['all-photos', 'favorites', 'archive', 'heap'].includes(sectionRaw) + ? sectionRaw + : 'all-photos'; + return { + section, + heapUid: params.get('heap'), + folderPath: params.get('folder'), + search: params.get('q') ?? '' + }; +} + +/** Serialise the current filter state to URL search params (only set keys + * that differ from defaults so the URL stays clean). */ +export function filtersToUrlParams(f: FilterState = filters): URLSearchParams { + const params = new URLSearchParams(); + if (f.section !== 'all-photos') params.set('section', f.section); + if (f.heapUid) params.set('heap', f.heapUid); + if (f.folderPath) params.set('folder', f.folderPath); + if (f.search) params.set('q', f.search); + return params; +} diff --git a/web/src/lib/stores/preview.svelte.ts b/web/src/lib/stores/preview.svelte.ts new file mode 100644 index 0000000..b5cceee --- /dev/null +++ b/web/src/lib/stores/preview.svelte.ts @@ -0,0 +1,36 @@ +/** + * Single-photo preview overlay state. The lightbox sits in +layout.svelte + * and listens to this store; any view (timeline, duplicates view, heaps) + * can `open(uid)` to pop it. The order array is mirrored from whatever + * list the user is currently looking at so prev/next stay in context. + */ +export const preview = $state<{ + uid: string | null; + order: string[]; +}>({ + uid: null, + order: [] +}); + +export function openPreview(uid: string, order?: string[]): void { + if (order) preview.order = order; + preview.uid = uid; +} + +export function closePreview(): void { + preview.uid = null; +} + +export function previewNext(): void { + if (!preview.uid) return; + const i = preview.order.indexOf(preview.uid); + if (i < 0 || i >= preview.order.length - 1) return; + preview.uid = preview.order[i + 1]; +} + +export function previewPrev(): void { + if (!preview.uid) return; + const i = preview.order.indexOf(preview.uid); + if (i <= 0) return; + preview.uid = preview.order[i - 1]; +} diff --git a/web/src/lib/stores/selection.svelte.ts b/web/src/lib/stores/selection.svelte.ts new file mode 100644 index 0000000..4af5cca --- /dev/null +++ b/web/src/lib/stores/selection.svelte.ts @@ -0,0 +1,115 @@ +import { SvelteSet } from 'svelte/reactivity'; + +/** + * Multi-select state for the timeline + duplicate views. Tracks which photo + * UIDs are picked, plus an anchor for shift-range extension and a focus + * cursor for arrow-key navigation. Components import `selection` and read + * its reactive fields; mutations go through the helpers below. + * + * SvelteSet is required (not plain Set) so component-level `selection.ids` + * reads re-run when membership changes. + */ +export const selection = $state<{ + ids: SvelteSet; + anchor: string | null; + focused: string | null; + /** + * Mirror of the active ordered photo list, kept in sync by the timeline. + * Needed for shift-range extension and arrow-key navigation. + */ + order: string[]; +}>({ + ids: new SvelteSet(), + anchor: null, + focused: null, + order: [] +}); + +/** + * Side index of `order`. Rebuilt by `setOrder`. Keeping the array around + * (rather than dropping it entirely) lets preview navigation and + * `cullTargets` iterate cheaply; the map exists solely to take + * `selectRange` and arrow-key navigation from O(n) to O(1) on large + * libraries. Not reactive — only `setOrder` reads/writes it. + */ +const orderIndex = new Map(); + +/** O(1) index lookup. Returns -1 when the uid isn't in the current order + * (consistent with `Array.indexOf`). */ +export function indexOf(uid: string | null): number { + if (uid === null) return -1; + const i = orderIndex.get(uid); + return i === undefined ? -1 : i; +} + +export function isSelected(uid: string): boolean { + return selection.ids.has(uid); +} + +export function clearSelection(): void { + selection.ids.clear(); + selection.anchor = null; +} + +export function toggle(uid: string): void { + if (selection.ids.has(uid)) { + selection.ids.delete(uid); + } else { + selection.ids.add(uid); + selection.anchor = uid; + } +} + +export function selectOnly(uid: string): void { + selection.ids.clear(); + selection.ids.add(uid); + selection.anchor = uid; +} + +export function selectRange(uid: string): void { + // When the user hasn't explicitly anchored (no toggle/selectOnly before + // this shift-click), treat the focused tile as the anchor — that's the + // "starting photo" the user just clicked or arrow-keyed to. Without this + // fallback, shift-clicking after a plain click would select only the + // shift-clicked tile and the starting photo would be dropped. + const anchor = selection.anchor ?? selection.focused; + if (!anchor) { + selectOnly(uid); + return; + } + const a = indexOf(anchor); + const b = indexOf(uid); + if (a < 0 || b < 0) { + selectOnly(uid); + return; + } + const [lo, hi] = a < b ? [a, b] : [b, a]; + selection.ids.clear(); + for (let i = lo; i <= hi; i++) selection.ids.add(selection.order[i]); + // Promote the anchor we used so subsequent shift-clicks keep the same + // start point (otherwise focus-as-anchor would drift each move). + selection.anchor = anchor; +} + +export function setOrder(order: string[]): void { + selection.order = order; + // Rebuild the side index. `clear` + per-element `set` is O(n) and + // allocation-free vs `new Map(order.map(...))` which would churn GC + // on every page append in the infinite-scroll path. + orderIndex.clear(); + for (let i = 0; i < order.length; i++) orderIndex.set(order[i], i); +} + +export function setFocused(uid: string | null): void { + selection.focused = uid; +} + +/** + * Promote a uid to the shift-range anchor without adding it to the selection. + * Used on plain click + arrow-key navigation so the next shift-click extends + * from the user's most recent interaction (the "starting photo"), even when + * the selection set is empty. + */ +export function setAnchor(uid: string | null): void { + selection.anchor = uid; +} diff --git a/web/src/lib/stores/session.svelte.ts b/web/src/lib/stores/session.svelte.ts new file mode 100644 index 0000000..cf32377 --- /dev/null +++ b/web/src/lib/stores/session.svelte.ts @@ -0,0 +1,89 @@ +import { browser } from '$app/environment'; +import type { PpClientConfig, PpSessionResponse, PpUser } from '$lib/types/photoprism'; + +const STORAGE_KEY = 'pp_session'; + +interface PersistedSession { + id: string; + accessToken: string; + previewToken: string; + downloadToken: string; + user: PpUser; +} + +function loadInitial(): PersistedSession | null { + if (!browser) return null; + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return null; + return JSON.parse(raw) as PersistedSession; + } catch { + return null; + } +} + +/** + * Single-source session state for the Svelte client. Components import the + * `session` object and read its reactive fields; mutations go through the + * helpers below. State is mirrored to localStorage so a hard refresh keeps + * the user signed in. + */ +const initial = loadInitial(); + +export const session = $state<{ + id: string | null; + accessToken: string | null; + previewToken: string | null; + downloadToken: string | null; + user: PpUser | null; +}>({ + id: initial?.id ?? null, + accessToken: initial?.accessToken ?? null, + previewToken: initial?.previewToken ?? null, + downloadToken: initial?.downloadToken ?? null, + user: initial?.user ?? null +}); + +export function isAuthenticated(): boolean { + return Boolean(session.accessToken); +} + +export function adoptSession(resp: PpSessionResponse, cfg?: PpClientConfig): void { + session.id = resp.id; + session.accessToken = resp.access_token; + session.previewToken = (cfg ?? resp.config)?.previewToken ?? ''; + session.downloadToken = (cfg ?? resp.config)?.downloadToken ?? ''; + session.user = resp.user; + persist(); +} + +export function clearSession(): void { + session.id = null; + session.accessToken = null; + session.previewToken = null; + session.downloadToken = null; + session.user = null; + if (browser) localStorage.removeItem(STORAGE_KEY); +} + +function persist(): void { + if (!browser || !session.accessToken) return; + const payload: PersistedSession = { + id: session.id ?? '', + accessToken: session.accessToken, + previewToken: session.previewToken ?? '', + downloadToken: session.downloadToken ?? '', + user: session.user! + }; + localStorage.setItem(STORAGE_KEY, JSON.stringify(payload)); +} + +/** + * Build a thumbnail URL for a photo. PhotoPrism's thumb endpoint is + * /api/v1/t/:hash/:token/:size — the token is the per-session + * previewToken, which the session response provides on login. + */ +export function thumbUrl(hash: string, size = 'tile_500'): string { + if (!session.previewToken) return ''; + return `/api/v1/t/${hash}/${session.previewToken}/${size}`; +} diff --git a/web/src/lib/stores/undo.svelte.ts b/web/src/lib/stores/undo.svelte.ts new file mode 100644 index 0000000..4059652 --- /dev/null +++ b/web/src/lib/stores/undo.svelte.ts @@ -0,0 +1,44 @@ +/** + * LIFO stack of undoable actions. Each push registers an inverse callback; + * pop runs the most recent inverse and removes it from the stack. Keep the + * UI feedback honest: undo entries are best-effort and exact restoration + * isn't always possible (e.g. when the inverse depends on server state that + * has since changed). + */ + +export interface UndoEntry { + id: string; + label: string; + pushedAt: number; + undo(): Promise | void; +} + +const MAX_ENTRIES = 25; + +let counter = 0; +export const undoStack = $state<{ entries: UndoEntry[] }>({ entries: [] }); + +export function push(label: string, undo: UndoEntry['undo']): UndoEntry { + const entry: UndoEntry = { + id: `u-${++counter}`, + label, + pushedAt: Date.now(), + undo + }; + undoStack.entries.push(entry); + if (undoStack.entries.length > MAX_ENTRIES) { + undoStack.entries.shift(); + } + return entry; +} + +export async function popAndRun(): Promise { + const entry = undoStack.entries.pop(); + if (!entry) return null; + await entry.undo(); + return entry; +} + +export function clear(): void { + undoStack.entries.length = 0; +} diff --git a/web/src/lib/stores/view.svelte.ts b/web/src/lib/stores/view.svelte.ts new file mode 100644 index 0000000..09e52c0 --- /dev/null +++ b/web/src/lib/stores/view.svelte.ts @@ -0,0 +1,123 @@ +import { browser } from '$app/environment'; + +/** + * View-level UI preferences. Persisted to localStorage so collapse state, + * thumb size, etc. survive a refresh. Same module is reused by the + * timeline page and the preview overlay so the sidebar toggle stays in + * sync across views (matches mule-image's "intelligent preview recall"). + */ +const STORAGE_KEY = 'mule_view'; + +/** + * Mirrors mule-image's mule-image-viewSettingsStore thumbnail presets so the + * UX scales the same way: five steps with `M` as the default. Labels are + * cosmetic; the numbers feed the `minmax(px, 1fr)` grid template. + */ +export const THUMBNAIL_SIZE_PRESETS = [96, 128, 160, 208, 272] as const; +export type ThumbnailSize = (typeof THUMBNAIL_SIZE_PRESETS)[number]; +export const THUMBNAIL_SIZE_LABELS = ['XS', 'S', 'M', 'L', 'XL'] as const; +export const DEFAULT_THUMBNAIL_SIZE: ThumbnailSize = 160; + +interface Persisted { + rightSidebarCollapsed?: boolean; + leftSidebarCollapsed?: boolean; + thumbnailSize?: ThumbnailSize; + leftSidebarWidth?: number; + rightSidebarWidth?: number; +} + +export const MIN_LEFT_WIDTH = 180; +export const MAX_LEFT_WIDTH = 480; +export const DEFAULT_LEFT_WIDTH = 224; +export const MIN_RIGHT_WIDTH = 220; +export const MAX_RIGHT_WIDTH = 480; +export const DEFAULT_RIGHT_WIDTH = 280; + +function clamp(n: number, lo: number, hi: number): number { + return Math.min(hi, Math.max(lo, n)); +} + +function isThumbnailSize(n: unknown): n is ThumbnailSize { + return ( + typeof n === 'number' && + (THUMBNAIL_SIZE_PRESETS as readonly number[]).includes(n) + ); +} + +function loadInitial(): Persisted { + if (!browser) return {}; + try { + const raw = localStorage.getItem(STORAGE_KEY); + return raw ? (JSON.parse(raw) as Persisted) : {}; + } catch { + return {}; + } +} + +const initial = loadInitial(); + +export const view = $state<{ + rightSidebarCollapsed: boolean; + leftSidebarCollapsed: boolean; + thumbnailSize: ThumbnailSize; + leftSidebarWidth: number; + rightSidebarWidth: number; +}>({ + rightSidebarCollapsed: initial.rightSidebarCollapsed ?? false, + leftSidebarCollapsed: initial.leftSidebarCollapsed ?? false, + thumbnailSize: isThumbnailSize(initial.thumbnailSize) + ? initial.thumbnailSize + : DEFAULT_THUMBNAIL_SIZE, + leftSidebarWidth: clamp( + typeof initial.leftSidebarWidth === 'number' ? initial.leftSidebarWidth : DEFAULT_LEFT_WIDTH, + MIN_LEFT_WIDTH, + MAX_LEFT_WIDTH + ), + rightSidebarWidth: clamp( + typeof initial.rightSidebarWidth === 'number' ? initial.rightSidebarWidth : DEFAULT_RIGHT_WIDTH, + MIN_RIGHT_WIDTH, + MAX_RIGHT_WIDTH + ) +}); + +function persist(): void { + if (!browser) return; + const payload: Persisted = { + rightSidebarCollapsed: view.rightSidebarCollapsed, + leftSidebarCollapsed: view.leftSidebarCollapsed, + thumbnailSize: view.thumbnailSize, + leftSidebarWidth: view.leftSidebarWidth, + rightSidebarWidth: view.rightSidebarWidth + }; + localStorage.setItem(STORAGE_KEY, JSON.stringify(payload)); +} + +export function setLeftSidebarWidth(px: number): void { + view.leftSidebarWidth = clamp(Math.round(px), MIN_LEFT_WIDTH, MAX_LEFT_WIDTH); + persist(); +} + +export function setRightSidebarWidth(px: number): void { + view.rightSidebarWidth = clamp(Math.round(px), MIN_RIGHT_WIDTH, MAX_RIGHT_WIDTH); + persist(); +} + +export function setThumbnailSize(size: ThumbnailSize): void { + view.thumbnailSize = size; + persist(); +} + +export function toggleRightSidebar(): void { + view.rightSidebarCollapsed = !view.rightSidebarCollapsed; + persist(); +} + +export function setRightSidebarCollapsed(collapsed: boolean): void { + view.rightSidebarCollapsed = collapsed; + persist(); +} + +export function toggleLeftSidebar(): void { + view.leftSidebarCollapsed = !view.leftSidebarCollapsed; + persist(); +} diff --git a/web/src/lib/types/photoprism.ts b/web/src/lib/types/photoprism.ts new file mode 100644 index 0000000..93fcc9d --- /dev/null +++ b/web/src/lib/types/photoprism.ts @@ -0,0 +1,203 @@ +/** + * PhotoPrism API response shapes — only the fields the Svelte client uses. + * Keep this surface narrow; extend as the UI grows. + */ + +export type PpRole = 'admin' | 'user' | 'contributor' | 'guest' | 'visitor' | string; + +export interface PpUser { + UID: string; + Name: string; + DisplayName?: string; + Email?: string; + Role: PpRole; +} + +export interface PpClientConfig { + mode: 'public' | 'user'; + name: string; + edition: string; + version: string; + siteUrl: string; + siteTitle: string; + previewToken: string; + downloadToken: string; + flags?: string; + count?: { + photos?: number; + videos?: number; + albums?: number; + labels?: number; + people?: number; + }; +} + +export interface PpSessionResponse { + id: string; + access_token: string; + user: PpUser; + config: PpClientConfig; +} + +export interface PpPhoto { + UID: string; + /** + * `Hash` is only present on the list endpoint (`/photos?...`). The + * single-photo endpoint (`/photos/:uid`) returns the file hash nested + * under `Files[i].Hash` — use `primaryFile(photo).Hash` instead of + * reading this field unconditionally. + */ + Hash?: string; + /** Auto-derived display name (no extension). */ + Name?: string; + /** PhotoPrism filename + extension, populated on list responses. */ + FileName?: string; + /** User-editable original/preferred name. Persisted in DB + sidecar. */ + OriginalName?: string; + Title?: string; + TitleSrc?: string; + Description?: string; + Caption?: string; + CaptionSrc?: string; + TakenAt?: string; + TakenAtLocal?: string; + TakenSrc?: string; + Year?: number; + Month?: number; + Day?: number; + /** Top-level Width/Height appear on list responses but not on detail. */ + Width?: number; + Height?: number; + Rating?: number; + Color?: string | number; + Favorite?: boolean; + Private?: boolean; + Archived?: boolean; + Files?: PpFile[]; + Lat?: number; + Lng?: number; + Altitude?: number; + Country?: string; + CountrySrc?: string; + TimeZone?: string; + Iso?: number; + FNumber?: number; + FocalLength?: number; + Exposure?: string; + Quality?: number; + Type?: string; + Camera?: PpCamera; + CameraID?: number; + CameraSrc?: string; + Lens?: PpLens; + LensID?: number; + Place?: PpPlace; + PlaceID?: string; + PlaceSrc?: string; + Details?: PpDetails; + Labels?: PpPhotoLabel[]; + IndexedAt?: string; + EditedAt?: string; + UpdatedAt?: string; + CreatedAt?: string; +} + +export interface PpCamera { + ID?: number; + Slug?: string; + Name?: string; + Make?: string; + Model?: string; +} + +export interface PpLens { + ID?: number; + Slug?: string; + Name?: string; + Make?: string; + Model?: string; + Type?: string; +} + +export interface PpPlace { + ID?: string; + Label?: string; + PlaceLabel?: string; + City?: string; + State?: string; + Country?: string; +} + +export interface PpDetails { + PhotoID?: number; + Keywords?: string; + KeywordsSrc?: string; + Notes?: string; + NotesSrc?: string; + Subject?: string; + SubjectSrc?: string; + Artist?: string; + ArtistSrc?: string; + Copyright?: string; + CopyrightSrc?: string; + License?: string; + LicenseSrc?: string; + Software?: string; + SoftwareSrc?: string; +} + +export interface PpPhotoLabel { + UID?: string; + Source?: string; + Priority?: number; + Uncertainty?: number; + Label?: { Slug: string; Name: string; Favorite?: boolean }; +} + +/** + * Return the photo's primary file (the one with `Primary: true`) or the + * first file if no primary marker is set. Falls back to a synthetic entry + * that surfaces the top-level Hash so list-shape photos still resolve. + */ +export function primaryFile(p: PpPhoto): PpFile { + const files = p.Files ?? []; + const primary = files.find((f) => f.Primary) ?? files[0]; + if (primary) return primary; + return { + UID: p.UID, + Hash: p.Hash ?? '', + Name: p.FileName ?? p.Name ?? '', + Root: '/', + Primary: true, + Width: p.Width, + Height: p.Height + }; +} + +export interface PpFile { + UID: string; + Hash: string; + Name: string; + Root: string; + Primary?: boolean; + Stack?: number; + Width?: number; + Height?: number; + Size?: number; + FileType?: string; + MediaType?: string; +} + +export type PpThumbSize = + | 'tile_50' + | 'tile_100' + | 'tile_224' + | 'tile_500' + | 'fit_720' + | 'fit_1280' + | 'fit_1920' + | 'fit_2048' + | 'fit_2560' + | 'fit_3840' + | 'fit_4096' + | 'fit_7680'; diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts new file mode 100644 index 0000000..0eeb2bc --- /dev/null +++ b/web/src/lib/utils.ts @@ -0,0 +1,10 @@ +import { clsx, type ClassValue } from 'clsx'; +import { twMerge } from 'tailwind-merge'; + +/** + * shadcn-svelte's canonical class composer: merge tailwind classes, + * de-duplicate conflicts (last one wins for the same utility group). + */ +export function cn(...inputs: ClassValue[]): string { + return twMerge(clsx(inputs)); +} diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte new file mode 100644 index 0000000..b9051e8 --- /dev/null +++ b/web/src/routes/+layout.svelte @@ -0,0 +1,84 @@ + + + + Mulimage + + + + + + + {#if isAuthenticated() && page.url.pathname !== '/login'} + +
+ +
+ {#if !view.leftSidebarCollapsed} + + {/if} + +
+ {@render children?.()} +
+
+
+ {:else} + {@render children?.()} + {/if} + +
diff --git a/web/src/routes/+layout.ts b/web/src/routes/+layout.ts new file mode 100644 index 0000000..8754275 --- /dev/null +++ b/web/src/routes/+layout.ts @@ -0,0 +1,4 @@ +// SPA mode: disable SSR / prerendering across the app. The Svelte client +// talks directly to PhotoPrism's REST + WebSocket; no server runtime needed. +export const ssr = false; +export const prerender = false; diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte new file mode 100644 index 0000000..fa3630e --- /dev/null +++ b/web/src/routes/+page.svelte @@ -0,0 +1,871 @@ + + + + + {sectionLabel} + + {#if filters.section === 'archive' && photos.length > 0} + + {/if} +
+ + + {#if filters.search} + + {/if} +
+ + {#snippet trailing()} + +
+ {#each THUMBNAIL_SIZE_PRESETS as size, i (size)} + + {/each} +
+ + + + + {/snippet} +
+ +
+
{ + visFirst = f; + visLast = l; + }, + sampleEvery: TILE_SAMPLE + }} + > +
+ {#if photosQuery.isPending} +

Loading photos…

+ {:else if photosQuery.isError} +

+ Failed to load photos: {photosQuery.error instanceof Error + ? photosQuery.error.message + : 'unknown error'} +

+ {:else if photos.length === 0} +

+ {#if filters.section === 'archive'} + Archive is empty. + {:else if filters.section === 'favorites'} + No favorites yet. Heart a photo to add it here. + {:else if filters.section === 'heap'} + This heap has no photos yet. Select some photos and use the bulk bar's + "+ Add to heap" button. + {:else} + No photos. Index a folder via PhotoPrism's reindex command. + {/if} +

+ {:else} +
+ {#each rows as row (row.kind === 'header' ? `h:${row.key}` : `t:${row.photo.UID}`)} + {#if row.kind === 'header'} + +

+ {row.label} + + {row.count} + +

+ {:else} + {@const photo = row.photo} + {@const i = row.tileIndex} + {@const inWindow = i >= renderFirst && i <= renderLast} + +
+ {#if inWindow} + {@const hash = photo.Hash ?? primaryFile(photo).Hash} + {@const sel = isSelected(photo.UID) || selection.focused === photo.UID} + + + {/if} +
+ {/if} + {/each} +
+ + + + {#if photosQuery.isFetchingNextPage} +

Loading more…

+ {/if} + {/if} +
+
+ + {#if !view.rightSidebarCollapsed} + + {/if} +
+ + diff --git a/web/src/routes/colors/+page.svelte b/web/src/routes/colors/+page.svelte new file mode 100644 index 0000000..2943d88 --- /dev/null +++ b/web/src/routes/colors/+page.svelte @@ -0,0 +1,180 @@ + + + + + Colors + + {#if selectedGroup} + + + + {selectedGroup.title} + + + {selectedGroup.photos.length} photo{selectedGroup.photos.length === 1 ? '' : 's'} + + {/if} + {#snippet trailing()} + + {groups.length} color{groups.length === 1 ? '' : 's'} + + {/snippet} + + +
+ {#if marksQuery.isPending || photosQuery.isPending} +

Loading colors…

+ {:else if marksQuery.isError || photosQuery.isError} +

Failed to load colors.

+ {:else if groups.length === 0} +

+ No color labels yet. Open a photo and use the four-swatch row in the right + sidebar to tag it. +

+ {:else if selectedGroup} +
+ {#each selectedGroup.photos as photo (photo.UID)} + {@const hash = photo.Hash ?? primaryFile(photo).Hash} + + {/each} +
+ {:else} +
+ {#each groups as group (group.key)} + {@const rep = group.photos[0]} + {@const hash = rep.Hash ?? primaryFile(rep).Hash} + + {/each} +
+ {/if} +
diff --git a/web/src/routes/duplicates/+page.svelte b/web/src/routes/duplicates/+page.svelte new file mode 100644 index 0000000..c756593 --- /dev/null +++ b/web/src/routes/duplicates/+page.svelte @@ -0,0 +1,67 @@ + + + + + Duplicates · stacks + + {#snippet trailing()} + +
+ {#each THUMBNAIL_SIZE_PRESETS as size, i (size)} + + {/each} +
+ + {dupesQuery.data?.length ?? 0} group{dupesQuery.data?.length === 1 ? '' : 's'} + + {/snippet} +
+ +
+ +
diff --git a/web/src/routes/login/+page.svelte b/web/src/routes/login/+page.svelte new file mode 100644 index 0000000..2e600aa --- /dev/null +++ b/web/src/routes/login/+page.svelte @@ -0,0 +1,78 @@ + + +
+
+
+

Mule

+

Sign in with your PhotoPrism account.

+
+ + + + + + + +

+ OIDC SSO ships in M4 when the IdP is wired up. +

+
+
diff --git a/web/src/routes/map/+page.svelte b/web/src/routes/map/+page.svelte new file mode 100644 index 0000000..b636a1c --- /dev/null +++ b/web/src/routes/map/+page.svelte @@ -0,0 +1,388 @@ + + + + + Map + + {#snippet trailing()} + + {geoQuery.data?.features?.length ?? 0} geotagged + + {/snippet} + + +
+ + diff --git a/web/src/routes/photo/[uid]/+page.svelte b/web/src/routes/photo/[uid]/+page.svelte new file mode 100644 index 0000000..0ac6e41 --- /dev/null +++ b/web/src/routes/photo/[uid]/+page.svelte @@ -0,0 +1,16 @@ + diff --git a/web/src/routes/ratings/+page.svelte b/web/src/routes/ratings/+page.svelte new file mode 100644 index 0000000..26b1a89 --- /dev/null +++ b/web/src/routes/ratings/+page.svelte @@ -0,0 +1,171 @@ + + + + + Ratings + + {#if selectedGroup} + + + {starLabel(selectedGroup.rating)} + + + {selectedGroup.photos.length} photo{selectedGroup.photos.length === 1 ? '' : 's'} + + {/if} + {#snippet trailing()} + + {groups.length} rating{groups.length === 1 ? '' : 's'} + + {/snippet} + + +
+ {#if marksQuery.isPending || photosQuery.isPending} +

Loading ratings…

+ {:else if marksQuery.isError || photosQuery.isError} +

Failed to load ratings.

+ {:else if groups.length === 0} +

+ No rated photos yet. Open a photo and use the star row in the right sidebar + (or 1–5 in bulk mode) to rate it. +

+ {:else if selectedGroup} +
+ {#each selectedGroup.photos as photo (photo.UID)} + {@const hash = photo.Hash ?? primaryFile(photo).Hash} + + {/each} +
+ {:else} +
+ {#each groups as group (group.rating)} + {@const rep = group.photos[0]} + {@const hash = rep.Hash ?? primaryFile(rep).Hash} + + {/each} +
+ {/if} +
diff --git a/web/src/routes/tags/+page.svelte b/web/src/routes/tags/+page.svelte new file mode 100644 index 0000000..c059fee --- /dev/null +++ b/web/src/routes/tags/+page.svelte @@ -0,0 +1,68 @@ + + + + + Tags + + {#snippet trailing()} + + {labelsQuery.data?.length ?? 0} label{labelsQuery.data?.length === 1 ? '' : 's'} + + {/snippet} + + +
+ {#if labelsQuery.isPending} +

Loading labels…

+ {:else if labelsQuery.isError} +

Failed to load labels.

+ {:else if (labelsQuery.data ?? []).length === 0} +

+ No labels yet. PhotoPrism's TensorFlow indexer generates these from photo content; if the + indexer hasn't run on real photos yet, the list will be empty. +

+ {:else} +
+ {#each labelsQuery.data ?? [] as label (label.UID)} + + {/each} +
+ {/if} +
diff --git a/web/static/favicon.png b/web/static/favicon.png new file mode 100644 index 0000000..4769920 Binary files /dev/null and b/web/static/favicon.png differ diff --git a/web/static/mule/desert.png b/web/static/mule/desert.png new file mode 100644 index 0000000..274f3bd Binary files /dev/null and b/web/static/mule/desert.png differ diff --git a/web/static/mule/mule-sprites.png b/web/static/mule/mule-sprites.png new file mode 100644 index 0000000..b3f203a Binary files /dev/null and b/web/static/mule/mule-sprites.png differ diff --git a/web/static/robots.txt b/web/static/robots.txt new file mode 100644 index 0000000..b6dd667 --- /dev/null +++ b/web/static/robots.txt @@ -0,0 +1,3 @@ +# allow crawling everything by default +User-agent: * +Disallow: diff --git a/web/svelte.config.js b/web/svelte.config.js new file mode 100644 index 0000000..cd023d6 --- /dev/null +++ b/web/svelte.config.js @@ -0,0 +1,18 @@ +import adapter from '@sveltejs/adapter-static'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + compilerOptions: { + // Force runes mode for the project, except for libraries. + runes: ({ filename }) => (filename.split(/[/\\]/).includes('node_modules') ? undefined : true) + }, + kit: { + // SPA mode: every unknown path serves index.html, which then mounts + // the client router. Required for dynamic routes (e.g. /photo/[uid]). + adapter: adapter({ + fallback: 'index.html' + }) + } +}; + +export default config; diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..2c2ed3c --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "rewriteRelativeImportExtensions": true, + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } + // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias + // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files + // + // To make changes to top-level options such as include and exclude, we recommend extending + // the generated config; see https://svelte.dev/docs/kit/configuration#typescript +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..cf11692 --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,25 @@ +import { sveltekit } from '@sveltejs/kit/vite'; +import tailwindcss from '@tailwindcss/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [tailwindcss(), sveltekit()], + server: { + proxy: { + // mule-sidecar — file rename + (future) folder mutations and + // per-user heap sharing. Matched BEFORE `/api/*` so PhotoPrism + // doesn't swallow these paths. + '/api/sidecar': { + target: 'http://localhost:8000', + changeOrigin: false, + rewrite: (p) => p + }, + // PhotoPrism REST + WebSocket. + '/api': { + target: 'http://localhost:2342', + changeOrigin: true, + ws: true + } + } + } +});