/** * OSINT Lagemonitor - Hauptanwendungslogik. */ /** Feste Zeitzone fuer alle Anzeigen — NIEMALS aendern. */ const TIMEZONE = 'Europe/Berlin'; /** Gibt Jahr/Monat(0-basiert)/Tag/Stunde/Minute in Berliner Zeit zurueck. */ function _tz(d) { const s = d.toLocaleString('en-CA', { timeZone: TIMEZONE, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }); const m = s.match(/(\d{4})-(\d{2})-(\d{2}),?\s*(\d{2}):(\d{2}):(\d{2})/); if (!m) return { year: d.getFullYear(), month: d.getMonth(), date: d.getDate(), hours: d.getHours(), minutes: d.getMinutes() }; return { year: +m[1], month: +m[2] - 1, date: +m[3], hours: +m[4], minutes: +m[5] }; } /** * Theme Manager: Dark/Light Theme Toggle mit localStorage-Persistenz. */ const ThemeManager = { _key: 'osint_theme', init() { const saved = localStorage.getItem(this._key); const theme = saved || 'dark'; document.documentElement.setAttribute('data-theme', theme); this._updateIcon(theme); }, toggle() { const current = document.documentElement.getAttribute('data-theme') || 'dark'; const next = current === 'dark' ? 'light' : 'dark'; document.documentElement.setAttribute('data-theme', next); localStorage.setItem(this._key, next); this._updateIcon(next); UI.updateMapTheme(); }, _updateIcon(theme) { const el = document.getElementById('theme-toggle'); if (!el) return; el.classList.remove('dark', 'light'); el.classList.add(theme); el.setAttribute('aria-checked', theme === 'dark' ? 'true' : 'false'); } }; /** * Barrierefreiheits-Manager: Panel mit 4 Schaltern (Kontrast, Focus, Schrift, Animationen). */ const A11yManager = { _key: 'osint_a11y', _isOpen: false, _settings: { contrast: false, focus: false, fontsize: false, motion: false }, init() { // Einstellungen aus localStorage laden try { const saved = JSON.parse(localStorage.getItem(this._key) || '{}'); Object.keys(this._settings).forEach(k => { if (typeof saved[k] === 'boolean') this._settings[k] = saved[k]; }); } catch (e) { /* Ungültige Daten ignorieren */ } // Button + Panel dynamisch in .header-right einfügen (vor Theme-Toggle) const headerRight = document.querySelector('.header-right'); const themeToggle = document.getElementById('theme-toggle'); if (!headerRight) return; const container = document.createElement('div'); container.className = 'a11y-center'; container.innerHTML = ` `; if (themeToggle) { headerRight.insertBefore(container, themeToggle); } else { headerRight.prepend(container); } // Toggle-Event-Listener ['contrast', 'focus', 'fontsize', 'motion'].forEach(key => { document.getElementById('a11y-' + key).addEventListener('change', () => this.toggle(key)); }); // Button öffnet/schließt Panel document.getElementById('a11y-btn').addEventListener('click', (e) => { e.stopPropagation(); this._isOpen ? this._closePanel() : this._openPanel(); }); // Klick außerhalb schließt Panel document.addEventListener('click', (e) => { if (this._isOpen && !container.contains(e.target)) { this._closePanel(); } }); // Keyboard: Esc schließt, Pfeiltasten navigieren container.addEventListener('keydown', (e) => { if (e.key === 'Escape' && this._isOpen) { e.stopPropagation(); this._closePanel(); return; } if (!this._isOpen) return; if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); const options = Array.from(document.querySelectorAll('.a11y-option input[type="checkbox"]')); const idx = options.indexOf(document.activeElement); let next; if (e.key === 'ArrowDown') { next = idx < options.length - 1 ? idx + 1 : 0; } else { next = idx > 0 ? idx - 1 : options.length - 1; } options[next].focus(); } }); // Einstellungen anwenden + Checkboxen synchronisieren this._apply(); this._syncUI(); }, toggle(key) { this._settings[key] = !this._settings[key]; this._apply(); this._syncUI(); this._save(); }, _apply() { const root = document.documentElement; Object.keys(this._settings).forEach(k => { if (this._settings[k]) { root.setAttribute('data-a11y-' + k, 'true'); } else { root.removeAttribute('data-a11y-' + k); } }); }, _syncUI() { Object.keys(this._settings).forEach(k => { const cb = document.getElementById('a11y-' + k); if (cb) cb.checked = this._settings[k]; }); }, _save() { localStorage.setItem(this._key, JSON.stringify(this._settings)); }, _openPanel() { this._isOpen = true; document.getElementById('a11y-panel').style.display = ''; document.getElementById('a11y-btn').setAttribute('aria-expanded', 'true'); // Fokus auf erste Option setzen requestAnimationFrame(() => { const first = document.querySelector('.a11y-option input[type="checkbox"]'); if (first) first.focus(); }); }, _closePanel() { this._isOpen = false; document.getElementById('a11y-panel').style.display = 'none'; const btn = document.getElementById('a11y-btn'); btn.setAttribute('aria-expanded', 'false'); btn.focus(); } }; /** * Notification-Center: Glocke mit Badge + History-Panel. */ const NotificationCenter = { _notifications: [], _unreadCount: 0, _isOpen: false, _maxItems: 50, _syncTimer: null, async init() { // Glocken-Container dynamisch in .header-right vor #header-user einfügen const headerRight = document.querySelector('.header-right'); const headerUser = document.getElementById('header-user'); if (!headerRight || !headerUser) return; const container = document.createElement('div'); container.className = 'notification-center'; container.innerHTML = ` `; headerRight.insertBefore(container, headerUser); // Event-Listener document.getElementById('notification-bell').addEventListener('click', (e) => { e.stopPropagation(); this.toggle(); }); document.getElementById('notification-mark-read').addEventListener('click', (e) => { e.stopPropagation(); this.markAllRead(); }); // Klick außerhalb schließt Panel document.addEventListener('click', (e) => { if (this._isOpen && !container.contains(e.target)) { this.close(); } }); // Notifications aus DB laden await this._loadFromDB(); }, add(notification) { // Optimistisches UI: sofort anzeigen notification.read = false; notification.timestamp = notification.timestamp || new Date().toISOString(); this._notifications.unshift(notification); if (this._notifications.length > this._maxItems) { this._notifications.pop(); } this._unreadCount++; this._updateBadge(); this._renderList(); // DB-Sync mit Debounce (Orchestrator schreibt parallel in DB) clearTimeout(this._syncTimer); this._syncTimer = setTimeout(() => this._syncFromDB(), 500); }, toggle() { this._isOpen ? this.close() : this.open(); }, open() { this._isOpen = true; const panel = document.getElementById('notification-panel'); if (panel) panel.style.display = 'flex'; const bell = document.getElementById('notification-bell'); if (bell) bell.setAttribute('aria-expanded', 'true'); }, close() { this._isOpen = false; const panel = document.getElementById('notification-panel'); if (panel) panel.style.display = 'none'; const bell = document.getElementById('notification-bell'); if (bell) bell.setAttribute('aria-expanded', 'false'); }, async markAllRead() { this._notifications.forEach(n => n.read = true); this._unreadCount = 0; this._updateBadge(); this._renderList(); // In DB als gelesen markieren (fire-and-forget) try { await API.markNotificationsRead(null); } catch (e) { console.warn('Notifications als gelesen markieren fehlgeschlagen:', e); } }, _updateBadge() { const badge = document.getElementById('notification-badge'); if (!badge) return; if (this._unreadCount > 0) { badge.style.display = 'flex'; badge.textContent = this._unreadCount > 99 ? '99+' : this._unreadCount; document.title = `(${this._unreadCount}) ${App._originalTitle}`; } else { badge.style.display = 'none'; document.title = App._originalTitle; } }, _renderList() { const list = document.getElementById('notification-panel-list'); if (!list) return; if (this._notifications.length === 0) { list.innerHTML = '
Keine Benachrichtigungen
'; return; } list.innerHTML = this._notifications.map(n => { const time = new Date(n.timestamp); const timeStr = time.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', timeZone: TIMEZONE }); const unreadClass = n.read ? '' : ' unread'; const icon = n.icon || 'info'; return `
${this._iconSymbol(icon)}
${this._escapeHtml(n.title)}
${this._escapeHtml(n.text)}
${timeStr}
`; }).join(''); }, _handleClick(incidentId) { this.close(); if (incidentId) { App.selectIncident(incidentId); } }, _iconSymbol(type) { switch (type) { case 'success': return '\u2713'; case 'warning': return '!'; case 'error': return '\u2717'; default: return 'i'; } }, _escapeHtml(text) { const d = document.createElement('div'); d.textContent = text || ''; return d.innerHTML; }, async _loadFromDB() { try { const items = await API.listNotifications(50); this._notifications = items.map(n => ({ id: n.id, incident_id: n.incident_id, title: n.title, text: n.text, icon: n.icon || 'info', type: n.type, read: !!n.is_read, timestamp: n.created_at, })); this._unreadCount = this._notifications.filter(n => !n.read).length; this._updateBadge(); this._renderList(); } catch (e) { console.warn('Notifications laden fehlgeschlagen:', e); } }, async _syncFromDB() { try { const items = await API.listNotifications(50); this._notifications = items.map(n => ({ id: n.id, incident_id: n.incident_id, title: n.title, text: n.text, icon: n.icon || 'info', type: n.type, read: !!n.is_read, timestamp: n.created_at, })); this._unreadCount = this._notifications.filter(n => !n.read).length; this._updateBadge(); this._renderList(); } catch (e) { console.warn('Notifications sync fehlgeschlagen:', e); } }, }; const App = { currentIncidentId: null, incidents: [], _originalTitle: document.title, _refreshingIncidents: new Set(), _editingIncidentId: null, _currentArticles: [], _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()); // Telegram-Kategorien Toggle const tgCheckbox = document.getElementById('inc-telegram'); if (tgCheckbox) { } // Feedback document.getElementById('feedback-form').addEventListener('submit', (e) => this.submitFeedback(e)); document.getElementById('fb-message').addEventListener('input', (e) => { document.getElementById('fb-char-count').textContent = e.target.value.length.toLocaleString('de-DE'); }); // Sidebar-Chevrons initial auf offen setzen (Archiv geschlossen) document.querySelectorAll('.sidebar-chevron').forEach(c => c.classList.add('open')); document.getElementById('chevron-archived-incidents').classList.remove('open'); var chevronNetwork = document.getElementById('chevron-network-analyses-list'); if (chevronNetwork) chevronNetwork.classList.add('open'); // Lagen laden (frueh, damit Sidebar sofort sichtbar) await this.loadIncidents(); // Netzwerkanalysen laden await this.loadNetworkAnalyses(); // 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)); WS.on('network_status', (msg) => this._handleNetworkStatus(msg)); WS.on('network_complete', (msg) => this._handleNetworkComplete(msg)); WS.on('network_error', (msg) => this._handleNetworkError(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); } } // Zuletzt ausgewählte Netzwerkanalyse wiederherstellen if (!savedId || !this.incidents.some(inc => inc.id === parseInt(savedId, 10))) { const savedNetworkId = localStorage.getItem('selectedNetworkId'); if (savedNetworkId) { const nid = parseInt(savedNetworkId, 10); if (this.networkAnalyses.some(na => na.id === nid)) { await this.selectNetworkAnalysis(nid); } } } // 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' ? 'Kein eigenes Live-Monitoring' : 'Kein Live-Monitoring'; const emptyLabelResearch = this._sidebarFilter === 'mine' ? 'Keine eigenen Deep-Research' : 'Keine Deep-Research'; activeContainer.innerHTML = activeAdhoc.length ? activeAdhoc.map(i => UI.renderIncidentItem(i, i.id === this.currentIncidentId)).join('') : `
${emptyLabelAdhoc}
`; researchContainer.innerHTML = activeResearch.length ? activeResearch.map(i => UI.renderIncidentItem(i, i.id === this.currentIncidentId)).join('') : `
${emptyLabelResearch}
`; archivedContainer.innerHTML = archived.length ? archived.map(i => UI.renderIncidentItem(i, i.id === this.currentIncidentId)).join('') : '
Kein Archiv
'; // Zähler aktualisieren const countAdhoc = document.getElementById('count-active-incidents'); const countResearch = document.getElementById('count-active-research'); const countArchived = document.getElementById('count-archived-incidents'); if (countAdhoc) countAdhoc.textContent = `(${activeAdhoc.length})`; if (countResearch) countResearch.textContent = `(${activeResearch.length})`; if (countArchived) countArchived.textContent = `(${archived.length})`; // Sidebar-Stats aktualisieren this.updateSidebarStats(); }, setSidebarFilter(filter) { this._sidebarFilter = filter; this.renderSidebar(); }, _announceForSR(text) { let el = document.getElementById('sr-announcement'); if (!el) { el = document.createElement('div'); el.id = 'sr-announcement'; el.setAttribute('role', 'status'); el.setAttribute('aria-live', 'polite'); el.className = 'sr-only'; document.body.appendChild(el); } el.textContent = ''; requestAnimationFrame(() => { el.textContent = text; }); }, async selectIncident(id) { this.closeRefreshHistory(); this.currentIncidentId = id; localStorage.setItem('selectedIncidentId', id); const inc = this.incidents.find(i => i.id === id); if (inc) this._announceForSR('Lage ausgewählt: ' + inc.title); this.renderSidebar(); var mc = document.getElementById("main-content"); mc.scrollTop = 0; document.getElementById('empty-state').style.display = 'none'; document.getElementById('incident-view').style.display = 'flex'; document.getElementById('network-view').style.display = 'none'; this.currentNetworkId = null; localStorage.removeItem('selectedNetworkId'); this.renderNetworkSidebar(); // 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("source-overview-header-stats"); if (el) el.textContent = ""; el = document.getElementById("timeline-entries"); if (el) el.innerHTML = ""; await this.loadIncidentDetail(id); // Scroll-Sperre nach 3 Frames aufheben (nach allen doppelten rAF-Callbacks) mc.scrollTop = 0; requestAnimationFrame(() => { requestAnimationFrame(() => { requestAnimationFrame(() => { mc.scrollTop = 0; mc.removeEventListener('scroll', scrollLock); if (gridEl) gridEl.classList.add('grid-stack-animate'); }); }); }); }, async loadIncidentDetail(id) { try { const [incident, articles, factchecks, snapshots, locationsResponse] = await Promise.all([ API.getIncident(id), API.getArticles(id), API.getFactChecks(id), API.getSnapshots(id), API.getLocations(id).catch(() => []), ]); // Locations-API gibt jetzt {category_labels, locations} oder Array (Rückwärtskompatibel) let locations, categoryLabels; if (Array.isArray(locationsResponse)) { locations = locationsResponse; categoryLabels = null; } else if (locationsResponse && locationsResponse.locations) { locations = locationsResponse.locations; categoryLabels = locationsResponse.category_labels || null; } else { locations = []; categoryLabels = null; } this.renderIncidentDetail(incident, articles, factchecks, snapshots, locations, categoryLabels); } catch (err) { console.error('loadIncidentDetail Fehler:', err); UI.showToast('Fehler beim Laden: ' + err.message, 'error'); } }, renderIncidentDetail(incident, articles, factchecks, snapshots, locations, categoryLabels) { // Header Strip { const _e = document.getElementById('incident-title'); if (_e) _e.textContent = incident.title; } { const _e = document.getElementById('incident-description'); if (_e) _e.textContent = incident.description || ''; } // Typ-Badge const typeBadge = document.getElementById('incident-type-badge'); typeBadge.className = 'incident-type-badge ' + (incident.type === 'research' ? 'type-research' : 'type-adhoc'); typeBadge.textContent = incident.type === 'research' ? 'Analyse' : 'Live'; // 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` : ''; } { const _e = document.getElementById('meta-refresh-mode'); if (_e) _e.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); // Stats im Header aktualisieren (sichtbar im zugeklappten Zustand) const _soStats = document.getElementById("source-overview-header-stats"); if (_soStats) { const _soSources = new Set(articles.map(a => a.source).filter(Boolean)); _soStats.textContent = articles.length + " Artikel aus " + _soSources.size + " Quellen"; } // 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; const _tsEl = document.getElementById('timeline-search'); if (_tsEl) _tsEl.value = ''; document.querySelectorAll('.ht-filter-btn').forEach(btn => { const isActive = btn.dataset.filter === 'all'; btn.classList.toggle('active', isActive); btn.setAttribute('aria-pressed', String(isActive)); }); document.querySelectorAll('.ht-range-btn').forEach(btn => { const isActive = btn.dataset.range === 'all'; btn.classList.toggle('active', isActive); btn.setAttribute('aria-pressed', String(isActive)); }); this.rerenderTimeline(); this._resizeTimelineTile(); // Karte rendern UI.renderMap(locations || [], categoryLabels); }, _collectEntries(filterType, searchTerm, range) { const type = this._currentIncidentType; const getArticleDate = (a) => (type === 'research' && a.published_at) ? a.published_at : a.collected_at; let entries = []; if (filterType === 'all' || filterType === 'articles') { let articles = this._currentArticles || []; if (searchTerm) { articles = articles.filter(a => { const text = `${a.headline || ''} ${a.headline_de || ''} ${a.source || ''} ${a.content_de || ''} ${a.content_original || ''}`.toLowerCase(); return text.includes(searchTerm); }); } articles.forEach(a => entries.push({ kind: 'article', data: a, timestamp: getArticleDate(a) || '' })); } if (filterType === 'all' || filterType === 'snapshots') { let snapshots = this._currentSnapshots || []; if (searchTerm) { 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 `
${UI.escape(bucket.label)} (${bucket.entries.length} Eintr${bucket.entries.length === 1 ? 'ag' : 'äge'})
${entriesHtml}
`; }, 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 ``; }, /** * 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 locResp = await API.getLocations(incidentId).catch(() => []); let locs, catLabels; if (Array.isArray(locResp)) { locs = locResp; catLabels = null; } else if (locResp && locResp.locations) { locs = locResp.locations; catLabels = locResp.category_labels || null; } else { locs = []; catLabels = null; } UI.renderMap(locs, catLabels); } else if (st.status === 'done') { UI.showToast('Keine neuen Orte gefunden', 'info'); } else if (st.status === 'error') { UI.showToast('Geoparsing fehlgeschlagen: ' + (st.error || ''), 'error'); } } } catch { clearInterval(this._geoparsePolling); this._geoparsePolling = null; if (btn) { btn.disabled = false; btn.textContent = 'Orte erkennen'; } } }, 3000); }, _formatInterval(minutes) { if (minutes >= 10080 && minutes % 10080 === 0) { const w = minutes / 10080; return w === 1 ? '1 Woche' : `${w} Wochen`; } if (minutes >= 1440 && minutes % 1440 === 0) { const d = minutes / 1440; return d === 1 ? '1 Tag' : `${d} Tage`; } if (minutes >= 60 && minutes % 60 === 0) { const h = minutes / 60; return h === 1 ? '1 Stunde' : `${h} Stunden`; } return `${minutes} Min.`; }, _setIntervalFields(minutes) { let value, unit; if (minutes >= 10080 && minutes % 10080 === 0) { value = minutes / 10080; unit = '10080'; } else if (minutes >= 1440 && minutes % 1440 === 0) { value = minutes / 1440; unit = '1440'; } else if (minutes >= 60 && minutes % 60 === 0) { value = minutes / 60; unit = '60'; } else { value = minutes; unit = '1'; } const input = document.getElementById('inc-refresh-value'); input.value = value; input.min = unit === '1' ? 10 : 1; { const _e = document.getElementById('inc-refresh-unit'); if (_e) _e.value = unit; } }, _refreshHistoryOpen: false, toggleRefreshHistory() { if (this._refreshHistoryOpen) { this.closeRefreshHistory(); } else { this._openRefreshHistory(); } }, async _openRefreshHistory() { if (!this.currentIncidentId) return; const popover = document.getElementById('refresh-history-popover'); if (!popover) return; this._refreshHistoryOpen = true; popover.style.display = 'flex'; // Lade Refresh-Log const list = document.getElementById('refresh-history-list'); list.innerHTML = '
Lade...
'; try { const logs = await API.getRefreshLog(this.currentIncidentId, 20); this._renderRefreshHistory(logs); } catch (e) { list.innerHTML = '
Fehler beim Laden
'; } // Outside-Click Listener setTimeout(() => { const handler = (e) => { if (!popover.contains(e.target) && !e.target.closest('.meta-updated-link')) { this.closeRefreshHistory(); document.removeEventListener('click', handler); } }; document.addEventListener('click', handler); popover._outsideHandler = handler; }, 0); }, closeRefreshHistory() { this._refreshHistoryOpen = false; const popover = document.getElementById('refresh-history-popover'); if (popover) { popover.style.display = 'none'; if (popover._outsideHandler) { document.removeEventListener('click', popover._outsideHandler); delete popover._outsideHandler; } } }, _renderRefreshHistory(logs) { const list = document.getElementById('refresh-history-list'); if (!list) return; if (!logs || logs.length === 0) { list.innerHTML = '
Noch keine Refreshes durchgeführt
'; return; } list.innerHTML = logs.map(log => { const started = parseUTC(log.started_at) || new Date(log.started_at); const timeStr = started.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', timeZone: TIMEZONE }) + ' ' + started.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', timeZone: TIMEZONE }); let detail = ''; if (log.status === 'completed') { detail = `${log.articles_found} Artikel`; if (log.duration_seconds != null) { detail += ` in ${this._formatDuration(log.duration_seconds)}`; } } else if (log.status === 'running') { detail = 'Läuft...'; } else if (log.status === 'error') { detail = ''; } const retryInfo = log.retry_count > 0 ? ` (Versuch ${log.retry_count + 1})` : ''; const errorHtml = log.error_message ? `
${log.error_message}
` : ''; return `
${timeStr}${retryInfo}
${detail ? `
${detail}
` : ''} ${errorHtml}
${log.trigger_type === 'auto' ? 'Auto' : 'Manuell'}
`; }).join(''); }, _formatDuration(seconds) { if (seconds == null) return ''; if (seconds < 60) return `${Math.round(seconds)}s`; const m = Math.floor(seconds / 60); const s = Math.round(seconds % 60); return s > 0 ? `${m}m ${s}s` : `${m}m`; }, _timeAgo(date) { if (!date) return ''; const now = new Date(); const diff = Math.floor((now - date) / 1000); if (diff < 60) return 'gerade eben'; if (diff < 3600) return `vor ${Math.floor(diff / 60)}m`; if (diff < 86400) return `vor ${Math.floor(diff / 3600)}h`; return `vor ${Math.floor(diff / 86400)}d`; }, _updateRefreshButton(disabled) { const btn = document.getElementById('refresh-btn'); if (!btn) return; 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 { const _e = document.getElementById('inc-title'); if (_e) _e.value = incident.title; } { const _e = document.getElementById('inc-description'); if (_e) _e.value = incident.description || ''; } { const _e = document.getElementById('inc-type'); if (_e) _e.value = incident.type || 'adhoc'; } { const _e = document.getElementById('inc-refresh-mode'); if (_e) _e.value = incident.refresh_mode; } App._setIntervalFields(incident.refresh_interval); { const _e = document.getElementById('inc-retention'); if (_e) _e.value = incident.retention_days; } { const _e = document.getElementById('inc-international'); if (_e) _e.checked = incident.international_sources !== false && incident.international_sources !== 0; } { const _e = document.getElementById('inc-telegram'); if (_e) _e.checked = !!incident.include_telegram; } { const _e = document.getElementById('inc-visibility'); if (_e) _e.checked = incident.visibility !== 'private'; } updateVisibilityHint(); updateSourcesHint(); toggleTypeDefaults(); toggleRefreshInterval(); // Modal-Titel und Submit ändern { const _e = document.getElementById('modal-new-title'); if (_e) _e.textContent = 'Lage bearbeiten'; } { const _e = document.getElementById('modal-new-submit'); if (_e) _e.textContent = 'Speichern'; } // E-Mail-Subscription laden try { const sub = await API.getSubscription(this.currentIncidentId); { const _e = document.getElementById('inc-notify-summary'); if (_e) _e.checked = !!sub.notify_email_summary; } { const _e = document.getElementById('inc-notify-new-articles'); if (_e) _e.checked = !!sub.notify_email_new_articles; } { const _e = document.getElementById('inc-notify-status-change'); if (_e) _e.checked = !!sub.notify_email_status_change; } } catch (e) { { const _e = document.getElementById('inc-notify-summary'); if (_e) _e.checked = false; } { const _e = document.getElementById('inc-notify-new-articles'); if (_e) _e.checked = false; } { const _e = document.getElementById('inc-notify-status-change'); if (_e) _e.checked = false; } } openModal('modal-new'); }, async handleArchive() { if (!this.currentIncidentId) return; const incident = this.incidents.find(i => i.id === this.currentIncidentId); if (!incident) return; const isArchived = incident.status === 'archived'; const action = isArchived ? 'wiederherstellen' : 'archivieren'; if (!await confirmDialog(`Lage wirklich ${action}?`)) return; try { const newStatus = isArchived ? 'active' : 'archived'; await API.updateIncident(this.currentIncidentId, { status: newStatus }); await this.loadIncidents(); await this.loadIncidentDetail(this.currentIncidentId); this._updateArchiveButton(newStatus); UI.showToast(isArchived ? 'Lage wiederhergestellt.' : 'Lage archiviert.', 'success'); } catch (err) { UI.showToast('Fehler: ' + err.message, 'error'); } }, _updateSidebarDot(incidentId, mode) { const dot = document.getElementById(`dot-${incidentId}`); if (!dot) return; const incident = this.incidents.find(i => i.id === incidentId); const baseClass = incident ? (incident.status === 'active' ? 'active' : 'archived') : 'active'; if (mode === 'error') { dot.className = `incident-dot refresh-error`; setTimeout(() => { dot.className = `incident-dot ${baseClass}`; }, 3000); } else if (this._refreshingIncidents.has(incidentId)) { dot.className = `incident-dot refreshing`; } else { dot.className = `incident-dot ${baseClass}`; } }, _updateArchiveButton(status) { const btn = document.getElementById('archive-incident-btn'); if (!btn) return; 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'); } }, openPdfExportDialog() { this._closeExportDropdown(); if (!this.currentIncidentId) return; openModal('modal-pdf-export'); }, executePdfExport() { closeModal('modal-pdf-export'); const checked = [...document.querySelectorAll('#pdf-export-tiles input:checked')].map(c => c.value); if (!checked.length) { UI.showToast('Keine Kacheln ausgewählt', 'warning'); return; } this._generatePdf(checked); }, _generatePdf(tiles) { const title = document.getElementById('incident-title')?.textContent || 'Export'; const now = new Date().toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }); let sections = ''; const esc = (s) => s ? s.replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"') : ''; // === Lagebild === if (tiles.includes('lagebild')) { const summaryEl = document.getElementById('summary-text'); const timestamp = document.getElementById('lagebild-timestamp')?.textContent || ''; if (summaryEl && summaryEl.innerHTML.trim()) { // Clone innerHTML and make citation links clickable with full URL visible let summaryHtml = summaryEl.innerHTML; // Ensure citation links are styled for print (underlined, blue) summaryHtml = summaryHtml.replace(/]*class="citation"[^>]*>(\[[^\]]+\])<\/a>/g, '$2'); sections += '
' + '

Lagebild

' + (timestamp ? '

' + esc(timestamp) + '

' : '') + '
' + summaryHtml + '
' + '
'; } } // === Quellen === if (tiles.includes('quellen')) { const articles = this._currentArticles || []; if (articles.length) { const sourceMap = {}; articles.forEach(a => { const name = a.source || 'Unbekannt'; if (!sourceMap[name]) sourceMap[name] = []; sourceMap[name].push(a); }); const sources = Object.entries(sourceMap).sort((a,b) => b[1].length - a[1].length); let s = '

' + articles.length + ' Artikel aus ' + sources.length + ' Quellen

'; s += ''; sources.forEach(([name, arts]) => { const langs = [...new Set(arts.map(a => (a.language || 'de').toUpperCase()))].join(', '); s += ''; }); s += '
QuelleArtikelSprache
' + esc(name) + '' + arts.length + '' + langs + '
'; s += '
'; sources.forEach(([name, arts]) => { s += '

' + esc(name) + ' (' + arts.length + ')

'; arts.forEach(a => { const hl = esc(a.headline_de || a.headline || 'Ohne Titel'); const url = a.source_url || ''; const dateStr = a.published_at ? new Date(a.published_at).toLocaleDateString('de-DE') : ''; s += '
'; s += url ? '' + hl + '' : '' + hl + ''; if (dateStr) s += ' (' + dateStr + ')'; s += '
'; }); }); s += '
'; sections += '

Quellenübersicht

' + s + '
'; } } // === Faktencheck === if (tiles.includes('faktencheck')) { const fcItems = document.querySelectorAll('#factcheck-list .factcheck-item'); if (fcItems.length) { let s = '
'; fcItems.forEach(item => { const status = item.dataset.fcStatus || ''; const statusEl = item.querySelector('.fc-status-text, .factcheck-status'); const claimEl = item.querySelector('.fc-claim-text, .factcheck-claim'); const evidenceEls = item.querySelectorAll('.fc-evidence-chip, .evidence-chip'); const statusText = statusEl ? statusEl.textContent.trim() : status; const claim = claimEl ? claimEl.textContent.trim() : ''; const statusClass = (status.includes('confirmed') || status.includes('verified')) ? 'confirmed' : (status.includes('refuted') || status.includes('disputed')) ? 'refuted' : 'unverified'; s += '
' + '' + esc(statusText) + '' + '
' + esc(claim) + '
'; if (evidenceEls.length) { s += '
'; evidenceEls.forEach(ev => { const link = ev.closest('a'); const href = link ? link.href : ''; const text = ev.textContent.trim(); s += href ? '' + esc(text) + ' ' : '' + esc(text) + ' '; }); s += '
'; } s += '
'; }); s += '
'; sections += '

Faktencheck

' + s + '
'; } } // === Timeline === if (tiles.includes('timeline')) { const buckets = document.querySelectorAll('#timeline .ht-bucket'); if (buckets.length) { let s = '
'; buckets.forEach(bucket => { const label = bucket.querySelector('.ht-bucket-label'); const items = bucket.querySelectorAll('.ht-item'); if (label) s += '

' + esc(label.textContent.trim()) + '

'; items.forEach(item => { const time = item.querySelector('.ht-item-time'); const ttl = item.querySelector('.ht-item-title'); const src = item.querySelector('.ht-item-source'); s += '
'; if (time) s += '' + esc(time.textContent.trim()) + ' '; if (ttl) s += '' + esc(ttl.textContent.trim()) + ''; if (src) s += ' ' + esc(src.textContent.trim()) + ''; s += '
'; }); }); s += '
'; sections += '

Ereignis-Timeline

' + s + '
'; } } if (!sections) { UI.showToast('Keine Inhalte zum Exportieren', 'warning'); return; } const css = ` @page { margin: 18mm 15mm 18mm 15mm; size: A4; } * { box-sizing: border-box; margin: 0; padding: 0; } body { font-family: -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; font-size: 11pt; line-height: 1.55; color: #1a1a1a; background: #fff; padding: 0; } a { color: #1a5276; } /* Header: compact, inline with content */ .pdf-header { border-bottom: 2px solid #2c3e50; padding-bottom: 10px; margin-bottom: 16px; } .pdf-header h1 { font-size: 18pt; font-weight: 700; color: #2c3e50; margin-bottom: 2px; } .pdf-header .pdf-subtitle { font-size: 9pt; color: #666; } /* Sections */ .pdf-section { margin-bottom: 22px; } .pdf-section h2 { font-size: 13pt; font-weight: 600; color: #2c3e50; border-bottom: 1px solid #ccc; padding-bottom: 4px; margin-bottom: 10px; } .pdf-section h4 { font-size: 10pt; font-weight: 600; color: #444; margin: 10px 0 3px; } .pdf-meta { font-size: 9pt; color: #888; margin-bottom: 8px; } /* Lagebild content */ .pdf-content { font-size: 10.5pt; line-height: 1.6; } .pdf-content h3 { font-size: 11.5pt; font-weight: 600; color: #2c3e50; margin: 12px 0 5px; } .pdf-content strong { font-weight: 600; } .pdf-content ul { margin: 4px 0 4px 18px; } .pdf-content li { margin-bottom: 2px; } .pdf-content a, .pdf-content .citation { color: #1a5276; font-weight: 600; text-decoration: underline; cursor: pointer; } /* Quellen table */ .pdf-table { width: 100%; border-collapse: collapse; font-size: 9.5pt; margin-bottom: 14px; } .pdf-table th { background: #f0f0f0; text-align: left; padding: 5px 8px; border: 1px solid #ddd; font-weight: 600; font-size: 8.5pt; text-transform: uppercase; color: #555; } .pdf-table td { padding: 4px 8px; border: 1px solid #ddd; } .pdf-table tr:nth-child(even) { background: #fafafa; } .pdf-article-list { font-size: 9.5pt; } .pdf-article-item { padding: 1px 0; break-inside: avoid; } .pdf-article-item a { color: #1a5276; text-decoration: none; } .pdf-article-item a:hover { text-decoration: underline; } .pdf-date { color: #888; font-size: 8.5pt; } /* Faktencheck */ .pdf-fc-list { display: flex; flex-direction: column; gap: 10px; } .pdf-fc-item { border: 1px solid #ddd; border-radius: 4px; padding: 8px 12px; break-inside: avoid; } .pdf-fc-badge { display: inline-block; font-size: 7.5pt; font-weight: 700; text-transform: uppercase; letter-spacing: 0.4px; padding: 1px 7px; border-radius: 3px; margin-bottom: 3px; } .pdf-fc-confirmed { background: #d4edda; color: #155724; } .pdf-fc-refuted { background: #f8d7da; color: #721c24; } .pdf-fc-unverified { background: #fff3cd; color: #856404; } .pdf-fc-claim { font-size: 10.5pt; margin-top: 3px; } .pdf-fc-evidence { margin-top: 5px; font-size: 8.5pt; } .pdf-fc-ev-link { color: #1a5276; text-decoration: underline; margin-right: 5px; } .pdf-fc-ev-tag { background: #eee; padding: 1px 5px; border-radius: 3px; margin-right: 3px; } /* Timeline */ .pdf-timeline h4 { color: #2c3e50; border-bottom: 1px solid #eee; padding-bottom: 2px; margin-top: 8px; } .pdf-tl-item { padding: 1px 0; font-size: 9.5pt; break-inside: avoid; } .pdf-tl-time { color: #888; font-size: 8.5pt; min-width: 36px; display: inline-block; } .pdf-tl-source { color: #888; font-size: 8.5pt; } /* Footer */ .pdf-footer { margin-top: 24px; padding-top: 8px; border-top: 1px solid #ddd; font-size: 8pt; color: #999; text-align: center; } `; const printHtml = '\n\n\n\n' + '' + esc(title) + ' \u2014 AegisSight Export\n' + '\n' + '\n\n' + '
\n' + '

' + esc(title) + '

\n' + '
AegisSight Monitor \u2014 Exportiert am ' + esc(now) + '
\n' + '
\n' + sections + '\n' + '\n' + ''; const printWin = window.open('', '_blank', 'width=800,height=600'); if (!printWin) { UI.showToast('Popup blockiert \u2014 bitte Popup-Blocker deaktivieren', 'error'); return; } printWin.document.write(printHtml); printWin.document.close(); printWin.onload = function() { printWin.focus(); printWin.print(); }; setTimeout(function() { try { printWin.focus(); printWin.print(); } catch(e) {} }, 500); }, 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'); } }, // === 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'; document.getElementById('src-type-select').value = 'rss_feed'; // Save-Button Text zurücksetzen const saveBtn = document.querySelector('#src-discovery-result .sources-discovery-actions .btn-primary'); if (saveBtn) saveBtn.textContent = 'Speichern'; // Block-Form ausblenden const blockForm = document.getElementById('sources-block-form'); if (blockForm) blockForm.style.display = 'none'; } else { // Beim Schließen: Bearbeitungsmodus zurücksetzen this._editingSourceId = null; const saveBtn = document.querySelector('#src-discovery-result .sources-discovery-actions .btn-primary'); if (saveBtn) saveBtn.textContent = 'Speichern'; } }, async discoverSource() { const urlInput = document.getElementById('src-discover-url'); const urlVal = urlInput.value.trim(); // Telegram-URLs direkt behandeln (kein Discovery noetig) if (urlVal.match(/^(https?:\/\/)?(t\.me|telegram\.me)\//i)) { const channelName = urlVal.replace(/^(https?:\/\/)?(t\.me|telegram\.me)\//, '').replace(/\/$/, ''); const tgUrl = 't.me/' + channelName; this._discoveredData = { name: '@' + channelName, domain: 't.me', source_type: 'telegram_channel', rss_url: null, }; document.getElementById('src-name').value = '@' + channelName; document.getElementById('src-type-select').value = 'telegram_channel'; document.getElementById('src-type-display').value = 'Telegram'; document.getElementById('src-domain').value = tgUrl; document.getElementById('src-rss-url-group').style.display = 'none'; document.getElementById('src-discovery-result').style.display = 'block'; const saveBtn = document.querySelector('#src-discovery-result .sources-discovery-actions .btn-primary'); if (saveBtn) { saveBtn.disabled = false; saveBtn.textContent = 'Speichern'; } return; } const url = urlInput.value.trim(); if (!url) { UI.showToast('Bitte URL oder Domain eingeben.', 'warning'); return; } // Prüfen ob Domain ausgeschlossen ist const inputDomain = url.replace(/^https?:\/\//, '').replace(/^www\./, '').split('/')[0].toLowerCase(); const isBlocked = inputDomain && this._myExclusions.some(e => (e.domain || '').toLowerCase() === inputDomain); if (isBlocked) { if (!await confirmDialog(`"${inputDomain}" ist ausgeschlossen. Trotzdem hinzufügen? Der Ausschluss wird dabei aufgehoben.`)) return; await API.unblockDomain(inputDomain); } const btn = document.getElementById('src-discover-btn'); btn.disabled = true; btn.textContent = 'Suche Feeds...'; try { const result = await API.discoverMulti(url); if (result.fallback_single) { this._discoveredData = { name: result.domain, domain: result.domain, category: result.category, source_type: result.total_found > 0 ? 'rss_feed' : 'web_source', rss_url: result.sources.length > 0 ? result.sources[0].url : null, }; if (result.sources.length > 0) { this._discoveredData.name = result.sources[0].name; } document.getElementById('src-name').value = this._discoveredData.name || ''; document.getElementById('src-category').value = this._discoveredData.category || 'sonstige'; document.getElementById('src-domain').value = this._discoveredData.domain || ''; document.getElementById('src-notes').value = ''; const typeLabel = this._discoveredData.source_type === 'rss_feed' ? 'RSS-Feed' : this._discoveredData.source_type === 'telegram_channel' ? 'Telegram' : 'Web-Quelle'; const typeSelect = document.getElementById('src-type-select'); if (typeSelect) typeSelect.value = this._discoveredData.source_type || 'web_source'; document.getElementById('src-type-display').value = typeLabel; const rssGroup = document.getElementById('src-rss-url-group'); const rssInput = document.getElementById('src-rss-url'); if (this._discoveredData.rss_url) { rssInput.value = this._discoveredData.rss_url; rssGroup.style.display = 'block'; } else { rssInput.value = ''; rssGroup.style.display = 'none'; } document.getElementById('src-discovery-result').style.display = 'block'; if (result.added_count > 0) { UI.showToast(`${result.domain}: Feed wurde automatisch hinzugefügt.`, 'success'); this.toggleSourceForm(false); await this.loadSources(); } else if (result.total_found === 0) { UI.showToast('Kein RSS-Feed gefunden. Als Web-Quelle speichern?', 'info'); } else { UI.showToast('Feed bereits vorhanden.', 'info'); } } else { document.getElementById('src-discovery-result').style.display = 'none'; if (result.added_count > 0) { UI.showToast(`${result.domain}: ${result.added_count} Feeds hinzugefügt` + (result.skipped_count > 0 ? ` (${result.skipped_count} bereits vorhanden)` : ''), 'success'); } else if (result.skipped_count > 0) { UI.showToast(`${result.domain}: Alle ${result.skipped_count} Feeds bereits vorhanden.`, 'info'); } else { UI.showToast(`${result.domain}: Keine relevanten Feeds gefunden.`, 'info'); } this.toggleSourceForm(false); await this.loadSources(); } } catch (err) { UI.showToast('Erkennung fehlgeschlagen: ' + err.message, 'error'); } finally { btn.disabled = false; btn.textContent = 'Erkennen'; } }, editSource(id) { const source = this._sourcesOnly.find(s => s.id === id); if (!source) { UI.showToast('Quelle nicht gefunden.', 'error'); return; } this._editingSourceId = id; // Formular öffnen falls geschlossen (direkt, ohne toggleSourceForm das _editingSourceId zurücksetzt) const form = document.getElementById('sources-add-form'); if (form) { form.style.display = 'block'; const blockForm = document.getElementById('sources-block-form'); if (blockForm) blockForm.style.display = 'none'; } // Discovery-URL mit vorhandener URL/Domain befüllen const discoverUrlInput = document.getElementById('src-discover-url'); if (discoverUrlInput) { discoverUrlInput.value = source.url || source.domain || ''; } // Discovery-Ergebnis anzeigen und Felder befüllen document.getElementById('src-discovery-result').style.display = 'block'; document.getElementById('src-name').value = source.name || ''; document.getElementById('src-category').value = source.category || 'sonstige'; document.getElementById('src-notes').value = source.notes || ''; document.getElementById('src-domain').value = source.domain || ''; const typeLabel = source.source_type === 'rss_feed' ? 'RSS-Feed' : source.source_type === 'telegram_channel' ? 'Telegram' : 'Web-Quelle'; const typeSelect = document.getElementById('src-type-select'); if (typeSelect) typeSelect.value = source.source_type || 'web_source'; document.getElementById('src-type-display').value = typeLabel; const rssGroup = document.getElementById('src-rss-url-group'); const rssInput = document.getElementById('src-rss-url'); if (source.url) { rssInput.value = source.url; rssGroup.style.display = 'block'; } else { rssInput.value = ''; rssGroup.style.display = 'none'; } // _discoveredData setzen damit saveSource() die richtigen Werte nutzt this._discoveredData = { name: source.name, domain: source.domain, category: source.category, source_type: source.source_type, rss_url: source.url, }; // Submit-Button-Text ändern const saveBtn = document.querySelector('#src-discovery-result .sources-discovery-actions .btn-primary'); if (saveBtn) saveBtn.textContent = 'Quelle speichern'; // Zum Formular scrollen if (form) form.scrollIntoView({ behavior: 'smooth', block: 'start' }); }, async saveSource() { const name = document.getElementById('src-name').value.trim(); if (!name) { UI.showToast('Name ist erforderlich. Bitte erst "Erkennen" klicken.', 'warning'); return; } const discovered = this._discoveredData || {}; const data = { name, source_type: discovered.source_type || 'web_source', category: document.getElementById('src-category').value, url: discovered.rss_url || (discovered.source_type === 'telegram_channel' ? (document.getElementById('src-domain').value || null) : null), domain: document.getElementById('src-domain').value.trim() || discovered.domain || null, notes: document.getElementById('src-notes').value.trim() || null, }; if (!data.domain && discovered.domain) { data.domain = discovered.domain; } try { if (this._editingSourceId) { await API.updateSource(this._editingSourceId, data); UI.showToast('Quelle aktualisiert.', 'success'); } else { await API.createSource(data); UI.showToast('Quelle hinzugefügt.', 'success'); } this.toggleSourceForm(false); await this.loadSources(); this.updateSidebarStats(); } catch (err) { UI.showToast('Fehler: ' + err.message, 'error'); } }, logout() { localStorage.removeItem('osint_token'); localStorage.removeItem('osint_username'); this._sessionWarningShown = false; WS.disconnect(); window.location.href = '/'; }, }; // === Barrierefreier Bestätigungsdialog === function confirmDialog(message) { return new Promise((resolve) => { // Overlay erstellen const overlay = document.createElement('div'); overlay.className = 'modal-overlay active'; overlay.setAttribute('role', 'alertdialog'); overlay.setAttribute('aria-modal', 'true'); overlay.setAttribute('aria-labelledby', 'confirm-dialog-msg'); const modal = document.createElement('div'); modal.className = 'modal'; modal.style.maxWidth = '420px'; modal.innerHTML = ` `; overlay.appendChild(modal); document.body.appendChild(overlay); const previousFocus = document.activeElement; const cleanup = (result) => { releaseFocus(overlay); overlay.remove(); if (previousFocus) previousFocus.focus(); resolve(result); }; modal.querySelector('#confirm-cancel').addEventListener('click', () => cleanup(false)); modal.querySelector('#confirm-ok').addEventListener('click', () => cleanup(true)); overlay.addEventListener('click', (e) => { if (e.target === overlay) cleanup(false); }); overlay.addEventListener('keydown', (e) => { if (e.key === 'Escape') cleanup(false); }); trapFocus(overlay); }); } // === Globale Hilfsfunktionen === // --- Focus-Trap für Modals (WCAG 2.4.3) --- const FOCUSABLE_SELECTOR = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; function trapFocus(modalEl) { const handler = (e) => { if (e.key !== 'Tab') return; const focusable = Array.from(modalEl.querySelectorAll(FOCUSABLE_SELECTOR)).filter(el => el.offsetParent !== null); if (focusable.length === 0) return; const first = focusable[0]; const last = focusable[focusable.length - 1]; if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } }; modalEl._focusTrapHandler = handler; modalEl.addEventListener('keydown', handler); // Fokus auf erstes Element setzen requestAnimationFrame(() => { const focusable = Array.from(modalEl.querySelectorAll(FOCUSABLE_SELECTOR)).filter(el => el.offsetParent !== null); if (focusable.length > 0) focusable[0].focus(); }); } function releaseFocus(modalEl) { if (modalEl._focusTrapHandler) { modalEl.removeEventListener('keydown', modalEl._focusTrapHandler); delete modalEl._focusTrapHandler; } } function openModal(id) { if (id === 'modal-new' && !App._editingIncidentId) { // Create-Modus: Formular zurücksetzen document.getElementById('new-incident-form').reset(); document.getElementById('modal-new-title').textContent = '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 = `
${articles.length} Artikel aus ${sources.length} Quellen
${langChips}
`; sources.forEach(([name, data]) => { const langs = [...data.languages].join('/'); const escapedName = UI.escape(name); html += `
${escapedName} ${langs} ${data.articles.length}
`; data.articles.forEach(a => { const headline = UI.escape(a.headline_de || a.headline || 'Ohne Titel'); const time = a.collected_at ? (parseUTC(a.collected_at) || new Date(a.collected_at)).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit', timeZone: TIMEZONE }) : ''; const langBadge = a.language && a.language !== 'de' ? `${a.language.toUpperCase()}` : ''; const link = a.source_url ? `` : ''; html += `
${time} ${headline} ${langBadge} ${link}
`; }); html += `
`; }); return html; } function toggleRefreshInterval() { const mode = document.getElementById('inc-refresh-mode').value; const field = document.getElementById('refresh-interval-field'); 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 = 'Recherchiert in Tiefe: Nachrichtenarchive, Parlamentsdokumente, Fachmedien, Expertenquellen. Empfohlen: Manuell starten und bei Bedarf vertiefen.'; refreshMode.value = 'manual'; toggleRefreshInterval(); } else { hint.textContent = 'Durchsucht laufend hunderte Nachrichtenquellen nach neuen Meldungen. Empfohlen: Automatische Aktualisierung.'; } } // 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());