diff --git a/examples/presentation-route.gpx b/examples/presentation-route.gpx new file mode 100644 index 0000000..cf570b7 --- /dev/null +++ b/examples/presentation-route.gpx @@ -0,0 +1,29 @@ + + + + Wegwichtel Demo – Rundweg + Beispielstrecke für Präsentations- und Funktionstests. Keine reale Wegführung. + + + Wegwichtel Demo – Rundweg + + 328 + 330 + 332 + 334 + 337 + 339 + 338 + 336 + 333 + 331 + 329 + 327 + 327 + 328 + + + diff --git a/examples/presentation-route.json b/examples/presentation-route.json new file mode 100644 index 0000000..4f44e92 --- /dev/null +++ b/examples/presentation-route.json @@ -0,0 +1,38 @@ +{ + "name": "Wegwichtel Demo – Rundweg", + "description": "Beispielstrecke für Präsentations- und Funktionstests. Keine reale Wegführung.", + "pois": [ + { + "title": "Demo-Start", + "description": "Hier beginnt die Präsentationsstrecke.", + "lat": 50.57988, + "lon": 10.43142, + "triggerRadiusM": 25, + "sequence": 0 + }, + { + "title": "Wiesenblick", + "description": "Ein Beispiel-Trackpunkt für Texte, Bilder und Audio.", + "lat": 50.58109, + "lon": 10.43384, + "triggerRadiusM": 25, + "sequence": 1 + }, + { + "title": "Waldrand", + "description": "Zweiter Beispiel-Trackpunkt der Demo-Strecke.", + "lat": 50.58082, + "lon": 10.43546, + "triggerRadiusM": 25, + "sequence": 2 + }, + { + "title": "Demo-Ziel", + "description": "Hier endet die Präsentationsstrecke.", + "lat": 50.57972, + "lon": 10.43231, + "triggerRadiusM": 25, + "sequence": 3 + } + ] +} diff --git a/package.json b/package.json index 1e2a59a..96524f5 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "file-type": "22.0.1", "jquery": "4.0.0", "jquery-ui": "1.14.2", - "multer": "^2.4.0" + "multer": "2.2.0" }, "devDependencies": { "c8": "11.0.0" diff --git a/public/css/bootstrap.css b/public/css/bootstrap.css index 2681e39..1b3d067 100644 --- a/public/css/bootstrap.css +++ b/public/css/bootstrap.css @@ -1,14 +1,16 @@ :root { --bootstrap-bg: #f3f7ee; --bootstrap-card-bg: #ffffff; - --bootstrap-text: #1c2b20; - --bootstrap-muted: #53605a; - --bootstrap-accent: #315d3a; - --bootstrap-accent-strong: #25462c; - --bootstrap-border: #d6dfd3; - --bootstrap-error: #8b1e1e; - --bootstrap-shadow: 0 1rem 3rem rgb(29 52 36 / 18%); + --bootstrap-text: #10291b; + --bootstrap-muted: #67726c; + --bootstrap-accent: #286438; + --bootstrap-accent-strong: #194d2b; + --bootstrap-border: #dce6dc; + --bootstrap-error: #a32020; + --bootstrap-shadow: 0 1.2rem 3.6rem rgb(29 52 36 / 16%); + --bootstrap-fade-duration: 180ms; } + #bootstrap-screen { position: fixed; inset: 0; @@ -18,7 +20,7 @@ justify-items: center; min-height: 100svh; box-sizing: border-box; - 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)); + padding: max(5.25rem, 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)); overflow-x: hidden; overflow-y: auto; overscroll-behavior-y: contain; @@ -26,127 +28,315 @@ -webkit-overflow-scrolling: touch; 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); + radial-gradient(circle at 12% 6%, rgb(126 183 101 / 24%), transparent 27rem), + radial-gradient(circle at 88% 94%, rgb(235 196 104 / 18%), transparent 25rem), + linear-gradient(180deg, #f7faf4 0%, var(--bootstrap-bg) 100%); } + #bootstrap-screen[hidden] { display: none; } #bootstrap-screen > section { - width: min(100%, 34rem); + width: min(100%, 42rem); box-sizing: border-box; - padding: clamp(1.25rem, 5vw, 2rem); - border: 1px solid var(--bootstrap-border); - border-radius: 1.5rem; + padding: clamp(1.1rem, 4vw, 1.8rem); + border: 1px solid rgb(214 224 214 / 80%); + border-radius: clamp(1.4rem, 5vw, 2rem); box-shadow: var(--bootstrap-shadow); - background: var(--bootstrap-card-bg); + background: rgb(255 255 255 / 96%); } -#bootstrap-screen [aria-hidden="true"] { - display: inline-block; - margin-bottom: .25rem; - font-size: 2rem; +.bootstrap-brand { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: center; + gap: 1rem; + margin-bottom: 1rem; } + +.bootstrap-logo { + width: clamp(4.25rem, 15vw, 5.75rem); + height: auto; + filter: drop-shadow(0 .35rem .6rem rgb(49 93 58 / 12%)); +} + #bootstrap-screen h1 { - margin: 0 0 .5rem; - font-size: clamp(1.8rem, 8vw, 2.5rem); - line-height: 1.05; + margin: 0 0 .3rem; + color: #0d2b1b; + font-size: clamp(2rem, 8vw, 3rem); + line-height: 1; + letter-spacing: -.035em; } -#bootstrap-screen p { - margin: .65rem 0; +#bootstrap-screen .bootstrap-brand p { + margin: 0; color: var(--bootstrap-muted); + font-size: clamp(.98rem, 3.8vw, 1.18rem); + line-height: 1.35; +} + +.bootstrap-hero { + overflow: hidden; + margin: .35rem 0 1rem; + border-radius: 1.5rem; + background: #eef7f1; +} + +.bootstrap-hero svg { + display: block; + width: 100%; + height: auto; + max-height: 16rem; +} + +.bootstrap-action-slot { + display: grid; + margin: 1rem 0; +} + +.bootstrap-action-slot > * { + grid-area: 1 / 1; +} + +.bootstrap-action-status, +#bootstrap-enter.ui-button, +.bootstrap-enter { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + min-height: 4rem; + box-sizing: border-box; + margin: 0; + padding: .8rem 1.25rem; + border: 0; + border-radius: 999px; + font: inherit; + font-size: clamp(1.05rem, 4.4vw, 1.28rem); + font-weight: 750; + letter-spacing: .005em; +} + +.bootstrap-action-status { + gap: .75rem; + color: #eff8f0; + background: linear-gradient(135deg, #387347, var(--bootstrap-accent)); + box-shadow: 0 .45rem 1.1rem rgb(40 100 56 / 18%); + opacity: 1; + transform: translateY(0); + transition: opacity var(--bootstrap-fade-duration) ease, transform var(--bootstrap-fade-duration) ease; +} + +.bootstrap-action-status.is-leaving { + opacity: 0; + transform: translateY(-.2rem); + pointer-events: none; +} + +.bootstrap-loading-dot { + flex: 0 0 auto; + width: .65rem; + height: .65rem; + border-radius: 50%; + background: #d9f0de; + box-shadow: 0 0 0 0 rgb(217 240 222 / 50%); + animation: bootstrap-pulse 1.35s ease-out infinite; +} + +.bootstrap-status-text { + opacity: 1; + transition: opacity var(--bootstrap-fade-duration) ease; +} + +.bootstrap-status-text.is-fading { + opacity: 0; +} + +#bootstrap-enter.ui-button, +.bootstrap-enter { + gap: .75rem; + color: #fff; + background: linear-gradient(135deg, #2d743d, var(--bootstrap-accent-strong)); + box-shadow: 0 .55rem 1.25rem rgb(25 77 43 / 24%); + cursor: pointer; + opacity: 0; + transform: translateY(.2rem); + transition: opacity var(--bootstrap-fade-duration) ease, transform var(--bootstrap-fade-duration) ease, background-color .15s ease; +} + +#bootstrap-enter.is-visible { + opacity: 1; + transform: translateY(0); +} + +#bootstrap-enter svg { + width: 1.5rem; + height: 1.5rem; + margin-left: auto; + fill: none; + stroke: currentColor; + stroke-width: 2.5; + stroke-linecap: round; + stroke-linejoin: round; +} + +#bootstrap-enter:hover, +#bootstrap-enter:focus-visible { + background: #173f24; +} + +.bootstrap-features { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0; + margin: 1rem 0; + padding: .8rem .4rem; + border: 1px solid #e2ebe2; + border-radius: 1.35rem; + background: linear-gradient(135deg, #f4f9f4, #fbfcf8); +} + +.bootstrap-features > div { + display: grid; + place-items: center; + gap: .4rem; + min-width: 0; + padding: .3rem .45rem; + text-align: center; +} + +.bootstrap-features > div + div { + border-left: 1px solid #dce6dc; +} + +.bootstrap-features svg { + width: 2rem; + height: 2rem; + fill: none; + stroke: var(--bootstrap-accent); + stroke-width: 2.2; + stroke-linecap: round; + stroke-linejoin: round; +} + +.bootstrap-features > div:nth-child(2) svg { + stroke: #7351b4; +} + +.bootstrap-features > div:nth-child(3) svg { + fill: #e96c2a; + stroke: #e96c2a; +} + +.bootstrap-features span { + color: #24402c; + font-size: clamp(.78rem, 3vw, .95rem); + font-weight: 650; + line-height: 1.2; } #bootstrap-screen .privacy-summary { display: grid; - gap: .75rem; - margin: 1rem 0; + gap: .65rem; + margin: .85rem 0 0; padding: .85rem; border: 1px solid var(--bootstrap-border); - border-radius: 1rem; + border-radius: 1.15rem; background: #f9fbf6; } #bootstrap-screen .privacy-summary > p { margin: 0; + color: var(--bootstrap-muted); + font-size: .92rem; + line-height: 1.4; } + #bootstrap-screen .privacy-summary > .switch-row.switch-disabled { display: none; } -#bootstrap-screen progress { - width: 100%; - 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 { - display: grid; - gap: .35rem; - margin: 1rem 0; - padding-left: 1.4rem; +.storage-cleanup-status { + min-height: 1.2rem; + margin: .75rem 0 0; color: var(--bootstrap-muted); + font-size: .86rem; + text-align: center; } -#bootstrap-steps li[data-state="done"] { - color: var(--bootstrap-accent-strong); -} - -#bootstrap-steps li[data-state="active"] { - font-weight: 700; - color: var(--bootstrap-text); -} - -#bootstrap-error { +.bootstrap-error { + margin: .65rem 0 0; + padding: .75rem .9rem; + border-radius: .9rem; color: var(--bootstrap-error); + background: #fff0f0; font-weight: 700; } -#bootstrap-retry, -#bootstrap-enter { + +.bootstrap-retry, +#bootstrap-retry.ui-button { width: 100%; min-height: 3rem; - margin-top: .75rem; - border: 0; + box-sizing: border-box; + margin-top: .65rem; + border: 1px solid #d8abab; border-radius: 999px; - color: #ffffff; - background: var(--bootstrap-accent); + color: #861d1d; + background: #fff; 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, +.bootstrap-state-model { + display: none !important; } -.bootstrap-ready { - font-weight: 700; - color: var(--bootstrap-accent-strong); +@keyframes bootstrap-pulse { + 0% { box-shadow: 0 0 0 0 rgb(217 240 222 / 55%); } + 65%, 100% { box-shadow: 0 0 0 .7rem rgb(217 240 222 / 0%); } } -.storage-cleanup-status { - min-height: 1.25rem; - font-size: .95rem; +@media (max-width: 34rem) { + #bootstrap-screen { + padding-right: max(.75rem, env(safe-area-inset-right)); + padding-left: max(.75rem, env(safe-area-inset-left)); + } + + #bootstrap-screen > section { + padding: 1rem; + } + + .bootstrap-brand { + gap: .75rem; + } + + .bootstrap-hero svg { + max-height: 12.5rem; + } + + .bootstrap-features { + padding: .65rem .25rem; + } +} + +@media (max-height: 700px) { + .bootstrap-hero svg { + max-height: 10rem; + } +} + +@media (prefers-reduced-motion: reduce) { + .bootstrap-loading-dot { + animation: none; + } + + .bootstrap-status-text, + .bootstrap-action-status, + #bootstrap-enter.ui-button, + .bootstrap-enter { + transition: none; + } } diff --git a/public/index.html b/public/index.html index e05a4cb..1233a29 100644 --- a/public/index.html +++ b/public/index.html @@ -22,12 +22,77 @@
-
- -

Wegwichtel

-

Der Wegwichtel bereitet Routen und Medien vor.

+
+
+ +
+

Wegwichtel

+

Der Wegwichtel bereitet Routen und Medien vor.

+
+
+ + + +
+ + + Wegwichtel wird vorbereitet … + + +
+ + + + + +
+
+ + Routen
entdecken
+
+
+ + Spannende
Geschichten
+
+
+ + Kindgerechte
Erlebnisse
+
+
+
-

Cookies: Diese Anwendung setzt derzeit keine Cookies und verwendet weder Werbung noch Tracking.

+

Datenschutzfreundlich: Keine Werbung, kein Tracking und derzeit keine Cookies.

- -
    + + + - - - -
diff --git a/public/js/bootstrap-loader.js b/public/js/bootstrap-loader.js index be33404..244d4d7 100644 --- a/public/js/bootstrap-loader.js +++ b/public/js/bootstrap-loader.js @@ -3,6 +3,14 @@ const versions = Object.freeze({ app: '0.12.14', jquery: '4.0.0', jqueryUi: '1.14.2' }); const steps = ['jquery', 'jquery-ui', 'modules', 'server', 'routes', 'location']; + const stepLabels = Object.freeze({ + jquery: 'jQuery laden …', + 'jquery-ui': 'jQuery UI laden …', + modules: 'Anwendungsmodule laden …', + server: 'Server prüfen …', + routes: 'Routen laden …', + location: 'Standort ermitteln …' + }); const screen = document.getElementById('bootstrap-screen'); const progress = document.getElementById('bootstrap-progress'); const errorBox = document.getElementById('bootstrap-error'); @@ -10,16 +18,54 @@ const enter = document.getElementById('bootstrap-enter'); const readyText = document.getElementById('bootstrap-ready'); const cleanupStatus = document.getElementById('storage-cleanup-status'); + const actionStatus = document.getElementById('bootstrap-action-status'); + const statusText = document.getElementById('bootstrap-status-text'); let ready = false; + let statusTransition = 0; const versioned = path => `${path}${path.includes('?') ? '&' : '?'}v=${encodeURIComponent(versions.app)}`; + function setStatusText(text, immediate = false) { + if (!statusText) return; + statusTransition += 1; + const transition = statusTransition; + if (immediate) { + statusText.textContent = text; + statusText.classList.remove('is-fading'); + return; + } + statusText.classList.add('is-fading'); + globalThis.setTimeout(() => { + if (transition !== statusTransition) return; + statusText.textContent = text; + statusText.classList.remove('is-fading'); + }, 180); + } + + function nextPendingStep() { + return steps.find(step => !document.querySelector(`[data-step="${step}"]`)?.classList.contains('done')); + } + + function announceStep(step) { + if (stepLabels[step]) setStatusText(stepLabels[step]); + } + function mark(step, state) { const item = document.querySelector(`[data-step="${step}"]`); if (!item) return; item.classList.remove('done', 'failed'); item.classList.add(state); + item.dataset.state = state; progress.value = document.querySelectorAll('#bootstrap-steps .done').length; + + if (state === 'failed') { + setStatusText(`Fehler bei: ${stepLabels[step] || step}`); + return; + } + if (state === 'done') { + const next = nextPendingStep(); + if (next) announceStep(next); + } } function loadScript(src, test) { @@ -78,7 +124,14 @@ screen.setAttribute('aria-busy', 'false'); screen.classList.add('ready'); readyText.hidden = false; + + setStatusText('Vorbereitung abgeschlossen.'); + actionStatus?.classList.add('is-leaving'); enter.hidden = false; + globalThis.requestAnimationFrame(() => enter.classList.add('is-visible')); + globalThis.setTimeout(() => { + if (actionStatus) actionStatus.hidden = true; + }, 220); enter.focus(); } @@ -88,11 +141,18 @@ screen.classList.remove('ready'); readyText.hidden = true; enter.hidden = true; + enter.classList.remove('is-visible'); errorBox.hidden = true; retry.hidden = true; + if (actionStatus) { + actionStatus.hidden = false; + actionStatus.classList.remove('is-leaving'); + } try { + setStatusText('Lokalen Speicher prüfen …', true); await cleanupOldProgress(); + announceStep('jquery'); await loadScript( `vendor/jquery/jquery-${versions.jquery}.min.js`, @@ -116,7 +176,7 @@ showReadyState(); } catch (error) { console.error(error); - const current = steps.find(step => !document.querySelector(`[data-step="${step}"]`)?.classList.contains('done')); + const current = nextPendingStep(); if (current) mark(current, 'failed'); errorBox.textContent = error.message; errorBox.hidden = false; diff --git a/scripts/upload-presentation-route.sh b/scripts/upload-presentation-route.sh new file mode 100644 index 0000000..4289d77 --- /dev/null +++ b/scripts/upload-presentation-route.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +set -euo pipefail + +BASE_URL="${1:-https://wegwichtel.de/api}" +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)" +GPX_FILE="$ROOT_DIR/examples/presentation-route.gpx" +JSON_FILE="$ROOT_DIR/examples/presentation-route.json" + +for command in curl python3; do + command -v "$command" >/dev/null 2>&1 || { + echo "Fehler: $command wird benötigt." >&2 + exit 1 + } +done + +read_json() { + python3 - "$JSON_FILE" "$1" <<'PY' +import json +import sys +with open(sys.argv[1], encoding='utf-8') as handle: + data = json.load(handle) +value = data +for part in sys.argv[2].split('.'): + value = value[int(part)] if part.isdigit() else value[part] +print(value) +PY +} + +NAME="$(read_json name)" +DESCRIPTION="$(read_json description)" + +echo "Lege Beispielstrecke über $BASE_URL an …" +ROUTE_RESPONSE="$(curl --fail --silent --show-error \ + -X POST "$BASE_URL/routes" \ + -F "name=$NAME" \ + -F "description=$DESCRIPTION" \ + -F "gpx=@$GPX_FILE;type=application/gpx+xml")" + +ROUTE_ID="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])' <<<"$ROUTE_RESPONSE")" + +rollback() { + echo "Upload fehlgeschlagen. Entferne die unvollständige Beispielstrecke $ROUTE_ID …" >&2 + curl --fail --silent --show-error -X DELETE "$BASE_URL/routes/$ROUTE_ID" >/dev/null || true +} +trap rollback ERR + +POI_COUNT="$(python3 -c 'import json,sys; print(len(json.load(open(sys.argv[1], encoding="utf-8"))["pois"]))' "$JSON_FILE")" +for ((index=0; index/dev/null + echo " Trackpunkt $((index + 1))/$POI_COUNT: $TITLE" +done + +trap - ERR +cat < { + const html = await read('public/index.html'); + + assert.match(html, /class="bootstrap-brand"/); + assert.match(html, /class="bootstrap-hero"/); + assert.match(html, /class="bootstrap-features"/); + assert.match(html, /id="bootstrap-action-status"/); + assert.match(html, /id="bootstrap-status-text"/); + assert.match(html, /id="bootstrap-enter"[^>]*hidden/); + assert.match(html, />Los geht’s]*hidden/); + assert.match(html, /id="bootstrap-progress"[^>]*hidden/); +}); + +test('bootstrap status text fades between loading steps and is replaced by the start button', async () => { + const css = await read('public/css/bootstrap.css'); + const loader = await read('public/js/bootstrap-loader.js'); + + const statusTextRule = css.match(/\.bootstrap-status-text\s*\{([\s\S]*?)\}/)?.[1] ?? ''; + const fadingRule = css.match(/\.bootstrap-status-text\.is-fading\s*\{([\s\S]*?)\}/)?.[1] ?? ''; + + assert.match(statusTextRule, /transition:\s*opacity/); + assert.match(fadingRule, /opacity:\s*0/); + assert.match(loader, /statusText\.classList\.add\('is-fading'\)/); + assert.match(loader, /statusText\.classList\.remove\('is-fading'\)/); + assert.match(loader, /server:\s*'Server prüfen …'/); + assert.match(loader, /routes:\s*'Routen laden …'/); + assert.match(loader, /location:\s*'Standort ermitteln …'/); + assert.match(loader, /actionStatus\?\.classList\.add\('is-leaving'\)/); + assert.match(loader, /enter\.classList\.add\('is-visible'\)/); +}); diff --git a/test/presentation-route.test.js b/test/presentation-route.test.js new file mode 100644 index 0000000..5530a30 --- /dev/null +++ b/test/presentation-route.test.js @@ -0,0 +1,22 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import test from 'node:test'; + +const root = path.resolve(import.meta.dirname, '..'); + +async function read(relativePath) { + return fs.readFile(path.join(root, relativePath), 'utf8'); +} + +test('presentation example uses real GPX track points and the existing upload API', async () => { + const gpx = await read('examples/presentation-route.gpx'); + const metadata = JSON.parse(await read('examples/presentation-route.json')); + const script = await read('scripts/upload-presentation-route.sh'); + + assert.ok((gpx.match(/= 8); + assert.doesNotMatch(gpx, /= 3); + assert.match(script, /POST "\$BASE_URL\/routes"/); + assert.match(script, /POST "\$BASE_URL\/routes\/\$ROUTE_ID\/pois"/); +});