fix: Topbar-Wrap auf Mobile, Klassische-Ansicht raus, Tags-Greying + Title-Bug

Drei Korrekturen:

1. Mobile-Topbar: Auth-Widget + Bundesland-Selector + Theme-Toggle
   pushten die rechte Kante über 390 px Phone-Viewport. Fix: Topbar
   darf in @media (max-width: 900px) flex-wrappen, height auto,
   row-gap fuer mehrzeilig.

2. Topbar-Link "Klassische Ansicht" → /classic entfernt (verlinkt auf
   das alte v1-Frontend; v2 bzw. das neue v3 sind die aktiven Modi).

3. /tags-Seite hatte zwei Bugs:
   - Titel wurde aus a.titel (existiert nicht) statt a.title gelesen
     → User sah nur Drucksachen-Nummern und dachte "kaum Daten".
   - Kein Visual-Feedback welche Tag-Kombinationen leer wären.
   Beide gefixt: title-Field korrekt, plus Tag-Greying via class
   .tag-pill.disabled fuer Tags die zu 0 Treffern fuehren wuerden.
   Ausserdem Score-Field gwoeScore-Fallback und HTML-Escape fuer alle
   Strings (vorher XSS-anfaellig bei Title/Fraktion).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dotty Dotter 2026-05-07 12:05:10 +02:00
parent 848039627c
commit f0588714bf
3 changed files with 69 additions and 11 deletions

View File

