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.
438 lines
15 KiB
HTML
438 lines
15 KiB
HTML
{# ─────────────────────────────────────────────────────────────────────
|
||
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>
|