☉
-
Kein Vorfall ausgewählt
-
Erstelle einen neuen Fall oder wähle einen bestehenden aus der Seitenleiste.
+
Kein Vorfall ausgewählt
+
Erstelle einen neuen Fall oder wähle einen bestehenden aus der Seitenleiste.
@@ -270,11 +270,11 @@
Geografische Verteilung
-
Keine Orte erkannt
+
Keine Orte erkannt
@@ -729,7 +729,7 @@
-
+
diff --git a/src/static/i18n/de.json b/src/static/i18n/de.json
index d79ddf8..60429d6 100644
--- a/src/static/i18n/de.json
+++ b/src/static/i18n/de.json
@@ -1,172 +1,205 @@
-{
- "sidebar.live_monitoring": "Live-Monitoring",
- "sidebar.research": "Recherchen",
- "sidebar.archive": "Archiv",
- "sidebar.sources": "Quellen",
- "sidebar.feedback": "Feedback",
- "sidebar.manage_sources_title": "Quellen verwalten",
- "sidebar.feedback_title": "Feedback senden",
- "sidebar.stat.sources_suffix": "Quellen",
- "sidebar.stat.articles_suffix": "Artikel",
- "sidebar.empty_adhoc": "Kein Live-Monitoring",
- "sidebar.empty_adhoc_mine": "Kein eigenes Live-Monitoring",
- "sidebar.empty_research": "Keine Deep-Research",
- "sidebar.empty_research_mine": "Keine eigenen Deep-Research",
- "action.refresh": "Aktualisieren",
- "action.edit": "Bearbeiten",
- "action.export": "Bericht exportieren",
- "action.archive": "Archivieren",
- "action.delete": "Löschen",
- "action.refreshing": "Läuft...",
- "action.restore": "Wiederherstellen",
- "action.budget_exceeded": "Budget aufgebraucht",
- "action.read_only": "Nur Lesezugriff",
- "action.budget_exceeded_title": "Token-Budget aufgebraucht. Bitte Verwaltung kontaktieren.",
- "action.read_only_title": "Lizenz erlaubt keinen Schreibzugriff",
- "sidebar.empty": "Keine Lagen vorhanden",
- "header.logout": "Abmelden",
- "header.new_incident": "+ Neuer Fall",
- "header.theme_toggle": "Theme wechseln",
- "header.notifications": "Benachrichtigungen",
- "filter.all": "Alle",
- "filter.own": "Eigene",
- "filter.everything": "Alles",
- "common.close": "Schließen",
- "common.cancel": "Abbrechen",
- "common.save": "Speichern",
- "common.delete": "Löschen",
- "common.edit": "Bearbeiten",
- "common.loading": "Lädt...",
- "common.confirm": "Bestätigen",
- "common.error": "Fehler",
- "modal.new_incident.title": "Neue Lage anlegen",
- "modal.new_incident.title_field": "Titel des Vorfalls",
- "modal.new_incident.description": "Beschreibung / Kontext",
- "modal.new_incident.enhance": "Beschreibung generieren",
- "modal.new_incident.enhance_loading": "Wird generiert...",
- "enhance.error_default": "Beschreibung konnte nicht generiert werden",
- "enhance.error_unavailable": "KI-Zugang aktuell nicht verfügbar. Bitte Administrator kontaktieren.",
- "enhance.error_busy": "KI ist gerade ausgelastet. Bitte kurz warten und erneut versuchen.",
- "enhance.error_timeout": "KI antwortet gerade nicht. Bitte erneut versuchen.",
- "modal.new_incident.visibility": "Sichtbarkeit",
- "modal.new_incident.visibility_public": "Öffentlich",
- "modal.new_incident.visibility_private": "Privat",
- "modal.new_incident.submit": "Lage anlegen",
- "modal.new_incident.title2": "Neuen Fall anlegen",
- "modal.new_incident.edit_title": "Lage bearbeiten",
- "modal.placeholder.title": "z.B. Explosion in Madrid",
- "modal.placeholder.description": "Weitere Details zum Vorfall (optional)",
- "modal.field.type": "Art der Lage",
- "modal.option.type_adhoc": "Live-Monitoring : Ereignis beobachten",
- "modal.option.type_research": "Recherche : Thema analysieren",
- "modal.hint.type_adhoc": "Durchsucht laufend hunderte Nachrichtenquellen nach neuen Meldungen. Empfohlen: Automatische Aktualisierung.",
- "modal.hint.type_research": "Strukturierte Tiefenrecherche mit mehreren Durchläufen. Empfohlen: Manuell starten und bei Bedarf vertiefen.",
- "modal.field.sources": "Quellen",
- "modal.toggle.international": "Internationale Quellen einbeziehen",
- "modal.toggle.telegram": "Telegram-Kanäle einbeziehen",
- "modal.toggle.visibility_public_text": "Öffentlich : für alle Nutzer sichtbar",
- "modal.toggle.visibility_private_text": "Privat : nur für dich sichtbar",
- "modal.field.refresh": "Aktualisierung",
- "modal.option.manual": "Manuell",
- "modal.option.auto": "Automatisch",
- "modal.field.interval": "Intervall",
- "modal.unit.minutes": "Minuten",
- "modal.unit.hours": "Stunden",
- "modal.unit.days": "Tage",
- "modal.unit.weeks": "Wochen",
- "modal.field.start_time": "Erste Aktualisierung um",
- "modal.field.retention": "Aufbewahrung (Tage)",
- "modal.placeholder.retention": "0 = Unbegrenzt",
- "modal.field.notifications": "E-Mail-Benachrichtigungen",
- "modal.hint.notifications": "Per E-Mail benachrichtigen bei:",
- "modal.notify.summary": "Neues Lagebild",
- "modal.notify.summary_research": "Neuer Recherchebericht",
- "modal.notify.new_articles": "Neue Artikel",
- "modal.notify.status_change": "Statusänderung Faktencheck",
- "aria.close": "Schließen",
- "modal.sources.title": "Quellenverwaltung",
- "modal.sources.approve_all_high": "Alle ≥ 0.85 genehmigen",
- "modal.export.title": "Bericht exportieren",
- "modal.fc_status.title": "Statusänderung Faktencheck",
- "tile.factcheck": "Faktencheck",
- "tile.research_evaluated": "Recherche-Lagen werden mehrfach evaluiert...",
- "tile.summary": "Lagebild",
- "tile.summary_research": "Recherchebericht",
- "tile.timeline": "Zeitachse",
- "tile.map": "Karte",
- "tile.sources": "Quellen",
- "tab.latest_developments": "Neueste Entwicklungen",
- "tab.summary": "Lagebild",
- "tab.timeline": "Ereignis-Timeline",
- "tab.map": "Geografische Verteilung",
- "tab.factcheck": "Faktencheck",
- "tab.pipeline": "Analysepipeline",
- "tab.sources_overview": "Quellenübersicht",
- "tab.summary_short": "Zusammenfassung",
- "tab.summary_report": "Recherchebericht",
- "card.summary": "Lagebild",
- "card.timeline": "Ereignis-Timeline",
- "card.map": "Geografische Verteilung",
- "card.pipeline": "Analysepipeline",
- "card.sources_overview": "Quellenübersicht",
- "fc.label.confirmed": "Bestätigt durch mehrere Quellen",
- "fc.label.unconfirmed": "Nicht unabhängig bestätigt",
- "fc.label.contradicted": "Widerlegt",
- "fc.label.developing": "Faktenlage noch im Fluss",
- "fc.label.established": "Gesicherter Fakt (3+ Quellen)",
- "fc.label.disputed": "Umstrittener Sachverhalt",
- "fc.label.unverified": "Nicht unabhängig verifizierbar",
- "fc.tooltip.confirmed": "Bestätigt: Mindestens zwei unabhängige, seriöse Quellen stützen diese Aussage übereinstimmend.",
- "fc.tooltip.established": "Gesichert: Drei oder mehr unabhängige Quellen bestätigen den Sachverhalt. Hohe Verlässlichkeit.",
- "fc.tooltip.developing": "Unklar: Die Faktenlage ist noch im Fluss. Neue Informationen können das Bild verändern.",
- "fc.tooltip.unconfirmed": "Unbestätigt: Bisher nur aus einer Quelle bekannt. Eine unabhängige Bestätigung steht aus.",
- "fc.tooltip.unverified": "Ungeprüft: Die Aussage konnte bisher nicht anhand verfügbarer Quellen überprüft werden.",
- "fc.tooltip.disputed": "Umstritten: Quellen widersprechen sich. Es gibt sowohl stützende als auch widersprechende Belege.",
- "fc.tooltip.contradicted": "Widerlegt: Zuverlässige Quellen widersprechen dieser Aussage. Wahrscheinlich falsch.",
- "fc.chip.confirmed": "Bestätigt",
- "fc.chip.unconfirmed": "Unbestätigt",
- "fc.chip.contradicted": "Widerlegt",
- "fc.chip.developing": "Unklar",
- "fc.chip.established": "Gesichert",
- "fc.chip.disputed": "Umstritten",
- "fc.chip.unverified": "Ungeprüft",
- "refresh.no_developments": "Keine neuen Entwicklungen",
- "refresh.new_articles_suffix": "neue Artikel",
- "refresh.confirmed_suffix": "Fakten bestätigt",
- "refresh.contradicted_suffix": "widerlegt",
- "progress.status.queued": "In Warteschlange",
- "progress.status.researching": "Recherchiert...",
- "progress.status.deep_researching": "Tiefenrecherche...",
- "progress.status.analyzing": "Analysiert...",
- "progress.status.factchecking": "Faktencheck...",
- "progress.status.cancelling": "Wird abgebrochen...",
- "progress.title.first_refresh": "Erste Recherche läuft",
- "progress.title.refresh": "Aktualisierung läuft",
- "progress.title.queued": "In Warteschlange",
- "progress.title.cancelling": "Wird abgebrochen…",
- "progress.factcheck_running": "Faktencheck läuft",
- "progress.check.researching": "Quellen werden durchsucht",
- "progress.check.analyzing": "Meldungen werden analysiert",
- "pipeline.empty": "Noch nie aktualisiert. Starte den ersten Refresh.",
- "pipeline.load_failed": "Pipeline laden fehlgeschlagen",
- "pipeline.running": "Aktualisierung läuft...",
- "pipeline.cancelled": "abgebrochen",
- "pipeline.with_errors": "mit Fehler beendet",
- "pipeline.duration_prefix": "Dauer:",
- "pipeline.status.done": "erledigt",
- "pipeline.status.running": "läuft...",
- "pipeline.status.error": "Fehler",
- "pipeline.count.sources_reviewed": "{n} Quellen geprüft",
- "pipeline.count.collected": "{n} Meldungen",
- "pipeline.count.collected_from": "{n} Meldungen aus {s} Quellen",
- "time.just_now": "gerade eben",
- "time.minutes_ago": "vor {n} Min",
- "time.hours_ago": "vor {n} Std",
- "time.days_ago": "vor {n} Tagen",
- "time.day_ago": "vor 1 Tag",
- "toast.incident_refreshed": "Lage aktualisiert.",
- "toast.data_refreshed": "Daten aktualisiert.",
- "toast.source_updated": "Quelle aktualisiert.",
- "toast.session_expires": "Session läuft in {min} Minute(n) ab. Bitte erneut anmelden.",
- "confirm.delete_incident": "Lage wirklich löschen? Alle gesammelten Daten gehen verloren."
-}
+{
+ "sidebar.live_monitoring": "Live-Monitoring",
+ "sidebar.research": "Recherchen",
+ "sidebar.archive": "Archiv",
+ "sidebar.sources": "Quellen",
+ "sidebar.feedback": "Feedback",
+ "sidebar.manage_sources_title": "Quellen verwalten",
+ "sidebar.feedback_title": "Feedback senden",
+ "sidebar.stat.sources_suffix": "Quellen",
+ "sidebar.stat.articles_suffix": "Artikel",
+ "sidebar.empty_adhoc": "Kein Live-Monitoring",
+ "sidebar.empty_adhoc_mine": "Kein eigenes Live-Monitoring",
+ "sidebar.empty_research": "Keine Deep-Research",
+ "sidebar.empty_research_mine": "Keine eigenen Deep-Research",
+ "action.refresh": "Aktualisieren",
+ "action.edit": "Bearbeiten",
+ "action.export": "Bericht exportieren",
+ "action.archive": "Archivieren",
+ "action.delete": "Löschen",
+ "action.refreshing": "Läuft...",
+ "action.restore": "Wiederherstellen",
+ "action.budget_exceeded": "Budget aufgebraucht",
+ "action.read_only": "Nur Lesezugriff",
+ "action.budget_exceeded_title": "Token-Budget aufgebraucht. Bitte Verwaltung kontaktieren.",
+ "action.read_only_title": "Lizenz erlaubt keinen Schreibzugriff",
+ "sidebar.empty": "Keine Lagen vorhanden",
+ "header.logout": "Abmelden",
+ "header.new_incident": "+ Neuer Fall",
+ "header.theme_toggle": "Theme wechseln",
+ "header.notifications": "Benachrichtigungen",
+ "filter.all": "Alle",
+ "filter.own": "Eigene",
+ "filter.everything": "Alles",
+ "common.close": "Schließen",
+ "common.cancel": "Abbrechen",
+ "common.save": "Speichern",
+ "common.delete": "Löschen",
+ "common.edit": "Bearbeiten",
+ "common.loading": "Lädt...",
+ "common.confirm": "Bestätigen",
+ "common.error": "Fehler",
+ "modal.new_incident.title": "Neue Lage anlegen",
+ "modal.new_incident.title_field": "Titel des Vorfalls",
+ "modal.new_incident.description": "Beschreibung / Kontext",
+ "modal.new_incident.enhance": "Beschreibung generieren",
+ "modal.new_incident.enhance_loading": "Wird generiert...",
+ "enhance.error_default": "Beschreibung konnte nicht generiert werden",
+ "enhance.error_unavailable": "KI-Zugang aktuell nicht verfügbar. Bitte Administrator kontaktieren.",
+ "enhance.error_busy": "KI ist gerade ausgelastet. Bitte kurz warten und erneut versuchen.",
+ "enhance.error_timeout": "KI antwortet gerade nicht. Bitte erneut versuchen.",
+ "modal.new_incident.visibility": "Sichtbarkeit",
+ "modal.new_incident.visibility_public": "Öffentlich",
+ "modal.new_incident.visibility_private": "Privat",
+ "modal.new_incident.submit": "Lage anlegen",
+ "modal.new_incident.title2": "Neuen Fall anlegen",
+ "modal.new_incident.edit_title": "Lage bearbeiten",
+ "modal.placeholder.title": "z.B. Explosion in Madrid",
+ "modal.placeholder.description": "Weitere Details zum Vorfall (optional)",
+ "modal.field.type": "Art der Lage",
+ "modal.option.type_adhoc": "Live-Monitoring : Ereignis beobachten",
+ "modal.option.type_research": "Recherche : Thema analysieren",
+ "modal.hint.type_adhoc": "Durchsucht laufend hunderte Nachrichtenquellen nach neuen Meldungen. Empfohlen: Automatische Aktualisierung.",
+ "modal.hint.type_research": "Strukturierte Tiefenrecherche mit mehreren Durchläufen. Empfohlen: Manuell starten und bei Bedarf vertiefen.",
+ "modal.field.sources": "Quellen",
+ "modal.toggle.international": "Internationale Quellen einbeziehen",
+ "modal.toggle.telegram": "Telegram-Kanäle einbeziehen",
+ "modal.toggle.visibility_public_text": "Öffentlich : für alle Nutzer sichtbar",
+ "modal.toggle.visibility_private_text": "Privat : nur für dich sichtbar",
+ "modal.field.refresh": "Aktualisierung",
+ "modal.option.manual": "Manuell",
+ "modal.option.auto": "Automatisch",
+ "modal.field.interval": "Intervall",
+ "modal.unit.minutes": "Minuten",
+ "modal.unit.hours": "Stunden",
+ "modal.unit.days": "Tage",
+ "modal.unit.weeks": "Wochen",
+ "modal.field.start_time": "Erste Aktualisierung um",
+ "modal.field.retention": "Aufbewahrung (Tage)",
+ "modal.placeholder.retention": "0 = Unbegrenzt",
+ "modal.field.notifications": "E-Mail-Benachrichtigungen",
+ "modal.hint.notifications": "Per E-Mail benachrichtigen bei:",
+ "modal.notify.summary": "Neues Lagebild",
+ "modal.notify.summary_research": "Neuer Recherchebericht",
+ "modal.notify.new_articles": "Neue Artikel",
+ "modal.notify.status_change": "Statusänderung Faktencheck",
+ "aria.close": "Schließen",
+ "modal.sources.title": "Quellenverwaltung",
+ "modal.sources.approve_all_high": "Alle ≥ 0.85 genehmigen",
+ "modal.export.title": "Bericht exportieren",
+ "modal.fc_status.title": "Statusänderung Faktencheck",
+ "tile.factcheck": "Faktencheck",
+ "tile.research_evaluated": "Recherche-Lagen werden mehrfach evaluiert...",
+ "tile.summary": "Lagebild",
+ "tile.summary_research": "Recherchebericht",
+ "tile.timeline": "Zeitachse",
+ "tile.map": "Karte",
+ "tile.sources": "Quellen",
+ "tab.latest_developments": "Neueste Entwicklungen",
+ "tab.summary": "Lagebild",
+ "tab.timeline": "Ereignis-Timeline",
+ "tab.map": "Geografische Verteilung",
+ "tab.factcheck": "Faktencheck",
+ "tab.pipeline": "Analysepipeline",
+ "tab.sources_overview": "Quellenübersicht",
+ "tab.summary_short": "Zusammenfassung",
+ "tab.summary_report": "Recherchebericht",
+ "card.summary": "Lagebild",
+ "card.timeline": "Ereignis-Timeline",
+ "card.map": "Geografische Verteilung",
+ "card.pipeline": "Analysepipeline",
+ "card.sources_overview": "Quellenübersicht",
+ "fc.label.confirmed": "Bestätigt durch mehrere Quellen",
+ "fc.label.unconfirmed": "Nicht unabhängig bestätigt",
+ "fc.label.contradicted": "Widerlegt",
+ "fc.label.developing": "Faktenlage noch im Fluss",
+ "fc.label.established": "Gesicherter Fakt (3+ Quellen)",
+ "fc.label.disputed": "Umstrittener Sachverhalt",
+ "fc.label.unverified": "Nicht unabhängig verifizierbar",
+ "fc.tooltip.confirmed": "Bestätigt: Mindestens zwei unabhängige, seriöse Quellen stützen diese Aussage übereinstimmend.",
+ "fc.tooltip.established": "Gesichert: Drei oder mehr unabhängige Quellen bestätigen den Sachverhalt. Hohe Verlässlichkeit.",
+ "fc.tooltip.developing": "Unklar: Die Faktenlage ist noch im Fluss. Neue Informationen können das Bild verändern.",
+ "fc.tooltip.unconfirmed": "Unbestätigt: Bisher nur aus einer Quelle bekannt. Eine unabhängige Bestätigung steht aus.",
+ "fc.tooltip.unverified": "Ungeprüft: Die Aussage konnte bisher nicht anhand verfügbarer Quellen überprüft werden.",
+ "fc.tooltip.disputed": "Umstritten: Quellen widersprechen sich. Es gibt sowohl stützende als auch widersprechende Belege.",
+ "fc.tooltip.contradicted": "Widerlegt: Zuverlässige Quellen widersprechen dieser Aussage. Wahrscheinlich falsch.",
+ "fc.chip.confirmed": "Bestätigt",
+ "fc.chip.unconfirmed": "Unbestätigt",
+ "fc.chip.contradicted": "Widerlegt",
+ "fc.chip.developing": "Unklar",
+ "fc.chip.established": "Gesichert",
+ "fc.chip.disputed": "Umstritten",
+ "fc.chip.unverified": "Ungeprüft",
+ "refresh.no_developments": "Keine neuen Entwicklungen",
+ "refresh.new_articles_suffix": "neue Artikel",
+ "refresh.confirmed_suffix": "Fakten bestätigt",
+ "refresh.contradicted_suffix": "widerlegt",
+ "progress.status.queued": "In Warteschlange",
+ "progress.status.researching": "Recherchiert...",
+ "progress.status.deep_researching": "Tiefenrecherche...",
+ "progress.status.analyzing": "Analysiert...",
+ "progress.status.factchecking": "Faktencheck...",
+ "progress.status.cancelling": "Wird abgebrochen...",
+ "progress.title.first_refresh": "Erste Recherche läuft",
+ "progress.title.refresh": "Aktualisierung läuft",
+ "progress.title.queued": "In Warteschlange",
+ "progress.title.cancelling": "Wird abgebrochen…",
+ "progress.factcheck_running": "Faktencheck läuft",
+ "progress.check.researching": "Quellen werden durchsucht",
+ "progress.check.analyzing": "Meldungen werden analysiert",
+ "pipeline.empty": "Noch nie aktualisiert. Starte den ersten Refresh.",
+ "pipeline.load_failed": "Pipeline laden fehlgeschlagen",
+ "pipeline.running": "Aktualisierung läuft...",
+ "pipeline.cancelled": "abgebrochen",
+ "pipeline.with_errors": "mit Fehler beendet",
+ "pipeline.duration_prefix": "Dauer:",
+ "pipeline.status.done": "erledigt",
+ "pipeline.status.running": "läuft...",
+ "pipeline.status.error": "Fehler",
+ "pipeline.count.sources_reviewed": "{n} Quellen geprüft",
+ "pipeline.count.collected": "{n} Meldungen",
+ "pipeline.count.collected_from": "{n} Meldungen aus {s} Quellen",
+ "time.just_now": "gerade eben",
+ "time.minutes_ago": "vor {n} Min",
+ "time.hours_ago": "vor {n} Std",
+ "time.days_ago": "vor {n} Tagen",
+ "time.day_ago": "vor 1 Tag",
+ "toast.incident_refreshed": "Lage aktualisiert.",
+ "toast.data_refreshed": "Daten aktualisiert.",
+ "toast.source_updated": "Quelle aktualisiert.",
+ "toast.session_expires": "Session läuft in {min} Minute(n) ab. Bitte erneut anmelden.",
+ "confirm.delete_incident": "Lage wirklich löschen? Alle gesammelten Daten gehen verloren.",
+ "toast.incident_updated": "Lage aktualisiert.",
+ "toast.refresh_started": "Aktualisierung gestartet.",
+ "toast.incident_deleted": "Lage gelöscht.",
+ "toast.incident_archived": "Lage archiviert.",
+ "toast.incident_restored": "Lage wiederhergestellt.",
+ "toast.research_cancelled": "Recherche abgebrochen.",
+ "toast.no_active_refresh": "Kein aktiver Refresh zum Abbrechen gefunden.",
+ "toast.report_downloaded": "Bericht heruntergeladen",
+ "toast.data_updated": "Daten aktualisiert.",
+ "toast.no_rss_save_as_web": "Kein RSS-Feed gefunden. Als Web-Quelle speichern?",
+ "toast.source_added": "Quelle hinzugefügt.",
+ "confirm.cancel_running_research": "Laufende Recherche abbrechen?",
+ "action.starting": "Wird gestartet...",
+ "action.cancelling": "Wird abgebrochen...",
+ "action.creating": "Wird erstellt...",
+ "action.sending": "Wird gesendet...",
+ "action.searching_feeds": "Suche Feeds...",
+ "action.save_source": "Quelle speichern",
+ "license.expired_readonly": "Lizenz abgelaufen – nur Lesezugriff",
+ "license.none_readonly": "Keine aktive Lizenz – nur Lesezugriff",
+ "license.org_disabled_readonly": "Organisation deaktiviert – nur Lesezugriff",
+ "notifications.title": "Benachrichtigungen",
+ "notifications.mark_all_read": "Alle gelesen",
+ "notifications.empty": "Keine Benachrichtigungen",
+ "empty.no_incident_title": "Kein Vorfall ausgewählt",
+ "empty.no_incident_text": "Erstelle einen neuen Fall oder wähle einen bestehenden aus der Seitenleiste.",
+ "map.import_locations": "Orte einlesen",
+ "map.import_locations_title": "Orte aus Artikeln einlesen",
+ "map.empty": "Keine Orte erkannt",
+ "source.type.rss_feed": "RSS-Feed",
+ "source.type.telegram": "Telegram",
+ "source.type.web": "Web-Quelle",
+ "modal.hint.sources_german_only": "Nur deutschsprachige Quellen (DE, AT, CH)"
+}
diff --git a/src/static/i18n/en.json b/src/static/i18n/en.json
index ecacf95..a08db0b 100644
--- a/src/static/i18n/en.json
+++ b/src/static/i18n/en.json
@@ -1,172 +1,205 @@
-{
- "sidebar.live_monitoring": "Live monitoring",
- "sidebar.research": "Research",
- "sidebar.archive": "Archive",
- "sidebar.sources": "Sources",
- "sidebar.feedback": "Feedback",
- "sidebar.manage_sources_title": "Manage sources",
- "sidebar.feedback_title": "Send feedback",
- "sidebar.stat.sources_suffix": "sources",
- "sidebar.stat.articles_suffix": "articles",
- "sidebar.empty_adhoc": "No live monitoring",
- "sidebar.empty_adhoc_mine": "No own live monitoring",
- "sidebar.empty_research": "No deep research",
- "sidebar.empty_research_mine": "No own deep research",
- "action.refresh": "Refresh",
- "action.edit": "Edit",
- "action.export": "Export report",
- "action.archive": "Archive",
- "action.delete": "Delete",
- "action.refreshing": "Running...",
- "action.restore": "Restore",
- "action.budget_exceeded": "Budget exhausted",
- "action.read_only": "Read-only",
- "action.budget_exceeded_title": "Token budget exhausted. Please contact administration.",
- "action.read_only_title": "License does not permit write access",
- "sidebar.empty": "No situations yet",
- "header.logout": "Sign out",
- "header.new_incident": "+ New situation",
- "header.theme_toggle": "Toggle theme",
- "header.notifications": "Notifications",
- "filter.all": "All",
- "filter.own": "Own",
- "filter.everything": "Everything",
- "common.close": "Close",
- "common.cancel": "Cancel",
- "common.save": "Save",
- "common.delete": "Delete",
- "common.edit": "Edit",
- "common.loading": "Loading...",
- "common.confirm": "Confirm",
- "common.error": "Error",
- "modal.new_incident.title": "Create new situation",
- "modal.new_incident.title_field": "Incident title",
- "modal.new_incident.description": "Description / context",
- "modal.new_incident.enhance": "Generate description",
- "modal.new_incident.enhance_loading": "Generating...",
- "enhance.error_default": "Description could not be generated",
- "enhance.error_unavailable": "AI access currently unavailable. Please contact your administrator.",
- "enhance.error_busy": "AI is currently busy. Please wait briefly and try again.",
- "enhance.error_timeout": "AI is not responding. Please try again.",
- "modal.new_incident.visibility": "Visibility",
- "modal.new_incident.visibility_public": "Public",
- "modal.new_incident.visibility_private": "Private",
- "modal.new_incident.submit": "Create situation",
- "modal.new_incident.title2": "Create new case",
- "modal.new_incident.edit_title": "Edit situation",
- "modal.placeholder.title": "e.g. Explosion in Madrid",
- "modal.placeholder.description": "More details about the incident (optional)",
- "modal.field.type": "Type of situation",
- "modal.option.type_adhoc": "Live monitoring : track an event",
- "modal.option.type_research": "Research : analyse a topic",
- "modal.hint.type_adhoc": "Continuously searches hundreds of news sources for new articles. Recommended: automatic refresh.",
- "modal.hint.type_research": "Structured deep research with multiple passes. Recommended: start manually and deepen when needed.",
- "modal.field.sources": "Sources",
- "modal.toggle.international": "Include international sources",
- "modal.toggle.telegram": "Include Telegram channels",
- "modal.toggle.visibility_public_text": "Public : visible to all users",
- "modal.toggle.visibility_private_text": "Private : only visible to you",
- "modal.field.refresh": "Refresh",
- "modal.option.manual": "Manual",
- "modal.option.auto": "Automatic",
- "modal.field.interval": "Interval",
- "modal.unit.minutes": "Minutes",
- "modal.unit.hours": "Hours",
- "modal.unit.days": "Days",
- "modal.unit.weeks": "Weeks",
- "modal.field.start_time": "First refresh at",
- "modal.field.retention": "Retention (days)",
- "modal.placeholder.retention": "0 = unlimited",
- "modal.field.notifications": "Email notifications",
- "modal.hint.notifications": "Notify me by email about:",
- "modal.notify.summary": "New briefing",
- "modal.notify.summary_research": "New research report",
- "modal.notify.new_articles": "New articles",
- "modal.notify.status_change": "Fact-check status change",
- "aria.close": "Close",
- "modal.sources.title": "Source management",
- "modal.sources.approve_all_high": "Approve all ≥ 0.85",
- "modal.export.title": "Export report",
- "modal.fc_status.title": "Fact-check status change",
- "tile.factcheck": "Fact check",
- "tile.research_evaluated": "Research situations are evaluated multiple times...",
- "tile.summary": "Briefing",
- "tile.summary_research": "Research report",
- "tile.timeline": "Timeline",
- "tile.map": "Map",
- "tile.sources": "Sources",
- "tab.latest_developments": "Latest developments",
- "tab.summary": "Briefing",
- "tab.timeline": "Event timeline",
- "tab.map": "Geographic distribution",
- "tab.factcheck": "Fact check",
- "tab.pipeline": "Analysis pipeline",
- "tab.sources_overview": "Sources overview",
- "tab.summary_short": "Summary",
- "tab.summary_report": "Research report",
- "card.summary": "Briefing",
- "card.timeline": "Event timeline",
- "card.map": "Geographic distribution",
- "card.pipeline": "Analysis pipeline",
- "card.sources_overview": "Sources overview",
- "fc.label.confirmed": "Confirmed by multiple sources",
- "fc.label.unconfirmed": "Not independently confirmed",
- "fc.label.contradicted": "Contradicted",
- "fc.label.developing": "Facts still developing",
- "fc.label.established": "Established fact (3+ sources)",
- "fc.label.disputed": "Disputed matter",
- "fc.label.unverified": "Not independently verifiable",
- "fc.tooltip.confirmed": "Confirmed: at least two independent, reputable sources support this claim consistently.",
- "fc.tooltip.established": "Established: three or more independent sources confirm the matter. High reliability.",
- "fc.tooltip.developing": "Developing: the facts are still in flux. New information may change the picture.",
- "fc.tooltip.unconfirmed": "Unconfirmed: known from only one source so far. Independent confirmation is pending.",
- "fc.tooltip.unverified": "Unverified: the claim could not yet be checked against available sources.",
- "fc.tooltip.disputed": "Disputed: sources disagree. There is both supporting and contradicting evidence.",
- "fc.tooltip.contradicted": "Contradicted: reliable sources contradict this claim. Likely false.",
- "fc.chip.confirmed": "Confirmed",
- "fc.chip.unconfirmed": "Unconfirmed",
- "fc.chip.contradicted": "Contradicted",
- "fc.chip.developing": "Developing",
- "fc.chip.established": "Established",
- "fc.chip.disputed": "Disputed",
- "fc.chip.unverified": "Unverified",
- "refresh.no_developments": "No new developments",
- "refresh.new_articles_suffix": "new articles",
- "refresh.confirmed_suffix": "facts confirmed",
- "refresh.contradicted_suffix": "contradicted",
- "progress.status.queued": "Queued",
- "progress.status.researching": "Researching...",
- "progress.status.deep_researching": "Deep research...",
- "progress.status.analyzing": "Analyzing...",
- "progress.status.factchecking": "Fact-checking...",
- "progress.status.cancelling": "Cancelling...",
- "progress.title.first_refresh": "Initial research running",
- "progress.title.refresh": "Refresh running",
- "progress.title.queued": "Queued",
- "progress.title.cancelling": "Cancelling…",
- "progress.factcheck_running": "Fact-check running",
- "progress.check.researching": "Searching sources",
- "progress.check.analyzing": "Analyzing articles",
- "pipeline.empty": "Never refreshed. Start the first refresh.",
- "pipeline.load_failed": "Failed to load pipeline",
- "pipeline.running": "Refresh running...",
- "pipeline.cancelled": "cancelled",
- "pipeline.with_errors": "finished with errors",
- "pipeline.duration_prefix": "Duration:",
- "pipeline.status.done": "done",
- "pipeline.status.running": "running...",
- "pipeline.status.error": "error",
- "pipeline.count.sources_reviewed": "{n} sources checked",
- "pipeline.count.collected": "{n} articles",
- "pipeline.count.collected_from": "{n} articles from {s} sources",
- "time.just_now": "just now",
- "time.minutes_ago": "{n} min ago",
- "time.hours_ago": "{n}h ago",
- "time.days_ago": "{n} days ago",
- "time.day_ago": "1 day ago",
- "toast.incident_refreshed": "Situation refreshed.",
- "toast.data_refreshed": "Data refreshed.",
- "toast.source_updated": "Source updated.",
- "toast.session_expires": "Session expires in {min} minute(s). Please sign in again.",
- "confirm.delete_incident": "Really delete this situation? All collected data will be lost."
-}
+{
+ "sidebar.live_monitoring": "Live monitoring",
+ "sidebar.research": "Research",
+ "sidebar.archive": "Archive",
+ "sidebar.sources": "Sources",
+ "sidebar.feedback": "Feedback",
+ "sidebar.manage_sources_title": "Manage sources",
+ "sidebar.feedback_title": "Send feedback",
+ "sidebar.stat.sources_suffix": "sources",
+ "sidebar.stat.articles_suffix": "articles",
+ "sidebar.empty_adhoc": "No live monitoring",
+ "sidebar.empty_adhoc_mine": "No own live monitoring",
+ "sidebar.empty_research": "No deep research",
+ "sidebar.empty_research_mine": "No own deep research",
+ "action.refresh": "Refresh",
+ "action.edit": "Edit",
+ "action.export": "Export report",
+ "action.archive": "Archive",
+ "action.delete": "Delete",
+ "action.refreshing": "Running...",
+ "action.restore": "Restore",
+ "action.budget_exceeded": "Budget exhausted",
+ "action.read_only": "Read-only",
+ "action.budget_exceeded_title": "Token budget exhausted. Please contact administration.",
+ "action.read_only_title": "License does not permit write access",
+ "sidebar.empty": "No situations yet",
+ "header.logout": "Sign out",
+ "header.new_incident": "+ New situation",
+ "header.theme_toggle": "Toggle theme",
+ "header.notifications": "Notifications",
+ "filter.all": "All",
+ "filter.own": "Own",
+ "filter.everything": "Everything",
+ "common.close": "Close",
+ "common.cancel": "Cancel",
+ "common.save": "Save",
+ "common.delete": "Delete",
+ "common.edit": "Edit",
+ "common.loading": "Loading...",
+ "common.confirm": "Confirm",
+ "common.error": "Error",
+ "modal.new_incident.title": "Create new situation",
+ "modal.new_incident.title_field": "Incident title",
+ "modal.new_incident.description": "Description / context",
+ "modal.new_incident.enhance": "Generate description",
+ "modal.new_incident.enhance_loading": "Generating...",
+ "enhance.error_default": "Description could not be generated",
+ "enhance.error_unavailable": "AI access currently unavailable. Please contact your administrator.",
+ "enhance.error_busy": "AI is currently busy. Please wait briefly and try again.",
+ "enhance.error_timeout": "AI is not responding. Please try again.",
+ "modal.new_incident.visibility": "Visibility",
+ "modal.new_incident.visibility_public": "Public",
+ "modal.new_incident.visibility_private": "Private",
+ "modal.new_incident.submit": "Create situation",
+ "modal.new_incident.title2": "Create new case",
+ "modal.new_incident.edit_title": "Edit situation",
+ "modal.placeholder.title": "e.g. Explosion in Madrid",
+ "modal.placeholder.description": "More details about the incident (optional)",
+ "modal.field.type": "Type of situation",
+ "modal.option.type_adhoc": "Live monitoring : track an event",
+ "modal.option.type_research": "Research : analyse a topic",
+ "modal.hint.type_adhoc": "Continuously searches hundreds of news sources for new articles. Recommended: automatic refresh.",
+ "modal.hint.type_research": "Structured deep research with multiple passes. Recommended: start manually and deepen when needed.",
+ "modal.field.sources": "Sources",
+ "modal.toggle.international": "Include international sources",
+ "modal.toggle.telegram": "Include Telegram channels",
+ "modal.toggle.visibility_public_text": "Public : visible to all users",
+ "modal.toggle.visibility_private_text": "Private : only visible to you",
+ "modal.field.refresh": "Refresh",
+ "modal.option.manual": "Manual",
+ "modal.option.auto": "Automatic",
+ "modal.field.interval": "Interval",
+ "modal.unit.minutes": "Minutes",
+ "modal.unit.hours": "Hours",
+ "modal.unit.days": "Days",
+ "modal.unit.weeks": "Weeks",
+ "modal.field.start_time": "First refresh at",
+ "modal.field.retention": "Retention (days)",
+ "modal.placeholder.retention": "0 = unlimited",
+ "modal.field.notifications": "Email notifications",
+ "modal.hint.notifications": "Notify me by email about:",
+ "modal.notify.summary": "New briefing",
+ "modal.notify.summary_research": "New research report",
+ "modal.notify.new_articles": "New articles",
+ "modal.notify.status_change": "Fact-check status change",
+ "aria.close": "Close",
+ "modal.sources.title": "Source management",
+ "modal.sources.approve_all_high": "Approve all ≥ 0.85",
+ "modal.export.title": "Export report",
+ "modal.fc_status.title": "Fact-check status change",
+ "tile.factcheck": "Fact check",
+ "tile.research_evaluated": "Research situations are evaluated multiple times...",
+ "tile.summary": "Briefing",
+ "tile.summary_research": "Research report",
+ "tile.timeline": "Timeline",
+ "tile.map": "Map",
+ "tile.sources": "Sources",
+ "tab.latest_developments": "Latest developments",
+ "tab.summary": "Briefing",
+ "tab.timeline": "Event timeline",
+ "tab.map": "Geographic distribution",
+ "tab.factcheck": "Fact check",
+ "tab.pipeline": "Analysis pipeline",
+ "tab.sources_overview": "Sources overview",
+ "tab.summary_short": "Summary",
+ "tab.summary_report": "Research report",
+ "card.summary": "Briefing",
+ "card.timeline": "Event timeline",
+ "card.map": "Geographic distribution",
+ "card.pipeline": "Analysis pipeline",
+ "card.sources_overview": "Sources overview",
+ "fc.label.confirmed": "Confirmed by multiple sources",
+ "fc.label.unconfirmed": "Not independently confirmed",
+ "fc.label.contradicted": "Contradicted",
+ "fc.label.developing": "Facts still developing",
+ "fc.label.established": "Established fact (3+ sources)",
+ "fc.label.disputed": "Disputed matter",
+ "fc.label.unverified": "Not independently verifiable",
+ "fc.tooltip.confirmed": "Confirmed: at least two independent, reputable sources support this claim consistently.",
+ "fc.tooltip.established": "Established: three or more independent sources confirm the matter. High reliability.",
+ "fc.tooltip.developing": "Developing: the facts are still in flux. New information may change the picture.",
+ "fc.tooltip.unconfirmed": "Unconfirmed: known from only one source so far. Independent confirmation is pending.",
+ "fc.tooltip.unverified": "Unverified: the claim could not yet be checked against available sources.",
+ "fc.tooltip.disputed": "Disputed: sources disagree. There is both supporting and contradicting evidence.",
+ "fc.tooltip.contradicted": "Contradicted: reliable sources contradict this claim. Likely false.",
+ "fc.chip.confirmed": "Confirmed",
+ "fc.chip.unconfirmed": "Unconfirmed",
+ "fc.chip.contradicted": "Contradicted",
+ "fc.chip.developing": "Developing",
+ "fc.chip.established": "Established",
+ "fc.chip.disputed": "Disputed",
+ "fc.chip.unverified": "Unverified",
+ "refresh.no_developments": "No new developments",
+ "refresh.new_articles_suffix": "new articles",
+ "refresh.confirmed_suffix": "facts confirmed",
+ "refresh.contradicted_suffix": "contradicted",
+ "progress.status.queued": "Queued",
+ "progress.status.researching": "Researching...",
+ "progress.status.deep_researching": "Deep research...",
+ "progress.status.analyzing": "Analyzing...",
+ "progress.status.factchecking": "Fact-checking...",
+ "progress.status.cancelling": "Cancelling...",
+ "progress.title.first_refresh": "Initial research running",
+ "progress.title.refresh": "Refresh running",
+ "progress.title.queued": "Queued",
+ "progress.title.cancelling": "Cancelling…",
+ "progress.factcheck_running": "Fact-check running",
+ "progress.check.researching": "Searching sources",
+ "progress.check.analyzing": "Analyzing articles",
+ "pipeline.empty": "Never refreshed. Start the first refresh.",
+ "pipeline.load_failed": "Failed to load pipeline",
+ "pipeline.running": "Refresh running...",
+ "pipeline.cancelled": "cancelled",
+ "pipeline.with_errors": "finished with errors",
+ "pipeline.duration_prefix": "Duration:",
+ "pipeline.status.done": "done",
+ "pipeline.status.running": "running...",
+ "pipeline.status.error": "error",
+ "pipeline.count.sources_reviewed": "{n} sources checked",
+ "pipeline.count.collected": "{n} articles",
+ "pipeline.count.collected_from": "{n} articles from {s} sources",
+ "time.just_now": "just now",
+ "time.minutes_ago": "{n} min ago",
+ "time.hours_ago": "{n}h ago",
+ "time.days_ago": "{n} days ago",
+ "time.day_ago": "1 day ago",
+ "toast.incident_refreshed": "Situation refreshed.",
+ "toast.data_refreshed": "Data refreshed.",
+ "toast.source_updated": "Source updated.",
+ "toast.session_expires": "Session expires in {min} minute(s). Please sign in again.",
+ "confirm.delete_incident": "Really delete this situation? All collected data will be lost.",
+ "toast.incident_updated": "Situation refreshed.",
+ "toast.refresh_started": "Refresh started.",
+ "toast.incident_deleted": "Situation deleted.",
+ "toast.incident_archived": "Situation archived.",
+ "toast.incident_restored": "Situation restored.",
+ "toast.research_cancelled": "Research cancelled.",
+ "toast.no_active_refresh": "No active refresh found to cancel.",
+ "toast.report_downloaded": "Report downloaded",
+ "toast.data_updated": "Data refreshed.",
+ "toast.no_rss_save_as_web": "No RSS feed found. Save as web source?",
+ "toast.source_added": "Source added.",
+ "confirm.cancel_running_research": "Cancel running research?",
+ "action.starting": "Starting...",
+ "action.cancelling": "Cancelling...",
+ "action.creating": "Generating...",
+ "action.sending": "Sending...",
+ "action.searching_feeds": "Searching feeds...",
+ "action.save_source": "Save source",
+ "license.expired_readonly": "License expired – read-only",
+ "license.none_readonly": "No active license – read-only",
+ "license.org_disabled_readonly": "Organization disabled – read-only",
+ "notifications.title": "Notifications",
+ "notifications.mark_all_read": "Mark all read",
+ "notifications.empty": "No notifications",
+ "empty.no_incident_title": "No situation selected",
+ "empty.no_incident_text": "Create a new case or pick an existing one from the sidebar.",
+ "map.import_locations": "Import locations",
+ "map.import_locations_title": "Import locations from articles",
+ "map.empty": "No locations detected",
+ "source.type.rss_feed": "RSS feed",
+ "source.type.telegram": "Telegram",
+ "source.type.web": "Web source",
+ "modal.hint.sources_german_only": "Primary-language sources only"
+}
diff --git a/src/static/js/app.js b/src/static/js/app.js
index 3a2f84e..9d1917a 100644
--- a/src/static/js/app.js
+++ b/src/static/js/app.js
@@ -1,3842 +1,3842 @@
-/**
- * OSINT Lagemonitor - Hauptanwendungslogik.
- */
-
-/** Feste Zeitzone fuer alle Anzeigen — NIEMALS aendern. */
-const TIMEZONE = 'Europe/Berlin';
-
-/** Gibt Jahr/Monat(0-basiert)/Tag/Stunde/Minute in Berliner Zeit zurueck. */
-function _tz(d) {
- const s = d.toLocaleString('en-CA', {
- timeZone: TIMEZONE, year: 'numeric', month: '2-digit', day: '2-digit',
- hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false
- });
- const m = s.match(/(\d{4})-(\d{2})-(\d{2}),?\s*(\d{2}):(\d{2}):(\d{2})/);
- if (!m) return { year: d.getFullYear(), month: d.getMonth(), date: d.getDate(), hours: d.getHours(), minutes: d.getMinutes() };
- return { year: +m[1], month: +m[2] - 1, date: +m[3], hours: +m[4], minutes: +m[5] };
-}
-
-/**
- * Theme Manager: Dark/Light Theme Toggle mit localStorage-Persistenz.
- */
-const ThemeManager = {
- _key: 'osint_theme',
- init() {
- const saved = localStorage.getItem(this._key);
- const theme = saved || 'dark';
- document.documentElement.setAttribute('data-theme', theme);
- this._updateIcon(theme);
- },
- toggle() {
- const current = document.documentElement.getAttribute('data-theme') || 'dark';
- const next = current === 'dark' ? 'light' : 'dark';
- document.documentElement.setAttribute('data-theme', next);
- localStorage.setItem(this._key, next);
- this._updateIcon(next);
- UI.updateMapTheme();
- },
- _updateIcon(theme) {
- const el = document.getElementById('theme-toggle');
- if (!el) return;
- el.classList.remove('dark', 'light');
- el.classList.add(theme);
- el.setAttribute('aria-checked', theme === 'dark' ? 'true' : 'false');
- }
-};
-
-/**
- * Barrierefreiheits-Manager: Panel mit 4 Schaltern (Kontrast, Focus, Schrift, Animationen).
- */
-const A11yManager = {
- _key: 'osint_a11y',
- _isOpen: false,
- _settings: { contrast: false, focus: false, fontsize: false, motion: false },
-
- init() {
- // Einstellungen aus localStorage laden
- try {
- const saved = JSON.parse(localStorage.getItem(this._key) || '{}');
- Object.keys(this._settings).forEach(k => {
- if (typeof saved[k] === 'boolean') this._settings[k] = saved[k];
- });
- } catch (e) { /* Ungültige Daten ignorieren */ }
-
- // Button + Panel dynamisch in .header-right einfügen (vor Theme-Toggle)
- const headerRight = document.querySelector('.header-right');
- const themeToggle = document.getElementById('theme-toggle');
- if (!headerRight) return;
-
- const container = document.createElement('div');
- container.className = 'a11y-center';
- container.innerHTML = `
-
-
-
-
-
-
-
- `;
-
- if (themeToggle) {
- headerRight.insertBefore(container, themeToggle);
- } else {
- headerRight.prepend(container);
- }
-
- // Toggle-Event-Listener
- ['contrast', 'focus', 'fontsize', 'motion'].forEach(key => {
- document.getElementById('a11y-' + key).addEventListener('change', () => this.toggle(key));
- });
-
- // Button öffnet/schließt Panel
- document.getElementById('a11y-btn').addEventListener('click', (e) => {
- e.stopPropagation();
- this._isOpen ? this._closePanel() : this._openPanel();
- });
-
- // Klick außerhalb schließt Panel
- document.addEventListener('click', (e) => {
- if (this._isOpen && !container.contains(e.target)) {
- this._closePanel();
- }
- });
-
- // Keyboard: Esc schließt, Pfeiltasten navigieren
- container.addEventListener('keydown', (e) => {
- if (e.key === 'Escape' && this._isOpen) {
- e.stopPropagation();
- this._closePanel();
- return;
- }
- if (!this._isOpen) return;
- if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
- e.preventDefault();
- const options = Array.from(document.querySelectorAll('.a11y-option input[type="checkbox"]'));
- const idx = options.indexOf(document.activeElement);
- let next;
- if (e.key === 'ArrowDown') {
- next = idx < options.length - 1 ? idx + 1 : 0;
- } else {
- next = idx > 0 ? idx - 1 : options.length - 1;
- }
- options[next].focus();
- }
- });
-
- // Einstellungen anwenden + Checkboxen synchronisieren
- this._apply();
- this._syncUI();
- },
-
- toggle(key) {
- this._settings[key] = !this._settings[key];
- this._apply();
- this._syncUI();
- this._save();
- },
-
- _apply() {
- const root = document.documentElement;
- Object.keys(this._settings).forEach(k => {
- if (this._settings[k]) {
- root.setAttribute('data-a11y-' + k, 'true');
- } else {
- root.removeAttribute('data-a11y-' + k);
- }
- });
- },
-
- _syncUI() {
- Object.keys(this._settings).forEach(k => {
- const cb = document.getElementById('a11y-' + k);
- if (cb) cb.checked = this._settings[k];
- });
- },
-
- _save() {
- localStorage.setItem(this._key, JSON.stringify(this._settings));
- },
-
- _openPanel() {
- this._isOpen = true;
- document.getElementById('a11y-panel').style.display = '';
- document.getElementById('a11y-btn').setAttribute('aria-expanded', 'true');
- // Fokus auf erste Option setzen
- requestAnimationFrame(() => {
- const first = document.querySelector('.a11y-option input[type="checkbox"]');
- if (first) first.focus();
- });
- },
-
- _closePanel() {
- this._isOpen = false;
- document.getElementById('a11y-panel').style.display = 'none';
- const btn = document.getElementById('a11y-btn');
- btn.setAttribute('aria-expanded', 'false');
- btn.focus();
- }
-};
-
-/**
- * Notification-Center: Glocke mit Badge + History-Panel.
- */
-const NotificationCenter = {
- _notifications: [],
- _unreadCount: 0,
- _isOpen: false,
- _maxItems: 50,
- _syncTimer: null,
-
- async init() {
- // Glocken-Container dynamisch in .header-right vor #header-user einfügen
- const headerRight = document.querySelector('.header-right');
- const headerUser = document.getElementById('header-user');
- if (!headerRight || !headerUser) return;
-
- const container = document.createElement('div');
- container.className = 'notification-center';
- container.innerHTML = `
-
-
-
-
-
- 0
-
-
-
-
-
Keine Benachrichtigungen
-
-
- `;
- headerRight.insertBefore(container, headerUser);
-
- // Event-Listener
- document.getElementById('notification-bell').addEventListener('click', (e) => {
- e.stopPropagation();
- this.toggle();
- });
- document.getElementById('notification-mark-read').addEventListener('click', (e) => {
- e.stopPropagation();
- this.markAllRead();
- });
- // Klick außerhalb schließt Panel
- document.addEventListener('click', (e) => {
- if (this._isOpen && !container.contains(e.target)) {
- this.close();
- }
- });
-
- // Notifications aus DB laden
- await this._loadFromDB();
- },
-
- add(notification) {
- // Optimistisches UI: sofort anzeigen
- notification.read = false;
- notification.timestamp = notification.timestamp || new Date().toISOString();
- this._notifications.unshift(notification);
- if (this._notifications.length > this._maxItems) {
- this._notifications.pop();
- }
- this._unreadCount++;
- this._updateBadge();
- this._renderList();
-
- // DB-Sync mit Debounce (Orchestrator schreibt parallel in DB)
- clearTimeout(this._syncTimer);
- this._syncTimer = setTimeout(() => this._syncFromDB(), 500);
- },
-
- toggle() {
- this._isOpen ? this.close() : this.open();
- },
-
- open() {
- this._isOpen = true;
- const panel = document.getElementById('notification-panel');
- if (panel) panel.style.display = 'flex';
- const bell = document.getElementById('notification-bell');
- if (bell) bell.setAttribute('aria-expanded', 'true');
- },
-
- close() {
- this._isOpen = false;
- const panel = document.getElementById('notification-panel');
- if (panel) panel.style.display = 'none';
- const bell = document.getElementById('notification-bell');
- if (bell) bell.setAttribute('aria-expanded', 'false');
- },
-
- async markAllRead() {
- this._notifications.forEach(n => n.read = true);
- this._unreadCount = 0;
- this._updateBadge();
- this._renderList();
-
- // In DB als gelesen markieren (fire-and-forget)
- try {
- await API.markNotificationsRead(null);
- } catch (e) {
- console.warn('Notifications als gelesen markieren fehlgeschlagen:', e);
- }
- },
-
- _updateBadge() {
- const badge = document.getElementById('notification-badge');
- if (!badge) return;
- if (this._unreadCount > 0) {
- badge.style.display = 'flex';
- badge.textContent = this._unreadCount > 99 ? '99+' : this._unreadCount;
- document.title = `(${this._unreadCount}) ${App._originalTitle}`;
- } else {
- badge.style.display = 'none';
- document.title = App._originalTitle;
- }
- },
-
- _renderList() {
- const list = document.getElementById('notification-panel-list');
- if (!list) return;
-
- if (this._notifications.length === 0) {
- list.innerHTML = 'Keine Benachrichtigungen
';
- return;
- }
-
- list.innerHTML = this._notifications.map(n => {
- const time = new Date(n.timestamp);
- const timeStr = time.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', timeZone: TIMEZONE });
- const unreadClass = n.read ? '' : ' unread';
- const icon = n.icon || 'info';
- return `
-
${this._iconSymbol(icon)}
-
-
${this._escapeHtml(n.title)}
-
${this._escapeHtml(n.text)}
-
-
${timeStr}
-
`;
- }).join('');
- },
-
- _handleClick(incidentId) {
- this.close();
- if (incidentId) {
- App.selectIncident(incidentId);
- }
- },
-
- _iconSymbol(type) {
- switch (type) {
- case 'success': return '\u2713';
- case 'warning': return '!';
- case 'error': return '\u2717';
- default: return 'i';
- }
- },
-
- _escapeHtml(text) {
- const d = document.createElement('div');
- d.textContent = text || '';
- return d.innerHTML;
- },
-
- async _loadFromDB() {
- try {
- const items = await API.listNotifications(50);
- this._notifications = items.map(n => ({
- id: n.id,
- incident_id: n.incident_id,
- title: n.title,
- text: n.text,
- icon: n.icon || 'info',
- type: n.type,
- read: !!n.is_read,
- timestamp: n.created_at,
- }));
- this._unreadCount = this._notifications.filter(n => !n.read).length;
- this._updateBadge();
- this._renderList();
- } catch (e) {
- console.warn('Notifications laden fehlgeschlagen:', e);
- }
- },
-
- async _syncFromDB() {
- try {
- const items = await API.listNotifications(50);
- this._notifications = items.map(n => ({
- id: n.id,
- incident_id: n.incident_id,
- title: n.title,
- text: n.text,
- icon: n.icon || 'info',
- type: n.type,
- read: !!n.is_read,
- timestamp: n.created_at,
- }));
- this._unreadCount = this._notifications.filter(n => !n.read).length;
- this._updateBadge();
- this._renderList();
- } catch (e) {
- console.warn('Notifications sync fehlgeschlagen:', e);
- }
- },
-};
-
-const App = {
- currentIncidentId: null,
- incidents: [],
- _originalTitle: document.title,
- _refreshingIncidents: new Set(),
- _editingIncidentId: null,
- _currentArticles: [],
- _currentSnapshots: [],
- _snapshotFullCache: new Map(),
- _currentSources: [],
- _currentIncidentType: 'adhoc',
- _sidebarFilter: 'all',
- _currentUsername: '',
- _allSources: [],
- _sourcesOnly: [],
- _myExclusions: [], // [{domain, notes, created_at}]
- _expandedGroups: new Set(),
- _editingSourceId: null,
- _timelineFilter: 'all',
- _timelineRange: 'all',
- _activeStripWindow: null,
- _timelineSearchTimer: null,
- _pendingComplete: null,
- _pendingCompleteTimer: null,
-
- async init() {
- ThemeManager.init();
- A11yManager.init();
- // Auth prüfen
- const token = localStorage.getItem('osint_token');
- if (!token) {
- window.location.href = '/';
- return;
- }
-
- try {
- const user = await API.getMe();
- this.user = user;
- this._currentUsername = user.email;
-
- // i18n: Sprache anhand der Org laden (default 'de') und DOM uebersetzen
- if (window.I18N) {
- const targetLang = user.output_language || 'de';
- await window.I18N.load(targetLang);
- window.I18N.applyDom();
- }
-
- document.getElementById('header-user').textContent = user.email;
-
- // Dropdown-Daten befuellen
- const orgNameEl = document.getElementById('header-org-name');
- if (orgNameEl) orgNameEl.textContent = user.org_name || '-';
-
- const licInfoEl = document.getElementById('header-license-info');
- if (licInfoEl) {
- const licenseLabels = {
- trial: 'Trial',
- annual: 'Jahreslizenz',
- permanent: 'Permanent',
- };
- const label = user.read_only ? 'Abgelaufen'
- : licenseLabels[user.license_type] || user.license_status || '-';
- licInfoEl.textContent = label;
- }
-
- // Credits-Anzeige im Dropdown
- const creditsSection = document.getElementById('credits-section');
- if (creditsSection && user.credits_total) {
- creditsSection.style.display = 'block';
- const bar = document.getElementById('credits-bar');
- const remainingEl = document.getElementById('credits-remaining');
- const totalEl = document.getElementById('credits-total');
-
- const remaining = user.credits_remaining || 0;
- const total = user.credits_total || 1;
- const percentUsed = user.credits_percent_used || 0;
- const percentRemaining = Math.max(0, 100 - percentUsed);
-
- remainingEl.textContent = remaining.toLocaleString('de-DE');
- totalEl.textContent = total.toLocaleString('de-DE');
- bar.style.width = percentRemaining + '%';
-
- // Farbwechsel je nach Verbrauch
- bar.classList.remove('warning', 'critical');
- if (percentUsed > 80) {
- bar.classList.add('critical');
- } else if (percentUsed > 50) {
- bar.classList.add('warning');
- }
- const percentEl = document.getElementById("credits-percent");
- if (percentEl) percentEl.textContent = percentRemaining.toFixed(0) + "% verbleibend";
- }
-
- // Dropdown Toggle
- const userBtn = document.getElementById('header-user-btn');
- const userDropdown = document.getElementById('header-user-dropdown');
- if (userBtn && userDropdown) {
- userBtn.addEventListener('click', (e) => {
- e.stopPropagation();
- const isOpen = userDropdown.classList.toggle('open');
- userBtn.setAttribute('aria-expanded', isOpen);
- });
- userDropdown.addEventListener('click', (e) => {
- e.stopPropagation();
- });
- document.addEventListener('click', () => {
- userDropdown.classList.remove('open');
- userBtn.setAttribute('aria-expanded', 'false');
- });
- }
-
- // Warnung bei Read-Only (Lizenz abgelaufen oder Token-Budget aufgebraucht)
- const warningEl = document.getElementById('header-license-warning');
- if (warningEl) {
- if (user.read_only) {
- let text = 'Nur Lesezugriff';
- const reason = user.read_only_reason;
- if (reason === 'budget_exceeded') {
- text = 'Token-Budget aufgebraucht – nur Lesezugriff. Für Aufstockung oder Upgrade bitte info@aegis-sight.de kontaktieren.';
- } else if (reason === 'expired') {
- text = 'Lizenz abgelaufen – nur Lesezugriff';
- } else if (reason === 'no_license') {
- text = 'Keine aktive Lizenz – nur Lesezugriff';
- } else if (reason === 'org_disabled') {
- text = 'Organisation deaktiviert – nur Lesezugriff';
- }
- warningEl.textContent = text;
- warningEl.classList.add('visible');
- } else {
- warningEl.textContent = '';
- warningEl.classList.remove('visible');
- }
- }
-
- // --- Global Admin: Org-Switcher (herausnehmbar) ---
- if (user.is_global_admin) {
- this._initOrgSwitcher(user.tenant_id);
- }
-
- // Tutorial nur bei deutscher Org starten -- englische Demo-Mandanten
- // sollen direkt im Dashboard landen.
- try {
- const lang = (window.I18N && window.I18N.lang) || 'de';
- if (lang === 'de' && typeof Tutorial !== 'undefined' && Tutorial.init) {
- Tutorial.init();
- }
- } catch (e) { /* Tutorial optional */ }
- } catch {
- window.location.href = '/';
- return;
- }
-
- // Event-Listener
- document.getElementById('logout-btn').addEventListener('click', () => this.logout());
- document.getElementById('new-incident-btn').addEventListener('click', () => openModal('modal-new'));
- document.getElementById('new-incident-form').addEventListener('submit', (e) => this.handleFormSubmit(e));
- document.getElementById('refresh-btn').addEventListener('click', () => this.handleRefresh());
- document.getElementById('delete-incident-btn').addEventListener('click', () => this.handleDelete());
- document.getElementById('edit-incident-btn').addEventListener('click', () => this.handleEdit());
- document.getElementById('archive-incident-btn').addEventListener('click', () => this.handleArchive());
- document.getElementById('inc-international').addEventListener('change', () => updateSourcesHint());
- document.getElementById('inc-visibility').addEventListener('change', () => updateVisibilityHint());
- // Telegram-Kategorien Toggle
- const tgCheckbox = document.getElementById('inc-telegram');
- if (tgCheckbox) {
-
- }
-
-
- // Feedback
- document.getElementById('feedback-form').addEventListener('submit', (e) => this.submitFeedback(e));
- document.getElementById('fb-message').addEventListener('input', (e) => {
- document.getElementById('fb-char-count').textContent = e.target.value.length.toLocaleString('de-DE');
- });
-
- // Sidebar-Chevrons initial auf offen setzen (Archiv geschlossen)
- document.querySelectorAll('.sidebar-chevron').forEach(c => c.classList.add('open'));
- document.getElementById('chevron-archived-incidents').classList.remove('open');
-
- // Lagen laden (frueh, damit Sidebar sofort sichtbar)
- await this.loadIncidents();
-
- // Netzwerkanalysen laden
-
- // Notification-Center initialisieren
- try { await NotificationCenter.init(); } catch (e) { console.warn('NotificationCenter:', e); }
-
- // WebSocket
- WS.connect();
- WS.on('status_update', (msg) => this.handleStatusUpdate(msg));
- WS.on('refresh_complete', (msg) => this.handleRefreshComplete(msg));
- WS.on('refresh_summary', (msg) => this.handleRefreshSummary(msg));
- WS.on('refresh_error', (msg) => this.handleRefreshError(msg));
- WS.on('refresh_cancelled', (msg) => this.handleRefreshCancelled(msg));
-
- // Laufende Refreshes wiederherstellen
- try {
- const data = await API.getRefreshingIncidents();
- const details = data.details || {};
- const currentTask = data.current;
- const queuedIds = data.queued || [];
-
- // Restore running refreshes
- if (data.refreshing && data.refreshing.length > 0) {
- data.refreshing.forEach(id => {
- this._refreshingIncidents.add(id);
- const d = details[String(id)] || {};
- const inc = this.incidents.find(i => i.id === id);
- const isFirst = inc && !inc.has_summary;
- const isCurrent = (id === currentTask);
- // Use 'researching' as default step for the actively running task
- UI.showProgress(isCurrent ? 'researching' : 'queued', { started_at: d.started_at }, id, isFirst);
- });
- }
-
- // Restore queued incidents
- if (queuedIds.length > 0) {
- queuedIds.forEach((id, idx) => {
- this._refreshingIncidents.add(id);
- const inc = this.incidents.find(i => i.id === id);
- const isFirst = inc && !inc.has_summary;
- UI.showProgress('queued', { queue_position: idx + 1 }, id, isFirst);
- // Pipeline-Reset auch nach F5: aktive Lage in Queue -> Icons grau
- if (id === this.currentIncidentId && typeof Pipeline !== 'undefined' && Pipeline.beginQueue) {
- Pipeline.beginQueue(id);
- }
- });
- }
-
- if (data.refreshing.length > 0 || queuedIds.length > 0) {
- this.renderSidebar();
- }
- } catch (e) { /* Kein kritischer Fehler */ }
-
- // Heartbeat: periodischer Status-Abgleich als Sicherheitsnetz
- this._statusSyncInterval = setInterval(() => this.syncRefreshStatus(), 60000);
-
- // Zuletzt ausgewählte Lage wiederherstellen
- const savedId = localStorage.getItem('selectedIncidentId');
- if (savedId) {
- const id = parseInt(savedId, 10);
- if (this.incidents.some(inc => inc.id === id)) {
- await this.selectIncident(id);
- }
- }
-
- // Leaflet-Karte nachladen falls CDN langsam war
- setTimeout(() => UI.retryPendingMap(), 2000);
- },
-
- async loadIncidents() {
- try {
- this.incidents = await API.listIncidents();
- this.renderSidebar();
- } catch (err) {
- UI.showToast('Fehler beim Laden der Lagen: ' + err.message, 'error');
- }
- },
-
- renderSidebar() {
- const activeContainer = document.getElementById('active-incidents');
- const researchContainer = document.getElementById('active-research');
- const archivedContainer = document.getElementById('archived-incidents');
-
- // Filter-Buttons aktualisieren
- document.querySelectorAll('.sidebar-filter-btn').forEach(btn => {
- const isActive = btn.dataset.filter === this._sidebarFilter;
- btn.classList.toggle('active', isActive);
- btn.setAttribute('aria-pressed', String(isActive));
- });
-
- // Lagen nach Filter einschränken
- let filtered = this.incidents;
- if (this._sidebarFilter === 'mine') {
- filtered = filtered.filter(i => i.created_by_username === this._currentUsername);
- }
-
- // Aktive Lagen nach Typ aufteilen
- const activeAdhoc = filtered.filter(i => i.status === 'active' && (!i.type || i.type === 'adhoc'));
- const activeResearch = filtered.filter(i => i.status === 'active' && i.type === 'research');
- const archived = filtered.filter(i => i.status === 'archived');
-
- const _tEmpty = (k, fb) => (typeof T === 'function') ? T(k, fb) : fb;
- const emptyLabelAdhoc = this._sidebarFilter === 'mine'
- ? _tEmpty('sidebar.empty_adhoc_mine', 'Kein eigenes Live-Monitoring')
- : _tEmpty('sidebar.empty_adhoc', 'Kein Live-Monitoring');
- const emptyLabelResearch = this._sidebarFilter === 'mine'
- ? _tEmpty('sidebar.empty_research_mine', 'Keine eigenen Deep-Research')
- : _tEmpty('sidebar.empty_research', 'Keine Deep-Research');
-
- activeContainer.innerHTML = activeAdhoc.length
- ? activeAdhoc.map(i => UI.renderIncidentItem(i, i.id === this.currentIncidentId)).join('')
- : `${emptyLabelAdhoc}
`;
-
- researchContainer.innerHTML = activeResearch.length
- ? activeResearch.map(i => UI.renderIncidentItem(i, i.id === this.currentIncidentId)).join('')
- : `${emptyLabelResearch}
`;
-
- archivedContainer.innerHTML = archived.length
- ? archived.map(i => UI.renderIncidentItem(i, i.id === this.currentIncidentId)).join('')
- : 'Kein Archiv
';
-
- // Zähler aktualisieren
- const countAdhoc = document.getElementById('count-active-incidents');
- const countResearch = document.getElementById('count-active-research');
- const countArchived = document.getElementById('count-archived-incidents');
- if (countAdhoc) countAdhoc.textContent = `(${activeAdhoc.length})`;
- if (countResearch) countResearch.textContent = `(${activeResearch.length})`;
- if (countArchived) countArchived.textContent = `(${archived.length})`;
-
- // Sidebar-Stats aktualisieren
- this.updateSidebarStats();
- },
-
- setSidebarFilter(filter) {
- this._sidebarFilter = filter;
- this.renderSidebar();
- },
-
- _announceForSR(text) {
- let el = document.getElementById('sr-announcement');
- if (!el) {
- el = document.createElement('div');
- el.id = 'sr-announcement';
- el.setAttribute('role', 'status');
- el.setAttribute('aria-live', 'polite');
- el.className = 'sr-only';
- document.body.appendChild(el);
- }
- el.textContent = '';
- requestAnimationFrame(() => { el.textContent = text; });
- },
-
- async selectIncident(id) {
- this.closeRefreshHistory();
- this.currentIncidentId = id;
- localStorage.setItem('selectedIncidentId', id);
- const inc = this.incidents.find(i => i.id === id);
- if (inc) this._announceForSR('Lage ausgewählt: ' + inc.title);
- this.renderSidebar();
-
- var mc = document.getElementById("main-content");
- mc.scrollTop = 0;
-
- document.getElementById('empty-state').style.display = 'none';
- document.getElementById('incident-view').style.display = 'flex';
-
- // GridStack-Animation deaktivieren und Scroll komplett sperren
- // bis alle Tile-Resize-Operationen (doppeltes rAF) abgeschlossen sind
- var gridEl = document.querySelector('.tab-panels');
- if (gridEl) gridEl.classList.remove('grid-stack-animate');
- var scrollLock = function() { mc.scrollTop = 0; };
- mc.addEventListener('scroll', scrollLock);
-
- // gridstack-Layout initialisieren (einmalig)
- if (typeof LayoutManager !== 'undefined') LayoutManager.init();
-
- // Refresh-Status fuer diese Lage wiederherstellen
- const isRefreshing = this._refreshingIncidents.has(id);
- this._updateRefreshButton(isRefreshing);
- // Hide any popup/mini from previous incident
- const prevOverlay = document.getElementById('progress-overlay');
- if (prevOverlay) prevOverlay.style.display = 'none';
- const prevMini = document.getElementById('progress-mini');
- if (prevMini) prevMini.style.display = 'none';
- const blurTarget = document.getElementById('incident-view');
- // Wenn gerade ein erster Refresh laeuft, Blur stehen lassen statt
- // remove+add im selben Tick — CSS filter:blur greift sonst nicht.
- const _restState = isRefreshing ? UI._progressState[id] : null;
- const _willReBlur = _restState && _restState.isFirst && !_restState.minimized;
- if (blurTarget && !_willReBlur) blurTarget.classList.remove('refresh-blurred');
-
- if (isRefreshing) {
- const state = UI._progressState[id];
- if (state) {
- // Restore exactly as it was: popup open or minimized
- if (state.minimized) {
- UI._showMiniProgress(state.step, state);
- } else {
- UI._showPopupProgress(state.step, {}, state);
- }
- UI._lockActionsIfFirst(state.isFirst);
- } else {
- // No state yet — show popup (first status update will refine)
- UI.showProgress('researching', {}, id, false);
- }
- } else {
- UI._lockActionsIfFirst(false);
- }
-
-// Alte Inhalte sofort leeren um Flackern beim Wechsel zu vermeiden
- var el;
- el = document.getElementById("incident-title"); if (el) el.textContent = "";
- el = document.getElementById("summary-content"); if (el) el.scrollTop = 0;
- el = document.getElementById("summary-text"); if (el) el.innerHTML = "";
- el = document.getElementById("zusammenfassung-text"); if (el) el.innerHTML = "";
- el = document.getElementById("factcheck-filters"); if (el) el.innerHTML = "";
- el = document.querySelector(".factcheck-list"); if (el) el.scrollTop = 0;
- el = document.getElementById("factcheck-list"); if (el) el.innerHTML = "";
- el = document.getElementById("source-overview-content"); if (el) el.innerHTML = "";
- el = document.getElementById("source-overview-header-stats"); if (el) el.textContent = "";
- el = document.getElementById("timeline-entries"); if (el) el.innerHTML = "";
- await this.loadIncidentDetail(id);
-
- // Scroll-Sperre nach 3 Frames aufheben (nach allen doppelten rAF-Callbacks)
- mc.scrollTop = 0;
- requestAnimationFrame(() => {
- requestAnimationFrame(() => {
- requestAnimationFrame(() => {
- mc.scrollTop = 0;
- mc.removeEventListener('scroll', scrollLock);
- if (gridEl) gridEl.classList.add('grid-stack-animate');
- });
- });
- });
-
-
-
- },
-
- async loadIncidentDetail(id) {
- try {
- const [incident, articlesResponse, factchecks, snapshots, locationsResponse, sourcesResponse] = await Promise.all([
- API.getIncident(id),
- API.getArticles(id, { limit: 500, offset: 0 }),
- API.getFactChecks(id),
- API.getSnapshots(id),
- API.getLocations(id).catch(() => []),
- API.getIncidentSources(id).catch(() => ({ sources: [] })),
- ]);
-
- // Sources-Array (ersetzt frueheres incident.sources_json — lazy via /sources-Endpunkt)
- this._currentSources = (sourcesResponse && sourcesResponse.sources) || [];
-
- // Articles: neue Shape {total, articles} oder alter nackter Array (Rueckwaertskompatibel)
- let articles, articlesTotal;
- if (Array.isArray(articlesResponse)) {
- articles = articlesResponse;
- articlesTotal = articlesResponse.length;
- } else {
- articles = articlesResponse.articles || [];
- articlesTotal = articlesResponse.total || articles.length;
- }
-
- // Locations-API gibt jetzt {category_labels, locations} oder Array (Rueckwaertskompatibel)
- let locations, categoryLabels;
- if (Array.isArray(locationsResponse)) {
- locations = locationsResponse;
- categoryLabels = null;
- } else if (locationsResponse && locationsResponse.locations) {
- locations = locationsResponse.locations;
- categoryLabels = locationsResponse.category_labels || null;
- } else {
- locations = [];
- categoryLabels = null;
- }
-
- this._currentArticlesTotal = articlesTotal;
- this._currentArticlesLoaded = articles.length;
- this._currentIncidentIdForLoad = id;
-
- this.renderIncidentDetail(incident, articles, factchecks, snapshots, locations, categoryLabels);
-
- // Pipeline an die geladene Lage binden (laedt /api/incidents/{id}/pipeline)
- if (typeof Pipeline !== 'undefined' && Pipeline.bindToIncident) {
- Pipeline.bindToIncident(id).catch(err => console.warn('pipeline-bind:', err));
- }
-
- // Quellenuebersicht aus Aggregat-Endpunkt (alle Quellen, nicht nur erste Seite)
- this._loadSourcesSummary(id).catch(err => console.warn('sources-summary:', err));
-
- // Wenn mehr Artikel existieren als initial geladen: progressiver Hintergrund-Load
- if (articlesTotal > articles.length) {
- this._loadRemainingArticlesInBackground(id).catch(err => console.warn('bg-articles:', err));
- }
- } catch (err) {
- console.error('loadIncidentDetail Fehler:', err);
- UI.showToast('Fehler beim Laden: ' + err.message, 'error');
- }
- },
-
- /** Quellenuebersicht aus Aggregat-Endpunkt nachladen (ersetzt Client-Zaehlung). */
- async _loadSourcesSummary(incidentId) {
- const data = await API.getArticlesSourcesSummary(incidentId);
- if (this.currentIncidentId !== incidentId) return; // User hat gewechselt
- this._currentSourcesSummary = data;
- const soEl = document.getElementById('source-overview-content');
- const statsEl = document.getElementById('source-overview-header-stats');
- if (soEl && typeof UI.renderSourceOverviewFromSummary === 'function') {
- soEl.innerHTML = UI.renderSourceOverviewFromSummary(data);
- }
- if (statsEl && data) {
- statsEl.textContent = `${data.total} Artikel aus ${data.sources.length} Quellen`;
- }
- },
-
- /** Klick auf eine Quellen-Box: Liste der Artikel inline aufklappen (mutual-exclusive). */
- toggleSourceOverviewDetail(el) {
- if (!el) return;
- const grid = el.parentElement;
- if (!grid) return;
- const sourceName = el.dataset.source || '';
- const wasActive = el.classList.contains('active');
-
- // Alle anderen schliessen + bestehendes Detail entfernen
- grid.querySelectorAll('.source-overview-item.active').forEach(it => {
- it.classList.remove('active');
- it.setAttribute('aria-expanded', 'false');
- });
- const existingDetail = grid.querySelector('.source-overview-detail');
- if (existingDetail) existingDetail.remove();
-
- // Wenn das geklickte Item bereits aktiv war: nur schliessen
- if (wasActive) return;
-
- // Neues Detail einfuegen direkt nach dem geklickten Item
- el.classList.add('active');
- el.setAttribute('aria-expanded', 'true');
-
- const type = this._currentIncidentType;
- const getDate = (a) => (type === 'research' && a.published_at) ? a.published_at : (a.collected_at || a.published_at);
- const articles = (this._currentArticles || [])
- .filter(a => (a.source || 'Unbekannt') === sourceName)
- .sort((a, b) => {
- const ta = new Date(getDate(a) || 0).getTime();
- const tb = new Date(getDate(b) || 0).getTime();
- return tb - ta;
- });
-
- // Lagebild-Quellennummer pro Artikel ermitteln (matcht Artikel zu sources_json)
- const normalize = (s) => (s || '').toLowerCase().replace(/^(der|die|das)\s+/, '').replace(/\s+/g, ' ').trim();
- const sourcesList = this._currentSources || [];
- const urlToNr = new Map();
- sourcesList.forEach(s => {
- if (s.url && s.nr != null) urlToNr.set(String(s.url).trim(), s.nr);
- });
- const findNr = (a) => {
- // 1) Exakter URL-Match
- if (a.source_url) {
- const exact = urlToNr.get(String(a.source_url).trim());
- if (exact != null) return exact;
- }
- // 2) Fallback: Match via Quellen-Namen (kann mehrfach treffen, nimm erstes)
- if (a.source) {
- const target = normalize(a.source);
- const hit = sourcesList.find(s => s.nr != null && normalize(s.name) === target);
- if (hit) return hit.nr;
- }
- return null;
- };
-
- const detail = document.createElement('div');
- detail.className = 'source-overview-detail';
- if (articles.length === 0) {
- detail.innerHTML = 'Keine Artikel gefunden.
';
- } else {
- const fmtDate = (ts) => {
- if (!ts) return '—';
- try {
- const d = new Date(ts);
- if (isNaN(d.getTime())) return '—';
- return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit', timeZone: TIMEZONE })
- + ' '
- + d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', timeZone: TIMEZONE });
- } catch (e) { return '—'; }
- };
- const items = articles.map(a => {
- const nr = findNr(a);
- const numHtml = nr != null
- ? `[${UI.escape(String(nr))}] `
- : `— `;
- const dateStr = fmtDate(getDate(a));
- const headline = UI.escape(a.headline_de || a.headline || '(ohne Titel)');
- const inner = a.source_url
- ? `${headline} `
- : headline;
- return `
- ${numHtml}
- ${UI.escape(dateStr)}
- ${inner}
- `;
- }).join('');
- detail.innerHTML = ``;
- }
- el.insertAdjacentElement('afterend', detail);
- },
-
- /** Restliche Artikel seitenweise im Hintergrund nachladen und in _currentArticles mergen. */
- async _loadRemainingArticlesInBackground(incidentId) {
- const BATCH = 500;
- while (this.currentIncidentId === incidentId
- && this._currentArticlesLoaded < this._currentArticlesTotal) {
- let resp;
- try {
- resp = await API.getArticles(incidentId, { limit: BATCH, offset: this._currentArticlesLoaded });
- } catch (err) {
- console.warn('Hintergrund-Load Artikel fehlgeschlagen:', err);
- return;
- }
- if (this.currentIncidentId !== incidentId) return;
- const batch = (resp && resp.articles) ? resp.articles : (Array.isArray(resp) ? resp : []);
- if (!batch.length) break;
- this._currentArticles = (this._currentArticles || []).concat(batch);
- this._currentArticlesLoaded += batch.length;
- this.rerenderTimeline();
- // Kleiner Yield, damit das UI reaktiv bleibt
- await new Promise(r => setTimeout(r, 30));
- }
- },
-
- renderIncidentDetail(incident, articles, factchecks, snapshots, locations, categoryLabels) {
- // Header Strip
- { const _e = document.getElementById('incident-title'); if (_e) _e.textContent = incident.title; }
- { const _e = document.getElementById('incident-description'); if (_e) _e.textContent = incident.description || ''; }
-
- // Typ-Badge
- const typeBadge = document.getElementById('incident-type-badge');
- typeBadge.className = 'incident-type-badge ' + (incident.type === 'research' ? 'type-research' : 'type-adhoc');
- typeBadge.textContent = incident.type === 'research' ? 'Analyse' : 'Live';
-
- // Kachel-Label: 'Recherchebericht' fuer Recherche-Lagen, 'Lagebild' fuer Live-Monitoring
- const _tI18n = (k, fb) => (typeof T === 'function') ? T(k, fb) : fb;
- const _lbLabel = incident.type === 'research'
- ? _tI18n('tab.summary_report', 'Recherchebericht')
- : _tI18n('card.summary', 'Lagebild');
- const _cardTitle = document.querySelector('#panel-lagebild .card-title');
- if (_cardTitle) _cardTitle.textContent = _lbLabel;
- if (typeof LayoutManager !== 'undefined' && typeof LayoutManager.applyTypeLabels === 'function') {
- LayoutManager.applyTypeLabels(incident.type);
- }
- {
- const _nt = document.querySelector("#inc-notify-summary");
- if (_nt) {
- const _ns = _nt.closest("label")?.querySelector(".toggle-text");
- if (_ns) {
- _ns.textContent = incident.type === 'research'
- ? _tI18n('modal.notify.summary_research', 'Neuer Recherchebericht')
- : _tI18n('modal.notify.summary', 'Neues Lagebild');
- }
- }
- }
-
- // Archiv-Button Text
- this._updateArchiveButton(incident.status);
-
- // Ersteller anzeigen
- const creatorEl = document.getElementById('incident-creator');
- if (creatorEl) {
- creatorEl.textContent = (incident.created_by_username || '').split('@')[0];
- }
-
- // Delete-Button: nur Ersteller darf löschen
- const deleteBtn = document.getElementById('delete-incident-btn');
- const isCreator = incident.created_by_username === this._currentUsername;
- deleteBtn.disabled = !isCreator;
- deleteBtn.title = isCreator ? '' : `Nur ${(incident.created_by_username || '').split('@')[0]} kann diese Lage löschen`;
-
- // Zusammenfassung-Kachel + Lagebild-Kachel aufteilen
- const zusammenfassungText = document.getElementById('zusammenfassung-text');
- const summaryText = document.getElementById('summary-text');
- const zusammenfassungCard = document.getElementById('zusammenfassung-card');
- const zusammenfassungTitle = zusammenfassungCard ? zusammenfassungCard.querySelector('.card-title') : null;
-
- if (incident.type === 'research') {
- // Recherche: ZUSAMMENFASSUNG-Sektion aus Briefing extrahieren
- if (zusammenfassungTitle) zusammenfassungTitle.textContent = (typeof T === 'function') ? T('tab.summary_short', 'Zusammenfassung') : 'Zusammenfassung';
- if (incident.summary) {
- const { zusammenfassung, remaining } = UI.extractZusammenfassung(incident.summary);
- if (zusammenfassung) {
- if (zusammenfassungText) zusammenfassungText.innerHTML = UI.renderZusammenfassung(zusammenfassung, this._currentSources);
- if (zusammenfassungCard) zusammenfassungCard.style.display = '';
- summaryText.innerHTML = UI.renderSummary(remaining, this._currentSources, incident.type);
- } else {
- if (zusammenfassungText) zusammenfassungText.innerHTML = 'Zusammenfassung wird beim n\u00e4chsten Refresh generiert. ';
- if (zusammenfassungCard) zusammenfassungCard.style.display = '';
- summaryText.innerHTML = UI.renderSummary(incident.summary, this._currentSources, incident.type);
- }
- } else {
- if (zusammenfassungCard) zusammenfassungCard.style.display = 'none';
- summaryText.innerHTML = 'Noch keine Zusammenfassung. Klicke auf "Aktualisieren" um die Recherche zu starten. ';
- }
- } else {
- // Live-Monitoring (adhoc): Kachel zeigt "Neueste Entwicklungen" (max 8 Bullets mit Zeitstempel)
- if (zusammenfassungTitle) zusammenfassungTitle.textContent = (typeof T === 'function') ? T('tab.latest_developments', 'Neueste Entwicklungen') : 'Neueste Entwicklungen';
- if (zusammenfassungCard) zusammenfassungCard.style.display = '';
- const devText = (incident.latest_developments || '').trim();
- if (devText) {
- if (zusammenfassungText) zusammenfassungText.innerHTML = UI.renderLatestDevelopments(devText, this._currentSources);
- } else if (zusammenfassungText) {
- zusammenfassungText.innerHTML = 'Noch keine Entwicklungen erfasst. Wird beim n\u00e4chsten Refresh generiert. ';
- }
- if (incident.summary) {
- summaryText.innerHTML = UI.renderSummary(incident.summary, this._currentSources, incident.type);
- } else {
- summaryText.innerHTML = 'Noch kein Lagebild. Klicke auf "Aktualisieren" um die Recherche zu starten. ';
- }
- }
-
- // Meta (im Header-Strip) — relative Zeitangabe mit vollem Datum als Tooltip
- const updated = incident.updated_at ? parseUTC(incident.updated_at) : null;
- const metaUpdated = document.getElementById('meta-updated');
- if (updated) {
- const fullDate = `${updated.toLocaleDateString('de-DE', { timeZone: TIMEZONE })} ${updated.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', timeZone: TIMEZONE })}`;
- metaUpdated.textContent = `Stand: ${App._timeAgo(updated)}`;
- metaUpdated.title = fullDate;
- } else {
- metaUpdated.textContent = '';
- metaUpdated.title = '';
- }
-
- // Zeitstempel direkt im Lagebild-Card-Header
- const lagebildTs = document.getElementById('lagebild-timestamp');
- if (lagebildTs) {
- lagebildTs.textContent = updated
- ? `Stand: ${updated.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', timeZone: TIMEZONE })} ${updated.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', timeZone: TIMEZONE })} Uhr`
- : '';
- }
-
- { const _e = document.getElementById('meta-refresh-mode'); if (_e) {
- if (incident.refresh_mode === 'auto' && incident.refresh_start_time) {
- const intervalText = App._formatInterval(incident.refresh_interval);
- _e.textContent = `Auto alle ${intervalText} ab ${incident.refresh_start_time} Uhr`;
- } else if (incident.refresh_mode === 'auto') {
- _e.textContent = `Auto alle ${App._formatInterval(incident.refresh_interval)}`;
- } else {
- _e.textContent = 'Manuell';
- }
- } }
-
- // International-Badge
- const intlBadge = document.getElementById('intl-badge');
- if (intlBadge) {
- const isIntl = incident.international_sources !== false && incident.international_sources !== 0;
- intlBadge.className = 'intl-badge ' + (isIntl ? 'intl-yes' : 'intl-no');
- intlBadge.textContent = isIntl ? 'International' : 'Nur DE';
- }
-
- // Faktencheck
- const fcFilters = document.getElementById('fc-filters');
- const factcheckList = document.getElementById('factcheck-list');
- if (factchecks.length > 0) {
- fcFilters.innerHTML = UI.renderFactCheckFilters(factchecks);
- factcheckList.innerHTML = factchecks.map(fc => UI.renderFactCheck(fc)).join('');
- } else {
- fcFilters.innerHTML = '';
- factcheckList.innerHTML = 'Noch keine Fakten geprüft
';
- }
-
- // Quellenuebersicht wird aus dem Aggregat-Endpunkt (_loadSourcesSummary) gefuellt,
- // damit sie immer alle Artikel der Lage zeigt — unabhaengig von Paginierung.
- const sourceOverview = document.getElementById('source-overview-content');
- if (sourceOverview) {
- sourceOverview.innerHTML = 'Quellenübersicht wird geladen…
';
- }
- const _soStats = document.getElementById("source-overview-header-stats");
- if (_soStats) {
- const total = (this._currentArticlesTotal != null) ? this._currentArticlesTotal : articles.length;
- _soStats.textContent = total + " Artikel";
- }
-
- // Timeline - Artikel + Snapshots zwischenspeichern und rendern
- this._currentArticles = articles;
- this._currentSnapshots = snapshots || [];
- this._snapshotFullCache = new Map();
- this._currentIncidentType = incident.type;
-
- // Tab-Auswahl: gemerkt pro Lage (localStorage), Default = erster Tab
- if (typeof LayoutManager !== 'undefined' && typeof LayoutManager.restoreTabFor === 'function') {
- LayoutManager.restoreTabFor(incident.id);
- }
- this._timelineFilter = 'all';
- this._timelineRange = 'all';
- this._activeStripWindow = null;
- const _tsEl = document.getElementById('timeline-search'); if (_tsEl) _tsEl.value = '';
- document.querySelectorAll('.ht-filter-btn').forEach(btn => {
- const isActive = btn.dataset.filter === 'all';
- btn.classList.toggle('active', isActive);
- btn.setAttribute('aria-pressed', String(isActive));
- });
- document.querySelectorAll('.ht-range-btn').forEach(btn => {
- const isActive = btn.dataset.range === 'all';
- btn.classList.toggle('active', isActive);
- btn.setAttribute('aria-pressed', String(isActive));
- });
- this.rerenderTimeline();
- this._resizeTimelineTile();
-
- // Karte rendern
- UI.renderMap(locations || [], categoryLabels);
- },
-
- _collectEntries(filterType, searchTerm, range) {
- const type = this._currentIncidentType;
- const getArticleDate = (a) => (type === 'research' && a.published_at) ? a.published_at : a.collected_at;
-
- let entries = [];
-
- if (filterType === 'all' || filterType === 'articles') {
- let articles = this._currentArticles || [];
- if (searchTerm) {
- articles = articles.filter(a => {
- const text = `${a.headline || ''} ${a.headline_de || ''} ${a.source || ''} ${a.content_de || ''} ${a.content_original || ''}`.toLowerCase();
- return text.includes(searchTerm);
- });
- }
- articles.forEach(a => entries.push({ kind: 'article', data: a, timestamp: getArticleDate(a) || '' }));
- }
-
- if (filterType === 'all' || filterType === 'snapshots') {
- let snapshots = this._currentSnapshots || [];
- if (searchTerm) {
- // Suche erfolgt clientseitig ueber Preview (Snapshots-Liste enthaelt keinen Volltext mehr).
- // Die asynchrone Volltext-Server-Suche wird separat ausgeloest (rerenderTimeline).
- snapshots = snapshots.filter(s => (s.summary_preview || s.summary || '').toLowerCase().includes(searchTerm));
- }
- snapshots.forEach(s => entries.push({ kind: 'snapshot', data: s, timestamp: s.created_at || '' }));
- }
-
- if (range && range !== 'all') {
- const now = Date.now();
- const cutoff = range === '24h' ? now - 24 * 60 * 60 * 1000 : now - 7 * 24 * 60 * 60 * 1000;
- entries = entries.filter(e => new Date(e.timestamp || 0).getTime() >= cutoff);
- }
-
- return entries;
- },
-
- _updateTimelineCount(entries) {
- const articleCount = entries.filter(e => e.kind === 'article').length;
- const snapshotCount = entries.filter(e => e.kind === 'snapshot').length;
- const countEl = document.getElementById('article-count');
- if (!countEl) return;
- if (articleCount > 0 && snapshotCount > 0) {
- countEl.innerHTML = ` ${articleCount} Meldung${articleCount !== 1 ? 'en' : ''} + ${snapshotCount} Lagebericht${snapshotCount !== 1 ? 'e' : ''}`;
- } else if (articleCount > 0) {
- countEl.innerHTML = ` ${articleCount} Meldung${articleCount !== 1 ? 'en' : ''}`;
- } else if (snapshotCount > 0) {
- countEl.innerHTML = ` ${snapshotCount} Lagebericht${snapshotCount !== 1 ? 'e' : ''}`;
- } else {
- countEl.textContent = '0 Meldungen';
- }
- },
-
- debouncedRerenderTimeline() {
- clearTimeout(this._timelineSearchTimer);
- this._timelineSearchTimer = setTimeout(() => this.rerenderTimeline(), 250);
- },
-
- /** Heatmap-Strip oben + vertikaler Newsfeed-Stream darunter.
- * Klick auf Heatmap-Balken: Stream filtert auf das Zeitfenster (aktive Balken hervorgehoben).
- */
- rerenderTimeline() {
- const container = document.getElementById('timeline');
- if (!container) return;
- const searchTerm = (document.getElementById('timeline-search')?.value || '').toLowerCase();
- const filterType = this._timelineFilter;
- const range = this._timelineRange;
-
- let entries = this._collectEntries(filterType, searchTerm, range);
- this._updateTimelineCount(entries);
-
- // Strip nutzt IMMER alle Eintraege im Range (unabhaengig von Filter/Search/Strip-Window)
- const stripEntries = this._collectEntries('all', '', range);
- stripEntries.sort((a, b) => new Date(a.timestamp || 0) - new Date(b.timestamp || 0));
-
- // Wenn ein Heatmap-Balken aktiv ist: Stream zusaetzlich auf dieses Zeitfenster filtern
- const win = this._activeStripWindow;
- if (win && entries.length > 0) {
- entries = entries.filter(e => {
- const ts = new Date(e.timestamp || 0).getTime();
- return ts >= win.start && ts < win.end;
- });
- }
-
- let html = '';
- if (stripEntries.length > 0) {
- html += this._renderTimelineStrip(stripEntries);
- }
-
- // Banner mit aktivem Filter
- if (win) {
- html += `
- ▼
- Gefiltert auf ${UI.escape(win.label)} · ${entries.length} Eintr${entries.length === 1 ? 'ag' : 'äge'}
- Filter aufheben
-
`;
- }
-
- html += '
';
- if (entries.length === 0) {
- html += win
- ? '
Keine Einträge in diesem Zeitfenster.
'
- : (searchTerm || range !== 'all')
- ? '
Keine Einträge im gewählten Zeitraum.
'
- : '
Noch keine Meldungen. Starte eine Recherche mit "Aktualisieren".
';
- } else {
- html += this._renderVerticalStream(entries);
- }
- html += '
';
- html += '
';
- container.innerHTML = html;
- },
-
- /** Granularitaets-Heuristik fuer den Newsfeed: Stunden bei kurzen Spannen, sonst Tage. */
- _calcGranularity(entries) {
- if (!entries || entries.length < 2) return 'day';
- const ts = entries.map(e => new Date(e.timestamp || 0).getTime()).filter(t => t > 0);
- if (ts.length < 2) return 'day';
- const span = Math.max(...ts) - Math.min(...ts);
- if (span <= 48 * 60 * 60 * 1000) return 'hour';
- return 'day';
- },
-
- /** Vertikaler Stream: Datums-Trennzeilen + Lagebericht-Sektionen + Meldungen. */
- _renderVerticalStream(entries) {
- if (!entries || entries.length === 0) {
- return 'Keine Einträge.
';
- }
- // Neueste oben
- const sorted = [...entries].sort((a, b) => new Date(b.timestamp || 0) - new Date(a.timestamp || 0));
- const granularity = this._calcGranularity(sorted);
- const groups = this._groupByTimePeriod(sorted, granularity);
-
- let html = '';
- groups.forEach(g => {
- const groupId = 'vt-grp-' + g.key.replace(/[^a-z0-9]/gi, '-');
- html += `
`;
- html += `
${UI.escape(g.label)}
`;
- html += this._renderTimeGroupEntries(g.entries, this._currentIncidentType);
- html += `
`;
- });
- html += '
';
- return html;
- },
-
- /* ======= Quanti-Strip ======= */
- _stripGranularity(stripEntries) {
- if (stripEntries.length < 2) return 'day';
- const ts = stripEntries.map(e => new Date(e.timestamp || 0).getTime()).filter(t => t > 0);
- if (ts.length < 2) return 'day';
- const span = Math.max(...ts) - Math.min(...ts);
- const DAY = 86400000;
- if (span <= 2 * DAY) return 'hour';
- if (span <= 60 * DAY) return 'day';
- if (span <= 365 * DAY) return 'week';
- return 'month';
- },
-
- _buildStripBuckets(stripEntries, granularity) {
- if (stripEntries.length === 0) return [];
- const ts = stripEntries.map(e => new Date(e.timestamp || 0).getTime()).filter(t => t > 0);
- if (ts.length === 0) return [];
- const minTs = Math.min(...ts);
- const maxTs = Math.max(...ts);
-
- // Bucket-Start fuer minTs ermitteln
- const minDate = new Date(minTs);
- const tzMin = _tz(minDate);
- let firstStart;
- let stepMs;
- if (granularity === 'hour') {
- firstStart = new Date(tzMin.year, tzMin.month, tzMin.date, tzMin.hours).getTime();
- stepMs = 3600000;
- } else if (granularity === 'day') {
- firstStart = new Date(tzMin.year, tzMin.month, tzMin.date).getTime();
- stepMs = 86400000;
- } else if (granularity === 'week') {
- const dow = (minDate.getDay() + 6) % 7; // 0=Mo
- firstStart = new Date(tzMin.year, tzMin.month, tzMin.date - dow).getTime();
- stepMs = 7 * 86400000;
- } else {
- firstStart = new Date(tzMin.year, tzMin.month, 1).getTime();
- stepMs = null; // dynamisch (Monatsgrenzen)
- }
-
- const buckets = [];
- const fmt = (t) => {
- const d = new Date(t);
- if (granularity === 'hour') return d.toLocaleString('de-DE', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit', timeZone: TIMEZONE });
- if (granularity === 'day') return d.toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: 'short', timeZone: TIMEZONE });
- if (granularity === 'week') return 'Woche ab ' + d.toLocaleDateString('de-DE', { day: '2-digit', month: 'short', timeZone: TIMEZONE });
- return d.toLocaleDateString('de-DE', { month: 'long', year: 'numeric', timeZone: TIMEZONE });
- };
-
- if (granularity === 'month') {
- let d = new Date(firstStart);
- while (d.getTime() <= maxTs && buckets.length < 240) {
- const start = d.getTime();
- const next = new Date(d.getFullYear(), d.getMonth() + 1, 1).getTime();
- buckets.push({ start, end: next, label: fmt(start), articles: 0, snapshots: 0 });
- d = new Date(next);
- }
- } else {
- for (let t = firstStart; t <= maxTs && buckets.length < 240; t += stepMs) {
- buckets.push({ start: t, end: t + stepMs, label: fmt(t), articles: 0, snapshots: 0 });
- }
- }
-
- // Eintraege zaehlen
- stripEntries.forEach(e => {
- const ets = new Date(e.timestamp || 0).getTime();
- // Linear-Suche, da Buckets sortiert; bei vielen Buckets ggf. Binary
- for (let i = 0; i < buckets.length; i++) {
- if (ets >= buckets[i].start && ets < buckets[i].end) {
- if (e.kind === 'article') buckets[i].articles++;
- else if (e.kind === 'snapshot') buckets[i].snapshots++;
- break;
- }
- }
- });
-
- return buckets;
- },
-
- _renderTimelineStrip(stripEntries) {
- const granularity = this._stripGranularity(stripEntries);
- const buckets = this._buildStripBuckets(stripEntries, granularity);
- if (buckets.length === 0) return '';
-
- const maxCount = Math.max(1, ...buckets.map(b => b.articles));
- const win = this._activeStripWindow;
-
- let html = '';
- html += '
';
- buckets.forEach(b => {
- const intensity = b.articles > 0 ? Math.min(1, b.articles / maxCount) : 0;
- const cls = ['ht-strip-cell'];
- if (b.snapshots > 0) cls.push('has-snapshot');
- if (b.articles === 0 && b.snapshots === 0) cls.push('empty');
- if (win && win.start === b.start && win.end === b.end) cls.push('active');
- const tip = `${b.label}: ${b.articles} Meldung${b.articles === 1 ? '' : 'en'}` +
- (b.snapshots > 0 ? ` + ${b.snapshots} Lagebericht${b.snapshots === 1 ? '' : 'e'}` : '');
- // data-Attribute statt JSON-String im onclick-Inline (vermeidet Quote-Konflikte bei Labels mit Komma/Anführungszeichen)
- html += `
`;
- });
- html += '
';
-
- // Wenige Datums-Labels unter dem Strip
- const labelCount = Math.min(buckets.length, 6);
- const stride = Math.max(1, Math.floor(buckets.length / labelCount));
- const labelTexts = [];
- for (let i = 0; i < buckets.length; i += stride) {
- const b = buckets[i];
- const d = new Date(b.start);
- let txt;
- if (granularity === 'hour') txt = d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', timeZone: TIMEZONE });
- else if (granularity === 'day') txt = d.toLocaleDateString('de-DE', { day: '2-digit', month: 'short', timeZone: TIMEZONE });
- else if (granularity === 'week') txt = 'KW ' + d.toLocaleDateString('de-DE', { day: '2-digit', month: 'short', timeZone: TIMEZONE });
- else txt = d.toLocaleDateString('de-DE', { month: 'short', year: '2-digit', timeZone: TIMEZONE });
- labelTexts.push({ text: txt, idx: i });
- }
- if (labelTexts.length) {
- html += '
';
- const seen = new Set(labelTexts.map(l => l.idx));
- for (let i = 0; i < buckets.length; i++) {
- if (seen.has(i)) {
- const t = labelTexts.find(l => l.idx === i).text;
- html += `
${UI.escape(t)}
`;
- } else {
- html += '
';
- }
- }
- html += '
';
- }
- html += '
';
- return html;
- },
-
- setTimelineFilter(filter) {
- this._timelineFilter = filter;
- this._activeStripWindow = null;
- document.querySelectorAll('.ht-filter-btn').forEach(btn => {
- const isActive = btn.dataset.filter === filter;
- btn.classList.toggle('active', isActive);
- btn.setAttribute('aria-pressed', String(isActive));
- });
- this.rerenderTimeline();
- },
-
- setTimelineRange(range) {
- this._timelineRange = range;
- this._activeStripWindow = null;
- document.querySelectorAll('.ht-range-btn').forEach(btn => {
- const isActive = btn.dataset.range === range;
- btn.classList.toggle('active', isActive);
- btn.setAttribute('aria-pressed', String(isActive));
- });
- this.rerenderTimeline();
- },
-
- /** Robuster Click-Handler fuer Heatmap-Cells (vermeidet Quote-Konflikte). */
- handleStripClick(el) {
- if (!el) return;
- const start = parseInt(el.dataset.start, 10);
- const end = parseInt(el.dataset.end, 10);
- const label = el.dataset.label || '';
- if (!isNaN(start) && !isNaN(end)) {
- this.openTimelineWindow(start, end, label);
- }
- },
-
- /** Klick auf Heatmap-Balken: Stream auf dieses Zeitfenster filtern.
- * Zweiter Klick auf denselben Balken hebt den Filter auf.
- */
- openTimelineWindow(startMs, endMs, label) {
- const win = this._activeStripWindow;
- if (win && win.start === startMs && win.end === endMs) {
- this._activeStripWindow = null;
- } else {
- this._activeStripWindow = { start: startMs, end: endMs, label: label || '' };
- }
- this.rerenderTimeline();
- },
-
- /** Strip-Filter aufheben (z.B. via Banner-Button). */
- clearStripWindow() {
- this._activeStripWindow = null;
- this.rerenderTimeline();
- },
-
- _resizeTimelineTile() {
- // Tab-Modus: Kein internes Resize noetig, Panel waechst mit Inhalt.
- // Wir scrollen lediglich ein offenes Detail in den sichtbaren Bereich.
- requestAnimationFrame(() => { requestAnimationFrame(() => {
- const card = document.querySelector('.timeline-card');
- if (!card) return;
- const cardBottom = card.getBoundingClientRect().bottom;
- const viewBottom = window.innerHeight;
- if (cardBottom > viewBottom) {
- window.scrollBy({ top: cardBottom - viewBottom + 16, behavior: document.documentElement.dataset.a11yMotion ? 'instant' : 'smooth' });
- }
- }); });
- },
-
- _buildFullVerticalTimeline(filterType, searchTerm) {
- let entries = this._collectEntries(filterType, searchTerm);
- if (entries.length === 0) {
- return 'Keine Einträge.
';
- }
-
- entries.sort((a, b) => new Date(b.timestamp || 0) - new Date(a.timestamp || 0));
- const granularity = this._calcGranularity(entries);
- const groups = this._groupByTimePeriod(entries, granularity);
-
- let html = '';
- groups.forEach(g => {
- html += `
`;
- html += `
${UI.escape(g.label)}
`;
- html += this._renderTimeGroupEntries(g.entries, this._currentIncidentType);
- html += `
`;
- });
- html += '
';
- return html;
- },
-
- /**
- * Einträge nach Zeitperiode gruppieren.
- */
- _groupByTimePeriod(entries, granularity) {
- const np = _tz(new Date());
- const todayKey = `${np.year}-${np.month}-${np.date}`;
- const yp = _tz(new Date(Date.now() - 86400000));
- const yesterdayKey = `${yp.year}-${yp.month}-${yp.date}`;
-
- const groups = [];
- let currentGroup = null;
-
- entries.forEach(entry => {
- const d = entry.timestamp ? new Date(entry.timestamp) : null;
- let key, label;
-
- if (!d || isNaN(d.getTime())) {
- key = 'unknown';
- label = 'Unbekannt';
- } else if (granularity === 'hour') {
- const ep = _tz(d);
- key = `${ep.year}-${ep.month}-${ep.date}-${ep.hours}`;
- label = `${ep.hours.toString().padStart(2, '0')}:00 Uhr`;
- } else {
- const ep = _tz(d);
- key = `${ep.year}-${ep.month}-${ep.date}`;
- if (key === todayKey) {
- label = 'Heute';
- } else if (key === yesterdayKey) {
- label = 'Gestern';
- } else {
- label = d.toLocaleDateString('de-DE', { weekday: 'short', day: 'numeric', month: 'short', timeZone: TIMEZONE });
- }
- }
-
- if (!currentGroup || currentGroup.key !== key) {
- currentGroup = { key, label, entries: [] };
- groups.push(currentGroup);
- }
- currentGroup.entries.push(entry);
- });
-
- return groups;
- },
-
- /**
- * Entries einer Zeitgruppe rendern, mit Cluster-Erkennung.
- */
- _renderTimeGroupEntries(entries, type) {
- // Cluster-Erkennung: ≥4 Artikel pro Minute
- const minuteCounts = {};
- entries.forEach(e => {
- if (e.kind === 'article') {
- const mk = this._getMinuteKey(e.timestamp);
- minuteCounts[mk] = (minuteCounts[mk] || 0) + 1;
- }
- });
-
- const minuteRendered = {};
- let html = '';
-
- entries.forEach(e => {
- if (e.kind === 'snapshot') {
- html += this._renderSnapshotEntry(e.data);
- } else {
- const mk = this._getMinuteKey(e.timestamp);
- const isCluster = minuteCounts[mk] >= 4;
- const isFirstInCluster = isCluster && !minuteRendered[mk];
- if (isFirstInCluster) minuteRendered[mk] = true;
- html += this._renderArticleEntry(e.data, type, isFirstInCluster ? minuteCounts[mk] : 0);
- }
- });
-
- return html;
- },
-
- /**
- * Artikel-Eintrag für den Zeitstrahl rendern.
- */
- _renderArticleEntry(article, type, clusterCount) {
- const dateField = (type === 'research' && article.published_at)
- ? article.published_at : article.collected_at;
- const time = dateField
- ? (parseUTC(dateField) || new Date(dateField)).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', timeZone: TIMEZONE })
- : '--:--';
-
- const headline = article.headline_de || article.headline;
- const sourceUrl = article.source_url
- ? `${UI.escape(article.source)} `
- : UI.escape(article.source);
-
- const langBadge = article.language && article.language !== 'de'
- ? `${article.language.toUpperCase()} ` : '';
-
- const clusterBadge = clusterCount > 0
- ? `${clusterCount} ` : '';
-
- const content = article.content_de || article.content_original || '';
- const hasContent = content.length > 0;
-
- let detailHtml = '';
- if (hasContent) {
- const truncated = content.length > 400 ? content.substring(0, 400) + '...' : content;
- detailHtml = `
-
${UI.escape(truncated)}
- ${article.source_url ? `
Artikel öffnen → ` : ''}
-
`;
- }
-
- return `
-
- ${time}
- ${sourceUrl}
- ${langBadge}${clusterBadge}
-
-
${UI.escape(headline)}
- ${detailHtml}
-
`;
- },
-
- /**
- * Snapshot/Lagebericht-Eintrag für den Zeitstrahl rendern.
- * Volltext + sources_json werden erst beim Aufklappen lazy nachgeladen.
- */
- _renderSnapshotEntry(snapshot) {
- const time = snapshot.created_at
- ? (parseUTC(snapshot.created_at) || new Date(snapshot.created_at)).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', timeZone: TIMEZONE })
- : '--:--';
-
- const stats = [];
- if (snapshot.article_count) stats.push(`${snapshot.article_count} Artikel`);
- if (snapshot.fact_check_count) stats.push(`${snapshot.fact_check_count} Fakten`);
- const statsText = stats.join(', ');
-
- // Vorschau: erste 200 Zeichen aus summary_preview (vom Server gekuerzt) oder Fallback summary
- const previewText = snapshot.summary_preview || snapshot.summary || '';
- const preview = previewText.length > 200 ? previewText.substring(0, 200) + '...' : previewText;
-
- // Volltext aus Cache (falls bereits geladen), sonst Platzhalter fuer Lazy-Load
- const cached = this._snapshotFullCache && this._snapshotFullCache.get(snapshot.id);
- const detailHtml = cached
- ? UI.renderSummary(cached.summary, cached.sources_json, this._currentIncidentType)
- : 'Lagebericht wird geladen…
';
- const loadedAttr = cached ? ' data-loaded="yes"' : '';
-
- return `
-
-
${UI.escape(preview)}
-
${detailHtml}
-
`;
- },
-
- /**
- * Volltext eines Snapshots bei Bedarf nachladen und in das DOM einsetzen.
- * Ergebnis wird in _snapshotFullCache gecacht.
- */
- async lazyLoadSnapshotDetail(el) {
- if (!el || el.dataset.loaded === 'yes' || el.dataset.loaded === 'loading') return;
- const snapId = parseInt(el.dataset.snapshotId || '0', 10);
- if (!snapId || !this.currentIncidentId) return;
- el.dataset.loaded = 'loading';
- try {
- let snap = this._snapshotFullCache.get(snapId);
- if (!snap) {
- snap = await API.getSnapshot(this.currentIncidentId, snapId);
- this._snapshotFullCache.set(snapId, snap);
- }
- const detailEl = el.querySelector('.vt-snapshot-detail');
- if (detailEl) {
- detailEl.innerHTML = UI.renderSummary(snap.summary, snap.sources_json, this._currentIncidentType);
- }
- el.dataset.loaded = 'yes';
- // Nach dem Laden die Timeline-Kachel an neue Hoehe anpassen
- if (el.classList.contains('expanded')) this._resizeTimelineTile();
- } catch (err) {
- console.error('Snapshot-Volltext laden fehlgeschlagen:', err);
- el.dataset.loaded = '';
- const detailEl = el.querySelector('.vt-snapshot-detail');
- if (detailEl) detailEl.innerHTML = 'Fehler beim Laden des Lageberichts.
';
- }
- },
-
- /**
- * Timeline-Eintrag auf-/zuklappen (mutual-exclusive pro Zeitgruppe).
- */
- toggleTimelineEntry(el) {
- const container = el.closest('.ht-detail-content') || el.closest('.vt-time-group');
- if (container) {
- container.querySelectorAll('.vt-entry.expanded').forEach(item => {
- if (item !== el) item.classList.remove('expanded');
- });
- }
- el.classList.toggle('expanded');
- if (el.classList.contains('expanded')) {
- // Snapshots: Volltext lazy nachladen (nur wenn noch nicht geladen)
- if (el.classList.contains('vt-snapshot') && el.dataset.snapshotId) {
- this.lazyLoadSnapshotDetail(el);
- }
- requestAnimationFrame(() => {
- var scrollParent = el.closest('.ht-detail-content');
- if (scrollParent && el.classList.contains('vt-snapshot')) {
- scrollParent.scrollTo({ top: 0, behavior: document.documentElement.dataset.a11yMotion ? 'instant' : 'smooth' });
- } else {
- el.scrollIntoView({ behavior: document.documentElement.dataset.a11yMotion ? 'instant' : 'smooth', block: 'nearest' });
- }
- });
- }
- // Timeline-Kachel an Inhalt anpassen
- this._resizeTimelineTile();
- },
-
- /**
- * Minutenschlüssel für Cluster-Erkennung.
- */
- _getMinuteKey(timestamp) {
- if (!timestamp) return 'none';
- const d = new Date(timestamp);
- const p = _tz(d);
- return `${p.year}-${p.month}-${p.date}-${p.hours}-${p.minutes}`;
- },
-
- // === Event Handlers ===
-
- _getFormData() {
- const value = parseInt(document.getElementById('inc-refresh-value').value) || 15;
- const unit = parseInt(document.getElementById('inc-refresh-unit').value) || 1;
- const interval = Math.max(10, Math.min(10080, value * unit));
- return {
- title: document.getElementById('inc-title').value.trim(),
- description: document.getElementById('inc-description').value.trim() || null,
- type: document.getElementById('inc-type').value,
- refresh_mode: document.getElementById('inc-refresh-mode').value,
- refresh_interval: interval,
- refresh_start_time: document.getElementById('inc-refresh-mode').value === 'auto'
- ? document.getElementById('inc-refresh-starttime').value || null
- : null,
- retention_days: parseInt(document.getElementById('inc-retention').value) || 0,
- international_sources: document.getElementById('inc-international').checked,
- include_telegram: document.getElementById('inc-telegram').checked,
- visibility: document.getElementById('inc-visibility').checked ? 'public' : 'private',
- };
- },
-
- _clearFormErrors(formEl) {
- formEl.querySelectorAll('.form-error').forEach(el => el.remove());
- formEl.querySelectorAll('[aria-invalid]').forEach(el => {
- el.removeAttribute('aria-invalid');
- el.removeAttribute('aria-describedby');
- });
- },
-
- _showFieldError(field, message) {
- field.setAttribute('aria-invalid', 'true');
- const errorId = field.id + '-error';
- field.setAttribute('aria-describedby', errorId);
- const errorEl = document.createElement('div');
- errorEl.className = 'form-error';
- errorEl.id = errorId;
- errorEl.setAttribute('role', 'alert');
- errorEl.textContent = message;
- field.parentNode.appendChild(errorEl);
- },
-
- async handleFormSubmit(e) {
- e.preventDefault();
- const submitBtn = document.getElementById('modal-new-submit');
- const form = document.getElementById('new-incident-form');
- this._clearFormErrors(form);
-
- // Validierung
- const titleField = document.getElementById('inc-title');
- if (!titleField.value.trim()) {
- this._showFieldError(titleField, 'Bitte einen Titel eingeben.');
- titleField.focus();
- return;
- }
-
- submitBtn.disabled = true;
-
- try {
- const data = this._getFormData();
-
- if (this._editingIncidentId) {
- // Edit-Modus: ID sichern bevor closeModal sie löscht
- const editId = this._editingIncidentId;
- await API.updateIncident(editId, data);
-
- // E-Mail-Subscription speichern
- await API.updateSubscription(editId, {
- notify_email_summary: document.getElementById('inc-notify-summary').checked,
- notify_email_new_articles: document.getElementById('inc-notify-new-articles').checked,
- notify_email_status_change: document.getElementById('inc-notify-status-change').checked,
- });
-
- closeModal('modal-new');
- await this.loadIncidents();
- await this.loadIncidentDetail(editId);
- UI.showToast('Lage aktualisiert.', 'success');
- } else {
- // Create-Modus
- const incident = await API.createIncident(data);
-
- // E-Mail-Subscription speichern
- await API.updateSubscription(incident.id, {
- notify_email_summary: document.getElementById('inc-notify-summary').checked,
- notify_email_new_articles: document.getElementById('inc-notify-new-articles').checked,
- notify_email_status_change: document.getElementById('inc-notify-status-change').checked,
- });
-
- closeModal('modal-new');
-
- await this.loadIncidents();
-
- // Refresh-Status VOR selectIncident setzen, damit selectIncident
- // beim Oeffnen sofort Blur + Aktions-Lock setzt (statt sie erst
- // per WebSocket-Nachricht spaeter wieder zu aktivieren — dazwischen
- // war der Fallinhalt kurzzeitig unblurred und klickbar).
- this._refreshingIncidents.add(incident.id);
- UI._progressState[incident.id] = {
- step: 'queued', isFirst: true, startTime: null, minimized: false,
- };
-
- await this.selectIncident(incident.id);
-
- this._updateRefreshButton(true);
- await API.refreshIncident(incident.id);
- UI.showToast(`Lage "${incident.title}" angelegt. Recherche gestartet.`, 'success');
- }
- } catch (err) {
- UI.showToast('Fehler: ' + err.message, 'error');
- } finally {
- submitBtn.disabled = false;
- this._editingIncidentId = null;
- }
- },
-
-async generateDescription() {
- const title = document.getElementById('inc-title').value.trim();
- const description = document.getElementById('inc-description').value.trim();
- const type = document.getElementById('inc-type').value;
- const btn = document.getElementById('btn-enhance-description');
- const btnText = document.getElementById('enhance-btn-text');
- const spinner = document.getElementById('enhance-spinner');
- const textarea = document.getElementById('inc-description');
-
- if (title.length < 3 || !btn) return;
-
- // Vorherigen Request abbrechen falls noch aktiv
- if (this._enhanceController) this._enhanceController.abort();
- this._enhanceController = new AbortController();
-
- btn.disabled = true;
- btnText.textContent = (typeof T === 'function') ? T('modal.new_incident.enhance_loading', 'Wird generiert...') : 'Wird generiert...';
- spinner.style.display = '';
- textarea.readOnly = true;
- textarea.classList.add('textarea--loading');
-
- try {
- const result = await API.enhanceDescription(title, description || null, type, this._enhanceController.signal);
- textarea.value = result.description;
- _autoResizeTextarea(textarea);
- } catch (err) {
- if (err.name === 'AbortError') {
- // still
- } else {
- let msg = (typeof T === 'function') ? T('enhance.error_default', 'Beschreibung konnte nicht generiert werden') : 'Beschreibung konnte nicht generiert werden';
- if (err.status === 503) msg = (typeof T === 'function') ? T('enhance.error_unavailable', 'KI-Zugang aktuell nicht verfügbar. Bitte Administrator kontaktieren.') : 'KI-Zugang aktuell nicht verfügbar. Bitte Administrator kontaktieren.';
- else if (err.status === 429) msg = (typeof T === 'function') ? T('enhance.error_busy', 'KI ist gerade ausgelastet. Bitte kurz warten und erneut versuchen.') : 'KI ist gerade ausgelastet. Bitte kurz warten und erneut versuchen.';
- else if (err.status === 504) msg = (typeof T === 'function') ? T('enhance.error_timeout', 'KI antwortet gerade nicht. Bitte erneut versuchen.') : 'KI antwortet gerade nicht. Bitte erneut versuchen.';
- else if (err.status === 403) msg = err.detail || 'Zugriff verweigert.';
- UI.showToast(msg, 'error');
- }
- } finally {
- btnText.textContent = (typeof T === 'function') ? T('modal.new_incident.enhance', 'Beschreibung generieren') : 'Beschreibung generieren';
- spinner.style.display = 'none';
- btn.disabled = title.length < 3;
- textarea.readOnly = false;
- textarea.classList.remove('textarea--loading');
- this._enhanceController = null;
- }
- },
-
-async handleRefresh() {
- if (!this.currentIncidentId) return;
- if (this._refreshingIncidents.has(this.currentIncidentId)) {
- UI.showToast('Aktualisierung wurde bereits gestartet und ist in Bearbeitung.', 'info');
- return;
- }
- try {
- this._refreshingIncidents.add(this.currentIncidentId);
- this._updateRefreshButton(true);
- // showProgress called via handleStatusUpdate
- const result = await API.refreshIncident(this.currentIncidentId);
- // Pipeline auf "pending" setzen, damit alte gruene Haekchen nicht
- // faelschlich "schon fertig" suggerieren waehrend die Lage in der Queue steht
- if (typeof Pipeline !== 'undefined' && Pipeline.beginQueue) {
- Pipeline.beginQueue(this.currentIncidentId);
- }
- if (result && result.status === 'skipped') {
- UI.showToast('Aktualisierung ist in der Warteschlange und wird ausgefuehrt, sobald die aktuelle Recherche abgeschlossen ist.', 'info');
- } else {
- UI.showToast('Aktualisierung gestartet.', 'success');
- var _inc2 = this.incidents.find(function(i) { return i.id === this.currentIncidentId; }.bind(this));
- UI.showProgress('queued', {}, this.currentIncidentId, _inc2 && !_inc2.has_summary);
- }
- } catch (err) {
- this._refreshingIncidents.delete(this.currentIncidentId);
- this._updateRefreshButton(false);
- UI.hideProgress();
- UI.showToast('Fehler: ' + err.message, 'error');
- }
- },
-
- _geoparsePolling: null,
-
- async triggerGeoparse() {
- if (!this.currentIncidentId) return;
- const btn = document.getElementById('geoparse-btn');
- if (btn) { btn.disabled = true; btn.textContent = 'Wird gestartet...'; }
- try {
- const result = await API.triggerGeoparse(this.currentIncidentId);
- if (result.status === 'done') {
- UI.showToast(result.message, 'info');
- if (btn) { btn.disabled = false; btn.textContent = 'Orte erkennen'; }
- return;
- }
- UI.showToast(result.message, 'info');
- this._pollGeoparse(this.currentIncidentId);
- } catch (err) {
- UI.showToast('Geoparsing fehlgeschlagen: ' + err.message, 'error');
- if (btn) { btn.disabled = false; btn.textContent = 'Orte erkennen'; }
- }
- },
-
- _pollGeoparse(incidentId) {
- if (this._geoparsePolling) clearInterval(this._geoparsePolling);
- const btn = document.getElementById('geoparse-btn');
- this._geoparsePolling = setInterval(async () => {
- try {
- const st = await API.getGeoparseStatus(incidentId);
- if (st.status === 'running') {
- if (btn) btn.textContent = `${st.processed}/${st.total} Artikel...`;
- } else {
- clearInterval(this._geoparsePolling);
- this._geoparsePolling = null;
- if (btn) { btn.disabled = false; btn.textContent = 'Orte erkennen'; }
- if (st.status === 'done' && st.locations > 0) {
- UI.showToast(`${st.locations} Orte aus ${st.processed} Artikeln erkannt`, 'success');
- const locResp = await API.getLocations(incidentId).catch(() => []);
- let locs, catLabels;
- if (Array.isArray(locResp)) { locs = locResp; catLabels = null; }
- else if (locResp && locResp.locations) { locs = locResp.locations; catLabels = locResp.category_labels || null; }
- else { locs = []; catLabels = null; }
- UI.renderMap(locs, catLabels);
- } else if (st.status === 'done') {
- UI.showToast('Keine neuen Orte gefunden', 'info');
- } else if (st.status === 'error') {
- UI.showToast('Geoparsing fehlgeschlagen: ' + (st.error || ''), 'error');
- }
- }
- } catch {
- clearInterval(this._geoparsePolling);
- this._geoparsePolling = null;
- if (btn) { btn.disabled = false; btn.textContent = 'Orte erkennen'; }
- }
- }, 3000);
- },
-
- _formatInterval(minutes) {
- if (minutes >= 10080 && minutes % 10080 === 0) {
- const w = minutes / 10080;
- return w === 1 ? '1 Woche' : `${w} Wochen`;
- }
- if (minutes >= 1440 && minutes % 1440 === 0) {
- const d = minutes / 1440;
- return d === 1 ? '1 Tag' : `${d} Tage`;
- }
- if (minutes >= 60 && minutes % 60 === 0) {
- const h = minutes / 60;
- return h === 1 ? '1 Stunde' : `${h} Stunden`;
- }
- return `${minutes} Min.`;
- },
-
- _setIntervalFields(minutes) {
- let value, unit;
- if (minutes >= 10080 && minutes % 10080 === 0) {
- value = minutes / 10080; unit = '10080';
- } else if (minutes >= 1440 && minutes % 1440 === 0) {
- value = minutes / 1440; unit = '1440';
- } else if (minutes >= 60 && minutes % 60 === 0) {
- value = minutes / 60; unit = '60';
- } else {
- value = minutes; unit = '1';
- }
- const input = document.getElementById('inc-refresh-value');
- input.value = value;
- input.min = unit === '1' ? 10 : 1;
- { const _e = document.getElementById('inc-refresh-unit'); if (_e) _e.value = unit; }
- },
-
- _refreshHistoryOpen: false,
-
- toggleRefreshHistory() {
- if (this._refreshHistoryOpen) {
- this.closeRefreshHistory();
- } else {
- this._openRefreshHistory();
- }
- },
-
- async _openRefreshHistory() {
- if (!this.currentIncidentId) return;
- const popover = document.getElementById('refresh-history-popover');
- if (!popover) return;
-
- this._refreshHistoryOpen = true;
- popover.style.display = 'flex';
-
- // Lade Refresh-Log
- const list = document.getElementById('refresh-history-list');
- list.innerHTML = 'Lade...
';
-
- try {
- const logs = await API.getRefreshLog(this.currentIncidentId, 20);
- this._renderRefreshHistory(logs);
- } catch (e) {
- list.innerHTML = 'Fehler beim Laden
';
- }
-
- // Outside-Click Listener
- setTimeout(() => {
- const handler = (e) => {
- if (!popover.contains(e.target) && !e.target.closest('.meta-updated-link')) {
- this.closeRefreshHistory();
- document.removeEventListener('click', handler);
- }
- };
- document.addEventListener('click', handler);
- popover._outsideHandler = handler;
- }, 0);
- },
-
- closeRefreshHistory() {
- this._refreshHistoryOpen = false;
- const popover = document.getElementById('refresh-history-popover');
- if (popover) {
- popover.style.display = 'none';
- if (popover._outsideHandler) {
- document.removeEventListener('click', popover._outsideHandler);
- delete popover._outsideHandler;
- }
- }
- },
-
- _renderRefreshHistory(logs) {
- const list = document.getElementById('refresh-history-list');
- if (!list) return;
-
- if (!logs || logs.length === 0) {
- list.innerHTML = 'Noch keine Refreshes durchgeführt
';
- return;
- }
-
- list.innerHTML = logs.map(log => {
- const started = parseUTC(log.started_at) || new Date(log.started_at);
- const timeStr = started.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', timeZone: TIMEZONE }) + ' ' +
- started.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', timeZone: TIMEZONE });
-
- let detail = '';
- if (log.status === 'completed') {
- detail = `${log.articles_found} Artikel`;
- if (log.duration_seconds != null) {
- detail += ` in ${this._formatDuration(log.duration_seconds)}`;
- }
- } else if (log.status === 'running') {
- detail = 'Läuft...';
- } else if (log.status === 'error') {
- detail = '';
- }
-
- const retryInfo = log.retry_count > 0 ? ` (Versuch ${log.retry_count + 1})` : '';
- const errorHtml = log.error_message
- ? `${log.error_message}
`
- : '';
-
- return `
-
-
-
${timeStr}${retryInfo}
- ${detail ? `
${detail}
` : ''}
- ${errorHtml}
-
-
${log.trigger_type === 'auto' ? 'Auto' : 'Manuell'}
-
`;
- }).join('');
- },
-
- _formatDuration(seconds) {
- if (seconds == null) return '';
- if (seconds < 60) return `${Math.round(seconds)}s`;
- const m = Math.floor(seconds / 60);
- const s = Math.round(seconds % 60);
- return s > 0 ? `${m}m ${s}s` : `${m}m`;
- },
-
- _timeAgo(date) {
- if (!date) return '';
- const now = new Date();
- const diff = Math.floor((now - date) / 1000);
- if (diff < 60) return 'gerade eben';
- if (diff < 3600) return `vor ${Math.floor(diff / 60)}m`;
- if (diff < 86400) return `vor ${Math.floor(diff / 3600)}h`;
- return `vor ${Math.floor(diff / 86400)}d`;
- },
-
- _updateRefreshButton(disabled) {
- const btn = document.getElementById('refresh-btn');
- if (!btn) return;
- const _t = (k, fb) => (typeof T === 'function') ? T(k, fb) : fb;
- // Hard-Stop: Lese-Modus (Budget aufgebraucht / Lizenz abgelaufen) -> immer disabled
- if (this.user && this.user.read_only) {
- btn.disabled = true;
- const reason = this.user.read_only_reason;
- btn.textContent = reason === 'budget_exceeded'
- ? _t('action.budget_exceeded', 'Budget aufgebraucht')
- : _t('action.read_only', 'Nur Lesezugriff');
- btn.title = reason === 'budget_exceeded'
- ? _t('action.budget_exceeded_title', 'Token-Budget aufgebraucht. Bitte Verwaltung kontaktieren.')
- : _t('action.read_only_title', 'Lizenz erlaubt keinen Schreibzugriff');
- return;
- }
- btn.disabled = disabled;
- btn.textContent = disabled
- ? _t('action.refreshing', 'Läuft...')
- : _t('action.refresh', 'Aktualisieren');
- btn.title = '';
- },
-
- async handleDelete() {
- if (!this.currentIncidentId) return;
- if (!await confirmDialog('Lage wirklich löschen? Alle gesammelten Daten gehen verloren.')) return;
-
- try {
- await API.deleteIncident(this.currentIncidentId);
- this.currentIncidentId = null;
- if (typeof LayoutManager !== 'undefined') LayoutManager.destroy();
- document.getElementById('incident-view').style.display = 'none';
- document.getElementById('empty-state').style.display = 'flex';
- await this.loadIncidents();
- UI.showToast('Lage gelöscht.', 'success');
- } catch (err) {
- UI.showToast('Fehler: ' + err.message, 'error');
- }
- },
-
- async handleEdit() {
- if (!this.currentIncidentId) return;
- const incident = this.incidents.find(i => i.id === this.currentIncidentId);
- if (!incident) return;
-
- this._editingIncidentId = this.currentIncidentId;
-
- // Formular mit aktuellen Werten füllen
- { const _e = document.getElementById('inc-title'); if (_e) _e.value = incident.title; }
- { const _e = document.getElementById('inc-description'); if (_e) { _e.value = incident.description || ''; _autoResizeTextarea(_e); } }
- { const _b = document.getElementById('btn-enhance-description'); if (_b) _b.disabled = (incident.title || '').trim().length < 3; }
- { const _e = document.getElementById('inc-type'); if (_e) _e.value = incident.type || 'adhoc'; }
- { const _e = document.getElementById('inc-refresh-mode'); if (_e) _e.value = incident.refresh_mode; }
- App._setIntervalFields(incident.refresh_interval);
- { const _e = document.getElementById('inc-refresh-starttime'); if (_e) _e.value = incident.refresh_start_time || '07:00'; }
- { const _e = document.getElementById('inc-retention'); if (_e) _e.value = incident.retention_days; }
- { const _e = document.getElementById('inc-international'); if (_e) _e.checked = incident.international_sources !== false && incident.international_sources !== 0; }
- { const _e = document.getElementById('inc-telegram'); if (_e) _e.checked = !!incident.include_telegram; }
-
- { const _e = document.getElementById('inc-visibility'); if (_e) _e.checked = incident.visibility !== 'private'; }
- updateVisibilityHint();
- updateSourcesHint();
- toggleTypeDefaults(true);
- toggleRefreshInterval();
-
- // Modal-Titel und Submit ändern
- { const _e = document.getElementById('modal-new-title'); if (_e) _e.textContent = (typeof T === 'function') ? T('modal.new_incident.edit_title', 'Lage bearbeiten') : 'Lage bearbeiten'; }
- { const _e = document.getElementById('modal-new-submit'); if (_e) _e.textContent = (typeof T === 'function') ? T('common.save', 'Speichern') : 'Speichern'; }
-
- // E-Mail-Subscription laden
- try {
- const sub = await API.getSubscription(this.currentIncidentId);
- { const _e = document.getElementById('inc-notify-summary'); if (_e) _e.checked = !!sub.notify_email_summary; }
- { const _e = document.getElementById('inc-notify-new-articles'); if (_e) _e.checked = !!sub.notify_email_new_articles; }
- { const _e = document.getElementById('inc-notify-status-change'); if (_e) _e.checked = !!sub.notify_email_status_change; }
- } catch (e) {
- { const _e = document.getElementById('inc-notify-summary'); if (_e) _e.checked = false; }
- { const _e = document.getElementById('inc-notify-new-articles'); if (_e) _e.checked = false; }
- { const _e = document.getElementById('inc-notify-status-change'); if (_e) _e.checked = false; }
- }
-
- openModal('modal-new');
- },
-
- async handleArchive() {
- if (!this.currentIncidentId) return;
- const incident = this.incidents.find(i => i.id === this.currentIncidentId);
- if (!incident) return;
-
- const isArchived = incident.status === 'archived';
- const action = isArchived ? 'wiederherstellen' : 'archivieren';
-
- if (!await confirmDialog(`Lage wirklich ${action}?`)) return;
-
- try {
- const newStatus = isArchived ? 'active' : 'archived';
- await API.updateIncident(this.currentIncidentId, { status: newStatus });
- await this.loadIncidents();
- await this.loadIncidentDetail(this.currentIncidentId);
- this._updateArchiveButton(newStatus);
- UI.showToast(isArchived ? 'Lage wiederhergestellt.' : 'Lage archiviert.', 'success');
- } catch (err) {
- UI.showToast('Fehler: ' + err.message, 'error');
- }
- },
-
- _updateSidebarDot(incidentId, mode) {
- const dot = document.getElementById(`dot-${incidentId}`);
- if (!dot) return;
- const incident = this.incidents.find(i => i.id === incidentId);
- const baseClass = incident ? (incident.status === 'active' ? 'active' : 'archived') : 'active';
-
- if (mode === 'error') {
- dot.className = `incident-dot refresh-error`;
- setTimeout(() => {
- dot.className = `incident-dot ${baseClass}`;
- }, 3000);
- } else if (this._refreshingIncidents.has(incidentId)) {
- dot.className = `incident-dot refreshing`;
- } else {
- dot.className = `incident-dot ${baseClass}`;
- }
- },
-
- _updateArchiveButton(status) {
- const btn = document.getElementById('archive-incident-btn');
- if (!btn) return;
- const _t = (k, fb) => (typeof T === 'function') ? T(k, fb) : fb;
- btn.textContent = status === 'archived'
- ? _t('action.restore', 'Wiederherstellen')
- : _t('action.archive', 'Archivieren');
- },
-
- // === WebSocket Handlers ===
-
- handleStatusUpdate(msg) {
- const status = msg.data.status;
- if (status === 'retrying') {
- if (msg.incident_id === this.currentIncidentId) {
- UI.showProgressError('', true, msg.data.delay || 120, msg.incident_id);
- }
- return;
- }
- if (status !== 'idle') {
- this._refreshingIncidents.add(msg.incident_id);
- }
- this._updateSidebarDot(msg.incident_id);
- // Detect first refresh: no summary means first run
- const inc = this.incidents.find(i => i.id === msg.incident_id);
- const isFirst = inc && !inc.has_summary;
- // Update progress state for ALL incidents (sidebar + popup if current)
- UI.showProgress(status, msg.data, msg.incident_id, isFirst);
- // Re-render sidebar so status is baked into HTML (survives future re-renders)
- this.renderSidebar();
- if (msg.incident_id === this.currentIncidentId) {
- this._updateRefreshButton(status !== 'idle');
- }
- },
-
- async handleRefreshComplete(msg) {
- this._refreshingIncidents.delete(msg.incident_id);
- this._updateSidebarDot(msg.incident_id);
- UI._removeSidebarRefreshStatus(msg.incident_id);
- delete UI._progressState[msg.incident_id];
- UI._reindexQueuePositions();
- this.renderSidebar();
-
- if (msg.incident_id === this.currentIncidentId) {
- this._updateRefreshButton(false);
- await this.loadIncidentDetail(msg.incident_id);
-
- // Progress-Popup nicht sofort ausblenden — auf refresh_summary warten
- this._pendingComplete = msg.incident_id;
- if (this._pendingCompleteTimer) clearTimeout(this._pendingCompleteTimer);
- this._pendingCompleteTimer = setTimeout(() => {
- if (this._pendingComplete === msg.incident_id) {
- this._pendingComplete = null;
- UI.hideProgress(msg.incident_id);
- }
- }, 5000);
- }
-
- await this.loadIncidents();
- },
-
-
-
- handleRefreshSummary(msg) {
- const d = msg.data;
- const title = d.incident_title || 'Lage';
-
- // Abschluss-Animation auslösen wenn pending
- if (this._pendingComplete === msg.incident_id) {
- if (this._pendingCompleteTimer) {
- clearTimeout(this._pendingCompleteTimer);
- this._pendingCompleteTimer = null;
- }
- this._pendingComplete = null;
- UI.showProgressComplete(d, msg.incident_id);
- }
-
- // Toast-Text zusammenbauen
- const parts = [];
- if (d.new_articles > 0) {
- parts.push(`${d.new_articles} neue Meldung${d.new_articles !== 1 ? 'en' : ''}`);
- }
- if (d.confirmed_count > 0) {
- parts.push(`${d.confirmed_count} bestätigt`);
- }
- if (d.contradicted_count > 0) {
- parts.push(`${d.contradicted_count} widersprochen`);
- }
- if (d.status_changes && d.status_changes.length > 0) {
- parts.push(`${d.status_changes.length} Statusänderung${d.status_changes.length !== 1 ? 'en' : ''}`);
- }
-
- const summaryText = parts.length > 0
- ? parts.join(', ')
- : 'Keine neuen Entwicklungen';
-
- // 1 Toast statt 5-10
- UI.showToast(`Recherche abgeschlossen: ${summaryText}`, 'success', 6000);
-
- // Ins NotificationCenter eintragen
- NotificationCenter.add({
- incident_id: msg.incident_id,
- title: title,
- text: `Recherche: ${summaryText}`,
- icon: d.contradicted_count > 0 ? 'warning' : 'success',
- });
-
- // Status-Änderungen als separate Einträge
- if (d.status_changes) {
- d.status_changes.forEach(sc => {
- const oldLabel = this._translateStatus(sc.old_status);
- const newLabel = this._translateStatus(sc.new_status);
- NotificationCenter.add({
- incident_id: msg.incident_id,
- title: title,
- text: `${sc.claim}: ${oldLabel} \u2192 ${newLabel}`,
- icon: sc.new_status === 'contradicted' || sc.new_status === 'disputed' ? 'error' : 'success',
- });
- });
- }
-
- // Sidebar-Dot blinken
- const dot = document.getElementById(`dot-${msg.incident_id}`);
- if (dot) {
- dot.classList.add('has-notification');
- setTimeout(() => dot.classList.remove('has-notification'), 10000);
- }
- },
-
- _translateStatus(status) {
- const map = {
- confirmed: 'Bestätigt',
- established: 'Gesichert',
- unconfirmed: 'Unbestätigt',
- contradicted: 'Widersprochen',
- disputed: 'Umstritten',
- developing: 'In Entwicklung',
- unverified: 'Ungeprüft',
- };
- return map[status] || status;
- },
-
- handleRefreshError(msg) {
- this._refreshingIncidents.delete(msg.incident_id);
- this._updateSidebarDot(msg.incident_id, 'error');
- UI._removeSidebarRefreshStatus(msg.incident_id);
- delete UI._progressState[msg.incident_id];
- UI._reindexQueuePositions();
- this.renderSidebar();
- if (msg.incident_id === this.currentIncidentId) {
- this._updateRefreshButton(false);
- // Pending-Complete aufräumen
- if (this._pendingCompleteTimer) {
- clearTimeout(this._pendingCompleteTimer);
- this._pendingCompleteTimer = null;
- }
- this._pendingComplete = null;
- UI.showProgressError(msg.data.error, false, 0, msg.incident_id);
- }
- UI.showToast(`Recherche-Fehler: ${msg.data.error}`, 'error');
- },
-
- handleRefreshCancelled(msg) {
- this._refreshingIncidents.delete(msg.incident_id);
- this._updateSidebarDot(msg.incident_id);
- UI._removeSidebarRefreshStatus(msg.incident_id);
- delete UI._progressState[msg.incident_id];
- UI._reindexQueuePositions();
- this.renderSidebar();
- if (msg.incident_id === this.currentIncidentId) {
- this._updateRefreshButton(false);
- if (this._pendingCompleteTimer) {
- clearTimeout(this._pendingCompleteTimer);
- this._pendingCompleteTimer = null;
- }
- this._pendingComplete = null;
- UI.hideProgress(msg.incident_id);
- }
- UI.showToast('Recherche abgebrochen.', 'info');
- },
-
- /**
- * Gleicht den lokalen Refresh-Status mit dem Server ab.
- * Bereinigt verwaiste Status-Anzeigen, die durch verpasste WebSocket-Nachrichten entstehen.
- */
- async syncRefreshStatus() {
- if (this._refreshingIncidents.size === 0) return;
- try {
- const data = await API.getRefreshingIncidents();
- const serverRefreshing = new Set(data.refreshing || []);
- const serverQueued = new Set(data.queued || []);
- const serverAll = new Set([...serverRefreshing, ...serverQueued]);
-
- // Finde lokal als refreshing/queued markierte IDs, die serverseitig nicht mehr laufen
- const stale = [];
- this._refreshingIncidents.forEach(id => {
- if (!serverAll.has(id)) stale.push(id);
- });
-
- if (stale.length > 0) {
- console.log('Status-Sync: Bereinige verwaiste Refreshes:', stale);
- stale.forEach(id => {
- this._refreshingIncidents.delete(id);
- this._updateSidebarDot(id);
- UI._removeSidebarRefreshStatus(id);
- delete UI._progressState[id];
- if (id === this.currentIncidentId) {
- this._updateRefreshButton(false);
- UI.hideProgress(id);
- }
- });
- UI._reindexQueuePositions();
- this.renderSidebar();
- }
- } catch (e) {
- // Netzwerkfehler ignorieren, naechster Zyklus probiert erneut
- }
- },
-
- minimizeProgress() {
- UI.minimizeProgress(this.currentIncidentId);
- },
-
- openProgressPopup() {
- UI.openProgressPopup(this.currentIncidentId);
- },
-
- async cancelRefresh() {
- if (!this.currentIncidentId) return;
-
- // Temporarily hide progress popup so confirm dialog is fully visible
- const progressOverlay = document.getElementById('progress-overlay');
- if (progressOverlay) progressOverlay.style.display = 'none';
-
- const ok = await confirmDialog('Laufende Recherche abbrechen?');
-
- // Restore progress popup if not confirmed
- if (!ok) {
- const state = UI._progressState[this.currentIncidentId];
- if (state && progressOverlay) progressOverlay.style.display = 'flex';
- return;
- }
-
- // Show cancelling state in popup
- if (progressOverlay) progressOverlay.style.display = 'flex';
- const btn = document.getElementById('progress-cancel-btn');
- if (btn) {
- btn.textContent = 'Wird abgebrochen...';
- btn.disabled = true;
- }
- const titleEl = document.getElementById('progress-popup-title');
- if (titleEl) titleEl.textContent = 'Wird abgebrochen...';
-
- try {
- const result = await API.cancelRefresh(this.currentIncidentId);
- if (!result) {
- UI.showToast('Kein aktiver Refresh zum Abbrechen gefunden.', 'info');
- if (btn) { btn.textContent = 'Abbrechen'; btn.disabled = false; }
- if (titleEl) titleEl.textContent = 'Aktualisierung l\u00e4uft';
- }
- } catch (err) {
- UI.showToast('Abbrechen fehlgeschlagen: ' + err.message, 'error');
- if (btn) { btn.textContent = 'Abbrechen'; btn.disabled = false; }
- if (titleEl) titleEl.textContent = 'Aktualisierung l\u00e4uft';
- }
- },
-
- // === Export ===
-
- openExportModal() {
- if (!this.currentIncidentId) return;
- openModal('modal-export');
- },
-
- async submitExport() {
- if (!this.currentIncidentId) return;
- const checked = document.querySelectorAll('input[name="export-section"]:checked');
- const sections = Array.from(checked).map(cb => cb.value);
- if (sections.length === 0) {
- UI.showToast('Bitte mindestens einen Bereich ausw\u00e4hlen.', 'warning');
- return;
- }
- const format = document.querySelector('input[name="export-format"]:checked').value;
-
- const btn = document.getElementById('export-submit-btn');
- const origText = btn.textContent;
- btn.disabled = true;
- btn.textContent = 'Wird erstellt...';
-
- try {
- const response = await API.exportReport(this.currentIncidentId, format, null, sections);
- if (!response.ok) {
- const err = await response.json().catch(() => ({}));
- throw new Error(err.detail || 'Fehler ' + response.status);
- }
- const blob = await response.blob();
- const disposition = response.headers.get('Content-Disposition') || '';
- let filename = 'bericht.' + format;
- const match = disposition.match(/filename="?([^"]+)"?/);
- if (match) filename = match[1];
- const url = URL.createObjectURL(blob);
- const a = document.createElement('a');
- a.href = url;
- a.download = filename;
- document.body.appendChild(a);
- a.click();
- document.body.removeChild(a);
- URL.revokeObjectURL(url);
- closeModal('modal-export');
- UI.showToast('Bericht heruntergeladen', 'success');
- } catch (err) {
- UI.showToast('Export fehlgeschlagen: ' + err.message, 'error');
- } finally {
- btn.disabled = false;
- btn.textContent = origText;
- }
- },
-
- // === Sidebar-Stats ===
-
- async updateSidebarStats() {
- const _t = (k, fb) => (typeof T === 'function') ? T(k, fb) : fb;
- const lblSources = _t('sidebar.stat.sources_suffix', 'Quellen');
- const lblArticles = _t('sidebar.stat.articles_suffix', 'Artikel');
- try {
- const stats = await API.getSourceStats();
- const srcCount = document.getElementById('stat-sources-count');
- const artCount = document.getElementById('stat-articles-count');
- if (srcCount) srcCount.textContent = `${stats.total_sources} ${lblSources}`;
- if (artCount) artCount.textContent = `${stats.total_articles} ${lblArticles}`;
- } catch {
- // Fallback: aus Lagen berechnen
- const totalArticles = this.incidents.reduce((sum, i) => sum + i.article_count, 0);
- const totalSources = this.incidents.reduce((sum, i) => sum + i.source_count, 0);
- const srcCount = document.getElementById('stat-sources-count');
- const artCount = document.getElementById('stat-articles-count');
- if (srcCount) srcCount.textContent = `${totalSources} ${lblSources}`;
- if (artCount) artCount.textContent = `${totalArticles} ${lblArticles}`;
- }
- },
-
- // === Soft-Refresh (F5) ===
-
- async softRefresh() {
- try {
- await this.loadIncidents();
- if (this.currentIncidentId) {
- await this.selectIncident(this.currentIncidentId);
- }
- UI.showToast('Daten aktualisiert.', 'success', 2000);
- } catch (err) {
- UI.showToast('Aktualisierung fehlgeschlagen: ' + err.message, 'error');
- }
- },
-
- // === Feedback ===
-
- openFeedback() {
- const form = document.getElementById('feedback-form');
- if (form) form.reset();
- const counter = document.getElementById('fb-char-count');
- if (counter) counter.textContent = '0';
- openModal('modal-feedback');
- },
-
- async submitFeedback(e) {
- e.preventDefault();
- const form = document.getElementById('feedback-form');
- this._clearFormErrors(form);
-
- const btn = document.getElementById('fb-submit-btn');
- const category = document.getElementById('fb-category').value;
- const msgField = document.getElementById('fb-message');
- const message = msgField.value.trim();
-
- if (message.length < 10) {
- this._showFieldError(msgField, 'Bitte mindestens 10 Zeichen eingeben.');
- msgField.focus();
- return;
- }
-
- // Dateien pruefen
- const fileInput = document.getElementById('fb-files');
- const files = fileInput ? Array.from(fileInput.files) : [];
- if (files.length > 3) {
- UI.showToast('Maximal 3 Bilder erlaubt.', 'error');
- return;
- }
- for (const f of files) {
- if (f.size > 5 * 1024 * 1024) {
- UI.showToast('Datei "' + f.name + '" ist groesser als 5 MB.', 'error');
- return;
- }
- }
-
- btn.disabled = true;
- btn.textContent = 'Wird gesendet...';
- try {
- const formData = new FormData();
- formData.append('category', category);
- formData.append('message', message);
- for (const f of files) {
- formData.append('files', f);
- }
- await API.sendFeedbackForm(formData);
- closeModal('modal-feedback');
- UI.showToast('Feedback gesendet. Vielen Dank!', 'success');
- } catch (err) {
- UI.showToast('Fehler: ' + err.message, 'error');
- } finally {
- btn.disabled = false;
- btn.textContent = 'Absenden';
- }
- },
-
- // === Sidebar Sektionen ein-/ausklappen ===
-
- toggleSidebarSection(sectionId) {
- const list = document.getElementById(sectionId);
- if (!list) return;
- const chevron = document.getElementById('chevron-' + sectionId);
- const isHidden = list.style.display === 'none';
- list.style.display = isHidden ? '' : 'none';
- if (chevron) {
- chevron.classList.toggle('open', isHidden);
- }
- // aria-expanded auf dem Section-Title synchronisieren
- const title = chevron ? chevron.closest('.sidebar-section-title') : null;
- if (title) title.setAttribute('aria-expanded', String(isHidden));
- },
-
- // === Quellenverwaltung ===
-
- async openSourceManagement() {
- openModal('modal-sources');
- await this.loadSources();
- },
-
- async loadSources() {
- try {
- const [sources, stats, myExclusions] = await Promise.all([
- API.listSources(),
- API.getSourceStats(),
- API.getMyExclusions(),
- ]);
- this._allSources = sources;
- this._sourcesOnly = sources.filter(s => s.source_type !== 'excluded');
- this._myExclusions = myExclusions || [];
-
- this.renderSourceStats(stats);
- this.renderSourceList();
- } catch (err) {
- UI.showToast('Fehler beim Laden der Quellen: ' + err.message, 'error');
- }
- },
-
- renderSourceStats(stats) {
- const bar = document.getElementById('sources-stats-bar');
- if (!bar) return;
-
- const rss = stats.by_type.rss_feed || { count: 0, articles: 0 };
- const web = stats.by_type.web_source || { count: 0, articles: 0 };
- const tg = stats.by_type.telegram_channel || { count: 0, articles: 0 };
- const excluded = this._myExclusions.length;
-
- bar.innerHTML = `
- ${rss.count} RSS-Feeds
- ${web.count} Web-Quellen
- ${tg.count} Telegram
- ${excluded} Ausgeschlossen
- ${stats.total_articles} Artikel gesamt
- `;
- },
-
- /**
- * Quellen nach Domain gruppiert rendern.
- */
- renderSourceList() {
- const list = document.getElementById('sources-list');
- if (!list) return;
-
- // Filter anwenden
- const typeFilter = document.getElementById('sources-filter-type')?.value || '';
- const catFilter = document.getElementById('sources-filter-category')?.value || '';
- const politicalFilter = document.getElementById('sources-filter-political')?.value || '';
- const mediaTypeFilter = document.getElementById('sources-filter-mediatype')?.value || '';
- const reliabilityFilter = document.getElementById('sources-filter-reliability')?.value || '';
- const alignmentFilter = document.getElementById('sources-filter-alignment')?.value || '';
- const externFilter = document.getElementById('sources-filter-extern')?.value || '';
- const search = (document.getElementById('sources-search')?.value || '').toLowerCase();
-
- // Alle Quellen nach Domain gruppieren
- const groups = new Map();
- const excludedDomains = new Set();
- const excludedNotes = {};
-
- // User-Ausschlüsse sammeln
- this._myExclusions.forEach(e => {
- const domain = (e.domain || '').toLowerCase();
- if (domain) {
- excludedDomains.add(domain);
- excludedNotes[domain] = e.notes || '';
- }
- });
-
- // Feeds nach Domain gruppieren
- this._sourcesOnly.forEach(s => {
- const domain = (s.domain || '').toLowerCase() || `_single_${s.id}`;
- if (!groups.has(domain)) groups.set(domain, []);
- groups.get(domain).push(s);
- });
-
- // Ausgeschlossene Domains die keine Feeds haben auch als Gruppe
- this._myExclusions.forEach(e => {
- const domain = (e.domain || '').toLowerCase();
- if (domain && !groups.has(domain)) {
- groups.set(domain, []);
- }
- });
-
- // Filter auf Gruppen anwenden
- let filteredGroups = [];
- for (const [domain, feeds] of groups) {
- const isExcluded = excludedDomains.has(domain);
- const isGlobal = feeds.some(f => f.is_global);
-
- // Typ-Filter
- if (typeFilter === 'excluded' && !isExcluded) continue;
- if (typeFilter && typeFilter !== 'excluded') {
- const hasMatchingType = feeds.some(f => f.source_type === typeFilter);
- if (!hasMatchingType) continue;
- }
-
- // Kategorie-Filter
- if (catFilter) {
- const hasMatchingCat = feeds.some(f => f.category === catFilter);
- if (!hasMatchingCat) continue;
- }
-
- // Klassifikations-Filter
- if (politicalFilter) {
- if (!feeds.some(f => (f.political_orientation || 'na') === politicalFilter)) continue;
- }
- if (mediaTypeFilter) {
- if (!feeds.some(f => (f.media_type || 'sonstige') === mediaTypeFilter)) continue;
- }
- if (reliabilityFilter) {
- if (!feeds.some(f => (f.reliability || 'na') === reliabilityFilter)) continue;
- }
- if (alignmentFilter) {
- if (!feeds.some(f => Array.isArray(f.alignments) && f.alignments.includes(alignmentFilter))) continue;
- }
- if (externFilter === 'ifcn') {
- if (!feeds.some(f => f.ifcn_signatory)) continue;
- } else if (externFilter === 'eu_disinfo') {
- if (!feeds.some(f => f.eu_disinfo_listed)) continue;
- }
-
- // Suche
- if (search) {
- const groupText = feeds.map(f =>
- `${f.name} ${f.domain || ''} ${f.url || ''} ${f.notes || ''}`
- ).join(' ').toLowerCase() + ' ' + domain;
- if (!groupText.includes(search)) continue;
- }
-
- filteredGroups.push({ domain, feeds, isExcluded, isGlobal });
- }
-
- if (filteredGroups.length === 0) {
- list.innerHTML = 'Keine Quellen gefunden
';
- return;
- }
-
- // Sortierung: Aktive zuerst (alphabetisch), dann ausgeschlossene
- filteredGroups.sort((a, b) => {
- if (a.isExcluded !== b.isExcluded) return a.isExcluded ? 1 : -1;
- return a.domain.localeCompare(b.domain);
- });
-
- list.innerHTML = filteredGroups.map(g =>
- UI.renderSourceGroup(g.domain, g.feeds, g.isExcluded, excludedNotes[g.domain] || '', g.isGlobal)
- ).join('');
-
- // Erweiterte Gruppen wiederherstellen
- this._expandedGroups.forEach(domain => {
- const feedsEl = list.querySelector(`.source-group-feeds[data-domain="${CSS.escape(domain)}"]`);
- if (feedsEl) {
- feedsEl.classList.add('expanded');
- const header = feedsEl.previousElementSibling;
- if (header) header.classList.add('expanded');
- }
- });
- },
-
- filterSources() {
- this.renderSourceList();
- },
-
- /**
- * Domain-Gruppe auf-/zuklappen.
- */
- toggleSourceOverview() {
- const content = document.getElementById('source-overview-content');
- const chevron = document.getElementById('source-overview-chevron');
- if (!content) return;
- const isHidden = content.style.display === 'none';
- content.style.display = isHidden ? '' : 'none';
- if (chevron) {
- chevron.classList.toggle('open', isHidden);
- chevron.title = isHidden ? 'Einklappen' : 'Aufklappen';
- }
- // aria-expanded auf dem Header-Toggle synchronisieren
- const header = chevron ? chevron.closest('[role="button"]') : null;
- if (header) header.setAttribute('aria-expanded', String(isHidden));
- // Tab-Modus: Panel waechst mit Inhalt, kein Resize noetig
- },
-
- toggleGroup(domain) {
- const list = document.getElementById('sources-list');
- if (!list) return;
- const feedsEl = list.querySelector(`.source-group-feeds[data-domain="${CSS.escape(domain)}"]`);
- if (!feedsEl) return;
-
- const isExpanded = feedsEl.classList.toggle('expanded');
- const header = feedsEl.previousElementSibling;
- if (header) {
- header.classList.toggle('expanded', isExpanded);
- header.setAttribute('aria-expanded', String(isExpanded));
- }
-
- if (isExpanded) {
- this._expandedGroups.add(domain);
- } else {
- this._expandedGroups.delete(domain);
- }
- },
-
- /**
- * Domain ausschließen (aus dem Inline-Formular).
- */
- async blockDomain() {
- const input = document.getElementById('block-domain-input');
- const domain = (input?.value || '').trim();
- if (!domain) {
- UI.showToast('Domain ist erforderlich.', 'warning');
- return;
- }
-
- const notes = (document.getElementById('block-domain-notes')?.value || '').trim() || null;
-
- try {
- await API.blockDomain(domain, notes);
- UI.showToast(`${domain} ausgeschlossen.`, 'success');
- this.showBlockDomainDialog(false);
- await this.loadSources();
- this.updateSidebarStats();
- } catch (err) {
- UI.showToast('Fehler: ' + err.message, 'error');
- }
- },
-
- /**
- * Faktencheck-Filter umschalten.
- */
- toggleFactCheckFilter(status) {
- const checkbox = document.querySelector(`.fc-dropdown-item[data-status="${status}"] input`);
- if (!checkbox) return;
- const isActive = checkbox.checked;
-
- document.querySelectorAll(`.factcheck-item[data-fc-status="${status}"]`).forEach(el => {
- el.style.display = isActive ? '' : 'none';
- });
- },
-
- toggleFcDropdown(e) {
- e.stopPropagation();
- const btn = e.target.closest('.fc-dropdown-toggle');
- const menu = btn ? btn.nextElementSibling : document.getElementById('fc-dropdown-menu');
- if (!menu) return;
- const isOpen = menu.classList.toggle('open');
- if (btn) btn.setAttribute('aria-expanded', String(isOpen));
- if (isOpen) {
- const close = (ev) => {
- if (!menu.contains(ev.target)) {
- menu.classList.remove('open');
- document.removeEventListener('click', close);
- }
- };
- setTimeout(() => document.addEventListener('click', close), 0);
- }
- },
-
- filterModalTimeline(searchTerm) {
- const filterBtn = document.querySelector('.ht-modal-filter-btn.active');
- const filterType = filterBtn ? filterBtn.dataset.filter : 'all';
- const body = document.getElementById('content-viewer-body');
- if (!body) return;
- body.innerHTML = this._buildFullVerticalTimeline(filterType, (searchTerm || '').toLowerCase());
- },
-
- filterModalTimelineType(filterType, btn) {
- document.querySelectorAll('.ht-modal-filter-btn').forEach(b => b.classList.remove('active'));
- if (btn) btn.classList.add('active');
- const searchInput = document.querySelector('#content-viewer-header-extra .timeline-filter-input');
- const searchTerm = searchInput ? searchInput.value.toLowerCase() : '';
- const body = document.getElementById('content-viewer-body');
- if (!body) return;
- body.innerHTML = this._buildFullVerticalTimeline(filterType, searchTerm);
- },
-
- /**
- * Domain direkt ausschließen (aus der Gruppenliste).
- */
- async blockDomainDirect(domain) {
- if (!await confirmDialog(`"${domain}" wirklich ausschließen? Artikel dieser Domain werden bei allen deinen Recherchen ignoriert. Dies betrifft nicht andere Nutzer deiner Organisation.`)) return;
-
- try {
- await API.blockDomain(domain);
- UI.showToast(`${domain} ausgeschlossen.`, 'success');
- await this.loadSources();
- this.updateSidebarStats();
- } catch (err) {
- UI.showToast('Fehler: ' + err.message, 'error');
- }
- },
-
- /**
- * Domain-Ausschluss aufheben.
- */
- async unblockDomain(domain) {
- try {
- await API.unblockDomain(domain);
- UI.showToast(`${domain} Ausschluss aufgehoben.`, 'success');
- await this.loadSources();
- this.updateSidebarStats();
- } catch (err) {
- UI.showToast('Fehler: ' + err.message, 'error');
- }
- },
-
- /**
- * Alle Quellen einer Domain löschen.
- */
- async deleteDomain(domain) {
- if (!await confirmDialog(`Alle Quellen von "${domain}" wirklich löschen?`)) return;
-
- try {
- await API.deleteDomain(domain);
- UI.showToast(`${domain} gelöscht.`, 'success');
- await this.loadSources();
- this.updateSidebarStats();
- } catch (err) {
- UI.showToast('Fehler: ' + err.message, 'error');
- }
- },
-
- /**
- * Einzelnen Feed löschen.
- */
- async deleteSingleFeed(sourceId) {
- try {
- await API.deleteSource(sourceId);
- this._allSources = this._allSources.filter(s => s.id !== sourceId);
- this._sourcesOnly = this._sourcesOnly.filter(s => s.id !== sourceId);
- this.renderSourceList();
- this.updateSidebarStats();
- UI.showToast('Feed gelöscht.', 'success');
- } catch (err) {
- UI.showToast('Fehler: ' + err.message, 'error');
- }
- },
-
- /**
- * "Domain ausschließen" Dialog ein-/ausblenden.
- */
- showBlockDomainDialog(show) {
- const form = document.getElementById('sources-block-form');
- if (!form) return;
-
- if (show === undefined || show === true) {
- form.style.display = 'block';
- document.getElementById('block-domain-input').value = '';
- document.getElementById('block-domain-notes').value = '';
- // Add-Form ausblenden
- const addForm = document.getElementById('sources-add-form');
- if (addForm) addForm.style.display = 'none';
- } else {
- form.style.display = 'none';
- }
- },
-
- _discoveredData: null,
-
- toggleSourceForm(show) {
- const form = document.getElementById('sources-add-form');
- if (!form) return;
-
- if (show === undefined) {
- show = form.style.display === 'none';
- }
-
- form.style.display = show ? 'block' : 'none';
-
- if (show) {
- this._editingSourceId = null;
- this._discoveredData = null;
- document.getElementById('src-discover-url').value = '';
- document.getElementById('src-discovery-result').style.display = 'none';
- document.getElementById('src-discover-btn').disabled = false;
- document.getElementById('src-discover-btn').textContent = 'Erkennen';
- document.getElementById('src-type-select').value = 'rss_feed';
- // Save-Button Text zurücksetzen
- const saveBtn = document.querySelector('#src-discovery-result .sources-discovery-actions .btn-primary');
- if (saveBtn) saveBtn.textContent = 'Speichern';
- // Block-Form ausblenden
- const blockForm = document.getElementById('sources-block-form');
- if (blockForm) blockForm.style.display = 'none';
- } else {
- // Beim Schließen: Bearbeitungsmodus zurücksetzen
- this._editingSourceId = null;
- const saveBtn = document.querySelector('#src-discovery-result .sources-discovery-actions .btn-primary');
- if (saveBtn) saveBtn.textContent = 'Speichern';
- }
- },
-
- async discoverSource() {
- const urlInput = document.getElementById('src-discover-url');
- const urlVal = urlInput.value.trim();
-
- // Telegram-URLs direkt behandeln (kein Discovery noetig)
- if (urlVal.match(/^(https?:\/\/)?(t\.me|telegram\.me)\//i)) {
- const channelName = urlVal.replace(/^(https?:\/\/)?(t\.me|telegram\.me)\//, '').replace(/\/$/, '');
- const tgUrl = 't.me/' + channelName;
- this._discoveredData = {
- name: '@' + channelName,
- domain: 't.me',
- source_type: 'telegram_channel',
- rss_url: null,
- };
- document.getElementById('src-name').value = '@' + channelName;
- document.getElementById('src-type-select').value = 'telegram_channel';
- document.getElementById('src-type-display').value = 'Telegram';
- document.getElementById('src-domain').value = tgUrl;
- document.getElementById('src-rss-url-group').style.display = 'none';
- document.getElementById('src-discovery-result').style.display = 'block';
- const saveBtn = document.querySelector('#src-discovery-result .sources-discovery-actions .btn-primary');
- if (saveBtn) { saveBtn.disabled = false; saveBtn.textContent = 'Speichern'; }
- return;
- }
- const url = urlInput.value.trim();
- if (!url) {
- UI.showToast('Bitte URL oder Domain eingeben.', 'warning');
- return;
- }
-
- // Prüfen ob Domain ausgeschlossen ist
- const inputDomain = url.replace(/^https?:\/\//, '').replace(/^www\./, '').split('/')[0].toLowerCase();
- const isBlocked = inputDomain && this._myExclusions.some(e => (e.domain || '').toLowerCase() === inputDomain);
-
- if (isBlocked) {
- if (!await confirmDialog(`"${inputDomain}" ist ausgeschlossen. Trotzdem hinzufügen? Der Ausschluss wird dabei aufgehoben.`)) return;
- await API.unblockDomain(inputDomain);
- }
-
- const btn = document.getElementById('src-discover-btn');
- btn.disabled = true;
- btn.textContent = 'Suche Feeds...';
-
- try {
- const result = await API.discoverMulti(url);
-
- if (result.fallback_single) {
- this._discoveredData = {
- name: result.domain,
- domain: result.domain,
- category: result.category,
- source_type: result.total_found > 0 ? 'rss_feed' : 'web_source',
- rss_url: result.sources.length > 0 ? result.sources[0].url : null,
- };
- if (result.sources.length > 0) {
- this._discoveredData.name = result.sources[0].name;
- }
-
- document.getElementById('src-name').value = this._discoveredData.name || '';
- document.getElementById('src-category').value = this._discoveredData.category || 'sonstige';
- document.getElementById('src-domain').value = this._discoveredData.domain || '';
- document.getElementById('src-notes').value = '';
-
- const typeLabel = this._discoveredData.source_type === 'rss_feed' ? 'RSS-Feed' : this._discoveredData.source_type === 'telegram_channel' ? 'Telegram' : 'Web-Quelle';
- const typeSelect = document.getElementById('src-type-select');
- if (typeSelect) typeSelect.value = this._discoveredData.source_type || 'web_source';
- document.getElementById('src-type-display').value = typeLabel;
-
- const rssGroup = document.getElementById('src-rss-url-group');
- const rssInput = document.getElementById('src-rss-url');
- if (this._discoveredData.rss_url) {
- rssInput.value = this._discoveredData.rss_url;
- rssGroup.style.display = 'block';
- } else {
- rssInput.value = '';
- rssGroup.style.display = 'none';
- }
-
- document.getElementById('src-discovery-result').style.display = 'block';
-
- if (result.added_count > 0) {
- UI.showToast(`${result.domain}: Feed wurde automatisch hinzugefügt.`, 'success');
- this.toggleSourceForm(false);
- await this.loadSources();
- } else if (result.total_found === 0) {
- UI.showToast('Kein RSS-Feed gefunden. Als Web-Quelle speichern?', 'info');
- } else {
- UI.showToast('Feed bereits vorhanden.', 'info');
- }
- } else {
- document.getElementById('src-discovery-result').style.display = 'none';
-
- if (result.added_count > 0) {
- UI.showToast(`${result.domain}: ${result.added_count} Feeds hinzugefügt` +
- (result.skipped_count > 0 ? ` (${result.skipped_count} bereits vorhanden)` : ''),
- 'success');
- } else if (result.skipped_count > 0) {
- UI.showToast(`${result.domain}: Alle ${result.skipped_count} Feeds bereits vorhanden.`, 'info');
- } else {
- UI.showToast(`${result.domain}: Keine relevanten Feeds gefunden.`, 'info');
- }
-
- this.toggleSourceForm(false);
- await this.loadSources();
- }
- } catch (err) {
- UI.showToast('Erkennung fehlgeschlagen: ' + err.message, 'error');
- } finally {
- btn.disabled = false;
- btn.textContent = 'Erkennen';
- }
- },
-
- editSource(id) {
- const source = this._sourcesOnly.find(s => s.id === id);
- if (!source) {
- UI.showToast('Quelle nicht gefunden.', 'error');
- return;
- }
-
- this._editingSourceId = id;
-
- // Formular öffnen falls geschlossen (direkt, ohne toggleSourceForm das _editingSourceId zurücksetzt)
- const form = document.getElementById('sources-add-form');
- if (form) {
- form.style.display = 'block';
- const blockForm = document.getElementById('sources-block-form');
- if (blockForm) blockForm.style.display = 'none';
- }
-
- // Discovery-URL mit vorhandener URL/Domain befüllen
- const discoverUrlInput = document.getElementById('src-discover-url');
- if (discoverUrlInput) {
- discoverUrlInput.value = source.url || source.domain || '';
- }
-
- // Discovery-Ergebnis anzeigen und Felder befüllen
- document.getElementById('src-discovery-result').style.display = 'block';
- document.getElementById('src-name').value = source.name || '';
- document.getElementById('src-category').value = source.category || 'sonstige';
- document.getElementById('src-notes').value = source.notes || '';
- document.getElementById('src-domain').value = source.domain || '';
-
- const typeLabel = source.source_type === 'rss_feed' ? 'RSS-Feed' : source.source_type === 'telegram_channel' ? 'Telegram' : 'Web-Quelle';
- const typeSelect = document.getElementById('src-type-select');
- if (typeSelect) typeSelect.value = source.source_type || 'web_source';
- document.getElementById('src-type-display').value = typeLabel;
-
- const rssGroup = document.getElementById('src-rss-url-group');
- const rssInput = document.getElementById('src-rss-url');
- if (source.url) {
- rssInput.value = source.url;
- rssGroup.style.display = 'block';
- } else {
- rssInput.value = '';
- rssGroup.style.display = 'none';
- }
-
- // _discoveredData setzen damit saveSource() die richtigen Werte nutzt
- this._discoveredData = {
- name: source.name,
- domain: source.domain,
- category: source.category,
- source_type: source.source_type,
- rss_url: source.url,
- };
-
- // Submit-Button-Text ändern
- const saveBtn = document.querySelector('#src-discovery-result .sources-discovery-actions .btn-primary');
- if (saveBtn) saveBtn.textContent = 'Quelle speichern';
-
- // Zum Formular scrollen
- if (form) form.scrollIntoView({ behavior: 'smooth', block: 'start' });
- },
-
- async saveSource() {
- const name = document.getElementById('src-name').value.trim();
- if (!name) {
- UI.showToast('Name ist erforderlich. Bitte erst "Erkennen" klicken.', 'warning');
- return;
- }
-
- const discovered = this._discoveredData || {};
- const data = {
- name,
- source_type: discovered.source_type || 'web_source',
- category: document.getElementById('src-category').value,
- url: discovered.rss_url || (discovered.source_type === 'telegram_channel' ? (document.getElementById('src-domain').value || null) : null),
- domain: document.getElementById('src-domain').value.trim() || discovered.domain || null,
- notes: document.getElementById('src-notes').value.trim() || null,
- };
-
- if (!data.domain && discovered.domain) {
- data.domain = discovered.domain;
- }
-
- try {
- if (this._editingSourceId) {
- await API.updateSource(this._editingSourceId, data);
- UI.showToast('Quelle aktualisiert.', 'success');
- } else {
- await API.createSource(data);
- UI.showToast('Quelle hinzugefügt.', 'success');
- }
-
- this.toggleSourceForm(false);
- await this.loadSources();
- this.updateSidebarStats();
- } catch (err) {
- UI.showToast('Fehler: ' + err.message, 'error');
- }
- },
-
- // --- Global Admin: Org-Switcher (herausnehmbar) ---
- async _initOrgSwitcher(currentTenantId) {
- const section = document.getElementById('org-switcher-section');
- const select = document.getElementById('org-switcher-select');
- if (!section || !select) return;
-
- try {
- const orgs = await API.listOrganizations();
- if (!orgs || orgs.length < 2) return;
-
- section.style.display = 'block';
- select.innerHTML = '';
- orgs.forEach(org => {
- const opt = document.createElement('option');
- opt.value = org.id;
- opt.textContent = org.name + (org.is_active ? '' : ' (inaktiv)');
- if (org.id === currentTenantId) opt.selected = true;
- select.appendChild(opt);
- });
-
- select.addEventListener('change', async () => {
- const orgId = parseInt(select.value, 10);
- if (orgId === currentTenantId) return;
- try {
- const result = await API.switchOrg(orgId);
- localStorage.setItem('osint_token', result.access_token);
- window.location.reload();
- } catch (err) {
- console.error('Org-Wechsel fehlgeschlagen:', err);
- }
- });
- } catch {
- // Kein Global Admin oder Fehler - Switcher bleibt versteckt
- }
- },
-
- logout() {
- localStorage.removeItem('osint_token');
- localStorage.removeItem('osint_username');
- this._sessionWarningShown = false;
- WS.disconnect();
- window.location.href = '/';
- },
-};
-
-// === Barrierefreier Bestätigungsdialog ===
-
-function confirmDialog(message) {
- return new Promise((resolve) => {
- // Overlay erstellen
- const overlay = document.createElement('div');
- overlay.className = 'modal-overlay active';
- overlay.setAttribute('role', 'alertdialog');
- overlay.setAttribute('aria-modal', 'true');
- overlay.setAttribute('aria-labelledby', 'confirm-dialog-msg');
-
- const modal = document.createElement('div');
- modal.className = 'modal';
- modal.style.maxWidth = '420px';
- modal.innerHTML = `
-
-
-
${message.replace(//g, '>')}
-
-
- `;
- overlay.appendChild(modal);
- document.body.appendChild(overlay);
-
- const previousFocus = document.activeElement;
-
- const cleanup = (result) => {
- releaseFocus(overlay);
- overlay.remove();
- if (previousFocus) previousFocus.focus();
- resolve(result);
- };
-
- modal.querySelector('#confirm-cancel').addEventListener('click', () => cleanup(false));
- modal.querySelector('#confirm-ok').addEventListener('click', () => cleanup(true));
- overlay.addEventListener('click', (e) => {
- if (e.target === overlay) cleanup(false);
- });
- overlay.addEventListener('keydown', (e) => {
- if (e.key === 'Escape') cleanup(false);
- });
-
- trapFocus(overlay);
- });
-}
-
-// === Globale Hilfsfunktionen ===
-
-// --- Focus-Trap für Modals (WCAG 2.4.3) ---
-const FOCUSABLE_SELECTOR = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
-
-function trapFocus(modalEl) {
- const handler = (e) => {
- if (e.key !== 'Tab') return;
- const focusable = Array.from(modalEl.querySelectorAll(FOCUSABLE_SELECTOR)).filter(el => el.offsetParent !== null);
- if (focusable.length === 0) return;
- const first = focusable[0];
- const last = focusable[focusable.length - 1];
- if (e.shiftKey && document.activeElement === first) {
- e.preventDefault();
- last.focus();
- } else if (!e.shiftKey && document.activeElement === last) {
- e.preventDefault();
- first.focus();
- }
- };
- modalEl._focusTrapHandler = handler;
- modalEl.addEventListener('keydown', handler);
- // Fokus auf erstes Element setzen
- requestAnimationFrame(() => {
- const focusable = Array.from(modalEl.querySelectorAll(FOCUSABLE_SELECTOR)).filter(el => el.offsetParent !== null);
- if (focusable.length > 0) focusable[0].focus();
- });
-}
-
-function releaseFocus(modalEl) {
- if (modalEl._focusTrapHandler) {
- modalEl.removeEventListener('keydown', modalEl._focusTrapHandler);
- delete modalEl._focusTrapHandler;
- }
-}
-
-function openModal(id) {
- if (id === 'modal-new' && !App._editingIncidentId) {
- // Create-Modus: Formular zurücksetzen
- document.getElementById('new-incident-form').reset();
- document.getElementById('modal-new-title').textContent = (typeof T === 'function') ? T('modal.new_incident.title2', 'Neue Lage anlegen') : 'Neue Lage anlegen';
- document.getElementById('modal-new-submit').textContent = (typeof T === 'function') ? T('modal.new_incident.submit', 'Lage anlegen') : 'Lage anlegen';
- { const _b = document.getElementById('btn-enhance-description'); if (_b) _b.disabled = true; }
- { const _t = document.getElementById("inc-description"); if (_t) { _t.style.height = ""; _autoResizeTextarea(_t); } }
- // E-Mail-Checkboxen zuruecksetzen
- document.getElementById('inc-notify-summary').checked = false;
- document.getElementById('inc-notify-new-articles').checked = false;
- document.getElementById('inc-notify-status-change').checked = false;
- toggleTypeDefaults();
- toggleRefreshInterval();
- }
- const modal = document.getElementById(id);
- modal._previousFocus = document.activeElement;
- modal.classList.add('active');
- trapFocus(modal);
-}
-
-function closeModal(id) {
- // Laufenden Beschreibung-generieren-Request abbrechen
- if (id === 'modal-new' && App._enhanceController) {
- App._enhanceController.abort();
- App._enhanceController = null;
- const ta = document.getElementById('inc-description');
- if (ta) { ta.readOnly = false; ta.classList.remove('textarea--loading'); }
- }
- const modal = document.getElementById(id);
- releaseFocus(modal);
- modal.classList.remove('active');
- if (modal._previousFocus) {
- modal._previousFocus.focus();
- delete modal._previousFocus;
- }
- if (id === 'modal-new') {
- App._editingIncidentId = null;
- document.getElementById('modal-new-title').textContent = (typeof T === 'function') ? T('modal.new_incident.title2', 'Neue Lage anlegen') : 'Neue Lage anlegen';
- document.getElementById('modal-new-submit').textContent = (typeof T === 'function') ? T('modal.new_incident.submit', 'Lage anlegen') : 'Lage anlegen';
- }
-}
-
-function openContentModal(title, sourceElementId) {
- const source = document.getElementById(sourceElementId);
- if (!source) return;
-
- document.getElementById('content-viewer-title').textContent = title;
- const body = document.getElementById('content-viewer-body');
- const headerExtra = document.getElementById('content-viewer-header-extra');
- headerExtra.innerHTML = '';
-
- if (sourceElementId === 'factcheck-list') {
- // Faktencheck: Filter in den Modal-Header, Liste in den Body
- const filters = document.getElementById('fc-filters');
- if (filters && filters.innerHTML.trim()) {
- headerExtra.innerHTML = `${filters.innerHTML}
`;
- }
- body.innerHTML = source.innerHTML;
- // Filter im Modal auf Modal-Items umleiten
- headerExtra.querySelectorAll('.fc-dropdown-item input[type="checkbox"]').forEach(cb => {
- cb.onchange = function() {
- const status = this.closest('.fc-dropdown-item').dataset.status;
- body.querySelectorAll(`.factcheck-item[data-fc-status="${status}"]`).forEach(el => {
- el.style.display = cb.checked ? '' : 'none';
- });
- };
- });
- } else if (sourceElementId === 'source-overview-content') {
- // Quellenübersicht: Detailansicht mit Suchleiste
- headerExtra.innerHTML = ' ';
- body.innerHTML = buildDetailedSourceOverview();
- } else if (sourceElementId === 'timeline') {
- // Timeline: Vollständige vertikale Timeline im Modal mit Filter + Suche
- headerExtra.innerHTML = `
-
- Alle
- Meldungen
- Lageberichte
-
-
-
`;
- body.innerHTML = App._buildFullVerticalTimeline('all', '');
- } else {
- body.innerHTML = source.innerHTML;
- }
-
- openModal('modal-content-viewer');
-}
-
-App.filterModalSources = function(query) {
- const q = query.toLowerCase().trim();
- const details = document.querySelectorAll('#content-viewer-body details');
- details.forEach(d => {
- if (!q) {
- d.style.display = '';
- d.removeAttribute('open');
- return;
- }
- const name = d.querySelector('summary').textContent.toLowerCase();
- // Quellenname oder Artikel-Headlines durchsuchen
- const articles = d.querySelectorAll('div > div');
- let articleMatch = false;
- articles.forEach(a => {
- const text = a.textContent.toLowerCase();
- const hit = text.includes(q);
- a.style.display = hit ? '' : 'none';
- if (hit) articleMatch = true;
- });
- const match = name.includes(q) || articleMatch;
- d.style.display = match ? '' : 'none';
- // Bei Artikeltreffer aufklappen, bei Namens-Match alle Artikel zeigen
- if (match && articleMatch && !name.includes(q)) {
- d.setAttribute('open', '');
- } else if (name.includes(q)) {
- articles.forEach(a => a.style.display = '');
- }
- });
-};
-
-function buildDetailedSourceOverview() {
- const articles = App._currentArticles || [];
- if (!articles.length) return 'Keine Artikel vorhanden
';
-
- // Nach Quelle gruppieren
- const sourceMap = {};
- articles.forEach(a => {
- const name = a.source || 'Unbekannt';
- if (!sourceMap[name]) sourceMap[name] = { articles: [], languages: new Set() };
- sourceMap[name].articles.push(a);
- sourceMap[name].languages.add((a.language || 'de').toUpperCase());
- });
-
- const sources = Object.entries(sourceMap).sort((a, b) => b[1].articles.length - a[1].articles.length);
-
- // Sprach-Statistik Header
- const langCount = {};
- articles.forEach(a => {
- const lang = (a.language || 'de').toUpperCase();
- langCount[lang] = (langCount[lang] || 0) + 1;
- });
- const langChips = Object.entries(langCount)
- .sort((a, b) => b[1] - a[1])
- .map(([lang, count]) => `${lang} ${count} `)
- .join('');
-
- let html = ``;
-
- sources.forEach(([name, data]) => {
- const langs = [...data.languages].join('/');
- const escapedName = UI.escape(name);
- html += `
-
- ▸
- ${escapedName}
- ${langs}
- ${data.articles.length}
-
- `;
- data.articles.forEach(a => {
- const headline = UI.escape(a.headline_de || a.headline || 'Ohne Titel');
- const time = a.collected_at
- ? (parseUTC(a.collected_at) || new Date(a.collected_at)).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit', timeZone: TIMEZONE })
- : '';
- const langBadge = a.language && a.language !== 'de'
- ? `
${a.language.toUpperCase()} ` : '';
- const link = a.source_url
- ? `
↗ ` : '';
- html += `
- ${time}
- ${headline}
- ${langBadge}
- ${link}
-
`;
- });
- html += `
`;
- });
-
- return html;
-}
-
-
-
-
-function toggleRefreshInterval() {
- const mode = document.getElementById('inc-refresh-mode').value;
- const field = document.getElementById('refresh-interval-field');
- const startField = document.getElementById('refresh-starttime-field');
- field.classList.toggle('visible', mode === 'auto');
- if (startField) startField.classList.toggle('visible', mode === 'auto');
-}
-
-function updateIntervalMin() {
- const unit = parseInt(document.getElementById('inc-refresh-unit').value);
- const input = document.getElementById('inc-refresh-value');
- if (unit === 1) {
- // Minuten: Minimum 10
- input.min = 10;
- if (parseInt(input.value) < 10) input.value = 10;
- } else {
- // Stunden/Tage/Wochen: Minimum 1
- input.min = 1;
- if (parseInt(input.value) < 1) input.value = 1;
- }
-}
-
-function updateVisibilityHint() {
- const isPublic = document.getElementById('inc-visibility').checked;
- const text = document.getElementById('visibility-text');
- if (text) {
- const _t = (k, fb) => (typeof T === 'function') ? T(k, fb) : fb;
- text.textContent = isPublic
- ? _t('modal.toggle.visibility_public_text', 'Öffentlich — für alle Nutzer sichtbar')
- : _t('modal.toggle.visibility_private_text', 'Privat — nur für dich sichtbar');
- }
-}
-
-function updateSourcesHint() {
- const intl = document.getElementById('inc-international').checked;
- const hint = document.getElementById('sources-hint');
- if (hint) {
- hint.textContent = intl
- ? 'DE + internationale Feeds (Reuters, BBC, Al Jazeera etc.)'
- : 'Nur deutschsprachige Quellen (DE, AT, CH)';
- }
-}
-
-function toggleTypeDefaults(preserveMode = false) {
- const type = document.getElementById('inc-type').value;
- const hint = document.getElementById('type-hint');
- const refreshMode = document.getElementById('inc-refresh-mode');
-
- const _t = (k, fb) => (typeof T === 'function') ? T(k, fb) : fb;
- if (type === 'research') {
- hint.textContent = _t('modal.hint.type_research', 'Recherchiert in Tiefe: Nachrichtenarchive, Parlamentsdokumente, Fachmedien, Expertenquellen. Empfohlen: Manuell starten und bei Bedarf vertiefen.');
- // Nur bei Typ-Wechsel/Neuanlage Modus zurückziehen, beim Edit bestehender Lagen DB-Wert respektieren
- if (!preserveMode) {
- refreshMode.value = 'manual';
- toggleRefreshInterval();
- }
- } else {
- hint.textContent = _t('modal.hint.type_adhoc', 'Durchsucht laufend hunderte Nachrichtenquellen nach neuen Meldungen. Empfohlen: Automatische Aktualisierung.');
- }
-
- // Beschreibungs-Tooltip je nach Typ wechseln
- const descIcon = document.getElementById('description-info-icon');
- if (descIcon) {
- descIcon.setAttribute('data-tooltip', type === 'research'
- ? 'Nenne das vollst\u00e4ndige Thema, gew\u00fcnschte Schwerpunkte und relevante URLs.\nBeispiel: "Muster GmbH: Fokus auf F\u00fchrungspersonen, Kontroversen, Finanzkennzahlen"'
- : 'Beschreibe den Vorfall m\u00f6glichst genau: Was ist passiert? Wo? Wer ist beteiligt?\nJe pr\u00e4ziser, desto bessere Ergebnisse.');
- }
-}
-
-// Tab-Fokus: Nur Tab-Badge (Titel-Counter) zurücksetzen, nicht alle Notifications
-window.addEventListener('focus', () => {
- document.title = App._originalTitle;
-});
-
-// ESC schließt Modals
-// F5: Daten aktualisieren statt Seite neu laden
-document.addEventListener('keydown', (e) => {
- if (e.key === 'F5') {
- e.preventDefault();
- App.softRefresh();
- }
-});
-
-document.addEventListener('keydown', (e) => {
- if (e.key === 'Escape') {
- // Schließ-Reihenfolge: A11y-Panel > Notification-Panel > Export-Dropdown > FC-Dropdown > Modals
- if (A11yManager._isOpen) {
- A11yManager._closePanel();
- return;
- }
- if (NotificationCenter._isOpen) {
- NotificationCenter.close();
- return;
- }
-
- const fcMenu = document.querySelector('.fc-dropdown-menu.open');
- if (fcMenu) {
- fcMenu.classList.remove('open');
- const fcBtn = fcMenu.previousElementSibling;
- if (fcBtn) fcBtn.setAttribute('aria-expanded', 'false');
- return;
- }
- document.querySelectorAll('.modal-overlay.active').forEach(m => {
- closeModal(m.id);
- });
- }
-});
-
-// Keyboard-Handler: Enter/Space auf [role="button"] löst click aus (WCAG 2.1.1)
-document.addEventListener('keydown', (e) => {
- if ((e.key === 'Enter' || e.key === ' ') && e.target.matches('[role="button"]')) {
- e.preventDefault();
- e.target.click();
- }
-});
-
-// Session-Ablauf prüfen (alle 60 Sekunden)
-setInterval(() => {
- const token = localStorage.getItem('osint_token');
- if (!token) return;
- try {
- const payload = JSON.parse(atob(token.split('.')[1]));
- const expiresAt = payload.exp * 1000;
- const remaining = expiresAt - Date.now();
- const fiveMinutes = 5 * 60 * 1000;
- if (remaining <= 0) {
- App.logout();
- } else if (remaining <= fiveMinutes && !App._sessionWarningShown) {
- App._sessionWarningShown = true;
- const mins = Math.ceil(remaining / 60000);
- UI.showToast(`Session läuft in ${mins} Minute${mins !== 1 ? 'n' : ''} ab. Bitte erneut anmelden.`, 'warning', 15000);
- }
- } catch (e) { /* Token nicht parsbar */ }
-}, 60000);
-
-// Modal-Overlays: Klick auf Backdrop schließt NICHT mehr (nur X-Button)
-document.addEventListener('click', (e) => {
- if (e.target.classList.contains('modal-overlay') && e.target.classList.contains('active')) {
- // closeModal deaktiviert - Modal nur ueber X-Button schliessbar
- }
-});
-
-// App starten
-document.addEventListener('click', (e) => {
-
-});
-document.addEventListener('DOMContentLoaded', () => App.init());
-
-
-// Auto-Resize fuer Textarea
-function _autoResizeTextarea(el) {
- if (!el) return;
- el.style.height = 'auto';
- el.style.height = Math.max(80, el.scrollHeight) + 'px';
-}
-
-// Titel-Input: Button aktivieren + Textarea Auto-Resize
-document.addEventListener('DOMContentLoaded', () => {
- const titleInput = document.getElementById('inc-title');
- if (titleInput) {
- titleInput.addEventListener('input', function() {
- const btn = document.getElementById('btn-enhance-description');
- if (btn) btn.disabled = this.value.trim().length < 3;
- });
- }
- const descInput = document.getElementById('inc-description');
- if (descInput) {
- descInput.addEventListener('input', function() { _autoResizeTextarea(this); });
- }
-});
+/**
+ * OSINT Lagemonitor - Hauptanwendungslogik.
+ */
+
+/** Feste Zeitzone fuer alle Anzeigen — NIEMALS aendern. */
+const TIMEZONE = 'Europe/Berlin';
+
+/** Gibt Jahr/Monat(0-basiert)/Tag/Stunde/Minute in Berliner Zeit zurueck. */
+function _tz(d) {
+ const s = d.toLocaleString('en-CA', {
+ timeZone: TIMEZONE, year: 'numeric', month: '2-digit', day: '2-digit',
+ hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false
+ });
+ const m = s.match(/(\d{4})-(\d{2})-(\d{2}),?\s*(\d{2}):(\d{2}):(\d{2})/);
+ if (!m) return { year: d.getFullYear(), month: d.getMonth(), date: d.getDate(), hours: d.getHours(), minutes: d.getMinutes() };
+ return { year: +m[1], month: +m[2] - 1, date: +m[3], hours: +m[4], minutes: +m[5] };
+}
+
+/**
+ * Theme Manager: Dark/Light Theme Toggle mit localStorage-Persistenz.
+ */
+const ThemeManager = {
+ _key: 'osint_theme',
+ init() {
+ const saved = localStorage.getItem(this._key);
+ const theme = saved || 'dark';
+ document.documentElement.setAttribute('data-theme', theme);
+ this._updateIcon(theme);
+ },
+ toggle() {
+ const current = document.documentElement.getAttribute('data-theme') || 'dark';
+ const next = current === 'dark' ? 'light' : 'dark';
+ document.documentElement.setAttribute('data-theme', next);
+ localStorage.setItem(this._key, next);
+ this._updateIcon(next);
+ UI.updateMapTheme();
+ },
+ _updateIcon(theme) {
+ const el = document.getElementById('theme-toggle');
+ if (!el) return;
+ el.classList.remove('dark', 'light');
+ el.classList.add(theme);
+ el.setAttribute('aria-checked', theme === 'dark' ? 'true' : 'false');
+ }
+};
+
+/**
+ * Barrierefreiheits-Manager: Panel mit 4 Schaltern (Kontrast, Focus, Schrift, Animationen).
+ */
+const A11yManager = {
+ _key: 'osint_a11y',
+ _isOpen: false,
+ _settings: { contrast: false, focus: false, fontsize: false, motion: false },
+
+ init() {
+ // Einstellungen aus localStorage laden
+ try {
+ const saved = JSON.parse(localStorage.getItem(this._key) || '{}');
+ Object.keys(this._settings).forEach(k => {
+ if (typeof saved[k] === 'boolean') this._settings[k] = saved[k];
+ });
+ } catch (e) { /* Ungültige Daten ignorieren */ }
+
+ // Button + Panel dynamisch in .header-right einfügen (vor Theme-Toggle)
+ const headerRight = document.querySelector('.header-right');
+ const themeToggle = document.getElementById('theme-toggle');
+ if (!headerRight) return;
+
+ const container = document.createElement('div');
+ container.className = 'a11y-center';
+ container.innerHTML = `
+
+
+
+
+
+
+
+ `;
+
+ if (themeToggle) {
+ headerRight.insertBefore(container, themeToggle);
+ } else {
+ headerRight.prepend(container);
+ }
+
+ // Toggle-Event-Listener
+ ['contrast', 'focus', 'fontsize', 'motion'].forEach(key => {
+ document.getElementById('a11y-' + key).addEventListener('change', () => this.toggle(key));
+ });
+
+ // Button öffnet/schließt Panel
+ document.getElementById('a11y-btn').addEventListener('click', (e) => {
+ e.stopPropagation();
+ this._isOpen ? this._closePanel() : this._openPanel();
+ });
+
+ // Klick außerhalb schließt Panel
+ document.addEventListener('click', (e) => {
+ if (this._isOpen && !container.contains(e.target)) {
+ this._closePanel();
+ }
+ });
+
+ // Keyboard: Esc schließt, Pfeiltasten navigieren
+ container.addEventListener('keydown', (e) => {
+ if (e.key === 'Escape' && this._isOpen) {
+ e.stopPropagation();
+ this._closePanel();
+ return;
+ }
+ if (!this._isOpen) return;
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
+ e.preventDefault();
+ const options = Array.from(document.querySelectorAll('.a11y-option input[type="checkbox"]'));
+ const idx = options.indexOf(document.activeElement);
+ let next;
+ if (e.key === 'ArrowDown') {
+ next = idx < options.length - 1 ? idx + 1 : 0;
+ } else {
+ next = idx > 0 ? idx - 1 : options.length - 1;
+ }
+ options[next].focus();
+ }
+ });
+
+ // Einstellungen anwenden + Checkboxen synchronisieren
+ this._apply();
+ this._syncUI();
+ },
+
+ toggle(key) {
+ this._settings[key] = !this._settings[key];
+ this._apply();
+ this._syncUI();
+ this._save();
+ },
+
+ _apply() {
+ const root = document.documentElement;
+ Object.keys(this._settings).forEach(k => {
+ if (this._settings[k]) {
+ root.setAttribute('data-a11y-' + k, 'true');
+ } else {
+ root.removeAttribute('data-a11y-' + k);
+ }
+ });
+ },
+
+ _syncUI() {
+ Object.keys(this._settings).forEach(k => {
+ const cb = document.getElementById('a11y-' + k);
+ if (cb) cb.checked = this._settings[k];
+ });
+ },
+
+ _save() {
+ localStorage.setItem(this._key, JSON.stringify(this._settings));
+ },
+
+ _openPanel() {
+ this._isOpen = true;
+ document.getElementById('a11y-panel').style.display = '';
+ document.getElementById('a11y-btn').setAttribute('aria-expanded', 'true');
+ // Fokus auf erste Option setzen
+ requestAnimationFrame(() => {
+ const first = document.querySelector('.a11y-option input[type="checkbox"]');
+ if (first) first.focus();
+ });
+ },
+
+ _closePanel() {
+ this._isOpen = false;
+ document.getElementById('a11y-panel').style.display = 'none';
+ const btn = document.getElementById('a11y-btn');
+ btn.setAttribute('aria-expanded', 'false');
+ btn.focus();
+ }
+};
+
+/**
+ * Notification-Center: Glocke mit Badge + History-Panel.
+ */
+const NotificationCenter = {
+ _notifications: [],
+ _unreadCount: 0,
+ _isOpen: false,
+ _maxItems: 50,
+ _syncTimer: null,
+
+ async init() {
+ // Glocken-Container dynamisch in .header-right vor #header-user einfügen
+ const headerRight = document.querySelector('.header-right');
+ const headerUser = document.getElementById('header-user');
+ if (!headerRight || !headerUser) return;
+
+ const container = document.createElement('div');
+ container.className = 'notification-center';
+ container.innerHTML = `
+
+
+
+
+
+ 0
+
+
+
+
+
Keine Benachrichtigungen
+
+
+ `;
+ headerRight.insertBefore(container, headerUser);
+
+ // Event-Listener
+ document.getElementById('notification-bell').addEventListener('click', (e) => {
+ e.stopPropagation();
+ this.toggle();
+ });
+ document.getElementById('notification-mark-read').addEventListener('click', (e) => {
+ e.stopPropagation();
+ this.markAllRead();
+ });
+ // Klick außerhalb schließt Panel
+ document.addEventListener('click', (e) => {
+ if (this._isOpen && !container.contains(e.target)) {
+ this.close();
+ }
+ });
+
+ // Notifications aus DB laden
+ await this._loadFromDB();
+ },
+
+ add(notification) {
+ // Optimistisches UI: sofort anzeigen
+ notification.read = false;
+ notification.timestamp = notification.timestamp || new Date().toISOString();
+ this._notifications.unshift(notification);
+ if (this._notifications.length > this._maxItems) {
+ this._notifications.pop();
+ }
+ this._unreadCount++;
+ this._updateBadge();
+ this._renderList();
+
+ // DB-Sync mit Debounce (Orchestrator schreibt parallel in DB)
+ clearTimeout(this._syncTimer);
+ this._syncTimer = setTimeout(() => this._syncFromDB(), 500);
+ },
+
+ toggle() {
+ this._isOpen ? this.close() : this.open();
+ },
+
+ open() {
+ this._isOpen = true;
+ const panel = document.getElementById('notification-panel');
+ if (panel) panel.style.display = 'flex';
+ const bell = document.getElementById('notification-bell');
+ if (bell) bell.setAttribute('aria-expanded', 'true');
+ },
+
+ close() {
+ this._isOpen = false;
+ const panel = document.getElementById('notification-panel');
+ if (panel) panel.style.display = 'none';
+ const bell = document.getElementById('notification-bell');
+ if (bell) bell.setAttribute('aria-expanded', 'false');
+ },
+
+ async markAllRead() {
+ this._notifications.forEach(n => n.read = true);
+ this._unreadCount = 0;
+ this._updateBadge();
+ this._renderList();
+
+ // In DB als gelesen markieren (fire-and-forget)
+ try {
+ await API.markNotificationsRead(null);
+ } catch (e) {
+ console.warn('Notifications als gelesen markieren fehlgeschlagen:', e);
+ }
+ },
+
+ _updateBadge() {
+ const badge = document.getElementById('notification-badge');
+ if (!badge) return;
+ if (this._unreadCount > 0) {
+ badge.style.display = 'flex';
+ badge.textContent = this._unreadCount > 99 ? '99+' : this._unreadCount;
+ document.title = `(${this._unreadCount}) ${App._originalTitle}`;
+ } else {
+ badge.style.display = 'none';
+ document.title = App._originalTitle;
+ }
+ },
+
+ _renderList() {
+ const list = document.getElementById('notification-panel-list');
+ if (!list) return;
+
+ if (this._notifications.length === 0) {
+ list.innerHTML = ('' + (typeof T === 'function' ? T('notifications.empty', 'Keine Benachrichtigungen') : 'Keine Benachrichtigungen') + '
');
+ return;
+ }
+
+ list.innerHTML = this._notifications.map(n => {
+ const time = new Date(n.timestamp);
+ const timeStr = time.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', timeZone: TIMEZONE });
+ const unreadClass = n.read ? '' : ' unread';
+ const icon = n.icon || 'info';
+ return `
+
${this._iconSymbol(icon)}
+
+
${this._escapeHtml(n.title)}
+
${this._escapeHtml(n.text)}
+
+
${timeStr}
+
`;
+ }).join('');
+ },
+
+ _handleClick(incidentId) {
+ this.close();
+ if (incidentId) {
+ App.selectIncident(incidentId);
+ }
+ },
+
+ _iconSymbol(type) {
+ switch (type) {
+ case 'success': return '\u2713';
+ case 'warning': return '!';
+ case 'error': return '\u2717';
+ default: return 'i';
+ }
+ },
+
+ _escapeHtml(text) {
+ const d = document.createElement('div');
+ d.textContent = text || '';
+ return d.innerHTML;
+ },
+
+ async _loadFromDB() {
+ try {
+ const items = await API.listNotifications(50);
+ this._notifications = items.map(n => ({
+ id: n.id,
+ incident_id: n.incident_id,
+ title: n.title,
+ text: n.text,
+ icon: n.icon || 'info',
+ type: n.type,
+ read: !!n.is_read,
+ timestamp: n.created_at,
+ }));
+ this._unreadCount = this._notifications.filter(n => !n.read).length;
+ this._updateBadge();
+ this._renderList();
+ } catch (e) {
+ console.warn('Notifications laden fehlgeschlagen:', e);
+ }
+ },
+
+ async _syncFromDB() {
+ try {
+ const items = await API.listNotifications(50);
+ this._notifications = items.map(n => ({
+ id: n.id,
+ incident_id: n.incident_id,
+ title: n.title,
+ text: n.text,
+ icon: n.icon || 'info',
+ type: n.type,
+ read: !!n.is_read,
+ timestamp: n.created_at,
+ }));
+ this._unreadCount = this._notifications.filter(n => !n.read).length;
+ this._updateBadge();
+ this._renderList();
+ } catch (e) {
+ console.warn('Notifications sync fehlgeschlagen:', e);
+ }
+ },
+};
+
+const App = {
+ currentIncidentId: null,
+ incidents: [],
+ _originalTitle: document.title,
+ _refreshingIncidents: new Set(),
+ _editingIncidentId: null,
+ _currentArticles: [],
+ _currentSnapshots: [],
+ _snapshotFullCache: new Map(),
+ _currentSources: [],
+ _currentIncidentType: 'adhoc',
+ _sidebarFilter: 'all',
+ _currentUsername: '',
+ _allSources: [],
+ _sourcesOnly: [],
+ _myExclusions: [], // [{domain, notes, created_at}]
+ _expandedGroups: new Set(),
+ _editingSourceId: null,
+ _timelineFilter: 'all',
+ _timelineRange: 'all',
+ _activeStripWindow: null,
+ _timelineSearchTimer: null,
+ _pendingComplete: null,
+ _pendingCompleteTimer: null,
+
+ async init() {
+ ThemeManager.init();
+ A11yManager.init();
+ // Auth prüfen
+ const token = localStorage.getItem('osint_token');
+ if (!token) {
+ window.location.href = '/';
+ return;
+ }
+
+ try {
+ const user = await API.getMe();
+ this.user = user;
+ this._currentUsername = user.email;
+
+ // i18n: Sprache anhand der Org laden (default 'de') und DOM uebersetzen
+ if (window.I18N) {
+ const targetLang = user.output_language || 'de';
+ await window.I18N.load(targetLang);
+ window.I18N.applyDom();
+ }
+
+ document.getElementById('header-user').textContent = user.email;
+
+ // Dropdown-Daten befuellen
+ const orgNameEl = document.getElementById('header-org-name');
+ if (orgNameEl) orgNameEl.textContent = user.org_name || '-';
+
+ const licInfoEl = document.getElementById('header-license-info');
+ if (licInfoEl) {
+ const licenseLabels = {
+ trial: 'Trial',
+ annual: 'Jahreslizenz',
+ permanent: 'Permanent',
+ };
+ const label = user.read_only ? 'Abgelaufen'
+ : licenseLabels[user.license_type] || user.license_status || '-';
+ licInfoEl.textContent = label;
+ }
+
+ // Credits-Anzeige im Dropdown
+ const creditsSection = document.getElementById('credits-section');
+ if (creditsSection && user.credits_total) {
+ creditsSection.style.display = 'block';
+ const bar = document.getElementById('credits-bar');
+ const remainingEl = document.getElementById('credits-remaining');
+ const totalEl = document.getElementById('credits-total');
+
+ const remaining = user.credits_remaining || 0;
+ const total = user.credits_total || 1;
+ const percentUsed = user.credits_percent_used || 0;
+ const percentRemaining = Math.max(0, 100 - percentUsed);
+
+ remainingEl.textContent = remaining.toLocaleString('de-DE');
+ totalEl.textContent = total.toLocaleString('de-DE');
+ bar.style.width = percentRemaining + '%';
+
+ // Farbwechsel je nach Verbrauch
+ bar.classList.remove('warning', 'critical');
+ if (percentUsed > 80) {
+ bar.classList.add('critical');
+ } else if (percentUsed > 50) {
+ bar.classList.add('warning');
+ }
+ const percentEl = document.getElementById("credits-percent");
+ if (percentEl) percentEl.textContent = percentRemaining.toFixed(0) + "% verbleibend";
+ }
+
+ // Dropdown Toggle
+ const userBtn = document.getElementById('header-user-btn');
+ const userDropdown = document.getElementById('header-user-dropdown');
+ if (userBtn && userDropdown) {
+ userBtn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ const isOpen = userDropdown.classList.toggle('open');
+ userBtn.setAttribute('aria-expanded', isOpen);
+ });
+ userDropdown.addEventListener('click', (e) => {
+ e.stopPropagation();
+ });
+ document.addEventListener('click', () => {
+ userDropdown.classList.remove('open');
+ userBtn.setAttribute('aria-expanded', 'false');
+ });
+ }
+
+ // Warnung bei Read-Only (Lizenz abgelaufen oder Token-Budget aufgebraucht)
+ const warningEl = document.getElementById('header-license-warning');
+ if (warningEl) {
+ if (user.read_only) {
+ let text = 'Nur Lesezugriff';
+ const reason = user.read_only_reason;
+ if (reason === 'budget_exceeded') {
+ text = 'Token-Budget aufgebraucht – nur Lesezugriff. Für Aufstockung oder Upgrade bitte info@aegis-sight.de kontaktieren.';
+ } else if (reason === 'expired') {
+ text = (typeof T === 'function' ? T('license.expired_readonly', 'Lizenz abgelaufen – nur Lesezugriff') : 'Lizenz abgelaufen – nur Lesezugriff');
+ } else if (reason === 'no_license') {
+ text = (typeof T === 'function' ? T('license.none_readonly', 'Keine aktive Lizenz – nur Lesezugriff') : 'Keine aktive Lizenz – nur Lesezugriff');
+ } else if (reason === 'org_disabled') {
+ text = (typeof T === 'function' ? T('license.org_disabled_readonly', 'Organisation deaktiviert – nur Lesezugriff') : 'Organisation deaktiviert – nur Lesezugriff');
+ }
+ warningEl.textContent = text;
+ warningEl.classList.add('visible');
+ } else {
+ warningEl.textContent = '';
+ warningEl.classList.remove('visible');
+ }
+ }
+
+ // --- Global Admin: Org-Switcher (herausnehmbar) ---
+ if (user.is_global_admin) {
+ this._initOrgSwitcher(user.tenant_id);
+ }
+
+ // Tutorial nur bei deutscher Org starten -- englische Demo-Mandanten
+ // sollen direkt im Dashboard landen.
+ try {
+ const lang = (window.I18N && window.I18N.lang) || 'de';
+ if (lang === 'de' && typeof Tutorial !== 'undefined' && Tutorial.init) {
+ Tutorial.init();
+ }
+ } catch (e) { /* Tutorial optional */ }
+ } catch {
+ window.location.href = '/';
+ return;
+ }
+
+ // Event-Listener
+ document.getElementById('logout-btn').addEventListener('click', () => this.logout());
+ document.getElementById('new-incident-btn').addEventListener('click', () => openModal('modal-new'));
+ document.getElementById('new-incident-form').addEventListener('submit', (e) => this.handleFormSubmit(e));
+ document.getElementById('refresh-btn').addEventListener('click', () => this.handleRefresh());
+ document.getElementById('delete-incident-btn').addEventListener('click', () => this.handleDelete());
+ document.getElementById('edit-incident-btn').addEventListener('click', () => this.handleEdit());
+ document.getElementById('archive-incident-btn').addEventListener('click', () => this.handleArchive());
+ document.getElementById('inc-international').addEventListener('change', () => updateSourcesHint());
+ document.getElementById('inc-visibility').addEventListener('change', () => updateVisibilityHint());
+ // Telegram-Kategorien Toggle
+ const tgCheckbox = document.getElementById('inc-telegram');
+ if (tgCheckbox) {
+
+ }
+
+
+ // Feedback
+ document.getElementById('feedback-form').addEventListener('submit', (e) => this.submitFeedback(e));
+ document.getElementById('fb-message').addEventListener('input', (e) => {
+ document.getElementById('fb-char-count').textContent = e.target.value.length.toLocaleString('de-DE');
+ });
+
+ // Sidebar-Chevrons initial auf offen setzen (Archiv geschlossen)
+ document.querySelectorAll('.sidebar-chevron').forEach(c => c.classList.add('open'));
+ document.getElementById('chevron-archived-incidents').classList.remove('open');
+
+ // Lagen laden (frueh, damit Sidebar sofort sichtbar)
+ await this.loadIncidents();
+
+ // Netzwerkanalysen laden
+
+ // Notification-Center initialisieren
+ try { await NotificationCenter.init(); } catch (e) { console.warn('NotificationCenter:', e); }
+
+ // WebSocket
+ WS.connect();
+ WS.on('status_update', (msg) => this.handleStatusUpdate(msg));
+ WS.on('refresh_complete', (msg) => this.handleRefreshComplete(msg));
+ WS.on('refresh_summary', (msg) => this.handleRefreshSummary(msg));
+ WS.on('refresh_error', (msg) => this.handleRefreshError(msg));
+ WS.on('refresh_cancelled', (msg) => this.handleRefreshCancelled(msg));
+
+ // Laufende Refreshes wiederherstellen
+ try {
+ const data = await API.getRefreshingIncidents();
+ const details = data.details || {};
+ const currentTask = data.current;
+ const queuedIds = data.queued || [];
+
+ // Restore running refreshes
+ if (data.refreshing && data.refreshing.length > 0) {
+ data.refreshing.forEach(id => {
+ this._refreshingIncidents.add(id);
+ const d = details[String(id)] || {};
+ const inc = this.incidents.find(i => i.id === id);
+ const isFirst = inc && !inc.has_summary;
+ const isCurrent = (id === currentTask);
+ // Use 'researching' as default step for the actively running task
+ UI.showProgress(isCurrent ? 'researching' : 'queued', { started_at: d.started_at }, id, isFirst);
+ });
+ }
+
+ // Restore queued incidents
+ if (queuedIds.length > 0) {
+ queuedIds.forEach((id, idx) => {
+ this._refreshingIncidents.add(id);
+ const inc = this.incidents.find(i => i.id === id);
+ const isFirst = inc && !inc.has_summary;
+ UI.showProgress('queued', { queue_position: idx + 1 }, id, isFirst);
+ // Pipeline-Reset auch nach F5: aktive Lage in Queue -> Icons grau
+ if (id === this.currentIncidentId && typeof Pipeline !== 'undefined' && Pipeline.beginQueue) {
+ Pipeline.beginQueue(id);
+ }
+ });
+ }
+
+ if (data.refreshing.length > 0 || queuedIds.length > 0) {
+ this.renderSidebar();
+ }
+ } catch (e) { /* Kein kritischer Fehler */ }
+
+ // Heartbeat: periodischer Status-Abgleich als Sicherheitsnetz
+ this._statusSyncInterval = setInterval(() => this.syncRefreshStatus(), 60000);
+
+ // Zuletzt ausgewählte Lage wiederherstellen
+ const savedId = localStorage.getItem('selectedIncidentId');
+ if (savedId) {
+ const id = parseInt(savedId, 10);
+ if (this.incidents.some(inc => inc.id === id)) {
+ await this.selectIncident(id);
+ }
+ }
+
+ // Leaflet-Karte nachladen falls CDN langsam war
+ setTimeout(() => UI.retryPendingMap(), 2000);
+ },
+
+ async loadIncidents() {
+ try {
+ this.incidents = await API.listIncidents();
+ this.renderSidebar();
+ } catch (err) {
+ UI.showToast('Fehler beim Laden der Lagen: ' + err.message, 'error');
+ }
+ },
+
+ renderSidebar() {
+ const activeContainer = document.getElementById('active-incidents');
+ const researchContainer = document.getElementById('active-research');
+ const archivedContainer = document.getElementById('archived-incidents');
+
+ // Filter-Buttons aktualisieren
+ document.querySelectorAll('.sidebar-filter-btn').forEach(btn => {
+ const isActive = btn.dataset.filter === this._sidebarFilter;
+ btn.classList.toggle('active', isActive);
+ btn.setAttribute('aria-pressed', String(isActive));
+ });
+
+ // Lagen nach Filter einschränken
+ let filtered = this.incidents;
+ if (this._sidebarFilter === 'mine') {
+ filtered = filtered.filter(i => i.created_by_username === this._currentUsername);
+ }
+
+ // Aktive Lagen nach Typ aufteilen
+ const activeAdhoc = filtered.filter(i => i.status === 'active' && (!i.type || i.type === 'adhoc'));
+ const activeResearch = filtered.filter(i => i.status === 'active' && i.type === 'research');
+ const archived = filtered.filter(i => i.status === 'archived');
+
+ const _tEmpty = (k, fb) => (typeof T === 'function') ? T(k, fb) : fb;
+ const emptyLabelAdhoc = this._sidebarFilter === 'mine'
+ ? _tEmpty('sidebar.empty_adhoc_mine', 'Kein eigenes Live-Monitoring')
+ : _tEmpty('sidebar.empty_adhoc', 'Kein Live-Monitoring');
+ const emptyLabelResearch = this._sidebarFilter === 'mine'
+ ? _tEmpty('sidebar.empty_research_mine', 'Keine eigenen Deep-Research')
+ : _tEmpty('sidebar.empty_research', 'Keine Deep-Research');
+
+ activeContainer.innerHTML = activeAdhoc.length
+ ? activeAdhoc.map(i => UI.renderIncidentItem(i, i.id === this.currentIncidentId)).join('')
+ : `${emptyLabelAdhoc}
`;
+
+ researchContainer.innerHTML = activeResearch.length
+ ? activeResearch.map(i => UI.renderIncidentItem(i, i.id === this.currentIncidentId)).join('')
+ : `${emptyLabelResearch}
`;
+
+ archivedContainer.innerHTML = archived.length
+ ? archived.map(i => UI.renderIncidentItem(i, i.id === this.currentIncidentId)).join('')
+ : 'Kein Archiv
';
+
+ // Zähler aktualisieren
+ const countAdhoc = document.getElementById('count-active-incidents');
+ const countResearch = document.getElementById('count-active-research');
+ const countArchived = document.getElementById('count-archived-incidents');
+ if (countAdhoc) countAdhoc.textContent = `(${activeAdhoc.length})`;
+ if (countResearch) countResearch.textContent = `(${activeResearch.length})`;
+ if (countArchived) countArchived.textContent = `(${archived.length})`;
+
+ // Sidebar-Stats aktualisieren
+ this.updateSidebarStats();
+ },
+
+ setSidebarFilter(filter) {
+ this._sidebarFilter = filter;
+ this.renderSidebar();
+ },
+
+ _announceForSR(text) {
+ let el = document.getElementById('sr-announcement');
+ if (!el) {
+ el = document.createElement('div');
+ el.id = 'sr-announcement';
+ el.setAttribute('role', 'status');
+ el.setAttribute('aria-live', 'polite');
+ el.className = 'sr-only';
+ document.body.appendChild(el);
+ }
+ el.textContent = '';
+ requestAnimationFrame(() => { el.textContent = text; });
+ },
+
+ async selectIncident(id) {
+ this.closeRefreshHistory();
+ this.currentIncidentId = id;
+ localStorage.setItem('selectedIncidentId', id);
+ const inc = this.incidents.find(i => i.id === id);
+ if (inc) this._announceForSR('Lage ausgewählt: ' + inc.title);
+ this.renderSidebar();
+
+ var mc = document.getElementById("main-content");
+ mc.scrollTop = 0;
+
+ document.getElementById('empty-state').style.display = 'none';
+ document.getElementById('incident-view').style.display = 'flex';
+
+ // GridStack-Animation deaktivieren und Scroll komplett sperren
+ // bis alle Tile-Resize-Operationen (doppeltes rAF) abgeschlossen sind
+ var gridEl = document.querySelector('.tab-panels');
+ if (gridEl) gridEl.classList.remove('grid-stack-animate');
+ var scrollLock = function() { mc.scrollTop = 0; };
+ mc.addEventListener('scroll', scrollLock);
+
+ // gridstack-Layout initialisieren (einmalig)
+ if (typeof LayoutManager !== 'undefined') LayoutManager.init();
+
+ // Refresh-Status fuer diese Lage wiederherstellen
+ const isRefreshing = this._refreshingIncidents.has(id);
+ this._updateRefreshButton(isRefreshing);
+ // Hide any popup/mini from previous incident
+ const prevOverlay = document.getElementById('progress-overlay');
+ if (prevOverlay) prevOverlay.style.display = 'none';
+ const prevMini = document.getElementById('progress-mini');
+ if (prevMini) prevMini.style.display = 'none';
+ const blurTarget = document.getElementById('incident-view');
+ // Wenn gerade ein erster Refresh laeuft, Blur stehen lassen statt
+ // remove+add im selben Tick — CSS filter:blur greift sonst nicht.
+ const _restState = isRefreshing ? UI._progressState[id] : null;
+ const _willReBlur = _restState && _restState.isFirst && !_restState.minimized;
+ if (blurTarget && !_willReBlur) blurTarget.classList.remove('refresh-blurred');
+
+ if (isRefreshing) {
+ const state = UI._progressState[id];
+ if (state) {
+ // Restore exactly as it was: popup open or minimized
+ if (state.minimized) {
+ UI._showMiniProgress(state.step, state);
+ } else {
+ UI._showPopupProgress(state.step, {}, state);
+ }
+ UI._lockActionsIfFirst(state.isFirst);
+ } else {
+ // No state yet — show popup (first status update will refine)
+ UI.showProgress('researching', {}, id, false);
+ }
+ } else {
+ UI._lockActionsIfFirst(false);
+ }
+
+// Alte Inhalte sofort leeren um Flackern beim Wechsel zu vermeiden
+ var el;
+ el = document.getElementById("incident-title"); if (el) el.textContent = "";
+ el = document.getElementById("summary-content"); if (el) el.scrollTop = 0;
+ el = document.getElementById("summary-text"); if (el) el.innerHTML = "";
+ el = document.getElementById("zusammenfassung-text"); if (el) el.innerHTML = "";
+ el = document.getElementById("factcheck-filters"); if (el) el.innerHTML = "";
+ el = document.querySelector(".factcheck-list"); if (el) el.scrollTop = 0;
+ el = document.getElementById("factcheck-list"); if (el) el.innerHTML = "";
+ el = document.getElementById("source-overview-content"); if (el) el.innerHTML = "";
+ el = document.getElementById("source-overview-header-stats"); if (el) el.textContent = "";
+ el = document.getElementById("timeline-entries"); if (el) el.innerHTML = "";
+ await this.loadIncidentDetail(id);
+
+ // Scroll-Sperre nach 3 Frames aufheben (nach allen doppelten rAF-Callbacks)
+ mc.scrollTop = 0;
+ requestAnimationFrame(() => {
+ requestAnimationFrame(() => {
+ requestAnimationFrame(() => {
+ mc.scrollTop = 0;
+ mc.removeEventListener('scroll', scrollLock);
+ if (gridEl) gridEl.classList.add('grid-stack-animate');
+ });
+ });
+ });
+
+
+
+ },
+
+ async loadIncidentDetail(id) {
+ try {
+ const [incident, articlesResponse, factchecks, snapshots, locationsResponse, sourcesResponse] = await Promise.all([
+ API.getIncident(id),
+ API.getArticles(id, { limit: 500, offset: 0 }),
+ API.getFactChecks(id),
+ API.getSnapshots(id),
+ API.getLocations(id).catch(() => []),
+ API.getIncidentSources(id).catch(() => ({ sources: [] })),
+ ]);
+
+ // Sources-Array (ersetzt frueheres incident.sources_json — lazy via /sources-Endpunkt)
+ this._currentSources = (sourcesResponse && sourcesResponse.sources) || [];
+
+ // Articles: neue Shape {total, articles} oder alter nackter Array (Rueckwaertskompatibel)
+ let articles, articlesTotal;
+ if (Array.isArray(articlesResponse)) {
+ articles = articlesResponse;
+ articlesTotal = articlesResponse.length;
+ } else {
+ articles = articlesResponse.articles || [];
+ articlesTotal = articlesResponse.total || articles.length;
+ }
+
+ // Locations-API gibt jetzt {category_labels, locations} oder Array (Rueckwaertskompatibel)
+ let locations, categoryLabels;
+ if (Array.isArray(locationsResponse)) {
+ locations = locationsResponse;
+ categoryLabels = null;
+ } else if (locationsResponse && locationsResponse.locations) {
+ locations = locationsResponse.locations;
+ categoryLabels = locationsResponse.category_labels || null;
+ } else {
+ locations = [];
+ categoryLabels = null;
+ }
+
+ this._currentArticlesTotal = articlesTotal;
+ this._currentArticlesLoaded = articles.length;
+ this._currentIncidentIdForLoad = id;
+
+ this.renderIncidentDetail(incident, articles, factchecks, snapshots, locations, categoryLabels);
+
+ // Pipeline an die geladene Lage binden (laedt /api/incidents/{id}/pipeline)
+ if (typeof Pipeline !== 'undefined' && Pipeline.bindToIncident) {
+ Pipeline.bindToIncident(id).catch(err => console.warn('pipeline-bind:', err));
+ }
+
+ // Quellenuebersicht aus Aggregat-Endpunkt (alle Quellen, nicht nur erste Seite)
+ this._loadSourcesSummary(id).catch(err => console.warn('sources-summary:', err));
+
+ // Wenn mehr Artikel existieren als initial geladen: progressiver Hintergrund-Load
+ if (articlesTotal > articles.length) {
+ this._loadRemainingArticlesInBackground(id).catch(err => console.warn('bg-articles:', err));
+ }
+ } catch (err) {
+ console.error('loadIncidentDetail Fehler:', err);
+ UI.showToast('Fehler beim Laden: ' + err.message, 'error');
+ }
+ },
+
+ /** Quellenuebersicht aus Aggregat-Endpunkt nachladen (ersetzt Client-Zaehlung). */
+ async _loadSourcesSummary(incidentId) {
+ const data = await API.getArticlesSourcesSummary(incidentId);
+ if (this.currentIncidentId !== incidentId) return; // User hat gewechselt
+ this._currentSourcesSummary = data;
+ const soEl = document.getElementById('source-overview-content');
+ const statsEl = document.getElementById('source-overview-header-stats');
+ if (soEl && typeof UI.renderSourceOverviewFromSummary === 'function') {
+ soEl.innerHTML = UI.renderSourceOverviewFromSummary(data);
+ }
+ if (statsEl && data) {
+ statsEl.textContent = `${data.total} Artikel aus ${data.sources.length} Quellen`;
+ }
+ },
+
+ /** Klick auf eine Quellen-Box: Liste der Artikel inline aufklappen (mutual-exclusive). */
+ toggleSourceOverviewDetail(el) {
+ if (!el) return;
+ const grid = el.parentElement;
+ if (!grid) return;
+ const sourceName = el.dataset.source || '';
+ const wasActive = el.classList.contains('active');
+
+ // Alle anderen schliessen + bestehendes Detail entfernen
+ grid.querySelectorAll('.source-overview-item.active').forEach(it => {
+ it.classList.remove('active');
+ it.setAttribute('aria-expanded', 'false');
+ });
+ const existingDetail = grid.querySelector('.source-overview-detail');
+ if (existingDetail) existingDetail.remove();
+
+ // Wenn das geklickte Item bereits aktiv war: nur schliessen
+ if (wasActive) return;
+
+ // Neues Detail einfuegen direkt nach dem geklickten Item
+ el.classList.add('active');
+ el.setAttribute('aria-expanded', 'true');
+
+ const type = this._currentIncidentType;
+ const getDate = (a) => (type === 'research' && a.published_at) ? a.published_at : (a.collected_at || a.published_at);
+ const articles = (this._currentArticles || [])
+ .filter(a => (a.source || 'Unbekannt') === sourceName)
+ .sort((a, b) => {
+ const ta = new Date(getDate(a) || 0).getTime();
+ const tb = new Date(getDate(b) || 0).getTime();
+ return tb - ta;
+ });
+
+ // Lagebild-Quellennummer pro Artikel ermitteln (matcht Artikel zu sources_json)
+ const normalize = (s) => (s || '').toLowerCase().replace(/^(der|die|das)\s+/, '').replace(/\s+/g, ' ').trim();
+ const sourcesList = this._currentSources || [];
+ const urlToNr = new Map();
+ sourcesList.forEach(s => {
+ if (s.url && s.nr != null) urlToNr.set(String(s.url).trim(), s.nr);
+ });
+ const findNr = (a) => {
+ // 1) Exakter URL-Match
+ if (a.source_url) {
+ const exact = urlToNr.get(String(a.source_url).trim());
+ if (exact != null) return exact;
+ }
+ // 2) Fallback: Match via Quellen-Namen (kann mehrfach treffen, nimm erstes)
+ if (a.source) {
+ const target = normalize(a.source);
+ const hit = sourcesList.find(s => s.nr != null && normalize(s.name) === target);
+ if (hit) return hit.nr;
+ }
+ return null;
+ };
+
+ const detail = document.createElement('div');
+ detail.className = 'source-overview-detail';
+ if (articles.length === 0) {
+ detail.innerHTML = 'Keine Artikel gefunden.
';
+ } else {
+ const fmtDate = (ts) => {
+ if (!ts) return '—';
+ try {
+ const d = new Date(ts);
+ if (isNaN(d.getTime())) return '—';
+ return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit', timeZone: TIMEZONE })
+ + ' '
+ + d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', timeZone: TIMEZONE });
+ } catch (e) { return '—'; }
+ };
+ const items = articles.map(a => {
+ const nr = findNr(a);
+ const numHtml = nr != null
+ ? `[${UI.escape(String(nr))}] `
+ : `— `;
+ const dateStr = fmtDate(getDate(a));
+ const headline = UI.escape(a.headline_de || a.headline || '(ohne Titel)');
+ const inner = a.source_url
+ ? `${headline} `
+ : headline;
+ return `
+ ${numHtml}
+ ${UI.escape(dateStr)}
+ ${inner}
+ `;
+ }).join('');
+ detail.innerHTML = ``;
+ }
+ el.insertAdjacentElement('afterend', detail);
+ },
+
+ /** Restliche Artikel seitenweise im Hintergrund nachladen und in _currentArticles mergen. */
+ async _loadRemainingArticlesInBackground(incidentId) {
+ const BATCH = 500;
+ while (this.currentIncidentId === incidentId
+ && this._currentArticlesLoaded < this._currentArticlesTotal) {
+ let resp;
+ try {
+ resp = await API.getArticles(incidentId, { limit: BATCH, offset: this._currentArticlesLoaded });
+ } catch (err) {
+ console.warn('Hintergrund-Load Artikel fehlgeschlagen:', err);
+ return;
+ }
+ if (this.currentIncidentId !== incidentId) return;
+ const batch = (resp && resp.articles) ? resp.articles : (Array.isArray(resp) ? resp : []);
+ if (!batch.length) break;
+ this._currentArticles = (this._currentArticles || []).concat(batch);
+ this._currentArticlesLoaded += batch.length;
+ this.rerenderTimeline();
+ // Kleiner Yield, damit das UI reaktiv bleibt
+ await new Promise(r => setTimeout(r, 30));
+ }
+ },
+
+ renderIncidentDetail(incident, articles, factchecks, snapshots, locations, categoryLabels) {
+ // Header Strip
+ { const _e = document.getElementById('incident-title'); if (_e) _e.textContent = incident.title; }
+ { const _e = document.getElementById('incident-description'); if (_e) _e.textContent = incident.description || ''; }
+
+ // Typ-Badge
+ const typeBadge = document.getElementById('incident-type-badge');
+ typeBadge.className = 'incident-type-badge ' + (incident.type === 'research' ? 'type-research' : 'type-adhoc');
+ typeBadge.textContent = incident.type === 'research' ? 'Analyse' : 'Live';
+
+ // Kachel-Label: 'Recherchebericht' fuer Recherche-Lagen, 'Lagebild' fuer Live-Monitoring
+ const _tI18n = (k, fb) => (typeof T === 'function') ? T(k, fb) : fb;
+ const _lbLabel = incident.type === 'research'
+ ? _tI18n('tab.summary_report', 'Recherchebericht')
+ : _tI18n('card.summary', 'Lagebild');
+ const _cardTitle = document.querySelector('#panel-lagebild .card-title');
+ if (_cardTitle) _cardTitle.textContent = _lbLabel;
+ if (typeof LayoutManager !== 'undefined' && typeof LayoutManager.applyTypeLabels === 'function') {
+ LayoutManager.applyTypeLabels(incident.type);
+ }
+ {
+ const _nt = document.querySelector("#inc-notify-summary");
+ if (_nt) {
+ const _ns = _nt.closest("label")?.querySelector(".toggle-text");
+ if (_ns) {
+ _ns.textContent = incident.type === 'research'
+ ? _tI18n('modal.notify.summary_research', 'Neuer Recherchebericht')
+ : _tI18n('modal.notify.summary', 'Neues Lagebild');
+ }
+ }
+ }
+
+ // Archiv-Button Text
+ this._updateArchiveButton(incident.status);
+
+ // Ersteller anzeigen
+ const creatorEl = document.getElementById('incident-creator');
+ if (creatorEl) {
+ creatorEl.textContent = (incident.created_by_username || '').split('@')[0];
+ }
+
+ // Delete-Button: nur Ersteller darf löschen
+ const deleteBtn = document.getElementById('delete-incident-btn');
+ const isCreator = incident.created_by_username === this._currentUsername;
+ deleteBtn.disabled = !isCreator;
+ deleteBtn.title = isCreator ? '' : `Nur ${(incident.created_by_username || '').split('@')[0]} kann diese Lage löschen`;
+
+ // Zusammenfassung-Kachel + Lagebild-Kachel aufteilen
+ const zusammenfassungText = document.getElementById('zusammenfassung-text');
+ const summaryText = document.getElementById('summary-text');
+ const zusammenfassungCard = document.getElementById('zusammenfassung-card');
+ const zusammenfassungTitle = zusammenfassungCard ? zusammenfassungCard.querySelector('.card-title') : null;
+
+ if (incident.type === 'research') {
+ // Recherche: ZUSAMMENFASSUNG-Sektion aus Briefing extrahieren
+ if (zusammenfassungTitle) zusammenfassungTitle.textContent = (typeof T === 'function') ? T('tab.summary_short', 'Zusammenfassung') : 'Zusammenfassung';
+ if (incident.summary) {
+ const { zusammenfassung, remaining } = UI.extractZusammenfassung(incident.summary);
+ if (zusammenfassung) {
+ if (zusammenfassungText) zusammenfassungText.innerHTML = UI.renderZusammenfassung(zusammenfassung, this._currentSources);
+ if (zusammenfassungCard) zusammenfassungCard.style.display = '';
+ summaryText.innerHTML = UI.renderSummary(remaining, this._currentSources, incident.type);
+ } else {
+ if (zusammenfassungText) zusammenfassungText.innerHTML = 'Zusammenfassung wird beim n\u00e4chsten Refresh generiert. ';
+ if (zusammenfassungCard) zusammenfassungCard.style.display = '';
+ summaryText.innerHTML = UI.renderSummary(incident.summary, this._currentSources, incident.type);
+ }
+ } else {
+ if (zusammenfassungCard) zusammenfassungCard.style.display = 'none';
+ summaryText.innerHTML = 'Noch keine Zusammenfassung. Klicke auf "Aktualisieren" um die Recherche zu starten. ';
+ }
+ } else {
+ // Live-Monitoring (adhoc): Kachel zeigt "Neueste Entwicklungen" (max 8 Bullets mit Zeitstempel)
+ if (zusammenfassungTitle) zusammenfassungTitle.textContent = (typeof T === 'function') ? T('tab.latest_developments', 'Neueste Entwicklungen') : 'Neueste Entwicklungen';
+ if (zusammenfassungCard) zusammenfassungCard.style.display = '';
+ const devText = (incident.latest_developments || '').trim();
+ if (devText) {
+ if (zusammenfassungText) zusammenfassungText.innerHTML = UI.renderLatestDevelopments(devText, this._currentSources);
+ } else if (zusammenfassungText) {
+ zusammenfassungText.innerHTML = 'Noch keine Entwicklungen erfasst. Wird beim n\u00e4chsten Refresh generiert. ';
+ }
+ if (incident.summary) {
+ summaryText.innerHTML = UI.renderSummary(incident.summary, this._currentSources, incident.type);
+ } else {
+ summaryText.innerHTML = 'Noch kein Lagebild. Klicke auf "Aktualisieren" um die Recherche zu starten. ';
+ }
+ }
+
+ // Meta (im Header-Strip) — relative Zeitangabe mit vollem Datum als Tooltip
+ const updated = incident.updated_at ? parseUTC(incident.updated_at) : null;
+ const metaUpdated = document.getElementById('meta-updated');
+ if (updated) {
+ const fullDate = `${updated.toLocaleDateString('de-DE', { timeZone: TIMEZONE })} ${updated.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', timeZone: TIMEZONE })}`;
+ metaUpdated.textContent = `Stand: ${App._timeAgo(updated)}`;
+ metaUpdated.title = fullDate;
+ } else {
+ metaUpdated.textContent = '';
+ metaUpdated.title = '';
+ }
+
+ // Zeitstempel direkt im Lagebild-Card-Header
+ const lagebildTs = document.getElementById('lagebild-timestamp');
+ if (lagebildTs) {
+ lagebildTs.textContent = updated
+ ? `Stand: ${updated.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', timeZone: TIMEZONE })} ${updated.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', timeZone: TIMEZONE })} Uhr`
+ : '';
+ }
+
+ { const _e = document.getElementById('meta-refresh-mode'); if (_e) {
+ if (incident.refresh_mode === 'auto' && incident.refresh_start_time) {
+ const intervalText = App._formatInterval(incident.refresh_interval);
+ _e.textContent = `Auto alle ${intervalText} ab ${incident.refresh_start_time} Uhr`;
+ } else if (incident.refresh_mode === 'auto') {
+ _e.textContent = `Auto alle ${App._formatInterval(incident.refresh_interval)}`;
+ } else {
+ _e.textContent = 'Manuell';
+ }
+ } }
+
+ // International-Badge
+ const intlBadge = document.getElementById('intl-badge');
+ if (intlBadge) {
+ const isIntl = incident.international_sources !== false && incident.international_sources !== 0;
+ intlBadge.className = 'intl-badge ' + (isIntl ? 'intl-yes' : 'intl-no');
+ intlBadge.textContent = isIntl ? 'International' : 'Nur DE';
+ }
+
+ // Faktencheck
+ const fcFilters = document.getElementById('fc-filters');
+ const factcheckList = document.getElementById('factcheck-list');
+ if (factchecks.length > 0) {
+ fcFilters.innerHTML = UI.renderFactCheckFilters(factchecks);
+ factcheckList.innerHTML = factchecks.map(fc => UI.renderFactCheck(fc)).join('');
+ } else {
+ fcFilters.innerHTML = '';
+ factcheckList.innerHTML = 'Noch keine Fakten geprüft
';
+ }
+
+ // Quellenuebersicht wird aus dem Aggregat-Endpunkt (_loadSourcesSummary) gefuellt,
+ // damit sie immer alle Artikel der Lage zeigt — unabhaengig von Paginierung.
+ const sourceOverview = document.getElementById('source-overview-content');
+ if (sourceOverview) {
+ sourceOverview.innerHTML = 'Quellenübersicht wird geladen…
';
+ }
+ const _soStats = document.getElementById("source-overview-header-stats");
+ if (_soStats) {
+ const total = (this._currentArticlesTotal != null) ? this._currentArticlesTotal : articles.length;
+ _soStats.textContent = total + " Artikel";
+ }
+
+ // Timeline - Artikel + Snapshots zwischenspeichern und rendern
+ this._currentArticles = articles;
+ this._currentSnapshots = snapshots || [];
+ this._snapshotFullCache = new Map();
+ this._currentIncidentType = incident.type;
+
+ // Tab-Auswahl: gemerkt pro Lage (localStorage), Default = erster Tab
+ if (typeof LayoutManager !== 'undefined' && typeof LayoutManager.restoreTabFor === 'function') {
+ LayoutManager.restoreTabFor(incident.id);
+ }
+ this._timelineFilter = 'all';
+ this._timelineRange = 'all';
+ this._activeStripWindow = null;
+ const _tsEl = document.getElementById('timeline-search'); if (_tsEl) _tsEl.value = '';
+ document.querySelectorAll('.ht-filter-btn').forEach(btn => {
+ const isActive = btn.dataset.filter === 'all';
+ btn.classList.toggle('active', isActive);
+ btn.setAttribute('aria-pressed', String(isActive));
+ });
+ document.querySelectorAll('.ht-range-btn').forEach(btn => {
+ const isActive = btn.dataset.range === 'all';
+ btn.classList.toggle('active', isActive);
+ btn.setAttribute('aria-pressed', String(isActive));
+ });
+ this.rerenderTimeline();
+ this._resizeTimelineTile();
+
+ // Karte rendern
+ UI.renderMap(locations || [], categoryLabels);
+ },
+
+ _collectEntries(filterType, searchTerm, range) {
+ const type = this._currentIncidentType;
+ const getArticleDate = (a) => (type === 'research' && a.published_at) ? a.published_at : a.collected_at;
+
+ let entries = [];
+
+ if (filterType === 'all' || filterType === 'articles') {
+ let articles = this._currentArticles || [];
+ if (searchTerm) {
+ articles = articles.filter(a => {
+ const text = `${a.headline || ''} ${a.headline_de || ''} ${a.source || ''} ${a.content_de || ''} ${a.content_original || ''}`.toLowerCase();
+ return text.includes(searchTerm);
+ });
+ }
+ articles.forEach(a => entries.push({ kind: 'article', data: a, timestamp: getArticleDate(a) || '' }));
+ }
+
+ if (filterType === 'all' || filterType === 'snapshots') {
+ let snapshots = this._currentSnapshots || [];
+ if (searchTerm) {
+ // Suche erfolgt clientseitig ueber Preview (Snapshots-Liste enthaelt keinen Volltext mehr).
+ // Die asynchrone Volltext-Server-Suche wird separat ausgeloest (rerenderTimeline).
+ snapshots = snapshots.filter(s => (s.summary_preview || s.summary || '').toLowerCase().includes(searchTerm));
+ }
+ snapshots.forEach(s => entries.push({ kind: 'snapshot', data: s, timestamp: s.created_at || '' }));
+ }
+
+ if (range && range !== 'all') {
+ const now = Date.now();
+ const cutoff = range === '24h' ? now - 24 * 60 * 60 * 1000 : now - 7 * 24 * 60 * 60 * 1000;
+ entries = entries.filter(e => new Date(e.timestamp || 0).getTime() >= cutoff);
+ }
+
+ return entries;
+ },
+
+ _updateTimelineCount(entries) {
+ const articleCount = entries.filter(e => e.kind === 'article').length;
+ const snapshotCount = entries.filter(e => e.kind === 'snapshot').length;
+ const countEl = document.getElementById('article-count');
+ if (!countEl) return;
+ if (articleCount > 0 && snapshotCount > 0) {
+ countEl.innerHTML = ` ${articleCount} Meldung${articleCount !== 1 ? 'en' : ''} + ${snapshotCount} Lagebericht${snapshotCount !== 1 ? 'e' : ''}`;
+ } else if (articleCount > 0) {
+ countEl.innerHTML = ` ${articleCount} Meldung${articleCount !== 1 ? 'en' : ''}`;
+ } else if (snapshotCount > 0) {
+ countEl.innerHTML = ` ${snapshotCount} Lagebericht${snapshotCount !== 1 ? 'e' : ''}`;
+ } else {
+ countEl.textContent = '0 Meldungen';
+ }
+ },
+
+ debouncedRerenderTimeline() {
+ clearTimeout(this._timelineSearchTimer);
+ this._timelineSearchTimer = setTimeout(() => this.rerenderTimeline(), 250);
+ },
+
+ /** Heatmap-Strip oben + vertikaler Newsfeed-Stream darunter.
+ * Klick auf Heatmap-Balken: Stream filtert auf das Zeitfenster (aktive Balken hervorgehoben).
+ */
+ rerenderTimeline() {
+ const container = document.getElementById('timeline');
+ if (!container) return;
+ const searchTerm = (document.getElementById('timeline-search')?.value || '').toLowerCase();
+ const filterType = this._timelineFilter;
+ const range = this._timelineRange;
+
+ let entries = this._collectEntries(filterType, searchTerm, range);
+ this._updateTimelineCount(entries);
+
+ // Strip nutzt IMMER alle Eintraege im Range (unabhaengig von Filter/Search/Strip-Window)
+ const stripEntries = this._collectEntries('all', '', range);
+ stripEntries.sort((a, b) => new Date(a.timestamp || 0) - new Date(b.timestamp || 0));
+
+ // Wenn ein Heatmap-Balken aktiv ist: Stream zusaetzlich auf dieses Zeitfenster filtern
+ const win = this._activeStripWindow;
+ if (win && entries.length > 0) {
+ entries = entries.filter(e => {
+ const ts = new Date(e.timestamp || 0).getTime();
+ return ts >= win.start && ts < win.end;
+ });
+ }
+
+ let html = '';
+ if (stripEntries.length > 0) {
+ html += this._renderTimelineStrip(stripEntries);
+ }
+
+ // Banner mit aktivem Filter
+ if (win) {
+ html += `
+ ▼
+ Gefiltert auf ${UI.escape(win.label)} · ${entries.length} Eintr${entries.length === 1 ? 'ag' : 'äge'}
+ Filter aufheben
+
`;
+ }
+
+ html += '
';
+ if (entries.length === 0) {
+ html += win
+ ? '
Keine Einträge in diesem Zeitfenster.
'
+ : (searchTerm || range !== 'all')
+ ? '
Keine Einträge im gewählten Zeitraum.
'
+ : '
Noch keine Meldungen. Starte eine Recherche mit "Aktualisieren".
';
+ } else {
+ html += this._renderVerticalStream(entries);
+ }
+ html += '
';
+ html += '
';
+ container.innerHTML = html;
+ },
+
+ /** Granularitaets-Heuristik fuer den Newsfeed: Stunden bei kurzen Spannen, sonst Tage. */
+ _calcGranularity(entries) {
+ if (!entries || entries.length < 2) return 'day';
+ const ts = entries.map(e => new Date(e.timestamp || 0).getTime()).filter(t => t > 0);
+ if (ts.length < 2) return 'day';
+ const span = Math.max(...ts) - Math.min(...ts);
+ if (span <= 48 * 60 * 60 * 1000) return 'hour';
+ return 'day';
+ },
+
+ /** Vertikaler Stream: Datums-Trennzeilen + Lagebericht-Sektionen + Meldungen. */
+ _renderVerticalStream(entries) {
+ if (!entries || entries.length === 0) {
+ return 'Keine Einträge.
';
+ }
+ // Neueste oben
+ const sorted = [...entries].sort((a, b) => new Date(b.timestamp || 0) - new Date(a.timestamp || 0));
+ const granularity = this._calcGranularity(sorted);
+ const groups = this._groupByTimePeriod(sorted, granularity);
+
+ let html = '';
+ groups.forEach(g => {
+ const groupId = 'vt-grp-' + g.key.replace(/[^a-z0-9]/gi, '-');
+ html += `
`;
+ html += `
${UI.escape(g.label)}
`;
+ html += this._renderTimeGroupEntries(g.entries, this._currentIncidentType);
+ html += `
`;
+ });
+ html += '
';
+ return html;
+ },
+
+ /* ======= Quanti-Strip ======= */
+ _stripGranularity(stripEntries) {
+ if (stripEntries.length < 2) return 'day';
+ const ts = stripEntries.map(e => new Date(e.timestamp || 0).getTime()).filter(t => t > 0);
+ if (ts.length < 2) return 'day';
+ const span = Math.max(...ts) - Math.min(...ts);
+ const DAY = 86400000;
+ if (span <= 2 * DAY) return 'hour';
+ if (span <= 60 * DAY) return 'day';
+ if (span <= 365 * DAY) return 'week';
+ return 'month';
+ },
+
+ _buildStripBuckets(stripEntries, granularity) {
+ if (stripEntries.length === 0) return [];
+ const ts = stripEntries.map(e => new Date(e.timestamp || 0).getTime()).filter(t => t > 0);
+ if (ts.length === 0) return [];
+ const minTs = Math.min(...ts);
+ const maxTs = Math.max(...ts);
+
+ // Bucket-Start fuer minTs ermitteln
+ const minDate = new Date(minTs);
+ const tzMin = _tz(minDate);
+ let firstStart;
+ let stepMs;
+ if (granularity === 'hour') {
+ firstStart = new Date(tzMin.year, tzMin.month, tzMin.date, tzMin.hours).getTime();
+ stepMs = 3600000;
+ } else if (granularity === 'day') {
+ firstStart = new Date(tzMin.year, tzMin.month, tzMin.date).getTime();
+ stepMs = 86400000;
+ } else if (granularity === 'week') {
+ const dow = (minDate.getDay() + 6) % 7; // 0=Mo
+ firstStart = new Date(tzMin.year, tzMin.month, tzMin.date - dow).getTime();
+ stepMs = 7 * 86400000;
+ } else {
+ firstStart = new Date(tzMin.year, tzMin.month, 1).getTime();
+ stepMs = null; // dynamisch (Monatsgrenzen)
+ }
+
+ const buckets = [];
+ const fmt = (t) => {
+ const d = new Date(t);
+ if (granularity === 'hour') return d.toLocaleString('de-DE', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit', timeZone: TIMEZONE });
+ if (granularity === 'day') return d.toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: 'short', timeZone: TIMEZONE });
+ if (granularity === 'week') return 'Woche ab ' + d.toLocaleDateString('de-DE', { day: '2-digit', month: 'short', timeZone: TIMEZONE });
+ return d.toLocaleDateString('de-DE', { month: 'long', year: 'numeric', timeZone: TIMEZONE });
+ };
+
+ if (granularity === 'month') {
+ let d = new Date(firstStart);
+ while (d.getTime() <= maxTs && buckets.length < 240) {
+ const start = d.getTime();
+ const next = new Date(d.getFullYear(), d.getMonth() + 1, 1).getTime();
+ buckets.push({ start, end: next, label: fmt(start), articles: 0, snapshots: 0 });
+ d = new Date(next);
+ }
+ } else {
+ for (let t = firstStart; t <= maxTs && buckets.length < 240; t += stepMs) {
+ buckets.push({ start: t, end: t + stepMs, label: fmt(t), articles: 0, snapshots: 0 });
+ }
+ }
+
+ // Eintraege zaehlen
+ stripEntries.forEach(e => {
+ const ets = new Date(e.timestamp || 0).getTime();
+ // Linear-Suche, da Buckets sortiert; bei vielen Buckets ggf. Binary
+ for (let i = 0; i < buckets.length; i++) {
+ if (ets >= buckets[i].start && ets < buckets[i].end) {
+ if (e.kind === 'article') buckets[i].articles++;
+ else if (e.kind === 'snapshot') buckets[i].snapshots++;
+ break;
+ }
+ }
+ });
+
+ return buckets;
+ },
+
+ _renderTimelineStrip(stripEntries) {
+ const granularity = this._stripGranularity(stripEntries);
+ const buckets = this._buildStripBuckets(stripEntries, granularity);
+ if (buckets.length === 0) return '';
+
+ const maxCount = Math.max(1, ...buckets.map(b => b.articles));
+ const win = this._activeStripWindow;
+
+ let html = '';
+ html += '
';
+ buckets.forEach(b => {
+ const intensity = b.articles > 0 ? Math.min(1, b.articles / maxCount) : 0;
+ const cls = ['ht-strip-cell'];
+ if (b.snapshots > 0) cls.push('has-snapshot');
+ if (b.articles === 0 && b.snapshots === 0) cls.push('empty');
+ if (win && win.start === b.start && win.end === b.end) cls.push('active');
+ const tip = `${b.label}: ${b.articles} Meldung${b.articles === 1 ? '' : 'en'}` +
+ (b.snapshots > 0 ? ` + ${b.snapshots} Lagebericht${b.snapshots === 1 ? '' : 'e'}` : '');
+ // data-Attribute statt JSON-String im onclick-Inline (vermeidet Quote-Konflikte bei Labels mit Komma/Anführungszeichen)
+ html += `
`;
+ });
+ html += '
';
+
+ // Wenige Datums-Labels unter dem Strip
+ const labelCount = Math.min(buckets.length, 6);
+ const stride = Math.max(1, Math.floor(buckets.length / labelCount));
+ const labelTexts = [];
+ for (let i = 0; i < buckets.length; i += stride) {
+ const b = buckets[i];
+ const d = new Date(b.start);
+ let txt;
+ if (granularity === 'hour') txt = d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', timeZone: TIMEZONE });
+ else if (granularity === 'day') txt = d.toLocaleDateString('de-DE', { day: '2-digit', month: 'short', timeZone: TIMEZONE });
+ else if (granularity === 'week') txt = 'KW ' + d.toLocaleDateString('de-DE', { day: '2-digit', month: 'short', timeZone: TIMEZONE });
+ else txt = d.toLocaleDateString('de-DE', { month: 'short', year: '2-digit', timeZone: TIMEZONE });
+ labelTexts.push({ text: txt, idx: i });
+ }
+ if (labelTexts.length) {
+ html += '
';
+ const seen = new Set(labelTexts.map(l => l.idx));
+ for (let i = 0; i < buckets.length; i++) {
+ if (seen.has(i)) {
+ const t = labelTexts.find(l => l.idx === i).text;
+ html += `
${UI.escape(t)}
`;
+ } else {
+ html += '
';
+ }
+ }
+ html += '
';
+ }
+ html += '
';
+ return html;
+ },
+
+ setTimelineFilter(filter) {
+ this._timelineFilter = filter;
+ this._activeStripWindow = null;
+ document.querySelectorAll('.ht-filter-btn').forEach(btn => {
+ const isActive = btn.dataset.filter === filter;
+ btn.classList.toggle('active', isActive);
+ btn.setAttribute('aria-pressed', String(isActive));
+ });
+ this.rerenderTimeline();
+ },
+
+ setTimelineRange(range) {
+ this._timelineRange = range;
+ this._activeStripWindow = null;
+ document.querySelectorAll('.ht-range-btn').forEach(btn => {
+ const isActive = btn.dataset.range === range;
+ btn.classList.toggle('active', isActive);
+ btn.setAttribute('aria-pressed', String(isActive));
+ });
+ this.rerenderTimeline();
+ },
+
+ /** Robuster Click-Handler fuer Heatmap-Cells (vermeidet Quote-Konflikte). */
+ handleStripClick(el) {
+ if (!el) return;
+ const start = parseInt(el.dataset.start, 10);
+ const end = parseInt(el.dataset.end, 10);
+ const label = el.dataset.label || '';
+ if (!isNaN(start) && !isNaN(end)) {
+ this.openTimelineWindow(start, end, label);
+ }
+ },
+
+ /** Klick auf Heatmap-Balken: Stream auf dieses Zeitfenster filtern.
+ * Zweiter Klick auf denselben Balken hebt den Filter auf.
+ */
+ openTimelineWindow(startMs, endMs, label) {
+ const win = this._activeStripWindow;
+ if (win && win.start === startMs && win.end === endMs) {
+ this._activeStripWindow = null;
+ } else {
+ this._activeStripWindow = { start: startMs, end: endMs, label: label || '' };
+ }
+ this.rerenderTimeline();
+ },
+
+ /** Strip-Filter aufheben (z.B. via Banner-Button). */
+ clearStripWindow() {
+ this._activeStripWindow = null;
+ this.rerenderTimeline();
+ },
+
+ _resizeTimelineTile() {
+ // Tab-Modus: Kein internes Resize noetig, Panel waechst mit Inhalt.
+ // Wir scrollen lediglich ein offenes Detail in den sichtbaren Bereich.
+ requestAnimationFrame(() => { requestAnimationFrame(() => {
+ const card = document.querySelector('.timeline-card');
+ if (!card) return;
+ const cardBottom = card.getBoundingClientRect().bottom;
+ const viewBottom = window.innerHeight;
+ if (cardBottom > viewBottom) {
+ window.scrollBy({ top: cardBottom - viewBottom + 16, behavior: document.documentElement.dataset.a11yMotion ? 'instant' : 'smooth' });
+ }
+ }); });
+ },
+
+ _buildFullVerticalTimeline(filterType, searchTerm) {
+ let entries = this._collectEntries(filterType, searchTerm);
+ if (entries.length === 0) {
+ return 'Keine Einträge.
';
+ }
+
+ entries.sort((a, b) => new Date(b.timestamp || 0) - new Date(a.timestamp || 0));
+ const granularity = this._calcGranularity(entries);
+ const groups = this._groupByTimePeriod(entries, granularity);
+
+ let html = '';
+ groups.forEach(g => {
+ html += `
`;
+ html += `
${UI.escape(g.label)}
`;
+ html += this._renderTimeGroupEntries(g.entries, this._currentIncidentType);
+ html += `
`;
+ });
+ html += '
';
+ return html;
+ },
+
+ /**
+ * Einträge nach Zeitperiode gruppieren.
+ */
+ _groupByTimePeriod(entries, granularity) {
+ const np = _tz(new Date());
+ const todayKey = `${np.year}-${np.month}-${np.date}`;
+ const yp = _tz(new Date(Date.now() - 86400000));
+ const yesterdayKey = `${yp.year}-${yp.month}-${yp.date}`;
+
+ const groups = [];
+ let currentGroup = null;
+
+ entries.forEach(entry => {
+ const d = entry.timestamp ? new Date(entry.timestamp) : null;
+ let key, label;
+
+ if (!d || isNaN(d.getTime())) {
+ key = 'unknown';
+ label = 'Unbekannt';
+ } else if (granularity === 'hour') {
+ const ep = _tz(d);
+ key = `${ep.year}-${ep.month}-${ep.date}-${ep.hours}`;
+ label = `${ep.hours.toString().padStart(2, '0')}:00 Uhr`;
+ } else {
+ const ep = _tz(d);
+ key = `${ep.year}-${ep.month}-${ep.date}`;
+ if (key === todayKey) {
+ label = 'Heute';
+ } else if (key === yesterdayKey) {
+ label = 'Gestern';
+ } else {
+ label = d.toLocaleDateString('de-DE', { weekday: 'short', day: 'numeric', month: 'short', timeZone: TIMEZONE });
+ }
+ }
+
+ if (!currentGroup || currentGroup.key !== key) {
+ currentGroup = { key, label, entries: [] };
+ groups.push(currentGroup);
+ }
+ currentGroup.entries.push(entry);
+ });
+
+ return groups;
+ },
+
+ /**
+ * Entries einer Zeitgruppe rendern, mit Cluster-Erkennung.
+ */
+ _renderTimeGroupEntries(entries, type) {
+ // Cluster-Erkennung: ≥4 Artikel pro Minute
+ const minuteCounts = {};
+ entries.forEach(e => {
+ if (e.kind === 'article') {
+ const mk = this._getMinuteKey(e.timestamp);
+ minuteCounts[mk] = (minuteCounts[mk] || 0) + 1;
+ }
+ });
+
+ const minuteRendered = {};
+ let html = '';
+
+ entries.forEach(e => {
+ if (e.kind === 'snapshot') {
+ html += this._renderSnapshotEntry(e.data);
+ } else {
+ const mk = this._getMinuteKey(e.timestamp);
+ const isCluster = minuteCounts[mk] >= 4;
+ const isFirstInCluster = isCluster && !minuteRendered[mk];
+ if (isFirstInCluster) minuteRendered[mk] = true;
+ html += this._renderArticleEntry(e.data, type, isFirstInCluster ? minuteCounts[mk] : 0);
+ }
+ });
+
+ return html;
+ },
+
+ /**
+ * Artikel-Eintrag für den Zeitstrahl rendern.
+ */
+ _renderArticleEntry(article, type, clusterCount) {
+ const dateField = (type === 'research' && article.published_at)
+ ? article.published_at : article.collected_at;
+ const time = dateField
+ ? (parseUTC(dateField) || new Date(dateField)).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', timeZone: TIMEZONE })
+ : '--:--';
+
+ const headline = article.headline_de || article.headline;
+ const sourceUrl = article.source_url
+ ? `${UI.escape(article.source)} `
+ : UI.escape(article.source);
+
+ const langBadge = article.language && article.language !== 'de'
+ ? `${article.language.toUpperCase()} ` : '';
+
+ const clusterBadge = clusterCount > 0
+ ? `${clusterCount} ` : '';
+
+ const content = article.content_de || article.content_original || '';
+ const hasContent = content.length > 0;
+
+ let detailHtml = '';
+ if (hasContent) {
+ const truncated = content.length > 400 ? content.substring(0, 400) + '...' : content;
+ detailHtml = `
+
${UI.escape(truncated)}
+ ${article.source_url ? `
Artikel öffnen → ` : ''}
+
`;
+ }
+
+ return `
+
+ ${time}
+ ${sourceUrl}
+ ${langBadge}${clusterBadge}
+
+
${UI.escape(headline)}
+ ${detailHtml}
+
`;
+ },
+
+ /**
+ * Snapshot/Lagebericht-Eintrag für den Zeitstrahl rendern.
+ * Volltext + sources_json werden erst beim Aufklappen lazy nachgeladen.
+ */
+ _renderSnapshotEntry(snapshot) {
+ const time = snapshot.created_at
+ ? (parseUTC(snapshot.created_at) || new Date(snapshot.created_at)).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', timeZone: TIMEZONE })
+ : '--:--';
+
+ const stats = [];
+ if (snapshot.article_count) stats.push(`${snapshot.article_count} Artikel`);
+ if (snapshot.fact_check_count) stats.push(`${snapshot.fact_check_count} Fakten`);
+ const statsText = stats.join(', ');
+
+ // Vorschau: erste 200 Zeichen aus summary_preview (vom Server gekuerzt) oder Fallback summary
+ const previewText = snapshot.summary_preview || snapshot.summary || '';
+ const preview = previewText.length > 200 ? previewText.substring(0, 200) + '...' : previewText;
+
+ // Volltext aus Cache (falls bereits geladen), sonst Platzhalter fuer Lazy-Load
+ const cached = this._snapshotFullCache && this._snapshotFullCache.get(snapshot.id);
+ const detailHtml = cached
+ ? UI.renderSummary(cached.summary, cached.sources_json, this._currentIncidentType)
+ : 'Lagebericht wird geladen…
';
+ const loadedAttr = cached ? ' data-loaded="yes"' : '';
+
+ return `
+
+
${UI.escape(preview)}
+
${detailHtml}
+
`;
+ },
+
+ /**
+ * Volltext eines Snapshots bei Bedarf nachladen und in das DOM einsetzen.
+ * Ergebnis wird in _snapshotFullCache gecacht.
+ */
+ async lazyLoadSnapshotDetail(el) {
+ if (!el || el.dataset.loaded === 'yes' || el.dataset.loaded === 'loading') return;
+ const snapId = parseInt(el.dataset.snapshotId || '0', 10);
+ if (!snapId || !this.currentIncidentId) return;
+ el.dataset.loaded = 'loading';
+ try {
+ let snap = this._snapshotFullCache.get(snapId);
+ if (!snap) {
+ snap = await API.getSnapshot(this.currentIncidentId, snapId);
+ this._snapshotFullCache.set(snapId, snap);
+ }
+ const detailEl = el.querySelector('.vt-snapshot-detail');
+ if (detailEl) {
+ detailEl.innerHTML = UI.renderSummary(snap.summary, snap.sources_json, this._currentIncidentType);
+ }
+ el.dataset.loaded = 'yes';
+ // Nach dem Laden die Timeline-Kachel an neue Hoehe anpassen
+ if (el.classList.contains('expanded')) this._resizeTimelineTile();
+ } catch (err) {
+ console.error('Snapshot-Volltext laden fehlgeschlagen:', err);
+ el.dataset.loaded = '';
+ const detailEl = el.querySelector('.vt-snapshot-detail');
+ if (detailEl) detailEl.innerHTML = 'Fehler beim Laden des Lageberichts.
';
+ }
+ },
+
+ /**
+ * Timeline-Eintrag auf-/zuklappen (mutual-exclusive pro Zeitgruppe).
+ */
+ toggleTimelineEntry(el) {
+ const container = el.closest('.ht-detail-content') || el.closest('.vt-time-group');
+ if (container) {
+ container.querySelectorAll('.vt-entry.expanded').forEach(item => {
+ if (item !== el) item.classList.remove('expanded');
+ });
+ }
+ el.classList.toggle('expanded');
+ if (el.classList.contains('expanded')) {
+ // Snapshots: Volltext lazy nachladen (nur wenn noch nicht geladen)
+ if (el.classList.contains('vt-snapshot') && el.dataset.snapshotId) {
+ this.lazyLoadSnapshotDetail(el);
+ }
+ requestAnimationFrame(() => {
+ var scrollParent = el.closest('.ht-detail-content');
+ if (scrollParent && el.classList.contains('vt-snapshot')) {
+ scrollParent.scrollTo({ top: 0, behavior: document.documentElement.dataset.a11yMotion ? 'instant' : 'smooth' });
+ } else {
+ el.scrollIntoView({ behavior: document.documentElement.dataset.a11yMotion ? 'instant' : 'smooth', block: 'nearest' });
+ }
+ });
+ }
+ // Timeline-Kachel an Inhalt anpassen
+ this._resizeTimelineTile();
+ },
+
+ /**
+ * Minutenschlüssel für Cluster-Erkennung.
+ */
+ _getMinuteKey(timestamp) {
+ if (!timestamp) return 'none';
+ const d = new Date(timestamp);
+ const p = _tz(d);
+ return `${p.year}-${p.month}-${p.date}-${p.hours}-${p.minutes}`;
+ },
+
+ // === Event Handlers ===
+
+ _getFormData() {
+ const value = parseInt(document.getElementById('inc-refresh-value').value) || 15;
+ const unit = parseInt(document.getElementById('inc-refresh-unit').value) || 1;
+ const interval = Math.max(10, Math.min(10080, value * unit));
+ return {
+ title: document.getElementById('inc-title').value.trim(),
+ description: document.getElementById('inc-description').value.trim() || null,
+ type: document.getElementById('inc-type').value,
+ refresh_mode: document.getElementById('inc-refresh-mode').value,
+ refresh_interval: interval,
+ refresh_start_time: document.getElementById('inc-refresh-mode').value === 'auto'
+ ? document.getElementById('inc-refresh-starttime').value || null
+ : null,
+ retention_days: parseInt(document.getElementById('inc-retention').value) || 0,
+ international_sources: document.getElementById('inc-international').checked,
+ include_telegram: document.getElementById('inc-telegram').checked,
+ visibility: document.getElementById('inc-visibility').checked ? 'public' : 'private',
+ };
+ },
+
+ _clearFormErrors(formEl) {
+ formEl.querySelectorAll('.form-error').forEach(el => el.remove());
+ formEl.querySelectorAll('[aria-invalid]').forEach(el => {
+ el.removeAttribute('aria-invalid');
+ el.removeAttribute('aria-describedby');
+ });
+ },
+
+ _showFieldError(field, message) {
+ field.setAttribute('aria-invalid', 'true');
+ const errorId = field.id + '-error';
+ field.setAttribute('aria-describedby', errorId);
+ const errorEl = document.createElement('div');
+ errorEl.className = 'form-error';
+ errorEl.id = errorId;
+ errorEl.setAttribute('role', 'alert');
+ errorEl.textContent = message;
+ field.parentNode.appendChild(errorEl);
+ },
+
+ async handleFormSubmit(e) {
+ e.preventDefault();
+ const submitBtn = document.getElementById('modal-new-submit');
+ const form = document.getElementById('new-incident-form');
+ this._clearFormErrors(form);
+
+ // Validierung
+ const titleField = document.getElementById('inc-title');
+ if (!titleField.value.trim()) {
+ this._showFieldError(titleField, 'Bitte einen Titel eingeben.');
+ titleField.focus();
+ return;
+ }
+
+ submitBtn.disabled = true;
+
+ try {
+ const data = this._getFormData();
+
+ if (this._editingIncidentId) {
+ // Edit-Modus: ID sichern bevor closeModal sie löscht
+ const editId = this._editingIncidentId;
+ await API.updateIncident(editId, data);
+
+ // E-Mail-Subscription speichern
+ await API.updateSubscription(editId, {
+ notify_email_summary: document.getElementById('inc-notify-summary').checked,
+ notify_email_new_articles: document.getElementById('inc-notify-new-articles').checked,
+ notify_email_status_change: document.getElementById('inc-notify-status-change').checked,
+ });
+
+ closeModal('modal-new');
+ await this.loadIncidents();
+ await this.loadIncidentDetail(editId);
+ UI.showToast((typeof T === 'function' ? T('toast.incident_updated', 'Lage aktualisiert.') : 'Lage aktualisiert.'), 'success');
+ } else {
+ // Create-Modus
+ const incident = await API.createIncident(data);
+
+ // E-Mail-Subscription speichern
+ await API.updateSubscription(incident.id, {
+ notify_email_summary: document.getElementById('inc-notify-summary').checked,
+ notify_email_new_articles: document.getElementById('inc-notify-new-articles').checked,
+ notify_email_status_change: document.getElementById('inc-notify-status-change').checked,
+ });
+
+ closeModal('modal-new');
+
+ await this.loadIncidents();
+
+ // Refresh-Status VOR selectIncident setzen, damit selectIncident
+ // beim Oeffnen sofort Blur + Aktions-Lock setzt (statt sie erst
+ // per WebSocket-Nachricht spaeter wieder zu aktivieren — dazwischen
+ // war der Fallinhalt kurzzeitig unblurred und klickbar).
+ this._refreshingIncidents.add(incident.id);
+ UI._progressState[incident.id] = {
+ step: 'queued', isFirst: true, startTime: null, minimized: false,
+ };
+
+ await this.selectIncident(incident.id);
+
+ this._updateRefreshButton(true);
+ await API.refreshIncident(incident.id);
+ UI.showToast(`Lage "${incident.title}" angelegt. Recherche gestartet.`, 'success');
+ }
+ } catch (err) {
+ UI.showToast('Fehler: ' + err.message, 'error');
+ } finally {
+ submitBtn.disabled = false;
+ this._editingIncidentId = null;
+ }
+ },
+
+async generateDescription() {
+ const title = document.getElementById('inc-title').value.trim();
+ const description = document.getElementById('inc-description').value.trim();
+ const type = document.getElementById('inc-type').value;
+ const btn = document.getElementById('btn-enhance-description');
+ const btnText = document.getElementById('enhance-btn-text');
+ const spinner = document.getElementById('enhance-spinner');
+ const textarea = document.getElementById('inc-description');
+
+ if (title.length < 3 || !btn) return;
+
+ // Vorherigen Request abbrechen falls noch aktiv
+ if (this._enhanceController) this._enhanceController.abort();
+ this._enhanceController = new AbortController();
+
+ btn.disabled = true;
+ btnText.textContent = (typeof T === 'function') ? T('modal.new_incident.enhance_loading', 'Wird generiert...') : 'Wird generiert...';
+ spinner.style.display = '';
+ textarea.readOnly = true;
+ textarea.classList.add('textarea--loading');
+
+ try {
+ const result = await API.enhanceDescription(title, description || null, type, this._enhanceController.signal);
+ textarea.value = result.description;
+ _autoResizeTextarea(textarea);
+ } catch (err) {
+ if (err.name === 'AbortError') {
+ // still
+ } else {
+ let msg = (typeof T === 'function') ? T('enhance.error_default', 'Beschreibung konnte nicht generiert werden') : 'Beschreibung konnte nicht generiert werden';
+ if (err.status === 503) msg = (typeof T === 'function') ? T('enhance.error_unavailable', 'KI-Zugang aktuell nicht verfügbar. Bitte Administrator kontaktieren.') : 'KI-Zugang aktuell nicht verfügbar. Bitte Administrator kontaktieren.';
+ else if (err.status === 429) msg = (typeof T === 'function') ? T('enhance.error_busy', 'KI ist gerade ausgelastet. Bitte kurz warten und erneut versuchen.') : 'KI ist gerade ausgelastet. Bitte kurz warten und erneut versuchen.';
+ else if (err.status === 504) msg = (typeof T === 'function') ? T('enhance.error_timeout', 'KI antwortet gerade nicht. Bitte erneut versuchen.') : 'KI antwortet gerade nicht. Bitte erneut versuchen.';
+ else if (err.status === 403) msg = err.detail || 'Zugriff verweigert.';
+ UI.showToast(msg, 'error');
+ }
+ } finally {
+ btnText.textContent = (typeof T === 'function') ? T('modal.new_incident.enhance', 'Beschreibung generieren') : 'Beschreibung generieren';
+ spinner.style.display = 'none';
+ btn.disabled = title.length < 3;
+ textarea.readOnly = false;
+ textarea.classList.remove('textarea--loading');
+ this._enhanceController = null;
+ }
+ },
+
+async handleRefresh() {
+ if (!this.currentIncidentId) return;
+ if (this._refreshingIncidents.has(this.currentIncidentId)) {
+ UI.showToast('Aktualisierung wurde bereits gestartet und ist in Bearbeitung.', 'info');
+ return;
+ }
+ try {
+ this._refreshingIncidents.add(this.currentIncidentId);
+ this._updateRefreshButton(true);
+ // showProgress called via handleStatusUpdate
+ const result = await API.refreshIncident(this.currentIncidentId);
+ // Pipeline auf "pending" setzen, damit alte gruene Haekchen nicht
+ // faelschlich "schon fertig" suggerieren waehrend die Lage in der Queue steht
+ if (typeof Pipeline !== 'undefined' && Pipeline.beginQueue) {
+ Pipeline.beginQueue(this.currentIncidentId);
+ }
+ if (result && result.status === 'skipped') {
+ UI.showToast('Aktualisierung ist in der Warteschlange und wird ausgefuehrt, sobald die aktuelle Recherche abgeschlossen ist.', 'info');
+ } else {
+ UI.showToast((typeof T === 'function' ? T('toast.refresh_started', 'Aktualisierung gestartet.') : 'Aktualisierung gestartet.'), 'success');
+ var _inc2 = this.incidents.find(function(i) { return i.id === this.currentIncidentId; }.bind(this));
+ UI.showProgress('queued', {}, this.currentIncidentId, _inc2 && !_inc2.has_summary);
+ }
+ } catch (err) {
+ this._refreshingIncidents.delete(this.currentIncidentId);
+ this._updateRefreshButton(false);
+ UI.hideProgress();
+ UI.showToast('Fehler: ' + err.message, 'error');
+ }
+ },
+
+ _geoparsePolling: null,
+
+ async triggerGeoparse() {
+ if (!this.currentIncidentId) return;
+ const btn = document.getElementById('geoparse-btn');
+ if (btn) { btn.disabled = true; btn.textContent = (typeof T === 'function' ? T('action.starting', 'Wird gestartet...') : 'Wird gestartet...'); }
+ try {
+ const result = await API.triggerGeoparse(this.currentIncidentId);
+ if (result.status === 'done') {
+ UI.showToast(result.message, 'info');
+ if (btn) { btn.disabled = false; btn.textContent = 'Orte erkennen'; }
+ return;
+ }
+ UI.showToast(result.message, 'info');
+ this._pollGeoparse(this.currentIncidentId);
+ } catch (err) {
+ UI.showToast('Geoparsing fehlgeschlagen: ' + err.message, 'error');
+ if (btn) { btn.disabled = false; btn.textContent = 'Orte erkennen'; }
+ }
+ },
+
+ _pollGeoparse(incidentId) {
+ if (this._geoparsePolling) clearInterval(this._geoparsePolling);
+ const btn = document.getElementById('geoparse-btn');
+ this._geoparsePolling = setInterval(async () => {
+ try {
+ const st = await API.getGeoparseStatus(incidentId);
+ if (st.status === 'running') {
+ if (btn) btn.textContent = `${st.processed}/${st.total} Artikel...`;
+ } else {
+ clearInterval(this._geoparsePolling);
+ this._geoparsePolling = null;
+ if (btn) { btn.disabled = false; btn.textContent = 'Orte erkennen'; }
+ if (st.status === 'done' && st.locations > 0) {
+ UI.showToast(`${st.locations} Orte aus ${st.processed} Artikeln erkannt`, 'success');
+ const locResp = await API.getLocations(incidentId).catch(() => []);
+ let locs, catLabels;
+ if (Array.isArray(locResp)) { locs = locResp; catLabels = null; }
+ else if (locResp && locResp.locations) { locs = locResp.locations; catLabels = locResp.category_labels || null; }
+ else { locs = []; catLabels = null; }
+ UI.renderMap(locs, catLabels);
+ } else if (st.status === 'done') {
+ UI.showToast('Keine neuen Orte gefunden', 'info');
+ } else if (st.status === 'error') {
+ UI.showToast('Geoparsing fehlgeschlagen: ' + (st.error || ''), 'error');
+ }
+ }
+ } catch {
+ clearInterval(this._geoparsePolling);
+ this._geoparsePolling = null;
+ if (btn) { btn.disabled = false; btn.textContent = 'Orte erkennen'; }
+ }
+ }, 3000);
+ },
+
+ _formatInterval(minutes) {
+ if (minutes >= 10080 && minutes % 10080 === 0) {
+ const w = minutes / 10080;
+ return w === 1 ? '1 Woche' : `${w} Wochen`;
+ }
+ if (minutes >= 1440 && minutes % 1440 === 0) {
+ const d = minutes / 1440;
+ return d === 1 ? '1 Tag' : `${d} Tage`;
+ }
+ if (minutes >= 60 && minutes % 60 === 0) {
+ const h = minutes / 60;
+ return h === 1 ? '1 Stunde' : `${h} Stunden`;
+ }
+ return `${minutes} Min.`;
+ },
+
+ _setIntervalFields(minutes) {
+ let value, unit;
+ if (minutes >= 10080 && minutes % 10080 === 0) {
+ value = minutes / 10080; unit = '10080';
+ } else if (minutes >= 1440 && minutes % 1440 === 0) {
+ value = minutes / 1440; unit = '1440';
+ } else if (minutes >= 60 && minutes % 60 === 0) {
+ value = minutes / 60; unit = '60';
+ } else {
+ value = minutes; unit = '1';
+ }
+ const input = document.getElementById('inc-refresh-value');
+ input.value = value;
+ input.min = unit === '1' ? 10 : 1;
+ { const _e = document.getElementById('inc-refresh-unit'); if (_e) _e.value = unit; }
+ },
+
+ _refreshHistoryOpen: false,
+
+ toggleRefreshHistory() {
+ if (this._refreshHistoryOpen) {
+ this.closeRefreshHistory();
+ } else {
+ this._openRefreshHistory();
+ }
+ },
+
+ async _openRefreshHistory() {
+ if (!this.currentIncidentId) return;
+ const popover = document.getElementById('refresh-history-popover');
+ if (!popover) return;
+
+ this._refreshHistoryOpen = true;
+ popover.style.display = 'flex';
+
+ // Lade Refresh-Log
+ const list = document.getElementById('refresh-history-list');
+ list.innerHTML = 'Lade...
';
+
+ try {
+ const logs = await API.getRefreshLog(this.currentIncidentId, 20);
+ this._renderRefreshHistory(logs);
+ } catch (e) {
+ list.innerHTML = 'Fehler beim Laden
';
+ }
+
+ // Outside-Click Listener
+ setTimeout(() => {
+ const handler = (e) => {
+ if (!popover.contains(e.target) && !e.target.closest('.meta-updated-link')) {
+ this.closeRefreshHistory();
+ document.removeEventListener('click', handler);
+ }
+ };
+ document.addEventListener('click', handler);
+ popover._outsideHandler = handler;
+ }, 0);
+ },
+
+ closeRefreshHistory() {
+ this._refreshHistoryOpen = false;
+ const popover = document.getElementById('refresh-history-popover');
+ if (popover) {
+ popover.style.display = 'none';
+ if (popover._outsideHandler) {
+ document.removeEventListener('click', popover._outsideHandler);
+ delete popover._outsideHandler;
+ }
+ }
+ },
+
+ _renderRefreshHistory(logs) {
+ const list = document.getElementById('refresh-history-list');
+ if (!list) return;
+
+ if (!logs || logs.length === 0) {
+ list.innerHTML = 'Noch keine Refreshes durchgeführt
';
+ return;
+ }
+
+ list.innerHTML = logs.map(log => {
+ const started = parseUTC(log.started_at) || new Date(log.started_at);
+ const timeStr = started.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', timeZone: TIMEZONE }) + ' ' +
+ started.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', timeZone: TIMEZONE });
+
+ let detail = '';
+ if (log.status === 'completed') {
+ detail = `${log.articles_found} Artikel`;
+ if (log.duration_seconds != null) {
+ detail += ` in ${this._formatDuration(log.duration_seconds)}`;
+ }
+ } else if (log.status === 'running') {
+ detail = 'Läuft...';
+ } else if (log.status === 'error') {
+ detail = '';
+ }
+
+ const retryInfo = log.retry_count > 0 ? ` (Versuch ${log.retry_count + 1})` : '';
+ const errorHtml = log.error_message
+ ? `${log.error_message}
`
+ : '';
+
+ return `
+
+
+
${timeStr}${retryInfo}
+ ${detail ? `
${detail}
` : ''}
+ ${errorHtml}
+
+
${log.trigger_type === 'auto' ? 'Auto' : 'Manuell'}
+
`;
+ }).join('');
+ },
+
+ _formatDuration(seconds) {
+ if (seconds == null) return '';
+ if (seconds < 60) return `${Math.round(seconds)}s`;
+ const m = Math.floor(seconds / 60);
+ const s = Math.round(seconds % 60);
+ return s > 0 ? `${m}m ${s}s` : `${m}m`;
+ },
+
+ _timeAgo(date) {
+ if (!date) return '';
+ const now = new Date();
+ const diff = Math.floor((now - date) / 1000);
+ if (diff < 60) return 'gerade eben';
+ if (diff < 3600) return `vor ${Math.floor(diff / 60)}m`;
+ if (diff < 86400) return `vor ${Math.floor(diff / 3600)}h`;
+ return `vor ${Math.floor(diff / 86400)}d`;
+ },
+
+ _updateRefreshButton(disabled) {
+ const btn = document.getElementById('refresh-btn');
+ if (!btn) return;
+ const _t = (k, fb) => (typeof T === 'function') ? T(k, fb) : fb;
+ // Hard-Stop: Lese-Modus (Budget aufgebraucht / Lizenz abgelaufen) -> immer disabled
+ if (this.user && this.user.read_only) {
+ btn.disabled = true;
+ const reason = this.user.read_only_reason;
+ btn.textContent = reason === 'budget_exceeded'
+ ? _t('action.budget_exceeded', 'Budget aufgebraucht')
+ : _t('action.read_only', 'Nur Lesezugriff');
+ btn.title = reason === 'budget_exceeded'
+ ? _t('action.budget_exceeded_title', 'Token-Budget aufgebraucht. Bitte Verwaltung kontaktieren.')
+ : _t('action.read_only_title', 'Lizenz erlaubt keinen Schreibzugriff');
+ return;
+ }
+ btn.disabled = disabled;
+ btn.textContent = disabled
+ ? _t('action.refreshing', 'Läuft...')
+ : _t('action.refresh', 'Aktualisieren');
+ btn.title = '';
+ },
+
+ async handleDelete() {
+ if (!this.currentIncidentId) return;
+ if (!await confirmDialog('Lage wirklich löschen? Alle gesammelten Daten gehen verloren.')) return;
+
+ try {
+ await API.deleteIncident(this.currentIncidentId);
+ this.currentIncidentId = null;
+ if (typeof LayoutManager !== 'undefined') LayoutManager.destroy();
+ document.getElementById('incident-view').style.display = 'none';
+ document.getElementById('empty-state').style.display = 'flex';
+ await this.loadIncidents();
+ UI.showToast((typeof T === 'function' ? T('toast.incident_deleted', 'Lage gelöscht.') : 'Lage gelöscht.'), 'success');
+ } catch (err) {
+ UI.showToast('Fehler: ' + err.message, 'error');
+ }
+ },
+
+ async handleEdit() {
+ if (!this.currentIncidentId) return;
+ const incident = this.incidents.find(i => i.id === this.currentIncidentId);
+ if (!incident) return;
+
+ this._editingIncidentId = this.currentIncidentId;
+
+ // Formular mit aktuellen Werten füllen
+ { const _e = document.getElementById('inc-title'); if (_e) _e.value = incident.title; }
+ { const _e = document.getElementById('inc-description'); if (_e) { _e.value = incident.description || ''; _autoResizeTextarea(_e); } }
+ { const _b = document.getElementById('btn-enhance-description'); if (_b) _b.disabled = (incident.title || '').trim().length < 3; }
+ { const _e = document.getElementById('inc-type'); if (_e) _e.value = incident.type || 'adhoc'; }
+ { const _e = document.getElementById('inc-refresh-mode'); if (_e) _e.value = incident.refresh_mode; }
+ App._setIntervalFields(incident.refresh_interval);
+ { const _e = document.getElementById('inc-refresh-starttime'); if (_e) _e.value = incident.refresh_start_time || '07:00'; }
+ { const _e = document.getElementById('inc-retention'); if (_e) _e.value = incident.retention_days; }
+ { const _e = document.getElementById('inc-international'); if (_e) _e.checked = incident.international_sources !== false && incident.international_sources !== 0; }
+ { const _e = document.getElementById('inc-telegram'); if (_e) _e.checked = !!incident.include_telegram; }
+
+ { const _e = document.getElementById('inc-visibility'); if (_e) _e.checked = incident.visibility !== 'private'; }
+ updateVisibilityHint();
+ updateSourcesHint();
+ toggleTypeDefaults(true);
+ toggleRefreshInterval();
+
+ // Modal-Titel und Submit ändern
+ { const _e = document.getElementById('modal-new-title'); if (_e) _e.textContent = (typeof T === 'function') ? T('modal.new_incident.edit_title', 'Lage bearbeiten') : 'Lage bearbeiten'; }
+ { const _e = document.getElementById('modal-new-submit'); if (_e) _e.textContent = (typeof T === 'function') ? T('common.save', 'Speichern') : 'Speichern'; }
+
+ // E-Mail-Subscription laden
+ try {
+ const sub = await API.getSubscription(this.currentIncidentId);
+ { const _e = document.getElementById('inc-notify-summary'); if (_e) _e.checked = !!sub.notify_email_summary; }
+ { const _e = document.getElementById('inc-notify-new-articles'); if (_e) _e.checked = !!sub.notify_email_new_articles; }
+ { const _e = document.getElementById('inc-notify-status-change'); if (_e) _e.checked = !!sub.notify_email_status_change; }
+ } catch (e) {
+ { const _e = document.getElementById('inc-notify-summary'); if (_e) _e.checked = false; }
+ { const _e = document.getElementById('inc-notify-new-articles'); if (_e) _e.checked = false; }
+ { const _e = document.getElementById('inc-notify-status-change'); if (_e) _e.checked = false; }
+ }
+
+ openModal('modal-new');
+ },
+
+ async handleArchive() {
+ if (!this.currentIncidentId) return;
+ const incident = this.incidents.find(i => i.id === this.currentIncidentId);
+ if (!incident) return;
+
+ const isArchived = incident.status === 'archived';
+ const action = isArchived ? 'wiederherstellen' : 'archivieren';
+
+ if (!await confirmDialog(`Lage wirklich ${action}?`)) return;
+
+ try {
+ const newStatus = isArchived ? 'active' : 'archived';
+ await API.updateIncident(this.currentIncidentId, { status: newStatus });
+ await this.loadIncidents();
+ await this.loadIncidentDetail(this.currentIncidentId);
+ this._updateArchiveButton(newStatus);
+ UI.showToast(isArchived ? (typeof T === 'function' ? T('toast.incident_restored', 'Lage wiederhergestellt.') : 'Lage wiederhergestellt.') : (typeof T === 'function' ? T('toast.incident_archived', 'Lage archiviert.') : 'Lage archiviert.'), 'success');
+ } catch (err) {
+ UI.showToast('Fehler: ' + err.message, 'error');
+ }
+ },
+
+ _updateSidebarDot(incidentId, mode) {
+ const dot = document.getElementById(`dot-${incidentId}`);
+ if (!dot) return;
+ const incident = this.incidents.find(i => i.id === incidentId);
+ const baseClass = incident ? (incident.status === 'active' ? 'active' : 'archived') : 'active';
+
+ if (mode === 'error') {
+ dot.className = `incident-dot refresh-error`;
+ setTimeout(() => {
+ dot.className = `incident-dot ${baseClass}`;
+ }, 3000);
+ } else if (this._refreshingIncidents.has(incidentId)) {
+ dot.className = `incident-dot refreshing`;
+ } else {
+ dot.className = `incident-dot ${baseClass}`;
+ }
+ },
+
+ _updateArchiveButton(status) {
+ const btn = document.getElementById('archive-incident-btn');
+ if (!btn) return;
+ const _t = (k, fb) => (typeof T === 'function') ? T(k, fb) : fb;
+ btn.textContent = status === 'archived'
+ ? _t('action.restore', 'Wiederherstellen')
+ : _t('action.archive', 'Archivieren');
+ },
+
+ // === WebSocket Handlers ===
+
+ handleStatusUpdate(msg) {
+ const status = msg.data.status;
+ if (status === 'retrying') {
+ if (msg.incident_id === this.currentIncidentId) {
+ UI.showProgressError('', true, msg.data.delay || 120, msg.incident_id);
+ }
+ return;
+ }
+ if (status !== 'idle') {
+ this._refreshingIncidents.add(msg.incident_id);
+ }
+ this._updateSidebarDot(msg.incident_id);
+ // Detect first refresh: no summary means first run
+ const inc = this.incidents.find(i => i.id === msg.incident_id);
+ const isFirst = inc && !inc.has_summary;
+ // Update progress state for ALL incidents (sidebar + popup if current)
+ UI.showProgress(status, msg.data, msg.incident_id, isFirst);
+ // Re-render sidebar so status is baked into HTML (survives future re-renders)
+ this.renderSidebar();
+ if (msg.incident_id === this.currentIncidentId) {
+ this._updateRefreshButton(status !== 'idle');
+ }
+ },
+
+ async handleRefreshComplete(msg) {
+ this._refreshingIncidents.delete(msg.incident_id);
+ this._updateSidebarDot(msg.incident_id);
+ UI._removeSidebarRefreshStatus(msg.incident_id);
+ delete UI._progressState[msg.incident_id];
+ UI._reindexQueuePositions();
+ this.renderSidebar();
+
+ if (msg.incident_id === this.currentIncidentId) {
+ this._updateRefreshButton(false);
+ await this.loadIncidentDetail(msg.incident_id);
+
+ // Progress-Popup nicht sofort ausblenden — auf refresh_summary warten
+ this._pendingComplete = msg.incident_id;
+ if (this._pendingCompleteTimer) clearTimeout(this._pendingCompleteTimer);
+ this._pendingCompleteTimer = setTimeout(() => {
+ if (this._pendingComplete === msg.incident_id) {
+ this._pendingComplete = null;
+ UI.hideProgress(msg.incident_id);
+ }
+ }, 5000);
+ }
+
+ await this.loadIncidents();
+ },
+
+
+
+ handleRefreshSummary(msg) {
+ const d = msg.data;
+ const title = d.incident_title || 'Lage';
+
+ // Abschluss-Animation auslösen wenn pending
+ if (this._pendingComplete === msg.incident_id) {
+ if (this._pendingCompleteTimer) {
+ clearTimeout(this._pendingCompleteTimer);
+ this._pendingCompleteTimer = null;
+ }
+ this._pendingComplete = null;
+ UI.showProgressComplete(d, msg.incident_id);
+ }
+
+ // Toast-Text zusammenbauen
+ const parts = [];
+ if (d.new_articles > 0) {
+ parts.push(`${d.new_articles} neue Meldung${d.new_articles !== 1 ? 'en' : ''}`);
+ }
+ if (d.confirmed_count > 0) {
+ parts.push(`${d.confirmed_count} bestätigt`);
+ }
+ if (d.contradicted_count > 0) {
+ parts.push(`${d.contradicted_count} widersprochen`);
+ }
+ if (d.status_changes && d.status_changes.length > 0) {
+ parts.push(`${d.status_changes.length} Statusänderung${d.status_changes.length !== 1 ? 'en' : ''}`);
+ }
+
+ const summaryText = parts.length > 0
+ ? parts.join(', ')
+ : 'Keine neuen Entwicklungen';
+
+ // 1 Toast statt 5-10
+ UI.showToast(`Recherche abgeschlossen: ${summaryText}`, 'success', 6000);
+
+ // Ins NotificationCenter eintragen
+ NotificationCenter.add({
+ incident_id: msg.incident_id,
+ title: title,
+ text: `Recherche: ${summaryText}`,
+ icon: d.contradicted_count > 0 ? 'warning' : 'success',
+ });
+
+ // Status-Änderungen als separate Einträge
+ if (d.status_changes) {
+ d.status_changes.forEach(sc => {
+ const oldLabel = this._translateStatus(sc.old_status);
+ const newLabel = this._translateStatus(sc.new_status);
+ NotificationCenter.add({
+ incident_id: msg.incident_id,
+ title: title,
+ text: `${sc.claim}: ${oldLabel} \u2192 ${newLabel}`,
+ icon: sc.new_status === 'contradicted' || sc.new_status === 'disputed' ? 'error' : 'success',
+ });
+ });
+ }
+
+ // Sidebar-Dot blinken
+ const dot = document.getElementById(`dot-${msg.incident_id}`);
+ if (dot) {
+ dot.classList.add('has-notification');
+ setTimeout(() => dot.classList.remove('has-notification'), 10000);
+ }
+ },
+
+ _translateStatus(status) {
+ const map = {
+ confirmed: 'Bestätigt',
+ established: 'Gesichert',
+ unconfirmed: 'Unbestätigt',
+ contradicted: 'Widersprochen',
+ disputed: 'Umstritten',
+ developing: 'In Entwicklung',
+ unverified: 'Ungeprüft',
+ };
+ return map[status] || status;
+ },
+
+ handleRefreshError(msg) {
+ this._refreshingIncidents.delete(msg.incident_id);
+ this._updateSidebarDot(msg.incident_id, 'error');
+ UI._removeSidebarRefreshStatus(msg.incident_id);
+ delete UI._progressState[msg.incident_id];
+ UI._reindexQueuePositions();
+ this.renderSidebar();
+ if (msg.incident_id === this.currentIncidentId) {
+ this._updateRefreshButton(false);
+ // Pending-Complete aufräumen
+ if (this._pendingCompleteTimer) {
+ clearTimeout(this._pendingCompleteTimer);
+ this._pendingCompleteTimer = null;
+ }
+ this._pendingComplete = null;
+ UI.showProgressError(msg.data.error, false, 0, msg.incident_id);
+ }
+ UI.showToast(`Recherche-Fehler: ${msg.data.error}`, 'error');
+ },
+
+ handleRefreshCancelled(msg) {
+ this._refreshingIncidents.delete(msg.incident_id);
+ this._updateSidebarDot(msg.incident_id);
+ UI._removeSidebarRefreshStatus(msg.incident_id);
+ delete UI._progressState[msg.incident_id];
+ UI._reindexQueuePositions();
+ this.renderSidebar();
+ if (msg.incident_id === this.currentIncidentId) {
+ this._updateRefreshButton(false);
+ if (this._pendingCompleteTimer) {
+ clearTimeout(this._pendingCompleteTimer);
+ this._pendingCompleteTimer = null;
+ }
+ this._pendingComplete = null;
+ UI.hideProgress(msg.incident_id);
+ }
+ UI.showToast((typeof T === 'function' ? T('toast.research_cancelled', 'Recherche abgebrochen.') : 'Recherche abgebrochen.'), 'info');
+ },
+
+ /**
+ * Gleicht den lokalen Refresh-Status mit dem Server ab.
+ * Bereinigt verwaiste Status-Anzeigen, die durch verpasste WebSocket-Nachrichten entstehen.
+ */
+ async syncRefreshStatus() {
+ if (this._refreshingIncidents.size === 0) return;
+ try {
+ const data = await API.getRefreshingIncidents();
+ const serverRefreshing = new Set(data.refreshing || []);
+ const serverQueued = new Set(data.queued || []);
+ const serverAll = new Set([...serverRefreshing, ...serverQueued]);
+
+ // Finde lokal als refreshing/queued markierte IDs, die serverseitig nicht mehr laufen
+ const stale = [];
+ this._refreshingIncidents.forEach(id => {
+ if (!serverAll.has(id)) stale.push(id);
+ });
+
+ if (stale.length > 0) {
+ console.log('Status-Sync: Bereinige verwaiste Refreshes:', stale);
+ stale.forEach(id => {
+ this._refreshingIncidents.delete(id);
+ this._updateSidebarDot(id);
+ UI._removeSidebarRefreshStatus(id);
+ delete UI._progressState[id];
+ if (id === this.currentIncidentId) {
+ this._updateRefreshButton(false);
+ UI.hideProgress(id);
+ }
+ });
+ UI._reindexQueuePositions();
+ this.renderSidebar();
+ }
+ } catch (e) {
+ // Netzwerkfehler ignorieren, naechster Zyklus probiert erneut
+ }
+ },
+
+ minimizeProgress() {
+ UI.minimizeProgress(this.currentIncidentId);
+ },
+
+ openProgressPopup() {
+ UI.openProgressPopup(this.currentIncidentId);
+ },
+
+ async cancelRefresh() {
+ if (!this.currentIncidentId) return;
+
+ // Temporarily hide progress popup so confirm dialog is fully visible
+ const progressOverlay = document.getElementById('progress-overlay');
+ if (progressOverlay) progressOverlay.style.display = 'none';
+
+ const ok = await confirmDialog((typeof T === 'function' ? T('confirm.cancel_running_research', 'Laufende Recherche abbrechen?') : 'Laufende Recherche abbrechen?'));
+
+ // Restore progress popup if not confirmed
+ if (!ok) {
+ const state = UI._progressState[this.currentIncidentId];
+ if (state && progressOverlay) progressOverlay.style.display = 'flex';
+ return;
+ }
+
+ // Show cancelling state in popup
+ if (progressOverlay) progressOverlay.style.display = 'flex';
+ const btn = document.getElementById('progress-cancel-btn');
+ if (btn) {
+ btn.textContent = (typeof T === 'function' ? T('action.cancelling', 'Wird abgebrochen...') : 'Wird abgebrochen...');
+ btn.disabled = true;
+ }
+ const titleEl = document.getElementById('progress-popup-title');
+ if (titleEl) titleEl.textContent = (typeof T === 'function' ? T('action.cancelling', 'Wird abgebrochen...') : 'Wird abgebrochen...');
+
+ try {
+ const result = await API.cancelRefresh(this.currentIncidentId);
+ if (!result) {
+ UI.showToast((typeof T === 'function' ? T('toast.no_active_refresh', 'Kein aktiver Refresh zum Abbrechen gefunden.') : 'Kein aktiver Refresh zum Abbrechen gefunden.'), 'info');
+ if (btn) { btn.textContent = 'Abbrechen'; btn.disabled = false; }
+ if (titleEl) titleEl.textContent = (typeof T === 'function' ? T('progress.title.refresh', 'Aktualisierung läuft') : 'Aktualisierung läuft');
+ }
+ } catch (err) {
+ UI.showToast('Abbrechen fehlgeschlagen: ' + err.message, 'error');
+ if (btn) { btn.textContent = 'Abbrechen'; btn.disabled = false; }
+ if (titleEl) titleEl.textContent = 'Aktualisierung l\u00e4uft';
+ }
+ },
+
+ // === Export ===
+
+ openExportModal() {
+ if (!this.currentIncidentId) return;
+ openModal('modal-export');
+ },
+
+ async submitExport() {
+ if (!this.currentIncidentId) return;
+ const checked = document.querySelectorAll('input[name="export-section"]:checked');
+ const sections = Array.from(checked).map(cb => cb.value);
+ if (sections.length === 0) {
+ UI.showToast('Bitte mindestens einen Bereich ausw\u00e4hlen.', 'warning');
+ return;
+ }
+ const format = document.querySelector('input[name="export-format"]:checked').value;
+
+ const btn = document.getElementById('export-submit-btn');
+ const origText = btn.textContent;
+ btn.disabled = true;
+ btn.textContent = (typeof T === 'function' ? T('action.creating', 'Wird erstellt...') : 'Wird erstellt...');
+
+ try {
+ const response = await API.exportReport(this.currentIncidentId, format, null, sections);
+ if (!response.ok) {
+ const err = await response.json().catch(() => ({}));
+ throw new Error(err.detail || 'Fehler ' + response.status);
+ }
+ const blob = await response.blob();
+ const disposition = response.headers.get('Content-Disposition') || '';
+ let filename = 'bericht.' + format;
+ const match = disposition.match(/filename="?([^"]+)"?/);
+ if (match) filename = match[1];
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = filename;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+ closeModal('modal-export');
+ UI.showToast((typeof T === 'function' ? T('toast.report_downloaded', 'Bericht heruntergeladen') : 'Bericht heruntergeladen'), 'success');
+ } catch (err) {
+ UI.showToast('Export fehlgeschlagen: ' + err.message, 'error');
+ } finally {
+ btn.disabled = false;
+ btn.textContent = origText;
+ }
+ },
+
+ // === Sidebar-Stats ===
+
+ async updateSidebarStats() {
+ const _t = (k, fb) => (typeof T === 'function') ? T(k, fb) : fb;
+ const lblSources = _t('sidebar.stat.sources_suffix', 'Quellen');
+ const lblArticles = _t('sidebar.stat.articles_suffix', 'Artikel');
+ try {
+ const stats = await API.getSourceStats();
+ const srcCount = document.getElementById('stat-sources-count');
+ const artCount = document.getElementById('stat-articles-count');
+ if (srcCount) srcCount.textContent = `${stats.total_sources} ${lblSources}`;
+ if (artCount) artCount.textContent = `${stats.total_articles} ${lblArticles}`;
+ } catch {
+ // Fallback: aus Lagen berechnen
+ const totalArticles = this.incidents.reduce((sum, i) => sum + i.article_count, 0);
+ const totalSources = this.incidents.reduce((sum, i) => sum + i.source_count, 0);
+ const srcCount = document.getElementById('stat-sources-count');
+ const artCount = document.getElementById('stat-articles-count');
+ if (srcCount) srcCount.textContent = `${totalSources} ${lblSources}`;
+ if (artCount) artCount.textContent = `${totalArticles} ${lblArticles}`;
+ }
+ },
+
+ // === Soft-Refresh (F5) ===
+
+ async softRefresh() {
+ try {
+ await this.loadIncidents();
+ if (this.currentIncidentId) {
+ await this.selectIncident(this.currentIncidentId);
+ }
+ UI.showToast((typeof T === 'function' ? T('toast.data_updated', 'Daten aktualisiert.') : 'Daten aktualisiert.'), 'success', 2000);
+ } catch (err) {
+ UI.showToast('Aktualisierung fehlgeschlagen: ' + err.message, 'error');
+ }
+ },
+
+ // === Feedback ===
+
+ openFeedback() {
+ const form = document.getElementById('feedback-form');
+ if (form) form.reset();
+ const counter = document.getElementById('fb-char-count');
+ if (counter) counter.textContent = '0';
+ openModal('modal-feedback');
+ },
+
+ async submitFeedback(e) {
+ e.preventDefault();
+ const form = document.getElementById('feedback-form');
+ this._clearFormErrors(form);
+
+ const btn = document.getElementById('fb-submit-btn');
+ const category = document.getElementById('fb-category').value;
+ const msgField = document.getElementById('fb-message');
+ const message = msgField.value.trim();
+
+ if (message.length < 10) {
+ this._showFieldError(msgField, 'Bitte mindestens 10 Zeichen eingeben.');
+ msgField.focus();
+ return;
+ }
+
+ // Dateien pruefen
+ const fileInput = document.getElementById('fb-files');
+ const files = fileInput ? Array.from(fileInput.files) : [];
+ if (files.length > 3) {
+ UI.showToast('Maximal 3 Bilder erlaubt.', 'error');
+ return;
+ }
+ for (const f of files) {
+ if (f.size > 5 * 1024 * 1024) {
+ UI.showToast('Datei "' + f.name + '" ist groesser als 5 MB.', 'error');
+ return;
+ }
+ }
+
+ btn.disabled = true;
+ btn.textContent = (typeof T === 'function' ? T('action.sending', 'Wird gesendet...') : 'Wird gesendet...');
+ try {
+ const formData = new FormData();
+ formData.append('category', category);
+ formData.append('message', message);
+ for (const f of files) {
+ formData.append('files', f);
+ }
+ await API.sendFeedbackForm(formData);
+ closeModal('modal-feedback');
+ UI.showToast('Feedback gesendet. Vielen Dank!', 'success');
+ } catch (err) {
+ UI.showToast('Fehler: ' + err.message, 'error');
+ } finally {
+ btn.disabled = false;
+ btn.textContent = 'Absenden';
+ }
+ },
+
+ // === Sidebar Sektionen ein-/ausklappen ===
+
+ toggleSidebarSection(sectionId) {
+ const list = document.getElementById(sectionId);
+ if (!list) return;
+ const chevron = document.getElementById('chevron-' + sectionId);
+ const isHidden = list.style.display === 'none';
+ list.style.display = isHidden ? '' : 'none';
+ if (chevron) {
+ chevron.classList.toggle('open', isHidden);
+ }
+ // aria-expanded auf dem Section-Title synchronisieren
+ const title = chevron ? chevron.closest('.sidebar-section-title') : null;
+ if (title) title.setAttribute('aria-expanded', String(isHidden));
+ },
+
+ // === Quellenverwaltung ===
+
+ async openSourceManagement() {
+ openModal('modal-sources');
+ await this.loadSources();
+ },
+
+ async loadSources() {
+ try {
+ const [sources, stats, myExclusions] = await Promise.all([
+ API.listSources(),
+ API.getSourceStats(),
+ API.getMyExclusions(),
+ ]);
+ this._allSources = sources;
+ this._sourcesOnly = sources.filter(s => s.source_type !== 'excluded');
+ this._myExclusions = myExclusions || [];
+
+ this.renderSourceStats(stats);
+ this.renderSourceList();
+ } catch (err) {
+ UI.showToast('Fehler beim Laden der Quellen: ' + err.message, 'error');
+ }
+ },
+
+ renderSourceStats(stats) {
+ const bar = document.getElementById('sources-stats-bar');
+ if (!bar) return;
+
+ const rss = stats.by_type.rss_feed || { count: 0, articles: 0 };
+ const web = stats.by_type.web_source || { count: 0, articles: 0 };
+ const tg = stats.by_type.telegram_channel || { count: 0, articles: 0 };
+ const excluded = this._myExclusions.length;
+
+ bar.innerHTML = `
+ ${rss.count} RSS-Feeds
+ ${web.count} Web-Quellen
+ ${tg.count} Telegram
+ ${excluded} Ausgeschlossen
+ ${stats.total_articles} Artikel gesamt
+ `;
+ },
+
+ /**
+ * Quellen nach Domain gruppiert rendern.
+ */
+ renderSourceList() {
+ const list = document.getElementById('sources-list');
+ if (!list) return;
+
+ // Filter anwenden
+ const typeFilter = document.getElementById('sources-filter-type')?.value || '';
+ const catFilter = document.getElementById('sources-filter-category')?.value || '';
+ const politicalFilter = document.getElementById('sources-filter-political')?.value || '';
+ const mediaTypeFilter = document.getElementById('sources-filter-mediatype')?.value || '';
+ const reliabilityFilter = document.getElementById('sources-filter-reliability')?.value || '';
+ const alignmentFilter = document.getElementById('sources-filter-alignment')?.value || '';
+ const externFilter = document.getElementById('sources-filter-extern')?.value || '';
+ const search = (document.getElementById('sources-search')?.value || '').toLowerCase();
+
+ // Alle Quellen nach Domain gruppieren
+ const groups = new Map();
+ const excludedDomains = new Set();
+ const excludedNotes = {};
+
+ // User-Ausschlüsse sammeln
+ this._myExclusions.forEach(e => {
+ const domain = (e.domain || '').toLowerCase();
+ if (domain) {
+ excludedDomains.add(domain);
+ excludedNotes[domain] = e.notes || '';
+ }
+ });
+
+ // Feeds nach Domain gruppieren
+ this._sourcesOnly.forEach(s => {
+ const domain = (s.domain || '').toLowerCase() || `_single_${s.id}`;
+ if (!groups.has(domain)) groups.set(domain, []);
+ groups.get(domain).push(s);
+ });
+
+ // Ausgeschlossene Domains die keine Feeds haben auch als Gruppe
+ this._myExclusions.forEach(e => {
+ const domain = (e.domain || '').toLowerCase();
+ if (domain && !groups.has(domain)) {
+ groups.set(domain, []);
+ }
+ });
+
+ // Filter auf Gruppen anwenden
+ let filteredGroups = [];
+ for (const [domain, feeds] of groups) {
+ const isExcluded = excludedDomains.has(domain);
+ const isGlobal = feeds.some(f => f.is_global);
+
+ // Typ-Filter
+ if (typeFilter === 'excluded' && !isExcluded) continue;
+ if (typeFilter && typeFilter !== 'excluded') {
+ const hasMatchingType = feeds.some(f => f.source_type === typeFilter);
+ if (!hasMatchingType) continue;
+ }
+
+ // Kategorie-Filter
+ if (catFilter) {
+ const hasMatchingCat = feeds.some(f => f.category === catFilter);
+ if (!hasMatchingCat) continue;
+ }
+
+ // Klassifikations-Filter
+ if (politicalFilter) {
+ if (!feeds.some(f => (f.political_orientation || 'na') === politicalFilter)) continue;
+ }
+ if (mediaTypeFilter) {
+ if (!feeds.some(f => (f.media_type || 'sonstige') === mediaTypeFilter)) continue;
+ }
+ if (reliabilityFilter) {
+ if (!feeds.some(f => (f.reliability || 'na') === reliabilityFilter)) continue;
+ }
+ if (alignmentFilter) {
+ if (!feeds.some(f => Array.isArray(f.alignments) && f.alignments.includes(alignmentFilter))) continue;
+ }
+ if (externFilter === 'ifcn') {
+ if (!feeds.some(f => f.ifcn_signatory)) continue;
+ } else if (externFilter === 'eu_disinfo') {
+ if (!feeds.some(f => f.eu_disinfo_listed)) continue;
+ }
+
+ // Suche
+ if (search) {
+ const groupText = feeds.map(f =>
+ `${f.name} ${f.domain || ''} ${f.url || ''} ${f.notes || ''}`
+ ).join(' ').toLowerCase() + ' ' + domain;
+ if (!groupText.includes(search)) continue;
+ }
+
+ filteredGroups.push({ domain, feeds, isExcluded, isGlobal });
+ }
+
+ if (filteredGroups.length === 0) {
+ list.innerHTML = 'Keine Quellen gefunden
';
+ return;
+ }
+
+ // Sortierung: Aktive zuerst (alphabetisch), dann ausgeschlossene
+ filteredGroups.sort((a, b) => {
+ if (a.isExcluded !== b.isExcluded) return a.isExcluded ? 1 : -1;
+ return a.domain.localeCompare(b.domain);
+ });
+
+ list.innerHTML = filteredGroups.map(g =>
+ UI.renderSourceGroup(g.domain, g.feeds, g.isExcluded, excludedNotes[g.domain] || '', g.isGlobal)
+ ).join('');
+
+ // Erweiterte Gruppen wiederherstellen
+ this._expandedGroups.forEach(domain => {
+ const feedsEl = list.querySelector(`.source-group-feeds[data-domain="${CSS.escape(domain)}"]`);
+ if (feedsEl) {
+ feedsEl.classList.add('expanded');
+ const header = feedsEl.previousElementSibling;
+ if (header) header.classList.add('expanded');
+ }
+ });
+ },
+
+ filterSources() {
+ this.renderSourceList();
+ },
+
+ /**
+ * Domain-Gruppe auf-/zuklappen.
+ */
+ toggleSourceOverview() {
+ const content = document.getElementById('source-overview-content');
+ const chevron = document.getElementById('source-overview-chevron');
+ if (!content) return;
+ const isHidden = content.style.display === 'none';
+ content.style.display = isHidden ? '' : 'none';
+ if (chevron) {
+ chevron.classList.toggle('open', isHidden);
+ chevron.title = isHidden ? 'Einklappen' : 'Aufklappen';
+ }
+ // aria-expanded auf dem Header-Toggle synchronisieren
+ const header = chevron ? chevron.closest('[role="button"]') : null;
+ if (header) header.setAttribute('aria-expanded', String(isHidden));
+ // Tab-Modus: Panel waechst mit Inhalt, kein Resize noetig
+ },
+
+ toggleGroup(domain) {
+ const list = document.getElementById('sources-list');
+ if (!list) return;
+ const feedsEl = list.querySelector(`.source-group-feeds[data-domain="${CSS.escape(domain)}"]`);
+ if (!feedsEl) return;
+
+ const isExpanded = feedsEl.classList.toggle('expanded');
+ const header = feedsEl.previousElementSibling;
+ if (header) {
+ header.classList.toggle('expanded', isExpanded);
+ header.setAttribute('aria-expanded', String(isExpanded));
+ }
+
+ if (isExpanded) {
+ this._expandedGroups.add(domain);
+ } else {
+ this._expandedGroups.delete(domain);
+ }
+ },
+
+ /**
+ * Domain ausschließen (aus dem Inline-Formular).
+ */
+ async blockDomain() {
+ const input = document.getElementById('block-domain-input');
+ const domain = (input?.value || '').trim();
+ if (!domain) {
+ UI.showToast('Domain ist erforderlich.', 'warning');
+ return;
+ }
+
+ const notes = (document.getElementById('block-domain-notes')?.value || '').trim() || null;
+
+ try {
+ await API.blockDomain(domain, notes);
+ UI.showToast(`${domain} ausgeschlossen.`, 'success');
+ this.showBlockDomainDialog(false);
+ await this.loadSources();
+ this.updateSidebarStats();
+ } catch (err) {
+ UI.showToast('Fehler: ' + err.message, 'error');
+ }
+ },
+
+ /**
+ * Faktencheck-Filter umschalten.
+ */
+ toggleFactCheckFilter(status) {
+ const checkbox = document.querySelector(`.fc-dropdown-item[data-status="${status}"] input`);
+ if (!checkbox) return;
+ const isActive = checkbox.checked;
+
+ document.querySelectorAll(`.factcheck-item[data-fc-status="${status}"]`).forEach(el => {
+ el.style.display = isActive ? '' : 'none';
+ });
+ },
+
+ toggleFcDropdown(e) {
+ e.stopPropagation();
+ const btn = e.target.closest('.fc-dropdown-toggle');
+ const menu = btn ? btn.nextElementSibling : document.getElementById('fc-dropdown-menu');
+ if (!menu) return;
+ const isOpen = menu.classList.toggle('open');
+ if (btn) btn.setAttribute('aria-expanded', String(isOpen));
+ if (isOpen) {
+ const close = (ev) => {
+ if (!menu.contains(ev.target)) {
+ menu.classList.remove('open');
+ document.removeEventListener('click', close);
+ }
+ };
+ setTimeout(() => document.addEventListener('click', close), 0);
+ }
+ },
+
+ filterModalTimeline(searchTerm) {
+ const filterBtn = document.querySelector('.ht-modal-filter-btn.active');
+ const filterType = filterBtn ? filterBtn.dataset.filter : 'all';
+ const body = document.getElementById('content-viewer-body');
+ if (!body) return;
+ body.innerHTML = this._buildFullVerticalTimeline(filterType, (searchTerm || '').toLowerCase());
+ },
+
+ filterModalTimelineType(filterType, btn) {
+ document.querySelectorAll('.ht-modal-filter-btn').forEach(b => b.classList.remove('active'));
+ if (btn) btn.classList.add('active');
+ const searchInput = document.querySelector('#content-viewer-header-extra .timeline-filter-input');
+ const searchTerm = searchInput ? searchInput.value.toLowerCase() : '';
+ const body = document.getElementById('content-viewer-body');
+ if (!body) return;
+ body.innerHTML = this._buildFullVerticalTimeline(filterType, searchTerm);
+ },
+
+ /**
+ * Domain direkt ausschließen (aus der Gruppenliste).
+ */
+ async blockDomainDirect(domain) {
+ if (!await confirmDialog(`"${domain}" wirklich ausschließen? Artikel dieser Domain werden bei allen deinen Recherchen ignoriert. Dies betrifft nicht andere Nutzer deiner Organisation.`)) return;
+
+ try {
+ await API.blockDomain(domain);
+ UI.showToast(`${domain} ausgeschlossen.`, 'success');
+ await this.loadSources();
+ this.updateSidebarStats();
+ } catch (err) {
+ UI.showToast('Fehler: ' + err.message, 'error');
+ }
+ },
+
+ /**
+ * Domain-Ausschluss aufheben.
+ */
+ async unblockDomain(domain) {
+ try {
+ await API.unblockDomain(domain);
+ UI.showToast(`${domain} Ausschluss aufgehoben.`, 'success');
+ await this.loadSources();
+ this.updateSidebarStats();
+ } catch (err) {
+ UI.showToast('Fehler: ' + err.message, 'error');
+ }
+ },
+
+ /**
+ * Alle Quellen einer Domain löschen.
+ */
+ async deleteDomain(domain) {
+ if (!await confirmDialog(`Alle Quellen von "${domain}" wirklich löschen?`)) return;
+
+ try {
+ await API.deleteDomain(domain);
+ UI.showToast(`${domain} gelöscht.`, 'success');
+ await this.loadSources();
+ this.updateSidebarStats();
+ } catch (err) {
+ UI.showToast('Fehler: ' + err.message, 'error');
+ }
+ },
+
+ /**
+ * Einzelnen Feed löschen.
+ */
+ async deleteSingleFeed(sourceId) {
+ try {
+ await API.deleteSource(sourceId);
+ this._allSources = this._allSources.filter(s => s.id !== sourceId);
+ this._sourcesOnly = this._sourcesOnly.filter(s => s.id !== sourceId);
+ this.renderSourceList();
+ this.updateSidebarStats();
+ UI.showToast('Feed gelöscht.', 'success');
+ } catch (err) {
+ UI.showToast('Fehler: ' + err.message, 'error');
+ }
+ },
+
+ /**
+ * "Domain ausschließen" Dialog ein-/ausblenden.
+ */
+ showBlockDomainDialog(show) {
+ const form = document.getElementById('sources-block-form');
+ if (!form) return;
+
+ if (show === undefined || show === true) {
+ form.style.display = 'block';
+ document.getElementById('block-domain-input').value = '';
+ document.getElementById('block-domain-notes').value = '';
+ // Add-Form ausblenden
+ const addForm = document.getElementById('sources-add-form');
+ if (addForm) addForm.style.display = 'none';
+ } else {
+ form.style.display = 'none';
+ }
+ },
+
+ _discoveredData: null,
+
+ toggleSourceForm(show) {
+ const form = document.getElementById('sources-add-form');
+ if (!form) return;
+
+ if (show === undefined) {
+ show = form.style.display === 'none';
+ }
+
+ form.style.display = show ? 'block' : 'none';
+
+ if (show) {
+ this._editingSourceId = null;
+ this._discoveredData = null;
+ document.getElementById('src-discover-url').value = '';
+ document.getElementById('src-discovery-result').style.display = 'none';
+ document.getElementById('src-discover-btn').disabled = false;
+ document.getElementById('src-discover-btn').textContent = 'Erkennen';
+ document.getElementById('src-type-select').value = 'rss_feed';
+ // Save-Button Text zurücksetzen
+ const saveBtn = document.querySelector('#src-discovery-result .sources-discovery-actions .btn-primary');
+ if (saveBtn) saveBtn.textContent = 'Speichern';
+ // Block-Form ausblenden
+ const blockForm = document.getElementById('sources-block-form');
+ if (blockForm) blockForm.style.display = 'none';
+ } else {
+ // Beim Schließen: Bearbeitungsmodus zurücksetzen
+ this._editingSourceId = null;
+ const saveBtn = document.querySelector('#src-discovery-result .sources-discovery-actions .btn-primary');
+ if (saveBtn) saveBtn.textContent = 'Speichern';
+ }
+ },
+
+ async discoverSource() {
+ const urlInput = document.getElementById('src-discover-url');
+ const urlVal = urlInput.value.trim();
+
+ // Telegram-URLs direkt behandeln (kein Discovery noetig)
+ if (urlVal.match(/^(https?:\/\/)?(t\.me|telegram\.me)\//i)) {
+ const channelName = urlVal.replace(/^(https?:\/\/)?(t\.me|telegram\.me)\//, '').replace(/\/$/, '');
+ const tgUrl = 't.me/' + channelName;
+ this._discoveredData = {
+ name: '@' + channelName,
+ domain: 't.me',
+ source_type: 'telegram_channel',
+ rss_url: null,
+ };
+ document.getElementById('src-name').value = '@' + channelName;
+ document.getElementById('src-type-select').value = 'telegram_channel';
+ document.getElementById('src-type-display').value = 'Telegram';
+ document.getElementById('src-domain').value = tgUrl;
+ document.getElementById('src-rss-url-group').style.display = 'none';
+ document.getElementById('src-discovery-result').style.display = 'block';
+ const saveBtn = document.querySelector('#src-discovery-result .sources-discovery-actions .btn-primary');
+ if (saveBtn) { saveBtn.disabled = false; saveBtn.textContent = 'Speichern'; }
+ return;
+ }
+ const url = urlInput.value.trim();
+ if (!url) {
+ UI.showToast('Bitte URL oder Domain eingeben.', 'warning');
+ return;
+ }
+
+ // Prüfen ob Domain ausgeschlossen ist
+ const inputDomain = url.replace(/^https?:\/\//, '').replace(/^www\./, '').split('/')[0].toLowerCase();
+ const isBlocked = inputDomain && this._myExclusions.some(e => (e.domain || '').toLowerCase() === inputDomain);
+
+ if (isBlocked) {
+ if (!await confirmDialog(`"${inputDomain}" ist ausgeschlossen. Trotzdem hinzufügen? Der Ausschluss wird dabei aufgehoben.`)) return;
+ await API.unblockDomain(inputDomain);
+ }
+
+ const btn = document.getElementById('src-discover-btn');
+ btn.disabled = true;
+ btn.textContent = (typeof T === 'function' ? T('action.searching_feeds', 'Suche Feeds...') : 'Suche Feeds...');
+
+ try {
+ const result = await API.discoverMulti(url);
+
+ if (result.fallback_single) {
+ this._discoveredData = {
+ name: result.domain,
+ domain: result.domain,
+ category: result.category,
+ source_type: result.total_found > 0 ? 'rss_feed' : 'web_source',
+ rss_url: result.sources.length > 0 ? result.sources[0].url : null,
+ };
+ if (result.sources.length > 0) {
+ this._discoveredData.name = result.sources[0].name;
+ }
+
+ document.getElementById('src-name').value = this._discoveredData.name || '';
+ document.getElementById('src-category').value = this._discoveredData.category || 'sonstige';
+ document.getElementById('src-domain').value = this._discoveredData.domain || '';
+ document.getElementById('src-notes').value = '';
+
+ const typeLabel = this._discoveredData.source_type === 'rss_feed' ? 'RSS-Feed' : this._discoveredData.source_type === 'telegram_channel' ? 'Telegram' : 'Web-Quelle';
+ const typeSelect = document.getElementById('src-type-select');
+ if (typeSelect) typeSelect.value = this._discoveredData.source_type || 'web_source';
+ document.getElementById('src-type-display').value = typeLabel;
+
+ const rssGroup = document.getElementById('src-rss-url-group');
+ const rssInput = document.getElementById('src-rss-url');
+ if (this._discoveredData.rss_url) {
+ rssInput.value = this._discoveredData.rss_url;
+ rssGroup.style.display = 'block';
+ } else {
+ rssInput.value = '';
+ rssGroup.style.display = 'none';
+ }
+
+ document.getElementById('src-discovery-result').style.display = 'block';
+
+ if (result.added_count > 0) {
+ UI.showToast(`${result.domain}: Feed wurde automatisch hinzugefügt.`, 'success');
+ this.toggleSourceForm(false);
+ await this.loadSources();
+ } else if (result.total_found === 0) {
+ UI.showToast((typeof T === 'function' ? T('toast.no_rss_save_as_web', 'Kein RSS-Feed gefunden. Als Web-Quelle speichern?') : 'Kein RSS-Feed gefunden. Als Web-Quelle speichern?'), 'info');
+ } else {
+ UI.showToast('Feed bereits vorhanden.', 'info');
+ }
+ } else {
+ document.getElementById('src-discovery-result').style.display = 'none';
+
+ if (result.added_count > 0) {
+ UI.showToast(`${result.domain}: ${result.added_count} Feeds hinzugefügt` +
+ (result.skipped_count > 0 ? ` (${result.skipped_count} bereits vorhanden)` : ''),
+ 'success');
+ } else if (result.skipped_count > 0) {
+ UI.showToast(`${result.domain}: Alle ${result.skipped_count} Feeds bereits vorhanden.`, 'info');
+ } else {
+ UI.showToast(`${result.domain}: Keine relevanten Feeds gefunden.`, 'info');
+ }
+
+ this.toggleSourceForm(false);
+ await this.loadSources();
+ }
+ } catch (err) {
+ UI.showToast('Erkennung fehlgeschlagen: ' + err.message, 'error');
+ } finally {
+ btn.disabled = false;
+ btn.textContent = 'Erkennen';
+ }
+ },
+
+ editSource(id) {
+ const source = this._sourcesOnly.find(s => s.id === id);
+ if (!source) {
+ UI.showToast('Quelle nicht gefunden.', 'error');
+ return;
+ }
+
+ this._editingSourceId = id;
+
+ // Formular öffnen falls geschlossen (direkt, ohne toggleSourceForm das _editingSourceId zurücksetzt)
+ const form = document.getElementById('sources-add-form');
+ if (form) {
+ form.style.display = 'block';
+ const blockForm = document.getElementById('sources-block-form');
+ if (blockForm) blockForm.style.display = 'none';
+ }
+
+ // Discovery-URL mit vorhandener URL/Domain befüllen
+ const discoverUrlInput = document.getElementById('src-discover-url');
+ if (discoverUrlInput) {
+ discoverUrlInput.value = source.url || source.domain || '';
+ }
+
+ // Discovery-Ergebnis anzeigen und Felder befüllen
+ document.getElementById('src-discovery-result').style.display = 'block';
+ document.getElementById('src-name').value = source.name || '';
+ document.getElementById('src-category').value = source.category || 'sonstige';
+ document.getElementById('src-notes').value = source.notes || '';
+ document.getElementById('src-domain').value = source.domain || '';
+
+ const typeLabel = source.source_type === 'rss_feed' ? 'RSS-Feed' : source.source_type === 'telegram_channel' ? 'Telegram' : 'Web-Quelle';
+ const typeSelect = document.getElementById('src-type-select');
+ if (typeSelect) typeSelect.value = source.source_type || 'web_source';
+ document.getElementById('src-type-display').value = typeLabel;
+
+ const rssGroup = document.getElementById('src-rss-url-group');
+ const rssInput = document.getElementById('src-rss-url');
+ if (source.url) {
+ rssInput.value = source.url;
+ rssGroup.style.display = 'block';
+ } else {
+ rssInput.value = '';
+ rssGroup.style.display = 'none';
+ }
+
+ // _discoveredData setzen damit saveSource() die richtigen Werte nutzt
+ this._discoveredData = {
+ name: source.name,
+ domain: source.domain,
+ category: source.category,
+ source_type: source.source_type,
+ rss_url: source.url,
+ };
+
+ // Submit-Button-Text ändern
+ const saveBtn = document.querySelector('#src-discovery-result .sources-discovery-actions .btn-primary');
+ if (saveBtn) saveBtn.textContent = (typeof T === 'function' ? T('action.save_source', 'Quelle speichern') : 'Quelle speichern');
+
+ // Zum Formular scrollen
+ if (form) form.scrollIntoView({ behavior: 'smooth', block: 'start' });
+ },
+
+ async saveSource() {
+ const name = document.getElementById('src-name').value.trim();
+ if (!name) {
+ UI.showToast('Name ist erforderlich. Bitte erst "Erkennen" klicken.', 'warning');
+ return;
+ }
+
+ const discovered = this._discoveredData || {};
+ const data = {
+ name,
+ source_type: discovered.source_type || 'web_source',
+ category: document.getElementById('src-category').value,
+ url: discovered.rss_url || (discovered.source_type === 'telegram_channel' ? (document.getElementById('src-domain').value || null) : null),
+ domain: document.getElementById('src-domain').value.trim() || discovered.domain || null,
+ notes: document.getElementById('src-notes').value.trim() || null,
+ };
+
+ if (!data.domain && discovered.domain) {
+ data.domain = discovered.domain;
+ }
+
+ try {
+ if (this._editingSourceId) {
+ await API.updateSource(this._editingSourceId, data);
+ UI.showToast((typeof T === 'function' ? T('toast.source_updated', 'Quelle aktualisiert.') : 'Quelle aktualisiert.'), 'success');
+ } else {
+ await API.createSource(data);
+ UI.showToast((typeof T === 'function' ? T('toast.source_added', 'Quelle hinzugefügt.') : 'Quelle hinzugefügt.'), 'success');
+ }
+
+ this.toggleSourceForm(false);
+ await this.loadSources();
+ this.updateSidebarStats();
+ } catch (err) {
+ UI.showToast('Fehler: ' + err.message, 'error');
+ }
+ },
+
+ // --- Global Admin: Org-Switcher (herausnehmbar) ---
+ async _initOrgSwitcher(currentTenantId) {
+ const section = document.getElementById('org-switcher-section');
+ const select = document.getElementById('org-switcher-select');
+ if (!section || !select) return;
+
+ try {
+ const orgs = await API.listOrganizations();
+ if (!orgs || orgs.length < 2) return;
+
+ section.style.display = 'block';
+ select.innerHTML = '';
+ orgs.forEach(org => {
+ const opt = document.createElement('option');
+ opt.value = org.id;
+ opt.textContent = org.name + (org.is_active ? '' : ' (inaktiv)');
+ if (org.id === currentTenantId) opt.selected = true;
+ select.appendChild(opt);
+ });
+
+ select.addEventListener('change', async () => {
+ const orgId = parseInt(select.value, 10);
+ if (orgId === currentTenantId) return;
+ try {
+ const result = await API.switchOrg(orgId);
+ localStorage.setItem('osint_token', result.access_token);
+ window.location.reload();
+ } catch (err) {
+ console.error('Org-Wechsel fehlgeschlagen:', err);
+ }
+ });
+ } catch {
+ // Kein Global Admin oder Fehler - Switcher bleibt versteckt
+ }
+ },
+
+ logout() {
+ localStorage.removeItem('osint_token');
+ localStorage.removeItem('osint_username');
+ this._sessionWarningShown = false;
+ WS.disconnect();
+ window.location.href = '/';
+ },
+};
+
+// === Barrierefreier Bestätigungsdialog ===
+
+function confirmDialog(message) {
+ return new Promise((resolve) => {
+ // Overlay erstellen
+ const overlay = document.createElement('div');
+ overlay.className = 'modal-overlay active';
+ overlay.setAttribute('role', 'alertdialog');
+ overlay.setAttribute('aria-modal', 'true');
+ overlay.setAttribute('aria-labelledby', 'confirm-dialog-msg');
+
+ const modal = document.createElement('div');
+ modal.className = 'modal';
+ modal.style.maxWidth = '420px';
+ modal.innerHTML = `
+
+
+
${message.replace(//g, '>')}
+
+
+ `;
+ overlay.appendChild(modal);
+ document.body.appendChild(overlay);
+
+ const previousFocus = document.activeElement;
+
+ const cleanup = (result) => {
+ releaseFocus(overlay);
+ overlay.remove();
+ if (previousFocus) previousFocus.focus();
+ resolve(result);
+ };
+
+ modal.querySelector('#confirm-cancel').addEventListener('click', () => cleanup(false));
+ modal.querySelector('#confirm-ok').addEventListener('click', () => cleanup(true));
+ overlay.addEventListener('click', (e) => {
+ if (e.target === overlay) cleanup(false);
+ });
+ overlay.addEventListener('keydown', (e) => {
+ if (e.key === 'Escape') cleanup(false);
+ });
+
+ trapFocus(overlay);
+ });
+}
+
+// === Globale Hilfsfunktionen ===
+
+// --- Focus-Trap für Modals (WCAG 2.4.3) ---
+const FOCUSABLE_SELECTOR = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
+
+function trapFocus(modalEl) {
+ const handler = (e) => {
+ if (e.key !== 'Tab') return;
+ const focusable = Array.from(modalEl.querySelectorAll(FOCUSABLE_SELECTOR)).filter(el => el.offsetParent !== null);
+ if (focusable.length === 0) return;
+ const first = focusable[0];
+ const last = focusable[focusable.length - 1];
+ if (e.shiftKey && document.activeElement === first) {
+ e.preventDefault();
+ last.focus();
+ } else if (!e.shiftKey && document.activeElement === last) {
+ e.preventDefault();
+ first.focus();
+ }
+ };
+ modalEl._focusTrapHandler = handler;
+ modalEl.addEventListener('keydown', handler);
+ // Fokus auf erstes Element setzen
+ requestAnimationFrame(() => {
+ const focusable = Array.from(modalEl.querySelectorAll(FOCUSABLE_SELECTOR)).filter(el => el.offsetParent !== null);
+ if (focusable.length > 0) focusable[0].focus();
+ });
+}
+
+function releaseFocus(modalEl) {
+ if (modalEl._focusTrapHandler) {
+ modalEl.removeEventListener('keydown', modalEl._focusTrapHandler);
+ delete modalEl._focusTrapHandler;
+ }
+}
+
+function openModal(id) {
+ if (id === 'modal-new' && !App._editingIncidentId) {
+ // Create-Modus: Formular zurücksetzen
+ document.getElementById('new-incident-form').reset();
+ document.getElementById('modal-new-title').textContent = (typeof T === 'function') ? T('modal.new_incident.title2', 'Neue Lage anlegen') : 'Neue Lage anlegen';
+ document.getElementById('modal-new-submit').textContent = (typeof T === 'function') ? T('modal.new_incident.submit', 'Lage anlegen') : 'Lage anlegen';
+ { const _b = document.getElementById('btn-enhance-description'); if (_b) _b.disabled = true; }
+ { const _t = document.getElementById("inc-description"); if (_t) { _t.style.height = ""; _autoResizeTextarea(_t); } }
+ // E-Mail-Checkboxen zuruecksetzen
+ document.getElementById('inc-notify-summary').checked = false;
+ document.getElementById('inc-notify-new-articles').checked = false;
+ document.getElementById('inc-notify-status-change').checked = false;
+ toggleTypeDefaults();
+ toggleRefreshInterval();
+ }
+ const modal = document.getElementById(id);
+ modal._previousFocus = document.activeElement;
+ modal.classList.add('active');
+ trapFocus(modal);
+}
+
+function closeModal(id) {
+ // Laufenden Beschreibung-generieren-Request abbrechen
+ if (id === 'modal-new' && App._enhanceController) {
+ App._enhanceController.abort();
+ App._enhanceController = null;
+ const ta = document.getElementById('inc-description');
+ if (ta) { ta.readOnly = false; ta.classList.remove('textarea--loading'); }
+ }
+ const modal = document.getElementById(id);
+ releaseFocus(modal);
+ modal.classList.remove('active');
+ if (modal._previousFocus) {
+ modal._previousFocus.focus();
+ delete modal._previousFocus;
+ }
+ if (id === 'modal-new') {
+ App._editingIncidentId = null;
+ document.getElementById('modal-new-title').textContent = (typeof T === 'function') ? T('modal.new_incident.title2', 'Neue Lage anlegen') : 'Neue Lage anlegen';
+ document.getElementById('modal-new-submit').textContent = (typeof T === 'function') ? T('modal.new_incident.submit', 'Lage anlegen') : 'Lage anlegen';
+ }
+}
+
+function openContentModal(title, sourceElementId) {
+ const source = document.getElementById(sourceElementId);
+ if (!source) return;
+
+ document.getElementById('content-viewer-title').textContent = title;
+ const body = document.getElementById('content-viewer-body');
+ const headerExtra = document.getElementById('content-viewer-header-extra');
+ headerExtra.innerHTML = '';
+
+ if (sourceElementId === 'factcheck-list') {
+ // Faktencheck: Filter in den Modal-Header, Liste in den Body
+ const filters = document.getElementById('fc-filters');
+ if (filters && filters.innerHTML.trim()) {
+ headerExtra.innerHTML = `${filters.innerHTML}
`;
+ }
+ body.innerHTML = source.innerHTML;
+ // Filter im Modal auf Modal-Items umleiten
+ headerExtra.querySelectorAll('.fc-dropdown-item input[type="checkbox"]').forEach(cb => {
+ cb.onchange = function() {
+ const status = this.closest('.fc-dropdown-item').dataset.status;
+ body.querySelectorAll(`.factcheck-item[data-fc-status="${status}"]`).forEach(el => {
+ el.style.display = cb.checked ? '' : 'none';
+ });
+ };
+ });
+ } else if (sourceElementId === 'source-overview-content') {
+ // Quellenübersicht: Detailansicht mit Suchleiste
+ headerExtra.innerHTML = ' ';
+ body.innerHTML = buildDetailedSourceOverview();
+ } else if (sourceElementId === 'timeline') {
+ // Timeline: Vollständige vertikale Timeline im Modal mit Filter + Suche
+ headerExtra.innerHTML = `
+
+ Alle
+ Meldungen
+ Lageberichte
+
+
+
`;
+ body.innerHTML = App._buildFullVerticalTimeline('all', '');
+ } else {
+ body.innerHTML = source.innerHTML;
+ }
+
+ openModal('modal-content-viewer');
+}
+
+App.filterModalSources = function(query) {
+ const q = query.toLowerCase().trim();
+ const details = document.querySelectorAll('#content-viewer-body details');
+ details.forEach(d => {
+ if (!q) {
+ d.style.display = '';
+ d.removeAttribute('open');
+ return;
+ }
+ const name = d.querySelector('summary').textContent.toLowerCase();
+ // Quellenname oder Artikel-Headlines durchsuchen
+ const articles = d.querySelectorAll('div > div');
+ let articleMatch = false;
+ articles.forEach(a => {
+ const text = a.textContent.toLowerCase();
+ const hit = text.includes(q);
+ a.style.display = hit ? '' : 'none';
+ if (hit) articleMatch = true;
+ });
+ const match = name.includes(q) || articleMatch;
+ d.style.display = match ? '' : 'none';
+ // Bei Artikeltreffer aufklappen, bei Namens-Match alle Artikel zeigen
+ if (match && articleMatch && !name.includes(q)) {
+ d.setAttribute('open', '');
+ } else if (name.includes(q)) {
+ articles.forEach(a => a.style.display = '');
+ }
+ });
+};
+
+function buildDetailedSourceOverview() {
+ const articles = App._currentArticles || [];
+ if (!articles.length) return 'Keine Artikel vorhanden
';
+
+ // Nach Quelle gruppieren
+ const sourceMap = {};
+ articles.forEach(a => {
+ const name = a.source || 'Unbekannt';
+ if (!sourceMap[name]) sourceMap[name] = { articles: [], languages: new Set() };
+ sourceMap[name].articles.push(a);
+ sourceMap[name].languages.add((a.language || 'de').toUpperCase());
+ });
+
+ const sources = Object.entries(sourceMap).sort((a, b) => b[1].articles.length - a[1].articles.length);
+
+ // Sprach-Statistik Header
+ const langCount = {};
+ articles.forEach(a => {
+ const lang = (a.language || 'de').toUpperCase();
+ langCount[lang] = (langCount[lang] || 0) + 1;
+ });
+ const langChips = Object.entries(langCount)
+ .sort((a, b) => b[1] - a[1])
+ .map(([lang, count]) => `${lang} ${count} `)
+ .join('');
+
+ let html = ``;
+
+ sources.forEach(([name, data]) => {
+ const langs = [...data.languages].join('/');
+ const escapedName = UI.escape(name);
+ html += `
+
+ ▸
+ ${escapedName}
+ ${langs}
+ ${data.articles.length}
+
+ `;
+ data.articles.forEach(a => {
+ const headline = UI.escape(a.headline_de || a.headline || 'Ohne Titel');
+ const time = a.collected_at
+ ? (parseUTC(a.collected_at) || new Date(a.collected_at)).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit', timeZone: TIMEZONE })
+ : '';
+ const langBadge = a.language && a.language !== 'de'
+ ? `
${a.language.toUpperCase()} ` : '';
+ const link = a.source_url
+ ? `
↗ ` : '';
+ html += `
+ ${time}
+ ${headline}
+ ${langBadge}
+ ${link}
+
`;
+ });
+ html += `
`;
+ });
+
+ return html;
+}
+
+
+
+
+function toggleRefreshInterval() {
+ const mode = document.getElementById('inc-refresh-mode').value;
+ const field = document.getElementById('refresh-interval-field');
+ const startField = document.getElementById('refresh-starttime-field');
+ field.classList.toggle('visible', mode === 'auto');
+ if (startField) startField.classList.toggle('visible', mode === 'auto');
+}
+
+function updateIntervalMin() {
+ const unit = parseInt(document.getElementById('inc-refresh-unit').value);
+ const input = document.getElementById('inc-refresh-value');
+ if (unit === 1) {
+ // Minuten: Minimum 10
+ input.min = 10;
+ if (parseInt(input.value) < 10) input.value = 10;
+ } else {
+ // Stunden/Tage/Wochen: Minimum 1
+ input.min = 1;
+ if (parseInt(input.value) < 1) input.value = 1;
+ }
+}
+
+function updateVisibilityHint() {
+ const isPublic = document.getElementById('inc-visibility').checked;
+ const text = document.getElementById('visibility-text');
+ if (text) {
+ const _t = (k, fb) => (typeof T === 'function') ? T(k, fb) : fb;
+ text.textContent = isPublic
+ ? _t('modal.toggle.visibility_public_text', 'Öffentlich — für alle Nutzer sichtbar')
+ : _t('modal.toggle.visibility_private_text', 'Privat — nur für dich sichtbar');
+ }
+}
+
+function updateSourcesHint() {
+ const intl = document.getElementById('inc-international').checked;
+ const hint = document.getElementById('sources-hint');
+ if (hint) {
+ hint.textContent = intl
+ ? 'DE + internationale Feeds (Reuters, BBC, Al Jazeera etc.)'
+ : (typeof T === 'function' ? T('modal.hint.sources_german_only', 'Nur deutschsprachige Quellen (DE, AT, CH)') : 'Nur deutschsprachige Quellen (DE, AT, CH)');
+ }
+}
+
+function toggleTypeDefaults(preserveMode = false) {
+ const type = document.getElementById('inc-type').value;
+ const hint = document.getElementById('type-hint');
+ const refreshMode = document.getElementById('inc-refresh-mode');
+
+ const _t = (k, fb) => (typeof T === 'function') ? T(k, fb) : fb;
+ if (type === 'research') {
+ hint.textContent = _t('modal.hint.type_research', 'Recherchiert in Tiefe: Nachrichtenarchive, Parlamentsdokumente, Fachmedien, Expertenquellen. Empfohlen: Manuell starten und bei Bedarf vertiefen.');
+ // Nur bei Typ-Wechsel/Neuanlage Modus zurückziehen, beim Edit bestehender Lagen DB-Wert respektieren
+ if (!preserveMode) {
+ refreshMode.value = 'manual';
+ toggleRefreshInterval();
+ }
+ } else {
+ hint.textContent = _t('modal.hint.type_adhoc', 'Durchsucht laufend hunderte Nachrichtenquellen nach neuen Meldungen. Empfohlen: Automatische Aktualisierung.');
+ }
+
+ // Beschreibungs-Tooltip je nach Typ wechseln
+ const descIcon = document.getElementById('description-info-icon');
+ if (descIcon) {
+ descIcon.setAttribute('data-tooltip', type === 'research'
+ ? 'Nenne das vollst\u00e4ndige Thema, gew\u00fcnschte Schwerpunkte und relevante URLs.\nBeispiel: "Muster GmbH: Fokus auf F\u00fchrungspersonen, Kontroversen, Finanzkennzahlen"'
+ : 'Beschreibe den Vorfall m\u00f6glichst genau: Was ist passiert? Wo? Wer ist beteiligt?\nJe pr\u00e4ziser, desto bessere Ergebnisse.');
+ }
+}
+
+// Tab-Fokus: Nur Tab-Badge (Titel-Counter) zurücksetzen, nicht alle Notifications
+window.addEventListener('focus', () => {
+ document.title = App._originalTitle;
+});
+
+// ESC schließt Modals
+// F5: Daten aktualisieren statt Seite neu laden
+document.addEventListener('keydown', (e) => {
+ if (e.key === 'F5') {
+ e.preventDefault();
+ App.softRefresh();
+ }
+});
+
+document.addEventListener('keydown', (e) => {
+ if (e.key === 'Escape') {
+ // Schließ-Reihenfolge: A11y-Panel > Notification-Panel > Export-Dropdown > FC-Dropdown > Modals
+ if (A11yManager._isOpen) {
+ A11yManager._closePanel();
+ return;
+ }
+ if (NotificationCenter._isOpen) {
+ NotificationCenter.close();
+ return;
+ }
+
+ const fcMenu = document.querySelector('.fc-dropdown-menu.open');
+ if (fcMenu) {
+ fcMenu.classList.remove('open');
+ const fcBtn = fcMenu.previousElementSibling;
+ if (fcBtn) fcBtn.setAttribute('aria-expanded', 'false');
+ return;
+ }
+ document.querySelectorAll('.modal-overlay.active').forEach(m => {
+ closeModal(m.id);
+ });
+ }
+});
+
+// Keyboard-Handler: Enter/Space auf [role="button"] löst click aus (WCAG 2.1.1)
+document.addEventListener('keydown', (e) => {
+ if ((e.key === 'Enter' || e.key === ' ') && e.target.matches('[role="button"]')) {
+ e.preventDefault();
+ e.target.click();
+ }
+});
+
+// Session-Ablauf prüfen (alle 60 Sekunden)
+setInterval(() => {
+ const token = localStorage.getItem('osint_token');
+ if (!token) return;
+ try {
+ const payload = JSON.parse(atob(token.split('.')[1]));
+ const expiresAt = payload.exp * 1000;
+ const remaining = expiresAt - Date.now();
+ const fiveMinutes = 5 * 60 * 1000;
+ if (remaining <= 0) {
+ App.logout();
+ } else if (remaining <= fiveMinutes && !App._sessionWarningShown) {
+ App._sessionWarningShown = true;
+ const mins = Math.ceil(remaining / 60000);
+ UI.showToast(`Session läuft in ${mins} Minute${mins !== 1 ? 'n' : ''} ab. Bitte erneut anmelden.`, 'warning', 15000);
+ }
+ } catch (e) { /* Token nicht parsbar */ }
+}, 60000);
+
+// Modal-Overlays: Klick auf Backdrop schließt NICHT mehr (nur X-Button)
+document.addEventListener('click', (e) => {
+ if (e.target.classList.contains('modal-overlay') && e.target.classList.contains('active')) {
+ // closeModal deaktiviert - Modal nur ueber X-Button schliessbar
+ }
+});
+
+// App starten
+document.addEventListener('click', (e) => {
+
+});
+document.addEventListener('DOMContentLoaded', () => App.init());
+
+
+// Auto-Resize fuer Textarea
+function _autoResizeTextarea(el) {
+ if (!el) return;
+ el.style.height = 'auto';
+ el.style.height = Math.max(80, el.scrollHeight) + 'px';
+}
+
+// Titel-Input: Button aktivieren + Textarea Auto-Resize
+document.addEventListener('DOMContentLoaded', () => {
+ const titleInput = document.getElementById('inc-title');
+ if (titleInput) {
+ titleInput.addEventListener('input', function() {
+ const btn = document.getElementById('btn-enhance-description');
+ if (btn) btn.disabled = this.value.trim().length < 3;
+ });
+ }
+ const descInput = document.getElementById('inc-description');
+ if (descInput) {
+ descInput.addEventListener('input', function() { _autoResizeTextarea(this); });
+ }
+});