gwoe-antragspruefer/app/templates/v2/screens/antrag_detail.html
Dotty Dotter 61c39eb820 fix(share): Threads-Unicode + Instagram-Dialog macOS (#178)
Threads-Encoding: rendered Sonderzeichen als ? oder Rauten, weil der
Text mit zerlegten Codepoints (z.B. ``a`` + Combining Diaeresis statt
``ä``) ankam — Threads' Composer kommt damit nicht klar. Fix: NFC-
Normalisierung (``str.normalize('NFC')``) vor encodeURIComponent. Das
vereinigt zerlegte Umlaute und typografische Anführungszeichen.

Instagram-Share auf macOS: bisher versuchten wir auch auf Desktop den
``navigator.share()``-Pfad mit File. Das öffnet das macOS-Share-Sheet,
zeigt aber nur AirDrop / Mail / Notizen — Instagram-App ist auf Desktop
nicht installiert, also nutzlos. Fix: Mobile-Detection via User-Agent
+ maxTouchPoints (für iPad-iOS-13+-Maskierung). Auf Desktop direkt zu
Pfad B (Download + Clipboard) statt OS-Sheet.
2026-05-09 02:13:28 +02:00

1334 lines
65 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% extends "v2/base.html" %}
{% from "v2/components/score_hero.html" import score_hero %}
{% from "v2/components/matrix_mini.html" import matrix_mini %}
{% from "v2/components/quote_card.html" import quote_card %}
{% from "v2/components/kasten.html" import kasten %}
{% from "v2/components/redline.html" import redline %}
{% block title %}
{% if antrag is defined and antrag %}{{ antrag.title }} — {% endif %}GWÖ-Antragsprüfer
{% endblock %}
{% block head_extra %}
{% if antrag is defined and antrag %}
{# ── Open-Graph / Twitter-Card-Meta (#141) ────────────────────────── #}
{% set _og_img = "/api/og/" ~ (antrag.drucksache | urlencode) ~ ".png" %}
<meta property="og:type" content="article">
<meta property="og:title" content="{{ antrag.title }} — GWÖ-Antragsprüfer">
<meta property="og:description" content="GWÖ-Score {{ '%.1f'|format(antrag.score|float) }}/10 — {{ antrag.zusammenfassung | truncate(160, True) }}">
<meta property="og:image" content="{{ _og_img }}">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
{% if antrag.updated_at_raw %}
<meta property="og:updated_time" content="{{ antrag.updated_at_raw }}">
{% endif %}
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{{ antrag.title }} — GWÖ-Antragsprüfer">
<meta name="twitter:description" content="GWÖ-Score {{ '%.1f'|format(antrag.score|float) }}/10 — {{ antrag.zusammenfassung | truncate(160, True) }}">
<meta name="twitter:image" content="{{ _og_img }}">
{% endif %}
{% endblock %}
{% set v2_active_nav = "durchsuchen" %}
{% block main %}
{# ── Fehlerfall ──────────────────────────────────────────────────── #}
{% if error is defined and error %}
<div class="v2-kasten" style="border-color:var(--redline-contra);margin-top:32px;">
<h3 style="color:var(--redline-contra);">Antrag nicht gefunden</h3>
<p>{{ error }}</p>
<p><a href="/">← Zurück zur Übersicht</a></p>
</div>
{% elif antrag is not defined or not antrag %}
{# ── Demo-Daten wenn kein echtes Antrag-Objekt übergeben ────────── #}
{% set antrag = {
"drucksache": "18/4412",
"bundesland": "BW",
"parlament": "Landtag",
"typ": "Antrag",
"datum": "12.04.2026",
"analysiert": "14.04.2026",
"modell": "qwen-plus",
"parteien": ["GRÜNE", "SPD"],
"zitate_count": 3,
"title": "Kommunale Wärmeplanung bis 2028 verpflichtend machen",
"score": 9.1,
"verdict_title": "Vorbildlich",
"verdict_body": "Starker Beitrag zur ökologischen Nachhaltigkeit und Transparenz auf kommunaler Ebene.",
"zusammenfassung": "Der Antrag verpflichtet Kommunen ab 10 000 Einwohner:innen zur Erstellung einer kommunalen Wärmeplanung bis Ende 2028.",
"staerkster_wert": {
"titel": "Ökologische Nachhaltigkeit",
"text": "Verpflichtende Wärmeplanung führt zu messbaren Klimazielen. E3 = ++, D3 = ++."
},
"schwaechster_wert": {
"titel": "Soziale Gerechtigkeit",
"text": "Kostenverteilung auf Mieter:innen versus Eigentümer:innen ist im Antrag nicht geregelt."
},
"redline": {
"segments": [
{"type": "ctx", "text": "§ 3 Abs. 2 "},
{"type": "del", "text": "auf Antrag"},
{"type": "ins", "text": "verpflichtend"},
{"type": "ctx", "text": " eine sozialverträgliche Umlage"}
]
},
"matrix": {
"A1": {"rating": 0, "symbol": "○"}, "A2": {"rating": 1, "symbol": "+"},
"A3": {"rating": 2, "symbol": "++"}, "A4": {"rating": 0, "symbol": "○"},
"A5": {"rating": 1, "symbol": "+"},
"B1": {"rating": 0, "symbol": "○"}, "B2": {"rating": 1, "symbol": "+"},
"B3": {"rating": 2, "symbol": "++"}, "B4": {"rating": -1, "symbol": ""},
"B5": {"rating": 1, "symbol": "+"},
"C1": {"rating": 0, "symbol": "○"}, "C2": {"rating": 1, "symbol": "+"},
"C3": {"rating": 1, "symbol": "+"}, "C4": {"rating": 0, "symbol": "○"},
"C5": {"rating": 2, "symbol": "++"},
"D1": {"rating": 1, "symbol": "+"}, "D2": {"rating": 1, "symbol": "+"},
"D3": {"rating": 2, "symbol": "++"}, "D4": {"rating": 1, "symbol": "+"},
"D5": {"rating": 2, "symbol": "++"},
"E1": {"rating": 1, "symbol": "+"}, "E2": {"rating": 2, "symbol": "++"},
"E3": {"rating": 2, "symbol": "++"}, "E4": {"rating": 1, "symbol": "+"},
"E5": {"rating": 1, "symbol": "+"}
},
"zitate": [
{
"text": "Wir verpflichten alle Kommunen zu einer verbindlichen kommunalen Wärmeplanung bis 2028.",
"source": "Wahlprogramm GRÜNE 2022 · S. 84",
"partei": "GRÜNE",
"verified": True,
"contra": False,
"pdf_href": "/api/wahlprogramm-cite?pid=gruene-nrw-2022&seite=84&q=Wärmeplanung"
}
],
"verbesserungen": [],
"staerken": [],
"schwaechen": []
} %}
{# Fallthrough: Demo-Daten rendern wie echte Daten #}
{% set _render = True %}
{% else %}
{% set _render = True %}
{% endif %}
{# ── Eigentlicher Detail-Inhalt ──────────────────────────────────── #}
{% if _render is defined and _render and antrag is defined and antrag %}
{# ── Zurück-Link ─────────────────────────────────────────────────── #}
<p style="font-family:var(--font-mono);font-size:11px;margin-bottom:20px;">
<a href="/">← Zurück zur Übersicht</a>
</p>
{# ── Split-Layout ────────────────────────────────────────────────── #}
<div class="v2-detail">
{# ── Linke Spalte: Redaktionelle Analyse ── #}
<div class="left">
{% block antrag_id_section %}
<div class="v2-antrag-id">
{{ antrag.bundesland | default("") }}
{% if antrag.drucksache %} · Drs. {{ antrag.drucksache }}{% endif %}
{% if antrag.typ %} · {{ antrag.typ }}{% endif %}
{% if antrag.datum %} · eingebracht {{ antrag.datum }}{% endif %}
</div>
{% endblock %}
<h1 class="v2-big-title">{{ antrag.title | default("Antrag") }}</h1>
{% if antrag.parteien or antrag.analysiert %}
<div class="v2-byline">
{% if antrag.parteien %}Eingebracht von {{ antrag.parteien | join(", ") }}{% endif %}
{% if antrag.analysiert %} — Analyse {{ antrag.analysiert }}{% endif %}
{% if antrag.modell %}, {{ antrag.modell }}{% endif %}
{% if antrag.zitate_count %} · {{ antrag.zitate_count }} Zitat{{ "e" if antrag.zitate_count != 1 else "" }} verifiziert{% endif %}
</div>
{% endif %}
{% if antrag.zusammenfassung %}
<h3 class="v2-h3">Zusammenfassung</h3>
<p style="font-size:14.5px;line-height:1.55">{{ antrag.zusammenfassung }}</p>
{% endif %}
{# Stärkster Wert #}
{% if antrag.staerkster_wert and antrag.staerkster_wert.text %}
<div class="v2-kasten outline-green" style="margin-top:20px;">
<h4>Stärkster Wert{% if antrag.staerkster_wert.titel %} — {{ antrag.staerkster_wert.titel }}{% endif %}</h4>
<p>{{ antrag.staerkster_wert.text }}</p>
</div>
{% elif antrag.staerken %}
<div class="v2-kasten outline-green" style="margin-top:20px;">
<h4>Stärken</h4>
<ul style="margin:0;padding-left:1.2em;">
{% for s in antrag.staerken %}
<li style="font-size:13.5px;line-height:1.5;">{{ s }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
{# Schwächster Wert #}
{% if antrag.schwaechster_wert and antrag.schwaechster_wert.text %}
<div class="v2-kasten outline-blue">
<h4>Schwächster Wert{% if antrag.schwaechster_wert.titel %} — {{ antrag.schwaechster_wert.titel }}{% endif %}</h4>
<p>{{ antrag.schwaechster_wert.text }}</p>
</div>
{% elif antrag.schwaechen %}
<div class="v2-kasten outline-blue">
<h4>Schwächen</h4>
<ul style="margin:0;padding-left:1.2em;">
{% for s in antrag.schwaechen %}
<li style="font-size:13.5px;line-height:1.5;">{{ s }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% block verbesserungen_section %}
{# Verbesserungsvorschläge: alle verbesserungen rendern wenn vorhanden #}
{% if antrag.verbesserungen %}
<h3 class="v2-h3" style="margin-top:24px;">Verbesserungsvorschläge</h3>
{% for v in antrag.verbesserungen %}
<div style="margin-bottom:16px;">
{% if antrag.verbesserungen | length > 1 %}
<div style="font-family:var(--font-mono);font-size:11px;color:var(--ecg-dark);opacity:0.65;margin-bottom:4px;">
Vorschlag {{ loop.index }} von {{ antrag.verbesserungen | length }}
</div>
{% endif %}
{% from "v2/components/redline.html" import redline %}
{% if v.segments %}
{{ redline(original=v.original | default(""), segments=v.segments) }}
{% else %}
{{ redline(original=v.original | default(""), vorschlag=v.vorschlag | default("")) }}
{% endif %}
{% if v.begruendung %}
<p style="font-size:12px;color:var(--ecg-dark);opacity:0.75;margin:4px 0 0;font-family:var(--font-mono);">
{{ v.begruendung }}
</p>
{% endif %}
</div>
{% endfor %}
{% elif antrag.redline and antrag.redline.segments %}
<h3 class="v2-h3" style="margin-top:24px;">Verbesserungsvorschlag</h3>
{% from "v2/components/redline.html" import redline %}
{{ redline(segments=antrag.redline.segments) }}
{% endif %}
{% endblock %}
</div>{# .left #}
{# ── Rechte Spalte: Bewertungs-Panel ── #}
<div class="right">
{# Bewertungs-Header mit Merken-Button rechts — visuelle Einheit
(User-Wunsch: „Bewertung passend zu Merken verschieben"). #}
<div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:6px;">
<div class="v2-antrag-id">Bewertung</div>
<button id="v2-merkliste-btn"
onclick="v2DetailMerklisteToggle()"
style="display:inline-flex;align-items:center;gap:6px;padding:4px 10px;
border:1px solid var(--hairline);border-radius:4px;background:none;
cursor:pointer;font-family:var(--font-mono);font-size:11px;color:var(--ecg-dark);">
<span id="v2-merkliste-star"></span>
<span id="v2-merkliste-label">Merken</span>
</button>
</div>
{% block score_hero_section %}
{{ score_hero(antrag.score | default(0), antrag.verdict_title | default(""), antrag.verdict_body | default("")) }}
{% endblock %}
{# ── Namentliche Abstimmung (#106 Phase 1) ── #}
{% if antrag.abstimmungsverhalten %}
{% set aw = antrag.abstimmungsverhalten %}
<h3 class="v2-h3" style="margin-top:24px;">Namentliche Abstimmung</h3>
<div style="font-family:var(--font-mono);font-size:11px;color:var(--ecg-dark);opacity:0.75;margin-bottom:10px;">
{% if aw.datum %}{{ aw.datum }} · {% endif %}
{% if aw.accepted %}Angenommen{% else %}Abgelehnt{% endif %}
</div>
{% for f in aw.fraktionen %}
{% set total = (f.yes + f.no + f.abstain + f.no_show) | int %}
{% if total > 0 %}
<div style="margin-bottom:8px;">
<div style="font-family:var(--font-mono);font-size:11px;margin-bottom:3px;display:flex;justify-content:space-between;">
<span>{{ f.partei }}</span>
<span style="opacity:0.65;">{{ f.yes }}✓ {{ f.abstain }}○ {{ f.no }}✗</span>
</div>
<div style="display:flex;height:8px;border-radius:4px;overflow:hidden;background:var(--ecg-light,#f0f0f0);">
{% if f.yes > 0 %}
<div style="flex:{{ f.yes }};background:#2da44e;" title="{{ f.yes }} Ja"></div>
{% endif %}
{% if f.abstain > 0 %}
<div style="flex:{{ f.abstain }};background:#adb5bd;" title="{{ f.abstain }} Enthaltung"></div>
{% endif %}
{% if f.no > 0 %}
<div style="flex:{{ f.no }};background:#cf222e;" title="{{ f.no }} Nein"></div>
{% endif %}
</div>
</div>
{% endif %}
{% endfor %}
{% endif %}{# abstimmungsverhalten #}
{# ── Fraktions-aggregierte Plenum-Abstimmung aus Plenarprotokoll (#106) ── #}
{% if antrag.plenum_votes %}
<h3 class="v2-h3" style="margin-top:24px;">Abstimmungsergebnis</h3>
{% set ergebnis_color = {
"angenommen": "#2da44e",
"abgelehnt": "#cf222e",
"überwiesen": "#0969da",
"zurückgezogen": "#8250df",
"bestätigt": "#2da44e",
"sammel": "#0969da",
} %}
{# Konsistenz-Hinweis: GWÖ-Empfehlung vs. tatsächlicher Beschluss.
Logik in app/marker.py — siehe ADR 0010. #}
{% set _state = consistency_state(antrag.verdict_title, antrag.plenum_votes) %}
{% set _decisive = decisive_outcome(antrag.plenum_votes) %}
{% if _state %}
<div style="margin-bottom:10px;padding:8px 12px;border-radius:6px;font-size:12px;line-height:1.5;
background:{% if _state == 'conflict' %}color-mix(in srgb,#cf222e 8%,transparent){% else %}color-mix(in srgb,#2da44e 8%,transparent){% endif %};
border-left:3px solid {% if _state == 'conflict' %}#cf222e{% else %}#2da44e{% endif %};">
<strong>{% if _state == 'conflict' %}Mehrheit kontra GWÖ-Empfehlung{% else %}Mehrheit deckt sich mit GWÖ-Empfehlung{% endif %}</strong>
— Empfohlen: <em>{{ antrag.verdict_title }}</em>; Beschluss: <em>{{ _decisive | capitalize }}</em>.
</div>
{% endif %}
{% for v in antrag.plenum_votes %}
<div style="border:1px solid var(--hairline);border-radius:6px;padding:12px 14px;margin-bottom:10px;background:var(--paper);">
<div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:6px;">
<span style="font-family:var(--font-display);font-size:14px;font-weight:700;color:{{ ergebnis_color.get(v.ergebnis, '#6e7781') }};">
{{ v.ergebnis | capitalize }}{% if v.einstimmig %} · einstimmig{% endif %}
</span>
{% if v.quelle_url %}
<a href="{{ v.quelle_url }}" target="_blank" rel="noopener"
class="v2-quelle-link"
style="font-family:var(--font-mono);font-size:10px;color:var(--ecg-blue);text-decoration:none;border-bottom:1px solid rgba(0,157,165,0.35);"
title="Plenarprotokoll im neuen Tab öffnen ({{ v.quelle_url }})">
{{ v.quelle_protokoll }} <span aria-hidden="true"></span>
</a>
{% else %}
<span style="font-family:var(--font-mono);font-size:10px;opacity:0.6;">{{ v.quelle_protokoll }}</span>
{% endif %}
</div>
{% if v.fraktionen_ja or v.fraktionen_nein or v.fraktionen_enthaltung %}
{# Mehrheits-Bar: Fraktions-Anzahlen pro Lager als Stacked Bar #}
{% set _n_ja = v.fraktionen_ja | length %}
{% set _n_nein = v.fraktionen_nein | length %}
{% set _n_enth = v.fraktionen_enthaltung | length %}
{% set _n_total = _n_ja + _n_nein + _n_enth %}
{% if _n_total > 0 %}
<div style="margin:6px 0 10px;">
<div style="display:flex;height:10px;border-radius:3px;overflow:hidden;border:1px solid var(--hairline);"
title="Fraktions-Mehrheit: {{ _n_ja }} Ja · {{ _n_nein }} Nein · {{ _n_enth }} Enth. (kein Sitz-/Stimm-Anteil)">
{% if _n_ja %}<div style="width:{{ (100 * _n_ja / _n_total) }}%;background:#2da44e;"></div>{% endif %}
{% if _n_enth %}<div style="width:{{ (100 * _n_enth / _n_total) }}%;background:#6e7781;"></div>{% endif %}
{% if _n_nein %}<div style="width:{{ (100 * _n_nein / _n_total) }}%;background:#cf222e;"></div>{% endif %}
</div>
<div style="font-family:var(--font-mono);font-size:9px;opacity:0.5;margin-top:3px;">
{{ _n_ja }}/{{ _n_total }} Fraktionen Ja · {{ _n_nein }} Nein · {{ _n_enth }} Enth.
<span style="opacity:0.7;cursor:help;" title="Mehrheit nach Fraktionsanzahl, nicht nach Sitzen — Plenarprotokoll liefert keine Sitz-/Stimm-Counts."></span>
</div>
</div>
{% endif %}
<div style="display:flex;flex-wrap:wrap;gap:12px;font-family:var(--font-mono);font-size:11px;">
{% if v.fraktionen_ja %}
<div><span style="color:#2da44e;font-weight:700;">Ja:</span>
{% for f in v.fraktionen_ja %}
{% set _opp_match = opportunismus_score(f, antrag.fraktions_scores) %}
<span class="v2-vote-pill v2-vote-ja">{{ f }}{% if _opp_match is not none %}<span class="v2-vote-marker v2-marker-opp" tabindex="0" role="button" aria-label="Opportunismus-Hinweis: Ja trotz schwacher Wahlprogramm-Übereinstimmung" title="Opportunismus-Marker — Diese Fraktion stimmte mit Ja, obwohl der Antrag nicht zum eigenen Wahlprogramm passt (WP-Score {{ '%.0f' | format(_opp_match) }}/10).">!</span>{% endif %}</span>
{% endfor %}
</div>
{% endif %}
{% if v.fraktionen_nein %}
<div><span style="color:#cf222e;font-weight:700;">Nein:</span>
{% for f in v.fraktionen_nein %}
{% set _wp_match = heuchelei_score(f, antrag.fraktions_scores) %}
<span class="v2-vote-pill v2-vote-nein">{{ f }}{% if _wp_match is not none %}<span class="v2-vote-marker v2-marker-heuchelei" tabindex="0" role="button" aria-label="Heuchelei-Hinweis: Nein trotz hoher Wahlprogramm-Übereinstimmung" title="Heuchelei-Marker — Diese Fraktion stimmte mit Nein, obwohl der Antrag gut zum eigenen Wahlprogramm passt (WP-Score {{ '%.0f' | format(_wp_match) }}/10)."></span>{% endif %}</span>
{% endfor %}
</div>
{% endif %}
{% if v.fraktionen_enthaltung %}
<div><span style="color:#6e7781;font-weight:700;cursor:help;border-bottom:1px dotted currentColor;" title="Enth. — Enthaltung: weder Zustimmung noch Ablehnung.">Enth.:</span>
{% for f in v.fraktionen_enthaltung %}<span class="v2-vote-pill v2-vote-enth">{{ f }}</span>{% endfor %}
</div>
{% endif %}
</div>
{# Marker-Legende: nur einblenden wenn auf dem Vote-Block ein
⚠- oder !-Marker tatsächlich vorkam, sonst würde das Bord der
Bürgerin Erklärungstext zeigen, wofür sie kein Symbol sieht. #}
{% set _has_heuchelei = false %}
{% for f in (v.fraktionen_nein or []) %}
{% if heuchelei_score(f, antrag.fraktions_scores) is not none %}{% set _has_heuchelei = true %}{% endif %}
{% endfor %}
{% set _has_opp = false %}
{% for f in (v.fraktionen_ja or []) %}
{% if opportunismus_score(f, antrag.fraktions_scores) is not none %}{% set _has_opp = true %}{% endif %}
{% endfor %}
{% if _has_heuchelei or _has_opp %}
<div class="v2-marker-legend">
{% if _has_heuchelei %}
<span><span class="v2-marker-icon v2-marker-heuchelei"></span> Heuchelei: Nein trotz hoher Wahlprogramm-Übereinstimmung (WP&nbsp;&nbsp;7/10)</span>
{% endif %}
{% if _has_opp %}
<span><span class="v2-marker-icon v2-marker-opp">!</span> Opportunismus: Ja trotz schwacher Wahlprogramm-Übereinstimmung (WP&nbsp;&lt;&nbsp;3/10)</span>
{% endif %}
</div>
{% endif %}
{% endif %}
</div>
{% endfor %}
<div style="font-family:var(--font-mono);font-size:10px;opacity:0.5;margin-top:-4px;margin-bottom:8px;">
Quelle: Plenarprotokoll · automatisch extrahiert
</div>
{% endif %}{# plenum_votes #}
{% block matrix_section %}
{% if antrag.matrix %}
<h3 class="v2-h3">Matrix 2.0 · 25 Felder</h3>
{{ matrix_mini(antrag.matrix) }}
{% endif %}
{% endblock %}
{# Programm-Treue im BELEGE-Layout: pro Partei zwei <details>-Blöcke
(Wahlprogramm + Parteiprogramm). Summary zeigt Bewertung,
expanded zeigt Einschätzung + Belege. Default OPEN — alles sofort
lesbar, der Faltmechanismus ist optional zum Skimmen. #}
{% if antrag.fraktions_scores %}
<h3 class="v2-h3" style="margin-top:24px;">Programm-Treue pro Fraktion</h3>
<div class="v2-fraktions-scores">
{% for fs in antrag.fraktions_scores %}
<div class="v2-fraktion-row">
<div class="v2-fraktion-head">
<span class="v2-fraktion-label">{{ fs.fraktion }}</span>
{% if fs.ist_antragsteller %}<span class="v2-pill v2-pill-antrag">Antragsteller:in</span>{% endif %}
{% if fs.ist_regierung %}<span class="v2-pill v2-pill-reg">Regierungsfraktion</span>{% endif %}
</div>
{% for prog_key, prog_label in [("wahlprogramm","Wahlprogramm"), ("parteiprogramm","Parteiprogramm")] %}
{% set prog = fs[prog_key] %}
{% set p_score = prog.score | float %}
<details class="v2-treue-block" open>
<summary class="v2-treue-row">
<span class="v2-treue-label">{{ prog_label }}</span>
<span class="v2-treue-spacer"></span>
<span class="v2-treue-score-tag">Bewertung</span>
<span class="v2-score-chip {% if p_score >= 7 %}chip-green{% elif p_score >= 4 %}chip-mid{% else %}chip-red{% endif %}">{{ "%.0f"|format(p_score) }}/10</span>
</summary>
<div class="v2-treue-body">
{% if prog.begruendung %}
<div class="v2-einschaetzung">
<div class="v2-treue-sublabel">Einschätzung</div>
<div class="v2-einschaetzung-text">{{ prog.begruendung }}</div>
</div>
{% endif %}
{% if prog.zitate %}
<div class="v2-belege">
<div class="v2-treue-sublabel">Belege</div>
{% for z in prog.zitate %}
{{ quote_card(z.text, z.source, True, False, z.pdf_href) }}
{% endfor %}
</div>
{% endif %}
</div>
</details>
{% endfor %}
</div>
{% endfor %}
</div>
{% endif %}
{# ── News-Match-Box: aktuelle News passend zu diesem Antrag (#170) ── #}
<div id="ad-news-box" style="display:none;margin-top:32px;background:var(--ecg-card-bg);border:1px solid var(--ecg-border);border-radius:6px;padding:16px;">
<h3 class="v2-h3" style="margin:0 0 8px;">Aktuelle News passend zu diesem Antrag</h3>
<p style="font-size:11px;font-family:var(--font-mono);opacity:0.65;margin:0 0 12px;">
Embedding-Match aus den letzten 90 Tagen. Quelle: Tagesschau-API + Bundestag-RSS.
</p>
<div id="ad-news-list">
<div style="font-family:var(--font-mono);font-size:12px;opacity:0.5;">Lade …</div>
</div>
</div>
{% block aktions_section %}
{# Aktions-Links #}
<div style="margin-top:24px;font-family:var(--font-mono);font-size:11px;color:var(--ecg-dark);opacity:0.85;display:flex;gap:16px;flex-wrap:wrap;">
<a href="/api/assessment/pdf?drucksache={{ antrag.drucksache | urlencode }}"
style="color:var(--ecg-blue);border-bottom:1px solid rgba(0,157,165,0.35);">
PDF-Bericht
</a>
<a href="/api/assessment?drucksache={{ antrag.drucksache | urlencode }}"
style="color:var(--ecg-blue);border-bottom:1px solid rgba(0,157,165,0.35);">
JSON-Export
</a>
<a href="/antrag/{{ antrag.drucksache }}"
onclick="v3CopyPermalink(event, this)"
data-copied-label="Permalink kopiert ✓"
style="color:var(--ecg-blue);border-bottom:1px solid rgba(0,157,165,0.35);">
Permalink kopieren
</a>
</div>
{% endblock %}
{# ── Voting-Block ─────────────────────────────────────────────── #}
<div style="margin-top:24px;">
<div style="font-family:var(--font-mono);font-size:10px;text-transform:uppercase;letter-spacing:0.07em;color:var(--ecg-dark);opacity:0.6;margin-bottom:8px;">Bewertung treffend?</div>
<div id="v2-vote-overall" style="display:flex;gap:8px;align-items:center;">
<button id="v2-vote-up"
onclick="v2DetailCastVote('{{ antrag.drucksache | e }}','up')"
style="display:inline-flex;align-items:center;gap:5px;padding:5px 10px;border:1px solid var(--hairline);border-radius:4px;background:none;cursor:pointer;font-family:var(--font-mono);font-size:12px;color:var(--ecg-dark);">
👍 <span id="v2-vote-up-count">0</span>
</button>
<button id="v2-vote-down"
onclick="v2DetailCastVote('{{ antrag.drucksache | e }}','down')"
style="display:inline-flex;align-items:center;gap:5px;padding:5px 10px;border:1px solid var(--hairline);border-radius:4px;background:none;cursor:pointer;font-family:var(--font-mono);font-size:12px;color:var(--ecg-dark);">
👎 <span id="v2-vote-down-count">0</span>
</button>
</div>
</div>
{# ── Share-Block (analog v1) — nur fuer angemeldete User sichtbar,
wird in initAuth() ein-/ausgeblendet (display:none default). ── #}
<div id="v2-share-block" style="margin-top:20px;display:none;">
<div style="font-family:var(--font-mono);font-size:10px;text-transform:uppercase;letter-spacing:0.07em;color:var(--ecg-dark);opacity:0.6;margin-bottom:8px;">Teilen</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;">
<button onclick="v2DetailShareCopy()"
style="padding:5px 10px;border:1px solid var(--hairline);border-radius:4px;background:none;cursor:pointer;font-family:var(--font-mono);font-size:11px;color:var(--ecg-dark);">
📋 Kopieren
</button>
<button onclick="v2DetailShare('threads')"
style="padding:5px 10px;border:1px solid var(--hairline);border-radius:4px;background:none;cursor:pointer;font-family:var(--font-mono);font-size:11px;color:var(--ecg-dark);">
Threads
</button>
<button onclick="v2DetailShareMastodon()"
style="padding:5px 10px;border:1px solid var(--hairline);border-radius:4px;background:none;cursor:pointer;font-family:var(--font-mono);font-size:11px;color:var(--ecg-dark);">
Mastodon
</button>
<button onclick="v2DetailShare('linkedin')"
style="padding:5px 10px;border:1px solid var(--hairline);border-radius:4px;background:none;cursor:pointer;font-family:var(--font-mono);font-size:11px;color:var(--ecg-dark);">
LinkedIn
</button>
<button onclick="v2DetailShareInstagram()"
style="padding:5px 10px;border:1px solid var(--hairline);border-radius:4px;background:none;cursor:pointer;font-family:var(--font-mono);font-size:11px;color:var(--ecg-dark);">
📷 Instagram
</button>
<button onclick="v2DetailShareEmail()"
style="padding:5px 10px;border:1px solid var(--hairline);border-radius:4px;background:none;cursor:pointer;font-family:var(--font-mono);font-size:11px;color:var(--ecg-dark);">
📧 E-Mail
</button>
<button onclick="v2DetailShareScorecard()"
style="padding:5px 10px;border:1px solid var(--hairline);border-radius:4px;background:none;cursor:pointer;font-family:var(--font-mono);font-size:11px;color:var(--ecg-dark);">
📊 Scorecard ansehen
</button>
<button onclick="v2DetailShareImage()"
title="Stockphoto-Suche bei Magnific zum Antragsthema"
style="padding:5px 10px;border:1px solid var(--hairline);border-radius:4px;background:none;cursor:pointer;font-family:var(--font-mono);font-size:11px;color:var(--ecg-dark);">
🖼 Stock-Bild
</button>
</div>
</div>
{# ── Re-Analyze-Block ─────────────────────────────────────────── #}
<div style="margin-top:20px;">
<button id="v2-reanalyze-btn"
onclick="v2DetailReAnalyze(this)"
style="padding:5px 12px;border:1px solid var(--hairline);border-radius:4px;background:none;cursor:pointer;font-family:var(--font-mono);font-size:11px;color:var(--ecg-dark);opacity:0.8;">
Neu analysieren
</button>
</div>
{# ── Bewertungs-Historie ───────────────────────────────────────── #}
<div style="margin-top:24px;">
<div style="font-family:var(--font-mono);font-size:10px;text-transform:uppercase;letter-spacing:0.07em;color:var(--ecg-dark);opacity:0.6;margin-bottom:8px;">Bewertungs-Historie</div>
<div id="v2-history-list">
<span style="font-family:var(--font-mono);font-size:12px;color:var(--ecg-dark);opacity:0.45;">Lade…</span>
</div>
</div>
</div>{# .right #}
</div>{# .v2-detail #}
{% block comments_section %}
{# ── Kommentare ───────────────────────────────────────────────────────── #}
<div style="margin-top:40px;border-top:1px solid var(--hairline);padding-top:28px;">
<h3 class="v2-h3" style="margin-bottom:16px;">Kommentare</h3>
<div id="v2-comments-list" style="margin-bottom:20px;">
<span style="font-family:var(--font-mono);font-size:12px;color:var(--ecg-dark);opacity:0.5;">Lade…</span>
</div>
{# Kommentar-Formular — wird per JS eingeblendet wenn angemeldet #}
<div id="v2-comment-form" style="display:none;">
<div style="font-family:var(--font-mono);font-size:10px;text-transform:uppercase;letter-spacing:0.07em;color:var(--ecg-dark);opacity:0.6;margin-bottom:8px;">Kommentar hinzufügen</div>
<textarea id="v2-comment-input"
rows="3"
placeholder="Kommentar…"
style="width:100%;box-sizing:border-box;padding:8px 10px;border:1px solid var(--hairline);border-radius:4px;font-family:var(--font-sans);font-size:13px;background:var(--surface);color:var(--ecg-dark);resize:vertical;margin-bottom:8px;outline:none;"></textarea>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
<select id="v2-comment-visibility"
style="padding:5px 8px;border:1px solid var(--hairline);border-radius:4px;font-family:var(--font-mono);font-size:11px;background:var(--surface);color:var(--ecg-dark);">
<option value="all">Öffentlich</option>
<option value="authenticated">Nur Angemeldete</option>
<option value="private">Nur ich</option>
</select>
<button onclick="v2DetailAddComment('{{ antrag.drucksache | e }}')"
style="padding:5px 14px;border:none;border-radius:4px;background:var(--ecg-blue);color:#fff;cursor:pointer;font-family:var(--font-mono);font-size:11px;font-weight:700;">
Absenden
</button>
</div>
</div>
<div id="v2-comment-login-hint" style="display:none;">
<button onclick="v2AuthModalOpen()"
style="padding:5px 12px;border:1px solid var(--hairline);border-radius:4px;background:none;cursor:pointer;font-family:var(--font-mono);font-size:11px;color:var(--ecg-blue);">
Anmelden um zu kommentieren
</button>
</div>
</div>
{% endblock %}
{# ── Matrix-Feld-Info-Modal ───────────────────────────────────────────── #}
<div id="v2-matrix-field-modal"
role="dialog" aria-modal="true" aria-label="Matrix-Feld Erklärung"
style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.45);
z-index:9100;align-items:center;justify-content:center;"
onclick="if(event.target===this)this.style.display='none'">
<div style="background:var(--ecg-card-bg,#fff);border:1px solid var(--ecg-border,#ddd);
border-radius:8px;padding:24px 28px;min-width:300px;max-width:540px;
font-family:var(--font-sans);font-size:14px;color:var(--ecg-dark);
line-height:1.55;box-shadow:0 8px 32px rgba(0,0,0,0.18);">
<div style="display:flex;justify-content:space-between;align-items:baseline;gap:12px;margin-bottom:6px;">
<strong id="v2-matrix-field-title"
style="font-family:var(--font-display,inherit);font-size:16px;font-weight:900;
color:var(--ecg-teal,#009da5);letter-spacing:0.03em;"></strong>
<span id="v2-matrix-field-rating"
style="font-family:var(--font-mono);font-size:12px;font-weight:700;
padding:2px 8px;border-radius:3px;white-space:nowrap;"></span>
<button onclick="document.getElementById('v2-matrix-field-modal').style.display='none'"
style="background:none;border:none;font-size:18px;cursor:pointer;color:var(--ecg-dark);
opacity:0.55;padding:0;line-height:1;margin-left:auto;"
aria-label="Schließen">×</button>
</div>
{# Antrags-spezifischer Block: Label + ausformulierte Begründung #}
<div id="v2-matrix-field-antrag" style="display:none;margin:14px 0 0;">
<div style="font-family:var(--font-mono);font-size:10px;text-transform:uppercase;
letter-spacing:0.07em;color:var(--ecg-dark);opacity:0.6;margin-bottom:4px;">
Bewertung in diesem Antrag
</div>
<div id="v2-matrix-field-label"
style="font-weight:700;font-size:14px;margin-bottom:4px;"></div>
<div id="v2-matrix-field-aspect"
style="font-size:13.5px;line-height:1.55;"></div>
</div>
{# Allgemeine Erklärung des Matrix-Felds (als Hintergrund) #}
<div style="margin-top:16px;padding-top:12px;border-top:1px solid var(--hairline);">
<div style="font-family:var(--font-mono);font-size:10px;text-transform:uppercase;
letter-spacing:0.07em;color:var(--ecg-dark);opacity:0.6;margin-bottom:4px;">
Was misst dieses Feld?
</div>
<p id="v2-matrix-field-text" style="margin:0;font-size:13px;line-height:1.55;opacity:0.85;"></p>
</div>
</div>
</div>
{% endif %}{# _render #}
{% endblock %}
{% block body_scripts %}
<script>
/* Escape auf der Detailseite → history.back() (außer wenn Matrix-Modal offen) */
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && !e.target.matches('input, textarea, select')) {
var modal = document.getElementById('v2-matrix-field-modal');
if (modal && modal.style.display === 'flex') {
modal.style.display = 'none';
return;
}
e.preventDefault();
history.back();
}
});
</script>
{# Matrix-Erklärungen als JSON in den Browser übertragen #}
{% if matrix_explanations is defined %}
<script>
window._v2MatrixExplanations = {{ matrix_explanations | tojson }};
window._v2MatrixCells = {{ (antrag.matrix if antrag is defined and antrag else {}) | tojson }};
window.v2ShowMatrixFieldInfo = function(field) {
var explains = window._v2MatrixExplanations || {};
var cells = window._v2MatrixCells || {};
var generalText = explains[field] || '';
var cell = cells[field] || {};
var titleEl = document.getElementById('v2-matrix-field-title');
var ratingEl = document.getElementById('v2-matrix-field-rating');
var antragEl = document.getElementById('v2-matrix-field-antrag');
var labelEl = document.getElementById('v2-matrix-field-label');
var aspectEl = document.getElementById('v2-matrix-field-aspect');
var textEl = document.getElementById('v2-matrix-field-text');
var modal = document.getElementById('v2-matrix-field-modal');
if (!modal) return;
if (titleEl) titleEl.textContent = 'Feld ' + field;
/* Rating-Chip mit gleichem Farbcode wie matrix_mini-Klassen */
if (ratingEl) {
var r = (cell.rating != null) ? cell.rating : 0;
var sym = cell.symbol || (r >= 4 ? '++' : r >= 1 ? '+' : r === 0 ? '○' : r <= -4 ? '' : '');
var bg = '#eee', fg = '#555';
if (r >= 4) { bg = 'rgba(0,157,165,0.20)'; fg = '#0d6f76'; }
else if (r >= 1) { bg = 'rgba(0,157,165,0.10)'; fg = '#0d6f76'; }
else if (r === 0) { bg = 'rgba(180,180,180,0.18)'; fg = '#666'; }
else if (r >= -3) { bg = 'rgba(207,34,46,0.12)'; fg = '#a40e26'; }
else { bg = 'rgba(207,34,46,0.22)'; fg = '#a40e26'; }
ratingEl.style.background = bg;
ratingEl.style.color = fg;
ratingEl.textContent = sym + ' ' + (r > 0 ? '+' : '') + r;
}
/* Antrags-spezifischer Block: nur sichtbar wenn LLM Begründung lieferte */
var hasAntrag = (cell.label || cell.aspect);
if (antragEl) antragEl.style.display = hasAntrag ? 'block' : 'none';
if (labelEl) labelEl.textContent = cell.label || '';
if (aspectEl) aspectEl.textContent = cell.aspect || '';
/* Allgemeine Erklärung des Felds */
if (textEl) textEl.textContent = generalText || '(Keine allgemeine Erklärung vorhanden)';
modal.style.display = 'flex';
};
</script>
{% endif %}
{% if antrag is defined and antrag and antrag.drucksache %}
<script>
(function () {
var DRS = {{ antrag.drucksache | tojson }};
var BL = {{ antrag.bundesland | tojson }};
var SHARE_THR = {{ (antrag.share_threads or '') | tojson }};
var SHARE_TWI = {{ (antrag.share_twitter or '') | tojson }};
var SHARE_MAS = {{ (antrag.share_mastodon or '') | tojson }};
var TITLE = {{ antrag.title | tojson }};
var SCORE = {{ antrag.score | tojson }};
window.ANTRAG_TOPICS = {{ (antrag.themen or []) | tojson }};
var PERMALINK = 'https://gwoe.toppyr.de/antrag/' + encodeURIComponent(DRS);
var currentUser = null;
/* ── Auth-State laden ─────────────────────────────────────────── */
async function initAuth() {
try {
var resp = await fetch('/api/auth/me');
var data = await resp.json();
currentUser = (data && data.authenticated) ? data : null;
} catch (_) { currentUser = null; }
var form = document.getElementById('v2-comment-form');
var loginHint = document.getElementById('v2-comment-login-hint');
var shareBlock = document.getElementById('v2-share-block');
if (currentUser) {
if (form) form.style.display = 'block';
if (loginHint) loginHint.style.display = 'none';
if (shareBlock) shareBlock.style.display = ''; /* default-display, nicht 'block' (v3-rest-block ist flex/grid je nach Klasse) */
} else {
if (form) form.style.display = 'none';
if (loginHint) loginHint.style.display = 'block';
if (shareBlock) shareBlock.style.display = 'none';
}
loadComments();
loadVotes();
}
/* ── Kommentare ───────────────────────────────────────────────── */
async function loadComments() {
var container = document.getElementById('v2-comments-list');
if (!container) return;
try {
var comments = await fetch('/api/comments?drucksache=' + encodeURIComponent(DRS)).then(function(r){ return r.json(); });
var visible = comments.filter(function(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;
if (c.visibility && c.visibility.startsWith('group:')) return true;
return false;
});
if (visible.length === 0) {
container.innerHTML = '<span style="font-family:var(--font-mono);font-size:12px;color:var(--ecg-dark);opacity:0.45;">Noch keine Kommentare.</span>';
return;
}
function visBadge(v) {
var lbl = v === 'private' ? '👤' : v === 'authenticated' ? '🔒' : '🌐';
return '<span style="font-family:var(--font-mono);font-size:10px;background:var(--surface);border:1px solid var(--hairline);padding:1px 4px;border-radius:2px;">' + lbl + '</span>';
}
container.innerHTML = visible.map(function(c) {
var dateStr = new Date(c.created_at).toLocaleString('de-DE', {day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'});
var delBtn = (currentUser && currentUser.sub === c.user_id)
? '<button onclick="v2DetailDeleteComment(' + c.id + ')" style="margin-left:auto;background:none;border:none;color:#c33;cursor:pointer;font-family:var(--font-mono);font-size:11px;padding:0;" title="Löschen">✕</button>'
: '';
return '<div style="padding:10px 0;border-bottom:1px solid var(--hairline);">'
+ '<div style="font-family:var(--font-mono);font-size:11px;color:var(--ecg-dark);opacity:0.75;margin-bottom:5px;display:flex;align-items:center;gap:6px;">'
+ '<strong>' + (c.user_name || 'Anonym') + '</strong>'
+ visBadge(c.visibility)
+ '<span style="opacity:0.6;">' + dateStr + '</span>'
+ delBtn
+ '</div>'
+ '<div style="font-size:13.5px;line-height:1.5;">' + c.text + '</div>'
+ '</div>';
}).join('');
} catch (_) {
document.getElementById('v2-comments-list').innerHTML = '';
}
}
window.v2DetailAddComment = async function(drucksache) {
var input = document.getElementById('v2-comment-input');
var visSel = document.getElementById('v2-comment-visibility');
if (!input || !input.value.trim()) return;
var visibility = visSel ? visSel.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();
};
window.v2DetailDeleteComment = async function(commentId) {
await fetch('/api/comment/' + commentId, {method: 'DELETE'});
loadComments();
};
/* ── Voting ───────────────────────────────────────────────────── */
async function loadVotes() {
try {
var data = await fetch('/api/votes?drucksache=' + encodeURIComponent(DRS)).then(function(r){ return r.json(); });
var counts = (data.counts && data.counts.overall) ? data.counts.overall : {up:0, down:0};
var myVote = data.my_votes && data.my_votes.overall;
var upEl = document.getElementById('v2-vote-up-count');
var downEl = document.getElementById('v2-vote-down-count');
var upBtn = document.getElementById('v2-vote-up');
var downBtn = document.getElementById('v2-vote-down');
if (upEl) upEl.textContent = counts.up || 0;
if (downEl) downEl.textContent = counts.down || 0;
if (upBtn) {
upBtn.style.background = myVote === 'up' ? 'rgba(137,158,51,0.12)' : '';
upBtn.style.borderColor = myVote === 'up' ? 'var(--ecg-green)' : '';
}
if (downBtn) {
downBtn.style.background = myVote === 'down' ? 'rgba(220,53,69,0.10)' : '';
downBtn.style.borderColor = myVote === 'down' ? '#dc3545' : '';
}
} catch (_) {}
}
window.v2DetailCastVote = async function(drucksache, vote) {
if (!currentUser) { v2AuthModalOpen(); return; }
try {
var resp = await fetch('/api/vote', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: 'drucksache=' + encodeURIComponent(drucksache) + '&target=overall&vote=' + vote
});
if (resp.ok) loadVotes();
} catch (_) {}
};
/* ── Share ────────────────────────────────────────────────────── */
// Limits pro Plattform (konservativ, mit Permalink-Reserve).
// Mastodon: meiste Instances erlauben 500 — minus Permalink (~80) = 420.
var SHARE_LIMITS = {threads: 460, mastodon: 420};
function buildShareText(platform) {
var limit = SHARE_LIMITS[platform]; // null = kein Limit (Copy/E-Mail)
var text;
if (platform === 'threads' && SHARE_THR) text = SHARE_THR;
else if (platform === 'mastodon' && SHARE_MAS) text = SHARE_MAS;
else if (platform === 'threads' && SHARE_MAS) text = SHARE_MAS; // Fallback Threads ← Mastodon
else {
var emoji = SCORE >= 8 ? '🟢' : SCORE >= 5 ? '🟡' : SCORE >= 3 ? '🟠' : '🔴';
text = emoji + ' GWÖ-Score ' + SCORE + '/10: „' + TITLE.substring(0, 70) + '" (' + DRS + ')\n\n#Gemeinwohl #GWÖ';
}
if (limit && text.length > limit) text = text.substring(0, limit - 1) + '…';
return text;
}
// Vollständiger Share-Body fuer LinkedIn/E-Mail/Copy — keine Längen-Cuts.
function buildLongShareText() {
var emoji = SCORE >= 8 ? '🟢' : SCORE >= 5 ? '🟡' : SCORE >= 3 ? '🟠' : '🔴';
var lines = [
emoji + ' GWÖ-Score ' + SCORE + '/10 für „' + TITLE + '" (' + DRS + ')',
'',
'Bewertet nach der Gemeinwohl-Matrix 2.0 — Würde, Solidarität,',
'Nachhaltigkeit, Gerechtigkeit, Demokratie. Vollständige Auswertung',
'mit Fraktions-Programmtreue, Verbesserungsvorschlägen und Belegen:',
'',
PERMALINK,
'',
'#Gemeinwohl #GWÖ #Antragspruefer'
];
return lines.join('\n');
}
/* Permalink-Klick kopiert in die Zwischenablage statt zu navigieren. */
window.v3CopyPermalink = function (e, el) {
e.preventDefault();
var permalink = window.location.origin + el.getAttribute('href');
var orig = el.textContent;
var done = el.dataset.copiedLabel || 'Kopiert ✓';
function flash() {
el.textContent = done;
setTimeout(function () { el.textContent = orig; }, 1600);
}
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(permalink).then(flash, function () {
window.prompt('Permalink kopieren:', permalink);
});
} else {
window.prompt('Permalink kopieren:', permalink);
}
};
window.v2DetailShare = function(platform) {
if (platform === 'linkedin') {
// LinkedIn hat /sharing/share-offsite/ zurueckgebaut — der prefillt
// keinen Text mehr. Stattdessen feed/?shareActive=true&text=… : das
// oeffnet den Compose-Dialog mit vorgefuelltem Text. Permalink ist
// im body schon enthalten, LinkedIn rendert daraus die OG-Preview.
var body = buildLongShareText();
var url = 'https://www.linkedin.com/feed/?shareActive=true&text=' + encodeURIComponent(body);
var win = window.open(url, '_blank', 'noopener');
// Belt-and-suspenders: Text auch in die Zwischenablage, falls
// LinkedIn ihn doch verschluckt — dann reicht Strg/⌘-V im Composer.
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(body).catch(function() {});
}
if (!win) v2ShareToast('Bitte Pop-up-Blocker prüfen — LinkedIn-Tab konnte nicht geöffnet werden.');
return;
}
// NFC-Normalisierung: typografische Anführungszeichen + Umlaute werden
// mancherorts als zerlegte Codepoints (z.B. "a" + Combining Diaeresis)
// angeliefert. Threads' Composer rendert die zerlegten Formen als
// Rauten / ?#. NFC vereinigt sie zu "ä".
var rawText = buildShareText(platform) + '\n' + PERMALINK;
var text = rawText.normalize ? rawText.normalize('NFC') : rawText;
var urls = {
threads: 'https://www.threads.net/intent/post?text=' + encodeURIComponent(text),
};
if (urls[platform]) window.open(urls[platform], '_blank', 'noopener');
};
window.v2DetailShareCopy = function(evt) {
// Kompletter Body, keine Längen-Cuts. PERMALINK enthalten.
var text = buildLongShareText();
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(function() {
var btn = (evt && evt.currentTarget) || (window.event && window.event.currentTarget);
if (btn) {
var orig = btn.textContent;
btn.textContent = '✓ kopiert';
setTimeout(function(){ btn.textContent = orig; }, 1500);
}
});
} else {
prompt('Zum Kopieren markieren und Cmd/Strg-C drücken:', text);
}
};
window.v2DetailShareEmail = function() {
var subject = 'GWÖ-Bewertung: ' + (TITLE.substring(0, 60));
var emoji = SCORE >= 8 ? '🟢' : SCORE >= 5 ? '🟡' : SCORE >= 3 ? '🟠' : '🔴';
var body = [
emoji + ' GWÖ-Score ' + SCORE + '/10',
'',
'Antrag: ' + TITLE,
'Drucksache: ' + DRS,
'',
(SHARE_MAS || SHARE_THR || ('Eine Auswertung aus Sicht der Gemeinwohl-Ökonomie zum Antrag „' + TITLE.substring(0, 80) + '".')),
'',
'Vollständige Bewertung mit Matrix 2.0, Programm-Treue pro Fraktion,',
'Verbesserungsvorschlägen und Belegen:',
'',
PERMALINK,
'',
'— GWÖ-Antragsprüfer · gwoe.toppyr.de'
].join('\n');
window.location.href = 'mailto:?subject=' + encodeURIComponent(subject) + '&body=' + encodeURIComponent(body);
};
/* Scorecard-Preview — Default portrait (1080×1350). Cache-Buster
erzwingt frischen Server-Render, sonst zeigt der Browser eine
alte gecachete HTML-Variante. */
window.v2DetailShareScorecard = function() {
var url = '/v2/scorecard?drucksache=' + encodeURIComponent(DRS)
+ '&bundesland=' + encodeURIComponent(BL || 'NRW')
+ '&_=' + Date.now();
window.open(url, '_blank', 'noopener');
};
/* Instagram-Sharing — Realismus:
Instagram hat KEINE Web-Publishing-API. Wir koennen also nicht
direkt posten. Realistisch sind zwei Pfade:
A) Mobile (iOS Safari / Chrome Android) mit Web-Share-Files-Support:
navigator.share({files:[png]}) oeffnet den OS-Share-Sheet,
Instagram-App taucht dort als Ziel auf, der Bild-Blob wird
an die App uebergeben. Funktioniert NICHT auf Firefox Mobile
und unzuverlaessig je nach Android-Build.
B) Desktop oder unsupported Browser:
- Bild via <a download> herunterladen (User hat das PNG dann lokal)
- Begleittext in die Zwischenablage
- Klare Anweisung: Bild auf's Phone uebertragen, in Instagram
posten, Text mit Strg/⌘-V einfuegen.
Es gibt keinen ehrlicheren Weg auf Desktop. */
window.v2DetailShareInstagram = async function() {
var url = '/api/assessment/scorecard.png?drucksache=' + encodeURIComponent(DRS)
+ '&bundesland=' + encodeURIComponent(BL || 'NRW')
+ '&format=portrait&scale=2';
var body = buildLongShareText();
var safeDrs = (DRS || 'antrag').replace(/[^a-zA-Z0-9_-]/g, '-');
var filename = 'gwoe-' + safeDrs + '.png';
// Pfad A — Web-Share mit Datei nur auf Mobile, weil der OS-Share-Sheet
// unter macOS Safari/Chrome eine schlechte UX liefert (Instagram-App
// ist nicht installiert auf Desktop, das Sheet zeigt nur AirDrop, Mail
// und Notizen). Mobile-Detection per User-Agent und maxTouchPoints —
// eine reine Touch-Erkennung würde Hybrid-Geräte falsch klassifizieren.
var isMobile = (function () {
var ua = navigator.userAgent || '';
if (/Android|iPhone|iPad|iPod|Mobile/i.test(ua)) return true;
// iPad iOS 13+ tarnt sich als macOS — Touch-Support als Hint.
if (/Macintosh/.test(ua) && navigator.maxTouchPoints > 1) return true;
return false;
})();
if (isMobile && navigator.canShare && navigator.share) {
try {
var resp = await fetch(url);
if (resp.ok) {
var blob = await resp.blob();
var file = new File([blob], filename, { type: 'image/png' });
var data = { title: TITLE, text: body, files: [file] };
if (navigator.canShare(data)) {
await navigator.share(data);
return; // Erfolg — kein Toast, OS-Sheet hat eigenes Feedback
}
}
} catch (e) {
if (e && e.name === 'AbortError') return; // User abgebrochen
// sonst weiter zu Pfad B
}
}
// Pfad B — Desktop: Download + Clipboard
if (navigator.clipboard && navigator.clipboard.writeText) {
try { await navigator.clipboard.writeText(body); } catch (_) { /* OK */ }
}
var a = document.createElement('a');
a.href = url;
a.download = filename;
a.style.display = 'none';
document.body.appendChild(a);
a.click();
setTimeout(function() { document.body.removeChild(a); }, 100);
v2ShareToast('Bild heruntergeladen, Begleittext in der Zwischenablage. Bild auf\'s Phone übertragen, in der Instagram-App posten, Text einfügen.');
};
window.v2DetailShareImage = function() {
var topics = (window.ANTRAG_TOPICS || []).slice(0, 2).join(' ');
var query = (topics || TITLE.substring(0, 40)) + ' Politik';
// Magnific: license-Selection vorausgewählt (selection=1, last_filter=selection).
var url = 'https://www.magnific.com/search?format=search'
+ '&last_filter=selection&last_value=1'
+ '&query=' + encodeURIComponent(query)
+ '&selection=1';
window.open(url, '_blank', 'noopener');
};
window.v2DetailShareMastodon = function() {
// Encoding-Fix: encodeURIComponent kümmert sich um UTF-8 — wir
// dürfen den Text NICHT vorher manuell escapen.
var text = buildShareText('mastodon') + '\n' + PERMALINK;
var instance = localStorage.getItem('mastodon_instance');
if (!instance) {
instance = prompt('Deine Mastodon-Instanz (z.B. mastodon.social):');
if (!instance) return;
instance = instance.trim().replace(/^https?:\/\//, '').replace(/\/+$/, '');
localStorage.setItem('mastodon_instance', instance);
}
window.open('https://' + instance + '/share?text=' + encodeURIComponent(text), '_blank', 'noopener');
};
// Kleine Toast-Helper-Funktion — verschwindet nach 2 s
function v2ShareToast(msg) {
var t = document.createElement('div');
t.textContent = msg;
t.style.cssText = 'position:fixed;bottom:24px;left:50%;transform:translateX(-50%);'
+ 'background:var(--ecg-teal);color:#fff;padding:10px 18px;border-radius:4px;'
+ 'font-family:var(--font-mono);font-size:12px;z-index:9999;'
+ 'box-shadow:0 4px 12px rgba(0,0,0,0.15);';
document.body.appendChild(t);
setTimeout(function(){ t.remove(); }, 2500);
}
/* ── Re-Analyze ───────────────────────────────────────────────── */
function pollReAnalyze(jobId, btn) {
fetch('/status/' + jobId)
.then(function(r){ return r.json(); })
.then(function(data) {
if (data.status === 'completed') {
btn.textContent = 'Fertig — lade neu…';
setTimeout(function(){ location.reload(); }, 800);
} else if (data.status === 'failed') {
btn.textContent = 'Analyse fehlgeschlagen';
btn.disabled = false;
} else {
setTimeout(function(){ pollReAnalyze(jobId, btn); }, 2000);
}
})
.catch(function() { btn.textContent = 'Polling-Fehler'; btn.disabled = false; });
}
window.v2DetailReAnalyze = async function(btn) {
if (!currentUser) { v2AuthModalOpen(); return; }
btn.disabled = true;
btn.textContent = 'Lösche alte Bewertung…';
try {
var delResp = await fetch('/api/assessment/delete?drucksache=' + encodeURIComponent(DRS), {method: 'DELETE'});
if (delResp.status === 401) {
btn.textContent = 'Nicht angemeldet';
setTimeout(function(){ btn.textContent = 'Neu analysieren'; btn.disabled = false; }, 3000);
return;
}
btn.textContent = 'Analyse läuft…';
var resp = await fetch('/api/analyze-drucksache', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: 'drucksache=' + encodeURIComponent(DRS) + '&bundesland=' + encodeURIComponent(BL)
});
if (resp.status === 401) {
btn.textContent = 'Nicht angemeldet';
setTimeout(function(){ btn.textContent = 'Neu analysieren'; btn.disabled = false; }, 3000);
return;
}
var data = await resp.json();
if (data.status === 'queued') {
btn.textContent = 'Wird analysiert…';
pollReAnalyze(data.job_id, btn);
} else {
btn.textContent = 'Fehler: ' + (data.detail || 'unbekannt');
setTimeout(function(){ btn.textContent = 'Neu analysieren'; btn.disabled = false; }, 4000);
}
} catch (e) {
btn.textContent = 'Fehler: ' + e.message;
setTimeout(function(){ btn.textContent = 'Neu analysieren'; btn.disabled = false; }, 4000);
}
};
/* ── Merkliste (#140) ────────────────────────────────────────── */
var _merklisteActive = false;
async function initMerkliste() {
if (!currentUser) return;
try {
var resp = await fetch('/api/me/merkliste');
if (!resp.ok) return;
var entries = await resp.json();
var isInList = entries.some(function(e) { return e.antrag_id === DRS; });
_updateMerkliste(isInList);
} catch (_) {}
}
function _updateMerkliste(active) {
_merklisteActive = active;
var star = document.getElementById('v2-merkliste-star');
var label = document.getElementById('v2-merkliste-label');
var btn = document.getElementById('v2-merkliste-btn');
if (!star) return;
star.textContent = active ? '★' : '☆';
label.textContent = active ? 'Gemerkt' : 'Merken';
if (btn) {
btn.style.background = active ? 'rgba(0,157,165,0.10)' : '';
btn.style.borderColor = active ? 'var(--ecg-teal)' : '';
btn.style.color = active ? 'var(--ecg-teal)' : '';
}
}
window.v2DetailMerklisteToggle = async function() {
if (!currentUser) { v2AuthModalOpen(); return; }
try {
if (_merklisteActive) {
var r = await fetch('/api/me/merkliste/' + encodeURIComponent(DRS), { method: 'DELETE' });
if (r.ok) _updateMerkliste(false);
} else {
var r = await fetch('/api/me/merkliste', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ antrag_id: DRS })
});
if (r.ok) _updateMerkliste(true);
}
} catch (_) {}
};
/* ── Bewertungs-Historie ─────────────────────────────────────── */
async function loadHistory() {
var container = document.getElementById('v2-history-list');
if (!container) return;
try {
var entries = await fetch('/api/assessment/history?drucksache=' + encodeURIComponent(DRS)).then(function(r){ return r.json(); });
if (!Array.isArray(entries) || entries.length === 0) {
container.innerHTML = '<span style="font-family:var(--font-mono);font-size:12px;color:var(--ecg-dark);opacity:0.45;">Nur eine Version vorhanden.</span>';
return;
}
container.innerHTML = entries.map(function(v, i) {
var dateStr = v.created_at
? new Date(v.created_at).toLocaleString('de-DE', {day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'})
: '';
var version = v.version !== undefined ? v.version : (entries.length - i);
return '<div style="display:flex;justify-content:space-between;align-items:baseline;'
+ 'padding:5px 0;border-bottom:1px solid var(--hairline);'
+ 'font-family:var(--font-mono);font-size:11px;color:var(--ecg-dark);">'
+ '<span>v' + version + ' &nbsp;<span style="opacity:0.55;">' + dateStr + '</span></span>'
+ '<a href="/api/assessment?drucksache=' + encodeURIComponent(DRS) + '&version=' + version + '" target="_blank" rel="noopener"'
+ ' style="color:var(--ecg-blue);border-bottom:1px solid rgba(0,157,165,0.35);">JSON</a>'
+ '</div>';
}).join('');
} catch (_) {
if (container) container.innerHTML = '';
}
}
/* ── News-Match-Box (#170 Reverse-Sicht) ──────────────────────── */
async function loadNewsMatches() {
const ds = "{{ antrag.drucksache | e }}";
const box = document.getElementById('ad-news-box');
const list = document.getElementById('ad-news-list');
if (!box || !list) return;
try {
// Parallel: News-Matches + Cluster-Map (fuer Cluster-Indicator)
const [matchesResp, clusterResp] = await Promise.all([
fetch('/api/aktuelle-themen/news-fuer-antrag?drucksache='
+ encodeURIComponent(ds)
+ '&top_k=5&min_similarity=0.4&days=90'),
fetch('/api/aktuelle-themen/cluster?days=90&min_cluster_size=2'),
]);
const matchData = await matchesResp.json();
const matches = matchData.matches || [];
if (!matches.length) return; // Box bleibt unsichtbar
// Cluster-Lookup-Map: URL → {clusterIdx, size, otherTitles}
let clusterByUrl = {};
try {
const clusterData = await clusterResp.json();
(clusterData.clusters || []).forEach((c, i) => {
(c.members || []).forEach(m => {
clusterByUrl[m.url] = {
size: c.size,
tags: c.top_tags || [],
others: (c.members || [])
.filter(o => o.url !== m.url)
.map(o => `${o.source}: ${o.titel}`)
.slice(0, 5),
};
});
});
} catch (_) { /* clusters optional */ }
box.style.display = '';
let html = '';
for (const n of matches) {
const d = (n.datum || '').slice(0, 10);
const tags = (n.tags || []).slice(0, 3).map(
t => '<span style="display:inline-block;padding:1px 6px;background:var(--ecg-bg-subtle);border-radius:3px;font-family:var(--font-mono);font-size:10px;margin-right:4px;">' + t + '</span>'
).join('');
const summary = n.summary
? '<p style="font-size:12px;margin:4px 0 8px;opacity:0.85;line-height:1.5;">' + n.summary + '</p>'
: '';
const clusterInfo = clusterByUrl[n.url];
const clusterBadge = clusterInfo
? '<span style="display:inline-block;padding:1px 7px;background:rgba(0,157,165,0.15);border-radius:11px;font-family:var(--font-mono);font-size:10px;margin-left:6px;color:var(--ecg-teal);" '
+ 'title="' + clusterInfo.others.map(s => s.replace(/"/g, '&quot;')).join(' • ') + '">'
+ '🔗 Cluster (' + clusterInfo.size + ' News)</span>'
: '';
html += '<div style="border-bottom:1px dotted var(--ecg-border);padding:10px 0;">';
html += '<div style="font-family:var(--font-mono);font-size:10px;opacity:0.6;margin-bottom:3px;">'
+ d + ' · ' + n.source + (n.ressort ? ' / ' + n.ressort : '')
+ ' · sim ' + n.similarity + clusterBadge + '</div>';
html += '<a href="' + n.url + '" target="_blank" rel="noopener" '
+ 'style="color:var(--ecg-teal);text-decoration:none;font-weight:500;">'
+ n.titel + '</a>';
html += summary;
if (tags) html += '<div style="margin-bottom:6px;">' + tags + '</div>';
// PM-Generieren-Button nur fuer angemeldete User — der Endpoint
// erfordert auth + verbraucht qwen-max-Credits.
if (currentUser) {
html += '<button onclick="adGeneratePresse(\''
+ ds.replace(/'/g, "\\'") + '\', \''
+ encodeURIComponent(n.url) + '\', this)" '
+ 'style="font-family:var(--font-mono);font-size:11px;padding:4px 10px;'
+ 'border:1px solid var(--ecg-teal);background:var(--ecg-card-bg);'
+ 'color:var(--ecg-teal);border-radius:3px;cursor:pointer;">'
+ 'PM-Vorschlag generieren</button>';
}
html += '</div>';
}
list.innerHTML = html;
} catch (e) {
// Bei Fehler: Box bleibt unsichtbar — kein Stoerfaktor
}
}
window.adGeneratePresse = async function(drucksache, newsUrlEnc, btn) {
if (!confirm('Pressemitteilung für ' + drucksache + ' anzeigen / generieren?\n\n'
+ 'Falls bereits ein Entwurf existiert, wird dieser ohne LLM-Call zurückgegeben.\n'
+ 'Sonst: qwen-max generiert (~6 Cent, ~30 s).')) return;
btn.disabled = true;
btn.textContent = '…';
try {
const r = await fetch('/api/aktuelle-themen/generate-presse'
+ '?drucksache=' + encodeURIComponent(drucksache)
+ '&news_url=' + newsUrlEnc, { method: 'POST' });
if (!r.ok) {
const err = await r.json().catch(() => ({}));
alert('Fehler: ' + (err.detail || r.statusText));
return;
}
const d = await r.json();
const note = d._was_existing
? '(bereits generiert am ' + (d.created_at || '').slice(0, 10) + ')'
: '(neu generiert)';
alert(d.titel + '\n' + note + '\n\n' + d.body
+ '\n\n— Auf /aktuelle-themen sichtbar im Tab "PM-Entwürfe".');
} catch (e) {
alert('Fehler: ' + e);
} finally {
btn.disabled = false;
btn.textContent = 'PM-Vorschlag generieren';
}
};
/* ── Init ─────────────────────────────────────────────────────── */
document.addEventListener('DOMContentLoaded', async function() {
// Auth zuerst, weil loadNewsMatches() den PM-Button nur fuer
// angemeldete User rendert — sonst race und Button waere fuer
// alle weg.
await initAuth();
initMerkliste();
loadHistory();
loadNewsMatches();
});
})();
</script>
{% endif %}
{% endblock %}