Dateien
AegisSight-Monitor/src/static/js/app.js
claude-dev c09c2a49b9 feat(ui): Sammelaktionen auch in der klassischen Ansicht, mit Lucide-Icons
Im Studio lassen sich mehrere Faelle auf einmal archivieren oder loeschen, in
der klassischen Ansicht ging das nur einzeln ueber die Aktionsleiste. Wer dort
zehn alte Lagen wegraeumen wollte, musste jede einzeln oeffnen.

Die Seitenleiste bekommt deshalb dieselbe Bedienung wie das Studio. Unten
stehen zwei Knoepfe, Archivieren und Loeschen. Der erste Klick schaltet den
Auswahlmodus ein, erst dann erscheinen Haken in der Lagenliste, an Stelle des
Statuspunktes. Ein Klick auf eine Zeile waehlt dann aus, statt die Lage zu
oeffnen. Bestaetigt wird in der Leiste darunter, die mitzaehlt und nur
anbietet, was zur Auswahl passt. Sind nur aktive Lagen gewaehlt, erscheint
kein Aktivieren-Knopf, im Loeschmodus keiner der beiden anderen.

"Alle sichtbaren" beruecksichtigt den Filter der Seitenleiste, waehlt also bei
aktivem Eigene-Filter nur die eigenen Lagen aus. Das Loeschen fragt vorher nach
und nennt Titel und Artikelzahl, weil es nicht rueckgaengig zu machen ist. Die
Aktionen laufen nacheinander ueber die normalen Endpunkte, damit deren
Rechtepruefung greift, und Fehlschlaege werden mit Nummer gemeldet statt still
verschluckt. War die offene Lage dabei, springt die Ansicht zurueck.

Dazu Lucide-Icons fuer Archivieren, Aktivieren, Loeschen und Abbrechen, in
beiden Ansichten. Im Studio setzte _renderBulkBar die Beschriftung bisher ueber
textContent des Knopfes, was das Icon bei jedem Neuzeichnen entfernt haette.
Die Beschriftung liegt jetzt in einer eigenen span.

Cache-Buster auf 20260802a, sonst laden Browser die alten Skripte weiter.

Neu sind 34 Pruefungen, sie laufen ohne Browser gegen ein DOM-Double.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 12:19:52 +00:00

4049 Zeilen
184 KiB
JavaScript

