feat(countries): replace Map view with browse-by-country (like Tags)

Removes the maplibre-gl Map view and adds "Countries" as a sixth
TagCategory, reusing the existing /tags/[category]/[[value]] browse
machinery instead of a bespoke map UI. Backed by a new self-contained
sidecar endpoint that aggregates photos.photo_country with BasePath
scoping, mirroring handleLabels/handleScopedCounts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-30 21:25:24 +02:00
parent 74bae78270
commit ba5684d120
12 changed files with 242 additions and 736 deletions

View File

@@ -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.
];

View File

@@ -6,7 +6,6 @@
PUT (Details fields need the full body).
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
import { toast } from 'svelte-sonner';
@@ -16,10 +15,10 @@
Calendar,
File,
Folder,
Globe,
HardDrive,
ImageIcon,
Loader2,
Map as MapIcon,
MapPin,
Star,
Tag,
@@ -43,8 +42,9 @@
import { push as pushUndo } from '$lib/stores/undo.svelte';
import { getMetadataSectionOpen, setMetadataSection } from '$lib/stores/view.svelte';
import { photoNameAndDir, primaryFile, type PpPhoto } from '$lib/types/photoprism';
import { navigateToFolder } from '$lib/stores/filters.svelte';
import { navigateToFolder, navigateToTag } from '$lib/stores/filters.svelte';
import { COLOR_SWATCHES } from '$lib/utils/tagGroups';
import { countryName } from '$lib/utils/countries';
import { suggestDateFromPath } from '$lib/utils/suggestDateFromPath';
interface Props {
@@ -432,28 +432,22 @@
</span>
</div>
<!-- Location (read-only label + open-on-map icon). The arrow-up-
right icon flies the in-app map to the photo's coordinates at
zoom 17 (close enough for the photo's marker to be its own,
out of any cluster). Hidden when the photo has no
coordinates. -->
<!-- Location (read-only label + jump-to-country icon). Hidden when
the photo has no resolved country. -->
<div class="flex items-center gap-2">
<MapPin class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span class="min-w-0 flex-1 truncate px-1 py-0.5 text-muted-foreground">
{placeLabel || 'No location'}
</span>
{#if photo.Lat && photo.Lng}
{#if photo.Country && photo.Country !== 'zz'}
<button
type="button"
class="text-muted-foreground hover:text-foreground"
onclick={() =>
void goto(
`/map?lat=${photo.Lat}&lng=${photo.Lng}&zoom=17&focus=${photo.UID}`
)}
title="Open on map"
aria-label="Open on map"
onclick={() => void navigateToTag('countries', photo.Country ?? null)}
title={`View other photos from ${countryName(photo.Country)}`}
aria-label={`View other photos from ${countryName(photo.Country)}`}
>
<MapIcon class="h-3 w-3" />
<Globe class="h-3 w-3" />
</button>
{/if}
</div>

View File

@@ -3,11 +3,13 @@
import {
aggregateKeywords,
getAllMarks,
listCountries,
listLabels,
listPhotosByUids,
listSubjects,
type AggregatedKeyword,
type PhotoMarksMap,
type PpCountry,
type PpLabel,
type PpSubject
} from '$lib/services/photoprism';
@@ -20,9 +22,10 @@
COLOR_SWATCHES,
starLabel
} from '$lib/utils/tagGroups';
import { countryFlag, countryName } from '$lib/utils/countries';
import type { PpPhoto } from '$lib/types/photoprism';
import { EmptyState, InlineLoader } from '$lib/components/feedback';
import { Hash, Tag, User } from 'lucide-svelte';
import { Globe, Hash, Tag, User } from 'lucide-svelte';
interface Props {
category: TagCategory;
@@ -64,6 +67,12 @@
enabled: isAuthenticated() && category === 'people'
}));
const countriesQuery = createQuery<PpCountry[]>(() => ({
queryKey: ['countries'],
queryFn: listCountries,
enabled: isAuthenticated() && category === 'countries'
}));
const marksQuery = createQuery<PhotoMarksMap>(() => ({
queryKey: ['marks'],
queryFn: getAllMarks,
@@ -125,6 +134,19 @@
);
});
// PhotoPrism returns countries unsorted; sort by photo count descending so
// the most-photographed countries surface first (mirrors labels/people).
const countriesSorted = $derived(
[...(countriesQuery.data ?? [])].sort((a, b) => b.PhotoCount - a.PhotoCount)
);
const filteredCountries = $derived.by(() => {
const q = filterText.trim().toLowerCase();
if (!q) return countriesSorted;
return countriesSorted.filter((c) =>
countryName(c.Code).toLowerCase().includes(q)
);
});
const ratingGroups = $derived(
buildRatingGroups(marksQuery.data, marksPoolQuery.data)
);
@@ -152,9 +174,11 @@
const visibleLabels = $derived(filteredLabels.slice(0, visibleCount));
const visibleKeywords = $derived(filteredKeywords.slice(0, visibleCount));
const visibleSubjects = $derived(filteredSubjects.slice(0, visibleCount));
const visibleCountries = $derived(filteredCountries.slice(0, visibleCount));
const hasMoreLabels = $derived(visibleCount < filteredLabels.length);
const hasMoreKeywords = $derived(visibleCount < filteredKeywords.length);
const hasMoreSubjects = $derived(visibleCount < filteredSubjects.length);
const hasMoreCountries = $derived(visibleCount < filteredCountries.length);
function loadMore() {
visibleCount += PAGE_SIZE;
@@ -179,6 +203,9 @@
const s = String(r);
if (selectedValue !== s) onSelect(s);
}
function pickCountry(code: string) {
if (selectedValue !== code) onSelect(code);
}
// First non-empty entry for the active category. Labels/keywords are
// already sorted by count desc, so [0] is the most-used tag; colors
@@ -203,6 +230,9 @@
const g = ratingGroups[0];
return g ? String(g.rating) : null;
}
if (category === 'countries') {
return countriesSorted[0]?.Code ?? null;
}
return null;
});
@@ -228,11 +258,16 @@
? 'People'
: category === 'colors'
? 'Colors'
: 'Ratings'
: category === 'countries'
? 'Countries'
: 'Ratings'
);
const showFilterInput = $derived(
category === 'labels' || category === 'keywords' || category === 'people'
category === 'labels' ||
category === 'keywords' ||
category === 'people' ||
category === 'countries'
);
</script>
@@ -448,6 +483,69 @@
{/if}
</div>
{/if}
{:else if category === 'countries'}
{#if countriesQuery.isPending}
<InlineLoader size="sm" label="Loading countries…" />
{:else if countriesQuery.isError}
<EmptyState size="compact" tone="destructive" title="Failed to load countries" />
{:else if filteredCountries.length === 0}
<EmptyState
size="compact"
icon={Globe}
title={filterText ? 'No countries match the filter' : 'No geotagged photos yet'}
/>
{:else}
<div bind:this={scrollEl} class="min-h-0 flex-1 overflow-y-auto">
{#each visibleCountries as countryRow (countryRow.Code)}
{@const active = countryRow.Code === selectedValue}
<button
type="button"
class="flex h-8 w-full items-center gap-2 px-3 text-left text-[12px] leading-tight hover:bg-accent"
class:bg-primary={active}
class:text-primary-foreground={active}
class:hover:bg-primary={active}
onclick={() => pickCountry(countryRow.Code)}
title={countryName(countryRow.Code)}
>
{#if countryRow.Thumb}
<img
src={thumbUrl(countryRow.Thumb, 'tile_50')}
alt=""
loading="lazy"
class="h-5 w-5 shrink-0 rounded object-cover"
/>
{:else}
<span class="flex h-5 w-5 shrink-0 items-center justify-center text-[14px]">
{countryFlag(countryRow.Code)}
</span>
{/if}
<span class="min-w-0 flex-1 truncate">{countryName(countryRow.Code)}</span>
<span
class="flex h-4 min-w-[20px] shrink-0 items-center justify-center rounded px-1 text-[10px] tabular-nums {active
? 'bg-primary-foreground/15 text-primary-foreground'
: 'bg-secondary text-muted-foreground'}"
>
{countryRow.PhotoCount}
</span>
</button>
{/each}
<div
use:nearBottom={{
onHit: loadMore,
enabled: hasMoreCountries,
root: scrollEl ?? null,
preloadPx: 400
}}
class="h-px"
aria-hidden="true"
></div>
{#if hasMoreCountries}
<p class="px-3 py-2 text-center text-[10px] text-muted-foreground/70">
Loading more… ({visibleCount} / {filteredCountries.length})
</p>
{/if}
</div>
{/if}
{:else if category === 'colors'}
{#if marksQuery.isPending || marksPoolQuery.isPending}
<p class="px-3 py-2 text-[11px] text-muted-foreground">Loading colors…</p>

View File

@@ -560,36 +560,19 @@ export async function listFolderCounts(paths: string[]): Promise<Record<string,
return out;
}
// ── Geo ──────────────────────────────────────────────────────────────────────
// ── Countries ────────────────────────────────────────────────────────────────
export interface PpGeoFeature {
type: 'Feature';
id: string;
geometry: { type: 'Point'; coordinates: [number, number] };
properties: {
UID: string;
Hash: string;
Title?: string;
TakenAt?: string;
FavId?: number;
};
export interface PpCountry {
Code: string;
PhotoCount: number;
Thumb?: string;
}
export interface PpGeoCollection {
type: 'FeatureCollection';
features: PpGeoFeature[];
bbox?: number[];
}
export async function listGeo(q = ''): Promise<PpGeoCollection> {
// 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<PpGeoCollection>('/geo', {
params: { count: 50000, q: q || undefined }
});
export async function listCountries(): Promise<PpCountry[]> {
// 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<PpCountry[]>('/api/sidecar/countries');
return data;
}

View File

@@ -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));

View File

@@ -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('');
}