diff --git a/sidecar/handlers_countries.go b/sidecar/handlers_countries.go new file mode 100644 index 0000000..73c2f54 --- /dev/null +++ b/sidecar/handlers_countries.go @@ -0,0 +1,70 @@ +package main + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// PpCountry is the country aggregation row returned to the client: a +// 2-letter ISO 3166-1 code, the user-scoped photo count, and a representative +// thumb hash for the sidebar row. +type PpCountry struct { + Code string `json:"Code"` + PhotoCount int `json:"PhotoCount"` + Thumb string `json:"Thumb"` +} + +// handleCountries aggregates photos.photo_country directly against +// PhotoPrism's DB (no upstream proxy needed — this is a simple GROUP BY) +// and scopes the result to the caller's BasePath, mirroring handleLabels. +// +// Route: GET /api/sidecar/countries (behind requireSession, ppDb != nil) +func handleCountries(ppDb *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + basePath := ctxBasePath(c) + + type countryStat struct { + Code string `gorm:"column:code"` + Cnt int64 `gorm:"column:cnt"` + ThumbHash string `gorm:"column:thumb_hash"` + } + var stats []countryStat + + query := ppDb.Table("photos p"). + Select(`p.photo_country AS code, + COUNT(DISTINCT p.id) AS cnt, + COALESCE(MIN(f.file_hash), '') AS thumb_hash`). + Joins(`LEFT JOIN files f ON f.photo_uid = p.photo_uid + AND f.file_primary = 1 + AND f.file_missing = 0`). + Where("p.deleted_at IS NULL"). + Where("p.photo_country != '' AND p.photo_country != 'zz'") + + if basePath != "" { + prefix := basePath + "/%" + query = query.Where("(p.photo_path = ? OR p.photo_path LIKE ?)", basePath, prefix) + } + + if err := query. + Group("p.photo_country"). + Having("cnt > 0"). + Order("cnt DESC"). + Scan(&stats).Error; err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": "country stats query failed"}) + return + } + + out := make([]PpCountry, 0, len(stats)) + for _, s := range stats { + out = append(out, PpCountry{ + Code: s.Code, + PhotoCount: int(s.Cnt), + Thumb: s.ThumbHash, + }) + } + + c.JSON(http.StatusOK, out) + } +} diff --git a/sidecar/main.go b/sidecar/main.go index 8ef86ab..c0acb91 100644 --- a/sidecar/main.go +++ b/sidecar/main.go @@ -104,6 +104,7 @@ func main() { if ppDb != nil { auth.GET("/labels", handleLabels(pp, ppDb)) auth.GET("/counts", handleScopedCounts(ppDb)) + auth.GET("/countries", handleCountries(ppDb)) } // User-scoped photos — post-filters by BasePath so review/archive diff --git a/web/package-lock.json b/web/package-lock.json index fc3308e..fb63c8a 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -14,7 +14,6 @@ "bits-ui": "^2.18.1", "clsx": "^2.1.1", "lucide-svelte": "^1.0.1", - "maplibre-gl": "^5.24.0", "mode-watcher": "^1.1.0", "svelte-sonner": "^1.1.1", "tailwind-merge": "^3.6.0", @@ -148,110 +147,6 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@mapbox/jsonlint-lines-primitives": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz", - "integrity": "sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/@mapbox/point-geometry": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz", - "integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==", - "license": "ISC" - }, - "node_modules/@mapbox/tiny-sdf": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.2.0.tgz", - "integrity": "sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg==", - "license": "BSD-2-Clause" - }, - "node_modules/@mapbox/unitbezier": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz", - "integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==", - "license": "BSD-2-Clause" - }, - "node_modules/@mapbox/vector-tile": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-2.0.4.tgz", - "integrity": "sha512-AkOLcbgGTdXScosBWwmmD7cDlvOjkg/DetGva26pIRiZPdeJYjYKarIlb4uxVzi6bwHO6EWH82eZ5Nuv4T5DUg==", - "license": "BSD-3-Clause", - "dependencies": { - "@mapbox/point-geometry": "~1.1.0", - "@types/geojson": "^7946.0.16", - "pbf": "^4.0.1" - } - }, - "node_modules/@mapbox/whoots-js": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz", - "integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==", - "license": "ISC", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@maplibre/geojson-vt": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-6.1.0.tgz", - "integrity": "sha512-2eIY4gZxeKIVOZVNkAMb+5NgXhgsMQpOveTQAvnp53LYqHGJZDidk7Ew0Tged9PThidpbS+NFTh0g4zivhPDzQ==", - "license": "ISC", - "dependencies": { - "kdbush": "^4.0.2" - } - }, - "node_modules/@maplibre/maplibre-gl-style-spec": { - "version": "24.8.5", - "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-24.8.5.tgz", - "integrity": "sha512-EzEJmMt6thioRH7GI9LWS7ahXTcAhAPGWCe6oTP2Ps4YnsXOOAfeqx854lZaiDnwURfHmcCKV1mr6oo0i23x6w==", - "license": "ISC", - "dependencies": { - "@mapbox/jsonlint-lines-primitives": "~2.0.2", - "@mapbox/unitbezier": "^0.0.1", - "json-stringify-pretty-compact": "^4.0.0", - "minimist": "^1.2.8", - "quickselect": "^3.0.0", - "tinyqueue": "^3.0.0" - }, - "bin": { - "gl-style-format": "dist/gl-style-format.mjs", - "gl-style-migrate": "dist/gl-style-migrate.mjs", - "gl-style-validate": "dist/gl-style-validate.mjs" - } - }, - "node_modules/@maplibre/mlt": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.1.9.tgz", - "integrity": "sha512-g/tD8EYJB97udq33ipuJ9a4Q7fcbZnTEnUrgnEc/tLMmEL+zaCbR+X5fkDBO2dgpaAMsLH179qE3UXg2N0Nc/g==", - "license": "(MIT OR Apache-2.0)", - "dependencies": { - "@mapbox/point-geometry": "^1.1.0" - } - }, - "node_modules/@maplibre/vt-pbf": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.3.0.tgz", - "integrity": "sha512-jIvp8F5hQCcreqOOpEt42TJMUlsrEcpf/kI1T2v85YrQRV6PPXUcEXUg5karKtH6oh47XJZ4kHu56pUkOuqA7w==", - "license": "MIT", - "dependencies": { - "@mapbox/point-geometry": "^1.1.0", - "@mapbox/vector-tile": "^2.0.4", - "@maplibre/geojson-vt": "^5.0.4", - "@types/geojson": "^7946.0.16", - "@types/supercluster": "^7.1.3", - "pbf": "^4.0.1", - "supercluster": "^8.0.1" - } - }, - "node_modules/@maplibre/vt-pbf/node_modules/@maplibre/geojson-vt": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-5.0.4.tgz", - "integrity": "sha512-KGg9sma45S+stfH9vPCJk1J0lSDLWZgCT9Y8u8qWZJyjFlP8MNP1WGTxIMYJZjDvVT3PDn05kN1C95Sut1HpgQ==", - "license": "ISC" - }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", @@ -998,12 +893,6 @@ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "license": "MIT" }, - "node_modules/@types/geojson": { - "version": "7946.0.16", - "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", - "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", - "license": "MIT" - }, "node_modules/@types/node": { "version": "25.8.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.8.0.tgz", @@ -1014,15 +903,6 @@ "undici-types": ">=7.24.0 <7.24.7" } }, - "node_modules/@types/supercluster": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz", - "integrity": "sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==", - "license": "MIT", - "dependencies": { - "@types/geojson": "*" - } - }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -1248,12 +1128,6 @@ "node": ">= 0.4" } }, - "node_modules/earcut": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz", - "integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==", - "license": "ISC" - }, "node_modules/enhanced-resolve": { "version": "5.21.3", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.3.tgz", @@ -1451,12 +1325,6 @@ "node": ">= 0.4" } }, - "node_modules/gl-matrix": { - "version": "3.4.4", - "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz", - "integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==", - "license": "MIT" - }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -1553,18 +1421,6 @@ "jiti": "lib/jiti-cli.mjs" } }, - "node_modules/json-stringify-pretty-compact": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz", - "integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==", - "license": "MIT" - }, - "node_modules/kdbush": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz", - "integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==", - "license": "ISC" - }, "node_modules/kleur": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", @@ -1879,40 +1735,6 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/maplibre-gl": { - "version": "5.24.0", - "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.24.0.tgz", - "integrity": "sha512-ALyFxgtd5R+65UqZ/++lOqwWcC0SNho9c27fYSyLmG7AfnAul2o46F05aDJGPbFU57wos9dgcIySHs0Xe6ia3A==", - "license": "BSD-3-Clause", - "dependencies": { - "@mapbox/jsonlint-lines-primitives": "^2.0.2", - "@mapbox/point-geometry": "^1.1.0", - "@mapbox/tiny-sdf": "^2.1.0", - "@mapbox/unitbezier": "^0.0.1", - "@mapbox/vector-tile": "^2.0.4", - "@mapbox/whoots-js": "^3.1.0", - "@maplibre/geojson-vt": "^6.1.0", - "@maplibre/maplibre-gl-style-spec": "^24.8.1", - "@maplibre/mlt": "^1.1.8", - "@maplibre/vt-pbf": "^4.3.0", - "@types/geojson": "^7946.0.16", - "earcut": "^3.0.2", - "gl-matrix": "^3.4.4", - "kdbush": "^4.0.2", - "murmurhash-js": "^1.0.0", - "pbf": "^4.0.1", - "potpack": "^2.1.0", - "quickselect": "^3.0.0", - "tinyqueue": "^3.0.0" - }, - "engines": { - "node": ">=16.14.0", - "npm": ">=8.1.0" - }, - "funding": { - "url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1" - } - }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -1952,15 +1774,6 @@ "node": ">= 0.6" } }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/mode-watcher": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/mode-watcher/-/mode-watcher-1.1.0.tgz", @@ -2050,12 +1863,6 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, - "node_modules/murmurhash-js": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz", - "integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==", - "license": "MIT" - }, "node_modules/nanoid": { "version": "3.3.12", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", @@ -2086,18 +1893,6 @@ ], "license": "MIT" }, - "node_modules/pbf": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.1.tgz", - "integrity": "sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==", - "license": "BSD-3-Clause", - "dependencies": { - "resolve-protobuf-schema": "^2.1.0" - }, - "bin": { - "pbf": "bin/pbf" - } - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2147,18 +1942,6 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/potpack": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz", - "integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==", - "license": "ISC" - }, - "node_modules/protocol-buffers-schema": { - "version": "3.6.1", - "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz", - "integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==", - "license": "MIT" - }, "node_modules/proxy-from-env": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", @@ -2168,12 +1951,6 @@ "node": ">=10" } }, - "node_modules/quickselect": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz", - "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==", - "license": "ISC" - }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -2188,15 +1965,6 @@ "url": "https://paulmillr.com/funding/" } }, - "node_modules/resolve-protobuf-schema": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz", - "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==", - "license": "MIT", - "dependencies": { - "protocol-buffers-schema": "^3.3.1" - } - }, "node_modules/rolldown": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz", @@ -2309,15 +2077,6 @@ "inline-style-parser": "0.2.7" } }, - "node_modules/supercluster": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz", - "integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==", - "license": "ISC", - "dependencies": { - "kdbush": "^4.0.2" - } - }, "node_modules/svelte": { "version": "5.55.7", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.7.tgz", @@ -2489,12 +2248,6 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tinyqueue": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz", - "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==", - "license": "ISC" - }, "node_modules/totalist": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", diff --git a/web/package.json b/web/package.json index 90c09a0..5a53a4b 100644 --- a/web/package.json +++ b/web/package.json @@ -30,7 +30,6 @@ "bits-ui": "^2.18.1", "clsx": "^2.1.1", "lucide-svelte": "^1.0.1", - "maplibre-gl": "^5.24.0", "mode-watcher": "^1.1.0", "svelte-sonner": "^1.1.1", "tailwind-merge": "^3.6.0", diff --git a/web/src/lib/components/layout/LeftSidebar.svelte b/web/src/lib/components/layout/LeftSidebar.svelte index ebeba30..4179646 100644 --- a/web/src/lib/components/layout/LeftSidebar.svelte +++ b/web/src/lib/components/layout/LeftSidebar.svelte @@ -214,7 +214,8 @@ keywords: 'Keywords', people: 'People', colors: 'Colors', - ratings: 'Ratings' + ratings: 'Ratings', + countries: 'Countries' }; function isTagCategoryActive(cat: TagCategory): boolean { @@ -381,7 +382,7 @@ // // `getCount` is a getter (not a snapshot) so the badge reads the latest // derived value on every render — the arrays themselves are constant. - // Map and Tags intentionally render without a count badge; the count + // Tags intentionally renders without a count badge; the count // columns inside the TagsBrowserSidebar are the canonical surface for // per-tag totals. Review rolls in the duplicates tabs hosted under // /review — stacks always contributes; cross-folder only contributes @@ -396,10 +397,9 @@ // separate "everything regardless of folder" destination would just // duplicate it for users whose photos live under the root. const views: ViewItem[] = [ - { kind: 'route', href: '/map', label: 'Map', getCount: () => undefined } // Tags is rendered as a bespoke expandable block below the // `views` loop — it has sub-categories (Labels/Keywords/Colors/ - // Ratings) and a chevron, neither of which fits the flat + // Ratings/Countries) and a chevron, neither of which fits the flat // section/route ViewItem shape. Notes lives under that expandable // alongside the tag categories. ]; diff --git a/web/src/lib/components/sidebar/RightSidebar.svelte b/web/src/lib/components/sidebar/RightSidebar.svelte index 61c5e88..39eb49a 100644 --- a/web/src/lib/components/sidebar/RightSidebar.svelte +++ b/web/src/lib/components/sidebar/RightSidebar.svelte @@ -6,7 +6,6 @@ PUT (Details fields need the full body). --> @@ -448,6 +483,69 @@ {/if} {/if} + {:else if category === 'countries'} + {#if countriesQuery.isPending} + + {:else if countriesQuery.isError} + + {:else if filteredCountries.length === 0} + + {:else} +
+ {#each visibleCountries as countryRow (countryRow.Code)} + {@const active = countryRow.Code === selectedValue} + + {/each} + + {#if hasMoreCountries} +

+ Loading more… ({visibleCount} / {filteredCountries.length}) +

+ {/if} +
+ {/if} {:else if category === 'colors'} {#if marksQuery.isPending || marksPoolQuery.isPending}

Loading colors…

diff --git a/web/src/lib/services/photoprism.ts b/web/src/lib/services/photoprism.ts index dbaf096..d8eb382 100644 --- a/web/src/lib/services/photoprism.ts +++ b/web/src/lib/services/photoprism.ts @@ -560,36 +560,19 @@ export async function listFolderCounts(paths: string[]): Promise { - // PhotoPrism's `/geo` returns a GeoJSON FeatureCollection of every - // matching geocoded photo. MapLibre's native clustering handles 50k+ - // points without breaking a sweat (PhotoPrism upstream documents - // 500k); we ask for a generous cap that covers realistic libraries. - const { data } = await http.get('/geo', { - params: { count: 50000, q: q || undefined } - }); - +export async function listCountries(): Promise { + // Self-contained sidecar aggregation (groups photos.photo_country directly, + // no PhotoPrism proxy round-trip) so counts/thumbs are scoped to the + // caller's BasePath the same way /labels and /counts are. + const { data } = await sidecar.get('/api/sidecar/countries'); return data; } diff --git a/web/src/lib/stores/filters.svelte.ts b/web/src/lib/stores/filters.svelte.ts index 33a8dbb..7929df1 100644 --- a/web/src/lib/stores/filters.svelte.ts +++ b/web/src/lib/stores/filters.svelte.ts @@ -17,14 +17,21 @@ export type Section = | 'hidden' | 'heap'; -export type TagCategory = 'labels' | 'keywords' | 'people' | 'colors' | 'ratings'; +export type TagCategory = + | 'labels' + | 'keywords' + | 'people' + | 'colors' + | 'ratings' + | 'countries'; export const TAG_CATEGORIES: readonly TagCategory[] = [ 'labels', 'keywords', 'people', 'colors', - 'ratings' + 'ratings', + 'countries' ] as const; export function isTagCategory(v: unknown): v is TagCategory { @@ -239,6 +246,8 @@ export function filtersToQ(f: FilterState = filters): string { parts.push(`keywords:${quoteIfNeeded(f.tagValue)}`); } else if (f.tagCategory === 'people') { parts.push(`person:${quoteIfNeeded(f.tagValue)}`); + } else if (f.tagCategory === 'countries') { + parts.push(`country:${quoteIfNeeded(f.tagValue)}`); } } if (f.search) parts.push(quoteIfNeeded(f.search)); diff --git a/web/src/lib/utils/countries.ts b/web/src/lib/utils/countries.ts new file mode 100644 index 0000000..c07b47d --- /dev/null +++ b/web/src/lib/utils/countries.ts @@ -0,0 +1,29 @@ +// Country code (ISO 3166-1 alpha-2, lowercase — PhotoPrism's `Country` field +// shape) → display helpers for the Countries tag-browser category. + +let regionNames: Intl.DisplayNames | undefined; +function getRegionNames(): Intl.DisplayNames | undefined { + if (regionNames) return regionNames; + try { + regionNames = new Intl.DisplayNames(['en'], { type: 'region' }); + } catch { + regionNames = undefined; + } + return regionNames; +} + +export function countryName(code: string): string { + if (!code) return code; + const name = getRegionNames()?.of(code.toUpperCase()); + return name ?? code; +} + +const REGIONAL_INDICATOR_OFFSET = 0x1f1a5; // 0x1f1e6 ('A') - 'A'.charCodeAt(0) + +export function countryFlag(code: string): string { + if (!code || code.length !== 2) return ''; + const upper = code.toUpperCase(); + return Array.from(upper) + .map((ch) => String.fromCodePoint(ch.charCodeAt(0) + REGIONAL_INDICATOR_OFFSET)) + .join(''); +} diff --git a/web/src/routes/map/+page.svelte b/web/src/routes/map/+page.svelte deleted file mode 100644 index 958e178..0000000 --- a/web/src/routes/map/+page.svelte +++ /dev/null @@ -1,435 +0,0 @@ - - - - - Map - - {#snippet trailing()} - - {geoQuery.data?.features?.length ?? 0} geotagged - - {/snippet} - - -
- - diff --git a/web/src/routes/tags/[category]/[[value]]/+page.svelte b/web/src/routes/tags/[category]/[[value]]/+page.svelte index 322a22e..592ae8a 100644 --- a/web/src/routes/tags/[category]/[[value]]/+page.svelte +++ b/web/src/routes/tags/[category]/[[value]]/+page.svelte @@ -29,6 +29,7 @@ COLOR_SWATCHES, starLabel } from '$lib/utils/tagGroups'; + import { countryName } from '$lib/utils/countries'; import BulkActionBar from '$lib/components/timeline/BulkActionBar.svelte'; import BulkMetadataSidebar from '$lib/components/sidebar/BulkMetadataSidebar.svelte'; import PhotoGrid from '$lib/components/timeline/PhotoGrid.svelte'; @@ -69,7 +70,10 @@ // label badge of 157 could otherwise drill into 0 photos because the // session is scoped to a folder that has none of them). const useServer = $derived( - category === 'labels' || category === 'keywords' || category === 'people' + category === 'labels' || + category === 'keywords' || + category === 'people' || + category === 'countries' ); const drillQ = $derived( useServer && selectedValue @@ -161,6 +165,7 @@ COLOR_SWATCHES.find((c) => c.key === selectedValue)?.title ?? selectedValue ); } + if (category === 'countries') return countryName(selectedValue); return selectedValue; });