feat: localize ui and remove demo mode

This commit is contained in:
2026-06-07 19:43:06 +02:00
parent c21a67d4fb
commit 047177c88b

View File

@@ -33,7 +33,7 @@ function resolveDavUrlFromShareUrl(shareUrl: string): string {
return `${url.origin}/public.php/dav/files/${davShare[1]}/`; return `${url.origin}/public.php/dav/files/${davShare[1]}/`;
} }
throw new Error("Bitte einen öffentlichen Nextcloud-Share-Link einfügen."); throw new Error("Please enter a public Nextcloud share link.");
} }
async function proxyUpstream(url: string, init?: RequestInit) { async function proxyUpstream(url: string, init?: RequestInit) {
@@ -47,45 +47,6 @@ async function proxyUpstream(url: string, init?: RequestInit) {
}; };
} }
const demoPhotos: Photo[] = [
{
id: "demo-1",
name: "berlin-brandenburg-gate.jpg",
latitude: 52.516275,
longitude: 13.377704,
capturedAt: "2026-06-07T08:20:00.000Z",
thumbUrl:
"https://images.unsplash.com/photo-1467269204594-9661b134dd2b?auto=format&fit=crop&w=240&q=60",
fullUrl:
"https://images.unsplash.com/photo-1467269204594-9661b134dd2b?auto=format&fit=crop&w=1600&q=80",
source: "demo"
},
{
id: "demo-2",
name: "museum-island.jpg",
latitude: 52.5169,
longitude: 13.4015,
capturedAt: "2026-06-07T08:42:00.000Z",
thumbUrl:
"https://images.unsplash.com/photo-1477959858617-67f85cf4f1df?auto=format&fit=crop&w=240&q=60",
fullUrl:
"https://images.unsplash.com/photo-1477959858617-67f85cf4f1df?auto=format&fit=crop&w=1600&q=80",
source: "demo"
},
{
id: "demo-3",
name: "alexanderplatz.jpg",
latitude: 52.521918,
longitude: 13.413215,
capturedAt: "2026-06-07T09:05:00.000Z",
thumbUrl:
"https://images.unsplash.com/photo-1494526585095-c41746248156?auto=format&fit=crop&w=240&q=60",
fullUrl:
"https://images.unsplash.com/photo-1494526585095-c41746248156?auto=format&fit=crop&w=1600&q=80",
source: "demo"
}
];
function htmlPage(): string { function htmlPage(): string {
return `<!doctype html> return `<!doctype html>
<html lang="de"> <html lang="de">
@@ -228,6 +189,24 @@ function htmlPage(): string {
padding: 10px 14px; padding: 10px 14px;
font: inherit; font: inherit;
cursor: pointer; cursor: pointer;
display: inline-flex;
align-items: center;
gap: 8px;
}
.button-icon {
width: 1em;
height: 1em;
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
}
.button-icon svg {
display: block;
width: 100%;
height: 100%;
} }
.primary { .primary {
@@ -354,6 +333,15 @@ function htmlPage(): string {
font-size: 0.82rem; font-size: 0.82rem;
} }
.empty-state {
padding: 14px;
border-radius: 14px;
border: 1px dashed rgba(15, 23, 42, 0.16);
color: var(--muted);
background: rgba(255, 255, 255, 0.45);
text-align: center;
}
main { main {
position: relative; position: relative;
min-height: 0; min-height: 0;
@@ -464,33 +452,48 @@ function htmlPage(): string {
<header> <header>
<div class="brand"> <div class="brand">
<h1>mapy-mg</h1> <h1>mapy-mg</h1>
<p>Fotos laden, EXIF lokal im Browser auslesen und auf OpenStreetMap anzeigen.</p> <p>Load photos, read EXIF locally in the browser, and show them on OpenStreetMap.</p>
</div> </div>
<div class="meta">Client-only Import · kein Bildspeicher auf dem Server</div> <div class="meta">Client-side import · no image storage on the server</div>
</header> </header>
<div class="layout"> <div class="layout">
<aside> <aside>
<section class="card"> <section class="card">
<h2>Nextcloud Share</h2> <h2>Nextcloud Share</h2>
<p>Public share-Link einfügen. Die App lädt die Bilder direkt im Browser und extrahiert die GPS-Daten lokal.</p> <p>Paste a public share link. The app loads the images in the browser and extracts GPS data locally.</p>
<label> <label>
Share-Link Share URL
<input <input
id="share-url" id="share-url"
value="https://cloud.br0tkasten.de/index.php/s/cjYjeSYZwgLJNBT" placeholder="https://cloud.example.com/index.php/s/..."
spellcheck="false" spellcheck="false"
autocomplete="off" autocomplete="off"
/> />
</label> </label>
<div class="button-row"> <div class="button-row">
<button class="primary" id="load-share" type="button">Share laden</button> <button class="primary" id="load-share" type="button">
<button class="danger" id="cancel-share" type="button" disabled>Abbrechen</button> <span class="button-icon" aria-hidden="true">
<button class="secondary" id="use-demo" type="button">Demo anzeigen</button> <svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 2v7" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>
<path d="M5 6.5 8 9.5 11 6.5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3 12.5h10" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>
</svg>
</span>
<span>Load share</span>
</button>
<button class="danger" id="cancel-share" type="button" disabled>
<span class="button-icon" aria-hidden="true">
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>
</svg>
</span>
<span>Cancel</span>
</button>
</div> </div>
<div class="status-row"> <div class="status-row">
<span class="spinner" id="activity-spinner" aria-hidden="true"></span> <span class="spinner" id="activity-spinner" aria-hidden="true"></span>
<div class="status" id="share-status">Demo-Daten aktiv.</div> <div class="status" id="share-status">Ready to load a share.</div>
</div> </div>
<div class="progress" aria-label="Ladefortschritt"> <div class="progress" aria-label="Ladefortschritt">
<div class="progress-bar" aria-hidden="true"> <div class="progress-bar" aria-hidden="true">
@@ -498,21 +501,23 @@ function htmlPage(): string {
</div> </div>
<div class="progress-meta"> <div class="progress-meta">
<span id="progress-text">0 / 0</span> <span id="progress-text">0 / 0</span>
<span id="progress-detail">bereit</span> <span id="progress-detail">ready</span>
</div> </div>
</div> </div>
<small>Hinweis: Öffentliche Nextcloud-Shares werden per WebDAV gelesen.</small> <small>Public Nextcloud shares are read via WebDAV.</small>
</section> </section>
<section class="card"> <section class="card">
<h2>Fotos</h2> <h2>Photos</h2>
<small id="photo-count" class="muted">0 Bilder</small> <small id="photo-count" class="muted">0 photos</small>
<div id="photo-list" class="list"></div> <div id="photo-list" class="list">
<div class="empty-state" id="empty-state">No images loaded yet.</div>
</div>
</section> </section>
</aside> </aside>
<main> <main>
<div class="map-label">Hover: Thumbnail · Klick: Vollbild · Route: zeitlich sortiert</div> <div class="map-label">Hover: thumbnail · click: fullscreen · route: time sorted</div>
<div id="map"></div> <div id="map"></div>
</main> </main>
</div> </div>
@@ -520,8 +525,15 @@ function htmlPage(): string {
<div class="overlay" id="overlay" aria-hidden="true"> <div class="overlay" id="overlay" aria-hidden="true">
<div class="overlay-card"> <div class="overlay-card">
<header> <header>
<strong id="overlay-title">Foto</strong> <strong id="overlay-title">Photo</strong>
<button class="close" id="close-overlay" type="button">Schließen</button> <button class="close" id="close-overlay" type="button">
<span class="button-icon" aria-hidden="true">
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>
</svg>
</span>
<span>Close</span>
</button>
</header> </header>
<img id="overlay-image" alt="" /> <img id="overlay-image" alt="" />
</div> </div>
@@ -531,9 +543,8 @@ function htmlPage(): string {
<script type="module"> <script type="module">
import exifr from "https://cdn.jsdelivr.net/npm/exifr@7.1.3/+esm"; import exifr from "https://cdn.jsdelivr.net/npm/exifr@7.1.3/+esm";
const DEMO_PHOTOS = ${JSON.stringify(demoPhotos)};
const state = { const state = {
photos: DEMO_PHOTOS, photos: [],
objectUrls: [], objectUrls: [],
processed: 0, processed: 0,
total: 0 total: 0
@@ -558,6 +569,7 @@ function htmlPage(): string {
const overlayImage = mustGet("overlay-image"); const overlayImage = mustGet("overlay-image");
const closeOverlay = mustGet("close-overlay"); const closeOverlay = mustGet("close-overlay");
const photoList = mustGet("photo-list"); const photoList = mustGet("photo-list");
const emptyState = mustGet("empty-state");
const photoCount = mustGet("photo-count"); const photoCount = mustGet("photo-count");
const progressFill = mustGet("progress-fill"); const progressFill = mustGet("progress-fill");
const progressText = mustGet("progress-text"); const progressText = mustGet("progress-text");
@@ -567,7 +579,6 @@ function htmlPage(): string {
const shareStatus = mustGet("share-status"); const shareStatus = mustGet("share-status");
const loadShare = mustGet("load-share"); const loadShare = mustGet("load-share");
const cancelShare = mustGet("cancel-share"); const cancelShare = mustGet("cancel-share");
const useDemo = mustGet("use-demo");
function mustGet(id) { function mustGet(id) {
const element = document.getElementById(id); const element = document.getElementById(id);
@@ -629,7 +640,6 @@ function htmlPage(): string {
function setImporting(isImporting) { function setImporting(isImporting) {
activitySpinner.classList.toggle("active", isImporting); activitySpinner.classList.toggle("active", isImporting);
loadShare.disabled = isImporting; loadShare.disabled = isImporting;
useDemo.disabled = isImporting;
cancelShare.disabled = !isImporting; cancelShare.disabled = !isImporting;
} }
@@ -644,9 +654,9 @@ function htmlPage(): string {
state.photos = []; state.photos = [];
markers.clearLayers(); markers.clearLayers();
route.setLatLngs([]); route.setLatLngs([]);
photoList.replaceChildren(); photoList.replaceChildren(emptyState);
photoCount.textContent = "0 Bilder"; photoCount.textContent = "0 photos";
setProgress(0, 0, "bereit"); setProgress(0, 0, "ready");
} }
function openOverlay(photo) { function openOverlay(photo) {
@@ -686,7 +696,10 @@ function htmlPage(): string {
function appendPhoto(photo) { function appendPhoto(photo) {
state.photos.push(photo); state.photos.push(photo);
photoCount.textContent = state.photos.length + (state.photos.length === 1 ? " Bild" : " Bilder"); photoCount.textContent = state.photos.length + (state.photos.length === 1 ? " photo" : " photos");
if (emptyState.isConnected) {
emptyState.remove();
}
if (photo.latitude !== null && photo.longitude !== null) { if (photo.latitude !== null && photo.longitude !== null) {
const marker = L.marker([photo.latitude, photo.longitude]).addTo(markers); const marker = L.marker([photo.latitude, photo.longitude]).addTo(markers);
@@ -696,7 +709,7 @@ function htmlPage(): string {
'<img src="' + photo.thumbUrl + '" alt="' + photo.name + '" />' + '<img src="' + photo.thumbUrl + '" alt="' + photo.name + '" />' +
'<strong>' + photo.name + '</strong>' + '<strong>' + photo.name + '</strong>' +
'<small>' + formatDate(photo.capturedAt) + '</small>' + '<small>' + formatDate(photo.capturedAt) + '</small>' +
'<button type="button" data-open="' + photo.id + '">Vollbild</button>' + '<button type="button" data-open="' + photo.id + '">Open</button>' +
'</div>' '</div>'
); );
@@ -715,16 +728,6 @@ function htmlPage(): string {
updateRouteView(); updateRouteView();
} }
function renderPhotos(photos) {
clearGallery();
let processed = 0;
for (const photo of photos) {
appendPhoto(photo);
processed += 1;
setProgress(processed, photos.length, "Demo geladen");
}
}
function parseShareInput(value) { function parseShareInput(value) {
const trimmed = value.trim(); const trimmed = value.trim();
const url = new URL(trimmed); const url = new URL(trimmed);
@@ -744,7 +747,7 @@ function htmlPage(): string {
}; };
} }
throw new Error("Bitte einen öffentlichen Nextcloud-Share-Link einfügen."); throw new Error("Please enter a public Nextcloud share link.");
} }
function textContent(node, namespace, localName) { function textContent(node, namespace, localName) {
@@ -771,7 +774,7 @@ function htmlPage(): string {
return { return {
href: absoluteUrl, href: absoluteUrl,
name: displayName || decodeURIComponent(absoluteUrl.split("/").pop() || "Bild"), name: displayName || decodeURIComponent(absoluteUrl.split("/").pop() || "image"),
lastModified, lastModified,
contentType, contentType,
isCollection isCollection
@@ -795,7 +798,7 @@ function htmlPage(): string {
); );
if (!response.ok) { if (!response.ok) {
throw new Error("Nextcloud-Liste konnte nicht geladen werden: " + response.status); throw new Error("Could not load the Nextcloud listing: " + response.status);
} }
return parseListing(await response.text(), davUrl); return parseListing(await response.text(), davUrl);
@@ -806,7 +809,7 @@ function htmlPage(): string {
signal signal
}); });
if (!response.ok) { if (!response.ok) {
throw new Error("Bild konnte nicht geladen werden: " + entry.name); throw new Error("Could not load image: " + entry.name);
} }
const blob = await response.blob(); const blob = await response.blob();
@@ -841,15 +844,15 @@ function htmlPage(): string {
try { try {
clearObjectUrls(); clearObjectUrls();
clearGallery(); clearGallery();
updateStatus("Share wird geladen..."); updateStatus("Loading share...");
const { davUrl } = parseShareInput(shareUrl.value); const { davUrl } = parseShareInput(shareUrl.value);
const listing = await loadShareListing(davUrl, controller.signal); const listing = await loadShareListing(davUrl, controller.signal);
if (!listing.length) { if (!listing.length) {
throw new Error("Im Share wurden keine Bilder gefunden."); throw new Error("No images were found in the share.");
} }
setProgress(0, listing.length, "Dateien werden geprüft"); setProgress(0, listing.length, "checking files");
let loaded = 0; let loaded = 0;
let skipped = 0; let skipped = 0;
@@ -857,7 +860,7 @@ function htmlPage(): string {
for (const entry of listing) { for (const entry of listing) {
if (controller.signal.aborted) { if (controller.signal.aborted) {
throw controller.signal.reason ?? new Error("Import abgebrochen"); throw controller.signal.reason ?? new Error("Import canceled");
} }
try { try {
@@ -877,39 +880,39 @@ function htmlPage(): string {
setProgress( setProgress(
processed, processed,
listing.length, listing.length,
"geladen " + loaded + ", übersprungen " + skipped "loaded " + loaded + ", skipped " + skipped
); );
updateStatus( updateStatus(
"Import läuft: " + "Import running: " +
loaded + loaded +
" Bilder angezeigt, " + " images shown, " +
skipped + skipped +
" übersprungen." " skipped."
); );
} }
if (!loaded) { if (!loaded) {
throw new Error("Keine Bilder mit GPS-Daten gefunden."); throw new Error("No images with GPS data were found.");
} }
updateStatus( updateStatus(
"Nextcloud-Import fertig: " + "Nextcloud import complete: " +
loaded + loaded +
" Bilder geladen" + " images loaded" +
(skipped ? ", " + skipped + " übersprungen" : "") + (skipped ? ", " + skipped + " skipped" : "") +
"." "."
); );
setProgress(listing.length, listing.length, "fertig"); setProgress(listing.length, listing.length, "done");
} catch (error) { } catch (error) {
if (controller.signal.aborted) { if (controller.signal.aborted) {
updateStatus("Import abgebrochen."); updateStatus("Import canceled.");
setProgress(state.processed, state.total, "abgebrochen"); setProgress(state.processed, state.total, "canceled");
return; return;
} }
console.error(error); console.error(error);
updateStatus( updateStatus(
"Import fehlgeschlagen: " + (error instanceof Error ? error.message : "Unbekannter Fehler"), "Import failed: " + (error instanceof Error ? error.message : "unknown error"),
"error" "error"
); );
} finally { } finally {
@@ -918,12 +921,6 @@ function htmlPage(): string {
} }
} }
function showDemo() {
clearObjectUrls();
renderPhotos(DEMO_PHOTOS);
updateStatus("Demo-Daten aktiv.");
}
document.addEventListener("click", (event) => { document.addEventListener("click", (event) => {
const target = event.target; const target = event.target;
if (target instanceof HTMLElement && target.dataset.open) { if (target instanceof HTMLElement && target.dataset.open) {
@@ -940,13 +937,12 @@ function htmlPage(): string {
cancelShare.addEventListener("click", () => { cancelShare.addEventListener("click", () => {
if (activeImportController) { if (activeImportController) {
activeImportController.abort("Import durch Benutzer abgebrochen"); activeImportController.abort("Import canceled by user");
} }
}); });
useDemo.addEventListener("click", showDemo); clearGallery();
updateStatus("Ready to load a share.");
showDemo();
</script> </script>
</body> </body>
</html>`; </html>`;