v2-Frontend (#139, ECOnGOOD CD Manual Juni 2024): - app/static/v2/: tokens.css, fonts.css, v2.css, Nunito-Sans woff2, Phosphor-Icons (21 SVGs) - app/templates/v2/: base.html + 11 Screens + 8 Component-Macros - AppShell mit Sidebar (Lesen/Pruefen/Daten/Admin), v2-Detail mit allen Features (ScoreHero, MatrixMini, QuoteCard, Redline, Fraktions-Scores) - v2 ist jetzt Default unter / — classic unter /classic - Login-Modal in v2-Topbar mit Tabs Anmelden/Registrieren (#129) - Phosphor-Icons in Sidebar + Topbar mit dynamischem Theme-Toggle - Keyboard-Shortcuts (j/k/Enter/Esc/?/path), Landtag-Suche, Antrag-Historie, Sort-Dropdown, Matrix-Feld-Info-Modal, Bookmarks/Comments/Voting/Share/Re-Analyze Backend-Erweiterungen: - main.py: ~30 neue Routes (/v2/*, /antrag/{ds}, /api/auth/{login,refresh,logout}, /api/me/merkliste/*, /api/admin/*, /v2/admin/*, OG-Cards, etc.) - og_card.py + og_template: Open-Graph-Bilder via Playwright (#141) - wahlprogramm_fetch.py + wahlprogramm-links.yaml: SHA-Gate Auto-DL (#138) - auswertungen.py: BL-Filter + get_wahlperioden Helper (#137) - auth.py: Direct-Access-Grant + Refresh-Token-Cookie Classic-Updates: - Header-DRY via _header.html, Auswertungen redirected, Batch-Inline raus Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
3920 lines
205 KiB
HTML
3920 lines
205 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="de">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>{{ app_name }}</title>
|
||
<link rel="alternate" type="application/atom+xml" title="GWÖ-Antragsprüfer — Neue Bewertungen" href="/api/feed.xml">
|
||
<script src="/static/d3.v7.min.js"></script>
|
||
|
||
<style>
|
||
:root {
|
||
--color-darkgray: #5a5a5a;
|
||
--color-green: #889e33;
|
||
--color-blue: #007a80;
|
||
--color-lightgray: #bfbfbf;
|
||
--color-bg: #f5f5f5;
|
||
--color-card: white;
|
||
--color-text: #5a5a5a;
|
||
--color-border: #e0e0e0;
|
||
--color-muted: #666;
|
||
}
|
||
[data-theme="dark"] {
|
||
--color-darkgray: #d0d0d0;
|
||
--color-blue: #2ec4cc;
|
||
--color-bg: #1a1a2e;
|
||
--color-card: #16213e;
|
||
--color-text: #d0d0d0;
|
||
--color-lightgray: #2a2a4a;
|
||
--color-border: #333366;
|
||
--color-muted: #999;
|
||
}
|
||
|
||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||
:focus-visible { outline: 2px solid var(--color-blue); outline-offset: 2px; }
|
||
|
||
body {
|
||
font-family: 'Avenir', 'Segoe UI', sans-serif;
|
||
color: var(--color-text);
|
||
line-height: 1.6;
|
||
background: var(--color-bg);
|
||
height: 100vh;
|
||
height: 100dvh;
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
}
|
||
|
||
/* Header */
|
||
.header {
|
||
background: var(--color-card);
|
||
padding: 1rem 2rem;
|
||
border-bottom: 1px solid var(--color-lightgray);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 1rem;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.header h1 {
|
||
color: var(--color-blue);
|
||
font-size: 1.5rem;
|
||
}
|
||
|
||
.header .subtitle {
|
||
color: var(--color-darkgray);
|
||
font-size: 0.9rem;
|
||
}
|
||
|
||
.bundesland-select {
|
||
padding: 0.5rem 1rem;
|
||
border: 1px solid var(--color-lightgray);
|
||
border-radius: 4px;
|
||
font-size: 0.9rem;
|
||
background: var(--color-card);
|
||
cursor: pointer;
|
||
}
|
||
|
||
.bundesland-select:focus {
|
||
outline: none;
|
||
border-color: var(--color-blue);
|
||
}
|
||
|
||
/* Main Layout */
|
||
.main-container {
|
||
display: flex;
|
||
flex: 1;
|
||
min-height: 0;
|
||
}
|
||
|
||
/* Left Panel - List */
|
||
.list-panel {
|
||
width: 400px;
|
||
min-width: 300px;
|
||
background: var(--color-card);
|
||
border-right: 1px solid var(--color-lightgray);
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.list-header {
|
||
padding: 1rem;
|
||
border-bottom: 1px solid var(--color-lightgray);
|
||
}
|
||
|
||
.search-row {
|
||
display: flex;
|
||
gap: 0.5rem;
|
||
}
|
||
|
||
.search-box {
|
||
flex: 1;
|
||
padding: 0.75rem;
|
||
border: 1px solid var(--color-lightgray);
|
||
border-radius: 4px;
|
||
font-size: 0.9rem;
|
||
}
|
||
|
||
.btn-landtag {
|
||
padding: 0.5rem 0.75rem;
|
||
background: var(--color-green);
|
||
color: white;
|
||
border: none;
|
||
border-radius: 4px;
|
||
font-size: 0.8rem;
|
||
cursor: pointer;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.btn-landtag:hover {
|
||
background: #728a2b;
|
||
}
|
||
|
||
.btn-landtag:disabled {
|
||
background: #ccc;
|
||
cursor: wait;
|
||
}
|
||
|
||
.list-filters {
|
||
display: flex;
|
||
gap: 0.5rem;
|
||
margin-top: 0.5rem;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.filter-btn {
|
||
padding: 0.25rem 0.75rem;
|
||
border: 1px solid var(--color-lightgray);
|
||
border-radius: 20px;
|
||
background: var(--color-card);
|
||
cursor: pointer;
|
||
font-size: 0.8rem;
|
||
}
|
||
|
||
.filter-btn.active {
|
||
background: var(--color-blue);
|
||
color: white;
|
||
border-color: var(--color-blue);
|
||
}
|
||
|
||
.list-content {
|
||
}
|
||
|
||
.list-item {
|
||
padding: 1rem;
|
||
border-bottom: 1px solid var(--color-lightgray);
|
||
cursor: pointer;
|
||
transition: background 0.2s;
|
||
}
|
||
|
||
.list-item:hover {
|
||
background: var(--color-bg);
|
||
}
|
||
|
||
.list-item.active {
|
||
background: #e8f4f5;
|
||
border-left: 3px solid var(--color-blue);
|
||
}
|
||
|
||
.list-item-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: flex-start;
|
||
margin-bottom: 0.25rem;
|
||
}
|
||
|
||
.list-item-id {
|
||
font-weight: bold;
|
||
color: var(--color-blue);
|
||
}
|
||
|
||
/* Bundesland-Badge: Im Listen-Item links neben der Drucksachen-Nummer.
|
||
Im Bundesland-spezifischen Modus per data-mode="single" am Container
|
||
ausgeblendet (redundant, da alle Einträge demselben Land zugehören). */
|
||
.bl-badge {
|
||
display: inline-block;
|
||
padding: 1px 6px;
|
||
margin-right: 0.4rem;
|
||
font-size: 0.7rem;
|
||
font-weight: 600;
|
||
letter-spacing: 0.02em;
|
||
color: var(--color-blue);
|
||
border: 1px solid var(--color-blue);
|
||
border-radius: 3px;
|
||
vertical-align: middle;
|
||
}
|
||
.list-content[data-mode="single"] .bl-badge { display: none; }
|
||
|
||
/* Detail-Header: Parlament-Name unter dem Titel, vor der Drucksache-Zeile */
|
||
.detail-parlament {
|
||
font-size: 0.85rem;
|
||
color: var(--color-blue);
|
||
font-weight: 600;
|
||
margin: 0.2rem 0 0.1rem;
|
||
}
|
||
|
||
.list-item-score {
|
||
font-size: 0.9rem;
|
||
font-weight: bold;
|
||
padding: 0.1rem 0.5rem;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
/* Neue Skala -5 bis +5 */
|
||
.score-high { background: #155724; color: white; } /* +4 bis +5: dunkelgrün */
|
||
.score-mid { background: #889e33; color: white; } /* +2 bis +3: GWÖ-grün */
|
||
.score-neutral { background: #6c757d; color: white; } /* 0 bis +1: grau */
|
||
.score-low { background: #fd7e14; color: white; } /* -1 bis -2: orange */
|
||
.score-negative { background: #dc3545; color: white; } /* -3 bis -5: rot */
|
||
.score-none { background: #e2e3e5; color: #383d41; }
|
||
|
||
.status-unchecked {
|
||
background: #fff3cd;
|
||
color: #856404;
|
||
font-size: 0.75rem;
|
||
padding: 0.2rem 0.5rem;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
.status-checked {
|
||
background: #d4edda;
|
||
color: #155724;
|
||
font-size: 0.75rem;
|
||
padding: 0.2rem 0.5rem;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
.btn-check-now {
|
||
display: inline-block;
|
||
padding: 0.3rem 0.75rem;
|
||
background: var(--color-blue);
|
||
color: white;
|
||
border: none;
|
||
border-radius: 4px;
|
||
font-size: 0.8rem;
|
||
cursor: pointer;
|
||
margin-top: 0.5rem;
|
||
}
|
||
|
||
.btn-check-now:hover {
|
||
background: #007b82;
|
||
}
|
||
|
||
.btn-check-now:disabled {
|
||
background: #ccc;
|
||
cursor: wait;
|
||
}
|
||
|
||
.list-item-title {
|
||
font-size: 0.9rem;
|
||
color: var(--color-darkgray);
|
||
margin-bottom: 0.25rem;
|
||
}
|
||
|
||
.list-item-meta {
|
||
font-size: 0.8rem;
|
||
color: var(--color-muted);
|
||
}
|
||
|
||
.list-item-tags {
|
||
display: flex;
|
||
gap: 0.25rem;
|
||
margin-top: 0.5rem;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.tag {
|
||
font-size: 0.7rem;
|
||
padding: 0.1rem 0.4rem;
|
||
background: var(--color-bg);
|
||
border-radius: 3px;
|
||
}
|
||
|
||
/* Right Panel - Detail */
|
||
.detail-panel {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
padding: 2rem;
|
||
}
|
||
|
||
.detail-placeholder {
|
||
text-align: center;
|
||
color: var(--color-muted);
|
||
padding: 4rem;
|
||
}
|
||
|
||
.detail-card {
|
||
background: var(--color-card);
|
||
border-radius: 8px;
|
||
padding: 2rem;
|
||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||
margin-bottom: 1rem;
|
||
}
|
||
|
||
.detail-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: flex-start;
|
||
margin-bottom: 1rem;
|
||
}
|
||
|
||
.detail-title {
|
||
font-size: 1.3rem;
|
||
color: var(--color-blue);
|
||
}
|
||
|
||
.detail-id {
|
||
font-size: 0.9rem;
|
||
color: var(--color-muted);
|
||
}
|
||
|
||
.score-display {
|
||
text-align: center;
|
||
padding: 1rem;
|
||
}
|
||
|
||
.score-big {
|
||
font-size: 3rem;
|
||
font-weight: bold;
|
||
color: var(--color-blue);
|
||
}
|
||
|
||
.score-label {
|
||
font-size: 0.9rem;
|
||
color: var(--color-muted);
|
||
}
|
||
|
||
.matrix-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||
gap: 0.5rem;
|
||
margin: 1rem 0;
|
||
}
|
||
|
||
.matrix-item {
|
||
padding: 0.75rem;
|
||
background: var(--color-bg);
|
||
border-radius: 4px;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
|
||
.matrix-label {
|
||
font-size: 0.85rem;
|
||
}
|
||
|
||
.matrix-rating {
|
||
font-weight: bold;
|
||
}
|
||
|
||
.rating-pos { color: var(--color-green); }
|
||
.rating-neg { color: #dc3545; }
|
||
.rating-neutral { color: var(--color-muted); }
|
||
|
||
/* Matrix Table (5x5 Grid wie im PDF) */
|
||
.matrix-table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
margin: 0.5rem 0;
|
||
font-size: 0.85rem;
|
||
}
|
||
|
||
.matrix-table th, .matrix-table td {
|
||
border: 1px solid var(--color-lightgray);
|
||
padding: 0.4rem 0.5rem;
|
||
text-align: center;
|
||
}
|
||
|
||
.matrix-table thead th {
|
||
background: var(--color-blue);
|
||
color: white;
|
||
font-weight: normal;
|
||
}
|
||
|
||
.matrix-table tbody th {
|
||
background: var(--color-bg);
|
||
text-align: left;
|
||
font-weight: normal;
|
||
}
|
||
|
||
.matrix-table .positive {
|
||
background: var(--color-green);
|
||
color: white;
|
||
font-weight: bold;
|
||
}
|
||
|
||
.matrix-table .negative {
|
||
background: #dc3545;
|
||
color: white;
|
||
font-weight: bold;
|
||
}
|
||
|
||
.matrix-table .neutral {
|
||
background: #f0f0f0;
|
||
}
|
||
|
||
/* Themen Tags in Detail */
|
||
.themen-tags .tag {
|
||
display: inline-block;
|
||
margin-right: 0.5rem;
|
||
margin-bottom: 0.25rem;
|
||
padding: 0.25rem 0.5rem;
|
||
background: var(--color-blue);
|
||
color: white;
|
||
border-radius: 3px;
|
||
font-size: 0.8rem;
|
||
}
|
||
|
||
/* Kernpunkte Liste */
|
||
.kernpunkte-list {
|
||
margin-left: 1.5rem;
|
||
}
|
||
|
||
.kernpunkte-list li {
|
||
margin-bottom: 0.5rem;
|
||
}
|
||
|
||
.section-title {
|
||
font-size: 1.1rem;
|
||
color: var(--color-darkgray);
|
||
margin: 1.5rem 0 0.75rem;
|
||
padding-bottom: 0.5rem;
|
||
border-bottom: 1px solid var(--color-lightgray);
|
||
}
|
||
|
||
.text-block {
|
||
background: var(--color-bg);
|
||
padding: 1rem;
|
||
border-radius: 4px;
|
||
margin: 0.5rem 0;
|
||
}
|
||
|
||
.strength-list, .weakness-list {
|
||
list-style: none;
|
||
}
|
||
|
||
.strength-list li::before {
|
||
content: "✓ ";
|
||
color: var(--color-green);
|
||
}
|
||
|
||
.weakness-list li::before {
|
||
content: "✗ ";
|
||
color: #dc3545;
|
||
}
|
||
|
||
.btn-pdf {
|
||
display: inline-block;
|
||
padding: 0.75rem 1.5rem;
|
||
background: var(--color-blue);
|
||
color: white;
|
||
text-decoration: none;
|
||
border-radius: 4px;
|
||
margin-top: 1rem;
|
||
}
|
||
|
||
.btn-pdf:hover {
|
||
background: #007b82;
|
||
}
|
||
|
||
/* Upload Tab */
|
||
.upload-section {
|
||
background: var(--color-card);
|
||
border-radius: 8px;
|
||
padding: 2rem;
|
||
max-width: 600px;
|
||
margin: 0 auto;
|
||
}
|
||
|
||
.tabs {
|
||
display: flex;
|
||
gap: 1rem;
|
||
margin-bottom: 1.5rem;
|
||
}
|
||
|
||
.tab-btn {
|
||
padding: 0.5rem 1rem;
|
||
border: none;
|
||
background: none;
|
||
cursor: pointer;
|
||
border-bottom: 2px solid transparent;
|
||
color: var(--color-darkgray);
|
||
}
|
||
|
||
.tab-btn.active {
|
||
border-bottom-color: var(--color-blue);
|
||
color: var(--color-blue);
|
||
}
|
||
|
||
textarea {
|
||
width: 100%;
|
||
min-height: 200px;
|
||
padding: 1rem;
|
||
border: 1px solid var(--color-lightgray);
|
||
border-radius: 4px;
|
||
font-family: inherit;
|
||
resize: vertical;
|
||
}
|
||
|
||
.file-drop {
|
||
border: 2px dashed var(--color-lightgray);
|
||
border-radius: 8px;
|
||
padding: 3rem;
|
||
text-align: center;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.file-drop:hover {
|
||
border-color: var(--color-blue);
|
||
}
|
||
|
||
.btn-analyze {
|
||
display: block;
|
||
width: 100%;
|
||
padding: 1rem;
|
||
background: var(--color-green);
|
||
color: white;
|
||
border: none;
|
||
border-radius: 4px;
|
||
font-size: 1rem;
|
||
cursor: pointer;
|
||
margin-top: 1rem;
|
||
}
|
||
|
||
.btn-analyze:hover {
|
||
background: #728a2b;
|
||
}
|
||
|
||
.btn-analyze:disabled {
|
||
background: #ccc;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
/* Mode Toggle */
|
||
.mode-toggle {
|
||
display: flex;
|
||
background: var(--color-bg);
|
||
border-radius: 4px;
|
||
padding: 0.25rem;
|
||
margin-left: auto;
|
||
}
|
||
|
||
.mode-btn {
|
||
padding: 0.5rem 1rem;
|
||
border: none;
|
||
background: none;
|
||
cursor: pointer;
|
||
border-radius: 4px;
|
||
font-size: 0.9rem;
|
||
color: var(--color-darkgray);
|
||
}
|
||
|
||
.mode-btn.active {
|
||
background: var(--color-card);
|
||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||
}
|
||
|
||
.hamburger-dropdown {
|
||
display: none;
|
||
position: absolute;
|
||
right: 0;
|
||
top: 100%;
|
||
background: var(--color-card);
|
||
border: 1px solid #ddd;
|
||
border-radius: 6px;
|
||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||
min-width: 180px;
|
||
z-index: 100;
|
||
padding: 0.3rem 0;
|
||
}
|
||
.hamburger-dropdown.open { display: block; }
|
||
.hamburger-dropdown a,
|
||
.hamburger-dropdown button {
|
||
display: block;
|
||
width: 100%;
|
||
padding: 0.5rem 1rem;
|
||
text-align: left;
|
||
text-decoration: none;
|
||
color: var(--color-darkgray);
|
||
background: none;
|
||
border: none;
|
||
cursor: pointer;
|
||
font-size: 0.9rem;
|
||
}
|
||
.hamburger-dropdown a:hover,
|
||
.hamburger-dropdown button:hover {
|
||
background: #f5f5f5;
|
||
}
|
||
|
||
/* Loading */
|
||
.loading {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 1rem;
|
||
padding: 2rem;
|
||
}
|
||
|
||
.spinner {
|
||
width: 30px;
|
||
height: 30px;
|
||
border: 3px solid var(--color-lightgray);
|
||
border-top-color: var(--color-blue);
|
||
border-radius: 50%;
|
||
animation: spin 1s linear infinite;
|
||
}
|
||
|
||
@keyframes spin {
|
||
to { transform: rotate(360deg); }
|
||
}
|
||
|
||
/* Mobile Back-to-List Button (nur Mobile sichtbar) */
|
||
.btn-back-mobile {
|
||
display: none;
|
||
padding: 0.5rem 0.75rem;
|
||
background: var(--color-bg);
|
||
border: 1px solid var(--color-lightgray);
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
margin-bottom: 1rem;
|
||
font-size: 0.85rem;
|
||
color: var(--color-darkgray);
|
||
}
|
||
|
||
/* Responsive */
|
||
@media (max-width: 900px) {
|
||
.header {
|
||
padding: 0.75rem 1rem;
|
||
gap: 0.5rem;
|
||
}
|
||
|
||
.header h1 {
|
||
font-size: 1.2rem;
|
||
}
|
||
|
||
.header .subtitle {
|
||
font-size: 0.8rem;
|
||
flex-basis: 100%;
|
||
order: 10;
|
||
}
|
||
|
||
.mode-toggle {
|
||
margin-left: auto;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.mode-btn {
|
||
padding: 0.4rem 0.6rem;
|
||
font-size: 0.85rem;
|
||
}
|
||
|
||
.main-container {
|
||
flex-direction: column;
|
||
flex: 1;
|
||
min-height: 0;
|
||
}
|
||
|
||
.list-panel {
|
||
width: 100%;
|
||
min-width: 0;
|
||
border-right: none;
|
||
border-bottom: 1px solid var(--color-lightgray);
|
||
}
|
||
|
||
.list-content {
|
||
max-height: none;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.detail-panel {
|
||
padding: 1rem;
|
||
overflow: visible;
|
||
flex: none;
|
||
}
|
||
|
||
.btn-back-mobile {
|
||
display: inline-block;
|
||
}
|
||
|
||
/* Mobile: Detail-Ansicht blendet Liste aus (#115) */
|
||
.list-panel.mobile-hidden {
|
||
display: none;
|
||
}
|
||
|
||
.detail-panel.mobile-fullscreen {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
z-index: 100;
|
||
background: white;
|
||
overflow-y: auto;
|
||
padding: 1rem;
|
||
}
|
||
|
||
[data-theme="dark"] .detail-panel.mobile-fullscreen {
|
||
background: var(--color-bg);
|
||
}
|
||
|
||
.list-item {
|
||
padding: 0.75rem;
|
||
min-height: 44px; /* Touch-Target */
|
||
}
|
||
}
|
||
|
||
@media (max-width: 600px) {
|
||
.header h1 { font-size: 1.1rem; }
|
||
.header .subtitle { display: none; }
|
||
.detail-card { padding: 1rem; }
|
||
.matrix-table { font-size: 0.7rem; }
|
||
.matrix-table th, .matrix-table td { padding: 0.25rem 0.3rem; }
|
||
.matrix-grid { grid-template-columns: 1fr; }
|
||
.upload-section { padding: 1rem; }
|
||
.file-drop { padding: 1.5rem; }
|
||
.score-big { font-size: 2.2rem; }
|
||
.detail-header { flex-wrap: wrap; gap: 0.5rem; }
|
||
.hamburger-dropdown a, .hamburger-dropdown button {
|
||
min-height: 44px;
|
||
display: flex;
|
||
align-items: center;
|
||
}
|
||
}
|
||
|
||
/* Stats Bar */
|
||
.stats-bar {
|
||
display: flex;
|
||
gap: 2rem;
|
||
padding: 1rem;
|
||
background: var(--color-bg);
|
||
border-bottom: 1px solid var(--color-lightgray);
|
||
}
|
||
|
||
.stat {
|
||
text-align: center;
|
||
}
|
||
|
||
.stat-value {
|
||
font-size: 1.5rem;
|
||
font-weight: bold;
|
||
color: var(--color-blue);
|
||
}
|
||
|
||
.stat-label {
|
||
font-size: 0.8rem;
|
||
color: var(--color-muted);
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<header class="header">
|
||
<h1>{{ app_name }}</h1>
|
||
<span class="subtitle">Gemeinwohl-Matrix 2.0 für Gemeinden</span>
|
||
<select class="bundesland-select" id="bundesland-select" onchange="changeBundesland(this.value)">
|
||
{% for bl in bundeslaender %}
|
||
<option value="{{ bl.code }}" {% if bl.active %}{% else %}disabled{% endif %}>
|
||
{{ bl.name }}{% if not bl.active %} (bald){% endif %}
|
||
</option>
|
||
{% endfor %}
|
||
</select>
|
||
<div class="mode-toggle">
|
||
<button class="mode-btn active" onclick="showMode('browse')">📋 Durchsuchen</button>
|
||
<button class="mode-btn" id="btn-bookmarks" onclick="showMode('bookmarks')" style="display:none;">⭐ Merkliste</button>
|
||
<button class="mode-btn" onclick="showMode('tags')">🏷️ Tags</button>
|
||
<div class="hamburger-wrapper" style="position:relative;display:inline-block;">
|
||
<button class="mode-btn" onclick="document.getElementById('hamburger-menu').classList.toggle('open')" style="font-size:1.2rem;padding:0.3rem 0.6rem;" aria-label="Menü öffnen">☰</button>
|
||
<div id="hamburger-menu" class="hamburger-dropdown">
|
||
<a href="/quellen">📚 Quellen</a>
|
||
<a href="/methodik">🔍 Methodik</a>
|
||
<a href="/impressum">📋 Impressum</a>
|
||
<a href="/datenschutz">🔒 Datenschutz</a>
|
||
<hr style="margin:0.3rem 0;border:none;border-top:1px solid #eee;">
|
||
<button id="btn-auswertungen" style="display:none;" onclick="event.stopPropagation();showMode('auswertungen');document.getElementById('hamburger-menu').classList.remove('open');">📈 Auswertungen</button>
|
||
<button id="btn-admin" style="display:none;" onclick="event.stopPropagation();showMode('admin');document.getElementById('hamburger-menu').classList.remove('open');">⚙ Administration</button>
|
||
<hr style="margin:0.3rem 0;border:none;border-top:1px solid #eee;">
|
||
<button id="auth-btn" onclick="event.stopPropagation();document.getElementById('auth-modal').style.display='flex';document.getElementById('hamburger-menu').classList.remove('open');">🔑 Anmelden / Registrieren</button>
|
||
<button id="subs-btn" style="display:none;" onclick="event.stopPropagation();document.getElementById('subs-panel').style.display='block';loadSubscriptions();document.getElementById('hamburger-menu').classList.remove('open');">📧 E-Mail-Abos</button>
|
||
<hr style="margin:0.3rem 0;border:none;border-top:1px solid #eee;">
|
||
<button onclick="event.stopPropagation();toggleDarkMode();">🌙 Dark Mode</button>
|
||
<hr style="margin:0.3rem 0;border:none;border-top:1px solid #eee;">
|
||
<a href="/" id="v2-design-link" style="color:var(--color-green);font-weight:600;">✨ Zum neuen Design →</a>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
<div class="main-container" id="browse-mode">
|
||
<!-- Left: List -->
|
||
<aside class="list-panel">
|
||
<div class="list-header">
|
||
<!--
|
||
#16: zwei klar getrennte Suchfelder. Das erste filtert
|
||
in der DB der bereits geprüften Anträge (Live, debounced).
|
||
Das zweite triggert per Enter oder Button eine Live-
|
||
Anfrage gegen den Landtag-Adapter. Beide schreiben in
|
||
dieselbe Liste, unterscheiden sich aber visuell und
|
||
semantisch klar.
|
||
-->
|
||
<div class="search-row" style="flex-direction: column; gap: 0.4rem;">
|
||
<div style="display: flex; gap: 0.4rem; width: 100%;">
|
||
<input type="text" class="search-box" id="search-input"
|
||
placeholder="📊 Suche in geprüften Anträgen (DB)…"
|
||
aria-label="Anträge durchsuchen"
|
||
oninput="debounceSearch(this.value)"
|
||
style="flex: 1;">
|
||
</div>
|
||
<div style="display: flex; gap: 0.4rem; width: 100%;">
|
||
<input type="text" class="search-box" id="landtag-search-input"
|
||
placeholder="🏛️ Im Landtag suchen (live)…"
|
||
onkeydown="if(event.key==='Enter')searchLandtag()"
|
||
style="flex: 1;">
|
||
<button class="btn-landtag" id="btn-landtag" onclick="searchLandtag()">🔍 Suchen</button>
|
||
</div>
|
||
</div>
|
||
<div class="list-filters">
|
||
<button class="filter-btn active" data-filter="all" onclick="setScoreFilter('all', this)">Alle</button>
|
||
<button class="filter-btn" data-filter="high" onclick="setScoreFilter('high', this)">8-10</button>
|
||
<button class="filter-btn" data-filter="mid" onclick="setScoreFilter('mid', this)">5-7</button>
|
||
<button class="filter-btn" data-filter="low" onclick="setScoreFilter('low', this)">0-4</button>
|
||
<select id="partei-filter" onchange="setParteiFilter(this.value)" style="padding: 0.25rem 0.5rem; border-radius: 20px; border: 1px solid var(--color-lightgray); font-size: 0.8rem; cursor: pointer;">
|
||
<option value="">Alle Parteien</option>
|
||
</select>
|
||
<select id="sort-select" onchange="setSortOrder(this.value)" style="padding: 0.25rem 0.5rem; border-radius: 20px; border: 1px solid var(--color-lightgray); font-size: 0.8rem; cursor: pointer;">
|
||
<option value="score-desc">↓ GWÖ-Score</option>
|
||
<option value="score-asc">↑ GWÖ-Score</option>
|
||
<option value="date-desc">↓ Datum</option>
|
||
<option value="date-asc">↑ Datum</option>
|
||
<option value="nr-desc">↓ Nummer</option>
|
||
<option value="title-asc">A-Z Titel</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="stats-bar" style="padding: 0.4rem 1rem; position: relative;">
|
||
<span id="stats-summary" style="font-size: 0.8rem; cursor: help;"><strong id="stat-total">0</strong> geprüft · <strong id="stat-high">0</strong> vorbildlich · Ø <strong id="stat-avg">0</strong></span>
|
||
<span style="color: var(--color-lightgray); margin: 0 0.3rem;">|</span>
|
||
<span id="partei-stats" style="font-size: 0.8rem; display: flex; gap: 0.5rem; flex-wrap: wrap; cursor: help;"></span>
|
||
<span id="bundesland-stats" style="display:none;"></span>
|
||
<!-- Tooltip für BL-Details (hover über Gesamtzahl) -->
|
||
<div id="bl-tooltip" onmouseenter="this.style.display='block'" onmouseleave="this.style.display='none'" style="display:none;position:absolute;top:100%;left:0;background:var(--color-card);border:1px solid var(--color-lightgray);border-radius:0 0 6px 6px;padding:0.75rem 1rem;box-shadow:0 4px 12px rgba(0,0,0,0.1);z-index:51;font-size:0.8rem;max-height:70vh;overflow-y:auto;min-width:280px;"></div>
|
||
<!-- Tooltip für Partei-Details (hover über Parteien) -->
|
||
<div id="partei-tooltip" onmouseenter="this.style.display='block'" onmouseleave="this.style.display='none'" style="display:none;position:absolute;top:100%;right:0;background:var(--color-card);border:1px solid var(--color-lightgray);border-radius:0 0 6px 6px;padding:0.75rem 1rem;box-shadow:0 4px 12px rgba(0,0,0,0.1);z-index:51;font-size:0.8rem;max-height:70vh;overflow-y:auto;min-width:280px;"></div>
|
||
</div>
|
||
<div class="list-content" id="list-content">
|
||
<div class="loading">
|
||
<div class="spinner"></div>
|
||
<span>Lade Bewertungen...</span>
|
||
</div>
|
||
</div>
|
||
</aside>
|
||
|
||
<!-- Right: Detail -->
|
||
<main class="detail-panel" id="detail-panel">
|
||
<div class="detail-placeholder">
|
||
<p>👈 Wähle einen Antrag aus der Liste</p>
|
||
</div>
|
||
</main>
|
||
</div>
|
||
|
||
<!-- Bookmarks / Merkliste Mode -->
|
||
<div class="main-container" id="bookmarks-mode" style="display: none;">
|
||
<div class="list-panel" style="max-width: 100%;">
|
||
<h2 style="color: var(--color-blue); margin-bottom: 1rem;">⭐ Merkliste</h2>
|
||
<div id="bookmarks-content">
|
||
<p style="color: #888;">Lade Merkliste...</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Clusters Mode (#105) -->
|
||
<div class="main-container" id="clusters-mode" style="display: none;">
|
||
<div class="list-panel" style="max-width: 100%;">
|
||
<h2 style="color: var(--color-blue); margin-bottom: 0.3rem;">🎯 Antrags-Cluster</h2>
|
||
<p style="font-size: 0.85rem; color: #666; margin-bottom: 0.5rem;">
|
||
Anträge gruppiert nach semantischer Ähnlichkeit (v4-Embeddings).
|
||
Klick auf einen Cluster → Force-Graph mit Cosine-Ähnlichkeit als Kanten.
|
||
</p>
|
||
<div style="margin-bottom: 0.5rem; display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap;">
|
||
<label style="font-size: 0.85rem;">Bundesland:
|
||
<select id="cluster-bl" onchange="loadClusters()" style="padding: 0.3rem; border: 1px solid #ddd; border-radius: 4px;">
|
||
<option value="">Alle</option>
|
||
{% for bl in bundeslaender if bl.code != 'ALL' and bl.active %}
|
||
<option value="{{ bl.code }}">{{ bl.name }}</option>
|
||
{% endfor %}
|
||
</select>
|
||
</label>
|
||
<label style="font-size: 0.85rem;">Schwelle:
|
||
<input type="range" id="cluster-threshold" min="0.40" max="0.80" step="0.05" value="0.55"
|
||
oninput="document.getElementById('cluster-thr-val').textContent = this.value" onchange="loadClusters()">
|
||
<span id="cluster-thr-val">0.55</span>
|
||
</label>
|
||
<label style="font-size: 0.85rem;">Ansicht:
|
||
<select id="cluster-view" onchange="renderClusters()" style="padding: 0.3rem; border: 1px solid #ddd; border-radius: 4px;">
|
||
<option value="bubbles">🫧 Bubble-Chart</option>
|
||
<option value="list">📋 Liste</option>
|
||
</select>
|
||
</label>
|
||
</div>
|
||
<div id="clusters-content" style="min-height: 600px;">
|
||
<p style="color: #888;">Lade Cluster…</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Force-Graph Modal für einen einzelnen Cluster -->
|
||
<div id="force-modal" style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.6);z-index:400;" onclick="if(event.target===this)closeForceModal()">
|
||
<div style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:min(900px,95vw);height:min(700px,90vh);background:var(--color-card);border-radius:8px;box-shadow:0 12px 48px rgba(0,0,0,0.3);display:flex;flex-direction:column;overflow:hidden;">
|
||
<div style="padding:1rem;border-bottom:1px solid #eee;display:flex;justify-content:space-between;align-items:center;">
|
||
<div>
|
||
<h3 id="force-title" style="color:var(--color-blue);margin:0;">Cluster</h3>
|
||
<div id="force-meta" style="font-size:0.8rem;color:#888;margin-top:0.2rem;"></div>
|
||
</div>
|
||
<button onclick="closeForceModal()" style="background:none;border:none;font-size:1.4rem;cursor:pointer;">✕</button>
|
||
</div>
|
||
<div id="force-container" style="flex:1;position:relative;background:var(--color-bg);"></div>
|
||
<div id="force-legend" style="padding:0.5rem 1rem;border-top:1px solid #eee;font-size:0.75rem;color:#666;display:flex;gap:1rem;flex-wrap:wrap;">
|
||
<span>Knoten-Größe: GWÖ-Score</span>
|
||
<span>Kanten-Dicke: Ähnlichkeit</span>
|
||
<span>Farbe: dominante Fraktion</span>
|
||
<span style="margin-left:auto;">Tipp: Knoten ziehen, zum Details-Panel klicken</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Tags Mode -->
|
||
<div class="main-container" id="tags-mode" style="display: none;">
|
||
<aside class="list-panel" style="display:flex;flex-direction:column;">
|
||
<div class="list-header" style="flex-shrink:0;">
|
||
<h3 style="margin-bottom: 0.5rem;">🏷️ Filter nach Tags</h3>
|
||
<input type="text" class="search-box" id="tag-search-input" placeholder="Tags durchsuchen..." oninput="filterTagCloud(this.value)">
|
||
<div id="active-tags" style="margin-top: 0.5rem; display: flex; flex-wrap: wrap; gap: 0.3rem;"></div>
|
||
<button onclick="clearTagFilters()" style="margin-top: 0.5rem; padding: 0.3rem 0.8rem; background: #dc3545; color: white; border: none; border-radius: 4px; cursor: pointer; display: none; font-size: 0.8rem;" id="clear-tags-btn">✕ Zurücksetzen</button>
|
||
</div>
|
||
<div id="tag-cloud" style="padding: 0.75rem; border-bottom: 1px solid var(--color-lightgray); max-height: 40vh; overflow-y: auto; flex-shrink: 0;">
|
||
<div class="loading"><div class="spinner"></div><span>Lade Tags...</span></div>
|
||
</div>
|
||
<div id="tag-results-list" style="padding: 0; flex: 1; overflow-y: auto;">
|
||
<p style="color: #888; padding: 1rem;">Klicke Tags oben um Anträge zu filtern.</p>
|
||
</div>
|
||
</aside>
|
||
<main class="detail-panel" id="tag-detail-panel">
|
||
<div class="detail-placeholder">
|
||
<p>Wähle einen Antrag aus der Tag-Liste um Details zu sehen.</p>
|
||
</div>
|
||
</main>
|
||
</div>
|
||
|
||
<!-- Auswertungen Mode (#131) -->
|
||
<div class="main-container" id="auswertungen-mode" style="display: none;">
|
||
<aside class="list-panel" style="display:flex;flex-direction:column;width:280px;min-width:220px;">
|
||
<div class="list-header" style="flex-shrink:0;">
|
||
<h3>📈 Auswertungen</h3>
|
||
</div>
|
||
<nav style="flex:1;overflow-y:auto;">
|
||
<div style="padding:0.5rem 0;">
|
||
<div style="font-size:0.75rem;color:var(--color-muted);padding:0.3rem 1rem;text-transform:uppercase;letter-spacing:0.05em;">Übersichten</div>
|
||
<a href="#" onclick="showAuswertung('matrix');return false;" class="admin-link" style="display:block;padding:0.5rem 1rem;color:var(--color-text);text-decoration:none;font-size:0.9rem;border-left:3px solid transparent;">📊 Bundesland × Partei</a>
|
||
<a href="#" onclick="showAuswertung('themen');return false;" class="admin-link" style="display:block;padding:0.5rem 1rem;color:var(--color-text);text-decoration:none;font-size:0.9rem;border-left:3px solid transparent;">🏷 Themen × Fraktion</a>
|
||
<a href="#" onclick="showAuswertung('cluster');return false;" class="admin-link" style="display:block;padding:0.5rem 1rem;color:var(--color-text);text-decoration:none;font-size:0.9rem;border-left:3px solid transparent;">🎯 Antrag-Cluster</a>
|
||
</div>
|
||
<div style="padding:0.5rem 0;">
|
||
<div style="font-size:0.75rem;color:var(--color-muted);padding:0.3rem 1rem;text-transform:uppercase;letter-spacing:0.05em;">Filter</div>
|
||
<div style="padding:0.3rem 1rem;" id="ausw-wp-container" style="display:none;">
|
||
<label style="font-size:0.8rem;">Wahlperiode:<br>
|
||
<select id="ausw-wp" onchange="refreshAuswertung()" style="width:100%;padding:0.3rem;border:1px solid #ddd;border-radius:4px;font-size:0.85rem;">
|
||
<option value="">Alle</option>
|
||
</select>
|
||
</label>
|
||
</div>
|
||
<div style="padding:0.3rem 1rem;">
|
||
<label style="font-size:0.8rem;">Bundesland:<br>
|
||
<select id="ausw-bl" onchange="onAuswBlChange()" style="width:100%;padding:0.3rem;border:1px solid #ddd;border-radius:4px;font-size:0.85rem;">
|
||
<option value="">Alle</option>
|
||
</select>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
</nav>
|
||
</aside>
|
||
<main class="detail-panel" id="auswertungen-detail" style="overflow-y:auto;">
|
||
<div class="detail-placeholder">
|
||
<p>Wähle eine Auswertung links.</p>
|
||
</div>
|
||
</main>
|
||
</div>
|
||
|
||
<!-- Administration Mode -->
|
||
<div class="main-container" id="admin-mode" style="display: none;">
|
||
<aside class="list-panel" style="display:flex;flex-direction:column;width:280px;min-width:220px;">
|
||
<div class="list-header" style="flex-shrink:0;">
|
||
<h3>⚙ Administration</h3>
|
||
</div>
|
||
<nav style="flex:1;overflow-y:auto;" id="admin-nav">
|
||
<div style="padding:0.5rem 0;">
|
||
<div style="font-size:0.75rem;color:var(--color-muted);padding:0.3rem 1rem;text-transform:uppercase;letter-spacing:0.05em;">Export & Daten</div>
|
||
<a href="#" onclick="showAdminSection('export');return false;" class="admin-link" style="display:block;padding:0.5rem 1rem;color:var(--color-text);text-decoration:none;font-size:0.9rem;border-left:3px solid transparent;">📥 Daten exportieren</a>
|
||
<a href="#" onclick="showAdminSection('notifications');return false;" class="admin-link" style="display:block;padding:0.5rem 1rem;color:var(--color-text);text-decoration:none;font-size:0.9rem;border-left:3px solid transparent;">🔔 Benachrichtigungen</a>
|
||
</div>
|
||
<div style="padding:0.5rem 0;">
|
||
<div style="font-size:0.75rem;color:var(--color-muted);padding:0.3rem 1rem;text-transform:uppercase;letter-spacing:0.05em;">Analyse</div>
|
||
<a href="#" onclick="showAdminSection('manual-check');return false;" class="admin-link" style="display:block;padding:0.5rem 1rem;color:var(--color-text);text-decoration:none;font-size:0.9rem;border-left:3px solid transparent;">📤 Manuell prüfen</a>
|
||
<a href="#" id="admin-batch-link" style="display:none;" onclick="showAdminSection('batch');return false;" class="admin-link" style="display:block;padding:0.5rem 1rem;color:var(--color-text);text-decoration:none;font-size:0.9rem;border-left:3px solid transparent;">📦 Batch-Analyse</a>
|
||
</div>
|
||
<div style="padding:0.5rem 0;display:none;" id="admin-users-section">
|
||
<div style="font-size:0.75rem;color:var(--color-muted);padding:0.3rem 1rem;text-transform:uppercase;letter-spacing:0.05em;">Nutzerverwaltung</div>
|
||
<a href="#" onclick="showAdminSection('pending-users');return false;" class="admin-link" style="display:block;padding:0.5rem 1rem;color:var(--color-text);text-decoration:none;font-size:0.9rem;border-left:3px solid transparent;">👥 Freischaltungen</a>
|
||
</div>
|
||
</nav>
|
||
</aside>
|
||
<main class="detail-panel" id="admin-detail">
|
||
<div class="detail-placeholder">
|
||
<p>Wähle einen Menüpunkt links.</p>
|
||
</div>
|
||
</main>
|
||
</div>
|
||
|
||
<!-- Experimentell Mode -->
|
||
<div class="main-container" id="experimental-mode" style="display: none;">
|
||
<aside class="list-panel" style="display:flex;flex-direction:column;width:280px;min-width:220px;">
|
||
<div class="list-header" style="flex-shrink:0;">
|
||
<h3>🧪 Experimentell</h3>
|
||
</div>
|
||
<nav style="flex:1;overflow-y:auto;">
|
||
<div style="padding:0.5rem 0;">
|
||
<a href="#" onclick="showAdminSection('clusters');return false;" class="admin-link" style="display:block;padding:0.5rem 1rem;color:var(--color-text);text-decoration:none;font-size:0.9rem;border-left:3px solid transparent;">🎯 Antrag-Cluster</a>
|
||
<a href="#" onclick="showAdminSection('similar');return false;" class="admin-link" style="display:block;padding:0.5rem 1rem;color:var(--color-text);text-decoration:none;font-size:0.9rem;border-left:3px solid transparent;">🔗 Ähnliche Anträge</a>
|
||
</div>
|
||
</nav>
|
||
</aside>
|
||
<main class="detail-panel" id="experimental-detail">
|
||
<div class="detail-placeholder">
|
||
<p>Wähle ein Experiment links.</p>
|
||
</div>
|
||
</main>
|
||
</div>
|
||
|
||
<!-- Upload Mode -->
|
||
<div class="main-container" id="upload-mode" style="display: none;">
|
||
<div class="detail-panel">
|
||
<div class="upload-section">
|
||
<h2 style="margin-bottom: 1rem; color: var(--color-blue);">Neuen Antrag prüfen</h2>
|
||
|
||
<div class="tabs">
|
||
<button class="tab-btn active" onclick="showTab('text')">Text eingeben</button>
|
||
<button class="tab-btn" onclick="showTab('file')">PDF hochladen</button>
|
||
</div>
|
||
|
||
<div id="tab-text">
|
||
<textarea id="antrag-text" placeholder="Antragstext hier einfügen..."></textarea>
|
||
</div>
|
||
|
||
<div id="tab-file" style="display: none;">
|
||
<div class="file-drop" onclick="document.getElementById('file-input').click()">
|
||
<p>📄 PDF hier ablegen oder klicken</p>
|
||
<input type="file" id="file-input" accept=".pdf" style="display: none" onchange="handleFile(this)">
|
||
<p id="file-name" style="margin-top: 0.5rem; color: var(--color-blue);"></p>
|
||
</div>
|
||
</div>
|
||
|
||
<div style="margin-top: 1rem;">
|
||
<label>Bundesland:</label>
|
||
<select id="bundesland" style="padding: 0.5rem; margin-left: 0.5rem;">
|
||
<option value="" disabled selected>— Bundesland wählen —</option>
|
||
{% for bl in bundeslaender if bl.code != 'ALL' %}
|
||
<option value="{{ bl.code }}" {% if not bl.active %}disabled{% endif %}>
|
||
{{ bl.name }}{% if not bl.active %} (bald){% endif %}
|
||
</option>
|
||
{% endfor %}
|
||
</select>
|
||
</div>
|
||
|
||
<button class="btn-analyze" id="analyze-btn" onclick="startAnalysis()">
|
||
🔍 GWÖ-Analyse starten
|
||
</button>
|
||
|
||
<div id="analysis-status" style="display: none;">
|
||
<div class="loading">
|
||
<div class="spinner"></div>
|
||
<span id="status-text">Analyse läuft...</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="analysis-result" style="display: none; margin-top: 1rem;"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
let allAssessments = [];
|
||
let currentScoreFilter = 'all';
|
||
let currentParteiFilter = '';
|
||
let currentBundesland = 'ALL';
|
||
let searchTimeout = null;
|
||
let isSearching = false;
|
||
let selectedTags = new Set();
|
||
let allTags = {};
|
||
let currentUser = null; // #43: Auth-State
|
||
let currentSort = localStorage.getItem('sortOrder') || 'score-desc';
|
||
|
||
// #43: Auth prüfen beim Load. Steuert ob "Jetzt prüfen" aktiv ist.
|
||
async function initAuth() {
|
||
try {
|
||
const resp = await fetch('/api/auth/me');
|
||
const data = await resp.json();
|
||
currentUser = data.authenticated ? data : null;
|
||
} catch { currentUser = null; }
|
||
updateAuthUI();
|
||
}
|
||
function updateAuthUI() {
|
||
const authBtn = document.getElementById('auth-btn');
|
||
if (!authBtn) return;
|
||
if (currentUser) {
|
||
authBtn.textContent = '✓ Angemeldet (Logout)';
|
||
authBtn.classList.add('logged-in');
|
||
authBtn.style.color = '#889e33';
|
||
authBtn.onclick = () => {
|
||
// Cookie löschen + Keycloak-Logout
|
||
document.cookie = 'access_token=; Max-Age=0; path=/; secure; samesite=lax';
|
||
currentUser = null;
|
||
updateAuthUI();
|
||
loadAssessments(); // Liste neu rendern (Buttons deaktivieren)
|
||
};
|
||
// Admin-Features anzeigen
|
||
if (currentUser.roles && currentUser.roles.includes('admin')) {
|
||
const pendingBtn = document.getElementById('admin-pending-btn');
|
||
if (pendingBtn) pendingBtn.style.display = 'block';
|
||
const batchBtn = document.getElementById('batch-menu-btn');
|
||
if (batchBtn) batchBtn.style.display = 'block';
|
||
const batchLink = document.getElementById('admin-batch-link');
|
||
if (batchLink) batchLink.style.display = 'block';
|
||
const usersSection = document.getElementById('admin-users-section');
|
||
if (usersSection) usersSection.style.display = 'block';
|
||
}
|
||
// Eingeloggte Features einblenden
|
||
const subsBtn = document.getElementById('subs-btn');
|
||
if (subsBtn) subsBtn.style.display = 'block';
|
||
const bookmarksBtn = document.getElementById('btn-bookmarks');
|
||
if (bookmarksBtn) bookmarksBtn.style.display = '';
|
||
const auswBtn = document.getElementById('btn-auswertungen');
|
||
if (auswBtn) auswBtn.style.display = 'block';
|
||
const adminBtn = document.getElementById('btn-admin');
|
||
if (adminBtn) adminBtn.style.display = 'block';
|
||
// Bestehende Liste neu rendern damit Buttons aktiv werden
|
||
if (allAssessments.length > 0) renderList(sortAssessments(allAssessments));
|
||
} else {
|
||
authBtn.textContent = '🔑 Anmelden / Registrieren';
|
||
authBtn.style.color = '';
|
||
authBtn.classList.remove('logged-in');
|
||
authBtn.onclick = () => {
|
||
document.getElementById('auth-modal').style.display = 'flex';
|
||
};
|
||
// Eingeloggte Features ausblenden
|
||
['btn-bookmarks', 'btn-auswertungen', 'btn-admin', 'subs-btn'].forEach(id => {
|
||
const el = document.getElementById(id);
|
||
if (el) el.style.display = 'none';
|
||
});
|
||
// Zurück zu Durchsuchen falls in geschütztem Modus
|
||
const mode = ['admin-mode','auswertungen-mode','bookmarks-mode'].find(
|
||
id => document.getElementById(id)?.style.display === 'flex'
|
||
);
|
||
if (mode) showMode('browse');
|
||
}
|
||
}
|
||
|
||
// Matrix-Feld-Info-Modal
|
||
function showFieldInfo(field, aspect, rating) {
|
||
const labels = {{ matrix_labels | tojson }};
|
||
const explains = {{ matrix_explanations | tojson }};
|
||
const rowNames = {'A':'Ausgelagerte Betriebe, Lieferant:innen','B':'Finanzpartner:innen, Steuerzahler:innen','C':'Politische Führung, Verwaltung','D':'Bürger:innen und Wirtschaft','E':'Staat, Gesellschaft und Natur'};
|
||
const colNames = {1:'Menschenwürde',2:'Solidarität',3:'Ökologische Nachhaltigkeit',4:'Soziale Gerechtigkeit',5:'Transparenz & Mitbestimmung'};
|
||
const row = field[0], col = parseInt(field[1]);
|
||
const ratingText = rating >= 4 ? '++ stark fördernd' : rating >= 1 ? '+ fördernd' : rating === 0 ? '○ neutral / nicht bewertet' : rating >= -3 ? '− widersprechend' : '−− stark widersprechend';
|
||
const ratingColor = rating > 0 ? '#889e33' : rating < 0 ? '#dc3545' : '#888';
|
||
|
||
const html = `
|
||
<div style="position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.4);z-index:300;display:flex;justify-content:center;align-items:center;" onclick="if(event.target===this)this.remove()">
|
||
<div style="background:white;border-radius:8px;padding:1.5rem;max-width:500px;width:90%;box-shadow:0 8px 24px rgba(0,0,0,0.2);">
|
||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem;">
|
||
<h3 style="color:var(--color-blue);margin:0;">${field}: ${labels[field] || field}</h3>
|
||
<button onclick="this.closest('[style*=fixed]').remove()" style="background:none;border:none;font-size:1.2rem;cursor:pointer;">✕</button>
|
||
</div>
|
||
<div style="font-size:0.85rem;color:#666;margin-bottom:0.75rem;">
|
||
<strong>Zeile ${row}:</strong> ${rowNames[row] || ''}<br>
|
||
<strong>Spalte ${col}:</strong> ${colNames[col] || ''}
|
||
</div>
|
||
<div style="background:#f8f9fa;padding:0.75rem;border-radius:6px;margin-bottom:0.75rem;font-size:0.9rem;">
|
||
<strong>Was bedeutet das für Bürger:innen?</strong><br>
|
||
${explains[field] || 'Keine Erklärung verfügbar.'}
|
||
</div>
|
||
${aspect ? `<div style="background:#fffbf0;padding:0.75rem;border-radius:6px;margin-bottom:0.75rem;font-size:0.9rem;border-left:3px solid ${ratingColor};">
|
||
<strong>Bewertung dieses Antrags:</strong><br>
|
||
${aspect}<br>
|
||
<span style="color:${ratingColor};font-weight:bold;">Bewertung: ${rating} (${ratingText})</span>
|
||
</div>` : '<p style="color:#aaa;font-size:0.85rem;">Dieses Feld wird vom Antrag nicht berührt.</p>'}
|
||
</div>
|
||
</div>`;
|
||
document.body.insertAdjacentHTML('beforeend', html);
|
||
}
|
||
|
||
function showColumnInfo(col) {
|
||
const names = {1:'Menschenwürde',2:'Solidarität',3:'Ökologische Nachhaltigkeit',4:'Soziale Gerechtigkeit',5:'Transparenz & Mitbestimmung'};
|
||
const prinzipien = {1:'Rechtsstaatsprinzip',2:'Gemeinnutz',3:'Umwelt-Verantwortung',4:'Sozialstaatsprinzip',5:'Demokratie'};
|
||
const details = {
|
||
1: 'Werden Ihre Grundrechte geschützt? Sind Sie vor willkürlicher Behandlung durch Behörden sicher? Haben alle Menschen — unabhängig von Herkunft, Geschlecht oder Religion — die gleichen Rechte?',
|
||
2: 'Profitiert die Gemeinschaft? Wenn Ihre Kommune Geld ausgibt, kommt es bei den Menschen an? Werden Vereine, Nachbarschaftshilfe und ehrenamtliches Engagement gestärkt?',
|
||
3: 'Können Ihre Kinder noch saubere Luft atmen und in Seen schwimmen? Wird bei Entscheidungen an den Klimawandel gedacht? Gibt es mehr Grün in der Stadt, weniger Versiegelung?',
|
||
4: 'Kann sich jede Familie eine warme Wohnung leisten? Bekommen Kinder aus armen Familien die gleichen Bildungschancen? Gibt es bezahlbare Gesundheitsversorgung für alle?',
|
||
5: 'Können Sie als Bürger:in mitentscheiden, was in Ihrer Stadt passiert? Werden Sie rechtzeitig informiert? Sind Ratssitzungen öffentlich und verständlich?'
|
||
};
|
||
document.body.insertAdjacentHTML('beforeend', `
|
||
<div style="position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.4);z-index:300;display:flex;justify-content:center;align-items:center;" onclick="if(event.target===this)this.remove()">
|
||
<div style="background:white;border-radius:8px;padding:1.5rem;max-width:500px;width:90%;box-shadow:0 8px 24px rgba(0,0,0,0.2);">
|
||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem;">
|
||
<h3 style="color:var(--color-blue);margin:0;">Wert ${col}: ${names[col]}</h3>
|
||
<button onclick="this.closest('[style*=fixed]').remove()" style="background:none;border:none;font-size:1.2rem;cursor:pointer;">✕</button>
|
||
</div>
|
||
<p style="font-size:0.85rem;color:#666;margin-bottom:0.5rem;"><strong>Staatsprinzip:</strong> ${prinzipien[col]}</p>
|
||
<div style="background:#f8f9fa;padding:0.75rem;border-radius:6px;font-size:0.9rem;">${details[col]}</div>
|
||
</div>
|
||
</div>`);
|
||
}
|
||
|
||
function showRowInfo(row) {
|
||
const names = {'A':'Ausgelagerte Betriebe, Lieferant:innen, Dienstleister:innen','B':'Finanzpartner:innen, Geldgeber:innen, Steuerzahler:innen','C':'Politische Führung, Verwaltung, Ehrenamtliche','D':'Bürger:innen und Wirtschaft','E':'Staat, Gesellschaft und Natur'};
|
||
const details = {
|
||
'A': 'Wenn Ihre Stadt einen Spielplatz baut: Wer liefert das Material? Unter welchen Bedingungen wurde es hergestellt? Wird regional eingekauft — beim Handwerker um die Ecke — oder beim billigsten Anbieter aus dem Ausland? Diese Zeile bewertet, wie verantwortungsvoll die öffentliche Hand einkauft.',
|
||
'B': 'Wohin fließen Ihre Steuergelder? Liegt das Geld der Stadt bei einer ethischen Bank oder bei einem Finanzkonzern? Werden Investitionen so getätigt, dass sie der Allgemeinheit nutzen? Hier geht es um den verantwortungsvollen Umgang mit dem Geld, das Sie als Steuerzahler:in einzahlen.',
|
||
'C': 'Wie geht Ihre Stadtverwaltung mit den eigenen Mitarbeitenden um? Gibt es faire Bezahlung, Gleichstellung, familienfreundliche Arbeitszeiten? Engagieren sich Ehrenamtliche — und werden sie dafür wertgeschätzt? Diese Zeile bewertet die interne Kultur der öffentlichen Hand.',
|
||
'D': 'Das ist die Zeile, die Sie direkt betrifft: Funktioniert der Bus? Ist die Schule gut? Bekommen Sie einen Termin beim Amt? Können Sie sich die Miete leisten? Hier wird bewertet, ob die öffentliche Daseinsvorsorge bei Ihnen ankommt — bei allen, nicht nur bei wenigen.',
|
||
'E': 'Denkt Ihre Kommune über den eigenen Tellerrand hinaus? Wird beim Straßenbau an den Klimawandel gedacht? Gibt es Partnerschaften mit strukturschwachen Regionen? Hier wird bewertet, ob Entscheidungen auch für künftige Generationen und Menschen anderswo gut sind.'
|
||
};
|
||
document.body.insertAdjacentHTML('beforeend', `
|
||
<div style="position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.4);z-index:300;display:flex;justify-content:center;align-items:center;" onclick="if(event.target===this)this.remove()">
|
||
<div style="background:white;border-radius:8px;padding:1.5rem;max-width:500px;width:90%;box-shadow:0 8px 24px rgba(0,0,0,0.2);">
|
||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem;">
|
||
<h3 style="color:var(--color-blue);margin:0;">Gruppe ${row}: ${names[row]}</h3>
|
||
<button onclick="this.closest('[style*=fixed]').remove()" style="background:none;border:none;font-size:1.2rem;cursor:pointer;">✕</button>
|
||
</div>
|
||
<div style="background:#f8f9fa;padding:0.75rem;border-radius:6px;font-size:0.9rem;">${details[row]}</div>
|
||
</div>
|
||
</div>`);
|
||
}
|
||
|
||
// Sortierung (#100)
|
||
function setSortOrder(order) {
|
||
currentSort = order;
|
||
localStorage.setItem('sortOrder', order);
|
||
renderList(sortAssessments(allAssessments));
|
||
}
|
||
function sortAssessments(items) {
|
||
const sorted = [...items];
|
||
switch (currentSort) {
|
||
case 'score-desc': return sorted.sort((a, b) => (b.gwoeScore || 0) - (a.gwoeScore || 0));
|
||
case 'score-asc': return sorted.sort((a, b) => (a.gwoeScore || 0) - (b.gwoeScore || 0));
|
||
case 'date-desc': return sorted.sort((a, b) => (b.datum || '').localeCompare(a.datum || ''));
|
||
case 'date-asc': return sorted.sort((a, b) => (a.datum || '').localeCompare(b.datum || ''));
|
||
case 'nr-desc': return sorted.sort((a, b) => {
|
||
const na = parseInt((a.drucksache || '').split('/')[1]) || 0;
|
||
const nb = parseInt((b.drucksache || '').split('/')[1]) || 0;
|
||
return nb - na;
|
||
});
|
||
case 'title-asc': return sorted.sort((a, b) => (a.title || '').localeCompare(b.title || '', 'de'));
|
||
default: return sorted;
|
||
}
|
||
}
|
||
|
||
// Dark Mode (#114)
|
||
function toggleDarkMode() {
|
||
const current = document.documentElement.getAttribute('data-theme');
|
||
const next = current === 'dark' ? 'light' : 'dark';
|
||
document.documentElement.setAttribute('data-theme', next);
|
||
localStorage.setItem('theme', next);
|
||
}
|
||
// Beim Laden: System-Präferenz oder gespeicherte Wahl
|
||
(function() {
|
||
const saved = localStorage.getItem('theme');
|
||
if (saved) {
|
||
document.documentElement.setAttribute('data-theme', saved);
|
||
} else if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||
document.documentElement.setAttribute('data-theme', 'dark');
|
||
}
|
||
})();
|
||
|
||
// Hamburger-Menü schließen bei Klick außerhalb
|
||
document.addEventListener('click', (e) => {
|
||
const menu = document.getElementById('hamburger-menu');
|
||
if (menu && !e.target.closest('.hamburger-wrapper')) {
|
||
menu.classList.remove('open');
|
||
}
|
||
});
|
||
|
||
// Partei-Normalisierung (global, für Stats + Labels)
|
||
function normalizePartei(f) {
|
||
const u = f.toUpperCase();
|
||
if (u === 'AFD') return 'AfD';
|
||
if (u === 'GRÜNE' || u === 'GRUENE' || u === 'BÜNDNIS 90/DIE GRÜNEN') return 'GRÜNE';
|
||
if (u === 'DIE LINKE') return 'LINKE';
|
||
return f;
|
||
}
|
||
|
||
// Map code → parlament_name, vom Backend mit dem Initial-Render geliefert.
|
||
// Wird im Detail-Header und im Listen-Item-Badge-Tooltip verwendet.
|
||
const PARLAMENT_NAMES = {{ parlament_names | tojson }};
|
||
|
||
// localStorage gwoe.ui-Setter deaktiviert — verursachte Redirect-Loop mit v2
|
||
// Umschalter wird allein über Topbar-Links gesteuert, keine Persistenz nötig
|
||
(function () { try { localStorage.removeItem('gwoe.ui'); } catch (e) {} })();
|
||
|
||
// Load assessments on page load — localStorage-Auswahl wiederherstellen
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
initAuth(); // #43: Auth-State prüfen
|
||
// Sort-Auswahl aus localStorage wiederherstellen
|
||
const savedSort = localStorage.getItem('sortOrder');
|
||
if (savedSort) {
|
||
document.getElementById('sort-select').value = savedSort;
|
||
currentSort = savedSort;
|
||
}
|
||
const saved = localStorage.getItem('selectedBundesland');
|
||
const select = document.getElementById('bundesland-select');
|
||
if (saved) {
|
||
// Validieren: existiert die Option?
|
||
const exists = Array.from(select.options).some(
|
||
o => o.value === saved && !o.disabled
|
||
);
|
||
if (exists) {
|
||
currentBundesland = saved;
|
||
select.value = saved;
|
||
}
|
||
}
|
||
// Modus-Klasse für CSS (Badges aus/an)
|
||
document.getElementById('list-content').dataset.mode =
|
||
(currentBundesland === 'ALL') ? 'all' : 'single';
|
||
// Landtag-Button-State für Initial-Auswahl
|
||
const btnLandtag = document.getElementById('btn-landtag');
|
||
if (currentBundesland === 'ALL') {
|
||
btnLandtag.disabled = true;
|
||
btnLandtag.title = 'Landtag-Suche nur mit konkretem Bundesland möglich';
|
||
}
|
||
loadAssessments().then(() => {
|
||
// #132: Direkte Verlinkbarkeit — wenn ?drucksache=X in der URL, Detail öffnen
|
||
const params = new URLSearchParams(window.location.search);
|
||
const ds = params.get('drucksache');
|
||
if (ds) {
|
||
setTimeout(() => showDetail(ds), 300);
|
||
}
|
||
const mode = params.get('mode');
|
||
if (mode && ['auswertungen','admin','tags','bookmarks'].includes(mode)) {
|
||
setTimeout(() => showMode(mode), 100);
|
||
}
|
||
});
|
||
});
|
||
|
||
// #132: Browser-Back schließt Detail-Panel und kehrt zur Liste zurück.
|
||
// pushState passiert in showDetail; hier ist der Gegenpart.
|
||
window.addEventListener('popstate', (e) => {
|
||
const params = new URLSearchParams(window.location.search);
|
||
const ds = params.get('drucksache');
|
||
if (ds) {
|
||
showDetail(ds);
|
||
} else if (document.getElementById('detail-panel').innerHTML.trim()) {
|
||
document.getElementById('detail-panel').innerHTML = '';
|
||
document.querySelectorAll('.list-item').forEach(el => el.classList.remove('active'));
|
||
if (typeof closeMobileDetail === 'function') closeMobileDetail();
|
||
}
|
||
});
|
||
|
||
// Keyboard Shortcuts (#116)
|
||
document.addEventListener('keydown', (e) => {
|
||
// Nicht in Input-Feldern oder Textareas
|
||
if (e.target.matches('input, textarea, select')) return;
|
||
// Nicht bei offenen Modals/Panels
|
||
if (document.querySelector('[style*="display: block"][style*="position: fixed"], [style*="display:block"][style*="position:fixed"]')) return;
|
||
|
||
const items = document.querySelectorAll('#list-content .list-item:not(.scroll-sentinel)');
|
||
if (!items.length) return;
|
||
const active = document.querySelector('#list-content .list-item.active');
|
||
let idx = active ? Array.from(items).indexOf(active) : -1;
|
||
|
||
switch (e.key) {
|
||
case 'j': // nächster
|
||
e.preventDefault();
|
||
idx = Math.min(idx + 1, items.length - 1);
|
||
items[idx].classList.add('active');
|
||
if (active && active !== items[idx]) active.classList.remove('active');
|
||
items[idx].scrollIntoView({ block: 'nearest' });
|
||
break;
|
||
case 'k': // vorheriger
|
||
e.preventDefault();
|
||
idx = Math.max(idx - 1, 0);
|
||
items[idx].classList.add('active');
|
||
if (active && active !== items[idx]) active.classList.remove('active');
|
||
items[idx].scrollIntoView({ block: 'nearest' });
|
||
break;
|
||
case 'Enter': // öffnen
|
||
if (active) {
|
||
e.preventDefault();
|
||
const ds = active.dataset.drucksache;
|
||
if (ds && !active.classList.contains('unchecked')) showDetail(ds);
|
||
}
|
||
break;
|
||
case 'Escape': // Detail schließen → Liste
|
||
if (document.getElementById('detail-panel').innerHTML.trim()) {
|
||
e.preventDefault();
|
||
document.getElementById('detail-panel').innerHTML = '';
|
||
closeMobileDetail();
|
||
// #132: URL aufräumen wenn Detail via Escape geschlossen wird
|
||
if (new URLSearchParams(window.location.search).has('drucksache')) {
|
||
history.pushState({}, '', window.location.pathname);
|
||
}
|
||
}
|
||
break;
|
||
case '/': // Suche fokussieren
|
||
e.preventDefault();
|
||
document.getElementById('search-input').focus();
|
||
break;
|
||
case '?': // Shortcuts-Hilfe
|
||
e.preventDefault();
|
||
alert('Tastenkürzel:\n\nj / k — nächster / vorheriger Antrag\nEnter — Antrag öffnen\nEsc — Detail schließen\n/ — Suche fokussieren');
|
||
break;
|
||
}
|
||
});
|
||
|
||
// Bundestags-Fraktionen (21. WP, ab 2025)
|
||
// Quelle: bundestag.de/parlament/fraktionen
|
||
const BUNDESTAG_FRAKTIONEN = ['CDU/CSU', 'CDU', 'CSU', 'SPD', 'AfD', 'GRÜNE', 'LINKE'];
|
||
|
||
function buildParteienFilter() {
|
||
// Filter-Dropdown: ALLE Parteien aus den Daten (zum Filtern)
|
||
const parteien = new Set();
|
||
allAssessments.forEach(a => {
|
||
(a.fraktionen || []).forEach(f => parteien.add(normalizePartei(f)));
|
||
});
|
||
|
||
const select = document.getElementById('partei-filter');
|
||
const prev = select.value;
|
||
select.innerHTML = '<option value="">Alle Parteien</option>';
|
||
Array.from(parteien).sort().forEach(p => {
|
||
select.innerHTML += `<option value="${p}">${p}</option>`;
|
||
});
|
||
select.value = prev;
|
||
}
|
||
|
||
function buildTagCloud() {
|
||
allTags = {};
|
||
allAssessments.forEach(a => {
|
||
(a.themen || []).forEach(tag => {
|
||
allTags[tag] = (allTags[tag] || 0) + 1;
|
||
});
|
||
});
|
||
renderTagCloud();
|
||
}
|
||
|
||
function renderTagCloud(filter = '') {
|
||
const container = document.getElementById('tag-cloud');
|
||
const filterLower = filter.toLowerCase();
|
||
|
||
// Sortiert nach Häufigkeit
|
||
const sorted = Object.entries(allTags)
|
||
.filter(([tag]) => tag.toLowerCase().includes(filterLower))
|
||
.sort((a, b) => b[1] - a[1]);
|
||
|
||
if (sorted.length === 0) {
|
||
container.innerHTML = '<p style="color: #888;">Keine Tags gefunden</p>';
|
||
return;
|
||
}
|
||
|
||
const maxCount = Math.max(...sorted.map(([, c]) => c));
|
||
|
||
container.innerHTML = '<div style="display: flex; flex-wrap: wrap; gap: 0.5rem;">' +
|
||
sorted.map(([tag, count]) => {
|
||
const size = 0.75 + (count / maxCount) * 0.75;
|
||
const isActive = selectedTags.has(tag);
|
||
return `<button
|
||
onclick="toggleTag('${tag.replace(/'/g, "\\'")}')"
|
||
style="
|
||
font-size: ${size}rem;
|
||
padding: 0.3rem 0.6rem;
|
||
border-radius: 20px;
|
||
border: 1px solid ${isActive ? 'var(--color-blue)' : 'var(--color-lightgray)'};
|
||
background: ${isActive ? 'var(--color-blue)' : 'white'};
|
||
color: ${isActive ? 'white' : 'var(--color-darkgray)'};
|
||
cursor: pointer;
|
||
"
|
||
title="${count} Anträge"
|
||
>${tag} <span style="font-size: 0.7rem; opacity: 0.7;">(${count})</span></button>`;
|
||
}).join('') +
|
||
'</div>';
|
||
}
|
||
|
||
function filterTagCloud(query) {
|
||
renderTagCloud(query);
|
||
}
|
||
|
||
function toggleTag(tag) {
|
||
if (selectedTags.has(tag)) {
|
||
selectedTags.delete(tag);
|
||
} else {
|
||
selectedTags.add(tag);
|
||
}
|
||
updateTagFilterUI();
|
||
filterByTags();
|
||
}
|
||
|
||
function updateTagFilterUI() {
|
||
const container = document.getElementById('active-tags');
|
||
const clearBtn = document.getElementById('clear-tags-btn');
|
||
|
||
if (selectedTags.size === 0) {
|
||
container.innerHTML = '';
|
||
clearBtn.style.display = 'none';
|
||
} else {
|
||
container.innerHTML = Array.from(selectedTags).map(tag =>
|
||
`<span style="padding: 0.25rem 0.5rem; background: var(--color-blue); color: white; border-radius: 4px; font-size: 0.85rem;">
|
||
${tag} <span style="cursor: pointer; margin-left: 0.25rem;" onclick="toggleTag('${tag.replace(/'/g, "\\'")}')">×</span>
|
||
</span>`
|
||
).join('');
|
||
clearBtn.style.display = 'block';
|
||
}
|
||
renderTagCloud(document.getElementById('tag-search-input')?.value || '');
|
||
}
|
||
|
||
function clearTagFilters() {
|
||
selectedTags.clear();
|
||
updateTagFilterUI();
|
||
filterByTags();
|
||
}
|
||
|
||
function filterByTags() {
|
||
const resultsContainer = document.getElementById('tag-results-list');
|
||
|
||
if (selectedTags.size === 0) {
|
||
resultsContainer.innerHTML = '<p style="color: #888;">Wähle Tags aus der Wolke um Anträge zu filtern.</p>';
|
||
return;
|
||
}
|
||
|
||
// Schnittmenge: Anträge müssen ALLE ausgewählten Tags haben
|
||
const filtered = allAssessments.filter(a => {
|
||
const tags = new Set(a.themen || []);
|
||
return Array.from(selectedTags).every(t => tags.has(t));
|
||
});
|
||
|
||
if (filtered.length === 0) {
|
||
resultsContainer.innerHTML = '<p style="color: #888;">Keine Anträge mit dieser Tag-Kombination gefunden.</p>';
|
||
return;
|
||
}
|
||
|
||
resultsContainer.innerHTML = `
|
||
<p style="padding: 0.5rem 1rem; color: #666; font-size: 0.85rem; border-bottom: 1px solid var(--color-lightgray);">${filtered.length} Anträge</p>
|
||
${filtered.map(item => `
|
||
<div class="list-item" onclick="showTagDetail('${item.drucksache}')">
|
||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||
<span style="font-weight: bold; color: var(--color-blue); font-size: 0.9rem;">${item.drucksache}</span>
|
||
<span style="padding: 0.1rem 0.5rem; border-radius: 4px; font-size: 0.8rem; ${item.gwoeScore >= 8 ? 'background: #155724; color: white;' : item.gwoeScore >= 5 ? 'background: #889e33; color: white;' : 'background: #dc3545; color: white;'}">${item.gwoeScore}/10</span>
|
||
</div>
|
||
<div style="font-size: 0.85rem; margin-top: 0.2rem;">${(item.title || 'Ohne Titel').substring(0, 80)}</div>
|
||
<div style="font-size: 0.75rem; color: var(--color-muted);">${(item.fraktionen || []).join(', ')}</div>
|
||
</div>
|
||
`).join('')}
|
||
`;
|
||
}
|
||
|
||
async function loadAssessments() {
|
||
try {
|
||
const url = `/api/assessments?bundesland=${encodeURIComponent(currentBundesland)}`;
|
||
const resp = await fetch(url);
|
||
allAssessments = await resp.json();
|
||
updateStats();
|
||
renderList(sortAssessments(allAssessments));
|
||
buildParteienFilter();
|
||
buildTagCloud();
|
||
} catch (e) {
|
||
console.error('loadAssessments error:', e);
|
||
document.getElementById('list-content').innerHTML =
|
||
`<p style="padding: 1rem; color: #c00;">Fehler beim Laden: ${e.message}</p>`;
|
||
}
|
||
}
|
||
|
||
function updateStats() {
|
||
const checked = allAssessments.filter(a => a.status !== 'unchecked').length;
|
||
const high = allAssessments.filter(a => a.gwoeScore >= 8).length;
|
||
const avg = checked > 0 ? (allAssessments.filter(a => a.gwoeScore != null).reduce((s, a) => s + (a.gwoeScore || 0), 0) / checked).toFixed(1) : 0;
|
||
|
||
document.getElementById('stat-total').textContent = checked;
|
||
document.getElementById('stat-high').textContent = high;
|
||
document.getElementById('stat-avg').textContent = avg;
|
||
|
||
// BL-Tooltip (hover über Gesamtzahl)
|
||
const blTooltip = document.getElementById('bl-tooltip');
|
||
const summaryEl = document.getElementById('stats-summary');
|
||
const blStats = {};
|
||
allAssessments.forEach(a => {
|
||
if (a.gwoeScore == null || !a.bundesland) return;
|
||
if (!blStats[a.bundesland]) blStats[a.bundesland] = { sum: 0, count: 0 };
|
||
blStats[a.bundesland].sum += a.gwoeScore;
|
||
blStats[a.bundesland].count += 1;
|
||
});
|
||
const blCodes = Object.keys(blStats);
|
||
if (blCodes.length > 0) {
|
||
const sortedBl = blCodes
|
||
.map(c => ({ code: c, avg: blStats[c].sum / blStats[c].count, count: blStats[c].count }))
|
||
.sort((a, b) => b.avg - a.avg);
|
||
// Vorbildlich-Zählung pro BL
|
||
const blVorbildlich = {};
|
||
allAssessments.forEach(a => {
|
||
if (!a.bundesland) return;
|
||
if (!blVorbildlich[a.bundesland]) blVorbildlich[a.bundesland] = 0;
|
||
if ((a.gwoeScore || 0) >= 8) blVorbildlich[a.bundesland]++;
|
||
});
|
||
blTooltip.innerHTML = `<div style="margin-bottom:0.5rem;font-weight:bold;">Bundesländer — Übersicht</div>` +
|
||
`<div style="margin-bottom:0.5rem;font-size:0.85rem;color:var(--color-muted);">${checked} geprüft · ${high} vorbildlich (≥8) · Ø ${avg}/10</div>` +
|
||
'<div style="display:grid;grid-template-columns:auto auto auto auto;gap:0.2rem 0.8rem;align-items:center;">' +
|
||
'<strong>BL</strong><strong>Ø Score</strong><strong>Anträge</strong><strong>≥8</strong>' +
|
||
sortedBl.map(b => {
|
||
const color = b.avg >= 7 ? '#889e33' : b.avg >= 4 ? '#fd7e14' : '#dc3545';
|
||
const vb = blVorbildlich[b.code] || 0;
|
||
return `<span>${b.code}</span><span style="color:${color};font-weight:bold;">${b.avg.toFixed(1)}</span><span style="color:#888;">${b.count}</span><span style="color:#888;">${vb}</span>`;
|
||
}).join('') + '</div>';
|
||
summaryEl.onmouseenter = () => { document.getElementById('partei-tooltip').style.display = 'none'; blTooltip.style.display = 'block'; };
|
||
summaryEl.onmouseleave = () => { blTooltip.style.display = 'none'; };
|
||
}
|
||
|
||
// Partei-Durchschnitte berechnen (Normalisierung via globalem normalizePartei)
|
||
// Bei Bundesweit: nur Bundestags-Fraktionen anzeigen
|
||
const parteiStats = {};
|
||
allAssessments.forEach(a => {
|
||
if (a.gwoeScore == null) return;
|
||
(a.fraktionen || []).forEach(f => {
|
||
const norm = normalizePartei(f);
|
||
if (currentBundesland === 'ALL' && !BUNDESTAG_FRAKTIONEN.includes(norm)) return;
|
||
if (!parteiStats[norm]) parteiStats[norm] = { sum: 0, count: 0 };
|
||
parteiStats[norm].sum += a.gwoeScore;
|
||
parteiStats[norm].count += 1;
|
||
});
|
||
});
|
||
|
||
// Sortiert nach Durchschnitt (absteigend)
|
||
const sorted = Object.entries(parteiStats)
|
||
.map(([partei, data]) => ({ partei, avg: data.sum / data.count, count: data.count }))
|
||
.sort((a, b) => b.avg - a.avg);
|
||
|
||
// Vollständige Statistik (alle Parteien, ungefiltert) für Tooltip
|
||
const allParteiStats = {};
|
||
allAssessments.forEach(a => {
|
||
if (a.gwoeScore == null) return;
|
||
(a.fraktionen || []).forEach(f => {
|
||
const norm = normalizePartei(f);
|
||
if (!allParteiStats[norm]) allParteiStats[norm] = { sum: 0, count: 0 };
|
||
allParteiStats[norm].sum += a.gwoeScore;
|
||
allParteiStats[norm].count += 1;
|
||
});
|
||
});
|
||
const allSorted = Object.entries(allParteiStats)
|
||
.map(([partei, data]) => ({ partei, avg: data.sum / data.count, count: data.count }))
|
||
.sort((a, b) => b.avg - a.avg);
|
||
|
||
const container = document.getElementById('partei-stats');
|
||
container.style.cursor = 'help';
|
||
container.style.position = 'relative';
|
||
container.innerHTML = sorted.map(p => {
|
||
const color = p.avg >= 7 ? '#889e33' : p.avg >= 4 ? '#fd7e14' : '#dc3545';
|
||
return `<span style="color: ${color};"><strong>${p.partei}</strong> ${p.avg.toFixed(1)}</span>`;
|
||
}).join('');
|
||
|
||
// Partei-Tooltip (hover über Parteien-Leiste)
|
||
const parteiTooltip = document.getElementById('partei-tooltip');
|
||
// Min/Max + Vorbildlich pro Partei
|
||
const allParteiDetail = {};
|
||
allAssessments.forEach(a => {
|
||
if (a.gwoeScore == null) return;
|
||
(a.fraktionen || []).forEach(f => {
|
||
const norm = normalizePartei(f);
|
||
if (!allParteiDetail[norm]) allParteiDetail[norm] = { scores: [] };
|
||
allParteiDetail[norm].scores.push(a.gwoeScore);
|
||
});
|
||
});
|
||
parteiTooltip.innerHTML = '<div style="margin-bottom:0.5rem;font-weight:bold;">Alle Parteien — Übersicht</div>' +
|
||
'<div style="display:grid;grid-template-columns:auto auto auto auto auto;gap:0.2rem 0.6rem;font-size:0.8rem;">' +
|
||
'<strong>Partei</strong><strong>Ø</strong><strong>Min</strong><strong>Max</strong><strong>n</strong>' +
|
||
allSorted.map(p => {
|
||
const color = p.avg >= 7 ? '#889e33' : p.avg >= 4 ? '#fd7e14' : '#dc3545';
|
||
const detail = allParteiDetail[p.partei];
|
||
const min = detail ? Math.min(...detail.scores) : '-';
|
||
const max = detail ? Math.max(...detail.scores) : '-';
|
||
return `<span>${p.partei}</span><span style="color:${color};font-weight:bold;">${p.avg.toFixed(1)}</span><span style="color:#888;">${min}</span><span style="color:#888;">${max}</span><span style="color:#888;">${p.count}</span>`;
|
||
}).join('') + '</div>';
|
||
container.onmouseenter = () => { document.getElementById('bl-tooltip').style.display = 'none'; parteiTooltip.style.display = 'block'; };
|
||
container.onmouseleave = () => { parteiTooltip.style.display = 'none'; };
|
||
}
|
||
|
||
let _allItems = [];
|
||
let _shownCount = 0;
|
||
const PAGE_SIZE = 30;
|
||
|
||
let _scrollObserver = null;
|
||
|
||
function renderList(items) {
|
||
const container = document.getElementById('list-content');
|
||
_allItems = items;
|
||
_shownCount = 0;
|
||
if (_scrollObserver) { _scrollObserver.disconnect(); _scrollObserver = null; }
|
||
if (items.length === 0) {
|
||
container.innerHTML = '<p style="padding: 1rem; color: #888;">Keine Ergebnisse</p>';
|
||
return;
|
||
}
|
||
container.innerHTML = '';
|
||
appendItems(PAGE_SIZE);
|
||
_setupScrollObserver(container);
|
||
}
|
||
|
||
function _setupScrollObserver(container) {
|
||
if (_allItems.length <= _shownCount) return;
|
||
let sentinel = container.querySelector('.scroll-sentinel');
|
||
if (!sentinel) {
|
||
sentinel = document.createElement('div');
|
||
sentinel.className = 'scroll-sentinel';
|
||
sentinel.style.cssText = 'padding:1rem;text-align:center;color:#888;font-size:0.85rem;';
|
||
container.appendChild(sentinel);
|
||
}
|
||
sentinel.textContent = `${_shownCount} von ${_allItems.length} geladen…`;
|
||
_scrollObserver = new IntersectionObserver(entries => {
|
||
if (entries[0].isIntersecting && _shownCount < _allItems.length) {
|
||
appendItems(PAGE_SIZE);
|
||
if (_shownCount >= _allItems.length) {
|
||
sentinel.remove();
|
||
_scrollObserver.disconnect();
|
||
} else {
|
||
sentinel.textContent = `${_shownCount} von ${_allItems.length} geladen…`;
|
||
container.appendChild(sentinel);
|
||
}
|
||
}
|
||
}, { rootMargin: '200px' });
|
||
_scrollObserver.observe(sentinel);
|
||
}
|
||
|
||
function appendItems(count) {
|
||
const container = document.getElementById('list-content');
|
||
const batch = _allItems.slice(_shownCount, _shownCount + count);
|
||
_shownCount += batch.length;
|
||
container.insertAdjacentHTML('beforeend', batch.map(item => {
|
||
const isUnchecked = item.status === 'unchecked';
|
||
// Skala 0-10
|
||
const scoreClass = isUnchecked ? 'status-unchecked' :
|
||
item.gwoeScore >= 8 ? 'score-high' :
|
||
item.gwoeScore >= 5 ? 'score-mid' :
|
||
item.gwoeScore >= 3 ? 'score-low' : 'score-negative';
|
||
const fraktionen = (item.fraktionen || []).join(', ') || 'k.A.';
|
||
const themen = (item.themen || []).slice(0, 3);
|
||
const scoreText = isUnchecked ? '⏳' : `${item.gwoeScore}/10`;
|
||
|
||
const blBadge = item.bundesland
|
||
? `<span class="bl-badge" title="${PARLAMENT_NAMES[item.bundesland] || item.bundesland}">${item.bundesland}</span>`
|
||
: '';
|
||
return `
|
||
<div class="list-item ${isUnchecked ? 'unchecked' : ''}" data-drucksache="${item.drucksache}" onclick="${isUnchecked ? '' : `showDetail('${item.drucksache}')`}">
|
||
<div class="list-item-header">
|
||
<span class="list-item-id">${blBadge}${item.drucksache}</span>
|
||
<span class="list-item-score ${scoreClass}">${scoreText}</span>
|
||
</div>
|
||
<div class="list-item-title">${(item.title || 'Ohne Titel').length > 80 ? (item.title.substring(0, 80) + '…') : (item.title || 'Ohne Titel')}</div>
|
||
<div class="list-item-meta">${fraktionen} · ${item.datum || ''}</div>
|
||
${isUnchecked ? `
|
||
<button class="btn-check-now"
|
||
${currentUser ? '' : 'disabled title="Nur nach Anmeldung verfügbar" style="opacity:0.5;cursor:not-allowed;"'}
|
||
onclick="event.stopPropagation(); checkNow('${item.drucksache}', this)">
|
||
🔍 Jetzt prüfen
|
||
</button>
|
||
` : `
|
||
<div class="list-item-tags">
|
||
${themen.map(t => `<span class="tag">${t}</span>`).join('')}
|
||
</div>
|
||
`}
|
||
</div>
|
||
`;
|
||
}).join(''));
|
||
}
|
||
|
||
function debounceSearch(query) {
|
||
clearTimeout(searchTimeout);
|
||
searchTimeout = setTimeout(() => performSearch(query), 300);
|
||
}
|
||
|
||
async function performSearch(query) {
|
||
if (query.length < 2) {
|
||
// Show all from DB with current filters
|
||
applyAllFilters();
|
||
return;
|
||
}
|
||
|
||
isSearching = true;
|
||
document.getElementById('list-content').innerHTML = `
|
||
<div class="loading">
|
||
<div class="spinner"></div>
|
||
<span>Suche...</span>
|
||
</div>
|
||
`;
|
||
|
||
try {
|
||
const resp = await fetch(`/api/search?q=${encodeURIComponent(query)}&bundesland=${currentBundesland}`);
|
||
let results = await resp.json();
|
||
|
||
// Score-Filter anwenden
|
||
if (currentScoreFilter !== 'all') {
|
||
results = applyScoreFilter(results, currentScoreFilter);
|
||
}
|
||
|
||
// Partei-Filter anwenden
|
||
if (currentParteiFilter) {
|
||
results = results.filter(a =>
|
||
(a.fraktionen || []).includes(currentParteiFilter)
|
||
);
|
||
}
|
||
|
||
renderList(results);
|
||
} catch (e) {
|
||
document.getElementById('list-content').innerHTML =
|
||
'<p style="padding: 1rem; color: #888;">Suchfehler</p>';
|
||
}
|
||
|
||
isSearching = false;
|
||
}
|
||
|
||
async function checkNow(drucksache, btn) {
|
||
btn.disabled = true;
|
||
btn.textContent = '⏳ Prüfe...';
|
||
|
||
try {
|
||
const resp = await fetch('/api/analyze-drucksache', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||
body: `drucksache=${encodeURIComponent(drucksache)}&bundesland=${currentBundesland}`
|
||
});
|
||
const data = await resp.json();
|
||
|
||
if (data.status === 'queued') {
|
||
btn.textContent = '⏳ Analysiere...';
|
||
pollAnalysis(data.job_id, drucksache, btn);
|
||
} else if (data.status === 'already_checked') {
|
||
btn.textContent = '✓ Bereits geprüft';
|
||
setTimeout(() => loadAssessments(), 500);
|
||
} else {
|
||
btn.textContent = '✗ Fehler';
|
||
btn.disabled = false;
|
||
}
|
||
} catch (e) {
|
||
btn.textContent = '✗ Fehler';
|
||
btn.disabled = false;
|
||
}
|
||
}
|
||
|
||
// ─── Merkliste (#94) ────────────────────────────────────────
|
||
async function loadBookmarksList() {
|
||
const container = document.getElementById('bookmarks-content');
|
||
if (!currentUser) {
|
||
container.innerHTML = '<p style="color:#888;">Bitte anmelden um die Merkliste zu sehen.</p>';
|
||
return;
|
||
}
|
||
container.innerHTML = '<p style="color:#888;">Lade...</p>';
|
||
try {
|
||
const bookmarkIds = await fetch('/api/bookmarks').then(r => r.json());
|
||
if (bookmarkIds.length === 0) {
|
||
container.innerHTML = '<p style="color:#888;">Keine gemerkten Anträge. Klicke auf "🔖 Merken" bei einem Antrag.</p>';
|
||
return;
|
||
}
|
||
// Finde die Assessment-Daten für die gebookmarkten Drucksachen
|
||
const bookmarked = allAssessments.filter(a => bookmarkIds.includes(a.drucksache));
|
||
if (bookmarked.length === 0) {
|
||
container.innerHTML = '<p style="color:#888;">Gemerkte Anträge nicht in der aktuellen Auswahl gefunden. Wechsle zu "Bundesweit".</p>';
|
||
return;
|
||
}
|
||
container.innerHTML = bookmarked.map(item => {
|
||
const scoreClass = item.gwoeScore >= 8 ? 'score-high' : item.gwoeScore >= 5 ? 'score-mid' : item.gwoeScore >= 3 ? 'score-low' : 'score-negative';
|
||
const blBadge = item.bundesland ? `<span class="bl-badge">${item.bundesland}</span>` : '';
|
||
return `
|
||
<div class="list-item" onclick="showMode('browse'); setTimeout(() => showDetail('${item.drucksache}'), 100);" style="cursor:pointer;">
|
||
<div class="list-item-header">
|
||
<span class="list-item-id">${blBadge}${item.drucksache}</span>
|
||
<span class="list-item-score ${scoreClass}">${item.gwoeScore}/10</span>
|
||
</div>
|
||
<div class="list-item-title">${(item.title || '').substring(0, 80)}${(item.title || '').length > 80 ? '…' : ''}</div>
|
||
<div class="list-item-meta">${(item.fraktionen || []).join(', ')} · ${item.datum || ''}</div>
|
||
</div>`;
|
||
}).join('');
|
||
} catch (e) {
|
||
container.innerHTML = '<p style="color:#c00;">Fehler beim Laden der Merkliste.</p>';
|
||
}
|
||
}
|
||
|
||
// ─── Bookmarks + Comments (#94) ─────────────────────────────
|
||
async function toggleBookmark(drucksache, btn) {
|
||
const resp = await fetch('/api/bookmark', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||
body: `drucksache=${encodeURIComponent(drucksache)}`
|
||
});
|
||
if (resp.ok) {
|
||
const data = await resp.json();
|
||
btn.textContent = data.bookmarked ? '⭐ Gemerkt' : '🔖 Merken';
|
||
btn.style.background = data.bookmarked ? '#fff3cd' : 'none';
|
||
}
|
||
}
|
||
|
||
async function loadBookmarkState(drucksache) {
|
||
if (!currentUser) return;
|
||
const bookmarks = await fetch('/api/bookmarks').then(r => r.json());
|
||
const btn = document.getElementById('bookmark-btn-' + drucksache.replace('/', '-'));
|
||
if (btn && bookmarks.includes(drucksache)) {
|
||
btn.textContent = '⭐ Gemerkt';
|
||
btn.style.background = '#fff3cd';
|
||
}
|
||
}
|
||
|
||
async function loadComments(drucksache) {
|
||
const container = document.getElementById('comments-' + drucksache.replace('/', '-'));
|
||
if (!container) return;
|
||
try {
|
||
const comments = await fetch(`/api/comments?drucksache=${encodeURIComponent(drucksache)}`).then(r => r.json());
|
||
// Client-seitig filtern nach Sichtbarkeit
|
||
const visible = comments.filter(c => {
|
||
if (c.visibility === 'all') return true;
|
||
if (!currentUser) return false;
|
||
if (c.visibility === 'authenticated') return true;
|
||
if (c.visibility === 'private') return c.user_id === currentUser.sub;
|
||
// group:XYZ — TODO: gegen Keycloak-Gruppen prüfen
|
||
if (c.visibility?.startsWith('group:')) return true; // Platzhalter
|
||
return false;
|
||
});
|
||
if (visible.length === 0) {
|
||
container.innerHTML = '<span style="color:#aaa;font-size:0.85rem;">Noch keine Kommentare.</span>';
|
||
return;
|
||
}
|
||
const visBadge = (v) => {
|
||
if (v === 'private') return '<span style="font-size:0.7rem;background:#f0f0f0;padding:0.1rem 0.3rem;border-radius:2px;" title="Nur für dich sichtbar">👤</span>';
|
||
if (v === 'authenticated') return '<span style="font-size:0.7rem;background:#e8f4f8;padding:0.1rem 0.3rem;border-radius:2px;" title="Nur für angemeldete Nutzer">🔒</span>';
|
||
if (v?.startsWith('group:')) return `<span style="font-size:0.7rem;background:#f0e6ff;padding:0.1rem 0.3rem;border-radius:2px;" title="Gruppe: ${v.substring(6)}">👥 ${v.substring(6)}</span>`;
|
||
return '<span style="font-size:0.7rem;background:#e8ffe8;padding:0.1rem 0.3rem;border-radius:2px;" title="Öffentlich sichtbar">🌐</span>';
|
||
};
|
||
container.innerHTML = visible.map(c => `
|
||
<div style="padding:0.4rem 0;border-bottom:1px solid #f0f0f0;font-size:0.85rem;">
|
||
<strong>${c.user_name || 'Anonym'}</strong>
|
||
${visBadge(c.visibility)}
|
||
<span style="color:#aaa;font-size:0.75rem;margin-left:0.3rem;">${new Date(c.created_at).toLocaleString('de-DE')}</span>
|
||
${currentUser && currentUser.sub === c.user_id ? `<button onclick="deleteCommentUI(${c.id},'${drucksache}')" style="float:right;background:none;border:none;color:#dc3545;cursor:pointer;font-size:0.75rem;">✕</button>` : ''}
|
||
<div style="margin-top:0.2rem;">${c.text}</div>
|
||
</div>
|
||
`).join('');
|
||
} catch { container.innerHTML = ''; }
|
||
}
|
||
|
||
async function addCommentUI(drucksache) {
|
||
const safeDrs = drucksache.replace('/', '-');
|
||
const input = document.getElementById('comment-input-' + safeDrs);
|
||
const visSelect = document.getElementById('comment-visibility-' + safeDrs);
|
||
if (!input || !input.value.trim()) return;
|
||
const visibility = visSelect ? visSelect.value : 'all';
|
||
await fetch('/api/comment', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||
body: `drucksache=${encodeURIComponent(drucksache)}&text=${encodeURIComponent(input.value)}&visibility=${visibility}`
|
||
});
|
||
input.value = '';
|
||
loadComments(drucksache);
|
||
}
|
||
|
||
async function deleteCommentUI(commentId, drucksache) {
|
||
await fetch(`/api/comment/${commentId}`, {method: 'DELETE'});
|
||
loadComments(drucksache);
|
||
}
|
||
|
||
async function reAnalyze(drucksache, bundesland, btn) {
|
||
if (!currentUser) {
|
||
alert('Bitte zuerst anmelden.');
|
||
return;
|
||
}
|
||
btn.disabled = true;
|
||
btn.style.background = '#ffc107';
|
||
btn.textContent = '⏳ Lösche alte Bewertung...';
|
||
try {
|
||
// Altes Assessment löschen
|
||
const delResp = await fetch(`/api/assessment/delete?drucksache=${encodeURIComponent(drucksache)}`, {method: 'DELETE'});
|
||
if (delResp.status === 401) {
|
||
btn.textContent = '🔒 Nicht angemeldet';
|
||
btn.style.background = '#dc3545';
|
||
setTimeout(() => { btn.textContent = '🔄 Neu bewerten'; btn.style.background = '#6c757d'; btn.disabled = false; }, 3000);
|
||
return;
|
||
}
|
||
btn.textContent = '⏳ Analyse gestartet...';
|
||
// Neue Analyse enqueuen
|
||
const resp = await fetch('/api/analyze-drucksache', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||
body: `drucksache=${encodeURIComponent(drucksache)}&bundesland=${encodeURIComponent(bundesland)}`
|
||
});
|
||
if (resp.status === 401) {
|
||
btn.textContent = '🔒 Nicht angemeldet';
|
||
btn.style.background = '#dc3545';
|
||
setTimeout(() => { btn.textContent = '🔄 Neu bewerten'; btn.style.background = '#6c757d'; btn.disabled = false; }, 3000);
|
||
return;
|
||
}
|
||
const data = await resp.json();
|
||
if (data.status === 'queued') {
|
||
btn.textContent = '⏳ Wird analysiert...';
|
||
btn.style.background = '#009da5';
|
||
pollAnalysis(data.job_id, drucksache, btn);
|
||
} else {
|
||
btn.textContent = '❌ ' + (data.detail || 'Fehler');
|
||
btn.style.background = '#dc3545';
|
||
setTimeout(() => { btn.textContent = '🔄 Neu bewerten'; btn.style.background = '#6c757d'; btn.disabled = false; }, 3000);
|
||
}
|
||
} catch (e) {
|
||
btn.textContent = '❌ ' + e.message;
|
||
btn.style.background = '#dc3545';
|
||
setTimeout(() => { btn.textContent = '🔄 Neu bewerten'; btn.style.background = '#6c757d'; btn.disabled = false; }, 3000);
|
||
}
|
||
}
|
||
|
||
async function pollAnalysis(jobId, drucksache, btn) {
|
||
try {
|
||
const resp = await fetch(`/status/${jobId}`);
|
||
const data = await resp.json();
|
||
|
||
if (data.status === 'completed') {
|
||
btn.textContent = '✓ Geprüft';
|
||
btn.style.background = '#889e33'; // Green
|
||
// Update this item in current list to show as checked
|
||
const listItem = btn.closest('.list-item');
|
||
if (listItem) {
|
||
listItem.classList.remove('unchecked');
|
||
listItem.onclick = () => showDetail(drucksache);
|
||
}
|
||
// Reload assessments in background (for internal list)
|
||
loadAssessments();
|
||
// Show detail for this item
|
||
setTimeout(() => showDetail(drucksache), 500);
|
||
} else if (data.status === 'failed') {
|
||
btn.textContent = '✗ Fehlgeschlagen';
|
||
btn.disabled = false;
|
||
} else {
|
||
setTimeout(() => pollAnalysis(jobId, drucksache, btn), 2000);
|
||
}
|
||
} catch (e) {
|
||
btn.textContent = '✗ Fehler';
|
||
btn.disabled = false;
|
||
}
|
||
}
|
||
|
||
function changeBundesland(code) {
|
||
currentBundesland = code;
|
||
localStorage.setItem('selectedBundesland', code);
|
||
|
||
// Filter zurücksetzen — Parteien & Tags pro Bundesland unterschiedlich,
|
||
// ein "LINKE"-Filter aus LSA würde in NRW eine leere Liste zeigen.
|
||
currentScoreFilter = 'all';
|
||
currentParteiFilter = '';
|
||
selectedTags.clear();
|
||
document.getElementById('search-input').value = '';
|
||
const landtagInput = document.getElementById('landtag-search-input');
|
||
if (landtagInput) landtagInput.value = '';
|
||
document.querySelectorAll('.filter-btn').forEach(b => {
|
||
b.classList.toggle('active', b.dataset.filter === 'all');
|
||
});
|
||
const parteiSelect = document.getElementById('partei-filter');
|
||
if (parteiSelect) parteiSelect.value = '';
|
||
|
||
// Upload-Mode-Dropdown synchronisieren. Bei "ALL" KEIN automatischer
|
||
// Default — der User soll im Upload-Form bewusst ein Bundesland wählen.
|
||
const uploadDropdown = document.getElementById('bundesland');
|
||
if (uploadDropdown) {
|
||
if (code === 'ALL') {
|
||
uploadDropdown.value = '';
|
||
} else {
|
||
uploadDropdown.value = code;
|
||
}
|
||
}
|
||
|
||
// Landtag-Suche-Button im Bundesweit-Modus deaktivieren
|
||
const btnLandtag = document.getElementById('btn-landtag');
|
||
if (code === 'ALL') {
|
||
btnLandtag.disabled = true;
|
||
btnLandtag.title = 'Landtag-Suche nur mit konkretem Bundesland möglich';
|
||
} else {
|
||
btnLandtag.disabled = false;
|
||
btnLandtag.title = '';
|
||
}
|
||
|
||
// Modus-Klasse für CSS (Badges aus/an im Single-Modus)
|
||
document.getElementById('list-content').dataset.mode =
|
||
(code === 'ALL') ? 'all' : 'single';
|
||
|
||
loadAssessments();
|
||
}
|
||
|
||
async function searchLandtag() {
|
||
if (currentBundesland === 'ALL') {
|
||
alert('Landtag-Suche ist nur mit Auswahl eines konkreten Bundeslands möglich.\nBitte oben ein Bundesland auswählen.');
|
||
return;
|
||
}
|
||
// #16: Landtag-Suche zieht aus dem dedizierten Landtag-Input,
|
||
// nicht mehr aus dem DB-Suchfeld.
|
||
const query = document.getElementById('landtag-search-input').value.trim();
|
||
if (query.length < 2) {
|
||
alert('Bitte mindestens 2 Zeichen ins Landtag-Suchfeld eingeben');
|
||
return;
|
||
}
|
||
|
||
const btn = document.getElementById('btn-landtag');
|
||
btn.disabled = true;
|
||
btn.textContent = '⏳ Suche...';
|
||
|
||
document.getElementById('list-content').innerHTML = `
|
||
<div class="loading">
|
||
<div class="spinner"></div>
|
||
<span>Suche im Landtag ${currentBundesland}...</span>
|
||
</div>
|
||
`;
|
||
|
||
try {
|
||
const resp = await fetch(`/api/search-landtag?q=${encodeURIComponent(query)}&bundesland=${currentBundesland}`);
|
||
const results = await resp.json();
|
||
|
||
if (results.error) {
|
||
document.getElementById('list-content').innerHTML = `
|
||
<p style="padding: 1rem; color: #888;">${results.error}</p>
|
||
`;
|
||
} else if (results.length === 0) {
|
||
document.getElementById('list-content').innerHTML = `
|
||
<p style="padding: 1rem; color: #888;">Keine Treffer im Landtag für "${query}"</p>
|
||
`;
|
||
} else {
|
||
// Merge with checked status from DB
|
||
const checkedIds = new Set(allAssessments.map(a => a.drucksache));
|
||
const merged = results.map(r => ({
|
||
...r,
|
||
gwoeScore: checkedIds.has(r.drucksache)
|
||
? allAssessments.find(a => a.drucksache === r.drucksache)?.gwoeScore
|
||
: null,
|
||
status: checkedIds.has(r.drucksache) ? 'checked' : 'unchecked'
|
||
}));
|
||
renderList(merged);
|
||
}
|
||
} catch (e) {
|
||
document.getElementById('list-content').innerHTML = `
|
||
<p style="padding: 1rem; color: #888;">Suchfehler: ${e.message}</p>
|
||
`;
|
||
}
|
||
|
||
btn.disabled = false;
|
||
btn.textContent = '🔍 Im Landtag';
|
||
}
|
||
|
||
function filterList(query) {
|
||
const q = query.toLowerCase();
|
||
let filtered = allAssessments.filter(a =>
|
||
(a.title || '').toLowerCase().includes(q) ||
|
||
(a.drucksache || '').toLowerCase().includes(q) ||
|
||
(a.fraktionen || []).join(' ').toLowerCase().includes(q) ||
|
||
(a.themen || []).join(' ').toLowerCase().includes(q)
|
||
);
|
||
|
||
// Score-Filter anwenden
|
||
if (currentScoreFilter !== 'all') {
|
||
filtered = applyScoreFilter(filtered, currentScoreFilter);
|
||
}
|
||
|
||
// Partei-Filter anwenden
|
||
if (currentParteiFilter) {
|
||
filtered = filtered.filter(a =>
|
||
(a.fraktionen || []).includes(currentParteiFilter)
|
||
);
|
||
}
|
||
|
||
renderList(sortAssessments(filtered));
|
||
}
|
||
|
||
function setScoreFilter(filter, btn) {
|
||
currentScoreFilter = filter;
|
||
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
|
||
btn.classList.add('active');
|
||
applyAllFilters();
|
||
}
|
||
|
||
function setParteiFilter(partei) {
|
||
currentParteiFilter = partei;
|
||
applyAllFilters();
|
||
}
|
||
|
||
function applyAllFilters() {
|
||
let filtered = allAssessments;
|
||
|
||
// Score-Filter
|
||
if (currentScoreFilter !== 'all') {
|
||
filtered = applyScoreFilter(filtered, currentScoreFilter);
|
||
}
|
||
|
||
// Partei-Filter
|
||
if (currentParteiFilter) {
|
||
filtered = filtered.filter(a =>
|
||
(a.fraktionen || []).includes(currentParteiFilter)
|
||
);
|
||
}
|
||
|
||
renderList(sortAssessments(filtered));
|
||
}
|
||
|
||
function applyScoreFilter(items, filter) {
|
||
switch (filter) {
|
||
case 'high': return items.filter(a => a.gwoeScore >= 8);
|
||
case 'mid': return items.filter(a => a.gwoeScore >= 5 && a.gwoeScore < 8);
|
||
case 'low': return items.filter(a => a.gwoeScore < 5);
|
||
default: return items;
|
||
}
|
||
}
|
||
|
||
function closeMobileDetail() {
|
||
document.querySelector('.list-panel')?.classList.remove('mobile-hidden');
|
||
document.querySelector('.detail-panel')?.classList.remove('mobile-fullscreen');
|
||
}
|
||
|
||
async function showDetail(drucksache) {
|
||
// #132: URL aktualisieren für direkte Verlinkbarkeit
|
||
const newUrl = '/?drucksache=' + encodeURIComponent(drucksache);
|
||
if (window.location.search !== '?drucksache=' + encodeURIComponent(drucksache)) {
|
||
history.pushState({drucksache}, '', newUrl);
|
||
}
|
||
// Liste-Item für Basis-Daten (Score, Titel etc.)
|
||
const listItem_ = allAssessments.find(a => a.drucksache === drucksache);
|
||
if (!listItem_) return;
|
||
|
||
// Mobile: Liste verstecken, Detail fullscreen (#115)
|
||
if (window.innerWidth <= 900) {
|
||
document.querySelector('.list-panel')?.classList.add('mobile-hidden');
|
||
document.querySelector('.detail-panel')?.classList.add('mobile-fullscreen');
|
||
}
|
||
|
||
// Highlight active item
|
||
document.querySelectorAll('.list-item').forEach(el => el.classList.remove('active'));
|
||
const listItem = document.querySelector(`.list-item[data-drucksache="${drucksache}"]`);
|
||
if (listItem) listItem.classList.add('active');
|
||
|
||
// Lade-Indikator
|
||
document.getElementById('detail-panel').innerHTML = '<div style="padding:2rem;text-align:center;"><div class="spinner"></div> Lade Detail…</div>';
|
||
|
||
// Volle Assessment-Daten on-demand laden (#122)
|
||
let item;
|
||
try {
|
||
const resp = await fetch(`/api/assessment?drucksache=${encodeURIComponent(drucksache)}`);
|
||
if (!resp.ok) throw new Error('Nicht gefunden');
|
||
item = await resp.json();
|
||
} catch (e) {
|
||
document.getElementById('detail-panel').innerHTML = `<div style="padding:2rem;color:#dc3545;">Fehler beim Laden: ${e.message}</div>`;
|
||
return;
|
||
}
|
||
|
||
// Skala 0-10
|
||
const scoreClass = item.gwoeScore >= 8 ? 'score-high' :
|
||
item.gwoeScore >= 5 ? 'score-mid' :
|
||
item.gwoeScore >= 3 ? 'score-low' : 'score-negative';
|
||
|
||
// Matrix als 5x5-Tabelle wie im PDF
|
||
const matrixData = {};
|
||
(item.gwoeMatrix || []).forEach(m => { matrixData[m.field] = m; });
|
||
|
||
// GWÖ-Matrix Definitionen (aus models.py)
|
||
const rowLabels = {
|
||
'A': 'Ausgelagerte Betriebe, Lieferant:innen',
|
||
'B': 'Finanzpartner:innen, Steuerzahler:innen',
|
||
'C': 'Politische Führung, Verwaltung',
|
||
'D': 'Bürger:innen und Wirtschaft',
|
||
'E': 'Staat, Gesellschaft und Natur'
|
||
};
|
||
const rowTooltips = {
|
||
'A': 'Externe Beschaffung, Lieferketten, Dienstleister:innen',
|
||
'B': 'Umgang mit öffentlichen Mitteln, Haushalt, Geldgeber:innen',
|
||
'C': 'Mandatsträger:innen, Mitarbeitende, Ehrenamtliche',
|
||
'D': 'Wirkung innerhalb der Grenzen, Daseinsvorsorge',
|
||
'E': 'Wirkung über die Grenzen hinaus, Zukunft'
|
||
};
|
||
const colLabels = {
|
||
1: 'Menschenwürde',
|
||
2: 'Solidarität',
|
||
3: 'Ökol. Nachhaltigkeit',
|
||
4: 'Soz. Gerechtigkeit',
|
||
5: 'Transparenz'
|
||
};
|
||
const colFull = {
|
||
1: 'Menschenwürde',
|
||
2: 'Solidarität',
|
||
3: 'Ökologische Nachhaltigkeit',
|
||
4: 'Soziale Gerechtigkeit',
|
||
5: 'Transparenz & Mitbestimmung'
|
||
};
|
||
const colPrinzip = {
|
||
1: 'Rechtsstaatsprinzip — Werden Grundrechte geschützt? Rechtliche Gleichstellung?',
|
||
2: 'Gemeinnutz — Wird das Gemeinwohl gefördert? Mehrwert für die Gemeinschaft?',
|
||
3: 'Umwelt-Verantwortung — Klimaschutz? Ressourcenschonung? Biodiversität?',
|
||
4: 'Sozialstaatsprinzip — Gerechte Verteilung? Daseinsvorsorge? Soziale Absicherung?',
|
||
5: 'Demokratie — Bürgerbeteiligung? Offenlegung? Demokratische Prozesse?'
|
||
};
|
||
const fieldLabels = {{ matrix_labels | tojson }};
|
||
const ratingExplain = (r) => r >= 4 ? '++ stark fördernd' : r >= 1 ? '+ fördernd' : r === 0 ? '○ neutral' : r >= -3 ? '− widersprechend' : '−− stark widersprechend';
|
||
|
||
// Bürger:innen-Erklärungen pro Feld
|
||
const fieldExplain = {{ matrix_explanations | tojson }};
|
||
|
||
let matrixTableHtml = '<table class="matrix-table"><thead><tr><th style="min-width:120px;"></th>';
|
||
for (let col = 1; col <= 5; col++) matrixTableHtml += `<th style="cursor:pointer;" onclick="showColumnInfo(${col})">${col}. ${colLabels[col]}</th>`;
|
||
matrixTableHtml += '</tr></thead><tbody>';
|
||
|
||
['A', 'B', 'C', 'D', 'E'].forEach(row => {
|
||
matrixTableHtml += `<tr><th style="cursor:pointer;text-align:left;font-size:0.8rem;" onclick="showRowInfo('${row}')">${row}: ${rowLabels[row]}</th>`;
|
||
for (let col = 1; col <= 5; col++) {
|
||
const field = `${row}${col}`;
|
||
const entry = matrixData[field];
|
||
const fullLabel = fieldLabels[field] || field;
|
||
if (entry) {
|
||
const cssClass = entry.rating > 0 ? 'positive' : (entry.rating < 0 ? 'negative' : 'neutral');
|
||
// Aspect in data-Attribut speichern statt inline JS-String
|
||
matrixTableHtml += `<td class="${cssClass}" style="cursor:pointer;" data-field="${field}" data-aspect="${(entry.aspect||'').replace(/"/g,'"')}" data-rating="${entry.rating}" onclick="showFieldInfo(this.dataset.field, this.dataset.aspect, Number(this.dataset.rating))">${entry.symbol}</td>`;
|
||
} else {
|
||
matrixTableHtml += `<td style="cursor:pointer;color:#ccc;" onclick="showFieldInfo('${field}', '', 0)">○</td>`;
|
||
}
|
||
}
|
||
matrixTableHtml += '</tr>';
|
||
});
|
||
matrixTableHtml += '</tbody></table>';
|
||
|
||
// Detail-Liste der bewerteten Felder mit vollen Beschreibungen
|
||
const matrixDetailHtml = (item.gwoeMatrix || []).map(m => `
|
||
<div class="matrix-item" title="${ratingExplain(m.rating)}">
|
||
<span class="matrix-label"><strong>${m.field}</strong> ${fieldLabels[m.field] || m.label}: <em>${m.aspect || ''}</em></span>
|
||
<span class="matrix-rating ${m.rating > 0 ? 'rating-pos' : m.rating < 0 ? 'rating-neg' : 'rating-neutral'}">${m.symbol} (${m.rating})</span>
|
||
</div>
|
||
`).join('');
|
||
|
||
const stärkenHtml = (item.stärken || []).map(s => `<li>${s}</li>`).join('');
|
||
const schwächenHtml = (item.schwächen || []).map(s => `<li>${s}</li>`).join('');
|
||
|
||
// Verbesserungsvorschläge formatieren
|
||
const verbesserungenHtml = (item.verbesserungen || []).map(v => {
|
||
// Redline-Format: **fett** = grün/neu, ~~durchgestrichen~~ = rot/gelöscht
|
||
let vorschlag = v.vorschlag || '';
|
||
vorschlag = vorschlag.replace(/\*\*([^*]+)\*\*/g, '<span style="color: #889e33; font-weight: bold;">$1</span>');
|
||
vorschlag = vorschlag.replace(/~~([^~]+)~~/g, '<span style="color: #d00000; text-decoration: line-through;">$1</span>');
|
||
|
||
return `
|
||
<div style="margin: 0.75rem 0; padding: 0.75rem; border: 1px solid var(--color-lightgray); border-radius: 4px;">
|
||
<div style="background: #f5f5f5; padding: 0.5rem; margin-bottom: 0.5rem; font-size: 0.9rem;">
|
||
<strong>Original:</strong><br>${v.original || '-'}
|
||
</div>
|
||
<div style="background: rgba(136, 158, 51, 0.1); border-left: 3px solid #889e33; padding: 0.5rem; font-size: 0.9rem;">
|
||
<strong>Vorschlag:</strong><br>${vorschlag}
|
||
</div>
|
||
<div style="font-size: 0.85rem; color: #666; margin-top: 0.5rem; font-style: italic;">
|
||
${v.begruendung || ''}
|
||
</div>
|
||
</div>
|
||
`}).join('');
|
||
|
||
// Issue #47: Zitat-URLs zu Cite-Endpoint umschreiben für gelbes
|
||
// Highlighting. Funktioniert retroaktiv für Pre-#47-Assessments
|
||
// (statische /static/referenzen/X.pdf#page=N) und nativ für
|
||
// Post-#47 (die schon /api/wahlprogramm-cite enthalten).
|
||
// makeCiteUrl baut die Highlight-URL und hängt ds+bl an,
|
||
// damit der Server bei nicht-auffindbaren Zitaten automatisch
|
||
// eine Re-Analyse triggern kann (#47 + #60).
|
||
function makeCiteUrl(z, ds, bl) {
|
||
if (!z || !z.url) return '#';
|
||
const extra = (ds && bl) ? `&ds=${encodeURIComponent(ds)}&bl=${encodeURIComponent(bl)}` : '';
|
||
// Schon eine Cite-URL? ds/bl anhängen + #page=N.
|
||
if (z.url.includes('/api/wahlprogramm-cite')) {
|
||
const m = z.url.match(/seite=(\d+)/);
|
||
const page = m ? m[1] : '';
|
||
const base = z.url.split('#')[0];
|
||
return base + extra + (page ? '#page=' + page : '');
|
||
}
|
||
// Statische URL umschreiben
|
||
const m = z.url.match(/\/static\/referenzen\/(.+\.pdf)#page=(\d+)/);
|
||
if (m && z.text) {
|
||
const pdf = m[1];
|
||
const page = m[2];
|
||
const q = encodeURIComponent((z.text || '').substring(0, 200));
|
||
return `/api/wahlprogramm-cite?pdf=${encodeURIComponent(pdf)}&seite=${page}&q=${q}${extra}#page=${page}`;
|
||
}
|
||
return z.url;
|
||
}
|
||
|
||
const wahlprogrammHtml = (item.wahlprogrammScores || []).map(wp => {
|
||
// Zitate formatieren mit klickbaren Links + Highlighting
|
||
const zitateHtml = (wp.wahlprogramm?.zitate || []).map(z => {
|
||
const isVerified = z.verified !== false;
|
||
const borderColor = isVerified ? '#889e33' : '#ffc107';
|
||
const bgColor = isVerified ? '#f8f9fa' : '#fffbf0';
|
||
const badge = isVerified
|
||
? '<span style="font-size:0.7rem;color:#889e33;">✓ verifiziert</span>'
|
||
: '<span style="font-size:0.7rem;color:#b8860b;">~ paraphrasiert (nicht wörtlich im Programm)</span>';
|
||
return `
|
||
<div style="margin: 0.5rem 0; padding: 0.5rem; background: ${bgColor}; border-left: 3px ${isVerified ? 'solid' : 'dashed'} ${borderColor}; font-size: 0.85rem;">
|
||
<em>"${z.text}"</em><br>
|
||
${z.quelle ? `<a href="${makeCiteUrl(z, item.drucksache, item.bundesland)}" target="_blank" style="color: #009da5; font-size: 0.8rem;">
|
||
📄 ${z.quelle}
|
||
</a>` : ''}
|
||
${badge}
|
||
</div>`;
|
||
}).join('');
|
||
|
||
// Issue #63: Transparenz-Warnung bei Score > 0 ohne Zitate.
|
||
// Differenziert zwischen "Score 0 = keine Quellen" (LLM hat
|
||
// Force-Honesty befolgt) und "Score > 0 aber 0 Zitate" (LLM
|
||
// hat trotzdem geratet oder Zitate wurden von reconstruct_zitate
|
||
// verworfen). Nur bei Scores > 0 warnen, weil Score 0 schon
|
||
// selbsterklärend ist.
|
||
const wpScore = wp.wahlprogramm?.score ?? 0;
|
||
const wpZitateCount = (wp.wahlprogramm?.zitate || []).length;
|
||
const noQuotesWarning = (wpScore > 0 && wpZitateCount === 0) ? `
|
||
<div style="margin: 0.5rem 0; padding: 0.5rem; background: #fff3cd; border-left: 3px solid #ffc107; font-size: 0.8rem; color: #856404;">
|
||
⚠ Zu diesem Themenkomplex konnten keine konkreten Formulierungen im Wahlprogramm gefunden werden — Score basiert auf der allgemeinen Programmatik der Partei.
|
||
</div>
|
||
` : '';
|
||
|
||
// Labels: Antragsteller:in (aus item.fraktionen) und Landesregierung
|
||
// istAntragsteller/istRegierung aus dem LLM ist oft null — ableiten.
|
||
const isAntragsteller = (item.fraktionen || []).some(f => normalizePartei(f) === normalizePartei(wp.fraktion));
|
||
const roleLabels = [];
|
||
if (wp.istAntragsteller || isAntragsteller) roleLabels.push('<span style="background:#889e33;color:white;padding:0.1rem 0.4rem;border-radius:3px;font-size:0.75rem;">Antragsteller:in</span>');
|
||
if (wp.istRegierung) roleLabels.push('<span style="background:#009da5;color:white;padding:0.1rem 0.4rem;border-radius:3px;font-size:0.75rem;">Landesregierung</span>');
|
||
|
||
return `
|
||
<div style="margin: 0.75rem 0; padding: 0.75rem; background: var(--color-bg); border-radius: 4px;">
|
||
<strong>${wp.fraktion}</strong> ${roleLabels.join(' ')}<br>
|
||
<div style="margin: 0.5rem 0;">
|
||
Wahlprogramm: <strong>${wp.wahlprogramm?.score || '-'}/10</strong> ·
|
||
Parteiprogramm: <strong>${wp.parteiprogramm?.score || '-'}/10</strong>
|
||
</div>
|
||
${wp.wahlprogramm?.begründung ? `<div style="font-size: 0.9rem; color: #555; margin-bottom: 0.5rem;">${wp.wahlprogramm.begründung}</div>` : ''}
|
||
${noQuotesWarning}
|
||
${zitateHtml}
|
||
</div>
|
||
`}).join('');
|
||
|
||
document.getElementById('detail-panel').innerHTML = `
|
||
<div class="detail-card">
|
||
<button class="btn-back-mobile" onclick="closeMobileDetail()">← Zur Liste</button>
|
||
<div class="detail-header">
|
||
<div>
|
||
<div class="detail-title">${item.title || 'Ohne Titel'}</div>
|
||
${item.bundesland && PARLAMENT_NAMES[item.bundesland] ? `<div class="detail-parlament">${PARLAMENT_NAMES[item.bundesland]}</div>` : ''}
|
||
<div class="detail-id">${item.drucksache} · ${(item.fraktionen || []).join(', ')} · ${item.datum || ''}${item.updatedAt ? ` · Bewertet ${new Date(item.updatedAt).toLocaleDateString('de-DE')}` : ''}</div>
|
||
</div>
|
||
<div class="score-display">
|
||
<div class="score-big ${scoreClass}">${item.gwoeScore}</div>
|
||
<div class="score-label">GWÖ-Score</div>
|
||
${item.konfidenz ? `<div style="margin-top:0.3rem;font-size:0.75rem;"><span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:${item.konfidenz === 'hoch' ? '#889e33' : item.konfidenz === 'mittel' ? '#ffc107' : '#dc3545'};margin-right:4px;"></span>${item.konfidenz === 'hoch' ? 'Hohe' : item.konfidenz === 'mittel' ? 'Mittlere' : 'Niedrige'} Konfidenz</div>` : ''}
|
||
<div class="vote-buttons" id="vote-overall" style="margin-top:0.4rem;display:flex;gap:0.3rem;justify-content:center;">
|
||
<button onclick="castVote('${item.drucksache}','overall','up',this)" class="btn-vote" title="Bewertung treffend" style="padding:0.2rem 0.5rem;border:1px solid #ddd;border-radius:4px;background:none;cursor:pointer;font-size:0.9rem;">👍 <span class="vote-count">0</span></button>
|
||
<button onclick="castVote('${item.drucksache}','overall','down',this)" class="btn-vote" title="Bewertung fragwürdig" style="padding:0.2rem 0.5rem;border:1px solid #ddd;border-radius:4px;background:none;cursor:pointer;font-size:0.9rem;">👎 <span class="vote-count">0</span></button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
${item.themen && item.themen.length > 0 ? `
|
||
<div class="themen-tags" style="margin-bottom: 1rem;">
|
||
${item.themen.map(t => `<span class="tag">${t}</span>`).join(' ')}
|
||
</div>
|
||
` : ''}
|
||
|
||
<h3 class="section-title">Zusammenfassung</h3>
|
||
<div class="text-block">${item.antragZusammenfassung || '-'}</div>
|
||
|
||
${item.antragKernpunkte && item.antragKernpunkte.length > 0 ? `
|
||
<h3 class="section-title">Kernpunkte</h3>
|
||
<ul class="kernpunkte-list">${item.antragKernpunkte.map(k => `<li>${k}</li>`).join('')}</ul>
|
||
` : ''}
|
||
|
||
<h3 class="section-title">GWÖ-Begründung</h3>
|
||
<div class="text-block">${item.gwoeBegründung || '-'}</div>
|
||
|
||
${item.gwoeSchwerpunkt ? `
|
||
<h3 class="section-title">GWÖ-Schwerpunkt</h3>
|
||
<div class="text-block">${item.gwoeSchwerpunkt}</div>
|
||
` : ''}
|
||
|
||
${item.gwoeMatrix && item.gwoeMatrix.length > 0 ? `
|
||
<h3 class="section-title">GWÖ-Matrix</h3>
|
||
${matrixTableHtml}
|
||
<div class="matrix-grid" style="margin-top: 0.75rem;">${matrixDetailHtml}</div>
|
||
` : ''}
|
||
|
||
${wahlprogrammHtml ? `
|
||
<h3 class="section-title">Programmtreue</h3>
|
||
${(item.fehlendeProgramme && item.fehlendeProgramme.length > 0) ? `
|
||
<div style="padding:0.5rem;margin:0.5rem 0 0.75rem;background:#fff3cd;border-left:3px solid #ffc107;font-size:0.85rem;color:#856404;">
|
||
⚠ Wahlprogramm-Treue unvollständig: Für folgende Fraktionen liegt kein Wahlprogramm vor: <strong>${item.fehlendeProgramme.join(', ')}</strong>
|
||
</div>` : ''}
|
||
${wahlprogrammHtml}
|
||
` : ''}
|
||
|
||
${stärkenHtml ? `
|
||
<h3 class="section-title">Stärken</h3>
|
||
<ul class="strength-list">${stärkenHtml}</ul>
|
||
` : ''}
|
||
|
||
${schwächenHtml ? `
|
||
<h3 class="section-title">Schwächen</h3>
|
||
<ul class="weakness-list">${schwächenHtml}</ul>
|
||
` : ''}
|
||
|
||
${verbesserungenHtml ? `
|
||
<h3 class="section-title">Verbesserungsvorschläge</h3>
|
||
${verbesserungenHtml}
|
||
` : ''}
|
||
|
||
${item.verbesserungspotenzial ? `
|
||
<h3 class="section-title">Verbesserungspotenzial</h3>
|
||
<div class="text-block">${item.verbesserungspotenzial}</div>
|
||
` : ''}
|
||
|
||
<h3 class="section-title">Empfehlung</h3>
|
||
<div class="text-block">
|
||
<strong>${item.empfehlungSymbol || ''} ${item.empfehlung || '-'}</strong>
|
||
</div>
|
||
|
||
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap; margin-top: 1rem;">
|
||
<a href="${item.link}" target="_blank" class="btn-pdf">📄 Original-PDF</a>
|
||
<a href="/api/assessment/pdf?drucksache=${encodeURIComponent(item.drucksache)}" class="btn-pdf" style="background: var(--color-green);">📥 GWÖ-Report</a>
|
||
<button class="btn-pdf" style="background: #6c757d; border: none; cursor: pointer;"
|
||
${currentUser ? '' : 'disabled title="Nur nach Anmeldung verfügbar" style="background:#6c757d;opacity:0.5;cursor:not-allowed;"'}
|
||
onclick="reAnalyze('${item.drucksache}', '${item.bundesland}', this)">
|
||
🔄 Neu bewerten
|
||
</button>
|
||
</div>
|
||
<div style="display:flex;gap:0.4rem;flex-wrap:wrap;margin-top:0.75rem;">
|
||
<button onclick="shareAssessment('${item.drucksache}','copy')" class="btn-pdf" style="background:#6c757d;border:none;cursor:pointer;font-size:0.8rem;padding:0.3rem 0.6rem;">📋 Kopieren</button>
|
||
<a href="https://twitter.com/intent/tweet?text=${encodeURIComponent(_getShareText(item, 'twitter'))}&url=${encodeURIComponent('https://gwoe.toppyr.de/?drucksache=' + item.drucksache)}" target="_blank" rel="noopener" class="btn-pdf" style="background:#000;font-size:0.8rem;padding:0.3rem 0.6rem;">𝕏</a>
|
||
<a href="https://www.threads.net/intent/post?text=${encodeURIComponent(_getShareText(item, 'threads') + '\nhttps://gwoe.toppyr.de/?drucksache=' + item.drucksache)}" target="_blank" rel="noopener" class="btn-pdf" style="background:#000;font-size:0.8rem;padding:0.3rem 0.6rem;">Threads</a>
|
||
<button onclick="shareMastodon('${item.drucksache}')" class="btn-pdf" style="background:#6364ff;border:none;cursor:pointer;font-size:0.8rem;padding:0.3rem 0.6rem;">Mastodon</button>
|
||
<a href="https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent('https://gwoe.toppyr.de/?drucksache=' + item.drucksache)}" target="_blank" rel="noopener" class="btn-pdf" style="background:#0077b5;font-size:0.8rem;padding:0.3rem 0.6rem;">LinkedIn</a>
|
||
<a href="mailto:?subject=${encodeURIComponent('GWÖ-Bewertung: ' + (item.title || item.drucksache).substring(0,60))}&body=${encodeURIComponent(_emailBody(item))}" class="btn-pdf" style="background:#666;font-size:0.8rem;padding:0.3rem 0.6rem;">📧 E-Mail</a>
|
||
<a href="https://www.freepik.com/search?format=search&query=${encodeURIComponent((item.themen || []).slice(0,2).join(' ') + ' Politik')}" target="_blank" rel="noopener" class="btn-pdf" style="background:#1273eb;font-size:0.8rem;padding:0.3rem 0.6rem;">🖼 Bild suchen</a>
|
||
</div>
|
||
<div style="margin-top: 0.75rem; font-size: 0.8rem; color: var(--color-muted); border-top: 1px solid var(--color-border); padding-top: 0.5rem;">
|
||
Bewertet am ${item.updatedAt ? new Date(item.updatedAt).toLocaleDateString('de-DE') + ', ' + new Date(item.updatedAt).toLocaleTimeString('de-DE', {hour:'2-digit', minute:'2-digit'}) + ' Uhr' : '–'}
|
||
${item.source ? ` · Quelle: ${item.source}` : ''}
|
||
${item.model ? ` · Modell: ${item.model}` : ''}
|
||
</div>
|
||
|
||
<!-- Bookmark + Kommentare (#94) -->
|
||
<div id="history-${item.drucksache.replace('/','-')}" style="margin-top:0.5rem;font-size:0.8rem;color:#888;"></div>
|
||
|
||
<!-- Ähnliche Anträge (#108 Teil B) -->
|
||
<div id="similar-${item.drucksache.replace('/','-')}" style="margin-top:1rem;"></div>
|
||
|
||
<div style="margin-top: 1rem; border-top: 2px solid var(--color-lightgray); padding-top: 1rem;">
|
||
<div style="display: flex; align-items: center; gap: 1rem; margin-bottom: 0.75rem;">
|
||
<button id="bookmark-btn-${item.drucksache.replace('/','-')}"
|
||
style="background:none;border:1px solid #ddd;border-radius:4px;padding:0.3rem 0.8rem;cursor:pointer;font-size:0.9rem;"
|
||
onclick="toggleBookmark('${item.drucksache}', this)"
|
||
${currentUser ? '' : 'disabled title="Nur nach Anmeldung"'}>
|
||
🔖 Merken
|
||
</button>
|
||
<span style="font-size: 0.85rem; color: #888;">Kommentare:</span>
|
||
</div>
|
||
<div id="comments-${item.drucksache.replace('/','-')}" style="margin-bottom: 0.75rem;">
|
||
<span style="color:#aaa;font-size:0.85rem;">Lade Kommentare...</span>
|
||
</div>
|
||
${currentUser ? `
|
||
<div style="display:flex;gap:0.5rem;flex-wrap:wrap;align-items:center;">
|
||
<input id="comment-input-${item.drucksache.replace('/','-')}" type="text"
|
||
placeholder="Kommentar schreiben..."
|
||
style="flex:1;min-width:150px;padding:0.4rem;border:1px solid #ddd;border-radius:4px;font-size:0.85rem;"
|
||
onkeydown="if(event.key==='Enter')addCommentUI('${item.drucksache}')">
|
||
<select id="comment-visibility-${item.drucksache.replace('/','-')}"
|
||
style="padding:0.4rem;border:1px solid #ddd;border-radius:4px;font-size:0.8rem;color:#666;">
|
||
<option value="all">🌐 Öffentlich</option>
|
||
<option value="authenticated">🔒 Angemeldete</option>
|
||
<option value="private">👤 Nur ich</option>
|
||
</select>
|
||
<button onclick="addCommentUI('${item.drucksache}')"
|
||
style="padding:0.4rem 0.8rem;background:var(--color-blue);color:white;border:none;border-radius:4px;cursor:pointer;font-size:0.85rem;">
|
||
Senden
|
||
</button>
|
||
</div>
|
||
` : '<span style="font-size:0.8rem;color:#aaa;">Anmelden um zu kommentieren</span>'}
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
// Kommentare + Bookmark-Status + Votes laden
|
||
loadComments('${item.drucksache}');
|
||
loadBookmarkState('${item.drucksache}');
|
||
loadVotes('${item.drucksache}');
|
||
loadHistory('${item.drucksache}');
|
||
loadSimilar('${item.drucksache}');
|
||
|
||
// Auf Mobile: zum Detail-Panel scrollen, damit der gewählte Antrag sichtbar wird
|
||
if (window.matchMedia('(max-width: 900px)').matches) {
|
||
document.getElementById('detail-panel').scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||
}
|
||
}
|
||
|
||
// Mode Toggle
|
||
function showMode(mode) {
|
||
document.querySelectorAll('.mode-btn').forEach(b => b.classList.remove('active'));
|
||
if (event && event.currentTarget) {
|
||
event.currentTarget.classList.add('active');
|
||
} else {
|
||
// Find and activate the right button
|
||
document.querySelectorAll('.mode-btn').forEach(b => {
|
||
if (b.textContent.includes(mode === 'browse' ? 'Durchsuchen' : mode === 'tags' ? 'Tags' : 'Prüfen')) {
|
||
b.classList.add('active');
|
||
}
|
||
});
|
||
}
|
||
|
||
document.getElementById('browse-mode').style.display = mode === 'browse' ? 'flex' : 'none';
|
||
document.getElementById('bookmarks-mode').style.display = mode === 'bookmarks' ? 'flex' : 'none';
|
||
document.getElementById('clusters-mode').style.display = mode === 'clusters' ? 'flex' : 'none';
|
||
document.getElementById('tags-mode').style.display = mode === 'tags' ? 'flex' : 'none';
|
||
document.getElementById('upload-mode').style.display = mode === 'upload' ? 'flex' : 'none';
|
||
document.getElementById('auswertungen-mode').style.display = mode === 'auswertungen' ? 'flex' : 'none';
|
||
document.getElementById('admin-mode').style.display = mode === 'admin' ? 'flex' : 'none';
|
||
const expMode = document.getElementById('experimental-mode');
|
||
if (expMode) expMode.style.display = mode === 'experimental' ? 'flex' : 'none';
|
||
if (mode === 'auswertungen') {
|
||
// BL-Filter dynamisch befüllen aus den Assessments (#131)
|
||
const blSel = document.getElementById('ausw-bl');
|
||
if (blSel && blSel.options.length <= 1) {
|
||
const bls = new Set();
|
||
allAssessments.forEach(a => { if (a.bundesland) bls.add(a.bundesland); });
|
||
[...bls].sort().forEach(bl => {
|
||
const opt = document.createElement('option');
|
||
opt.value = bl; opt.textContent = bl;
|
||
blSel.appendChild(opt);
|
||
});
|
||
}
|
||
// WP-Dropdown beim Start ausgeblendet — erst nach BL-Wahl sichtbar (#137)
|
||
const wpContainer = document.getElementById('ausw-wp-container');
|
||
if (wpContainer) wpContainer.style.display = 'none';
|
||
showAuswertung('matrix');
|
||
}
|
||
if (mode === 'bookmarks') loadBookmarksList();
|
||
if (mode === 'clusters') loadClusters();
|
||
}
|
||
|
||
let _currentAuswertung = '';
|
||
async function showAuswertung(type) {
|
||
_currentAuswertung = type;
|
||
// Highlight active
|
||
document.querySelectorAll('#auswertungen-mode .admin-link').forEach(l => l.style.borderLeftColor = 'transparent');
|
||
if (event && event.target) event.target.style.borderLeftColor = 'var(--color-blue)';
|
||
|
||
const detail = document.getElementById('auswertungen-detail');
|
||
detail.innerHTML = '<div style="padding:2rem;text-align:center;"><div class="spinner"></div> Lade…</div>';
|
||
|
||
function scoreClass(avg) {
|
||
if (avg == null) return '';
|
||
if (avg >= 6) return 'background:#155724;color:white;';
|
||
if (avg >= 3) return 'background:#889e33;color:white;';
|
||
return 'background:#dc3545;color:white;';
|
||
}
|
||
|
||
try {
|
||
if (type === 'matrix') {
|
||
const wp = document.getElementById('ausw-wp')?.value || '';
|
||
const bl = document.getElementById('ausw-bl')?.value || '';
|
||
const qs = new URLSearchParams();
|
||
if (wp) qs.set('wahlperiode', wp);
|
||
if (bl) qs.set('bundesland', bl);
|
||
const url = qs.toString() ? `/api/auswertungen/matrix?${qs}` : '/api/auswertungen/matrix';
|
||
const data = await fetch(url).then(r => r.json());
|
||
if (!data.bundeslaender?.length) { detail.innerHTML = '<p style="padding:2rem;color:#888;">Keine Daten.</p>'; return; }
|
||
let html = '<div style="padding:1rem;"><h2 style="color:var(--color-blue);margin-bottom:1rem;">Bundesland × Partei — Ø GWÖ-Score</h2>';
|
||
html += '<div style="overflow-x:auto;"><table style="width:100%;border-collapse:collapse;font-size:0.85rem;"><thead><tr><th style="text-align:left;padding:0.4rem;">BL</th>';
|
||
for (const p of data.parteien) html += `<th style="padding:0.4rem;">${p}</th>`;
|
||
html += '</tr></thead><tbody>';
|
||
for (const bl of data.bundeslaender) {
|
||
html += `<tr><th style="text-align:left;padding:0.4rem;">${bl}</th>`;
|
||
for (const p of data.parteien) {
|
||
const cell = (data.cells[bl] || {})[p];
|
||
if (cell) html += `<td style="padding:0.4rem;text-align:center;${scoreClass(cell.avg)}border-radius:3px;cursor:pointer;" onclick="showZeitreiheInline('${bl}','${p}')">${cell.avg.toFixed(1)}<br><small>n=${cell.n}</small></td>`;
|
||
else html += '<td style="padding:0.4rem;text-align:center;color:#ccc;">—</td>';
|
||
}
|
||
html += '</tr>';
|
||
}
|
||
html += '</tbody></table></div>';
|
||
html += `<p style="margin-top:1rem;font-size:0.85rem;color:#888;">${data.total} Assessments | ${data.filter_wp || 'alle WPs'}</p></div>`;
|
||
detail.innerHTML = html;
|
||
} else if (type === 'themen') {
|
||
const bl = document.getElementById('ausw-bl')?.value || '';
|
||
const themenUrl = bl
|
||
? `/api/auswertungen/themen-matrix?min_count=2&bundesland=${encodeURIComponent(bl)}`
|
||
: '/api/auswertungen/themen-matrix?min_count=2';
|
||
const data = await fetch(themenUrl).then(r => r.json());
|
||
if (!data.themen?.length) { detail.innerHTML = '<p style="padding:2rem;color:#888;">Noch zu wenige Daten.</p>'; return; }
|
||
let html = '<div style="padding:1rem;"><h2 style="color:var(--color-blue);margin-bottom:1rem;">Themen × Fraktion — Ø GWÖ-Score</h2>';
|
||
html += '<div style="overflow-x:auto;"><table style="width:100%;border-collapse:collapse;font-size:0.85rem;"><thead><tr><th style="text-align:left;padding:0.4rem;">Thema</th>';
|
||
for (const f of data.fraktionen) html += `<th style="padding:0.4rem;">${f}</th>`;
|
||
html += '</tr></thead><tbody>';
|
||
for (const t of data.themen) {
|
||
html += `<tr><th style="text-align:left;padding:0.4rem;">${t}</th>`;
|
||
for (const f of data.fraktionen) {
|
||
const cell = (data.cells[t] || {})[f];
|
||
if (cell) html += `<td style="padding:0.4rem;text-align:center;${scoreClass(cell.avg)}border-radius:3px;" title="${t} × ${f}: Ø ${cell.avg}/10 (${cell.n})">${cell.avg.toFixed(1)}<br><small>n=${cell.n}</small></td>`;
|
||
else html += '<td style="padding:0.4rem;text-align:center;color:#ccc;">—</td>';
|
||
}
|
||
html += '</tr>';
|
||
}
|
||
html += '</tbody></table></div></div>';
|
||
detail.innerHTML = html;
|
||
} else if (type === 'cluster') {
|
||
detail.innerHTML = `<div style="padding:1rem;">
|
||
<h2 style="color:var(--color-blue);margin-bottom:0.5rem;">🎯 Antrag-Cluster</h2>
|
||
<div style="margin-bottom:1rem;display:flex;gap:0.75rem;align-items:center;flex-wrap:wrap;">
|
||
<button onclick="renderClusterView('list')" id="cl-btn-list" style="padding:0.3rem 0.8rem;border:1px solid var(--color-blue);border-radius:4px;background:var(--color-blue);color:white;cursor:pointer;font-size:0.85rem;">📋 Liste</button>
|
||
<button onclick="renderClusterView('bubble')" id="cl-btn-bubble" style="padding:0.3rem 0.8rem;border:1px solid var(--color-blue);border-radius:4px;background:none;color:var(--color-blue);cursor:pointer;font-size:0.85rem;">🫧 Bubble-Chart</button>
|
||
<label style="font-size:0.85rem;display:flex;align-items:center;gap:0.3rem;">Schwelle:
|
||
<input type="range" id="ausw-cluster-thr" min="0.40" max="0.80" step="0.05" value="0.55"
|
||
oninput="document.getElementById('ausw-thr-val').textContent=this.value"
|
||
onchange="reloadAuswCluster()">
|
||
<span id="ausw-thr-val">0.55</span>
|
||
</label>
|
||
</div>
|
||
<div id="ausw-cluster-content"><p>Lade…</p></div>
|
||
</div>`;
|
||
window._clusterDataAusw = await fetch('/api/clusters').then(r => r.json());
|
||
renderClusterView('list');
|
||
}
|
||
} catch (e) {
|
||
detail.innerHTML = `<p style="padding:2rem;color:#dc3545;">Fehler: ${e.message}</p>`;
|
||
}
|
||
}
|
||
function refreshAuswertung() { if (_currentAuswertung) showAuswertung(_currentAuswertung); }
|
||
|
||
function onAuswBlChange() {
|
||
const blSel = document.getElementById('ausw-bl');
|
||
const wpSel = document.getElementById('ausw-wp');
|
||
const wpContainer = document.getElementById('ausw-wp-container');
|
||
const selectedBl = blSel ? blSel.value : '';
|
||
|
||
// WP-Dropdown zurücksetzen
|
||
while (wpSel.options.length > 1) wpSel.remove(1);
|
||
wpSel.value = '';
|
||
|
||
if (selectedBl) {
|
||
// WPs für dieses BL ermitteln: Drucksachen-Prefix als WP-Ziffer, dann als "<BL>-WP<N>" formatieren
|
||
const wps = new Set();
|
||
allAssessments.forEach(a => {
|
||
if (a.bundesland !== selectedBl) return;
|
||
const ds = a.drucksache || '';
|
||
const wpNum = ds.split('/')[0];
|
||
if (wpNum) wps.add(wpNum);
|
||
});
|
||
[...wps].sort().forEach(wpNum => {
|
||
const opt = document.createElement('option');
|
||
opt.value = selectedBl + '-WP' + wpNum;
|
||
opt.textContent = 'WP ' + wpNum;
|
||
wpSel.appendChild(opt);
|
||
});
|
||
if (wpContainer) wpContainer.style.display = '';
|
||
} else {
|
||
if (wpContainer) wpContainer.style.display = 'none';
|
||
}
|
||
|
||
refreshAuswertung();
|
||
}
|
||
async function reloadAuswCluster() {
|
||
const thr = document.getElementById('ausw-cluster-thr')?.value || '0.55';
|
||
const container = document.getElementById('ausw-cluster-content');
|
||
if (container) container.innerHTML = '<p>Lade…</p>';
|
||
window._clusterDataAusw = await fetch('/api/clusters?threshold=' + thr).then(r => r.json());
|
||
// Behalte aktuelle Ansicht (Liste oder Bubble)
|
||
const isBubble = document.getElementById('cl-btn-bubble')?.style.background?.includes('var');
|
||
renderClusterView(isBubble ? 'bubble' : 'list');
|
||
}
|
||
|
||
function renderClusterView(view) {
|
||
const data = window._clusterDataAusw;
|
||
if (!data) return;
|
||
const container = document.getElementById('ausw-cluster-content');
|
||
const meta = data.meta || {};
|
||
// Toggle buttons
|
||
const listBtn = document.getElementById('cl-btn-list');
|
||
const bubbleBtn = document.getElementById('cl-btn-bubble');
|
||
if (listBtn) { listBtn.style.background = view === 'list' ? 'var(--color-blue)' : 'none'; listBtn.style.color = view === 'list' ? 'white' : 'var(--color-blue)'; }
|
||
if (bubbleBtn) { bubbleBtn.style.background = view === 'bubble' ? 'var(--color-blue)' : 'none'; bubbleBtn.style.color = view === 'bubble' ? 'white' : 'var(--color-blue)'; }
|
||
|
||
if (!data.clusters?.length) { container.innerHTML = '<p style="color:#888;">Keine Cluster.</p>'; return; }
|
||
|
||
if (view === 'list') {
|
||
let html = `<p style="color:#666;font-size:0.85rem;margin-bottom:1rem;">${meta.num_clusters} Cluster, ${meta.num_singletons} Singletons (${meta.total} Anträge)</p>`;
|
||
for (const c of data.clusters) {
|
||
html += `<div style="padding:0.75rem;border:1px solid var(--color-lightgray);border-radius:6px;margin-bottom:0.5rem;background:var(--color-card);">`;
|
||
html += `<strong style="color:var(--color-blue);">${c.label}</strong> <span style="color:#888;font-size:0.85rem;">(${c.size} · ${c.dominant_fraktion||'–'} · Ø ${c.avg_gwoe_score}/10)</span>`;
|
||
html += `<div style="margin-top:0.3rem;font-size:0.85rem;">${(c.drucksachen||[]).map(ds => `<span style="display:inline-block;padding:0.1rem 0.4rem;background:var(--color-bg);border-radius:3px;margin:0.15rem;cursor:pointer;" onclick="showMode('browse');showDetail('${ds}')">${ds}</span>`).join('')}</div></div>`;
|
||
}
|
||
container.innerHTML = html;
|
||
} else {
|
||
container.innerHTML = '<div id="ausw-bubble-holder" style="width:100%;height:500px;background:var(--color-card);border:1px solid var(--color-lightgray);border-radius:4px;"></div>';
|
||
// Re-use renderBubbleChart from cluster mode
|
||
if (typeof renderBubbleChart === 'function') {
|
||
// Temporarily set holder
|
||
const origHolder = document.getElementById('bubble-svg-holder');
|
||
const newHolder = document.getElementById('ausw-bubble-holder');
|
||
if (newHolder) {
|
||
newHolder.id = 'bubble-svg-holder';
|
||
renderBubbleChart(data);
|
||
newHolder.id = 'ausw-bubble-holder';
|
||
}
|
||
} else {
|
||
container.innerHTML = '<p style="color:#888;">Bubble-Chart nicht verfügbar (d3.js nicht geladen).</p>';
|
||
}
|
||
}
|
||
}
|
||
async function showZeitreiheInline(bl, partei) {
|
||
const detail = document.getElementById('auswertungen-detail');
|
||
detail.innerHTML = '<div style="padding:2rem;text-align:center;"><div class="spinner"></div> Lade Zeitreihe…</div>';
|
||
try {
|
||
const z = await fetch(`/api/auswertungen/zeitreihe?bundesland=${encodeURIComponent(bl)}&partei=${encodeURIComponent(partei)}`).then(r => r.json());
|
||
let html = `<div style="padding:1rem;"><h2 style="color:var(--color-blue);">${bl} × ${partei}</h2>`;
|
||
html += '<button onclick="showAuswertung(\'matrix\')" style="margin:0.5rem 0;padding:0.3rem 0.8rem;border:1px solid #ddd;border-radius:4px;background:none;cursor:pointer;">← Zurück zur Matrix</button>';
|
||
if (z.wahlperioden?.length) {
|
||
html += '<table style="margin-top:1rem;border-collapse:collapse;"><thead><tr><th style="padding:0.4rem;">WP</th><th style="padding:0.4rem;">Anträge</th><th style="padding:0.4rem;">Ø Score</th></tr></thead><tbody>';
|
||
for (const r of z.wahlperioden) html += `<tr><td style="padding:0.4rem;">WP ${r.wp}</td><td style="padding:0.4rem;">${r.n}</td><td style="padding:0.4rem;font-weight:bold;">${r.avg.toFixed(1)}</td></tr>`;
|
||
html += '</tbody></table>';
|
||
} else html += '<p style="color:#888;">Keine Daten.</p>';
|
||
html += '</div>';
|
||
detail.innerHTML = html;
|
||
} catch (e) {
|
||
detail.innerHTML = `<p style="padding:2rem;color:#dc3545;">Fehler: ${e.message}</p>`;
|
||
}
|
||
}
|
||
|
||
function updateFeedUrl() {
|
||
const bl = document.getElementById('feed-bl')?.value || '';
|
||
const partei = document.getElementById('feed-partei')?.value || '';
|
||
const limit = document.getElementById('feed-limit')?.value || '50';
|
||
const params = [];
|
||
if (bl) params.push('bundesland=' + encodeURIComponent(bl));
|
||
if (partei) params.push('partei=' + encodeURIComponent(partei));
|
||
if (limit !== '50') params.push('limit=' + limit);
|
||
const url = 'https://gwoe.toppyr.de/api/feed.xml' + (params.length ? '?' + params.join('&') : '');
|
||
const urlEl = document.getElementById('feed-url');
|
||
if (urlEl) urlEl.textContent = url;
|
||
const openLink = document.getElementById('feed-open-link');
|
||
if (openLink) openLink.href = '/api/feed.xml' + (params.length ? '?' + params.join('&') : '');
|
||
const dlLink = document.getElementById('feed-download-link');
|
||
if (dlLink) dlLink.href = openLink.href;
|
||
}
|
||
|
||
function showAdminSection(section) {
|
||
// Highlight active link
|
||
document.querySelectorAll('.admin-link').forEach(l => l.style.borderLeftColor = 'transparent');
|
||
if (event && event.target) event.target.style.borderLeftColor = 'var(--color-blue)';
|
||
|
||
const adminDetail = document.getElementById('admin-detail');
|
||
const expDetail = document.getElementById('experimental-detail');
|
||
const target = adminDetail?.style.display !== 'none' ? adminDetail : expDetail;
|
||
if (!target) return;
|
||
|
||
// Aktions-Sektionen (wechseln den Mode oder öffnen Overlays)
|
||
if (section === 'manual-check') {
|
||
target.innerHTML = document.getElementById('upload-mode').innerHTML;
|
||
return;
|
||
}
|
||
if (section === 'batch') { document.getElementById('batch-panel').style.display = 'block'; return; }
|
||
if (section === 'pending-users') { showPendingUsers(); return; }
|
||
|
||
// Cluster im Experimental-Detail
|
||
if (section === 'clusters') {
|
||
const el = expDetail || target;
|
||
el.innerHTML = '<div style="padding:1rem;" id="exp-cluster-content"><p style="color:#888;">Lade Cluster…</p></div>';
|
||
fetch('/api/clusters').then(r => r.json()).then(data => {
|
||
const meta = data.meta || {};
|
||
let html = `<h2 style="color:var(--color-blue);margin-bottom:1rem;">🎯 Antrag-Cluster</h2>`;
|
||
html += `<p style="color:#666;font-size:0.85rem;margin-bottom:1rem;">${meta.num_clusters} Cluster, ${meta.num_singletons} Singletons (${meta.total} Anträge)</p>`;
|
||
for (const c of (data.clusters || [])) {
|
||
html += `<div style="padding:0.75rem;border:1px solid var(--color-lightgray);border-radius:6px;margin-bottom:0.5rem;">`;
|
||
html += `<strong style="color:var(--color-blue);">${c.label}</strong> <span style="color:#888;font-size:0.85rem;">(${c.size} · ${c.dominant_fraktion||'–'} · Ø ${c.avg_gwoe_score})</span>`;
|
||
html += `<div style="margin-top:0.3rem;font-size:0.85rem;">${(c.drucksachen||[]).map(ds => `<span style="display:inline-block;padding:0.1rem 0.4rem;background:var(--color-bg);border-radius:3px;margin:0.15rem;cursor:pointer;" onclick="showMode('browse');showDetail('${ds}')">${ds}</span>`).join('')}</div></div>`;
|
||
}
|
||
document.getElementById('exp-cluster-content').innerHTML = html;
|
||
});
|
||
return;
|
||
}
|
||
|
||
// HTML-Sektionen
|
||
const pages = {
|
||
'export': `<div class="detail-card" style="padding:2rem;">
|
||
<h2 style="color:var(--color-blue);">📥 Daten exportieren</h2>
|
||
<p style="margin:0.5rem 0 1rem;color:var(--color-muted);">Alle GWÖ-Bewertungen herunterladen. Lizenz: CC BY 4.0.</p>
|
||
|
||
<h3 style="margin-top:1rem;">Inhalt</h3>
|
||
<p>Pro Antrag: Drucksache, Titel, Bundesland, Fraktionen, GWÖ-Score, 25-Felder-Matrix, Wahlprogramm-Treue mit Zitaten, Verbesserungsvorschläge, Themen, Datum.</p>
|
||
|
||
<h3 style="margin-top:1.5rem;">Format wählen</h3>
|
||
<div style="display:flex;gap:1rem;flex-wrap:wrap;margin-top:0.75rem;">
|
||
<div style="flex:1;min-width:200px;padding:1rem;border:1px solid var(--color-lightgray);border-radius:6px;background:var(--color-bg);">
|
||
<strong>JSON (Open Data)</strong>
|
||
<p style="font-size:0.85rem;color:var(--color-muted);margin:0.3rem 0 0.75rem;">Maschinenlesbar, vollständig. Für Entwickler, Datenanalyse, Weiterverarbeitung.</p>
|
||
<a href="/api/auswertungen/export.json" download class="btn-pdf" style="display:inline-block;background:var(--color-blue);padding:0.4rem 1rem;text-decoration:none;font-size:0.9rem;">📥 JSON</a>
|
||
</div>
|
||
<div style="flex:1;min-width:200px;padding:1rem;border:1px solid var(--color-lightgray);border-radius:6px;background:var(--color-bg);">
|
||
<strong>CSV (Tabelle)</strong>
|
||
<p style="font-size:0.85rem;color:var(--color-muted);margin:0.3rem 0 0.75rem;">Für Excel, Google Sheets, R, Python. Long-Format, eine Zeile pro Antrag.</p>
|
||
<a href="/api/auswertungen/export.csv" download class="btn-pdf" style="display:inline-block;background:var(--color-green);padding:0.4rem 1rem;text-decoration:none;font-size:0.9rem;">📊 CSV</a>
|
||
</div>
|
||
</div>
|
||
|
||
<h3 style="margin-top:1.5rem;">Einzelner Antrag als PDF</h3>
|
||
<p style="font-size:0.85rem;color:var(--color-muted);">In der Durchsuchen-Ansicht → Antrag öffnen → "PDF" Button im Detail-Panel.</p>
|
||
</div>`,
|
||
'notifications': `<div class="detail-card" style="padding:2rem;">
|
||
<h2 style="color:var(--color-blue);">🔔 Benachrichtigungen</h2>
|
||
<p style="margin:0.5rem 0 1rem;color:var(--color-muted);">Erhalte automatisch Updates wenn neue Anträge bewertet werden — per Feed-Reader oder E-Mail.</p>
|
||
|
||
<h3>Filter</h3>
|
||
<div style="display:flex;gap:0.75rem;flex-wrap:wrap;align-items:end;margin:0.5rem 0 1rem;">
|
||
<label style="font-size:0.85rem;">Bundesland:<br>
|
||
<select id="feed-bl" onchange="updateFeedUrl()" style="padding:0.4rem;border:1px solid #ddd;border-radius:4px;min-width:150px;">
|
||
<option value="">Alle</option>
|
||
${Array.from(document.getElementById('bundesland-select')?.options || []).filter(o => o.value && o.value !== 'ALL').map(o => '<option value="' + o.value + '">' + o.text + '</option>').join('')}
|
||
</select>
|
||
</label>
|
||
<label style="font-size:0.85rem;">Partei:<br>
|
||
<select id="feed-partei" onchange="updateFeedUrl()" style="padding:0.4rem;border:1px solid #ddd;border-radius:4px;min-width:120px;">
|
||
<option value="">Alle</option>
|
||
${Array.from(document.getElementById('partei-filter')?.options || []).filter(o => o.value).map(o => '<option value="' + o.value + '">' + o.text + '</option>').join('')}
|
||
</select>
|
||
</label>
|
||
</div>
|
||
|
||
<div style="display:flex;gap:1rem;flex-wrap:wrap;margin-top:0.5rem;">
|
||
<div style="flex:1;min-width:220px;padding:1rem;border:1px solid var(--color-lightgray);border-radius:6px;background:var(--color-bg);">
|
||
<strong>📡 Atom-Feed (RSS)</strong>
|
||
<p style="font-size:0.85rem;color:var(--color-muted);margin:0.3rem 0 0.5rem;">Für Feed-Reader (Feedly, Thunderbird, etc.). Kein Account nötig.</p>
|
||
<div style="display:flex;gap:0.3rem;align-items:center;margin-bottom:0.5rem;">
|
||
<code id="feed-url" style="flex:1;padding:0.3rem 0.5rem;background:white;border:1px solid #ddd;border-radius:4px;font-size:0.8rem;word-break:break-all;">https://gwoe.toppyr.de/api/feed.xml</code>
|
||
<button onclick="navigator.clipboard.writeText(document.getElementById('feed-url').textContent);this.textContent='✓';setTimeout(()=>this.textContent='📋',1500)" style="padding:0.3rem 0.5rem;border:1px solid #ddd;border-radius:4px;background:white;cursor:pointer;font-size:0.8rem;">📋</button>
|
||
</div>
|
||
<a id="feed-open-link" href="/api/feed.xml" target="_blank" class="btn-pdf" style="display:inline-block;background:#f7941d;padding:0.3rem 0.8rem;text-decoration:none;font-size:0.85rem;">Feed öffnen</a>
|
||
</div>
|
||
<div style="flex:1;min-width:220px;padding:1rem;border:1px solid var(--color-lightgray);border-radius:6px;background:var(--color-bg);">
|
||
<strong>📧 E-Mail-Digest</strong>
|
||
<p style="font-size:0.85rem;color:var(--color-muted);margin:0.3rem 0 0.5rem;">Tägliche Zusammenfassung neuer Bewertungen per E-Mail. Erfordert Anmeldung.</p>
|
||
${currentUser
|
||
? '<button onclick="document.getElementById(\\\'subs-panel\\\').style.display=\\\'block\\\';loadSubscriptions();" class="btn-pdf" style="display:inline-block;background:var(--color-blue);padding:0.3rem 0.8rem;border:none;cursor:pointer;font-size:0.85rem;">Abo verwalten</button>'
|
||
: '<p style="font-size:0.85rem;color:#888;"><em>Bitte erst anmelden.</em></p>'}
|
||
</div>
|
||
</div>
|
||
</div>`,
|
||
'similar': `<div class="detail-card" style="padding:2rem;">
|
||
<h2 style="color:var(--color-blue);">🔗 Ähnliche Anträge</h2>
|
||
<p style="margin:1rem 0;color:var(--color-muted);">Wähle einen Antrag in der Durchsuchen-Ansicht — die ähnlichen Anträge werden automatisch im Detail-Panel angezeigt (Sektion "🔗 ähnliche Anträge").</p>
|
||
</div>`,
|
||
};
|
||
|
||
if (pages[section]) target.innerHTML = pages[section];
|
||
}
|
||
|
||
// Upload Tab Toggle
|
||
function showTab(tab) {
|
||
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
|
||
event.currentTarget.classList.add('active');
|
||
|
||
document.getElementById('tab-text').style.display = tab === 'text' ? 'block' : 'none';
|
||
document.getElementById('tab-file').style.display = tab === 'file' ? 'block' : 'none';
|
||
}
|
||
|
||
function handleFile(input) {
|
||
if (input.files[0]) {
|
||
document.getElementById('file-name').textContent = input.files[0].name;
|
||
}
|
||
}
|
||
|
||
async function startAnalysis() {
|
||
const btn = document.getElementById('analyze-btn');
|
||
const statusDiv = document.getElementById('analysis-status');
|
||
const resultDiv = document.getElementById('analysis-result');
|
||
|
||
const text = document.getElementById('antrag-text').value;
|
||
const file = document.getElementById('file-input').files[0];
|
||
const bundesland = document.getElementById('bundesland').value;
|
||
|
||
if (!text && !file) {
|
||
alert('Bitte Text eingeben oder PDF hochladen');
|
||
return;
|
||
}
|
||
if (!bundesland) {
|
||
alert('Bitte ein Bundesland wählen.');
|
||
return;
|
||
}
|
||
|
||
btn.disabled = true;
|
||
statusDiv.style.display = 'block';
|
||
resultDiv.style.display = 'none';
|
||
|
||
try {
|
||
const formData = new FormData();
|
||
if (text) formData.append('text', text);
|
||
if (file) formData.append('file', file);
|
||
formData.append('bundesland', bundesland);
|
||
formData.append('model', 'qwen-plus');
|
||
|
||
const resp = await fetch('/analyze', { method: 'POST', body: formData });
|
||
const data = await resp.json();
|
||
|
||
if (data.job_id) {
|
||
pollStatus(data.job_id);
|
||
} else {
|
||
throw new Error(data.detail || 'Fehler');
|
||
}
|
||
} catch (e) {
|
||
statusDiv.innerHTML = `<p style="color: #dc3545;">✗ Fehler: ${e.message}</p>`;
|
||
btn.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function pollStatus(jobId) {
|
||
const statusText = document.getElementById('status-text');
|
||
const statusDiv = document.getElementById('analysis-status');
|
||
const resultDiv = document.getElementById('analysis-result');
|
||
const btn = document.getElementById('analyze-btn');
|
||
|
||
try {
|
||
const resp = await fetch(`/status/${jobId}`);
|
||
const data = await resp.json();
|
||
|
||
if (data.status === 'completed') {
|
||
statusDiv.style.display = 'none';
|
||
resultDiv.innerHTML = `
|
||
<p style="color: var(--color-green);">✓ Analyse abgeschlossen!</p>
|
||
<a href="/result/${jobId}" class="btn-pdf" style="background: var(--color-green);">📊 Ergebnis ansehen</a>
|
||
<a href="/result/${jobId}/pdf" class="btn-pdf">📄 PDF herunterladen</a>
|
||
`;
|
||
resultDiv.style.display = 'block';
|
||
btn.disabled = false;
|
||
} else if (data.status === 'failed') {
|
||
statusDiv.innerHTML = `<p style="color: #dc3545;">✗ Fehler: Analyse fehlgeschlagen. Bitte erneut versuchen.</p>`;
|
||
btn.disabled = false;
|
||
} else {
|
||
statusText.textContent = `Analysiere... (${data.status})`;
|
||
setTimeout(() => pollStatus(jobId), 2000);
|
||
}
|
||
} catch (e) {
|
||
statusDiv.innerHTML = `<p style="color: #dc3545;">✗ Fehler: ${e.message}</p>`;
|
||
btn.disabled = false;
|
||
}
|
||
}
|
||
</script>
|
||
<!-- Queue-Panel Overlay -->
|
||
<div id="queue-panel" style="display:none;position:fixed;top:0;right:0;width:400px;height:100vh;background:white;box-shadow:-4px 0 12px rgba(0,0,0,0.15);z-index:200;overflow-y:auto;padding:1.5rem;">
|
||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem;">
|
||
<h3 style="color:var(--color-blue);margin:0;">📊 Queue</h3>
|
||
<button onclick="document.getElementById('queue-panel').style.display='none'" style="background:none;border:none;font-size:1.2rem;cursor:pointer;">✕</button>
|
||
</div>
|
||
<div id="queue-panel-content"><span style="color:#aaa;">Lade...</span></div>
|
||
</div>
|
||
|
||
<!-- Batch-Panel Overlay -->
|
||
<!-- Registrierungs-Panel -->
|
||
<div id="subs-panel" style="display:none;position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);width:450px;background:white;box-shadow:0 8px 24px rgba(0,0,0,0.2);z-index:200;border-radius:8px;padding:1.5rem;max-height:80vh;overflow-y:auto;">
|
||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem;">
|
||
<h3 style="color:var(--color-blue);margin:0;">📧 E-Mail-Abonnements</h3>
|
||
<button onclick="document.getElementById('subs-panel').style.display='none'" style="background:none;border:none;font-size:1.2rem;cursor:pointer;">✕</button>
|
||
</div>
|
||
<p style="font-size:0.85rem;color:#666;margin-bottom:1rem;">Täglicher Digest aller neuen Bewertungen zu deinem Filter.</p>
|
||
<div id="subs-list" style="margin-bottom:1rem;"></div>
|
||
<h4 style="margin:0 0 0.5rem 0;font-size:0.95rem;">Neues Abo</h4>
|
||
<form onsubmit="createSubscription(event)" style="display:flex;flex-direction:column;gap:0.5rem;">
|
||
<label style="font-size:0.85rem;">Bundesland (optional):
|
||
<select name="bundesland" style="width:100%;padding:0.4rem;border:1px solid #ddd;border-radius:4px;">
|
||
<option value="">Alle</option>
|
||
{% for bl in bundeslaender if bl.code != 'ALL' and bl.active %}
|
||
<option value="{{ bl.code }}">{{ bl.name }}</option>
|
||
{% endfor %}
|
||
</select>
|
||
</label>
|
||
<label style="font-size:0.85rem;">Partei (optional):
|
||
<input name="partei" placeholder="z.B. CDU, SPD, GRÜNE (leer = alle)" style="width:100%;padding:0.4rem;border:1px solid #ddd;border-radius:4px;box-sizing:border-box;">
|
||
</label>
|
||
<button type="submit" style="padding:0.5rem;background:var(--color-blue);color:white;border:none;border-radius:4px;cursor:pointer;">Abo anlegen</button>
|
||
</form>
|
||
<div id="subs-status" style="margin-top:0.5rem;font-size:0.85rem;"></div>
|
||
</div>
|
||
|
||
<!-- Auth Modal: Login + Registrierung (#129) -->
|
||
<div id="auth-modal" style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.4);z-index:300;justify-content:center;align-items:center;" onclick="if(event.target===this)this.style.display='none'">
|
||
<div style="background:var(--color-card);border-radius:8px;padding:1.5rem;max-width:420px;width:90%;box-shadow:0 8px 24px rgba(0,0,0,0.2);">
|
||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem;">
|
||
<h3 style="color:var(--color-blue);margin:0;" id="auth-modal-title">Anmelden</h3>
|
||
<button onclick="document.getElementById('auth-modal').style.display='none'" style="background:none;border:none;font-size:1.2rem;cursor:pointer;">✕</button>
|
||
</div>
|
||
<div style="display:flex;gap:0;margin-bottom:1rem;border-bottom:2px solid #eee;">
|
||
<button onclick="switchAuthTab('login')" id="auth-tab-login" style="flex:1;padding:0.5rem;border:none;background:none;cursor:pointer;font-weight:bold;color:var(--color-blue);border-bottom:2px solid var(--color-blue);margin-bottom:-2px;">Anmelden</button>
|
||
<button onclick="switchAuthTab('register')" id="auth-tab-register" style="flex:1;padding:0.5rem;border:none;background:none;cursor:pointer;color:var(--color-muted);margin-bottom:-2px;">Registrieren</button>
|
||
</div>
|
||
<!-- Login Form -->
|
||
<form id="auth-login-form" onsubmit="submitLogin(event)" style="display:flex;flex-direction:column;gap:0.5rem;">
|
||
<input name="username" placeholder="Benutzername" required style="padding:0.5rem;border:1px solid #ddd;border-radius:4px;">
|
||
<input name="password" type="password" placeholder="Passwort" required style="padding:0.5rem;border:1px solid #ddd;border-radius:4px;">
|
||
<button type="submit" style="padding:0.5rem;background:var(--color-blue);color:white;border:none;border-radius:4px;cursor:pointer;font-size:1rem;">Anmelden</button>
|
||
</form>
|
||
<!-- Register Form -->
|
||
<form id="auth-register-form" onsubmit="submitRegistration(event)" style="display:none;flex-direction:column;gap:0.5rem;">
|
||
<input name="firstName" placeholder="Vorname" required style="padding:0.5rem;border:1px solid #ddd;border-radius:4px;">
|
||
<input name="lastName" placeholder="Nachname" required style="padding:0.5rem;border:1px solid #ddd;border-radius:4px;">
|
||
<input name="email" type="email" placeholder="E-Mail-Adresse" required style="padding:0.5rem;border:1px solid #ddd;border-radius:4px;">
|
||
<input name="username" placeholder="Benutzername" required style="padding:0.5rem;border:1px solid #ddd;border-radius:4px;">
|
||
<button type="submit" style="padding:0.5rem;background:var(--color-green);color:white;border:none;border-radius:4px;cursor:pointer;font-size:1rem;">Registrierung beantragen</button>
|
||
<p style="font-size:0.75rem;color:#888;margin:0;">Nach Freischaltung erhalten Sie eine E-Mail zum Passwort setzen.</p>
|
||
</form>
|
||
<div id="auth-status" style="margin-top:0.5rem;font-size:0.85rem;"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="batch-panel" style="display:none;position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);width:450px;background:white;box-shadow:0 8px 24px rgba(0,0,0,0.2);z-index:200;border-radius:8px;padding:1.5rem;">
|
||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem;">
|
||
<h3 style="color:var(--color-blue);margin:0;">📦 Batch-Analyse</h3>
|
||
<button onclick="document.getElementById('batch-panel').style.display='none'" style="background:none;border:none;font-size:1.2rem;cursor:pointer;">✕</button>
|
||
</div>
|
||
<p style="font-size:0.85rem;color:#666;margin-bottom:1rem;">Analysiert automatisch die neuesten ungeprüften Anträge.</p>
|
||
<div style="display:flex;gap:0.5rem;align-items:center;flex-wrap:wrap;">
|
||
<select id="batch-bl-modal" style="padding:0.4rem;border:1px solid #ddd;border-radius:4px;">
|
||
{% for bl in bundeslaender if bl.code != 'ALL' and bl.active %}
|
||
<option value="{{ bl.code }}">{{ bl.name }}</option>
|
||
{% endfor %}
|
||
</select>
|
||
<select id="batch-limit-modal" style="padding:0.4rem;border:1px solid #ddd;border-radius:4px;">
|
||
<option value="5">5</option>
|
||
<option value="10" selected>10</option>
|
||
<option value="20">20</option>
|
||
<option value="50">50</option>
|
||
</select>
|
||
<button onclick="startBatchModal()" style="padding:0.4rem 1rem;background:var(--color-green);color:white;border:none;border-radius:4px;cursor:pointer;">🚀 Starten</button>
|
||
</div>
|
||
<div id="batch-modal-status" style="margin-top:0.75rem;font-size:0.85rem;"></div>
|
||
</div>
|
||
|
||
<script>
|
||
async function showPendingUsers() {
|
||
try {
|
||
const resp = await fetch('/api/auth/pending-users');
|
||
if (!resp.ok) { alert('Fehler: ' + resp.statusText); return; }
|
||
const users = await resp.json();
|
||
if (!Array.isArray(users)) { alert('Unerwartete Antwort: ' + JSON.stringify(users).substring(0,100)); return; }
|
||
const html = users.length === 0
|
||
? '<p style="color:#888;">Keine ausstehenden Registrierungen.</p>'
|
||
: users.map(u => `
|
||
<div style="padding:0.5rem;border-bottom:1px solid #eee;display:flex;justify-content:space-between;align-items:center;">
|
||
<div><strong>${u.firstName} ${u.lastName}</strong><br><span style="color:#888;font-size:0.8rem;">${u.email} (${u.username})</span></div>
|
||
<button onclick="approveUser('${u.id}',this)" style="padding:0.3rem 0.8rem;background:var(--color-green);color:white;border:none;border-radius:4px;cursor:pointer;">✓ Freischalten</button>
|
||
</div>
|
||
`).join('');
|
||
document.body.insertAdjacentHTML('beforeend', `
|
||
<div style="position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.4);z-index:300;display:flex;justify-content:center;align-items:center;" onclick="if(event.target===this)this.remove()">
|
||
<div style="background:white;border-radius:8px;padding:1.5rem;max-width:500px;width:90%;box-shadow:0 8px 24px rgba(0,0,0,0.2);max-height:80vh;overflow-y:auto;">
|
||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem;">
|
||
<h3 style="color:var(--color-blue);margin:0;">👥 Ausstehende Registrierungen</h3>
|
||
<button onclick="this.closest('[style*=fixed]').remove()" style="background:none;border:none;font-size:1.2rem;cursor:pointer;">✕</button>
|
||
</div>
|
||
<div id="pending-users-list">${html}</div>
|
||
</div>
|
||
</div>`);
|
||
} catch (e) { alert('Fehler: ' + e.message); }
|
||
}
|
||
async function approveUser(userId, btn) {
|
||
btn.disabled = true; btn.textContent = '⏳...';
|
||
const resp = await fetch('/api/auth/approve-user', {
|
||
method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||
body: 'user_id=' + userId
|
||
});
|
||
if (resp.ok) {
|
||
const data = await resp.json();
|
||
btn.textContent = data.password_email_sent ? '✓ E-Mail gesendet' : '✓ (ohne E-Mail)';
|
||
btn.style.background = '#888';
|
||
}
|
||
else { btn.textContent = '❌'; btn.disabled = false; }
|
||
}
|
||
|
||
// ─── E-Mail-Abonnements (#124) ──────────────────────────────────────
|
||
async function loadSubscriptions() {
|
||
const list = document.getElementById('subs-list');
|
||
if (!list) return;
|
||
try {
|
||
const subs = await fetch('/api/subscriptions').then(r => r.json());
|
||
if (!subs || subs.length === 0) {
|
||
list.innerHTML = '<p style="color:#888;font-size:0.85rem;">Noch keine Abos.</p>';
|
||
return;
|
||
}
|
||
list.innerHTML = '<h4 style="margin:0 0 0.5rem 0;font-size:0.95rem;">Aktive Abos</h4>' + subs.map(s => {
|
||
const bl = s.bundesland || 'alle';
|
||
const p = s.partei || 'alle';
|
||
const lastSent = s.last_sent ? new Date(s.last_sent).toLocaleDateString('de-DE') : 'noch nie';
|
||
return `<div style="padding:0.5rem;border:1px solid #eee;border-radius:4px;margin-bottom:0.3rem;display:flex;justify-content:space-between;align-items:center;">
|
||
<div style="font-size:0.85rem;">
|
||
<strong>${bl}</strong> · ${p}<br>
|
||
<span style="color:#888;font-size:0.75rem;">letzte Mail: ${lastSent}</span>
|
||
</div>
|
||
<button onclick="deleteSubscription(${s.id})" style="background:none;border:1px solid #ccc;border-radius:4px;padding:0.2rem 0.5rem;cursor:pointer;font-size:0.8rem;">🗑 Löschen</button>
|
||
</div>`;
|
||
}).join('');
|
||
} catch (e) {
|
||
list.innerHTML = '<p style="color:#c33;">Fehler beim Laden.</p>';
|
||
}
|
||
}
|
||
|
||
async function createSubscription(event) {
|
||
event.preventDefault();
|
||
const form = event.target;
|
||
const fd = new FormData(form);
|
||
const status = document.getElementById('subs-status');
|
||
status.textContent = '⏳ Anlegen …';
|
||
try {
|
||
const resp = await fetch('/api/subscriptions', { method: 'POST', body: fd });
|
||
if (!resp.ok) {
|
||
const err = await resp.json().catch(() => ({}));
|
||
throw new Error(err.detail || resp.statusText);
|
||
}
|
||
status.textContent = '✓ Abo angelegt.';
|
||
status.style.color = 'var(--color-green)';
|
||
form.reset();
|
||
loadSubscriptions();
|
||
} catch (e) {
|
||
status.textContent = '❌ ' + e.message;
|
||
status.style.color = '#c33';
|
||
}
|
||
}
|
||
|
||
async function deleteSubscription(subId) {
|
||
if (!confirm('Abo wirklich löschen?')) return;
|
||
const resp = await fetch('/api/subscriptions/' + subId, { method: 'DELETE' });
|
||
if (resp.ok) loadSubscriptions();
|
||
}
|
||
|
||
function switchAuthTab(tab) {
|
||
document.getElementById('auth-login-form').style.display = tab === 'login' ? 'flex' : 'none';
|
||
document.getElementById('auth-register-form').style.display = tab === 'register' ? 'flex' : 'none';
|
||
document.getElementById('auth-tab-login').style.color = tab === 'login' ? 'var(--color-blue)' : 'var(--color-muted)';
|
||
document.getElementById('auth-tab-login').style.borderBottom = tab === 'login' ? '2px solid var(--color-blue)' : 'none';
|
||
document.getElementById('auth-tab-register').style.color = tab === 'register' ? 'var(--color-blue)' : 'var(--color-muted)';
|
||
document.getElementById('auth-tab-register').style.borderBottom = tab === 'register' ? '2px solid var(--color-blue)' : 'none';
|
||
document.getElementById('auth-modal-title').textContent = tab === 'login' ? 'Anmelden' : 'Registrieren';
|
||
document.getElementById('auth-status').innerHTML = '';
|
||
}
|
||
|
||
async function submitLogin(e) {
|
||
e.preventDefault();
|
||
const form = e.target;
|
||
const status = document.getElementById('auth-status');
|
||
status.innerHTML = '<span style="color:var(--color-blue);">⏳ Anmeldung...</span>';
|
||
try {
|
||
const resp = await fetch('/api/auth/login', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||
body: new URLSearchParams(new FormData(form)).toString()
|
||
});
|
||
const data = await resp.json();
|
||
if (resp.ok && data.authenticated) {
|
||
status.innerHTML = '<span style="color:var(--color-green);">✓ Angemeldet!</span>';
|
||
document.getElementById('auth-modal').style.display = 'none';
|
||
// Cookie wurde serverseitig gesetzt, jetzt Auth-State aktualisieren
|
||
await initAuth();
|
||
loadAssessments();
|
||
} else {
|
||
status.innerHTML = '<span style="color:#dc3545;">❌ ' + (data.detail || 'Anmeldung fehlgeschlagen') + '</span>';
|
||
}
|
||
} catch (err) {
|
||
status.innerHTML = '<span style="color:#dc3545;">❌ ' + err.message + '</span>';
|
||
}
|
||
}
|
||
|
||
async function submitRegistration(e) {
|
||
e.preventDefault();
|
||
const form = e.target;
|
||
const status = document.getElementById('auth-status');
|
||
status.innerHTML = '<span style="color:var(--color-blue);">⏳ Wird registriert...</span>';
|
||
try {
|
||
const resp = await fetch('/api/auth/register', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||
body: new URLSearchParams(new FormData(form)).toString()
|
||
});
|
||
const data = await resp.json();
|
||
if (resp.ok) {
|
||
status.innerHTML = '<span style="color:var(--color-green);">✓ ' + data.message + '</span>';
|
||
form.reset();
|
||
} else {
|
||
status.innerHTML = '<span style="color:#dc3545;">❌ ' + (data.detail || 'Fehler') + '</span>';
|
||
}
|
||
} catch (err) {
|
||
status.innerHTML = '<span style="color:#dc3545;">❌ ' + err.message + '</span>';
|
||
}
|
||
}
|
||
|
||
// ─── Share (#109) ────────────────────────────────────────────────────
|
||
function _shareText(item) {
|
||
const score = item.gwoeScore;
|
||
const emoji = score >= 8 ? '🟢' : score >= 5 ? '🟡' : score >= 3 ? '🟠' : '🔴';
|
||
const themen = (item.themen || []).slice(0, 2).join(', ');
|
||
return `${emoji} GWÖ-Score ${score}/10: „${(item.title || '').substring(0, 80)}" (${item.drucksache})${themen ? ' — ' + themen : ''}\n\n#Gemeinwohl #GWÖ`;
|
||
}
|
||
|
||
function _socialText(item) {
|
||
const score = item.gwoeScore;
|
||
const fraktionen = (item.fraktionen || []).join(', ');
|
||
const themen = (item.themen || []).slice(0, 3).join(', ');
|
||
const empfehlung = item.empfehlung || '';
|
||
const title = (item.title || '').substring(0, 70);
|
||
|
||
if (score >= 8) {
|
||
return `💪 Dieser Antrag scored ${score}/10 beim Gemeinwohl-Check!\n\n„${title}" (${fraktionen})\n\n${empfehlung}. Themen: ${themen}\n\nWie gemeinwohlorientiert ist die Politik in deinem Bundesland? Schau selbst 👇\n\n#Gemeinwohl #GWÖ #Demokratie #Transparenz`;
|
||
} else if (score >= 5) {
|
||
return `🤔 GWÖ-Score ${score}/10 — da geht noch was!\n\n„${title}" (${fraktionen})\n\n${empfehlung}. ${themen ? 'Themen: ' + themen : ''}\n\nJeder Parlamentsantrag hat Auswirkungen aufs Gemeinwohl. Hier kannst du sie prüfen 👇\n\n#Gemeinwohl #GWÖ #Politik`;
|
||
} else {
|
||
return `⚠️ GWÖ-Score nur ${score}/10 — kritisch fürs Gemeinwohl\n\n„${title}" (${fraktionen})\n\n${empfehlung}. ${themen ? 'Themen: ' + themen : ''}\n\nTransparenz schafft Verantwortung. Wie schneidet dein Bundesland ab? 👇\n\n#Gemeinwohl #GWÖ #Transparenz`;
|
||
}
|
||
}
|
||
|
||
// Returns LLM-generated share text if available, falls back to _socialText().
|
||
// limit: max characters for the text portion (URL added separately by callers).
|
||
function _getShareText(item, platform) {
|
||
const LIMITS = { twitter: 240, threads: 460, mastodon: 460 }; // URL (~40 chars) excluded
|
||
const limit = LIMITS[platform] || 460;
|
||
|
||
let text;
|
||
if (platform === 'twitter' && item.shareTwitter) {
|
||
text = item.shareTwitter;
|
||
} else if (platform === 'threads' && item.shareThreads) {
|
||
text = item.shareThreads;
|
||
} else if (platform === 'mastodon' && item.shareMastodon) {
|
||
text = item.shareMastodon;
|
||
} else {
|
||
text = _socialText(item);
|
||
}
|
||
|
||
if (text.length > limit) {
|
||
text = text.substring(0, limit - 1) + '…';
|
||
}
|
||
return text;
|
||
}
|
||
|
||
function _emailBody(item) {
|
||
const score = item.gwoeScore;
|
||
const emoji = score >= 8 ? '🟢' : score >= 5 ? '🟡' : score >= 3 ? '🟠' : '🔴';
|
||
const themen = (item.themen || []).join(', ');
|
||
const fraktionen = (item.fraktionen || []).join(', ');
|
||
const url = 'https://gwoe.toppyr.de/?drucksache=' + item.drucksache;
|
||
return `Hallo,
|
||
|
||
ich möchte dich auf diesen Parlamentsantrag aufmerksam machen:
|
||
|
||
${emoji} "${item.title || item.drucksache}"
|
||
|
||
GWÖ-Score: ${score}/10
|
||
Empfehlung: ${item.empfehlung || '–'}
|
||
Fraktionen: ${fraktionen}
|
||
Themen: ${themen}
|
||
Bundesland: ${item.bundesland}
|
||
|
||
${item.antragZusammenfassung || ''}
|
||
|
||
Zur vollständigen Bewertung:
|
||
${url}
|
||
|
||
—
|
||
Gesendet via GWÖ-Antragsprüfer (${url})`;
|
||
}
|
||
|
||
async function showTagDetail(drucksache) {
|
||
const panel = document.getElementById('tag-detail-panel');
|
||
if (!panel) return;
|
||
panel.innerHTML = '<div style="padding:2rem;text-align:center;"><div class="spinner"></div> Lade…</div>';
|
||
try {
|
||
const resp = await fetch(`/api/assessment?drucksache=${encodeURIComponent(drucksache)}`);
|
||
if (!resp.ok) throw new Error('Nicht gefunden');
|
||
const item = await resp.json();
|
||
// Reuse the same detail rendering as browse mode
|
||
// Simple version: key info
|
||
const score = item.gwoeScore;
|
||
const scoreClass = score >= 8 ? 'score-high' : score >= 5 ? 'score-mid' : score >= 3 ? 'score-low' : 'score-negative';
|
||
panel.innerHTML = `
|
||
<div class="detail-card" style="padding:1.5rem;">
|
||
<h2 style="color:var(--color-blue);margin-bottom:0.5rem;">${item.title || item.drucksache}</h2>
|
||
<div style="display:flex;gap:1rem;align-items:center;margin-bottom:1rem;">
|
||
<span class="score-badge ${scoreClass}" style="font-size:1.3rem;padding:0.3rem 0.8rem;border-radius:6px;">${score}/10</span>
|
||
<span>${item.empfehlung || ''}</span>
|
||
</div>
|
||
<p><strong>Fraktionen:</strong> ${(item.fraktionen || []).join(', ')}</p>
|
||
<p><strong>Bundesland:</strong> ${item.bundesland} · <strong>Datum:</strong> ${item.datum || '–'}</p>
|
||
<p><strong>Themen:</strong> ${(item.themen || []).join(', ')}</p>
|
||
${item.gwoeBegründung ? `<h3 style="margin-top:1rem;">Begründung</h3><p>${item.gwoeBegründung}</p>` : ''}
|
||
${item.antragZusammenfassung ? `<h3 style="margin-top:1rem;">Zusammenfassung</h3><p>${item.antragZusammenfassung}</p>` : ''}
|
||
<div style="margin-top:1rem;">
|
||
<a href="/?drucksache=${item.drucksache}" class="btn-pdf" style="background:var(--color-blue);font-size:0.85rem;padding:0.4rem 1rem;text-decoration:none;">Vollständige Ansicht →</a>
|
||
</div>
|
||
</div>`;
|
||
} catch (e) {
|
||
panel.innerHTML = `<div style="padding:2rem;color:#dc3545;">Fehler: ${e.message}</div>`;
|
||
}
|
||
}
|
||
|
||
function shareMastodon(drucksache) {
|
||
const item = allAssessments.find(a => a.drucksache === drucksache);
|
||
if (!item) return;
|
||
const text = _getShareText(item, 'mastodon') + '\nhttps://gwoe.toppyr.de/?drucksache=' + drucksache;
|
||
// Gespeicherte Instanz oder User fragen
|
||
let instance = localStorage.getItem('mastodon_instance');
|
||
if (!instance) {
|
||
instance = prompt('Deine Mastodon-Instanz (z.B. mastodon.social, chaos.social):');
|
||
if (!instance) return;
|
||
instance = instance.trim().replace(/^https?:\/\//, '').replace(/\/+$/, '');
|
||
localStorage.setItem('mastodon_instance', instance);
|
||
}
|
||
window.open(`https://${instance}/share?text=${encodeURIComponent(text)}`, '_blank');
|
||
}
|
||
|
||
function shareAssessment(drucksache, target) {
|
||
const item = allAssessments.find(a => a.drucksache === drucksache);
|
||
if (!item) return;
|
||
const text = _shareText(item) + '\n' + 'https://gwoe.toppyr.de/#' + drucksache;
|
||
if (target === 'copy') {
|
||
navigator.clipboard.writeText(text).then(() => {
|
||
const btn = event.target;
|
||
const orig = btn.textContent;
|
||
btn.textContent = '✓ Kopiert';
|
||
setTimeout(() => btn.textContent = orig, 2000);
|
||
});
|
||
}
|
||
}
|
||
|
||
// ─── Assessment-History (#110) ────────────────────────────────────────
|
||
// ─── Cluster-Visualisierung (#105) ──────────────────────────────────
|
||
// Kombination aus Bubble-Chart (Außenansicht) und Force-Graph (Detail pro Cluster)
|
||
|
||
let _clusterData = null; // zuletzt geladene /api/clusters Antwort
|
||
|
||
// Farb-Mapping pro Fraktion
|
||
const FRAKTION_COLORS = {
|
||
'CDU': '#000000',
|
||
'SPD': '#e3000f',
|
||
'GRÜNE': '#509b25',
|
||
'FDP': '#ffed00',
|
||
'AfD': '#009ee0',
|
||
'LINKE': '#bd1220',
|
||
'BSW': '#7b2d7d',
|
||
'FW': '#f29100',
|
||
'FREIE WÄHLER': '#f29100',
|
||
'SSW': '#2980b9',
|
||
'Landesregierung': '#777777',
|
||
};
|
||
function fraktionColor(f) { return FRAKTION_COLORS[f] || '#7a7a7a'; }
|
||
|
||
async function loadClusters() {
|
||
const container = document.getElementById('clusters-content');
|
||
if (!container) return;
|
||
const bl = document.getElementById('cluster-bl')?.value || '';
|
||
const thr = document.getElementById('cluster-threshold')?.value || '0.55';
|
||
container.innerHTML = '<p style="color:#888;">Lade Cluster…</p>';
|
||
try {
|
||
const url = `/api/clusters?threshold=${thr}${bl ? '&bundesland=' + bl : ''}`;
|
||
_clusterData = await fetch(url).then(r => r.json());
|
||
renderClusters();
|
||
} catch (e) {
|
||
container.innerHTML = `<p style="color:#c33;">Fehler: ${e.message}</p>`;
|
||
}
|
||
}
|
||
|
||
function renderClusters() {
|
||
const container = document.getElementById('clusters-content');
|
||
if (!_clusterData || !container) return;
|
||
const view = document.getElementById('cluster-view')?.value || 'bubbles';
|
||
const meta = _clusterData.meta || {};
|
||
|
||
if ((_clusterData.clusters || []).length === 0) {
|
||
container.innerHTML = `<p style="color:#888;">Keine Cluster bei Schwelle ${meta.threshold} (${meta.total} Anträge, alle Singletons).</p>`;
|
||
return;
|
||
}
|
||
|
||
const metaBar = `<div style="color:#666;font-size:0.85rem;margin-bottom:0.5rem;">
|
||
${meta.num_clusters} Cluster, ${meta.num_singletons} Singletons (${meta.total} Anträge gesamt) bei Schwelle ${meta.threshold}
|
||
</div>`;
|
||
|
||
if (view === 'bubbles') {
|
||
container.innerHTML = metaBar + '<div id="bubble-svg-holder" style="width:100%;height:640px;background:var(--color-card);border-radius:4px;border:1px solid #eee;"></div>';
|
||
renderBubbleChart(_clusterData);
|
||
} else {
|
||
container.innerHTML = metaBar + renderClusterList(_clusterData);
|
||
}
|
||
}
|
||
|
||
function renderClusterList(data) {
|
||
const byDs = {};
|
||
for (const a of allAssessments) byDs[a.drucksache] = a;
|
||
|
||
function renderCluster(c, depth = 0) {
|
||
const head = `<div style="cursor:pointer;font-weight:bold;color:var(--color-blue);margin-bottom:0.3rem;" onclick="this.nextElementSibling.style.display = this.nextElementSibling.style.display === 'none' ? 'block' : 'none';">
|
||
▸ ${c.label || 'Cluster'} <span style="color:#888;font-weight:normal;font-size:0.85rem;">(${c.size} · ${c.dominant_fraktion || '–'} · Ø ${c.avg_gwoe_score || '?'}/10)</span>
|
||
</div>`;
|
||
const items = (c.drucksachen || []).map(ds => {
|
||
const a = byDs[ds];
|
||
if (!a) return `<div style="padding:0.2rem 0.5rem;color:#888;font-size:0.85rem;">${ds}</div>`;
|
||
return `<div style="padding:0.3rem 0.5rem;cursor:pointer;border-left:2px solid #eee;margin-left:0.5rem;font-size:0.9rem;" onclick="showMode('browse');showDetail('${ds}')">
|
||
<strong>${a.title || ds}</strong><br>
|
||
<span style="color:#888;font-size:0.75rem;">${(a.fraktionen || []).join(', ')} · Score ${a.gwoeScore ?? '?'}/10</span>
|
||
</div>`;
|
||
}).join('');
|
||
return `<div style="padding:0.5rem;border:1px solid #eee;border-radius:4px;margin-bottom:0.5rem;background:var(--color-card);">
|
||
${head}<div>${items}</div>
|
||
</div>`;
|
||
}
|
||
return data.clusters.map(c => renderCluster(c)).join('') +
|
||
(data.singletons.length > 0 ? `<details style="margin-top:1rem;"><summary style="cursor:pointer;color:#666;">${data.singletons.length} Singletons</summary><div style="margin-top:0.5rem;font-size:0.85rem;color:#888;">${data.singletons.map(ds => `<span style="display:inline-block;padding:0.2rem 0.4rem;background:#f5f5f5;border-radius:3px;margin:0.2rem;cursor:pointer;" onclick="showMode('browse');showDetail('${ds}')">${ds}</span>`).join('')}</div></details>` : '');
|
||
}
|
||
|
||
function renderBubbleChart(data) {
|
||
const holder = document.getElementById('bubble-svg-holder');
|
||
if (!holder) return;
|
||
holder.innerHTML = '';
|
||
|
||
const width = holder.clientWidth || 800;
|
||
const height = 640;
|
||
|
||
const byDs = {};
|
||
for (const a of allAssessments) byDs[a.drucksache] = a;
|
||
|
||
// Hierarchie: root → clusters → drucksachen, plus singletons als flache children einer "Einzel-Ebene"
|
||
const root = {
|
||
name: 'root',
|
||
children: [
|
||
...data.clusters.map((c, ci) => ({
|
||
type: 'cluster',
|
||
id: 'c' + ci,
|
||
name: c.label,
|
||
dominant_fraktion: c.dominant_fraktion,
|
||
avg: c.avg_gwoe_score,
|
||
cluster: c,
|
||
children: c.drucksachen.map(ds => {
|
||
const a = byDs[ds] || {};
|
||
return {
|
||
type: 'antrag',
|
||
name: a.title || ds,
|
||
drucksache: ds,
|
||
value: Math.max(1, (a.gwoeScore ?? 5)),
|
||
fraktion: (a.fraktionen || [])[0],
|
||
};
|
||
}),
|
||
})),
|
||
// Singletons-Sammelkreis
|
||
...(data.singletons.length > 0 ? [{
|
||
type: 'singleton-group',
|
||
id: 'singletons',
|
||
name: `${data.singletons.length} Singletons`,
|
||
dominant_fraktion: null,
|
||
children: data.singletons.map(ds => {
|
||
const a = byDs[ds] || {};
|
||
return {
|
||
type: 'antrag',
|
||
name: a.title || ds,
|
||
drucksache: ds,
|
||
value: Math.max(1, (a.gwoeScore ?? 5)),
|
||
fraktion: (a.fraktionen || [])[0],
|
||
};
|
||
}),
|
||
}] : []),
|
||
],
|
||
};
|
||
|
||
const hierarchy = d3.hierarchy(root).sum(d => d.value || 1).sort((a, b) => b.value - a.value);
|
||
const pack = d3.pack().size([width, height]).padding(6);
|
||
pack(hierarchy);
|
||
|
||
const svg = d3.select(holder).append('svg')
|
||
.attr('width', width).attr('height', height)
|
||
.attr('viewBox', `0 0 ${width} ${height}`)
|
||
.attr('style', 'max-width:100%;font:10px sans-serif;display:block;');
|
||
|
||
// Cluster-Kreise
|
||
const clusterG = svg.append('g');
|
||
const clusterNodes = hierarchy.children || [];
|
||
|
||
clusterG.selectAll('g.cluster')
|
||
.data(clusterNodes)
|
||
.join('g')
|
||
.attr('class', 'cluster')
|
||
.attr('transform', d => `translate(${d.x},${d.y})`)
|
||
.each(function(d) {
|
||
const g = d3.select(this);
|
||
const isSingle = d.data.type === 'singleton-group';
|
||
g.append('circle')
|
||
.attr('r', d.r)
|
||
.attr('fill', isSingle ? '#f5f5f5' : fraktionColor(d.data.dominant_fraktion))
|
||
.attr('fill-opacity', 0.12)
|
||
.attr('stroke', isSingle ? '#ccc' : fraktionColor(d.data.dominant_fraktion))
|
||
.attr('stroke-width', 1.5)
|
||
.attr('cursor', isSingle ? 'default' : 'pointer')
|
||
.on('click', () => { if (!isSingle) openForceGraph(d.data.cluster); });
|
||
// Label (nur für normale Cluster, nicht Singletons)
|
||
if (!isSingle) {
|
||
const label = (d.data.name || '').substring(0, 40);
|
||
g.append('text')
|
||
.attr('y', -d.r + 14)
|
||
.attr('text-anchor', 'middle')
|
||
.attr('fill', '#333')
|
||
.attr('font-weight', 'bold')
|
||
.attr('font-size', '11px')
|
||
.text(label);
|
||
g.append('text')
|
||
.attr('y', -d.r + 26)
|
||
.attr('text-anchor', 'middle')
|
||
.attr('fill', '#888')
|
||
.attr('font-size', '9px')
|
||
.text(`${d.data.cluster.size} · ${d.data.dominant_fraktion || '–'} · Ø${d.data.avg ?? '?'}`);
|
||
} else {
|
||
g.append('text')
|
||
.attr('y', -d.r - 5)
|
||
.attr('text-anchor', 'middle')
|
||
.attr('fill', '#888')
|
||
.attr('font-size', '10px')
|
||
.text(d.data.name);
|
||
}
|
||
// Kinder (Anträge) als kleine Kreise
|
||
g.selectAll('circle.antrag')
|
||
.data(d.children || [])
|
||
.join('circle')
|
||
.attr('class', 'antrag')
|
||
.attr('cx', child => child.x - d.x)
|
||
.attr('cy', child => child.y - d.y)
|
||
.attr('r', child => child.r)
|
||
.attr('fill', child => fraktionColor(child.data.fraktion))
|
||
.attr('fill-opacity', 0.75)
|
||
.attr('stroke', '#fff')
|
||
.attr('stroke-width', 0.5)
|
||
.attr('cursor', 'pointer')
|
||
.on('click', (evt, child) => {
|
||
evt.stopPropagation();
|
||
showMode('browse');
|
||
showDetail(child.data.drucksache);
|
||
})
|
||
.append('title')
|
||
.text(child => `${child.data.name}\n${child.data.fraktion || '?'} · Score ${child.data.value}/10\n(Klick für Details)`);
|
||
});
|
||
}
|
||
|
||
function openForceGraph(cluster) {
|
||
const modal = document.getElementById('force-modal');
|
||
const container = document.getElementById('force-container');
|
||
document.getElementById('force-title').textContent = cluster.label || 'Cluster';
|
||
document.getElementById('force-meta').textContent =
|
||
`${cluster.size} Anträge · dominante Fraktion: ${cluster.dominant_fraktion || '–'} · Ø ${cluster.avg_gwoe_score || '?'}/10`;
|
||
modal.style.display = 'block';
|
||
|
||
// Container leeren
|
||
container.innerHTML = '';
|
||
const width = container.clientWidth || 800;
|
||
const height = container.clientHeight || 500;
|
||
|
||
const nodes = (cluster.nodes || []).map((n, i) => ({
|
||
id: i,
|
||
drucksache: n.drucksache,
|
||
title: n.title,
|
||
fraktion: (n.fraktionen || [])[0],
|
||
score: n.gwoe_score,
|
||
}));
|
||
const links = (cluster.edges || []).map(e => ({
|
||
source: e.a,
|
||
target: e.b,
|
||
sim: e.sim,
|
||
}));
|
||
|
||
const svg = d3.select(container).append('svg')
|
||
.attr('width', width).attr('height', height)
|
||
.attr('viewBox', `0 0 ${width} ${height}`)
|
||
.style('display', 'block');
|
||
|
||
const sim = d3.forceSimulation(nodes)
|
||
.force('link', d3.forceLink(links).id(d => d.id).distance(l => (1 - l.sim) * 250 + 40).strength(l => l.sim))
|
||
.force('charge', d3.forceManyBody().strength(-400))
|
||
.force('center', d3.forceCenter(width / 2, height / 2))
|
||
.force('collide', d3.forceCollide().radius(d => Math.sqrt(Math.max(1, d.score || 5)) * 7 + 4));
|
||
|
||
const link = svg.append('g')
|
||
.attr('stroke', '#999')
|
||
.attr('stroke-opacity', 0.4)
|
||
.selectAll('line')
|
||
.data(links)
|
||
.join('line')
|
||
.attr('stroke-width', d => Math.max(0.5, d.sim * 3));
|
||
|
||
const node = svg.append('g')
|
||
.selectAll('g')
|
||
.data(nodes)
|
||
.join('g')
|
||
.style('cursor', 'pointer')
|
||
.call(d3.drag()
|
||
.on('start', (e, d) => { if (!e.active) sim.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y; })
|
||
.on('drag', (e, d) => { d.fx = e.x; d.fy = e.y; })
|
||
.on('end', (e, d) => { if (!e.active) sim.alphaTarget(0); d.fx = null; d.fy = null; }))
|
||
.on('click', (e, d) => {
|
||
closeForceModal();
|
||
showMode('browse');
|
||
showDetail(d.drucksache);
|
||
});
|
||
|
||
node.append('circle')
|
||
.attr('r', d => Math.sqrt(Math.max(1, d.score || 5)) * 5 + 4)
|
||
.attr('fill', d => fraktionColor(d.fraktion))
|
||
.attr('fill-opacity', 0.85)
|
||
.attr('stroke', '#fff')
|
||
.attr('stroke-width', 1.5);
|
||
|
||
node.append('title').text(d => `${d.title}\n${d.fraktion || '?'} · Score ${d.score}/10\n(Klick für Details)`);
|
||
|
||
node.append('text')
|
||
.attr('x', 0)
|
||
.attr('y', d => Math.sqrt(Math.max(1, d.score || 5)) * 5 + 15)
|
||
.attr('text-anchor', 'middle')
|
||
.attr('font-size', '9px')
|
||
.attr('fill', '#333')
|
||
.text(d => (d.title || d.drucksache).substring(0, 30));
|
||
|
||
sim.on('tick', () => {
|
||
link.attr('x1', d => d.source.x).attr('y1', d => d.source.y)
|
||
.attr('x2', d => d.target.x).attr('y2', d => d.target.y);
|
||
node.attr('transform', d => `translate(${d.x},${d.y})`);
|
||
});
|
||
}
|
||
|
||
function closeForceModal() {
|
||
document.getElementById('force-modal').style.display = 'none';
|
||
document.getElementById('force-container').innerHTML = '';
|
||
}
|
||
|
||
async function loadSimilar(drucksache) {
|
||
const el = document.getElementById('similar-' + drucksache.replace('/','-'));
|
||
if (!el) return;
|
||
try {
|
||
const sims = await fetch(`/api/assessment/similar?drucksache=${encodeURIComponent(drucksache)}&top_k=5`).then(r => r.json());
|
||
if (!sims || !sims.length) { el.innerHTML = ''; return; }
|
||
const items = sims.map(s => {
|
||
const score = s.gwoe_score != null ? `${s.gwoe_score}/10` : '–';
|
||
const fraktionen = (s.fraktionen || []).join(', ');
|
||
const simPct = Math.round((s.similarity || 0) * 100);
|
||
return `<div style="padding:0.5rem;border:1px solid #eee;border-radius:4px;margin:0.3rem 0;cursor:pointer;background:#fafafa;" onclick="showDetail('${s.drucksache}')">
|
||
<div style="font-weight:bold;color:var(--color-blue);font-size:0.9rem;">${s.title || s.drucksache}</div>
|
||
<div style="color:#666;font-size:0.75rem;margin-top:0.2rem;">
|
||
${s.bundesland} · ${fraktionen} · Score ${score} · Ähnlichkeit ${simPct}%
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
el.innerHTML = `<details style="margin-top:0.5rem;"><summary style="cursor:pointer;color:var(--color-blue);font-size:0.9rem;">🔗 ${sims.length} ähnliche Anträge</summary>${items}</details>`;
|
||
} catch (e) { /* silent */ }
|
||
}
|
||
|
||
async function loadHistory(drucksache) {
|
||
const el = document.getElementById('history-' + drucksache.replace('/','-'));
|
||
if (!el) return;
|
||
try {
|
||
const history = await fetch(`/api/assessment/history?drucksache=${encodeURIComponent(drucksache)}`).then(r => r.json());
|
||
if (!history.length) { el.innerHTML = ''; return; }
|
||
el.innerHTML = `<details style="margin-top:0.5rem;"><summary style="cursor:pointer;color:var(--color-blue);">📋 ${history.length} frühere Version${history.length > 1 ? 'en' : ''}</summary>` +
|
||
history.map(h => `<div style="padding:0.3rem 0;border-bottom:1px solid #f0f0f0;">v${h.version} · Score ${h.gwoe_score ?? '?'}/10 · ${h.model || '?'} · ${new Date(h.created_at).toLocaleDateString('de-DE')}</div>`).join('') +
|
||
'</details>';
|
||
} catch (e) { /* silent */ }
|
||
}
|
||
|
||
// ─── Crowd-Validation / Votes (#112) ─────────────────────────────────
|
||
async function castVote(drucksache, target, vote, btn) {
|
||
if (!currentUser) { alert('Bitte zuerst anmelden.'); return; }
|
||
try {
|
||
const resp = await fetch('/api/vote', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||
body: `drucksache=${encodeURIComponent(drucksache)}&target=${target}&vote=${vote}`
|
||
});
|
||
if (resp.ok) loadVotes(drucksache);
|
||
} catch (e) { console.error('Vote error:', e); }
|
||
}
|
||
|
||
async function loadVotes(drucksache) {
|
||
try {
|
||
const data = await fetch(`/api/votes?drucksache=${encodeURIComponent(drucksache)}`).then(r => r.json());
|
||
const counts = data.counts?.overall || {up: 0, down: 0};
|
||
const myVote = data.my_votes?.overall;
|
||
const container = document.getElementById('vote-overall');
|
||
if (!container) return;
|
||
const buttons = container.querySelectorAll('.btn-vote');
|
||
buttons[0].querySelector('.vote-count').textContent = counts.up || 0;
|
||
buttons[1].querySelector('.vote-count').textContent = counts.down || 0;
|
||
buttons[0].style.background = myVote === 'up' ? '#e8f5e9' : '';
|
||
buttons[0].style.borderColor = myVote === 'up' ? '#889e33' : '#ddd';
|
||
buttons[1].style.background = myVote === 'down' ? '#fce4ec' : '';
|
||
buttons[1].style.borderColor = myVote === 'down' ? '#dc3545' : '#ddd';
|
||
} catch (e) { /* silent */ }
|
||
}
|
||
|
||
function loadQueuePanel() {
|
||
const el = document.getElementById('queue-panel-content');
|
||
async function refresh() {
|
||
try {
|
||
const qs = await fetch('/api/queue/status').then(r => r.json());
|
||
const jobs = (qs.jobs || []).filter(j => j.status !== 'stale');
|
||
if (jobs.length === 0 && qs.pending === 0) {
|
||
el.innerHTML = '<p style="color:#888;">Keine Aufträge in der Warteschlange.</p>';
|
||
return;
|
||
}
|
||
const completed = jobs.filter(j => j.status === 'completed').length;
|
||
const pct = jobs.length > 0 ? Math.round(completed / jobs.length * 100) : 0;
|
||
el.innerHTML = `
|
||
<div style="margin-bottom:0.5rem;font-size:0.85rem;color:#666;">
|
||
${qs.concurrency} Worker · ${qs.pending} wartend · ${qs.processed_total} verarbeitet
|
||
${qs.shutting_down ? '<br><span style="color:#dc3545;">⚠ Server wird heruntergefahren</span>' : ''}
|
||
</div>
|
||
<div style="background:#eee;border-radius:4px;height:6px;margin-bottom:0.75rem;">
|
||
<div style="background:var(--color-green);height:100%;width:${pct}%;transition:width 0.5s;border-radius:4px;"></div>
|
||
</div>
|
||
<table style="width:100%;font-size:0.8rem;border-collapse:collapse;">
|
||
${jobs.map(j => {
|
||
const icon = j.status === 'completed' ? '✅' : j.status === 'processing' ? '⏳' : j.status === 'failed' ? '❌' : '⏸';
|
||
return '<tr style="border-top:1px solid #f0f0f0;"><td>' + (j.drucksache || j.job_id.substring(0,8)) + '</td><td>' + icon + '</td><td>' + (j.duration ? j.duration + 's' : '') + '</td></tr>';
|
||
}).join('')}
|
||
</table>`;
|
||
} catch { el.innerHTML = '<span style="color:#c00;">Fehler</span>'; }
|
||
}
|
||
refresh();
|
||
// Auto-refresh solange Panel offen
|
||
const iv = setInterval(() => {
|
||
if (document.getElementById('queue-panel').style.display === 'none') { clearInterval(iv); return; }
|
||
refresh();
|
||
}, 3000);
|
||
}
|
||
|
||
async function startBatchModal() {
|
||
const bl = document.getElementById('batch-bl-modal').value;
|
||
const limit = document.getElementById('batch-limit-modal').value;
|
||
const status = document.getElementById('batch-modal-status');
|
||
status.innerHTML = '<span style="color:var(--color-blue);">⏳ Wird gestartet...</span>';
|
||
try {
|
||
const resp = await fetch('/api/batch-analyze', {
|
||
method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||
body: 'bundesland=' + bl + '&limit=' + limit
|
||
});
|
||
if (resp.status === 401 || resp.status === 403) {
|
||
status.innerHTML = '<span style="color:#dc3545;">🔒 Admin-Berechtigung erforderlich.</span>';
|
||
return;
|
||
}
|
||
const data = await resp.json();
|
||
status.innerHTML = '<span style="color:var(--color-green);">✓ ' + (data.enqueued || 0) + ' Anträge eingereiht. Queue-Panel öffnen um Fortschritt zu sehen.</span>';
|
||
} catch (e) {
|
||
status.innerHTML = '<span style="color:#dc3545;">❌ ' + e.message + '</span>';
|
||
}
|
||
}
|
||
</script>
|
||
<!-- Queue-Statusbar (unten links) -->
|
||
<div id="queue-statusbar" style="display:none;position:fixed;bottom:1rem;left:1rem;background:var(--color-card);border:1px solid var(--color-lightgray);border-radius:6px;padding:0.4rem 0.8rem;font-size:0.8rem;color:var(--color-muted);box-shadow:0 2px 8px rgba(0,0,0,0.1);z-index:100;cursor:default;transition:all 0.2s;" onmouseenter="showQueueTooltip()" onmouseleave="hideQueueTooltip()">
|
||
<span id="queue-status-text"></span>
|
||
</div>
|
||
<div id="queue-tooltip" style="display:none;position:fixed;bottom:3.5rem;left:1rem;background:var(--color-card);border:1px solid var(--color-lightgray);border-radius:6px;padding:1rem;font-size:0.8rem;box-shadow:0 4px 16px rgba(0,0,0,0.15);z-index:101;max-width:400px;max-height:300px;overflow-y:auto;">
|
||
</div>
|
||
|
||
<script>
|
||
// Queue-Statusbar: pollt alle 5s, zeigt kompakten Status
|
||
let _queueInterval;
|
||
function startQueuePolling() {
|
||
async function poll() {
|
||
try {
|
||
const qs = await fetch('/api/queue/status').then(r => r.json());
|
||
const jobs = (qs.jobs || []).filter(j => j.status !== 'stale');
|
||
const processing = jobs.filter(j => j.status === 'processing').length;
|
||
const queued = jobs.filter(j => j.status === 'queued' || j.status === 'pending').length;
|
||
const completed = jobs.filter(j => j.status === 'completed').length;
|
||
const failed = jobs.filter(j => j.status === 'failed').length;
|
||
const bar = document.getElementById('queue-statusbar');
|
||
const text = document.getElementById('queue-status-text');
|
||
const active = processing + queued;
|
||
if (active === 0 && completed === 0 && failed === 0) {
|
||
bar.style.display = 'none';
|
||
return;
|
||
}
|
||
bar.style.display = 'block';
|
||
let parts = [];
|
||
if (processing > 0) parts.push(`⏳ ${processing} in Bearbeitung`);
|
||
if (queued > 0) parts.push(`⏸ ${queued} wartend`);
|
||
if (completed > 0) parts.push(`✅ ${completed} fertig`);
|
||
if (failed > 0) parts.push(`❌ ${failed} fehlgeschlagen`);
|
||
text.textContent = parts.join(' · ');
|
||
// Tooltip-Content vorbereiten
|
||
const tooltip = document.getElementById('queue-tooltip');
|
||
tooltip.innerHTML = `<div style="margin-bottom:0.5rem;font-weight:bold;">Queue (${qs.workers_running} Worker)</div>` +
|
||
jobs.slice(0, 20).map(j => {
|
||
const icon = j.status === 'completed' ? '✅' : j.status === 'processing' ? '⏳' : j.status === 'failed' ? '❌' : '⏸';
|
||
return `<div style="padding:0.2rem 0;border-bottom:1px solid #f0f0f0;font-size:0.75rem;">${icon} ${j.drucksache || '?'} <span style="color:#aaa;">${j.bundesland || ''} ${j.duration ? j.duration + 's' : ''}</span></div>`;
|
||
}).join('');
|
||
} catch {}
|
||
}
|
||
poll();
|
||
_queueInterval = setInterval(poll, 5000);
|
||
}
|
||
function showQueueTooltip() { document.getElementById('queue-tooltip').style.display = 'block'; }
|
||
function hideQueueTooltip() { document.getElementById('queue-tooltip').style.display = 'none'; }
|
||
startQueuePolling();
|
||
</script>
|
||
|
||
</body>
|
||
</html>
|