@ -819,6 +819,18 @@ body.v2 ul.v2-manual ul li::before {
body.v2 .v2-main { body.v2 .v2-main {
padding: 16px 12px; padding: 16px 12px;
} }
/* Topbar darf auf Mobile umbrechen, sonst pusht das Auth-Widget +
Bundesland-Selector + Theme-Toggle die rechte Kante ueber 390 px. */
body.v2 .v2-topbar {
flex-wrap: wrap;
height: auto;
min-height: 32px;
row-gap: 4px;
padding: 4px 12px;
}
body.v2 .v2-topbar > * {
max-height: none;
}
.v2-sidebar { .v2-sidebar {
display: none; display: none;

View File

@ -79,7 +79,6 @@
<header class="v2-topbar"> <header class="v2-topbar">
<button id="v2-menu-toggle" class="v2-menu-toggle" aria-label="Navigation öffnen">&#9776;</button> <button id="v2-menu-toggle" class="v2-menu-toggle" aria-label="Navigation öffnen">&#9776;</button>
<span class="v2-topbar-spacer"></span> <span class="v2-topbar-spacer"></span>
<a href="/classic" class="v2-back-link">{{ icon("arrow-square-out", 13) }} Klassische Ansicht</a>
<a href="/methodik">{{ icon("info", 13) }} Methodik</a> <a href="/methodik">{{ icon("info", 13) }} Methodik</a>
<a href="/quellen">{{ icon("book-open", 13) }} Quellen</a> <a href="/quellen">{{ icon("book-open", 13) }} Quellen</a>

View File

@ -37,6 +37,20 @@
font-size: 10px; font-size: 10px;
} }
.tag-pill.active .tag-count { opacity: 0.9; } .tag-pill.active .tag-count { opacity: 0.9; }
/* Tags, die mit aktuellen Filtern zu 0 Treffern führen würden, werden
ausgegraut. Klickbar bleiben sie (User kann „falsche" Auswahl zurück-
nehmen), aber visuell deutlich entwertet. */
.tag-pill.disabled {
opacity: 0.35;
background: var(--ecg-bg-subtle);
border-style: dashed;
color: var(--ecg-text-muted);
}
.tag-pill.disabled:hover {
background: var(--ecg-bg-subtle);
border-color: var(--ecg-text-muted);
color: var(--ecg-text-muted);
}
</style> </style>
{% endblock %} {% endblock %}
@ -116,12 +130,42 @@ function toggleTag(btn, tag) {
btn.classList.add('active'); btn.classList.add('active');
} }
renderFiltered(); renderFiltered();
updateTagAvailability();
} }
function clearFilters() { function clearFilters() {
_selectedTags.clear(); _selectedTags.clear();
document.querySelectorAll('.tag-pill.active').forEach(b => b.classList.remove('active')); document.querySelectorAll('.tag-pill.active').forEach(b => b.classList.remove('active'));
renderFiltered(); renderFiltered();
updateTagAvailability();
}
/* Items, die zu den aktuellen aktiven Filtern passen.
Wird sowohl fuer die Ergebnis-Liste als auch fuer Tag-Greying genutzt. */
function currentFilteredItems() {
if (!_selectedTags.size) return _allItems.slice();
return _allItems.filter(a => {
const tags = new Set(a.themen || []);
return Array.from(_selectedTags).every(t => tags.has(t));
});
}
/* Pro Tag-Pill pruefen: wuerde Hinzufuegen dieses Tags die Ergebnisliste
auf 0 reduzieren? Wenn ja → ausgrauen. Aktive Tags werden nicht
ausgegraut (sonst kann man sie nicht mehr abwaehlen). */
function updateTagAvailability() {
const base = currentFilteredItems();
document.querySelectorAll('.tag-cloud .tag-pill').forEach(btn => {
const tag = btn.dataset.tag;
if (!tag) return;
if (_selectedTags.has(tag)) {
btn.classList.remove('disabled');
return;
}
// Wuerde Hinzufuegen dieses Tags die Liste leer machen?
const wouldHave = base.some(a => (a.themen || []).includes(tag));
btn.classList.toggle('disabled', !wouldHave);
});
} }
function renderFiltered() { function renderFiltered() {
@ -140,10 +184,7 @@ function renderFiltered() {
`<span class="tag-pill active" style="cursor:default;">${t}</span>` `<span class="tag-pill active" style="cursor:default;">${t}</span>`
).join(' '); ).join(' ');
const filtered = _allItems.filter(a => { const filtered = currentFilteredItems();
const tags = new Set(a.themen || []);
return Array.from(_selectedTags).every(t => tags.has(t));
});
if (!filtered.length) { if (!filtered.length) {
resultsEl.innerHTML = '<div class="v2-kasten outline-green"><h4>Keine Ergebnisse</h4><p>Die Filterauswahl liefert keine Treffer.</p></div>'; resultsEl.innerHTML = '<div class="v2-kasten outline-green"><h4>Keine Ergebnisse</h4><p>Die Filterauswahl liefert keine Treffer.</p></div>';
@ -151,19 +192,25 @@ function renderFiltered() {
} }
resultsEl.innerHTML = filtered.map(a => { resultsEl.innerHTML = filtered.map(a => {
const score = (typeof a.gwoe_score === 'number') ? a.gwoe_score.toFixed(1) : '—'; /* API-Felder: title (englisch), gwoe_score oder gwoeScore. Vorher
wurde a.titel (deutsch) gelesen — gibt's nicht → Fallback auf
Drucksache, was dann wie ein leeres Resultat aussieht. */
const score = (typeof a.gwoe_score === 'number') ? a.gwoe_score.toFixed(1)
: (typeof a.gwoeScore === 'number') ? a.gwoeScore.toFixed(1)
: '—';
const bl = a.bundesland || ''; const bl = a.bundesland || '';
const fraktion = (a.fraktionen || []).join(', '); const fraktion = (a.fraktionen || []).join(', ');
const title = a.titel || a.drucksache; const title = a.title || a.titel || a.drucksache;
const escAttr = (s) => String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[c]);
return `<a href="/antrag/${encodeURIComponent(a.drucksache)}" return `<a href="/antrag/${encodeURIComponent(a.drucksache)}"
class="v2-result-row" class="v2-result-row"
style="display:block;text-decoration:none;"> style="display:block;text-decoration:none;">
<div class="v2-result-meta"> <div class="v2-result-meta">
<span class="v2-chip" style="font-size:10px;">${bl}</span> <span class="v2-chip" style="font-size:10px;">${escAttr(bl)}</span>
<span style="font-size:11px;opacity:0.6;font-family:var(--font-mono);">${a.drucksache}</span> <span style="font-size:11px;opacity:0.6;font-family:var(--font-mono);">${escAttr(a.drucksache)}</span>
${fraktion ? `<span style="font-size:11px;opacity:0.6;">${fraktion}</span>` : ''} ${fraktion ? `<span style="font-size:11px;opacity:0.6;">${escAttr(fraktion)}</span>` : ''}
</div> </div>
<div class="v2-result-title">${title}</div> <div class="v2-result-title">${escAttr(title)}</div>
<div class="v2-result-score" style="font-family:var(--font-mono);font-size:13px;color:var(--ecg-teal);font-weight:700;"> <div class="v2-result-score" style="font-family:var(--font-mono);font-size:13px;color:var(--ecg-teal);font-weight:700;">
Score ${score} Score ${score}
</div> </div>