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>