gwoe-antragspruefer/app/templates/v3/components/tour.html
Dotty Dotter e397ae5028 fix(tour, nav): Audio-Auto-Play entsperren + Daten-Nav für alle sichtbar
Drei zusammenhängende UI-Bugs:

1) Audio kam nicht — Browser-Auto-Play-Block. ``new Audio(blobUrl).play()``
   nach einem await zählt nicht mehr als User-Gesture; Safari/Chrome
   kassieren mit NotAllowedError. Fix: persistentes <audio>-Element wird
   einmal beim Tour-Start (im Click-Handler, synchron) mit einer
   1×1-silent-WAV entsperrt. Folgende src-Updates spielen ohne Block.

2) Tour-Bubble „Weiter"-Button sah 90er aus — der lokale CSS-Override
   ``.gwoe-tour-bubble .v3-action-btn.primary`` hat den modernen
   pill-shaped Style ausgehebelt. Override entfernt; nutzt jetzt das
   globale ``.v3-action-btn.primary`` (teal-solid, runde Ecken,
   weicher Drop-Shadow).

3) Tour erzählt anonymen User:innen über „Auswertungen" und
   „Stimmverhalten", die in der linken Nav für Anonyme nicht sichtbar
   waren. Aggregierte Daten sind öffentlich — Daten-Nav-Gruppe jetzt für
   alle sichtbar (Auswertungen, Stimmverhalten, Aktuelle Themen,
   Export-API, Atom-Feed). Persönliche Items (Merkliste, Abos, Neuer
   Antrag, Batch) bleiben eingeloggt. Cluster + Landtag-Suche bleiben
   eingeloggt/admin (Backend-Routen sind ohnehin require_auth).
2026-05-09 08:07:27 +02:00

419 lines
14 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.
function getSteps() {
return Array.isArray(window.GWOE_TOUR_STEPS) ? window.GWOE_TOUR_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>