diff --git a/src/static/js/app.js b/src/static/js/app.js
index e69de29..2f26730 100644
--- a/src/static/js/app.js
+++ b/src/static/js/app.js
@@ -0,0 +1,3208 @@
+/**
+ * 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 = `
+
+
+
+
+
Keine Benachrichtigungen
+
+
+ `;
+ headerRight.insertBefore(container, headerUser);
+
+ // Event-Listener
+ document.getElementById('notification-bell').addEventListener('click', (e) => {
+ e.stopPropagation();
+ this.toggle();
+ });
+ document.getElementById('notification-mark-read').addEventListener('click', (e) => {
+ e.stopPropagation();
+ this.markAllRead();
+ });
+ // Klick außerhalb schließt Panel
+ document.addEventListener('click', (e) => {
+ if (this._isOpen && !container.contains(e.target)) {
+ this.close();
+ }
+ });
+
+ // Notifications aus DB laden
+ await this._loadFromDB();
+ },
+
+ add(notification) {
+ // Optimistisches UI: sofort anzeigen
+ notification.read = false;
+ notification.timestamp = notification.timestamp || new Date().toISOString();
+ this._notifications.unshift(notification);
+ if (this._notifications.length > this._maxItems) {
+ this._notifications.pop();
+ }
+ this._unreadCount++;
+ this._updateBadge();
+ this._renderList();
+
+ // DB-Sync mit Debounce (Orchestrator schreibt parallel in DB)
+ clearTimeout(this._syncTimer);
+ this._syncTimer = setTimeout(() => this._syncFromDB(), 500);
+ },
+
+ toggle() {
+ this._isOpen ? this.close() : this.open();
+ },
+
+ open() {
+ this._isOpen = true;
+ const panel = document.getElementById('notification-panel');
+ if (panel) panel.style.display = 'flex';
+ const bell = document.getElementById('notification-bell');
+ if (bell) bell.setAttribute('aria-expanded', 'true');
+ },
+
+ close() {
+ this._isOpen = false;
+ const panel = document.getElementById('notification-panel');
+ if (panel) panel.style.display = 'none';
+ const bell = document.getElementById('notification-bell');
+ if (bell) bell.setAttribute('aria-expanded', 'false');
+ },
+
+ async markAllRead() {
+ this._notifications.forEach(n => n.read = true);
+ this._unreadCount = 0;
+ this._updateBadge();
+ this._renderList();
+
+ // In DB als gelesen markieren (fire-and-forget)
+ try {
+ await API.markNotificationsRead(null);
+ } catch (e) {
+ console.warn('Notifications als gelesen markieren fehlgeschlagen:', e);
+ }
+ },
+
+ _updateBadge() {
+ const badge = document.getElementById('notification-badge');
+ if (!badge) return;
+ if (this._unreadCount > 0) {
+ badge.style.display = 'flex';
+ badge.textContent = this._unreadCount > 99 ? '99+' : this._unreadCount;
+ document.title = `(${this._unreadCount}) ${App._originalTitle}`;
+ } else {
+ badge.style.display = 'none';
+ document.title = App._originalTitle;
+ }
+ },
+
+ _renderList() {
+ const list = document.getElementById('notification-panel-list');
+ if (!list) return;
+
+ if (this._notifications.length === 0) {
+ list.innerHTML = 'Keine Benachrichtigungen
';
+ return;
+ }
+
+ list.innerHTML = this._notifications.map(n => {
+ const time = new Date(n.timestamp);
+ const timeStr = time.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', timeZone: TIMEZONE });
+ const unreadClass = n.read ? '' : ' unread';
+ const icon = n.icon || 'info';
+ return `
+
${this._iconSymbol(icon)}
+
+
${this._escapeHtml(n.title)}
+
${this._escapeHtml(n.text)}
+
+
${timeStr}
+
`;
+ }).join('');
+ },
+
+ _handleClick(incidentId) {
+ this.close();
+ if (incidentId) {
+ App.selectIncident(incidentId);
+ }
+ },
+
+ _iconSymbol(type) {
+ switch (type) {
+ case 'success': return '\u2713';
+ case 'warning': return '!';
+ case 'error': return '\u2717';
+ default: return 'i';
+ }
+ },
+
+ _escapeHtml(text) {
+ const d = document.createElement('div');
+ d.textContent = text || '';
+ return d.innerHTML;
+ },
+
+ async _loadFromDB() {
+ try {
+ const items = await API.listNotifications(50);
+ this._notifications = items.map(n => ({
+ id: n.id,
+ incident_id: n.incident_id,
+ title: n.title,
+ text: n.text,
+ icon: n.icon || 'info',
+ type: n.type,
+ read: !!n.is_read,
+ timestamp: n.created_at,
+ }));
+ this._unreadCount = this._notifications.filter(n => !n.read).length;
+ this._updateBadge();
+ this._renderList();
+ } catch (e) {
+ console.warn('Notifications laden fehlgeschlagen:', e);
+ }
+ },
+
+ async _syncFromDB() {
+ try {
+ const items = await API.listNotifications(50);
+ this._notifications = items.map(n => ({
+ id: n.id,
+ incident_id: n.incident_id,
+ title: n.title,
+ text: n.text,
+ icon: n.icon || 'info',
+ type: n.type,
+ read: !!n.is_read,
+ timestamp: n.created_at,
+ }));
+ this._unreadCount = this._notifications.filter(n => !n.read).length;
+ this._updateBadge();
+ this._renderList();
+ } catch (e) {
+ console.warn('Notifications sync fehlgeschlagen:', e);
+ }
+ },
+};
+
+const App = {
+ currentIncidentId: null,
+ incidents: [],
+ _originalTitle: document.title,
+ _refreshingIncidents: new Set(),
+ _editingIncidentId: null,
+ _currentArticles: [],
+ _currentIncidentType: 'adhoc',
+ _sidebarFilter: 'all',
+ _currentUsername: '',
+ _allSources: [],
+ _sourcesOnly: [],
+ _myExclusions: [], // [{domain, notes, created_at}]
+ _expandedGroups: new Set(),
+ _editingSourceId: null,
+ _timelineFilter: 'all',
+ _timelineRange: 'all',
+ _activePointIndex: 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._currentUsername = user.email;
+ 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;
+ }
+
+ // 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);
+ });
+ document.addEventListener('click', () => {
+ userDropdown.classList.remove('open');
+ userBtn.setAttribute('aria-expanded', 'false');
+ });
+ }
+
+ // Warnung bei abgelaufener Lizenz
+ const warningEl = document.getElementById('header-license-warning');
+ if (warningEl && user.read_only) {
+ warningEl.textContent = 'Lizenz abgelaufen – nur Lesezugriff';
+ warningEl.classList.add('visible');
+ }
+ } 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());
+
+ // 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();
+
+ // 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();
+ if (data.refreshing && data.refreshing.length > 0) {
+ data.refreshing.forEach(id => this._refreshingIncidents.add(id));
+ // Sidebar-Dots aktualisieren
+ data.refreshing.forEach(id => this._updateSidebarDot(id));
+ }
+ } catch (e) { /* Kein kritischer Fehler */ }
+
+ // 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 emptyLabelAdhoc = this._sidebarFilter === 'mine' ? 'Keine eigenen Ad-hoc-Lagen' : 'Keine Ad-hoc-Lagen';
+ const emptyLabelResearch = this._sidebarFilter === 'mine' ? 'Keine eigenen Recherchen' : 'Keine Recherchen';
+
+ 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('.grid-stack');
+ 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);
+ if (isRefreshing) {
+ UI.showProgress('researching');
+ } else {
+ UI.hideProgress();
+ }
+
+// 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("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("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, articles, factchecks, snapshots, locations] = await Promise.all([
+ API.getIncident(id),
+ API.getArticles(id),
+ API.getFactChecks(id),
+ API.getSnapshots(id),
+ API.getLocations(id).catch(() => []),
+ ]);
+
+ this.renderIncidentDetail(incident, articles, factchecks, snapshots, locations);
+ } catch (err) {
+ UI.showToast('Fehler beim Laden: ' + err.message, 'error');
+ }
+ },
+
+ renderIncidentDetail(incident, articles, factchecks, snapshots, locations) {
+ // Header Strip
+ document.getElementById('incident-title').textContent = incident.title;
+ document.getElementById('incident-description').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' ? 'Recherche' : 'Breaking';
+
+ // 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 mit Quellenverzeichnis
+ const summaryText = document.getElementById('summary-text');
+ if (incident.summary) {
+ summaryText.innerHTML = UI.renderSummary(
+ incident.summary,
+ incident.sources_json,
+ incident.type
+ );
+ } else {
+ summaryText.innerHTML = 'Noch keine Zusammenfassung. 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`
+ : '';
+ }
+
+ document.getElementById('meta-refresh-mode').textContent = incident.refresh_mode === 'auto'
+ ? `Auto alle ${App._formatInterval(incident.refresh_interval)}`
+ : '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
';
+ }
+
+ // Quellenübersicht
+ const sourceOverview = document.getElementById('source-overview-content');
+ if (sourceOverview) {
+ sourceOverview.innerHTML = UI.renderSourceOverview(articles);
+ // Kachel an Inhalt anpassen
+ if (typeof LayoutManager !== 'undefined' && LayoutManager._grid) {
+ if (sourceOverview.style.display !== 'none') {
+ // Offen → an Inhalt anpassen
+ requestAnimationFrame(() => requestAnimationFrame(() => {
+ LayoutManager.resizeTileToContent('quellen');
+ }));
+ } else {
+ // Geschlossen → einheitliche Default-Höhe
+ const defaults = LayoutManager.DEFAULT_LAYOUT.find(d => d.id === 'quellen');
+ if (defaults) {
+ const node = LayoutManager._grid.engine.nodes.find(
+ n => n.el && n.el.getAttribute('gs-id') === 'quellen'
+ );
+ if (node) LayoutManager._grid.update(node.el, { h: defaults.h });
+ }
+ }
+ }
+ }
+
+ // Timeline - Artikel + Snapshots zwischenspeichern und rendern
+ this._currentArticles = articles;
+ this._currentSnapshots = snapshots || [];
+ this._currentIncidentType = incident.type;
+ this._timelineFilter = 'all';
+ this._timelineRange = 'all';
+ this._activePointIndex = null;
+ document.getElementById('timeline-search').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 || []);
+ },
+
+ _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) {
+ snapshots = snapshots.filter(s => (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 (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);
+ },
+
+ rerenderTimeline() {
+ const container = document.getElementById('timeline');
+ 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);
+
+ if (entries.length === 0) {
+ this._activePointIndex = null;
+ container.innerHTML = (searchTerm || range !== 'all')
+ ? 'Keine Einträge im gewählten Zeitraum.
'
+ : 'Noch keine Meldungen. Starte eine Recherche mit "Aktualisieren".
';
+ return;
+ }
+
+ entries.sort((a, b) => new Date(a.timestamp || 0) - new Date(b.timestamp || 0));
+
+ const granularity = this._calcGranularity(entries, range);
+ let buckets = this._buildBuckets(entries, granularity);
+ buckets = this._mergeCloseBuckets(buckets);
+
+ // Aktiven Index validieren
+ if (this._activePointIndex !== null && this._activePointIndex >= buckets.length) {
+ this._activePointIndex = null;
+ }
+
+ // Achsen-Bereich
+ const rangeStart = buckets[0].timestamp;
+ const rangeEnd = buckets[buckets.length - 1].timestamp;
+ const maxCount = Math.max(...buckets.map(b => b.entries.length));
+
+ // Stunden- vs. Tages-Granularität
+ const isHourly = granularity === 'hour';
+ const axisLabels = this._buildAxisLabels(buckets, granularity, true);
+
+ // HTML aufbauen
+ let html = ``;
+
+ // Datums-Marker (immer anzeigen, ausgedünnt)
+ const dayMarkers = this._thinLabels(this._buildDayMarkers(buckets, rangeStart, rangeEnd), 10);
+ html += '
';
+ dayMarkers.forEach(m => {
+ html += `
`;
+ html += `
${UI.escape(m.text)}
`;
+ html += `
`;
+ html += `
`;
+ });
+ html += '
';
+
+ // Punkte
+ html += '
';
+ buckets.forEach((bucket, idx) => {
+ const pos = this._bucketPositionPercent(bucket, rangeStart, rangeEnd, buckets.length);
+ const size = this._calcPointSize(bucket.entries.length, maxCount);
+ const hasSnapshots = bucket.entries.some(e => e.kind === 'snapshot');
+ const hasArticles = bucket.entries.some(e => e.kind === 'article');
+
+ let pointClass = 'ht-point';
+ if (filterType === 'snapshots') {
+ pointClass += ' ht-snapshot-point';
+ } else if (hasSnapshots) {
+ pointClass += ' ht-mixed-point';
+ }
+ if (this._activePointIndex === idx) pointClass += ' active';
+
+ const tooltip = `${bucket.label}: ${bucket.entries.length} Eintr${bucket.entries.length === 1 ? 'ag' : 'äge'}`;
+
+ html += `
`;
+ html += `
${UI.escape(tooltip)}
`;
+ html += `
`;
+ });
+ html += '
';
+
+ // Achsenlinie
+ html += '
';
+
+ // Achsen-Labels (ausgedünnt um Überlappung zu vermeiden)
+ const thinned = this._thinLabels(axisLabels);
+ html += '
';
+ thinned.forEach(lbl => {
+ html += `
${UI.escape(lbl.text)}
`;
+ });
+ html += '
';
+ html += '
';
+
+ // Detail-Panel (wenn ein Punkt aktiv ist)
+ if (this._activePointIndex !== null && this._activePointIndex < buckets.length) {
+ html += this._renderDetailPanel(buckets[this._activePointIndex]);
+ }
+
+ container.innerHTML = html;
+ },
+
+ _calcGranularity(entries, range) {
+ if (entries.length < 2) return 'day';
+ const timestamps = entries.map(e => new Date(e.timestamp || 0).getTime()).filter(t => t > 0);
+ if (timestamps.length < 2) return 'day';
+ const span = Math.max(...timestamps) - Math.min(...timestamps);
+ if (range === '24h' || span <= 48 * 60 * 60 * 1000) return 'hour';
+ return 'day';
+ },
+
+ _buildBuckets(entries, granularity) {
+ const bucketMap = {};
+ entries.forEach(e => {
+ const d = new Date(e.timestamp || 0);
+ const b = _tz(d);
+ let key, label, ts;
+ if (granularity === 'hour') {
+ key = `${b.year}-${b.month + 1}-${b.date}-${b.hours}`;
+ label = d.toLocaleDateString('de-DE', { day: '2-digit', month: 'short', timeZone: TIMEZONE }) + ', ' + b.hours.toString().padStart(2, '0') + ':00';
+ ts = new Date(b.year, b.month, b.date, b.hours).getTime();
+ } else {
+ key = `${b.year}-${b.month + 1}-${b.date}`;
+ label = d.toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: 'short', timeZone: TIMEZONE });
+ ts = new Date(b.year, b.month, b.date, 12).getTime();
+ }
+ if (!bucketMap[key]) {
+ bucketMap[key] = { key, label, timestamp: ts, entries: [] };
+ }
+ bucketMap[key].entries.push(e);
+ });
+ return Object.values(bucketMap).sort((a, b) => a.timestamp - b.timestamp);
+ },
+
+ _mergeCloseBuckets(buckets) {
+ if (buckets.length < 2) return buckets;
+ const rangeStart = buckets[0].timestamp;
+ const rangeEnd = buckets[buckets.length - 1].timestamp;
+ if (rangeEnd <= rangeStart) return buckets;
+
+ const container = document.getElementById('timeline');
+ const axisWidth = (container ? container.offsetWidth : 800) * 0.92;
+ const maxCount = Math.max(...buckets.map(b => b.entries.length));
+ const result = [buckets[0]];
+
+ for (let i = 1; i < buckets.length; i++) {
+ const prev = result[result.length - 1];
+ const curr = buckets[i];
+
+ const distPx = ((curr.timestamp - prev.timestamp) / (rangeEnd - rangeStart)) * axisWidth;
+ const prevSize = Math.min(32, this._calcPointSize(prev.entries.length, maxCount));
+ const currSize = Math.min(32, this._calcPointSize(curr.entries.length, maxCount));
+ const minDistPx = (prevSize + currSize) / 2 + 6;
+
+ if (distPx < minDistPx) {
+ prev.entries = prev.entries.concat(curr.entries);
+ } else {
+ result.push(curr);
+ }
+ }
+ return result;
+ },
+
+ _bucketPositionPercent(bucket, rangeStart, rangeEnd, totalBuckets) {
+ if (totalBuckets === 1) return 50;
+ if (rangeEnd === rangeStart) return 50;
+ return ((bucket.timestamp - rangeStart) / (rangeEnd - rangeStart)) * 100;
+ },
+
+ _calcPointSize(count, maxCount) {
+ if (maxCount <= 1) return 16;
+ const minSize = 12;
+ const maxSize = 32;
+ const logScale = Math.log(count + 1) / Math.log(maxCount + 1);
+ return Math.round(minSize + logScale * (maxSize - minSize));
+ },
+
+ _buildAxisLabels(buckets, granularity, timeOnly) {
+ if (buckets.length === 0) return [];
+ const maxLabels = 8;
+ const labels = [];
+ const rangeStart = buckets[0].timestamp;
+ const rangeEnd = buckets[buckets.length - 1].timestamp;
+
+ const getLabelText = (b) => {
+ if (timeOnly) {
+ // Bei Tages-Granularität: Uhrzeit des ersten Eintrags nehmen
+ const ts = (granularity === 'day' && b.entries && b.entries.length > 0)
+ ? new Date(b.entries[0].timestamp || b.timestamp)
+ : new Date(b.timestamp);
+ const tp = _tz(ts);
+ return tp.hours.toString().padStart(2, '0') + ':' + tp.minutes.toString().padStart(2, '0');
+ }
+ return b.label;
+ };
+
+ if (buckets.length <= maxLabels) {
+ buckets.forEach(b => {
+ labels.push({ text: getLabelText(b), pos: this._bucketPositionPercent(b, rangeStart, rangeEnd, buckets.length) });
+ });
+ } else {
+ const step = (buckets.length - 1) / (maxLabels - 1);
+ for (let i = 0; i < maxLabels; i++) {
+ const idx = Math.round(i * step);
+ const b = buckets[idx];
+ labels.push({ text: getLabelText(b), pos: this._bucketPositionPercent(b, rangeStart, rangeEnd, buckets.length) });
+ }
+ }
+ return labels;
+ },
+
+ _thinLabels(labels, minGapPercent) {
+ if (!labels || labels.length <= 1) return labels;
+ const gap = minGapPercent || 8;
+ const result = [labels[0]];
+ for (let i = 1; i < labels.length; i++) {
+ if (labels[i].pos - result[result.length - 1].pos >= gap) {
+ result.push(labels[i]);
+ }
+ }
+ return result;
+ },
+
+ _buildDayMarkers(buckets, rangeStart, rangeEnd) {
+ const seen = {};
+ const markers = [];
+ buckets.forEach(b => {
+ const d = new Date(b.timestamp);
+ const bp = _tz(d);
+ const dayKey = `${bp.year}-${bp.month}-${bp.date}`;
+ if (!seen[dayKey]) {
+ seen[dayKey] = true;
+ 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}`;
+ let label;
+ const dateStr = d.toLocaleDateString('de-DE', { day: '2-digit', month: 'short', timeZone: TIMEZONE });
+ if (dayKey === todayKey) {
+ label = 'Heute, ' + dateStr;
+ } else if (dayKey === yesterdayKey) {
+ label = 'Gestern, ' + dateStr;
+ } else {
+ label = d.toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: 'short', timeZone: TIMEZONE });
+ }
+ const pos = this._bucketPositionPercent(b, rangeStart, rangeEnd, buckets.length);
+ markers.push({ text: label, pos });
+ }
+ });
+ return markers;
+ },
+
+ _renderDetailPanel(bucket) {
+ const type = this._currentIncidentType;
+ const sorted = [...bucket.entries].sort((a, b) => {
+ if (a.kind === 'snapshot' && b.kind !== 'snapshot') return -1;
+ if (a.kind !== 'snapshot' && b.kind === 'snapshot') return 1;
+ return new Date(b.timestamp || 0) - new Date(a.timestamp || 0);
+ });
+
+ let entriesHtml = '';
+ sorted.forEach(e => {
+ if (e.kind === 'snapshot') {
+ entriesHtml += this._renderSnapshotEntry(e.data);
+ } else {
+ entriesHtml += this._renderArticleEntry(e.data, type, 0);
+ }
+ });
+
+ return ``;
+ },
+
+ setTimelineFilter(filter) {
+ this._timelineFilter = filter;
+ this._activePointIndex = 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._activePointIndex = 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();
+ },
+
+ openTimelineDetail(bucketIndex) {
+ if (this._activePointIndex === bucketIndex) {
+ this._activePointIndex = null;
+ } else {
+ this._activePointIndex = bucketIndex;
+ }
+ this.rerenderTimeline();
+ this._resizeTimelineTile();
+ },
+
+ closeTimelineDetail() {
+ this._activePointIndex = null;
+ this.rerenderTimeline();
+ this._resizeTimelineTile();
+ },
+
+ _resizeTimelineTile() {
+ if (typeof LayoutManager === 'undefined' || !LayoutManager._grid) return;
+ requestAnimationFrame(() => { requestAnimationFrame(() => {
+ // Prüfen ob Detail-Panel oder expandierter Eintrag offen ist
+ const hasDetail = document.querySelector('.ht-detail-panel') !== null;
+ const hasExpanded = document.querySelector('.timeline-card .vt-entry.expanded') !== null;
+
+ if (hasDetail || hasExpanded) {
+ LayoutManager.resizeTileToContent('timeline');
+ } else {
+ // Zurück auf Default-Höhe
+ const defaults = LayoutManager.DEFAULT_LAYOUT.find(d => d.id === 'timeline');
+ if (defaults) {
+ const node = LayoutManager._grid.engine.nodes.find(
+ n => n.el && n.el.getAttribute('gs-id') === 'timeline'
+ );
+ if (node) {
+ LayoutManager._grid.update(node.el, { h: defaults.h });
+ LayoutManager._debouncedSave();
+ }
+ }
+ }
+ // Scroll in Sicht
+ const card = document.querySelector('.timeline-card');
+ const main = document.querySelector('.main-content');
+ if (!card || !main) return;
+ const cardBottom = card.getBoundingClientRect().bottom;
+ const mainBottom = main.getBoundingClientRect().bottom;
+ if (cardBottom > mainBottom) {
+ main.scrollBy({ top: cardBottom - mainBottom + 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.
+ */
+ _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 der Zusammenfassung
+ const summaryText = snapshot.summary || '';
+ const preview = summaryText.length > 200 ? summaryText.substring(0, 200) + '...' : summaryText;
+
+ // Vollständige Zusammenfassung via UI.renderSummary
+ const fullSummary = UI.renderSummary(snapshot.summary, snapshot.sources_json, this._currentIncidentType);
+
+ return `
+
+
${UI.escape(preview)}
+
${fullSummary}
+
`;
+ },
+
+ /**
+ * 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')) {
+ 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,
+ 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();
+ await this.selectIncident(incident.id);
+
+ // Sofort ersten Refresh starten
+ this._refreshingIncidents.add(incident.id);
+ this._updateRefreshButton(true);
+ UI.showProgress('queued');
+ 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 handleRefresh() {
+ if (!this.currentIncidentId) return;
+ if (this._refreshingIncidents.has(this.currentIncidentId)) {
+ UI.showToast('Recherche läuft bereits...', 'warning');
+ return;
+ }
+ try {
+ this._refreshingIncidents.add(this.currentIncidentId);
+ this._updateRefreshButton(true);
+ UI.showProgress('queued');
+ const result = await API.refreshIncident(this.currentIncidentId);
+ if (result && result.status === 'skipped') {
+ this._refreshingIncidents.delete(this.currentIncidentId);
+ this._updateRefreshButton(false);
+ UI.hideProgress();
+ UI.showToast('Recherche läuft bereits oder ist in der Warteschlange.', 'warning');
+ }
+ } 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 locations = await API.getLocations(incidentId).catch(() => []);
+ UI.renderMap(locations);
+ } 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;
+ document.getElementById('inc-refresh-unit').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;
+ btn.disabled = disabled;
+ btn.textContent = disabled ? 'Läuft...' : 'Aktualisieren';
+ },
+
+ 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
+ document.getElementById('inc-title').value = incident.title;
+ document.getElementById('inc-description').value = incident.description || '';
+ document.getElementById('inc-type').value = incident.type || 'adhoc';
+ document.getElementById('inc-refresh-mode').value = incident.refresh_mode;
+ App._setIntervalFields(incident.refresh_interval);
+ document.getElementById('inc-retention').value = incident.retention_days;
+ document.getElementById('inc-international').checked = incident.international_sources !== false && incident.international_sources !== 0;
+ document.getElementById('inc-telegram').checked = !!incident.include_telegram;
+ document.getElementById('inc-visibility').checked = incident.visibility !== 'private';
+ updateVisibilityHint();
+ updateSourcesHint();
+ toggleTypeDefaults();
+ toggleRefreshInterval();
+
+ // Modal-Titel und Submit ändern
+ document.getElementById('modal-new-title').textContent = 'Lage bearbeiten';
+ document.getElementById('modal-new-submit').textContent = 'Speichern';
+
+ // E-Mail-Subscription laden
+ try {
+ const sub = await API.getSubscription(this.currentIncidentId);
+ document.getElementById('inc-notify-summary').checked = !!sub.notify_email_summary;
+ document.getElementById('inc-notify-new-articles').checked = !!sub.notify_email_new_articles;
+ document.getElementById('inc-notify-status-change').checked = !!sub.notify_email_status_change;
+ } catch (e) {
+ document.getElementById('inc-notify-summary').checked = false;
+ document.getElementById('inc-notify-new-articles').checked = false;
+ document.getElementById('inc-notify-status-change').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;
+ btn.textContent = status === 'archived' ? 'Wiederherstellen' : 'Archivieren';
+ },
+
+ // === WebSocket Handlers ===
+
+ handleStatusUpdate(msg) {
+ const status = msg.data.status;
+ if (status === 'retrying') {
+ // Retry-Status → Fehleranzeige mit Retry-Info
+ if (msg.incident_id === this.currentIncidentId) {
+ UI.showProgressError('', true, msg.data.delay || 120);
+ }
+ return;
+ }
+ if (status !== 'idle') {
+ this._refreshingIncidents.add(msg.incident_id);
+ }
+ this._updateSidebarDot(msg.incident_id);
+ if (msg.incident_id === this.currentIncidentId) {
+ UI.showProgress(status, msg.data);
+ this._updateRefreshButton(status !== 'idle');
+ }
+ },
+
+ async handleRefreshComplete(msg) {
+ this._refreshingIncidents.delete(msg.incident_id);
+ this._updateSidebarDot(msg.incident_id);
+
+ if (msg.incident_id === this.currentIncidentId) {
+ this._updateRefreshButton(false);
+ await this.loadIncidentDetail(msg.incident_id);
+
+ // Progress-Bar nicht sofort ausblenden — auf refresh_summary warten
+ this._pendingComplete = msg.incident_id;
+ // Fallback: Wenn nach 5s kein refresh_summary kommt → direkt ausblenden
+ if (this._pendingCompleteTimer) clearTimeout(this._pendingCompleteTimer);
+ this._pendingCompleteTimer = setTimeout(() => {
+ if (this._pendingComplete === msg.incident_id) {
+ this._pendingComplete = null;
+ UI.hideProgress();
+ }
+ }, 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);
+ setTimeout(() => UI.hideProgress(), 4000);
+ }
+
+ // 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');
+ 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);
+ }
+ UI.showToast(`Recherche-Fehler: ${msg.data.error}`, 'error');
+ },
+
+ handleRefreshCancelled(msg) {
+ this._refreshingIncidents.delete(msg.incident_id);
+ this._updateSidebarDot(msg.incident_id);
+ if (msg.incident_id === this.currentIncidentId) {
+ this._updateRefreshButton(false);
+ if (this._pendingCompleteTimer) {
+ clearTimeout(this._pendingCompleteTimer);
+ this._pendingCompleteTimer = null;
+ }
+ this._pendingComplete = null;
+ UI.hideProgress();
+ }
+ UI.showToast('Recherche abgebrochen.', 'info');
+ },
+
+ async cancelRefresh() {
+ if (!this.currentIncidentId) return;
+ const ok = await confirmDialog('Laufende Recherche abbrechen?');
+ if (!ok) return;
+
+ const btn = document.getElementById('progress-cancel-btn');
+ if (btn) {
+ btn.textContent = 'Wird abgebrochen...';
+ btn.disabled = true;
+ }
+
+ try {
+ await API.cancelRefresh(this.currentIncidentId);
+ } catch (err) {
+ UI.showToast('Abbrechen fehlgeschlagen: ' + err.message, 'error');
+ if (btn) {
+ btn.textContent = 'Abbrechen';
+ btn.disabled = false;
+ }
+ }
+ },
+
+ // === Export ===
+
+ toggleExportDropdown(event) {
+ event.stopPropagation();
+ const menu = document.getElementById('export-dropdown-menu');
+ if (!menu) return;
+ const isOpen = menu.classList.toggle('show');
+ const btn = menu.previousElementSibling;
+ if (btn) btn.setAttribute('aria-expanded', String(isOpen));
+ },
+
+ _closeExportDropdown() {
+ const menu = document.getElementById('export-dropdown-menu');
+ if (menu) {
+ menu.classList.remove('show');
+ const btn = menu.previousElementSibling;
+ if (btn) btn.setAttribute('aria-expanded', 'false');
+ }
+ },
+
+ async exportIncident(format, scope) {
+ this._closeExportDropdown();
+ if (!this.currentIncidentId) return;
+ try {
+ const response = await API.exportIncident(this.currentIncidentId, format, scope);
+ 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 = 'export.' + 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);
+ UI.showToast('Export heruntergeladen', 'success');
+ } catch (err) {
+ UI.showToast('Export fehlgeschlagen: ' + err.message, 'error');
+ }
+ },
+
+ printIncident() {
+ this._closeExportDropdown();
+ window.print();
+ },
+
+ // === Sidebar-Stats ===
+
+ async updateSidebarStats() {
+ 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} Quellen`;
+ if (artCount) artCount.textContent = `${stats.total_articles} Artikel`;
+ } 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} Quellen`;
+ if (artCount) artCount.textContent = `${totalArticles} Artikel`;
+ }
+ },
+
+ // === 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 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;
+ }
+
+ // 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));
+ // gridstack-Kachel an Inhalt anpassen (doppelter rAF für vollständiges Layout)
+ if (typeof LayoutManager !== 'undefined' && LayoutManager._grid) {
+ if (isHidden) {
+ // Aufgeklappt → Inhalt muss erst layouten
+ requestAnimationFrame(() => requestAnimationFrame(() => {
+ LayoutManager.resizeTileToContent('quellen');
+ }));
+ } else {
+ // Zugeklappt → auf Default-Höhe zurück
+ const defaults = LayoutManager.DEFAULT_LAYOUT.find(d => d.id === 'quellen');
+ if (defaults) {
+ const node = LayoutManager._grid.engine.nodes.find(
+ n => n.el && n.el.getAttribute('gs-id') === 'quellen'
+ );
+ if (node) {
+ LayoutManager._grid.update(node.el, { h: defaults.h });
+ LayoutManager._debouncedSave();
+ }
+ }
+ }
+ }
+ },
+
+ 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';
+ // 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 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';
+ 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';
+ 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 || 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');
+ }
+ },
+
+ logout() {
+ localStorage.removeItem('osint_token');
+ localStorage.removeItem('osint_username');
+ this._sessionWarningShown = false;
+ WS.disconnect();
+ window.location.href = '/';
+ },
+};
+
+// === Barrierefreier Bestätigungsdialog ===
+
+function confirmDialog(message) {
+ return new Promise((resolve) => {
+ // Overlay erstellen
+ const overlay = document.createElement('div');
+ overlay.className = 'modal-overlay active';
+ overlay.setAttribute('role', 'alertdialog');
+ overlay.setAttribute('aria-modal', 'true');
+ overlay.setAttribute('aria-labelledby', 'confirm-dialog-msg');
+
+ const modal = document.createElement('div');
+ modal.className = 'modal';
+ modal.style.maxWidth = '420px';
+ modal.innerHTML = `
+
+
+
${message.replace(//g, '>')}
+
+
+ `;
+ overlay.appendChild(modal);
+ document.body.appendChild(overlay);
+
+ const previousFocus = document.activeElement;
+
+ const cleanup = (result) => {
+ releaseFocus(overlay);
+ overlay.remove();
+ if (previousFocus) previousFocus.focus();
+ resolve(result);
+ };
+
+ modal.querySelector('#confirm-cancel').addEventListener('click', () => cleanup(false));
+ modal.querySelector('#confirm-ok').addEventListener('click', () => cleanup(true));
+ overlay.addEventListener('click', (e) => {
+ if (e.target === overlay) cleanup(false);
+ });
+ overlay.addEventListener('keydown', (e) => {
+ if (e.key === 'Escape') cleanup(false);
+ });
+
+ trapFocus(overlay);
+ });
+}
+
+// === Globale Hilfsfunktionen ===
+
+// --- Focus-Trap für Modals (WCAG 2.4.3) ---
+const FOCUSABLE_SELECTOR = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
+
+function trapFocus(modalEl) {
+ const handler = (e) => {
+ if (e.key !== 'Tab') return;
+ const focusable = Array.from(modalEl.querySelectorAll(FOCUSABLE_SELECTOR)).filter(el => el.offsetParent !== null);
+ if (focusable.length === 0) return;
+ const first = focusable[0];
+ const last = focusable[focusable.length - 1];
+ if (e.shiftKey && document.activeElement === first) {
+ e.preventDefault();
+ last.focus();
+ } else if (!e.shiftKey && document.activeElement === last) {
+ e.preventDefault();
+ first.focus();
+ }
+ };
+ modalEl._focusTrapHandler = handler;
+ modalEl.addEventListener('keydown', handler);
+ // Fokus auf erstes Element setzen
+ requestAnimationFrame(() => {
+ const focusable = Array.from(modalEl.querySelectorAll(FOCUSABLE_SELECTOR)).filter(el => el.offsetParent !== null);
+ if (focusable.length > 0) focusable[0].focus();
+ });
+}
+
+function releaseFocus(modalEl) {
+ if (modalEl._focusTrapHandler) {
+ modalEl.removeEventListener('keydown', modalEl._focusTrapHandler);
+ delete modalEl._focusTrapHandler;
+ }
+}
+
+function openModal(id) {
+ if (id === 'modal-new' && !App._editingIncidentId) {
+ // Create-Modus: Formular zurücksetzen
+ document.getElementById('new-incident-form').reset();
+ document.getElementById('modal-new-title').textContent = 'Neue Lage anlegen';
+ document.getElementById('modal-new-submit').textContent = 'Lage anlegen';
+ // 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) {
+ 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 = 'Neue Lage anlegen';
+ document.getElementById('modal-new-submit').textContent = '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 = ``;
+
+ 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');
+ field.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) {
+ text.textContent = isPublic
+ ? 'Öffentlich — für alle Nutzer sichtbar'
+ : '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() {
+ const type = document.getElementById('inc-type').value;
+ const hint = document.getElementById('type-hint');
+ const refreshMode = document.getElementById('inc-refresh-mode');
+
+ if (type === 'research') {
+ hint.textContent = 'Nur WebSearch (Deep Research), manuelle Aktualisierung empfohlen';
+ refreshMode.value = 'manual';
+ toggleRefreshInterval();
+ } else {
+ hint.textContent = 'RSS-Feeds + WebSearch, automatische Aktualisierung empfohlen';
+ }
+}
+
+// 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 exportMenu = document.getElementById('export-dropdown-menu');
+ if (exportMenu && exportMenu.classList.contains('show')) {
+ App._closeExportDropdown();
+ 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) => {
+ if (!e.target.closest('.export-dropdown')) {
+ App._closeExportDropdown();
+ }
+});
+document.addEventListener('DOMContentLoaded', () => App.init());