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:
@@ -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.
|
||||
];
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user