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).
This commit is contained in:
Dotty Dotter 2026-05-09 08:07:27 +02:00
parent a3a2b90e9f
commit e397ae5028
2 changed files with 66 additions and 16 deletions

View File

@ -42,10 +42,20 @@
<span class="v2-nav-count">{{ assessment_count }}</span>
{% endif %}
</a>
{% if is_authenticated %}<a href="/v2/merkliste" class="v2-nav-item {% if v2_active_nav == 'merkliste' %}active{% endif %}">{{ icon("bookmark-simple", 14) }} Merkliste</a>{% endif %}
<a href="/v2/tags" class="v2-nav-item {% if v2_active_nav == 'tags' %}active{% endif %}">{{ icon("tag", 14) }} Tags</a>
{% if is_admin %}<a href="/v2/cluster" class="v2-nav-item {% if v2_active_nav == 'cluster' %}active{% endif %}">{{ icon("graph", 14) }} Cluster</a>{% endif %}
{% if is_authenticated %}<a href="/v2/landtag-suche" class="v2-nav-item {% if v2_active_nav == 'landtag_suche' %}active{% endif %}">{{ icon("magnifying-glass-plus", 14) }} Landtag-Suche</a>{% endif %}
{% if is_authenticated %}<a href="/v2/merkliste" class="v2-nav-item {% if v2_active_nav == 'merkliste' %}active{% endif %}">{{ icon("bookmark-simple", 14) }} Merkliste</a>{% endif %}
</div>
{# ── Daten — aggregiert, öffentlich für alle sichtbar ───────── #}
<div class="v2-nav-group">
<div class="v2-nav-label">— Daten</div>
<a href="/auswertungen" class="v2-nav-item {% if v2_active_nav == 'auswertungen' %}active{% endif %}">{{ icon("chart-bar", 14) }} Auswertungen</a>
<a href="/stimmverhalten" class="v2-nav-item {% if v2_active_nav == 'stimmverhalten' %}active{% endif %}">{{ icon("circle-half", 14) }} Stimmverhalten</a>
<a href="/aktuelle-themen" class="v2-nav-item {% if v2_active_nav == 'aktuelle-themen' %}active{% endif %}">{{ icon("book-open", 14) }} Aktuelle Themen</a>
<a href="/api/auswertungen/export.csv" class="v2-nav-item">{{ icon("file-csv", 14) }} Export · API</a>
<a href="/v2/feed" class="v2-nav-item {% if v2_active_nav == 'feed' %}active{% endif %}">{{ icon("rss", 14) }} Atom-Feed</a>
</div>
{% if is_authenticated %}
@ -56,12 +66,7 @@
</div>
<div class="v2-nav-group">
<div class="v2-nav-label">— Daten</div>
<a href="/auswertungen" class="v2-nav-item {% if v2_active_nav == 'auswertungen' %}active{% endif %}">{{ icon("chart-bar", 14) }} Auswertungen</a>
<a href="/stimmverhalten" class="v2-nav-item {% if v2_active_nav == 'stimmverhalten' %}active{% endif %}">{{ icon("circle-half", 14) }} Stimmverhalten</a>
<a href="/aktuelle-themen" class="v2-nav-item {% if v2_active_nav == 'aktuelle-themen' %}active{% endif %}">{{ icon("book-open", 14) }} Aktuelle Themen</a>
<a href="/api/auswertungen/export.csv" class="v2-nav-item">{{ icon("file-csv", 14) }} Export · API</a>
<a href="/v2/feed" class="v2-nav-item {% if v2_active_nav == 'feed' %}active{% endif %}">{{ icon("rss", 14) }} Atom-Feed</a>
<div class="v2-nav-label">— Persönlich</div>
<a href="/v2/abos" class="v2-nav-item {% if v2_active_nav == 'abos' %}active{% endif %}">{{ icon("envelope-simple", 14) }} Meine Abos</a>
</div>
{% endif %}

View File

@ -119,11 +119,8 @@
}
.gwoe-tour-spacer { flex: 1 1 auto; }
.gwoe-tour-mute { font-size: 12px; }
.gwoe-tour-bubble .v3-action-btn.primary {
background: var(--ecg-teal);
color: #fff;
border-color: var(--ecg-teal);
}
/* 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 {
@ -152,11 +149,48 @@
let _resolvedSteps = []; // STEPS gefiltert auf vorhandene Elemente
let _tourMuted = false;
let _tourUtter = null;
let _tourAudio = null; // <audio>-Element für ElevenLabs-MP3
// 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) {
@ -204,8 +238,13 @@
function playAudio(blobUrl) {
stopSpeak();
_tourAudio = new Audio(blobUrl);
_tourAudio.play().catch(() => { /* Autoplay-Block, harmless */ });
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) {
@ -220,9 +259,10 @@
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 (_) {}
_tourAudio = null;
}
}
@ -297,6 +337,11 @@
// 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.');