gwoe-antragspruefer/app/templates/v3/components/tour.html
Dotty Dotter 4c989ea443 fix(tour, csp): media-src für Tour-Audio + Tour global außer Administration
Zwei Bugs:

1) Audio kam nicht durch — die Content-Security-Policy hatte kein
   media-src und fiel auf default-src 'self' zurück. data:- (silent-WAV
   zum Element-Unlock) und blob:-URLs (ElevenLabs-MP3-Cache) wurden
   geblockt. Browser-Fehlermeldung im Console: „Loading media from
   ‚data:audio/wav;base64,…' violates the following Content Security
   Policy directive". Fix: ``media-src 'self' data: blob:;`` ergänzt.

2) Tour war nur auf Startseite + Antrag-Detail eingebunden. User-Wunsch:
   auf jeder Page außer Administration. Lösung: Tour-Engine-Include in
   v2/base.html, mit ``{% if v2_active_nav not in [admin_*] %}``-Guard.
   Pages ohne eigene ``window.GWOE_TOUR_STEPS`` bekommen einen Fallback
   mit drei Stationen (Logo+Konzept, Topbar, Sidebar).

   Topbar-Tour-Link sichtbar wenn ``window.gwoeTourStart`` existiert
   (Engine geladen) — nicht mehr abhängig von Page-eigenen Steps.

Aufräumen: redundante Tour-Includes aus durchsuchen.html und
antrag_detail.html entfernt — die Engine kommt jetzt nur einmal aus
base.html.
2026-05-09 08:43:35 +02:00

