Companion-App: zur deploybaren PWA ausgebaut (statisch)
- 04_Tablet-Quiz/prototype -> app/ (deploybarer Stand). - PWA: manifest.webmanifest + sw.js (Offline-App-Shell) + icon.svg, im <head> eingebunden, Service-Worker-Registrierung. - Debrief-Export als Datei-Download (Markdown UND JSON) ergaenzt. - DEPLOY.md: Anleitung fuer statisches Hosting (nginx/Caddy, HTTPS, Verifikation). - README: Umsetzungsstand + MVP-Haken aktualisiert. - .claude/launch.json (lokale Preview), settings.local.json ge-gitignored. Verifiziert: 40 Stationen / 3 Gates / 45 Quizfragen, JS+SW-Syntax + Manifest valide, alle Assets via http 200. (Inhalte noch in index.html eingebettet; YAML-Pipeline = naechster Schritt.) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
ab61222cf2
commit
7f0e847561
8 changed files with 196 additions and 9 deletions
11
.claude/launch.json
Normal file
11
.claude/launch.json
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
{
|
||||||
|
"version": "0.0.1",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "slc-companion",
|
||||||
|
"runtimeExecutable": "python",
|
||||||
|
"runtimeArgs": ["-m", "http.server", "8099", "--bind", "127.0.0.1", "--directory", "04_Tablet-Quiz/app"],
|
||||||
|
"port": 8099
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -4,3 +4,4 @@ _*.png
|
||||||
*.stl
|
*.stl
|
||||||
*.bak
|
*.bak
|
||||||
*.tmp
|
*.tmp
|
||||||
|
.claude/settings.local.json
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,14 @@
|
||||||
# Tablet-Quiz — Begleit-App (Teilprojekt)
|
# Tablet-Quiz — Begleit-App (Teilprojekt)
|
||||||
|
|
||||||
**Status:** Konzept · **Typ:** eigenständiges Software-Teilprojekt des SLC-Workshops
|
**Status:** App lauffähig (PWA) · Deploy vorbereitet · **Typ:** eigenständiges Software-Teilprojekt des SLC-Workshops
|
||||||
|
|
||||||
|
> **Umsetzungsstand:** Die App liegt unter [`app/`](app/) als statische **PWA**
|
||||||
|
> (offline-/kioskfähig). Sie führt den kompletten Flow durch (Action Card →
|
||||||
|
> Startpunkt → optionale Tour → Station: Diskussion/Quiz/Auflösung → Debrief mit
|
||||||
|
> **Markdown-/JSON-Export**). Inhalte (40 Stationen, 45 Quizfragen, 6 Use-Cases)
|
||||||
|
> sind derzeit in `app/index.html` eingebettet. **Deployment:** statisch, siehe
|
||||||
|
> [`app/DEPLOY.md`](app/DEPLOY.md). **Lokal testen:** `python -m http.server 8099
|
||||||
|
> --directory 04_Tablet-Quiz/app` (oder Preview-Config `.claude/launch.json`).
|
||||||
|
|
||||||
Das Tablet-Quiz ist der **digitale Begleiter** des Tabletops — kein Ersatz fürs
|
Das Tablet-Quiz ist der **digitale Begleiter** des Tabletops — kein Ersatz fürs
|
||||||
Brett. Es ist der **erklärende Gegenpart** zu den Pucks: Die Pucks tragen nur die
|
Brett. Es ist der **erklärende Gegenpart** zu den Pucks: Die Pucks tragen nur die
|
||||||
|
|
@ -65,12 +73,13 @@ steht, sondern in der App liegt.
|
||||||
|
|
||||||
## 5. Funktionsumfang (MVP)
|
## 5. Funktionsumfang (MVP)
|
||||||
|
|
||||||
- [ ] `questions.json` + Stations-Inhalte aus YAMLs generieren (Build-Skript).
|
- [x] Stationsführung: linearer Durchlauf mit „Nächste Station" + Fortschritt/Phasen-Farben.
|
||||||
- [ ] Stationsführung: linearer Durchlauf mit „Nächste Station" + Fortschritt/Phasen-Farben.
|
- [x] Fragetypen 1–3 (vermittelndes Quiz).
|
||||||
- [ ] Fragetypen 1–3 (vermittelndes Quiz).
|
- [x] „Auflösen"-Mechanik (Antwort erst auf Klick) **+ ausführliche Stationsauflösung** (Erklärung/RACI/Artefakt) nach dem Quiz.
|
||||||
- [ ] „Auflösen"-Mechanik (Antwort erst auf Klick) **+ ausführliche Stationsauflösung** (Erklärung/RACI/Artefakt) nach dem Quiz.
|
- [x] „Unklar"-Markierung je Aktivität.
|
||||||
- [ ] „Unklar"-Markierung je Aktivität.
|
- [x] Debrief-Export (Markdown **und** JSON, lokaler Download).
|
||||||
- [ ] Debrief-Export (Markdown/JSON, lokal).
|
- [x] PWA / offline lauffähig (Manifest + Service Worker).
|
||||||
|
- [ ] `questions.json` + Stations-Inhalte aus YAMLs generieren (Build-Skript) — Inhalte aktuell in `app/index.html` eingebettet (braucht Blueprint-Repo-Zugriff).
|
||||||
|
|
||||||
### Später (Ausbau)
|
### Später (Ausbau)
|
||||||
- Gate-Fragen mit Rollen-Check (Typ 4–5).
|
- Gate-Fragen mit Rollen-Check (Typ 4–5).
|
||||||
|
|
|
||||||
66
04_Tablet-Quiz/app/DEPLOY.md
Normal file
66
04_Tablet-Quiz/app/DEPLOY.md
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
# Deployment — SLC-Workshop Companion (App)
|
||||||
|
|
||||||
|
**Auftrag für die Server-Claude / Ops:** Diese App **statisch** ausliefern. Kein
|
||||||
|
Build-Schritt, kein Backend, keine Secrets, keine Datenbank.
|
||||||
|
|
||||||
|
## Was das ist
|
||||||
|
- Eine **statische Single-Page-PWA**. Alle Inhalte (Stationen, Quiz, Use-Cases)
|
||||||
|
sind in `index.html` eingebettet — keine Laufzeit-API nötig.
|
||||||
|
- Dateien im Ordner `04_Tablet-Quiz/app/`:
|
||||||
|
- `index.html` — die App
|
||||||
|
- `manifest.webmanifest` — PWA-Manifest
|
||||||
|
- `sw.js` — Service Worker (Offline-Cache der App-Shell)
|
||||||
|
- `icon.svg` — App-Icon
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
Den Ordner `04_Tablet-Quiz/app/` als **statisches Web-Root** über den vorhandenen
|
||||||
|
Webserver bereitstellen, erreichbar per **HTTPS** (oder localhost).
|
||||||
|
|
||||||
|
> **Wichtig:** Der Service Worker (Offline/Installierbar) läuft **nur über HTTPS
|
||||||
|
> oder localhost**. Über reines `http://` ohne TLS registriert er sich nicht —
|
||||||
|
> die App funktioniert dann trotzdem, nur ohne Offline-Cache.
|
||||||
|
|
||||||
|
## Schritte
|
||||||
|
1. Repo auf dem Server bereitstellen/aktualisieren:
|
||||||
|
`git clone https://git.1789.cloud/patrick/SLC_Game.git` (bzw. `git pull`).
|
||||||
|
2. Den Ordner `SLC_Game/04_Tablet-Quiz/app/` als statisches Root einbinden.
|
||||||
|
|
||||||
|
### Beispiel nginx
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
server_name slc.example.intern; # anpassen
|
||||||
|
# ssl_certificate ... ; ssl_certificate_key ... ;
|
||||||
|
root /srv/SLC_Game/04_Tablet-Quiz/app; # Pfad anpassen
|
||||||
|
index index.html;
|
||||||
|
location = /sw.js { add_header Cache-Control "no-cache"; } # SW immer frisch
|
||||||
|
location / { try_files $uri $uri/ /index.html; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Beispiel Caddy (TLS automatisch)
|
||||||
|
```caddy
|
||||||
|
slc.example.intern {
|
||||||
|
root * /srv/SLC_Game/04_Tablet-Quiz/app
|
||||||
|
file_server
|
||||||
|
header /sw.js Cache-Control "no-cache"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verifikation nach dem Deploy
|
||||||
|
1. URL öffnen → Startbildschirm „SLC Companion · Schritt 1 · Action Card".
|
||||||
|
2. DevTools → Application → **Service Workers**: `sw.js` ist *activated*.
|
||||||
|
3. Flugmodus/Offline → Seite neu laden → App lädt weiterhin (Offline-Cache).
|
||||||
|
4. Einen Durchlauf spielen → „Debrief" → **↓ Markdown / ↓ JSON** lädt eine Datei.
|
||||||
|
|
||||||
|
## Updates
|
||||||
|
- Bei neuem Stand: `git pull`. Wenn sich App-Assets geändert haben, in `sw.js`
|
||||||
|
die Konstante `CACHE` hochzählen (z. B. `slc-companion-v1` → `-v2`), damit der
|
||||||
|
Service Worker den Cache erneuert.
|
||||||
|
|
||||||
|
## Noch offen (nicht Teil dieses Deploys)
|
||||||
|
- **YAML→Inhaltspipeline:** Inhalte sind aktuell in `index.html` eingebettet.
|
||||||
|
Später aus den Blueprint-`service-lifecycle_*.yaml` generieren (braucht Zugriff
|
||||||
|
aufs Blueprint-Repo). Bis dahin werden Inhalte direkt in `index.html` gepflegt.
|
||||||
|
- **Companion-Chatbot** (optionaler Nachschlage-Bot) — siehe `../README.md` §8;
|
||||||
|
*braucht* ein LLM-Backend und ist daher **nicht** Teil der statischen App.
|
||||||
12
04_Tablet-Quiz/app/icon.svg
Normal file
12
04_Tablet-Quiz/app/icon.svg
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||||
|
<!-- maskable: vollflaechiger Hintergrund, Inhalt im sicheren Mittelbereich -->
|
||||||
|
<rect width="512" height="512" fill="#1d2430"/>
|
||||||
|
<!-- Bahn-Linie -->
|
||||||
|
<line x1="150" y1="256" x2="362" y2="256" stroke="#3a4658" stroke-width="10" stroke-linecap="round"/>
|
||||||
|
<!-- 5 Phasen-Pucks (blau/orange/gruen/teal/lila) -->
|
||||||
|
<circle cx="160" cy="256" r="24" fill="#2F80C9"/>
|
||||||
|
<circle cx="208" cy="256" r="24" fill="#E8893B"/>
|
||||||
|
<circle cx="256" cy="256" r="24" fill="#5BAE5B"/>
|
||||||
|
<circle cx="304" cy="256" r="24" fill="#3FB5B5"/>
|
||||||
|
<circle cx="352" cy="256" r="24" fill="#8E63B5"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 669 B |
|
|
@ -3,7 +3,12 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<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" />
|
||||||
<title>SLC-Workshop — Companion-App (Prototyp)</title>
|
<title>SLC-Workshop — Companion-App</title>
|
||||||
|
<meta name="description" content="Begleit-App zum SLC-Workshop-Tabletop: führt durch die Stationen, vermittelndes Quiz, Auflösung und Debrief. Offline lauffähig." />
|
||||||
|
<meta name="theme-color" content="#1d2430" />
|
||||||
|
<link rel="manifest" href="manifest.webmanifest" />
|
||||||
|
<link rel="apple-touch-icon" href="icon.svg" />
|
||||||
|
<link rel="icon" href="icon.svg" type="image/svg+xml" />
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--bg: #f4f5f7;
|
--bg: #f4f5f7;
|
||||||
|
|
@ -160,7 +165,7 @@
|
||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
<div class="brand">SLC <b>Companion</b></div>
|
<div class="brand">SLC <b>Companion</b></div>
|
||||||
<span class="tag">Prototyp · v0.5</span>
|
<span class="tag">v0.5</span>
|
||||||
<div id="cardBadge" class="cardBadge"></div>
|
<div id="cardBadge" class="cardBadge"></div>
|
||||||
<div class="spacer"></div>
|
<div class="spacer"></div>
|
||||||
<button class="ghost" id="resetBtn" title="Neue Action Card / Durchlauf zurücksetzen">Neu starten</button>
|
<button class="ghost" id="resetBtn" title="Neue Action Card / Durchlauf zurücksetzen">Neu starten</button>
|
||||||
|
|
@ -181,6 +186,8 @@
|
||||||
<pre class="export" id="debriefText"></pre>
|
<pre class="export" id="debriefText"></pre>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="ghost" id="copyBtn">In Zwischenablage</button>
|
<button class="ghost" id="copyBtn">In Zwischenablage</button>
|
||||||
|
<button class="ghost" id="dlMd">↓ Markdown</button>
|
||||||
|
<button class="ghost" id="dlJson">↓ JSON</button>
|
||||||
<div class="spacer"></div>
|
<div class="spacer"></div>
|
||||||
<button class="primary" id="closeDebrief">Schließen</button>
|
<button class="primary" id="closeDebrief">Schließen</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1157,6 +1164,14 @@ function quizScore(){
|
||||||
}));
|
}));
|
||||||
return {correct,total};
|
return {correct,total};
|
||||||
}
|
}
|
||||||
|
let DEBRIEF = { md:"", json:null };
|
||||||
|
function download(name, text, mime){
|
||||||
|
const blob = new Blob([text], {type:mime});
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url; a.download = name; document.body.appendChild(a); a.click();
|
||||||
|
a.remove(); setTimeout(()=>URL.revokeObjectURL(url), 1000);
|
||||||
|
}
|
||||||
function openDebrief(){
|
function openDebrief(){
|
||||||
const {correct,total} = quizScore();
|
const {correct,total} = quizScore();
|
||||||
const doneN = Object.keys(S.done).length;
|
const doneN = Object.keys(S.done).length;
|
||||||
|
|
@ -1179,6 +1194,24 @@ function openDebrief(){
|
||||||
if(p===undefined) return;
|
if(p===undefined) return;
|
||||||
md += `- [${p===q.richtig?"✓":"✗"}] ${st.id}: ${q.frage} → „${q.optionen[p]}"\n`;
|
md += `- [${p===q.richtig?"✓":"✗"}] ${st.id}: ${q.frage} → „${q.optionen[p]}"\n`;
|
||||||
}));
|
}));
|
||||||
|
const stamp = new Date().toISOString().slice(0,16).replace("T"," ");
|
||||||
|
const json = {
|
||||||
|
erzeugt: stamp,
|
||||||
|
service: USE_CASES[S.service].service,
|
||||||
|
changeTyp: CHANGE_TYPES[S.change],
|
||||||
|
ausloeser: USE_CASES[S.service].changes[S.change],
|
||||||
|
stationenBearbeitet: doneN,
|
||||||
|
stationenGesamt: STATIONEN.length,
|
||||||
|
quiz: { richtig: correct, gesamt: total },
|
||||||
|
unklar: unclearList.map(st=>({id:st.id, name:st.name})),
|
||||||
|
quizDetail: []
|
||||||
|
};
|
||||||
|
STATIONEN.forEach(st=> st.quiz.forEach((q,qi)=>{
|
||||||
|
const p = S.picks[pkey(st.id,qi)];
|
||||||
|
if(p===undefined) return;
|
||||||
|
json.quizDetail.push({station:st.id, frage:q.frage, gewaehlt:q.optionen[p], richtig:p===q.richtig});
|
||||||
|
}));
|
||||||
|
DEBRIEF = { md, json };
|
||||||
$("#debriefText").textContent = md;
|
$("#debriefText").textContent = md;
|
||||||
$("#debriefDlg").showModal();
|
$("#debriefDlg").showModal();
|
||||||
}
|
}
|
||||||
|
|
@ -1188,9 +1221,16 @@ function openDebrief(){
|
||||||
$("#debriefBtn").onclick = openDebrief;
|
$("#debriefBtn").onclick = openDebrief;
|
||||||
$("#closeDebrief").onclick = ()=> $("#debriefDlg").close();
|
$("#closeDebrief").onclick = ()=> $("#debriefDlg").close();
|
||||||
$("#copyBtn").onclick = ()=> navigator.clipboard?.writeText($("#debriefText").textContent);
|
$("#copyBtn").onclick = ()=> navigator.clipboard?.writeText($("#debriefText").textContent);
|
||||||
|
$("#dlMd").onclick = ()=> download("slc-debrief.md", DEBRIEF.md, "text/markdown");
|
||||||
|
$("#dlJson").onclick = ()=> download("slc-debrief.json", JSON.stringify(DEBRIEF.json, null, 2), "application/json");
|
||||||
$("#resetBtn").onclick = ()=>{ if(confirm("Neue Action Card ziehen und Durchlauf zurücksetzen?")){ S=defaultState(); save(); draw(); } };
|
$("#resetBtn").onclick = ()=>{ if(confirm("Neue Action Card ziehen und Durchlauf zurücksetzen?")){ S=defaultState(); save(); draw(); } };
|
||||||
draw();
|
draw();
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
// PWA: Service Worker fuer Offline-/Kiosk-Betrieb (nur ueber http/https aktiv)
|
||||||
|
if ("serviceWorker" in navigator) {
|
||||||
|
window.addEventListener("load", ()=> navigator.serviceWorker.register("sw.js").catch(()=>{}));
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
15
04_Tablet-Quiz/app/manifest.webmanifest
Normal file
15
04_Tablet-Quiz/app/manifest.webmanifest
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
{
|
||||||
|
"name": "SLC-Workshop Companion",
|
||||||
|
"short_name": "SLC Companion",
|
||||||
|
"description": "Begleit-App zum SLC-Workshop-Tabletop: Stationsführung, vermittelndes Quiz, Auflösung und Debrief. Offline lauffähig.",
|
||||||
|
"start_url": "./",
|
||||||
|
"scope": "./",
|
||||||
|
"display": "standalone",
|
||||||
|
"orientation": "landscape",
|
||||||
|
"lang": "de",
|
||||||
|
"background_color": "#f4f5f7",
|
||||||
|
"theme_color": "#1d2430",
|
||||||
|
"icons": [
|
||||||
|
{ "src": "icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any maskable" }
|
||||||
|
]
|
||||||
|
}
|
||||||
33
04_Tablet-Quiz/app/sw.js
Normal file
33
04_Tablet-Quiz/app/sw.js
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
/* Service Worker — SLC-Workshop Companion (App-Shell, offline-first) */
|
||||||
|
const CACHE = "slc-companion-v1";
|
||||||
|
const ASSETS = ["./", "index.html", "manifest.webmanifest", "icon.svg"];
|
||||||
|
|
||||||
|
self.addEventListener("install", (e) => {
|
||||||
|
e.waitUntil(
|
||||||
|
caches.open(CACHE).then((c) => c.addAll(ASSETS)).then(() => self.skipWaiting())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("activate", (e) => {
|
||||||
|
e.waitUntil(
|
||||||
|
caches.keys()
|
||||||
|
.then((ks) => Promise.all(ks.filter((k) => k !== CACHE).map((k) => caches.delete(k))))
|
||||||
|
.then(() => self.clients.claim())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("fetch", (e) => {
|
||||||
|
if (e.request.method !== "GET") return;
|
||||||
|
e.respondWith(
|
||||||
|
caches.match(e.request).then((hit) =>
|
||||||
|
hit ||
|
||||||
|
fetch(e.request)
|
||||||
|
.then((resp) => {
|
||||||
|
const copy = resp.clone();
|
||||||
|
caches.open(CACHE).then((c) => c.put(e.request, copy));
|
||||||
|
return resp;
|
||||||
|
})
|
||||||
|
.catch(() => caches.match("index.html"))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue