add photos, an immich-backed library behind its own sidecar
officer-photos owns the whole Immich contract: the instance URL and the API key live there and nowhere else, and the platform side is an auth-gated forwarder holding no credentials. The route surface is an allow-list keyed on the first path segment, so admin, auth, api-keys, sessions, jobs, system-config and libraries are unreachable by construction rather than by enumeration. The UI mirrors Immich's own sidebar — timeline, explore, map, search, albums, people, favorites, sharing, archive, trash — because the point of a sidecar screen is to reproduce what the upstream already ships, then extend it. The timeline reads Immich's columnar time-bucket format directly; selection lives in the URL per docs/navigation-audit.md. Two things worth knowing for anyone touching this later: - `duration` is an integer count of milliseconds in Immich 3.0. It was an HH:MM:SS.mmm string before, and every stale example still shows that form. - the map container is sized with h-full/w-full, never `absolute inset-0`. maplibre's stylesheet sets `position: relative; overflow: hidden` on the element it is given, and an unlayered vendor rule beats Tailwind 4's layered `.absolute` regardless of source order — so the div collapses to height 0 and clips its own canvas away. Nothing errors: the GL context is healthy, tiles download and pixels are drawn into a buffer nobody ever composites. maplibre-gl is pinned to 5.x deliberately; 6.0 resolves a separate worker file from import.meta.url, which Officer's index.html fallback answers with HTML. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -77,3 +77,10 @@ VAULT_STORE_KEY="<generate with: openssl rand -base64 32>"
|
|||||||
# NOTE: seed material is encrypted with VAULT_STORE_KEY (above) on top of the owner passphrase that
|
# NOTE: seed material is encrypted with VAULT_STORE_KEY (above) on top of the owner passphrase that
|
||||||
# seals it. Both are required to spend. If you lose VAULT_STORE_KEY, every stored seed is
|
# seals it. Both are required to spend. If you lose VAULT_STORE_KEY, every stored seed is
|
||||||
# unrecoverable — back up the mnemonics separately, offline.
|
# unrecoverable — back up the mnemonics separately, offline.
|
||||||
|
|
||||||
|
# Immich (officer-photos). The key is injected as x-api-key on every forwarded request; the platform never
|
||||||
|
# sees it. Create it in Immich: Account Settings → API Keys → New API Key. Immich keys are SCOPED — grant
|
||||||
|
# all permissions unless you want a read-only library, because a missing permission answers 403 on that one
|
||||||
|
# route and looks like a broken feature rather than a bad credential.
|
||||||
|
IMMICH_URL=http://127.0.0.1:2283
|
||||||
|
IMMICH_API_KEY="<immich api key>"
|
||||||
|
|||||||
@@ -96,6 +96,7 @@
|
|||||||
"jwt-decode": "^4.0.0",
|
"jwt-decode": "^4.0.0",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"mailparser": "^3.9.3",
|
"mailparser": "^3.9.3",
|
||||||
|
"maplibre-gl": "^5.24.0",
|
||||||
"markdown-it": "^14.1.0",
|
"markdown-it": "^14.1.0",
|
||||||
"material-file-icons": "^2.4.0",
|
"material-file-icons": "^2.4.0",
|
||||||
"monaco-editor": "^0.55.1",
|
"monaco-editor": "^0.55.1",
|
||||||
@@ -472,6 +473,26 @@
|
|||||||
|
|
||||||
"@levischuck/tiny-cbor": ["@levischuck/tiny-cbor@0.2.11", "", {}, "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow=="],
|
"@levischuck/tiny-cbor": ["@levischuck/tiny-cbor@0.2.11", "", {}, "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow=="],
|
||||||
|
|
||||||
|
"@mapbox/jsonlint-lines-primitives": ["@mapbox/jsonlint-lines-primitives@2.0.3", "", {}, "sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg=="],
|
||||||
|
|
||||||
|
"@mapbox/point-geometry": ["@mapbox/point-geometry@1.1.0", "", {}, "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ=="],
|
||||||
|
|
||||||
|
"@mapbox/tiny-sdf": ["@mapbox/tiny-sdf@2.2.0", "", {}, "sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg=="],
|
||||||
|
|
||||||
|
"@mapbox/unitbezier": ["@mapbox/unitbezier@0.0.1", "", {}, "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw=="],
|
||||||
|
|
||||||
|
"@mapbox/vector-tile": ["@mapbox/vector-tile@2.0.5", "", { "dependencies": { "@mapbox/point-geometry": "~1.1.0", "@types/geojson": "^7946.0.16", "pbf": "^4.0.2" } }, "sha512-pXj8m7KTsqZt+1jsE0xIpGvqTSbblfkuEJL/NJmNePMtEwxO8V3XMDo9WMSfDeqHvCtBI9Lmt4mGcGR10zecmw=="],
|
||||||
|
|
||||||
|
"@mapbox/whoots-js": ["@mapbox/whoots-js@3.1.0", "", {}, "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q=="],
|
||||||
|
|
||||||
|
"@maplibre/geojson-vt": ["@maplibre/geojson-vt@6.1.1", "", { "dependencies": { "kdbush": "^4.1.0" } }, "sha512-FVMOcmSP/yqol45t7StApEyTL5/vmqBCuFhH9n+fFuINenhaX+YgHHIt1yJ86S8kln3uJLcMvmEU2cfn6E2eCQ=="],
|
||||||
|
|
||||||
|
"@maplibre/maplibre-gl-style-spec": ["@maplibre/maplibre-gl-style-spec@24.10.0", "", { "dependencies": { "@mapbox/jsonlint-lines-primitives": "~2.0.2", "@mapbox/unitbezier": "^1.0.0", "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" } }, "sha512-lichxSiagMEBBrqHF0trtMQH9RKh+9jUlIJl0qW0QHvt2H/tbvUWdE+ZzI2Jd0/pT7j/iavLonlPu7EQ/ixTOw=="],
|
||||||
|
|
||||||
|
"@maplibre/mlt": ["@maplibre/mlt@1.1.12", "", { "dependencies": { "@mapbox/point-geometry": "^1.1.0" } }, "sha512-ZeK5w2TTeHOajcLaEQs1KZXw2V9wIKo1PmThlxlsHoXsQsYlBqLJzPOd6tJHRtGTChUY3DPPmjXRArYVvAbmZw=="],
|
||||||
|
|
||||||
|
"@maplibre/vt-pbf": ["@maplibre/vt-pbf@4.3.2", "", { "dependencies": { "@mapbox/point-geometry": "^1.1.0", "@types/geojson": "^7946.0.16", "pbf": "^5.1.0" } }, "sha512-j6p0AdjvAR19Z3XaCysle7A4ZSo08tYOzxD0Y9NQylwPAkwJJeYub5b2eVucdeDh7erhv69DahoLOevDRERRUw=="],
|
||||||
|
|
||||||
"@mediapipe/tasks-vision": ["@mediapipe/tasks-vision@0.10.17", "", {}, "sha512-CZWV/q6TTe8ta61cZXjfnnHsfWIdFhms03M9T7Cnd5y2mdpylJM0rF1qRq+wsQVRMLz1OYPVEBU9ph2Bx8cxrg=="],
|
"@mediapipe/tasks-vision": ["@mediapipe/tasks-vision@0.10.17", "", {}, "sha512-CZWV/q6TTe8ta61cZXjfnnHsfWIdFhms03M9T7Cnd5y2mdpylJM0rF1qRq+wsQVRMLz1OYPVEBU9ph2Bx8cxrg=="],
|
||||||
|
|
||||||
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.27.1", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA=="],
|
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.27.1", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA=="],
|
||||||
@@ -894,6 +915,8 @@
|
|||||||
|
|
||||||
"@types/filewriter": ["@types/filewriter@0.0.33", "", {}, "sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g=="],
|
"@types/filewriter": ["@types/filewriter@0.0.33", "", {}, "sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g=="],
|
||||||
|
|
||||||
|
"@types/geojson": ["@types/geojson@7946.0.16", "", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="],
|
||||||
|
|
||||||
"@types/har-format": ["@types/har-format@1.2.16", "", {}, "sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A=="],
|
"@types/har-format": ["@types/har-format@1.2.16", "", {}, "sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A=="],
|
||||||
|
|
||||||
"@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="],
|
"@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="],
|
||||||
@@ -1270,6 +1293,8 @@
|
|||||||
|
|
||||||
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||||
|
|
||||||
|
"earcut": ["earcut@3.2.3", "", {}, "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q=="],
|
||||||
|
|
||||||
"eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="],
|
"eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="],
|
||||||
|
|
||||||
"ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="],
|
"ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="],
|
||||||
@@ -1394,6 +1419,8 @@
|
|||||||
|
|
||||||
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
|
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
|
||||||
|
|
||||||
|
"gl-matrix": ["gl-matrix@3.4.4", "", {}, "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ=="],
|
||||||
|
|
||||||
"glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="],
|
"glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="],
|
||||||
|
|
||||||
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
|
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
|
||||||
@@ -1556,6 +1583,8 @@
|
|||||||
|
|
||||||
"json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
|
"json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
|
||||||
|
|
||||||
|
"json-stringify-pretty-compact": ["json-stringify-pretty-compact@4.0.0", "", {}, "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q=="],
|
||||||
|
|
||||||
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
|
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
|
||||||
|
|
||||||
"jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="],
|
"jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="],
|
||||||
@@ -1568,6 +1597,8 @@
|
|||||||
|
|
||||||
"jwt-decode": ["jwt-decode@4.0.0", "", {}, "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA=="],
|
"jwt-decode": ["jwt-decode@4.0.0", "", {}, "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA=="],
|
||||||
|
|
||||||
|
"kdbush": ["kdbush@4.1.0", "", {}, "sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ=="],
|
||||||
|
|
||||||
"leac": ["leac@0.6.0", "", {}, "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg=="],
|
"leac": ["leac@0.6.0", "", {}, "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg=="],
|
||||||
|
|
||||||
"libbase64": ["libbase64@1.3.0", "", {}, "sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg=="],
|
"libbase64": ["libbase64@1.3.0", "", {}, "sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg=="],
|
||||||
@@ -1602,6 +1633,8 @@
|
|||||||
|
|
||||||
"mailparser": ["mailparser@3.9.3", "", { "dependencies": { "@zone-eu/mailsplit": "5.4.8", "encoding-japanese": "2.2.0", "he": "1.2.0", "html-to-text": "9.0.5", "iconv-lite": "0.7.2", "libmime": "5.3.7", "linkify-it": "5.0.0", "nodemailer": "7.0.13", "punycode.js": "2.3.1", "tlds": "1.261.0" } }, "sha512-AnB0a3zROum6fLaa52L+/K2SoRJVyFDk78Ea6q1D0ofcZLxWEWDtsS1+OrVqKbV7r5dulKL/AwYQccFGAPpuYQ=="],
|
"mailparser": ["mailparser@3.9.3", "", { "dependencies": { "@zone-eu/mailsplit": "5.4.8", "encoding-japanese": "2.2.0", "he": "1.2.0", "html-to-text": "9.0.5", "iconv-lite": "0.7.2", "libmime": "5.3.7", "linkify-it": "5.0.0", "nodemailer": "7.0.13", "punycode.js": "2.3.1", "tlds": "1.261.0" } }, "sha512-AnB0a3zROum6fLaa52L+/K2SoRJVyFDk78Ea6q1D0ofcZLxWEWDtsS1+OrVqKbV7r5dulKL/AwYQccFGAPpuYQ=="],
|
||||||
|
|
||||||
|
"maplibre-gl": ["maplibre-gl@5.24.0", "", { "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" } }, "sha512-ALyFxgtd5R+65UqZ/++lOqwWcC0SNho9c27fYSyLmG7AfnAul2o46F05aDJGPbFU57wos9dgcIySHs0Xe6ia3A=="],
|
||||||
|
|
||||||
"markdown-it": ["markdown-it@14.1.1", "", { "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", "linkify-it": "^5.0.0", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" }, "bin": { "markdown-it": "bin/markdown-it.mjs" } }, "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA=="],
|
"markdown-it": ["markdown-it@14.1.1", "", { "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", "linkify-it": "^5.0.0", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" }, "bin": { "markdown-it": "bin/markdown-it.mjs" } }, "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA=="],
|
||||||
|
|
||||||
"markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="],
|
"markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="],
|
||||||
@@ -1722,12 +1755,16 @@
|
|||||||
|
|
||||||
"minimatch": ["minimatch@9.0.1", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w=="],
|
"minimatch": ["minimatch@9.0.1", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w=="],
|
||||||
|
|
||||||
|
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
||||||
|
|
||||||
"minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
|
"minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
|
||||||
|
|
||||||
"monaco-editor": ["monaco-editor@0.55.1", "", { "dependencies": { "dompurify": "3.2.7", "marked": "14.0.0" } }, "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A=="],
|
"monaco-editor": ["monaco-editor@0.55.1", "", { "dependencies": { "dompurify": "3.2.7", "marked": "14.0.0" } }, "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A=="],
|
||||||
|
|
||||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||||
|
|
||||||
|
"murmurhash-js": ["murmurhash-js@1.0.0", "", {}, "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw=="],
|
||||||
|
|
||||||
"mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="],
|
"mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="],
|
||||||
|
|
||||||
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||||
@@ -1814,6 +1851,8 @@
|
|||||||
|
|
||||||
"path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="],
|
"path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="],
|
||||||
|
|
||||||
|
"pbf": ["pbf@4.0.2", "", { "dependencies": { "resolve-protobuf-schema": "^2.1.0" }, "bin": { "pbf": "bin/pbf" } }, "sha512-J0ajxARhZfpUEebxYs1vhMGMuLSXtBe1e+fFPDrf2uA2hgo+UshKfNUWOz92HJNz6/NFEXseQPddnHkTreWRqg=="],
|
||||||
|
|
||||||
"peberminta": ["peberminta@0.9.0", "", {}, "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ=="],
|
"peberminta": ["peberminta@0.9.0", "", {}, "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ=="],
|
||||||
|
|
||||||
"pg": ["pg@8.19.0", "", { "dependencies": { "pg-connection-string": "^2.11.0", "pg-pool": "^3.12.0", "pg-protocol": "^1.12.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.3.0" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-QIcLGi508BAHkQ3pJNptsFz5WQMlpGbuBGBaIaXsWK8mel2kQ/rThYI+DbgjUvZrIr7MiuEuc9LcChJoEZK1xQ=="],
|
"pg": ["pg@8.19.0", "", { "dependencies": { "pg-connection-string": "^2.11.0", "pg-pool": "^3.12.0", "pg-protocol": "^1.12.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.3.0" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-QIcLGi508BAHkQ3pJNptsFz5WQMlpGbuBGBaIaXsWK8mel2kQ/rThYI+DbgjUvZrIr7MiuEuc9LcChJoEZK1xQ=="],
|
||||||
@@ -1878,7 +1917,7 @@
|
|||||||
|
|
||||||
"postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="],
|
"postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="],
|
||||||
|
|
||||||
"potpack": ["potpack@1.0.2", "", {}, "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ=="],
|
"potpack": ["potpack@2.1.0", "", {}, "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ=="],
|
||||||
|
|
||||||
"prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="],
|
"prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="],
|
||||||
|
|
||||||
@@ -1930,6 +1969,8 @@
|
|||||||
|
|
||||||
"proto-list": ["proto-list@1.2.4", "", {}, "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA=="],
|
"proto-list": ["proto-list@1.2.4", "", {}, "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA=="],
|
||||||
|
|
||||||
|
"protocol-buffers-schema": ["protocol-buffers-schema@3.6.1", "", {}, "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ=="],
|
||||||
|
|
||||||
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
|
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
|
||||||
|
|
||||||
"punycode.js": ["punycode.js@2.3.1", "", {}, "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA=="],
|
"punycode.js": ["punycode.js@2.3.1", "", {}, "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA=="],
|
||||||
@@ -1946,6 +1987,8 @@
|
|||||||
|
|
||||||
"quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="],
|
"quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="],
|
||||||
|
|
||||||
|
"quickselect": ["quickselect@3.0.0", "", {}, "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g=="],
|
||||||
|
|
||||||
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
|
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
|
||||||
|
|
||||||
"raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
|
"raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
|
||||||
@@ -2044,6 +2087,8 @@
|
|||||||
|
|
||||||
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
|
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
|
||||||
|
|
||||||
|
"resolve-protobuf-schema": ["resolve-protobuf-schema@2.1.0", "", { "dependencies": { "protocol-buffers-schema": "^3.3.1" } }, "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ=="],
|
||||||
|
|
||||||
"restore-cursor": ["restore-cursor@3.1.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA=="],
|
"restore-cursor": ["restore-cursor@3.1.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA=="],
|
||||||
|
|
||||||
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
|
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
|
||||||
@@ -2188,6 +2233,8 @@
|
|||||||
|
|
||||||
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||||
|
|
||||||
|
"tinyqueue": ["tinyqueue@3.0.0", "", {}, "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g=="],
|
||||||
|
|
||||||
"tlds": ["tlds@1.261.0", "", { "bin": { "tlds": "bin.js" } }, "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA=="],
|
"tlds": ["tlds@1.261.0", "", { "bin": { "tlds": "bin.js" } }, "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA=="],
|
||||||
|
|
||||||
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
|
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
|
||||||
@@ -2352,6 +2399,10 @@
|
|||||||
|
|
||||||
"@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
|
"@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
|
||||||
|
|
||||||
|
"@maplibre/maplibre-gl-style-spec/@mapbox/unitbezier": ["@mapbox/unitbezier@1.0.0", "", {}, "sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ=="],
|
||||||
|
|
||||||
|
"@maplibre/vt-pbf/pbf": ["pbf@5.1.2", "", { "dependencies": { "resolve-protobuf-schema": "^2.1.0" }, "bin": { "pbf": "bin/pbf" } }, "sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w=="],
|
||||||
|
|
||||||
"@noble/curves/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="],
|
"@noble/curves/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="],
|
||||||
|
|
||||||
"@radix-ui/react-alert-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
"@radix-ui/react-alert-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||||
@@ -2564,6 +2615,8 @@
|
|||||||
|
|
||||||
"three-stdlib/fflate": ["fflate@0.6.10", "", {}, "sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg=="],
|
"three-stdlib/fflate": ["fflate@0.6.10", "", {}, "sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg=="],
|
||||||
|
|
||||||
|
"three-stdlib/potpack": ["potpack@1.0.2", "", {}, "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ=="],
|
||||||
|
|
||||||
"tsx/esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="],
|
"tsx/esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="],
|
||||||
|
|
||||||
"tsx/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
"tsx/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||||
|
|||||||
@@ -88,6 +88,13 @@ module.exports = {
|
|||||||
args: 'run src/servers/sidecar/invoiceshelf/index.ts',
|
args: 'run src/servers/sidecar/invoiceshelf/index.ts',
|
||||||
watch: false,
|
watch: false,
|
||||||
},
|
},
|
||||||
|
// The photo library. Wraps a self-hosted Immich and holds its API key; the platform sees none of it.
|
||||||
|
{
|
||||||
|
name: 'officer-photos',
|
||||||
|
script: 'bun',
|
||||||
|
args: 'run src/servers/sidecar/photos/index.ts',
|
||||||
|
watch: false,
|
||||||
|
},
|
||||||
// The bitcoin wallet. Holds seed material (sealed under an owner passphrase) and node credentials, so
|
// The bitcoin wallet. Holds seed material (sealed under an owner passphrase) and node credentials, so
|
||||||
// it is the one sidecar whose restart has a security-relevant side effect: every wallet relocks.
|
// it is the one sidecar whose restart has a security-relevant side effect: every wallet relocks.
|
||||||
// The one place anything leaves this machine to tell the owner something: push (APNs + FCM) and the
|
// The one place anything leaves this machine to tell the owner something: push (APNs + FCM) and the
|
||||||
|
|||||||
@@ -119,6 +119,7 @@
|
|||||||
"jwt-decode": "^4.0.0",
|
"jwt-decode": "^4.0.0",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"mailparser": "^3.9.3",
|
"mailparser": "^3.9.3",
|
||||||
|
"maplibre-gl": "^5.24.0",
|
||||||
"markdown-it": "^14.1.0",
|
"markdown-it": "^14.1.0",
|
||||||
"material-file-icons": "^2.4.0",
|
"material-file-icons": "^2.4.0",
|
||||||
"monaco-editor": "^0.55.1",
|
"monaco-editor": "^0.55.1",
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ export function App() {
|
|||||||
<Route path="/soulseek" element={<Dashboard.SoulseekScreen />} />
|
<Route path="/soulseek" element={<Dashboard.SoulseekScreen />} />
|
||||||
<Route path="/headscale" element={<Dashboard.HeadscaleScreen />} />
|
<Route path="/headscale" element={<Dashboard.HeadscaleScreen />} />
|
||||||
<Route path="/headscale/:section" element={<Dashboard.HeadscaleScreen />} />
|
<Route path="/headscale/:section" element={<Dashboard.HeadscaleScreen />} />
|
||||||
|
<Route path="/photos" element={<Dashboard.PhotosScreen />} />
|
||||||
|
<Route path="/photos/:section" element={<Dashboard.PhotosScreen />} />
|
||||||
<Route path="/transmission" element={<Dashboard.TransmissionScreen />} />
|
<Route path="/transmission" element={<Dashboard.TransmissionScreen />} />
|
||||||
<Route path="/transmission/:section" element={<Dashboard.TransmissionScreen />} />
|
<Route path="/transmission/:section" element={<Dashboard.TransmissionScreen />} />
|
||||||
<Route path="/invoices" element={<Dashboard.InvoicesScreen />} />
|
<Route path="/invoices" element={<Dashboard.InvoicesScreen />} />
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ import {
|
|||||||
ArrowDownUp,
|
ArrowDownUp,
|
||||||
Bitcoin,
|
Bitcoin,
|
||||||
Receipt,
|
Receipt,
|
||||||
|
Images,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
export const ALL_DOCK_ITEMS: DockItem[] = [
|
export const ALL_DOCK_ITEMS: DockItem[] = [
|
||||||
@@ -146,6 +147,7 @@ export const ALL_DOCK_ITEMS: DockItem[] = [
|
|||||||
{ label: 'Email', to: '/email', icon: Mail, color: '#ef4444' },
|
{ label: 'Email', to: '/email', icon: Mail, color: '#ef4444' },
|
||||||
{ label: 'Chat', to: '/chat', icon: MessageCircle, color: '#60a5fa' },
|
{ label: 'Chat', to: '/chat', icon: MessageCircle, color: '#60a5fa' },
|
||||||
{ label: 'Music', to: '/music', icon: Music, color: '#22c55e' },
|
{ label: 'Music', to: '/music', icon: Music, color: '#22c55e' },
|
||||||
|
{ label: 'Photos', to: '/photos', icon: Images, color: '#10b981' },
|
||||||
{ label: 'Soulseek', to: '/soulseek', image: '/slskd.png', color: '#ffffff' },
|
{ label: 'Soulseek', to: '/soulseek', image: '/slskd.png', color: '#ffffff' },
|
||||||
{ label: 'Headscale', to: '/headscale', icon: Network, color: '#818cf8' },
|
{ label: 'Headscale', to: '/headscale', icon: Network, color: '#818cf8' },
|
||||||
{ label: 'Transmission', to: '/transmission', icon: ArrowDownUp, color: '#e11d48' },
|
{ label: 'Transmission', to: '/transmission', icon: ArrowDownUp, color: '#e11d48' },
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { useEffect, useMemo } from 'react';
|
||||||
|
import { Navigate, useParams } from 'react-router';
|
||||||
|
import type { LayoutNode } from 'officerdev';
|
||||||
|
import { WorkspaceView, DEFAULT_PHOTOS_SECTION, photosSectionPath, isPhotosSection } from 'officerdev';
|
||||||
|
import { useDashboardState } from 'state/useDashboardState';
|
||||||
|
import { defaultLayout } from './defaultLayout';
|
||||||
|
|
||||||
|
// /photos uses the Workspace/Panel system: the section nav (photos-nav) on the left, the section view
|
||||||
|
// (photos-view) on the right. Both talk to the officer-photos sidecar through the /api/photos auth proxy; the
|
||||||
|
// Immich URL and API key live in the sidecar's environment and the browser never sees either.
|
||||||
|
//
|
||||||
|
// The open section is :section in the URL; deeper selection (which asset, album, person, search) is in the
|
||||||
|
// query string. Nothing about "what is open" lives in a panel channel.
|
||||||
|
|
||||||
|
const ALLOWED_APP_TYPES = new Set<string | null>(['photos-nav', 'photos-view', null]);
|
||||||
|
|
||||||
|
function normalizeLayout(node: LayoutNode): LayoutNode {
|
||||||
|
if (node.type === 'panel') {
|
||||||
|
return ALLOWED_APP_TYPES.has(node.appType) ? node : { ...node, appType: 'photos-view' };
|
||||||
|
}
|
||||||
|
const children = node.children.map((c) => {
|
||||||
|
const fixed = normalizeLayout(c.node);
|
||||||
|
return fixed === c.node ? c : { ...c, node: fixed };
|
||||||
|
});
|
||||||
|
const changed = children.some((c, i) => c !== node.children[i]);
|
||||||
|
return changed ? { ...node, children } : node;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PhotosScreen = () => {
|
||||||
|
const { section } = useParams();
|
||||||
|
const rawWorkspace = useDashboardState<LayoutNode>('screens/photos', defaultLayout);
|
||||||
|
|
||||||
|
const workspace = useMemo(() => {
|
||||||
|
const fixed = normalizeLayout(rawWorkspace.value);
|
||||||
|
if (fixed === rawWorkspace.value) return rawWorkspace;
|
||||||
|
return { ...rawWorkspace, value: fixed };
|
||||||
|
}, [rawWorkspace]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (rawWorkspace.isLoaded && workspace.value !== rawWorkspace.value) {
|
||||||
|
rawWorkspace.setValue(workspace.value);
|
||||||
|
}
|
||||||
|
}, [rawWorkspace.isLoaded, workspace.value, rawWorkspace.value]);
|
||||||
|
|
||||||
|
// Bare /photos, or a section that doesn't exist, canonicalises rather than rendering a default behind a URL
|
||||||
|
// that names something else — the nav highlight comes from the router, so a bogus URL highlights nothing.
|
||||||
|
if (!isPhotosSection(section)) {
|
||||||
|
return <Navigate to={photosSectionPath(DEFAULT_PHOTOS_SECTION)} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full pt-2">
|
||||||
|
<WorkspaceView workspace={workspace} locked />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import type { LayoutNode } from 'officerdev';
|
||||||
|
|
||||||
|
export const defaultLayout: LayoutNode = {
|
||||||
|
type: 'group',
|
||||||
|
id: 'photos-root',
|
||||||
|
direction: 'horizontal',
|
||||||
|
children: [
|
||||||
|
{ node: { type: 'panel', id: 'photos-nav', appType: 'photos-nav' }, size: 18 },
|
||||||
|
{ node: { type: 'panel', id: 'photos-view', appType: 'photos-view' }, size: 82 },
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './PhotosScreen';
|
||||||
@@ -13,6 +13,7 @@ export * from './Files';
|
|||||||
export * from './Music';
|
export * from './Music';
|
||||||
export * from './Soulseek';
|
export * from './Soulseek';
|
||||||
export * from './Headscale';
|
export * from './Headscale';
|
||||||
|
export * from './Photos';
|
||||||
export * from './Transmission';
|
export * from './Transmission';
|
||||||
export * from './Invoices';
|
export * from './Invoices';
|
||||||
export * from './Wallet';
|
export * from './Wallet';
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const RULES: TitleRule[] = [
|
|||||||
{ match: (p) => p.startsWith('/email'), title: 'Email' },
|
{ match: (p) => p.startsWith('/email'), title: 'Email' },
|
||||||
{ match: (p) => p.startsWith('/files'), title: 'Files' },
|
{ match: (p) => p.startsWith('/files'), title: 'Files' },
|
||||||
{ match: (p) => p.startsWith('/music'), title: 'Music' },
|
{ match: (p) => p.startsWith('/music'), title: 'Music' },
|
||||||
|
{ match: (p) => p.startsWith('/photos'), title: 'Photos' },
|
||||||
{ match: (p) => p.startsWith('/soulseek'), title: 'Soulseek' },
|
{ match: (p) => p.startsWith('/soulseek'), title: 'Soulseek' },
|
||||||
{ match: (p) => p.startsWith('/headscale'), title: 'Headscale' },
|
{ match: (p) => p.startsWith('/headscale'), title: 'Headscale' },
|
||||||
{ match: (p) => p.startsWith('/transmission'), title: 'Transmission' },
|
{ match: (p) => p.startsWith('/transmission'), title: 'Transmission' },
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { createSidecarProxy } from '../../sidecar/create-proxy';
|
||||||
|
|
||||||
|
// /api/photos/* — auth, then forward to officer-photos. No routes of its own and no Immich knowledge:
|
||||||
|
// this file must never grow app logic.
|
||||||
|
//
|
||||||
|
// The sidecar owns the Immich contract and holds its API key.
|
||||||
|
|
||||||
|
const proxy = createSidecarProxy({
|
||||||
|
name: 'photos',
|
||||||
|
prefix: '/api/photos',
|
||||||
|
// Originals and `download/archive` zips are large and Immich builds the archive as it streams it, so the
|
||||||
|
// socket can sit quiet longer than the default 60s idle drop allows.
|
||||||
|
timeoutSeconds: 600,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const photosRouter = proxy.router;
|
||||||
|
|
||||||
|
/** Base URL of the sidecar's HTTP server, or null if it hasn't reported in yet. */
|
||||||
|
export const getPhotosServerUrl = proxy.getHttpUrl;
|
||||||
@@ -26,6 +26,7 @@ import { slskdRouter } from './api/slskd/router';
|
|||||||
import { headscaleRouter } from './api/headscale/router';
|
import { headscaleRouter } from './api/headscale/router';
|
||||||
import { transmissionRouter } from './api/transmission/router';
|
import { transmissionRouter } from './api/transmission/router';
|
||||||
import { invoiceshelfRouter } from './api/invoiceshelf/router';
|
import { invoiceshelfRouter } from './api/invoiceshelf/router';
|
||||||
|
import { photosRouter } from './api/photos/router';
|
||||||
import { walletRouter } from './api/wallet/router';
|
import { walletRouter } from './api/wallet/router';
|
||||||
import { vpnRouter } from './api/vpn/router';
|
import { vpnRouter } from './api/vpn/router';
|
||||||
import { terminalRouter } from './api/terminal/sidecar-server';
|
import { terminalRouter } from './api/terminal/sidecar-server';
|
||||||
@@ -118,6 +119,7 @@ protectedRouter.route('/notify', notifyRouter);
|
|||||||
protectedRouter.route('/headscale', headscaleRouter);
|
protectedRouter.route('/headscale', headscaleRouter);
|
||||||
protectedRouter.route('/transmission', transmissionRouter);
|
protectedRouter.route('/transmission', transmissionRouter);
|
||||||
protectedRouter.route('/invoiceshelf', invoiceshelfRouter);
|
protectedRouter.route('/invoiceshelf', invoiceshelfRouter);
|
||||||
|
protectedRouter.route('/photos', photosRouter);
|
||||||
protectedRouter.route('/wallet', walletRouter);
|
protectedRouter.route('/wallet', walletRouter);
|
||||||
protectedRouter.route('/vpn', vpnRouter);
|
protectedRouter.route('/vpn', vpnRouter);
|
||||||
protectedRouter.route('/system-monitor', systemMonitorRouter);
|
protectedRouter.route('/system-monitor', systemMonitorRouter);
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import type { SidecarCommand, SidecarEvent } from '../protocol';
|
||||||
|
import { createSidecarConnector } from '../connect';
|
||||||
|
import { handleOfficerRoute } from './routes';
|
||||||
|
import { callUpstream, getBase, getConfig } from './upstream';
|
||||||
|
|
||||||
|
// The officer-photos sidecar. Owns the whole Immich contract for Officer: the instance URL and the API key.
|
||||||
|
// The platform API is a thin auth-gated forwarder (src/servers/api/photos/router.ts) holding no Immich
|
||||||
|
// credentials.
|
||||||
|
//
|
||||||
|
// Named `photos`, not `immich`: the feature is the owner's photo library, and Immich is the implementation
|
||||||
|
// behind it. The route surface below is Officer's, so swapping the backend would not move the mount point.
|
||||||
|
//
|
||||||
|
// Built against the LIVE instance, which reports 3.0.3 (`GET /api/server/version`, unauthenticated).
|
||||||
|
//
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
// HTTP CONTRACT — the platform strips its /api/photos mount prefix before forwarding.
|
||||||
|
//
|
||||||
|
// GET /_health ours. Confirms the key is live and reports the Immich version and who the key is.
|
||||||
|
// * /_officer/<path> forwarded to <IMMICH_URL>/api/<path>, first-segment allow-list (routes.ts)
|
||||||
|
// anything else 404
|
||||||
|
//
|
||||||
|
// So `/api/photos/_officer/albums` on the platform is `/api/albums` on Immich, and
|
||||||
|
// `/api/photos/_officer/assets/<id>/thumbnail?size=preview` streams the thumbnail bytes back, Range and
|
||||||
|
// ETag included. The administrative half of Immich's API is unreachable — see routes.ts for the list.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const API_URL = process.env.API_URL ?? `ws://127.0.0.1:${process.env.PORT ?? '5000'}`;
|
||||||
|
|
||||||
|
/** Grab an ephemeral free port by briefly binding one and releasing it. */
|
||||||
|
function getFreePort(): number {
|
||||||
|
const probeServer = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') });
|
||||||
|
const p = probeServer.port;
|
||||||
|
probeServer.stop(true);
|
||||||
|
if (p == null) throw new Error('failed to acquire a free port');
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
const port = getFreePort();
|
||||||
|
|
||||||
|
const server = Bun.serve({
|
||||||
|
port,
|
||||||
|
hostname: '127.0.0.1',
|
||||||
|
// Uploads go through this proxy as multipart bodies, and a phone's video is not small.
|
||||||
|
maxRequestBodySize: 4 * 1024 * 1024 * 1024,
|
||||||
|
async fetch(req) {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const cfg = getConfig();
|
||||||
|
|
||||||
|
if (url.pathname === '/_health') {
|
||||||
|
if (!cfg) return Response.json({ ok: false, error: 'IMMICH_URL/IMMICH_API_KEY not configured' }, { status: 503 });
|
||||||
|
const started = Date.now();
|
||||||
|
try {
|
||||||
|
// Version is public, so it separates "instance down" from "key rejected" in one shot.
|
||||||
|
const [versionRes, meRes] = await Promise.all([
|
||||||
|
callUpstream(cfg, { path: '/api/server/version', withKey: false }),
|
||||||
|
callUpstream(cfg, { path: '/api/users/me' }),
|
||||||
|
]);
|
||||||
|
if (!versionRes.ok) {
|
||||||
|
return Response.json({ ok: false, error: `upstream returned ${versionRes.status}` }, { status: 502 });
|
||||||
|
}
|
||||||
|
const v = (await versionRes.json()) as { major?: number; minor?: number; patch?: number };
|
||||||
|
const version = [v.major, v.minor, v.patch].every((n) => typeof n === 'number')
|
||||||
|
? `${v.major}.${v.minor}.${v.patch}`
|
||||||
|
: null;
|
||||||
|
if (!meRes.ok) {
|
||||||
|
return Response.json(
|
||||||
|
{ ok: false, version, error: `IMMICH_API_KEY rejected (${meRes.status})`, ms: Date.now() - started },
|
||||||
|
{ status: 502 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const me = (await meRes.json()) as { email?: string; name?: string };
|
||||||
|
return Response.json({ ok: true, version, user: me.email ?? me.name ?? null, ms: Date.now() - started });
|
||||||
|
} catch (err) {
|
||||||
|
return Response.json({ ok: false, error: String(err), ms: Date.now() - started }, { status: 502 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname.startsWith('/_officer/')) {
|
||||||
|
if (!cfg) return Response.json({ error: 'photos not configured' }, { status: 503 });
|
||||||
|
try {
|
||||||
|
const res = await handleOfficerRoute(cfg, req, url);
|
||||||
|
if (res) return res;
|
||||||
|
return Response.json({ error: 'not found' }, { status: 404 });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[photos] ${req.method} ${url.pathname} failed`, err);
|
||||||
|
return Response.json({ error: 'internal error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json({ error: 'not found' }, { status: 404 });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[photos] listening on 127.0.0.1:${port} -> ${getBase() ?? '(IMMICH_URL unset)'}`);
|
||||||
|
|
||||||
|
type ReplyFn = (msg: SidecarEvent) => void;
|
||||||
|
|
||||||
|
function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
|
||||||
|
switch (cmd.type) {
|
||||||
|
case 'ping':
|
||||||
|
reply({ type: 'pong', id: cmd.id });
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
reply({
|
||||||
|
type: 'error',
|
||||||
|
id: (cmd as SidecarCommand).id,
|
||||||
|
error: `Unknown command type: ${(cmd as Record<string, unknown>).type}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const connection = createSidecarConnector({
|
||||||
|
apiUrl: `${API_URL}/api/sidecar/register`,
|
||||||
|
name: 'photos',
|
||||||
|
capabilities: ['photos'],
|
||||||
|
onCommand(cmd, reply) {
|
||||||
|
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
|
||||||
|
},
|
||||||
|
onConnected() {
|
||||||
|
connection.send({ type: 'photos:server', port });
|
||||||
|
console.log(`[photos] reported server port ${port} to API`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function shutdown(signal: string) {
|
||||||
|
console.log(`[photos] ${signal} received, shutting down...`);
|
||||||
|
try {
|
||||||
|
server.stop(true);
|
||||||
|
} catch {
|
||||||
|
/* already stopped */
|
||||||
|
}
|
||||||
|
connection.destroy();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||||
|
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import type { UpstreamConfig } from './upstream';
|
||||||
|
import { callUpstream } from './upstream';
|
||||||
|
|
||||||
|
// The Officer-owned route surface. This is an allow-list, not a passthrough.
|
||||||
|
//
|
||||||
|
// Immich's API is already REST and already shaped the way a photo UI wants it, so the value this sidecar
|
||||||
|
// adds is (a) holding the API key, (b) streaming the binary routes through without buffering them, and
|
||||||
|
// (c) refusing to expose the administrative half of the API at all.
|
||||||
|
//
|
||||||
|
// The gate is the FIRST path segment plus the method. Anything whose first segment is not listed here is a
|
||||||
|
// 404 before a request is made, so the deny side is closed by construction rather than by enumeration.
|
||||||
|
//
|
||||||
|
// DELIBERATELY NOT EXPOSED: admin/* (user creation, deletion, quotas), auth/* and oauth/* (session and
|
||||||
|
// password machinery — the key is the credential here, and nothing should be minting sessions through a
|
||||||
|
// dashboard), api-keys/* (a proxy that can mint its own credentials is not a proxy), sessions/*, jobs/*
|
||||||
|
// (queue control), system-config/* and system-metadata/* (rewrites the deployment), libraries/* (external
|
||||||
|
// library paths and scans) and sync/*. Those either mutate the deployment or can lock the owner out of it.
|
||||||
|
// Adding one should be a decision, not an accident.
|
||||||
|
|
||||||
|
const RESOURCES: Record<string, readonly string[]> = {
|
||||||
|
// The library itself.
|
||||||
|
assets: ['GET', 'POST', 'PUT', 'DELETE'],
|
||||||
|
albums: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
|
||||||
|
timeline: ['GET'],
|
||||||
|
memories: ['GET', 'POST', 'PUT', 'DELETE'],
|
||||||
|
people: ['GET', 'POST', 'PUT'],
|
||||||
|
faces: ['GET', 'PUT'],
|
||||||
|
tags: ['GET', 'POST', 'PUT', 'DELETE'],
|
||||||
|
stacks: ['GET', 'POST', 'PUT', 'DELETE'],
|
||||||
|
// Finding things. `search/metadata`, `search/smart` and `search/random` are POSTs with a JSON body.
|
||||||
|
search: ['GET', 'POST'],
|
||||||
|
duplicates: ['GET'],
|
||||||
|
map: ['GET'],
|
||||||
|
view: ['GET'],
|
||||||
|
// Getting things out. `download/info` then `download/archive` — both POST, the archive streams a zip.
|
||||||
|
download: ['POST'],
|
||||||
|
// Recoverable deletes. `trash/empty` is the one irreversible route in this list; it is here because it is
|
||||||
|
// the counterpart of a delete the UI can already perform, not because it is safe.
|
||||||
|
trash: ['GET', 'POST'],
|
||||||
|
// Sharing and social. Read-mostly, but a shared link is the point of them.
|
||||||
|
'shared-links': ['GET', 'POST', 'PATCH', 'DELETE'],
|
||||||
|
activities: ['GET', 'POST', 'DELETE'],
|
||||||
|
partners: ['GET'],
|
||||||
|
notifications: ['GET', 'PUT', 'DELETE'],
|
||||||
|
// Read-only context: who the key acts as, and what the server is.
|
||||||
|
users: ['GET'],
|
||||||
|
server: ['GET'],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Response headers worth carrying back. Content-Type and Content-Length for every response; the rest so a
|
||||||
|
// thumbnail can be cached and revalidated and a video can be seeked without the proxy understanding either.
|
||||||
|
const PASSTHROUGH_HEADERS = [
|
||||||
|
'content-type',
|
||||||
|
'content-length',
|
||||||
|
'content-disposition',
|
||||||
|
'content-range',
|
||||||
|
'accept-ranges',
|
||||||
|
'etag',
|
||||||
|
'last-modified',
|
||||||
|
'cache-control',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forward one request to Immich and stream the answer back.
|
||||||
|
*
|
||||||
|
* The body is a stream, never an ArrayBuffer: a full-resolution original or a `download/archive` zip is
|
||||||
|
* hundreds of megabytes and buffering it would hold all of it in the sidecar's heap for no reason.
|
||||||
|
*/
|
||||||
|
export async function handleOfficerRoute(cfg: UpstreamConfig, req: Request, url: URL): Promise<Response | null> {
|
||||||
|
const rest = url.pathname.slice('/_officer/'.length);
|
||||||
|
if (!rest) return null;
|
||||||
|
|
||||||
|
const [resource] = rest.split('/');
|
||||||
|
const allowedMethods = resource ? RESOURCES[resource] : undefined;
|
||||||
|
if (!allowedMethods) return null;
|
||||||
|
if (!allowedMethods.includes(req.method)) {
|
||||||
|
return Response.json({ error: `${req.method} not allowed on ${resource}` }, { status: 405 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasBody = req.method !== 'GET' && req.method !== 'HEAD';
|
||||||
|
|
||||||
|
// An <img>/<video> cannot send an Authorization header, so the browser puts Officer's JWT in `?token=`
|
||||||
|
// (userMiddleware accepts it there). That token is Officer's, not Immich's: forwarding it would write the
|
||||||
|
// owner's session credential into Immich's access log for every thumbnail. Drop it at the boundary.
|
||||||
|
const query = new URLSearchParams(url.search);
|
||||||
|
query.delete('token');
|
||||||
|
const search = query.toString();
|
||||||
|
|
||||||
|
const res = await callUpstream(cfg, {
|
||||||
|
path: `/api/${rest}`,
|
||||||
|
method: req.method,
|
||||||
|
query: search ? `?${search}` : '',
|
||||||
|
body: hasBody ? await req.arrayBuffer() : null,
|
||||||
|
contentType: req.headers.get('content-type'),
|
||||||
|
range: req.headers.get('range'),
|
||||||
|
ifNoneMatch: req.headers.get('if-none-match'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const headers = new Headers();
|
||||||
|
for (const name of PASSTHROUGH_HEADERS) {
|
||||||
|
const value = res.headers.get(name);
|
||||||
|
if (value) headers.set(name, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 204 and 304 must not carry a body, and Bun throws if one is attached.
|
||||||
|
const body = res.status === 204 || res.status === 304 ? null : res.body;
|
||||||
|
return new Response(body, { status: res.status, headers });
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
// Immich upstream config for the officer-photos sidecar.
|
||||||
|
//
|
||||||
|
// All knowledge of the Immich instance — its URL and its API key — lives here, mirroring
|
||||||
|
// officer-invoiceshelf/officer-transmission/officer-slskd: the platform API is a thin auth+forward proxy
|
||||||
|
// and holds NO Immich credentials.
|
||||||
|
//
|
||||||
|
// Two things about Immich's API are load-bearing:
|
||||||
|
//
|
||||||
|
// 1. Auth is the `x-api-key` header. Immich keys are SCOPED — a key created without a permission gets a
|
||||||
|
// 403 on that route, not a 401, so a partial key looks like a broken feature rather than a bad
|
||||||
|
// credential. Create the key with all permissions unless there is a reason not to.
|
||||||
|
// 2. NEVER forward Cookie, Origin or Referer. Immich accepts a session cookie as an alternative
|
||||||
|
// credential, and a browser-shaped request reaching it with the owner's Officer cookies attached is
|
||||||
|
// exactly the confusion this sidecar exists to prevent. Bun's fetch adds none of them on its own and
|
||||||
|
// nothing below adds them; the platform proxy forwards only content-type, range and if-none-match.
|
||||||
|
|
||||||
|
const { IMMICH_URL, IMMICH_API_KEY } = process.env;
|
||||||
|
|
||||||
|
export type UpstreamConfig = { base: string; key: string };
|
||||||
|
|
||||||
|
let warnedUnset = false;
|
||||||
|
|
||||||
|
/** The instance URL alone, for logging — set without a key is a real state and should read as one. */
|
||||||
|
export function getBase(): string | null {
|
||||||
|
return IMMICH_URL?.trim().replace(/\/+$/, '') || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The configured instance, or null when unconfigured — the sidecar then answers 503 rather than pretending
|
||||||
|
* to work. Warns once so a misconfigured deployment is obvious in the logs without flooding them.
|
||||||
|
*/
|
||||||
|
export function getConfig(): UpstreamConfig | null {
|
||||||
|
const base = IMMICH_URL?.trim().replace(/\/+$/, '');
|
||||||
|
const key = IMMICH_API_KEY?.trim();
|
||||||
|
if (!base || !key) {
|
||||||
|
if (!warnedUnset) {
|
||||||
|
const missing = [!base && 'IMMICH_URL', !key && 'IMMICH_API_KEY'].filter(Boolean).join(' and ');
|
||||||
|
console.warn(`[photos] ${missing} unset — the sidecar will respond 503 until set`);
|
||||||
|
warnedUnset = true;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { base, key };
|
||||||
|
}
|
||||||
|
|
||||||
|
type CallOptions = {
|
||||||
|
/** Absolute path on the Immich host, e.g. `/api/albums`. */
|
||||||
|
path: string;
|
||||||
|
method?: string;
|
||||||
|
/** Raw search string including the leading `?`, or empty. */
|
||||||
|
query?: string;
|
||||||
|
body?: BodyInit | null;
|
||||||
|
contentType?: string | null;
|
||||||
|
/** Byte range for thumbnail/original/video reads, forwarded verbatim. */
|
||||||
|
range?: string | null;
|
||||||
|
ifNoneMatch?: string | null;
|
||||||
|
/** Set false for the version/ping routes, which Immich serves unauthenticated. */
|
||||||
|
withKey?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The single door to Immich. Everything the sidecar fetches goes through here. */
|
||||||
|
export async function callUpstream(cfg: UpstreamConfig, opts: CallOptions): Promise<Response> {
|
||||||
|
const headers: Record<string, string> = { Accept: 'application/json' };
|
||||||
|
|
||||||
|
if (opts.withKey !== false) headers['x-api-key'] = cfg.key;
|
||||||
|
if (opts.contentType) headers['Content-Type'] = opts.contentType;
|
||||||
|
if (opts.range) headers.Range = opts.range;
|
||||||
|
if (opts.ifNoneMatch) headers['If-None-Match'] = opts.ifNoneMatch;
|
||||||
|
|
||||||
|
const res = await fetch(`${cfg.base}${opts.path}${opts.query ?? ''}`, {
|
||||||
|
method: opts.method ?? 'GET',
|
||||||
|
headers,
|
||||||
|
body: opts.body ?? undefined,
|
||||||
|
// A redirect from an API route means something has gone wrong with auth; surface it rather than
|
||||||
|
// following it into an HTML page.
|
||||||
|
redirect: 'manual',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status === 401 || res.status === 403) {
|
||||||
|
// 403 is the interesting one: the key is valid but lacks the permission this route needs.
|
||||||
|
console.warn(`[photos] ${opts.method ?? 'GET'} ${opts.path} → ${res.status} (key rejected or under-scoped)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res;
|
||||||
|
}
|
||||||
@@ -73,6 +73,8 @@ export type SidecarEvent =
|
|||||||
| { type: 'transmission:server'; port: number }
|
| { type: 'transmission:server'; port: number }
|
||||||
// InvoiceShelf — the sidecar reports where its HTTP server is listening (random port) on connect
|
// InvoiceShelf — the sidecar reports where its HTTP server is listening (random port) on connect
|
||||||
| { type: 'invoiceshelf:server'; port: number }
|
| { type: 'invoiceshelf:server'; port: number }
|
||||||
|
// Photos (Immich) — the sidecar reports where its HTTP server is listening (random port) on connect
|
||||||
|
| { type: 'photos:server'; port: number }
|
||||||
// Wallet — the sidecar reports where its HTTP server is listening (random port) on connect
|
// Wallet — the sidecar reports where its HTTP server is listening (random port) on connect
|
||||||
| { type: 'wallet:server'; port: number }
|
| { type: 'wallet:server'; port: number }
|
||||||
// PTY — the sidecar reports where its terminal HTTP/WS server is listening (random port) on connect
|
// PTY — the sidecar reports where its terminal HTTP/WS server is listening (random port) on connect
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { appRegistryMetas as desktopMetas } from '../apps/Desktop';
|
|||||||
import { appRegistryMetas as musicMetas } from '../apps/Music';
|
import { appRegistryMetas as musicMetas } from '../apps/Music';
|
||||||
import { appRegistryMetas as soulseekMetas } from '../apps/Soulseek';
|
import { appRegistryMetas as soulseekMetas } from '../apps/Soulseek';
|
||||||
import { appRegistryMetas as headscaleMetas } from '../apps/Headscale';
|
import { appRegistryMetas as headscaleMetas } from '../apps/Headscale';
|
||||||
|
import { appRegistryMetas as photosMetas } from '../apps/Photos';
|
||||||
import { appRegistryMetas as transmissionMetas } from '../apps/Transmission';
|
import { appRegistryMetas as transmissionMetas } from '../apps/Transmission';
|
||||||
import { appRegistryMetas as invoicesMetas } from '../apps/Invoices';
|
import { appRegistryMetas as invoicesMetas } from '../apps/Invoices';
|
||||||
import { appRegistryMetas as walletMetas } from '../apps/Wallet';
|
import { appRegistryMetas as walletMetas } from '../apps/Wallet';
|
||||||
@@ -30,6 +31,7 @@ const apps = [
|
|||||||
...musicMetas,
|
...musicMetas,
|
||||||
...soulseekMetas,
|
...soulseekMetas,
|
||||||
...headscaleMetas,
|
...headscaleMetas,
|
||||||
|
...photosMetas,
|
||||||
...transmissionMetas,
|
...transmissionMetas,
|
||||||
...invoicesMetas,
|
...invoicesMetas,
|
||||||
...walletMetas,
|
...walletMetas,
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { Link, useSearchParams } from 'react-router';
|
||||||
|
import { ArrowLeft, ImageOff, MoreVertical, Plus } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import { useClient } from 'hooks/useClient';
|
||||||
|
import { AssetViewer } from './AssetViewer';
|
||||||
|
import { PhotoGrid } from './PhotoGrid';
|
||||||
|
import { SelectionBar } from './SelectionBar';
|
||||||
|
import { ALBUM_PARAM, formatDateRange, thumbUrl, toGridAsset } from './shared';
|
||||||
|
import { useAlbum, useAlbums } from './usePhotosData';
|
||||||
|
import { useAssetActions } from './useAssetActions';
|
||||||
|
import { useSelection } from './useSelection';
|
||||||
|
|
||||||
|
// Two states in one section, switched by `?album=<id>`: the wall of album covers, and one album's photos.
|
||||||
|
// A separate `/photos/albums/:id` route would work too, but the album is a selection inside the section — the
|
||||||
|
// same shape as `?selected=` elsewhere in the app — and this keeps the nav highlight where it belongs.
|
||||||
|
|
||||||
|
export const AlbumsSection = () => {
|
||||||
|
const [params] = useSearchParams();
|
||||||
|
const albumId = params.get(ALBUM_PARAM);
|
||||||
|
return albumId ? <AlbumDetailView albumId={albumId} /> : <AlbumListView />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AlbumListView = () => {
|
||||||
|
const { token } = useClient();
|
||||||
|
const { data: albums, isLoading } = useAlbums();
|
||||||
|
const { createAlbum, deleteAlbum, renameAlbum } = useAssetActions();
|
||||||
|
|
||||||
|
const sorted = useMemo(() => [...(albums ?? [])].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)), [albums]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<header className="flex items-center gap-2 border-b px-3 py-2">
|
||||||
|
<h1 className="text-sm font-semibold">Albums</h1>
|
||||||
|
<span className="text-xs text-muted-foreground">{sorted.length}</span>
|
||||||
|
<div className="flex-1" />
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
const albumName = window.prompt('New album name');
|
||||||
|
if (albumName) createAlbum.mutate({ albumName });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus className="mr-1.5 h-4 w-4" />
|
||||||
|
Create album
|
||||||
|
</Button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto p-3">
|
||||||
|
{isLoading && <p className="text-sm text-muted-foreground">Loading…</p>}
|
||||||
|
{!isLoading && sorted.length === 0 && <p className="text-sm text-muted-foreground">No albums yet.</p>}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-[repeat(auto-fill,minmax(180px,1fr))] gap-4">
|
||||||
|
{sorted.map((album) => (
|
||||||
|
<div key={album.id} className="group relative">
|
||||||
|
<Link to={`?${ALBUM_PARAM}=${album.id}`} className="block">
|
||||||
|
<div className="aspect-square overflow-hidden rounded-lg bg-white/5">
|
||||||
|
{album.albumThumbnailAssetId ? (
|
||||||
|
<img
|
||||||
|
src={thumbUrl(album.albumThumbnailAssetId, token)}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
className="h-full w-full object-cover transition group-hover:brightness-110"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||||
|
<ImageOff className="h-8 w-8" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 truncate text-sm font-medium">{album.albumName}</p>
|
||||||
|
<p className="truncate text-xs text-muted-foreground">
|
||||||
|
{album.assetCount} items
|
||||||
|
{album.shared ? ' · shared' : ''}
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="absolute right-1 top-1 h-7 w-7 bg-black/50 text-white opacity-0 group-hover:opacity-100"
|
||||||
|
>
|
||||||
|
<MoreVertical className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
const albumName = window.prompt('Rename album', album.albumName);
|
||||||
|
if (albumName) renameAlbum.mutate({ albumId: album.id, albumName });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Rename
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => window.confirm(`Delete "${album.albumName}"?`) && deleteAlbum.mutate(album.id)}
|
||||||
|
>
|
||||||
|
Delete album
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const AlbumDetailView = ({ albumId }: { albumId: string }) => {
|
||||||
|
const { token } = useClient();
|
||||||
|
const [params] = useSearchParams();
|
||||||
|
const { data: album, isLoading } = useAlbum(albumId);
|
||||||
|
|
||||||
|
const assets = useMemo(() => (album?.assets ?? []).map(toGridAsset), [album]);
|
||||||
|
const selection = useSelection(assets.map((asset) => asset.id));
|
||||||
|
|
||||||
|
const back = (() => {
|
||||||
|
const next = new URLSearchParams(params);
|
||||||
|
next.delete(ALBUM_PARAM);
|
||||||
|
const search = next.toString();
|
||||||
|
return search ? `?${search}` : '?';
|
||||||
|
})();
|
||||||
|
|
||||||
|
const range = formatDateRange(album?.startDate, album?.endDate);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
{selection.isSelecting ? (
|
||||||
|
<SelectionBar selection={selection} albumId={albumId} />
|
||||||
|
) : (
|
||||||
|
<header className="flex items-center gap-2 border-b px-3 py-2">
|
||||||
|
<Button asChild variant="ghost" size="icon" title="Back to albums">
|
||||||
|
<Link to={back}>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h1 className="truncate text-sm font-semibold">{album?.albumName ?? 'Album'}</h1>
|
||||||
|
<p className="truncate text-xs text-muted-foreground">
|
||||||
|
{album?.assetCount ?? 0} items{range ? ` · ${range}` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto p-2">
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="p-2 text-sm text-muted-foreground">Loading…</p>
|
||||||
|
) : (
|
||||||
|
<PhotoGrid
|
||||||
|
assets={assets}
|
||||||
|
token={token}
|
||||||
|
selection={selection}
|
||||||
|
empty={<p className="p-2 text-sm text-muted-foreground">This album is empty.</p>}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AssetViewer ids={assets.map((asset) => asset.id)} token={token} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import type { Asset } from './shared';
|
||||||
|
import { Link } from 'react-router';
|
||||||
|
import { Camera, Image as ImageIcon, MapPin } from 'lucide-react';
|
||||||
|
import { PERSON_PARAM, formatBytes, formatLongDate, formatTime, personThumbUrl, photosSectionPath } from './shared';
|
||||||
|
|
||||||
|
// The lightbox's info drawer, in the order Immich's own panel uses: description, date, file, camera, location,
|
||||||
|
// people. Sections with nothing in them are omitted rather than shown empty — a library full of screenshots
|
||||||
|
// has no camera data and shouldn't render a row of dashes for every one.
|
||||||
|
|
||||||
|
type AssetInfoPanelProps = { asset: Asset | undefined; token: string | null };
|
||||||
|
|
||||||
|
export const AssetInfoPanel = ({ asset, token }: AssetInfoPanelProps) => {
|
||||||
|
if (!asset) return <div className="p-4 text-sm text-muted-foreground">Loading…</div>;
|
||||||
|
|
||||||
|
const exif = asset.exifInfo;
|
||||||
|
const megapixels = asset.width && asset.height ? (asset.width * asset.height) / 1_000_000 : 0;
|
||||||
|
const place = [exif?.city, exif?.state, exif?.country].filter(Boolean).join(', ');
|
||||||
|
const aperture = exif?.fNumber ? `ƒ/${exif.fNumber}` : null;
|
||||||
|
const shot = [
|
||||||
|
aperture,
|
||||||
|
exif?.exposureTime ? `${exif.exposureTime}s` : null,
|
||||||
|
exif?.focalLength ? `${Math.round(exif.focalLength)}mm` : null,
|
||||||
|
exif?.iso ? `ISO ${exif.iso}` : null,
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5 p-4 text-sm">
|
||||||
|
{exif?.description && <p className="text-muted-foreground">{exif.description}</p>}
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<p className="font-medium">{formatLongDate(asset.localDateTime)}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{formatTime(asset.localDateTime)}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<Section icon={<ImageIcon className="h-4 w-4" />} title={asset.originalFileName}>
|
||||||
|
<Row
|
||||||
|
label={`${asset.width} × ${asset.height}`}
|
||||||
|
value={megapixels >= 0.1 ? `${megapixels.toFixed(1)} MP` : null}
|
||||||
|
/>
|
||||||
|
<Row label="Size" value={formatBytes(exif?.fileSizeInByte)} />
|
||||||
|
<Row label="Type" value={asset.originalMimeType ?? asset.type} />
|
||||||
|
<p className="break-all pt-1 text-xs text-muted-foreground">{asset.originalPath}</p>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{(exif?.make || exif?.model || shot.length > 0) && (
|
||||||
|
<Section icon={<Camera className="h-4 w-4" />} title={[exif?.make, exif?.model].filter(Boolean).join(' ')}>
|
||||||
|
{exif?.lensModel && <p className="text-xs text-muted-foreground">{exif.lensModel}</p>}
|
||||||
|
{shot.length > 0 && <p className="text-xs text-muted-foreground">{shot.join(' · ')}</p>}
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(place || (exif?.latitude != null && exif?.longitude != null)) && (
|
||||||
|
<Section icon={<MapPin className="h-4 w-4" />} title={place || 'Location'}>
|
||||||
|
{exif?.latitude != null && exif?.longitude != null && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{exif.latitude.toFixed(5)}, {exif.longitude.toFixed(5)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{asset.people && asset.people.length > 0 && (
|
||||||
|
<section>
|
||||||
|
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">People</h3>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{asset.people.map((person) => (
|
||||||
|
<Link
|
||||||
|
key={person.id}
|
||||||
|
to={`${photosSectionPath('people')}?${PERSON_PARAM}=${person.id}`}
|
||||||
|
className="w-16 text-center"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={personThumbUrl(person.id, token)}
|
||||||
|
alt=""
|
||||||
|
className="h-16 w-16 rounded-full object-cover ring-1 ring-white/10"
|
||||||
|
/>
|
||||||
|
<span className="mt-1 block truncate text-xs">{person.name || 'Unnamed'}</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type SectionProps = { icon: React.ReactNode; title: string; children?: React.ReactNode };
|
||||||
|
|
||||||
|
const Section = ({ icon, title, children }: SectionProps) => (
|
||||||
|
<section className="flex gap-3">
|
||||||
|
<span className="mt-0.5 shrink-0 text-muted-foreground">{icon}</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate font-medium">{title}</p>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
|
||||||
|
const Row = ({ label, value }: { label: string; value: string | null }) => (
|
||||||
|
<p className="text-xs text-muted-foreground">{value ? `${label} · ${value}` : label}</p>
|
||||||
|
);
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import type { GridAsset } from './shared';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Link, useSearchParams } from 'react-router';
|
||||||
|
import { CheckCircle2, Circle, Heart, Layers, Play } from 'lucide-react';
|
||||||
|
import { ASSET_PARAM, formatDuration, thumbUrl } from './shared';
|
||||||
|
|
||||||
|
// One tile in the justified grid.
|
||||||
|
//
|
||||||
|
// The tile is a real <Link> to `?asset=<id>` — the lightbox is addressable, so a photo can be cmd-clicked
|
||||||
|
// into a tab, linked to, and closed with the back button. The select checkbox is a sibling <button> layered
|
||||||
|
// over it, never nested inside the anchor: a button inside an anchor is invalid HTML and swallows the
|
||||||
|
// keyboard behaviour of both.
|
||||||
|
|
||||||
|
type AssetTileProps = {
|
||||||
|
asset: GridAsset;
|
||||||
|
token: string | null;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
selected: boolean;
|
||||||
|
/** True once anything is selected — the checkbox stops being hover-only and the click selects, not opens. */
|
||||||
|
selecting: boolean;
|
||||||
|
onToggle: (id: string, ev: React.MouseEvent) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AssetTile = ({ asset, token, width, height, selected, selecting, onToggle }: AssetTileProps) => {
|
||||||
|
const [params] = useSearchParams();
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
const duration = formatDuration(asset.duration);
|
||||||
|
|
||||||
|
const href = (() => {
|
||||||
|
const next = new URLSearchParams(params);
|
||||||
|
next.set(ASSET_PARAM, asset.id);
|
||||||
|
return `?${next.toString()}`;
|
||||||
|
})();
|
||||||
|
|
||||||
|
const badges = (
|
||||||
|
<>
|
||||||
|
{asset.isFavorite && <Heart className="absolute bottom-1.5 left-1.5 h-4 w-4 fill-white text-white drop-shadow" />}
|
||||||
|
{asset.stackCount > 1 && (
|
||||||
|
<span className="absolute right-1.5 top-1.5 flex items-center gap-0.5 rounded bg-black/60 px-1 text-[10px] font-medium text-white">
|
||||||
|
<Layers className="h-3 w-3" />
|
||||||
|
{asset.stackCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!asset.isImage && (
|
||||||
|
<span className="absolute bottom-1.5 right-1.5 flex items-center gap-1 rounded bg-black/60 px-1.5 py-0.5 text-[10px] font-medium text-white">
|
||||||
|
<Play className="h-2.5 w-2.5 fill-white" />
|
||||||
|
{duration ?? ''}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const inner = (
|
||||||
|
<>
|
||||||
|
{/* A neutral block until the thumbnail decodes, so the row doesn't flash white on a dark page. */}
|
||||||
|
<div className={`absolute inset-0 bg-white/5 transition-opacity ${loaded ? 'opacity-0' : 'opacity-100'}`} />
|
||||||
|
<img
|
||||||
|
src={thumbUrl(asset.id, token)}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
draggable={false}
|
||||||
|
onLoad={() => setLoaded(true)}
|
||||||
|
className={`h-full w-full object-cover transition-opacity duration-200 ${loaded ? 'opacity-100' : 'opacity-0'}`}
|
||||||
|
/>
|
||||||
|
{badges}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative shrink-0 overflow-hidden rounded-sm" style={{ width, height }}>
|
||||||
|
{selecting ? (
|
||||||
|
// Once a selection exists, a plain click extends it rather than navigating — matching Immich, where
|
||||||
|
// you can rubber-band through a month without the lightbox opening on every tile.
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(ev) => onToggle(asset.id, ev)}
|
||||||
|
className="group absolute inset-0 block h-full w-full cursor-pointer"
|
||||||
|
>
|
||||||
|
{inner}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<Link to={href} className="group absolute inset-0 block h-full w-full">
|
||||||
|
{inner}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(ev) => onToggle(asset.id, ev)}
|
||||||
|
title={selected ? 'Deselect' : 'Select'}
|
||||||
|
className={`absolute left-1.5 top-1.5 rounded-full text-white transition-opacity ${
|
||||||
|
selected ? 'opacity-100' : 'opacity-0 hover:opacity-100 focus-visible:opacity-100'
|
||||||
|
} ${selecting ? 'opacity-100' : ''}`}
|
||||||
|
>
|
||||||
|
{selected ? (
|
||||||
|
<CheckCircle2 className="h-5 w-5 fill-primary text-white" />
|
||||||
|
) : (
|
||||||
|
<Circle className="h-5 w-5 drop-shadow" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{selected && <span className="pointer-events-none absolute inset-0 rounded-sm ring-2 ring-inset ring-primary" />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
import type { Asset } from './shared';
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router';
|
||||||
|
import {
|
||||||
|
Archive,
|
||||||
|
ArchiveRestore,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Download,
|
||||||
|
Heart,
|
||||||
|
Info,
|
||||||
|
Trash2,
|
||||||
|
Undo2,
|
||||||
|
X,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { ASSET_PARAM, originalUrl, thumbUrl, videoUrl } from './shared';
|
||||||
|
import { AssetInfoPanel } from './AssetInfoPanel';
|
||||||
|
import { useAsset } from './usePhotosData';
|
||||||
|
import { useAssetActions } from './useAssetActions';
|
||||||
|
|
||||||
|
// The lightbox. Which asset is open lives in `?asset=<id>`, so it is linkable, survives a reload, and closes
|
||||||
|
// with the browser's back button — the rule from docs/navigation-audit.md applied to the one piece of this
|
||||||
|
// screen that most wants to be a modal with a useState behind it.
|
||||||
|
|
||||||
|
type AssetViewerProps = {
|
||||||
|
/** Every asset currently on screen, in display order — this is what prev/next walk. */
|
||||||
|
ids: string[];
|
||||||
|
token: string | null;
|
||||||
|
/** Trash shows restore/delete-forever instead of archive/delete. */
|
||||||
|
inTrash?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AssetViewer = ({ ids, token, inTrash = false }: AssetViewerProps) => {
|
||||||
|
const [params, setParams] = useSearchParams();
|
||||||
|
const [showInfo, setShowInfo] = useState(false);
|
||||||
|
const openId = params.get(ASSET_PARAM);
|
||||||
|
const { data: asset } = useAsset(openId);
|
||||||
|
|
||||||
|
const index = openId ? ids.indexOf(openId) : -1;
|
||||||
|
const prev = index > 0 ? ids[index - 1] : undefined;
|
||||||
|
const next = index >= 0 && index < ids.length - 1 ? ids[index + 1] : undefined;
|
||||||
|
|
||||||
|
const goTo = useCallback(
|
||||||
|
(id: string | undefined) => {
|
||||||
|
if (!id) return;
|
||||||
|
setParams(
|
||||||
|
(current) => {
|
||||||
|
const nextParams = new URLSearchParams(current);
|
||||||
|
nextParams.set(ASSET_PARAM, id);
|
||||||
|
return nextParams;
|
||||||
|
},
|
||||||
|
{ replace: true },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[setParams],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Replace, not push: stepping through fifty photos shouldn't bury the grid fifty entries deep in history.
|
||||||
|
// Opening the viewer pushed one entry, so one Back still returns to where you were.
|
||||||
|
const close = useCallback(() => {
|
||||||
|
setParams((current) => {
|
||||||
|
const nextParams = new URLSearchParams(current);
|
||||||
|
nextParams.delete(ASSET_PARAM);
|
||||||
|
return nextParams;
|
||||||
|
});
|
||||||
|
}, [setParams]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!openId) return;
|
||||||
|
|
||||||
|
const onKey = (ev: KeyboardEvent) => {
|
||||||
|
if (ev.key === 'Escape') close();
|
||||||
|
else if (ev.key === 'ArrowLeft') goTo(prev);
|
||||||
|
else if (ev.key === 'ArrowRight') goTo(next);
|
||||||
|
else if (ev.key === 'i') setShowInfo((value) => !value);
|
||||||
|
else return;
|
||||||
|
ev.preventDefault();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, [openId, prev, next, close, goTo]);
|
||||||
|
|
||||||
|
if (!openId) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex flex-col bg-black/95">
|
||||||
|
<ViewerHeader
|
||||||
|
asset={asset}
|
||||||
|
id={openId}
|
||||||
|
token={token}
|
||||||
|
inTrash={inTrash}
|
||||||
|
showInfo={showInfo}
|
||||||
|
onToggleInfo={() => setShowInfo((value) => !value)}
|
||||||
|
onClose={close}
|
||||||
|
onAdvance={() => (next ? goTo(next) : close())}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex min-h-0 flex-1">
|
||||||
|
<div className="relative flex min-w-0 flex-1 items-center justify-center">
|
||||||
|
<NavButton side="left" disabled={!prev} onClick={() => goTo(prev)} />
|
||||||
|
|
||||||
|
{asset?.type === 'VIDEO' ? (
|
||||||
|
<video
|
||||||
|
key={openId}
|
||||||
|
src={videoUrl(openId, token)}
|
||||||
|
poster={thumbUrl(openId, token, 'preview')}
|
||||||
|
controls
|
||||||
|
autoPlay
|
||||||
|
className="max-h-full max-w-full"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
// `preview` rather than `original`: originals are frequently 30MB HEIC/RAW the browser may not even
|
||||||
|
// decode, and Immich's own viewer shows the preview for the same reason. Download gets the original.
|
||||||
|
<img
|
||||||
|
key={openId}
|
||||||
|
src={thumbUrl(openId, token, 'preview')}
|
||||||
|
alt={asset?.originalFileName ?? ''}
|
||||||
|
className="max-h-full max-w-full object-contain"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<NavButton side="right" disabled={!next} onClick={() => goTo(next)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showInfo && (
|
||||||
|
<aside className="w-80 shrink-0 overflow-y-auto border-l border-white/10 bg-background">
|
||||||
|
<AssetInfoPanel asset={asset} token={token} />
|
||||||
|
</aside>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type NavButtonProps = { side: 'left' | 'right'; disabled: boolean; onClick: () => void };
|
||||||
|
|
||||||
|
const NavButton = ({ side, disabled, onClick }: NavButtonProps) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={onClick}
|
||||||
|
aria-label={side === 'left' ? 'Previous' : 'Next'}
|
||||||
|
className={`absolute ${side === 'left' ? 'left-2' : 'right-2'} z-10 rounded-full bg-black/40 p-2 text-white transition hover:bg-black/70 disabled:pointer-events-none disabled:opacity-0`}
|
||||||
|
>
|
||||||
|
{side === 'left' ? <ChevronLeft className="h-6 w-6" /> : <ChevronRight className="h-6 w-6" />}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
type ViewerHeaderProps = {
|
||||||
|
asset: Asset | undefined;
|
||||||
|
id: string;
|
||||||
|
token: string | null;
|
||||||
|
inTrash: boolean;
|
||||||
|
showInfo: boolean;
|
||||||
|
onToggleInfo: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
onAdvance: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ViewerHeader = ({ asset, id, token, inTrash, showInfo, onToggleInfo, onClose, onAdvance }: ViewerHeaderProps) => {
|
||||||
|
const { setFavorite, setArchived, trash, restore, deleteForever } = useAssetActions();
|
||||||
|
const archived = asset?.visibility === 'archive';
|
||||||
|
|
||||||
|
// Anything that removes the asset from the current view advances to the next one first — otherwise the
|
||||||
|
// viewer is left pointing at an id that no longer exists in the grid behind it.
|
||||||
|
const removeThen = (run: () => void) => {
|
||||||
|
run();
|
||||||
|
onAdvance();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="flex items-center gap-1 px-2 py-2 text-white">
|
||||||
|
<Button variant="ghost" size="icon" onClick={onClose} title="Close (Esc)" className="text-white">
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<span className="ml-1 min-w-0 flex-1 truncate text-sm text-white/70">{asset?.originalFileName}</span>
|
||||||
|
|
||||||
|
{inTrash ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
title="Restore"
|
||||||
|
className="text-white"
|
||||||
|
onClick={() => removeThen(() => restore.mutate([id]))}
|
||||||
|
>
|
||||||
|
<Undo2 className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
title="Delete permanently"
|
||||||
|
className="text-white"
|
||||||
|
onClick={() => removeThen(() => deleteForever.mutate([id]))}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
title={asset?.isFavorite ? 'Remove from favorites' : 'Favorite'}
|
||||||
|
className="text-white"
|
||||||
|
onClick={() => setFavorite.mutate({ ids: [id], isFavorite: !asset?.isFavorite })}
|
||||||
|
>
|
||||||
|
<Heart className={`h-5 w-5 ${asset?.isFavorite ? 'fill-white' : ''}`} />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
title={archived ? 'Unarchive' : 'Archive'}
|
||||||
|
className="text-white"
|
||||||
|
onClick={() => removeThen(() => setArchived.mutate({ ids: [id], archived: !archived }))}
|
||||||
|
>
|
||||||
|
{archived ? <ArchiveRestore className="h-5 w-5" /> : <Archive className="h-5 w-5" />}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
title="Move to trash"
|
||||||
|
className="text-white"
|
||||||
|
onClick={() => removeThen(() => trash.mutate([id]))}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<a
|
||||||
|
href={originalUrl(id, token)}
|
||||||
|
download={asset?.originalFileName ?? ''}
|
||||||
|
title="Download original"
|
||||||
|
className="inline-flex h-9 w-9 items-center justify-center rounded-md text-white hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<Download className="h-5 w-5" />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
title="Info (i)"
|
||||||
|
className={`text-white ${showInfo ? 'bg-white/15' : ''}`}
|
||||||
|
onClick={onToggleInfo}
|
||||||
|
>
|
||||||
|
<Info className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { Link } from 'react-router';
|
||||||
|
import { useClient } from 'hooks/useClient';
|
||||||
|
import { PERSON_PARAM, QUERY_PARAM, personThumbUrl, photosSectionPath, thumbUrl } from './shared';
|
||||||
|
import { useExplore, usePeople } from './usePhotosData';
|
||||||
|
|
||||||
|
// Explore: the faces row, then whatever Immich has clustered the library by — places and things.
|
||||||
|
//
|
||||||
|
// Immich's own explore tiles link to a structured search (`{"city":"Lisbon"}`). This proxy exposes smart and
|
||||||
|
// metadata search only, so a tile links to a smart search for its label instead. Close in spirit, and honest
|
||||||
|
// about it: a city tile finds photos that LOOK like that city rather than ones tagged with it.
|
||||||
|
|
||||||
|
const SECTION_TITLES: Record<string, string> = {
|
||||||
|
'exifInfo.city': 'Places',
|
||||||
|
'exifInfo.country': 'Countries',
|
||||||
|
'smartInfo.objects': 'Things',
|
||||||
|
'exifInfo.state': 'Regions',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ExploreSection = () => {
|
||||||
|
const { token } = useClient();
|
||||||
|
const { data: explore, isLoading } = useExplore();
|
||||||
|
const { data: people } = usePeople();
|
||||||
|
|
||||||
|
const faces = (people?.people ?? []).filter((person) => person.name).slice(0, 12);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<header className="flex items-center gap-2 border-b px-3 py-2">
|
||||||
|
<h1 className="text-sm font-semibold">Explore</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 space-y-6 overflow-y-auto p-3">
|
||||||
|
{faces.length > 0 && (
|
||||||
|
<section>
|
||||||
|
<div className="mb-2 flex items-baseline gap-2">
|
||||||
|
<h2 className="text-sm font-semibold">People</h2>
|
||||||
|
<Link to={photosSectionPath('people')} className="text-xs text-muted-foreground hover:underline">
|
||||||
|
View all
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-4">
|
||||||
|
{faces.map((person) => (
|
||||||
|
<Link
|
||||||
|
key={person.id}
|
||||||
|
to={`${photosSectionPath('people')}?${PERSON_PARAM}=${person.id}`}
|
||||||
|
className="w-20 text-center"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={personThumbUrl(person.id, token)}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
className="h-20 w-20 rounded-full object-cover ring-1 ring-white/10"
|
||||||
|
/>
|
||||||
|
<span className="mt-1 block truncate text-xs">{person.name}</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLoading && <p className="text-sm text-muted-foreground">Loading…</p>}
|
||||||
|
|
||||||
|
{(explore ?? []).map((section) => (
|
||||||
|
<section key={section.fieldName}>
|
||||||
|
<h2 className="mb-2 text-sm font-semibold">{SECTION_TITLES[section.fieldName] ?? section.fieldName}</h2>
|
||||||
|
<div className="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-3">
|
||||||
|
{section.items.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={`${section.fieldName}:${item.value}`}
|
||||||
|
to={`${photosSectionPath('search')}?${QUERY_PARAM}=${encodeURIComponent(item.value)}`}
|
||||||
|
className="group relative aspect-square overflow-hidden rounded-lg bg-white/5"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={thumbUrl(item.data.id, token)}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
className="h-full w-full object-cover transition group-hover:scale-105"
|
||||||
|
/>
|
||||||
|
<span className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent p-2 text-sm font-medium text-white">
|
||||||
|
{item.value}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{!isLoading && (explore ?? []).length === 0 && faces.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground">Nothing to explore yet.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,371 @@
|
|||||||
|
import type { GeoJSONSource } from 'maplibre-gl';
|
||||||
|
import type { MapFilter } from './usePhotosData';
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router';
|
||||||
|
import { LngLatBounds, Map as MapLibreMap, Marker, NavigationControl } from 'maplibre-gl';
|
||||||
|
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useColorMode } from '@/components/ui/ThemeProvider';
|
||||||
|
import { useClient } from 'hooks/useClient';
|
||||||
|
import { AssetViewer } from './AssetViewer';
|
||||||
|
import { ASSET_PARAM, thumbUrl } from './shared';
|
||||||
|
import { useMapMarkers, useServerConfig } from './usePhotosData';
|
||||||
|
|
||||||
|
// The map, as close to Immich's as the pieces allow.
|
||||||
|
//
|
||||||
|
// Three things are worth knowing before changing this:
|
||||||
|
//
|
||||||
|
// 1. The BASEMAP is the only thing on /photos the browser fetches from outside Officer. Its URL comes from
|
||||||
|
// Immich's own `/server/config` (proxied), but the vector tiles themselves come straight from
|
||||||
|
// tiles.immich.cloud — exactly as Immich's web app does it. Proxying a basemap would mean relaying
|
||||||
|
// thousands of tile requests that contain none of the owner's data, through a sidecar whose job is the
|
||||||
|
// library. The tile server learns which parts of the world are being looked at and nothing else.
|
||||||
|
// 2. CLUSTERING is maplibre's, not ours. A geotagged library is tens of thousands of pins, and the GeoJSON
|
||||||
|
// source does the bucketing on a worker thread. That is also why markers are fetched in one unbucketed
|
||||||
|
// request: a marker is six numbers, and clustering needs all of them at once to be correct.
|
||||||
|
// 3. The photo THUMBNAILS are DOM markers layered over an invisible-by-occlusion circle layer, capped at
|
||||||
|
// THUMB_LIMIT and rebuilt on `idle`. Rendering an image per asset into the GL context would mean
|
||||||
|
// uploading thousands of textures; the browser only ever holds the ones actually on screen.
|
||||||
|
//
|
||||||
|
// maplibre-gl is PINNED TO 5.x on purpose. In 6.0 the tile worker became a separate `maplibre-gl-worker.mjs`
|
||||||
|
// resolved at runtime from `import.meta.url`. Officer bundles the SPA and serves an index.html fallback for
|
||||||
|
// unknown paths, so that request comes back as HTML and the browser refuses it — "non-JavaScript MIME type of
|
||||||
|
// text/html", and no map at all. 5.x inlines the worker as a blob, which needs no serving infrastructure.
|
||||||
|
// Upgrading means either copying maplibre's worker + shared chunks into public/ at build time or calling
|
||||||
|
// `setWorkerUrl()`; do not bump the major without doing one of those.
|
||||||
|
|
||||||
|
const SOURCE = 'photos-assets';
|
||||||
|
const CLUSTER_LAYER = 'photos-clusters';
|
||||||
|
const COUNT_LAYER = 'photos-cluster-count';
|
||||||
|
const POINT_LAYER = 'photos-points';
|
||||||
|
|
||||||
|
/** How many photo thumbnails to draw at once. Past this it is a wall of images, not a map. */
|
||||||
|
const THUMB_LIMIT = 120;
|
||||||
|
|
||||||
|
const ACCENT = '#10b981';
|
||||||
|
|
||||||
|
type PointFeature = {
|
||||||
|
type: 'Feature';
|
||||||
|
geometry: { type: 'Point'; coordinates: [number, number] };
|
||||||
|
properties: { id: string };
|
||||||
|
};
|
||||||
|
type PointCollection = { type: 'FeatureCollection'; features: PointFeature[] };
|
||||||
|
|
||||||
|
type BoolFilterKey = 'isFavorite' | 'isArchived' | 'withPartners' | 'withSharedAlbums';
|
||||||
|
const TOGGLES: { key: BoolFilterKey; label: string; title: string }[] = [
|
||||||
|
{ key: 'isFavorite', label: 'Favorites', title: 'Only favorited photos' },
|
||||||
|
{ key: 'isArchived', label: 'Archived', title: 'Include archived photos' },
|
||||||
|
{ key: 'withPartners', label: 'Partners', title: "Include partners' photos" },
|
||||||
|
{ key: 'withSharedAlbums', label: 'Shared', title: 'Include photos from shared albums' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A marker is a plain DOM button rather than JSX, because maplibre owns the element's position and React must
|
||||||
|
* not re-parent it. Styles are inline for the same reason Tailwind is avoided here: the element is created
|
||||||
|
* outside the render tree, so keeping its appearance next to its construction is the readable choice.
|
||||||
|
*/
|
||||||
|
const makeThumbElement = (id: string, token: string | null, onOpen: (id: string) => void) => {
|
||||||
|
const el = document.createElement('button');
|
||||||
|
el.type = 'button';
|
||||||
|
el.title = 'Open photo';
|
||||||
|
el.style.cssText =
|
||||||
|
'width:46px;height:46px;padding:0;border-radius:9999px;border:2px solid rgba(255,255,255,.85);' +
|
||||||
|
'box-shadow:0 2px 8px rgba(0,0,0,.45);background-color:rgba(0,0,0,.35);background-size:cover;' +
|
||||||
|
'background-position:center;cursor:pointer;display:block';
|
||||||
|
el.style.backgroundImage = `url("${thumbUrl(id, token)}")`;
|
||||||
|
el.addEventListener('click', (ev) => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
onOpen(id);
|
||||||
|
});
|
||||||
|
return el;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MapSection = () => {
|
||||||
|
const { token } = useClient();
|
||||||
|
const { colorMode } = useColorMode();
|
||||||
|
const [, setParams] = useSearchParams();
|
||||||
|
|
||||||
|
const [filter, setFilter] = useState<MapFilter>({ withPartners: true, withSharedAlbums: true });
|
||||||
|
const { data: config } = useServerConfig();
|
||||||
|
const { data: markers, isLoading, error: markersError } = useMapMarkers(filter);
|
||||||
|
|
||||||
|
const styleUrl = colorMode === 'dark' ? config?.mapDarkStyleUrl : config?.mapLightStyleUrl;
|
||||||
|
|
||||||
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const mapRef = useRef<MapLibreMap | null>(null);
|
||||||
|
const thumbsRef = useRef<Marker[]>([]);
|
||||||
|
const shownRef = useRef('');
|
||||||
|
const loadedRef = useRef(false);
|
||||||
|
const fittedRef = useRef(false);
|
||||||
|
|
||||||
|
const [ready, setReady] = useState(false);
|
||||||
|
const [mapError, setMapError] = useState<string | null>(null);
|
||||||
|
const [visibleIds, setVisibleIds] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const open = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
setParams((current) => {
|
||||||
|
const next = new URLSearchParams(current);
|
||||||
|
next.set(ASSET_PARAM, id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[setParams],
|
||||||
|
);
|
||||||
|
|
||||||
|
const data = useMemo<PointCollection>(
|
||||||
|
() => ({
|
||||||
|
type: 'FeatureCollection',
|
||||||
|
features: (markers ?? []).map((marker) => ({
|
||||||
|
type: 'Feature',
|
||||||
|
geometry: { type: 'Point', coordinates: [marker.lon, marker.lat] },
|
||||||
|
properties: { id: marker.id },
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
[markers],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Build the map. Keyed on the style URL, so flipping Officer's light/dark mode rebuilds it against Immich's
|
||||||
|
// matching basemap — cheaper to reason about than swapping the style and re-adding every source by hand.
|
||||||
|
useEffect(() => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container || !styleUrl) return;
|
||||||
|
|
||||||
|
loadedRef.current = false;
|
||||||
|
fittedRef.current = false;
|
||||||
|
setMapError(null);
|
||||||
|
|
||||||
|
const map = new MapLibreMap({
|
||||||
|
container,
|
||||||
|
style: styleUrl,
|
||||||
|
center: [0, 25],
|
||||||
|
zoom: 1.2,
|
||||||
|
attributionControl: { compact: true },
|
||||||
|
});
|
||||||
|
map.addControl(new NavigationControl({ showCompass: false }), 'top-right');
|
||||||
|
|
||||||
|
// Immich's basemap style asks for icons its own sprite sheet doesn't ship — `places_locality` selects
|
||||||
|
// `capital` for capital cities, and none of the four sprite variants contain it (`townspot` is there).
|
||||||
|
// That is upstream's bug, visible in Immich's own web map too, and it costs nothing but a console warning
|
||||||
|
// per tile batch. Handing maplibre a blank keeps the log readable; the label still draws, just iconless.
|
||||||
|
map.on('styleimagemissing', (ev) => {
|
||||||
|
if (!map.hasImage(ev.id)) map.addImage(ev.id, { width: 1, height: 1, data: new Uint8Array(4) });
|
||||||
|
});
|
||||||
|
|
||||||
|
map.on('load', () => {
|
||||||
|
loadedRef.current = true;
|
||||||
|
setReady(true);
|
||||||
|
});
|
||||||
|
// Registering ANY `error` listener turns OFF maplibre's own console logging, so log it back out. Only
|
||||||
|
// pre-load failures are banner-worthy — a single tile 404 after that would make a working map look
|
||||||
|
// broken — but swallowing the rest leaves a blank map above an empty console, which cannot be debugged.
|
||||||
|
map.on('error', (ev) => {
|
||||||
|
console.error('[photos/map]', ev.error ?? ev);
|
||||||
|
if (!loadedRef.current) setMapError(ev.error?.message ?? 'The basemap could not be loaded.');
|
||||||
|
});
|
||||||
|
|
||||||
|
mapRef.current = map;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
thumbsRef.current.forEach((marker) => marker.remove());
|
||||||
|
thumbsRef.current = [];
|
||||||
|
shownRef.current = '';
|
||||||
|
map.remove();
|
||||||
|
mapRef.current = null;
|
||||||
|
setReady(false);
|
||||||
|
};
|
||||||
|
}, [styleUrl]);
|
||||||
|
|
||||||
|
// Feed the markers in, adding the layers the first time and only swapping data after that.
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map || !ready) return;
|
||||||
|
|
||||||
|
const existing = map.getSource(SOURCE) as GeoJSONSource | undefined;
|
||||||
|
if (existing) {
|
||||||
|
existing.setData(data);
|
||||||
|
} else {
|
||||||
|
map.addSource(SOURCE, { type: 'geojson', data, cluster: true, clusterRadius: 60, clusterMaxZoom: 15 });
|
||||||
|
|
||||||
|
map.addLayer({
|
||||||
|
id: CLUSTER_LAYER,
|
||||||
|
type: 'circle',
|
||||||
|
source: SOURCE,
|
||||||
|
filter: ['has', 'point_count'],
|
||||||
|
paint: {
|
||||||
|
'circle-color': ACCENT,
|
||||||
|
'circle-opacity': 0.85,
|
||||||
|
'circle-radius': ['step', ['get', 'point_count'], 16, 10, 20, 100, 26, 1000, 34],
|
||||||
|
'circle-stroke-width': 2,
|
||||||
|
'circle-stroke-color': 'rgba(255,255,255,.75)',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
map.addLayer({
|
||||||
|
id: COUNT_LAYER,
|
||||||
|
type: 'symbol',
|
||||||
|
source: SOURCE,
|
||||||
|
filter: ['has', 'point_count'],
|
||||||
|
// Immich's own style ships this fontstack; anything else would 404 and silently drop the numbers.
|
||||||
|
layout: {
|
||||||
|
'text-field': ['get', 'point_count_abbreviated'],
|
||||||
|
'text-font': ['Noto Sans Medium'],
|
||||||
|
'text-size': 12,
|
||||||
|
},
|
||||||
|
paint: { 'text-color': '#05231b' },
|
||||||
|
});
|
||||||
|
map.addLayer({
|
||||||
|
id: POINT_LAYER,
|
||||||
|
type: 'circle',
|
||||||
|
source: SOURCE,
|
||||||
|
filter: ['!', ['has', 'point_count']],
|
||||||
|
paint: {
|
||||||
|
'circle-color': ACCENT,
|
||||||
|
'circle-radius': 6,
|
||||||
|
'circle-stroke-width': 2,
|
||||||
|
'circle-stroke-color': 'rgba(255,255,255,.8)',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
map.on('click', CLUSTER_LAYER, (ev) => {
|
||||||
|
const feature = ev.features?.[0];
|
||||||
|
const clusterId = feature?.properties?.cluster_id;
|
||||||
|
if (feature?.geometry.type !== 'Point' || typeof clusterId !== 'number') return;
|
||||||
|
const [lon, lat] = feature.geometry.coordinates;
|
||||||
|
const source = map.getSource(SOURCE) as GeoJSONSource | undefined;
|
||||||
|
void source?.getClusterExpansionZoom(clusterId).then((zoom) => map.easeTo({ center: [lon, lat], zoom }));
|
||||||
|
});
|
||||||
|
map.on('click', POINT_LAYER, (ev) => {
|
||||||
|
const id = ev.features?.[0]?.properties?.id;
|
||||||
|
if (typeof id === 'string') open(id);
|
||||||
|
});
|
||||||
|
for (const layer of [CLUSTER_LAYER, POINT_LAYER]) {
|
||||||
|
map.on('mouseenter', layer, () => (map.getCanvas().style.cursor = 'pointer'));
|
||||||
|
map.on('mouseleave', layer, () => (map.getCanvas().style.cursor = ''));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Frame the library once. Re-fitting on every filter change would yank the view out from under someone
|
||||||
|
// who just zoomed into a city and then unticked "archived".
|
||||||
|
if (!fittedRef.current && data.features.length > 0) {
|
||||||
|
const bounds = new LngLatBounds();
|
||||||
|
for (const feature of data.features) bounds.extend(feature.geometry.coordinates);
|
||||||
|
map.fitBounds(bounds, { padding: 64, maxZoom: 12, animate: false });
|
||||||
|
fittedRef.current = true;
|
||||||
|
}
|
||||||
|
}, [ready, data, open]);
|
||||||
|
|
||||||
|
// Swap the thumbnail markers whenever the map settles. `idle` rather than `move`, so a drag costs one rebuild
|
||||||
|
// at the end instead of one per frame.
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map || !ready) return;
|
||||||
|
|
||||||
|
const refresh = () => {
|
||||||
|
if (!map.getLayer(POINT_LAYER)) return;
|
||||||
|
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const picked: { id: string; at: [number, number] }[] = [];
|
||||||
|
for (const feature of map.queryRenderedFeatures({ layers: [POINT_LAYER] })) {
|
||||||
|
const id = feature.properties?.id;
|
||||||
|
if (typeof id !== 'string' || seen.has(id) || feature.geometry.type !== 'Point') continue;
|
||||||
|
const [lon, lat] = feature.geometry.coordinates;
|
||||||
|
if (typeof lon !== 'number' || typeof lat !== 'number') continue;
|
||||||
|
seen.add(id);
|
||||||
|
picked.push({ id, at: [lon, lat] });
|
||||||
|
if (picked.length >= THUMB_LIMIT) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rebuilding identical markers would restart every image fade for no reason.
|
||||||
|
const signature = picked.map((item) => item.id).join(',');
|
||||||
|
if (signature === shownRef.current) return;
|
||||||
|
shownRef.current = signature;
|
||||||
|
|
||||||
|
thumbsRef.current.forEach((marker) => marker.remove());
|
||||||
|
thumbsRef.current = picked.map(({ id, at }) =>
|
||||||
|
new Marker({ element: makeThumbElement(id, token, open) }).setLngLat(at).addTo(map),
|
||||||
|
);
|
||||||
|
setVisibleIds(picked.map((item) => item.id));
|
||||||
|
};
|
||||||
|
|
||||||
|
map.on('idle', refresh);
|
||||||
|
return () => {
|
||||||
|
map.off('idle', refresh);
|
||||||
|
};
|
||||||
|
}, [ready, token, open]);
|
||||||
|
|
||||||
|
const toggle = (key: BoolFilterKey) =>
|
||||||
|
setFilter((current) => ({ ...current, [key]: current[key] ? undefined : true }));
|
||||||
|
|
||||||
|
const count = markers?.length ?? 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<header className="flex flex-wrap items-center gap-2 border-b px-3 py-2">
|
||||||
|
<h1 className="text-sm font-semibold">Map</h1>
|
||||||
|
{/* A failed marker fetch used to look identical to an empty library: the map simply stayed on its
|
||||||
|
opening view. Say so instead — the basemap can be perfectly fine while the pins are missing. */}
|
||||||
|
<span className={`text-xs ${markersError ? 'text-destructive' : 'text-muted-foreground'}`}>
|
||||||
|
{markersError
|
||||||
|
? `Markers failed: ${markersError instanceof Error ? markersError.message : 'request failed'}`
|
||||||
|
: isLoading
|
||||||
|
? 'Loading…'
|
||||||
|
: `${count.toLocaleString()} geotagged`}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1" />
|
||||||
|
{TOGGLES.map(({ key, label, title }) => (
|
||||||
|
<Button
|
||||||
|
key={key}
|
||||||
|
size="sm"
|
||||||
|
variant={filter[key] ? 'default' : 'ghost'}
|
||||||
|
title={title}
|
||||||
|
onClick={() => toggle(key)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="relative min-h-0 flex-1">
|
||||||
|
{/*
|
||||||
|
Sized with h-full/w-full, NOT `absolute inset-0`, and that is not a style preference.
|
||||||
|
|
||||||
|
maplibre puts `position: relative; overflow: hidden` on whatever element it is handed, via its own
|
||||||
|
stylesheet's `.maplibregl-map`. Tailwind 4 emits its utilities inside `@layer utilities`, and an
|
||||||
|
UNLAYERED rule beats a layered one no matter which comes later in the file — so `.maplibregl-map`
|
||||||
|
wins over `.absolute`, `inset-0` stops applying, and the div collapses to height 0. It then clips
|
||||||
|
its own canvas away, which looks exactly like a map that failed to load: the GL context is healthy,
|
||||||
|
tiles download, pixels are drawn, and none of it is ever composited. Nothing logs a thing.
|
||||||
|
|
||||||
|
h-full survives because maplibre's stylesheet sets no height, so there is no conflict to lose.
|
||||||
|
*/}
|
||||||
|
<div ref={containerRef} className="h-full w-full" />
|
||||||
|
|
||||||
|
{!styleUrl && !mapError && (
|
||||||
|
<p className="absolute inset-0 flex items-center justify-center text-sm text-muted-foreground">
|
||||||
|
Loading map…
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mapError && (
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center gap-1 p-6 text-center">
|
||||||
|
<p className="text-sm font-medium">The map could not load</p>
|
||||||
|
<p className="max-w-sm text-xs text-muted-foreground">
|
||||||
|
The basemap is fetched from Immich's tile server, so this needs outbound internet access.
|
||||||
|
{mapError ? ` (${mapError})` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{ready && !isLoading && count === 0 && (
|
||||||
|
<div className="pointer-events-none absolute inset-x-0 top-4 flex justify-center">
|
||||||
|
<p className="rounded-full bg-background/90 px-3 py-1.5 text-xs text-muted-foreground shadow">
|
||||||
|
No geotagged photos match this filter.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AssetViewer ids={visibleIds} token={token} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { useMemo, useRef, useState } from 'react';
|
||||||
|
import { Link, useSearchParams } from 'react-router';
|
||||||
|
import { ArrowLeft } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useClient } from 'hooks/useClient';
|
||||||
|
import { AssetViewer } from './AssetViewer';
|
||||||
|
import { SelectionBar } from './SelectionBar';
|
||||||
|
import { TimelineGrid } from './TimelineGrid';
|
||||||
|
import { PERSON_PARAM, personThumbUrl } from './shared';
|
||||||
|
import { useBucketAssets, usePeople, usePerson, useTimeBuckets } from './usePhotosData';
|
||||||
|
import { useSelection } from './useSelection';
|
||||||
|
|
||||||
|
// Faces. The wall of people, and one person's photos behind `?person=<id>`.
|
||||||
|
//
|
||||||
|
// A person's photos come from the same bucketed timeline endpoint with `personId` set, so a face with 12,000
|
||||||
|
// photos loads the same way the main timeline does rather than as one enormous response.
|
||||||
|
|
||||||
|
export const PeopleSection = () => {
|
||||||
|
const [params] = useSearchParams();
|
||||||
|
const personId = params.get(PERSON_PARAM);
|
||||||
|
return personId ? <PersonDetailView personId={personId} /> : <PeopleListView />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PeopleListView = () => {
|
||||||
|
const { token } = useClient();
|
||||||
|
const { data, isLoading } = usePeople();
|
||||||
|
|
||||||
|
// Immich hides unnamed faces behind a toggle; the named ones are what anyone actually browses by.
|
||||||
|
const [showUnnamed, setShowUnnamed] = useState(false);
|
||||||
|
const people = useMemo(
|
||||||
|
() => (data?.people ?? []).filter((person) => showUnnamed || person.name),
|
||||||
|
[data, showUnnamed],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<header className="flex items-center gap-2 border-b px-3 py-2">
|
||||||
|
<h1 className="text-sm font-semibold">People</h1>
|
||||||
|
<span className="text-xs text-muted-foreground">{people.length}</span>
|
||||||
|
<div className="flex-1" />
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setShowUnnamed((value) => !value)}>
|
||||||
|
{showUnnamed ? 'Hide unnamed' : 'Show unnamed'}
|
||||||
|
</Button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto p-3">
|
||||||
|
{isLoading && <p className="text-sm text-muted-foreground">Loading…</p>}
|
||||||
|
{!isLoading && people.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No people yet — Immich populates this as faces are recognised.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-[repeat(auto-fill,minmax(110px,1fr))] gap-4">
|
||||||
|
{people.map((person) => (
|
||||||
|
<Link key={person.id} to={`?${PERSON_PARAM}=${person.id}`} className="group text-center">
|
||||||
|
<img
|
||||||
|
src={personThumbUrl(person.id, token)}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
className="mx-auto aspect-square w-full rounded-full object-cover ring-1 ring-white/10 transition group-hover:ring-primary"
|
||||||
|
/>
|
||||||
|
<span className="mt-2 block truncate text-sm">{person.name || 'Unnamed'}</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const PersonDetailView = ({ personId }: { personId: string }) => {
|
||||||
|
const { token } = useClient();
|
||||||
|
const [params] = useSearchParams();
|
||||||
|
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const [loaded, setLoaded] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const { data: person } = usePerson(personId);
|
||||||
|
const filter = useMemo(() => ({ personId }), [personId]);
|
||||||
|
const { data: buckets } = useTimeBuckets(filter);
|
||||||
|
const assetsByBucket = useBucketAssets(filter, loaded);
|
||||||
|
|
||||||
|
const orderedIds = useMemo(
|
||||||
|
() => (buckets ?? []).flatMap((bucket) => (assetsByBucket[bucket.timeBucket] ?? []).map((asset) => asset.id)),
|
||||||
|
[buckets, assetsByBucket],
|
||||||
|
);
|
||||||
|
const selection = useSelection(orderedIds);
|
||||||
|
|
||||||
|
const back = (() => {
|
||||||
|
const next = new URLSearchParams(params);
|
||||||
|
next.delete(PERSON_PARAM);
|
||||||
|
const search = next.toString();
|
||||||
|
return search ? `?${search}` : '?';
|
||||||
|
})();
|
||||||
|
|
||||||
|
const total = (buckets ?? []).reduce((sum, bucket) => sum + bucket.count, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
{selection.isSelecting ? (
|
||||||
|
<SelectionBar selection={selection} />
|
||||||
|
) : (
|
||||||
|
<header className="flex items-center gap-2 border-b px-3 py-2">
|
||||||
|
<Button asChild variant="ghost" size="icon" title="Back to people">
|
||||||
|
<Link to={back}>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<img src={personThumbUrl(personId, token)} alt="" className="h-7 w-7 rounded-full object-cover" />
|
||||||
|
<h1 className="truncate text-sm font-semibold">{person?.name || 'Unnamed'}</h1>
|
||||||
|
<span className="text-xs text-muted-foreground">{total.toLocaleString()} items</span>
|
||||||
|
</header>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto px-2 pt-2">
|
||||||
|
<TimelineGrid
|
||||||
|
buckets={buckets ?? []}
|
||||||
|
assetsByBucket={assetsByBucket}
|
||||||
|
onBucketVisible={(bucket) => setLoaded((prev) => (prev.includes(bucket) ? prev : [...prev, bucket]))}
|
||||||
|
token={token}
|
||||||
|
selection={selection}
|
||||||
|
scrollRef={scrollRef}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AssetViewer ids={orderedIds} token={token} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import type { GridAsset } from './shared';
|
||||||
|
import type { Selection } from './useSelection';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { AssetTile } from './AssetTile';
|
||||||
|
import { justifyRows } from './shared';
|
||||||
|
import { useContainerWidth } from './useContainerWidth';
|
||||||
|
|
||||||
|
// A justified grid over a flat list of assets — albums, search results, a person's photos.
|
||||||
|
//
|
||||||
|
// The timeline uses TimelineGrid instead, because it has to lay out months it has not downloaded yet. This one
|
||||||
|
// always has every asset in hand, so it can lay the whole thing out in one pass.
|
||||||
|
|
||||||
|
type PhotoGridProps = {
|
||||||
|
assets: GridAsset[];
|
||||||
|
token: string | null;
|
||||||
|
selection: Selection;
|
||||||
|
rowHeight?: number;
|
||||||
|
gap?: number;
|
||||||
|
empty?: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PhotoGrid = ({ assets, token, selection, rowHeight = 210, gap = 4, empty }: PhotoGridProps) => {
|
||||||
|
const [ref, width] = useContainerWidth<HTMLDivElement>();
|
||||||
|
|
||||||
|
const rows = useMemo(
|
||||||
|
() =>
|
||||||
|
width > 0
|
||||||
|
? justifyRows({ items: assets, ratioOf: (a) => a.ratio, containerWidth: width, targetHeight: rowHeight, gap })
|
||||||
|
: [],
|
||||||
|
[assets, width, rowHeight, gap],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className="w-full">
|
||||||
|
{assets.length === 0 && empty}
|
||||||
|
<div className="flex flex-col" style={{ gap }}>
|
||||||
|
{rows.map((row, index) => (
|
||||||
|
<div key={index} className="flex" style={{ gap }}>
|
||||||
|
{row.tiles.map(({ item, width: w, height: h }) => (
|
||||||
|
<AssetTile
|
||||||
|
key={item.id}
|
||||||
|
asset={item}
|
||||||
|
token={token}
|
||||||
|
width={w}
|
||||||
|
height={h}
|
||||||
|
selected={selection.selected.has(item.id)}
|
||||||
|
selecting={selection.isSelecting}
|
||||||
|
onToggle={(id, ev) => selection.toggle(id, ev)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import type { LucideIcon } from 'lucide-react';
|
||||||
|
import { NavLink } from 'react-router';
|
||||||
|
import { Archive, Compass, Heart, Images, Map as MapIcon, Search, Share2, Trash2, Users } from 'lucide-react';
|
||||||
|
import { PHOTOS_SECTIONS, photosSectionPath, type PhotosSectionId } from './shared';
|
||||||
|
import { useAssetStats, usePhotosHealth } from './usePhotosData';
|
||||||
|
|
||||||
|
// Left panel of the /photos workspace, mirroring Immich's own sidebar: library sections up top, the sharing
|
||||||
|
// and cleanup ones below a divider, and the library size at the bottom.
|
||||||
|
//
|
||||||
|
// Sections are react-router NavLinks to /photos/<section> — active state from the router, cmd-click works.
|
||||||
|
|
||||||
|
const ICONS: Record<PhotosSectionId, LucideIcon> = {
|
||||||
|
timeline: Images,
|
||||||
|
explore: Compass,
|
||||||
|
map: MapIcon,
|
||||||
|
search: Search,
|
||||||
|
albums: Images,
|
||||||
|
people: Users,
|
||||||
|
favorites: Heart,
|
||||||
|
archive: Archive,
|
||||||
|
sharing: Share2,
|
||||||
|
trash: Trash2,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Immich draws the same line: browsing the library, then everything else.
|
||||||
|
const LIBRARY: PhotosSectionId[] = ['timeline', 'explore', 'map', 'search', 'albums', 'people', 'favorites'];
|
||||||
|
|
||||||
|
const ROW = 'group relative flex items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors';
|
||||||
|
|
||||||
|
export const PhotosNav = () => {
|
||||||
|
const { data: stats } = useAssetStats();
|
||||||
|
const { data: health } = usePhotosHealth();
|
||||||
|
|
||||||
|
const group = (ids: PhotosSectionId[]) =>
|
||||||
|
PHOTOS_SECTIONS.filter((section) => ids.includes(section.id)).map(({ id, label }) => {
|
||||||
|
const Icon = ICONS[id];
|
||||||
|
return (
|
||||||
|
<NavLink
|
||||||
|
key={id}
|
||||||
|
to={photosSectionPath(id)}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`${ROW} ${
|
||||||
|
isActive
|
||||||
|
? 'bg-primary/10 font-medium text-primary'
|
||||||
|
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{({ isActive }) => (
|
||||||
|
<>
|
||||||
|
{isActive && (
|
||||||
|
<span className="absolute left-0 top-1/2 h-5 w-1 -translate-y-1/2 rounded-r-full bg-primary" />
|
||||||
|
)}
|
||||||
|
<Icon
|
||||||
|
className={`h-4 w-4 shrink-0 ${isActive ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground'}`}
|
||||||
|
/>
|
||||||
|
{label}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</NavLink>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const rest = PHOTOS_SECTIONS.map((section) => section.id).filter((id) => !LIBRARY.includes(id));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col overflow-y-auto bg-muted/30">
|
||||||
|
<div className="flex items-center gap-3 px-4 py-4">
|
||||||
|
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-emerald-500/15 text-emerald-400 ring-1 ring-black/5">
|
||||||
|
<Images className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-sm font-semibold leading-tight">Photos</div>
|
||||||
|
<div className="truncate text-xs text-muted-foreground">
|
||||||
|
{health?.ok ? (health.version ?? 'connected') : 'not connected'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex flex-col gap-0.5 px-2">{group(LIBRARY)}</nav>
|
||||||
|
<div className="mx-4 my-2 border-t" />
|
||||||
|
<nav className="flex flex-col gap-0.5 px-2 pb-3">{group(rest)}</nav>
|
||||||
|
|
||||||
|
<div className="flex-1" />
|
||||||
|
|
||||||
|
{stats && (
|
||||||
|
<div className="border-t px-4 py-3 text-xs text-muted-foreground">
|
||||||
|
<p>{stats.total.toLocaleString()} items</p>
|
||||||
|
<p>
|
||||||
|
{stats.images.toLocaleString()} photos · {stats.videos.toLocaleString()} videos
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { AlbumsSection } from './AlbumsSection';
|
||||||
|
import { ExploreSection } from './ExploreSection';
|
||||||
|
import { MapSection } from './MapSection';
|
||||||
|
import { PeopleSection } from './PeopleSection';
|
||||||
|
import { SearchSection } from './SearchSection';
|
||||||
|
import { SharingSection } from './SharingSection';
|
||||||
|
import { TimelineSection } from './TimelineSection';
|
||||||
|
import { usePhotosHealth } from './usePhotosData';
|
||||||
|
import { usePhotosSection } from './usePhotosSection';
|
||||||
|
|
||||||
|
// Right panel of the /photos workspace — renders the section named by the URL.
|
||||||
|
//
|
||||||
|
// Timeline, favorites, archive and trash are one component with a filter; the rest are their own.
|
||||||
|
|
||||||
|
export const PhotosView = () => {
|
||||||
|
const section = usePhotosSection();
|
||||||
|
const { data: health, isLoading } = usePhotosHealth();
|
||||||
|
|
||||||
|
// Every section is useless without the upstream, and a wall of empty grids is a worse answer than saying so.
|
||||||
|
if (!isLoading && health && !health.ok) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center gap-1 p-6 text-center">
|
||||||
|
<p className="text-sm font-medium">Photos is not connected</p>
|
||||||
|
<p className="max-w-sm text-xs text-muted-foreground">
|
||||||
|
{health.error ?? 'The photos sidecar could not reach Immich. Check IMMICH_URL and IMMICH_API_KEY.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (section) {
|
||||||
|
case 'explore':
|
||||||
|
return <ExploreSection />;
|
||||||
|
case 'map':
|
||||||
|
return <MapSection />;
|
||||||
|
case 'search':
|
||||||
|
return <SearchSection />;
|
||||||
|
case 'albums':
|
||||||
|
return <AlbumsSection />;
|
||||||
|
case 'people':
|
||||||
|
return <PeopleSection />;
|
||||||
|
case 'sharing':
|
||||||
|
return <SharingSection />;
|
||||||
|
case 'favorites':
|
||||||
|
case 'archive':
|
||||||
|
case 'trash':
|
||||||
|
case 'timeline':
|
||||||
|
default:
|
||||||
|
return <TimelineSection section={section} />;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { Images } from 'lucide-react';
|
||||||
|
import { PHOTOS_SECTIONS } from './shared';
|
||||||
|
import { usePhotosSection } from './usePhotosSection';
|
||||||
|
|
||||||
|
// Panel header for the right (photos-view) panel.
|
||||||
|
|
||||||
|
export const PhotosViewHeader = () => {
|
||||||
|
const section = usePhotosSection();
|
||||||
|
const label = PHOTOS_SECTIONS.find((s) => s.id === section)?.label ?? 'Photos';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Images className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
<span className="flex-1 truncate text-xs font-medium">{label}</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Link, useSearchParams } from 'react-router';
|
||||||
|
import { Search, Sparkles } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { useClient } from 'hooks/useClient';
|
||||||
|
import { AssetViewer } from './AssetViewer';
|
||||||
|
import { PhotoGrid } from './PhotoGrid';
|
||||||
|
import { SelectionBar } from './SelectionBar';
|
||||||
|
import { ALBUM_PARAM, QUERY_PARAM, photosSectionPath, thumbUrl, toGridAsset } from './shared';
|
||||||
|
import { useSearch } from './usePhotosData';
|
||||||
|
import { useSelection } from './useSelection';
|
||||||
|
|
||||||
|
// Search. The query lives in `?q=`, so a search is a link — the same reason the lightbox lives in `?asset=`.
|
||||||
|
//
|
||||||
|
// Smart search is Immich's CLIP model ("red bicycle", "birthday cake") and is the default because it is the
|
||||||
|
// thing Immich has that a file browser does not. The toggle falls back to filename/metadata matching.
|
||||||
|
|
||||||
|
export const SearchSection = () => {
|
||||||
|
const { token } = useClient();
|
||||||
|
const [params, setParams] = useSearchParams();
|
||||||
|
const submitted = params.get(QUERY_PARAM) ?? '';
|
||||||
|
|
||||||
|
const [draft, setDraft] = useState(submitted);
|
||||||
|
const [smart, setSmart] = useState(true);
|
||||||
|
|
||||||
|
// The box follows the URL, so a link into /photos/search?q=… arrives with its own query already typed.
|
||||||
|
useEffect(() => setDraft(submitted), [submitted]);
|
||||||
|
|
||||||
|
const { data, isFetching } = useSearch({ query: submitted, smart, enabled: submitted.length > 0 });
|
||||||
|
|
||||||
|
const assets = useMemo(() => (data?.assets.items ?? []).map(toGridAsset), [data]);
|
||||||
|
const selection = useSelection(assets.map((asset) => asset.id));
|
||||||
|
|
||||||
|
const submit = (ev: React.FormEvent) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
setParams((current) => {
|
||||||
|
const next = new URLSearchParams(current);
|
||||||
|
if (draft.trim()) next.set(QUERY_PARAM, draft.trim());
|
||||||
|
else next.delete(QUERY_PARAM);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
{selection.isSelecting ? (
|
||||||
|
<SelectionBar selection={selection} />
|
||||||
|
) : (
|
||||||
|
<header className="flex items-center gap-2 border-b px-3 py-2">
|
||||||
|
<form onSubmit={submit} className="flex flex-1 items-center gap-2">
|
||||||
|
<Search className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
value={draft}
|
||||||
|
onChange={(ev) => setDraft(ev.target.value)}
|
||||||
|
placeholder={smart ? 'Describe a photo — "sunset over water"' : 'Filename or path'}
|
||||||
|
className="h-8"
|
||||||
|
/>
|
||||||
|
<Button type="submit" size="sm" variant="secondary">
|
||||||
|
Search
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
<Button
|
||||||
|
variant={smart ? 'default' : 'ghost'}
|
||||||
|
size="sm"
|
||||||
|
title="Smart search uses Immich's CLIP model instead of matching filenames"
|
||||||
|
onClick={() => setSmart((value) => !value)}
|
||||||
|
>
|
||||||
|
<Sparkles className="mr-1.5 h-4 w-4" />
|
||||||
|
Smart
|
||||||
|
</Button>
|
||||||
|
</header>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto p-2">
|
||||||
|
{!submitted && <p className="p-2 text-sm text-muted-foreground">Search your library.</p>}
|
||||||
|
{submitted && isFetching && <p className="p-2 text-sm text-muted-foreground">Searching…</p>}
|
||||||
|
|
||||||
|
{data && data.albums.items.length > 0 && (
|
||||||
|
<section className="mb-4">
|
||||||
|
<h2 className="mb-2 px-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">Albums</h2>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{data.albums.items.map((album) => (
|
||||||
|
<Link key={album.id} to={`${photosSectionPath('albums')}?${ALBUM_PARAM}=${album.id}`} className="w-32">
|
||||||
|
<div className="aspect-square overflow-hidden rounded-lg bg-white/5">
|
||||||
|
{album.albumThumbnailAssetId && (
|
||||||
|
<img
|
||||||
|
src={thumbUrl(album.albumThumbnailAssetId, token)}
|
||||||
|
alt=""
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 truncate text-xs">{album.albumName}</p>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{submitted && !isFetching && (
|
||||||
|
<PhotoGrid
|
||||||
|
assets={assets}
|
||||||
|
token={token}
|
||||||
|
selection={selection}
|
||||||
|
empty={<p className="p-2 text-sm text-muted-foreground">No matches.</p>}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AssetViewer ids={assets.map((asset) => asset.id)} token={token} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import type { Selection } from './useSelection';
|
||||||
|
import { Archive, ArchiveRestore, Heart, Plus, Trash2, Undo2, X } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import { useAlbums } from './usePhotosData';
|
||||||
|
import { useAssetActions } from './useAssetActions';
|
||||||
|
|
||||||
|
// The bar that replaces the header once anything is selected — Immich does the same, and it keeps the
|
||||||
|
// destructive buttons off screen entirely until they have something to act on.
|
||||||
|
|
||||||
|
type SelectionBarProps = {
|
||||||
|
selection: Selection;
|
||||||
|
/** Trash swaps the whole action set: restore and delete-forever, not favorite and archive. */
|
||||||
|
inTrash?: boolean;
|
||||||
|
/** When viewing one album, offer "remove from album" alongside the library-wide actions. */
|
||||||
|
albumId?: string;
|
||||||
|
/** Currently in the archive view, so the archive button should read "unarchive". */
|
||||||
|
archived?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SelectionBar = ({ selection, inTrash = false, albumId, archived = false }: SelectionBarProps) => {
|
||||||
|
const { ids, count, clear, selectAll } = selection;
|
||||||
|
const { setFavorite, setArchived, trash, restore, deleteForever, addToAlbum, removeFromAlbum, createAlbum } =
|
||||||
|
useAssetActions();
|
||||||
|
const { data: albums } = useAlbums();
|
||||||
|
|
||||||
|
const run = (action: () => void) => {
|
||||||
|
action();
|
||||||
|
clear();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1 border-b px-2 py-1.5">
|
||||||
|
<Button variant="ghost" size="icon" onClick={clear} title="Clear selection">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<span className="mr-2 text-sm font-medium">{count} selected</span>
|
||||||
|
<Button variant="ghost" size="sm" onClick={selectAll}>
|
||||||
|
Select all
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="flex-1" />
|
||||||
|
|
||||||
|
{inTrash ? (
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => run(() => restore.mutate(ids))}>
|
||||||
|
<Undo2 className="mr-1.5 h-4 w-4" />
|
||||||
|
Restore
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => run(() => deleteForever.mutate(ids))}>
|
||||||
|
<Trash2 className="mr-1.5 h-4 w-4" />
|
||||||
|
Delete permanently
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="sm">
|
||||||
|
<Plus className="mr-1.5 h-4 w-4" />
|
||||||
|
Add to album
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="max-h-80 overflow-y-auto">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
const albumName = window.prompt('New album name');
|
||||||
|
if (albumName) run(() => createAlbum.mutate({ albumName, assetIds: ids }));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
New album
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{albums && albums.length > 0 && <DropdownMenuSeparator />}
|
||||||
|
{albums?.map((album) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={album.id}
|
||||||
|
onClick={() => run(() => addToAlbum.mutate({ albumId: album.id, ids }))}
|
||||||
|
>
|
||||||
|
{album.albumName}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
{albumId && (
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => run(() => removeFromAlbum.mutate({ albumId, ids }))}>
|
||||||
|
Remove from album
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
title="Favorite"
|
||||||
|
onClick={() => run(() => setFavorite.mutate({ ids, isFavorite: true }))}
|
||||||
|
>
|
||||||
|
<Heart className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
title={archived ? 'Unarchive' : 'Archive'}
|
||||||
|
onClick={() => run(() => setArchived.mutate({ ids, archived: !archived }))}
|
||||||
|
>
|
||||||
|
{archived ? <ArchiveRestore className="h-4 w-4" /> : <Archive className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" title="Move to trash" onClick={() => run(() => trash.mutate(ids))}>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { Link } from 'react-router';
|
||||||
|
import { Copy, ImageOff, Link2 } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useClient } from 'hooks/useClient';
|
||||||
|
import { ALBUM_PARAM, formatShortDate, photosSectionPath, thumbUrl } from './shared';
|
||||||
|
import { useAlbums, useSharedLinks } from './usePhotosData';
|
||||||
|
|
||||||
|
// Sharing: shared albums and shared links, read-mostly.
|
||||||
|
//
|
||||||
|
// The link URL is Immich's own public URL, not an Officer route — a shared link is meant to be opened by
|
||||||
|
// someone who has no Officer account, so it must point at the Immich instance directly. The proxy deliberately
|
||||||
|
// never learns that public URL, so this copies the key and the owner pairs it with their own instance address.
|
||||||
|
|
||||||
|
export const SharingSection = () => {
|
||||||
|
const { token } = useClient();
|
||||||
|
const { data: links, isLoading } = useSharedLinks();
|
||||||
|
const { data: albums } = useAlbums();
|
||||||
|
|
||||||
|
const sharedAlbums = (albums ?? []).filter((album) => album.shared || album.hasSharedLink);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<header className="flex items-center gap-2 border-b px-3 py-2">
|
||||||
|
<h1 className="text-sm font-semibold">Sharing</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 space-y-6 overflow-y-auto p-3">
|
||||||
|
<section>
|
||||||
|
<h2 className="mb-2 text-sm font-semibold">Shared albums</h2>
|
||||||
|
{sharedAlbums.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">No shared albums.</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-[repeat(auto-fill,minmax(160px,1fr))] gap-4">
|
||||||
|
{sharedAlbums.map((album) => (
|
||||||
|
<Link key={album.id} to={`${photosSectionPath('albums')}?${ALBUM_PARAM}=${album.id}`}>
|
||||||
|
<div className="aspect-square overflow-hidden rounded-lg bg-white/5">
|
||||||
|
{album.albumThumbnailAssetId ? (
|
||||||
|
<img
|
||||||
|
src={thumbUrl(album.albumThumbnailAssetId, token)}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||||
|
<ImageOff className="h-8 w-8" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 truncate text-sm font-medium">{album.albumName}</p>
|
||||||
|
<p className="truncate text-xs text-muted-foreground">
|
||||||
|
{album.assetCount} items · {album.albumUsers.length} people
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 className="mb-2 text-sm font-semibold">Shared links</h2>
|
||||||
|
{isLoading && <p className="text-sm text-muted-foreground">Loading…</p>}
|
||||||
|
{!isLoading && (links ?? []).length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground">No shared links.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ul className="divide-y rounded-lg border">
|
||||||
|
{(links ?? []).map((link) => (
|
||||||
|
<li key={link.id} className="flex items-center gap-3 px-3 py-2">
|
||||||
|
<Link2 className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm">
|
||||||
|
{link.album?.albumName ?? link.description ?? `${link.assets.length} items`}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{link.expiresAt ? `Expires ${formatShortDate(link.expiresAt)}` : 'No expiry'}
|
||||||
|
{link.allowDownload ? ' · download' : ''}
|
||||||
|
{link.allowUpload ? ' · upload' : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
title="Copy share key — append it to your instance's /share/ URL"
|
||||||
|
onClick={() => {
|
||||||
|
void navigator.clipboard.writeText(link.key);
|
||||||
|
toast.success('Share key copied');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import type { GridAsset, TimeBucket } from './shared';
|
||||||
|
import type { Selection } from './useSelection';
|
||||||
|
import { useEffect, useMemo, useRef } from 'react';
|
||||||
|
import { AssetTile } from './AssetTile';
|
||||||
|
import { estimateBucketHeight, formatBucket, justifyRows } from './shared';
|
||||||
|
import { useContainerWidth } from './useContainerWidth';
|
||||||
|
|
||||||
|
// The month-bucketed timeline.
|
||||||
|
//
|
||||||
|
// Every month in the library is rendered from the moment the screen opens, but only as a correctly-sized empty
|
||||||
|
// box: the bucket index gives a count, and `estimateBucketHeight` turns a count into a height. That is what
|
||||||
|
// makes the scrollbar honest before anything is downloaded — the page is already its true length, so dragging
|
||||||
|
// to the bottom lands on the oldest month instead of on a spinner that then pushes the content away.
|
||||||
|
//
|
||||||
|
// An IntersectionObserver with a tall rootMargin turns a box into a real request just before it reaches the
|
||||||
|
// viewport. Loaded months are never unloaded; React Query holds them and scrolling back up is instant.
|
||||||
|
|
||||||
|
const ROW_HEIGHT = 210;
|
||||||
|
const GAP = 4;
|
||||||
|
|
||||||
|
type TimelineGridProps = {
|
||||||
|
buckets: TimeBucket[];
|
||||||
|
assetsByBucket: Record<string, GridAsset[]>;
|
||||||
|
onBucketVisible: (bucket: string) => void;
|
||||||
|
token: string | null;
|
||||||
|
selection: Selection;
|
||||||
|
/** The scroll container the observer should watch. Panels scroll their own body, not the window. */
|
||||||
|
scrollRef: React.RefObject<HTMLElement | null>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TimelineGrid = ({
|
||||||
|
buckets,
|
||||||
|
assetsByBucket,
|
||||||
|
onBucketVisible,
|
||||||
|
token,
|
||||||
|
selection,
|
||||||
|
scrollRef,
|
||||||
|
}: TimelineGridProps) => {
|
||||||
|
const [ref, width] = useContainerWidth<HTMLDivElement>();
|
||||||
|
const nodes = useRef(new Map<string, HTMLElement>());
|
||||||
|
const notify = useRef(onBucketVisible);
|
||||||
|
notify.current = onBucketVisible;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!width) return;
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
const bucket = entry.target.getAttribute('data-bucket');
|
||||||
|
if (entry.isIntersecting && bucket) notify.current(bucket);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Two viewports of lead time in each direction: enough that a fast scroll rarely outruns the fetch,
|
||||||
|
// small enough that opening the screen doesn't request the entire library.
|
||||||
|
{ root: scrollRef.current ?? null, rootMargin: '200% 0px' },
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const node of nodes.current.values()) observer.observe(node);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [width, buckets, scrollRef]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className="w-full">
|
||||||
|
{buckets.map((bucket) => (
|
||||||
|
<BucketSection
|
||||||
|
key={bucket.timeBucket}
|
||||||
|
bucket={bucket}
|
||||||
|
assets={assetsByBucket[bucket.timeBucket]}
|
||||||
|
width={width}
|
||||||
|
token={token}
|
||||||
|
selection={selection}
|
||||||
|
register={(node) => {
|
||||||
|
if (node) nodes.current.set(bucket.timeBucket, node);
|
||||||
|
else nodes.current.delete(bucket.timeBucket);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type BucketSectionProps = {
|
||||||
|
bucket: TimeBucket;
|
||||||
|
assets: GridAsset[] | undefined;
|
||||||
|
width: number;
|
||||||
|
token: string | null;
|
||||||
|
selection: Selection;
|
||||||
|
register: (node: HTMLElement | null) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const BucketSection = ({ bucket, assets, width, token, selection, register }: BucketSectionProps) => {
|
||||||
|
const rows = useMemo(
|
||||||
|
() =>
|
||||||
|
assets && width > 0
|
||||||
|
? justifyRows({
|
||||||
|
items: assets,
|
||||||
|
ratioOf: (a) => a.ratio,
|
||||||
|
containerWidth: width,
|
||||||
|
targetHeight: ROW_HEIGHT,
|
||||||
|
gap: GAP,
|
||||||
|
})
|
||||||
|
: [],
|
||||||
|
[assets, width],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Until the month arrives, hold its place with the height it will occupy. The estimate uses the same row
|
||||||
|
// height and gap the real layout will, so the jump when it loads is a few pixels rather than a page.
|
||||||
|
const placeholder =
|
||||||
|
!assets && width > 0
|
||||||
|
? estimateBucketHeight({ count: bucket.count, containerWidth: width, rowHeight: ROW_HEIGHT, gap: GAP })
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section ref={register} data-bucket={bucket.timeBucket} className="mb-6">
|
||||||
|
<h2 className="sticky top-0 z-10 -mx-1 mb-2 bg-background/85 px-1 py-1.5 text-sm font-semibold backdrop-blur">
|
||||||
|
{formatBucket(bucket.timeBucket)}
|
||||||
|
<span className="ml-2 text-xs font-normal text-muted-foreground">{bucket.count}</span>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{assets ? (
|
||||||
|
<div className="flex flex-col" style={{ gap: GAP }}>
|
||||||
|
{rows.map((row, index) => (
|
||||||
|
<div key={index} className="flex" style={{ gap: GAP }}>
|
||||||
|
{row.tiles.map(({ item, width: w, height: h }) => (
|
||||||
|
<AssetTile
|
||||||
|
key={item.id}
|
||||||
|
asset={item}
|
||||||
|
token={token}
|
||||||
|
width={w}
|
||||||
|
height={h}
|
||||||
|
selected={selection.selected.has(item.id)}
|
||||||
|
selecting={selection.isSelecting}
|
||||||
|
onToggle={(id, ev) => selection.toggle(id, ev)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ height: placeholder }} className="w-full rounded-sm bg-white/[0.03]" />
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import type { PhotosSectionId } from './shared';
|
||||||
|
import type { TimelineFilter } from './usePhotosData';
|
||||||
|
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||||
|
import { Trash2 } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useClient } from 'hooks/useClient';
|
||||||
|
import { AssetViewer } from './AssetViewer';
|
||||||
|
import { SelectionBar } from './SelectionBar';
|
||||||
|
import { TimelineGrid } from './TimelineGrid';
|
||||||
|
import { useAssetActions } from './useAssetActions';
|
||||||
|
import { useBucketAssets, useTimeBuckets } from './usePhotosData';
|
||||||
|
import { useSelection } from './useSelection';
|
||||||
|
|
||||||
|
// Timeline, Favorites, Archive and Trash are the same screen with a different filter — which is also true in
|
||||||
|
// Immich, and is why the bucket API takes the filter as query params rather than having four endpoints.
|
||||||
|
|
||||||
|
const FILTERS: Partial<Record<PhotosSectionId, TimelineFilter>> = {
|
||||||
|
timeline: { visibility: 'timeline', withPartners: true, withStacked: true },
|
||||||
|
favorites: { isFavorite: true, withStacked: true },
|
||||||
|
archive: { visibility: 'archive', withStacked: true },
|
||||||
|
trash: { isTrashed: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
const TITLES: Partial<Record<PhotosSectionId, string>> = {
|
||||||
|
timeline: 'Photos',
|
||||||
|
favorites: 'Favorites',
|
||||||
|
archive: 'Archive',
|
||||||
|
trash: 'Trash',
|
||||||
|
};
|
||||||
|
|
||||||
|
type TimelineSectionProps = { section: PhotosSectionId };
|
||||||
|
|
||||||
|
export const TimelineSection = ({ section }: TimelineSectionProps) => {
|
||||||
|
const { token } = useClient();
|
||||||
|
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const [loaded, setLoaded] = useState<string[]>([]);
|
||||||
|
const { emptyTrash } = useAssetActions();
|
||||||
|
|
||||||
|
const filter = FILTERS[section] ?? FILTERS.timeline!;
|
||||||
|
const inTrash = section === 'trash';
|
||||||
|
|
||||||
|
const { data: buckets, isLoading } = useTimeBuckets(filter);
|
||||||
|
const assetsByBucket = useBucketAssets(filter, loaded);
|
||||||
|
|
||||||
|
const orderedIds = useMemo(
|
||||||
|
() => (buckets ?? []).flatMap((bucket) => (assetsByBucket[bucket.timeBucket] ?? []).map((asset) => asset.id)),
|
||||||
|
[buckets, assetsByBucket],
|
||||||
|
);
|
||||||
|
const selection = useSelection(orderedIds);
|
||||||
|
|
||||||
|
const onBucketVisible = useCallback((bucket: string) => {
|
||||||
|
setLoaded((prev) => (prev.includes(bucket) ? prev : [...prev, bucket]));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const total = (buckets ?? []).reduce((sum, bucket) => sum + bucket.count, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
{selection.isSelecting ? (
|
||||||
|
<SelectionBar selection={selection} inTrash={inTrash} archived={section === 'archive'} />
|
||||||
|
) : (
|
||||||
|
<header className="flex items-center gap-2 border-b px-3 py-2">
|
||||||
|
<h1 className="text-sm font-semibold">{TITLES[section] ?? 'Photos'}</h1>
|
||||||
|
<span className="text-xs text-muted-foreground">{total.toLocaleString()} items</span>
|
||||||
|
<div className="flex-1" />
|
||||||
|
{inTrash && total > 0 && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => window.confirm(`Permanently delete ${total} items?`) && emptyTrash.mutate()}
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-1.5 h-4 w-4" />
|
||||||
|
Empty trash
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto px-2 pt-2">
|
||||||
|
{isLoading && <p className="p-4 text-sm text-muted-foreground">Loading…</p>}
|
||||||
|
{!isLoading && total === 0 && <p className="p-4 text-sm text-muted-foreground">Nothing here.</p>}
|
||||||
|
<TimelineGrid
|
||||||
|
buckets={buckets ?? []}
|
||||||
|
assetsByBucket={assetsByBucket}
|
||||||
|
onBucketVisible={onBucketVisible}
|
||||||
|
token={token}
|
||||||
|
selection={selection}
|
||||||
|
scrollRef={scrollRef}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AssetViewer ids={orderedIds} token={token} inTrash={inTrash} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { AppRegistryMeta } from '../../AppRegistry';
|
||||||
|
import { PanelLeft, LayoutGrid } from 'lucide-react';
|
||||||
|
import { PhotosNav } from './PhotosNav';
|
||||||
|
import { PhotosView } from './PhotosView';
|
||||||
|
import { PhotosViewHeader } from './PhotosViewHeader';
|
||||||
|
|
||||||
|
export { PhotosNav, PhotosView };
|
||||||
|
|
||||||
|
export const appRegistryMetas: AppRegistryMeta[] = [
|
||||||
|
{ key: 'photos-nav', name: 'Photos', icon: PanelLeft, component: PhotosNav, availableOnPanel: false },
|
||||||
|
{
|
||||||
|
key: 'photos-view',
|
||||||
|
name: 'Photos',
|
||||||
|
icon: LayoutGrid,
|
||||||
|
component: PhotosView,
|
||||||
|
header: PhotosViewHeader,
|
||||||
|
availableOnPanel: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,399 @@
|
|||||||
|
// Shared types, constants and pure helpers for the /photos workspace panels.
|
||||||
|
//
|
||||||
|
// Unlike the Headscale panels — whose sidecar reshapes Headscale's API into Officer's own types — the photos
|
||||||
|
// sidecar is a deliberate pass-through, so these types mirror Immich's wire shapes directly. They were
|
||||||
|
// generated from the LIVE instance's OpenAPI document (`GET /api/spec.json`, Immich 3.0.3), not from memory:
|
||||||
|
// several of them changed shape in 3.0 (`isArchived` became `visibility`, and the timeline bucket response
|
||||||
|
// became columnar), so anything written from an older mental model would be subtly wrong.
|
||||||
|
|
||||||
|
export const PHOTOS_SECTIONS = [
|
||||||
|
{ id: 'timeline', label: 'Photos' },
|
||||||
|
{ id: 'explore', label: 'Explore' },
|
||||||
|
{ id: 'map', label: 'Map' },
|
||||||
|
{ id: 'search', label: 'Search' },
|
||||||
|
{ id: 'albums', label: 'Albums' },
|
||||||
|
{ id: 'people', label: 'People' },
|
||||||
|
{ id: 'favorites', label: 'Favorites' },
|
||||||
|
{ id: 'archive', label: 'Archive' },
|
||||||
|
{ id: 'sharing', label: 'Sharing' },
|
||||||
|
{ id: 'trash', label: 'Trash' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type PhotosSectionId = (typeof PHOTOS_SECTIONS)[number]['id'];
|
||||||
|
|
||||||
|
/** Where /photos lands, and where an unrecognised section redirects to. */
|
||||||
|
export const DEFAULT_PHOTOS_SECTION: PhotosSectionId = 'timeline';
|
||||||
|
|
||||||
|
export const isPhotosSection = (value: string | undefined): value is PhotosSectionId =>
|
||||||
|
PHOTOS_SECTIONS.some((s) => s.id === value);
|
||||||
|
|
||||||
|
/** The one place the section URL is spelled, so nav, guard and deep links cannot drift apart. */
|
||||||
|
export const photosSectionPath = (id: PhotosSectionId) => `/photos/${id}`;
|
||||||
|
|
||||||
|
/** Which asset is open in the lightbox — `?asset=<id>`. */
|
||||||
|
export const ASSET_PARAM = 'asset';
|
||||||
|
/** Which album is open — `?album=<id>`. */
|
||||||
|
export const ALBUM_PARAM = 'album';
|
||||||
|
/** Which person is open — `?person=<id>`. */
|
||||||
|
export const PERSON_PARAM = 'person';
|
||||||
|
/** The search text — `?q=…`. */
|
||||||
|
export const QUERY_PARAM = 'q';
|
||||||
|
|
||||||
|
// ── Wire types ────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type AssetVisibility = 'archive' | 'timeline' | 'hidden' | 'locked';
|
||||||
|
export type AssetType = 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER';
|
||||||
|
|
||||||
|
export type ExifInfo = {
|
||||||
|
make?: string | null;
|
||||||
|
model?: string | null;
|
||||||
|
lensModel?: string | null;
|
||||||
|
exifImageWidth?: number | null;
|
||||||
|
exifImageHeight?: number | null;
|
||||||
|
fileSizeInByte?: number | null;
|
||||||
|
dateTimeOriginal?: string | null;
|
||||||
|
fNumber?: number | null;
|
||||||
|
focalLength?: number | null;
|
||||||
|
iso?: number | null;
|
||||||
|
exposureTime?: string | null;
|
||||||
|
latitude?: number | null;
|
||||||
|
longitude?: number | null;
|
||||||
|
city?: string | null;
|
||||||
|
state?: string | null;
|
||||||
|
country?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
rating?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Asset = {
|
||||||
|
id: string;
|
||||||
|
type: AssetType;
|
||||||
|
thumbhash: string | null;
|
||||||
|
localDateTime: string;
|
||||||
|
fileCreatedAt: string;
|
||||||
|
/** Milliseconds, null for stills. It was an `HH:MM:SS.mmm` string before Immich 3.0 — it is a number now. */
|
||||||
|
duration: number | null;
|
||||||
|
livePhotoVideoId?: string | null;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
ownerId: string;
|
||||||
|
originalFileName: string;
|
||||||
|
originalPath: string;
|
||||||
|
originalMimeType?: string;
|
||||||
|
isFavorite: boolean;
|
||||||
|
isArchived: boolean;
|
||||||
|
isTrashed: boolean;
|
||||||
|
isOffline: boolean;
|
||||||
|
visibility: AssetVisibility;
|
||||||
|
exifInfo?: ExifInfo;
|
||||||
|
people?: Person[];
|
||||||
|
stack?: { id: string; primaryAssetId: string; assetCount: number } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Album = {
|
||||||
|
id: string;
|
||||||
|
albumName: string;
|
||||||
|
description: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
albumThumbnailAssetId: string | null;
|
||||||
|
shared: boolean;
|
||||||
|
hasSharedLink: boolean;
|
||||||
|
assetCount: number;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
albumUsers: { user: { id: string; name: string; email: string } }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** `GET /albums/{id}` returns the album with its assets attached. */
|
||||||
|
export type AlbumDetail = Album & { assets?: Asset[] };
|
||||||
|
|
||||||
|
export type Person = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
birthDate: string | null;
|
||||||
|
thumbnailPath: string;
|
||||||
|
isHidden: boolean;
|
||||||
|
isFavorite?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PeopleResponse = { total: number; hidden: number; people: Person[]; hasNextPage?: boolean };
|
||||||
|
|
||||||
|
export type SearchResponse = {
|
||||||
|
assets: { total: number; count: number; items: Asset[]; nextPage: string | null };
|
||||||
|
albums: { total: number; count: number; items: Album[] };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ExploreItem = { value: string; data: Asset };
|
||||||
|
export type ExploreSection = { fieldName: string; items: ExploreItem[] };
|
||||||
|
|
||||||
|
export type SharedLink = {
|
||||||
|
id: string;
|
||||||
|
key: string;
|
||||||
|
type: string;
|
||||||
|
album?: Album | null;
|
||||||
|
assets: Asset[];
|
||||||
|
description?: string | null;
|
||||||
|
expiresAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
allowDownload: boolean;
|
||||||
|
allowUpload: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TimeBucket = { timeBucket: string; count: number };
|
||||||
|
|
||||||
|
/** `GET /map/markers` — one row per geotagged asset. Deliberately tiny: the whole library fits in one response. */
|
||||||
|
export type MapMarker = { id: string; lat: number; lon: number; city: string | null; country: string | null };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The slice of `GET /server/config` this UI needs.
|
||||||
|
*
|
||||||
|
* The map style is Immich's own, fetched by the browser straight from `tiles.immich.cloud` — the same place
|
||||||
|
* Immich's web app gets it, and the only part of /photos that talks to anything other than Officer. It is not
|
||||||
|
* proxied because a vector basemap is thousands of tile requests that have nothing to do with the library.
|
||||||
|
*/
|
||||||
|
export type ServerConfig = { mapDarkStyleUrl: string; mapLightStyleUrl: string; version?: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `GET /timeline/bucket` is COLUMNAR — parallel arrays, not a list of objects. Immich went this way in 1.133
|
||||||
|
* because a month of assets is tens of thousands of near-identical objects and the array-of-arrays form is a
|
||||||
|
* fraction of the JSON. Zip it with `bucketAssets()` before touching it.
|
||||||
|
*/
|
||||||
|
export type TimeBucketColumns = {
|
||||||
|
id: string[];
|
||||||
|
ownerId: string[];
|
||||||
|
ratio: number[];
|
||||||
|
isFavorite: boolean[];
|
||||||
|
visibility: AssetVisibility[];
|
||||||
|
isTrashed: boolean[];
|
||||||
|
isImage: boolean[];
|
||||||
|
thumbhash: (string | null)[];
|
||||||
|
createdAt: string[];
|
||||||
|
fileCreatedAt: string[];
|
||||||
|
localOffsetHours: number[];
|
||||||
|
/** Milliseconds, null for stills — same units as `Asset.duration`. */
|
||||||
|
duration: (number | null)[];
|
||||||
|
stack?: (string[] | null)[];
|
||||||
|
projectionType: (string | null)[];
|
||||||
|
livePhotoVideoId: (string | null)[];
|
||||||
|
city?: (string | null)[];
|
||||||
|
country?: (string | null)[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The minimum a tile needs to draw itself. Both the columnar timeline and a full Asset reduce to this. */
|
||||||
|
export type GridAsset = {
|
||||||
|
id: string;
|
||||||
|
/** width / height. The whole point of the columnar payload: layout without loading a single image. */
|
||||||
|
ratio: number;
|
||||||
|
isFavorite: boolean;
|
||||||
|
isImage: boolean;
|
||||||
|
isTrashed: boolean;
|
||||||
|
/** Milliseconds, null for stills. */
|
||||||
|
duration: number | null;
|
||||||
|
livePhotoVideoId: string | null;
|
||||||
|
fileCreatedAt: string;
|
||||||
|
stackCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function bucketAssets(cols: TimeBucketColumns): GridAsset[] {
|
||||||
|
return cols.id.map((id, i) => ({
|
||||||
|
id,
|
||||||
|
ratio: cols.ratio[i] || 1,
|
||||||
|
isFavorite: cols.isFavorite[i] ?? false,
|
||||||
|
isImage: cols.isImage[i] ?? true,
|
||||||
|
isTrashed: cols.isTrashed[i] ?? false,
|
||||||
|
duration: cols.duration?.[i] ?? null,
|
||||||
|
livePhotoVideoId: cols.livePhotoVideoId?.[i] ?? null,
|
||||||
|
fileCreatedAt: cols.fileCreatedAt[i] ?? '',
|
||||||
|
// stack is [stackId, assetCount] when present.
|
||||||
|
stackCount: Number(cols.stack?.[i]?.[1] ?? 0),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const toGridAsset = (a: Asset): GridAsset => ({
|
||||||
|
id: a.id,
|
||||||
|
ratio: a.height ? a.width / a.height : 1,
|
||||||
|
isFavorite: a.isFavorite,
|
||||||
|
isImage: a.type === 'IMAGE',
|
||||||
|
isTrashed: a.isTrashed,
|
||||||
|
duration: a.duration ?? null,
|
||||||
|
livePhotoVideoId: a.livePhotoVideoId ?? null,
|
||||||
|
fileCreatedAt: a.fileCreatedAt,
|
||||||
|
stackCount: a.stack?.assetCount ?? 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Media URLs ────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// An <img> cannot send an Authorization header, so media URLs carry `?token=` instead — the same escape
|
||||||
|
// hatch userMiddleware already supports for <audio>/<video> and the one the music app uses for cover art.
|
||||||
|
// The photos sidecar strips the token before forwarding, so it never reaches Immich or its access log.
|
||||||
|
|
||||||
|
const withToken = (url: string, token: string | null) =>
|
||||||
|
token ? `${url}${url.includes('?') ? '&' : '?'}token=${encodeURIComponent(token)}` : url;
|
||||||
|
|
||||||
|
export const PHOTOS_API = '/api/photos/_officer';
|
||||||
|
|
||||||
|
/** `thumbnail` is the grid tile (~250px); `preview` is the lightbox; `fullsize` is the decoded original. */
|
||||||
|
export const thumbUrl = (id: string, token: string | null, size: 'thumbnail' | 'preview' | 'fullsize' = 'thumbnail') =>
|
||||||
|
withToken(`${PHOTOS_API}/assets/${id}/thumbnail?size=${size}`, token);
|
||||||
|
|
||||||
|
export const originalUrl = (id: string, token: string | null) =>
|
||||||
|
withToken(`${PHOTOS_API}/assets/${id}/original`, token);
|
||||||
|
|
||||||
|
export const videoUrl = (id: string, token: string | null) =>
|
||||||
|
withToken(`${PHOTOS_API}/assets/${id}/video/playback`, token);
|
||||||
|
|
||||||
|
export const personThumbUrl = (id: string, token: string | null) =>
|
||||||
|
withToken(`${PHOTOS_API}/people/${id}/thumbnail`, token);
|
||||||
|
|
||||||
|
// ── Formatting ────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const formatBytes = (bytes: number | null | undefined): string => {
|
||||||
|
if (!bytes) return '—';
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
let value = bytes;
|
||||||
|
let unit = 0;
|
||||||
|
while (value >= 1024 && unit < units.length - 1) {
|
||||||
|
value /= 1024;
|
||||||
|
unit += 1;
|
||||||
|
}
|
||||||
|
return `${value < 10 && unit > 0 ? value.toFixed(1) : Math.round(value)} ${units[unit]}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Milliseconds → the grid badge's `M:SS` (or `H:MM:SS` when it's long).
|
||||||
|
*
|
||||||
|
* Immich 3.0 sends durations as an integer count of milliseconds. Older versions sent an `HH:MM:SS.mmm`
|
||||||
|
* string, which is worth knowing because every stale example on the internet still shows that form — and
|
||||||
|
* `'…'.split(':')` on a number is a render-time TypeError, not a wrong label.
|
||||||
|
*/
|
||||||
|
export const formatDuration = (ms: number | null | undefined): string | null => {
|
||||||
|
if (!ms || !Number.isFinite(ms)) return null;
|
||||||
|
const total = Math.floor(ms / 1000);
|
||||||
|
if (total <= 0) return null;
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
const hours = Math.floor(total / 3600);
|
||||||
|
const minutes = Math.floor((total % 3600) / 60);
|
||||||
|
const seconds = total % 60;
|
||||||
|
return hours > 0 ? `${hours}:${pad(minutes)}:${pad(seconds)}` : `${minutes}:${pad(seconds)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MONTH_YEAR = new Intl.DateTimeFormat(undefined, { month: 'long', year: 'numeric' });
|
||||||
|
const LONG_DATE = new Intl.DateTimeFormat(undefined, {
|
||||||
|
weekday: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
const SHORT_DATE = new Intl.DateTimeFormat(undefined, { day: 'numeric', month: 'short', year: 'numeric' });
|
||||||
|
const TIME = new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit' });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `2026-08-01` → `August 2026`.
|
||||||
|
*
|
||||||
|
* Parsed by hand, not with `new Date(bucket)`: a bare `YYYY-MM-DD` is read as UTC midnight, so formatting it
|
||||||
|
* in a timezone west of UTC lands on the last day of the PREVIOUS month and the header reads July.
|
||||||
|
*/
|
||||||
|
export const formatBucket = (bucket: string): string => {
|
||||||
|
const [year, month] = bucket.split('-').map(Number);
|
||||||
|
if (!year || !month) return bucket;
|
||||||
|
return MONTH_YEAR.format(new Date(year, month - 1, 1));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatLongDate = (iso: string | null | undefined): string => {
|
||||||
|
if (!iso) return '—';
|
||||||
|
const date = new Date(iso);
|
||||||
|
return Number.isNaN(date.getTime()) ? '—' : LONG_DATE.format(date);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatShortDate = (iso: string | null | undefined): string => {
|
||||||
|
if (!iso) return '—';
|
||||||
|
const date = new Date(iso);
|
||||||
|
return Number.isNaN(date.getTime()) ? '—' : SHORT_DATE.format(date);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatTime = (iso: string | null | undefined): string => {
|
||||||
|
if (!iso) return '';
|
||||||
|
const date = new Date(iso);
|
||||||
|
return Number.isNaN(date.getTime()) ? '' : TIME.format(date);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** `2020-01-05` + `2020-03-11` → `5 Jan 2020 – 11 Mar 2020`, collapsing to one when they match. */
|
||||||
|
export const formatDateRange = (start?: string, end?: string): string | null => {
|
||||||
|
if (!start && !end) return null;
|
||||||
|
const a = start ? formatShortDate(start) : null;
|
||||||
|
const b = end ? formatShortDate(end) : null;
|
||||||
|
if (a && b && a !== b) return `${a} – ${b}`;
|
||||||
|
return a ?? b;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Justified rows ────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type LaidOutTile<T> = { item: T; width: number; height: number };
|
||||||
|
export type LaidOutRow<T> = { tiles: LaidOutTile<T>[]; height: number };
|
||||||
|
|
||||||
|
type JustifyParams<T> = {
|
||||||
|
items: T[];
|
||||||
|
ratioOf: (item: T) => number;
|
||||||
|
containerWidth: number;
|
||||||
|
targetHeight: number;
|
||||||
|
gap: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immich's signature layout: variable-width tiles packed into rows of equal height, each row filling the
|
||||||
|
* container exactly, so nothing is cropped and there are no ragged gaps.
|
||||||
|
*
|
||||||
|
* Greedy — take tiles at the target height until the row overflows, then scale the row down to fit. The last
|
||||||
|
* row is left at the target height rather than stretched, because stretching four holiday photos across a
|
||||||
|
* 2000px viewport makes them enormous.
|
||||||
|
*/
|
||||||
|
export function justifyRows<T>({
|
||||||
|
items,
|
||||||
|
ratioOf,
|
||||||
|
containerWidth,
|
||||||
|
targetHeight,
|
||||||
|
gap,
|
||||||
|
}: JustifyParams<T>): LaidOutRow<T>[] {
|
||||||
|
if (containerWidth <= 0 || items.length === 0) return [];
|
||||||
|
|
||||||
|
// A panorama can be wider than the container on its own; clamp so one asset can't own a 12-wide row. The
|
||||||
|
// SAME clamped value has to drive both the row's ratio sum and the tile's width, or the row is scaled to fit
|
||||||
|
// a total it then overflows.
|
||||||
|
const clampRatio = (item: T) => Math.min(Math.max(ratioOf(item) || 1, 0.2), 5);
|
||||||
|
|
||||||
|
const rows: LaidOutRow<T>[] = [];
|
||||||
|
let current: T[] = [];
|
||||||
|
let ratioSum = 0;
|
||||||
|
|
||||||
|
const flush = (stretch: boolean) => {
|
||||||
|
if (current.length === 0) return;
|
||||||
|
const available = containerWidth - gap * (current.length - 1);
|
||||||
|
const height = stretch ? available / ratioSum : targetHeight;
|
||||||
|
rows.push({
|
||||||
|
height,
|
||||||
|
tiles: current.map((item) => ({ item, width: Math.max(1, clampRatio(item) * height), height })),
|
||||||
|
});
|
||||||
|
current = [];
|
||||||
|
ratioSum = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const ratio = clampRatio(item);
|
||||||
|
current.push(item);
|
||||||
|
ratioSum += ratio;
|
||||||
|
const width = ratioSum * targetHeight + gap * (current.length - 1);
|
||||||
|
if (width >= containerWidth) flush(true);
|
||||||
|
}
|
||||||
|
flush(false);
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Estimated pixel height of an unloaded bucket, so the scrollbar is roughly honest before it loads. */
|
||||||
|
type EstimateParams = { count: number; containerWidth: number; rowHeight: number; gap: number };
|
||||||
|
export const estimateBucketHeight = ({ count, containerWidth, rowHeight, gap }: EstimateParams): number => {
|
||||||
|
const perRow = Math.max(1, Math.round(containerWidth / (rowHeight * 1.35)));
|
||||||
|
const rows = Math.ceil(count / perRow);
|
||||||
|
return rows * (rowHeight + gap);
|
||||||
|
};
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import type { Album } from './shared';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useClient } from 'hooks/useClient';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
// Every mutation the /photos panels can perform, in one place.
|
||||||
|
//
|
||||||
|
// All of them are bulk-shaped even when the UI is acting on a single tile: Immich's own API is
|
||||||
|
// `PUT /assets {ids, …}` / `DELETE /assets {ids}`, and having one code path means the selection bar and the
|
||||||
|
// lightbox's single-asset buttons cannot drift into behaving differently.
|
||||||
|
//
|
||||||
|
// Invalidation is broad — the whole ['photos'] key. Favoriting an asset changes the favorites bucket index,
|
||||||
|
// the timeline tile, the album cover and the asset detail; the queries are cheap and correctness is worth
|
||||||
|
// more than a surgical cache patch.
|
||||||
|
|
||||||
|
export function useAssetActions() {
|
||||||
|
const { put, post, patch, delete: del } = useClient();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const invalidate = () => qc.invalidateQueries({ queryKey: ['photos'] });
|
||||||
|
|
||||||
|
const fail = (verb: string) => (err: unknown) =>
|
||||||
|
toast.error(`Could not ${verb}: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
|
||||||
|
const setFavorite = useMutation({
|
||||||
|
mutationFn: ({ ids, isFavorite }: { ids: string[]; isFavorite: boolean }) =>
|
||||||
|
put('/photos/_officer/assets', { ids, isFavorite }),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
onError: fail('update favorites'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const setArchived = useMutation({
|
||||||
|
mutationFn: ({ ids, archived }: { ids: string[]; archived: boolean }) =>
|
||||||
|
put('/photos/_officer/assets', { ids, visibility: archived ? 'archive' : 'timeline' }),
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidate();
|
||||||
|
toast.success('Moved');
|
||||||
|
},
|
||||||
|
onError: fail('archive'),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Immich's delete is a move to trash unless `force`. Keep those as two named actions rather than one with a
|
||||||
|
// boolean at the call site — "delete" and "delete permanently" should not look alike in the code either.
|
||||||
|
const trash = useMutation({
|
||||||
|
mutationFn: (ids: string[]) => del('/photos/_officer/assets', { ids }),
|
||||||
|
onSuccess: (_data, ids) => {
|
||||||
|
invalidate();
|
||||||
|
toast.success(`${ids.length} moved to trash`);
|
||||||
|
},
|
||||||
|
onError: fail('delete'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteForever = useMutation({
|
||||||
|
mutationFn: (ids: string[]) => del('/photos/_officer/assets', { ids, force: true }),
|
||||||
|
onSuccess: (_data, ids) => {
|
||||||
|
invalidate();
|
||||||
|
toast.success(`${ids.length} permanently deleted`);
|
||||||
|
},
|
||||||
|
onError: fail('delete permanently'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const restore = useMutation({
|
||||||
|
mutationFn: (ids: string[]) => post('/photos/_officer/trash/restore/assets', { ids }),
|
||||||
|
onSuccess: (_data, ids) => {
|
||||||
|
invalidate();
|
||||||
|
toast.success(`${ids.length} restored`);
|
||||||
|
},
|
||||||
|
onError: fail('restore'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const emptyTrash = useMutation({
|
||||||
|
mutationFn: () => post('/photos/_officer/trash/empty'),
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidate();
|
||||||
|
toast.success('Trash emptied');
|
||||||
|
},
|
||||||
|
onError: fail('empty the trash'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const addToAlbum = useMutation({
|
||||||
|
mutationFn: ({ albumId, ids }: { albumId: string; ids: string[] }) =>
|
||||||
|
put(`/photos/_officer/albums/${albumId}/assets`, { ids }),
|
||||||
|
onSuccess: (_data, { ids }) => {
|
||||||
|
invalidate();
|
||||||
|
toast.success(`${ids.length} added to album`);
|
||||||
|
},
|
||||||
|
onError: fail('add to the album'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const removeFromAlbum = useMutation({
|
||||||
|
mutationFn: ({ albumId, ids }: { albumId: string; ids: string[] }) =>
|
||||||
|
del(`/photos/_officer/albums/${albumId}/assets`, { ids }),
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidate();
|
||||||
|
toast.success('Removed from album');
|
||||||
|
},
|
||||||
|
onError: fail('remove from the album'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createAlbum = useMutation({
|
||||||
|
mutationFn: ({ albumName, assetIds }: { albumName: string; assetIds?: string[] }) =>
|
||||||
|
post<Album>('/photos/_officer/albums', { albumName, assetIds: assetIds ?? [] }),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
onError: fail('create the album'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteAlbum = useMutation({
|
||||||
|
mutationFn: (albumId: string) => del(`/photos/_officer/albums/${albumId}`),
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidate();
|
||||||
|
toast.success('Album deleted');
|
||||||
|
},
|
||||||
|
onError: fail('delete the album'),
|
||||||
|
});
|
||||||
|
|
||||||
|
// PATCH, not PUT: on Immich, PUT /albums/{id}/assets means "add assets" — the album itself is patched.
|
||||||
|
const renameAlbum = useMutation({
|
||||||
|
mutationFn: ({ albumId, albumName }: { albumId: string; albumName: string }) =>
|
||||||
|
patch(`/photos/_officer/albums/${albumId}`, { albumName }),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
onError: fail('rename the album'),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
setFavorite,
|
||||||
|
setArchived,
|
||||||
|
trash,
|
||||||
|
deleteForever,
|
||||||
|
restore,
|
||||||
|
emptyTrash,
|
||||||
|
addToAlbum,
|
||||||
|
removeFromAlbum,
|
||||||
|
createAlbum,
|
||||||
|
deleteAlbum,
|
||||||
|
renameAlbum,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { useLayoutEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Measure an element's inner width and keep it current.
|
||||||
|
*
|
||||||
|
* The justified grid needs a real pixel width before it can lay anything out, and panels here are resized by
|
||||||
|
* dragging a splitter — a width read once on mount is wrong within seconds. ResizeObserver rather than a
|
||||||
|
* window resize listener for exactly that reason: the window never changes size, the panel does.
|
||||||
|
*/
|
||||||
|
export function useContainerWidth<T extends HTMLElement>() {
|
||||||
|
const ref = useRef<T | null>(null);
|
||||||
|
const [width, setWidth] = useState(0);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
setWidth(el.clientWidth);
|
||||||
|
const observer = new ResizeObserver((entries) => {
|
||||||
|
const next = entries[0]?.contentRect.width ?? 0;
|
||||||
|
setWidth((prev) => (Math.abs(prev - next) < 1 ? prev : next));
|
||||||
|
});
|
||||||
|
observer.observe(el);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return [ref, width] as const;
|
||||||
|
}
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
import type {
|
||||||
|
Album,
|
||||||
|
AlbumDetail,
|
||||||
|
Asset,
|
||||||
|
ExploreSection,
|
||||||
|
GridAsset,
|
||||||
|
MapMarker,
|
||||||
|
PeopleResponse,
|
||||||
|
Person,
|
||||||
|
SearchResponse,
|
||||||
|
ServerConfig,
|
||||||
|
SharedLink,
|
||||||
|
TimeBucket,
|
||||||
|
TimeBucketColumns,
|
||||||
|
} from './shared';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useQueries, useQuery } from '@tanstack/react-query';
|
||||||
|
import { useClient } from 'hooks/useClient';
|
||||||
|
import { bucketAssets } from './shared';
|
||||||
|
|
||||||
|
// Reads for the /photos panels. Everything goes through /api/photos/_officer/*, which is the auth proxy in
|
||||||
|
// front of the officer-photos sidecar — the browser never learns the Immich URL or its API key.
|
||||||
|
//
|
||||||
|
// The timeline is two queries, not one: a cheap bucket INDEX (one row per month) and a per-bucket fetch the
|
||||||
|
// grid triggers as you scroll. Immich's own web app works this way, and it is the only reason a library of
|
||||||
|
// 100k assets can render a correct scrollbar without downloading 100k rows first.
|
||||||
|
|
||||||
|
const KEY = 'photos';
|
||||||
|
|
||||||
|
/** Timeline filters. The same query shape serves the main timeline, favorites, archive, trash and albums. */
|
||||||
|
export type TimelineFilter = {
|
||||||
|
albumId?: string;
|
||||||
|
personId?: string;
|
||||||
|
tagId?: string;
|
||||||
|
isFavorite?: boolean;
|
||||||
|
isTrashed?: boolean;
|
||||||
|
visibility?: 'timeline' | 'archive';
|
||||||
|
withPartners?: boolean;
|
||||||
|
withStacked?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type QueryValues = Record<string, string | number | boolean | undefined>;
|
||||||
|
|
||||||
|
const filterQuery = (filter: QueryValues): string => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
for (const [key, value] of Object.entries(filter)) {
|
||||||
|
if (value !== undefined && value !== null) params.set(key, String(value));
|
||||||
|
}
|
||||||
|
return params.toString();
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useTimeBuckets(filter: TimelineFilter, enabled = true) {
|
||||||
|
const { get } = useClient();
|
||||||
|
const query = filterQuery(filter);
|
||||||
|
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [KEY, 'buckets', query],
|
||||||
|
queryFn: () => get<TimeBucket[]>(`/photos/_officer/timeline/buckets?${query}`),
|
||||||
|
enabled,
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch several buckets at once — the months the grid has actually scrolled into view.
|
||||||
|
*
|
||||||
|
* `useQueries` rather than a loop of `useTimeBucket`, because the number of loaded months grows as you scroll
|
||||||
|
* and hooks cannot be called conditionally. Each bucket keeps its own cache entry, so scrolling back up is
|
||||||
|
* free and a mutation invalidating ['photos'] refetches only the months still mounted. A month that has
|
||||||
|
* already scrolled past does not refetch when you scroll back to it.
|
||||||
|
*/
|
||||||
|
export function useBucketAssets(filter: TimelineFilter, buckets: string[]) {
|
||||||
|
const { get } = useClient();
|
||||||
|
const query = filterQuery(filter);
|
||||||
|
|
||||||
|
const results = useQueries({
|
||||||
|
queries: buckets.map((bucket) => ({
|
||||||
|
queryKey: [KEY, 'bucket', query, bucket],
|
||||||
|
queryFn: () =>
|
||||||
|
get<TimeBucketColumns>(`/photos/_officer/timeline/bucket?${query}&timeBucket=${encodeURIComponent(bucket)}`),
|
||||||
|
staleTime: 5 * 60_000,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
const map: Record<string, GridAsset[]> = {};
|
||||||
|
buckets.forEach((bucket, i) => {
|
||||||
|
const cols = results[i]?.data;
|
||||||
|
if (cols) map[bucket] = bucketAssets(cols);
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
// `results` is a fresh array every render; its identity is meaningless. The data is what matters.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [buckets, results.map((r) => r.dataUpdatedAt).join(',')]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAlbums() {
|
||||||
|
const { get } = useClient();
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [KEY, 'albums'],
|
||||||
|
queryFn: () => get<Album[]>('/photos/_officer/albums'),
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAlbum(id: string | null) {
|
||||||
|
const { get } = useClient();
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [KEY, 'album', id],
|
||||||
|
queryFn: () => get<AlbumDetail>(`/photos/_officer/albums/${id}`),
|
||||||
|
enabled: !!id,
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePeople() {
|
||||||
|
const { get } = useClient();
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [KEY, 'people'],
|
||||||
|
queryFn: () => get<PeopleResponse>('/photos/_officer/people?withHidden=false&size=1000'),
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePerson(id: string | null) {
|
||||||
|
const { get } = useClient();
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [KEY, 'person', id],
|
||||||
|
queryFn: () => get<Person>(`/photos/_officer/people/${id}`),
|
||||||
|
enabled: !!id,
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useExplore() {
|
||||||
|
const { get } = useClient();
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [KEY, 'explore'],
|
||||||
|
queryFn: () => get<ExploreSection[]>('/photos/_officer/search/explore'),
|
||||||
|
staleTime: 5 * 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSharedLinks() {
|
||||||
|
const { get } = useClient();
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [KEY, 'shared-links'],
|
||||||
|
queryFn: () => get<SharedLink[]>('/photos/_officer/shared-links'),
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAsset(id: string | null) {
|
||||||
|
const { get } = useClient();
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [KEY, 'asset', id],
|
||||||
|
queryFn: () => get<Asset>(`/photos/_officer/assets/${id}`),
|
||||||
|
enabled: !!id,
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search. `smart` is Immich's CLIP search ("a dog on a beach") and is what the box does by default; `metadata`
|
||||||
|
* matches filenames, paths and EXIF text. They take the same filters and return the same envelope, so the mode
|
||||||
|
* is one flag rather than two hooks.
|
||||||
|
*/
|
||||||
|
type SearchParams = { query: string; smart: boolean; enabled: boolean };
|
||||||
|
export function useSearch({ query, smart, enabled }: SearchParams) {
|
||||||
|
const { post } = useClient();
|
||||||
|
const text = query.trim();
|
||||||
|
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [KEY, 'search', smart ? 'smart' : 'metadata', text],
|
||||||
|
queryFn: () =>
|
||||||
|
smart
|
||||||
|
? post<SearchResponse>('/photos/_officer/search/smart', { query: text, size: 250, withExif: true })
|
||||||
|
: post<SearchResponse>('/photos/_officer/search/metadata', {
|
||||||
|
originalFileName: text,
|
||||||
|
size: 250,
|
||||||
|
withExif: true,
|
||||||
|
order: 'desc',
|
||||||
|
}),
|
||||||
|
enabled: enabled && text.length > 0,
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AssetStats = { images: number; videos: number; total: number };
|
||||||
|
|
||||||
|
export function useAssetStats() {
|
||||||
|
const { get } = useClient();
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [KEY, 'stats'],
|
||||||
|
queryFn: () => get<AssetStats>('/photos/_officer/assets/statistics'),
|
||||||
|
staleTime: 5 * 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map pins. `/map/markers` is NOT bucketed — it returns every geotagged asset in one array, because a marker is
|
||||||
|
* six numbers and the whole library is a couple of megabytes at most. Clustering happens in the browser.
|
||||||
|
*
|
||||||
|
* Note `isArchived`, not `visibility`: the map endpoint kept the old boolean flags when the timeline moved to
|
||||||
|
* the `visibility` enum, so this filter deliberately does not match `TimelineFilter`.
|
||||||
|
*/
|
||||||
|
export type MapFilter = {
|
||||||
|
isArchived?: boolean;
|
||||||
|
isFavorite?: boolean;
|
||||||
|
withPartners?: boolean;
|
||||||
|
withSharedAlbums?: boolean;
|
||||||
|
fileCreatedAfter?: string;
|
||||||
|
fileCreatedBefore?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useMapMarkers(filter: MapFilter) {
|
||||||
|
const { get } = useClient();
|
||||||
|
const query = filterQuery(filter);
|
||||||
|
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [KEY, 'map-markers', query],
|
||||||
|
queryFn: () => get<MapMarker[]>(`/photos/_officer/map/markers?${query}`),
|
||||||
|
staleTime: 5 * 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Immich's own map style URLs live in its server config, so the owner's Immich settings pick the basemap. */
|
||||||
|
export function useServerConfig() {
|
||||||
|
const { get } = useClient();
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [KEY, 'server-config'],
|
||||||
|
queryFn: () => get<ServerConfig>('/photos/_officer/server/config'),
|
||||||
|
staleTime: 30 * 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PhotosHealth = { ok: boolean; version?: string | null; user?: string | null; error?: string };
|
||||||
|
|
||||||
|
export function usePhotosHealth() {
|
||||||
|
const { get } = useClient();
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [KEY, 'health'],
|
||||||
|
queryFn: () => get<PhotosHealth>('/photos/_health'),
|
||||||
|
staleTime: 60_000,
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { useParams } from 'react-router';
|
||||||
|
import { DEFAULT_PHOTOS_SECTION, isPhotosSection, type PhotosSectionId } from './shared';
|
||||||
|
|
||||||
|
// The URL names the section; nothing else does. PhotosScreen redirects anything unrecognised, so the fallback
|
||||||
|
// here only covers the instant before that lands.
|
||||||
|
|
||||||
|
export function usePhotosSection(): PhotosSectionId {
|
||||||
|
const { section } = useParams();
|
||||||
|
return isPhotosSection(section) ? section : DEFAULT_PHOTOS_SECTION;
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Multi-select over an ordered list of asset ids.
|
||||||
|
*
|
||||||
|
* Shift-click selects a range, which is why the hook needs the ORDER of everything currently on screen and not
|
||||||
|
* just the set of chosen ids. The order comes from the view, so a timeline that has loaded three months and an
|
||||||
|
* album that has loaded one page both get the same behaviour without either knowing how the other paginates.
|
||||||
|
*/
|
||||||
|
export function useSelection(orderedIds: string[]) {
|
||||||
|
const [selected, setSelected] = useState<ReadonlySet<string>>(new Set());
|
||||||
|
const anchor = useRef<string | null>(null);
|
||||||
|
|
||||||
|
const order = useMemo(() => new Map(orderedIds.map((id, i) => [id, i])), [orderedIds]);
|
||||||
|
|
||||||
|
const toggle = useCallback(
|
||||||
|
(id: string, ev?: { shiftKey?: boolean; preventDefault?: () => void; stopPropagation?: () => void }) => {
|
||||||
|
ev?.preventDefault?.();
|
||||||
|
ev?.stopPropagation?.();
|
||||||
|
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
const from = anchor.current;
|
||||||
|
|
||||||
|
if (ev?.shiftKey && from && order.has(from) && order.has(id)) {
|
||||||
|
const a = order.get(from) ?? 0;
|
||||||
|
const b = order.get(id) ?? 0;
|
||||||
|
for (let i = Math.min(a, b); i <= Math.max(a, b); i++) {
|
||||||
|
const between = orderedIds[i];
|
||||||
|
if (between) next.add(between);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
anchor.current = id;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[order, orderedIds],
|
||||||
|
);
|
||||||
|
|
||||||
|
const clear = useCallback(() => {
|
||||||
|
anchor.current = null;
|
||||||
|
setSelected(new Set());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const selectAll = useCallback(() => setSelected(new Set(orderedIds)), [orderedIds]);
|
||||||
|
|
||||||
|
const ids = useMemo(() => orderedIds.filter((id) => selected.has(id)), [orderedIds, selected]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
selected,
|
||||||
|
/** In document order, so "add to album" preserves what the eye saw rather than insertion order. */
|
||||||
|
ids,
|
||||||
|
count: selected.size,
|
||||||
|
isSelecting: selected.size > 0,
|
||||||
|
toggle,
|
||||||
|
clear,
|
||||||
|
selectAll,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Selection = ReturnType<typeof useSelection>;
|
||||||
@@ -31,6 +31,10 @@ export { CodeEditorView } from './apps/CodeEditor';
|
|||||||
export { DEFAULT_HEADSCALE_SECTION, headscaleSectionPath, isHeadscaleSection } from './apps/Headscale/shared';
|
export { DEFAULT_HEADSCALE_SECTION, headscaleSectionPath, isHeadscaleSection } from './apps/Headscale/shared';
|
||||||
export type { HeadscaleSectionId } from './apps/Headscale/shared';
|
export type { HeadscaleSectionId } from './apps/Headscale/shared';
|
||||||
|
|
||||||
|
// Same for /photos.
|
||||||
|
export { DEFAULT_PHOTOS_SECTION, photosSectionPath, isPhotosSection } from './apps/Photos/shared';
|
||||||
|
export type { PhotosSectionId } from './apps/Photos/shared';
|
||||||
|
|
||||||
// Same for /transmission.
|
// Same for /transmission.
|
||||||
export {
|
export {
|
||||||
DEFAULT_TRANSMISSION_SECTION,
|
DEFAULT_TRANSMISSION_SECTION,
|
||||||
|
|||||||
Reference in New Issue
Block a user