-
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 = ` - - - `; - 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'} - -
    `; - } - - 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 ``; - }, - - /** - * 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 = ` - - - - `; - 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 = `
    -
    - - - -
    - -
    `; - 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 = `
    - ${articles.length} Artikel aus ${sources.length} Quellen -
    ${langChips}
    -
    `; - - 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 = ` + + + `; + 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'} + +
    `; + } + + 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 ``; + }, + + /** + * 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 = ` + + + + `; + 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 = `
    +
    + + + +
    + +
    `; + 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 = `
    + ${articles.length} Artikel aus ${sources.length} Quellen +
    ${langChips}
    +
    `; + + 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); }); + } +});