/**
* 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');
}
};
/*
* Der A11yManager (Barrierefreiheits-Panel) liegt seit 2026-07-25 in
* js/a11y.js, weil ihn die Studio-Ansicht ebenfalls einbindet.
*/
/**
* 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 = `
<button class="notification-bell" id="notification-bell" title="Benachrichtigungen" aria-label="Benachrichtigungen" aria-expanded="false" aria-haspopup="true">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/>
<path d="M13.73 21a2 2 0 0 1-3.46 0"/>
</svg>
<span class="notification-badge" id="notification-badge" style="display:none;">0</span>
</button>
<div class="notification-panel" id="notification-panel" style="display:none;">
<div class="notification-panel-header">
<span class="notification-panel-title">${(typeof T === 'function' ? T('notifications.title', 'Benachrichtigungen') : 'Benachrichtigungen')}</span>
<button class="notification-mark-read" id="notification-mark-read">${(typeof T === 'function' ? T('notifications.mark_all_read', 'Alle gelesen') : 'Alle gelesen')}</button>
</div>
<div class="notification-panel-list" id="notification-panel-list">
<div class="notification-empty">Keine Benachrichtigungen</div>
</div>
</div>
`;
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 = ('<div class="notification-empty">' + (typeof T === 'function' ? T('notifications.empty', 'Keine Benachrichtigungen') : 'Keine Benachrichtigungen') + '</div>');
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 `<div class="notification-item${unreadClass}" onclick="NotificationCenter._handleClick(${n.incident_id})" data-id="${n.incident_id}" role="button" tabindex="0">
<div class="notification-item-icon ${icon}">${this._iconSymbol(icon)}</div>
<div class="notification-item-body">
<div class="notification-item-title">${this._escapeHtml(n.title)}</div>
<div class="notification-item-text">${this._escapeHtml(n.text)}</div>
</div>
<div class="notification-item-time">${timeStr}</div>
</div>`;
}).join('');
},
_handleClick(incidentId) {
this.close();
if (incidentId) {
App.selectIncident(incidentId);
}
},
_iconSymbol(type) {
switch (type) {
case 'success': return '\u2713';
case 'warning': return '!';
case 'error': return '\u2717';
default: return 'i';
}
},
_escapeHtml(text) {
const d = document.createElement('div');
d.textContent = text || '';
return d.innerHTML;
},
async _loadFromDB() {
try {
const items = await API.listNotifications(50);
this._notifications = items.map(n => ({
id: n.id,
incident_id: n.incident_id,
title: n.title,
text: n.text,
icon: n.icon || 'info',
type: n.type,
read: !!n.is_read,
timestamp: n.created_at,
}));
this._unreadCount = this._notifications.filter(n => !n.read).length;
this._updateBadge();
this._renderList();
} catch (e) {
console.warn('Notifications laden fehlgeschlagen:', e);
}
},
async _syncFromDB() {
try {
const items = await API.listNotifications(50);
this._notifications = items.map(n => ({
id: n.id,
incident_id: n.incident_id,
title: n.title,
text: n.text,
icon: n.icon || 'info',
type: n.type,
read: !!n.is_read,
timestamp: n.created_at,
}));
this._unreadCount = this._notifications.filter(n => !n.read).length;
this._updateBadge();
this._renderList();
} catch (e) {
console.warn('Notifications sync fehlgeschlagen:', e);
}
},
};
const App = {
currentIncidentId: null,
incidents: [],
_originalTitle: document.title,
_refreshingIncidents: new Set(),
_editingIncidentId: null,
_currentArticles: [],
_currentSnapshots: [],
_snapshotFullCache: new Map(),
_currentSources: [],
_currentIncidentType: 'adhoc',
_sidebarFilter: 'all',
_currentUsername: '',
_allSources: [],
_sourcesOnly: [],
_myExclusions: [], // [{domain, notes, created_at}]
_expandedGroups: new Set(),
_editingSourceId: null,
_timelineFilter: 'all',
_timelineRange: 'all',
_activeStripWindow: null,
_timelineSearchTimer: null,
_pendingComplete: null,
_pendingCompleteTimer: null,
async init() {
ThemeManager.init();
A11yManager.init();
// Auth prüfen
const token = localStorage.getItem('osint_token');
if (!token) {
window.location.href = '/';
return;
}
try {
const user = await API.getMe();
this.user = user;
this._currentUsername = user.email;
// Studio-Button vorerst nur fuer info@aegis-sight.de sichtbar (experimentelle Ansicht)
if (user.email === 'info@aegis-sight.de') {
const studioLink = document.getElementById('studio-link');
if (studioLink) studioLink.style.display = '';
}
// i18n: Sprache anhand der Org laden (default 'de') und DOM uebersetzen
if (window.I18N) {
const targetLang = user.output_language || 'de';
await window.I18N.load(targetLang);
window.I18N.applyDom();
}
document.getElementById('header-user').textContent = user.email;
// Dropdown-Daten befuellen
const orgNameEl = document.getElementById('header-org-name');
if (orgNameEl) orgNameEl.textContent = user.org_name || '-';
const licInfoEl = document.getElementById('header-license-info');
if (licInfoEl) {
const licenseLabels = {
trial: 'Trial',
annual: 'Jahreslizenz',
permanent: 'Permanent',
};
const label = user.read_only ? 'Abgelaufen'
: licenseLabels[user.license_type] || user.license_status || '-';
licInfoEl.textContent = label;
}
// Credits-Anzeige im Dropdown
const creditsSection = document.getElementById('credits-section');
if (creditsSection && user.credits_total) {
creditsSection.style.display = 'block';
const bar = document.getElementById('credits-bar');
const remainingEl = document.getElementById('credits-remaining');
const totalEl = document.getElementById('credits-total');
const remaining = user.credits_remaining || 0;
const total = user.credits_total || 1;
const percentUsed = user.credits_percent_used || 0;
const percentRemaining = Math.max(0, 100 - percentUsed);
remainingEl.textContent = remaining.toLocaleString('de-DE');
totalEl.textContent = total.toLocaleString('de-DE');
bar.style.width = percentRemaining + '%';
// Farbwechsel je nach Verbrauch
bar.classList.remove('warning', 'critical');
if (percentUsed > 80) {
bar.classList.add('critical');
} else if (percentUsed > 50) {
bar.classList.add('warning');
}
const percentEl = document.getElementById("credits-percent");
if (percentEl) percentEl.textContent = percentRemaining.toFixed(0) + "% verbleibend";
// Bezugszeitraum benennen. Ohne den Zusatz liest sich ein
// monatliches Kontingent wie ein Gesamtvorrat, der nie wiederkommt.
const labelEl = document.getElementById('credits-label');
if (labelEl) {
const _tt = (k, fb) => (typeof T === 'function') ? T(k, fb) : fb;
labelEl.textContent = user.credits_period === 'monthly'
? _tt('credits.label_monthly', 'Credits diesen Monat')
: _tt('credits.label', 'Credits');
}
}
// Dropdown Toggle
const userBtn = document.getElementById('header-user-btn');
const userDropdown = document.getElementById('header-user-dropdown');
if (userBtn && userDropdown) {
userBtn.addEventListener('click', (e) => {
e.stopPropagation();
const isOpen = userDropdown.classList.toggle('open');
userBtn.setAttribute('aria-expanded', isOpen);
});
userDropdown.addEventListener('click', (e) => {
e.stopPropagation();
});
document.addEventListener('click', () => {
userDropdown.classList.remove('open');
userBtn.setAttribute('aria-expanded', 'false');
});
}
// Warnung bei Read-Only (Lizenz abgelaufen oder Token-Budget aufgebraucht)
const warningEl = document.getElementById('header-license-warning');
if (warningEl) {
if (user.read_only) {
let text = 'Nur Lesezugriff';
const reason = user.read_only_reason;
if (reason === 'budget_exceeded') {
text = 'Token-Budget aufgebraucht – nur Lesezugriff. Für Aufstockung oder Upgrade bitte info@aegis-sight.de kontaktieren.';
} else if (reason === 'expired') {
text = (typeof T === 'function' ? T('license.expired_readonly', 'Lizenz abgelaufen – nur Lesezugriff') : 'Lizenz abgelaufen – nur Lesezugriff');
} else if (reason === 'no_license') {
text = (typeof T === 'function' ? T('license.none_readonly', 'Keine aktive Lizenz – nur Lesezugriff') : 'Keine aktive Lizenz – nur Lesezugriff');
} else if (reason === 'org_disabled') {
text = (typeof T === 'function' ? T('license.org_disabled_readonly', 'Organisation deaktiviert – nur Lesezugriff') : 'Organisation deaktiviert – nur Lesezugriff');
}
warningEl.textContent = text;
warningEl.classList.add('visible');
} else {
warningEl.textContent = '';
warningEl.classList.remove('visible');
}
}
// --- Global Admin: Org-Switcher (herausnehmbar) ---
if (user.is_global_admin) {
this._initOrgSwitcher(user.tenant_id);
}
// Tutorial nur bei deutscher Org starten -- englische Demo-Mandanten
// sollen direkt im Dashboard landen.
try {
const lang = (window.I18N && window.I18N.lang) || 'de';
if (lang === 'de' && typeof Tutorial !== 'undefined' && Tutorial.init) {
Tutorial.init();
}
} catch (e) { /* Tutorial optional */ }
} catch {
window.location.href = '/';
return;
}
// Event-Listener
document.getElementById('logout-btn').addEventListener('click', () => this.logout());
document.getElementById('new-incident-btn').addEventListener('click', () => openModal('modal-new'));
document.getElementById('new-incident-form').addEventListener('submit', (e) => this.handleFormSubmit(e));
document.getElementById('refresh-btn').addEventListener('click', () => this.handleRefresh());
document.getElementById('delete-incident-btn').addEventListener('click', () => this.handleDelete());
document.getElementById('edit-incident-btn').addEventListener('click', () => this.handleEdit());
document.getElementById('archive-incident-btn').addEventListener('click', () => this.handleArchive());
document.getElementById('inc-international').addEventListener('change', () => updateSourcesHint());
document.getElementById('inc-visibility').addEventListener('change', () => updateVisibilityHint());
// Telegram-Kategorien Toggle
const tgCheckbox = document.getElementById('inc-telegram');
if (tgCheckbox) {
tgCheckbox.addEventListener('change', () => updateIntervalMin());
}
{ const xCheckbox = document.getElementById('inc-x');
if (xCheckbox) xCheckbox.addEventListener('change', () => updateIntervalMin()); }
{ const ivInput = document.getElementById('inc-refresh-value');
if (ivInput) ivInput.addEventListener('change', () => {
const u = parseInt(document.getElementById('inc-refresh-unit').value);
const m = (u === 1) ? _getMinIntervalMinutes() : 1;
if (isNaN(parseInt(ivInput.value)) || parseInt(ivInput.value) < m) ivInput.value = m;
}); }
// Feedback
document.getElementById('feedback-form').addEventListener('submit', (e) => this.submitFeedback(e));
document.getElementById('fb-message').addEventListener('input', (e) => {
document.getElementById('fb-char-count').textContent = e.target.value.length.toLocaleString('de-DE');
});
// Sidebar-Chevrons initial auf offen setzen (Archiv geschlossen)
document.querySelectorAll('.sidebar-chevron').forEach(c => c.classList.add('open'));
document.getElementById('chevron-archived-incidents').classList.remove('open');
// Lagen laden (frueh, damit Sidebar sofort sichtbar)
await this.loadIncidents();
// Netzwerkanalysen laden
// Notification-Center initialisieren
try { await NotificationCenter.init(); } catch (e) { console.warn('NotificationCenter:', e); }
// WebSocket
WS.connect();
WS.on('status_update', (msg) => this.handleStatusUpdate(msg));
WS.on('refresh_complete', (msg) => this.handleRefreshComplete(msg));
WS.on('refresh_summary', (msg) => this.handleRefreshSummary(msg));
WS.on('refresh_error', (msg) => this.handleRefreshError(msg));
WS.on('refresh_cancelled', (msg) => this.handleRefreshCancelled(msg));
// Laufende Refreshes wiederherstellen
try {
const data = await API.getRefreshingIncidents();
const details = data.details || {};
const currentTask = data.current;
const queuedIds = data.queued || [];
// Restore running refreshes
if (data.refreshing && data.refreshing.length > 0) {
data.refreshing.forEach(id => {
this._refreshingIncidents.add(id);
const d = details[String(id)] || {};
const inc = this.incidents.find(i => i.id === id);
const isFirst = inc && !inc.has_summary;
const isCurrent = (id === currentTask);
// Use 'researching' as default step for the actively running task
UI.showProgress(isCurrent ? 'researching' : 'queued', { started_at: d.started_at }, id, isFirst);
});
}
// Restore queued incidents
if (queuedIds.length > 0) {
queuedIds.forEach((id, idx) => {
this._refreshingIncidents.add(id);
const inc = this.incidents.find(i => i.id === id);
const isFirst = inc && !inc.has_summary;
UI.showProgress('queued', { queue_position: idx + 1 }, id, isFirst);
// Pipeline-Reset auch nach F5: aktive Lage in Queue -> Icons grau
if (id === this.currentIncidentId && typeof Pipeline !== 'undefined' && Pipeline.beginQueue) {
Pipeline.beginQueue(id);
}
});
}
if (data.refreshing.length > 0 || queuedIds.length > 0) {
this.renderSidebar();
}
} catch (e) { /* Kein kritischer Fehler */ }
// Heartbeat: periodischer Status-Abgleich als Sicherheitsnetz
this._statusSyncInterval = setInterval(() => this.syncRefreshStatus(), 60000);
// Zuletzt ausgewählte Lage wiederherstellen
const savedId = localStorage.getItem('selectedIncidentId');
if (savedId) {
const id = parseInt(savedId, 10);
if (this.incidents.some(inc => inc.id === id)) {
await this.selectIncident(id);
}
}
// Leaflet-Karte nachladen falls CDN langsam war
setTimeout(() => UI.retryPendingMap(), 2000);
},
async loadIncidents() {
try {
this.incidents = await API.listIncidents();
this.renderSidebar();
} catch (err) {
UI.showToast('Fehler beim Laden der Lagen: ' + err.message, 'error');
}
},
// === Sammelaktionen in der Seitenleiste ===
// Die Haken sind normalerweise weg. Erst ein Klick auf Archivieren oder
// Loeschen unten in der Seitenleiste schaltet den Auswahlmodus ein. Das
// entspricht dem Verhalten im Studio, damit beide Ansichten sich gleich
// bedienen lassen.
_bulkMode: null,
_bulkSel: new Set(),
startBulkMode(mode) {
this._bulkMode = mode;
this._bulkSel.clear();
this.renderSidebar();
},
endBulkMode() {
this._bulkMode = null;
this._bulkSel.clear();
const alle = document.getElementById('sidebar-bulk-all');
if (alle) alle.checked = false;
this.renderSidebar();
},
toggleBulkSel(id, on) {
if (on) this._bulkSel.add(id); else this._bulkSel.delete(id);
this.renderSidebar();
},
// Sichtbar heisst: was der aktive Filter der Seitenleiste gerade zeigt.
_visibleIncidents() {
let sichtbar = this.incidents || [];
if (this._sidebarFilter === 'mine') {
sichtbar = sichtbar.filter(i => i.created_by_username === this._currentUsername);
}
return sichtbar;
},
selectAllVisible(on) {
const sichtbar = this._visibleIncidents();
if (on) sichtbar.forEach(i => this._bulkSel.add(i.id));
else sichtbar.forEach(i => this._bulkSel.delete(i.id));
this.renderSidebar();
},
_renderBulkBar() {
const mode = this._bulkMode;
const tools = document.getElementById('sidebar-bulk-tools');
const bar = document.getElementById('sidebar-bulk-bar');
if (tools) tools.hidden = mode !== null;
if (bar) bar.hidden = mode === null;
if (mode === null) return;
const gewaehlt = (this.incidents || []).filter(i => this._bulkSel.has(i.id));
const n = gewaehlt.length;
const aktive = gewaehlt.filter(i => i.status === 'active').length;
const archivierte = n - aktive;
const zaehler = document.getElementById('sidebar-bulk-count');
if (zaehler) {
zaehler.textContent = n === 0
? (mode === 'delete' ? 'Lagen zum Löschen anhaken' : 'Lagen zum Archivieren anhaken')
: (n === 1 ? '1 Lage ausgewählt' : n + ' Lagen ausgewählt');
}
// Nur anbieten, was zum Modus und zur Auswahl passt.
const knopfArchiv = document.getElementById('sidebar-bulk-archive');
const knopfAktiv = document.getElementById('sidebar-bulk-activate');
const knopfLoesch = document.getElementById('sidebar-bulk-delete');
if (knopfArchiv) {
knopfArchiv.hidden = mode !== 'archive' || aktive === 0;
const t = knopfArchiv.querySelector('span');
if (t) t.textContent = 'Archivieren (' + aktive + ')';
}
if (knopfAktiv) {
knopfAktiv.hidden = mode !== 'archive' || archivierte === 0;
const t = knopfAktiv.querySelector('span');
if (t) t.textContent = 'Aktivieren (' + archivierte + ')';
}
if (knopfLoesch) {
knopfLoesch.hidden = mode !== 'delete' || n === 0;
const t = knopfLoesch.querySelector('span');
if (t) t.textContent = 'Löschen (' + n + ')';
}
const sichtbar = this._visibleIncidents();
const alle = document.getElementById('sidebar-bulk-all');
if (alle) alle.checked = sichtbar.length > 0 && sichtbar.every(i => this._bulkSel.has(i.id));
},
// Die Sammelaktionen laufen nacheinander ueber die normalen Endpunkte, damit
// deren Rechtepruefung greift. Fehlschlaege werden gezaehlt statt still
// verschluckt.
async _bulkRun(ids, fn, verb) {
let ok = 0;
const fehlgeschlagen = [];
for (const id of ids) {
try { await fn(id); ok++; }
catch (e) { fehlgeschlagen.push(id); }
}
this._bulkSel.clear();
this._bulkMode = null;
await this.loadIncidents();
if (fehlgeschlagen.length) {
UI.showToast(ok + ' ' + verb + ', ' + fehlgeschlagen.length
+ ' fehlgeschlagen (IDs ' + fehlgeschlagen.join(', ') + ')', 'error');
} else {
UI.showToast(ok + ' ' + (ok === 1 ? 'Lage' : 'Lagen') + ' ' + verb, 'success');
}
},
async bulkStatus(status) {
const ids = (this.incidents || [])
.filter(i => this._bulkSel.has(i.id)
&& (status === 'archived' ? i.status === 'active' : i.status !== 'active'))
.map(i => i.id);
if (!ids.length) return;
const verb = status === 'archived' ? 'archiviert' : 'wieder aktiviert';
await this._bulkRun(ids, (id) => API.updateIncident(id, { status }), verb);
},
async bulkDelete() {
const gewaehlt = (this.incidents || []).filter(i => this._bulkSel.has(i.id));
if (!gewaehlt.length) return;
const artikel = gewaehlt.reduce((n, i) => n + (i.article_count || 0), 0);
const namen = gewaehlt.slice(0, 8).map(i => '\n\u2022 ' + i.title).join('')
+ (gewaehlt.length > 8 ? '\n\u2022 … und ' + (gewaehlt.length - 8) + ' weitere' : '');
const frage = gewaehlt.length + ' ' + (gewaehlt.length === 1 ? 'Lage' : 'Lagen')
+ ' endgültig löschen?' + namen
+ '\n\nDamit gehen ' + artikel + ' Artikel samt Faktenchecks, Lageberichten'
+ ' und Karten verloren. Das lässt sich nicht rückgängig machen.'
+ '\n\nWenn du sie nur aus der Liste nehmen willst, nutze stattdessen Archivieren.';
if (!confirm(frage)) return;
const ids = gewaehlt.map(i => i.id);
const offeneDabei = this.currentIncidentId && ids.includes(this.currentIncidentId);
await this._bulkRun(ids, (id) => API.deleteIncident(id), 'gelöscht');
if (offeneDabei) {
this.currentIncidentId = null;
const leer = document.getElementById('empty-state');
const inhalt = document.getElementById('incident-view');
if (leer) leer.style.display = 'flex';
if (inhalt) inhalt.style.display = 'none';
}
},
renderSidebar() {
const activeContainer = document.getElementById('active-incidents');
const researchContainer = document.getElementById('active-research');
const archivedContainer = document.getElementById('archived-incidents');
// Filter-Buttons aktualisieren
document.querySelectorAll('.sidebar-filter-btn').forEach(btn => {
const isActive = btn.dataset.filter === this._sidebarFilter;
btn.classList.toggle('active', isActive);
btn.setAttribute('aria-pressed', String(isActive));
});
// Lagen nach Filter einschränken
let filtered = this.incidents;
if (this._sidebarFilter === 'mine') {
filtered = filtered.filter(i => i.created_by_username === this._currentUsername);
}
// Aktive Lagen nach Typ aufteilen
const activeAdhoc = filtered.filter(i => i.status === 'active' && (!i.type || i.type === 'adhoc'));
const activeResearch = filtered.filter(i => i.status === 'active' && i.type === 'research');
const archived = filtered.filter(i => i.status === 'archived');
const _tEmpty = (k, fb) => (typeof T === 'function') ? T(k, fb) : fb;
const emptyLabelAdhoc = this._sidebarFilter === 'mine'
? _tEmpty('sidebar.empty_adhoc_mine', 'Kein eigenes Live-Monitoring')
: _tEmpty('sidebar.empty_adhoc', 'Kein Live-Monitoring');
const emptyLabelResearch = this._sidebarFilter === 'mine'
? _tEmpty('sidebar.empty_research_mine', 'Keine eigenen Deep-Research')
: _tEmpty('sidebar.empty_research', 'Keine Deep-Research');
activeContainer.innerHTML = activeAdhoc.length
? activeAdhoc.map(i => UI.renderIncidentItem(i, i.id === this.currentIncidentId)).join('')
: `<div style="padding:8px 12px;font-size:12px;color:var(--text-tertiary);">${emptyLabelAdhoc}</div>`;
researchContainer.innerHTML = activeResearch.length
? activeResearch.map(i => UI.renderIncidentItem(i, i.id === this.currentIncidentId)).join('')
: `<div style="padding:8px 12px;font-size:12px;color:var(--text-tertiary);">${emptyLabelResearch}</div>`;
archivedContainer.innerHTML = archived.length
? archived.map(i => UI.renderIncidentItem(i, i.id === this.currentIncidentId)).join('')
: '<div style="padding:8px 12px;font-size:12px;color:var(--text-tertiary);">Kein Archiv</div>';
// 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();
// Zustand der Sammelaktionen nachziehen
this._renderBulkBar();
},
setSidebarFilter(filter) {
this._sidebarFilter = filter;
this.renderSidebar();
},
_announceForSR(text) {
let el = document.getElementById('sr-announcement');
if (!el) {
el = document.createElement('div');
el.id = 'sr-announcement';
el.setAttribute('role', 'status');
el.setAttribute('aria-live', 'polite');
el.className = 'sr-only';
document.body.appendChild(el);
}
el.textContent = '';
requestAnimationFrame(() => { el.textContent = text; });
},
async selectIncident(id) {
this.closeRefreshHistory();
this.currentIncidentId = id;
localStorage.setItem('selectedIncidentId', id);
const inc = this.incidents.find(i => i.id === id);
if (inc) this._announceForSR('Lage ausgewählt: ' + inc.title);
this.renderSidebar();
var mc = document.getElementById("main-content");
mc.scrollTop = 0;
document.getElementById('empty-state').style.display = 'none';
document.getElementById('incident-view').style.display = 'flex';
// GridStack-Animation deaktivieren und Scroll komplett sperren
// bis alle Tile-Resize-Operationen (doppeltes rAF) abgeschlossen sind
var gridEl = document.querySelector('.tab-panels');
if (gridEl) gridEl.classList.remove('grid-stack-animate');
var scrollLock = function() { mc.scrollTop = 0; };
mc.addEventListener('scroll', scrollLock);
// gridstack-Layout initialisieren (einmalig)
if (typeof LayoutManager !== 'undefined') LayoutManager.init();
// Refresh-Status fuer diese Lage wiederherstellen
const isRefreshing = this._refreshingIncidents.has(id);
this._updateRefreshButton(isRefreshing);
// Hide any popup/mini from previous incident
const prevOverlay = document.getElementById('progress-overlay');
if (prevOverlay) prevOverlay.style.display = 'none';
if (typeof UI !== 'undefined' && UI._syncHeaderAccess) UI._syncHeaderAccess();
const prevMini = document.getElementById('progress-mini');
if (prevMini) prevMini.style.display = 'none';
const blurTarget = document.getElementById('incident-view');
// Wenn gerade ein erster Refresh laeuft, Blur stehen lassen statt
// remove+add im selben Tick — CSS filter:blur greift sonst nicht.
const _restState = isRefreshing ? UI._progressState[id] : null;
const _willReBlur = _restState && _restState.isFirst && !_restState.minimized;
if (blurTarget && !_willReBlur) blurTarget.classList.remove('refresh-blurred');
if (isRefreshing) {
const state = UI._progressState[id];
if (state) {
// Restore exactly as it was: popup open or minimized
if (state.minimized) {
UI._showMiniProgress(state.step, state);
} else {
UI._showPopupProgress(state.step, {}, state);
}
UI._lockActionsIfFirst(state.isFirst);
} else {
// No state yet — show popup (first status update will refine)
UI.showProgress('researching', {}, id, false);
}
} else {
UI._lockActionsIfFirst(false);
}
// Alte Inhalte sofort leeren um Flackern beim Wechsel zu vermeiden
var el;
el = document.getElementById("incident-title"); if (el) el.textContent = "";
el = document.getElementById("summary-content"); if (el) el.scrollTop = 0;
el = document.getElementById("summary-text"); if (el) el.innerHTML = "";
el = document.getElementById("zusammenfassung-text"); if (el) el.innerHTML = "";
el = document.getElementById("factcheck-filters"); if (el) el.innerHTML = "";
el = document.querySelector(".factcheck-list"); if (el) el.scrollTop = 0;
el = document.getElementById("factcheck-list"); if (el) el.innerHTML = "";
el = document.getElementById("source-overview-content"); if (el) el.innerHTML = "";
el = document.getElementById("source-overview-header-stats"); if (el) el.textContent = "";
el = document.getElementById("timeline-entries"); if (el) el.innerHTML = "";
await this.loadIncidentDetail(id);
// Scroll-Sperre nach 3 Frames aufheben (nach allen doppelten rAF-Callbacks)
mc.scrollTop = 0;
requestAnimationFrame(() => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
mc.scrollTop = 0;
mc.removeEventListener('scroll', scrollLock);
if (gridEl) gridEl.classList.add('grid-stack-animate');
});
});
});
},
async loadIncidentDetail(id) {
try {
const [incident, articlesResponse, factchecks, snapshots, locationsResponse, sourcesResponse] = await Promise.all([
API.getIncident(id),
API.getArticles(id, { limit: 500, offset: 0 }),
API.getFactChecks(id),
API.getSnapshots(id),
API.getLocations(id).catch(() => []),
API.getIncidentSources(id).catch(() => ({ sources: [] })),
]);
// Sources-Array (ersetzt frueheres incident.sources_json — lazy via /sources-Endpunkt)
this._currentSources = (sourcesResponse && sourcesResponse.sources) || [];
// Articles: neue Shape {total, articles} oder alter nackter Array (Rueckwaertskompatibel)
let articles, articlesTotal;
if (Array.isArray(articlesResponse)) {
articles = articlesResponse;
articlesTotal = articlesResponse.length;
} else {
articles = articlesResponse.articles || [];
articlesTotal = articlesResponse.total || articles.length;
}
// Locations-API gibt jetzt {category_labels, locations} oder Array (Rueckwaertskompatibel)
let locations, categoryLabels;
if (Array.isArray(locationsResponse)) {
locations = locationsResponse;
categoryLabels = null;
} else if (locationsResponse && locationsResponse.locations) {
locations = locationsResponse.locations;
categoryLabels = locationsResponse.category_labels || null;
} else {
locations = [];
categoryLabels = null;
}
this._currentArticlesTotal = articlesTotal;
this._currentArticlesLoaded = articles.length;
this._currentIncidentIdForLoad = id;
this.renderIncidentDetail(incident, articles, factchecks, snapshots, locations, categoryLabels);
// Pipeline an die geladene Lage binden (laedt /api/incidents/{id}/pipeline)
if (typeof Pipeline !== 'undefined' && Pipeline.bindToIncident) {
Pipeline.bindToIncident(id).catch(err => console.warn('pipeline-bind:', err));
}
// Quellenuebersicht aus Aggregat-Endpunkt (alle Quellen, nicht nur erste Seite)
this._loadSourcesSummary(id).catch(err => console.warn('sources-summary:', err));
// Wenn mehr Artikel existieren als initial geladen: progressiver Hintergrund-Load
if (articlesTotal > articles.length) {
this._loadRemainingArticlesInBackground(id).catch(err => console.warn('bg-articles:', err));
}
} catch (err) {
console.error('loadIncidentDetail Fehler:', err);
UI.showToast('Fehler beim Laden: ' + err.message, 'error');
}
},
/** Quellenuebersicht aus Aggregat-Endpunkt nachladen (ersetzt Client-Zaehlung). */
async _loadSourcesSummary(incidentId) {
const data = await API.getArticlesSourcesSummary(incidentId);
if (this.currentIncidentId !== incidentId) return; // User hat gewechselt
this._currentSourcesSummary = data;
const soEl = document.getElementById('source-overview-content');
const statsEl = document.getElementById('source-overview-header-stats');
if (soEl && typeof UI.renderSourceOverviewFromSummary === 'function') {
soEl.innerHTML = UI.renderSourceOverviewFromSummary(data);
}
if (statsEl && data) {
statsEl.textContent = `${data.total} Artikel aus ${data.sources.length} Quellen`;
}
},
/** Quellenuebersicht der Lage nach Quellentyp filtern (Web/Telegram/X). */
filterSourceOverview(type, chipEl) {
const content = document.getElementById('source-overview-content');
if (!content) return;
content.querySelectorAll('.source-type-filter-chip').forEach(c => c.classList.remove('active'));
if (chipEl) chipEl.classList.add('active');
// ein offenes Detail-Panel schliessen
const det = content.querySelector('.source-overview-detail');
if (det) det.remove();
content.querySelectorAll('.source-overview-item.active').forEach(it => {
it.classList.remove('active');
it.setAttribute('aria-expanded', 'false');
});
// Quellen-Boxen nach Typ ein-/ausblenden
content.querySelectorAll('.source-overview-item').forEach(it => {
const t = it.dataset.type || 'web';
it.style.display = (!type || t === type) ? '' : 'none';
});
},
/** Klick auf eine Quellen-Box: Liste der Artikel inline aufklappen (mutual-exclusive). */
toggleSourceOverviewDetail(el) {
if (!el) return;
const grid = el.parentElement;
if (!grid) return;
const sourceName = el.dataset.source || '';
const wasActive = el.classList.contains('active');
// Alle anderen schliessen + bestehendes Detail entfernen
grid.querySelectorAll('.source-overview-item.active').forEach(it => {
it.classList.remove('active');
it.setAttribute('aria-expanded', 'false');
});
const existingDetail = grid.querySelector('.source-overview-detail');
if (existingDetail) existingDetail.remove();
// Wenn das geklickte Item bereits aktiv war: nur schliessen
if (wasActive) return;
// Neues Detail einfuegen direkt nach dem geklickten Item
el.classList.add('active');
el.setAttribute('aria-expanded', 'true');
const type = this._currentIncidentType;
const getDate = (a) => (type === 'research' && a.published_at) ? a.published_at : (a.collected_at || a.published_at);
const articles = (this._currentArticles || [])
.filter(a => (a.source || 'Unbekannt') === sourceName)
.sort((a, b) => {
const ta = new Date(getDate(a) || 0).getTime();
const tb = new Date(getDate(b) || 0).getTime();
return tb - ta;
});
// Lagebild-Quellennummer pro Artikel ermitteln (matcht Artikel zu sources_json)
const normalize = (s) => (s || '').toLowerCase().replace(/^(der|die|das)\s+/, '').replace(/\s+/g, ' ').trim();
const sourcesList = this._currentSources || [];
const urlToNr = new Map();
sourcesList.forEach(s => {
if (s.url && s.nr != null) urlToNr.set(String(s.url).trim(), s.nr);
});
const findNr = (a) => {
// 1) Exakter URL-Match
if (a.source_url) {
const exact = urlToNr.get(String(a.source_url).trim());
if (exact != null) return exact;
}
// 2) Fallback: Match via Quellen-Namen (kann mehrfach treffen, nimm erstes)
if (a.source) {
const target = normalize(a.source);
const hit = sourcesList.find(s => s.nr != null && normalize(s.name) === target);
if (hit) return hit.nr;
}
return null;
};
const detail = document.createElement('div');
detail.className = 'source-overview-detail';
if (articles.length === 0) {
detail.innerHTML = '<div class="source-overview-detail-empty">Keine Artikel gefunden.</div>';
} else {
const fmtDate = (ts) => {
if (!ts) return '—';
try {
const d = new Date(ts);
if (isNaN(d.getTime())) return '—';
return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit', timeZone: TIMEZONE })
+ ' '
+ d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', timeZone: TIMEZONE });
} catch (e) { return '—'; }
};
const items = articles.map(a => {
const nr = findNr(a);
const numHtml = nr != null
? `<span class="source-overview-detail-num">[${UI.escape(String(nr))}]</span>`
: `<span class="source-overview-detail-num source-overview-detail-num--none" title="Nicht im Lagebild zitiert">—</span>`;
const dateStr = fmtDate(getDate(a));
const headline = UI.escape(a.headline_de || a.headline || '(ohne Titel)');
const inner = a.source_url
? `<a href="${UI.escape(a.source_url)}" target="_blank" rel="noopener">${headline}</a>`
: headline;
return `<li>
${numHtml}
<span class="source-overview-detail-date">${UI.escape(dateStr)}</span>
<span class="source-overview-detail-headline">${inner}</span>
</li>`;
}).join('');
detail.innerHTML = `<ul class="source-overview-detail-list">${items}</ul>`;
}
el.insertAdjacentElement('afterend', detail);
},
/** Restliche Artikel seitenweise im Hintergrund nachladen und in _currentArticles mergen. */
async _loadRemainingArticlesInBackground(incidentId) {
const BATCH = 500;
while (this.currentIncidentId === incidentId
&& this._currentArticlesLoaded < this._currentArticlesTotal) {
let resp;
try {
resp = await API.getArticles(incidentId, { limit: BATCH, offset: this._currentArticlesLoaded });
} catch (err) {
console.warn('Hintergrund-Load Artikel fehlgeschlagen:', err);
return;
}
if (this.currentIncidentId !== incidentId) return;
const batch = (resp && resp.articles) ? resp.articles : (Array.isArray(resp) ? resp : []);
if (!batch.length) break;
this._currentArticles = (this._currentArticles || []).concat(batch);
this._currentArticlesLoaded += batch.length;
this.rerenderTimeline();
// Kleiner Yield, damit das UI reaktiv bleibt
await new Promise(r => setTimeout(r, 30));
}
},
renderIncidentDetail(incident, articles, factchecks, snapshots, locations, categoryLabels) {
// Header Strip
{ const _e = document.getElementById('incident-title'); if (_e) _e.textContent = incident.title; }
{ const _e = document.getElementById('incident-description'); if (_e) _e.textContent = incident.description || ''; }
// Typ-Badge
const typeBadge = document.getElementById('incident-type-badge');
typeBadge.className = 'incident-type-badge ' + (incident.type === 'research' ? 'type-research' : 'type-adhoc');
typeBadge.textContent = incident.type === 'research' ? 'Analyse' : 'Live';
// KI-Weg-Kennzeichen. Wird nur angezeigt, wenn die Lage ausdruecklich
// auf einen Weg gestellt ist. Ohne Angabe gilt die Vorgabe der
// Organisation, dann bleibt die Kopfzeile bewusst unbeschriftet.
{ const _ai = document.getElementById('incident-ai-badge');
if (_ai) {
const _b = incident.ai_backend;
if (_b === 'bedrock') {
_ai.hidden = false;
_ai.className = 'incident-ai-badge ai-eu';
_ai.textContent = 'EU';
_ai.title = 'KI-Verarbeitung in der EU (AWS Bedrock, Frankfurt), Recherche ueber den europaeischen Suchindex staan';
} else if (_b === 'cli') {
_ai.hidden = false;
_ai.className = 'incident-ai-badge ai-anthropic';
_ai.textContent = 'Anthropic';
_ai.title = 'KI-Verarbeitung ueber Anthropic mit eingebauter Websuche (heutiger Weg)';
} else {
_ai.hidden = true;
}
} }
// Kachel-Label: 'Recherchebericht' fuer Recherche-Lagen, 'Lagebild' fuer Live-Monitoring
const _tI18n = (k, fb) => (typeof T === 'function') ? T(k, fb) : fb;
const _lbLabel = incident.type === 'research'
? _tI18n('tab.summary_report', 'Recherchebericht')
: _tI18n('card.summary', 'Lagebild');
const _cardTitle = document.querySelector('#panel-lagebild .card-title');
if (_cardTitle) _cardTitle.textContent = _lbLabel;
if (typeof LayoutManager !== 'undefined' && typeof LayoutManager.applyTypeLabels === 'function') {
LayoutManager.applyTypeLabels(incident.type);
}
{
const _nt = document.querySelector("#inc-notify-summary");
if (_nt) {
const _ns = _nt.closest("label")?.querySelector(".toggle-text");
if (_ns) {
_ns.textContent = incident.type === 'research'
? _tI18n('modal.notify.summary_research', 'Neuer Recherchebericht')
: _tI18n('modal.notify.summary', 'Neues Lagebild');
}
}
}
// Archiv-Button Text
this._updateArchiveButton(incident.status);
// Ersteller anzeigen
const creatorEl = document.getElementById('incident-creator');
if (creatorEl) {
creatorEl.textContent = (incident.created_by_username || '').split('@')[0];
}
// Delete-Button: nur Ersteller darf löschen
const deleteBtn = document.getElementById('delete-incident-btn');
const isCreator = incident.created_by_username === this._currentUsername;
deleteBtn.disabled = !isCreator;
deleteBtn.title = isCreator ? '' : `Nur ${(incident.created_by_username || '').split('@')[0]} kann diese Lage löschen`;
// Zusammenfassung-Kachel + Lagebild-Kachel aufteilen
const zusammenfassungText = document.getElementById('zusammenfassung-text');
const summaryText = document.getElementById('summary-text');
const zusammenfassungCard = document.getElementById('zusammenfassung-card');
const zusammenfassungTitle = zusammenfassungCard ? zusammenfassungCard.querySelector('.card-title') : null;
if (incident.type === 'research') {
// Recherche: ZUSAMMENFASSUNG-Sektion aus Briefing extrahieren
if (zusammenfassungTitle) zusammenfassungTitle.textContent = (typeof T === 'function') ? T('tab.summary_short', 'Zusammenfassung') : 'Zusammenfassung';
if (incident.summary) {
const { zusammenfassung, remaining } = UI.extractZusammenfassung(incident.summary);
if (zusammenfassung) {
if (zusammenfassungText) zusammenfassungText.innerHTML = UI.renderZusammenfassung(zusammenfassung, this._currentSources);
if (zusammenfassungCard) zusammenfassungCard.style.display = '';
summaryText.innerHTML = UI.renderSummary(remaining, this._currentSources, incident.type);
} else {
if (zusammenfassungText) zusammenfassungText.innerHTML = '<span style="color:var(--text-disabled);">Zusammenfassung wird beim n\u00e4chsten Refresh generiert.</span>';
if (zusammenfassungCard) zusammenfassungCard.style.display = '';
summaryText.innerHTML = UI.renderSummary(incident.summary, this._currentSources, incident.type);
}
} else {
if (zusammenfassungCard) zusammenfassungCard.style.display = 'none';
summaryText.innerHTML = '<span style="color:var(--text-disabled);">Noch keine Zusammenfassung. Klicke auf "Aktualisieren" um die Recherche zu starten.</span>';
}
} else {
// Live-Monitoring (adhoc): Kachel zeigt "Neueste Entwicklungen" (max 8 Bullets mit Zeitstempel)
if (zusammenfassungTitle) zusammenfassungTitle.textContent = (typeof T === 'function') ? T('tab.latest_developments', 'Neueste Entwicklungen') : 'Neueste Entwicklungen';
if (zusammenfassungCard) zusammenfassungCard.style.display = '';
const devText = (incident.latest_developments || '').trim();
if (devText) {
if (zusammenfassungText) zusammenfassungText.innerHTML = UI.renderLatestDevelopments(devText, this._currentSources);
} else if (zusammenfassungText) {
zusammenfassungText.innerHTML = '<span style="color:var(--text-disabled);">Noch keine Entwicklungen erfasst. Wird beim n\u00e4chsten Refresh generiert.</span>';
}
if (incident.summary) {
summaryText.innerHTML = UI.renderSummary(incident.summary, this._currentSources, incident.type);
} else {
summaryText.innerHTML = '<span style="color:var(--text-disabled);">Noch kein Lagebild. Klicke auf "Aktualisieren" um die Recherche zu starten.</span>';
}
}
// 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`
: '';
}
// Öffentliche Stimmung (Foren-Kachel): Tab + Inhalt nur einblenden,
// wenn fuer diese Lage tatsaechlich Stimmungs-Text vorhanden ist.
const stimmungTabBtn = document.getElementById('tab-btn-stimmung');
const stimmungText = document.getElementById('stimmung-text');
const stimmungTs = document.getElementById('stimmung-timestamp');
const moodText = (incident.public_mood || '').trim();
if (moodText && stimmungTabBtn) {
stimmungTabBtn.style.display = '';
if (stimmungText) stimmungText.innerHTML = UI.renderPublicMood(moodText);
if (stimmungTs && incident.public_mood_updated_at) {
const mUpd = parseUTC(incident.public_mood_updated_at);
if (mUpd) {
stimmungTs.textContent = `Stand: ${mUpd.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', timeZone: TIMEZONE })} ${mUpd.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', timeZone: TIMEZONE })} Uhr`;
}
}
} else if (stimmungTabBtn) {
stimmungTabBtn.style.display = 'none';
if (stimmungText) stimmungText.innerHTML = '';
}
{ const _e = document.getElementById('meta-refresh-mode'); if (_e) {
if (incident.refresh_mode === 'auto' && incident.refresh_start_time) {
const intervalText = App._formatInterval(incident.refresh_interval);
_e.textContent = `Auto alle ${intervalText} ab ${incident.refresh_start_time} Uhr`;
} else if (incident.refresh_mode === 'auto') {
_e.textContent = `Auto alle ${App._formatInterval(incident.refresh_interval)}`;
} else {
_e.textContent = 'Manuell';
}
} }
// International-Badge
const intlBadge = document.getElementById('intl-badge');
if (intlBadge) {
const isIntl = incident.international_sources !== false && incident.international_sources !== 0;
intlBadge.className = 'intl-badge ' + (isIntl ? 'intl-yes' : 'intl-no');
intlBadge.textContent = isIntl ? 'International' : 'Nur DE';
}
// Faktencheck
const fcFilters = document.getElementById('fc-filters');
const factcheckList = document.getElementById('factcheck-list');
if (factchecks.length > 0) {
fcFilters.innerHTML = UI.renderFactCheckFilters(factchecks);
factcheckList.innerHTML = factchecks.map(fc => UI.renderFactCheck(fc)).join('');
} else {
fcFilters.innerHTML = '';
factcheckList.innerHTML = '<div style="padding:12px;font-size:13px;color:var(--text-tertiary);">Noch keine Fakten geprüft</div>';
}
// Quellenuebersicht wird aus dem Aggregat-Endpunkt (_loadSourcesSummary) gefuellt,
// damit sie immer alle Artikel der Lage zeigt — unabhaengig von Paginierung.
const sourceOverview = document.getElementById('source-overview-content');
if (sourceOverview) {
sourceOverview.innerHTML = '<div style="padding:12px;font-size:13px;color:var(--text-tertiary);">Quellenübersicht wird geladen…</div>';
}
const _soStats = document.getElementById("source-overview-header-stats");
if (_soStats) {
const total = (this._currentArticlesTotal != null) ? this._currentArticlesTotal : articles.length;
_soStats.textContent = total + " Artikel";
}
// Timeline - Artikel + Snapshots zwischenspeichern und rendern
this._currentArticles = articles;
this._currentSnapshots = snapshots || [];
this._snapshotFullCache = new Map();
this._currentIncidentType = incident.type;
// Tab-Auswahl: gemerkt pro Lage (localStorage), Default = erster Tab
if (typeof LayoutManager !== 'undefined' && typeof LayoutManager.restoreTabFor === 'function') {
LayoutManager.restoreTabFor(incident.id);
}
this._timelineFilter = 'all';
this._timelineRange = 'all';
this._activeStripWindow = null;
const _tsEl = document.getElementById('timeline-search'); if (_tsEl) _tsEl.value = '';
document.querySelectorAll('.ht-filter-btn').forEach(btn => {
const isActive = btn.dataset.filter === 'all';
btn.classList.toggle('active', isActive);
btn.setAttribute('aria-pressed', String(isActive));
});
document.querySelectorAll('.ht-range-btn').forEach(btn => {
const isActive = btn.dataset.range === 'all';
btn.classList.toggle('active', isActive);
btn.setAttribute('aria-pressed', String(isActive));
});
this.rerenderTimeline();
this._resizeTimelineTile();
// Karte rendern
UI.renderMap(locations || [], categoryLabels);
},
_collectEntries(filterType, searchTerm, range) {
const type = this._currentIncidentType;
const getArticleDate = (a) => (type === 'research' && a.published_at) ? a.published_at : a.collected_at;
let entries = [];
if (filterType === 'all' || filterType === 'articles') {
let articles = this._currentArticles || [];
if (searchTerm) {
articles = articles.filter(a => {
const text = `${a.headline || ''} ${a.headline_de || ''} ${a.source || ''} ${a.content_de || ''} ${a.content_original || ''}`.toLowerCase();
return text.includes(searchTerm);
});
}
articles.forEach(a => entries.push({ kind: 'article', data: a, timestamp: getArticleDate(a) || '' }));
}
if (filterType === 'all' || filterType === 'snapshots') {
let snapshots = this._currentSnapshots || [];
if (searchTerm) {
// Suche erfolgt clientseitig ueber Preview (Snapshots-Liste enthaelt keinen Volltext mehr).
// Die asynchrone Volltext-Server-Suche wird separat ausgeloest (rerenderTimeline).
snapshots = snapshots.filter(s => (s.summary_preview || s.summary || '').toLowerCase().includes(searchTerm));
}
snapshots.forEach(s => entries.push({ kind: 'snapshot', data: s, timestamp: s.created_at || '' }));
}
if (range && range !== 'all') {
const now = Date.now();
const cutoff = range === '24h' ? now - 24 * 60 * 60 * 1000 : now - 7 * 24 * 60 * 60 * 1000;
entries = entries.filter(e => new Date(e.timestamp || 0).getTime() >= cutoff);
}
return entries;
},
_updateTimelineCount(entries) {
const articleCount = entries.filter(e => e.kind === 'article').length;
const snapshotCount = entries.filter(e => e.kind === 'snapshot').length;
const countEl = document.getElementById('article-count');
if (!countEl) return;
if (articleCount > 0 && snapshotCount > 0) {
countEl.innerHTML = `<span class="ht-legend-dot"></span> ${articleCount} Meldung${articleCount !== 1 ? 'en' : ''} + <span class="ht-legend-dot ht-legend-gold"></span> ${snapshotCount} Lagebericht${snapshotCount !== 1 ? 'e' : ''}`;
} else if (articleCount > 0) {
countEl.innerHTML = `<span class="ht-legend-dot"></span> ${articleCount} Meldung${articleCount !== 1 ? 'en' : ''}`;
} else if (snapshotCount > 0) {
countEl.innerHTML = `<span class="ht-legend-dot ht-legend-gold"></span> ${snapshotCount} Lagebericht${snapshotCount !== 1 ? 'e' : ''}`;
} else {
countEl.textContent = '0 Meldungen';
}
},
debouncedRerenderTimeline() {
clearTimeout(this._timelineSearchTimer);
this._timelineSearchTimer = setTimeout(() => this.rerenderTimeline(), 250);
},
/** Heatmap-Strip oben + vertikaler Newsfeed-Stream darunter.
* Klick auf Heatmap-Balken: Stream filtert auf das Zeitfenster (aktive Balken hervorgehoben).
*/
rerenderTimeline() {
const container = document.getElementById('timeline');
if (!container) return;
const searchTerm = (document.getElementById('timeline-search')?.value || '').toLowerCase();
const filterType = this._timelineFilter;
const range = this._timelineRange;
let entries = this._collectEntries(filterType, searchTerm, range);
this._updateTimelineCount(entries);
// Strip nutzt IMMER alle Eintraege im Range (unabhaengig von Filter/Search/Strip-Window)
const stripEntries = this._collectEntries('all', '', range);
stripEntries.sort((a, b) => new Date(a.timestamp || 0) - new Date(b.timestamp || 0));
// Wenn ein Heatmap-Balken aktiv ist: Stream zusaetzlich auf dieses Zeitfenster filtern
const win = this._activeStripWindow;
if (win && entries.length > 0) {
entries = entries.filter(e => {
const ts = new Date(e.timestamp || 0).getTime();
return ts >= win.start && ts < win.end;
});
}
let html = '<div class="ht-tl">';
if (stripEntries.length > 0) {
html += this._renderTimelineStrip(stripEntries);
}
// Banner mit aktivem Filter
if (win) {
html += `<div class="ht-strip-banner">
<span class="ht-strip-banner-icon" aria-hidden="true">▼</span>
<span class="ht-strip-banner-text">Gefiltert auf <strong>${UI.escape(win.label)}</strong> · ${entries.length} Eintr${entries.length === 1 ? 'ag' : 'äge'}</span>
<button class="ht-strip-banner-close" onclick="App.clearStripWindow()" aria-label="Filter aufheben">Filter aufheben</button>
</div>`;
}
html += '<div class="ht-stream">';
if (entries.length === 0) {
html += win
? '<div class="ht-empty">Keine Einträge in diesem Zeitfenster.</div>'
: (searchTerm || range !== 'all')
? '<div class="ht-empty">Keine Einträge im gewählten Zeitraum.</div>'
: '<div class="ht-empty">Noch keine Meldungen. Starte eine Recherche mit "Aktualisieren".</div>';
} else {
html += this._renderVerticalStream(entries);
}
html += '</div>';
html += '</div>';
container.innerHTML = html;
},
/** Granularitaets-Heuristik fuer den Newsfeed: Stunden bei kurzen Spannen, sonst Tage. */
_calcGranularity(entries) {
if (!entries || entries.length < 2) return 'day';
const ts = entries.map(e => new Date(e.timestamp || 0).getTime()).filter(t => t > 0);
if (ts.length < 2) return 'day';
const span = Math.max(...ts) - Math.min(...ts);
if (span <= 48 * 60 * 60 * 1000) return 'hour';
return 'day';
},
/** Vertikaler Stream: Datums-Trennzeilen + Lagebericht-Sektionen + Meldungen. */
_renderVerticalStream(entries) {
if (!entries || entries.length === 0) {
return '<div class="ht-empty">Keine Einträge.</div>';
}
// Neueste oben
const sorted = [...entries].sort((a, b) => new Date(b.timestamp || 0) - new Date(a.timestamp || 0));
const granularity = this._calcGranularity(sorted);
const groups = this._groupByTimePeriod(sorted, granularity);
let html = '<div class="vt-timeline">';
groups.forEach(g => {
const groupId = 'vt-grp-' + g.key.replace(/[^a-z0-9]/gi, '-');
html += `<div class="vt-time-group" id="${groupId}" data-time-key="${UI.escape(g.key)}">`;
html += `<div class="vt-time-label"><span class="vt-time-label-text">${UI.escape(g.label)}</span></div>`;
html += this._renderTimeGroupEntries(g.entries, this._currentIncidentType);
html += `</div>`;
});
html += '</div>';
return html;
},
/* ======= Quanti-Strip ======= */
_stripGranularity(stripEntries) {
if (stripEntries.length < 2) return 'day';
const ts = stripEntries.map(e => new Date(e.timestamp || 0).getTime()).filter(t => t > 0);
if (ts.length < 2) return 'day';
const span = Math.max(...ts) - Math.min(...ts);
const DAY = 86400000;
if (span <= 2 * DAY) return 'hour';
if (span <= 60 * DAY) return 'day';
if (span <= 365 * DAY) return 'week';
return 'month';
},
_buildStripBuckets(stripEntries, granularity) {
if (stripEntries.length === 0) return [];
const ts = stripEntries.map(e => new Date(e.timestamp || 0).getTime()).filter(t => t > 0);
if (ts.length === 0) return [];
const minTs = Math.min(...ts);
const maxTs = Math.max(...ts);
// Bucket-Start fuer minTs ermitteln
const minDate = new Date(minTs);
const tzMin = _tz(minDate);
let firstStart;
let stepMs;
if (granularity === 'hour') {
firstStart = new Date(tzMin.year, tzMin.month, tzMin.date, tzMin.hours).getTime();
stepMs = 3600000;
} else if (granularity === 'day') {
firstStart = new Date(tzMin.year, tzMin.month, tzMin.date).getTime();
stepMs = 86400000;
} else if (granularity === 'week') {
const dow = (minDate.getDay() + 6) % 7; // 0=Mo
firstStart = new Date(tzMin.year, tzMin.month, tzMin.date - dow).getTime();
stepMs = 7 * 86400000;
} else {
firstStart = new Date(tzMin.year, tzMin.month, 1).getTime();
stepMs = null; // dynamisch (Monatsgrenzen)
}
const buckets = [];
const fmt = (t) => {
const d = new Date(t);
if (granularity === 'hour') return d.toLocaleString('de-DE', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit', timeZone: TIMEZONE });
if (granularity === 'day') return d.toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: 'short', timeZone: TIMEZONE });
if (granularity === 'week') return 'Woche ab ' + d.toLocaleDateString('de-DE', { day: '2-digit', month: 'short', timeZone: TIMEZONE });
return d.toLocaleDateString('de-DE', { month: 'long', year: 'numeric', timeZone: TIMEZONE });
};
if (granularity === 'month') {
let d = new Date(firstStart);
while (d.getTime() <= maxTs && buckets.length < 240) {
const start = d.getTime();
const next = new Date(d.getFullYear(), d.getMonth() + 1, 1).getTime();
buckets.push({ start, end: next, label: fmt(start), articles: 0, snapshots: 0 });
d = new Date(next);
}
} else {
for (let t = firstStart; t <= maxTs && buckets.length < 240; t += stepMs) {
buckets.push({ start: t, end: t + stepMs, label: fmt(t), articles: 0, snapshots: 0 });
}
}
// Eintraege zaehlen
stripEntries.forEach(e => {
const ets = new Date(e.timestamp || 0).getTime();
// Linear-Suche, da Buckets sortiert; bei vielen Buckets ggf. Binary
for (let i = 0; i < buckets.length; i++) {
if (ets >= buckets[i].start && ets < buckets[i].end) {
if (e.kind === 'article') buckets[i].articles++;
else if (e.kind === 'snapshot') buckets[i].snapshots++;
break;
}
}
});
return buckets;
},
_renderTimelineStrip(stripEntries) {
const granularity = this._stripGranularity(stripEntries);
const buckets = this._buildStripBuckets(stripEntries, granularity);
if (buckets.length === 0) return '';
const maxCount = Math.max(1, ...buckets.map(b => b.articles));
const win = this._activeStripWindow;
let html = '<div class="ht-strip">';
html += '<div class="ht-strip-cells">';
buckets.forEach(b => {
const intensity = b.articles > 0 ? Math.min(1, b.articles / maxCount) : 0;
const cls = ['ht-strip-cell'];
if (b.snapshots > 0) cls.push('has-snapshot');
if (b.articles === 0 && b.snapshots === 0) cls.push('empty');
if (win && win.start === b.start && win.end === b.end) cls.push('active');
const tip = `${b.label}: ${b.articles} Meldung${b.articles === 1 ? '' : 'en'}` +
(b.snapshots > 0 ? ` + ${b.snapshots} Lagebericht${b.snapshots === 1 ? '' : 'e'}` : '');
// data-Attribute statt JSON-String im onclick-Inline (vermeidet Quote-Konflikte bei Labels mit Komma/Anführungszeichen)
html += `<div class="${cls.join(' ')}" style="--intensity:${intensity.toFixed(3)};" title="${UI.escape(tip)}" data-start="${b.start}" data-end="${b.end}" data-label="${UI.escape(b.label || '')}" onclick="App.handleStripClick(this)"></div>`;
});
html += '</div>';
// Wenige Datums-Labels unter dem Strip
const labelCount = Math.min(buckets.length, 6);
const stride = Math.max(1, Math.floor(buckets.length / labelCount));
const labelTexts = [];
for (let i = 0; i < buckets.length; i += stride) {
const b = buckets[i];
const d = new Date(b.start);
let txt;
if (granularity === 'hour') txt = d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', timeZone: TIMEZONE });
else if (granularity === 'day') txt = d.toLocaleDateString('de-DE', { day: '2-digit', month: 'short', timeZone: TIMEZONE });
else if (granularity === 'week') txt = 'KW ' + d.toLocaleDateString('de-DE', { day: '2-digit', month: 'short', timeZone: TIMEZONE });
else txt = d.toLocaleDateString('de-DE', { month: 'short', year: '2-digit', timeZone: TIMEZONE });
labelTexts.push({ text: txt, idx: i });
}
if (labelTexts.length) {
html += '<div class="ht-strip-labels" style="grid-template-columns: repeat(' + buckets.length + ', 1fr);">';
const seen = new Set(labelTexts.map(l => l.idx));
for (let i = 0; i < buckets.length; i++) {
if (seen.has(i)) {
const t = labelTexts.find(l => l.idx === i).text;
html += `<div class="ht-strip-label">${UI.escape(t)}</div>`;
} else {
html += '<div></div>';
}
}
html += '</div>';
}
html += '</div>';
return html;
},
setTimelineFilter(filter) {
this._timelineFilter = filter;
this._activeStripWindow = null;
document.querySelectorAll('.ht-filter-btn').forEach(btn => {
const isActive = btn.dataset.filter === filter;
btn.classList.toggle('active', isActive);
btn.setAttribute('aria-pressed', String(isActive));
});
this.rerenderTimeline();
},
setTimelineRange(range) {
this._timelineRange = range;
this._activeStripWindow = null;
document.querySelectorAll('.ht-range-btn').forEach(btn => {
const isActive = btn.dataset.range === range;
btn.classList.toggle('active', isActive);
btn.setAttribute('aria-pressed', String(isActive));
});
this.rerenderTimeline();
},
/** Robuster Click-Handler fuer Heatmap-Cells (vermeidet Quote-Konflikte). */
handleStripClick(el) {
if (!el) return;
const start = parseInt(el.dataset.start, 10);
const end = parseInt(el.dataset.end, 10);
const label = el.dataset.label || '';
if (!isNaN(start) && !isNaN(end)) {
this.openTimelineWindow(start, end, label);
}
},
/** Klick auf Heatmap-Balken: Stream auf dieses Zeitfenster filtern.
* Zweiter Klick auf denselben Balken hebt den Filter auf.
*/
openTimelineWindow(startMs, endMs, label) {
const win = this._activeStripWindow;
if (win && win.start === startMs && win.end === endMs) {
this._activeStripWindow = null;
} else {
this._activeStripWindow = { start: startMs, end: endMs, label: label || '' };
}
this.rerenderTimeline();
},
/** Strip-Filter aufheben (z.B. via Banner-Button). */
clearStripWindow() {
this._activeStripWindow = null;
this.rerenderTimeline();
},
_resizeTimelineTile() {
// Tab-Modus: Kein internes Resize noetig, Panel waechst mit Inhalt.
// Wir scrollen lediglich ein offenes Detail in den sichtbaren Bereich.
requestAnimationFrame(() => { requestAnimationFrame(() => {
const card = document.querySelector('.timeline-card');
if (!card) return;
const cardBottom = card.getBoundingClientRect().bottom;
const viewBottom = window.innerHeight;
if (cardBottom > viewBottom) {
window.scrollBy({ top: cardBottom - viewBottom + 16, behavior: document.documentElement.dataset.a11yMotion ? 'instant' : 'smooth' });
}
}); });
},
_buildFullVerticalTimeline(filterType, searchTerm) {
let entries = this._collectEntries(filterType, searchTerm);
if (entries.length === 0) {
return '<div class="ht-empty">Keine Einträge.</div>';
}
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 = '<div class="vt-timeline">';
groups.forEach(g => {
html += `<div class="vt-time-group">`;
html += `<div class="vt-time-label"><span class="vt-time-label-text">${UI.escape(g.label)}</span></div>`;
html += this._renderTimeGroupEntries(g.entries, this._currentIncidentType);
html += `</div>`;
});
html += '</div>';
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
? `<a href="${UI.escape(article.source_url)}" target="_blank" rel="noopener" onclick="event.stopPropagation()">${UI.escape(article.source)}</a>`
: UI.escape(article.source);
const langBadge = article.language && article.language !== 'de'
? `<span class="lang-badge">${article.language.toUpperCase()}</span>` : '';
const clusterBadge = clusterCount > 0
? `<span class="vt-cluster-count">${clusterCount}</span>` : '';
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 = `<div class="vt-article-detail">
<div class="vt-article-detail-content">${UI.escape(truncated)}</div>
${article.source_url ? `<a href="${UI.escape(article.source_url)}" target="_blank" rel="noopener" class="vt-article-detail-link" onclick="event.stopPropagation()">Artikel öffnen &rarr;</a>` : ''}
</div>`;
}
return `<div class="vt-entry ${hasContent ? 'expandable' : ''}" ${hasContent ? 'onclick="App.toggleTimelineEntry(this)"' : ''}>
<div class="vt-article-header">
<span class="vt-article-time">${time}</span>
<span class="vt-article-source">${sourceUrl}</span>
${langBadge}${clusterBadge}
</div>
<div class="vt-article-headline">${UI.escape(headline)}</div>
${detailHtml}
</div>`;
},
/**
* Snapshot/Lagebericht-Eintrag für den Zeitstrahl rendern.
* Volltext + sources_json werden erst beim Aufklappen lazy nachgeladen.
*/
_renderSnapshotEntry(snapshot) {
const time = snapshot.created_at
? (parseUTC(snapshot.created_at) || new Date(snapshot.created_at)).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', timeZone: TIMEZONE })
: '--:--';
const stats = [];
if (snapshot.article_count) stats.push(`${snapshot.article_count} Artikel`);
if (snapshot.fact_check_count) stats.push(`${snapshot.fact_check_count} Fakten`);
const statsText = stats.join(', ');
// Vorschau: erste 200 Zeichen aus summary_preview (vom Server gekuerzt) oder Fallback summary
const previewText = snapshot.summary_preview || snapshot.summary || '';
const preview = previewText.length > 200 ? previewText.substring(0, 200) + '...' : previewText;
// Volltext aus Cache (falls bereits geladen), sonst Platzhalter fuer Lazy-Load
const cached = this._snapshotFullCache && this._snapshotFullCache.get(snapshot.id);
const detailHtml = cached
? UI.renderSummary(cached.summary, cached.sources_json, this._currentIncidentType)
: '<div class="vt-snapshot-loading">Lagebericht wird geladen…</div>';
const loadedAttr = cached ? ' data-loaded="yes"' : '';
return `<div class="vt-entry vt-snapshot expandable" data-snapshot-id="${snapshot.id}"${loadedAttr} onclick="App.toggleTimelineEntry(this)">
<div class="vt-snapshot-header">
<span class="vt-snapshot-badge">Lagebericht</span>
<span class="vt-snapshot-time">${time}</span>
<span class="vt-snapshot-stats">${UI.escape(statsText)}</span>
</div>
<div class="vt-snapshot-preview">${UI.escape(preview)}</div>
<div class="vt-snapshot-detail">${detailHtml}</div>
</div>`;
},
/**
* Volltext eines Snapshots bei Bedarf nachladen und in das DOM einsetzen.
* Ergebnis wird in _snapshotFullCache gecacht.
*/
async lazyLoadSnapshotDetail(el) {
if (!el || el.dataset.loaded === 'yes' || el.dataset.loaded === 'loading') return;
const snapId = parseInt(el.dataset.snapshotId || '0', 10);
if (!snapId || !this.currentIncidentId) return;
el.dataset.loaded = 'loading';
try {
let snap = this._snapshotFullCache.get(snapId);
if (!snap) {
snap = await API.getSnapshot(this.currentIncidentId, snapId);
this._snapshotFullCache.set(snapId, snap);
}
const detailEl = el.querySelector('.vt-snapshot-detail');
if (detailEl) {
detailEl.innerHTML = UI.renderSummary(snap.summary, snap.sources_json, this._currentIncidentType);
}
el.dataset.loaded = 'yes';
// Nach dem Laden die Timeline-Kachel an neue Hoehe anpassen
if (el.classList.contains('expanded')) this._resizeTimelineTile();
} catch (err) {
console.error('Snapshot-Volltext laden fehlgeschlagen:', err);
el.dataset.loaded = '';
const detailEl = el.querySelector('.vt-snapshot-detail');
if (detailEl) detailEl.innerHTML = '<div class="vt-snapshot-error">Fehler beim Laden des Lageberichts.</div>';
}
},
/**
* Timeline-Eintrag auf-/zuklappen (mutual-exclusive pro Zeitgruppe).
*/
toggleTimelineEntry(el) {
const container = el.closest('.ht-detail-content') || el.closest('.vt-time-group');
if (container) {
container.querySelectorAll('.vt-entry.expanded').forEach(item => {
if (item !== el) item.classList.remove('expanded');
});
}
el.classList.toggle('expanded');
if (el.classList.contains('expanded')) {
// Snapshots: Volltext lazy nachladen (nur wenn noch nicht geladen)
if (el.classList.contains('vt-snapshot') && el.dataset.snapshotId) {
this.lazyLoadSnapshotDetail(el);
}
requestAnimationFrame(() => {
var scrollParent = el.closest('.ht-detail-content');
if (scrollParent && el.classList.contains('vt-snapshot')) {
scrollParent.scrollTo({ top: 0, behavior: document.documentElement.dataset.a11yMotion ? 'instant' : 'smooth' });
} else {
el.scrollIntoView({ behavior: document.documentElement.dataset.a11yMotion ? 'instant' : 'smooth', block: 'nearest' });
}
});
}
// Timeline-Kachel an Inhalt anpassen
this._resizeTimelineTile();
},
/**
* Minutenschlüssel für Cluster-Erkennung.
*/
_getMinuteKey(timestamp) {
if (!timestamp) return 'none';
const d = new Date(timestamp);
const p = _tz(d);
return `${p.year}-${p.month}-${p.date}-${p.hours}-${p.minutes}`;
},
// === Event Handlers ===
_getFormData() {
const value = parseInt(document.getElementById('inc-refresh-value').value) || 30;
const unit = parseInt(document.getElementById('inc-refresh-unit').value) || 1;
const interval = Math.max(_getMinIntervalMinutes(), Math.min(10080, value * unit));
return {
title: document.getElementById('inc-title').value.trim(),
description: document.getElementById('inc-description').value.trim() || null,
type: document.getElementById('inc-type').value,
refresh_mode: document.getElementById('inc-refresh-mode').value,
refresh_interval: interval,
refresh_start_time: document.getElementById('inc-refresh-mode').value === 'auto'
? document.getElementById('inc-refresh-starttime').value || null
: null,
retention_days: parseInt(document.getElementById('inc-retention').value) || 0,
international_sources: document.getElementById('inc-international').checked,
include_telegram: document.getElementById('inc-telegram').checked,
include_x: document.getElementById('inc-x').checked,
visibility: document.getElementById('inc-visibility').checked ? 'public' : 'private',
ai_backend: (document.getElementById('inc-ai-backend') || {}).value || '',
};
},
_clearFormErrors(formEl) {
formEl.querySelectorAll('.form-error').forEach(el => el.remove());
formEl.querySelectorAll('[aria-invalid]').forEach(el => {
el.removeAttribute('aria-invalid');
el.removeAttribute('aria-describedby');
});
},
_showFieldError(field, message) {
field.setAttribute('aria-invalid', 'true');
const errorId = field.id + '-error';
field.setAttribute('aria-describedby', errorId);
const errorEl = document.createElement('div');
errorEl.className = 'form-error';
errorEl.id = errorId;
errorEl.setAttribute('role', 'alert');
errorEl.textContent = message;
field.parentNode.appendChild(errorEl);
},
async handleFormSubmit(e) {
e.preventDefault();
const submitBtn = document.getElementById('modal-new-submit');
const form = document.getElementById('new-incident-form');
this._clearFormErrors(form);
// Validierung
const titleField = document.getElementById('inc-title');
if (!titleField.value.trim()) {
this._showFieldError(titleField, 'Bitte einen Titel eingeben.');
titleField.focus();
return;
}
submitBtn.disabled = true;
try {
const data = this._getFormData();
if (this._editingIncidentId) {
// Edit-Modus: ID sichern bevor closeModal sie löscht
const editId = this._editingIncidentId;
await API.updateIncident(editId, data);
// E-Mail-Subscription speichern
await API.updateSubscription(editId, {
notify_email_summary: document.getElementById('inc-notify-summary').checked,
notify_email_new_articles: document.getElementById('inc-notify-new-articles').checked,
notify_email_status_change: document.getElementById('inc-notify-status-change').checked,
});
closeModal('modal-new');
await this.loadIncidents();
await this.loadIncidentDetail(editId);
UI.showToast((typeof T === 'function' ? T('toast.incident_updated', 'Lage aktualisiert.') : 'Lage aktualisiert.'), 'success');
} else {
// Create-Modus
const incident = await API.createIncident(data);
// E-Mail-Subscription speichern
await API.updateSubscription(incident.id, {
notify_email_summary: document.getElementById('inc-notify-summary').checked,
notify_email_new_articles: document.getElementById('inc-notify-new-articles').checked,
notify_email_status_change: document.getElementById('inc-notify-status-change').checked,
});
closeModal('modal-new');
await this.loadIncidents();
// Refresh-Status VOR selectIncident setzen, damit selectIncident
// beim Oeffnen sofort Blur + Aktions-Lock setzt (statt sie erst
// per WebSocket-Nachricht spaeter wieder zu aktivieren — dazwischen
// war der Fallinhalt kurzzeitig unblurred und klickbar).
this._refreshingIncidents.add(incident.id);
UI._progressState[incident.id] = {
step: 'queued', isFirst: true, startTime: null, minimized: false,
};
await this.selectIncident(incident.id);
this._updateRefreshButton(true);
await API.refreshIncident(incident.id);
UI.showToast(`Lage "${incident.title}" angelegt. Recherche gestartet.`, 'success');
}
} catch (err) {
UI.showToast('Fehler: ' + err.message, 'error');
} finally {
submitBtn.disabled = false;
this._editingIncidentId = null;
}
},
async generateDescription() {
const title = document.getElementById('inc-title').value.trim();
const description = document.getElementById('inc-description').value.trim();
const type = document.getElementById('inc-type').value;
const btn = document.getElementById('btn-enhance-description');
const btnText = document.getElementById('enhance-btn-text');
const spinner = document.getElementById('enhance-spinner');
const textarea = document.getElementById('inc-description');
if (title.length < 3 || !btn) return;
// Vorherigen Request abbrechen falls noch aktiv
if (this._enhanceController) this._enhanceController.abort();
this._enhanceController = new AbortController();
btn.disabled = true;
btnText.textContent = (typeof T === 'function') ? T('modal.new_incident.enhance_loading', 'Wird generiert...') : 'Wird generiert...';
spinner.style.display = '';
textarea.readOnly = true;
textarea.classList.add('textarea--loading');
try {
const result = await API.enhanceDescription(title, description || null, type, this._enhanceController.signal);
textarea.value = result.description;
_autoResizeTextarea(textarea);
} catch (err) {
if (err.name === 'AbortError') {
// still
} else {
let msg = (typeof T === 'function') ? T('enhance.error_default', 'Beschreibung konnte nicht generiert werden') : 'Beschreibung konnte nicht generiert werden';
if (err.status === 503) msg = (typeof T === 'function') ? T('enhance.error_unavailable', 'KI-Zugang aktuell nicht verfügbar. Bitte Administrator kontaktieren.') : 'KI-Zugang aktuell nicht verfügbar. Bitte Administrator kontaktieren.';
else if (err.status === 429) msg = (typeof T === 'function') ? T('enhance.error_busy', 'KI ist gerade ausgelastet. Bitte kurz warten und erneut versuchen.') : 'KI ist gerade ausgelastet. Bitte kurz warten und erneut versuchen.';
else if (err.status === 504) msg = (typeof T === 'function') ? T('enhance.error_timeout', 'KI antwortet gerade nicht. Bitte erneut versuchen.') : 'KI antwortet gerade nicht. Bitte erneut versuchen.';
else if (err.status === 403) msg = err.detail || 'Zugriff verweigert.';
UI.showToast(msg, 'error');
}
} finally {
btnText.textContent = (typeof T === 'function') ? T('modal.new_incident.enhance', 'Beschreibung generieren') : 'Beschreibung generieren';
spinner.style.display = 'none';
btn.disabled = title.length < 3;
textarea.readOnly = false;
textarea.classList.remove('textarea--loading');
this._enhanceController = null;
}
},
async handleRefresh() {
if (!this.currentIncidentId) return;
if (this._refreshingIncidents.has(this.currentIncidentId)) {
UI.showToast('Aktualisierung wurde bereits gestartet und ist in Bearbeitung.', 'info');
return;
}
try {
this._refreshingIncidents.add(this.currentIncidentId);
this._updateRefreshButton(true);
// showProgress called via handleStatusUpdate
const result = await API.refreshIncident(this.currentIncidentId);
// Pipeline auf "pending" setzen, damit alte gruene Haekchen nicht
// faelschlich "schon fertig" suggerieren waehrend die Lage in der Queue steht
if (typeof Pipeline !== 'undefined' && Pipeline.beginQueue) {
Pipeline.beginQueue(this.currentIncidentId);
}
if (result && result.status === 'skipped') {
UI.showToast('Aktualisierung ist in der Warteschlange und wird ausgefuehrt, sobald die aktuelle Recherche abgeschlossen ist.', 'info');
} else {
UI.showToast((typeof T === 'function' ? T('toast.refresh_started', 'Aktualisierung gestartet.') : 'Aktualisierung gestartet.'), 'success');
var _inc2 = this.incidents.find(function(i) { return i.id === this.currentIncidentId; }.bind(this));
UI.showProgress('queued', {}, this.currentIncidentId, _inc2 && !_inc2.has_summary);
}
} catch (err) {
this._refreshingIncidents.delete(this.currentIncidentId);
this._updateRefreshButton(false);
UI.hideProgress();
UI.showToast('Fehler: ' + err.message, 'error');
}
},
_geoparsePolling: null,
async triggerGeoparse() {
if (!this.currentIncidentId) return;
const btn = document.getElementById('geoparse-btn');
if (btn) { btn.disabled = true; btn.textContent = (typeof T === 'function' ? T('action.starting', 'Wird gestartet...') : 'Wird gestartet...'); }
try {
const result = await API.triggerGeoparse(this.currentIncidentId);
if (result.status === 'done') {
UI.showToast(result.message, 'info');
if (btn) { btn.disabled = false; btn.textContent = 'Orte erkennen'; }
return;
}
UI.showToast(result.message, 'info');
this._pollGeoparse(this.currentIncidentId);
} catch (err) {
UI.showToast('Geoparsing fehlgeschlagen: ' + err.message, 'error');
if (btn) { btn.disabled = false; btn.textContent = 'Orte erkennen'; }
}
},
_pollGeoparse(incidentId) {
if (this._geoparsePolling) clearInterval(this._geoparsePolling);
const btn = document.getElementById('geoparse-btn');
this._geoparsePolling = setInterval(async () => {
try {
const st = await API.getGeoparseStatus(incidentId);
if (st.status === 'running') {
if (btn) btn.textContent = `${st.processed}/${st.total} Artikel...`;
} else {
clearInterval(this._geoparsePolling);
this._geoparsePolling = null;
if (btn) { btn.disabled = false; btn.textContent = 'Orte erkennen'; }
if (st.status === 'done' && st.locations > 0) {
UI.showToast(`${st.locations} Orte aus ${st.processed} Artikeln erkannt`, 'success');
const locResp = await API.getLocations(incidentId).catch(() => []);
let locs, catLabels;
if (Array.isArray(locResp)) { locs = locResp; catLabels = null; }
else if (locResp && locResp.locations) { locs = locResp.locations; catLabels = locResp.category_labels || null; }
else { locs = []; catLabels = null; }
UI.renderMap(locs, catLabels);
} else if (st.status === 'done') {
UI.showToast('Keine neuen Orte gefunden', 'info');
} else if (st.status === 'error') {
UI.showToast('Geoparsing fehlgeschlagen: ' + (st.error || ''), 'error');
}
}
} catch {
clearInterval(this._geoparsePolling);
this._geoparsePolling = null;
if (btn) { btn.disabled = false; btn.textContent = 'Orte erkennen'; }
}
}, 3000);
},
_formatInterval(minutes) {
if (minutes >= 10080 && minutes % 10080 === 0) {
const w = minutes / 10080;
return w === 1 ? '1 Woche' : `${w} Wochen`;
}
if (minutes >= 1440 && minutes % 1440 === 0) {
const d = minutes / 1440;
return d === 1 ? '1 Tag' : `${d} Tage`;
}
if (minutes >= 60 && minutes % 60 === 0) {
const h = minutes / 60;
return h === 1 ? '1 Stunde' : `${h} Stunden`;
}
return `${minutes} Min.`;
},
_setIntervalFields(minutes) {
let value, unit;
if (minutes >= 10080 && minutes % 10080 === 0) {
value = minutes / 10080; unit = '10080';
} else if (minutes >= 1440 && minutes % 1440 === 0) {
value = minutes / 1440; unit = '1440';
} else if (minutes >= 60 && minutes % 60 === 0) {
value = minutes / 60; unit = '60';
} else {
value = minutes; unit = '1';
}
const input = document.getElementById('inc-refresh-value');
input.value = value;
input.min = unit === '1' ? 10 : 1;
{ const _e = document.getElementById('inc-refresh-unit'); if (_e) _e.value = unit; }
},
_refreshHistoryOpen: false,
toggleRefreshHistory() {
if (this._refreshHistoryOpen) {
this.closeRefreshHistory();
} else {
this._openRefreshHistory();
}
},
async _openRefreshHistory() {
if (!this.currentIncidentId) return;
const popover = document.getElementById('refresh-history-popover');
if (!popover) return;
this._refreshHistoryOpen = true;
popover.style.display = 'flex';
// Lade Refresh-Log
const list = document.getElementById('refresh-history-list');
list.innerHTML = '<div style="padding:12px;color:var(--text-disabled);font-size:12px;">Lade...</div>';
try {
const logs = await API.getRefreshLog(this.currentIncidentId, 20);
this._renderRefreshHistory(logs);
} catch (e) {
list.innerHTML = '<div style="padding:12px;color:var(--error);font-size:12px;">Fehler beim Laden</div>';
}
// 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 = '<div style="padding:12px;color:var(--text-disabled);font-size:12px;">Noch keine Refreshes durchgeführt</div>';
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
? `<div class="rh-info-error" title="${log.error_message.replace(/"/g, '&quot;')}">${log.error_message}</div>`
: '';
return `<div class="refresh-history-entry">
<div class="rh-status-dot ${log.status}"></div>
<div class="rh-info">
<div class="rh-info-time">${timeStr}${retryInfo}</div>
${detail ? `<div class="rh-info-detail">${detail}</div>` : ''}
${errorHtml}
</div>
<span class="rh-trigger-badge ${log.trigger_type}">${log.trigger_type === 'auto' ? 'Auto' : 'Manuell'}</span>
</div>`;
}).join('');
},
_formatDuration(seconds) {
if (seconds == null) return '';
if (seconds < 60) return `${Math.round(seconds)}s`;
const m = Math.floor(seconds / 60);
const s = Math.round(seconds % 60);
return s > 0 ? `${m}m ${s}s` : `${m}m`;
},
_timeAgo(date) {
if (!date) return '';
const now = new Date();
const diff = Math.floor((now - date) / 1000);
if (diff < 60) return 'gerade eben';
if (diff < 3600) return `vor ${Math.floor(diff / 60)}m`;
if (diff < 86400) return `vor ${Math.floor(diff / 3600)}h`;
return `vor ${Math.floor(diff / 86400)}d`;
},
_updateRefreshButton(disabled) {
const btn = document.getElementById('refresh-btn');
if (!btn) return;
const _t = (k, fb) => (typeof T === 'function') ? T(k, fb) : fb;
// Hard-Stop: Lese-Modus (Budget aufgebraucht / Lizenz abgelaufen) -> immer disabled
if (this.user && this.user.read_only) {
btn.disabled = true;
const reason = this.user.read_only_reason;
btn.textContent = reason === 'budget_exceeded'
? _t('action.budget_exceeded', 'Budget aufgebraucht')
: _t('action.read_only', 'Nur Lesezugriff');
btn.title = reason === 'budget_exceeded'
? _t('action.budget_exceeded_title', 'Token-Budget aufgebraucht. Bitte Verwaltung kontaktieren.')
: _t('action.read_only_title', 'Lizenz erlaubt keinen Schreibzugriff');
return;
}
btn.disabled = disabled;
btn.textContent = disabled
? _t('action.refreshing', 'Läuft...')
: _t('action.refresh', 'Aktualisieren');
btn.title = '';
},
async handleDelete() {
if (!this.currentIncidentId) return;
if (!await confirmDialog('Lage wirklich löschen? Alle gesammelten Daten gehen verloren.')) return;
try {
await API.deleteIncident(this.currentIncidentId);
this.currentIncidentId = null;
if (typeof LayoutManager !== 'undefined') LayoutManager.destroy();
document.getElementById('incident-view').style.display = 'none';
document.getElementById('empty-state').style.display = 'flex';
await this.loadIncidents();
UI.showToast((typeof T === 'function' ? T('toast.incident_deleted', 'Lage gelöscht.') : 'Lage gelöscht.'), 'success');
} catch (err) {
UI.showToast('Fehler: ' + err.message, 'error');
}
},
async handleEdit() {
if (!this.currentIncidentId) return;
const incident = this.incidents.find(i => i.id === this.currentIncidentId);
if (!incident) return;
this._editingIncidentId = this.currentIncidentId;
// Formular mit aktuellen Werten füllen
{ const _e = document.getElementById('inc-title'); if (_e) _e.value = incident.title; }
{ const _e = document.getElementById('inc-description'); if (_e) { _e.value = incident.description || ''; _autoResizeTextarea(_e); } }
{ const _b = document.getElementById('btn-enhance-description'); if (_b) _b.disabled = (incident.title || '').trim().length < 3; }
{ const _e = document.getElementById('inc-type'); if (_e) _e.value = incident.type || 'adhoc'; }
{ const _e = document.getElementById('inc-refresh-mode'); if (_e) _e.value = incident.refresh_mode; }
App._setIntervalFields(incident.refresh_interval);
{ const _e = document.getElementById('inc-refresh-starttime'); if (_e) _e.value = incident.refresh_start_time || '07:00'; }
{ const _e = document.getElementById('inc-retention'); if (_e) _e.value = incident.retention_days; }
{ const _e = document.getElementById('inc-international'); if (_e) _e.checked = incident.international_sources !== false && incident.international_sources !== 0; }
{ const _e = document.getElementById('inc-telegram'); if (_e) _e.checked = !!incident.include_telegram; }
{ const _e = document.getElementById('inc-x'); if (_e) _e.checked = !!incident.include_x; }
{ const _e = document.getElementById('inc-visibility'); if (_e) _e.checked = incident.visibility !== 'private'; }
{ const _e = document.getElementById('inc-ai-backend'); if (_e) _e.value = incident.ai_backend || ''; }
updateVisibilityHint();
updateSourcesHint();
toggleTypeDefaults(true);
toggleRefreshInterval();
updateIntervalMin();
// Modal-Titel und Submit ändern
{ const _e = document.getElementById('modal-new-title'); if (_e) _e.textContent = (typeof T === 'function') ? T('modal.new_incident.edit_title', 'Lage bearbeiten') : 'Lage bearbeiten'; }
{ const _e = document.getElementById('modal-new-submit'); if (_e) _e.textContent = (typeof T === 'function') ? T('common.save', 'Speichern') : 'Speichern'; }
// E-Mail-Subscription laden
try {
const sub = await API.getSubscription(this.currentIncidentId);
{ const _e = document.getElementById('inc-notify-summary'); if (_e) _e.checked = !!sub.notify_email_summary; }
{ const _e = document.getElementById('inc-notify-new-articles'); if (_e) _e.checked = !!sub.notify_email_new_articles; }
{ const _e = document.getElementById('inc-notify-status-change'); if (_e) _e.checked = !!sub.notify_email_status_change; }
} catch (e) {
{ const _e = document.getElementById('inc-notify-summary'); if (_e) _e.checked = false; }
{ const _e = document.getElementById('inc-notify-new-articles'); if (_e) _e.checked = false; }
{ const _e = document.getElementById('inc-notify-status-change'); if (_e) _e.checked = false; }
}
openModal('modal-new');
},
async handleArchive() {
if (!this.currentIncidentId) return;
const incident = this.incidents.find(i => i.id === this.currentIncidentId);
if (!incident) return;
const isArchived = incident.status === 'archived';
const action = isArchived ? 'wiederherstellen' : 'archivieren';
if (!await confirmDialog(`Lage wirklich ${action}?`)) return;
try {
const newStatus = isArchived ? 'active' : 'archived';
await API.updateIncident(this.currentIncidentId, { status: newStatus });
await this.loadIncidents();
await this.loadIncidentDetail(this.currentIncidentId);
this._updateArchiveButton(newStatus);
UI.showToast(isArchived ? (typeof T === 'function' ? T('toast.incident_restored', 'Lage wiederhergestellt.') : 'Lage wiederhergestellt.') : (typeof T === 'function' ? T('toast.incident_archived', 'Lage archiviert.') : 'Lage archiviert.'), 'success');
} catch (err) {
UI.showToast('Fehler: ' + err.message, 'error');
}
},
_updateSidebarDot(incidentId, mode) {
const dot = document.getElementById(`dot-${incidentId}`);
if (!dot) return;
const incident = this.incidents.find(i => i.id === incidentId);
const baseClass = incident ? (incident.status === 'active' ? 'active' : 'archived') : 'active';
if (mode === 'error') {
dot.className = `incident-dot refresh-error`;
setTimeout(() => {
dot.className = `incident-dot ${baseClass}`;
}, 3000);
} else if (this._refreshingIncidents.has(incidentId)) {
dot.className = `incident-dot refreshing`;
} else {
dot.className = `incident-dot ${baseClass}`;
}
},
_updateArchiveButton(status) {
const btn = document.getElementById('archive-incident-btn');
if (!btn) return;
const _t = (k, fb) => (typeof T === 'function') ? T(k, fb) : fb;
btn.textContent = status === 'archived'
? _t('action.restore', 'Wiederherstellen')
: _t('action.archive', 'Archivieren');
},
// === WebSocket Handlers ===
handleStatusUpdate(msg) {
const status = msg.data.status;
if (status === 'retrying') {
if (msg.incident_id === this.currentIncidentId) {
UI.showProgressError('', true, msg.data.delay || 120, msg.incident_id);
}
return;
}
if (status !== 'idle') {
this._refreshingIncidents.add(msg.incident_id);
}
this._updateSidebarDot(msg.incident_id);
// Detect first refresh: no summary means first run
const inc = this.incidents.find(i => i.id === msg.incident_id);
const isFirst = inc && !inc.has_summary;
// Update progress state for ALL incidents (sidebar + popup if current)
UI.showProgress(status, msg.data, msg.incident_id, isFirst);
// Re-render sidebar so status is baked into HTML (survives future re-renders)
this.renderSidebar();
if (msg.incident_id === this.currentIncidentId) {
this._updateRefreshButton(status !== 'idle');
}
},
async handleRefreshComplete(msg) {
this._refreshingIncidents.delete(msg.incident_id);
this._updateSidebarDot(msg.incident_id);
UI._removeSidebarRefreshStatus(msg.incident_id);
delete UI._progressState[msg.incident_id];
UI._reindexQueuePositions();
this.renderSidebar();
if (msg.incident_id === this.currentIncidentId) {
this._updateRefreshButton(false);
await this.loadIncidentDetail(msg.incident_id);
// Progress-Popup nicht sofort ausblenden — auf refresh_summary warten
this._pendingComplete = msg.incident_id;
if (this._pendingCompleteTimer) clearTimeout(this._pendingCompleteTimer);
this._pendingCompleteTimer = setTimeout(() => {
if (this._pendingComplete === msg.incident_id) {
this._pendingComplete = null;
UI.hideProgress(msg.incident_id);
}
}, 5000);
}
await this.loadIncidents();
},
handleRefreshSummary(msg) {
const d = msg.data;
const title = d.incident_title || 'Lage';
// Abschluss-Animation auslösen wenn pending
if (this._pendingComplete === msg.incident_id) {
if (this._pendingCompleteTimer) {
clearTimeout(this._pendingCompleteTimer);
this._pendingCompleteTimer = null;
}
this._pendingComplete = null;
UI.showProgressComplete(d, msg.incident_id);
}
// Toast-Text zusammenbauen
const parts = [];
if (d.new_articles > 0) {
parts.push(`${d.new_articles} neue Meldung${d.new_articles !== 1 ? 'en' : ''}`);
}
if (d.confirmed_count > 0) {
parts.push(`${d.confirmed_count} bestätigt`);
}
if (d.contradicted_count > 0) {
parts.push(`${d.contradicted_count} widersprochen`);
}
if (d.status_changes && d.status_changes.length > 0) {
parts.push(`${d.status_changes.length} Statusänderung${d.status_changes.length !== 1 ? 'en' : ''}`);
}
const summaryText = parts.length > 0
? parts.join(', ')
: 'Keine neuen Entwicklungen';
// 1 Toast statt 5-10
UI.showToast(`Recherche abgeschlossen: ${summaryText}`, 'success', 6000);
// Ins NotificationCenter eintragen
NotificationCenter.add({
incident_id: msg.incident_id,
title: title,
text: `Recherche: ${summaryText}`,
icon: d.contradicted_count > 0 ? 'warning' : 'success',
});
// Status-Änderungen als separate Einträge
if (d.status_changes) {
d.status_changes.forEach(sc => {
const oldLabel = this._translateStatus(sc.old_status);
const newLabel = this._translateStatus(sc.new_status);
NotificationCenter.add({
incident_id: msg.incident_id,
title: title,
text: `${sc.claim}: ${oldLabel} \u2192 ${newLabel}`,
icon: sc.new_status === 'contradicted' || sc.new_status === 'disputed' ? 'error' : 'success',
});
});
}
// Sidebar-Dot blinken
const dot = document.getElementById(`dot-${msg.incident_id}`);
if (dot) {
dot.classList.add('has-notification');
setTimeout(() => dot.classList.remove('has-notification'), 10000);
}
},
_translateStatus(status) {
const map = {
confirmed: 'Bestätigt',
established: 'Gesichert',
unconfirmed: 'Unbestätigt',
contradicted: 'Widersprüchlich',
false: 'Widerlegt',
disputed: 'Umstritten',
developing: 'In Entwicklung',
unverified: 'Ungeprüft',
};
return map[status] || status;
},
handleRefreshError(msg) {
this._refreshingIncidents.delete(msg.incident_id);
this._updateSidebarDot(msg.incident_id, 'error');
UI._removeSidebarRefreshStatus(msg.incident_id);
delete UI._progressState[msg.incident_id];
UI._reindexQueuePositions();
this.renderSidebar();
if (msg.incident_id === this.currentIncidentId) {
this._updateRefreshButton(false);
// Pending-Complete aufräumen
if (this._pendingCompleteTimer) {
clearTimeout(this._pendingCompleteTimer);
this._pendingCompleteTimer = null;
}
this._pendingComplete = null;
UI.showProgressError(msg.data.error, false, 0, msg.incident_id);
}
UI.showToast(`Recherche-Fehler: ${msg.data.error}`, 'error');
},
handleRefreshCancelled(msg) {
this._refreshingIncidents.delete(msg.incident_id);
this._updateSidebarDot(msg.incident_id);
UI._removeSidebarRefreshStatus(msg.incident_id);
delete UI._progressState[msg.incident_id];
UI._reindexQueuePositions();
this.renderSidebar();
if (msg.incident_id === this.currentIncidentId) {
this._updateRefreshButton(false);
if (this._pendingCompleteTimer) {
clearTimeout(this._pendingCompleteTimer);
this._pendingCompleteTimer = null;
}
this._pendingComplete = null;
UI.hideProgress(msg.incident_id);
}
UI.showToast((typeof T === 'function' ? T('toast.research_cancelled', 'Recherche abgebrochen.') : 'Recherche abgebrochen.'), 'info');
},
/**
* Gleicht den lokalen Refresh-Status mit dem Server ab.
* Bereinigt verwaiste Status-Anzeigen, die durch verpasste WebSocket-Nachrichten entstehen.
*/
async syncRefreshStatus() {
if (this._refreshingIncidents.size === 0) return;
try {
const data = await API.getRefreshingIncidents();
const serverRefreshing = new Set(data.refreshing || []);
const serverQueued = new Set(data.queued || []);
const serverAll = new Set([...serverRefreshing, ...serverQueued]);
// Finde lokal als refreshing/queued markierte IDs, die serverseitig nicht mehr laufen
const stale = [];
this._refreshingIncidents.forEach(id => {
if (!serverAll.has(id)) stale.push(id);
});
if (stale.length > 0) {
console.log('Status-Sync: Bereinige verwaiste Refreshes:', stale);
stale.forEach(id => {
this._refreshingIncidents.delete(id);
this._updateSidebarDot(id);
UI._removeSidebarRefreshStatus(id);
delete UI._progressState[id];
if (id === this.currentIncidentId) {
this._updateRefreshButton(false);
UI.hideProgress(id);
}
});
UI._reindexQueuePositions();
this.renderSidebar();
}
} catch (e) {
// Netzwerkfehler ignorieren, naechster Zyklus probiert erneut
}
},
minimizeProgress() {
UI.minimizeProgress(this.currentIncidentId);
},
openProgressPopup() {
UI.openProgressPopup(this.currentIncidentId);
},
async cancelRefresh() {
if (!this.currentIncidentId) return;
// Temporarily hide progress popup so confirm dialog is fully visible
const progressOverlay = document.getElementById('progress-overlay');
if (progressOverlay) progressOverlay.style.display = 'none';
UI._syncHeaderAccess();
const ok = await confirmDialog((typeof T === 'function' ? T('confirm.cancel_running_research', 'Laufende Recherche abbrechen?') : 'Laufende Recherche abbrechen?'));
// Restore progress popup if not confirmed
if (!ok) {
const state = UI._progressState[this.currentIncidentId];
if (state && progressOverlay) progressOverlay.style.display = 'flex';
UI._syncHeaderAccess();
return;
}
// Show cancelling state in popup
if (progressOverlay) progressOverlay.style.display = 'flex';
UI._syncHeaderAccess();
const btn = document.getElementById('progress-cancel-btn');
if (btn) {
btn.textContent = (typeof T === 'function' ? T('action.cancelling', 'Wird abgebrochen...') : 'Wird abgebrochen...');
btn.disabled = true;
}
const titleEl = document.getElementById('progress-popup-title');
if (titleEl) titleEl.textContent = (typeof T === 'function' ? T('action.cancelling', 'Wird abgebrochen...') : 'Wird abgebrochen...');
try {
const result = await API.cancelRefresh(this.currentIncidentId);
if (!result) {
UI.showToast((typeof T === 'function' ? T('toast.no_active_refresh', 'Kein aktiver Refresh zum Abbrechen gefunden.') : 'Kein aktiver Refresh zum Abbrechen gefunden.'), 'info');
if (btn) { btn.textContent = 'Abbrechen'; btn.disabled = false; }
if (titleEl) titleEl.textContent = (typeof T === 'function' ? T('progress.title.refresh', 'Aktualisierung läuft') : 'Aktualisierung läuft');
}
} catch (err) {
UI.showToast('Abbrechen fehlgeschlagen: ' + err.message, 'error');
if (btn) { btn.textContent = 'Abbrechen'; btn.disabled = false; }
if (titleEl) titleEl.textContent = 'Aktualisierung l\u00e4uft';
}
},
// === Export ===
openExportModal() {
if (!this.currentIncidentId) return;
openModal('modal-export');
},
async submitExport() {
if (!this.currentIncidentId) return;
const checked = document.querySelectorAll('input[name="export-section"]:checked');
const sections = Array.from(checked).map(cb => cb.value);
if (sections.length === 0) {
UI.showToast('Bitte mindestens einen Bereich ausw\u00e4hlen.', 'warning');
return;
}
const format = document.querySelector('input[name="export-format"]:checked').value;
const brandingEl = document.querySelector('input[name="export-branding"]:checked');
const includeBranding = !brandingEl || brandingEl.value === 'on';
const ersteller = (document.getElementById('export-ersteller')?.value || '').trim();
const btn = document.getElementById('export-submit-btn');
const origText = btn.textContent;
btn.disabled = true;
btn.textContent = (typeof T === 'function' ? T('action.creating', 'Wird erstellt...') : 'Wird erstellt...');
try {
const response = await API.exportReport(this.currentIncidentId, format, null, sections, includeBranding, ersteller);
if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw new Error(err.detail || 'Fehler ' + response.status);
}
const blob = await response.blob();
const disposition = response.headers.get('Content-Disposition') || '';
let filename = 'bericht.' + format;
const match = disposition.match(/filename="?([^"]+)"?/);
if (match) filename = match[1];
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
closeModal('modal-export');
UI.showToast((typeof T === 'function' ? T('toast.report_downloaded', 'Bericht heruntergeladen') : 'Bericht heruntergeladen'), 'success');
} catch (err) {
UI.showToast('Export fehlgeschlagen: ' + err.message, 'error');
} finally {
btn.disabled = false;
btn.textContent = origText;
}
},
// === Sidebar-Stats ===
async updateSidebarStats() {
const _t = (k, fb) => (typeof T === 'function') ? T(k, fb) : fb;
const lblSources = _t('sidebar.stat.sources_suffix', 'Quellen');
const lblArticles = _t('sidebar.stat.articles_suffix', 'Artikel');
try {
const stats = await API.getSourceStats();
const srcCount = document.getElementById('stat-sources-count');
const artCount = document.getElementById('stat-articles-count');
if (srcCount) srcCount.textContent = `${stats.total_sources} ${lblSources}`;
if (artCount) artCount.textContent = `${stats.total_articles} ${lblArticles}`;
} catch {
// Fallback: aus Lagen berechnen
const totalArticles = this.incidents.reduce((sum, i) => sum + i.article_count, 0);
const totalSources = this.incidents.reduce((sum, i) => sum + i.source_count, 0);
const srcCount = document.getElementById('stat-sources-count');
const artCount = document.getElementById('stat-articles-count');
if (srcCount) srcCount.textContent = `${totalSources} ${lblSources}`;
if (artCount) artCount.textContent = `${totalArticles} ${lblArticles}`;
}
},
// === Soft-Refresh (F5) ===
async softRefresh() {
try {
await this.loadIncidents();
if (this.currentIncidentId) {
await this.selectIncident(this.currentIncidentId);
}
UI.showToast((typeof T === 'function' ? T('toast.data_updated', 'Daten aktualisiert.') : 'Daten aktualisiert.'), 'success', 2000);
} catch (err) {
UI.showToast('Aktualisierung fehlgeschlagen: ' + err.message, 'error');
}
},
// === Feedback ===
openFeedback() {
const form = document.getElementById('feedback-form');
if (form) form.reset();
const counter = document.getElementById('fb-char-count');
if (counter) counter.textContent = '0';
openModal('modal-feedback');
},
async submitFeedback(e) {
e.preventDefault();
const form = document.getElementById('feedback-form');
this._clearFormErrors(form);
const btn = document.getElementById('fb-submit-btn');
const category = document.getElementById('fb-category').value;
const msgField = document.getElementById('fb-message');
const message = msgField.value.trim();
if (message.length < 10) {
this._showFieldError(msgField, 'Bitte mindestens 10 Zeichen eingeben.');
msgField.focus();
return;
}
// Dateien pruefen
const fileInput = document.getElementById('fb-files');
const files = fileInput ? Array.from(fileInput.files) : [];
if (files.length > 3) {
UI.showToast('Maximal 3 Bilder erlaubt.', 'error');
return;
}
for (const f of files) {
if (f.size > 5 * 1024 * 1024) {
UI.showToast('Datei "' + f.name + '" ist groesser als 5 MB.', 'error');
return;
}
}
btn.disabled = true;
btn.textContent = (typeof T === 'function' ? T('action.sending', 'Wird gesendet...') : 'Wird gesendet...');
try {
const formData = new FormData();
formData.append('category', category);
formData.append('message', message);
for (const f of files) {
formData.append('files', f);
}
await API.sendFeedbackForm(formData);
closeModal('modal-feedback');
UI.showToast('Feedback gesendet. Vielen Dank!', 'success');
} catch (err) {
UI.showToast('Fehler: ' + err.message, 'error');
} finally {
btn.disabled = false;
btn.textContent = 'Absenden';
}
},
// === Sidebar Sektionen ein-/ausklappen ===
toggleSidebarSection(sectionId) {
const list = document.getElementById(sectionId);
if (!list) return;
const chevron = document.getElementById('chevron-' + sectionId);
const isHidden = list.style.display === 'none';
list.style.display = isHidden ? '' : 'none';
if (chevron) {
chevron.classList.toggle('open', isHidden);
}
// aria-expanded auf dem Section-Title synchronisieren
const title = chevron ? chevron.closest('.sidebar-section-title') : null;
if (title) title.setAttribute('aria-expanded', String(isHidden));
},
// === Quellenverwaltung ===
async openSourceManagement() {
openModal('modal-sources');
await this.loadSources();
},
async loadSources() {
try {
const [sources, stats, myExclusions] = await Promise.all([
API.listSources(),
API.getSourceStats(),
API.getMyExclusions(),
]);
this._allSources = sources;
this._sourcesOnly = sources.filter(s => s.source_type !== 'excluded');
this._myExclusions = myExclusions || [];
this.renderSourceStats(stats);
this.renderSourceList();
} catch (err) {
UI.showToast('Fehler beim Laden der Quellen: ' + err.message, 'error');
}
},
renderSourceStats(stats) {
const bar = document.getElementById('sources-stats-bar');
if (!bar) return;
const rss = stats.by_type.rss_feed || { count: 0, articles: 0 };
const web = stats.by_type.web_source || { count: 0, articles: 0 };
const tg = stats.by_type.telegram_channel || { count: 0, articles: 0 };
const x = stats.by_type.x_account || { count: 0, articles: 0 };
const excluded = this._myExclusions.length;
bar.innerHTML = `
<span class="sources-stat-item"><span class="sources-stat-value">${rss.count}</span> ${(typeof T === 'function' ? T('sources_modal.stats.rss', 'RSS-Feeds') : 'RSS-Feeds')}</span>
<span class="sources-stat-item"><span class="sources-stat-value">${web.count}</span> ${(typeof T === 'function' ? T('sources_modal.stats.web', 'Web-Quellen') : 'Web-Quellen')}</span>
<span class="sources-stat-item"><span class="sources-stat-value">${tg.count}</span> Telegram</span>
<span class="sources-stat-item"><span class="sources-stat-value">${x.count}</span> X</span>
<span class="sources-stat-item"><span class="sources-stat-value">${excluded}</span> ${(typeof T === 'function' ? T('sources_modal.stats.excluded', 'Ausgeschlossen') : 'Ausgeschlossen')}</span>
<span class="sources-stat-item"><span class="sources-stat-value">${stats.total_articles}</span> Artikel gesamt</span>
`;
},
/**
* Quellen nach Domain gruppiert rendern.
*/
renderSourceList() {
const list = document.getElementById('sources-list');
if (!list) return;
// Filter anwenden
const typeFilter = document.getElementById('sources-filter-type')?.value || '';
const catFilter = document.getElementById('sources-filter-category')?.value || '';
const politicalFilter = document.getElementById('sources-filter-political')?.value || '';
const mediaTypeFilter = document.getElementById('sources-filter-mediatype')?.value || '';
const reliabilityFilter = document.getElementById('sources-filter-reliability')?.value || '';
const alignmentFilter = document.getElementById('sources-filter-alignment')?.value || '';
const externFilter = document.getElementById('sources-filter-extern')?.value || '';
const search = (document.getElementById('sources-search')?.value || '').toLowerCase();
// Alle Quellen nach Domain gruppieren
const groups = new Map();
const excludedDomains = new Set();
const excludedNotes = {};
// User-Ausschlüsse sammeln
this._myExclusions.forEach(e => {
const domain = (e.domain || '').toLowerCase();
if (domain) {
excludedDomains.add(domain);
excludedNotes[domain] = e.notes || '';
}
});
// Feeds nach Domain gruppieren
this._sourcesOnly.forEach(s => {
const domain = (s.domain || '').toLowerCase() || `_single_${s.id}`;
if (!groups.has(domain)) groups.set(domain, []);
groups.get(domain).push(s);
});
// Ausgeschlossene Domains die keine Feeds haben auch als Gruppe
this._myExclusions.forEach(e => {
const domain = (e.domain || '').toLowerCase();
if (domain && !groups.has(domain)) {
groups.set(domain, []);
}
});
// Filter auf Gruppen anwenden
let filteredGroups = [];
for (const [domain, feeds] of groups) {
const isExcluded = excludedDomains.has(domain);
const isGlobal = feeds.some(f => f.is_global);
// Typ-Filter
if (typeFilter === 'excluded' && !isExcluded) continue;
if (typeFilter && typeFilter !== 'excluded') {
const hasMatchingType = feeds.some(f => f.source_type === typeFilter);
if (!hasMatchingType) continue;
}
// Kategorie-Filter
if (catFilter) {
const hasMatchingCat = feeds.some(f => f.category === catFilter);
if (!hasMatchingCat) continue;
}
// Klassifikations-Filter
if (politicalFilter) {
if (!feeds.some(f => (f.political_orientation || 'na') === politicalFilter)) continue;
}
if (mediaTypeFilter) {
if (!feeds.some(f => (f.media_type || 'sonstige') === mediaTypeFilter)) continue;
}
if (reliabilityFilter) {
if (!feeds.some(f => (f.reliability || 'na') === reliabilityFilter)) continue;
}
if (alignmentFilter) {
if (!feeds.some(f => Array.isArray(f.alignments) && f.alignments.includes(alignmentFilter))) continue;
}
if (externFilter === 'ifcn') {
if (!feeds.some(f => f.ifcn_signatory)) continue;
} else if (externFilter === 'eu_disinfo') {
if (!feeds.some(f => f.eu_disinfo_listed)) continue;
}
// Suche
if (search) {
const groupText = feeds.map(f =>
`${f.name} ${f.domain || ''} ${f.url || ''} ${f.notes || ''}`
).join(' ').toLowerCase() + ' ' + domain;
if (!groupText.includes(search)) continue;
}
filteredGroups.push({ domain, feeds, isExcluded, isGlobal });
}
if (filteredGroups.length === 0) {
list.innerHTML = '<div style="padding:24px;text-align:center;font-size:13px;color:var(--text-disabled);">Keine Quellen gefunden</div>';
return;
}
// Sortierung: Aktive zuerst (alphabetisch), dann ausgeschlossene
filteredGroups.sort((a, b) => {
if (a.isExcluded !== b.isExcluded) return a.isExcluded ? 1 : -1;
return a.domain.localeCompare(b.domain);
});
list.innerHTML = filteredGroups.map(g =>
UI.renderSourceGroup(g.domain, g.feeds, g.isExcluded, excludedNotes[g.domain] || '', g.isGlobal)
).join('');
// Erweiterte Gruppen wiederherstellen
this._expandedGroups.forEach(domain => {
const feedsEl = list.querySelector(`.source-group-feeds[data-domain="${CSS.escape(domain)}"]`);
if (feedsEl) {
feedsEl.classList.add('expanded');
const header = feedsEl.previousElementSibling;
if (header) header.classList.add('expanded');
}
});
},
filterSources() {
this.renderSourceList();
},
/**
* Domain-Gruppe auf-/zuklappen.
*/
toggleSourceOverview() {
const content = document.getElementById('source-overview-content');
const chevron = document.getElementById('source-overview-chevron');
if (!content) return;
const isHidden = content.style.display === 'none';
content.style.display = isHidden ? '' : 'none';
if (chevron) {
chevron.classList.toggle('open', isHidden);
chevron.title = isHidden ? 'Einklappen' : 'Aufklappen';
}
// aria-expanded auf dem Header-Toggle synchronisieren
const header = chevron ? chevron.closest('[role="button"]') : null;
if (header) header.setAttribute('aria-expanded', String(isHidden));
// Tab-Modus: Panel waechst mit Inhalt, kein Resize noetig
},
toggleGroup(domain) {
const list = document.getElementById('sources-list');
if (!list) return;
const feedsEl = list.querySelector(`.source-group-feeds[data-domain="${CSS.escape(domain)}"]`);
if (!feedsEl) return;
const isExpanded = feedsEl.classList.toggle('expanded');
const header = feedsEl.previousElementSibling;
if (header) {
header.classList.toggle('expanded', isExpanded);
header.setAttribute('aria-expanded', String(isExpanded));
}
if (isExpanded) {
this._expandedGroups.add(domain);
} else {
this._expandedGroups.delete(domain);
}
},
/**
* Domain ausschließen (aus dem Inline-Formular).
*/
async blockDomain() {
const input = document.getElementById('block-domain-input');
const domain = (input?.value || '').trim();
if (!domain) {
UI.showToast('Domain ist erforderlich.', 'warning');
return;
}
const notes = (document.getElementById('block-domain-notes')?.value || '').trim() || null;
try {
await API.blockDomain(domain, notes);
UI.showToast(`${domain} ausgeschlossen.`, 'success');
this.showBlockDomainDialog(false);
await this.loadSources();
this.updateSidebarStats();
} catch (err) {
UI.showToast('Fehler: ' + err.message, 'error');
}
},
/**
* Faktencheck-Filter umschalten.
*/
toggleFactCheckFilter(status) {
const checkbox = document.querySelector(`.fc-dropdown-item[data-status="${status}"] input`);
if (!checkbox) return;
const isActive = checkbox.checked;
document.querySelectorAll(`.factcheck-item[data-fc-status="${status}"]`).forEach(el => {
el.style.display = isActive ? '' : 'none';
});
},
toggleFcDropdown(e) {
e.stopPropagation();
const btn = e.target.closest('.fc-dropdown-toggle');
const menu = btn ? btn.nextElementSibling : document.getElementById('fc-dropdown-menu');
if (!menu) return;
const isOpen = menu.classList.toggle('open');
if (btn) btn.setAttribute('aria-expanded', String(isOpen));
if (isOpen) {
const close = (ev) => {
if (!menu.contains(ev.target)) {
menu.classList.remove('open');
document.removeEventListener('click', close);
}
};
setTimeout(() => document.addEventListener('click', close), 0);
}
},
filterModalTimeline(searchTerm) {
const filterBtn = document.querySelector('.ht-modal-filter-btn.active');
const filterType = filterBtn ? filterBtn.dataset.filter : 'all';
const body = document.getElementById('content-viewer-body');
if (!body) return;
body.innerHTML = this._buildFullVerticalTimeline(filterType, (searchTerm || '').toLowerCase());
},
filterModalTimelineType(filterType, btn) {
document.querySelectorAll('.ht-modal-filter-btn').forEach(b => b.classList.remove('active'));
if (btn) btn.classList.add('active');
const searchInput = document.querySelector('#content-viewer-header-extra .timeline-filter-input');
const searchTerm = searchInput ? searchInput.value.toLowerCase() : '';
const body = document.getElementById('content-viewer-body');
if (!body) return;
body.innerHTML = this._buildFullVerticalTimeline(filterType, searchTerm);
},
/**
* Domain direkt ausschließen (aus der Gruppenliste).
*/
async blockDomainDirect(domain) {
if (!await confirmDialog(`"${domain}" wirklich ausschließen? Artikel dieser Domain werden bei allen deinen Recherchen ignoriert. Dies betrifft nicht andere Nutzer deiner Organisation.`)) return;
try {
await API.blockDomain(domain);
UI.showToast(`${domain} ausgeschlossen.`, 'success');
await this.loadSources();
this.updateSidebarStats();
} catch (err) {
UI.showToast('Fehler: ' + err.message, 'error');
}
},
/**
* Domain-Ausschluss aufheben.
*/
async unblockDomain(domain) {
try {
await API.unblockDomain(domain);
UI.showToast(`${domain} Ausschluss aufgehoben.`, 'success');
await this.loadSources();
this.updateSidebarStats();
} catch (err) {
UI.showToast('Fehler: ' + err.message, 'error');
}
},
/**
* Alle Quellen einer Domain löschen.
*/
async deleteDomain(domain) {
if (!await confirmDialog(`Alle Quellen von "${domain}" wirklich löschen?`)) return;
try {
await API.deleteDomain(domain);
UI.showToast(`${domain} gelöscht.`, 'success');
await this.loadSources();
this.updateSidebarStats();
} catch (err) {
UI.showToast('Fehler: ' + err.message, 'error');
}
},
/**
* Einzelnen Feed löschen.
*/
async deleteSingleFeed(sourceId) {
try {
await API.deleteSource(sourceId);
this._allSources = this._allSources.filter(s => s.id !== sourceId);
this._sourcesOnly = this._sourcesOnly.filter(s => s.id !== sourceId);
this.renderSourceList();
this.updateSidebarStats();
UI.showToast('Feed gelöscht.', 'success');
} catch (err) {
UI.showToast('Fehler: ' + err.message, 'error');
}
},
/**
* "Domain ausschließen" Dialog ein-/ausblenden.
*/
showBlockDomainDialog(show) {
const form = document.getElementById('sources-block-form');
if (!form) return;
if (show === undefined || show === true) {
form.style.display = 'block';
document.getElementById('block-domain-input').value = '';
document.getElementById('block-domain-notes').value = '';
// Add-Form ausblenden
const addForm = document.getElementById('sources-add-form');
if (addForm) addForm.style.display = 'none';
} else {
form.style.display = 'none';
}
},
_discoveredData: null,
openPdfUpload() {
const form = document.getElementById("pdf-upload-form");
if (form) form.reset();
const err = document.getElementById("pdf-upload-error");
if (err) { err.style.display = "none"; err.textContent = ""; }
const prog = document.getElementById("pdf-upload-progress");
if (prog) prog.style.display = "none";
openModal("modal-pdf-upload");
this._bindPdfUploadFormOnce();
},
_bindPdfUploadFormOnce() {
const form = document.getElementById("pdf-upload-form");
if (!form || form.dataset.bound === "1") return;
form.dataset.bound = "1";
form.addEventListener("submit", async (e) => {
e.preventDefault();
const errEl = document.getElementById("pdf-upload-error");
const progEl = document.getElementById("pdf-upload-progress");
const submitBtn = document.getElementById("pdf-upload-submit");
errEl.style.display = "none";
const fileInput = document.getElementById("pdf-upload-file");
const f = fileInput && fileInput.files && fileInput.files[0];
if (!f) {
errEl.textContent = "Bitte eine PDF-Datei auswaehlen.";
errEl.style.display = "block";
return;
}
if (f.size > 50 * 1024 * 1024) {
errEl.textContent = "Datei ueberschreitet 50 MB.";
errEl.style.display = "block";
return;
}
const fd = new FormData();
fd.append("file", f);
const nm = (document.getElementById("pdf-upload-name").value || "").trim();
if (nm) fd.append("name", nm);
fd.append("category", document.getElementById("pdf-upload-category").value || "sonstige");
const lng = (document.getElementById("pdf-upload-language").value || "").trim();
if (lng) fd.append("language", lng);
const nt = (document.getElementById("pdf-upload-notes").value || "").trim();
if (nt) fd.append("notes", nt);
submitBtn.disabled = true;
progEl.style.display = "block";
try {
await API.upload("/sources/upload-pdf", fd);
closeModal("modal-pdf-upload");
if (typeof UI !== "undefined" && UI.showToast) {
UI.showToast("PDF hochgeladen -- Verarbeitung laeuft im Hintergrund", "success");
}
await App.loadSources();
} catch (err) {
errEl.textContent = err && err.message ? err.message : "Upload fehlgeschlagen";
errEl.style.display = "block";
} finally {
submitBtn.disabled = false;
progEl.style.display = "none";
}
});
},
toggleSourceForm(show) {
const form = document.getElementById('sources-add-form');
if (!form) return;
if (show === undefined) {
show = form.style.display === 'none';
}
form.style.display = show ? 'block' : 'none';
if (show) {
this._editingSourceId = null;
this._discoveredData = null;
document.getElementById('src-discover-url').value = '';
document.getElementById('src-discovery-result').style.display = 'none';
document.getElementById('src-discover-btn').disabled = false;
document.getElementById('src-discover-btn').textContent = 'Erkennen';
document.getElementById('src-type-select').value = 'rss_feed';
// Save-Button Text zurücksetzen
const saveBtn = document.querySelector('#src-discovery-result .sources-discovery-actions .btn-primary');
if (saveBtn) saveBtn.textContent = 'Speichern';
// Block-Form ausblenden
const blockForm = document.getElementById('sources-block-form');
if (blockForm) blockForm.style.display = 'none';
} else {
// Beim Schließen: Bearbeitungsmodus zurücksetzen
this._editingSourceId = null;
const saveBtn = document.querySelector('#src-discovery-result .sources-discovery-actions .btn-primary');
if (saveBtn) saveBtn.textContent = 'Speichern';
}
},
async discoverSource() {
const urlInput = document.getElementById('src-discover-url');
const urlVal = urlInput.value.trim();
// Telegram-URLs direkt behandeln (kein Discovery noetig)
if (urlVal.match(/^(https?:\/\/)?(t\.me|telegram\.me)\//i)) {
const channelName = urlVal.replace(/^(https?:\/\/)?(t\.me|telegram\.me)\//, '').replace(/\/$/, '');
const tgUrl = 't.me/' + channelName;
this._discoveredData = {
name: '@' + channelName,
domain: 't.me',
source_type: 'telegram_channel',
rss_url: null,
};
document.getElementById('src-name').value = '@' + channelName;
document.getElementById('src-type-select').value = 'telegram_channel';
document.getElementById('src-type-display').value = 'Telegram';
document.getElementById('src-domain').value = tgUrl;
document.getElementById('src-rss-url-group').style.display = 'none';
document.getElementById('src-discovery-result').style.display = 'block';
const saveBtn = document.querySelector('#src-discovery-result .sources-discovery-actions .btn-primary');
if (saveBtn) { saveBtn.disabled = false; saveBtn.textContent = 'Speichern'; }
return;
}
// X (Twitter)-URLs direkt behandeln (kein Discovery noetig)
if (urlVal.match(/^(https?:\/\/)?(x\.com|twitter\.com)\//i)) {
const handle = urlVal
.replace(/^(https?:\/\/)?(x\.com|twitter\.com)\//i, '')
.replace(/\/$/, '')
.split(/[/?]/)[0]
.replace(/^@/, '');
const xUrl = 'x.com/' + handle;
this._discoveredData = {
name: '@' + handle,
domain: xUrl,
source_type: 'x_account',
rss_url: null,
};
document.getElementById('src-name').value = '@' + handle;
document.getElementById('src-type-select').value = 'x_account';
document.getElementById('src-type-display').value = 'X (Twitter)';
document.getElementById('src-domain').value = xUrl;
document.getElementById('src-rss-url-group').style.display = 'none';
document.getElementById('src-discovery-result').style.display = 'block';
const saveBtnX = document.querySelector('#src-discovery-result .sources-discovery-actions .btn-primary');
if (saveBtnX) { saveBtnX.disabled = false; saveBtnX.textContent = 'Speichern'; }
return;
}
const url = urlInput.value.trim();
if (!url) {
UI.showToast('Bitte URL oder Domain eingeben.', 'warning');
return;
}
// Prüfen ob Domain ausgeschlossen ist
const inputDomain = url.replace(/^https?:\/\//, '').replace(/^www\./, '').split('/')[0].toLowerCase();
const isBlocked = inputDomain && this._myExclusions.some(e => (e.domain || '').toLowerCase() === inputDomain);
if (isBlocked) {
if (!await confirmDialog(`"${inputDomain}" ist ausgeschlossen. Trotzdem hinzufügen? Der Ausschluss wird dabei aufgehoben.`)) return;
await API.unblockDomain(inputDomain);
}
const btn = document.getElementById('src-discover-btn');
btn.disabled = true;
btn.textContent = (typeof T === 'function' ? T('action.searching_feeds', 'Suche Feeds...') : 'Suche Feeds...');
try {
const result = await API.discoverMulti(url);
if (result.fallback_single) {
this._discoveredData = {
name: result.domain,
domain: result.domain,
category: result.category,
source_type: result.total_found > 0 ? 'rss_feed' : 'web_source',
rss_url: result.sources.length > 0 ? result.sources[0].url : null,
};
if (result.sources.length > 0) {
this._discoveredData.name = result.sources[0].name;
}
document.getElementById('src-name').value = this._discoveredData.name || '';
document.getElementById('src-category').value = this._discoveredData.category || 'sonstige';
document.getElementById('src-domain').value = this._discoveredData.domain || '';
document.getElementById('src-notes').value = '';
const typeLabel = this._discoveredData.source_type === 'rss_feed' ? 'RSS-Feed' : this._discoveredData.source_type === 'telegram_channel' ? 'Telegram' : 'Web-Quelle';
const typeSelect = document.getElementById('src-type-select');
if (typeSelect) typeSelect.value = this._discoveredData.source_type || 'web_source';
document.getElementById('src-type-display').value = typeLabel;
const rssGroup = document.getElementById('src-rss-url-group');
const rssInput = document.getElementById('src-rss-url');
if (this._discoveredData.rss_url) {
rssInput.value = this._discoveredData.rss_url;
rssGroup.style.display = 'block';
} else {
rssInput.value = '';
rssGroup.style.display = 'none';
}
document.getElementById('src-discovery-result').style.display = 'block';
if (result.added_count > 0) {
UI.showToast(`${result.domain}: Feed wurde automatisch hinzugefügt.`, 'success');
this.toggleSourceForm(false);
await this.loadSources();
} else if (result.total_found === 0) {
UI.showToast((typeof T === 'function' ? T('toast.no_rss_save_as_web', 'Kein RSS-Feed gefunden. Als Web-Quelle speichern?') : 'Kein RSS-Feed gefunden. Als Web-Quelle speichern?'), 'info');
} else {
UI.showToast('Feed bereits vorhanden.', 'info');
}
} else {
document.getElementById('src-discovery-result').style.display = 'none';
if (result.added_count > 0) {
UI.showToast(`${result.domain}: ${result.added_count} Feeds hinzugefügt` +
(result.skipped_count > 0 ? ` (${result.skipped_count} bereits vorhanden)` : ''),
'success');
} else if (result.skipped_count > 0) {
UI.showToast(`${result.domain}: Alle ${result.skipped_count} Feeds bereits vorhanden.`, 'info');
} else {
UI.showToast(`${result.domain}: Keine relevanten Feeds gefunden.`, 'info');
}
this.toggleSourceForm(false);
await this.loadSources();
}
} catch (err) {
UI.showToast('Erkennung fehlgeschlagen: ' + err.message, 'error');
} finally {
btn.disabled = false;
btn.textContent = 'Erkennen';
}
},
editSource(id) {
const source = this._sourcesOnly.find(s => s.id === id);
if (!source) {
UI.showToast('Quelle nicht gefunden.', 'error');
return;
}
this._editingSourceId = id;
// Formular öffnen falls geschlossen (direkt, ohne toggleSourceForm das _editingSourceId zurücksetzt)
const form = document.getElementById('sources-add-form');
if (form) {
form.style.display = 'block';
const blockForm = document.getElementById('sources-block-form');
if (blockForm) blockForm.style.display = 'none';
}
// Discovery-URL mit vorhandener URL/Domain befüllen
const discoverUrlInput = document.getElementById('src-discover-url');
if (discoverUrlInput) {
discoverUrlInput.value = source.url || source.domain || '';
}
// Discovery-Ergebnis anzeigen und Felder befüllen
document.getElementById('src-discovery-result').style.display = 'block';
document.getElementById('src-name').value = source.name || '';
document.getElementById('src-category').value = source.category || 'sonstige';
document.getElementById('src-notes').value = source.notes || '';
document.getElementById('src-domain').value = source.domain || '';
const typeLabel = source.source_type === 'rss_feed' ? 'RSS-Feed' : source.source_type === 'telegram_channel' ? 'Telegram' : source.source_type === 'x_account' ? 'X (Twitter)' : 'Web-Quelle';
const typeSelect = document.getElementById('src-type-select');
if (typeSelect) typeSelect.value = source.source_type || 'web_source';
document.getElementById('src-type-display').value = typeLabel;
const rssGroup = document.getElementById('src-rss-url-group');
const rssInput = document.getElementById('src-rss-url');
if (source.url) {
rssInput.value = source.url;
rssGroup.style.display = 'block';
} else {
rssInput.value = '';
rssGroup.style.display = 'none';
}
// _discoveredData setzen damit saveSource() die richtigen Werte nutzt
this._discoveredData = {
name: source.name,
domain: source.domain,
category: source.category,
source_type: source.source_type,
rss_url: source.url,
};
// Submit-Button-Text ändern
const saveBtn = document.querySelector('#src-discovery-result .sources-discovery-actions .btn-primary');
if (saveBtn) saveBtn.textContent = (typeof T === 'function' ? T('action.save_source', 'Quelle speichern') : 'Quelle speichern');
// Zum Formular scrollen
if (form) form.scrollIntoView({ behavior: 'smooth', block: 'start' });
},
async saveSource() {
const name = document.getElementById('src-name').value.trim();
if (!name) {
UI.showToast('Name ist erforderlich. Bitte erst "Erkennen" klicken.', 'warning');
return;
}
const discovered = this._discoveredData || {};
const data = {
name,
source_type: discovered.source_type || 'web_source',
category: document.getElementById('src-category').value,
url: discovered.rss_url || ((discovered.source_type === 'telegram_channel' || discovered.source_type === 'x_account') ? (document.getElementById('src-domain').value || null) : null),
domain: document.getElementById('src-domain').value.trim() || discovered.domain || null,
notes: document.getElementById('src-notes').value.trim() || null,
};
if (!data.domain && discovered.domain) {
data.domain = discovered.domain;
}
try {
if (this._editingSourceId) {
await API.updateSource(this._editingSourceId, data);
UI.showToast((typeof T === 'function' ? T('toast.source_updated', 'Quelle aktualisiert.') : 'Quelle aktualisiert.'), 'success');
} else {
await API.createSource(data);
UI.showToast((typeof T === 'function' ? T('toast.source_added', 'Quelle hinzugefügt.') : 'Quelle hinzugefügt.'), 'success');
}
this.toggleSourceForm(false);
await this.loadSources();
this.updateSidebarStats();
} catch (err) {
UI.showToast('Fehler: ' + err.message, 'error');
}
},
// --- Global Admin: Org-Switcher (herausnehmbar) ---
async _initOrgSwitcher(currentTenantId) {
const section = document.getElementById('org-switcher-section');
const select = document.getElementById('org-switcher-select');
if (!section || !select) return;
try {
const orgs = await API.listOrganizations();
if (!orgs || orgs.length < 2) return;
section.style.display = 'block';
select.innerHTML = '';
orgs.forEach(org => {
const opt = document.createElement('option');
opt.value = org.id;
opt.textContent = org.name + (org.is_active ? '' : ' (inaktiv)');
if (org.id === currentTenantId) opt.selected = true;
select.appendChild(opt);
});
select.addEventListener('change', async () => {
const orgId = parseInt(select.value, 10);
if (orgId === currentTenantId) return;
try {
const result = await API.switchOrg(orgId);
localStorage.setItem('osint_token', result.access_token);
window.location.reload();
} catch (err) {
console.error('Org-Wechsel fehlgeschlagen:', err);
}
});
} catch {
// Kein Global Admin oder Fehler - Switcher bleibt versteckt
}
},
logout() {
localStorage.removeItem('osint_token');
localStorage.removeItem('osint_username');
this._sessionWarningShown = false;
WS.disconnect();
window.location.href = '/';
},
};
// === Barrierefreier Bestätigungsdialog ===
function confirmDialog(message) {
return new Promise((resolve) => {
// Overlay erstellen
const overlay = document.createElement('div');
overlay.className = 'modal-overlay active';
overlay.setAttribute('role', 'alertdialog');
overlay.setAttribute('aria-modal', 'true');
overlay.setAttribute('aria-labelledby', 'confirm-dialog-msg');
const modal = document.createElement('div');
modal.className = 'modal';
modal.style.maxWidth = '420px';
modal.innerHTML = `
<div class="modal-header">
<div class="modal-title">Bestätigung</div>
</div>
<div class="modal-body" style="padding:16px 24px;">
<p id="confirm-dialog-msg" style="margin:0;font-size:14px;color:var(--text-primary);line-height:1.5;">${message.replace(/</g, '&lt;').replace(/>/g, '&gt;')}</p>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" id="confirm-cancel">Abbrechen</button>
<button class="btn btn-primary" id="confirm-ok">Bestätigen</button>
</div>
`;
overlay.appendChild(modal);
document.body.appendChild(overlay);
const previousFocus = document.activeElement;
const cleanup = (result) => {
releaseFocus(overlay);
overlay.remove();
if (previousFocus) previousFocus.focus();
resolve(result);
};
modal.querySelector('#confirm-cancel').addEventListener('click', () => cleanup(false));
modal.querySelector('#confirm-ok').addEventListener('click', () => cleanup(true));
overlay.addEventListener('click', (e) => {
if (e.target === overlay) cleanup(false);
});
overlay.addEventListener('keydown', (e) => {
if (e.key === 'Escape') cleanup(false);
});
trapFocus(overlay);
});
}
// === Globale Hilfsfunktionen ===
// --- Focus-Trap für Modals (WCAG 2.4.3) ---
const FOCUSABLE_SELECTOR = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
function trapFocus(modalEl) {
const handler = (e) => {
if (e.key !== 'Tab') return;
const focusable = Array.from(modalEl.querySelectorAll(FOCUSABLE_SELECTOR)).filter(el => el.offsetParent !== null);
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
};
modalEl._focusTrapHandler = handler;
modalEl.addEventListener('keydown', handler);
// Fokus auf erstes Element setzen
requestAnimationFrame(() => {
const focusable = Array.from(modalEl.querySelectorAll(FOCUSABLE_SELECTOR)).filter(el => el.offsetParent !== null);
if (focusable.length > 0) focusable[0].focus();
});
}
function releaseFocus(modalEl) {
if (modalEl._focusTrapHandler) {
modalEl.removeEventListener('keydown', modalEl._focusTrapHandler);
delete modalEl._focusTrapHandler;
}
}
function openModal(id) {
if (id === 'modal-new' && !App._editingIncidentId) {
// Create-Modus: Formular zurücksetzen
document.getElementById('new-incident-form').reset();
document.getElementById('modal-new-title').textContent = (typeof T === 'function') ? T('modal.new_incident.title2', 'Neue Lage anlegen') : 'Neue Lage anlegen';
document.getElementById('modal-new-submit').textContent = (typeof T === 'function') ? T('modal.new_incident.submit', 'Lage anlegen') : 'Lage anlegen';
{ const _b = document.getElementById('btn-enhance-description'); if (_b) _b.disabled = true; }
{ const _t = document.getElementById("inc-description"); if (_t) { _t.style.height = ""; _autoResizeTextarea(_t); } }
// E-Mail-Checkboxen zuruecksetzen
document.getElementById('inc-notify-summary').checked = false;
document.getElementById('inc-notify-new-articles').checked = false;
document.getElementById('inc-notify-status-change').checked = false;
toggleTypeDefaults();
toggleRefreshInterval();
updateIntervalMin();
}
const modal = document.getElementById(id);
modal._previousFocus = document.activeElement;
modal.classList.add('active');
trapFocus(modal);
}
function closeModal(id) {
// Laufenden Beschreibung-generieren-Request abbrechen
if (id === 'modal-new' && App._enhanceController) {
App._enhanceController.abort();
App._enhanceController = null;
const ta = document.getElementById('inc-description');
if (ta) { ta.readOnly = false; ta.classList.remove('textarea--loading'); }
}
const modal = document.getElementById(id);
releaseFocus(modal);
modal.classList.remove('active');
if (modal._previousFocus) {
modal._previousFocus.focus();
delete modal._previousFocus;
}
if (id === 'modal-new') {
App._editingIncidentId = null;
document.getElementById('modal-new-title').textContent = (typeof T === 'function') ? T('modal.new_incident.title2', 'Neue Lage anlegen') : 'Neue Lage anlegen';
document.getElementById('modal-new-submit').textContent = (typeof T === 'function') ? T('modal.new_incident.submit', 'Lage anlegen') : 'Lage anlegen';
}
}
function openContentModal(title, sourceElementId) {
const source = document.getElementById(sourceElementId);
if (!source) return;
document.getElementById('content-viewer-title').textContent = title;
const body = document.getElementById('content-viewer-body');
const headerExtra = document.getElementById('content-viewer-header-extra');
headerExtra.innerHTML = '';
if (sourceElementId === 'factcheck-list') {
// Faktencheck: Filter in den Modal-Header, Liste in den Body
const filters = document.getElementById('fc-filters');
if (filters && filters.innerHTML.trim()) {
headerExtra.innerHTML = `<div class="fc-filter-bar">${filters.innerHTML}</div>`;
}
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 = '<input type="text" class="timeline-filter-input" placeholder="Quellen durchsuchen..." oninput="App.filterModalSources(this.value)" style="width:200px;">';
body.innerHTML = buildDetailedSourceOverview();
} else if (sourceElementId === 'timeline') {
// Timeline: Vollständige vertikale Timeline im Modal mit Filter + Suche
headerExtra.innerHTML = `<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
<div class="ht-filter-group">
<button class="ht-modal-filter-btn active" data-filter="all" onclick="App.filterModalTimelineType('all', this)">Alle</button>
<button class="ht-modal-filter-btn" data-filter="articles" onclick="App.filterModalTimelineType('articles', this)">Meldungen</button>
<button class="ht-modal-filter-btn" data-filter="snapshots" onclick="App.filterModalTimelineType('snapshots', this)">Lageberichte</button>
</div>
<input type="text" class="timeline-filter-input" placeholder="Suche..." oninput="App.filterModalTimeline(this.value)" style="width:180px;">
</div>`;
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 '<div style="padding:24px;color:var(--text-disabled);">Keine Artikel vorhanden</div>';
// 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]) => `<span class="source-lang-chip">${lang} <strong>${count}</strong></span>`)
.join('');
let html = `<div class="source-overview-header">
<span class="source-overview-stat">${articles.length} Artikel aus ${sources.length} Quellen</span>
<div class="source-lang-chips">${langChips}</div>
</div>`;
sources.forEach(([name, data]) => {
const langs = [...data.languages].join('/');
const escapedName = UI.escape(name);
html += `<details style="border:1px solid var(--border);border-radius:4px;margin-bottom:6px;">
<summary style="display:flex;align-items:center;gap:8px;padding:10px 14px;cursor:pointer;background:var(--bg-secondary);color:var(--text-primary);font-size:13px;list-style:none;">
<span style="color:var(--text-secondary);font-size:12px;flex-shrink:0;">&#9656;</span>
<span style="flex:1;font-weight:500;">${escapedName}</span>
<span style="font-size:10px;color:var(--text-secondary);flex-shrink:0;">${langs}</span>
<span style="font-size:12px;font-weight:700;color:var(--accent);background:var(--tint-accent);padding:1px 6px;border-radius:4px;flex-shrink:0;">${data.articles.length}</span>
</summary>
<div style="border-top:1px solid var(--border);">`;
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'
? `<span class="lang-badge">${a.language.toUpperCase()}</span>` : '';
const link = a.source_url
? `<a href="${UI.escape(a.source_url)}" target="_blank" rel="noopener" style="color:var(--accent);text-decoration:none;font-size:14px;flex-shrink:0;opacity:0.6;" title="Artikel öffnen">&#8599;</a>` : '';
html += `<div style="display:flex;align-items:center;gap:10px;padding:8px 14px 8px 36px;font-size:12px;border-bottom:1px solid var(--border);">
<span style="color:var(--text-secondary);flex-shrink:0;min-width:90px;font-size:11px;">${time}</span>
<span style="flex:1;color:var(--text-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${headline}</span>
${langBadge}
${link}
</div>`;
});
html += `</div></details>`;
});
return html;
}
function toggleRefreshInterval() {
const mode = document.getElementById('inc-refresh-mode').value;
const field = document.getElementById('refresh-interval-field');
const startField = document.getElementById('refresh-starttime-field');
field.classList.toggle('visible', mode === 'auto');
if (startField) startField.classList.toggle('visible', mode === 'auto');
}
function _getMinIntervalMinutes() {
// Mindest-Intervall (Minuten) je nach Quellen: 30 Basis, 45 bei X oder Telegram, 60 bei beiden. International zaehlt nicht.
const tg = document.getElementById('inc-telegram');
const x = document.getElementById('inc-x');
const tgOn = !!(tg && tg.checked);
const xOn = !!(x && x.checked);
if (tgOn && xOn) return 60;
if (tgOn || xOn) return 45;
return 30;
}
function updateIntervalMin() {
const unit = parseInt(document.getElementById('inc-refresh-unit').value);
const input = document.getElementById('inc-refresh-value');
const minMinutes = _getMinIntervalMinutes();
const hint = document.getElementById('interval-min-hint');
if (unit === 1) {
// Minuten: dynamisches Minimum (30 / 45 bei X oder Telegram / 60 bei beiden)
input.min = minMinutes;
if (isNaN(parseInt(input.value)) || parseInt(input.value) < minMinutes) input.value = minMinutes;
if (hint) {
let zusatz = '';
if (minMinutes === 45) zusatz = ' (X oder Telegram aktiv)';
else if (minMinutes === 60) zusatz = ' (X und Telegram aktiv)';
hint.textContent = 'Mindestens ' + minMinutes + ' Minuten' + zusatz;
hint.style.display = '';
}
} else {
// Stunden/Tage/Wochen: eine Einheit liegt ueber jedem Minuten-Minimum
input.min = 1;
if (isNaN(parseInt(input.value)) || parseInt(input.value) < 1) input.value = 1;
if (hint) hint.style.display = 'none';
}
}
function updateVisibilityHint() {
const isPublic = document.getElementById('inc-visibility').checked;
const text = document.getElementById('visibility-text');
if (text) {
const _t = (k, fb) => (typeof T === 'function') ? T(k, fb) : fb;
text.textContent = isPublic
? _t('modal.toggle.visibility_public_text', 'Öffentlich — für alle Nutzer sichtbar')
: _t('modal.toggle.visibility_private_text', 'Privat — nur für dich sichtbar');
}
}
function updateSourcesHint() {
const intl = document.getElementById('inc-international').checked;
const hint = document.getElementById('sources-hint');
if (hint) {
hint.textContent = intl
? 'DE + internationale Feeds (Reuters, BBC, Al Jazeera etc.)'
: (typeof T === 'function' ? T('modal.hint.sources_german_only', 'Nur deutschsprachige Quellen (DE, AT, CH)') : 'Nur deutschsprachige Quellen (DE, AT, CH)');
}
}
function toggleTypeDefaults(preserveMode = false) {
const type = document.getElementById('inc-type').value;
const hint = document.getElementById('type-hint');
const refreshMode = document.getElementById('inc-refresh-mode');
const _t = (k, fb) => (typeof T === 'function') ? T(k, fb) : fb;
if (type === 'research') {
hint.textContent = _t('modal.hint.type_research', 'Recherchiert in Tiefe: Nachrichtenarchive, Parlamentsdokumente, Fachmedien, Expertenquellen. Empfohlen: Manuell starten und bei Bedarf vertiefen.');
// Nur bei Typ-Wechsel/Neuanlage Modus zurückziehen, beim Edit bestehender Lagen DB-Wert respektieren
if (!preserveMode) {
refreshMode.value = 'manual';
toggleRefreshInterval();
}
} else {
hint.textContent = _t('modal.hint.type_adhoc', 'Durchsucht laufend hunderte Nachrichtenquellen nach neuen Meldungen. Empfohlen: Automatische Aktualisierung.');
}
// Beschreibungs-Tooltip je nach Typ wechseln
const descIcon = document.getElementById('description-info-icon');
if (descIcon) {
descIcon.setAttribute('data-tooltip', type === 'research'
? 'Nenne das vollst\u00e4ndige Thema, gew\u00fcnschte Schwerpunkte und relevante URLs.\nBeispiel: "Muster GmbH: Fokus auf F\u00fchrungspersonen, Kontroversen, Finanzkennzahlen"'
: 'Beschreibe den Vorfall m\u00f6glichst genau: Was ist passiert? Wo? Wer ist beteiligt?\nJe pr\u00e4ziser, desto bessere Ergebnisse.');
}
}
// Tab-Fokus: Nur Tab-Badge (Titel-Counter) zurücksetzen, nicht alle Notifications
window.addEventListener('focus', () => {
document.title = App._originalTitle;
});
// ESC schließt Modals
// F5: Daten aktualisieren statt Seite neu laden
document.addEventListener('keydown', (e) => {
if (e.key === 'F5') {
e.preventDefault();
App.softRefresh();
}
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
// Schließ-Reihenfolge: A11y-Panel > Notification-Panel > Export-Dropdown > FC-Dropdown > Modals
if (A11yManager._isOpen) {
A11yManager._closePanel();
return;
}
if (NotificationCenter._isOpen) {
NotificationCenter.close();
return;
}
const fcMenu = document.querySelector('.fc-dropdown-menu.open');
if (fcMenu) {
fcMenu.classList.remove('open');
const fcBtn = fcMenu.previousElementSibling;
if (fcBtn) fcBtn.setAttribute('aria-expanded', 'false');
return;
}
document.querySelectorAll('.modal-overlay.active').forEach(m => {
closeModal(m.id);
});
}
});
// Keyboard-Handler: Enter/Space auf [role="button"] löst click aus (WCAG 2.1.1)
document.addEventListener('keydown', (e) => {
if ((e.key === 'Enter' || e.key === ' ') && e.target.matches('[role="button"]')) {
e.preventDefault();
e.target.click();
}
});
// Session-Ablauf prüfen (alle 60 Sekunden)
setInterval(() => {
const token = localStorage.getItem('osint_token');
if (!token) return;
try {
const payload = JSON.parse(atob(token.split('.')[1]));
const expiresAt = payload.exp * 1000;
const remaining = expiresAt - Date.now();
const fiveMinutes = 5 * 60 * 1000;
if (remaining <= 0) {
App.logout();
} else if (remaining <= fiveMinutes && !App._sessionWarningShown) {
App._sessionWarningShown = true;
const mins = Math.ceil(remaining / 60000);
UI.showToast(`Session läuft in ${mins} Minute${mins !== 1 ? 'n' : ''} ab. Bitte erneut anmelden.`, 'warning', 15000);
}
} catch (e) { /* Token nicht parsbar */ }
}, 60000);
// Modal-Overlays: Klick auf Backdrop schließt NICHT mehr (nur X-Button)
document.addEventListener('click', (e) => {
if (e.target.classList.contains('modal-overlay') && e.target.classList.contains('active')) {
// closeModal deaktiviert - Modal nur ueber X-Button schliessbar
}
});
// App starten
document.addEventListener('click', (e) => {
});
document.addEventListener('DOMContentLoaded', () => App.init());
// Auto-Resize fuer Textarea
function _autoResizeTextarea(el) {
if (!el) return;
el.style.height = 'auto';
el.style.height = Math.max(80, el.scrollHeight) + 'px';
}
// Titel-Input: Button aktivieren + Textarea Auto-Resize
document.addEventListener('DOMContentLoaded', () => {
const titleInput = document.getElementById('inc-title');
if (titleInput) {
titleInput.addEventListener('input', function() {
const btn = document.getElementById('btn-enhance-description');
if (btn) btn.disabled = this.value.trim().length < 3;
});
}
const descInput = document.getElementById('inc-description');
if (descInput) {
descInput.addEventListener('input', function() { _autoResizeTextarea(this); });
}
});