diff --git a/src/static/js/app.js b/src/static/js/app.js index e69de29..2f26730 100644 --- a/src/static/js/app.js +++ b/src/static/js/app.js @@ -0,0 +1,3208 @@ +/** + * OSINT Lagemonitor - Hauptanwendungslogik. + */ + +/** Feste Zeitzone fuer alle Anzeigen — NIEMALS aendern. */ +const TIMEZONE = 'Europe/Berlin'; + +/** Gibt Jahr/Monat(0-basiert)/Tag/Stunde/Minute in Berliner Zeit zurueck. */ +function _tz(d) { + const s = d.toLocaleString('en-CA', { + timeZone: TIMEZONE, year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false + }); + const m = s.match(/(\d{4})-(\d{2})-(\d{2}),?\s*(\d{2}):(\d{2}):(\d{2})/); + if (!m) return { year: d.getFullYear(), month: d.getMonth(), date: d.getDate(), hours: d.getHours(), minutes: d.getMinutes() }; + return { year: +m[1], month: +m[2] - 1, date: +m[3], hours: +m[4], minutes: +m[5] }; +} + +/** + * Theme Manager: Dark/Light Theme Toggle mit localStorage-Persistenz. + */ +const ThemeManager = { + _key: 'osint_theme', + init() { + const saved = localStorage.getItem(this._key); + const theme = saved || 'dark'; + document.documentElement.setAttribute('data-theme', theme); + this._updateIcon(theme); + }, + toggle() { + const current = document.documentElement.getAttribute('data-theme') || 'dark'; + const next = current === 'dark' ? 'light' : 'dark'; + document.documentElement.setAttribute('data-theme', next); + localStorage.setItem(this._key, next); + this._updateIcon(next); + UI.updateMapTheme(); + }, + _updateIcon(theme) { + const el = document.getElementById('theme-toggle'); + if (!el) return; + el.classList.remove('dark', 'light'); + el.classList.add(theme); + el.setAttribute('aria-checked', theme === 'dark' ? 'true' : 'false'); + } +}; + +/** + * Barrierefreiheits-Manager: Panel mit 4 Schaltern (Kontrast, Focus, Schrift, Animationen). + */ +const A11yManager = { + _key: 'osint_a11y', + _isOpen: false, + _settings: { contrast: false, focus: false, fontsize: false, motion: false }, + + init() { + // Einstellungen aus localStorage laden + try { + const saved = JSON.parse(localStorage.getItem(this._key) || '{}'); + Object.keys(this._settings).forEach(k => { + if (typeof saved[k] === 'boolean') this._settings[k] = saved[k]; + }); + } catch (e) { /* Ungültige Daten ignorieren */ } + + // Button + Panel dynamisch in .header-right einfügen (vor Theme-Toggle) + const headerRight = document.querySelector('.header-right'); + const themeToggle = document.getElementById('theme-toggle'); + if (!headerRight) return; + + const container = document.createElement('div'); + container.className = 'a11y-center'; + container.innerHTML = ` + + + `; + + if (themeToggle) { + headerRight.insertBefore(container, themeToggle); + } else { + headerRight.prepend(container); + } + + // Toggle-Event-Listener + ['contrast', 'focus', 'fontsize', 'motion'].forEach(key => { + document.getElementById('a11y-' + key).addEventListener('change', () => this.toggle(key)); + }); + + // Button öffnet/schließt Panel + document.getElementById('a11y-btn').addEventListener('click', (e) => { + e.stopPropagation(); + this._isOpen ? this._closePanel() : this._openPanel(); + }); + + // Klick außerhalb schließt Panel + document.addEventListener('click', (e) => { + if (this._isOpen && !container.contains(e.target)) { + this._closePanel(); + } + }); + + // Keyboard: Esc schließt, Pfeiltasten navigieren + container.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && this._isOpen) { + e.stopPropagation(); + this._closePanel(); + return; + } + if (!this._isOpen) return; + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + e.preventDefault(); + const options = Array.from(document.querySelectorAll('.a11y-option input[type="checkbox"]')); + const idx = options.indexOf(document.activeElement); + let next; + if (e.key === 'ArrowDown') { + next = idx < options.length - 1 ? idx + 1 : 0; + } else { + next = idx > 0 ? idx - 1 : options.length - 1; + } + options[next].focus(); + } + }); + + // Einstellungen anwenden + Checkboxen synchronisieren + this._apply(); + this._syncUI(); + }, + + toggle(key) { + this._settings[key] = !this._settings[key]; + this._apply(); + this._syncUI(); + this._save(); + }, + + _apply() { + const root = document.documentElement; + Object.keys(this._settings).forEach(k => { + if (this._settings[k]) { + root.setAttribute('data-a11y-' + k, 'true'); + } else { + root.removeAttribute('data-a11y-' + k); + } + }); + }, + + _syncUI() { + Object.keys(this._settings).forEach(k => { + const cb = document.getElementById('a11y-' + k); + if (cb) cb.checked = this._settings[k]; + }); + }, + + _save() { + localStorage.setItem(this._key, JSON.stringify(this._settings)); + }, + + _openPanel() { + this._isOpen = true; + document.getElementById('a11y-panel').style.display = ''; + document.getElementById('a11y-btn').setAttribute('aria-expanded', 'true'); + // Fokus auf erste Option setzen + requestAnimationFrame(() => { + const first = document.querySelector('.a11y-option input[type="checkbox"]'); + if (first) first.focus(); + }); + }, + + _closePanel() { + this._isOpen = false; + document.getElementById('a11y-panel').style.display = 'none'; + const btn = document.getElementById('a11y-btn'); + btn.setAttribute('aria-expanded', 'false'); + btn.focus(); + } +}; + +/** + * Notification-Center: Glocke mit Badge + History-Panel. + */ +const NotificationCenter = { + _notifications: [], + _unreadCount: 0, + _isOpen: false, + _maxItems: 50, + _syncTimer: null, + + async init() { + // Glocken-Container dynamisch in .header-right vor #header-user einfügen + const headerRight = document.querySelector('.header-right'); + const headerUser = document.getElementById('header-user'); + if (!headerRight || !headerUser) return; + + const container = document.createElement('div'); + container.className = 'notification-center'; + container.innerHTML = ` + + + `; + headerRight.insertBefore(container, headerUser); + + // Event-Listener + document.getElementById('notification-bell').addEventListener('click', (e) => { + e.stopPropagation(); + this.toggle(); + }); + document.getElementById('notification-mark-read').addEventListener('click', (e) => { + e.stopPropagation(); + this.markAllRead(); + }); + // Klick außerhalb schließt Panel + document.addEventListener('click', (e) => { + if (this._isOpen && !container.contains(e.target)) { + this.close(); + } + }); + + // Notifications aus DB laden + await this._loadFromDB(); + }, + + add(notification) { + // Optimistisches UI: sofort anzeigen + notification.read = false; + notification.timestamp = notification.timestamp || new Date().toISOString(); + this._notifications.unshift(notification); + if (this._notifications.length > this._maxItems) { + this._notifications.pop(); + } + this._unreadCount++; + this._updateBadge(); + this._renderList(); + + // DB-Sync mit Debounce (Orchestrator schreibt parallel in DB) + clearTimeout(this._syncTimer); + this._syncTimer = setTimeout(() => this._syncFromDB(), 500); + }, + + toggle() { + this._isOpen ? this.close() : this.open(); + }, + + open() { + this._isOpen = true; + const panel = document.getElementById('notification-panel'); + if (panel) panel.style.display = 'flex'; + const bell = document.getElementById('notification-bell'); + if (bell) bell.setAttribute('aria-expanded', 'true'); + }, + + close() { + this._isOpen = false; + const panel = document.getElementById('notification-panel'); + if (panel) panel.style.display = 'none'; + const bell = document.getElementById('notification-bell'); + if (bell) bell.setAttribute('aria-expanded', 'false'); + }, + + async markAllRead() { + this._notifications.forEach(n => n.read = true); + this._unreadCount = 0; + this._updateBadge(); + this._renderList(); + + // In DB als gelesen markieren (fire-and-forget) + try { + await API.markNotificationsRead(null); + } catch (e) { + console.warn('Notifications als gelesen markieren fehlgeschlagen:', e); + } + }, + + _updateBadge() { + const badge = document.getElementById('notification-badge'); + if (!badge) return; + if (this._unreadCount > 0) { + badge.style.display = 'flex'; + badge.textContent = this._unreadCount > 99 ? '99+' : this._unreadCount; + document.title = `(${this._unreadCount}) ${App._originalTitle}`; + } else { + badge.style.display = 'none'; + document.title = App._originalTitle; + } + }, + + _renderList() { + const list = document.getElementById('notification-panel-list'); + if (!list) return; + + if (this._notifications.length === 0) { + list.innerHTML = '
Keine Benachrichtigungen
'; + return; + } + + list.innerHTML = this._notifications.map(n => { + const time = new Date(n.timestamp); + const timeStr = time.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', timeZone: TIMEZONE }); + const unreadClass = n.read ? '' : ' unread'; + const icon = n.icon || 'info'; + return `
+
${this._iconSymbol(icon)}
+
+
${this._escapeHtml(n.title)}
+
${this._escapeHtml(n.text)}
+
+
${timeStr}
+
`; + }).join(''); + }, + + _handleClick(incidentId) { + this.close(); + if (incidentId) { + App.selectIncident(incidentId); + } + }, + + _iconSymbol(type) { + switch (type) { + case 'success': return '\u2713'; + case 'warning': return '!'; + case 'error': return '\u2717'; + default: return 'i'; + } + }, + + _escapeHtml(text) { + const d = document.createElement('div'); + d.textContent = text || ''; + return d.innerHTML; + }, + + async _loadFromDB() { + try { + const items = await API.listNotifications(50); + this._notifications = items.map(n => ({ + id: n.id, + incident_id: n.incident_id, + title: n.title, + text: n.text, + icon: n.icon || 'info', + type: n.type, + read: !!n.is_read, + timestamp: n.created_at, + })); + this._unreadCount = this._notifications.filter(n => !n.read).length; + this._updateBadge(); + this._renderList(); + } catch (e) { + console.warn('Notifications laden fehlgeschlagen:', e); + } + }, + + async _syncFromDB() { + try { + const items = await API.listNotifications(50); + this._notifications = items.map(n => ({ + id: n.id, + incident_id: n.incident_id, + title: n.title, + text: n.text, + icon: n.icon || 'info', + type: n.type, + read: !!n.is_read, + timestamp: n.created_at, + })); + this._unreadCount = this._notifications.filter(n => !n.read).length; + this._updateBadge(); + this._renderList(); + } catch (e) { + console.warn('Notifications sync fehlgeschlagen:', e); + } + }, +}; + +const App = { + currentIncidentId: null, + incidents: [], + _originalTitle: document.title, + _refreshingIncidents: new Set(), + _editingIncidentId: null, + _currentArticles: [], + _currentIncidentType: 'adhoc', + _sidebarFilter: 'all', + _currentUsername: '', + _allSources: [], + _sourcesOnly: [], + _myExclusions: [], // [{domain, notes, created_at}] + _expandedGroups: new Set(), + _editingSourceId: null, + _timelineFilter: 'all', + _timelineRange: 'all', + _activePointIndex: null, + _timelineSearchTimer: null, + _pendingComplete: null, + _pendingCompleteTimer: null, + + async init() { + ThemeManager.init(); + A11yManager.init(); + // Auth prüfen + const token = localStorage.getItem('osint_token'); + if (!token) { + window.location.href = '/'; + return; + } + + try { + const user = await API.getMe(); + this._currentUsername = user.email; + document.getElementById('header-user').textContent = user.email; + + // Dropdown-Daten befuellen + const orgNameEl = document.getElementById('header-org-name'); + if (orgNameEl) orgNameEl.textContent = user.org_name || '-'; + + const licInfoEl = document.getElementById('header-license-info'); + if (licInfoEl) { + const licenseLabels = { + trial: 'Trial', + annual: 'Jahreslizenz', + permanent: 'Permanent', + }; + const label = user.read_only ? 'Abgelaufen' + : licenseLabels[user.license_type] || user.license_status || '-'; + licInfoEl.textContent = label; + } + + // Dropdown Toggle + const userBtn = document.getElementById('header-user-btn'); + const userDropdown = document.getElementById('header-user-dropdown'); + if (userBtn && userDropdown) { + userBtn.addEventListener('click', (e) => { + e.stopPropagation(); + const isOpen = userDropdown.classList.toggle('open'); + userBtn.setAttribute('aria-expanded', isOpen); + }); + document.addEventListener('click', () => { + userDropdown.classList.remove('open'); + userBtn.setAttribute('aria-expanded', 'false'); + }); + } + + // Warnung bei abgelaufener Lizenz + const warningEl = document.getElementById('header-license-warning'); + if (warningEl && user.read_only) { + warningEl.textContent = 'Lizenz abgelaufen – nur Lesezugriff'; + warningEl.classList.add('visible'); + } + } catch { + window.location.href = '/'; + return; + } + + // Event-Listener + document.getElementById('logout-btn').addEventListener('click', () => this.logout()); + document.getElementById('new-incident-btn').addEventListener('click', () => openModal('modal-new')); + document.getElementById('new-incident-form').addEventListener('submit', (e) => this.handleFormSubmit(e)); + document.getElementById('refresh-btn').addEventListener('click', () => this.handleRefresh()); + document.getElementById('delete-incident-btn').addEventListener('click', () => this.handleDelete()); + document.getElementById('edit-incident-btn').addEventListener('click', () => this.handleEdit()); + document.getElementById('archive-incident-btn').addEventListener('click', () => this.handleArchive()); + document.getElementById('inc-international').addEventListener('change', () => updateSourcesHint()); + document.getElementById('inc-visibility').addEventListener('change', () => updateVisibilityHint()); + + // Feedback + document.getElementById('feedback-form').addEventListener('submit', (e) => this.submitFeedback(e)); + document.getElementById('fb-message').addEventListener('input', (e) => { + document.getElementById('fb-char-count').textContent = e.target.value.length.toLocaleString('de-DE'); + }); + + // Sidebar-Chevrons initial auf offen setzen (Archiv geschlossen) + document.querySelectorAll('.sidebar-chevron').forEach(c => c.classList.add('open')); + document.getElementById('chevron-archived-incidents').classList.remove('open'); + + // Lagen laden (frueh, damit Sidebar sofort sichtbar) + await this.loadIncidents(); + + // Notification-Center initialisieren + try { await NotificationCenter.init(); } catch (e) { console.warn('NotificationCenter:', e); } + + // WebSocket + WS.connect(); + WS.on('status_update', (msg) => this.handleStatusUpdate(msg)); + WS.on('refresh_complete', (msg) => this.handleRefreshComplete(msg)); + WS.on('refresh_summary', (msg) => this.handleRefreshSummary(msg)); + WS.on('refresh_error', (msg) => this.handleRefreshError(msg)); + WS.on('refresh_cancelled', (msg) => this.handleRefreshCancelled(msg)); + + // Laufende Refreshes wiederherstellen + try { + const data = await API.getRefreshingIncidents(); + if (data.refreshing && data.refreshing.length > 0) { + data.refreshing.forEach(id => this._refreshingIncidents.add(id)); + // Sidebar-Dots aktualisieren + data.refreshing.forEach(id => this._updateSidebarDot(id)); + } + } catch (e) { /* Kein kritischer Fehler */ } + + // Zuletzt ausgewählte Lage wiederherstellen + const savedId = localStorage.getItem('selectedIncidentId'); + if (savedId) { + const id = parseInt(savedId, 10); + if (this.incidents.some(inc => inc.id === id)) { + await this.selectIncident(id); + } + } + + // Leaflet-Karte nachladen falls CDN langsam war + setTimeout(() => UI.retryPendingMap(), 2000); + }, + + async loadIncidents() { + try { + this.incidents = await API.listIncidents(); + this.renderSidebar(); + } catch (err) { + UI.showToast('Fehler beim Laden der Lagen: ' + err.message, 'error'); + } + }, + + renderSidebar() { + const activeContainer = document.getElementById('active-incidents'); + const researchContainer = document.getElementById('active-research'); + const archivedContainer = document.getElementById('archived-incidents'); + + // Filter-Buttons aktualisieren + document.querySelectorAll('.sidebar-filter-btn').forEach(btn => { + const isActive = btn.dataset.filter === this._sidebarFilter; + btn.classList.toggle('active', isActive); + btn.setAttribute('aria-pressed', String(isActive)); + }); + + // Lagen nach Filter einschränken + let filtered = this.incidents; + if (this._sidebarFilter === 'mine') { + filtered = filtered.filter(i => i.created_by_username === this._currentUsername); + } + + // Aktive Lagen nach Typ aufteilen + const activeAdhoc = filtered.filter(i => i.status === 'active' && (!i.type || i.type === 'adhoc')); + const activeResearch = filtered.filter(i => i.status === 'active' && i.type === 'research'); + const archived = filtered.filter(i => i.status === 'archived'); + + const emptyLabelAdhoc = this._sidebarFilter === 'mine' ? 'Keine eigenen Ad-hoc-Lagen' : 'Keine Ad-hoc-Lagen'; + const emptyLabelResearch = this._sidebarFilter === 'mine' ? 'Keine eigenen Recherchen' : 'Keine Recherchen'; + + activeContainer.innerHTML = activeAdhoc.length + ? activeAdhoc.map(i => UI.renderIncidentItem(i, i.id === this.currentIncidentId)).join('') + : `
${emptyLabelAdhoc}
`; + + researchContainer.innerHTML = activeResearch.length + ? activeResearch.map(i => UI.renderIncidentItem(i, i.id === this.currentIncidentId)).join('') + : `
${emptyLabelResearch}
`; + + archivedContainer.innerHTML = archived.length + ? archived.map(i => UI.renderIncidentItem(i, i.id === this.currentIncidentId)).join('') + : '
Kein Archiv
'; + + // Zähler aktualisieren + const countAdhoc = document.getElementById('count-active-incidents'); + const countResearch = document.getElementById('count-active-research'); + const countArchived = document.getElementById('count-archived-incidents'); + if (countAdhoc) countAdhoc.textContent = `(${activeAdhoc.length})`; + if (countResearch) countResearch.textContent = `(${activeResearch.length})`; + if (countArchived) countArchived.textContent = `(${archived.length})`; + + // Sidebar-Stats aktualisieren + this.updateSidebarStats(); + }, + + setSidebarFilter(filter) { + this._sidebarFilter = filter; + this.renderSidebar(); + }, + + _announceForSR(text) { + let el = document.getElementById('sr-announcement'); + if (!el) { + el = document.createElement('div'); + el.id = 'sr-announcement'; + el.setAttribute('role', 'status'); + el.setAttribute('aria-live', 'polite'); + el.className = 'sr-only'; + document.body.appendChild(el); + } + el.textContent = ''; + requestAnimationFrame(() => { el.textContent = text; }); + }, + + async selectIncident(id) { + this.closeRefreshHistory(); + this.currentIncidentId = id; + localStorage.setItem('selectedIncidentId', id); + const inc = this.incidents.find(i => i.id === id); + if (inc) this._announceForSR('Lage ausgewählt: ' + inc.title); + this.renderSidebar(); + + var mc = document.getElementById("main-content"); + mc.scrollTop = 0; + + document.getElementById('empty-state').style.display = 'none'; + document.getElementById('incident-view').style.display = 'flex'; + + // GridStack-Animation deaktivieren und Scroll komplett sperren + // bis alle Tile-Resize-Operationen (doppeltes rAF) abgeschlossen sind + var gridEl = document.querySelector('.grid-stack'); + if (gridEl) gridEl.classList.remove('grid-stack-animate'); + var scrollLock = function() { mc.scrollTop = 0; }; + mc.addEventListener('scroll', scrollLock); + + // gridstack-Layout initialisieren (einmalig) + if (typeof LayoutManager !== 'undefined') LayoutManager.init(); + + // Refresh-Status fuer diese Lage wiederherstellen + const isRefreshing = this._refreshingIncidents.has(id); + this._updateRefreshButton(isRefreshing); + if (isRefreshing) { + UI.showProgress('researching'); + } else { + UI.hideProgress(); + } + +// Alte Inhalte sofort leeren um Flackern beim Wechsel zu vermeiden + var el; + el = document.getElementById("incident-title"); if (el) el.textContent = ""; + el = document.getElementById("summary-content"); if (el) el.scrollTop = 0; + el = document.getElementById("summary-text"); if (el) el.innerHTML = ""; + el = document.getElementById("factcheck-filters"); if (el) el.innerHTML = ""; + el = document.querySelector(".factcheck-list"); if (el) el.scrollTop = 0; + el = document.getElementById("factcheck-list"); if (el) el.innerHTML = ""; + el = document.getElementById("source-overview-content"); if (el) el.innerHTML = ""; + el = document.getElementById("timeline-entries"); if (el) el.innerHTML = ""; + await this.loadIncidentDetail(id); + + // Scroll-Sperre nach 3 Frames aufheben (nach allen doppelten rAF-Callbacks) + mc.scrollTop = 0; + requestAnimationFrame(() => { + requestAnimationFrame(() => { + requestAnimationFrame(() => { + mc.scrollTop = 0; + mc.removeEventListener('scroll', scrollLock); + if (gridEl) gridEl.classList.add('grid-stack-animate'); + }); + }); + }); + + + + }, + + async loadIncidentDetail(id) { + try { + const [incident, articles, factchecks, snapshots, locations] = await Promise.all([ + API.getIncident(id), + API.getArticles(id), + API.getFactChecks(id), + API.getSnapshots(id), + API.getLocations(id).catch(() => []), + ]); + + this.renderIncidentDetail(incident, articles, factchecks, snapshots, locations); + } catch (err) { + UI.showToast('Fehler beim Laden: ' + err.message, 'error'); + } + }, + + renderIncidentDetail(incident, articles, factchecks, snapshots, locations) { + // Header Strip + document.getElementById('incident-title').textContent = incident.title; + document.getElementById('incident-description').textContent = incident.description || ''; + + // Typ-Badge + const typeBadge = document.getElementById('incident-type-badge'); + typeBadge.className = 'incident-type-badge ' + (incident.type === 'research' ? 'type-research' : 'type-adhoc'); + typeBadge.textContent = incident.type === 'research' ? 'Recherche' : 'Breaking'; + + // Archiv-Button Text + this._updateArchiveButton(incident.status); + + // Ersteller anzeigen + const creatorEl = document.getElementById('incident-creator'); + if (creatorEl) { + creatorEl.textContent = (incident.created_by_username || '').split('@')[0]; + } + + // Delete-Button: nur Ersteller darf löschen + const deleteBtn = document.getElementById('delete-incident-btn'); + const isCreator = incident.created_by_username === this._currentUsername; + deleteBtn.disabled = !isCreator; + deleteBtn.title = isCreator ? '' : `Nur ${(incident.created_by_username || '').split('@')[0]} kann diese Lage löschen`; + + // Zusammenfassung mit Quellenverzeichnis + const summaryText = document.getElementById('summary-text'); + if (incident.summary) { + summaryText.innerHTML = UI.renderSummary( + incident.summary, + incident.sources_json, + incident.type + ); + } else { + summaryText.innerHTML = 'Noch keine Zusammenfassung. Klicke auf "Aktualisieren" um die Recherche zu starten.'; + } + + // Meta (im Header-Strip) — relative Zeitangabe mit vollem Datum als Tooltip + const updated = incident.updated_at ? parseUTC(incident.updated_at) : null; + const metaUpdated = document.getElementById('meta-updated'); + if (updated) { + const fullDate = `${updated.toLocaleDateString('de-DE', { timeZone: TIMEZONE })} ${updated.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', timeZone: TIMEZONE })}`; + metaUpdated.textContent = `Stand: ${App._timeAgo(updated)}`; + metaUpdated.title = fullDate; + } else { + metaUpdated.textContent = ''; + metaUpdated.title = ''; + } + + // Zeitstempel direkt im Lagebild-Card-Header + const lagebildTs = document.getElementById('lagebild-timestamp'); + if (lagebildTs) { + lagebildTs.textContent = updated + ? `Stand: ${updated.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', timeZone: TIMEZONE })} ${updated.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', timeZone: TIMEZONE })} Uhr` + : ''; + } + + document.getElementById('meta-refresh-mode').textContent = incident.refresh_mode === 'auto' + ? `Auto alle ${App._formatInterval(incident.refresh_interval)}` + : 'Manuell'; + + // International-Badge + const intlBadge = document.getElementById('intl-badge'); + if (intlBadge) { + const isIntl = incident.international_sources !== false && incident.international_sources !== 0; + intlBadge.className = 'intl-badge ' + (isIntl ? 'intl-yes' : 'intl-no'); + intlBadge.textContent = isIntl ? 'International' : 'Nur DE'; + } + + // Faktencheck + const fcFilters = document.getElementById('fc-filters'); + const factcheckList = document.getElementById('factcheck-list'); + if (factchecks.length > 0) { + fcFilters.innerHTML = UI.renderFactCheckFilters(factchecks); + factcheckList.innerHTML = factchecks.map(fc => UI.renderFactCheck(fc)).join(''); + } else { + fcFilters.innerHTML = ''; + factcheckList.innerHTML = '
Noch keine Fakten geprüft
'; + } + + // Quellenübersicht + const sourceOverview = document.getElementById('source-overview-content'); + if (sourceOverview) { + sourceOverview.innerHTML = UI.renderSourceOverview(articles); + // Kachel an Inhalt anpassen + if (typeof LayoutManager !== 'undefined' && LayoutManager._grid) { + if (sourceOverview.style.display !== 'none') { + // Offen → an Inhalt anpassen + requestAnimationFrame(() => requestAnimationFrame(() => { + LayoutManager.resizeTileToContent('quellen'); + })); + } else { + // Geschlossen → einheitliche Default-Höhe + const defaults = LayoutManager.DEFAULT_LAYOUT.find(d => d.id === 'quellen'); + if (defaults) { + const node = LayoutManager._grid.engine.nodes.find( + n => n.el && n.el.getAttribute('gs-id') === 'quellen' + ); + if (node) LayoutManager._grid.update(node.el, { h: defaults.h }); + } + } + } + } + + // Timeline - Artikel + Snapshots zwischenspeichern und rendern + this._currentArticles = articles; + this._currentSnapshots = snapshots || []; + this._currentIncidentType = incident.type; + this._timelineFilter = 'all'; + this._timelineRange = 'all'; + this._activePointIndex = null; + document.getElementById('timeline-search').value = ''; + document.querySelectorAll('.ht-filter-btn').forEach(btn => { + const isActive = btn.dataset.filter === 'all'; + btn.classList.toggle('active', isActive); + btn.setAttribute('aria-pressed', String(isActive)); + }); + document.querySelectorAll('.ht-range-btn').forEach(btn => { + const isActive = btn.dataset.range === 'all'; + btn.classList.toggle('active', isActive); + btn.setAttribute('aria-pressed', String(isActive)); + }); + this.rerenderTimeline(); + this._resizeTimelineTile(); + + // Karte rendern + UI.renderMap(locations || []); + }, + + _collectEntries(filterType, searchTerm, range) { + const type = this._currentIncidentType; + const getArticleDate = (a) => (type === 'research' && a.published_at) ? a.published_at : a.collected_at; + + let entries = []; + + if (filterType === 'all' || filterType === 'articles') { + let articles = this._currentArticles || []; + if (searchTerm) { + articles = articles.filter(a => { + const text = `${a.headline || ''} ${a.headline_de || ''} ${a.source || ''} ${a.content_de || ''} ${a.content_original || ''}`.toLowerCase(); + return text.includes(searchTerm); + }); + } + articles.forEach(a => entries.push({ kind: 'article', data: a, timestamp: getArticleDate(a) || '' })); + } + + if (filterType === 'all' || filterType === 'snapshots') { + let snapshots = this._currentSnapshots || []; + if (searchTerm) { + snapshots = snapshots.filter(s => (s.summary || '').toLowerCase().includes(searchTerm)); + } + snapshots.forEach(s => entries.push({ kind: 'snapshot', data: s, timestamp: s.created_at || '' })); + } + + if (range && range !== 'all') { + const now = Date.now(); + const cutoff = range === '24h' ? now - 24 * 60 * 60 * 1000 : now - 7 * 24 * 60 * 60 * 1000; + entries = entries.filter(e => new Date(e.timestamp || 0).getTime() >= cutoff); + } + + return entries; + }, + + _updateTimelineCount(entries) { + const articleCount = entries.filter(e => e.kind === 'article').length; + const snapshotCount = entries.filter(e => e.kind === 'snapshot').length; + const countEl = document.getElementById('article-count'); + if (articleCount > 0 && snapshotCount > 0) { + countEl.innerHTML = ` ${articleCount} Meldung${articleCount !== 1 ? 'en' : ''} + ${snapshotCount} Lagebericht${snapshotCount !== 1 ? 'e' : ''}`; + } else if (articleCount > 0) { + countEl.innerHTML = ` ${articleCount} Meldung${articleCount !== 1 ? 'en' : ''}`; + } else if (snapshotCount > 0) { + countEl.innerHTML = ` ${snapshotCount} Lagebericht${snapshotCount !== 1 ? 'e' : ''}`; + } else { + countEl.textContent = '0 Meldungen'; + } + }, + + debouncedRerenderTimeline() { + clearTimeout(this._timelineSearchTimer); + this._timelineSearchTimer = setTimeout(() => this.rerenderTimeline(), 250); + }, + + rerenderTimeline() { + const container = document.getElementById('timeline'); + const searchTerm = (document.getElementById('timeline-search').value || '').toLowerCase(); + const filterType = this._timelineFilter; + const range = this._timelineRange; + + let entries = this._collectEntries(filterType, searchTerm, range); + this._updateTimelineCount(entries); + + if (entries.length === 0) { + this._activePointIndex = null; + container.innerHTML = (searchTerm || range !== 'all') + ? '
Keine Einträge im gewählten Zeitraum.
' + : '
Noch keine Meldungen. Starte eine Recherche mit "Aktualisieren".
'; + return; + } + + entries.sort((a, b) => new Date(a.timestamp || 0) - new Date(b.timestamp || 0)); + + const granularity = this._calcGranularity(entries, range); + let buckets = this._buildBuckets(entries, granularity); + buckets = this._mergeCloseBuckets(buckets); + + // Aktiven Index validieren + if (this._activePointIndex !== null && this._activePointIndex >= buckets.length) { + this._activePointIndex = null; + } + + // Achsen-Bereich + const rangeStart = buckets[0].timestamp; + const rangeEnd = buckets[buckets.length - 1].timestamp; + const maxCount = Math.max(...buckets.map(b => b.entries.length)); + + // Stunden- vs. Tages-Granularität + const isHourly = granularity === 'hour'; + const axisLabels = this._buildAxisLabels(buckets, granularity, true); + + // HTML aufbauen + let html = `
`; + + // Datums-Marker (immer anzeigen, ausgedünnt) + const dayMarkers = this._thinLabels(this._buildDayMarkers(buckets, rangeStart, rangeEnd), 10); + html += '
'; + dayMarkers.forEach(m => { + html += `
`; + html += `
${UI.escape(m.text)}
`; + html += `
`; + html += `
`; + }); + html += '
'; + + // Punkte + html += '
'; + buckets.forEach((bucket, idx) => { + const pos = this._bucketPositionPercent(bucket, rangeStart, rangeEnd, buckets.length); + const size = this._calcPointSize(bucket.entries.length, maxCount); + const hasSnapshots = bucket.entries.some(e => e.kind === 'snapshot'); + const hasArticles = bucket.entries.some(e => e.kind === 'article'); + + let pointClass = 'ht-point'; + if (filterType === 'snapshots') { + pointClass += ' ht-snapshot-point'; + } else if (hasSnapshots) { + pointClass += ' ht-mixed-point'; + } + if (this._activePointIndex === idx) pointClass += ' active'; + + const tooltip = `${bucket.label}: ${bucket.entries.length} Eintr${bucket.entries.length === 1 ? 'ag' : 'äge'}`; + + html += `
`; + html += `
${UI.escape(tooltip)}
`; + html += `
`; + }); + html += '
'; + + // Achsenlinie + html += '
'; + + // Achsen-Labels (ausgedünnt um Überlappung zu vermeiden) + const thinned = this._thinLabels(axisLabels); + html += '
'; + thinned.forEach(lbl => { + html += `
${UI.escape(lbl.text)}
`; + }); + html += '
'; + html += '
'; + + // Detail-Panel (wenn ein Punkt aktiv ist) + if (this._activePointIndex !== null && this._activePointIndex < buckets.length) { + html += this._renderDetailPanel(buckets[this._activePointIndex]); + } + + container.innerHTML = html; + }, + + _calcGranularity(entries, range) { + if (entries.length < 2) return 'day'; + const timestamps = entries.map(e => new Date(e.timestamp || 0).getTime()).filter(t => t > 0); + if (timestamps.length < 2) return 'day'; + const span = Math.max(...timestamps) - Math.min(...timestamps); + if (range === '24h' || span <= 48 * 60 * 60 * 1000) return 'hour'; + return 'day'; + }, + + _buildBuckets(entries, granularity) { + const bucketMap = {}; + entries.forEach(e => { + const d = new Date(e.timestamp || 0); + const b = _tz(d); + let key, label, ts; + if (granularity === 'hour') { + key = `${b.year}-${b.month + 1}-${b.date}-${b.hours}`; + label = d.toLocaleDateString('de-DE', { day: '2-digit', month: 'short', timeZone: TIMEZONE }) + ', ' + b.hours.toString().padStart(2, '0') + ':00'; + ts = new Date(b.year, b.month, b.date, b.hours).getTime(); + } else { + key = `${b.year}-${b.month + 1}-${b.date}`; + label = d.toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: 'short', timeZone: TIMEZONE }); + ts = new Date(b.year, b.month, b.date, 12).getTime(); + } + if (!bucketMap[key]) { + bucketMap[key] = { key, label, timestamp: ts, entries: [] }; + } + bucketMap[key].entries.push(e); + }); + return Object.values(bucketMap).sort((a, b) => a.timestamp - b.timestamp); + }, + + _mergeCloseBuckets(buckets) { + if (buckets.length < 2) return buckets; + const rangeStart = buckets[0].timestamp; + const rangeEnd = buckets[buckets.length - 1].timestamp; + if (rangeEnd <= rangeStart) return buckets; + + const container = document.getElementById('timeline'); + const axisWidth = (container ? container.offsetWidth : 800) * 0.92; + const maxCount = Math.max(...buckets.map(b => b.entries.length)); + const result = [buckets[0]]; + + for (let i = 1; i < buckets.length; i++) { + const prev = result[result.length - 1]; + const curr = buckets[i]; + + const distPx = ((curr.timestamp - prev.timestamp) / (rangeEnd - rangeStart)) * axisWidth; + const prevSize = Math.min(32, this._calcPointSize(prev.entries.length, maxCount)); + const currSize = Math.min(32, this._calcPointSize(curr.entries.length, maxCount)); + const minDistPx = (prevSize + currSize) / 2 + 6; + + if (distPx < minDistPx) { + prev.entries = prev.entries.concat(curr.entries); + } else { + result.push(curr); + } + } + return result; + }, + + _bucketPositionPercent(bucket, rangeStart, rangeEnd, totalBuckets) { + if (totalBuckets === 1) return 50; + if (rangeEnd === rangeStart) return 50; + return ((bucket.timestamp - rangeStart) / (rangeEnd - rangeStart)) * 100; + }, + + _calcPointSize(count, maxCount) { + if (maxCount <= 1) return 16; + const minSize = 12; + const maxSize = 32; + const logScale = Math.log(count + 1) / Math.log(maxCount + 1); + return Math.round(minSize + logScale * (maxSize - minSize)); + }, + + _buildAxisLabels(buckets, granularity, timeOnly) { + if (buckets.length === 0) return []; + const maxLabels = 8; + const labels = []; + const rangeStart = buckets[0].timestamp; + const rangeEnd = buckets[buckets.length - 1].timestamp; + + const getLabelText = (b) => { + if (timeOnly) { + // Bei Tages-Granularität: Uhrzeit des ersten Eintrags nehmen + const ts = (granularity === 'day' && b.entries && b.entries.length > 0) + ? new Date(b.entries[0].timestamp || b.timestamp) + : new Date(b.timestamp); + const tp = _tz(ts); + return tp.hours.toString().padStart(2, '0') + ':' + tp.minutes.toString().padStart(2, '0'); + } + return b.label; + }; + + if (buckets.length <= maxLabels) { + buckets.forEach(b => { + labels.push({ text: getLabelText(b), pos: this._bucketPositionPercent(b, rangeStart, rangeEnd, buckets.length) }); + }); + } else { + const step = (buckets.length - 1) / (maxLabels - 1); + for (let i = 0; i < maxLabels; i++) { + const idx = Math.round(i * step); + const b = buckets[idx]; + labels.push({ text: getLabelText(b), pos: this._bucketPositionPercent(b, rangeStart, rangeEnd, buckets.length) }); + } + } + return labels; + }, + + _thinLabels(labels, minGapPercent) { + if (!labels || labels.length <= 1) return labels; + const gap = minGapPercent || 8; + const result = [labels[0]]; + for (let i = 1; i < labels.length; i++) { + if (labels[i].pos - result[result.length - 1].pos >= gap) { + result.push(labels[i]); + } + } + return result; + }, + + _buildDayMarkers(buckets, rangeStart, rangeEnd) { + const seen = {}; + const markers = []; + buckets.forEach(b => { + const d = new Date(b.timestamp); + const bp = _tz(d); + const dayKey = `${bp.year}-${bp.month}-${bp.date}`; + if (!seen[dayKey]) { + seen[dayKey] = true; + const np = _tz(new Date()); + const todayKey = `${np.year}-${np.month}-${np.date}`; + const yp = _tz(new Date(Date.now() - 86400000)); + const yesterdayKey = `${yp.year}-${yp.month}-${yp.date}`; + let label; + const dateStr = d.toLocaleDateString('de-DE', { day: '2-digit', month: 'short', timeZone: TIMEZONE }); + if (dayKey === todayKey) { + label = 'Heute, ' + dateStr; + } else if (dayKey === yesterdayKey) { + label = 'Gestern, ' + dateStr; + } else { + label = d.toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: 'short', timeZone: TIMEZONE }); + } + const pos = this._bucketPositionPercent(b, rangeStart, rangeEnd, buckets.length); + markers.push({ text: label, pos }); + } + }); + return markers; + }, + + _renderDetailPanel(bucket) { + const type = this._currentIncidentType; + const sorted = [...bucket.entries].sort((a, b) => { + if (a.kind === 'snapshot' && b.kind !== 'snapshot') return -1; + if (a.kind !== 'snapshot' && b.kind === 'snapshot') return 1; + return new Date(b.timestamp || 0) - new Date(a.timestamp || 0); + }); + + let entriesHtml = ''; + sorted.forEach(e => { + if (e.kind === 'snapshot') { + entriesHtml += this._renderSnapshotEntry(e.data); + } else { + entriesHtml += this._renderArticleEntry(e.data, type, 0); + } + }); + + return `
+
+ ${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 locations = await API.getLocations(incidentId).catch(() => []); + UI.renderMap(locations); + } else if (st.status === 'done') { + UI.showToast('Keine neuen Orte gefunden', 'info'); + } else if (st.status === 'error') { + UI.showToast('Geoparsing fehlgeschlagen: ' + (st.error || ''), 'error'); + } + } + } catch { + clearInterval(this._geoparsePolling); + this._geoparsePolling = null; + if (btn) { btn.disabled = false; btn.textContent = 'Orte erkennen'; } + } + }, 3000); + }, + + _formatInterval(minutes) { + if (minutes >= 10080 && minutes % 10080 === 0) { + const w = minutes / 10080; + return w === 1 ? '1 Woche' : `${w} Wochen`; + } + if (minutes >= 1440 && minutes % 1440 === 0) { + const d = minutes / 1440; + return d === 1 ? '1 Tag' : `${d} Tage`; + } + if (minutes >= 60 && minutes % 60 === 0) { + const h = minutes / 60; + return h === 1 ? '1 Stunde' : `${h} Stunden`; + } + return `${minutes} Min.`; + }, + + _setIntervalFields(minutes) { + let value, unit; + if (minutes >= 10080 && minutes % 10080 === 0) { + value = minutes / 10080; unit = '10080'; + } else if (minutes >= 1440 && minutes % 1440 === 0) { + value = minutes / 1440; unit = '1440'; + } else if (minutes >= 60 && minutes % 60 === 0) { + value = minutes / 60; unit = '60'; + } else { + value = minutes; unit = '1'; + } + const input = document.getElementById('inc-refresh-value'); + input.value = value; + input.min = unit === '1' ? 10 : 1; + document.getElementById('inc-refresh-unit').value = unit; + }, + + _refreshHistoryOpen: false, + + toggleRefreshHistory() { + if (this._refreshHistoryOpen) { + this.closeRefreshHistory(); + } else { + this._openRefreshHistory(); + } + }, + + async _openRefreshHistory() { + if (!this.currentIncidentId) return; + const popover = document.getElementById('refresh-history-popover'); + if (!popover) return; + + this._refreshHistoryOpen = true; + popover.style.display = 'flex'; + + // Lade Refresh-Log + const list = document.getElementById('refresh-history-list'); + list.innerHTML = '
Lade...
'; + + try { + const logs = await API.getRefreshLog(this.currentIncidentId, 20); + this._renderRefreshHistory(logs); + } catch (e) { + list.innerHTML = '
Fehler beim Laden
'; + } + + // Outside-Click Listener + setTimeout(() => { + const handler = (e) => { + if (!popover.contains(e.target) && !e.target.closest('.meta-updated-link')) { + this.closeRefreshHistory(); + document.removeEventListener('click', handler); + } + }; + document.addEventListener('click', handler); + popover._outsideHandler = handler; + }, 0); + }, + + closeRefreshHistory() { + this._refreshHistoryOpen = false; + const popover = document.getElementById('refresh-history-popover'); + if (popover) { + popover.style.display = 'none'; + if (popover._outsideHandler) { + document.removeEventListener('click', popover._outsideHandler); + delete popover._outsideHandler; + } + } + }, + + _renderRefreshHistory(logs) { + const list = document.getElementById('refresh-history-list'); + if (!list) return; + + if (!logs || logs.length === 0) { + list.innerHTML = '
Noch keine Refreshes durchgeführt
'; + return; + } + + list.innerHTML = logs.map(log => { + const started = parseUTC(log.started_at) || new Date(log.started_at); + const timeStr = started.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', timeZone: TIMEZONE }) + ' ' + + started.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', timeZone: TIMEZONE }); + + let detail = ''; + if (log.status === 'completed') { + detail = `${log.articles_found} Artikel`; + if (log.duration_seconds != null) { + detail += ` in ${this._formatDuration(log.duration_seconds)}`; + } + } else if (log.status === 'running') { + detail = 'Läuft...'; + } else if (log.status === 'error') { + detail = ''; + } + + const retryInfo = log.retry_count > 0 ? ` (Versuch ${log.retry_count + 1})` : ''; + const errorHtml = log.error_message + ? `
${log.error_message}
` + : ''; + + return `
+
+
+
${timeStr}${retryInfo}
+ ${detail ? `
${detail}
` : ''} + ${errorHtml} +
+ ${log.trigger_type === 'auto' ? 'Auto' : 'Manuell'} +
`; + }).join(''); + }, + + _formatDuration(seconds) { + if (seconds == null) return ''; + if (seconds < 60) return `${Math.round(seconds)}s`; + const m = Math.floor(seconds / 60); + const s = Math.round(seconds % 60); + return s > 0 ? `${m}m ${s}s` : `${m}m`; + }, + + _timeAgo(date) { + if (!date) return ''; + const now = new Date(); + const diff = Math.floor((now - date) / 1000); + if (diff < 60) return 'gerade eben'; + if (diff < 3600) return `vor ${Math.floor(diff / 60)}m`; + if (diff < 86400) return `vor ${Math.floor(diff / 3600)}h`; + return `vor ${Math.floor(diff / 86400)}d`; + }, + + _updateRefreshButton(disabled) { + const btn = document.getElementById('refresh-btn'); + if (!btn) return; + btn.disabled = disabled; + btn.textContent = disabled ? 'Läuft...' : 'Aktualisieren'; + }, + + async handleDelete() { + if (!this.currentIncidentId) return; + if (!await confirmDialog('Lage wirklich löschen? Alle gesammelten Daten gehen verloren.')) return; + + try { + await API.deleteIncident(this.currentIncidentId); + this.currentIncidentId = null; + if (typeof LayoutManager !== 'undefined') LayoutManager.destroy(); + document.getElementById('incident-view').style.display = 'none'; + document.getElementById('empty-state').style.display = 'flex'; + await this.loadIncidents(); + UI.showToast('Lage gelöscht.', 'success'); + } catch (err) { + UI.showToast('Fehler: ' + err.message, 'error'); + } + }, + + async handleEdit() { + if (!this.currentIncidentId) return; + const incident = this.incidents.find(i => i.id === this.currentIncidentId); + if (!incident) return; + + this._editingIncidentId = this.currentIncidentId; + + // Formular mit aktuellen Werten füllen + document.getElementById('inc-title').value = incident.title; + document.getElementById('inc-description').value = incident.description || ''; + document.getElementById('inc-type').value = incident.type || 'adhoc'; + document.getElementById('inc-refresh-mode').value = incident.refresh_mode; + App._setIntervalFields(incident.refresh_interval); + document.getElementById('inc-retention').value = incident.retention_days; + document.getElementById('inc-international').checked = incident.international_sources !== false && incident.international_sources !== 0; + document.getElementById('inc-telegram').checked = !!incident.include_telegram; + document.getElementById('inc-visibility').checked = incident.visibility !== 'private'; + updateVisibilityHint(); + updateSourcesHint(); + toggleTypeDefaults(); + toggleRefreshInterval(); + + // Modal-Titel und Submit ändern + document.getElementById('modal-new-title').textContent = 'Lage bearbeiten'; + document.getElementById('modal-new-submit').textContent = 'Speichern'; + + // E-Mail-Subscription laden + try { + const sub = await API.getSubscription(this.currentIncidentId); + document.getElementById('inc-notify-summary').checked = !!sub.notify_email_summary; + document.getElementById('inc-notify-new-articles').checked = !!sub.notify_email_new_articles; + document.getElementById('inc-notify-status-change').checked = !!sub.notify_email_status_change; + } catch (e) { + document.getElementById('inc-notify-summary').checked = false; + document.getElementById('inc-notify-new-articles').checked = false; + document.getElementById('inc-notify-status-change').checked = false; + } + + openModal('modal-new'); + }, + + async handleArchive() { + if (!this.currentIncidentId) return; + const incident = this.incidents.find(i => i.id === this.currentIncidentId); + if (!incident) return; + + const isArchived = incident.status === 'archived'; + const action = isArchived ? 'wiederherstellen' : 'archivieren'; + + if (!await confirmDialog(`Lage wirklich ${action}?`)) return; + + try { + const newStatus = isArchived ? 'active' : 'archived'; + await API.updateIncident(this.currentIncidentId, { status: newStatus }); + await this.loadIncidents(); + await this.loadIncidentDetail(this.currentIncidentId); + this._updateArchiveButton(newStatus); + UI.showToast(isArchived ? 'Lage wiederhergestellt.' : 'Lage archiviert.', 'success'); + } catch (err) { + UI.showToast('Fehler: ' + err.message, 'error'); + } + }, + + _updateSidebarDot(incidentId, mode) { + const dot = document.getElementById(`dot-${incidentId}`); + if (!dot) return; + const incident = this.incidents.find(i => i.id === incidentId); + const baseClass = incident ? (incident.status === 'active' ? 'active' : 'archived') : 'active'; + + if (mode === 'error') { + dot.className = `incident-dot refresh-error`; + setTimeout(() => { + dot.className = `incident-dot ${baseClass}`; + }, 3000); + } else if (this._refreshingIncidents.has(incidentId)) { + dot.className = `incident-dot refreshing`; + } else { + dot.className = `incident-dot ${baseClass}`; + } + }, + + _updateArchiveButton(status) { + const btn = document.getElementById('archive-incident-btn'); + if (!btn) return; + btn.textContent = status === 'archived' ? 'Wiederherstellen' : 'Archivieren'; + }, + + // === WebSocket Handlers === + + handleStatusUpdate(msg) { + const status = msg.data.status; + if (status === 'retrying') { + // Retry-Status → Fehleranzeige mit Retry-Info + if (msg.incident_id === this.currentIncidentId) { + UI.showProgressError('', true, msg.data.delay || 120); + } + return; + } + if (status !== 'idle') { + this._refreshingIncidents.add(msg.incident_id); + } + this._updateSidebarDot(msg.incident_id); + if (msg.incident_id === this.currentIncidentId) { + UI.showProgress(status, msg.data); + this._updateRefreshButton(status !== 'idle'); + } + }, + + async handleRefreshComplete(msg) { + this._refreshingIncidents.delete(msg.incident_id); + this._updateSidebarDot(msg.incident_id); + + if (msg.incident_id === this.currentIncidentId) { + this._updateRefreshButton(false); + await this.loadIncidentDetail(msg.incident_id); + + // Progress-Bar nicht sofort ausblenden — auf refresh_summary warten + this._pendingComplete = msg.incident_id; + // Fallback: Wenn nach 5s kein refresh_summary kommt → direkt ausblenden + if (this._pendingCompleteTimer) clearTimeout(this._pendingCompleteTimer); + this._pendingCompleteTimer = setTimeout(() => { + if (this._pendingComplete === msg.incident_id) { + this._pendingComplete = null; + UI.hideProgress(); + } + }, 5000); + } + + await this.loadIncidents(); + }, + + + + handleRefreshSummary(msg) { + const d = msg.data; + const title = d.incident_title || 'Lage'; + + // Abschluss-Animation auslösen wenn pending + if (this._pendingComplete === msg.incident_id) { + if (this._pendingCompleteTimer) { + clearTimeout(this._pendingCompleteTimer); + this._pendingCompleteTimer = null; + } + this._pendingComplete = null; + UI.showProgressComplete(d); + setTimeout(() => UI.hideProgress(), 4000); + } + + // Toast-Text zusammenbauen + const parts = []; + if (d.new_articles > 0) { + parts.push(`${d.new_articles} neue Meldung${d.new_articles !== 1 ? 'en' : ''}`); + } + if (d.confirmed_count > 0) { + parts.push(`${d.confirmed_count} bestätigt`); + } + if (d.contradicted_count > 0) { + parts.push(`${d.contradicted_count} widersprochen`); + } + if (d.status_changes && d.status_changes.length > 0) { + parts.push(`${d.status_changes.length} Statusänderung${d.status_changes.length !== 1 ? 'en' : ''}`); + } + + const summaryText = parts.length > 0 + ? parts.join(', ') + : 'Keine neuen Entwicklungen'; + + // 1 Toast statt 5-10 + UI.showToast(`Recherche abgeschlossen: ${summaryText}`, 'success', 6000); + + // Ins NotificationCenter eintragen + NotificationCenter.add({ + incident_id: msg.incident_id, + title: title, + text: `Recherche: ${summaryText}`, + icon: d.contradicted_count > 0 ? 'warning' : 'success', + }); + + // Status-Änderungen als separate Einträge + if (d.status_changes) { + d.status_changes.forEach(sc => { + const oldLabel = this._translateStatus(sc.old_status); + const newLabel = this._translateStatus(sc.new_status); + NotificationCenter.add({ + incident_id: msg.incident_id, + title: title, + text: `${sc.claim}: ${oldLabel} \u2192 ${newLabel}`, + icon: sc.new_status === 'contradicted' || sc.new_status === 'disputed' ? 'error' : 'success', + }); + }); + } + + // Sidebar-Dot blinken + const dot = document.getElementById(`dot-${msg.incident_id}`); + if (dot) { + dot.classList.add('has-notification'); + setTimeout(() => dot.classList.remove('has-notification'), 10000); + } + }, + + _translateStatus(status) { + const map = { + confirmed: 'Bestätigt', + established: 'Gesichert', + unconfirmed: 'Unbestätigt', + contradicted: 'Widersprochen', + disputed: 'Umstritten', + developing: 'In Entwicklung', + unverified: 'Ungeprüft', + }; + return map[status] || status; + }, + + handleRefreshError(msg) { + this._refreshingIncidents.delete(msg.incident_id); + this._updateSidebarDot(msg.incident_id, 'error'); + if (msg.incident_id === this.currentIncidentId) { + this._updateRefreshButton(false); + // Pending-Complete aufräumen + if (this._pendingCompleteTimer) { + clearTimeout(this._pendingCompleteTimer); + this._pendingCompleteTimer = null; + } + this._pendingComplete = null; + UI.showProgressError(msg.data.error, false); + } + UI.showToast(`Recherche-Fehler: ${msg.data.error}`, 'error'); + }, + + handleRefreshCancelled(msg) { + this._refreshingIncidents.delete(msg.incident_id); + this._updateSidebarDot(msg.incident_id); + if (msg.incident_id === this.currentIncidentId) { + this._updateRefreshButton(false); + if (this._pendingCompleteTimer) { + clearTimeout(this._pendingCompleteTimer); + this._pendingCompleteTimer = null; + } + this._pendingComplete = null; + UI.hideProgress(); + } + UI.showToast('Recherche abgebrochen.', 'info'); + }, + + async cancelRefresh() { + if (!this.currentIncidentId) return; + const ok = await confirmDialog('Laufende Recherche abbrechen?'); + if (!ok) return; + + const btn = document.getElementById('progress-cancel-btn'); + if (btn) { + btn.textContent = 'Wird abgebrochen...'; + btn.disabled = true; + } + + try { + await API.cancelRefresh(this.currentIncidentId); + } catch (err) { + UI.showToast('Abbrechen fehlgeschlagen: ' + err.message, 'error'); + if (btn) { + btn.textContent = 'Abbrechen'; + btn.disabled = false; + } + } + }, + + // === Export === + + toggleExportDropdown(event) { + event.stopPropagation(); + const menu = document.getElementById('export-dropdown-menu'); + if (!menu) return; + const isOpen = menu.classList.toggle('show'); + const btn = menu.previousElementSibling; + if (btn) btn.setAttribute('aria-expanded', String(isOpen)); + }, + + _closeExportDropdown() { + const menu = document.getElementById('export-dropdown-menu'); + if (menu) { + menu.classList.remove('show'); + const btn = menu.previousElementSibling; + if (btn) btn.setAttribute('aria-expanded', 'false'); + } + }, + + async exportIncident(format, scope) { + this._closeExportDropdown(); + if (!this.currentIncidentId) return; + try { + const response = await API.exportIncident(this.currentIncidentId, format, scope); + if (!response.ok) { + const err = await response.json().catch(() => ({})); + throw new Error(err.detail || 'Fehler ' + response.status); + } + const blob = await response.blob(); + const disposition = response.headers.get('Content-Disposition') || ''; + let filename = 'export.' + format; + const match = disposition.match(/filename="?([^"]+)"?/); + if (match) filename = match[1]; + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + UI.showToast('Export heruntergeladen', 'success'); + } catch (err) { + UI.showToast('Export fehlgeschlagen: ' + err.message, 'error'); + } + }, + + printIncident() { + this._closeExportDropdown(); + window.print(); + }, + + // === Sidebar-Stats === + + async updateSidebarStats() { + try { + const stats = await API.getSourceStats(); + const srcCount = document.getElementById('stat-sources-count'); + const artCount = document.getElementById('stat-articles-count'); + if (srcCount) srcCount.textContent = `${stats.total_sources} Quellen`; + if (artCount) artCount.textContent = `${stats.total_articles} Artikel`; + } catch { + // Fallback: aus Lagen berechnen + const totalArticles = this.incidents.reduce((sum, i) => sum + i.article_count, 0); + const totalSources = this.incidents.reduce((sum, i) => sum + i.source_count, 0); + const srcCount = document.getElementById('stat-sources-count'); + const artCount = document.getElementById('stat-articles-count'); + if (srcCount) srcCount.textContent = `${totalSources} Quellen`; + if (artCount) artCount.textContent = `${totalArticles} Artikel`; + } + }, + + // === Soft-Refresh (F5) === + + async softRefresh() { + try { + await this.loadIncidents(); + if (this.currentIncidentId) { + await this.selectIncident(this.currentIncidentId); + } + UI.showToast('Daten aktualisiert.', 'success', 2000); + } catch (err) { + UI.showToast('Aktualisierung fehlgeschlagen: ' + err.message, 'error'); + } + }, + + // === Feedback === + + openFeedback() { + const form = document.getElementById('feedback-form'); + if (form) form.reset(); + const counter = document.getElementById('fb-char-count'); + if (counter) counter.textContent = '0'; + openModal('modal-feedback'); + }, + + async submitFeedback(e) { + e.preventDefault(); + const form = document.getElementById('feedback-form'); + this._clearFormErrors(form); + + const btn = document.getElementById('fb-submit-btn'); + const category = document.getElementById('fb-category').value; + const msgField = document.getElementById('fb-message'); + const message = msgField.value.trim(); + + if (message.length < 10) { + this._showFieldError(msgField, 'Bitte mindestens 10 Zeichen eingeben.'); + msgField.focus(); + return; + } + + // Dateien pruefen + const fileInput = document.getElementById('fb-files'); + const files = fileInput ? Array.from(fileInput.files) : []; + if (files.length > 3) { + UI.showToast('Maximal 3 Bilder erlaubt.', 'error'); + return; + } + for (const f of files) { + if (f.size > 5 * 1024 * 1024) { + UI.showToast('Datei "' + f.name + '" ist groesser als 5 MB.', 'error'); + return; + } + } + + btn.disabled = true; + btn.textContent = 'Wird gesendet...'; + try { + const formData = new FormData(); + formData.append('category', category); + formData.append('message', message); + for (const f of files) { + formData.append('files', f); + } + await API.sendFeedbackForm(formData); + closeModal('modal-feedback'); + UI.showToast('Feedback gesendet. Vielen Dank!', 'success'); + } catch (err) { + UI.showToast('Fehler: ' + err.message, 'error'); + } finally { + btn.disabled = false; + btn.textContent = 'Absenden'; + } + }, + + // === Sidebar Sektionen ein-/ausklappen === + + toggleSidebarSection(sectionId) { + const list = document.getElementById(sectionId); + if (!list) return; + const chevron = document.getElementById('chevron-' + sectionId); + const isHidden = list.style.display === 'none'; + list.style.display = isHidden ? '' : 'none'; + if (chevron) { + chevron.classList.toggle('open', isHidden); + } + // aria-expanded auf dem Section-Title synchronisieren + const title = chevron ? chevron.closest('.sidebar-section-title') : null; + if (title) title.setAttribute('aria-expanded', String(isHidden)); + }, + + // === Quellenverwaltung === + + async openSourceManagement() { + openModal('modal-sources'); + await this.loadSources(); + }, + + async loadSources() { + try { + const [sources, stats, myExclusions] = await Promise.all([ + API.listSources(), + API.getSourceStats(), + API.getMyExclusions(), + ]); + this._allSources = sources; + this._sourcesOnly = sources.filter(s => s.source_type !== 'excluded'); + this._myExclusions = myExclusions || []; + + this.renderSourceStats(stats); + this.renderSourceList(); + } catch (err) { + UI.showToast('Fehler beim Laden der Quellen: ' + err.message, 'error'); + } + }, + + renderSourceStats(stats) { + const bar = document.getElementById('sources-stats-bar'); + if (!bar) return; + + const rss = stats.by_type.rss_feed || { count: 0, articles: 0 }; + const web = stats.by_type.web_source || { count: 0, articles: 0 }; + const tg = stats.by_type.telegram_channel || { count: 0, articles: 0 }; + const excluded = this._myExclusions.length; + + bar.innerHTML = ` + ${rss.count} RSS-Feeds + ${web.count} Web-Quellen + ${tg.count} Telegram + ${excluded} Ausgeschlossen + ${stats.total_articles} Artikel gesamt + `; + }, + + /** + * Quellen nach Domain gruppiert rendern. + */ + renderSourceList() { + const list = document.getElementById('sources-list'); + if (!list) return; + + // Filter anwenden + const typeFilter = document.getElementById('sources-filter-type')?.value || ''; + const catFilter = document.getElementById('sources-filter-category')?.value || ''; + const search = (document.getElementById('sources-search')?.value || '').toLowerCase(); + + // Alle Quellen nach Domain gruppieren + const groups = new Map(); + const excludedDomains = new Set(); + const excludedNotes = {}; + + // User-Ausschlüsse sammeln + this._myExclusions.forEach(e => { + const domain = (e.domain || '').toLowerCase(); + if (domain) { + excludedDomains.add(domain); + excludedNotes[domain] = e.notes || ''; + } + }); + + // Feeds nach Domain gruppieren + this._sourcesOnly.forEach(s => { + const domain = (s.domain || '').toLowerCase() || `_single_${s.id}`; + if (!groups.has(domain)) groups.set(domain, []); + groups.get(domain).push(s); + }); + + // Ausgeschlossene Domains die keine Feeds haben auch als Gruppe + this._myExclusions.forEach(e => { + const domain = (e.domain || '').toLowerCase(); + if (domain && !groups.has(domain)) { + groups.set(domain, []); + } + }); + + // Filter auf Gruppen anwenden + let filteredGroups = []; + for (const [domain, feeds] of groups) { + const isExcluded = excludedDomains.has(domain); + const isGlobal = feeds.some(f => f.is_global); + + // Typ-Filter + if (typeFilter === 'excluded' && !isExcluded) continue; + if (typeFilter && typeFilter !== 'excluded') { + const hasMatchingType = feeds.some(f => f.source_type === typeFilter); + if (!hasMatchingType) continue; + } + + // Kategorie-Filter + if (catFilter) { + const hasMatchingCat = feeds.some(f => f.category === catFilter); + if (!hasMatchingCat) continue; + } + + // Suche + if (search) { + const groupText = feeds.map(f => + `${f.name} ${f.domain || ''} ${f.url || ''} ${f.notes || ''}` + ).join(' ').toLowerCase() + ' ' + domain; + if (!groupText.includes(search)) continue; + } + + filteredGroups.push({ domain, feeds, isExcluded, isGlobal }); + } + + if (filteredGroups.length === 0) { + list.innerHTML = '
Keine Quellen gefunden
'; + return; + } + + // Sortierung: Aktive zuerst (alphabetisch), dann ausgeschlossene + filteredGroups.sort((a, b) => { + if (a.isExcluded !== b.isExcluded) return a.isExcluded ? 1 : -1; + return a.domain.localeCompare(b.domain); + }); + + list.innerHTML = filteredGroups.map(g => + UI.renderSourceGroup(g.domain, g.feeds, g.isExcluded, excludedNotes[g.domain] || '', g.isGlobal) + ).join(''); + + // Erweiterte Gruppen wiederherstellen + this._expandedGroups.forEach(domain => { + const feedsEl = list.querySelector(`.source-group-feeds[data-domain="${CSS.escape(domain)}"]`); + if (feedsEl) { + feedsEl.classList.add('expanded'); + const header = feedsEl.previousElementSibling; + if (header) header.classList.add('expanded'); + } + }); + }, + + filterSources() { + this.renderSourceList(); + }, + + /** + * Domain-Gruppe auf-/zuklappen. + */ + toggleSourceOverview() { + const content = document.getElementById('source-overview-content'); + const chevron = document.getElementById('source-overview-chevron'); + if (!content) return; + const isHidden = content.style.display === 'none'; + content.style.display = isHidden ? '' : 'none'; + if (chevron) { + chevron.classList.toggle('open', isHidden); + chevron.title = isHidden ? 'Einklappen' : 'Aufklappen'; + } + // aria-expanded auf dem Header-Toggle synchronisieren + const header = chevron ? chevron.closest('[role="button"]') : null; + if (header) header.setAttribute('aria-expanded', String(isHidden)); + // gridstack-Kachel an Inhalt anpassen (doppelter rAF für vollständiges Layout) + if (typeof LayoutManager !== 'undefined' && LayoutManager._grid) { + if (isHidden) { + // Aufgeklappt → Inhalt muss erst layouten + requestAnimationFrame(() => requestAnimationFrame(() => { + LayoutManager.resizeTileToContent('quellen'); + })); + } else { + // Zugeklappt → auf Default-Höhe zurück + const defaults = LayoutManager.DEFAULT_LAYOUT.find(d => d.id === 'quellen'); + if (defaults) { + const node = LayoutManager._grid.engine.nodes.find( + n => n.el && n.el.getAttribute('gs-id') === 'quellen' + ); + if (node) { + LayoutManager._grid.update(node.el, { h: defaults.h }); + LayoutManager._debouncedSave(); + } + } + } + } + }, + + toggleGroup(domain) { + const list = document.getElementById('sources-list'); + if (!list) return; + const feedsEl = list.querySelector(`.source-group-feeds[data-domain="${CSS.escape(domain)}"]`); + if (!feedsEl) return; + + const isExpanded = feedsEl.classList.toggle('expanded'); + const header = feedsEl.previousElementSibling; + if (header) { + header.classList.toggle('expanded', isExpanded); + header.setAttribute('aria-expanded', String(isExpanded)); + } + + if (isExpanded) { + this._expandedGroups.add(domain); + } else { + this._expandedGroups.delete(domain); + } + }, + + /** + * Domain ausschließen (aus dem Inline-Formular). + */ + async blockDomain() { + const input = document.getElementById('block-domain-input'); + const domain = (input?.value || '').trim(); + if (!domain) { + UI.showToast('Domain ist erforderlich.', 'warning'); + return; + } + + const notes = (document.getElementById('block-domain-notes')?.value || '').trim() || null; + + try { + await API.blockDomain(domain, notes); + UI.showToast(`${domain} ausgeschlossen.`, 'success'); + this.showBlockDomainDialog(false); + await this.loadSources(); + this.updateSidebarStats(); + } catch (err) { + UI.showToast('Fehler: ' + err.message, 'error'); + } + }, + + /** + * Faktencheck-Filter umschalten. + */ + toggleFactCheckFilter(status) { + const checkbox = document.querySelector(`.fc-dropdown-item[data-status="${status}"] input`); + if (!checkbox) return; + const isActive = checkbox.checked; + + document.querySelectorAll(`.factcheck-item[data-fc-status="${status}"]`).forEach(el => { + el.style.display = isActive ? '' : 'none'; + }); + }, + + toggleFcDropdown(e) { + e.stopPropagation(); + const btn = e.target.closest('.fc-dropdown-toggle'); + const menu = btn ? btn.nextElementSibling : document.getElementById('fc-dropdown-menu'); + if (!menu) return; + const isOpen = menu.classList.toggle('open'); + if (btn) btn.setAttribute('aria-expanded', String(isOpen)); + if (isOpen) { + const close = (ev) => { + if (!menu.contains(ev.target)) { + menu.classList.remove('open'); + document.removeEventListener('click', close); + } + }; + setTimeout(() => document.addEventListener('click', close), 0); + } + }, + + filterModalTimeline(searchTerm) { + const filterBtn = document.querySelector('.ht-modal-filter-btn.active'); + const filterType = filterBtn ? filterBtn.dataset.filter : 'all'; + const body = document.getElementById('content-viewer-body'); + if (!body) return; + body.innerHTML = this._buildFullVerticalTimeline(filterType, (searchTerm || '').toLowerCase()); + }, + + filterModalTimelineType(filterType, btn) { + document.querySelectorAll('.ht-modal-filter-btn').forEach(b => b.classList.remove('active')); + if (btn) btn.classList.add('active'); + const searchInput = document.querySelector('#content-viewer-header-extra .timeline-filter-input'); + const searchTerm = searchInput ? searchInput.value.toLowerCase() : ''; + const body = document.getElementById('content-viewer-body'); + if (!body) return; + body.innerHTML = this._buildFullVerticalTimeline(filterType, searchTerm); + }, + + /** + * Domain direkt ausschließen (aus der Gruppenliste). + */ + async blockDomainDirect(domain) { + if (!await confirmDialog(`"${domain}" wirklich ausschließen? Artikel dieser Domain werden bei allen deinen Recherchen ignoriert. Dies betrifft nicht andere Nutzer deiner Organisation.`)) return; + + try { + await API.blockDomain(domain); + UI.showToast(`${domain} ausgeschlossen.`, 'success'); + await this.loadSources(); + this.updateSidebarStats(); + } catch (err) { + UI.showToast('Fehler: ' + err.message, 'error'); + } + }, + + /** + * Domain-Ausschluss aufheben. + */ + async unblockDomain(domain) { + try { + await API.unblockDomain(domain); + UI.showToast(`${domain} Ausschluss aufgehoben.`, 'success'); + await this.loadSources(); + this.updateSidebarStats(); + } catch (err) { + UI.showToast('Fehler: ' + err.message, 'error'); + } + }, + + /** + * Alle Quellen einer Domain löschen. + */ + async deleteDomain(domain) { + if (!await confirmDialog(`Alle Quellen von "${domain}" wirklich löschen?`)) return; + + try { + await API.deleteDomain(domain); + UI.showToast(`${domain} gelöscht.`, 'success'); + await this.loadSources(); + this.updateSidebarStats(); + } catch (err) { + UI.showToast('Fehler: ' + err.message, 'error'); + } + }, + + /** + * Einzelnen Feed löschen. + */ + async deleteSingleFeed(sourceId) { + try { + await API.deleteSource(sourceId); + this._allSources = this._allSources.filter(s => s.id !== sourceId); + this._sourcesOnly = this._sourcesOnly.filter(s => s.id !== sourceId); + this.renderSourceList(); + this.updateSidebarStats(); + UI.showToast('Feed gelöscht.', 'success'); + } catch (err) { + UI.showToast('Fehler: ' + err.message, 'error'); + } + }, + + /** + * "Domain ausschließen" Dialog ein-/ausblenden. + */ + showBlockDomainDialog(show) { + const form = document.getElementById('sources-block-form'); + if (!form) return; + + if (show === undefined || show === true) { + form.style.display = 'block'; + document.getElementById('block-domain-input').value = ''; + document.getElementById('block-domain-notes').value = ''; + // Add-Form ausblenden + const addForm = document.getElementById('sources-add-form'); + if (addForm) addForm.style.display = 'none'; + } else { + form.style.display = 'none'; + } + }, + + _discoveredData: null, + + toggleSourceForm(show) { + const form = document.getElementById('sources-add-form'); + if (!form) return; + + if (show === undefined) { + show = form.style.display === 'none'; + } + + form.style.display = show ? 'block' : 'none'; + + if (show) { + this._editingSourceId = null; + this._discoveredData = null; + document.getElementById('src-discover-url').value = ''; + document.getElementById('src-discovery-result').style.display = 'none'; + document.getElementById('src-discover-btn').disabled = false; + document.getElementById('src-discover-btn').textContent = 'Erkennen'; + // Save-Button Text zurücksetzen + const saveBtn = document.querySelector('#src-discovery-result .sources-discovery-actions .btn-primary'); + if (saveBtn) saveBtn.textContent = 'Speichern'; + // Block-Form ausblenden + const blockForm = document.getElementById('sources-block-form'); + if (blockForm) blockForm.style.display = 'none'; + } else { + // Beim Schließen: Bearbeitungsmodus zurücksetzen + this._editingSourceId = null; + const saveBtn = document.querySelector('#src-discovery-result .sources-discovery-actions .btn-primary'); + if (saveBtn) saveBtn.textContent = 'Speichern'; + } + }, + + async discoverSource() { + const urlInput = document.getElementById('src-discover-url'); + const url = urlInput.value.trim(); + if (!url) { + UI.showToast('Bitte URL oder Domain eingeben.', 'warning'); + return; + } + + // Prüfen ob Domain ausgeschlossen ist + const inputDomain = url.replace(/^https?:\/\//, '').replace(/^www\./, '').split('/')[0].toLowerCase(); + const isBlocked = inputDomain && this._myExclusions.some(e => (e.domain || '').toLowerCase() === inputDomain); + + if (isBlocked) { + if (!await confirmDialog(`"${inputDomain}" ist ausgeschlossen. Trotzdem hinzufügen? Der Ausschluss wird dabei aufgehoben.`)) return; + await API.unblockDomain(inputDomain); + } + + const btn = document.getElementById('src-discover-btn'); + btn.disabled = true; + btn.textContent = 'Suche Feeds...'; + + try { + const result = await API.discoverMulti(url); + + if (result.fallback_single) { + this._discoveredData = { + name: result.domain, + domain: result.domain, + category: result.category, + source_type: result.total_found > 0 ? 'rss_feed' : 'web_source', + rss_url: result.sources.length > 0 ? result.sources[0].url : null, + }; + if (result.sources.length > 0) { + this._discoveredData.name = result.sources[0].name; + } + + document.getElementById('src-name').value = this._discoveredData.name || ''; + document.getElementById('src-category').value = this._discoveredData.category || 'sonstige'; + document.getElementById('src-domain').value = this._discoveredData.domain || ''; + document.getElementById('src-notes').value = ''; + + const typeLabel = this._discoveredData.source_type === 'rss_feed' ? 'RSS-Feed' : this._discoveredData.source_type === 'telegram_channel' ? 'Telegram' : 'Web-Quelle'; + document.getElementById('src-type-display').value = typeLabel; + + const rssGroup = document.getElementById('src-rss-url-group'); + const rssInput = document.getElementById('src-rss-url'); + if (this._discoveredData.rss_url) { + rssInput.value = this._discoveredData.rss_url; + rssGroup.style.display = 'block'; + } else { + rssInput.value = ''; + rssGroup.style.display = 'none'; + } + + document.getElementById('src-discovery-result').style.display = 'block'; + + if (result.added_count > 0) { + UI.showToast(`${result.domain}: Feed wurde automatisch hinzugefügt.`, 'success'); + this.toggleSourceForm(false); + await this.loadSources(); + } else if (result.total_found === 0) { + UI.showToast('Kein RSS-Feed gefunden. Als Web-Quelle speichern?', 'info'); + } else { + UI.showToast('Feed bereits vorhanden.', 'info'); + } + } else { + document.getElementById('src-discovery-result').style.display = 'none'; + + if (result.added_count > 0) { + UI.showToast(`${result.domain}: ${result.added_count} Feeds hinzugefügt` + + (result.skipped_count > 0 ? ` (${result.skipped_count} bereits vorhanden)` : ''), + 'success'); + } else if (result.skipped_count > 0) { + UI.showToast(`${result.domain}: Alle ${result.skipped_count} Feeds bereits vorhanden.`, 'info'); + } else { + UI.showToast(`${result.domain}: Keine relevanten Feeds gefunden.`, 'info'); + } + + this.toggleSourceForm(false); + await this.loadSources(); + } + } catch (err) { + UI.showToast('Erkennung fehlgeschlagen: ' + err.message, 'error'); + } finally { + btn.disabled = false; + btn.textContent = 'Erkennen'; + } + }, + + editSource(id) { + const source = this._sourcesOnly.find(s => s.id === id); + if (!source) { + UI.showToast('Quelle nicht gefunden.', 'error'); + return; + } + + this._editingSourceId = id; + + // Formular öffnen falls geschlossen (direkt, ohne toggleSourceForm das _editingSourceId zurücksetzt) + const form = document.getElementById('sources-add-form'); + if (form) { + form.style.display = 'block'; + const blockForm = document.getElementById('sources-block-form'); + if (blockForm) blockForm.style.display = 'none'; + } + + // Discovery-URL mit vorhandener URL/Domain befüllen + const discoverUrlInput = document.getElementById('src-discover-url'); + if (discoverUrlInput) { + discoverUrlInput.value = source.url || source.domain || ''; + } + + // Discovery-Ergebnis anzeigen und Felder befüllen + document.getElementById('src-discovery-result').style.display = 'block'; + document.getElementById('src-name').value = source.name || ''; + document.getElementById('src-category').value = source.category || 'sonstige'; + document.getElementById('src-notes').value = source.notes || ''; + document.getElementById('src-domain').value = source.domain || ''; + + const typeLabel = source.source_type === 'rss_feed' ? 'RSS-Feed' : source.source_type === 'telegram_channel' ? 'Telegram' : 'Web-Quelle'; + document.getElementById('src-type-display').value = typeLabel; + + const rssGroup = document.getElementById('src-rss-url-group'); + const rssInput = document.getElementById('src-rss-url'); + if (source.url) { + rssInput.value = source.url; + rssGroup.style.display = 'block'; + } else { + rssInput.value = ''; + rssGroup.style.display = 'none'; + } + + // _discoveredData setzen damit saveSource() die richtigen Werte nutzt + this._discoveredData = { + name: source.name, + domain: source.domain, + category: source.category, + source_type: source.source_type, + rss_url: source.url, + }; + + // Submit-Button-Text ändern + const saveBtn = document.querySelector('#src-discovery-result .sources-discovery-actions .btn-primary'); + if (saveBtn) saveBtn.textContent = 'Quelle speichern'; + + // Zum Formular scrollen + if (form) form.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }, + + async saveSource() { + const name = document.getElementById('src-name').value.trim(); + if (!name) { + UI.showToast('Name ist erforderlich. Bitte erst "Erkennen" klicken.', 'warning'); + return; + } + + const discovered = this._discoveredData || {}; + const data = { + name, + source_type: discovered.source_type || 'web_source', + category: document.getElementById('src-category').value, + url: discovered.rss_url || null, + domain: document.getElementById('src-domain').value.trim() || discovered.domain || null, + notes: document.getElementById('src-notes').value.trim() || null, + }; + + if (!data.domain && discovered.domain) { + data.domain = discovered.domain; + } + + try { + if (this._editingSourceId) { + await API.updateSource(this._editingSourceId, data); + UI.showToast('Quelle aktualisiert.', 'success'); + } else { + await API.createSource(data); + UI.showToast('Quelle hinzugefügt.', 'success'); + } + + this.toggleSourceForm(false); + await this.loadSources(); + this.updateSidebarStats(); + } catch (err) { + UI.showToast('Fehler: ' + err.message, 'error'); + } + }, + + logout() { + localStorage.removeItem('osint_token'); + localStorage.removeItem('osint_username'); + this._sessionWarningShown = false; + WS.disconnect(); + window.location.href = '/'; + }, +}; + +// === Barrierefreier Bestätigungsdialog === + +function confirmDialog(message) { + return new Promise((resolve) => { + // Overlay erstellen + const overlay = document.createElement('div'); + overlay.className = 'modal-overlay active'; + overlay.setAttribute('role', 'alertdialog'); + overlay.setAttribute('aria-modal', 'true'); + overlay.setAttribute('aria-labelledby', 'confirm-dialog-msg'); + + const modal = document.createElement('div'); + modal.className = 'modal'; + modal.style.maxWidth = '420px'; + modal.innerHTML = ` + + + + `; + 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 = 'Nur WebSearch (Deep Research), manuelle Aktualisierung empfohlen'; + refreshMode.value = 'manual'; + toggleRefreshInterval(); + } else { + hint.textContent = 'RSS-Feeds + WebSearch, automatische Aktualisierung empfohlen'; + } +} + +// Tab-Fokus: Nur Tab-Badge (Titel-Counter) zurücksetzen, nicht alle Notifications +window.addEventListener('focus', () => { + document.title = App._originalTitle; +}); + +// ESC schließt Modals +// F5: Daten aktualisieren statt Seite neu laden +document.addEventListener('keydown', (e) => { + if (e.key === 'F5') { + e.preventDefault(); + App.softRefresh(); + } +}); + +document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + // Schließ-Reihenfolge: A11y-Panel > Notification-Panel > Export-Dropdown > FC-Dropdown > Modals + if (A11yManager._isOpen) { + A11yManager._closePanel(); + return; + } + if (NotificationCenter._isOpen) { + NotificationCenter.close(); + return; + } + const exportMenu = document.getElementById('export-dropdown-menu'); + if (exportMenu && exportMenu.classList.contains('show')) { + App._closeExportDropdown(); + return; + } + const fcMenu = document.querySelector('.fc-dropdown-menu.open'); + if (fcMenu) { + fcMenu.classList.remove('open'); + const fcBtn = fcMenu.previousElementSibling; + if (fcBtn) fcBtn.setAttribute('aria-expanded', 'false'); + return; + } + document.querySelectorAll('.modal-overlay.active').forEach(m => { + closeModal(m.id); + }); + } +}); + +// Keyboard-Handler: Enter/Space auf [role="button"] löst click aus (WCAG 2.1.1) +document.addEventListener('keydown', (e) => { + if ((e.key === 'Enter' || e.key === ' ') && e.target.matches('[role="button"]')) { + e.preventDefault(); + e.target.click(); + } +}); + +// Session-Ablauf prüfen (alle 60 Sekunden) +setInterval(() => { + const token = localStorage.getItem('osint_token'); + if (!token) return; + try { + const payload = JSON.parse(atob(token.split('.')[1])); + const expiresAt = payload.exp * 1000; + const remaining = expiresAt - Date.now(); + const fiveMinutes = 5 * 60 * 1000; + if (remaining <= 0) { + App.logout(); + } else if (remaining <= fiveMinutes && !App._sessionWarningShown) { + App._sessionWarningShown = true; + const mins = Math.ceil(remaining / 60000); + UI.showToast(`Session läuft in ${mins} Minute${mins !== 1 ? 'n' : ''} ab. Bitte erneut anmelden.`, 'warning', 15000); + } + } catch (e) { /* Token nicht parsbar */ } +}, 60000); + +// Modal-Overlays: Klick auf Backdrop schließt NICHT mehr (nur X-Button) +document.addEventListener('click', (e) => { + if (e.target.classList.contains('modal-overlay') && e.target.classList.contains('active')) { + // closeModal deaktiviert - Modal nur ueber X-Button schliessbar + } +}); + +// App starten +document.addEventListener('click', (e) => { + if (!e.target.closest('.export-dropdown')) { + App._closeExportDropdown(); + } +}); +document.addEventListener('DOMContentLoaded', () => App.init());