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]}/`;
}
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) {
@@ -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 {
return `<!doctype html>
<html lang="de">
@@ -228,6 +189,24 @@ function htmlPage(): string {
padding: 10px 14px;
font: inherit;
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 {
@@ -354,6 +333,15 @@ function htmlPage(): string {
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 {
position: relative;
min-height: 0;
@@ -464,33 +452,48 @@ function htmlPage(): string {
<header>
<div class="brand">
<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 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>
<div class="layout">
<aside>
<section class="card">
<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>
Share-Link
Share URL
<input
id="share-url"
value="https://cloud.br0tkasten.de/index.php/s/cjYjeSYZwgLJNBT"
placeholder="https://cloud.example.com/index.php/s/..."
spellcheck="false"
autocomplete="off"
/>
</label>
<div class="button-row">
<button class="primary" id="load-share" type="button">Share laden</button>
<button class="danger" id="cancel-share" type="button" disabled>Abbrechen</button>
<button class="secondary" id="use-demo" type="button">Demo anzeigen</button>
<button class="primary" id="load-share" 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="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 class="status-row">
<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 class="progress" aria-label="Ladefortschritt">
<div class="progress-bar" aria-hidden="true">
@@ -498,21 +501,23 @@ function htmlPage(): string {
</div>
<div class="progress-meta">
<span id="progress-text">0 / 0</span>
<span id="progress-detail">bereit</span>
<span id="progress-detail">ready</span>
</div>
</div>
<small>Hinweis: Öffentliche Nextcloud-Shares werden per WebDAV gelesen.</small>
<small>Public Nextcloud shares are read via WebDAV.</small>
</section>
<section class="card">
<h2>Fotos</h2>
<small id="photo-count" class="muted">0 Bilder</small>
<div id="photo-list" class="list"></div>
<h2>Photos</h2>
<small id="photo-count" class="muted">0 photos</small>
<div id="photo-list" class="list">
<div class="empty-state" id="empty-state">No images loaded yet.</div>
</div>
</section>
</aside>
<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>
</main>
</div>
@@ -520,8 +525,15 @@ function htmlPage(): string {
<div class="overlay" id="overlay" aria-hidden="true">
<div class="overlay-card">
<header>
<strong id="overlay-title">Foto</strong>
<button class="close" id="close-overlay" type="button">Schließen</button>
<strong id="overlay-title">Photo</strong>
<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>
<img id="overlay-image" alt="" />
</div>
@@ -531,9 +543,8 @@ function htmlPage(): string {
<script type="module">
import exifr from "https://cdn.jsdelivr.net/npm/exifr@7.1.3/+esm";
const DEMO_PHOTOS = ${JSON.stringify(demoPhotos)};
const state = {
photos: DEMO_PHOTOS,
photos: [],
objectUrls: [],
processed: 0,
total: 0
@@ -558,6 +569,7 @@ function htmlPage(): string {
const overlayImage = mustGet("overlay-image");
const closeOverlay = mustGet("close-overlay");
const photoList = mustGet("photo-list");
const emptyState = mustGet("empty-state");
const photoCount = mustGet("photo-count");
const progressFill = mustGet("progress-fill");
const progressText = mustGet("progress-text");
@@ -567,7 +579,6 @@ function htmlPage(): string {
const shareStatus = mustGet("share-status");
const loadShare = mustGet("load-share");
const cancelShare = mustGet("cancel-share");
const useDemo = mustGet("use-demo");
function mustGet(id) {
const element = document.getElementById(id);
@@ -629,7 +640,6 @@ function htmlPage(): string {
function setImporting(isImporting) {
activitySpinner.classList.toggle("active", isImporting);
loadShare.disabled = isImporting;
useDemo.disabled = isImporting;
cancelShare.disabled = !isImporting;
}
@@ -644,9 +654,9 @@ function htmlPage(): string {
state.photos = [];
markers.clearLayers();
route.setLatLngs([]);
photoList.replaceChildren();
photoCount.textContent = "0 Bilder";
setProgress(0, 0, "bereit");
photoList.replaceChildren(emptyState);
photoCount.textContent = "0 photos";
setProgress(0, 0, "ready");
}
function openOverlay(photo) {
@@ -686,7 +696,10 @@ function htmlPage(): string {
function appendPhoto(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) {
const marker = L.marker([photo.latitude, photo.longitude]).addTo(markers);
@@ -696,7 +709,7 @@ function htmlPage(): string {
'<img src="' + photo.thumbUrl + '" alt="' + photo.name + '" />' +
'<strong>' + photo.name + '</strong>' +
'<small>' + formatDate(photo.capturedAt) + '</small>' +
'<button type="button" data-open="' + photo.id + '">Vollbild</button>' +
'<button type="button" data-open="' + photo.id + '">Open</button>' +
'</div>'
);
@@ -715,16 +728,6 @@ function htmlPage(): string {
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) {
const trimmed = value.trim();
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) {
@@ -771,7 +774,7 @@ function htmlPage(): string {
return {
href: absoluteUrl,
name: displayName || decodeURIComponent(absoluteUrl.split("/").pop() || "Bild"),
name: displayName || decodeURIComponent(absoluteUrl.split("/").pop() || "image"),
lastModified,
contentType,
isCollection
@@ -795,7 +798,7 @@ function htmlPage(): string {
);
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);
@@ -806,7 +809,7 @@ function htmlPage(): string {
signal
});
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();
@@ -841,15 +844,15 @@ function htmlPage(): string {
try {
clearObjectUrls();
clearGallery();
updateStatus("Share wird geladen...");
updateStatus("Loading share...");
const { davUrl } = parseShareInput(shareUrl.value);
const listing = await loadShareListing(davUrl, controller.signal);
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 skipped = 0;
@@ -857,7 +860,7 @@ function htmlPage(): string {
for (const entry of listing) {
if (controller.signal.aborted) {
throw controller.signal.reason ?? new Error("Import abgebrochen");
throw controller.signal.reason ?? new Error("Import canceled");
}
try {
@@ -877,39 +880,39 @@ function htmlPage(): string {
setProgress(
processed,
listing.length,
"geladen " + loaded + ", übersprungen " + skipped
"loaded " + loaded + ", skipped " + skipped
);
updateStatus(
"Import läuft: " +
"Import running: " +
loaded +
" Bilder angezeigt, " +
" images shown, " +
skipped +
" übersprungen."
" skipped."
);
}
if (!loaded) {
throw new Error("Keine Bilder mit GPS-Daten gefunden.");
throw new Error("No images with GPS data were found.");
}
updateStatus(
"Nextcloud-Import fertig: " +
"Nextcloud import complete: " +
loaded +
" Bilder geladen" +
(skipped ? ", " + skipped + " übersprungen" : "") +
" images loaded" +
(skipped ? ", " + skipped + " skipped" : "") +
"."
);
setProgress(listing.length, listing.length, "fertig");
setProgress(listing.length, listing.length, "done");
} catch (error) {
if (controller.signal.aborted) {
updateStatus("Import abgebrochen.");
setProgress(state.processed, state.total, "abgebrochen");
updateStatus("Import canceled.");
setProgress(state.processed, state.total, "canceled");
return;
}
console.error(error);
updateStatus(
"Import fehlgeschlagen: " + (error instanceof Error ? error.message : "Unbekannter Fehler"),
"Import failed: " + (error instanceof Error ? error.message : "unknown error"),
"error"
);
} finally {
@@ -918,12 +921,6 @@ function htmlPage(): string {
}
}
function showDemo() {
clearObjectUrls();
renderPhotos(DEMO_PHOTOS);
updateStatus("Demo-Daten aktiv.");
}
document.addEventListener("click", (event) => {
const target = event.target;
if (target instanceof HTMLElement && target.dataset.open) {
@@ -940,13 +937,12 @@ function htmlPage(): string {
cancelShare.addEventListener("click", () => {
if (activeImportController) {
activeImportController.abort("Import durch Benutzer abgebrochen");
activeImportController.abort("Import canceled by user");
}
});
useDemo.addEventListener("click", showDemo);
showDemo();
clearGallery();
updateStatus("Ready to load a share.");
</script>
</body>
</html>`;