438 lines
15 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{# ─────────────────────────────────────────────────────────────────────
Geführte Tour-Engine (#185).
Was diese Komponente liefert:
- Spotlight-Overlay + Erklärblase + Vor/Zurück/Schließen + Mute
- Audio via Web Speech API (de-DE, möglichst weiblich)
- Engine: liest pro Page hinterlegte ``window.GWOE_TOUR_STEPS``
(Liste mit ``{selector, title, text}``), zeigt nur Stationen
deren Element wirklich da ist.
Pro Page:
<script>window.GWOE_TOUR_STEPS = [{selector: '...', title: '...', text: '...'}];</script>
{% include "v3/components/tour.html" %}
<button onclick="gwoeTourStart()">Tour starten</button>
Phase 1: Browser-TTS. Phase 2 (ElevenLabs) tauscht nur das
Audio-Backend; das Tour-Skript bleibt gleich.
───────────────────────────────────────────────────────────────────── #}
<div id="gwoe-tour-overlay" class="gwoe-tour-overlay" hidden aria-hidden="true">
<div id="gwoe-tour-spotlight" class="gwoe-tour-spotlight"></div>
<div id="gwoe-tour-bubble" class="gwoe-tour-bubble" role="dialog"
aria-labelledby="gwoe-tour-title" aria-describedby="gwoe-tour-text">
<div class="gwoe-tour-head">
<span id="gwoe-tour-step">Schritt 1 / 4</span>
<button type="button" onclick="gwoeTourEnd()" class="gwoe-tour-close"
title="Tour beenden" aria-label="Tour beenden">×</button>
</div>
<h3 id="gwoe-tour-title" class="gwoe-tour-title">Titel</h3>
<p id="gwoe-tour-text" class="gwoe-tour-text">Erklär-Text</p>
<div class="gwoe-tour-actions">
<button type="button" id="gwoe-tour-mute"
onclick="gwoeTourToggleMute()"
class="v3-action-btn gwoe-tour-mute"
title="Sprachausgabe an / aus">
<span id="gwoe-tour-mute-icon">🔊</span>
<span id="gwoe-tour-mute-label">Stimme an</span>
</button>
<span class="gwoe-tour-spacer"></span>
<button type="button" id="gwoe-tour-prev"
onclick="gwoeTourPrev()" class="v3-action-btn">← Zurück</button>
<button type="button" id="gwoe-tour-next"
onclick="gwoeTourNext()" class="v3-action-btn primary">Weiter →</button>
</div>
</div>
</div>
<style>
.gwoe-tour-overlay {
position: fixed;
inset: 0;
z-index: 99998;
background: rgba(0, 0, 0, 0.55);
pointer-events: auto;
}
.gwoe-tour-overlay[hidden] { display: none; }
.gwoe-tour-spotlight {
position: absolute;
border-radius: 6px;
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.55);
pointer-events: none;
transition: top 0.3s ease, left 0.3s ease, width 0.3s ease, height 0.3s ease;
outline: 2px solid var(--ecg-teal);
outline-offset: 4px;
}
.gwoe-tour-bubble {
position: absolute;
z-index: 99999;
background: var(--ecg-card-bg, #fff);
color: var(--ecg-dark, #1a1a1a);
border: 1px solid var(--hairline, rgba(0,0,0,0.12));
border-radius: 6px;
padding: 14px 16px 12px;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.25);
max-width: 380px;
font-family: var(--font-sans);
transition: top 0.3s ease, left 0.3s ease;
}
.gwoe-tour-head {
display: flex;
justify-content: space-between;
align-items: center;
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.06em;
text-transform: uppercase;
opacity: 0.7;
margin-bottom: 4px;
}
.gwoe-tour-close {
background: none;
border: none;
font-size: 20px;
line-height: 1;
cursor: pointer;
color: var(--ecg-dark);
opacity: 0.7;
padding: 0 4px;
}
.gwoe-tour-close:hover { opacity: 1; }
.gwoe-tour-title {
font-family: var(--font-display);
font-size: 16px;
margin: 4px 0 8px;
color: var(--ecg-teal);
}
.gwoe-tour-text {
font-size: 14px;
line-height: 1.5;
margin: 0 0 12px;
}
.gwoe-tour-actions {
display: flex;
gap: 6px;
align-items: center;
border-top: 1px dashed var(--hairline, rgba(0,0,0,0.1));
padding-top: 10px;
}
.gwoe-tour-spacer { flex: 1 1 auto; }
.gwoe-tour-mute { font-size: 12px; }
/* primary-Override entfernt — verwendet jetzt das globale .v3-action-btn.primary
Styling (pill-shaped, teal-solid, weicher Drop-Shadow). */
@media (max-width: 700px) {
.gwoe-tour-bubble {
/* auf Mobile zentriert unten andocken statt gefloated, sonst kollidiert
mit dem Spotlight */
left: 12px !important;
right: 12px !important;
max-width: none;
bottom: 12px;
top: auto !important;
}
}
</style>
<script>
(function () {
// Tour-Stationen kommen pro Page via window.GWOE_TOUR_STEPS.
// Jeder Eintrag: {selector, title, text}. Selectoren werden zur Laufzeit
// aufgelöst — fehlt das Element (z.B. keine Plenum-Votes auf einer
// bestimmten Drucksache), überspringt die Tour den Schritt automatisch.
//
// Fallback: pages ohne eigene Stationen bekommen eine kurze
// Orientierungs-Tour, damit die Tour-Schaltfläche überall einen
// sinnvollen Inhalt zeigt.
const FALLBACK_STEPS = [
{ selector: '.v2-brand-link, .v2-brand',
title: 'Willkommen beim GWÖ-Antragsprüfer',
text: 'Diese Seite bewertet Anträge aus deutschen Parlamenten nach der Gemeinwohl-Matrix. Was sie auszeichnet: Sie schaut nicht nur, ob ein Antrag „gut klingt", sondern wie sehr er fünf Werten dient — Würde, Solidarität, Nachhaltigkeit, Gerechtigkeit und Demokratie. Klick auf das Logo bringt dich jederzeit zur Übersicht zurück.' },
{ selector: '.v2-topbar',
title: 'Topbar',
text: 'Oben findest du Methodik (wie wir bewerten), Quellen (alle Wahl- und Grundsatzprogramme, semantisch durchsuchbar) und den Bundesland-Filter. Rechts ist der Theme-Toggle und — falls eingeloggt — dein Profil.' },
{ selector: '#v2-sidebar nav',
title: 'Navigation links',
text: 'Links findest du die Hauptbereiche. Die Liste passt sich an: angemeldete Nutzer:innen sehen außerdem Auswertungen, Stimmverhalten und persönliche Funktionen wie eine Merkliste.' },
];
function getSteps() {
if (Array.isArray(window.GWOE_TOUR_STEPS) && window.GWOE_TOUR_STEPS.length > 0) {
return window.GWOE_TOUR_STEPS;
}
return FALLBACK_STEPS;
}
let _tourIdx = 0;
let _resolvedSteps = []; // STEPS gefiltert auf vorhandene Elemente
let _tourMuted = false;
let _tourUtter = null;
// Persistentes <audio>-Element. Wir entsperren es einmal beim
// Tour-Start (User-Gesture vom Click) mit einer leeren src + play(),
// damit spätere src-Updates ohne Auto-Play-Block laufen — sonst
// kassieren Safari/Chrome jedes ``new Audio(blobUrl).play()`` nach
// einem await mit "NotAllowedError".
let _tourAudio = null;
let _tourAudioUnlocked = false;
// Cache nur in dieser Session: vermeidet doppelte API-Roundtrips bei
// Vor/Zurück-Navigation. Server-seitig sind die MP3s ohnehin gecacht.
const _audioBlobCache = {};
function ensureAudioElement() {
if (!_tourAudio) {
_tourAudio = new Audio();
_tourAudio.preload = 'auto';
}
return _tourAudio;
}
function unlockAudioOnGesture() {
// Muss SYNCHRON im Click-Handler aufgerufen werden, sonst zählt es
// nicht als User-Gesture. Wir starten ein silent-play und pausieren
// sofort — das markiert das Element als "vom User initiiert".
if (_tourAudioUnlocked) return;
const a = ensureAudioElement();
try {
// 1×1 silent WAV als Data-URL (winzig, garantiert valid)
a.src = 'data:audio/wav;base64,UklGRiQAAABXQVZFZm10IBAAAAABAAEAESsAACJWAAACABAAZGF0YQAAAAA=';
const p = a.play();
if (p && typeof p.then === 'function') {
p.then(() => {
a.pause();
a.currentTime = 0;
_tourAudioUnlocked = true;
}).catch(() => { /* manche Browser blocken auch das — ignorieren */ });
} else {
a.pause();
_tourAudioUnlocked = true;
}
} catch (_) { /* ignore */ }
}
function $(id) { return document.getElementById(id); }
function speakWebSpeech(text) {
if (!('speechSynthesis' in window)) return;
try {
window.speechSynthesis.cancel();
const u = new SpeechSynthesisUtterance(text);
u.lang = 'de-DE';
u.rate = 1.0;
u.pitch = 1.0;
const voices = window.speechSynthesis.getVoices();
const de = voices.filter(v => /de(-DE|_DE)?/i.test(v.lang));
const female = de.find(v => /female|frau|anna|petra|katja|helga|marlene|vicki/i.test(v.name));
u.voice = female || de[0] || null;
_tourUtter = u;
window.speechSynthesis.speak(u);
} catch (_) { /* TTS optional */ }
}
async function speakElevenLabs(text) {
// Server-Endpoint ruft ElevenLabs + cacht; bei 503 (kein API-Key)
// fallen wir auf Web Speech zurück.
if (_audioBlobCache[text]) {
playAudio(_audioBlobCache[text]);
return true;
}
try {
const url = '/api/tour/voice?text=' + encodeURIComponent(text);
const resp = await fetch(url);
if (resp.status === 503) return false; // ElevenLabs nicht konfiguriert
if (!resp.ok) {
console.warn('Tour-Audio ' + resp.status + ': fallback auf Web Speech');
return false;
}
const blob = await resp.blob();
const blobUrl = URL.createObjectURL(blob);
_audioBlobCache[text] = blobUrl;
playAudio(blobUrl);
return true;
} catch (e) {
console.warn('Tour-Audio-Fehler, fallback Web Speech:', e);
return false;
}
}
function playAudio(blobUrl) {
stopSpeak();
const a = ensureAudioElement();
a.src = blobUrl;
a.play().catch((err) => {
// Wenn der Browser doch blockiert (z.B. Page reload ohne Gesture):
// wir loggen, der User kann den Mute-Toggle erneut drücken.
console.warn('Tour-Audio play blocked:', err && err.name);
});
}
async function speak(text) {
if (_tourMuted) return;
// Bevorzugt ElevenLabs (Server). Bei nicht-konfiguriert auf Web Speech.
const ok = await speakElevenLabs(text);
if (!ok) speakWebSpeech(text);
}
function stopSpeak() {
if ('speechSynthesis' in window) {
try { window.speechSynthesis.cancel(); } catch (_) {}
}
_tourUtter = null;
// Persistentes _tourAudio nicht zerstören (sonst geht der Unlock
// verloren), nur stoppen.
if (_tourAudio) {
try { _tourAudio.pause(); _tourAudio.currentTime = 0; } catch (_) {}
}
}
function resolveSteps() {
_resolvedSteps = [];
for (const s of getSteps()) {
// Erstes Match aus comma-separated Selector-Liste.
const parts = s.selector.split(',').map(x => x.trim());
let el = null;
for (const p of parts) {
el = document.querySelector(p);
if (el) break;
}
if (el) _resolvedSteps.push({ ...s, el });
}
}
function positionBubble(el) {
const rect = el.getBoundingClientRect();
const bubble = $('gwoe-tour-bubble');
const spotlight = $('gwoe-tour-spotlight');
// Spotlight um das Element legen.
spotlight.style.top = (rect.top - 4) + 'px';
spotlight.style.left = (rect.left - 4) + 'px';
spotlight.style.width = (rect.width + 8) + 'px';
spotlight.style.height = (rect.height + 8) + 'px';
// Bubble unter oder über das Element legen, wo Platz ist.
const bubbleH = 240; // grobe Annahme
const spaceBelow = window.innerHeight - rect.bottom;
const spaceAbove = rect.top;
let top, left;
if (spaceBelow > bubbleH + 20) {
top = rect.bottom + 14;
} else if (spaceAbove > bubbleH + 20) {
top = rect.top - bubbleH - 14;
} else {
// Nichts passt vertikal — Bubble nach rechts neben das Element.
top = Math.max(20, rect.top);
}
left = Math.max(20, Math.min(window.innerWidth - 400, rect.left));
bubble.style.top = top + 'px';
bubble.style.left = left + 'px';
}
function showStep(i) {
if (i < 0 || i >= _resolvedSteps.length) {
gwoeTourEnd();
return;
}
_tourIdx = i;
const step = _resolvedSteps[i];
const overlay = $('gwoe-tour-overlay');
overlay.hidden = false;
overlay.setAttribute('aria-hidden', 'false');
$('gwoe-tour-step').textContent = `Schritt ${i + 1} / ${_resolvedSteps.length}`;
$('gwoe-tour-title').textContent = step.title;
$('gwoe-tour-text').textContent = step.text;
$('gwoe-tour-prev').disabled = (i === 0);
$('gwoe-tour-next').textContent = (i === _resolvedSteps.length - 1)
? 'Fertig ✓' : 'Weiter →';
// Element ins Sichtfeld scrollen, dann positionieren.
step.el.scrollIntoView({ behavior: 'smooth', block: 'center' });
setTimeout(() => positionBubble(step.el), 350);
// TTS sprechen lassen
speak(step.title + '. ' + step.text);
}
// Globale Schnittstelle
window.gwoeTourStart = function () {
// Audio-Element JETZT entsperren — synchron im User-Gesture-Frame.
// showStep() macht später async fetches; das play() danach würde
// sonst ohne diesen Trick vom Auto-Play-Block kassiert.
unlockAudioOnGesture();
resolveSteps();
if (_resolvedSteps.length === 0) {
console.warn('Tour: keine sichtbaren Stationen.');
return;
}
_tourIdx = 0;
showStep(0);
document.body.style.overflow = 'hidden'; // Page-Scroll blockieren
};
window.gwoeTourEnd = function () {
const overlay = $('gwoe-tour-overlay');
overlay.hidden = true;
overlay.setAttribute('aria-hidden', 'true');
document.body.style.overflow = '';
stopSpeak();
};
window.gwoeTourNext = function () {
if (_tourIdx >= _resolvedSteps.length - 1) {
gwoeTourEnd();
return;
}
showStep(_tourIdx + 1);
};
window.gwoeTourPrev = function () {
if (_tourIdx <= 0) return;
showStep(_tourIdx - 1);
};
window.gwoeTourToggleMute = function () {
_tourMuted = !_tourMuted;
$('gwoe-tour-mute-icon').textContent = _tourMuted ? '🔇' : '🔊';
$('gwoe-tour-mute-label').textContent = _tourMuted ? 'Stimme aus' : 'Stimme an';
if (_tourMuted) stopSpeak();
else if (_resolvedSteps[_tourIdx]) {
const s = _resolvedSteps[_tourIdx];
speak(s.title + '. ' + s.text);
}
};
// ESC schließt; Klick auf Overlay (außerhalb der Bubble) auch.
document.addEventListener('keydown', (e) => {
const overlay = $('gwoe-tour-overlay');
if (!overlay || overlay.hidden) return;
if (e.key === 'Escape') gwoeTourEnd();
else if (e.key === 'ArrowRight') gwoeTourNext();
else if (e.key === 'ArrowLeft') gwoeTourPrev();
});
document.addEventListener('click', (e) => {
const overlay = $('gwoe-tour-overlay');
if (!overlay || overlay.hidden) return;
// Overlay-Klick hinter dem Spotlight oder Bubble → Tour-Ende
if (e.target === overlay) gwoeTourEnd();
});
// Bei Resize: aktive Bubble + Spotlight neu positionieren
window.addEventListener('resize', () => {
if (!$('gwoe-tour-overlay').hidden && _resolvedSteps[_tourIdx]) {
positionBubble(_resolvedSteps[_tourIdx].el);
}
});
// Beim Verlassen der Page: Sprachausgabe stoppen.
window.addEventListener('pagehide', stopSpeak);
// Voices laden — manche Browser füllen sie asynchron.
if ('speechSynthesis' in window) {
window.speechSynthesis.onvoiceschanged = () => { /* trigger preload */ };
window.speechSynthesis.getVoices();
}
})();
</script>