added privacy switches and documents, assets and local storage for already visited POIs
This commit is contained in:
parent
fa9ab1aa14
commit
d1b368d0c4
16
README.md
16
README.md
@ -182,3 +182,19 @@ Wegwichtel.Vibration.start(Wegwichtel.Vibration.Patterns.ACTIVE_POI);
|
||||
Während die Route läuft, werden Positionsänderungen fortlaufend verarbeitet. Eine Browser-Webanwendung ist jedoch kein nativer Hintergrunddienst: Betriebssystem und Browser können die Aktualisierung bei gesperrtem Bildschirm, Energiesparmodus oder im Hintergrund drosseln beziehungsweise anhalten.
|
||||
|
||||
Die Symbole liegen unter `public/images/icons/`. Sie befinden sich innerhalb nativer `<button>`-Elemente. Die Bilder selbst sind dekorativ (`alt=""`); der zugängliche Name wird über `aria-label` und `title` am jeweiligen Button bereitgestellt.
|
||||
|
||||
## Datenschutz, Impressum und lokale Browserdaten
|
||||
|
||||
Die Oberfläche enthält rechts oben einen global verfügbaren Informationsbutton. Er öffnet ein touch- und tastaturbedienbares Overlay mit Datenschutzerklärung, Impressum sowie einer technischen Erläuterung zu Cookies, Local Storage und den lokal eingebundenen JavaScript-Bibliotheken.
|
||||
|
||||
Die enthaltenen Rechtstexte sind technische Vorlagen. Vor einer öffentlichen Bereitstellung müssen insbesondere Betreiber, ladungsfähige Anschrift, Kontakt, Hostinganbieter, Protokollierungsdauer, Datenschutzkontakt und zuständige Aufsicht ergänzt und rechtlich geprüft werden. Eine Checkliste steht in [`docs/PUBLICATION-CHECKLIST.md`](docs/PUBLICATION-CHECKLIST.md).
|
||||
|
||||
Wegwichtel setzt selbst keine Cookies und bindet keine Analyse-, Werbe- oder Trackingdienste ein. Der Startbildschirm weist darauf hin. Die freiwillige Speicherung des Streckenfortschritts wird getrennt gesteuert:
|
||||
|
||||
- `wegwichtel.privacy.v1` speichert die gewählte Einstellung,
|
||||
- `wegwichtel.route-progress.v1` enthält bei aktivierter Funktion ein JSON-Objekt mit aktueller Route, Routenstatus, besuchten und angesehenen Stationen, abgespielten Audioeinträgen und Zeitstempeln,
|
||||
- Streckendaten mit einer letzten Aktualisierung von mindestens 24 Stunden werden beim nächsten Start schrittweise entfernt,
|
||||
- beim Ausschalten der Funktion wird der gesamte Streckenfortschritt sofort aus dem Local Storage gelöscht,
|
||||
- ohne aktivierte Speicherung bleiben alle Routen- und Stationsdaten ausschließlich im Arbeitsspeicher des geöffneten Browserfensters.
|
||||
|
||||
Der Initialisierungsbildschirm bleibt nach erfolgreichem Laden sichtbar. Erst eine bewusste Pointer- oder Tastaturaktion öffnet die eigentliche Anwendung. Das Informationsfenster und die Datenschutzeinstellungen können bereits auf dem Startbildschirm verwendet werden, ohne die Anwendung weiterzuschalten.
|
||||
|
||||
43
docs/PUBLICATION-CHECKLIST.md
Normal file
43
docs/PUBLICATION-CHECKLIST.md
Normal file
@ -0,0 +1,43 @@
|
||||
# Checkliste vor der öffentlichen Bereitstellung
|
||||
|
||||
Die in der Anwendung enthaltenen Texte sind Vorlagen und ersetzen keine Prüfung des konkreten Betriebsmodells. Vor der Veröffentlichung sind mindestens die folgenden Punkte zu bearbeiten.
|
||||
|
||||
## Betreiber und Impressum
|
||||
|
||||
- vollständiger Name und Rechtsform der Betreiberin
|
||||
- vertretungsberechtigte Person
|
||||
- ladungsfähige Anschrift
|
||||
- E-Mail-Adresse und weiterer unmittelbarer Kontaktweg
|
||||
- inhaltlich verantwortliche Person
|
||||
- gegebenenfalls Aufsichtsbehörde, Register und steuerliche Pflichtangaben
|
||||
|
||||
## Datenschutz
|
||||
|
||||
- Verantwortliche Stelle und Datenschutzkontakt
|
||||
- Hostinganbieter und Ort der Verarbeitung
|
||||
- Art und Dauer der Serverprotokollierung
|
||||
- Rechtsgrundlagen für Serverbetrieb und freiwillige Browser-Speicherung
|
||||
- zuständige Datenschutzaufsichtsbehörde
|
||||
- Verfahren für Auskunft, Berichtigung, Löschung und Widerruf
|
||||
- Auftragsverarbeitungsvertrag mit dem Hostinganbieter, soweit erforderlich
|
||||
|
||||
## Inhalte von Kindern und Dritten
|
||||
|
||||
- Einwilligungen der Sorgeberechtigten für erkennbare Kinder
|
||||
- Freigaben für Sprachaufnahmen und Namensnennungen
|
||||
- dokumentierte Nutzungsrechte an Bildern, Texten, Tonaufnahmen und Routendaten
|
||||
- redaktionelle Prüfung vor Veröffentlichung
|
||||
- festgelegtes Verfahren für Korrektur- und Löschanfragen
|
||||
|
||||
## Technischer Betrieb
|
||||
|
||||
- ausschließlich HTTPS für den öffentlichen Zugriff
|
||||
- Prüfung der Reverse-Proxy-, Zugriffs- und Sicherheitsheader
|
||||
- Schutz aller schreibenden API-Endpunkte
|
||||
- regelmäßige Aktualisierung der lokalen JavaScript-Abhängigkeiten
|
||||
- dokumentierte Datensicherung und Wiederherstellung
|
||||
- Prüfung, dass keine zusätzlichen Cookies, externen Skripte, Schriftarten oder Trackingdienste durch Hosting oder Reverse Proxy ergänzt werden
|
||||
|
||||
## Konfiguration der Vorlagentexte
|
||||
|
||||
In `public/index.html` sind alle mit eckigen Klammern gekennzeichneten Platzhalter zu ersetzen. Nicht benötigte Abschnitte sind zu entfernen; zusätzliche gesetzliche oder organisatorische Pflichtangaben sind zu ergänzen.
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "wegwichtel-next",
|
||||
"version": "0.12.12",
|
||||
"version": "0.12.14",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "wegwichtel-next",
|
||||
"version": "0.12.12",
|
||||
"version": "0.12.14",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@file-type/av": "0.2.0",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "wegwichtel-next",
|
||||
"version": "0.12.12",
|
||||
"version": "0.12.14",
|
||||
"private": true,
|
||||
"description": "GPS-gestützte Lern- und Wanderwege mit GPX, POIs, Bildern und Audioansagen.",
|
||||
"type": "module",
|
||||
|
||||
BIN
public/assets/logo_1254.png
Normal file
BIN
public/assets/logo_1254.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.4 MiB |
BIN
public/assets/logo_256.png
Normal file
BIN
public/assets/logo_256.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 163 KiB |
@ -575,3 +575,252 @@ button,
|
||||
transition-duration: .01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
.info-button {
|
||||
position: fixed;
|
||||
top: calc(env(safe-area-inset-top) + .45rem);
|
||||
right: max(.65rem, env(safe-area-inset-right));
|
||||
z-index: 10001;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: var(--icon-button-size);
|
||||
height: var(--icon-button-size);
|
||||
padding: 0;
|
||||
border: 2px solid rgba(255, 255, 255, .9);
|
||||
border-radius: 50%;
|
||||
background: var(--primary-dark);
|
||||
color: #fff;
|
||||
box-shadow: 0 .15rem .55rem rgba(0, 0, 0, .28);
|
||||
font-size: 1.45rem;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.info-button:focus-visible {
|
||||
outline: .2rem solid #fff;
|
||||
outline-offset: .15rem;
|
||||
}
|
||||
|
||||
.app-page > header:not(.has-back-button) h1 {
|
||||
padding-right: calc(var(--icon-button-size) + 1rem);
|
||||
padding-left: calc(var(--icon-button-size) + 1rem);
|
||||
}
|
||||
|
||||
.switch-row {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: .85rem;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
padding: .75rem;
|
||||
border: 1px solid #c8d3ca;
|
||||
border-radius: .65rem;
|
||||
background: #fff;
|
||||
color: var(--text);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.switch-row > span:first-child {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: .2rem;
|
||||
}
|
||||
|
||||
.switch-row small {
|
||||
color: var(--muted);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.switch-row > input {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.switch-track {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 3.25rem;
|
||||
height: 1.8rem;
|
||||
border: 2px solid #6b786e;
|
||||
border-radius: 999px;
|
||||
background: #d9ded9;
|
||||
transition: background-color 160ms ease, border-color 160ms ease;
|
||||
}
|
||||
|
||||
.switch-track::after {
|
||||
position: absolute;
|
||||
top: .15rem;
|
||||
left: .15rem;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
box-shadow: 0 .08rem .2rem rgba(0, 0, 0, .35);
|
||||
content: "";
|
||||
transition: transform 160ms ease;
|
||||
}
|
||||
|
||||
.switch-row > input:checked + .switch-track {
|
||||
border-color: var(--primary-dark);
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
.switch-row > input:checked + .switch-track::after {
|
||||
transform: translateX(1.45rem);
|
||||
}
|
||||
|
||||
.switch-row > input:focus-visible + .switch-track {
|
||||
outline: .2rem solid var(--focus);
|
||||
outline-offset: .15rem;
|
||||
}
|
||||
|
||||
.switch-disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: .72;
|
||||
}
|
||||
|
||||
.compact-switch {
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.info-dialog {
|
||||
width: min(48rem, calc(100% - 1.5rem));
|
||||
max-width: 48rem;
|
||||
height: min(48rem, calc(100dvh - 1.5rem));
|
||||
max-height: calc(100dvh - 1.5rem);
|
||||
box-sizing: border-box;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border: 0;
|
||||
border-radius: .85rem;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
box-shadow: 0 .8rem 2.5rem rgba(0, 0, 0, .35);
|
||||
}
|
||||
|
||||
.info-dialog::backdrop {
|
||||
background: rgba(12, 20, 14, .72);
|
||||
backdrop-filter: blur(.15rem);
|
||||
}
|
||||
|
||||
.info-dialog-shell {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.info-dialog-header {
|
||||
display: grid;
|
||||
grid-template-columns: var(--icon-button-size) minmax(0, 1fr) var(--icon-button-size);
|
||||
align-items: center;
|
||||
gap: .4rem;
|
||||
padding: max(.4rem, env(safe-area-inset-top)) .5rem .4rem;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.info-dialog-header h2 {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
font-size: 1.15rem;
|
||||
text-align: center;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.info-dialog-header button {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: var(--icon-button-size);
|
||||
height: var(--icon-button-size);
|
||||
padding: 0;
|
||||
border: 1px solid rgba(255, 255, 255, .55);
|
||||
border-radius: .45rem;
|
||||
background: var(--primary-dark);
|
||||
color: #fff;
|
||||
font-size: 1.8rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.info-dialog-content {
|
||||
min-height: 0;
|
||||
padding: 1rem max(1rem, env(safe-area-inset-right)) calc(1rem + env(safe-area-inset-bottom)) max(1rem, env(safe-area-inset-left));
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
touch-action: pan-y;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.info-menu {
|
||||
display: grid;
|
||||
gap: .75rem;
|
||||
}
|
||||
|
||||
.info-menu button {
|
||||
min-height: 3.5rem;
|
||||
padding: .85rem 1rem;
|
||||
border: 1px solid #aebcaf;
|
||||
border-radius: .65rem;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
box-shadow: 0 .12rem .35rem rgba(0, 0, 0, .08);
|
||||
font-weight: 700;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.info-page {
|
||||
line-height: 1.55;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.info-page h3 {
|
||||
margin: 1.4rem 0 .45rem;
|
||||
color: var(--primary-dark);
|
||||
}
|
||||
|
||||
.info-page h3:first-of-type {
|
||||
margin-top: .5rem;
|
||||
}
|
||||
|
||||
.info-page code {
|
||||
padding: .08rem .25rem;
|
||||
border-radius: .2rem;
|
||||
background: #e8eee7;
|
||||
}
|
||||
|
||||
.legal-warning {
|
||||
margin: 0 0 1rem;
|
||||
padding: .8rem;
|
||||
border-left: .35rem solid var(--notice-border);
|
||||
border-radius: .25rem;
|
||||
background: var(--notice);
|
||||
}
|
||||
|
||||
@media (max-width: 34rem) {
|
||||
.info-dialog {
|
||||
width: 100%;
|
||||
height: 100dvh;
|
||||
max-height: 100dvh;
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.switch-row {
|
||||
gap: .55rem;
|
||||
padding: .65rem;
|
||||
}
|
||||
}
|
||||
|
||||
56
public/css/bootstrap.css
vendored
56
public/css/bootstrap.css
vendored
@ -55,3 +55,59 @@ body {
|
||||
background: #fee;
|
||||
color: #701010;
|
||||
}
|
||||
|
||||
#bootstrap-screen {
|
||||
align-items: start;
|
||||
overflow-y: auto;
|
||||
padding-top: calc(1rem + env(safe-area-inset-top));
|
||||
padding-bottom: calc(1rem + env(safe-area-inset-bottom));
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
#bootstrap-screen.ready {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#bootstrap-screen > section {
|
||||
margin: auto 0;
|
||||
}
|
||||
|
||||
.privacy-summary {
|
||||
display: grid;
|
||||
gap: .65rem;
|
||||
margin: 1rem 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.privacy-summary > p {
|
||||
margin: 0;
|
||||
padding: .7rem;
|
||||
border-left: .35rem solid #315d3a;
|
||||
border-radius: .25rem;
|
||||
background: #eef4e9;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.storage-cleanup-status,
|
||||
.bootstrap-ready {
|
||||
margin: .85rem 0;
|
||||
padding: .75rem;
|
||||
border-radius: .5rem;
|
||||
background: #fff8d6;
|
||||
color: #493700;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.bootstrap-ready {
|
||||
border: 2px solid #315d3a;
|
||||
background: #eef4e9;
|
||||
color: #1f3022;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
#bootstrap-enter,
|
||||
#bootstrap-retry {
|
||||
min-height: 3rem;
|
||||
margin: .35rem;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
@ -5,17 +5,45 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#315d3a">
|
||||
<title>Wegwichtel</title>
|
||||
<link rel="stylesheet" href="css/bootstrap.css?v=0.12.12">
|
||||
<link rel="stylesheet" href="vendor/jquery-ui/jquery-ui-1.14.2.min.css?v=0.12.12">
|
||||
<link rel="stylesheet" href="css/app.css?v=0.12.12">
|
||||
<link rel="stylesheet" href="css/bootstrap.css?v=0.12.14">
|
||||
<link rel="stylesheet" href="vendor/jquery-ui/jquery-ui-1.14.2.min.css?v=0.12.14">
|
||||
<link rel="stylesheet" href="css/app.css?v=0.12.14">
|
||||
</head>
|
||||
<body>
|
||||
<button id="info-button" class="info-button" type="button" aria-label="Informationen, Datenschutz und Impressum öffnen" title="Informationen">
|
||||
<span aria-hidden="true">i</span>
|
||||
</button>
|
||||
|
||||
<div id="bootstrap-screen" aria-live="polite" aria-busy="true">
|
||||
<section>
|
||||
<span aria-hidden="true">🧭</span>
|
||||
<h1>Wegwichtel</h1>
|
||||
<p>Der Wegwichtel bereitet Routen und Medien vor.</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">
|
||||
<span>
|
||||
<strong>Optionale Cookies</strong>
|
||||
<small>Nicht verwendet und deshalb dauerhaft ausgeschaltet.</small>
|
||||
</span>
|
||||
<input class="cookie-toggle" type="checkbox" role="switch" disabled>
|
||||
<span class="switch-track" aria-hidden="true"></span>
|
||||
</label>
|
||||
|
||||
<label class="switch-row">
|
||||
<span>
|
||||
<strong>Streckenfortschritt lokal speichern</strong>
|
||||
<small>Speichert ausgewählte Routen, besuchte und angesehene Stationen sowie abgespielte Audiodateien ausschließlich in diesem Browser.</small>
|
||||
</span>
|
||||
<input class="progress-storage-toggle" type="checkbox" role="switch">
|
||||
<span class="switch-track" aria-hidden="true"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p id="storage-cleanup-status" class="storage-cleanup-status" hidden></p>
|
||||
|
||||
<progress id="bootstrap-progress" max="6" value="0"></progress>
|
||||
|
||||
<ol id="bootstrap-steps">
|
||||
@ -27,8 +55,10 @@
|
||||
<li data-step="location">Standort ermitteln</li>
|
||||
</ol>
|
||||
|
||||
<p id="bootstrap-ready" class="bootstrap-ready" hidden>Alles ist bereit. Tippe oder klicke auf diese Startseite, um die Anwendung zu öffnen.</p>
|
||||
<p id="bootstrap-error" hidden></p>
|
||||
<button id="bootstrap-retry" type="button" hidden>Erneut versuchen</button>
|
||||
<button id="bootstrap-enter" type="button" hidden>Anwendung öffnen</button>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@ -151,6 +181,145 @@
|
||||
</footer>
|
||||
</main>
|
||||
|
||||
<script src="js/bootstrap-loader.js?v=0.12.12"></script>
|
||||
<dialog id="info-dialog" class="info-dialog" aria-labelledby="info-dialog-title">
|
||||
<div class="info-dialog-shell">
|
||||
<header class="info-dialog-header">
|
||||
<button id="info-back" type="button" aria-label="Zurück zum Informationsmenü" title="Zurück" hidden>‹</button>
|
||||
<h2 id="info-dialog-title">Informationen</h2>
|
||||
<button id="info-close" type="button" aria-label="Informationsfenster schließen" title="Schließen">×</button>
|
||||
</header>
|
||||
|
||||
<div class="info-dialog-content">
|
||||
<nav id="info-menu" class="info-menu" aria-label="Rechtliche und technische Informationen">
|
||||
<button type="button" data-info-target="privacy">Datenschutzerklärung</button>
|
||||
<button type="button" data-info-target="imprint">Impressum</button>
|
||||
<button type="button" data-info-target="technology">Cookies und verwendete JavaScripts</button>
|
||||
</nav>
|
||||
|
||||
<article class="info-page" data-info-page="privacy" hidden>
|
||||
<p class="legal-warning"><strong>Vor der öffentlichen Freigabe:</strong> Die gekennzeichneten Angaben müssen durch die verantwortliche Schule, Gemeinde, Initiative oder sonstige Betreiberin ergänzt und rechtlich geprüft werden.</p>
|
||||
|
||||
<h3>1. Verantwortliche Stelle</h3>
|
||||
<p>
|
||||
<strong>[Name der verantwortlichen Organisation ergänzen]</strong><br>
|
||||
[Straße und Hausnummer ergänzen]<br>
|
||||
[Postleitzahl und Ort ergänzen]<br>
|
||||
E-Mail: [E-Mail-Adresse ergänzen]<br>
|
||||
Telefon: [Telefonnummer ergänzen]
|
||||
</p>
|
||||
|
||||
<h3>2. Bereitstellung der Website</h3>
|
||||
<p>Beim Aufruf der Website können durch den Webserver technisch erforderliche Verbindungsdaten verarbeitet werden. Dazu können IP-Adresse, Zeitpunkt, aufgerufene Adresse, übertragene Datenmenge, Browsertyp und Betriebssystem gehören. Welche Protokolldaten tatsächlich gespeichert werden und wie lange dies geschieht, muss für den eingesetzten Hostingdienst ergänzt werden.</p>
|
||||
<p>Hostinganbieter: <strong>[Anbieter, Anschrift und Speicherdauer ergänzen]</strong></p>
|
||||
|
||||
<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>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>
|
||||
<strong>Streckenfortschritt lokal speichern</strong>
|
||||
<small>Freiwillige Browser-Speicherung ein- oder ausschalten.</small>
|
||||
</span>
|
||||
<input class="progress-storage-toggle" type="checkbox" role="switch">
|
||||
<span class="switch-track" aria-hidden="true"></span>
|
||||
</label>
|
||||
|
||||
<h3>5. 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>
|
||||
|
||||
<h3>6. Inhalte von Schülerinnen und Schülern</h3>
|
||||
<p>Fotos, Texte und Sprachaufnahmen dürfen nur veröffentlicht werden, wenn die erforderlichen Rechte und Einwilligungen vorliegen. Besonders bei erkennbaren Kindern sind die Vorgaben der Schule und die Einwilligungen der Sorgeberechtigten zu beachten.</p>
|
||||
|
||||
<h3>7. Rechte betroffener Personen</h3>
|
||||
<p>Betroffene Personen können je nach rechtlicher Voraussetzung Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung, Datenübertragbarkeit und Widerspruch verlangen. Erteilte Einwilligungen können für die Zukunft widerrufen werden.</p>
|
||||
<p>Datenschutzkontakt: <strong>[Kontakt ergänzen]</strong><br>Zuständige Datenschutzaufsicht: <strong>[Behörde und Kontakt ergänzen]</strong></p>
|
||||
</article>
|
||||
|
||||
<article class="info-page" data-info-page="imprint" hidden>
|
||||
<p class="legal-warning"><strong>Platzhalter:</strong> Dieses Impressum ist vor der Veröffentlichung mit den Angaben der tatsächlichen Betreiberin zu vervollständigen.</p>
|
||||
|
||||
<h3>Anbieter und verantwortlich für dieses Angebot</h3>
|
||||
<p>
|
||||
<strong>[Vollständiger Name der Schule, Gemeinde, Initiative oder Person]</strong><br>
|
||||
[Rechtsform oder Vertretungsberechtigung ergänzen]<br>
|
||||
[Straße und Hausnummer]<br>
|
||||
[Postleitzahl und Ort]
|
||||
</p>
|
||||
|
||||
<h3>Kontakt</h3>
|
||||
<p>
|
||||
Telefon: [Telefonnummer ergänzen]<br>
|
||||
E-Mail: [E-Mail-Adresse ergänzen]
|
||||
</p>
|
||||
|
||||
<h3>Inhaltlich verantwortlich</h3>
|
||||
<p>[Name und ladungsfähige Anschrift ergänzen]</p>
|
||||
|
||||
<h3>Weitere Pflichtangaben</h3>
|
||||
<p>Je nach Betreiberin können zusätzliche Angaben erforderlich sein, zum Beispiel zur Aufsichtsbehörde, Registernummer, Umsatzsteuer-ID oder zuständigen Körperschaft. Nicht benötigte Angaben sind zu entfernen.</p>
|
||||
|
||||
<h3>Haftung für Inhalte und Verweise</h3>
|
||||
<p>Die Inhalte werden sorgfältig erstellt und regelmäßig geprüft. Für Hinweise auf fehlerhafte, veraltete oder rechtsverletzende Inhalte steht der oben genannte Kontakt zur Verfügung. Für Inhalte externer Seiten sind deren Betreiber verantwortlich.</p>
|
||||
|
||||
<h3>Urheber- und Nutzungsrechte</h3>
|
||||
<p>Texte, Bilder, Tonaufnahmen und Routendaten dürfen nur im Rahmen der jeweils eingeräumten Nutzungsrechte verwendet werden. Die Rechte der beteiligten Kinder, Familien, Lehrkräfte und weiteren Urheberinnen und Urheber bleiben unberührt.</p>
|
||||
</article>
|
||||
|
||||
<article class="info-page" data-info-page="technology" hidden>
|
||||
<h3>Cookies</h3>
|
||||
<p>Wegwichtel setzt derzeit keine Cookies. Die Anwendung besitzt keine Werbe-, Analyse- oder Trackingfunktionen. Der Cookie-Schalter bleibt deshalb ausgeschaltet.</p>
|
||||
|
||||
<label class="switch-row switch-disabled compact-switch">
|
||||
<span>
|
||||
<strong>Optionale Cookies</strong>
|
||||
<small>Derzeit nicht vorhanden.</small>
|
||||
</span>
|
||||
<input class="cookie-toggle" type="checkbox" role="switch" disabled>
|
||||
<span class="switch-track" aria-hidden="true"></span>
|
||||
</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>
|
||||
|
||||
<label class="switch-row compact-switch">
|
||||
<span>
|
||||
<strong>Streckenfortschritt lokal speichern</strong>
|
||||
<small>Die Änderung gilt sofort auf diesem Gerät.</small>
|
||||
</span>
|
||||
<input class="progress-storage-toggle" type="checkbox" role="switch">
|
||||
<span class="switch-track" aria-hidden="true"></span>
|
||||
</label>
|
||||
|
||||
<h3>Lokal eingebundene JavaScripts</h3>
|
||||
<ul>
|
||||
<li><strong>jQuery 4.0.0:</strong> DOM-Zugriffe, Ereignisbehandlung und API-Anfragen.</li>
|
||||
<li><strong>jQuery UI 1.14.2:</strong> Schaltflächen und Suchvorschläge.</li>
|
||||
<li><strong>Wegwichtel-Module:</strong> Routenanzeige, Navigation, Standort, Geräteausrichtung, Bilder, Audio, Vibration, Datenschutz und lokaler Fortschritt.</li>
|
||||
</ul>
|
||||
<p>Alle Bibliotheken werden vom eigenen Server ausgeliefert. Die Anwendung lädt keine JavaScripts, Schriftarten oder Analysepixel von externen Anbietern.</p>
|
||||
|
||||
<h3>Browser-Schnittstellen</h3>
|
||||
<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>HTML-Audio und HTML-Bilder für lokal bereitgestellte Medien,</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.</p>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<script src="js/privacy-storage.js?v=0.12.14"></script>
|
||||
<script src="js/legal-ui.js?v=0.12.14"></script>
|
||||
<script src="js/bootstrap-loader.js?v=0.12.14"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -9,13 +9,61 @@
|
||||
routeState: 'idle',
|
||||
activePage: null,
|
||||
routeProgressIndex: null,
|
||||
deviceHeading: null
|
||||
deviceHeading: null,
|
||||
viewed: new Set(),
|
||||
playedAudio: new Set(),
|
||||
activePoiId: null,
|
||||
persistTimer: null
|
||||
};
|
||||
|
||||
function escapeHtml(value) {
|
||||
return $('<div>').text(value ?? '').html();
|
||||
}
|
||||
|
||||
|
||||
function persistRouteProgress() {
|
||||
if (!state.route) return;
|
||||
if (state.persistTimer != null) {
|
||||
globalThis.clearTimeout(state.persistTimer);
|
||||
state.persistTimer = null;
|
||||
}
|
||||
|
||||
ns.PrivacyStorage.saveRouteState(
|
||||
state.route,
|
||||
state.routeState,
|
||||
state.routeProgressIndex,
|
||||
[...state.triggered],
|
||||
[...state.viewed],
|
||||
[...state.playedAudio]
|
||||
);
|
||||
}
|
||||
|
||||
function scheduleRouteProgressPersist() {
|
||||
if (!state.route || state.persistTimer != null) return;
|
||||
state.persistTimer = globalThis.setTimeout(persistRouteProgress, 1200);
|
||||
}
|
||||
|
||||
function restoreRouteProgress(route) {
|
||||
const saved = ns.PrivacyStorage.activateRoute(route);
|
||||
state.triggered = new Set(saved?.visitedPoiIds ?? []);
|
||||
state.viewed = new Set(saved?.viewedPoiIds ?? []);
|
||||
state.playedAudio = new Set(saved?.playedAudioPoiIds ?? []);
|
||||
state.routeProgressIndex = Number.isInteger(saved?.routeProgressIndex)
|
||||
? saved.routeProgressIndex
|
||||
: null;
|
||||
state.routeState = saved?.routeState === 'running' || saved?.routeState === 'paused'
|
||||
? 'paused'
|
||||
: 'idle';
|
||||
|
||||
if (state.routeState === 'paused') {
|
||||
$('#tracking-status')
|
||||
.prop('hidden', false)
|
||||
.text('Gespeicherter Streckenfortschritt gefunden. Die Route kann fortgesetzt werden.');
|
||||
} else {
|
||||
$('#tracking-status').prop('hidden', true).text('');
|
||||
}
|
||||
}
|
||||
|
||||
function activate(selector, handler) {
|
||||
$(document)
|
||||
.on('pointerup', selector, function (event) {
|
||||
@ -185,8 +233,12 @@
|
||||
function endRoute({ announce = true, returnToRoutes = false } = {}) {
|
||||
const wasActive = state.routeState !== 'idle';
|
||||
state.routeState = 'idle';
|
||||
persistRouteProgress();
|
||||
state.routeProgressIndex = null;
|
||||
state.triggered.clear();
|
||||
state.viewed.clear();
|
||||
state.playedAudio.clear();
|
||||
state.activePoiId = null;
|
||||
stopRouteSensors();
|
||||
ns.AudioPlayer.unload();
|
||||
setActivePoi(null);
|
||||
@ -210,6 +262,7 @@
|
||||
if (state.routeState !== 'running') return;
|
||||
|
||||
state.routeState = 'paused';
|
||||
persistRouteProgress();
|
||||
stopRouteSensors();
|
||||
ns.AudioPlayer.pause();
|
||||
$('#tracking-status')
|
||||
@ -223,15 +276,14 @@
|
||||
if (state.routeState !== 'idle') endRoute({ announce: false });
|
||||
|
||||
state.route = route;
|
||||
state.triggered.clear();
|
||||
state.routeProgressIndex = null;
|
||||
state.activePoiId = null;
|
||||
restoreRouteProgress(route);
|
||||
setActivePoi(null);
|
||||
$('#route-title').text(route.name);
|
||||
$('#route-description').text(route.description || '');
|
||||
$('#route-distance').text(ns.Distance.format(route.distanceM));
|
||||
$('#route-elevation').text(`${Math.round(route.elevationGainM)} m`);
|
||||
$('#route-poi-count').text(route.pois.length);
|
||||
$('#tracking-status').prop('hidden', true).text('');
|
||||
updateRouteControls();
|
||||
|
||||
const list = $('#poi-list').empty();
|
||||
@ -261,6 +313,9 @@
|
||||
}
|
||||
|
||||
function showPoi(poi) {
|
||||
state.activePoiId = Number(poi.id);
|
||||
state.viewed.add(state.activePoiId);
|
||||
persistRouteProgress();
|
||||
setActivePoi(poi.id);
|
||||
$('#poi-title').text(poi.title);
|
||||
$('#poi-description').text(poi.description || '');
|
||||
@ -345,6 +400,7 @@
|
||||
}
|
||||
|
||||
function updateRouteProgress(position) {
|
||||
const previousProgressIndex = state.routeProgressIndex;
|
||||
const points = state.route?.points || [];
|
||||
if (!points.length) return null;
|
||||
|
||||
@ -370,9 +426,11 @@
|
||||
|
||||
if (targetIndex === lastIndex && distance <= reachedRadius) {
|
||||
state.routeProgressIndex = lastIndex;
|
||||
if (state.routeProgressIndex !== previousProgressIndex) scheduleRouteProgressPersist();
|
||||
return { reached: true, target, distance: 0 };
|
||||
}
|
||||
|
||||
if (state.routeProgressIndex !== previousProgressIndex) scheduleRouteProgressPersist();
|
||||
return { reached: false, target, distance };
|
||||
}
|
||||
|
||||
@ -464,6 +522,7 @@
|
||||
|
||||
if (nearest && nearest.distance <= nearest.poi.triggerRadiusM) {
|
||||
state.triggered.add(nearest.poi.id);
|
||||
persistRouteProgress();
|
||||
ns.Vibration.start(ns.Vibration.Patterns.ACTIVE_POI);
|
||||
showPoi(nearest.poi);
|
||||
updateNavigation(position);
|
||||
@ -481,6 +540,7 @@
|
||||
|
||||
state.routeState = 'running';
|
||||
state.deviceHeading = null;
|
||||
persistRouteProgress();
|
||||
updateRouteControls();
|
||||
$('#tracking-status')
|
||||
.prop('hidden', false)
|
||||
@ -505,9 +565,9 @@
|
||||
|
||||
function routesFromResponse(response) {
|
||||
if (!Array.isArray(response?.routes)) {
|
||||
throw new Error('Die Routen-API hat keine gültige Routenliste geliefert.');
|
||||
throw new TypeError('Die Routen-API hat keine gültige Routenliste geliefert.');
|
||||
}
|
||||
return response.routes.filter(route => route && route.id != null && route.name != null);
|
||||
return response.routes.filter(route => route?.id != null && route?.name != null);
|
||||
}
|
||||
|
||||
function updateRouteProximity(position) {
|
||||
@ -584,6 +644,17 @@
|
||||
endRoute({ returnToRoutes: true });
|
||||
});
|
||||
|
||||
$('#poi-audio').on('play', function () {
|
||||
if (!state.route || state.activePoiId == null) return;
|
||||
state.playedAudio.add(state.activePoiId);
|
||||
persistRouteProgress();
|
||||
});
|
||||
|
||||
document.addEventListener('wegwichtel:progress-storage-changed', event => {
|
||||
if (event.detail?.enabled) persistRouteProgress();
|
||||
});
|
||||
|
||||
globalThis.addEventListener('pagehide', persistRouteProgress);
|
||||
globalThis.addEventListener('popstate', event => {
|
||||
const page = event.state?.page || '#routes-page';
|
||||
navigate(page, { updateHistory: false, instant: true });
|
||||
@ -592,6 +663,7 @@
|
||||
globalThis.addEventListener('pageshow', restoreRouteLists);
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'visible') restoreRouteLists();
|
||||
else persistRouteProgress();
|
||||
});
|
||||
}
|
||||
|
||||
@ -612,7 +684,7 @@
|
||||
updateRouteControls();
|
||||
hideNavigation();
|
||||
|
||||
void initializeLocation(context);
|
||||
await initializeLocation(context);
|
||||
}
|
||||
};
|
||||
}(globalThis.Wegwichtel, globalThis.jQuery));
|
||||
|
||||
62
public/js/bootstrap-loader.js
vendored
62
public/js/bootstrap-loader.js
vendored
@ -1,11 +1,16 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const versions = Object.freeze({ app: '0.12.12', jquery: '4.0.0', jqueryUi: '1.14.2' });
|
||||
const versions = Object.freeze({ app: '0.12.14', jquery: '4.0.0', jqueryUi: '1.14.2' });
|
||||
const steps = ['jquery', 'jquery-ui', 'modules', 'server', 'routes', 'location'];
|
||||
const screen = document.getElementById('bootstrap-screen');
|
||||
const progress = document.getElementById('bootstrap-progress');
|
||||
const errorBox = document.getElementById('bootstrap-error');
|
||||
const retry = document.getElementById('bootstrap-retry');
|
||||
const enter = document.getElementById('bootstrap-enter');
|
||||
const readyText = document.getElementById('bootstrap-ready');
|
||||
const cleanupStatus = document.getElementById('storage-cleanup-status');
|
||||
let ready = false;
|
||||
|
||||
const versioned = path => `${path}${path.includes('?') ? '&' : '?'}v=${encodeURIComponent(versions.app)}`;
|
||||
|
||||
@ -34,17 +39,61 @@
|
||||
function initializeJqueryUi() {
|
||||
const $ = globalThis.jQuery;
|
||||
$('#bootstrap-retry').button({ icon: 'ui-icon-refresh' });
|
||||
$('#bootstrap-enter').button();
|
||||
$('.back-button').button();
|
||||
$('#clear-route-search').button({ icon: 'ui-icon-close', showLabel: false });
|
||||
$('#slide-prev').button();
|
||||
$('#slide-next').button();
|
||||
}
|
||||
|
||||
function updateCleanupProgress(removed, total) {
|
||||
cleanupStatus.textContent = `Entferne alte Streckendaten aus dem Speicher (${removed} / ${total})`;
|
||||
cleanupStatus.hidden = false;
|
||||
}
|
||||
|
||||
async function cleanupOldProgress() {
|
||||
cleanupStatus.hidden = true;
|
||||
await globalThis.Wegwichtel.PrivacyStorage.cleanupExpired(updateCleanupProgress);
|
||||
}
|
||||
|
||||
function isActivation(event) {
|
||||
if (event.type === 'keydown') return event.key === 'Enter' || event.key === ' ';
|
||||
if (event.type !== 'pointerup') return false;
|
||||
if (event.isPrimary === false) return false;
|
||||
return event.pointerType !== 'mouse' || event.button === 0;
|
||||
}
|
||||
|
||||
function openApplication(event) {
|
||||
if (!ready || !isActivation(event)) return;
|
||||
if (event.target !== enter && event.target.closest('button, input, label, a, dialog')) return;
|
||||
|
||||
event.preventDefault();
|
||||
screen.hidden = true;
|
||||
document.getElementById('app-shell').hidden = false;
|
||||
globalThis.Wegwichtel.App.navigate('#routes-page', { replace: true });
|
||||
}
|
||||
|
||||
function showReadyState() {
|
||||
ready = true;
|
||||
screen.setAttribute('aria-busy', 'false');
|
||||
screen.classList.add('ready');
|
||||
readyText.hidden = false;
|
||||
enter.hidden = false;
|
||||
enter.focus();
|
||||
}
|
||||
|
||||
async function boot() {
|
||||
ready = false;
|
||||
screen.setAttribute('aria-busy', 'true');
|
||||
screen.classList.remove('ready');
|
||||
readyText.hidden = true;
|
||||
enter.hidden = true;
|
||||
errorBox.hidden = true;
|
||||
retry.hidden = true;
|
||||
|
||||
try {
|
||||
await cleanupOldProgress();
|
||||
|
||||
await loadScript(
|
||||
`vendor/jquery/jquery-${versions.jquery}.min.js`,
|
||||
() => globalThis.jQuery?.fn?.jquery === versions.jquery
|
||||
@ -64,9 +113,7 @@
|
||||
mark('modules', 'done');
|
||||
|
||||
await globalThis.Wegwichtel.App.initialize({ markStep: mark });
|
||||
document.getElementById('bootstrap-screen').hidden = true;
|
||||
document.getElementById('app-shell').hidden = false;
|
||||
globalThis.Wegwichtel.App.navigate('#routes-page', { replace: true });
|
||||
showReadyState();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
const current = steps.find(step => !document.querySelector(`[data-step="${step}"]`)?.classList.contains('done'));
|
||||
@ -78,13 +125,16 @@
|
||||
}
|
||||
|
||||
function retryBoot(event) {
|
||||
if (event.type === 'keydown' && event.key !== 'Enter' && event.key !== ' ') return;
|
||||
if (event.type === 'pointerup' && event.pointerType === 'mouse' && event.button !== 0) return;
|
||||
if (!isActivation(event)) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
location.reload();
|
||||
}
|
||||
|
||||
retry.addEventListener('pointerup', retryBoot);
|
||||
retry.addEventListener('keydown', retryBoot);
|
||||
enter.addEventListener('pointerup', openApplication);
|
||||
enter.addEventListener('keydown', openApplication);
|
||||
screen.addEventListener('pointerup', openApplication);
|
||||
boot();
|
||||
}());
|
||||
|
||||
120
public/js/legal-ui.js
Normal file
120
public/js/legal-ui.js
Normal file
@ -0,0 +1,120 @@
|
||||
(function (ns) {
|
||||
'use strict';
|
||||
|
||||
const dialog = document.getElementById('info-dialog');
|
||||
const title = document.getElementById('info-dialog-title');
|
||||
const menu = document.getElementById('info-menu');
|
||||
const backButton = document.getElementById('info-back');
|
||||
const closeButton = document.getElementById('info-close');
|
||||
const infoButton = document.getElementById('info-button');
|
||||
const pageTitles = Object.freeze({
|
||||
privacy: 'Datenschutzerklärung',
|
||||
imprint: 'Impressum',
|
||||
technology: 'Cookies und Technik'
|
||||
});
|
||||
|
||||
function isActivation(event) {
|
||||
if (event.type === 'keydown') return event.key === 'Enter' || event.key === ' ';
|
||||
if (event.type !== 'pointerup') return false;
|
||||
if (event.isPrimary === false) return false;
|
||||
return event.pointerType !== 'mouse' || event.button === 0;
|
||||
}
|
||||
|
||||
function activate(element, handler) {
|
||||
element.addEventListener('pointerup', event => {
|
||||
if (!isActivation(event)) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
handler(event);
|
||||
});
|
||||
element.addEventListener('keydown', event => {
|
||||
if (!isActivation(event)) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
handler(event);
|
||||
});
|
||||
}
|
||||
|
||||
function syncStorageToggles() {
|
||||
const enabled = ns.PrivacyStorage.isProgressStorageEnabled();
|
||||
document.querySelectorAll('.progress-storage-toggle').forEach(toggle => {
|
||||
toggle.checked = enabled;
|
||||
});
|
||||
document.querySelectorAll('.cookie-toggle').forEach(toggle => {
|
||||
toggle.checked = false;
|
||||
});
|
||||
}
|
||||
|
||||
function showMenu() {
|
||||
title.textContent = 'Informationen';
|
||||
menu.hidden = false;
|
||||
backButton.hidden = true;
|
||||
document.querySelectorAll('[data-info-page]').forEach(page => {
|
||||
page.hidden = true;
|
||||
});
|
||||
}
|
||||
|
||||
function showPage(name) {
|
||||
const page = document.querySelector(`[data-info-page="${name}"]`);
|
||||
if (!page) return;
|
||||
|
||||
title.textContent = pageTitles[name] ?? 'Informationen';
|
||||
menu.hidden = true;
|
||||
backButton.hidden = false;
|
||||
document.querySelectorAll('[data-info-page]').forEach(item => {
|
||||
item.hidden = item !== page;
|
||||
});
|
||||
const content = document.querySelector('.info-dialog-content');
|
||||
if (content) content.scrollTop = 0;
|
||||
}
|
||||
|
||||
function openDialog() {
|
||||
if (dialog.open) return;
|
||||
showMenu();
|
||||
syncStorageToggles();
|
||||
if (typeof dialog.showModal === 'function') dialog.showModal();
|
||||
else dialog.setAttribute('open', '');
|
||||
closeButton.focus();
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
if (typeof dialog.close === 'function') dialog.close();
|
||||
else dialog.removeAttribute('open');
|
||||
infoButton.focus();
|
||||
}
|
||||
|
||||
function bindStorageToggles() {
|
||||
document.querySelectorAll('.progress-storage-toggle').forEach(toggle => {
|
||||
toggle.addEventListener('change', event => {
|
||||
const enabled = ns.PrivacyStorage.setProgressStorageEnabled(event.currentTarget.checked);
|
||||
syncStorageToggles();
|
||||
document.dispatchEvent(new CustomEvent('wegwichtel:progress-storage-changed', {
|
||||
detail: { enabled }
|
||||
}));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
activate(infoButton, openDialog);
|
||||
activate(closeButton, closeDialog);
|
||||
activate(backButton, showMenu);
|
||||
document.querySelectorAll('[data-info-target]').forEach(button => {
|
||||
activate(button, () => showPage(button.dataset.infoTarget));
|
||||
});
|
||||
|
||||
dialog.addEventListener('pointerup', event => {
|
||||
if (event.target !== dialog || !isActivation(event)) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
closeDialog();
|
||||
});
|
||||
dialog.addEventListener('cancel', event => {
|
||||
event.preventDefault();
|
||||
closeDialog();
|
||||
});
|
||||
|
||||
bindStorageToggles();
|
||||
syncStorageToggles();
|
||||
|
||||
ns.LegalUi = Object.freeze({ open: openDialog, close: closeDialog, syncStorageToggles });
|
||||
}(globalThis.Wegwichtel ??= {}));
|
||||
251
public/js/privacy-storage.js
Normal file
251
public/js/privacy-storage.js
Normal file
@ -0,0 +1,251 @@
|
||||
(function (ns) {
|
||||
'use strict';
|
||||
|
||||
const SETTINGS_KEY = 'wegwichtel.privacy.v1';
|
||||
const PROGRESS_KEY = 'wegwichtel.route-progress.v1';
|
||||
const COOKIE_PREFIX = 'wegwichtel_';
|
||||
const MAX_PROGRESS_AGE_MS = 24 * 60 * 60 * 1000;
|
||||
const STORE_VERSION = 1;
|
||||
|
||||
function storage() {
|
||||
try {
|
||||
return globalThis.localStorage ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function readJson(key, fallback) {
|
||||
const backend = storage();
|
||||
if (!backend) return fallback;
|
||||
|
||||
try {
|
||||
const raw = backend.getItem(key);
|
||||
return raw ? JSON.parse(raw) : fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function writeJson(key, value) {
|
||||
const backend = storage();
|
||||
if (!backend) return false;
|
||||
|
||||
try {
|
||||
backend.setItem(key, JSON.stringify(value));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function removeItem(key) {
|
||||
const backend = storage();
|
||||
if (!backend) return;
|
||||
|
||||
try {
|
||||
backend.removeItem(key);
|
||||
} catch {
|
||||
// Private browsing modes may reject storage access. The app remains usable without persistence.
|
||||
}
|
||||
}
|
||||
|
||||
function defaultSettings() {
|
||||
return { progressStorage: false, updatedAt: null };
|
||||
}
|
||||
|
||||
function settings() {
|
||||
const value = readJson(SETTINGS_KEY, defaultSettings());
|
||||
return {
|
||||
progressStorage: value?.progressStorage === true,
|
||||
updatedAt: typeof value?.updatedAt === 'string' ? value.updatedAt : null
|
||||
};
|
||||
}
|
||||
|
||||
function saveSettings(progressStorage) {
|
||||
return writeJson(SETTINGS_KEY, {
|
||||
progressStorage: progressStorage === true,
|
||||
updatedAt: nowIso()
|
||||
});
|
||||
}
|
||||
|
||||
function defaultProgressStore() {
|
||||
return { version: STORE_VERSION, currentRouteId: null, routes: {} };
|
||||
}
|
||||
|
||||
function progressStore() {
|
||||
const value = readJson(PROGRESS_KEY, defaultProgressStore());
|
||||
if (value?.version !== STORE_VERSION || typeof value?.routes !== 'object' || value.routes === null) {
|
||||
return defaultProgressStore();
|
||||
}
|
||||
|
||||
return {
|
||||
version: STORE_VERSION,
|
||||
currentRouteId: value.currentRouteId ?? null,
|
||||
routes: value.routes
|
||||
};
|
||||
}
|
||||
|
||||
function persistProgressStore(value) {
|
||||
if (!settings().progressStorage) return false;
|
||||
return writeJson(PROGRESS_KEY, value);
|
||||
}
|
||||
|
||||
function uniqueIds(values) {
|
||||
return [...new Set((Array.isArray(values) ? values : [])
|
||||
.map(value => Number(value))
|
||||
.filter(Number.isFinite))];
|
||||
}
|
||||
|
||||
function routeRecord(route, previous = {}) {
|
||||
const timestamp = nowIso();
|
||||
return {
|
||||
routeId: route.id,
|
||||
routeName: String(route.name ?? previous.routeName ?? ''),
|
||||
routeState: previous.routeState ?? 'idle',
|
||||
routeProgressIndex: Number.isInteger(previous.routeProgressIndex)
|
||||
? previous.routeProgressIndex
|
||||
: null,
|
||||
visitedPoiIds: uniqueIds(previous.visitedPoiIds),
|
||||
viewedPoiIds: uniqueIds(previous.viewedPoiIds),
|
||||
playedAudioPoiIds: uniqueIds(previous.playedAudioPoiIds),
|
||||
startedAt: previous.startedAt ?? timestamp,
|
||||
updatedAt: timestamp
|
||||
};
|
||||
}
|
||||
|
||||
function updateRoute(routeId, updater) {
|
||||
if (!settings().progressStorage || routeId == null) return null;
|
||||
|
||||
const store = progressStore();
|
||||
const key = String(routeId);
|
||||
const current = store.routes[key];
|
||||
if (!current) return null;
|
||||
|
||||
const updated = updater({ ...current });
|
||||
updated.updatedAt = nowIso();
|
||||
store.currentRouteId = routeId;
|
||||
store.routes[key] = updated;
|
||||
persistProgressStore(store);
|
||||
return updated;
|
||||
}
|
||||
|
||||
function markPoi(routeId, field, poiId) {
|
||||
const normalizedId = Number(poiId);
|
||||
if (!Number.isFinite(normalizedId)) return null;
|
||||
|
||||
return updateRoute(routeId, record => {
|
||||
record[field] = uniqueIds([...(record[field] ?? []), normalizedId]);
|
||||
return record;
|
||||
});
|
||||
}
|
||||
|
||||
function clearApplicationCookies() {
|
||||
const cookieText = globalThis.document?.cookie;
|
||||
if (!cookieText) return;
|
||||
|
||||
cookieText.split(';').forEach(item => {
|
||||
const name = item.split('=', 1)[0].trim();
|
||||
if (!name.startsWith(COOKIE_PREFIX)) return;
|
||||
globalThis.document.cookie = `${name}=; Max-Age=0; Path=/; SameSite=Lax`;
|
||||
});
|
||||
}
|
||||
|
||||
function setProgressStorageEnabled(enabled) {
|
||||
const active = enabled === true;
|
||||
const saved = saveSettings(active);
|
||||
if (!active) removeItem(PROGRESS_KEY);
|
||||
return saved && settings().progressStorage;
|
||||
}
|
||||
|
||||
function activateRoute(route) {
|
||||
if (!settings().progressStorage || route?.id == null) return null;
|
||||
|
||||
const store = progressStore();
|
||||
const key = String(route.id);
|
||||
const record = routeRecord(route, store.routes[key]);
|
||||
store.currentRouteId = route.id;
|
||||
store.routes[key] = record;
|
||||
persistProgressStore(store);
|
||||
return record;
|
||||
}
|
||||
|
||||
function saveRouteState(route, routeState, routeProgressIndex, visitedPoiIds, viewedPoiIds, playedAudioPoiIds) {
|
||||
if (route?.id == null) return null;
|
||||
|
||||
return updateRoute(route.id, record => ({
|
||||
...record,
|
||||
routeName: String(route.name ?? record.routeName ?? ''),
|
||||
routeState: String(routeState ?? 'idle'),
|
||||
routeProgressIndex: Number.isInteger(routeProgressIndex) ? routeProgressIndex : null,
|
||||
visitedPoiIds: uniqueIds(visitedPoiIds),
|
||||
viewedPoiIds: uniqueIds(viewedPoiIds),
|
||||
playedAudioPoiIds: uniqueIds(playedAudioPoiIds)
|
||||
}));
|
||||
}
|
||||
|
||||
function loadRoute(routeId) {
|
||||
if (!settings().progressStorage || routeId == null) return null;
|
||||
return progressStore().routes[String(routeId)] ?? null;
|
||||
}
|
||||
|
||||
function nextTurn() {
|
||||
return new Promise(resolve => globalThis.setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
async function cleanupExpired(onProgress = () => {}) {
|
||||
if (!settings().progressStorage) {
|
||||
removeItem(PROGRESS_KEY);
|
||||
return { removed: 0, total: 0 };
|
||||
}
|
||||
|
||||
const store = progressStore();
|
||||
const cutoff = Date.now() - MAX_PROGRESS_AGE_MS;
|
||||
const expiredKeys = Object.entries(store.routes)
|
||||
.filter(([, route]) => {
|
||||
const timestamp = Date.parse(route?.updatedAt ?? '');
|
||||
return !Number.isFinite(timestamp) || timestamp <= cutoff;
|
||||
})
|
||||
.map(([key]) => key);
|
||||
|
||||
if (!expiredKeys.length) return { removed: 0, total: 0 };
|
||||
|
||||
let removed = 0;
|
||||
onProgress(removed, expiredKeys.length);
|
||||
|
||||
for (const key of expiredKeys) {
|
||||
delete store.routes[key];
|
||||
if (String(store.currentRouteId) === key) store.currentRouteId = null;
|
||||
removed += 1;
|
||||
persistProgressStore(store);
|
||||
onProgress(removed, expiredKeys.length);
|
||||
await nextTurn();
|
||||
}
|
||||
|
||||
return { removed, total: expiredKeys.length };
|
||||
}
|
||||
|
||||
clearApplicationCookies();
|
||||
|
||||
ns.PrivacyStorage = Object.freeze({
|
||||
SETTINGS_KEY,
|
||||
PROGRESS_KEY,
|
||||
MAX_PROGRESS_AGE_MS,
|
||||
cookiesEnabled: () => false,
|
||||
clearApplicationCookies,
|
||||
isProgressStorageEnabled: () => settings().progressStorage,
|
||||
setProgressStorageEnabled,
|
||||
cleanupExpired,
|
||||
activateRoute,
|
||||
loadRoute,
|
||||
saveRouteState,
|
||||
markVisited: (routeId, poiId) => markPoi(routeId, 'visitedPoiIds', poiId),
|
||||
markViewed: (routeId, poiId) => markPoi(routeId, 'viewedPoiIds', poiId),
|
||||
markAudioPlayed: (routeId, poiId) => markPoi(routeId, 'playedAudioPoiIds', poiId),
|
||||
clearProgress: () => removeItem(PROGRESS_KEY)
|
||||
});
|
||||
}(globalThis.Wegwichtel ??= {}));
|
||||
165
test/bootstrap-loader.test.js
vendored
Normal file
165
test/bootstrap-loader.test.js
vendored
Normal file
@ -0,0 +1,165 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import vm from 'node:vm';
|
||||
|
||||
const root = path.resolve(import.meta.dirname, '..');
|
||||
|
||||
class ClassList {
|
||||
constructor() {
|
||||
this.values = new Set();
|
||||
}
|
||||
|
||||
add(...names) {
|
||||
names.forEach(name => this.values.add(name));
|
||||
}
|
||||
|
||||
remove(...names) {
|
||||
names.forEach(name => this.values.delete(name));
|
||||
}
|
||||
|
||||
contains(name) {
|
||||
return this.values.has(name);
|
||||
}
|
||||
}
|
||||
|
||||
class FakeElement extends EventTarget {
|
||||
constructor(id = '') {
|
||||
super();
|
||||
this.id = id;
|
||||
this.hidden = false;
|
||||
this.value = 0;
|
||||
this.textContent = '';
|
||||
this.src = '';
|
||||
this.async = true;
|
||||
this.classList = new ClassList();
|
||||
this.attributes = new Map();
|
||||
this.focused = false;
|
||||
}
|
||||
|
||||
focus() {
|
||||
this.focused = true;
|
||||
}
|
||||
|
||||
setAttribute(name, value) {
|
||||
this.attributes.set(name, String(value));
|
||||
}
|
||||
|
||||
closest() {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function pointerEvent() {
|
||||
const event = new Event('pointerup', { cancelable: true });
|
||||
Object.defineProperties(event, {
|
||||
isPrimary: { value: true },
|
||||
pointerType: { value: 'touch' },
|
||||
button: { value: 0 }
|
||||
});
|
||||
return event;
|
||||
}
|
||||
|
||||
test('bootstrap remains visible after loading and opens the app only after deliberate activation', async () => {
|
||||
const sourcePath = path.join(root, 'public/js/bootstrap-loader.js');
|
||||
const source = await fs.readFile(sourcePath, 'utf8');
|
||||
const ids = [
|
||||
'bootstrap-screen',
|
||||
'bootstrap-progress',
|
||||
'bootstrap-error',
|
||||
'bootstrap-retry',
|
||||
'bootstrap-enter',
|
||||
'bootstrap-ready',
|
||||
'storage-cleanup-status',
|
||||
'app-shell'
|
||||
];
|
||||
const elements = new Map(ids.map(id => [id, new FakeElement(id)]));
|
||||
const steps = new Map([
|
||||
'jquery',
|
||||
'jquery-ui',
|
||||
'modules',
|
||||
'server',
|
||||
'routes',
|
||||
'location'
|
||||
].map(name => [name, new FakeElement(name)]));
|
||||
const navigations = [];
|
||||
|
||||
function jquery() {
|
||||
return {
|
||||
button() {
|
||||
return this;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const context = {
|
||||
Wegwichtel: {
|
||||
PrivacyStorage: {
|
||||
async cleanupExpired(callback) {
|
||||
callback(0, 1);
|
||||
callback(1, 1);
|
||||
return { removed: 1, total: 1 };
|
||||
}
|
||||
},
|
||||
App: {
|
||||
async initialize({ markStep }) {
|
||||
markStep('server', 'done');
|
||||
markStep('routes', 'done');
|
||||
markStep('location', 'done');
|
||||
},
|
||||
navigate(...args) {
|
||||
navigations.push(args);
|
||||
}
|
||||
}
|
||||
},
|
||||
document: {
|
||||
getElementById: id => elements.get(id),
|
||||
querySelector(selector) {
|
||||
const match = selector.match(/^\[data-step="(.+)"\]$/);
|
||||
return match ? steps.get(match[1]) : null;
|
||||
},
|
||||
querySelectorAll(selector) {
|
||||
if (selector === '#bootstrap-steps .done') {
|
||||
return [...steps.values()].filter(step => step.classList.contains('done'));
|
||||
}
|
||||
return [];
|
||||
},
|
||||
createElement: () => new FakeElement('script'),
|
||||
head: {
|
||||
appendChild(script) {
|
||||
if (script.src.includes('/jquery-4.0.0.min.js')) {
|
||||
jquery.fn = { jquery: '4.0.0' };
|
||||
context.jQuery = jquery;
|
||||
}
|
||||
if (script.src.includes('/jquery-ui-1.14.2.min.js')) {
|
||||
jquery.ui = { version: '1.14.2' };
|
||||
}
|
||||
queueMicrotask(() => script.onload());
|
||||
}
|
||||
}
|
||||
},
|
||||
Event,
|
||||
EventTarget,
|
||||
Promise,
|
||||
console,
|
||||
encodeURIComponent,
|
||||
queueMicrotask,
|
||||
location: { reload() {} }
|
||||
};
|
||||
context.globalThis = context;
|
||||
vm.runInNewContext(source, context, { filename: sourcePath });
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
assert.equal(elements.get('bootstrap-screen').hidden, false);
|
||||
assert.equal(elements.get('bootstrap-screen').attributes.get('aria-busy'), 'false');
|
||||
assert.equal(elements.get('bootstrap-enter').hidden, false);
|
||||
assert.equal(elements.get('storage-cleanup-status').textContent, 'Entferne alte Streckendaten aus dem Speicher (1 / 1)');
|
||||
assert.equal(navigations.length, 0);
|
||||
|
||||
elements.get('bootstrap-screen').dispatchEvent(pointerEvent());
|
||||
assert.equal(elements.get('bootstrap-screen').hidden, true);
|
||||
assert.equal(elements.get('app-shell').hidden, false);
|
||||
assert.equal(navigations[0][0], '#routes-page');
|
||||
assert.equal(navigations[0][1].replace, true);
|
||||
});
|
||||
198
test/legal-ui.test.js
Normal file
198
test/legal-ui.test.js
Normal file
@ -0,0 +1,198 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import vm from 'node:vm';
|
||||
|
||||
const root = path.resolve(import.meta.dirname, '..');
|
||||
|
||||
async function read(relativePath) {
|
||||
return fs.readFile(path.join(root, relativePath), 'utf8');
|
||||
}
|
||||
|
||||
test('start screen exposes cookie status, optional local progress and deliberate entry', async () => {
|
||||
const html = await read('public/index.html');
|
||||
const loader = await read('public/js/bootstrap-loader.js');
|
||||
const storage = await read('public/js/privacy-storage.js');
|
||||
|
||||
assert.match(html, /Diese Anwendung setzt derzeit keine Cookies/);
|
||||
assert.match(html, /class="cookie-toggle"[^>]*disabled/);
|
||||
assert.match(html, /class="progress-storage-toggle"/);
|
||||
assert.match(html, /id="storage-cleanup-status"/);
|
||||
assert.match(html, /id="bootstrap-enter"[^>]*hidden/);
|
||||
assert.match(loader, /Entferne alte Streckendaten aus dem Speicher \(\$\{removed\} \/ \$\{total\}\)/);
|
||||
assert.match(loader, /showReadyState\(\)/);
|
||||
assert.doesNotMatch(loader, /getElementById\('bootstrap-screen'\)\.hidden = true/);
|
||||
assert.match(loader, /event\.target\.closest\('button, input, label, a, dialog'\)/);
|
||||
assert.match(storage, /MAX_PROGRESS_AGE_MS = 24 \* 60 \* 60 \* 1000/);
|
||||
assert.match(storage, /cookiesEnabled: \(\) => false/);
|
||||
});
|
||||
|
||||
test('information button opens scrollable overlay pages for privacy, imprint and technology', async () => {
|
||||
const html = await read('public/index.html');
|
||||
const css = await read('public/css/app.css');
|
||||
const ui = await read('public/js/legal-ui.js');
|
||||
|
||||
assert.match(html, /id="info-button"/);
|
||||
assert.match(html, /<dialog id="info-dialog"/);
|
||||
assert.match(html, /data-info-target="privacy"/);
|
||||
assert.match(html, /data-info-target="imprint"/);
|
||||
assert.match(html, /data-info-target="technology"/);
|
||||
assert.match(html, /wegwichtel\.privacy\.v1/);
|
||||
assert.match(html, /wegwichtel\.route-progress\.v1/);
|
||||
assert.match(html, /jQuery 4\.0\.0/);
|
||||
assert.match(css, /\.info-dialog-content[\s\S]*overflow-y:\s*auto/);
|
||||
assert.match(css, /\.info-dialog-content[\s\S]*touch-action:\s*pan-y/);
|
||||
assert.match(ui, /pointerup/);
|
||||
assert.match(ui, /keydown/);
|
||||
assert.doesNotMatch(ui, /addEventListener\(['"]click['"]/);
|
||||
});
|
||||
|
||||
test('application persists current route, viewed stations and played audio only through privacy storage', async () => {
|
||||
const app = await read('public/js/app.js');
|
||||
|
||||
assert.match(app, /PrivacyStorage\.saveRouteState/);
|
||||
assert.match(app, /PrivacyStorage\.activateRoute/);
|
||||
assert.match(app, /state\.viewed\.add\(state\.activePoiId\)/);
|
||||
assert.match(app, /\$\('#poi-audio'\)\.on\('play'/);
|
||||
assert.match(app, /state\.playedAudio\.add\(state\.activePoiId\)/);
|
||||
assert.match(app, /globalThis\.addEventListener\('pagehide', persistRouteProgress\)/);
|
||||
assert.match(app, /await initializeLocation\(context\)/);
|
||||
});
|
||||
|
||||
|
||||
class FakeElement extends EventTarget {
|
||||
constructor(id = '') {
|
||||
super();
|
||||
this.id = id;
|
||||
this.hidden = false;
|
||||
this.checked = false;
|
||||
this.disabled = false;
|
||||
this.open = false;
|
||||
this.textContent = '';
|
||||
this.scrollTop = 0;
|
||||
this.dataset = {};
|
||||
this.attributes = new Map();
|
||||
this.focused = false;
|
||||
}
|
||||
|
||||
focus() {
|
||||
this.focused = true;
|
||||
}
|
||||
|
||||
setAttribute(name, value) {
|
||||
this.attributes.set(name, String(value));
|
||||
if (name === 'open') this.open = true;
|
||||
}
|
||||
|
||||
removeAttribute(name) {
|
||||
this.attributes.delete(name);
|
||||
if (name === 'open') this.open = false;
|
||||
}
|
||||
|
||||
showModal() {
|
||||
this.open = true;
|
||||
}
|
||||
|
||||
close() {
|
||||
this.open = false;
|
||||
}
|
||||
}
|
||||
|
||||
function pointerEvent() {
|
||||
const event = new Event('pointerup', { bubbles: true, cancelable: true });
|
||||
Object.defineProperties(event, {
|
||||
isPrimary: { value: true },
|
||||
pointerType: { value: 'touch' },
|
||||
button: { value: 0 }
|
||||
});
|
||||
return event;
|
||||
}
|
||||
|
||||
test('legal overlay handlers synchronize switches without advancing the start screen', async () => {
|
||||
const sourcePath = path.join(root, 'public/js/legal-ui.js');
|
||||
const source = await fs.readFile(sourcePath, 'utf8');
|
||||
const elements = new Map([
|
||||
['info-dialog', new FakeElement('info-dialog')],
|
||||
['info-dialog-title', new FakeElement('info-dialog-title')],
|
||||
['info-menu', new FakeElement('info-menu')],
|
||||
['info-back', new FakeElement('info-back')],
|
||||
['info-close', new FakeElement('info-close')],
|
||||
['info-button', new FakeElement('info-button')]
|
||||
]);
|
||||
const content = new FakeElement();
|
||||
const privacyPage = new FakeElement();
|
||||
const imprintPage = new FakeElement();
|
||||
const technologyPage = new FakeElement();
|
||||
const pages = { privacy: privacyPage, imprint: imprintPage, technology: technologyPage };
|
||||
const targetButtons = Object.keys(pages).map(name => {
|
||||
const button = new FakeElement();
|
||||
button.dataset.infoTarget = name;
|
||||
return button;
|
||||
});
|
||||
const progressToggles = [new FakeElement(), new FakeElement()];
|
||||
const cookieToggles = [new FakeElement()];
|
||||
let enabled = false;
|
||||
const changes = [];
|
||||
|
||||
class TestCustomEvent extends Event {
|
||||
constructor(type, options = {}) {
|
||||
super(type);
|
||||
this.detail = options.detail;
|
||||
}
|
||||
}
|
||||
|
||||
const document = new EventTarget();
|
||||
document.getElementById = id => elements.get(id);
|
||||
document.querySelectorAll = selector => {
|
||||
if (selector === '.progress-storage-toggle') return progressToggles;
|
||||
if (selector === '.cookie-toggle') return cookieToggles;
|
||||
if (selector === '[data-info-page]') return Object.values(pages);
|
||||
if (selector === '[data-info-target]') return targetButtons;
|
||||
return [];
|
||||
};
|
||||
document.querySelector = selector => {
|
||||
if (selector === '.info-dialog-content') return content;
|
||||
const match = selector.match(/^\[data-info-page="(.+)"\]$/);
|
||||
return match ? pages[match[1]] : null;
|
||||
};
|
||||
document.addEventListener('wegwichtel:progress-storage-changed', event => changes.push(event.detail.enabled));
|
||||
|
||||
const context = {
|
||||
Wegwichtel: {
|
||||
PrivacyStorage: {
|
||||
isProgressStorageEnabled: () => enabled,
|
||||
setProgressStorageEnabled: value => {
|
||||
enabled = value;
|
||||
return enabled;
|
||||
}
|
||||
}
|
||||
},
|
||||
document,
|
||||
Event,
|
||||
EventTarget,
|
||||
CustomEvent: TestCustomEvent
|
||||
};
|
||||
context.globalThis = context;
|
||||
vm.runInNewContext(source, context, { filename: sourcePath });
|
||||
|
||||
elements.get('info-button').dispatchEvent(pointerEvent());
|
||||
assert.equal(elements.get('info-dialog').open, true);
|
||||
assert.equal(elements.get('info-menu').hidden, false);
|
||||
|
||||
targetButtons[0].dispatchEvent(pointerEvent());
|
||||
assert.equal(privacyPage.hidden, false);
|
||||
assert.equal(elements.get('info-back').hidden, false);
|
||||
assert.equal(elements.get('info-dialog-title').textContent, 'Datenschutzerklärung');
|
||||
|
||||
progressToggles[0].checked = true;
|
||||
progressToggles[0].dispatchEvent(new Event('change'));
|
||||
assert.deepEqual(changes, [true]);
|
||||
assert.equal(progressToggles[1].checked, true);
|
||||
assert.equal(cookieToggles[0].checked, false);
|
||||
|
||||
elements.get('info-back').dispatchEvent(pointerEvent());
|
||||
assert.equal(elements.get('info-menu').hidden, false);
|
||||
elements.get('info-close').dispatchEvent(pointerEvent());
|
||||
assert.equal(elements.get('info-dialog').open, false);
|
||||
});
|
||||
@ -144,9 +144,9 @@ test('the npm-started local API is exercised through a write-auth proxy on loopb
|
||||
const indexHtml = await indexResponse.text();
|
||||
assert.equal(indexResponse.status, 200);
|
||||
assert.match(indexResponse.headers.get('cache-control') || '', /no-store/);
|
||||
assert.match(indexHtml, /css\/app\.css\?v=0\.12\.12/);
|
||||
assert.match(indexHtml, /css\/app\.css\?v=0\.12\.14/);
|
||||
|
||||
const appStyleResponse = await fetch(`${baseUrl}/css/app.css?v=0.12.12`);
|
||||
const appStyleResponse = await fetch(`${baseUrl}/css/app.css?v=0.12.14`);
|
||||
assert.equal(appStyleResponse.status, 200);
|
||||
assert.match(appStyleResponse.headers.get('cache-control') || '', /no-store/);
|
||||
await appStyleResponse.arrayBuffer();
|
||||
|
||||
94
test/privacy-storage.test.js
Normal file
94
test/privacy-storage.test.js
Normal file
@ -0,0 +1,94 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import vm from 'node:vm';
|
||||
|
||||
const root = path.resolve(import.meta.dirname, '..');
|
||||
|
||||
class MemoryStorage {
|
||||
constructor() {
|
||||
this.values = new Map();
|
||||
}
|
||||
|
||||
getItem(key) {
|
||||
return this.values.has(key) ? this.values.get(key) : null;
|
||||
}
|
||||
|
||||
setItem(key, value) {
|
||||
this.values.set(key, String(value));
|
||||
}
|
||||
|
||||
removeItem(key) {
|
||||
this.values.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadModule() {
|
||||
const source = await fs.readFile(path.join(root, 'public/js/privacy-storage.js'), 'utf8');
|
||||
const localStorage = new MemoryStorage();
|
||||
const context = {
|
||||
Wegwichtel: {},
|
||||
localStorage,
|
||||
document: { cookie: '' },
|
||||
Date,
|
||||
JSON,
|
||||
Number,
|
||||
Set,
|
||||
String,
|
||||
Promise,
|
||||
setTimeout,
|
||||
clearTimeout
|
||||
};
|
||||
context.globalThis = context;
|
||||
vm.runInNewContext(source, context, { filename: path.join(root, 'public/js/privacy-storage.js') });
|
||||
return { storage: context.Wegwichtel.PrivacyStorage, localStorage };
|
||||
}
|
||||
|
||||
test('route progress storage is opt-in and deleting the option removes progress data', async () => {
|
||||
const { storage, localStorage } = await loadModule();
|
||||
assert.equal(storage.isProgressStorageEnabled(), false);
|
||||
assert.equal(storage.activateRoute({ id: 7, name: 'Testweg' }), null);
|
||||
|
||||
storage.setProgressStorageEnabled(true);
|
||||
storage.activateRoute({ id: 7, name: 'Testweg' });
|
||||
storage.markVisited(7, 11);
|
||||
storage.markViewed(7, 12);
|
||||
storage.markAudioPlayed(7, 12);
|
||||
storage.saveRouteState({ id: 7, name: 'Testweg' }, 'paused', 23, [11], [12], [12]);
|
||||
|
||||
const route = storage.loadRoute(7);
|
||||
assert.equal(route.routeState, 'paused');
|
||||
assert.equal(route.routeProgressIndex, 23);
|
||||
assert.deepEqual([...route.visitedPoiIds], [11]);
|
||||
assert.deepEqual([...route.viewedPoiIds], [12]);
|
||||
assert.deepEqual([...route.playedAudioPoiIds], [12]);
|
||||
|
||||
storage.setProgressStorageEnabled(false);
|
||||
assert.equal(storage.isProgressStorageEnabled(), false);
|
||||
assert.equal(localStorage.getItem(storage.PROGRESS_KEY), null);
|
||||
});
|
||||
|
||||
test('route progress older than 24 hours is removed with visible loop progress', async () => {
|
||||
const { storage, localStorage } = await loadModule();
|
||||
storage.setProgressStorageEnabled(true);
|
||||
|
||||
const oldTimestamp = new Date(Date.now() - storage.MAX_PROGRESS_AGE_MS - 1000).toISOString();
|
||||
const freshTimestamp = new Date().toISOString();
|
||||
localStorage.setItem(storage.PROGRESS_KEY, JSON.stringify({
|
||||
version: 1,
|
||||
currentRouteId: 1,
|
||||
routes: {
|
||||
1: { routeId: 1, routeName: 'Alt', updatedAt: oldTimestamp },
|
||||
2: { routeId: 2, routeName: 'Neu', updatedAt: freshTimestamp }
|
||||
}
|
||||
}));
|
||||
|
||||
const updates = [];
|
||||
const result = await storage.cleanupExpired((removed, total) => updates.push(`${removed} / ${total}`));
|
||||
assert.equal(result.removed, 1);
|
||||
assert.equal(result.total, 1);
|
||||
assert.deepEqual(updates, ['0 / 1', '1 / 1']);
|
||||
assert.equal(storage.loadRoute(1), null);
|
||||
assert.equal(storage.loadRoute(2).routeName, 'Neu');
|
||||
});
|
||||
@ -29,19 +29,22 @@ test('route selection renders independently from geolocation and restores after
|
||||
const server = await read('server.js');
|
||||
const api = await read('src/routes/api.js');
|
||||
|
||||
assert.match(html, /vendor\/jquery-ui\/jquery-ui-1\.14\.2\.min\.css\?v=0\.12\.12/);
|
||||
assert.match(html, /css\/app\.css\?v=0\.12\.12/);
|
||||
assert.match(html, /vendor\/jquery-ui\/jquery-ui-1\.14\.2\.min\.css\?v=0\.12\.14/);
|
||||
assert.match(html, /css\/app\.css\?v=0\.12\.14/);
|
||||
assert.match(html, /id="all-routes-summary"[^>]*>Routen werden geladen/);
|
||||
assert.doesNotMatch(loader, /loadStyle\(/);
|
||||
assert.doesNotMatch(css, /:has\(/);
|
||||
assert.match(html, /<header class="has-back-button">/);
|
||||
|
||||
const firstRender = app.indexOf('renderRoutes();', app.indexOf('async initialize(context)'));
|
||||
const locationStart = app.indexOf('void initializeLocation(context);', app.indexOf('async initialize(context)'));
|
||||
const locationStart = app.indexOf('await initializeLocation(context);', app.indexOf('async initialize(context)'));
|
||||
assert.ok(firstRender >= 0 && locationStart > firstRender, 'Routen müssen vor der Standortabfrage gerendert werden.');
|
||||
assert.match(app, /globalThis\.addEventListener\('pageshow', restoreRouteLists\)/);
|
||||
assert.match(app, /document\.visibilityState === 'visible'/);
|
||||
assert.match(app, /Array\.isArray\(response\?\.routes\)/);
|
||||
assert.match(app, /throw new TypeError\('Die Routen-API hat keine gültige Routenliste geliefert\.'\)/);
|
||||
assert.match(app, /route\?\.id != null && route\?\.name != null/);
|
||||
assert.doesNotMatch(app, /route && route\.id/);
|
||||
assert.match(server, /no-cache, no-store, must-revalidate/);
|
||||
assert.match(api, /api\.use\(\(req, res, next\)/);
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user