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:
@@ -12,7 +12,7 @@
|
||||
},
|
||||
{
|
||||
"title": "Wiesenblick",
|
||||
"description": "Ein Beispiel-Trackpunkt für Texte, Bilder und Audio.",
|
||||
"description": "Ein Beispiel-Streckenpunkt für Texte, Bilder und Audio.",
|
||||
"lat": 50.58109,
|
||||
"lon": 10.43384,
|
||||
"triggerRadiusM": 25,
|
||||
@@ -20,7 +20,7 @@
|
||||
},
|
||||
{
|
||||
"title": "Waldrand",
|
||||
"description": "Zweiter Beispiel-Trackpunkt der Demo-Strecke.",
|
||||
"description": "Zweiter Beispiel-Streckenpunkt der Demo-Strecke.",
|
||||
"lat": 50.58082,
|
||||
"lon": 10.43546,
|
||||
"triggerRadiusM": 25,
|
||||
|
||||
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 |
+27
-22
@@ -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>
|
||||
<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();
|
||||
}());
|
||||
|
||||
+173
-55
@@ -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);
|
||||
|
||||
try {
|
||||
await persistLocalCurrentRoute();
|
||||
setStatus('Trackpunkt und zugehörige JSON-Mediendaten lokal gespeichert.');
|
||||
} 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');
|
||||
|
||||
@@ -62,7 +62,7 @@ for ((index=0; index<POI_COUNT; index+=1)); do
|
||||
-F "lon=$LON" \
|
||||
-F "triggerRadiusM=$RADIUS" \
|
||||
-F "sequence=$SEQUENCE" >/dev/null
|
||||
echo " Trackpunkt $((index + 1))/$POI_COUNT: $TITLE"
|
||||
echo " Streckenpunkt $((index + 1))/$POI_COUNT: $TITLE"
|
||||
done
|
||||
|
||||
trap - ERR
|
||||
|
||||
Vendored
+15
@@ -21,6 +21,7 @@ test('start screen uses the presentation-style hero and one action slot', async
|
||||
assert.match(html, />Los geht’s</);
|
||||
assert.match(html, /<ol class="bootstrap-state-model" hidden>[\s\S]*data-step="storage"/);
|
||||
assert.match(html, /id="bootstrap-progress"[^>]*hidden/);
|
||||
assert.match(html, /id="android-browser-hint"[^>]*hidden/);
|
||||
});
|
||||
|
||||
test('bootstrap status text fades between loading steps and is replaced by the start button', async () => {
|
||||
@@ -46,3 +47,17 @@ test('bootstrap status text fades between loading steps and is replaced by the s
|
||||
assert.doesNotMatch(loader, /dataset\.state/);
|
||||
assert.match(loader, /event\.stopPropagation\?\.\(\)/);
|
||||
});
|
||||
|
||||
|
||||
test('Android start hint distinguishes Chrome from other Android browsers', async () => {
|
||||
const css = await read('public/css/bootstrap.css');
|
||||
const loader = await read('public/js/bootstrap-loader.js');
|
||||
|
||||
assert.match(css, /\.android-browser-hint\s*\{/);
|
||||
assert.match(css, /\.android-browser-hint\[data-browser="other"\]/);
|
||||
assert.match(loader, /getElementById\('android-browser-hint'\)/);
|
||||
assert.match(loader, /\/Android\/i/);
|
||||
assert.match(loader, /\/Chrome\\\//i);
|
||||
assert.match(loader, /Android mit Chrome erkannt/);
|
||||
assert.match(loader, /Android erkannt\. Für lokale Bilder und Audiodateien bitte Chrome verwenden/);
|
||||
});
|
||||
|
||||
+89
-14
@@ -20,7 +20,7 @@ test('editor markup and source describe local draft creation instead of immediat
|
||||
const ui = await read('public/js/editor-ui.js');
|
||||
|
||||
assert.match(html, /id="editor-button"[^>]+aria-label="Streckenverwaltung öffnen"/);
|
||||
assert.match(html, /<img[^>]+src="images\/icons\/route-management\.svg"[^>]+alt=""[^>]*>/);
|
||||
assert.match(html, /id="route-control-button"[\s\S]*?<img[^>]+src="images\/icons\/route-management\.svg"[^>]+alt=""[^>]*>/);
|
||||
assert.doesNotMatch(html, /class="editor-button-icon"/);
|
||||
assert.match(html, /<dialog id="editor-dialog"/);
|
||||
assert.match(html, /id="editor-route-form"/);
|
||||
@@ -36,8 +36,8 @@ test('editor markup and source describe local draft creation instead of immediat
|
||||
assert.doesNotMatch(html, /id="editor-poi-(?:lat|lon)"[^>]+step=/);
|
||||
assert.match(html, /id="editor-poi-media-preview"/);
|
||||
assert.match(html, /id="editor-poi-delete"[^>]+editor-danger-button/);
|
||||
assert.match(html, />Trackpunkte<\/h3>/);
|
||||
assert.match(html, /aria-label="Trackpunkt hinzufügen"/);
|
||||
assert.match(html, />Streckenpunkte<\/h3>/);
|
||||
assert.match(html, /aria-label="Streckenpunkt hinzufügen"/);
|
||||
assert.match(html, /id="editor-route-upload"[^>]+type="button"[^>]+hidden/);
|
||||
assert.doesNotMatch(html, /accept="video\//);
|
||||
assert.match(html, /ausschließlich in diesem Browser/i);
|
||||
@@ -51,13 +51,15 @@ test('editor markup and source describe local draft creation instead of immediat
|
||||
assert.match(css, /\.editor-media-remove/);
|
||||
assert.match(css, /\.editor-danger-button/);
|
||||
assert.match(css, /\.editor-icon-danger/);
|
||||
assert.match(css, /\.editor-button\s+img/);
|
||||
assert.match(css, /\.route-control-button img/);
|
||||
assert.doesNotMatch(css, /\.editor-button-icon/);
|
||||
|
||||
assert.match(ui, /readJson\(['"]\/routes\?activatedOnly=true['"]\)/);
|
||||
assert.match(ui, /ns\.LocalRoutes\.save\(/);
|
||||
assert.match(ui, /ns\.LocalRoutes\.parseWaypoints\(/);
|
||||
assert.match(ui, /ns\.LocalRoutes\.fileToDataUrl\(/);
|
||||
assert.match(ui, /ns\.LocalRoutes\.storeMediaFile\(/);
|
||||
assert.match(ui, /showOpenFilePicker/);
|
||||
assert.match(ui, /requestPermission:\s*true/);
|
||||
assert.match(ui, /ns\.LocalRoutes\.gpxForUpload\(/);
|
||||
assert.match(ui, /move-poi-up/);
|
||||
assert.match(ui, /move-poi-down/);
|
||||
@@ -76,6 +78,7 @@ test('editor markup and source describe local draft creation instead of immediat
|
||||
assert.match(ui, /sendForm\('\/routes',\s*'POST'/);
|
||||
assert.match(ui, /serverRouteId/);
|
||||
assert.match(ui, /position\?\.accuracy/);
|
||||
assert.match(ui, /setHidden\(byId\(ids\.routeGpxGroup\), !routeId\(route\)\)/);
|
||||
});
|
||||
|
||||
class FakeElement extends EventTarget {
|
||||
@@ -213,6 +216,7 @@ function buildEditorContext() {
|
||||
'editor-route-detail-view',
|
||||
'editor-route-form',
|
||||
'editor-route-gpx',
|
||||
'editor-route-gpx-group',
|
||||
'editor-route-id',
|
||||
'editor-route-list',
|
||||
'editor-route-list-view',
|
||||
@@ -271,6 +275,8 @@ function buildEditorContext() {
|
||||
return { ok: false, status: 404, text: async () => `unexpected ${method} ${url}` };
|
||||
}
|
||||
|
||||
const mediaBlobs = new Map();
|
||||
const storedMediaCalls = [];
|
||||
const LocalRoutes = {
|
||||
createPoiId: () => 9001,
|
||||
createRouteId: () => 'local-new',
|
||||
@@ -282,21 +288,48 @@ function buildEditorContext() {
|
||||
type: file.type || 'application/octet-stream'
|
||||
}),
|
||||
get: id => packages.get(String(id)) || null,
|
||||
getWithMedia: async id => {
|
||||
const value = packages.get(String(id));
|
||||
if (!value) return null;
|
||||
return {
|
||||
...value,
|
||||
pois: value.pois.map(poi => ({
|
||||
...poi,
|
||||
audio: poi.audio ? { ...poi.audio, url: 'blob:test-audio' } : null,
|
||||
images: (poi.images || []).map(image => ({ ...image, url: 'blob:test-image' }))
|
||||
}))
|
||||
};
|
||||
},
|
||||
gpxForUpload: routePackage => routePackage.gpx,
|
||||
list: () => [...packages.values()],
|
||||
parseWaypoints: () => [],
|
||||
readFileAsText: file => file.text(),
|
||||
remove: id => packages.delete(String(id)),
|
||||
mediaBlob: async media => mediaBlobs.get(media.mediaKey) || new Blob(['media'], { type: media.type || 'application/octet-stream' }),
|
||||
removeMedia: async media => { if (media?.mediaKey) mediaBlobs.delete(media.mediaKey); },
|
||||
remove: async id => packages.delete(String(id)),
|
||||
storeMediaFile: async (routeId, poiId, kind, mediaId, file, handle = null) => {
|
||||
storedMediaCalls.push({ routeId, poiId, kind, mediaId, file, handle });
|
||||
const mediaKey = `${routeId}:${poiId}:${kind}:${mediaId}`;
|
||||
mediaBlobs.set(mediaKey, file);
|
||||
return {
|
||||
id: String(mediaId),
|
||||
mediaKey,
|
||||
storage: handle ? 'file-handle' : 'indexeddb',
|
||||
name: file.name,
|
||||
size: file.size || 1,
|
||||
type: file.type || 'application/octet-stream'
|
||||
};
|
||||
},
|
||||
save: input => {
|
||||
const id = String(input.id);
|
||||
const now = '2026-09-19T14:00:00.000Z';
|
||||
const pois = input.pois.map((poi, sequence) => ({ ...poi, sequence }));
|
||||
const trackPoints = pois
|
||||
const gpxPoints = pois
|
||||
.map(poi => `<trkpt lat="${poi.lat}" lon="${poi.lon}"><name>${poi.title || ''}</name><extensions><wegwichtel:poiId>${poi.id}</wegwichtel:poiId></extensions></trkpt>`)
|
||||
.join('');
|
||||
const saved = {
|
||||
id,
|
||||
gpx: `<gpx><trk><trkseg>${trackPoints}</trkseg></trk></gpx>`,
|
||||
gpx: `<gpx><trk><trkseg>${gpxPoints}</trkseg></trk></gpx>`,
|
||||
route: { ...input.route, id, updatedAt: now },
|
||||
pois
|
||||
};
|
||||
@@ -315,6 +348,7 @@ function buildEditorContext() {
|
||||
FormData: FakeFormData,
|
||||
globalThis: null,
|
||||
structuredClone,
|
||||
URL: { createObjectURL: () => 'blob:test-preview', revokeObjectURL() {} },
|
||||
Wegwichtel: {
|
||||
App: { currentPosition: () => ({ lat: 50.123456789, lon: 10.987654321, accuracy: 4.321 }) },
|
||||
Config: { apiBase: '/api/' },
|
||||
@@ -322,10 +356,10 @@ function buildEditorContext() {
|
||||
}
|
||||
};
|
||||
context.globalThis = context;
|
||||
return { calls, context, elements, packages, saves };
|
||||
return { calls, context, elements, packages, saves, storedMediaCalls };
|
||||
}
|
||||
|
||||
test('public editor workflow saves new route and GPX track point locally without POSTing a route', async () => {
|
||||
test('public editor workflow saves new route and GPX Streckenpunkt locally without POSTing a route', async () => {
|
||||
const sourcePath = path.join(root, 'public/js/editor-ui.js');
|
||||
const source = await fs.readFile(sourcePath, 'utf8');
|
||||
const { calls, context, elements, packages, saves } = buildEditorContext();
|
||||
@@ -338,6 +372,7 @@ test('public editor workflow saves new route and GPX track point locally without
|
||||
assert.ok(calls.some(call => call.method === 'GET' && call.url === '/api/routes?activatedOnly=true'));
|
||||
|
||||
await context.Wegwichtel.EditorUi.openNewLocalRoute();
|
||||
assert.equal(elements.get('editor-route-gpx-group').hidden, true);
|
||||
elements.get('editor-route-name').value = 'Neue lokale Strecke';
|
||||
elements.get('editor-route-description').value = 'Nur im Browser';
|
||||
elements.get('editor-route-form').dispatchEvent(submitEvent());
|
||||
@@ -351,6 +386,7 @@ test('public editor workflow saves new route and GPX track point locally without
|
||||
assert.equal(elements.get('editor-status').textContent, 'Speichern erfolgreich.');
|
||||
assert.equal(elements.get('editor-route-upload').hidden, false);
|
||||
assert.equal(elements.get('editor-route-upload').disabled, false);
|
||||
assert.equal(elements.get('editor-route-gpx-group').hidden, false);
|
||||
|
||||
elements.get('editor-add-poi').dispatchEvent(pointerEvent());
|
||||
assert.equal(elements.get('editor-poi-audio').clickCount, 0);
|
||||
@@ -379,9 +415,11 @@ test('public editor workflow saves new route and GPX track point locally without
|
||||
assert.equal(local.pois[0].lat, 50.123456789);
|
||||
assert.equal(local.pois[0].lon, 10.987654321);
|
||||
assert.equal(local.pois[0].triggerRadiusM, 4.321);
|
||||
assert.match(local.pois[0].audio.dataUrl, /^data:audio\/mpeg/);
|
||||
assert.equal(local.pois[0].audio.storage, 'indexeddb');
|
||||
assert.ok(local.pois[0].audio.mediaKey);
|
||||
assert.equal(local.pois[0].images.length, 1);
|
||||
assert.match(local.pois[0].images[0].dataUrl, /^data:image\/jpeg/);
|
||||
assert.equal(local.pois[0].images[0].storage, 'indexeddb');
|
||||
assert.ok(local.pois[0].images[0].mediaKey);
|
||||
assert.match(local.gpx, /<trkpt lat="50\.123456789" lon="10\.987654321">/);
|
||||
|
||||
const pointListBeforeUpload = elements.get('editor-poi-list');
|
||||
@@ -450,7 +488,7 @@ test('public editor workflow saves new route and GPX track point locally without
|
||||
assert.equal(elements.get('editor-button').attributes.get('aria-expanded'), 'false');
|
||||
});
|
||||
|
||||
test('route and track point list expose red delete actions and remove local data', async () => {
|
||||
test('route and Streckenpunkt list expose red delete actions and remove local data', async () => {
|
||||
const sourcePath = path.join(root, 'public/js/editor-ui.js');
|
||||
const source = await fs.readFile(sourcePath, 'utf8');
|
||||
const { context, elements, packages } = buildEditorContext();
|
||||
@@ -463,7 +501,7 @@ test('route and track point list expose red delete actions and remove local data
|
||||
await tick();
|
||||
|
||||
elements.get('editor-add-poi').dispatchEvent(pointerEvent());
|
||||
elements.get('editor-poi-title').value = 'Trackpunkt zum Löschen';
|
||||
elements.get('editor-poi-title').value = 'Streckenpunkt zum Löschen';
|
||||
elements.get('editor-poi-form').dispatchEvent(submitEvent());
|
||||
await tick();
|
||||
await tick();
|
||||
@@ -539,3 +577,40 @@ test('editing an activated server route uses PUT and never writes activation sta
|
||||
assert.equal('activatedAt' in body, false);
|
||||
assert.equal(elements.get('editor-status').textContent, 'Speichern erfolgreich.');
|
||||
});
|
||||
|
||||
|
||||
test('local media picker keeps File System Access handles for later upload', async () => {
|
||||
const sourcePath = path.join(root, 'public/js/editor-ui.js');
|
||||
const source = await fs.readFile(sourcePath, 'utf8');
|
||||
const { context, elements, storedMediaCalls } = buildEditorContext();
|
||||
const imageFile = Object.assign(new Blob(['photo'], { type: 'image/jpeg' }), { name: 'foto.jpg' });
|
||||
const handle = {
|
||||
name: 'foto.jpg',
|
||||
async getFile() { return imageFile; }
|
||||
};
|
||||
context.showOpenFilePicker = async options => {
|
||||
assert.equal(options.multiple, true);
|
||||
return [handle];
|
||||
};
|
||||
|
||||
vm.runInNewContext(source, context, { filename: sourcePath });
|
||||
await context.Wegwichtel.EditorUi.openNewLocalRoute();
|
||||
elements.get('editor-route-name').value = 'Handle-Strecke';
|
||||
elements.get('editor-route-form').dispatchEvent(submitEvent());
|
||||
await tick();
|
||||
await tick();
|
||||
|
||||
elements.get('editor-add-poi').dispatchEvent(pointerEvent());
|
||||
elements.get('editor-poi-title').value = 'Streckenpunkt mit Bild';
|
||||
elements.get('editor-poi-images-add').dispatchEvent(pointerEvent());
|
||||
await tick();
|
||||
assert.equal(elements.get('editor-poi-images').clickCount, 0);
|
||||
|
||||
elements.get('editor-poi-form').dispatchEvent(submitEvent());
|
||||
await tick();
|
||||
await tick();
|
||||
const imageStore = storedMediaCalls.find(call => call.kind === 'image');
|
||||
assert.ok(imageStore);
|
||||
assert.equal(imageStore.handle, handle);
|
||||
assert.equal(imageStore.file, imageFile);
|
||||
});
|
||||
|
||||
+185
-11
@@ -217,6 +217,83 @@ class FakeStorage {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function fakeIdbRequest(operation) {
|
||||
const request = { result: null, error: null, onsuccess: null, onerror: null };
|
||||
queueMicrotask(() => {
|
||||
try {
|
||||
request.result = operation();
|
||||
request.onsuccess?.();
|
||||
} catch (error) {
|
||||
request.error = error;
|
||||
request.onerror?.();
|
||||
}
|
||||
});
|
||||
return request;
|
||||
}
|
||||
|
||||
class FakeIndexedDatabaseConnection {
|
||||
constructor(records) {
|
||||
this.records = records;
|
||||
this.objectStoreNames = { contains: name => name === 'media' };
|
||||
}
|
||||
|
||||
createObjectStore() {
|
||||
this.objectStoreNames = { contains: name => name === 'media' };
|
||||
return {};
|
||||
}
|
||||
|
||||
transaction() {
|
||||
const records = this.records;
|
||||
return {
|
||||
error: null,
|
||||
onabort: null,
|
||||
objectStore() {
|
||||
return {
|
||||
put(record) {
|
||||
return fakeIdbRequest(() => {
|
||||
records.set(record.key, record);
|
||||
return record.key;
|
||||
});
|
||||
},
|
||||
get(key) {
|
||||
return fakeIdbRequest(() => records.get(key) || undefined);
|
||||
},
|
||||
delete(key) {
|
||||
return fakeIdbRequest(() => {
|
||||
records.delete(key);
|
||||
return undefined;
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class FakeIndexedDB {
|
||||
constructor() {
|
||||
this.records = new Map();
|
||||
this.database = new FakeIndexedDatabaseConnection(this.records);
|
||||
}
|
||||
|
||||
open() {
|
||||
const request = {
|
||||
result: this.database,
|
||||
error: null,
|
||||
onupgradeneeded: null,
|
||||
onsuccess: null,
|
||||
onerror: null,
|
||||
onblocked: null
|
||||
};
|
||||
queueMicrotask(() => {
|
||||
request.onupgradeneeded?.();
|
||||
request.onsuccess?.();
|
||||
});
|
||||
return request;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeCustomEvent extends Event {
|
||||
constructor(type, options = {}) {
|
||||
super(type);
|
||||
@@ -245,11 +322,13 @@ async function loadLocalRoutes(options = {}) {
|
||||
const sourcePath = path.join(root, 'public/js/local-routes.js');
|
||||
const source = await fs.readFile(sourcePath, 'utf8');
|
||||
const localStorage = new FakeStorage();
|
||||
const indexedDB = new FakeIndexedDB();
|
||||
const document = new EventTarget();
|
||||
const events = [];
|
||||
document.addEventListener('wegwichtel:local-routes-changed', event => events.push(event.detail));
|
||||
|
||||
const context = {
|
||||
Blob,
|
||||
CustomEvent: FakeCustomEvent,
|
||||
DOMParser: FakeDOMParser,
|
||||
Event,
|
||||
@@ -259,13 +338,15 @@ async function loadLocalRoutes(options = {}) {
|
||||
crypto: options.crypto || { randomUUID: () => '11111111-2222-4333-8444-555555555555' },
|
||||
document,
|
||||
globalThis: null,
|
||||
indexedDB,
|
||||
localStorage,
|
||||
queueMicrotask,
|
||||
URL: { createObjectURL: blob => `blob:test-${blob.size}` },
|
||||
Wegwichtel: {}
|
||||
};
|
||||
context.globalThis = context;
|
||||
vm.runInNewContext(source, context, { filename: sourcePath });
|
||||
return { api: context.Wegwichtel.LocalRoutes, events, localStorage };
|
||||
return { api: context.Wegwichtel.LocalRoutes, events, indexedDB, localStorage };
|
||||
}
|
||||
|
||||
const sampleGpx = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
@@ -376,7 +457,7 @@ test('local route package stores GPX and JSON separately and reconstructs an app
|
||||
assert.equal('gpx' in metadata, false);
|
||||
assert.equal('_source' in metadata.route, false);
|
||||
|
||||
const appRoute = api.getAppRoute(routeId);
|
||||
const appRoute = await api.getAppRoute(routeId);
|
||||
assert.equal(appRoute.source, 'local');
|
||||
assert.equal(appRoute.local, true);
|
||||
assert.equal(appRoute.pois.length, 2);
|
||||
@@ -406,14 +487,14 @@ test('GPX waypoint import reads stable POI ids and validates coordinates', async
|
||||
|
||||
const waypointOnly = api.syncGpx(
|
||||
`<gpx version="1.1" xmlns="${GPX_NAMESPACE}"></gpx>`,
|
||||
{ name: 'Nur Trackpunkt' },
|
||||
[{ id: 2, title: 'Einzelner Trackpunkt', lat: 50.2, lon: 10.2, sequence: 0 }]
|
||||
{ name: 'Nur Streckenpunkt' },
|
||||
[{ id: 2, title: 'Einzelner Streckenpunkt', lat: 50.2, lon: 10.2, sequence: 0 }]
|
||||
);
|
||||
assert.match(waypointOnly, /<trkpt lat="50.2" lon="10.2">/);
|
||||
assert.doesNotMatch(waypointOnly, /<wpt\b/);
|
||||
});
|
||||
|
||||
test('local points are stored directly as managed GPX track points and uploaded unchanged', async () => {
|
||||
test('local points are stored directly as managed GPX Streckenpunkte and uploaded unchanged', async () => {
|
||||
const { api } = await loadLocalRoutes();
|
||||
const saved = api.save({
|
||||
id: 'local-upload',
|
||||
@@ -433,10 +514,10 @@ test('local points are stored directly as managed GPX track points and uploaded
|
||||
assert.equal(api.gpxForUpload(saved), saved.gpx, 'upload must use the already stored track without conversion');
|
||||
});
|
||||
|
||||
test('sync keeps imported foreign track points and upload rejects a route without track points', async () => {
|
||||
test('sync keeps imported foreign Streckenpunkte and upload rejects a route without Streckenpunkte', async () => {
|
||||
const { api } = await loadLocalRoutes();
|
||||
const synced = api.syncGpx(sampleGpx, { name: 'Import' }, [
|
||||
{ id: 200, title: 'Neuer Trackpunkt', lat: 50.2, lon: 10.2, sequence: 0 }
|
||||
{ id: 200, title: 'Neuer Streckenpunkt', lat: 50.2, lon: 10.2, sequence: 0 }
|
||||
]);
|
||||
|
||||
assert.match(synced, /<ele>100<\/ele>/);
|
||||
@@ -448,7 +529,7 @@ test('sync keeps imported foreign track points and upload rejects a route withou
|
||||
gpx: api.emptyGpx('Leer'),
|
||||
route: { name: 'Leer' },
|
||||
pois: []
|
||||
}), /noch keine GPX-Trackpunkte/);
|
||||
}), /noch keine Streckenpunkte im GPX-Track/);
|
||||
});
|
||||
|
||||
test('remove updates index and dispatches a local-route change event', async () => {
|
||||
@@ -461,11 +542,11 @@ test('remove updates index and dispatches a local-route change event', async ()
|
||||
});
|
||||
|
||||
assert.equal(api.list().length, 1);
|
||||
api.remove('local-remove');
|
||||
await api.remove('local-remove');
|
||||
assert.equal(api.list().length, 0);
|
||||
assert.equal(events.at(-1).action, 'removed');
|
||||
assert.equal(events.at(-1).id, 'local-remove');
|
||||
assert.throws(() => api.getAppRoute('local-remove'), /nicht gefunden/);
|
||||
await assert.rejects(api.getAppRoute('local-remove'), /nicht gefunden/);
|
||||
});
|
||||
|
||||
test('quota failure rolls back package writes and keeps the previous package readable', async () => {
|
||||
@@ -483,11 +564,40 @@ test('quota failure rolls back package writes and keeps the previous package rea
|
||||
gpx: sampleGpx,
|
||||
route: { name: 'Nachher' },
|
||||
pois: []
|
||||
}), /Browser-Speicher ist voll/);
|
||||
}), /Speicher für Streckendaten ist voll/);
|
||||
|
||||
assert.equal(api.get('local-quota').route.name, 'Vorher');
|
||||
});
|
||||
|
||||
test('local media uses IndexedDB so route JSON does not consume Local Storage quota', async () => {
|
||||
const { api, indexedDB, localStorage } = await loadLocalRoutes();
|
||||
const routeId = 'local-media';
|
||||
const audioFile = Object.assign(new Blob(['audio-payload'], { type: 'audio/mpeg' }), { name: 'weg.mp3' });
|
||||
const imageFile = Object.assign(new Blob(['image-payload'], { type: 'image/jpeg' }), { name: 'weg.jpg' });
|
||||
|
||||
const audio = await api.storeMediaFile(routeId, 1, 'audio', '1-audio', audioFile);
|
||||
const image = await api.storeMediaFile(routeId, 1, 'image', '1-image', imageFile);
|
||||
const saved = api.save({
|
||||
id: routeId,
|
||||
gpx: api.emptyGpx('Medientest'),
|
||||
route: { name: 'Medientest' },
|
||||
pois: [{ id: 1, title: 'Streckenpunkt 1', lat: 50.1, lon: 10.1, audio, images: [image] }]
|
||||
});
|
||||
|
||||
const metadata = localStorage.getItem(`${api.STORAGE_PREFIX}.${encodeURIComponent(routeId)}.json`);
|
||||
assert.doesNotMatch(metadata, /data:audio|data:image|audio-payload|image-payload/);
|
||||
assert.match(metadata, /"storage":"indexeddb"/);
|
||||
assert.equal(indexedDB.records.size, 2);
|
||||
|
||||
const hydrated = await api.getWithMedia(routeId);
|
||||
assert.match(hydrated.pois[0].audio.url, /^blob:test-/);
|
||||
assert.match(hydrated.pois[0].images[0].url, /^blob:test-/);
|
||||
assert.equal((await api.mediaBlob(saved.pois[0].audio)).size, audioFile.size);
|
||||
|
||||
await api.remove(routeId);
|
||||
assert.equal(indexedDB.records.size, 0);
|
||||
});
|
||||
|
||||
test('file helpers support Blob.text and FileReader-backed media data URLs', async () => {
|
||||
const { api } = await loadLocalRoutes();
|
||||
|
||||
@@ -503,3 +613,67 @@ test('file helpers support Blob.text and FileReader-backed media data URLs', asy
|
||||
await assert.rejects(api.fileToDataUrl({ invalidReaderResult: true }), { name: 'TypeError' });
|
||||
assert.equal(await api.fileToDataUrl(null), null);
|
||||
});
|
||||
|
||||
test('local media stores a File System Access handle instead of copying file bytes when supported', async () => {
|
||||
const { api, indexedDB, localStorage } = await loadLocalRoutes();
|
||||
const file = Object.assign(new Blob(['large-image'], { type: 'image/jpeg' }), { name: 'foto.jpg' });
|
||||
let permission = 'granted';
|
||||
let permissionRequests = 0;
|
||||
const handle = {
|
||||
name: 'foto.jpg',
|
||||
async getFile() { return file; },
|
||||
async queryPermission() { return permission; },
|
||||
async requestPermission() {
|
||||
permissionRequests += 1;
|
||||
permission = 'granted';
|
||||
return permission;
|
||||
}
|
||||
};
|
||||
|
||||
const media = await api.storeMediaFile('route-handle', 7, 'image', 'image-7', file, handle);
|
||||
assert.equal(media.storage, 'file-handle');
|
||||
const record = indexedDB.records.get(media.mediaKey);
|
||||
assert.equal(record.handle, handle);
|
||||
assert.equal('blob' in record, false);
|
||||
|
||||
api.save({
|
||||
id: 'route-handle',
|
||||
gpx: api.emptyGpx('Handle'),
|
||||
route: { name: 'Handle' },
|
||||
pois: [{ id: 7, title: 'Streckenpunkt', lat: 50.1, lon: 10.1, images: [media] }]
|
||||
});
|
||||
const metadata = localStorage.getItem(`${api.STORAGE_PREFIX}.${encodeURIComponent('route-handle')}.json`);
|
||||
assert.match(metadata, /"storage":"file-handle"/);
|
||||
assert.doesNotMatch(metadata, /large-image/);
|
||||
|
||||
assert.equal(await api.mediaBlob(media), file);
|
||||
permission = 'prompt';
|
||||
await assert.rejects(api.mediaBlob(media), /erneut erlaubt/);
|
||||
assert.equal(await api.mediaBlob(media, { requestPermission: true }), file);
|
||||
assert.equal(permissionRequests, 1);
|
||||
});
|
||||
|
||||
test('multiple Streckenpunkte with large local media keep Local Storage metadata small', async () => {
|
||||
const { api, localStorage } = await loadLocalRoutes();
|
||||
const routeId = 'multi-media';
|
||||
const large = new Uint8Array(1024 * 1024);
|
||||
const firstFile = Object.assign(new Blob([large], { type: 'image/jpeg' }), { name: 'eins.jpg' });
|
||||
const secondFile = Object.assign(new Blob([large], { type: 'image/jpeg' }), { name: 'zwei.jpg' });
|
||||
const first = await api.storeMediaFile(routeId, 1, 'image', 'image-1', firstFile);
|
||||
const second = await api.storeMediaFile(routeId, 2, 'image', 'image-2', secondFile);
|
||||
|
||||
api.save({
|
||||
id: routeId,
|
||||
gpx: api.emptyGpx('Mehrere Streckenpunkte'),
|
||||
route: { name: 'Mehrere Streckenpunkte' },
|
||||
pois: [
|
||||
{ id: 1, title: 'Erster Streckenpunkt', lat: 50.1, lon: 10.1, images: [first] },
|
||||
{ id: 2, title: 'Zweiter Streckenpunkt', lat: 50.2, lon: 10.2, images: [second] }
|
||||
]
|
||||
});
|
||||
|
||||
const metadata = localStorage.getItem(`${api.STORAGE_PREFIX}.${encodeURIComponent(routeId)}.json`);
|
||||
assert.ok(metadata.length < 5000, `metadata unexpectedly large: ${metadata.length}`);
|
||||
assert.doesNotMatch(metadata, /data:image/);
|
||||
assert.equal(api.get(routeId).pois.length, 2);
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ async function read(relativePath) {
|
||||
return fs.readFile(path.join(root, relativePath), 'utf8');
|
||||
}
|
||||
|
||||
test('presentation example uses real GPX track points and the existing upload API', async () => {
|
||||
test('presentation example uses real GPX Streckenpunkte and the existing upload API', async () => {
|
||||
const gpx = await read('examples/presentation-route.gpx');
|
||||
const metadata = JSON.parse(await read('examples/presentation-route.json'));
|
||||
const script = await read('scripts/upload-presentation-route.sh');
|
||||
|
||||
@@ -12,6 +12,7 @@ async function read(relativePath) {
|
||||
test('main screen contains a persistent route control next to the information control', async () => {
|
||||
const html = await read('public/index.html');
|
||||
const css = await read('public/css/editor.css');
|
||||
const icon = await read('public/images/icons/route-management.svg');
|
||||
|
||||
assert.match(html, /class="route-control"/);
|
||||
assert.match(html, /id="route-control-button"/);
|
||||
@@ -22,6 +23,10 @@ test('main screen contains a persistent route control next to the information co
|
||||
assert.match(html, /id="route-control-local-list"/);
|
||||
assert.match(html, /id="route-control-new"/);
|
||||
assert.match(html, /id="info-button"/);
|
||||
assert.match(html, /id="route-control-button"[\s\S]*?<img[^>]+route-management\.svg/);
|
||||
assert.match(html, /id="editor-button"[^>]*>Strecken verwalten<\/button>/);
|
||||
assert.match(icon, /C[^\"]+/);
|
||||
assert.match(icon, /stroke-dasharray/);
|
||||
|
||||
assert.match(css, /\.route-control\s*\{[\s\S]*position:\s*fixed/i);
|
||||
assert.match(css, /\.route-control\s*\{[\s\S]*right:\s*calc\([\s\S]*--icon-button-size/i);
|
||||
@@ -50,7 +55,7 @@ test('persistent route control displays the next unvisited POI and preserves pro
|
||||
const app = await read('public/js/app.js');
|
||||
|
||||
assert.match(app, /function nextPoiMetric\(/);
|
||||
assert.match(app, /Nächster POI:/);
|
||||
assert.match(app, /Nächster Streckenpunkt:/);
|
||||
assert.match(app, /state\.triggered\.has\(poi\.id\)/);
|
||||
assert.match(app, /function suspendCurrentRoute\(/);
|
||||
assert.match(app, /persistRouteProgress\(\)/);
|
||||
|
||||
Reference in New Issue
Block a user