feat: localize ui and remove demo mode
This commit is contained in:
@@ -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>`;
|
||||
|
||||
Reference in New Issue
Block a user