image handling update for route creation
Sonarqube Scanner / Build and analyze (push) Failing after 20s
Sonarqube Scanner / Build and analyze (push) Failing after 20s
This commit is contained in:
Vendored
+19
-1
@@ -236,7 +236,25 @@
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
#bootstrap-screen .privacy-summary {
|
||||
#bootstrap-screen
|
||||
.android-browser-hint {
|
||||
margin: .8rem 0 0;
|
||||
padding: .75rem .9rem;
|
||||
border: 1px solid #cbdccf;
|
||||
border-radius: .85rem;
|
||||
background: #eef7f1;
|
||||
color: #24462b;
|
||||
font-size: .9rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.android-browser-hint[data-browser="other"] {
|
||||
border-color: #e3c98c;
|
||||
background: #fff8e8;
|
||||
color: #694d14;
|
||||
}
|
||||
|
||||
.privacy-summary {
|
||||
display: grid;
|
||||
gap: .65rem;
|
||||
margin: .85rem 0 0;
|
||||
|
||||
+51
-30
@@ -3,31 +3,30 @@
|
||||
top: calc(env(safe-area-inset-top) + .45rem);
|
||||
right: calc(max(.65rem, env(safe-area-inset-right)) + var(--icon-button-size) + .5rem);
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: .4rem;
|
||||
}
|
||||
|
||||
.route-control-button,
|
||||
.route-control-button.ui-button {
|
||||
.route-control-summary {
|
||||
display: grid;
|
||||
width: min(13rem, calc(100vw - var(--icon-button-size) - 5.2rem));
|
||||
min-width: 0;
|
||||
width: min(13rem, calc(100vw - (var(--icon-button-size) * 2) - 6rem));
|
||||
min-height: var(--icon-button-size);
|
||||
box-sizing: border-box;
|
||||
align-content: center;
|
||||
gap: .08rem;
|
||||
margin: 0;
|
||||
padding: .35rem .75rem;
|
||||
overflow: hidden;
|
||||
border: 2px solid rgba(255, 255, 255, .9);
|
||||
border-radius: 999px;
|
||||
background: #24462b;
|
||||
border: 1px solid rgba(255, 255, 255, .72);
|
||||
border-radius: .9rem;
|
||||
background: rgba(36, 70, 43, .94);
|
||||
color: #fff;
|
||||
box-shadow: 0 .15rem .55rem rgba(0, 0, 0, .28);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
touch-action: manipulation;
|
||||
box-shadow: 0 .15rem .55rem rgba(0, 0, 0, .22);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.route-control-button strong,
|
||||
.route-control-button small {
|
||||
.route-control-summary strong,
|
||||
.route-control-summary small {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
@@ -35,17 +34,44 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.route-control-button strong {
|
||||
.route-control-summary strong {
|
||||
font-size: .9rem;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.route-control-button small {
|
||||
.route-control-summary small {
|
||||
color: rgba(255, 255, 255, .86);
|
||||
font-size: .68rem;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.route-control-button,
|
||||
.route-control-button.ui-button {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: var(--icon-button-size);
|
||||
min-width: var(--icon-button-size);
|
||||
height: var(--icon-button-size);
|
||||
min-height: var(--icon-button-size);
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: .45rem;
|
||||
overflow: hidden;
|
||||
border: 2px solid rgba(255, 255, 255, .9);
|
||||
border-radius: 50%;
|
||||
background: #24462b;
|
||||
color: #fff;
|
||||
box-shadow: 0 .15rem .55rem rgba(0, 0, 0, .28);
|
||||
cursor: pointer;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.route-control-button img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.route-control-button:focus-visible,
|
||||
.route-control-button.ui-button:focus-visible {
|
||||
outline: .2rem solid #fff;
|
||||
@@ -142,7 +168,7 @@
|
||||
|
||||
.route-control-actions {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: .5rem;
|
||||
margin-top: .85rem;
|
||||
padding-top: .75rem;
|
||||
@@ -172,14 +198,7 @@
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
min-height: 2.75rem;
|
||||
min-width: 2.75rem;
|
||||
padding: .5rem;
|
||||
}
|
||||
|
||||
.editor-button img {
|
||||
display: block;
|
||||
height: 1.65rem;
|
||||
width: 1.65rem;
|
||||
padding: .55rem .85rem;
|
||||
}
|
||||
|
||||
.editor-media-actions {
|
||||
@@ -511,6 +530,11 @@
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.editor-gpx-group {
|
||||
display: grid;
|
||||
gap: .6rem;
|
||||
}
|
||||
|
||||
.editor-secondary-button,
|
||||
.editor-icon-button {
|
||||
background: #fff;
|
||||
@@ -559,11 +583,8 @@
|
||||
right: calc(max(.5rem, env(safe-area-inset-right)) + var(--icon-button-size) + .35rem);
|
||||
}
|
||||
|
||||
.route-control-button,
|
||||
.route-control-button.ui-button {
|
||||
width: min(10.5rem, calc(100vw - var(--icon-button-size) - 4.7rem));
|
||||
padding-right: .55rem;
|
||||
padding-left: .55rem;
|
||||
.route-control-summary {
|
||||
width: min(10.5rem, calc(100vw - (var(--icon-button-size) * 2) - 5rem));
|
||||
}
|
||||
|
||||
.route-control-dropdown {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
||||
<path d="M11 29 14.2 3h3.6L21 29" stroke="white" stroke-width="2.8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M16 5v4m0 4v4m0 4v4" stroke="white" stroke-width="2.2" stroke-linecap="round"/>
|
||||
<path d="M7 29h18" stroke="white" stroke-width="2.8" stroke-linecap="round"/>
|
||||
<path d="M7.5 29C23.5 25.5 24.5 21 14 17C4.5 13.4 6.2 7.4 20.5 3" stroke="white" stroke-width="3" stroke-linecap="round"/>
|
||||
<path d="M14.5 29C30 25.5 31 19.6 20.5 15.7C11.5 12.3 13 7.4 26 3" stroke="white" stroke-width="3" stroke-linecap="round"/>
|
||||
<path d="M11 29C26.8 25.5 27.8 20.3 17.2 16.3C7.9 12.8 9.6 7.4 23.3 3" stroke="#f2ca6b" stroke-width="1.7" stroke-linecap="round" stroke-dasharray="2.7 3.5"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 370 B After Width: | Height: | Size: 492 B |
+29
-24
@@ -91,6 +91,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p id="android-browser-hint" class="android-browser-hint" hidden></p>
|
||||
|
||||
<div class="privacy-summary">
|
||||
<p><strong>Cookies:</strong> Diese Anwendung setzt derzeit keine Cookies und verwendet weder Werbung noch Tracking.</p>
|
||||
<label class="switch-row switch-disabled">
|
||||
@@ -104,7 +106,7 @@
|
||||
<label class="switch-row">
|
||||
<span>
|
||||
<strong>Streckenfortschritt lokal speichern</strong>
|
||||
<small>Speichert ausgewählte Routen und besuchte Stationen ausschließlich in diesem Browser.</small>
|
||||
<small>Speichert ausgewählte Routen und besuchte Streckenpunkte ausschließlich in diesem Browser.</small>
|
||||
</span>
|
||||
<input class="progress-storage-toggle" type="checkbox" role="switch" aria-checked="false">
|
||||
<span class="switch-track" aria-hidden="true"></span>
|
||||
@@ -126,9 +128,12 @@
|
||||
</div>
|
||||
<main id="app-shell" hidden>
|
||||
<div class="route-control">
|
||||
<button id="route-control-button" class="route-control-button" type="button" aria-label="Strecke auswählen oder wechseln" title="Strecke auswählen oder wechseln" aria-haspopup="true" aria-expanded="false" aria-controls="route-control-dropdown">
|
||||
<div class="route-control-summary" aria-live="polite">
|
||||
<strong id="route-control-title">Route wählen</strong>
|
||||
<small id="route-control-next">Aktive und lokale Strecken</small>
|
||||
</div>
|
||||
<button id="route-control-button" class="route-control-button" type="button" aria-label="Strecke auswählen oder wechseln" title="Strecke auswählen oder wechseln" aria-haspopup="true" aria-expanded="false" aria-controls="route-control-dropdown">
|
||||
<img src="images/icons/route-management.svg" alt="" aria-hidden="true">
|
||||
</button>
|
||||
<nav id="route-control-dropdown" class="route-control-dropdown" aria-label="Streckenauswahl und Streckenverwaltung" hidden>
|
||||
<section aria-labelledby="route-control-server-title">
|
||||
@@ -140,9 +145,7 @@
|
||||
<ul id="route-control-local-list" class="route-control-list" aria-live="polite"></ul>
|
||||
</section>
|
||||
<div class="route-control-actions">
|
||||
<button id="editor-button" class="editor-button" type="button" aria-label="Streckenverwaltung öffnen" title="Streckenverwaltung öffnen" aria-haspopup="dialog" aria-expanded="false" aria-controls="editor-dialog">
|
||||
<img src="images/icons/route-management.svg" alt="" aria-hidden="true">
|
||||
</button>
|
||||
<button id="editor-button" class="editor-button" type="button" aria-label="Streckenverwaltung öffnen" title="Streckenverwaltung öffnen" aria-haspopup="dialog" aria-expanded="false" aria-controls="editor-dialog">Strecken verwalten</button>
|
||||
<button id="route-control-new" class="editor-primary-button" type="button">Neue lokale Strecke</button>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -198,7 +201,7 @@
|
||||
<dl>
|
||||
<div><dt>Länge</dt><dd id="route-distance">–</dd></div>
|
||||
<div><dt>Höhenmeter</dt><dd id="route-elevation">–</dd></div>
|
||||
<div><dt>POIs</dt><dd id="route-poi-count">–</dd></div>
|
||||
<div><dt>Streckenpunkte</dt><dd id="route-poi-count">–</dd></div>
|
||||
</dl>
|
||||
<fieldset>
|
||||
<legend>Routensteuerung</legend>
|
||||
@@ -213,7 +216,7 @@
|
||||
</button>
|
||||
</fieldset>
|
||||
<p id="tracking-status" class="notice" hidden></p>
|
||||
<h2>Stationen</h2>
|
||||
<h2>Streckenpunkte</h2>
|
||||
<ul id="poi-list" class="route-list"></ul>
|
||||
</div>
|
||||
</section>
|
||||
@@ -222,7 +225,7 @@
|
||||
<button class="back-button" type="button" data-page-target="#route-page" aria-label="Zurück zur Route" title="Zurück zur Route">
|
||||
<img src="images/icons/back.svg" alt="">
|
||||
</button>
|
||||
<h1 id="poi-title">Station</h1>
|
||||
<h1 id="poi-title">Streckenpunkt</h1>
|
||||
</header>
|
||||
<div class="content">
|
||||
<div id="slideshow" aria-live="polite">
|
||||
@@ -244,7 +247,7 @@
|
||||
</fieldset>
|
||||
</div>
|
||||
<p id="poi-description"></p>
|
||||
<audio id="poi-audio" controls preload="metadata" aria-label="Audiowiedergabe zur Station" hidden>Audio kann auf diesem Gerät nicht wiedergegeben werden.</audio>
|
||||
<audio id="poi-audio" controls preload="metadata" aria-label="Audiowiedergabe zum Streckenpunkt" hidden>Audio kann auf diesem Gerät nicht wiedergegeben werden.</audio>
|
||||
</div>
|
||||
</section>
|
||||
<footer id="navigation-footer" hidden aria-live="polite">
|
||||
@@ -252,7 +255,7 @@
|
||||
<path d="M32 4 53 54 32 44 11 54Z"/>
|
||||
</svg>
|
||||
<p>
|
||||
<span id="navigation-mode">Richtung zum nächsten POI</span>
|
||||
<span id="navigation-mode">Richtung zum nächsten Streckenpunkt</span>
|
||||
<strong id="navigation-distance">–</strong>
|
||||
</p>
|
||||
</footer>
|
||||
@@ -291,7 +294,7 @@
|
||||
<h3>3. Standortdaten</h3>
|
||||
<p>Die Standortfreigabe ist freiwillig. Sie wird benötigt, um nahe Routen vorzuschlagen und während einer gestarteten Route Entfernungen und Richtungen zu berechnen. Die Anwendung verarbeitet die laufende Position im Browser. Sie legt daraus keinen serverseitigen Standortverlauf an.</p>
|
||||
<h3>4. Lokaler Streckenfortschritt</h3>
|
||||
<p>Nach ausdrücklicher Aktivierung speichert die Anwendung ein JSON-Objekt im Local Storage dieses Browsers. Es enthält die aktuell ausgewählte Strecke, besuchte und angesehene Stationen, abgespielte Audioeinträge, den Routenstatus sowie Zeitstempel. Diese Angaben bleiben auf dem verwendeten Gerät und werden nicht mit dem Server synchronisiert.</p>
|
||||
<p>Nach ausdrücklicher Aktivierung speichert die Anwendung ein JSON-Objekt im Local Storage dieses Browsers. Es enthält die aktuell ausgewählte Strecke, besuchte und angesehene Streckenpunkte, abgespielte Audioeinträge, den Routenstatus sowie Zeitstempel. Diese Angaben bleiben auf dem verwendeten Gerät und werden nicht mit dem Server synchronisiert.</p>
|
||||
<p>Gespeicherte Streckendaten werden beim nächsten Öffnen entfernt, sobald ihre letzte Aktualisierung mindestens 24 Stunden zurückliegt. Beim Ausschalten der Funktion werden die Streckendaten sofort gelöscht.</p>
|
||||
<label class="switch-row compact-switch">
|
||||
<span>
|
||||
@@ -302,7 +305,7 @@
|
||||
<span class="switch-track" aria-hidden="true"></span>
|
||||
</label>
|
||||
<h3>5. Lokale Streckenentwürfe</h3>
|
||||
<p>Neu angelegte Strecken werden zunächst ausschließlich im Local Storage dieses Browsers gespeichert. Die GPX-Daten enthalten den Streckenverlauf und die POI-Koordinaten. Ergänzende JSON-Daten ordnen den POIs Texte, Audiodateien und Bilder zu. Medien werden dafür als lokale Browserdaten gespeichert.</p>
|
||||
<p>Neu angelegte Strecken werden zunächst ausschließlich im Local Storage dieses Browsers gespeichert. Die GPX-Daten enthalten den Streckenverlauf und die Koordinaten der Streckenpunkte. Ergänzende JSON-Daten ordnen den Streckenpunkten Texte und Medienreferenzen zu. Bilder und Audiodateien werden platzsparend im lokalen Browser-Medienspeicher gespeichert.</p>
|
||||
<p>Lokale Streckenentwürfe sind bewusst von der freiwilligen Fortschrittsspeicherung getrennt. Sie werden nicht durch die 24-Stunden-Bereinigung entfernt und beim Ausschalten der Fortschrittsspeicherung nicht gelöscht. Sie verbleiben im Browser, bis sie ausdrücklich gelöscht werden oder die Browserdaten entfernt werden. Eine Übertragung an den Server erfolgt nur nach ausdrücklicher Betätigung des Upload-Buttons im Editor.</p>
|
||||
<h3>6. Cookies und Tracking</h3>
|
||||
<p>Die Anwendung setzt derzeit keine Cookies. Sie verwendet keine Reichweitenmessung, keine personalisierte Werbung und keine Trackingdienste. Eine technisch notwendige Local-Storage-Einstellung merkt sich lediglich, ob die freiwillige Speicherung des Streckenfortschritts aktiviert wurde.</p>
|
||||
@@ -366,7 +369,7 @@
|
||||
</label>
|
||||
<h3>Local Storage</h3>
|
||||
<p>Der Browser kann auf Wunsch Streckenfortschritte lokal speichern. Verwendet werden die Schlüssel <code>wegwichtel.privacy.v1</code> für die gewählte Einstellung und <code>wegwichtel.route-progress.v1</code> für die freiwilligen Streckendaten. Die Streckendaten werden nach 24 Stunden bereinigt oder beim Ausschalten sofort entfernt.</p>
|
||||
<p>Lokale Streckenentwürfe werden getrennt davon unter <code>wegwichtel.local-routes.v1.*</code> gespeichert. Pro Entwurf werden GPX- und JSON-Daten vorgehalten; Bilder und Audiodateien liegen als Bestandteil der lokalen JSON-Daten im Browser. Diese Entwürfe werden nicht nach 24 Stunden bereinigt und sind nicht vom Schalter für den Streckenfortschritt abhängig. Durch Mediendateien kann das Speicherlimit des Browsers vergleichsweise schnell erreicht werden. Eine Serverübertragung erfolgt ausschließlich nach ausdrücklicher Betätigung des Upload-Buttons im Editor.</p>
|
||||
<p>Lokale Streckenentwürfe werden getrennt davon unter <code>wegwichtel.local-routes.v1.*</code> gespeichert. Pro Entwurf werden GPX- und JSON-Daten im Local Storage vorgehalten; Bilder und Audiodateien liegen getrennt davon im lokalen Browser-Medienspeicher (IndexedDB). Diese Entwürfe werden nicht nach 24 Stunden bereinigt und sind nicht vom Schalter für den Streckenfortschritt abhängig. Durch Mediendateien kann das Speicherlimit des Browsers vergleichsweise schnell erreicht werden. Eine Serverübertragung erfolgt ausschließlich nach ausdrücklicher Betätigung des Upload-Buttons im Editor.</p>
|
||||
<label class="switch-row compact-switch">
|
||||
<span>
|
||||
<strong>Streckenfortschritt lokal speichern</strong>
|
||||
@@ -386,13 +389,13 @@
|
||||
<ul>
|
||||
<li>Geolocation API für freiwillige Standortdaten,</li>
|
||||
<li>Device Orientation API für den Richtungspfeil,</li>
|
||||
<li>Vibration API für Hinweise bei erreichten Stationen,</li>
|
||||
<li>Vibration API für Hinweise bei erreichten Streckenpunkten,</li>
|
||||
<li>HTML-Audio und HTML-Bilder für lokal bereitgestellte Medien,</li>
|
||||
<li>FileReader und XML-Schnittstellen zum lokalen Verarbeiten von GPX-, Bild- und Audiodateien,</li>
|
||||
<li>File System Access API und IndexedDB für lokale Medienreferenzen sowie FileReader und XML-Schnittstellen zum Verarbeiten von GPX-Dateien,</li>
|
||||
<li>History API für die Navigation zwischen den Ansichten.</li>
|
||||
</ul>
|
||||
<h3>Technische Grenzen</h3>
|
||||
<p>Browser und Betriebssystem können Standort, Kompass, Audio, Vibration oder Local Storage sperren. Die Anwendung bleibt grundsätzlich nutzbar, einzelne Komfortfunktionen können dann jedoch fehlen. Für lokale Streckenentwürfe gilt zusätzlich das vom Browser vorgegebene Local-Storage-Limit.</p>
|
||||
<p>Browser und Betriebssystem können Standort, Kompass, Audio, Vibration oder lokale Speicherfunktionen sperren. Die Anwendung bleibt grundsätzlich nutzbar, einzelne Komfortfunktionen können dann jedoch fehlen. Lokale Streckenmetadaten liegen im Local Storage; Medienreferenzen beziehungsweise der kompatible Blob-Fallback liegen getrennt in IndexedDB.</p>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
@@ -427,8 +430,10 @@
|
||||
|
||||
<label for="editor-route-description">Beschreibung</label>
|
||||
<textarea id="editor-route-description" rows="4"></textarea>
|
||||
<label for="editor-route-gpx">GPX-Datei importieren oder ersetzen</label>
|
||||
<input id="editor-route-gpx" type="file" accept=".gpx,application/gpx+xml,text/xml,application/xml">
|
||||
<div id="editor-route-gpx-group" class="editor-gpx-group">
|
||||
<label for="editor-route-gpx">GPX-Datei importieren oder ersetzen</label>
|
||||
<input id="editor-route-gpx" type="file" accept=".gpx,application/gpx+xml,text/xml,application/xml">
|
||||
</div>
|
||||
|
||||
<p id="editor-route-activated" class="editor-muted">Neue Strecken werden zunächst lokal gespeichert. Der Upload-Button überträgt den Entwurf als unveröffentlichte Strecke zur Prüfung an den Server.</p>
|
||||
|
||||
@@ -438,8 +443,8 @@
|
||||
</div>
|
||||
</form>
|
||||
<div class="editor-toolbar editor-poi-toolbar">
|
||||
<h3>Trackpunkte</h3>
|
||||
<button id="editor-add-poi" class="editor-round-button" type="button" aria-label="Trackpunkt hinzufügen" title="Trackpunkt hinzufügen">
|
||||
<h3>Streckenpunkte</h3>
|
||||
<button id="editor-add-poi" class="editor-round-button" type="button" aria-label="Streckenpunkt hinzufügen" title="Streckenpunkt hinzufügen">
|
||||
<span aria-hidden="true">+</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -448,7 +453,7 @@
|
||||
<section id="editor-poi-form-view" class="editor-view" aria-labelledby="editor-poi-form-title" hidden>
|
||||
<div class="editor-toolbar editor-toolbar-back">
|
||||
<button id="editor-poi-cancel" class="editor-secondary-button" type="button">Zurück zur Strecke</button>
|
||||
<h3 id="editor-poi-form-title">Trackpunkt bearbeiten</h3>
|
||||
<h3 id="editor-poi-form-title">Streckenpunkt bearbeiten</h3>
|
||||
</div>
|
||||
|
||||
<form id="editor-poi-form" class="editor-form">
|
||||
@@ -463,7 +468,7 @@
|
||||
<input id="editor-poi-lon" type="hidden">
|
||||
<input id="editor-poi-radius" type="hidden" value="25">
|
||||
|
||||
<div class="editor-media-actions" aria-label="Medien zum Trackpunkt hinzufügen">
|
||||
<div class="editor-media-actions" aria-label="Medien zum Streckenpunkt hinzufügen">
|
||||
<button id="editor-poi-audio-add" class="editor-media-button" type="button" title="Audio hinzufügen">
|
||||
<svg class="editor-media-icon" viewBox="0 0 32 32" aria-hidden="true" focusable="false">
|
||||
<path d="M18 5v15.2a5 5 0 1 1-2-4V9l10-2v9.2a5 5 0 1 1-2-4V4.6z"/>
|
||||
@@ -486,10 +491,10 @@
|
||||
<p id="editor-poi-media-status" class="editor-muted" aria-live="polite"></p>
|
||||
|
||||
<div class="editor-form-actions">
|
||||
<button id="editor-poi-save" class="editor-primary-button" type="submit">Trackpunkt speichern</button>
|
||||
<button id="editor-poi-save" class="editor-primary-button" type="submit">Streckenpunkt speichern</button>
|
||||
<button id="editor-poi-delete" class="editor-danger-button" type="button" hidden>
|
||||
<span class="editor-danger-icon" aria-hidden="true">−</span>
|
||||
<span>Trackpunkt entfernen</span>
|
||||
<span>Streckenpunkt entfernen</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
+13
-13
@@ -161,11 +161,11 @@
|
||||
title.text(state.route.name || 'Route');
|
||||
const metric = nextPoiMetric();
|
||||
if (!metric) {
|
||||
next.text((state.route.pois || []).length ? 'Alle POIs erreicht' : 'Keine POIs vorhanden');
|
||||
next.text((state.route.pois || []).length ? 'Alle Streckenpunkte erreicht' : 'Keine Streckenpunkte vorhanden');
|
||||
return;
|
||||
}
|
||||
const distance = metric.distance == null ? '' : ` · ${Math.round(metric.distance)} m`;
|
||||
next.text(`Nächster POI: ${metric.poi.title}${distance}`);
|
||||
next.text(`Nächster Streckenpunkt: ${metric.poi.title}${distance}`);
|
||||
}
|
||||
|
||||
function renderRouteControlList(selector, routes, source, emptyText) {
|
||||
@@ -377,7 +377,7 @@
|
||||
updateRouteControls();
|
||||
const list = $('#poi-list').empty();
|
||||
if (!route.pois.length) {
|
||||
list.append('<li><p>Diese Route enthält noch keine Stationen.</p></li>');
|
||||
list.append('<li><p>Diese Route enthält noch keine Streckenpunkte.</p></li>');
|
||||
updateRouteControlSummary();
|
||||
return;
|
||||
}
|
||||
@@ -530,13 +530,13 @@
|
||||
$('#navigation-arrow').css('transform', `rotate(${metric.rotation.toFixed(1)}deg)`);
|
||||
showNavigation(
|
||||
heading == null
|
||||
? `Norden oben · nächster POI: ${metric.poi.title}`
|
||||
: `Nächster POI: ${metric.poi.title}`,
|
||||
? `Norden oben · nächster Streckenpunkt: ${metric.poi.title}`
|
||||
: `Nächster Streckenpunkt: ${metric.poi.title}`,
|
||||
`${distance} m`
|
||||
);
|
||||
$('#navigation-footer').attr(
|
||||
'aria-label',
|
||||
`Nächster POI ${metric.poi.title} in ${distance} Metern`
|
||||
`Nächster Streckenpunkt ${metric.poi.title} in ${distance} Metern`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -549,8 +549,8 @@
|
||||
|
||||
if (progress.reached) {
|
||||
$('#navigation-arrow').css('transform', 'rotate(0deg)');
|
||||
showNavigation('Alle Stationen und das Routenziel erreicht', '0 m');
|
||||
$('#navigation-footer').attr('aria-label', 'Alle Stationen und das Routenziel erreicht');
|
||||
showNavigation('Alle Streckenpunkte und das Routenziel erreicht', '0 m');
|
||||
$('#navigation-footer').attr('aria-label', 'Alle Streckenpunkte und das Routenziel erreicht');
|
||||
return;
|
||||
}
|
||||
const bearing = ns.Distance.bearing(position, progress.target);
|
||||
@@ -602,7 +602,7 @@
|
||||
updateNavigation(position);
|
||||
$('#tracking-status').text(
|
||||
`GPS aktiv · Genauigkeit ${Math.round(position.accuracy)} m`
|
||||
+ (nearest ? ` · nächster POI ${Math.round(nearest.distance)} m` : ' · alle POIs erreicht')
|
||||
+ (nearest ? ` · nächster Streckenpunkt ${Math.round(nearest.distance)} m` : ' · alle Streckenpunkte erreicht')
|
||||
);
|
||||
if (nearest && nearest.distance <= nearest.poi.triggerRadiusM) {
|
||||
state.triggered.add(nearest.poi.id);
|
||||
@@ -705,17 +705,17 @@
|
||||
async function selectRoute(source, id) {
|
||||
setRouteControlOpen(false);
|
||||
const route = source === 'local'
|
||||
? ns.LocalRoutes.getAppRoute(id)
|
||||
? await ns.LocalRoutes.getAppRoute(id, { requestPermission: true })
|
||||
: { ...(await ns.Api.route(id)), source: 'server', local: false };
|
||||
renderRoute(route);
|
||||
navigate('#route-page');
|
||||
}
|
||||
|
||||
function refreshCurrentLocalRoute(id) {
|
||||
async function refreshCurrentLocalRoute(id) {
|
||||
if (state.route?.source !== 'local' || String(state.route.id) !== String(id)) return;
|
||||
persistRouteProgress();
|
||||
try {
|
||||
state.route = ns.LocalRoutes.getAppRoute(id);
|
||||
state.route = await ns.LocalRoutes.getAppRoute(id);
|
||||
renderRouteView(state.route);
|
||||
renderRouteControl();
|
||||
} catch {
|
||||
@@ -800,7 +800,7 @@
|
||||
document.addEventListener('wegwichtel:local-routes-changed', event => {
|
||||
loadLocalRoutes();
|
||||
if (state.position) updateRouteProximity(state.position);
|
||||
refreshCurrentLocalRoute(event.detail?.id);
|
||||
void refreshCurrentLocalRoute(event.detail?.id);
|
||||
renderRoutes();
|
||||
ns.EditorUi?.refresh?.();
|
||||
});
|
||||
|
||||
Vendored
+21
@@ -21,12 +21,32 @@
|
||||
const cleanupStatus = document.getElementById('storage-cleanup-status');
|
||||
const actionStatus = document.getElementById('bootstrap-action-status');
|
||||
const statusText = document.getElementById('bootstrap-status-text');
|
||||
const androidBrowserHint = document.getElementById('android-browser-hint');
|
||||
let ready = false;
|
||||
let statusTransition = 0;
|
||||
const completedSteps = new Set();
|
||||
|
||||
const versioned = path => `${path}${path.includes('?') ? '&' : '?'}v=${encodeURIComponent(versions.app)}`;
|
||||
|
||||
function updateAndroidBrowserHint() {
|
||||
if (!androidBrowserHint) return;
|
||||
const userAgent = globalThis.navigator?.userAgent || '';
|
||||
const platform = globalThis.navigator?.userAgentData?.platform || '';
|
||||
const isAndroid = /Android/i.test(`${userAgent} ${platform}`);
|
||||
if (!isAndroid) {
|
||||
androidBrowserHint.hidden = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const isChrome = /Chrome\//i.test(userAgent)
|
||||
&& !/(?:EdgA|OPR|SamsungBrowser)\//i.test(userAgent);
|
||||
androidBrowserHint.hidden = false;
|
||||
androidBrowserHint.dataset.browser = isChrome ? 'chrome' : 'other';
|
||||
androidBrowserHint.textContent = isChrome
|
||||
? 'Android mit Chrome erkannt. Lokale Bilder und Audiodateien können als Dateireferenzen verwendet werden.'
|
||||
: 'Android erkannt. Für lokale Bilder und Audiodateien bitte Chrome verwenden; dort können Dateireferenzen ohne Kopie in den Local Storage genutzt werden.';
|
||||
}
|
||||
|
||||
function schedule(callback, delay = 0) {
|
||||
if (typeof globalThis.setTimeout === 'function') {
|
||||
return globalThis.setTimeout(callback, delay);
|
||||
@@ -218,5 +238,6 @@
|
||||
enter.addEventListener('pointerup', openApplication);
|
||||
enter.addEventListener('keydown', openApplication);
|
||||
screen.addEventListener('pointerup', openApplication);
|
||||
updateAndroidBrowserHint();
|
||||
boot();
|
||||
}());
|
||||
|
||||
+174
-56
@@ -36,6 +36,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
routeDetailView: 'editor-route-detail-view',
|
||||
routeForm: 'editor-route-form',
|
||||
routeGpx: 'editor-route-gpx',
|
||||
routeGpxGroup: 'editor-route-gpx-group',
|
||||
routeId: 'editor-route-id',
|
||||
routeList: 'editor-route-list',
|
||||
routeListTitle: 'editor-route-list-title',
|
||||
@@ -88,6 +89,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
currentRoute: null,
|
||||
currentSource: null,
|
||||
pendingAudioFile: null,
|
||||
pendingAudioHandle: null,
|
||||
pendingImages: [],
|
||||
pois: [],
|
||||
routes: [],
|
||||
@@ -128,7 +130,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
}
|
||||
|
||||
function poiTitle(poi) {
|
||||
return text(poi?.title || poi?.name || 'Unbenannter Trackpunkt');
|
||||
return text(poi?.title || poi?.name || 'Unbenannter Streckenpunkt');
|
||||
}
|
||||
|
||||
function apiBase() {
|
||||
@@ -193,7 +195,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
}
|
||||
|
||||
function showPoiView() {
|
||||
setDialogTitle(state.currentPoi ? 'Trackpunkt bearbeiten' : 'Trackpunkt hinzufügen');
|
||||
setDialogTitle(state.currentPoi ? 'Streckenpunkt bearbeiten' : 'Streckenpunkt hinzufügen');
|
||||
setHidden(byId(ids.routeListView), true);
|
||||
setHidden(byId(ids.routeDetailView), true);
|
||||
setHidden(byId(ids.poiFormView), false);
|
||||
@@ -357,7 +359,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
const title = poiTitle(poi);
|
||||
const label = document.createElement('div');
|
||||
appendText(label, 'strong', title);
|
||||
appendText(label, 'small', `Trackpunkt ${index + 1}`);
|
||||
appendText(label, 'small', `Streckenpunkt ${index + 1}`);
|
||||
const actions = document.createElement('div');
|
||||
actions.className = classes.iconRow;
|
||||
actions.appendChild(iconButton(`${title} nach oben schieben`, '↑', 'move-poi-up', index === 0));
|
||||
@@ -397,7 +399,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
if (!state.pois.length) {
|
||||
const item = document.createElement('li');
|
||||
item.className = classes.poiItem;
|
||||
appendText(item, 'span', 'Noch keine Trackpunkte angelegt.');
|
||||
appendText(item, 'span', 'Noch keine Streckenpunkte angelegt.');
|
||||
list.appendChild(item);
|
||||
return;
|
||||
}
|
||||
@@ -476,6 +478,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
byId(ids.routeName).value = routeName(route) === 'Unbenannte Strecke' ? '' : routeName(route);
|
||||
byId(ids.routeDescription).value = text(route?.description);
|
||||
byId(ids.routeGpx).value = '';
|
||||
setHidden(byId(ids.routeGpxGroup), !routeId(route));
|
||||
|
||||
const uploadButton = byId(ids.routeUpload);
|
||||
if (state.currentSource === 'local') {
|
||||
@@ -495,8 +498,21 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
}
|
||||
}
|
||||
|
||||
function objectUrlFor(file) {
|
||||
if (!file || typeof globalThis.URL?.createObjectURL !== 'function') return null;
|
||||
return globalThis.URL.createObjectURL(file);
|
||||
}
|
||||
|
||||
function releasePendingPreview(pending) {
|
||||
const url = pending?.previewUrl;
|
||||
if (!url || !url.startsWith('blob:') || typeof globalThis.URL?.revokeObjectURL !== 'function') return;
|
||||
globalThis.URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function resetPendingMedia() {
|
||||
state.pendingAudioFile = null;
|
||||
state.pendingAudioHandle = null;
|
||||
state.pendingImages.forEach(releasePendingPreview);
|
||||
state.pendingImages = [];
|
||||
state.selectedMediaKey = null;
|
||||
if (byId(ids.poiAudio)) byId(ids.poiAudio).value = '';
|
||||
@@ -504,7 +520,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
}
|
||||
|
||||
function existingAudioUrl(poi) {
|
||||
return poi?.audio?.dataUrl || poi?.audioUrl || poi?.audio_path || null;
|
||||
return poi?.audio?.url || poi?.audio?.dataUrl || poi?.audioUrl || poi?.audio_path || null;
|
||||
}
|
||||
|
||||
function imagePreviewUrl(image) {
|
||||
@@ -531,11 +547,11 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
image.alt = '';
|
||||
select.appendChild(image);
|
||||
} else {
|
||||
const audio = document.createElement('span');
|
||||
audio.className = classes.mediaPreviewAudio;
|
||||
audio.setAttribute('aria-hidden', 'true');
|
||||
audio.textContent = '♪';
|
||||
select.appendChild(audio);
|
||||
const mediaSymbol = document.createElement('span');
|
||||
mediaSymbol.className = classes.mediaPreviewAudio;
|
||||
mediaSymbol.setAttribute('aria-hidden', 'true');
|
||||
mediaSymbol.textContent = kind === 'image' ? '▧' : '♪';
|
||||
select.appendChild(mediaSymbol);
|
||||
}
|
||||
appendText(select, 'small', label);
|
||||
|
||||
@@ -562,7 +578,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
if (!preview) return;
|
||||
preview.textContent = '';
|
||||
|
||||
if (existingAudioUrl(poi)) {
|
||||
if (poi?.audio || existingAudioUrl(poi)) {
|
||||
preview.appendChild(mediaPreviewCard({
|
||||
key: 'existing-audio',
|
||||
kind: 'audio',
|
||||
@@ -599,7 +615,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
kind: 'image',
|
||||
label: pending.file?.name || `Neues Bild ${index + 1}`,
|
||||
mediaIndex: index,
|
||||
previewUrl: pending.preview?.dataUrl || null,
|
||||
previewUrl: pending.previewUrl || null,
|
||||
source: 'pending'
|
||||
}));
|
||||
});
|
||||
@@ -608,7 +624,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
function updatePoiMediaStatus(poi = state.currentPoi) {
|
||||
const status = byId(ids.poiMediaStatus);
|
||||
if (!status) return;
|
||||
const existingAudio = existingAudioUrl(poi) ? 1 : 0;
|
||||
const existingAudio = poi?.audio || existingAudioUrl(poi) ? 1 : 0;
|
||||
const existingImages = Array.isArray(poi?.images) ? poi.images.length : 0;
|
||||
const audioCount = existingAudio + (state.pendingAudioFile ? 1 : 0);
|
||||
const imageCount = existingImages + state.pendingImages.length;
|
||||
@@ -627,10 +643,65 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
if (input && typeof input.click === 'function') input.click();
|
||||
}
|
||||
|
||||
function pickerOptions(kind) {
|
||||
if (kind === 'audio') {
|
||||
return {
|
||||
multiple: false,
|
||||
types: [{ description: 'Audio', accept: { 'audio/*': ['.mp3', '.m4a', '.wav', '.ogg', '.webm'] } }]
|
||||
};
|
||||
}
|
||||
return {
|
||||
multiple: true,
|
||||
types: [{ description: 'Bilder', accept: { 'image/*': ['.jpg', '.jpeg', '.png', '.webp'] } }]
|
||||
};
|
||||
}
|
||||
|
||||
async function pickWithFileSystemAccess(kind) {
|
||||
if (state.currentSource !== 'local' || typeof globalThis.showOpenFilePicker !== 'function') return false;
|
||||
let handles;
|
||||
try {
|
||||
handles = await globalThis.showOpenFilePicker(pickerOptions(kind));
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (kind === 'audio') {
|
||||
const handle = handles?.[0];
|
||||
if (!handle) return true;
|
||||
state.pendingAudioHandle = handle;
|
||||
state.pendingAudioFile = await handle.getFile();
|
||||
} else {
|
||||
for (const handle of handles || []) {
|
||||
const file = await handle.getFile();
|
||||
state.pendingImages.push({
|
||||
file,
|
||||
handle,
|
||||
key: `pending-image-${ns.LocalRoutes.createPoiId()}`,
|
||||
previewUrl: objectUrlFor(file)
|
||||
});
|
||||
}
|
||||
}
|
||||
state.selectedMediaKey = null;
|
||||
renderPoiMediaPreview();
|
||||
updatePoiMediaStatus();
|
||||
return true;
|
||||
}
|
||||
|
||||
function chooseMedia(kind, inputId) {
|
||||
if (state.currentSource !== 'local' || typeof globalThis.showOpenFilePicker !== 'function') {
|
||||
openFilePicker(inputId);
|
||||
return null;
|
||||
}
|
||||
return pickWithFileSystemAccess(kind).then(handled => {
|
||||
if (!handled) openFilePicker(inputId);
|
||||
});
|
||||
}
|
||||
|
||||
function fillPoiForm(poi) {
|
||||
resetPendingMedia();
|
||||
byId(ids.poiId).value = poiId(poi);
|
||||
byId(ids.poiTitle).value = poiTitle(poi) === 'Unbenannter Trackpunkt' ? '' : poiTitle(poi);
|
||||
byId(ids.poiTitle).value = poiTitle(poi) === 'Unbenannter Streckenpunkt' ? '' : poiTitle(poi);
|
||||
byId(ids.poiDescription).value = text(poi?.description || poi?.text);
|
||||
byId(ids.poiLat).value = text(poi?.lat ?? poi?.latitude);
|
||||
byId(ids.poiLon).value = text(poi?.lon ?? poi?.longitude);
|
||||
@@ -641,6 +712,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
}
|
||||
|
||||
function handleAudioSelection() {
|
||||
state.pendingAudioHandle = null;
|
||||
state.pendingAudioFile = byId(ids.poiAudio)?.files?.[0] || null;
|
||||
state.selectedMediaKey = null;
|
||||
if (byId(ids.poiAudio)) byId(ids.poiAudio).value = '';
|
||||
@@ -653,8 +725,9 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
for (const file of files) {
|
||||
state.pendingImages.push({
|
||||
file,
|
||||
handle: null,
|
||||
key: `pending-image-${ns.LocalRoutes.createPoiId()}`,
|
||||
preview: await ns.LocalRoutes.fileToDataUrl(file)
|
||||
previewUrl: objectUrlFor(file)
|
||||
});
|
||||
}
|
||||
state.selectedMediaKey = null;
|
||||
@@ -670,7 +743,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
state.currentSource = routeSource(route);
|
||||
|
||||
if (state.currentSource === 'local') {
|
||||
const routePackage = ns.LocalRoutes.get(id);
|
||||
const routePackage = await ns.LocalRoutes.getWithMedia(id, { requestPermission: true });
|
||||
if (!routePackage) throw new Error('Der lokale Entwurf wurde nicht gefunden.');
|
||||
state.currentRoute = {
|
||||
...routePackage.route,
|
||||
@@ -793,12 +866,11 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
}
|
||||
|
||||
async function storedMediaBlob(media) {
|
||||
if (!media?.dataUrl) return null;
|
||||
const response = await fetch(media.dataUrl);
|
||||
if (!response.ok || typeof response.blob !== 'function') {
|
||||
throw new Error(`Die Mediendatei ${media.name || ''} konnte nicht für den Upload vorbereitet werden.`.trim());
|
||||
const blob = await ns.LocalRoutes.mediaBlob(media, { requestPermission: true });
|
||||
if (!blob) {
|
||||
throw new Error(`Die Mediendatei ${media?.name || ''} konnte nicht für den Upload vorbereitet werden.`.trim());
|
||||
}
|
||||
return response.blob();
|
||||
return blob;
|
||||
}
|
||||
|
||||
async function uploadStoredMedia(path, fieldName, media, fallbackName) {
|
||||
@@ -811,11 +883,11 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
|
||||
async function uploadLocalPoiMedia(serverRouteId, serverPoiId, poi) {
|
||||
const basePath = `/routes/${encodeURIComponent(serverRouteId)}/pois/${encodeURIComponent(serverPoiId)}`;
|
||||
if (poi.audio?.dataUrl) {
|
||||
if (poi.audio) {
|
||||
await uploadStoredMedia(`${basePath}/audio`, 'audio', poi.audio, 'audio.bin');
|
||||
}
|
||||
for (const [index, image] of (poi.images || []).entries()) {
|
||||
if (!image?.dataUrl) continue;
|
||||
if (!image) continue;
|
||||
await uploadStoredMedia(`${basePath}/pictures`, 'picture', image, `bild-${index + 1}.bin`);
|
||||
}
|
||||
}
|
||||
@@ -831,7 +903,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
const orderedPois = [...(routePackage.pois || [])]
|
||||
.sort((left, right) => Number(left.sequence) - Number(right.sequence));
|
||||
for (const [index, poi] of orderedPois.entries()) {
|
||||
const poiTitle = poi.title || `Station ${index + 1}`;
|
||||
const poiTitle = poi.title || `Streckenpunkt ${index + 1}`;
|
||||
const poiPayload = await sendJson(`/routes/${encodeURIComponent(createdRouteId)}/pois`, 'POST', {
|
||||
description: poi.description || '',
|
||||
lat: poi.lat,
|
||||
@@ -842,7 +914,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
});
|
||||
const serverPoi = poiPayload.poi || poiPayload;
|
||||
const serverPoiId = poiId(serverPoi);
|
||||
if (!serverPoiId) throw new Error(`Für ${poiTitle} wurde keine Server-POI-ID zurückgegeben.`);
|
||||
if (!serverPoiId) throw new Error(`Für ${poiTitle} wurde keine Server-ID zurückgegeben.`);
|
||||
await uploadLocalPoiMedia(createdRouteId, serverPoiId, poi);
|
||||
}
|
||||
return createdRouteId;
|
||||
@@ -905,7 +977,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
const lat = Number(position?.lat);
|
||||
const lon = Number(position?.lon);
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
|
||||
setStatus('Für einen neuen Trackpunkt muss zuerst eine aktuelle GPS-Position verfügbar sein.', true);
|
||||
setStatus('Für einen neuen Streckenpunkt muss zuerst eine aktuelle GPS-Position verfügbar sein.', true);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -916,31 +988,55 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
lon,
|
||||
triggerRadiusM: Number.isFinite(accuracy) && accuracy > 0 ? accuracy : 25
|
||||
});
|
||||
byId(ids.poiFormTitle).textContent = 'Trackpunkt hinzufügen';
|
||||
byId(ids.poiFormTitle).textContent = 'Streckenpunkt hinzufügen';
|
||||
showPoiView();
|
||||
}
|
||||
|
||||
function editPoi(poi) {
|
||||
state.currentPoi = poi;
|
||||
fillPoiForm(poi);
|
||||
byId(ids.poiFormTitle).textContent = 'Trackpunkt bearbeiten';
|
||||
byId(ids.poiFormTitle).textContent = 'Streckenpunkt bearbeiten';
|
||||
showPoiView();
|
||||
}
|
||||
|
||||
async function mediaFromLocalForm(existingPoi, poiIdentifier) {
|
||||
const audio = state.pendingAudioFile
|
||||
? await ns.LocalRoutes.fileToDataUrl(state.pendingAudioFile)
|
||||
: existingPoi?.audio || null;
|
||||
const routeIdentifier = routeId(state.currentRoute);
|
||||
if (!routeIdentifier) throw new Error('Speichere zuerst die Strecke.');
|
||||
|
||||
const storedNow = [];
|
||||
const replacedAfterSave = [];
|
||||
let audio = existingPoi?.audio || null;
|
||||
|
||||
if (state.pendingAudioFile) {
|
||||
const mediaId = `${poiIdentifier}-audio`;
|
||||
const storedAudio = await ns.LocalRoutes.storeMediaFile(
|
||||
routeIdentifier,
|
||||
poiIdentifier,
|
||||
'audio',
|
||||
mediaId,
|
||||
state.pendingAudioFile,
|
||||
state.pendingAudioHandle
|
||||
);
|
||||
storedNow.push(storedAudio);
|
||||
if (audio) replacedAfterSave.push(audio);
|
||||
audio = storedAudio;
|
||||
}
|
||||
|
||||
const images = [...(existingPoi?.images || [])];
|
||||
for (const pending of state.pendingImages) {
|
||||
const image = pending.preview || await ns.LocalRoutes.fileToDataUrl(pending.file);
|
||||
images.push({
|
||||
...image,
|
||||
id: `${poiIdentifier}-image-${ns.LocalRoutes.createPoiId()}`,
|
||||
caption: ''
|
||||
});
|
||||
const mediaId = `${poiIdentifier}-image-${ns.LocalRoutes.createPoiId()}`;
|
||||
const image = await ns.LocalRoutes.storeMediaFile(
|
||||
routeIdentifier,
|
||||
poiIdentifier,
|
||||
'image',
|
||||
mediaId,
|
||||
pending.file,
|
||||
pending.handle
|
||||
);
|
||||
images.push({ ...image, caption: '' });
|
||||
storedNow.push(image);
|
||||
}
|
||||
return { audio, images };
|
||||
return { audio, images, storedNow, replacedAfterSave };
|
||||
}
|
||||
|
||||
async function persistLocalCurrentRoute() {
|
||||
@@ -953,17 +1049,18 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
route: state.currentRoute,
|
||||
pois: state.pois
|
||||
});
|
||||
state.currentRoute = { ...saved.route, id: saved.id, gpx: saved.gpx, _source: 'local' };
|
||||
state.pois = saved.pois;
|
||||
const hydrated = await ns.LocalRoutes.getWithMedia(id) || saved;
|
||||
state.currentRoute = { ...hydrated.route, id: hydrated.id, gpx: hydrated.gpx, _source: 'local' };
|
||||
state.pois = hydrated.pois;
|
||||
state.currentPoi = currentPoiId
|
||||
? state.pois.find(poi => poiId(poi) === currentPoiId) || null
|
||||
: null;
|
||||
return saved;
|
||||
return hydrated;
|
||||
}
|
||||
|
||||
async function saveLocalPoi(values) {
|
||||
if (!Number.isFinite(values.lat) || !Number.isFinite(values.lon)) {
|
||||
throw new TypeError('Für einen GPX-Trackpunkt sind gültige Breiten- und Längengrade erforderlich.');
|
||||
throw new TypeError('Für einen Streckenpunkt sind gültige Breiten- und Längengrade erforderlich.');
|
||||
}
|
||||
const currentId = Number(byId(ids.poiId).value);
|
||||
const identifier = Number.isSafeInteger(currentId) ? currentId : ns.LocalRoutes.createPoiId();
|
||||
@@ -972,15 +1069,24 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
const savedPoi = {
|
||||
...existing,
|
||||
...values,
|
||||
...media,
|
||||
audio: media.audio,
|
||||
images: media.images,
|
||||
id: identifier,
|
||||
sequence: existing?.sequence ?? state.pois.length
|
||||
};
|
||||
const index = state.pois.findIndex(poi => Number(poi.id) === identifier);
|
||||
if (index >= 0) state.pois[index] = savedPoi;
|
||||
else state.pois.push(savedPoi);
|
||||
await persistLocalCurrentRoute();
|
||||
setStatus('Trackpunkt und zugehörige JSON-Mediendaten lokal gespeichert.');
|
||||
|
||||
try {
|
||||
await persistLocalCurrentRoute();
|
||||
} catch (error) {
|
||||
await Promise.allSettled(media.storedNow.map(ns.LocalRoutes.removeMedia));
|
||||
throw error;
|
||||
}
|
||||
await Promise.allSettled(media.replacedAfterSave.map(ns.LocalRoutes.removeMedia));
|
||||
resetPendingMedia();
|
||||
setStatus('Streckenpunkt und zugehörige Mediendaten lokal gespeichert.');
|
||||
await loadRoute({ id: routeId(state.currentRoute), _source: 'local' });
|
||||
}
|
||||
|
||||
@@ -1013,13 +1119,13 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
const saved = payload.poi || payload;
|
||||
const savedPoiId = poiId(saved) || currentPoiId;
|
||||
if (savedPoiId) await uploadPoiMedia(currentRouteId, savedPoiId);
|
||||
setStatus('Trackpunkt gespeichert.');
|
||||
setStatus('Streckenpunkt gespeichert.');
|
||||
await loadRoute({ id: currentRouteId, _source: 'server' });
|
||||
}
|
||||
|
||||
async function savePoi() {
|
||||
const values = poiFromForm();
|
||||
if (!values.title) throw new Error('Der Titel des Trackpunkts ist erforderlich.');
|
||||
if (!values.title) throw new Error('Der Titel des Streckenpunkts ist erforderlich.');
|
||||
if (state.currentSource === 'local') await saveLocalPoi(values);
|
||||
else await saveServerPoi(values);
|
||||
}
|
||||
@@ -1034,9 +1140,13 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
async function removePendingMedia(kind, mediaIndex) {
|
||||
if (kind === 'audio') {
|
||||
state.pendingAudioFile = null;
|
||||
state.pendingAudioHandle = null;
|
||||
} else {
|
||||
const index = Number.parseInt(mediaIndex, 10);
|
||||
if (Number.isInteger(index)) state.pendingImages.splice(index, 1);
|
||||
if (Number.isInteger(index)) {
|
||||
const [removed] = state.pendingImages.splice(index, 1);
|
||||
releasePendingPreview(removed);
|
||||
}
|
||||
}
|
||||
state.selectedMediaKey = null;
|
||||
renderPoiMediaPreview();
|
||||
@@ -1046,15 +1156,20 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
async function removeExistingLocalMedia(kind, mediaIndex) {
|
||||
if (!state.currentPoi) return;
|
||||
const updated = { ...state.currentPoi };
|
||||
let removedMedia = null;
|
||||
if (kind === 'audio') {
|
||||
removedMedia = updated.audio;
|
||||
updated.audio = null;
|
||||
} else {
|
||||
const index = Number.parseInt(mediaIndex, 10);
|
||||
updated.images = [...(state.currentPoi.images || [])];
|
||||
if (Number.isInteger(index)) updated.images.splice(index, 1);
|
||||
if (Number.isInteger(index)) {
|
||||
[removedMedia] = updated.images.splice(index, 1);
|
||||
}
|
||||
}
|
||||
updateCurrentPoi(updated);
|
||||
await persistLocalCurrentRoute();
|
||||
if (removedMedia) await ns.LocalRoutes.removeMedia(removedMedia);
|
||||
state.selectedMediaKey = null;
|
||||
renderPoiMediaPreview();
|
||||
updatePoiMediaStatus();
|
||||
@@ -1064,7 +1179,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
async function removeExistingServerMedia(kind, mediaId) {
|
||||
const currentRouteId = routeId(state.currentRoute);
|
||||
const currentPoiId = poiId(state.currentPoi);
|
||||
if (!currentRouteId || !currentPoiId) throw new Error('Der Trackpunkt ist noch nicht gespeichert.');
|
||||
if (!currentRouteId || !currentPoiId) throw new Error('Der Streckenpunkt ist noch nicht gespeichert.');
|
||||
const basePath = `/routes/${encodeURIComponent(currentRouteId)}/pois/${encodeURIComponent(currentPoiId)}`;
|
||||
const updated = { ...state.currentPoi };
|
||||
|
||||
@@ -1118,20 +1233,23 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
if (!currentRouteId || !currentPoiId) return;
|
||||
|
||||
if (state.currentSource === 'local') {
|
||||
const removedPoi = state.currentPoi;
|
||||
state.pois = state.pois
|
||||
.filter(poi => poiId(poi) !== currentPoiId)
|
||||
.map((poi, sequence) => ({ ...poi, sequence }));
|
||||
state.currentPoi = null;
|
||||
await persistLocalCurrentRoute();
|
||||
const removedMedia = [removedPoi?.audio, ...(removedPoi?.images || [])].filter(Boolean);
|
||||
await Promise.allSettled(removedMedia.map(ns.LocalRoutes.removeMedia));
|
||||
renderPoiList();
|
||||
showRouteView();
|
||||
setStatus('Trackpunkt entfernt und lokale Strecke aktualisiert.');
|
||||
setStatus('Streckenpunkt entfernt und lokale Strecke aktualisiert.');
|
||||
return;
|
||||
}
|
||||
|
||||
await sendDelete(`/routes/${encodeURIComponent(currentRouteId)}/pois/${encodeURIComponent(currentPoiId)}`);
|
||||
await loadRoute({ id: currentRouteId, _source: 'server' });
|
||||
setStatus('Trackpunkt entfernt.');
|
||||
setStatus('Streckenpunkt entfernt.');
|
||||
}
|
||||
|
||||
function reorderPois(index, offset) {
|
||||
@@ -1154,7 +1272,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
if (!currentRouteId) return;
|
||||
if (state.currentSource === 'local') {
|
||||
await persistLocalCurrentRoute();
|
||||
setStatus('Lokale Trackpunkt-Reihenfolge gespeichert.');
|
||||
setStatus('Lokale Streckenpunkt-Reihenfolge gespeichert.');
|
||||
return;
|
||||
}
|
||||
const jobs = state.pois.map((poi, index) => {
|
||||
@@ -1188,7 +1306,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
const id = routeId(route);
|
||||
if (!id || routeSource(route) !== 'local') return;
|
||||
|
||||
ns.LocalRoutes.remove(id);
|
||||
await ns.LocalRoutes.remove(id);
|
||||
|
||||
if (routeId(state.currentRoute) === id && state.currentSource === routeSource(route)) {
|
||||
state.currentRoute = null;
|
||||
@@ -1255,8 +1373,8 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
activate(byId(ids.routeBack), () => guarded(loadRoutes).then(showListView));
|
||||
activate(byId(ids.routeUpload), () => guarded(uploadLocalRoute));
|
||||
activate(byId(ids.addPoi), newPoi);
|
||||
activate(byId(ids.poiAudioAdd), () => openFilePicker(ids.poiAudio));
|
||||
activate(byId(ids.poiImagesAdd), () => openFilePicker(ids.poiImages));
|
||||
activate(byId(ids.poiAudioAdd), () => guarded(() => chooseMedia('audio', ids.poiAudio)));
|
||||
activate(byId(ids.poiImagesAdd), () => guarded(() => chooseMedia('image', ids.poiImages)));
|
||||
byId(ids.poiAudio)?.addEventListener('change', handleAudioSelection);
|
||||
byId(ids.poiImages)?.addEventListener('change', () => guarded(handleImageSelection));
|
||||
activate(byId(ids.poiMediaPreview), event => guarded(() => mediaPreviewAction(event)));
|
||||
|
||||
+221
-24
@@ -7,6 +7,10 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
const INDEX_KEY = `${STORAGE_PREFIX}.index`;
|
||||
const GPX_NAMESPACE = 'http://www.topografix.com/GPX/1/1';
|
||||
const POI_NAMESPACE = 'urn:wegwichtel:poi';
|
||||
const MEDIA_DB_NAME = 'wegwichtel.local-routes.media.v1';
|
||||
const MEDIA_DB_VERSION = 1;
|
||||
const MEDIA_STORE_NAME = 'media';
|
||||
let mediaDatabasePromise = null;
|
||||
let poiCounter = 0;
|
||||
|
||||
function storage() {
|
||||
@@ -19,6 +23,181 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
return `${STORAGE_PREFIX}.${encodeURIComponent(String(id))}.${suffix}`;
|
||||
}
|
||||
|
||||
function openMediaDatabase() {
|
||||
if (mediaDatabasePromise) return mediaDatabasePromise;
|
||||
const indexedDb = globalThis.indexedDB;
|
||||
if (!indexedDb || typeof indexedDb.open !== 'function') {
|
||||
return Promise.reject(new Error('Der lokale Medienspeicher (IndexedDB) ist in diesem Browser nicht verfügbar.'));
|
||||
}
|
||||
|
||||
mediaDatabasePromise = new Promise((resolve, reject) => {
|
||||
const request = indexedDb.open(MEDIA_DB_NAME, MEDIA_DB_VERSION);
|
||||
request.onupgradeneeded = () => {
|
||||
const database = request.result;
|
||||
if (!database.objectStoreNames.contains(MEDIA_STORE_NAME)) {
|
||||
database.createObjectStore(MEDIA_STORE_NAME, { keyPath: 'key' });
|
||||
}
|
||||
};
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => {
|
||||
mediaDatabasePromise = null;
|
||||
reject(request.error || new Error('Der lokale Medienspeicher konnte nicht geöffnet werden.'));
|
||||
};
|
||||
request.onblocked = () => {
|
||||
mediaDatabasePromise = null;
|
||||
reject(new Error('Der lokale Medienspeicher ist durch eine andere Browser-Sitzung blockiert.'));
|
||||
};
|
||||
});
|
||||
return mediaDatabasePromise;
|
||||
}
|
||||
|
||||
async function mediaStoreRequest(mode, operation) {
|
||||
const database = await openMediaDatabase();
|
||||
return new Promise((resolve, reject) => {
|
||||
let request;
|
||||
try {
|
||||
const transaction = database.transaction(MEDIA_STORE_NAME, mode);
|
||||
request = operation(transaction.objectStore(MEDIA_STORE_NAME));
|
||||
request.onsuccess = () => resolve(request.result ?? null);
|
||||
request.onerror = () => reject(request.error || new Error('Der lokale Medienspeicher konnte nicht gelesen oder geschrieben werden.'));
|
||||
transaction.onabort = () => reject(transaction.error || new Error('Die Medien-Speicheroperation wurde abgebrochen.'));
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function mediaKey(routeId, poiId, kind, mediaId) {
|
||||
return [routeId, poiId, kind, mediaId]
|
||||
.map(value => encodeURIComponent(String(value)))
|
||||
.join(':');
|
||||
}
|
||||
|
||||
async function putMediaRecord(record) {
|
||||
await mediaStoreRequest('readwrite', store => store.put(record));
|
||||
}
|
||||
|
||||
async function storeMediaFile(routeId, poiId, kind, mediaId, file, fileHandle = null) {
|
||||
if (!file) return null;
|
||||
const key = mediaKey(routeId, poiId, kind, mediaId);
|
||||
const metadata = {
|
||||
key,
|
||||
name: file.name || 'Datei',
|
||||
size: Number(file.size) || 0,
|
||||
type: file.type || 'application/octet-stream'
|
||||
};
|
||||
|
||||
if (fileHandle && typeof fileHandle.getFile === 'function') {
|
||||
try {
|
||||
await putMediaRecord({ ...metadata, storage: 'file-handle', handle: fileHandle });
|
||||
return {
|
||||
id: String(mediaId),
|
||||
mediaKey: key,
|
||||
storage: 'file-handle',
|
||||
name: metadata.name,
|
||||
size: metadata.size,
|
||||
type: metadata.type
|
||||
};
|
||||
} catch (error) {
|
||||
if (error?.name === 'QuotaExceededError') {
|
||||
throw new Error('Der lokale Medienspeicher ist voll. Entferne nicht mehr benötigte lokale Medien oder Strecken.', { cause: error });
|
||||
}
|
||||
// Manche Browser können FileSystemFileHandle noch nicht in IndexedDB serialisieren.
|
||||
// In diesem Fall wird die Datei als Blob in IndexedDB gespeichert.
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await putMediaRecord({ ...metadata, storage: 'indexeddb', blob: file });
|
||||
} catch (error) {
|
||||
const prefix = error?.name === 'QuotaExceededError'
|
||||
? 'Der lokale Medienspeicher ist voll.'
|
||||
: 'Die Mediendatei konnte nicht lokal gespeichert werden.';
|
||||
throw new Error(`${prefix} Entferne nicht mehr benötigte lokale Medien oder Strecken.`, { cause: error });
|
||||
}
|
||||
return {
|
||||
id: String(mediaId),
|
||||
mediaKey: key,
|
||||
storage: 'indexeddb',
|
||||
name: metadata.name,
|
||||
size: metadata.size,
|
||||
type: metadata.type
|
||||
};
|
||||
}
|
||||
|
||||
async function fileFromHandle(handle, requestPermission = false) {
|
||||
if (!handle || typeof handle.getFile !== 'function') return null;
|
||||
let permission = 'granted';
|
||||
if (typeof handle.queryPermission === 'function') {
|
||||
permission = await handle.queryPermission({ mode: 'read' });
|
||||
}
|
||||
if (permission !== 'granted' && requestPermission && typeof handle.requestPermission === 'function') {
|
||||
permission = await handle.requestPermission({ mode: 'read' });
|
||||
}
|
||||
if (permission !== 'granted') {
|
||||
throw new Error('Der Zugriff auf eine lokale Mediendatei muss erneut erlaubt werden.');
|
||||
}
|
||||
try {
|
||||
return await handle.getFile();
|
||||
} catch (error) {
|
||||
throw new Error('Eine referenzierte Mediendatei ist auf dem Gerät nicht mehr verfügbar.', { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
async function mediaBlob(media, options = {}) {
|
||||
if (!media) return null;
|
||||
if (media.mediaKey) {
|
||||
const record = await mediaStoreRequest('readonly', store => store.get(media.mediaKey));
|
||||
if (record?.handle) return fileFromHandle(record.handle, options.requestPermission === true);
|
||||
if (record?.blob) return record.blob;
|
||||
return null;
|
||||
}
|
||||
if (media.dataUrl && typeof globalThis.fetch === 'function') {
|
||||
const response = await globalThis.fetch(media.dataUrl);
|
||||
if (response.ok && typeof response.blob === 'function') return response.blob();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function removeMedia(media) {
|
||||
if (!media?.mediaKey) return;
|
||||
await mediaStoreRequest('readwrite', store => store.delete(media.mediaKey));
|
||||
}
|
||||
|
||||
async function mediaForUse(media, options = {}) {
|
||||
if (!media || media.dataUrl || media.url) return media;
|
||||
try {
|
||||
const blob = await mediaBlob(media, options);
|
||||
if (!blob) return { ...media, unavailable: true };
|
||||
if (typeof globalThis.URL?.createObjectURL === 'function') {
|
||||
return { ...media, url: globalThis.URL.createObjectURL(blob), unavailable: false };
|
||||
}
|
||||
const fallback = await fileToDataUrl(blob);
|
||||
return { ...media, dataUrl: fallback.dataUrl, unavailable: false };
|
||||
} catch {
|
||||
return { ...media, unavailable: true };
|
||||
}
|
||||
}
|
||||
|
||||
async function withMedia(routePackage, options = {}) {
|
||||
if (!routePackage) return null;
|
||||
const pois = await Promise.all((routePackage.pois || []).map(async poi => ({
|
||||
...poi,
|
||||
audio: await mediaForUse(poi.audio, options),
|
||||
images: await Promise.all((poi.images || []).map(media => mediaForUse(media, options)))
|
||||
})));
|
||||
return { ...routePackage, pois };
|
||||
}
|
||||
|
||||
function mediaForStorage(media) {
|
||||
if (!media) return null;
|
||||
const stored = { ...media };
|
||||
delete stored.url;
|
||||
delete stored.blob;
|
||||
if (stored.storage === 'indexeddb' || stored.storage === 'file-handle') delete stored.dataUrl;
|
||||
return stored;
|
||||
}
|
||||
|
||||
function readIndex() {
|
||||
const raw = storage().getItem(INDEX_KEY);
|
||||
if (!raw) return [];
|
||||
@@ -121,7 +300,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
const document = parseXml(gpx);
|
||||
return Array.from(document.getElementsByTagNameNS('*', 'wpt')).map((node, index) => ({
|
||||
id: waypointId(node),
|
||||
title: textOfChild(node, 'name') || `Station ${index + 1}`,
|
||||
title: textOfChild(node, 'name') || `Streckenpunkt ${index + 1}`,
|
||||
description: textOfChild(node, 'desc'),
|
||||
lat: numberAttribute(node, 'lat'),
|
||||
lon: numberAttribute(node, 'lon'),
|
||||
@@ -176,7 +355,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
return segment;
|
||||
}
|
||||
|
||||
function isManagedTrackPoint(node) {
|
||||
function isManagedGpxPoint(node) {
|
||||
return Boolean(firstDescendantByLocalName(node, 'poiId'));
|
||||
}
|
||||
|
||||
@@ -185,32 +364,32 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
const document = parseXml(gpx || emptyGpx(routeName));
|
||||
updateMetadataName(document, routeName);
|
||||
|
||||
// Wegwichtel verwaltet neue Streckenpunkte direkt als GPX-Trackpunkte.
|
||||
// Fremde/importierte Trackpunkte ohne Wegwichtel-ID bleiben erhalten.
|
||||
// Wegwichtel verwaltet neue Streckenpunkte direkt als Punkte im GPX-Track.
|
||||
// Fremde/importierte GPX-Punkte ohne Wegwichtel-ID bleiben erhalten.
|
||||
Array.from(document.getElementsByTagNameNS('*', 'wpt')).forEach(node => node.remove());
|
||||
Array.from(document.getElementsByTagNameNS('*', 'trkpt'))
|
||||
.filter(isManagedTrackPoint)
|
||||
.filter(isManagedGpxPoint)
|
||||
.forEach(node => node.remove());
|
||||
|
||||
const segment = ensureTrackSegment(document, routeName);
|
||||
const orderedPois = [...(pois || [])].sort((left, right) => Number(left.sequence) - Number(right.sequence));
|
||||
orderedPois.forEach((poi, index) => {
|
||||
const title = poi.title || `Trackpunkt ${index + 1}`;
|
||||
const title = poi.title || `Streckenpunkt ${index + 1}`;
|
||||
if (!Number.isFinite(Number(poi.lat)) || !Number.isFinite(Number(poi.lon))) {
|
||||
throw new TypeError(`Für ${title} fehlen gültige Koordinaten.`);
|
||||
}
|
||||
|
||||
const trackPoint = createElement(document, 'trkpt');
|
||||
trackPoint.setAttribute('lat', String(Number(poi.lat)));
|
||||
trackPoint.setAttribute('lon', String(Number(poi.lon)));
|
||||
trackPoint.appendChild(createElement(document, 'name', title));
|
||||
if (poi.description) trackPoint.appendChild(createElement(document, 'desc', poi.description));
|
||||
const gpxPoint = createElement(document, 'trkpt');
|
||||
gpxPoint.setAttribute('lat', String(Number(poi.lat)));
|
||||
gpxPoint.setAttribute('lon', String(Number(poi.lon)));
|
||||
gpxPoint.appendChild(createElement(document, 'name', title));
|
||||
if (poi.description) gpxPoint.appendChild(createElement(document, 'desc', poi.description));
|
||||
const extensions = createElement(document, 'extensions');
|
||||
const poiId = document.createElementNS(POI_NAMESPACE, 'wegwichtel:poiId');
|
||||
poiId.textContent = String(poi.id);
|
||||
extensions.appendChild(poiId);
|
||||
trackPoint.appendChild(extensions);
|
||||
segment.appendChild(trackPoint);
|
||||
gpxPoint.appendChild(extensions);
|
||||
segment.appendChild(gpxPoint);
|
||||
});
|
||||
|
||||
return new XMLSerializer().serializeToString(document);
|
||||
@@ -268,6 +447,8 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
};
|
||||
const pois = (routePackage.pois || []).map((poi, index) => ({
|
||||
...poi,
|
||||
audio: mediaForStorage(poi.audio),
|
||||
images: (poi.images || []).map(mediaForStorage),
|
||||
id: Number.isSafeInteger(Number(poi.id)) ? Number(poi.id) : createPoiId(),
|
||||
sequence: index
|
||||
}));
|
||||
@@ -286,20 +467,28 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
} catch {
|
||||
// Die ursprüngliche Speicherfehlermeldung ist aussagekräftiger.
|
||||
}
|
||||
const name = error?.name === 'QuotaExceededError' ? 'Der lokale Browser-Speicher ist voll.' : 'Die lokale Strecke konnte nicht gespeichert werden.';
|
||||
throw new Error(`${name} Bilder und Audiodateien können das Local-Storage-Limit schnell erreichen.`, { cause: error });
|
||||
const name = error?.name === 'QuotaExceededError'
|
||||
? 'Der lokale Speicher für Streckendaten ist voll.'
|
||||
: 'Die lokale Strecke konnte nicht gespeichert werden.';
|
||||
throw new Error(`${name} Entferne nicht mehr benötigte lokale Strecken.`, { cause: error });
|
||||
}
|
||||
|
||||
notify(id, 'saved');
|
||||
return readPackage(id);
|
||||
}
|
||||
|
||||
function remove(id) {
|
||||
async function remove(id) {
|
||||
const target = storage();
|
||||
const routeId = String(id);
|
||||
const routePackage = readPackage(routeId);
|
||||
target.removeItem(routeKey(routeId, 'gpx'));
|
||||
target.removeItem(routeKey(routeId, 'json'));
|
||||
writeIndex(readIndex().filter(item => item !== routeId));
|
||||
|
||||
if (routePackage) {
|
||||
const media = routePackage.pois.flatMap(poi => [poi.audio, ...(poi.images || [])]).filter(Boolean);
|
||||
await Promise.allSettled(media.map(removeMedia));
|
||||
}
|
||||
notify(routeId, 'removed');
|
||||
}
|
||||
|
||||
@@ -309,9 +498,9 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
|
||||
function routePoints(gpx) {
|
||||
const document = parseXml(gpx);
|
||||
const trackPoints = Array.from(document.getElementsByTagNameNS('*', 'trkpt'));
|
||||
const gpxPoints = Array.from(document.getElementsByTagNameNS('*', 'trkpt'));
|
||||
const routePoints = Array.from(document.getElementsByTagNameNS('*', 'rtept'));
|
||||
const selected = trackPoints.length ? trackPoints : routePoints;
|
||||
const selected = gpxPoints.length ? gpxPoints : routePoints;
|
||||
return selected.map(node => {
|
||||
const elevation = Number.parseFloat(textOfChild(node, 'ele'));
|
||||
return {
|
||||
@@ -326,7 +515,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
function gpxForUpload(routePackage) {
|
||||
if (!routePackage?.gpx) throw new Error('Für die lokale Strecke ist keine GPX-Datei vorhanden.');
|
||||
if (!routePoints(routePackage.gpx).length) {
|
||||
throw new Error('Die lokale Strecke enthält noch keine GPX-Trackpunkte.');
|
||||
throw new Error('Die lokale Strecke enthält noch keine Streckenpunkte im GPX-Track.');
|
||||
}
|
||||
return routePackage.gpx;
|
||||
}
|
||||
@@ -387,18 +576,18 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
function appPoi(poi) {
|
||||
return {
|
||||
id: Number(poi.id),
|
||||
title: poi.title || 'Unbenannte Station',
|
||||
title: poi.title || 'Unbenannter Streckenpunkt',
|
||||
description: poi.description || '',
|
||||
lat: Number(poi.lat),
|
||||
lon: Number(poi.lon),
|
||||
triggerRadiusM: Number(poi.triggerRadiusM) || 25,
|
||||
sequence: Number(poi.sequence) || 0,
|
||||
audioUrl: poi.audio?.dataUrl || null,
|
||||
audioUrl: poi.audio?.url || poi.audio?.dataUrl || null,
|
||||
images: (poi.images || []).map((image, index) => ({
|
||||
id: image.id || `${poi.id}-${index}`,
|
||||
caption: image.caption || '',
|
||||
sequence: index,
|
||||
url: image.dataUrl
|
||||
url: image.url || image.dataUrl
|
||||
})).filter(image => image.url)
|
||||
};
|
||||
}
|
||||
@@ -433,12 +622,16 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
return listPackages().map(toAppRoute);
|
||||
}
|
||||
|
||||
function getAppRoute(id) {
|
||||
const routePackage = readPackage(id);
|
||||
async function getAppRoute(id, options = {}) {
|
||||
const routePackage = await withMedia(readPackage(id), options);
|
||||
if (!routePackage) throw new Error('Die lokale Strecke wurde nicht gefunden.');
|
||||
return toAppRoute(routePackage);
|
||||
}
|
||||
|
||||
async function getWithMedia(id, options = {}) {
|
||||
return withMedia(readPackage(id), options);
|
||||
}
|
||||
|
||||
function readFileAsText(file) {
|
||||
if (!file) return Promise.resolve('');
|
||||
if (typeof file.text !== 'function') {
|
||||
@@ -476,13 +669,17 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
fileToDataUrl,
|
||||
get: readPackage,
|
||||
getAppRoute,
|
||||
getWithMedia,
|
||||
gpxForUpload,
|
||||
list: listPackages,
|
||||
listAppRoutes,
|
||||
mediaBlob,
|
||||
parseWaypoints,
|
||||
readFileAsText,
|
||||
remove,
|
||||
removeMedia,
|
||||
save: savePackage,
|
||||
storeMediaFile,
|
||||
syncGpx,
|
||||
toAppRoute
|
||||
});
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
const image = images[index];
|
||||
$('#slide-image')
|
||||
.attr('src', mediaUrl(image.url))
|
||||
.attr('alt', image.caption || 'Bild zur Station')
|
||||
.attr('alt', image.caption || 'Bild zum Streckenpunkt')
|
||||
.off('error.wegwichtel')
|
||||
.on('error.wegwichtel', function () {
|
||||
$(this).removeAttr('src').attr('alt', 'Bild konnte nicht geladen werden');
|
||||
|
||||
Reference in New Issue
Block a user