2026-02-15 23:07:04 +01:00
|
|
|
'use client';
|
|
|
|
|
|
|
|
|
|
/**
|
2026-09-23 12:09:08 +02:00
|
|
|
* MapView — fetches /api/map and renders its location-level markers with
|
|
|
|
|
* themed popups through LeafletMap, which owns the Leaflet setup.
|
2026-02-15 23:07:04 +01:00
|
|
|
*/
|
|
|
|
|
|
2026-09-23 12:09:08 +02:00
|
|
|
import { useEffect, useMemo, useState } from 'react';
|
2026-08-23 23:18:14 +02:00
|
|
|
import { useDictionary } from './I18nProvider';
|
2026-09-23 12:09:08 +02:00
|
|
|
import { LeafletMap, escapeHtml, type LeafletMarker } from './LeafletMap';
|
2026-03-13 12:52:09 +01:00
|
|
|
|
2026-02-15 23:07:04 +01:00
|
|
|
interface MapLocationPublic {
|
2026-03-07 08:18:23 +01:00
|
|
|
city: string;
|
|
|
|
|
country: string;
|
|
|
|
|
lat: number;
|
|
|
|
|
lng: number;
|
|
|
|
|
photoCount: number;
|
|
|
|
|
coverUrl: string;
|
|
|
|
|
albums: { name: string; url: string }[];
|
2026-02-15 23:07:04 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function MapView() {
|
2026-08-23 23:18:14 +02:00
|
|
|
const t = useDictionary();
|
2026-09-23 12:09:08 +02:00
|
|
|
const [locations, setLocations] = useState<MapLocationPublic[] | null>(null);
|
2026-03-07 08:18:23 +01:00
|
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
let cancelled = false;
|
|
|
|
|
|
2026-09-23 12:09:08 +02:00
|
|
|
async function load() {
|
2026-03-07 08:18:23 +01:00
|
|
|
const res = await fetch('/api/map');
|
|
|
|
|
if (!res.ok) {
|
2026-03-13 12:31:21 +01:00
|
|
|
let detail = '';
|
2026-03-15 07:32:42 +01:00
|
|
|
try {
|
|
|
|
|
detail = await res.text();
|
|
|
|
|
} catch {
|
|
|
|
|
/* ignore */
|
|
|
|
|
}
|
2026-03-13 12:31:21 +01:00
|
|
|
console.error('[Map] API error', res.status, detail);
|
2026-09-23 12:09:08 +02:00
|
|
|
if (!cancelled) {
|
|
|
|
|
setError(t.map.loadFailed(res.status));
|
|
|
|
|
setLoading(false);
|
|
|
|
|
}
|
2026-03-07 08:18:23 +01:00
|
|
|
return;
|
|
|
|
|
}
|
2026-09-23 12:09:08 +02:00
|
|
|
const data: MapLocationPublic[] = await res.json();
|
|
|
|
|
if (!cancelled) setLocations(data);
|
|
|
|
|
}
|
2026-03-07 08:18:23 +01:00
|
|
|
|
2026-09-23 12:09:08 +02:00
|
|
|
load().catch((err) => {
|
|
|
|
|
console.error('[Map] Init error:', err);
|
|
|
|
|
if (!cancelled) {
|
|
|
|
|
setError(t.map.initFailed);
|
2026-03-07 08:18:23 +01:00
|
|
|
setLoading(false);
|
|
|
|
|
}
|
2026-09-23 12:09:08 +02:00
|
|
|
});
|
2026-03-07 08:18:23 +01:00
|
|
|
|
2026-09-23 12:09:08 +02:00
|
|
|
return () => {
|
|
|
|
|
cancelled = true;
|
|
|
|
|
};
|
|
|
|
|
}, [t]);
|
2026-03-07 08:18:23 +01:00
|
|
|
|
2026-09-23 12:09:08 +02:00
|
|
|
const markers = useMemo<LeafletMarker[]>(
|
|
|
|
|
() =>
|
|
|
|
|
(locations ?? []).map((loc) => {
|
2026-03-07 08:18:23 +01:00
|
|
|
const albumLinks = loc.albums
|
2026-03-15 07:32:42 +01:00
|
|
|
.map(
|
|
|
|
|
(a) =>
|
|
|
|
|
`<a href="${escapeHtml(a.url)}" class="map-popup__album-link">${escapeHtml(a.name)} →</a>`,
|
|
|
|
|
)
|
2026-03-07 08:18:23 +01:00
|
|
|
.join('');
|
|
|
|
|
|
release: v0.13.0 (#535)
Twenty-nine commits since v0.12.0. See CHANGELOG.md for the full list
and the upgrade notes.
Ten additions — the SEO package (site URL, robots.txt, sitemap.xml,
JSON-LD), slideshow mode, opt-in original downloads, copy-link,
previous/next album navigation, alt text from the Immich description,
per-album location precision, the update notice, a Help tab, and a
subpage cover grid separate from its photo grid. Two fixes: CJK album
slugs, and the lightbox control bar.
Photo of the Day (#476) was built on `dev` and reverted as not planned;
it never appears in a release.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01DmsHVRL9yQvckUB1ZpVWre
---------
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: ralksta <ralksta@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 10:08:12 +02:00
|
|
|
/*
|
|
|
|
|
* An album set to `location: country` has no city name to show — the
|
|
|
|
|
* API withholds it rather than naming a village inside a 100 km cell
|
|
|
|
|
* (#469). The country then carries the heading on its own.
|
|
|
|
|
*/
|
|
|
|
|
const heading = loc.city || loc.country;
|
|
|
|
|
const subheading = loc.city ? loc.country : '';
|
|
|
|
|
|
2026-09-23 12:09:08 +02:00
|
|
|
return {
|
|
|
|
|
lat: loc.lat,
|
|
|
|
|
lng: loc.lng,
|
|
|
|
|
html: `<div class="map-marker">${loc.photoCount}</div>`,
|
2026-10-02 18:14:23 +02:00
|
|
|
// "Dźwirzyno, Polen, 12 Fotos" instead of a bare "12".
|
|
|
|
|
label: [heading, subheading, t.common.photos(loc.photoCount)].filter(Boolean).join(', '),
|
2026-09-23 12:09:08 +02:00
|
|
|
popupHtml: `
|
2026-02-15 23:07:04 +01:00
|
|
|
<div class="map-popup">
|
release: v0.13.0 (#535)
Twenty-nine commits since v0.12.0. See CHANGELOG.md for the full list
and the upgrade notes.
Ten additions — the SEO package (site URL, robots.txt, sitemap.xml,
JSON-LD), slideshow mode, opt-in original downloads, copy-link,
previous/next album navigation, alt text from the Immich description,
per-album location precision, the update notice, a Help tab, and a
subpage cover grid separate from its photo grid. Two fixes: CJK album
slugs, and the lightbox control bar.
Photo of the Day (#476) was built on `dev` and reverted as not planned;
it never appears in a release.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01DmsHVRL9yQvckUB1ZpVWre
---------
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: ralksta <ralksta@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 10:08:12 +02:00
|
|
|
<img src="${escapeHtml(loc.coverUrl)}" alt="${escapeHtml(heading)}" class="map-popup__cover" loading="lazy" />
|
2026-02-15 23:07:04 +01:00
|
|
|
<div class="map-popup__body">
|
release: v0.13.0 (#535)
Twenty-nine commits since v0.12.0. See CHANGELOG.md for the full list
and the upgrade notes.
Ten additions — the SEO package (site URL, robots.txt, sitemap.xml,
JSON-LD), slideshow mode, opt-in original downloads, copy-link,
previous/next album navigation, alt text from the Immich description,
per-album location precision, the update notice, a Help tab, and a
subpage cover grid separate from its photo grid. Two fixes: CJK album
slugs, and the lightbox control bar.
Photo of the Day (#476) was built on `dev` and reverted as not planned;
it never appears in a release.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01DmsHVRL9yQvckUB1ZpVWre
---------
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: ralksta <ralksta@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 10:08:12 +02:00
|
|
|
<h3 class="map-popup__city">${escapeHtml(heading)}</h3>
|
|
|
|
|
${subheading ? `<p class="map-popup__country">${escapeHtml(subheading)}</p>` : ''}
|
2026-08-23 23:18:14 +02:00
|
|
|
<p class="map-popup__count">${escapeHtml(t.common.photos(loc.photoCount))}</p>
|
2026-02-15 23:07:04 +01:00
|
|
|
<div class="map-popup__albums">${albumLinks}</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-09-23 12:09:08 +02:00
|
|
|
`,
|
|
|
|
|
};
|
|
|
|
|
}),
|
|
|
|
|
[locations, t],
|
|
|
|
|
);
|
2026-03-07 08:18:23 +01:00
|
|
|
|
|
|
|
|
if (error) {
|
2026-02-15 23:07:04 +01:00
|
|
|
return (
|
2026-04-01 12:46:20 +02:00
|
|
|
<div
|
|
|
|
|
className="map-container__loading"
|
|
|
|
|
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%' }}
|
|
|
|
|
>
|
2026-03-07 08:18:23 +01:00
|
|
|
<p>{error}</p>
|
|
|
|
|
</div>
|
2026-02-15 23:07:04 +01:00
|
|
|
);
|
2026-03-07 08:18:23 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div style={{ position: 'relative' }}>
|
|
|
|
|
{loading && (
|
|
|
|
|
<div
|
|
|
|
|
className="map-container__loading"
|
2026-09-29 22:50:30 +02:00
|
|
|
// A loading notice is a status, not an alert (role="alert" is
|
|
|
|
|
// assertive and contradicted the aria-live="polite" beside it).
|
|
|
|
|
role="status"
|
2026-04-01 12:46:20 +02:00
|
|
|
style={{
|
|
|
|
|
position: 'absolute',
|
|
|
|
|
inset: 0,
|
2026-03-27 11:11:23 +01:00
|
|
|
zIndex: 1000,
|
|
|
|
|
display: 'flex',
|
|
|
|
|
flexDirection: 'column',
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
justifyContent: 'center',
|
|
|
|
|
background: 'rgba(0, 0, 0, 0.5)',
|
2026-04-01 12:46:20 +02:00
|
|
|
color: '#fff',
|
2026-03-27 11:11:23 +01:00
|
|
|
}}
|
2026-03-07 08:18:23 +01:00
|
|
|
>
|
2026-04-01 12:46:20 +02:00
|
|
|
<svg
|
|
|
|
|
aria-hidden="true"
|
|
|
|
|
width="24"
|
|
|
|
|
height="24"
|
|
|
|
|
viewBox="0 0 24 24"
|
|
|
|
|
fill="none"
|
|
|
|
|
stroke="currentColor"
|
|
|
|
|
strokeWidth="2"
|
|
|
|
|
strokeLinecap="round"
|
|
|
|
|
strokeLinejoin="round"
|
2026-03-27 11:11:23 +01:00
|
|
|
style={{ animation: 'spin 1s linear infinite', marginBottom: '8px' }}
|
|
|
|
|
>
|
|
|
|
|
<circle cx="12" cy="12" r="10" strokeOpacity="0.25"></circle>
|
|
|
|
|
<path d="M12 2a10 10 0 0 1 10 10"></path>
|
|
|
|
|
</svg>
|
2026-08-23 23:18:14 +02:00
|
|
|
<p>{t.map.loading}</p>
|
2026-03-27 11:11:23 +01:00
|
|
|
<style>{`
|
|
|
|
|
@keyframes spin {
|
|
|
|
|
from { transform: rotate(0deg); }
|
|
|
|
|
to { transform: rotate(360deg); }
|
|
|
|
|
}
|
|
|
|
|
`}</style>
|
2026-03-07 08:18:23 +01:00
|
|
|
</div>
|
|
|
|
|
)}
|
2026-09-23 12:09:08 +02:00
|
|
|
{locations ? (
|
|
|
|
|
<LeafletMap
|
|
|
|
|
markers={markers}
|
|
|
|
|
onReady={() => setLoading(false)}
|
|
|
|
|
onError={() => {
|
|
|
|
|
setError(t.map.initFailed);
|
|
|
|
|
setLoading(false);
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="map-container" />
|
|
|
|
|
)}
|
2026-03-07 08:18:23 +01:00
|
|
|
</div>
|
|
|
|
|
);
|
2026-02-15 23:07:04 +01:00
|
|
|
}
|