update legal documents and minor code smells
Sonarqube Scanner / Build and analyze (push) Failing after 21s
Sonarqube Scanner / Build and analyze (push) Failing after 21s
This commit is contained in:
Vendored
+120
-82
@@ -1,113 +1,151 @@
|
|||||||
html,
|
:root {
|
||||||
body {
|
--bootstrap-bg: #f3f7ee;
|
||||||
min-height: 100%;
|
--bootstrap-card-bg: #ffffff;
|
||||||
margin: 0;
|
--bootstrap-text: #1c2b20;
|
||||||
background: #eef4e9;
|
--bootstrap-muted: #53605a;
|
||||||
color: #1f3022;
|
--bootstrap-accent: #315d3a;
|
||||||
font-family: system-ui, sans-serif;
|
--bootstrap-accent-strong: #25462c;
|
||||||
|
--bootstrap-border: #d6dfd3;
|
||||||
|
--bootstrap-error: #8b1e1e;
|
||||||
|
--bootstrap-shadow: 0 1rem 3rem rgb(29 52 36 / 18%);
|
||||||
}
|
}
|
||||||
|
|
||||||
#bootstrap-screen {
|
#bootstrap-screen {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 9999;
|
z-index: 1000;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
box-sizing: border-box;
|
min-height: 100svh;
|
||||||
padding: 1rem;
|
padding: max(1rem, env(safe-area-inset-top)) max(1rem, env(safe-area-inset-right)) max(1rem, env(safe-area-inset-bottom)) max(1rem, env(safe-area-inset-left));
|
||||||
|
color: var(--bootstrap-text);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 20% 10%, rgb(135 185 112 / 32%), transparent 34rem),
|
||||||
|
radial-gradient(circle at 85% 90%, rgb(230 198 116 / 25%), transparent 28rem),
|
||||||
|
var(--bootstrap-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
#bootstrap-screen[hidden] {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
#bootstrap-screen > section {
|
#bootstrap-screen > section {
|
||||||
width: min(30rem, 100%);
|
width: min(100%, 34rem);
|
||||||
box-sizing: border-box;
|
padding: clamp(1.25rem, 5vw, 2rem);
|
||||||
padding: 1.5rem;
|
border: 1px solid var(--bootstrap-border);
|
||||||
|
border-radius: 1.5rem;
|
||||||
|
box-shadow: var(--bootstrap-shadow);
|
||||||
|
background: var(--bootstrap-card-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
#bootstrap-screen [aria-hidden="true"] {
|
||||||
|
display: inline-block;
|
||||||
|
margin-bottom: .25rem;
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#bootstrap-screen h1 {
|
||||||
|
margin: 0 0 .5rem;
|
||||||
|
font-size: clamp(1.8rem, 8vw, 2.5rem);
|
||||||
|
line-height: 1.05;
|
||||||
|
}
|
||||||
|
|
||||||
|
#bootstrap-screen p {
|
||||||
|
margin: .65rem 0;
|
||||||
|
color: var(--bootstrap-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
#bootstrap-screen .privacy-summary {
|
||||||
|
display: grid;
|
||||||
|
gap: .75rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
padding: .85rem;
|
||||||
|
border: 1px solid var(--bootstrap-border);
|
||||||
border-radius: 1rem;
|
border-radius: 1rem;
|
||||||
background: #fff;
|
background: #f9fbf6;
|
||||||
box-shadow: 0 .5rem 2rem rgba(0, 0, 0, .15);
|
|
||||||
text-align: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#bootstrap-screen > section > span {
|
#bootstrap-screen .privacy-summary > p {
|
||||||
font-size: 4rem;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
#bootstrap-progress {
|
#bootstrap-screen .privacy-summary > .switch-row.switch-disabled {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#bootstrap-screen progress {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 1.2rem;
|
height: .9rem;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #dce7d8;
|
||||||
|
}
|
||||||
|
|
||||||
|
#bootstrap-screen progress::-webkit-progress-bar {
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #dce7d8;
|
||||||
|
}
|
||||||
|
|
||||||
|
#bootstrap-screen progress::-webkit-progress-value {
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--bootstrap-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
#bootstrap-screen progress::-moz-progress-bar {
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--bootstrap-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
#bootstrap-steps {
|
#bootstrap-steps {
|
||||||
line-height: 1.8;
|
display: grid;
|
||||||
text-align: left;
|
gap: .35rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
padding-left: 1.4rem;
|
||||||
|
color: var(--bootstrap-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
#bootstrap-steps li.done::marker {
|
#bootstrap-steps li[data-state="done"] {
|
||||||
content: "✓ ";
|
color: var(--bootstrap-accent-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
#bootstrap-steps li.failed {
|
#bootstrap-steps li[data-state="active"] {
|
||||||
color: #8b1e1e;
|
font-weight: 700;
|
||||||
|
color: var(--bootstrap-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
#bootstrap-error {
|
#bootstrap-error {
|
||||||
padding: .75rem;
|
color: var(--bootstrap-error);
|
||||||
border-radius: .5rem;
|
|
||||||
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;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
#bootstrap-enter,
|
#bootstrap-retry,
|
||||||
#bootstrap-retry {
|
#bootstrap-enter {
|
||||||
|
width: 100%;
|
||||||
min-height: 3rem;
|
min-height: 3rem;
|
||||||
margin: .35rem;
|
margin-top: .75rem;
|
||||||
touch-action: manipulation;
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: #ffffff;
|
||||||
|
background: var(--bootstrap-accent);
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
#bootstrap-retry:hover,
|
||||||
|
#bootstrap-enter:hover,
|
||||||
|
#bootstrap-retry:focus-visible,
|
||||||
|
#bootstrap-enter:focus-visible {
|
||||||
|
background: var(--bootstrap-accent-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bootstrap-ready {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--bootstrap-accent-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-cleanup-status {
|
||||||
|
min-height: 1.25rem;
|
||||||
|
font-size: .95rem;
|
||||||
}
|
}
|
||||||
|
|||||||
+67
-77
@@ -5,15 +5,21 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
<meta name="theme-color" content="#315d3a">
|
<meta name="theme-color" content="#315d3a">
|
||||||
<title>Wegwichtel</title>
|
<title>Wegwichtel</title>
|
||||||
<link rel="stylesheet" href="css/bootstrap.css?v=0.12.14">
|
<link rel="stylesheet" href="css/bootstrap.css?v=0.12.15">
|
||||||
<link rel="stylesheet" href="vendor/jquery-ui/jquery-ui-1.14.2.min.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">
|
<link rel="stylesheet" href="css/app.css?v=0.12.15">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<button id="info-button" class="info-button" type="button" aria-label="Informationen, Datenschutz und Impressum öffnen" title="Informationen">
|
<div class="info-control">
|
||||||
|
<button id="info-button" class="info-button" type="button" aria-label="Informationsmenü öffnen" title="Informationen" aria-haspopup="true" aria-expanded="false" aria-controls="info-dropdown">
|
||||||
<span aria-hidden="true">i</span>
|
<span aria-hidden="true">i</span>
|
||||||
</button>
|
</button>
|
||||||
|
<nav id="info-dropdown" class="info-dropdown" aria-label="Rechtliche und technische Informationen" hidden>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
<div id="bootstrap-screen" aria-live="polite" aria-busy="true">
|
<div id="bootstrap-screen" aria-live="polite" aria-busy="true">
|
||||||
<section>
|
<section>
|
||||||
<span aria-hidden="true">🧭</span>
|
<span aria-hidden="true">🧭</span>
|
||||||
@@ -22,30 +28,26 @@
|
|||||||
|
|
||||||
<div class="privacy-summary">
|
<div class="privacy-summary">
|
||||||
<p><strong>Cookies:</strong> Diese Anwendung setzt derzeit keine Cookies und verwendet weder Werbung noch Tracking.</p>
|
<p><strong>Cookies:</strong> Diese Anwendung setzt derzeit keine Cookies und verwendet weder Werbung noch Tracking.</p>
|
||||||
|
|
||||||
<label class="switch-row switch-disabled">
|
<label class="switch-row switch-disabled">
|
||||||
<span>
|
<span>
|
||||||
<strong>Optionale Cookies</strong>
|
<strong>Optionale Cookies</strong>
|
||||||
<small>Nicht verwendet und deshalb dauerhaft ausgeschaltet.</small>
|
<small>Nicht verwendet und deshalb dauerhaft ausgeschaltet.</small>
|
||||||
</span>
|
</span>
|
||||||
<input class="cookie-toggle" type="checkbox" role="switch" disabled>
|
<input class="cookie-toggle" type="checkbox" role="switch" aria-checked="false" disabled>
|
||||||
<span class="switch-track" aria-hidden="true"></span>
|
<span class="switch-track" aria-hidden="true"></span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="switch-row">
|
<label class="switch-row">
|
||||||
<span>
|
<span>
|
||||||
<strong>Streckenfortschritt lokal speichern</strong>
|
<strong>Streckenfortschritt lokal speichern</strong>
|
||||||
<small>Speichert ausgewählte Routen, besuchte und angesehene Stationen sowie abgespielte Audiodateien ausschließlich in diesem Browser.</small>
|
<small>Speichert ausgewählte Routen, besuchte und angesehene Stationen sowie abgespielte Audiodateien ausschließlich in diesem Browser.</small>
|
||||||
</span>
|
</span>
|
||||||
<input class="progress-storage-toggle" type="checkbox" role="switch">
|
<input class="progress-storage-toggle" type="checkbox" role="switch" aria-checked="false">
|
||||||
<span class="switch-track" aria-hidden="true"></span>
|
<span class="switch-track" aria-hidden="true"></span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p id="storage-cleanup-status" class="storage-cleanup-status" hidden></p>
|
<p id="storage-cleanup-status" class="storage-cleanup-status" hidden></p>
|
||||||
|
|
||||||
<progress id="bootstrap-progress" max="6" value="0"></progress>
|
<progress id="bootstrap-progress" max="6" value="0"></progress>
|
||||||
|
|
||||||
<ol id="bootstrap-steps">
|
<ol id="bootstrap-steps">
|
||||||
<li data-step="jquery">jQuery laden</li>
|
<li data-step="jquery">jQuery laden</li>
|
||||||
<li data-step="jquery-ui">jQuery UI laden</li>
|
<li data-step="jquery-ui">jQuery UI laden</li>
|
||||||
@@ -54,14 +56,12 @@
|
|||||||
<li data-step="routes">Routen laden</li>
|
<li data-step="routes">Routen laden</li>
|
||||||
<li data-step="location">Standort ermitteln</li>
|
<li data-step="location">Standort ermitteln</li>
|
||||||
</ol>
|
</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-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>
|
<p id="bootstrap-error" hidden></p>
|
||||||
<button id="bootstrap-retry" type="button" hidden>Erneut versuchen</button>
|
<button id="bootstrap-retry" type="button" hidden>Erneut versuchen</button>
|
||||||
<button id="bootstrap-enter" type="button" hidden>Anwendung öffnen</button>
|
<button id="bootstrap-enter" type="button" hidden>Anwendung öffnen</button>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<main id="app-shell" hidden>
|
<main id="app-shell" hidden>
|
||||||
<section id="routes-page" class="app-page">
|
<section id="routes-page" class="app-page">
|
||||||
<header>
|
<header>
|
||||||
@@ -77,7 +77,6 @@
|
|||||||
<li><p>Routen werden geladen …</p></li>
|
<li><p>Routen werden geladen …</p></li>
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2>Alle Routen</h2>
|
<h2>Alle Routen</h2>
|
||||||
<label for="route-search">Routen durchsuchen</label>
|
<label for="route-search">Routen durchsuchen</label>
|
||||||
@@ -91,7 +90,6 @@
|
|||||||
placeholder="Name, Schule oder Beschreibung">
|
placeholder="Name, Schule oder Beschreibung">
|
||||||
<button id="clear-route-search" type="button">Suche leeren</button>
|
<button id="clear-route-search" type="button">Suche leeren</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p id="all-routes-summary" aria-live="polite">Routen werden geladen …</p>
|
<p id="all-routes-summary" aria-live="polite">Routen werden geladen …</p>
|
||||||
<ul id="all-route-list" class="route-list" aria-live="polite">
|
<ul id="all-route-list" class="route-list" aria-live="polite">
|
||||||
<li><p>Routen werden geladen …</p></li>
|
<li><p>Routen werden geladen …</p></li>
|
||||||
@@ -99,7 +97,6 @@
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="route-page" class="app-page" hidden>
|
<section id="route-page" class="app-page" hidden>
|
||||||
<header class="has-back-button">
|
<header class="has-back-button">
|
||||||
<button class="back-button" type="button" data-page-target="#routes-page" aria-label="Zurück zur Routenauswahl" title="Zurück zur Routenauswahl">
|
<button class="back-button" type="button" data-page-target="#routes-page" aria-label="Zurück zur Routenauswahl" title="Zurück zur Routenauswahl">
|
||||||
@@ -110,13 +107,11 @@
|
|||||||
|
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<p id="route-description"></p>
|
<p id="route-description"></p>
|
||||||
|
|
||||||
<dl>
|
<dl>
|
||||||
<div><dt>Länge</dt><dd id="route-distance">–</dd></div>
|
<div><dt>Länge</dt><dd id="route-distance">–</dd></div>
|
||||||
<div><dt>Höhenmeter</dt><dd id="route-elevation">–</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>POIs</dt><dd id="route-poi-count">–</dd></div>
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>Routensteuerung</legend>
|
<legend>Routensteuerung</legend>
|
||||||
<button id="start-route" type="button" aria-label="Route starten" title="Route starten">
|
<button id="start-route" type="button" aria-label="Route starten" title="Route starten">
|
||||||
@@ -130,7 +125,6 @@
|
|||||||
</button>
|
</button>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<p id="tracking-status" class="notice" hidden></p>
|
<p id="tracking-status" class="notice" hidden></p>
|
||||||
|
|
||||||
<h2>Stationen</h2>
|
<h2>Stationen</h2>
|
||||||
<ul id="poi-list" class="route-list"></ul>
|
<ul id="poi-list" class="route-list"></ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -143,13 +137,11 @@
|
|||||||
</button>
|
</button>
|
||||||
<h1 id="poi-title">Station</h1>
|
<h1 id="poi-title">Station</h1>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<div id="slideshow" aria-live="polite">
|
<div id="slideshow" aria-live="polite">
|
||||||
<figure>
|
<figure>
|
||||||
<img id="slide-image" alt="">
|
<img id="slide-image" alt="">
|
||||||
</figure>
|
</figure>
|
||||||
|
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>Bildnavigation</legend>
|
<legend>Bildnavigation</legend>
|
||||||
<button id="slide-prev" type="button" aria-label="Vorheriges Bild" title="Vorheriges Bild">
|
<button id="slide-prev" type="button" aria-label="Vorheriges Bild" title="Vorheriges Bild">
|
||||||
@@ -164,12 +156,10 @@
|
|||||||
</button>
|
</button>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p id="poi-description"></p>
|
<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 zur Station" hidden>Audio kann auf diesem Gerät nicht wiedergegeben werden.</audio>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<footer id="navigation-footer" hidden aria-live="polite">
|
<footer id="navigation-footer" hidden aria-live="polite">
|
||||||
<svg id="navigation-arrow" viewBox="0 0 64 64" aria-hidden="true" focusable="false">
|
<svg id="navigation-arrow" viewBox="0 0 64 64" aria-hidden="true" focusable="false">
|
||||||
<path d="M32 4 53 54 32 44 11 54Z"/>
|
<path d="M32 4 53 54 32 44 11 54Z"/>
|
||||||
@@ -180,120 +170,122 @@
|
|||||||
</p>
|
</p>
|
||||||
</footer>
|
</footer>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<dialog id="info-dialog" class="info-dialog" aria-labelledby="info-dialog-title">
|
<dialog id="info-dialog" class="info-dialog" aria-labelledby="info-dialog-title">
|
||||||
<div class="info-dialog-shell">
|
<div class="info-dialog-shell">
|
||||||
<header class="info-dialog-header">
|
<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>
|
<h2 id="info-dialog-title">Informationen</h2>
|
||||||
<button id="info-close" type="button" aria-label="Informationsfenster schließen" title="Schließen">×</button>
|
<button id="info-close" type="button" aria-label="Informationsfenster schließen" title="Schließen">×</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="info-dialog-content">
|
<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>
|
<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>
|
<p class="legal-warning"><strong>Hinweis:</strong> Diese Datenschutzhinweise sind mit den öffentlich angegebenen Kontaktdaten der Staatlichen Grundschule Herpf vorbefüllt. Hostinganbieter, konkrete Serverprotokollierung und Speicherdauer müssen für die tatsächlich betriebene Wegwichtel-Instanz noch technisch geprüft und ergänzt werden.</p>
|
||||||
|
<h3>1. Verantwortliche Stelle und Datenschutzkontakt</h3>
|
||||||
<h3>1. Verantwortliche Stelle</h3>
|
|
||||||
<p>
|
<p>
|
||||||
<strong>[Name der verantwortlichen Organisation ergänzen]</strong><br>
|
<strong>Staatliche Grundschule Herpf</strong><br>
|
||||||
[Straße und Hausnummer ergänzen]<br>
|
Zum Eichig 3<br>
|
||||||
[Postleitzahl und Ort ergänzen]<br>
|
98617 Meiningen / OT Herpf<br>
|
||||||
E-Mail: [E-Mail-Adresse ergänzen]<br>
|
Telefon: (036943) 63722<br>
|
||||||
Telefon: [Telefonnummer ergänzen]
|
E-Mail: info@grundschule-herpf.de
|
||||||
</p>
|
</p>
|
||||||
|
<p>
|
||||||
|
Datenschutzangaben der Schule:<br>
|
||||||
|
<strong>Staatliches Schulamt Südthüringen</strong><br>
|
||||||
|
Datenschutzbeauftragter<br>
|
||||||
|
Hölderlinstraße 1<br>
|
||||||
|
98527 Suhl<br>
|
||||||
|
Telefon: 03681 / 7341 25<br>
|
||||||
|
Fax: 03681 / 7341 09<br>
|
||||||
|
E-Mail: datenschutz.suedthueriungen@schulamt.thueringen.de
|
||||||
|
</p>
|
||||||
|
<p>Der Datenschutzbeauftragte kann außerdem über die Postadresse der Schule mit dem Zusatz „der Datenschutzbeauftragte“ erreicht werden.</p>
|
||||||
<h3>2. Bereitstellung der Website</h3>
|
<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>Beim Aufruf der Website können durch den Webserver technisch erforderliche Verbindungsdaten verarbeitet werden. Dazu können IP-Adresse, Datum und Uhrzeit der Anfrage, Zeitzonendifferenz zur Greenwich Mean Time, aufgerufene Seite, Zugriffsstatus oder HTTP-Statuscode, übertragene Datenmenge, Referrer, Browser, Betriebssystem, Oberfläche sowie Sprache und Version der Browsersoftware gehören. Diese Verarbeitung dient der technischen Bereitstellung, Stabilität und Sicherheit der Website.</p>
|
||||||
<p>Hostinganbieter: <strong>[Anbieter, Anschrift und Speicherdauer ergänzen]</strong></p>
|
<p>Hostinganbieter, konkrete Protokolldaten und Speicherdauer dieser Wegwichtel-Instanz: <strong>[Anbieter, Anschrift, Art der Serverlogs und Speicherdauer ergänzen]</strong></p>
|
||||||
|
|
||||||
<h3>3. Standortdaten</h3>
|
<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>
|
<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>
|
<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 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>
|
<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">
|
<label class="switch-row compact-switch">
|
||||||
<span>
|
<span>
|
||||||
<strong>Streckenfortschritt lokal speichern</strong>
|
<strong>Streckenfortschritt lokal speichern</strong>
|
||||||
<small>Freiwillige Browser-Speicherung ein- oder ausschalten.</small>
|
<small>Freiwillige Browser-Speicherung ein- oder ausschalten.</small>
|
||||||
</span>
|
</span>
|
||||||
<input class="progress-storage-toggle" type="checkbox" role="switch">
|
<input class="progress-storage-toggle" type="checkbox" role="switch" aria-checked="false">
|
||||||
<span class="switch-track" aria-hidden="true"></span>
|
<span class="switch-track" aria-hidden="true"></span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<h3>5. Cookies und Tracking</h3>
|
<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>
|
<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>
|
<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>
|
<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>
|
<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>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. Außerdem besteht das Recht, sich bei einer Datenschutzaufsichtsbehörde über die Verarbeitung personenbezogener Daten zu beschweren.</p>
|
||||||
<p>Datenschutzkontakt: <strong>[Kontakt ergänzen]</strong><br>Zuständige Datenschutzaufsicht: <strong>[Behörde und Kontakt ergänzen]</strong></p>
|
<p>
|
||||||
|
Zuständige Datenschutzaufsicht:<br>
|
||||||
|
<strong>Thüringer Landesbeauftragter für den Datenschutz und die Informationsfreiheit (TlfDI)</strong><br>
|
||||||
|
Postfach 90 04 55, 99107 Erfurt<br>
|
||||||
|
Häßlerstraße 8, 99096 Erfurt<br>
|
||||||
|
Telefon: 0361 / 57 311 29 00<br>
|
||||||
|
Telefax: 0361 / 57 311 29 04<br>
|
||||||
|
E-Mail: poststelle@datenschutz.thueringen.de<br>
|
||||||
|
Website: www.tlfdi.de
|
||||||
|
</p>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<article class="info-page" data-info-page="imprint" hidden>
|
<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>
|
<p class="legal-warning"><strong>Hinweis:</strong> Dieses Impressum ist mit den öffentlich angegebenen Angaben der Staatlichen Grundschule Herpf vorbefüllt. Vor Veröffentlichung der Wegwichtel-Instanz sollte geprüft werden, ob für dieses konkrete Angebot zusätzliche oder abweichende Pflichtangaben erforderlich sind.</p>
|
||||||
|
|
||||||
<h3>Anbieter und verantwortlich für dieses Angebot</h3>
|
<h3>Anbieter und verantwortlich für dieses Angebot</h3>
|
||||||
<p>
|
<p>
|
||||||
<strong>[Vollständiger Name der Schule, Gemeinde, Initiative oder Person]</strong><br>
|
<strong>Staatliche Grundschule Herpf</strong><br>
|
||||||
[Rechtsform oder Vertretungsberechtigung ergänzen]<br>
|
Zum Eichig 3<br>
|
||||||
[Straße und Hausnummer]<br>
|
98617 Meiningen / OT Herpf
|
||||||
[Postleitzahl und Ort]
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h3>Kontakt</h3>
|
<h3>Kontakt</h3>
|
||||||
<p>
|
<p>
|
||||||
Telefon: [Telefonnummer ergänzen]<br>
|
Telefon: (036943) 63722<br>
|
||||||
E-Mail: [E-Mail-Adresse ergänzen]
|
Telefax: (036943) 63843<br>
|
||||||
|
E-Mail: info@grundschule-herpf.de
|
||||||
|
</p>
|
||||||
|
<h3>Vertretungsberechtigte Person</h3>
|
||||||
|
<p>Frau Wichert, Schulleiterin</p>
|
||||||
|
<h3>Schulträger</h3>
|
||||||
|
<p>
|
||||||
|
<strong>Landkreis Schmalkalden-Meiningen</strong><br>
|
||||||
|
Landratsamt<br>
|
||||||
|
Obertshäuser Platz 1<br>
|
||||||
|
98617 Meiningen
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h3>Inhaltlich verantwortlich</h3>
|
<h3>Inhaltlich verantwortlich</h3>
|
||||||
<p>[Name und ladungsfähige Anschrift ergänzen]</p>
|
<p>Frau Wichert</p>
|
||||||
|
|
||||||
<h3>Weitere Pflichtangaben</h3>
|
<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>
|
<p>Je nach konkretem Betrieb der Wegwichtel-Instanz können zusätzliche Angaben erforderlich sein, zum Beispiel zur technischen Betreiberin, zum Hosting oder zu ergänzenden Zuständigkeiten. Nicht benötigte Angaben sind zu entfernen.</p>
|
||||||
|
|
||||||
<h3>Haftung für Inhalte und Verweise</h3>
|
<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>
|
<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>
|
<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>
|
<p>Soweit nicht anders angegeben, liegen die Rechte bei der Grundschule Herpf. 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>
|
||||||
|
|
||||||
<article class="info-page" data-info-page="technology" hidden>
|
<article class="info-page" data-info-page="technology" hidden>
|
||||||
<h3>Cookies</h3>
|
<h3>Cookies</h3>
|
||||||
<p>Wegwichtel setzt derzeit keine Cookies. Die Anwendung besitzt keine Werbe-, Analyse- oder Trackingfunktionen. Der Cookie-Schalter bleibt deshalb ausgeschaltet.</p>
|
<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">
|
<label class="switch-row switch-disabled compact-switch">
|
||||||
<span>
|
<span>
|
||||||
<strong>Optionale Cookies</strong>
|
<strong>Optionale Cookies</strong>
|
||||||
<small>Derzeit nicht vorhanden.</small>
|
<small>Derzeit nicht vorhanden.</small>
|
||||||
</span>
|
</span>
|
||||||
<input class="cookie-toggle" type="checkbox" role="switch" disabled>
|
<input class="cookie-toggle" type="checkbox" role="switch" aria-checked="false" disabled>
|
||||||
<span class="switch-track" aria-hidden="true"></span>
|
<span class="switch-track" aria-hidden="true"></span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<h3>Local Storage</h3>
|
<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>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">
|
<label class="switch-row compact-switch">
|
||||||
<span>
|
<span>
|
||||||
<strong>Streckenfortschritt lokal speichern</strong>
|
<strong>Streckenfortschritt lokal speichern</strong>
|
||||||
<small>Die Änderung gilt sofort auf diesem Gerät.</small>
|
<small>Die Änderung gilt sofort auf diesem Gerät.</small>
|
||||||
</span>
|
</span>
|
||||||
<input class="progress-storage-toggle" type="checkbox" role="switch">
|
<input class="progress-storage-toggle" type="checkbox" role="switch" aria-checked="false">
|
||||||
<span class="switch-track" aria-hidden="true"></span>
|
<span class="switch-track" aria-hidden="true"></span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<h3>Lokal eingebundene JavaScripts</h3>
|
<h3>Lokal eingebundene JavaScripts</h3>
|
||||||
<ul>
|
<ul>
|
||||||
<li><strong>jQuery 4.0.0:</strong> DOM-Zugriffe, Ereignisbehandlung und API-Anfragen.</li>
|
<li><strong>jQuery 4.0.0:</strong> DOM-Zugriffe, Ereignisbehandlung und API-Anfragen.</li>
|
||||||
@@ -301,7 +293,6 @@
|
|||||||
<li><strong>Wegwichtel-Module:</strong> Routenanzeige, Navigation, Standort, Geräteausrichtung, Bilder, Audio, Vibration, Datenschutz und lokaler Fortschritt.</li>
|
<li><strong>Wegwichtel-Module:</strong> Routenanzeige, Navigation, Standort, Geräteausrichtung, Bilder, Audio, Vibration, Datenschutz und lokaler Fortschritt.</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p>Alle Bibliotheken werden vom eigenen Server ausgeliefert. Die Anwendung lädt keine JavaScripts, Schriftarten oder Analysepixel von externen Anbietern.</p>
|
<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>
|
<h3>Browser-Schnittstellen</h3>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Geolocation API für freiwillige Standortdaten,</li>
|
<li>Geolocation API für freiwillige Standortdaten,</li>
|
||||||
@@ -310,7 +301,6 @@
|
|||||||
<li>HTML-Audio und HTML-Bilder für lokal bereitgestellte Medien,</li>
|
<li>HTML-Audio und HTML-Bilder für lokal bereitgestellte Medien,</li>
|
||||||
<li>History API für die Navigation zwischen den Ansichten.</li>
|
<li>History API für die Navigation zwischen den Ansichten.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h3>Technische Grenzen</h3>
|
<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>
|
<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>
|
</article>
|
||||||
@@ -318,8 +308,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
<script src="js/privacy-storage.js?v=0.12.14"></script>
|
<script src="js/privacy-storage.js?v=0.12.15"></script>
|
||||||
<script src="js/legal-ui.js?v=0.12.14"></script>
|
<script src="js/legal-ui.js?v=0.12.15"></script>
|
||||||
<script src="js/bootstrap-loader.js?v=0.12.14"></script>
|
<script src="js/bootstrap-loader.js?v=0.12.14"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+300
-213
@@ -1,20 +1,32 @@
|
|||||||
|
globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||||
|
|
||||||
(function (ns) {
|
(function (ns) {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
|
const DROPDOWN_ANIMATION_MS = 180;
|
||||||
const pageTitles = Object.freeze({
|
const pageTitles = Object.freeze({
|
||||||
privacy: 'Datenschutzerklärung',
|
privacy: 'Datenschutzerklärung',
|
||||||
imprint: 'Impressum',
|
imprint: 'Impressum',
|
||||||
technology: 'Cookies und Technik'
|
technology: 'Cookies und Technik'
|
||||||
});
|
});
|
||||||
|
|
||||||
const menuEntries = Object.freeze([
|
const menuEntries = Object.freeze([
|
||||||
['privacy', 'Datenschutzerklärung'],
|
['privacy', 'Datenschutzerklärung'],
|
||||||
['imprint', 'Impressum'],
|
['imprint', 'Impressum'],
|
||||||
['technology', 'Cookies und verwendete JavaScripts']
|
['technology', 'Cookies und verwendete JavaScripts']
|
||||||
]);
|
]);
|
||||||
|
const selectors = Object.freeze({
|
||||||
|
content: '.info-dialog-content',
|
||||||
|
cookieToggle: '.cookie-toggle',
|
||||||
|
infoControl: '.info-control',
|
||||||
|
infoDropdown: '.info-dropdown',
|
||||||
|
infoPage: '[data-info-page]',
|
||||||
|
infoTarget: '[data-info-target]',
|
||||||
|
progressToggle: '.progress-storage-toggle'
|
||||||
|
});
|
||||||
|
|
||||||
let elements = null;
|
let elements = null;
|
||||||
let dropdownOpen = false;
|
let dropdownOpen = false;
|
||||||
|
let dropdownCloseTimer = null;
|
||||||
let documentEventsBound = false;
|
let documentEventsBound = false;
|
||||||
|
|
||||||
function ready(callback) {
|
function ready(callback) {
|
||||||
@@ -30,20 +42,24 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function qs(selector, root = document) {
|
function qs(selector, root = document) {
|
||||||
return root && typeof root.querySelector === 'function' ? root.querySelector(selector) : null;
|
return typeof root?.querySelector === 'function' ? root.querySelector(selector) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function qsa(selector, root = document) {
|
function qsa(selector, root = document) {
|
||||||
if (root && typeof root.querySelectorAll === 'function') return Array.from(root.querySelectorAll(selector));
|
if (typeof root?.querySelectorAll === 'function') return Array.from(root.querySelectorAll(selector));
|
||||||
if (root !== document && typeof document.querySelectorAll === 'function') return Array.from(document.querySelectorAll(selector));
|
if (root !== document && typeof document.querySelectorAll === 'function') return Array.from(document.querySelectorAll(selector));
|
||||||
if (root === document && typeof document.querySelectorAll === 'function') return Array.from(document.querySelectorAll(selector));
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function createElement(tagName, attributes = {}, text = '') {
|
function createElement(tagName, attributes = {}, text = '') {
|
||||||
if (typeof document.createElement !== 'function') return null;
|
if (typeof document.createElement !== 'function') return null;
|
||||||
const element = document.createElement(tagName);
|
const element = document.createElement(tagName);
|
||||||
Object.entries(attributes).forEach(([name, value]) => {
|
Object.entries(attributes).forEach(([name, value]) => assignAttribute(element, name, value));
|
||||||
|
if (text) element.textContent = text;
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assignAttribute(element, name, value) {
|
||||||
if (value === null || value === undefined) return;
|
if (value === null || value === undefined) return;
|
||||||
if (name === 'className') {
|
if (name === 'className') {
|
||||||
element.className = value;
|
element.className = value;
|
||||||
@@ -56,28 +72,32 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
element.setAttribute(name, value);
|
element.setAttribute(name, value);
|
||||||
});
|
|
||||||
if (text) element.textContent = text;
|
|
||||||
return element;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setAttribute(element, name, value) {
|
function setAttribute(element, name, value) {
|
||||||
if (!element || typeof element.setAttribute !== 'function') return;
|
if (typeof element?.setAttribute !== 'function') return;
|
||||||
element.setAttribute(name, value);
|
element.setAttribute(name, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setHidden(element, hidden) {
|
||||||
|
if (!element) return;
|
||||||
|
element.hidden = Boolean(hidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSwitchState(toggle, checked) {
|
||||||
|
if (!toggle) return;
|
||||||
|
toggle.checked = Boolean(checked);
|
||||||
|
setAttribute(toggle, 'aria-checked', checked ? 'true' : 'false');
|
||||||
|
}
|
||||||
|
|
||||||
function toggleClass(element, className, enabled) {
|
function toggleClass(element, className, enabled) {
|
||||||
if (!element || !element.classList) return;
|
if (typeof element?.classList?.toggle === 'function') {
|
||||||
if (typeof element.classList.toggle === 'function') {
|
|
||||||
element.classList.toggle(className, enabled);
|
element.classList.toggle(className, enabled);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
if (enabled && typeof element.classList.add === 'function') element.classList.add(className);
|
|
||||||
if (!enabled && typeof element.classList.remove === 'function') element.classList.remove(className);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function append(parent, child) {
|
function append(parent, child) {
|
||||||
if (!parent || !child || typeof parent.appendChild !== 'function') return false;
|
if (!child || typeof parent?.appendChild !== 'function') return false;
|
||||||
parent.appendChild(child);
|
parent.appendChild(child);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -95,18 +115,47 @@
|
|||||||
return append(document.body, element);
|
return append(document.body, element);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureInfoControl() {
|
function runNextFrame(callback) {
|
||||||
let control = qs('.info-control');
|
if (typeof globalThis.requestAnimationFrame === 'function') {
|
||||||
let infoButton = byId('info-button') || qs('.info-button');
|
globalThis.requestAnimationFrame(callback);
|
||||||
let dropdown = byId('info-dropdown') || qs('.info-dropdown');
|
return;
|
||||||
|
}
|
||||||
if (!control && typeof document.createElement === 'function') {
|
callback();
|
||||||
control = createElement('div', { className: 'info-control' });
|
|
||||||
prependToBody(control);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!infoButton && control) {
|
function schedule(callback, timeout) {
|
||||||
infoButton = createElement('button', {
|
if (typeof globalThis.setTimeout !== 'function') {
|
||||||
|
callback();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return globalThis.setTimeout(callback, timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelSchedule(timer) {
|
||||||
|
if (timer !== null && typeof globalThis.clearTimeout === 'function') globalThis.clearTimeout(timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureInfoControl() {
|
||||||
|
const control = ensureControlContainer();
|
||||||
|
const infoButton = ensureInfoButton(control);
|
||||||
|
const dropdown = ensureDropdown(control);
|
||||||
|
configureInfoButton(infoButton);
|
||||||
|
configureDropdown(dropdown);
|
||||||
|
return { control, infoButton, dropdown };
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureControlContainer() {
|
||||||
|
const existing = qs(selectors.infoControl);
|
||||||
|
if (existing || typeof document.createElement !== 'function') return existing;
|
||||||
|
const control = createElement('div', { className: 'info-control' });
|
||||||
|
prependToBody(control);
|
||||||
|
return control;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureInfoButton(control) {
|
||||||
|
const existing = byId('info-button') || qs('.info-button');
|
||||||
|
if (existing || !control) return existing;
|
||||||
|
const infoButton = createElement('button', {
|
||||||
id: 'info-button',
|
id: 'info-button',
|
||||||
className: 'info-button',
|
className: 'info-button',
|
||||||
type: 'button',
|
type: 'button',
|
||||||
@@ -115,218 +164,244 @@
|
|||||||
});
|
});
|
||||||
append(infoButton, createElement('span', { 'aria-hidden': 'true' }, 'i'));
|
append(infoButton, createElement('span', { 'aria-hidden': 'true' }, 'i'));
|
||||||
append(control, infoButton);
|
append(control, infoButton);
|
||||||
|
return infoButton;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!dropdown && control) {
|
function ensureDropdown(control) {
|
||||||
dropdown = createElement('nav', {
|
const existing = byId('info-dropdown') || qs(selectors.infoDropdown);
|
||||||
|
if (existing || !control) return existing;
|
||||||
|
const dropdown = createElement('nav', {
|
||||||
id: 'info-dropdown',
|
id: 'info-dropdown',
|
||||||
className: 'info-dropdown',
|
className: 'info-dropdown',
|
||||||
'aria-label': 'Rechtliche und technische Informationen'
|
'aria-label': 'Rechtliche und technische Informationen'
|
||||||
});
|
});
|
||||||
append(control, dropdown);
|
append(control, dropdown);
|
||||||
|
return dropdown;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (infoButton) {
|
function configureInfoButton(infoButton) {
|
||||||
|
if (!infoButton) return;
|
||||||
setAttribute(infoButton, 'type', 'button');
|
setAttribute(infoButton, 'type', 'button');
|
||||||
setAttribute(infoButton, 'aria-haspopup', 'true');
|
setAttribute(infoButton, 'aria-haspopup', 'true');
|
||||||
setAttribute(infoButton, 'aria-expanded', 'false');
|
setAttribute(infoButton, 'aria-expanded', 'false');
|
||||||
setAttribute(infoButton, 'aria-controls', 'info-dropdown');
|
setAttribute(infoButton, 'aria-controls', 'info-dropdown');
|
||||||
if (typeof infoButton.getAttribute !== 'function' || !infoButton.getAttribute('aria-label')) {
|
setInfoButtonLabel(infoButton);
|
||||||
|
ensureInfoButtonIcon(infoButton);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setInfoButtonLabel(infoButton) {
|
||||||
|
if (typeof infoButton.getAttribute === 'function' && infoButton.getAttribute('aria-label')) return;
|
||||||
setAttribute(infoButton, 'aria-label', 'Informationsmenü öffnen');
|
setAttribute(infoButton, 'aria-label', 'Informationsmenü öffnen');
|
||||||
}
|
}
|
||||||
if (!String(infoButton.textContent ?? '').trim()) {
|
|
||||||
|
function ensureInfoButtonIcon(infoButton) {
|
||||||
|
if (String(infoButton.textContent ?? '').trim()) return;
|
||||||
append(infoButton, createElement('span', { 'aria-hidden': 'true' }, 'i'));
|
append(infoButton, createElement('span', { 'aria-hidden': 'true' }, 'i'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function configureDropdown(dropdown) {
|
||||||
|
if (!dropdown) return;
|
||||||
|
setAttribute(dropdown, 'aria-label', 'Rechtliche und technische Informationen');
|
||||||
|
setHidden(dropdown, true);
|
||||||
|
menuEntries.forEach(([target, label]) => ensureMenuButton(dropdown, target, label));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dropdown) {
|
function ensureMenuButton(dropdown, target, label) {
|
||||||
setAttribute(dropdown, 'aria-label', 'Rechtliche und technische Informationen');
|
let button = qs(`[data-info-target="${target}"]`, dropdown);
|
||||||
dropdown.hidden = true;
|
button = button || qsa(selectors.infoTarget, dropdown).find(item => item.dataset?.infoTarget === target);
|
||||||
|
|
||||||
menuEntries.forEach(([target, label]) => {
|
|
||||||
let button = qs(`[data-info-target="${target}"]`, dropdown)
|
|
||||||
|| qsa('[data-info-target]', dropdown).find(item => item.dataset?.infoTarget === target);
|
|
||||||
if (!button) {
|
if (!button) {
|
||||||
button = createElement('button', {
|
button = createElement('button', { type: 'button', dataset: { infoTarget: target } }, label);
|
||||||
type: 'button',
|
|
||||||
dataset: { infoTarget: target }
|
|
||||||
}, label);
|
|
||||||
append(dropdown, button);
|
append(dropdown, button);
|
||||||
}
|
}
|
||||||
|
configureMenuButton(button, target, label);
|
||||||
|
}
|
||||||
|
|
||||||
|
function configureMenuButton(button, target, label) {
|
||||||
if (!button) return;
|
if (!button) return;
|
||||||
setAttribute(button, 'type', 'button');
|
setAttribute(button, 'type', 'button');
|
||||||
button.dataset.infoTarget = target;
|
button.dataset.infoTarget = target;
|
||||||
if (!String(button.textContent ?? '').trim()) button.textContent = label;
|
if (!String(button.textContent ?? '').trim()) button.textContent = label;
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return { control, infoButton, dropdown };
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureInfoPage(content, name) {
|
|
||||||
let page = qs(`[data-info-page="${name}"]`, content) || qs(`[data-info-page="${name}"]`);
|
|
||||||
if (!page && content) {
|
|
||||||
page = createElement('article', {
|
|
||||||
className: 'info-page',
|
|
||||||
dataset: { infoPage: name }
|
|
||||||
});
|
|
||||||
append(page, createElement('p', {}, `${pageTitles[name]} ist noch nicht hinterlegt.`));
|
|
||||||
append(content, page);
|
|
||||||
}
|
|
||||||
if (page) {
|
|
||||||
page.dataset.infoPage = name;
|
|
||||||
if (name !== 'privacy') page.hidden = true;
|
|
||||||
}
|
|
||||||
return page;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureInfoDialog() {
|
function ensureInfoDialog() {
|
||||||
let dialog = byId('info-dialog') || qs('#info-dialog');
|
const dialog = ensureDialogElement();
|
||||||
let title = byId('info-dialog-title') || qs('#info-dialog-title', dialog);
|
const shell = ensureDialogShell(dialog);
|
||||||
let backButton = byId('info-back') || qs('#info-back', dialog);
|
const header = ensureDialogHeader(shell);
|
||||||
let legacyMenu = byId('info-menu') || qs('#info-menu', dialog);
|
const title = ensureDialogTitle(dialog, header);
|
||||||
let closeButton = byId('info-close') || qs('#info-close', dialog);
|
const backButton = configureBackButton(byId('info-back') || qs('#info-back', dialog));
|
||||||
let content = qs('.info-dialog-content', dialog) || qs('.info-dialog-content');
|
const legacyMenu = configureLegacyMenu(byId('info-menu') || qs('#info-menu', dialog));
|
||||||
|
const closeButton = ensureCloseButton(dialog, header);
|
||||||
|
const content = ensureDialogContent(dialog, shell);
|
||||||
|
configureDialog(dialog);
|
||||||
|
ensurePages(content);
|
||||||
|
return { dialog, title, backButton, legacyMenu, closeButton, content };
|
||||||
|
}
|
||||||
|
|
||||||
if (!dialog && typeof document.createElement === 'function') {
|
function ensureDialogElement() {
|
||||||
dialog = createElement('dialog', {
|
const existing = byId('info-dialog') || qs('#info-dialog');
|
||||||
|
if (existing || typeof document.createElement !== 'function') return existing;
|
||||||
|
const dialog = createElement('dialog', {
|
||||||
id: 'info-dialog',
|
id: 'info-dialog',
|
||||||
className: 'info-dialog',
|
className: 'info-dialog',
|
||||||
'aria-labelledby': 'info-dialog-title'
|
'aria-labelledby': 'info-dialog-title'
|
||||||
});
|
});
|
||||||
append(document.body, dialog);
|
append(document.body, dialog);
|
||||||
|
return dialog;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dialog && (!title || !closeButton || !content) && typeof document.createElement === 'function') {
|
function ensureDialogShell(dialog) {
|
||||||
let shell = qs('.info-dialog-shell', dialog);
|
if (!dialog) return null;
|
||||||
if (!shell) {
|
const existing = qs('.info-dialog-shell', dialog);
|
||||||
shell = createElement('div', { className: 'info-dialog-shell' });
|
if (existing || typeof document.createElement !== 'function') return existing;
|
||||||
|
const shell = createElement('div', { className: 'info-dialog-shell' });
|
||||||
append(dialog, shell);
|
append(dialog, shell);
|
||||||
|
return shell;
|
||||||
}
|
}
|
||||||
|
|
||||||
let header = qs('.info-dialog-header', shell);
|
function ensureDialogHeader(shell) {
|
||||||
if (!header) {
|
if (!shell) return null;
|
||||||
header = createElement('header', { className: 'info-dialog-header' });
|
const existing = qs('.info-dialog-header', shell);
|
||||||
|
if (existing || typeof document.createElement !== 'function') return existing;
|
||||||
|
const header = createElement('header', { className: 'info-dialog-header' });
|
||||||
append(shell, header);
|
append(shell, header);
|
||||||
|
return header;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!title) {
|
function ensureDialogTitle(dialog, header) {
|
||||||
title = createElement('h2', { id: 'info-dialog-title' }, pageTitles.privacy);
|
const existing = byId('info-dialog-title') || qs('#info-dialog-title', dialog);
|
||||||
|
if (existing || !header) return existing;
|
||||||
|
const title = createElement('h2', { id: 'info-dialog-title' }, pageTitles.privacy);
|
||||||
append(header, title);
|
append(header, title);
|
||||||
|
return title;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!closeButton) {
|
function configureBackButton(backButton) {
|
||||||
closeButton = createElement('button', {
|
setAttribute(backButton, 'type', 'button');
|
||||||
|
setAttribute(backButton, 'aria-label', 'Zurück zum Informationsmenü');
|
||||||
|
setHidden(backButton, true);
|
||||||
|
return backButton;
|
||||||
|
}
|
||||||
|
|
||||||
|
function configureLegacyMenu(legacyMenu) {
|
||||||
|
setHidden(legacyMenu, true);
|
||||||
|
return legacyMenu;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureCloseButton(dialog, header) {
|
||||||
|
const existing = byId('info-close') || qs('#info-close', dialog);
|
||||||
|
if (existing || !header) return configureCloseButton(existing);
|
||||||
|
const closeButton = createElement('button', {
|
||||||
id: 'info-close',
|
id: 'info-close',
|
||||||
type: 'button',
|
type: 'button',
|
||||||
'aria-label': 'Informationsfenster schließen',
|
'aria-label': 'Informationsfenster schließen',
|
||||||
title: 'Schließen'
|
title: 'Schließen'
|
||||||
}, '×');
|
}, '×');
|
||||||
append(header, closeButton);
|
append(header, closeButton);
|
||||||
|
return configureCloseButton(closeButton);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!content) {
|
function configureCloseButton(closeButton) {
|
||||||
content = createElement('div', { className: 'info-dialog-content' });
|
|
||||||
append(shell, content);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dialog) setAttribute(dialog, 'aria-labelledby', 'info-dialog-title');
|
|
||||||
if (backButton) {
|
|
||||||
setAttribute(backButton, 'type', 'button');
|
|
||||||
setAttribute(backButton, 'aria-label', 'Zurück zum Informationsmenü');
|
|
||||||
backButton.hidden = true;
|
|
||||||
}
|
|
||||||
if (legacyMenu) legacyMenu.hidden = true;
|
|
||||||
if (closeButton) {
|
|
||||||
setAttribute(closeButton, 'type', 'button');
|
setAttribute(closeButton, 'type', 'button');
|
||||||
setAttribute(closeButton, 'aria-label', 'Informationsfenster schließen');
|
setAttribute(closeButton, 'aria-label', 'Informationsfenster schließen');
|
||||||
if (!String(closeButton.textContent ?? '').trim()) closeButton.textContent = '×';
|
if (closeButton && !String(closeButton.textContent ?? '').trim()) closeButton.textContent = '×';
|
||||||
|
return closeButton;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (content) Object.keys(pageTitles).forEach(name => ensureInfoPage(content, name));
|
function ensureDialogContent(dialog, shell) {
|
||||||
|
const existing = qs(selectors.content, dialog) || qs(selectors.content);
|
||||||
|
if (existing || !shell) return existing;
|
||||||
|
const content = createElement('div', { className: 'info-dialog-content' });
|
||||||
|
append(shell, content);
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
return { dialog, title, backButton, legacyMenu, closeButton, content };
|
function configureDialog(dialog) {
|
||||||
|
setAttribute(dialog, 'aria-labelledby', 'info-dialog-title');
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensurePages(content) {
|
||||||
|
if (!content) return;
|
||||||
|
Object.keys(pageTitles).forEach(name => ensureInfoPage(content, name));
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureInfoPage(content, name) {
|
||||||
|
const existing = qs(`[data-info-page="${name}"]`, content) || qs(`[data-info-page="${name}"]`);
|
||||||
|
const page = existing || createFallbackPage(content, name);
|
||||||
|
if (!page) return null;
|
||||||
|
page.dataset.infoPage = name;
|
||||||
|
setHidden(page, name !== 'privacy');
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFallbackPage(content, name) {
|
||||||
|
const page = createElement('article', { className: 'info-page', dataset: { infoPage: name } });
|
||||||
|
append(page, createElement('p', {}, `${pageTitles[name]} ist noch nicht hinterlegt.`));
|
||||||
|
append(content, page);
|
||||||
|
return page;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureInfoUi() {
|
function ensureInfoUi() {
|
||||||
return {
|
return { ...ensureInfoControl(), ...ensureInfoDialog() };
|
||||||
...ensureInfoControl(),
|
|
||||||
...ensureInfoDialog()
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isActivation(event) {
|
function isActivation(event) {
|
||||||
if (!event) return false;
|
if (!event) return false;
|
||||||
if (event.type === 'pointerup') {
|
|
||||||
if (event.isPrimary === false) return false;
|
|
||||||
if (event.pointerType === 'mouse' && event.button !== 0) return false;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (event.type === 'keydown') return event.key === 'Enter' || event.key === ' ';
|
if (event.type === 'keydown') return event.key === 'Enter' || event.key === ' ';
|
||||||
return false;
|
if (event.type !== 'pointerup') return false;
|
||||||
|
if (event.isPrimary === false) return false;
|
||||||
|
return event.pointerType !== 'mouse' || event.button === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function activate(element, handler) {
|
function activate(element, handler) {
|
||||||
if (!element || typeof element.addEventListener !== 'function') return;
|
if (typeof element?.addEventListener !== 'function') return;
|
||||||
if (element.dataset) {
|
if (!markBound(element, handler)) return;
|
||||||
const key = handler.name ? `wegwichtelLegalUiBound${handler.name}` : 'wegwichtelLegalUiBound';
|
element.addEventListener('pointerup', event => handleActivation(event, handler));
|
||||||
if (element.dataset[key] === 'true') return;
|
element.addEventListener('keydown', event => handleActivation(event, handler));
|
||||||
element.dataset[key] = 'true';
|
|
||||||
}
|
}
|
||||||
element.addEventListener('pointerup', event => {
|
|
||||||
|
function markBound(element, handler) {
|
||||||
|
if (!element.dataset) return true;
|
||||||
|
const key = handler.name ? `wegwichtelLegalUiBound${handler.name}` : 'wegwichtelLegalUiBound';
|
||||||
|
if (element.dataset[key] === 'true') return false;
|
||||||
|
element.dataset[key] = 'true';
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleActivation(event, handler) {
|
||||||
if (!isActivation(event)) return;
|
if (!isActivation(event)) return;
|
||||||
if (typeof event.preventDefault === 'function') event.preventDefault();
|
event.preventDefault?.();
|
||||||
if (typeof event.stopPropagation === 'function') event.stopPropagation();
|
event.stopPropagation?.();
|
||||||
handler(event);
|
handler(event);
|
||||||
});
|
|
||||||
element.addEventListener('keydown', event => {
|
|
||||||
if (!isActivation(event)) return;
|
|
||||||
if (typeof event.preventDefault === 'function') event.preventDefault();
|
|
||||||
if (typeof event.stopPropagation === 'function') event.stopPropagation();
|
|
||||||
handler(event);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setDropdownOpen(open) {
|
function setDropdownOpen(open) {
|
||||||
if (!elements?.dropdown || !elements?.infoButton) return;
|
if (!elements?.dropdown || !elements?.infoButton) return;
|
||||||
dropdownOpen = Boolean(open);
|
dropdownOpen = Boolean(open);
|
||||||
elements.dropdown.hidden = !dropdownOpen;
|
|
||||||
toggleClass(elements.dropdown, 'is-open', dropdownOpen);
|
|
||||||
setAttribute(elements.infoButton, 'aria-expanded', dropdownOpen ? 'true' : 'false');
|
setAttribute(elements.infoButton, 'aria-expanded', dropdownOpen ? 'true' : 'false');
|
||||||
|
cancelSchedule(dropdownCloseTimer);
|
||||||
|
dropdownCloseTimer = null;
|
||||||
|
if (dropdownOpen) openDropdown();
|
||||||
|
else closeDropdown();
|
||||||
}
|
}
|
||||||
|
|
||||||
function hidePages() {
|
function openDropdown() {
|
||||||
qsa('[data-info-page]').forEach(item => {
|
setHidden(elements.dropdown, false);
|
||||||
item.hidden = true;
|
runNextFrame(() => toggleClass(elements.dropdown, 'is-open', true));
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function openLegacyMenu() {
|
function closeDropdown() {
|
||||||
if (!elements?.dialog) return;
|
toggleClass(elements.dropdown, 'is-open', false);
|
||||||
hidePages();
|
dropdownCloseTimer = schedule(() => {
|
||||||
if (elements.title) elements.title.textContent = 'Informationen';
|
if (!dropdownOpen) setHidden(elements.dropdown, true);
|
||||||
if (elements.legacyMenu) elements.legacyMenu.hidden = false;
|
}, DROPDOWN_ANIMATION_MS);
|
||||||
if (elements.backButton) elements.backButton.hidden = true;
|
|
||||||
if (!elements.dialog.open) {
|
|
||||||
if (typeof elements.dialog.showModal === 'function') elements.dialog.showModal();
|
|
||||||
else setAttribute(elements.dialog, 'open', '');
|
|
||||||
}
|
|
||||||
focusSafely(elements.closeButton ?? elements.dialog);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleInfoButton() {
|
function handleInfoButton() {
|
||||||
if (elements?.dropdown) {
|
|
||||||
setDropdownOpen(!dropdownOpen);
|
setDropdownOpen(!dropdownOpen);
|
||||||
return;
|
|
||||||
}
|
|
||||||
openLegacyMenu();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function elementContains(element, target) {
|
function elementContains(element, target) {
|
||||||
if (!element || !target) return false;
|
return Boolean(element && target && (element === target || element.contains?.(target)));
|
||||||
if (element === target) return true;
|
|
||||||
return typeof element.contains === 'function' && element.contains(target);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isInsideInfoControl(target) {
|
function isInsideInfoControl(target) {
|
||||||
@@ -334,39 +409,38 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isProgressStorageEnabled() {
|
function isProgressStorageEnabled() {
|
||||||
if (!ns.PrivacyStorage || typeof ns.PrivacyStorage.isProgressStorageEnabled !== 'function') return false;
|
return ns?.PrivacyStorage?.isProgressStorageEnabled?.() === true;
|
||||||
return ns.PrivacyStorage.isProgressStorageEnabled();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setProgressStorageEnabled(enabled) {
|
function setProgressStorageEnabled(enabled) {
|
||||||
if (!ns.PrivacyStorage || typeof ns.PrivacyStorage.setProgressStorageEnabled !== 'function') return false;
|
return ns?.PrivacyStorage?.setProgressStorageEnabled?.(enabled) === true;
|
||||||
return ns.PrivacyStorage.setProgressStorageEnabled(enabled);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncStorageToggles() {
|
function syncStorageToggles() {
|
||||||
const enabled = isProgressStorageEnabled();
|
const enabled = isProgressStorageEnabled();
|
||||||
qsa('.progress-storage-toggle').forEach(toggle => {
|
qsa(selectors.progressToggle).forEach(toggle => setSwitchState(toggle, enabled));
|
||||||
toggle.checked = enabled;
|
qsa(selectors.cookieToggle).forEach(toggle => setSwitchState(toggle, false));
|
||||||
});
|
}
|
||||||
qsa('.cookie-toggle').forEach(toggle => {
|
|
||||||
toggle.checked = false;
|
function pageNameOrDefault(name) {
|
||||||
});
|
return Object.hasOwn(pageTitles, name) ? name : 'privacy';
|
||||||
}
|
}
|
||||||
|
|
||||||
function showPage(name) {
|
function showPage(name) {
|
||||||
const pageName = Object.prototype.hasOwnProperty.call(pageTitles, name) ? name : 'privacy';
|
const pageName = pageNameOrDefault(name);
|
||||||
const page = qs(`[data-info-page="${pageName}"]`, elements?.content) || qs(`[data-info-page="${pageName}"]`);
|
const page = qs(`[data-info-page="${pageName}"]`, elements?.content) || qs(`[data-info-page="${pageName}"]`);
|
||||||
if (!page) return;
|
if (!page) return;
|
||||||
|
updateDialogTitle(pageName);
|
||||||
if (elements?.title) elements.title.textContent = pageTitles[pageName] ?? 'Informationen';
|
qsa(selectors.infoPage).forEach(item => setHidden(item, item !== page));
|
||||||
qsa('[data-info-page]').forEach(item => {
|
|
||||||
item.hidden = item !== page;
|
|
||||||
});
|
|
||||||
if (elements?.content) elements.content.scrollTop = 0;
|
if (elements?.content) elements.content.scrollTop = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateDialogTitle(pageName) {
|
||||||
|
if (elements?.title) elements.title.textContent = pageTitles[pageName] ?? 'Informationen';
|
||||||
|
}
|
||||||
|
|
||||||
function focusSafely(element) {
|
function focusSafely(element) {
|
||||||
if (!element || typeof element.focus !== 'function') return;
|
if (typeof element?.focus !== 'function') return;
|
||||||
try {
|
try {
|
||||||
element.focus({ preventScroll: true });
|
element.focus({ preventScroll: true });
|
||||||
} catch {
|
} catch {
|
||||||
@@ -376,85 +450,99 @@
|
|||||||
|
|
||||||
function openDialog(name = 'privacy') {
|
function openDialog(name = 'privacy') {
|
||||||
if (!elements?.dialog) return;
|
if (!elements?.dialog) return;
|
||||||
const pageName = Object.prototype.hasOwnProperty.call(pageTitles, name) ? name : 'privacy';
|
|
||||||
setDropdownOpen(false);
|
setDropdownOpen(false);
|
||||||
if (elements?.legacyMenu) elements.legacyMenu.hidden = true;
|
setHidden(elements.legacyMenu, true);
|
||||||
if (elements?.backButton) elements.backButton.hidden = !elements?.legacyMenu;
|
setHidden(elements.backButton, true);
|
||||||
showPage(pageName);
|
showPage(pageNameOrDefault(name));
|
||||||
syncStorageToggles();
|
syncStorageToggles();
|
||||||
if (!elements.dialog.open) {
|
showDialog();
|
||||||
if (typeof elements.dialog.showModal === 'function') elements.dialog.showModal();
|
|
||||||
else setAttribute(elements.dialog, 'open', '');
|
|
||||||
}
|
|
||||||
focusSafely(elements.closeButton ?? elements.dialog);
|
focusSafely(elements.closeButton ?? elements.dialog);
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeDialog() {
|
function showDialog() {
|
||||||
if (elements?.dialog) {
|
if (elements.dialog.open) return;
|
||||||
if (typeof elements.dialog.close === 'function') elements.dialog.close();
|
if (typeof elements.dialog.showModal === 'function') elements.dialog.showModal();
|
||||||
else if (typeof elements.dialog.removeAttribute === 'function') elements.dialog.removeAttribute('open');
|
else setAttribute(elements.dialog, 'open', '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function closeDialog() {
|
||||||
|
closeNativeDialog();
|
||||||
setDropdownOpen(false);
|
setDropdownOpen(false);
|
||||||
focusSafely(elements?.infoButton);
|
focusSafely(elements?.infoButton);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function closeNativeDialog() {
|
||||||
|
if (!elements?.dialog) return;
|
||||||
|
if (typeof elements.dialog.close === 'function') elements.dialog.close();
|
||||||
|
else elements.dialog.removeAttribute?.('open');
|
||||||
|
}
|
||||||
|
|
||||||
function bindStorageToggles() {
|
function bindStorageToggles() {
|
||||||
qsa('.progress-storage-toggle').forEach(toggle => {
|
qsa(selectors.progressToggle).forEach(bindStorageToggle);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindStorageToggle(toggle) {
|
||||||
if (toggle.dataset?.wegwichtelStorageBound === 'true') return;
|
if (toggle.dataset?.wegwichtelStorageBound === 'true') return;
|
||||||
if (toggle.dataset) toggle.dataset.wegwichtelStorageBound = 'true';
|
if (toggle.dataset) toggle.dataset.wegwichtelStorageBound = 'true';
|
||||||
toggle.addEventListener('change', event => {
|
toggle.addEventListener('change', event => handleStorageToggleChange(event, toggle));
|
||||||
const source = event.currentTarget || event.target || toggle;
|
}
|
||||||
|
|
||||||
|
function handleStorageToggleChange(event, fallbackToggle) {
|
||||||
|
const source = event.currentTarget || event.target || fallbackToggle;
|
||||||
const enabled = setProgressStorageEnabled(Boolean(source.checked));
|
const enabled = setProgressStorageEnabled(Boolean(source.checked));
|
||||||
syncStorageToggles();
|
syncStorageToggles();
|
||||||
document.dispatchEvent(new CustomEvent('wegwichtel:progress-storage-changed', {
|
document.dispatchEvent(new CustomEvent('wegwichtel:progress-storage-changed', { detail: { enabled } }));
|
||||||
detail: { enabled }
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function infoTargetButtons() {
|
function infoTargetButtons() {
|
||||||
if (elements?.dropdown) return qsa('[data-info-target]', elements.dropdown);
|
const root = elements?.dropdown || elements?.legacyMenu || document;
|
||||||
if (elements?.legacyMenu) return qsa('[data-info-target]', elements.legacyMenu);
|
return qsa(selectors.infoTarget, root);
|
||||||
return qsa('[data-info-target]');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function bindDialog() {
|
function bindDialog() {
|
||||||
activate(elements?.infoButton, handleInfoButton);
|
activate(elements?.infoButton, handleInfoButton);
|
||||||
activate(elements?.closeButton, closeDialog);
|
activate(elements?.closeButton, closeDialog);
|
||||||
activate(elements?.backButton, openLegacyMenu);
|
activate(elements?.backButton, () => setDropdownOpen(true));
|
||||||
|
infoTargetButtons().forEach(button => activate(button, () => openDialog(button.dataset?.infoTarget)));
|
||||||
|
bindDocumentEvents();
|
||||||
|
bindDialogEvents();
|
||||||
|
}
|
||||||
|
|
||||||
infoTargetButtons().forEach(button => {
|
function bindDocumentEvents() {
|
||||||
activate(button, () => openDialog(button.dataset?.infoTarget));
|
if (documentEventsBound) return;
|
||||||
});
|
|
||||||
|
|
||||||
if (!documentEventsBound) {
|
|
||||||
documentEventsBound = true;
|
documentEventsBound = true;
|
||||||
document.addEventListener('pointerup', event => {
|
document.addEventListener('pointerup', closeDropdownOnOutsidePointer);
|
||||||
if (!dropdownOpen || isInsideInfoControl(event.target)) return;
|
document.addEventListener('keydown', closeDropdownOnEscape);
|
||||||
setDropdownOpen(false);
|
}
|
||||||
});
|
|
||||||
document.addEventListener('keydown', event => {
|
function closeDropdownOnOutsidePointer(event) {
|
||||||
|
if (dropdownOpen && !isInsideInfoControl(event.target)) setDropdownOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDropdownOnEscape(event) {
|
||||||
if (!dropdownOpen || event.key !== 'Escape' || elements?.dialog?.open) return;
|
if (!dropdownOpen || event.key !== 'Escape' || elements?.dialog?.open) return;
|
||||||
if (typeof event.preventDefault === 'function') event.preventDefault();
|
event.preventDefault?.();
|
||||||
setDropdownOpen(false);
|
setDropdownOpen(false);
|
||||||
focusSafely(elements?.infoButton);
|
focusSafely(elements?.infoButton);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (elements?.dialog && elements.dialog.dataset?.wegwichtelLegalUiDialogBound !== 'true') {
|
function bindDialogEvents() {
|
||||||
|
if (!elements?.dialog || elements.dialog.dataset?.wegwichtelLegalUiDialogBound === 'true') return;
|
||||||
if (elements.dialog.dataset) elements.dialog.dataset.wegwichtelLegalUiDialogBound = 'true';
|
if (elements.dialog.dataset) elements.dialog.dataset.wegwichtelLegalUiDialogBound = 'true';
|
||||||
elements.dialog.addEventListener('pointerup', event => {
|
elements.dialog.addEventListener('pointerup', closeDialogOnBackdropPointer);
|
||||||
if (event.target !== elements.dialog || !isActivation(event)) return;
|
elements.dialog.addEventListener('cancel', closeDialogOnCancel);
|
||||||
if (typeof event.preventDefault === 'function') event.preventDefault();
|
|
||||||
if (typeof event.stopPropagation === 'function') event.stopPropagation();
|
|
||||||
closeDialog();
|
|
||||||
});
|
|
||||||
elements.dialog.addEventListener('cancel', event => {
|
|
||||||
if (typeof event.preventDefault === 'function') event.preventDefault();
|
|
||||||
closeDialog();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function closeDialogOnBackdropPointer(event) {
|
||||||
|
if (event.target !== elements.dialog || !isActivation(event)) return;
|
||||||
|
event.preventDefault?.();
|
||||||
|
event.stopPropagation?.();
|
||||||
|
closeDialog();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDialogOnCancel(event) {
|
||||||
|
event.preventDefault?.();
|
||||||
|
closeDialog();
|
||||||
}
|
}
|
||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
@@ -462,7 +550,6 @@
|
|||||||
bindDialog();
|
bindDialog();
|
||||||
bindStorageToggles();
|
bindStorageToggles();
|
||||||
syncStorageToggles();
|
syncStorageToggles();
|
||||||
|
|
||||||
ns.LegalUi = Object.freeze({
|
ns.LegalUi = Object.freeze({
|
||||||
open: openDialog,
|
open: openDialog,
|
||||||
close: closeDialog,
|
close: closeDialog,
|
||||||
@@ -473,4 +560,4 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
ready(init);
|
ready(init);
|
||||||
}(globalThis.Wegwichtel = globalThis.Wegwichtel || {}));
|
}(globalThis.Wegwichtel));
|
||||||
|
|||||||
+140
-184
@@ -1,11 +1,11 @@
|
|||||||
|
globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||||
|
|
||||||
(function (ns) {
|
(function (ns) {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const SETTINGS_KEY = 'wegwichtel.privacy.v1';
|
const PRIVACY_KEY = 'wegwichtel.privacy.v1';
|
||||||
const PROGRESS_KEY = 'wegwichtel.route-progress.v1';
|
const PROGRESS_KEY = 'wegwichtel.route-progress.v1';
|
||||||
const COOKIE_PREFIX = 'wegwichtel_';
|
|
||||||
const MAX_PROGRESS_AGE_MS = 24 * 60 * 60 * 1000;
|
const MAX_PROGRESS_AGE_MS = 24 * 60 * 60 * 1000;
|
||||||
const STORE_VERSION = 1;
|
|
||||||
|
|
||||||
function storage() {
|
function storage() {
|
||||||
try {
|
try {
|
||||||
@@ -15,28 +15,28 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function nowIso() {
|
function now() {
|
||||||
return new Date().toISOString();
|
return Date.now();
|
||||||
}
|
}
|
||||||
|
|
||||||
function readJson(key, fallback) {
|
function readJson(key, fallback) {
|
||||||
const backend = storage();
|
const store = storage();
|
||||||
if (!backend) return fallback;
|
if (!store) return fallback;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const raw = backend.getItem(key);
|
const raw = store.getItem(key);
|
||||||
return raw ? JSON.parse(raw) : fallback;
|
if (!raw) return fallback;
|
||||||
|
const value = JSON.parse(raw);
|
||||||
|
return value && typeof value === 'object' ? value : fallback;
|
||||||
} catch {
|
} catch {
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeJson(key, value) {
|
function writeJson(key, value) {
|
||||||
const backend = storage();
|
const store = storage();
|
||||||
if (!backend) return false;
|
if (!store) return false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
backend.setItem(key, JSON.stringify(value));
|
store.setItem(key, JSON.stringify(value));
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
@@ -44,208 +44,164 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function removeItem(key) {
|
function removeItem(key) {
|
||||||
const backend = storage();
|
const store = storage();
|
||||||
if (!backend) return;
|
if (!store) return false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
backend.removeItem(key);
|
store.removeItem(key);
|
||||||
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
// Private browsing modes may reject storage access. The app remains usable without persistence.
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function defaultSettings() {
|
function normalizeSettings(value) {
|
||||||
return { progressStorage: false, updatedAt: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
function settings() {
|
|
||||||
const value = readJson(SETTINGS_KEY, defaultSettings());
|
|
||||||
return {
|
return {
|
||||||
progressStorage: value?.progressStorage === true,
|
progressStorage: value?.progressStorage === true,
|
||||||
updatedAt: typeof value?.updatedAt === 'string' ? value.updatedAt : null
|
cookies: false,
|
||||||
|
updatedAt: Number(value?.updatedAt) || now()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveSettings(progressStorage) {
|
function readSettings() {
|
||||||
return writeJson(SETTINGS_KEY, {
|
return normalizeSettings(readJson(PRIVACY_KEY, {}));
|
||||||
progressStorage: progressStorage === true,
|
|
||||||
updatedAt: nowIso()
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function defaultProgressStore() {
|
function writeSettings(settings) {
|
||||||
return { version: STORE_VERSION, currentRouteId: null, routes: {} };
|
return writeJson(PRIVACY_KEY, normalizeSettings({ ...settings, updatedAt: now() }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function progressStore() {
|
function isProgressStorageEnabled() {
|
||||||
const value = readJson(PROGRESS_KEY, defaultProgressStore());
|
return readSettings().progressStorage === true;
|
||||||
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) {
|
function setProgressStorageEnabled(enabled) {
|
||||||
const active = enabled === true;
|
const nextValue = Boolean(enabled);
|
||||||
const saved = saveSettings(active);
|
writeSettings({ progressStorage: nextValue, cookies: false });
|
||||||
if (!active) removeItem(PROGRESS_KEY);
|
if (!nextValue) removeItem(PROGRESS_KEY);
|
||||||
return saved && settings().progressStorage;
|
return nextValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cookiesEnabled() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function routeId(route) {
|
||||||
|
const id = route?.id ?? route?.routeId ?? route;
|
||||||
|
return id === null || id === undefined ? null : String(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function asNumberOrNull(value) {
|
||||||
|
const number = Number(value);
|
||||||
|
return Number.isFinite(number) ? number : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function numericIdSet(values) {
|
||||||
|
if (!Array.isArray(values)) return [];
|
||||||
|
return [...new Set(values.map(Number).filter(Number.isFinite))];
|
||||||
|
}
|
||||||
|
|
||||||
|
function readProgressStore() {
|
||||||
|
const data = readJson(PROGRESS_KEY, {});
|
||||||
|
const entries = data?.routes && typeof data.routes === 'object' ? data.routes : data;
|
||||||
|
return entries && typeof entries === 'object' ? entries : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeProgressStore(routes) {
|
||||||
|
return writeJson(PROGRESS_KEY, {
|
||||||
|
version: 1,
|
||||||
|
updatedAt: now(),
|
||||||
|
routes
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSavedRouteState(value) {
|
||||||
|
if (!value || typeof value !== 'object') return null;
|
||||||
|
return {
|
||||||
|
routeId: value.routeId === null || value.routeId === undefined ? null : String(value.routeId),
|
||||||
|
routeState: value.routeState === 'running' || value.routeState === 'paused' ? value.routeState : 'idle',
|
||||||
|
routeProgressIndex: Number.isInteger(value.routeProgressIndex) ? value.routeProgressIndex : null,
|
||||||
|
visitedPoiIds: numericIdSet(value.visitedPoiIds),
|
||||||
|
viewedPoiIds: numericIdSet(value.viewedPoiIds),
|
||||||
|
playedAudioPoiIds: numericIdSet(value.playedAudioPoiIds),
|
||||||
|
savedAt: Number(value.savedAt) || Number(value.updatedAt) || 0,
|
||||||
|
updatedAt: Number(value.updatedAt) || Number(value.savedAt) || 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isExpired(value, referenceTime = now()) {
|
||||||
|
const saved = Number(value?.updatedAt) || Number(value?.savedAt) || 0;
|
||||||
|
return saved <= 0 || referenceTime - saved > MAX_PROGRESS_AGE_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveRouteState(route, routeState, routeProgressIndex, visitedPoiIds = [], viewedPoiIds = [], playedAudioPoiIds = []) {
|
||||||
|
if (!isProgressStorageEnabled()) return false;
|
||||||
|
const id = routeId(route);
|
||||||
|
if (!id) return false;
|
||||||
|
const routes = readProgressStore();
|
||||||
|
const timestamp = now();
|
||||||
|
routes[id] = {
|
||||||
|
routeId: id,
|
||||||
|
routeName: route?.name ?? '',
|
||||||
|
routeState: routeState === 'running' || routeState === 'paused' ? routeState : 'idle',
|
||||||
|
routeProgressIndex: Number.isInteger(routeProgressIndex) ? routeProgressIndex : null,
|
||||||
|
visitedPoiIds: numericIdSet(visitedPoiIds),
|
||||||
|
viewedPoiIds: numericIdSet(viewedPoiIds),
|
||||||
|
playedAudioPoiIds: numericIdSet(playedAudioPoiIds),
|
||||||
|
savedAt: timestamp,
|
||||||
|
updatedAt: timestamp
|
||||||
|
};
|
||||||
|
return writeProgressStore(routes);
|
||||||
}
|
}
|
||||||
|
|
||||||
function activateRoute(route) {
|
function activateRoute(route) {
|
||||||
if (!settings().progressStorage || route?.id == null) return null;
|
if (!isProgressStorageEnabled()) return null;
|
||||||
|
const id = routeId(route);
|
||||||
const store = progressStore();
|
if (!id) return null;
|
||||||
const key = String(route.id);
|
const routes = cleanupExpiredEntries().routes;
|
||||||
const record = routeRecord(route, store.routes[key]);
|
const saved = normalizeSavedRouteState(routes[id]);
|
||||||
store.currentRouteId = route.id;
|
return saved && !isExpired(saved) ? saved : null;
|
||||||
store.routes[key] = record;
|
|
||||||
persistProgressStore(store);
|
|
||||||
return record;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveRouteState(route, routeState, routeProgressIndex, visitedPoiIds, viewedPoiIds, playedAudioPoiIds) {
|
function cleanupExpiredEntries(progressCallback) {
|
||||||
if (route?.id == null) return null;
|
const referenceTime = now();
|
||||||
|
const routes = readProgressStore();
|
||||||
return updateRoute(route.id, record => ({
|
const entries = Object.entries(routes);
|
||||||
...record,
|
const kept = {};
|
||||||
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;
|
let removed = 0;
|
||||||
onProgress(removed, expiredKeys.length);
|
|
||||||
|
|
||||||
for (const key of expiredKeys) {
|
entries.forEach(([id, value], index) => {
|
||||||
delete store.routes[key];
|
const normalized = normalizeSavedRouteState(value);
|
||||||
if (String(store.currentRouteId) === key) store.currentRouteId = null;
|
if (normalized && !isExpired(normalized, referenceTime)) kept[id] = normalized;
|
||||||
removed += 1;
|
else removed += 1;
|
||||||
persistProgressStore(store);
|
reportCleanupProgress(progressCallback, index + 1, entries.length, removed);
|
||||||
onProgress(removed, expiredKeys.length);
|
});
|
||||||
await nextTurn();
|
|
||||||
|
if (removed > 0) writeProgressStore(kept);
|
||||||
|
return { removed, total: entries.length, routes: kept };
|
||||||
}
|
}
|
||||||
|
|
||||||
return { removed, total: expiredKeys.length };
|
function reportCleanupProgress(callback, processed, total, removed) {
|
||||||
|
if (typeof callback !== 'function') return;
|
||||||
|
callback({ processed, total, removed });
|
||||||
}
|
}
|
||||||
|
|
||||||
clearApplicationCookies();
|
async function cleanupExpiredProgress(progressCallback) {
|
||||||
|
return cleanupExpiredEntries(progressCallback);
|
||||||
|
}
|
||||||
|
|
||||||
ns.PrivacyStorage = Object.freeze({
|
ns.PrivacyStorage = Object.freeze({
|
||||||
SETTINGS_KEY,
|
PRIVACY_KEY,
|
||||||
PROGRESS_KEY,
|
PROGRESS_KEY,
|
||||||
MAX_PROGRESS_AGE_MS,
|
MAX_PROGRESS_AGE_MS,
|
||||||
cookiesEnabled: () => false,
|
|
||||||
clearApplicationCookies,
|
|
||||||
isProgressStorageEnabled: () => settings().progressStorage,
|
|
||||||
setProgressStorageEnabled,
|
|
||||||
cleanupExpired,
|
|
||||||
activateRoute,
|
activateRoute,
|
||||||
loadRoute,
|
cleanExpiredProgress: cleanupExpiredProgress,
|
||||||
|
cleanupExpiredProgress,
|
||||||
|
cookiesEnabled: () => false,
|
||||||
|
getPrivacySettings: readSettings,
|
||||||
|
isProgressStorageEnabled,
|
||||||
|
removeExpiredProgress: cleanupExpiredProgress,
|
||||||
|
removeExpiredRouteProgress: cleanupExpiredProgress,
|
||||||
saveRouteState,
|
saveRouteState,
|
||||||
markVisited: (routeId, poiId) => markPoi(routeId, 'visitedPoiIds', poiId),
|
setProgressStorageEnabled
|
||||||
markViewed: (routeId, poiId) => markPoi(routeId, 'viewedPoiIds', poiId),
|
|
||||||
markAudioPlayed: (routeId, poiId) => markPoi(routeId, 'playedAudioPoiIds', poiId),
|
|
||||||
clearProgress: () => removeItem(PROGRESS_KEY)
|
|
||||||
});
|
});
|
||||||
}(globalThis.Wegwichtel ??= {}));
|
}(globalThis.Wegwichtel));
|
||||||
|
|||||||
+17
-8
@@ -18,6 +18,7 @@ test('start screen exposes cookie status, optional local progress and deliberate
|
|||||||
assert.match(html, /Diese Anwendung setzt derzeit keine Cookies/);
|
assert.match(html, /Diese Anwendung setzt derzeit keine Cookies/);
|
||||||
assert.match(html, /class="cookie-toggle"[^>]*disabled/);
|
assert.match(html, /class="cookie-toggle"[^>]*disabled/);
|
||||||
assert.match(html, /class="progress-storage-toggle"/);
|
assert.match(html, /class="progress-storage-toggle"/);
|
||||||
|
assert.match(html, /role="switch"[^>]*aria-checked="false"/);
|
||||||
assert.match(html, /id="storage-cleanup-status"/);
|
assert.match(html, /id="storage-cleanup-status"/);
|
||||||
assert.match(html, /id="bootstrap-enter"[^>]*hidden/);
|
assert.match(html, /id="bootstrap-enter"[^>]*hidden/);
|
||||||
assert.match(loader, /Entferne alte Streckendaten aus dem Speicher \(\$\{removed\} \/ \$\{total\}\)/);
|
assert.match(loader, /Entferne alte Streckendaten aus dem Speicher \(\$\{removed\} \/ \$\{total\}\)/);
|
||||||
@@ -34,6 +35,7 @@ test('information button opens scrollable overlay pages for privacy, imprint and
|
|||||||
const ui = await read('public/js/legal-ui.js');
|
const ui = await read('public/js/legal-ui.js');
|
||||||
|
|
||||||
assert.match(html, /id="info-button"/);
|
assert.match(html, /id="info-button"/);
|
||||||
|
assert.match(html, /id="info-dropdown"/);
|
||||||
assert.match(html, /<dialog id="info-dialog"/);
|
assert.match(html, /<dialog id="info-dialog"/);
|
||||||
assert.match(html, /data-info-target="privacy"/);
|
assert.match(html, /data-info-target="privacy"/);
|
||||||
assert.match(html, /data-info-target="imprint"/);
|
assert.match(html, /data-info-target="imprint"/);
|
||||||
@@ -60,7 +62,6 @@ test('application persists current route, viewed stations and played audio only
|
|||||||
assert.match(app, /await initializeLocation\(context\)/);
|
assert.match(app, /await initializeLocation\(context\)/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
class FakeElement extends EventTarget {
|
class FakeElement extends EventTarget {
|
||||||
constructor(id = '') {
|
constructor(id = '') {
|
||||||
super();
|
super();
|
||||||
@@ -118,7 +119,8 @@ test('legal overlay handlers synchronize switches without advancing the start sc
|
|||||||
['info-menu', new FakeElement('info-menu')],
|
['info-menu', new FakeElement('info-menu')],
|
||||||
['info-back', new FakeElement('info-back')],
|
['info-back', new FakeElement('info-back')],
|
||||||
['info-close', new FakeElement('info-close')],
|
['info-close', new FakeElement('info-close')],
|
||||||
['info-button', new FakeElement('info-button')]
|
['info-button', new FakeElement('info-button')],
|
||||||
|
['info-dropdown', new FakeElement('info-dropdown')]
|
||||||
]);
|
]);
|
||||||
const content = new FakeElement();
|
const content = new FakeElement();
|
||||||
const privacyPage = new FakeElement();
|
const privacyPage = new FakeElement();
|
||||||
@@ -143,7 +145,8 @@ test('legal overlay handlers synchronize switches without advancing the start sc
|
|||||||
}
|
}
|
||||||
|
|
||||||
const document = new EventTarget();
|
const document = new EventTarget();
|
||||||
document.getElementById = id => elements.get(id);
|
document.readyState = 'complete';
|
||||||
|
document.getElementById = id => elements.get(id) || null;
|
||||||
document.querySelectorAll = selector => {
|
document.querySelectorAll = selector => {
|
||||||
if (selector === '.progress-storage-toggle') return progressToggles;
|
if (selector === '.progress-storage-toggle') return progressToggles;
|
||||||
if (selector === '.cookie-toggle') return cookieToggles;
|
if (selector === '.cookie-toggle') return cookieToggles;
|
||||||
@@ -177,22 +180,28 @@ test('legal overlay handlers synchronize switches without advancing the start sc
|
|||||||
vm.runInNewContext(source, context, { filename: sourcePath });
|
vm.runInNewContext(source, context, { filename: sourcePath });
|
||||||
|
|
||||||
elements.get('info-button').dispatchEvent(pointerEvent());
|
elements.get('info-button').dispatchEvent(pointerEvent());
|
||||||
assert.equal(elements.get('info-dialog').open, true);
|
assert.equal(elements.get('info-dropdown').hidden, false);
|
||||||
assert.equal(elements.get('info-menu').hidden, false);
|
assert.equal(elements.get('info-button').attributes.get('aria-expanded'), 'true');
|
||||||
|
assert.equal(elements.get('info-dialog').open, false);
|
||||||
|
|
||||||
targetButtons[0].dispatchEvent(pointerEvent());
|
targetButtons[0].dispatchEvent(pointerEvent());
|
||||||
|
assert.equal(elements.get('info-dialog').open, true);
|
||||||
|
assert.equal(elements.get('info-dropdown').hidden, true);
|
||||||
|
assert.equal(elements.get('info-menu').hidden, true);
|
||||||
|
assert.equal(elements.get('info-back').hidden, true);
|
||||||
assert.equal(privacyPage.hidden, false);
|
assert.equal(privacyPage.hidden, false);
|
||||||
assert.equal(elements.get('info-back').hidden, false);
|
|
||||||
assert.equal(elements.get('info-dialog-title').textContent, 'Datenschutzerklärung');
|
assert.equal(elements.get('info-dialog-title').textContent, 'Datenschutzerklärung');
|
||||||
|
|
||||||
progressToggles[0].checked = true;
|
progressToggles[0].checked = true;
|
||||||
progressToggles[0].dispatchEvent(new Event('change'));
|
progressToggles[0].dispatchEvent(new Event('change'));
|
||||||
assert.deepEqual(changes, [true]);
|
assert.deepEqual(changes, [true]);
|
||||||
|
assert.equal(progressToggles[0].checked, true);
|
||||||
assert.equal(progressToggles[1].checked, true);
|
assert.equal(progressToggles[1].checked, true);
|
||||||
|
assert.equal(progressToggles[0].attributes.get('aria-checked'), 'true');
|
||||||
|
assert.equal(progressToggles[1].attributes.get('aria-checked'), 'true');
|
||||||
assert.equal(cookieToggles[0].checked, false);
|
assert.equal(cookieToggles[0].checked, false);
|
||||||
|
assert.equal(cookieToggles[0].attributes.get('aria-checked'), 'false');
|
||||||
|
|
||||||
elements.get('info-back').dispatchEvent(pointerEvent());
|
|
||||||
assert.equal(elements.get('info-menu').hidden, false);
|
|
||||||
elements.get('info-close').dispatchEvent(pointerEvent());
|
elements.get('info-close').dispatchEvent(pointerEvent());
|
||||||
assert.equal(elements.get('info-dialog').open, false);
|
assert.equal(elements.get('info-dialog').open, false);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user