/**
* OSINT Lagemonitor - Hauptanwendungslogik.
*/
/** Feste Zeitzone fuer alle Anzeigen — NIEMALS aendern. */
const TIMEZONE = 'Europe/Berlin';
/** Gibt Jahr/Monat(0-basiert)/Tag/Stunde/Minute in Berliner Zeit zurueck. */
function _tz(d) {
const s = d.toLocaleString('en-CA', {
timeZone: TIMEZONE, year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false
});
const m = s.match(/(\d{4})-(\d{2})-(\d{2}),?\s*(\d{2}):(\d{2}):(\d{2})/);
if (!m) return { year: d.getFullYear(), month: d.getMonth(), date: d.getDate(), hours: d.getHours(), minutes: d.getMinutes() };
return { year: +m[1], month: +m[2] - 1, date: +m[3], hours: +m[4], minutes: +m[5] };
}
/**
* Theme Manager: Dark/Light Theme Toggle mit localStorage-Persistenz.
*/
const ThemeManager = {
_key: 'osint_theme',
init() {
const saved = localStorage.getItem(this._key);
const theme = saved || 'dark';
document.documentElement.setAttribute('data-theme', theme);
this._updateIcon(theme);
},
toggle() {
const current = document.documentElement.getAttribute('data-theme') || 'dark';
const next = current === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', next);
localStorage.setItem(this._key, next);
this._updateIcon(next);
UI.updateMapTheme();
},
_updateIcon(theme) {
const el = document.getElementById('theme-toggle');
if (!el) return;
el.classList.remove('dark', 'light');
el.classList.add(theme);
el.setAttribute('aria-checked', theme === 'dark' ? 'true' : 'false');
}
};
/**
* Barrierefreiheits-Manager: Panel mit 4 Schaltern (Kontrast, Focus, Schrift, Animationen).
*/
const A11yManager = {
_key: 'osint_a11y',
_isOpen: false,
_settings: { contrast: false, focus: false, fontsize: false, motion: false },
init() {
// Einstellungen aus localStorage laden
try {
const saved = JSON.parse(localStorage.getItem(this._key) || '{}');
Object.keys(this._settings).forEach(k => {
if (typeof saved[k] === 'boolean') this._settings[k] = saved[k];
});
} catch (e) { /* Ungültige Daten ignorieren */ }
// Button + Panel dynamisch in .header-right einfügen (vor Theme-Toggle)
const headerRight = document.querySelector('.header-right');
const themeToggle = document.getElementById('theme-toggle');
if (!headerRight) return;
const container = document.createElement('div');
container.className = 'a11y-center';
container.innerHTML = `
`;
if (themeToggle) {
headerRight.insertBefore(container, themeToggle);
} else {
headerRight.prepend(container);
}
// Toggle-Event-Listener
['contrast', 'focus', 'fontsize', 'motion'].forEach(key => {
document.getElementById('a11y-' + key).addEventListener('change', () => this.toggle(key));
});
// Button öffnet/schließt Panel
document.getElementById('a11y-btn').addEventListener('click', (e) => {
e.stopPropagation();
this._isOpen ? this._closePanel() : this._openPanel();
});
// Klick außerhalb schließt Panel
document.addEventListener('click', (e) => {
if (this._isOpen && !container.contains(e.target)) {
this._closePanel();
}
});
// Keyboard: Esc schließt, Pfeiltasten navigieren
container.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && this._isOpen) {
e.stopPropagation();
this._closePanel();
return;
}
if (!this._isOpen) return;
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
const options = Array.from(document.querySelectorAll('.a11y-option input[type="checkbox"]'));
const idx = options.indexOf(document.activeElement);
let next;
if (e.key === 'ArrowDown') {
next = idx < options.length - 1 ? idx + 1 : 0;
} else {
next = idx > 0 ? idx - 1 : options.length - 1;
}
options[next].focus();
}
});
// Einstellungen anwenden + Checkboxen synchronisieren
this._apply();
this._syncUI();
},
toggle(key) {
this._settings[key] = !this._settings[key];
this._apply();
this._syncUI();
this._save();
},
_apply() {
const root = document.documentElement;
Object.keys(this._settings).forEach(k => {
if (this._settings[k]) {
root.setAttribute('data-a11y-' + k, 'true');
} else {
root.removeAttribute('data-a11y-' + k);
}
});
},
_syncUI() {
Object.keys(this._settings).forEach(k => {
const cb = document.getElementById('a11y-' + k);
if (cb) cb.checked = this._settings[k];
});
},
_save() {
localStorage.setItem(this._key, JSON.stringify(this._settings));
},
_openPanel() {
this._isOpen = true;
document.getElementById('a11y-panel').style.display = '';
document.getElementById('a11y-btn').setAttribute('aria-expanded', 'true');
// Fokus auf erste Option setzen
requestAnimationFrame(() => {
const first = document.querySelector('.a11y-option input[type="checkbox"]');
if (first) first.focus();
});
},
_closePanel() {
this._isOpen = false;
document.getElementById('a11y-panel').style.display = 'none';
const btn = document.getElementById('a11y-btn');
btn.setAttribute('aria-expanded', 'false');
btn.focus();
}
};
/**
* Notification-Center: Glocke mit Badge + History-Panel.
*/
const NotificationCenter = {
_notifications: [],
_unreadCount: 0,
_isOpen: false,
_maxItems: 50,
_syncTimer: null,
async init() {
// Glocken-Container dynamisch in .header-right vor #header-user einfügen
const headerRight = document.querySelector('.header-right');
const headerUser = document.getElementById('header-user');
if (!headerRight || !headerUser) return;
const container = document.createElement('div');
container.className = 'notification-center';
container.innerHTML = `
0
`;
headerRight.insertBefore(container, headerUser);
// Event-Listener
document.getElementById('notification-bell').addEventListener('click', (e) => {
e.stopPropagation();
this.toggle();
});
document.getElementById('notification-mark-read').addEventListener('click', (e) => {
e.stopPropagation();
this.markAllRead();
});
// Klick außerhalb schließt Panel
document.addEventListener('click', (e) => {
if (this._isOpen && !container.contains(e.target)) {
this.close();
}
});
// Notifications aus DB laden
await this._loadFromDB();
},
add(notification) {
// Optimistisches UI: sofort anzeigen
notification.read = false;
notification.timestamp = notification.timestamp || new Date().toISOString();
this._notifications.unshift(notification);
if (this._notifications.length > this._maxItems) {
this._notifications.pop();
}
this._unreadCount++;
this._updateBadge();
this._renderList();
// DB-Sync mit Debounce (Orchestrator schreibt parallel in DB)
clearTimeout(this._syncTimer);
this._syncTimer = setTimeout(() => this._syncFromDB(), 500);
},
toggle() {
this._isOpen ? this.close() : this.open();
},
open() {
this._isOpen = true;
const panel = document.getElementById('notification-panel');
if (panel) panel.style.display = 'flex';
const bell = document.getElementById('notification-bell');
if (bell) bell.setAttribute('aria-expanded', 'true');
},
close() {
this._isOpen = false;
const panel = document.getElementById('notification-panel');
if (panel) panel.style.display = 'none';
const bell = document.getElementById('notification-bell');
if (bell) bell.setAttribute('aria-expanded', 'false');
},
async markAllRead() {
this._notifications.forEach(n => n.read = true);
this._unreadCount = 0;
this._updateBadge();
this._renderList();
// In DB als gelesen markieren (fire-and-forget)
try {
await API.markNotificationsRead(null);
} catch (e) {
console.warn('Notifications als gelesen markieren fehlgeschlagen:', e);
}
},
_updateBadge() {
const badge = document.getElementById('notification-badge');
if (!badge) return;
if (this._unreadCount > 0) {
badge.style.display = 'flex';
badge.textContent = this._unreadCount > 99 ? '99+' : this._unreadCount;
document.title = `(${this._unreadCount}) ${App._originalTitle}`;
} else {
badge.style.display = 'none';
document.title = App._originalTitle;
}
},
_renderList() {
const list = document.getElementById('notification-panel-list');
if (!list) return;
if (this._notifications.length === 0) {
list.innerHTML = 'Keine Benachrichtigungen
';
return;
}
list.innerHTML = this._notifications.map(n => {
const time = new Date(n.timestamp);
const timeStr = time.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', timeZone: TIMEZONE });
const unreadClass = n.read ? '' : ' unread';
const icon = n.icon || 'info';
return `
${this._iconSymbol(icon)}
${this._escapeHtml(n.title)}
${this._escapeHtml(n.text)}
${timeStr}
`;
}).join('');
},
_handleClick(incidentId) {
this.close();
if (incidentId) {
App.selectIncident(incidentId);
}
},
_iconSymbol(type) {
switch (type) {
case 'success': return '\u2713';
case 'warning': return '!';
case 'error': return '\u2717';
default: return 'i';
}
},
_escapeHtml(text) {
const d = document.createElement('div');
d.textContent = text || '';
return d.innerHTML;
},
async _loadFromDB() {
try {
const items = await API.listNotifications(50);
this._notifications = items.map(n => ({
id: n.id,
incident_id: n.incident_id,
title: n.title,
text: n.text,
icon: n.icon || 'info',
type: n.type,
read: !!n.is_read,
timestamp: n.created_at,
}));
this._unreadCount = this._notifications.filter(n => !n.read).length;
this._updateBadge();
this._renderList();
} catch (e) {
console.warn('Notifications laden fehlgeschlagen:', e);
}
},
async _syncFromDB() {
try {
const items = await API.listNotifications(50);
this._notifications = items.map(n => ({
id: n.id,
incident_id: n.incident_id,
title: n.title,
text: n.text,
icon: n.icon || 'info',
type: n.type,
read: !!n.is_read,
timestamp: n.created_at,
}));
this._unreadCount = this._notifications.filter(n => !n.read).length;
this._updateBadge();
this._renderList();
} catch (e) {
console.warn('Notifications sync fehlgeschlagen:', e);
}
},
};
const App = {
currentIncidentId: null,
incidents: [],
_originalTitle: document.title,
_refreshingIncidents: new Set(),
_editingIncidentId: null,
_currentArticles: [],
_currentSnapshots: [],
_snapshotFullCache: new Map(),
_currentSources: [],
_currentIncidentType: 'adhoc',
_sidebarFilter: 'all',
_currentUsername: '',
_allSources: [],
_sourcesOnly: [],
_myExclusions: [], // [{domain, notes, created_at}]
_expandedGroups: new Set(),
_editingSourceId: null,
_timelineFilter: 'all',
_timelineRange: 'all',
_activeStripWindow: null,
_timelineSearchTimer: null,
_pendingComplete: null,
_pendingCompleteTimer: null,
async init() {
ThemeManager.init();
A11yManager.init();
// Auth prüfen
const token = localStorage.getItem('osint_token');
if (!token) {
window.location.href = '/';
return;
}
try {
const user = await API.getMe();
this.user = user;
this._currentUsername = user.email;
document.getElementById('header-user').textContent = user.email;
// Dropdown-Daten befuellen
const orgNameEl = document.getElementById('header-org-name');
if (orgNameEl) orgNameEl.textContent = user.org_name || '-';
const licInfoEl = document.getElementById('header-license-info');
if (licInfoEl) {
const licenseLabels = {
trial: 'Trial',
annual: 'Jahreslizenz',
permanent: 'Permanent',
};
const label = user.read_only ? 'Abgelaufen'
: licenseLabels[user.license_type] || user.license_status || '-';
licInfoEl.textContent = label;
}
// Credits-Anzeige im Dropdown
const creditsSection = document.getElementById('credits-section');
if (creditsSection && user.credits_total) {
creditsSection.style.display = 'block';
const bar = document.getElementById('credits-bar');
const remainingEl = document.getElementById('credits-remaining');
const totalEl = document.getElementById('credits-total');
const remaining = user.credits_remaining || 0;
const total = user.credits_total || 1;
const percentUsed = user.credits_percent_used || 0;
const percentRemaining = Math.max(0, 100 - percentUsed);
remainingEl.textContent = remaining.toLocaleString('de-DE');
totalEl.textContent = total.toLocaleString('de-DE');
bar.style.width = percentRemaining + '%';
// Farbwechsel je nach Verbrauch
bar.classList.remove('warning', 'critical');
if (percentUsed > 80) {
bar.classList.add('critical');
} else if (percentUsed > 50) {
bar.classList.add('warning');
}
const percentEl = document.getElementById("credits-percent");
if (percentEl) percentEl.textContent = percentRemaining.toFixed(0) + "% verbleibend";
}
// Dropdown Toggle
const userBtn = document.getElementById('header-user-btn');
const userDropdown = document.getElementById('header-user-dropdown');
if (userBtn && userDropdown) {
userBtn.addEventListener('click', (e) => {
e.stopPropagation();
const isOpen = userDropdown.classList.toggle('open');
userBtn.setAttribute('aria-expanded', isOpen);
});
userDropdown.addEventListener('click', (e) => {
e.stopPropagation();
});
document.addEventListener('click', () => {
userDropdown.classList.remove('open');
userBtn.setAttribute('aria-expanded', 'false');
});
}
// Warnung bei Read-Only (Lizenz abgelaufen oder Token-Budget aufgebraucht)
const warningEl = document.getElementById('header-license-warning');
if (warningEl) {
if (user.read_only) {
let text = 'Nur Lesezugriff';
const reason = user.read_only_reason;
if (reason === 'budget_exceeded') {
text = 'Token-Budget aufgebraucht – nur Lesezugriff. Für Aufstockung oder Upgrade bitte info@aegis-sight.de kontaktieren.';
} else if (reason === 'expired') {
text = 'Lizenz abgelaufen – nur Lesezugriff';
} else if (reason === 'no_license') {
text = 'Keine aktive Lizenz – nur Lesezugriff';
} else if (reason === 'org_disabled') {
text = 'Organisation deaktiviert – nur Lesezugriff';
}
warningEl.textContent = text;
warningEl.classList.add('visible');
} else {
warningEl.textContent = '';
warningEl.classList.remove('visible');
}
}
// --- Global Admin: Org-Switcher (herausnehmbar) ---
if (user.is_global_admin) {
this._initOrgSwitcher(user.tenant_id);
}
} catch {
window.location.href = '/';
return;
}
// Event-Listener
document.getElementById('logout-btn').addEventListener('click', () => this.logout());
document.getElementById('new-incident-btn').addEventListener('click', () => openModal('modal-new'));
document.getElementById('new-incident-form').addEventListener('submit', (e) => this.handleFormSubmit(e));
document.getElementById('refresh-btn').addEventListener('click', () => this.handleRefresh());
document.getElementById('delete-incident-btn').addEventListener('click', () => this.handleDelete());
document.getElementById('edit-incident-btn').addEventListener('click', () => this.handleEdit());
document.getElementById('archive-incident-btn').addEventListener('click', () => this.handleArchive());
document.getElementById('inc-international').addEventListener('change', () => updateSourcesHint());
document.getElementById('inc-visibility').addEventListener('change', () => updateVisibilityHint());
// Telegram-Kategorien Toggle
const tgCheckbox = document.getElementById('inc-telegram');
if (tgCheckbox) {
}
// Feedback
document.getElementById('feedback-form').addEventListener('submit', (e) => this.submitFeedback(e));
document.getElementById('fb-message').addEventListener('input', (e) => {
document.getElementById('fb-char-count').textContent = e.target.value.length.toLocaleString('de-DE');
});
// Sidebar-Chevrons initial auf offen setzen (Archiv geschlossen)
document.querySelectorAll('.sidebar-chevron').forEach(c => c.classList.add('open'));
document.getElementById('chevron-archived-incidents').classList.remove('open');
// Lagen laden (frueh, damit Sidebar sofort sichtbar)
await this.loadIncidents();
// Netzwerkanalysen laden
// Notification-Center initialisieren
try { await NotificationCenter.init(); } catch (e) { console.warn('NotificationCenter:', e); }
// WebSocket
WS.connect();
WS.on('status_update', (msg) => this.handleStatusUpdate(msg));
WS.on('refresh_complete', (msg) => this.handleRefreshComplete(msg));
WS.on('refresh_summary', (msg) => this.handleRefreshSummary(msg));
WS.on('refresh_error', (msg) => this.handleRefreshError(msg));
WS.on('refresh_cancelled', (msg) => this.handleRefreshCancelled(msg));
// Laufende Refreshes wiederherstellen
try {
const data = await API.getRefreshingIncidents();
const details = data.details || {};
const currentTask = data.current;
const queuedIds = data.queued || [];
// Restore running refreshes
if (data.refreshing && data.refreshing.length > 0) {
data.refreshing.forEach(id => {
this._refreshingIncidents.add(id);
const d = details[String(id)] || {};
const inc = this.incidents.find(i => i.id === id);
const isFirst = inc && !inc.has_summary;
const isCurrent = (id === currentTask);
// Use 'researching' as default step for the actively running task
UI.showProgress(isCurrent ? 'researching' : 'queued', { started_at: d.started_at }, id, isFirst);
});
}
// Restore queued incidents
if (queuedIds.length > 0) {
queuedIds.forEach((id, idx) => {
this._refreshingIncidents.add(id);
const inc = this.incidents.find(i => i.id === id);
const isFirst = inc && !inc.has_summary;
UI.showProgress('queued', { queue_position: idx + 1 }, id, isFirst);
});
}
if (data.refreshing.length > 0 || queuedIds.length > 0) {
this.renderSidebar();
}
} catch (e) { /* Kein kritischer Fehler */ }
// Heartbeat: periodischer Status-Abgleich als Sicherheitsnetz
this._statusSyncInterval = setInterval(() => this.syncRefreshStatus(), 60000);
// Zuletzt ausgewählte Lage wiederherstellen
const savedId = localStorage.getItem('selectedIncidentId');
if (savedId) {
const id = parseInt(savedId, 10);
if (this.incidents.some(inc => inc.id === id)) {
await this.selectIncident(id);
}
}
// Leaflet-Karte nachladen falls CDN langsam war
setTimeout(() => UI.retryPendingMap(), 2000);
},
async loadIncidents() {
try {
this.incidents = await API.listIncidents();
this.renderSidebar();
} catch (err) {
UI.showToast('Fehler beim Laden der Lagen: ' + err.message, 'error');
}
},
renderSidebar() {
const activeContainer = document.getElementById('active-incidents');
const researchContainer = document.getElementById('active-research');
const archivedContainer = document.getElementById('archived-incidents');
// Filter-Buttons aktualisieren
document.querySelectorAll('.sidebar-filter-btn').forEach(btn => {
const isActive = btn.dataset.filter === this._sidebarFilter;
btn.classList.toggle('active', isActive);
btn.setAttribute('aria-pressed', String(isActive));
});
// Lagen nach Filter einschränken
let filtered = this.incidents;
if (this._sidebarFilter === 'mine') {
filtered = filtered.filter(i => i.created_by_username === this._currentUsername);
}
// Aktive Lagen nach Typ aufteilen
const activeAdhoc = filtered.filter(i => i.status === 'active' && (!i.type || i.type === 'adhoc'));
const activeResearch = filtered.filter(i => i.status === 'active' && i.type === 'research');
const archived = filtered.filter(i => i.status === 'archived');
const emptyLabelAdhoc = this._sidebarFilter === 'mine' ? 'Kein eigenes Live-Monitoring' : 'Kein Live-Monitoring';
const emptyLabelResearch = this._sidebarFilter === 'mine' ? 'Keine eigenen Deep-Research' : 'Keine Deep-Research';
activeContainer.innerHTML = activeAdhoc.length
? activeAdhoc.map(i => UI.renderIncidentItem(i, i.id === this.currentIncidentId)).join('')
: `${emptyLabelAdhoc}
`;
researchContainer.innerHTML = activeResearch.length
? activeResearch.map(i => UI.renderIncidentItem(i, i.id === this.currentIncidentId)).join('')
: `${emptyLabelResearch}
`;
archivedContainer.innerHTML = archived.length
? archived.map(i => UI.renderIncidentItem(i, i.id === this.currentIncidentId)).join('')
: 'Kein Archiv
';
// Zähler aktualisieren
const countAdhoc = document.getElementById('count-active-incidents');
const countResearch = document.getElementById('count-active-research');
const countArchived = document.getElementById('count-archived-incidents');
if (countAdhoc) countAdhoc.textContent = `(${activeAdhoc.length})`;
if (countResearch) countResearch.textContent = `(${activeResearch.length})`;
if (countArchived) countArchived.textContent = `(${archived.length})`;
// Sidebar-Stats aktualisieren
this.updateSidebarStats();
},
setSidebarFilter(filter) {
this._sidebarFilter = filter;
this.renderSidebar();
},
_announceForSR(text) {
let el = document.getElementById('sr-announcement');
if (!el) {
el = document.createElement('div');
el.id = 'sr-announcement';
el.setAttribute('role', 'status');
el.setAttribute('aria-live', 'polite');
el.className = 'sr-only';
document.body.appendChild(el);
}
el.textContent = '';
requestAnimationFrame(() => { el.textContent = text; });
},
async selectIncident(id) {
this.closeRefreshHistory();
this.currentIncidentId = id;
localStorage.setItem('selectedIncidentId', id);
const inc = this.incidents.find(i => i.id === id);
if (inc) this._announceForSR('Lage ausgewählt: ' + inc.title);
this.renderSidebar();
var mc = document.getElementById("main-content");
mc.scrollTop = 0;
document.getElementById('empty-state').style.display = 'none';
document.getElementById('incident-view').style.display = 'flex';
// GridStack-Animation deaktivieren und Scroll komplett sperren
// bis alle Tile-Resize-Operationen (doppeltes rAF) abgeschlossen sind
var gridEl = document.querySelector('.tab-panels');
if (gridEl) gridEl.classList.remove('grid-stack-animate');
var scrollLock = function() { mc.scrollTop = 0; };
mc.addEventListener('scroll', scrollLock);
// gridstack-Layout initialisieren (einmalig)
if (typeof LayoutManager !== 'undefined') LayoutManager.init();
// Refresh-Status fuer diese Lage wiederherstellen
const isRefreshing = this._refreshingIncidents.has(id);
this._updateRefreshButton(isRefreshing);
// Hide any popup/mini from previous incident
const prevOverlay = document.getElementById('progress-overlay');
if (prevOverlay) prevOverlay.style.display = 'none';
const prevMini = document.getElementById('progress-mini');
if (prevMini) prevMini.style.display = 'none';
const blurTarget = document.getElementById('incident-view');
// Wenn gerade ein erster Refresh laeuft, Blur stehen lassen statt
// remove+add im selben Tick — CSS filter:blur greift sonst nicht.
const _restState = isRefreshing ? UI._progressState[id] : null;
const _willReBlur = _restState && _restState.isFirst && !_restState.minimized;
if (blurTarget && !_willReBlur) blurTarget.classList.remove('refresh-blurred');
if (isRefreshing) {
const state = UI._progressState[id];
if (state) {
// Restore exactly as it was: popup open or minimized
if (state.minimized) {
UI._showMiniProgress(state.step, state);
} else {
UI._showPopupProgress(state.step, {}, state);
}
UI._lockActionsIfFirst(state.isFirst);
} else {
// No state yet — show popup (first status update will refine)
UI.showProgress('researching', {}, id, false);
}
} else {
UI._lockActionsIfFirst(false);
}
// Alte Inhalte sofort leeren um Flackern beim Wechsel zu vermeiden
var el;
el = document.getElementById("incident-title"); if (el) el.textContent = "";
el = document.getElementById("summary-content"); if (el) el.scrollTop = 0;
el = document.getElementById("summary-text"); if (el) el.innerHTML = "";
el = document.getElementById("zusammenfassung-text"); if (el) el.innerHTML = "";
el = document.getElementById("factcheck-filters"); if (el) el.innerHTML = "";
el = document.querySelector(".factcheck-list"); if (el) el.scrollTop = 0;
el = document.getElementById("factcheck-list"); if (el) el.innerHTML = "";
el = document.getElementById("source-overview-content"); if (el) el.innerHTML = "";
el = document.getElementById("source-overview-header-stats"); if (el) el.textContent = "";
el = document.getElementById("timeline-entries"); if (el) el.innerHTML = "";
await this.loadIncidentDetail(id);
// Scroll-Sperre nach 3 Frames aufheben (nach allen doppelten rAF-Callbacks)
mc.scrollTop = 0;
requestAnimationFrame(() => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
mc.scrollTop = 0;
mc.removeEventListener('scroll', scrollLock);
if (gridEl) gridEl.classList.add('grid-stack-animate');
});
});
});
},
async loadIncidentDetail(id) {
try {
const [incident, articlesResponse, factchecks, snapshots, locationsResponse, sourcesResponse] = await Promise.all([
API.getIncident(id),
API.getArticles(id, { limit: 500, offset: 0 }),
API.getFactChecks(id),
API.getSnapshots(id),
API.getLocations(id).catch(() => []),
API.getIncidentSources(id).catch(() => ({ sources: [] })),
]);
// Sources-Array (ersetzt frueheres incident.sources_json — lazy via /sources-Endpunkt)
this._currentSources = (sourcesResponse && sourcesResponse.sources) || [];
// Articles: neue Shape {total, articles} oder alter nackter Array (Rueckwaertskompatibel)
let articles, articlesTotal;
if (Array.isArray(articlesResponse)) {
articles = articlesResponse;
articlesTotal = articlesResponse.length;
} else {
articles = articlesResponse.articles || [];
articlesTotal = articlesResponse.total || articles.length;
}
// Locations-API gibt jetzt {category_labels, locations} oder Array (Rueckwaertskompatibel)
let locations, categoryLabels;
if (Array.isArray(locationsResponse)) {
locations = locationsResponse;
categoryLabels = null;
} else if (locationsResponse && locationsResponse.locations) {
locations = locationsResponse.locations;
categoryLabels = locationsResponse.category_labels || null;
} else {
locations = [];
categoryLabels = null;
}
this._currentArticlesTotal = articlesTotal;
this._currentArticlesLoaded = articles.length;
this._currentIncidentIdForLoad = id;
this.renderIncidentDetail(incident, articles, factchecks, snapshots, locations, categoryLabels);
// Pipeline an die geladene Lage binden (laedt /api/incidents/{id}/pipeline)
if (typeof Pipeline !== 'undefined' && Pipeline.bindToIncident) {
Pipeline.bindToIncident(id).catch(err => console.warn('pipeline-bind:', err));
}
// Quellenuebersicht aus Aggregat-Endpunkt (alle Quellen, nicht nur erste Seite)
this._loadSourcesSummary(id).catch(err => console.warn('sources-summary:', err));
// Wenn mehr Artikel existieren als initial geladen: progressiver Hintergrund-Load
if (articlesTotal > articles.length) {
this._loadRemainingArticlesInBackground(id).catch(err => console.warn('bg-articles:', err));
}
} catch (err) {
console.error('loadIncidentDetail Fehler:', err);
UI.showToast('Fehler beim Laden: ' + err.message, 'error');
}
},
/** Quellenuebersicht aus Aggregat-Endpunkt nachladen (ersetzt Client-Zaehlung). */
async _loadSourcesSummary(incidentId) {
const data = await API.getArticlesSourcesSummary(incidentId);
if (this.currentIncidentId !== incidentId) return; // User hat gewechselt
this._currentSourcesSummary = data;
const soEl = document.getElementById('source-overview-content');
const statsEl = document.getElementById('source-overview-header-stats');
if (soEl && typeof UI.renderSourceOverviewFromSummary === 'function') {
soEl.innerHTML = UI.renderSourceOverviewFromSummary(data);
}
if (statsEl && data) {
statsEl.textContent = `${data.total} Artikel aus ${data.sources.length} Quellen`;
}
},
/** Klick auf eine Quellen-Box: Liste der Artikel inline aufklappen (mutual-exclusive). */
toggleSourceOverviewDetail(el) {
if (!el) return;
const grid = el.parentElement;
if (!grid) return;
const sourceName = el.dataset.source || '';
const wasActive = el.classList.contains('active');
// Alle anderen schliessen + bestehendes Detail entfernen
grid.querySelectorAll('.source-overview-item.active').forEach(it => {
it.classList.remove('active');
it.setAttribute('aria-expanded', 'false');
});
const existingDetail = grid.querySelector('.source-overview-detail');
if (existingDetail) existingDetail.remove();
// Wenn das geklickte Item bereits aktiv war: nur schliessen
if (wasActive) return;
// Neues Detail einfuegen direkt nach dem geklickten Item
el.classList.add('active');
el.setAttribute('aria-expanded', 'true');
const type = this._currentIncidentType;
const getDate = (a) => (type === 'research' && a.published_at) ? a.published_at : (a.collected_at || a.published_at);
const articles = (this._currentArticles || [])
.filter(a => (a.source || 'Unbekannt') === sourceName)
.sort((a, b) => {
const ta = new Date(getDate(a) || 0).getTime();
const tb = new Date(getDate(b) || 0).getTime();
return tb - ta;
});
// Lagebild-Quellennummer pro Artikel ermitteln (matcht Artikel zu sources_json)
const normalize = (s) => (s || '').toLowerCase().replace(/^(der|die|das)\s+/, '').replace(/\s+/g, ' ').trim();
const sourcesList = this._currentSources || [];
const urlToNr = new Map();
sourcesList.forEach(s => {
if (s.url && s.nr != null) urlToNr.set(String(s.url).trim(), s.nr);
});
const findNr = (a) => {
// 1) Exakter URL-Match
if (a.source_url) {
const exact = urlToNr.get(String(a.source_url).trim());
if (exact != null) return exact;
}
// 2) Fallback: Match via Quellen-Namen (kann mehrfach treffen, nimm erstes)
if (a.source) {
const target = normalize(a.source);
const hit = sourcesList.find(s => s.nr != null && normalize(s.name) === target);
if (hit) return hit.nr;
}
return null;
};
const detail = document.createElement('div');
detail.className = 'source-overview-detail';
if (articles.length === 0) {
detail.innerHTML = 'Keine Artikel gefunden.
';
} else {
const fmtDate = (ts) => {
if (!ts) return '—';
try {
const d = new Date(ts);
if (isNaN(d.getTime())) return '—';
return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit', timeZone: TIMEZONE })
+ ' '
+ d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', timeZone: TIMEZONE });
} catch (e) { return '—'; }
};
const items = articles.map(a => {
const nr = findNr(a);
const numHtml = nr != null
? `[${UI.escape(String(nr))}] `
: `— `;
const dateStr = fmtDate(getDate(a));
const headline = UI.escape(a.headline_de || a.headline || '(ohne Titel)');
const inner = a.source_url
? `${headline} `
: headline;
return `
${numHtml}
${UI.escape(dateStr)}
${inner}
`;
}).join('');
detail.innerHTML = ``;
}
el.insertAdjacentElement('afterend', detail);
},
/** Restliche Artikel seitenweise im Hintergrund nachladen und in _currentArticles mergen. */
async _loadRemainingArticlesInBackground(incidentId) {
const BATCH = 500;
while (this.currentIncidentId === incidentId
&& this._currentArticlesLoaded < this._currentArticlesTotal) {
let resp;
try {
resp = await API.getArticles(incidentId, { limit: BATCH, offset: this._currentArticlesLoaded });
} catch (err) {
console.warn('Hintergrund-Load Artikel fehlgeschlagen:', err);
return;
}
if (this.currentIncidentId !== incidentId) return;
const batch = (resp && resp.articles) ? resp.articles : (Array.isArray(resp) ? resp : []);
if (!batch.length) break;
this._currentArticles = (this._currentArticles || []).concat(batch);
this._currentArticlesLoaded += batch.length;
this.rerenderTimeline();
// Kleiner Yield, damit das UI reaktiv bleibt
await new Promise(r => setTimeout(r, 30));
}
},
renderIncidentDetail(incident, articles, factchecks, snapshots, locations, categoryLabels) {
// Header Strip
{ const _e = document.getElementById('incident-title'); if (_e) _e.textContent = incident.title; }
{ const _e = document.getElementById('incident-description'); if (_e) _e.textContent = incident.description || ''; }
// Typ-Badge
const typeBadge = document.getElementById('incident-type-badge');
typeBadge.className = 'incident-type-badge ' + (incident.type === 'research' ? 'type-research' : 'type-adhoc');
typeBadge.textContent = incident.type === 'research' ? 'Analyse' : 'Live';
// Kachel-Label: 'Recherchebericht' fuer Recherche-Lagen, 'Lagebild' fuer Live-Monitoring
const _lbLabel = incident.type === 'research' ? 'Recherchebericht' : '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 = "Neues " + _lbLabel; } }
// 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 = 'Zusammenfassung';
if (incident.summary) {
const { zusammenfassung, remaining } = UI.extractZusammenfassung(incident.summary);
if (zusammenfassung) {
if (zusammenfassungText) zusammenfassungText.innerHTML = UI.renderZusammenfassung(zusammenfassung, this._currentSources);
if (zusammenfassungCard) zusammenfassungCard.style.display = '';
summaryText.innerHTML = UI.renderSummary(remaining, this._currentSources, incident.type);
} else {
if (zusammenfassungText) zusammenfassungText.innerHTML = 'Zusammenfassung wird beim n\u00e4chsten Refresh generiert. ';
if (zusammenfassungCard) zusammenfassungCard.style.display = '';
summaryText.innerHTML = UI.renderSummary(incident.summary, this._currentSources, incident.type);
}
} else {
if (zusammenfassungCard) zusammenfassungCard.style.display = 'none';
summaryText.innerHTML = 'Noch keine Zusammenfassung. Klicke auf "Aktualisieren" um die Recherche zu starten. ';
}
} else {
// Live-Monitoring (adhoc): Kachel zeigt "Neueste Entwicklungen" (max 8 Bullets mit Zeitstempel)
if (zusammenfassungTitle) zusammenfassungTitle.textContent = 'Neueste Entwicklungen';
if (zusammenfassungCard) zusammenfassungCard.style.display = '';
const devText = (incident.latest_developments || '').trim();
if (devText) {
if (zusammenfassungText) zusammenfassungText.innerHTML = UI.renderLatestDevelopments(devText, this._currentSources);
} else if (zusammenfassungText) {
zusammenfassungText.innerHTML = 'Noch keine Entwicklungen erfasst. Wird beim n\u00e4chsten Refresh generiert. ';
}
if (incident.summary) {
summaryText.innerHTML = UI.renderSummary(incident.summary, this._currentSources, incident.type);
} else {
summaryText.innerHTML = 'Noch kein Lagebild. Klicke auf "Aktualisieren" um die Recherche zu starten. ';
}
}
// Meta (im Header-Strip) — relative Zeitangabe mit vollem Datum als Tooltip
const updated = incident.updated_at ? parseUTC(incident.updated_at) : null;
const metaUpdated = document.getElementById('meta-updated');
if (updated) {
const fullDate = `${updated.toLocaleDateString('de-DE', { timeZone: TIMEZONE })} ${updated.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', timeZone: TIMEZONE })}`;
metaUpdated.textContent = `Stand: ${App._timeAgo(updated)}`;
metaUpdated.title = fullDate;
} else {
metaUpdated.textContent = '';
metaUpdated.title = '';
}
// Zeitstempel direkt im Lagebild-Card-Header
const lagebildTs = document.getElementById('lagebild-timestamp');
if (lagebildTs) {
lagebildTs.textContent = updated
? `Stand: ${updated.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', timeZone: TIMEZONE })} ${updated.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', timeZone: TIMEZONE })} Uhr`
: '';
}
{ const _e = document.getElementById('meta-refresh-mode'); if (_e) {
if (incident.refresh_mode === 'auto' && incident.refresh_start_time) {
const intervalText = App._formatInterval(incident.refresh_interval);
_e.textContent = `Auto alle ${intervalText} ab ${incident.refresh_start_time} Uhr`;
} else if (incident.refresh_mode === 'auto') {
_e.textContent = `Auto alle ${App._formatInterval(incident.refresh_interval)}`;
} else {
_e.textContent = 'Manuell';
}
} }
// International-Badge
const intlBadge = document.getElementById('intl-badge');
if (intlBadge) {
const isIntl = incident.international_sources !== false && incident.international_sources !== 0;
intlBadge.className = 'intl-badge ' + (isIntl ? 'intl-yes' : 'intl-no');
intlBadge.textContent = isIntl ? 'International' : 'Nur DE';
}
// Faktencheck
const fcFilters = document.getElementById('fc-filters');
const factcheckList = document.getElementById('factcheck-list');
if (factchecks.length > 0) {
fcFilters.innerHTML = UI.renderFactCheckFilters(factchecks);
factcheckList.innerHTML = factchecks.map(fc => UI.renderFactCheck(fc)).join('');
} else {
fcFilters.innerHTML = '';
factcheckList.innerHTML = 'Noch keine Fakten geprüft
';
}
// Quellenuebersicht wird aus dem Aggregat-Endpunkt (_loadSourcesSummary) gefuellt,
// damit sie immer alle Artikel der Lage zeigt — unabhaengig von Paginierung.
const sourceOverview = document.getElementById('source-overview-content');
if (sourceOverview) {
sourceOverview.innerHTML = 'Quellenübersicht wird geladen…
';
}
const _soStats = document.getElementById("source-overview-header-stats");
if (_soStats) {
const total = (this._currentArticlesTotal != null) ? this._currentArticlesTotal : articles.length;
_soStats.textContent = total + " Artikel";
}
// Timeline - Artikel + Snapshots zwischenspeichern und rendern
this._currentArticles = articles;
this._currentSnapshots = snapshots || [];
this._snapshotFullCache = new Map();
this._currentIncidentType = incident.type;
// Tab-Auswahl: gemerkt pro Lage (localStorage), Default = erster Tab
if (typeof LayoutManager !== 'undefined' && typeof LayoutManager.restoreTabFor === 'function') {
LayoutManager.restoreTabFor(incident.id);
}
this._timelineFilter = 'all';
this._timelineRange = 'all';
this._activeStripWindow = null;
const _tsEl = document.getElementById('timeline-search'); if (_tsEl) _tsEl.value = '';
document.querySelectorAll('.ht-filter-btn').forEach(btn => {
const isActive = btn.dataset.filter === 'all';
btn.classList.toggle('active', isActive);
btn.setAttribute('aria-pressed', String(isActive));
});
document.querySelectorAll('.ht-range-btn').forEach(btn => {
const isActive = btn.dataset.range === 'all';
btn.classList.toggle('active', isActive);
btn.setAttribute('aria-pressed', String(isActive));
});
this.rerenderTimeline();
this._resizeTimelineTile();
// Karte rendern
UI.renderMap(locations || [], categoryLabels);
},
_collectEntries(filterType, searchTerm, range) {
const type = this._currentIncidentType;
const getArticleDate = (a) => (type === 'research' && a.published_at) ? a.published_at : a.collected_at;
let entries = [];
if (filterType === 'all' || filterType === 'articles') {
let articles = this._currentArticles || [];
if (searchTerm) {
articles = articles.filter(a => {
const text = `${a.headline || ''} ${a.headline_de || ''} ${a.source || ''} ${a.content_de || ''} ${a.content_original || ''}`.toLowerCase();
return text.includes(searchTerm);
});
}
articles.forEach(a => entries.push({ kind: 'article', data: a, timestamp: getArticleDate(a) || '' }));
}
if (filterType === 'all' || filterType === 'snapshots') {
let snapshots = this._currentSnapshots || [];
if (searchTerm) {
// Suche erfolgt clientseitig ueber Preview (Snapshots-Liste enthaelt keinen Volltext mehr).
// Die asynchrone Volltext-Server-Suche wird separat ausgeloest (rerenderTimeline).
snapshots = snapshots.filter(s => (s.summary_preview || s.summary || '').toLowerCase().includes(searchTerm));
}
snapshots.forEach(s => entries.push({ kind: 'snapshot', data: s, timestamp: s.created_at || '' }));
}
if (range && range !== 'all') {
const now = Date.now();
const cutoff = range === '24h' ? now - 24 * 60 * 60 * 1000 : now - 7 * 24 * 60 * 60 * 1000;
entries = entries.filter(e => new Date(e.timestamp || 0).getTime() >= cutoff);
}
return entries;
},
_updateTimelineCount(entries) {
const articleCount = entries.filter(e => e.kind === 'article').length;
const snapshotCount = entries.filter(e => e.kind === 'snapshot').length;
const countEl = document.getElementById('article-count');
if (!countEl) return;
if (articleCount > 0 && snapshotCount > 0) {
countEl.innerHTML = ` ${articleCount} Meldung${articleCount !== 1 ? 'en' : ''} + ${snapshotCount} Lagebericht${snapshotCount !== 1 ? 'e' : ''}`;
} else if (articleCount > 0) {
countEl.innerHTML = ` ${articleCount} Meldung${articleCount !== 1 ? 'en' : ''}`;
} else if (snapshotCount > 0) {
countEl.innerHTML = ` ${snapshotCount} Lagebericht${snapshotCount !== 1 ? 'e' : ''}`;
} else {
countEl.textContent = '0 Meldungen';
}
},
debouncedRerenderTimeline() {
clearTimeout(this._timelineSearchTimer);
this._timelineSearchTimer = setTimeout(() => this.rerenderTimeline(), 250);
},
/** Heatmap-Strip oben + vertikaler Newsfeed-Stream darunter.
* Klick auf Heatmap-Balken: Stream filtert auf das Zeitfenster (aktive Balken hervorgehoben).
*/
rerenderTimeline() {
const container = document.getElementById('timeline');
if (!container) return;
const searchTerm = (document.getElementById('timeline-search')?.value || '').toLowerCase();
const filterType = this._timelineFilter;
const range = this._timelineRange;
let entries = this._collectEntries(filterType, searchTerm, range);
this._updateTimelineCount(entries);
// Strip nutzt IMMER alle Eintraege im Range (unabhaengig von Filter/Search/Strip-Window)
const stripEntries = this._collectEntries('all', '', range);
stripEntries.sort((a, b) => new Date(a.timestamp || 0) - new Date(b.timestamp || 0));
// Wenn ein Heatmap-Balken aktiv ist: Stream zusaetzlich auf dieses Zeitfenster filtern
const win = this._activeStripWindow;
if (win && entries.length > 0) {
entries = entries.filter(e => {
const ts = new Date(e.timestamp || 0).getTime();
return ts >= win.start && ts < win.end;
});
}
let html = '';
if (stripEntries.length > 0) {
html += this._renderTimelineStrip(stripEntries);
}
// Banner mit aktivem Filter
if (win) {
html += `
▼
Gefiltert auf ${UI.escape(win.label)} · ${entries.length} Eintr${entries.length === 1 ? 'ag' : 'äge'}
Filter aufheben
`;
}
html += '
';
if (entries.length === 0) {
html += win
? '
Keine Einträge in diesem Zeitfenster.
'
: (searchTerm || range !== 'all')
? '
Keine Einträge im gewählten Zeitraum.
'
: '
Noch keine Meldungen. Starte eine Recherche mit "Aktualisieren".
';
} else {
html += this._renderVerticalStream(entries);
}
html += '
';
html += '
';
container.innerHTML = html;
},
/** Granularitaets-Heuristik fuer den Newsfeed: Stunden bei kurzen Spannen, sonst Tage. */
_calcGranularity(entries) {
if (!entries || entries.length < 2) return 'day';
const ts = entries.map(e => new Date(e.timestamp || 0).getTime()).filter(t => t > 0);
if (ts.length < 2) return 'day';
const span = Math.max(...ts) - Math.min(...ts);
if (span <= 48 * 60 * 60 * 1000) return 'hour';
return 'day';
},
/** Vertikaler Stream: Datums-Trennzeilen + Lagebericht-Sektionen + Meldungen. */
_renderVerticalStream(entries) {
if (!entries || entries.length === 0) {
return 'Keine Einträge.
';
}
// Neueste oben
const sorted = [...entries].sort((a, b) => new Date(b.timestamp || 0) - new Date(a.timestamp || 0));
const granularity = this._calcGranularity(sorted);
const groups = this._groupByTimePeriod(sorted, granularity);
let html = '';
groups.forEach(g => {
const groupId = 'vt-grp-' + g.key.replace(/[^a-z0-9]/gi, '-');
html += `
`;
html += `
${UI.escape(g.label)}
`;
html += this._renderTimeGroupEntries(g.entries, this._currentIncidentType);
html += `
`;
});
html += '
';
return html;
},
/* ======= Quanti-Strip ======= */
_stripGranularity(stripEntries) {
if (stripEntries.length < 2) return 'day';
const ts = stripEntries.map(e => new Date(e.timestamp || 0).getTime()).filter(t => t > 0);
if (ts.length < 2) return 'day';
const span = Math.max(...ts) - Math.min(...ts);
const DAY = 86400000;
if (span <= 2 * DAY) return 'hour';
if (span <= 60 * DAY) return 'day';
if (span <= 365 * DAY) return 'week';
return 'month';
},
_buildStripBuckets(stripEntries, granularity) {
if (stripEntries.length === 0) return [];
const ts = stripEntries.map(e => new Date(e.timestamp || 0).getTime()).filter(t => t > 0);
if (ts.length === 0) return [];
const minTs = Math.min(...ts);
const maxTs = Math.max(...ts);
// Bucket-Start fuer minTs ermitteln
const minDate = new Date(minTs);
const tzMin = _tz(minDate);
let firstStart;
let stepMs;
if (granularity === 'hour') {
firstStart = new Date(tzMin.year, tzMin.month, tzMin.date, tzMin.hours).getTime();
stepMs = 3600000;
} else if (granularity === 'day') {
firstStart = new Date(tzMin.year, tzMin.month, tzMin.date).getTime();
stepMs = 86400000;
} else if (granularity === 'week') {
const dow = (minDate.getDay() + 6) % 7; // 0=Mo
firstStart = new Date(tzMin.year, tzMin.month, tzMin.date - dow).getTime();
stepMs = 7 * 86400000;
} else {
firstStart = new Date(tzMin.year, tzMin.month, 1).getTime();
stepMs = null; // dynamisch (Monatsgrenzen)
}
const buckets = [];
const fmt = (t) => {
const d = new Date(t);
if (granularity === 'hour') return d.toLocaleString('de-DE', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit', timeZone: TIMEZONE });
if (granularity === 'day') return d.toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: 'short', timeZone: TIMEZONE });
if (granularity === 'week') return 'Woche ab ' + d.toLocaleDateString('de-DE', { day: '2-digit', month: 'short', timeZone: TIMEZONE });
return d.toLocaleDateString('de-DE', { month: 'long', year: 'numeric', timeZone: TIMEZONE });
};
if (granularity === 'month') {
let d = new Date(firstStart);
while (d.getTime() <= maxTs && buckets.length < 240) {
const start = d.getTime();
const next = new Date(d.getFullYear(), d.getMonth() + 1, 1).getTime();
buckets.push({ start, end: next, label: fmt(start), articles: 0, snapshots: 0 });
d = new Date(next);
}
} else {
for (let t = firstStart; t <= maxTs && buckets.length < 240; t += stepMs) {
buckets.push({ start: t, end: t + stepMs, label: fmt(t), articles: 0, snapshots: 0 });
}
}
// Eintraege zaehlen
stripEntries.forEach(e => {
const ets = new Date(e.timestamp || 0).getTime();
// Linear-Suche, da Buckets sortiert; bei vielen Buckets ggf. Binary
for (let i = 0; i < buckets.length; i++) {
if (ets >= buckets[i].start && ets < buckets[i].end) {
if (e.kind === 'article') buckets[i].articles++;
else if (e.kind === 'snapshot') buckets[i].snapshots++;
break;
}
}
});
return buckets;
},
_renderTimelineStrip(stripEntries) {
const granularity = this._stripGranularity(stripEntries);
const buckets = this._buildStripBuckets(stripEntries, granularity);
if (buckets.length === 0) return '';
const maxCount = Math.max(1, ...buckets.map(b => b.articles));
const win = this._activeStripWindow;
let html = '';
html += '
';
buckets.forEach(b => {
const intensity = b.articles > 0 ? Math.min(1, b.articles / maxCount) : 0;
const cls = ['ht-strip-cell'];
if (b.snapshots > 0) cls.push('has-snapshot');
if (b.articles === 0 && b.snapshots === 0) cls.push('empty');
if (win && win.start === b.start && win.end === b.end) cls.push('active');
const tip = `${b.label}: ${b.articles} Meldung${b.articles === 1 ? '' : 'en'}` +
(b.snapshots > 0 ? ` + ${b.snapshots} Lagebericht${b.snapshots === 1 ? '' : 'e'}` : '');
// data-Attribute statt JSON-String im onclick-Inline (vermeidet Quote-Konflikte bei Labels mit Komma/Anführungszeichen)
html += `
`;
});
html += '
';
// Wenige Datums-Labels unter dem Strip
const labelCount = Math.min(buckets.length, 6);
const stride = Math.max(1, Math.floor(buckets.length / labelCount));
const labelTexts = [];
for (let i = 0; i < buckets.length; i += stride) {
const b = buckets[i];
const d = new Date(b.start);
let txt;
if (granularity === 'hour') txt = d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', timeZone: TIMEZONE });
else if (granularity === 'day') txt = d.toLocaleDateString('de-DE', { day: '2-digit', month: 'short', timeZone: TIMEZONE });
else if (granularity === 'week') txt = 'KW ' + d.toLocaleDateString('de-DE', { day: '2-digit', month: 'short', timeZone: TIMEZONE });
else txt = d.toLocaleDateString('de-DE', { month: 'short', year: '2-digit', timeZone: TIMEZONE });
labelTexts.push({ text: txt, idx: i });
}
if (labelTexts.length) {
html += '
';
const seen = new Set(labelTexts.map(l => l.idx));
for (let i = 0; i < buckets.length; i++) {
if (seen.has(i)) {
const t = labelTexts.find(l => l.idx === i).text;
html += `
${UI.escape(t)}
`;
} else {
html += '
';
}
}
html += '
';
}
html += '
';
return html;
},
setTimelineFilter(filter) {
this._timelineFilter = filter;
this._activeStripWindow = null;
document.querySelectorAll('.ht-filter-btn').forEach(btn => {
const isActive = btn.dataset.filter === filter;
btn.classList.toggle('active', isActive);
btn.setAttribute('aria-pressed', String(isActive));
});
this.rerenderTimeline();
},
setTimelineRange(range) {
this._timelineRange = range;
this._activeStripWindow = null;
document.querySelectorAll('.ht-range-btn').forEach(btn => {
const isActive = btn.dataset.range === range;
btn.classList.toggle('active', isActive);
btn.setAttribute('aria-pressed', String(isActive));
});
this.rerenderTimeline();
},
/** Robuster Click-Handler fuer Heatmap-Cells (vermeidet Quote-Konflikte). */
handleStripClick(el) {
if (!el) return;
const start = parseInt(el.dataset.start, 10);
const end = parseInt(el.dataset.end, 10);
const label = el.dataset.label || '';
if (!isNaN(start) && !isNaN(end)) {
this.openTimelineWindow(start, end, label);
}
},
/** Klick auf Heatmap-Balken: Stream auf dieses Zeitfenster filtern.
* Zweiter Klick auf denselben Balken hebt den Filter auf.
*/
openTimelineWindow(startMs, endMs, label) {
const win = this._activeStripWindow;
if (win && win.start === startMs && win.end === endMs) {
this._activeStripWindow = null;
} else {
this._activeStripWindow = { start: startMs, end: endMs, label: label || '' };
}
this.rerenderTimeline();
},
/** Strip-Filter aufheben (z.B. via Banner-Button). */
clearStripWindow() {
this._activeStripWindow = null;
this.rerenderTimeline();
},
_resizeTimelineTile() {
// Tab-Modus: Kein internes Resize noetig, Panel waechst mit Inhalt.
// Wir scrollen lediglich ein offenes Detail in den sichtbaren Bereich.
requestAnimationFrame(() => { requestAnimationFrame(() => {
const card = document.querySelector('.timeline-card');
if (!card) return;
const cardBottom = card.getBoundingClientRect().bottom;
const viewBottom = window.innerHeight;
if (cardBottom > viewBottom) {
window.scrollBy({ top: cardBottom - viewBottom + 16, behavior: document.documentElement.dataset.a11yMotion ? 'instant' : 'smooth' });
}
}); });
},
_buildFullVerticalTimeline(filterType, searchTerm) {
let entries = this._collectEntries(filterType, searchTerm);
if (entries.length === 0) {
return 'Keine Einträge.
';
}
entries.sort((a, b) => new Date(b.timestamp || 0) - new Date(a.timestamp || 0));
const granularity = this._calcGranularity(entries);
const groups = this._groupByTimePeriod(entries, granularity);
let html = '';
groups.forEach(g => {
html += `
`;
html += `
${UI.escape(g.label)}
`;
html += this._renderTimeGroupEntries(g.entries, this._currentIncidentType);
html += `
`;
});
html += '
';
return html;
},
/**
* Einträge nach Zeitperiode gruppieren.
*/
_groupByTimePeriod(entries, granularity) {
const np = _tz(new Date());
const todayKey = `${np.year}-${np.month}-${np.date}`;
const yp = _tz(new Date(Date.now() - 86400000));
const yesterdayKey = `${yp.year}-${yp.month}-${yp.date}`;
const groups = [];
let currentGroup = null;
entries.forEach(entry => {
const d = entry.timestamp ? new Date(entry.timestamp) : null;
let key, label;
if (!d || isNaN(d.getTime())) {
key = 'unknown';
label = 'Unbekannt';
} else if (granularity === 'hour') {
const ep = _tz(d);
key = `${ep.year}-${ep.month}-${ep.date}-${ep.hours}`;
label = `${ep.hours.toString().padStart(2, '0')}:00 Uhr`;
} else {
const ep = _tz(d);
key = `${ep.year}-${ep.month}-${ep.date}`;
if (key === todayKey) {
label = 'Heute';
} else if (key === yesterdayKey) {
label = 'Gestern';
} else {
label = d.toLocaleDateString('de-DE', { weekday: 'short', day: 'numeric', month: 'short', timeZone: TIMEZONE });
}
}
if (!currentGroup || currentGroup.key !== key) {
currentGroup = { key, label, entries: [] };
groups.push(currentGroup);
}
currentGroup.entries.push(entry);
});
return groups;
},
/**
* Entries einer Zeitgruppe rendern, mit Cluster-Erkennung.
*/
_renderTimeGroupEntries(entries, type) {
// Cluster-Erkennung: ≥4 Artikel pro Minute
const minuteCounts = {};
entries.forEach(e => {
if (e.kind === 'article') {
const mk = this._getMinuteKey(e.timestamp);
minuteCounts[mk] = (minuteCounts[mk] || 0) + 1;
}
});
const minuteRendered = {};
let html = '';
entries.forEach(e => {
if (e.kind === 'snapshot') {
html += this._renderSnapshotEntry(e.data);
} else {
const mk = this._getMinuteKey(e.timestamp);
const isCluster = minuteCounts[mk] >= 4;
const isFirstInCluster = isCluster && !minuteRendered[mk];
if (isFirstInCluster) minuteRendered[mk] = true;
html += this._renderArticleEntry(e.data, type, isFirstInCluster ? minuteCounts[mk] : 0);
}
});
return html;
},
/**
* Artikel-Eintrag für den Zeitstrahl rendern.
*/
_renderArticleEntry(article, type, clusterCount) {
const dateField = (type === 'research' && article.published_at)
? article.published_at : article.collected_at;
const time = dateField
? (parseUTC(dateField) || new Date(dateField)).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', timeZone: TIMEZONE })
: '--:--';
const headline = article.headline_de || article.headline;
const sourceUrl = article.source_url
? `${UI.escape(article.source)} `
: UI.escape(article.source);
const langBadge = article.language && article.language !== 'de'
? `${article.language.toUpperCase()} ` : '';
const clusterBadge = clusterCount > 0
? `${clusterCount} ` : '';
const content = article.content_de || article.content_original || '';
const hasContent = content.length > 0;
let detailHtml = '';
if (hasContent) {
const truncated = content.length > 400 ? content.substring(0, 400) + '...' : content;
detailHtml = `
${UI.escape(truncated)}
${article.source_url ? `
Artikel öffnen → ` : ''}
`;
}
return `
${time}
${sourceUrl}
${langBadge}${clusterBadge}
${UI.escape(headline)}
${detailHtml}
`;
},
/**
* Snapshot/Lagebericht-Eintrag für den Zeitstrahl rendern.
* Volltext + sources_json werden erst beim Aufklappen lazy nachgeladen.
*/
_renderSnapshotEntry(snapshot) {
const time = snapshot.created_at
? (parseUTC(snapshot.created_at) || new Date(snapshot.created_at)).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', timeZone: TIMEZONE })
: '--:--';
const stats = [];
if (snapshot.article_count) stats.push(`${snapshot.article_count} Artikel`);
if (snapshot.fact_check_count) stats.push(`${snapshot.fact_check_count} Fakten`);
const statsText = stats.join(', ');
// Vorschau: erste 200 Zeichen aus summary_preview (vom Server gekuerzt) oder Fallback summary
const previewText = snapshot.summary_preview || snapshot.summary || '';
const preview = previewText.length > 200 ? previewText.substring(0, 200) + '...' : previewText;
// Volltext aus Cache (falls bereits geladen), sonst Platzhalter fuer Lazy-Load
const cached = this._snapshotFullCache && this._snapshotFullCache.get(snapshot.id);
const detailHtml = cached
? UI.renderSummary(cached.summary, cached.sources_json, this._currentIncidentType)
: 'Lagebericht wird geladen…
';
const loadedAttr = cached ? ' data-loaded="yes"' : '';
return `
${UI.escape(preview)}
${detailHtml}
`;
},
/**
* Volltext eines Snapshots bei Bedarf nachladen und in das DOM einsetzen.
* Ergebnis wird in _snapshotFullCache gecacht.
*/
async lazyLoadSnapshotDetail(el) {
if (!el || el.dataset.loaded === 'yes' || el.dataset.loaded === 'loading') return;
const snapId = parseInt(el.dataset.snapshotId || '0', 10);
if (!snapId || !this.currentIncidentId) return;
el.dataset.loaded = 'loading';
try {
let snap = this._snapshotFullCache.get(snapId);
if (!snap) {
snap = await API.getSnapshot(this.currentIncidentId, snapId);
this._snapshotFullCache.set(snapId, snap);
}
const detailEl = el.querySelector('.vt-snapshot-detail');
if (detailEl) {
detailEl.innerHTML = UI.renderSummary(snap.summary, snap.sources_json, this._currentIncidentType);
}
el.dataset.loaded = 'yes';
// Nach dem Laden die Timeline-Kachel an neue Hoehe anpassen
if (el.classList.contains('expanded')) this._resizeTimelineTile();
} catch (err) {
console.error('Snapshot-Volltext laden fehlgeschlagen:', err);
el.dataset.loaded = '';
const detailEl = el.querySelector('.vt-snapshot-detail');
if (detailEl) detailEl.innerHTML = 'Fehler beim Laden des Lageberichts.
';
}
},
/**
* Timeline-Eintrag auf-/zuklappen (mutual-exclusive pro Zeitgruppe).
*/
toggleTimelineEntry(el) {
const container = el.closest('.ht-detail-content') || el.closest('.vt-time-group');
if (container) {
container.querySelectorAll('.vt-entry.expanded').forEach(item => {
if (item !== el) item.classList.remove('expanded');
});
}
el.classList.toggle('expanded');
if (el.classList.contains('expanded')) {
// Snapshots: Volltext lazy nachladen (nur wenn noch nicht geladen)
if (el.classList.contains('vt-snapshot') && el.dataset.snapshotId) {
this.lazyLoadSnapshotDetail(el);
}
requestAnimationFrame(() => {
var scrollParent = el.closest('.ht-detail-content');
if (scrollParent && el.classList.contains('vt-snapshot')) {
scrollParent.scrollTo({ top: 0, behavior: document.documentElement.dataset.a11yMotion ? 'instant' : 'smooth' });
} else {
el.scrollIntoView({ behavior: document.documentElement.dataset.a11yMotion ? 'instant' : 'smooth', block: 'nearest' });
}
});
}
// Timeline-Kachel an Inhalt anpassen
this._resizeTimelineTile();
},
/**
* Minutenschlüssel für Cluster-Erkennung.
*/
_getMinuteKey(timestamp) {
if (!timestamp) return 'none';
const d = new Date(timestamp);
const p = _tz(d);
return `${p.year}-${p.month}-${p.date}-${p.hours}-${p.minutes}`;
},
// === Event Handlers ===
_getFormData() {
const value = parseInt(document.getElementById('inc-refresh-value').value) || 15;
const unit = parseInt(document.getElementById('inc-refresh-unit').value) || 1;
const interval = Math.max(10, Math.min(10080, value * unit));
return {
title: document.getElementById('inc-title').value.trim(),
description: document.getElementById('inc-description').value.trim() || null,
type: document.getElementById('inc-type').value,
refresh_mode: document.getElementById('inc-refresh-mode').value,
refresh_interval: interval,
refresh_start_time: document.getElementById('inc-refresh-mode').value === 'auto'
? document.getElementById('inc-refresh-starttime').value || null
: null,
retention_days: parseInt(document.getElementById('inc-retention').value) || 0,
international_sources: document.getElementById('inc-international').checked,
include_telegram: document.getElementById('inc-telegram').checked,
visibility: document.getElementById('inc-visibility').checked ? 'public' : 'private',
};
},
_clearFormErrors(formEl) {
formEl.querySelectorAll('.form-error').forEach(el => el.remove());
formEl.querySelectorAll('[aria-invalid]').forEach(el => {
el.removeAttribute('aria-invalid');
el.removeAttribute('aria-describedby');
});
},
_showFieldError(field, message) {
field.setAttribute('aria-invalid', 'true');
const errorId = field.id + '-error';
field.setAttribute('aria-describedby', errorId);
const errorEl = document.createElement('div');
errorEl.className = 'form-error';
errorEl.id = errorId;
errorEl.setAttribute('role', 'alert');
errorEl.textContent = message;
field.parentNode.appendChild(errorEl);
},
async handleFormSubmit(e) {
e.preventDefault();
const submitBtn = document.getElementById('modal-new-submit');
const form = document.getElementById('new-incident-form');
this._clearFormErrors(form);
// Validierung
const titleField = document.getElementById('inc-title');
if (!titleField.value.trim()) {
this._showFieldError(titleField, 'Bitte einen Titel eingeben.');
titleField.focus();
return;
}
submitBtn.disabled = true;
try {
const data = this._getFormData();
if (this._editingIncidentId) {
// Edit-Modus: ID sichern bevor closeModal sie löscht
const editId = this._editingIncidentId;
await API.updateIncident(editId, data);
// E-Mail-Subscription speichern
await API.updateSubscription(editId, {
notify_email_summary: document.getElementById('inc-notify-summary').checked,
notify_email_new_articles: document.getElementById('inc-notify-new-articles').checked,
notify_email_status_change: document.getElementById('inc-notify-status-change').checked,
});
closeModal('modal-new');
await this.loadIncidents();
await this.loadIncidentDetail(editId);
UI.showToast('Lage aktualisiert.', 'success');
} else {
// Create-Modus
const incident = await API.createIncident(data);
// E-Mail-Subscription speichern
await API.updateSubscription(incident.id, {
notify_email_summary: document.getElementById('inc-notify-summary').checked,
notify_email_new_articles: document.getElementById('inc-notify-new-articles').checked,
notify_email_status_change: document.getElementById('inc-notify-status-change').checked,
});
closeModal('modal-new');
await this.loadIncidents();
// Refresh-Status VOR selectIncident setzen, damit selectIncident
// beim Oeffnen sofort Blur + Aktions-Lock setzt (statt sie erst
// per WebSocket-Nachricht spaeter wieder zu aktivieren — dazwischen
// war der Fallinhalt kurzzeitig unblurred und klickbar).
this._refreshingIncidents.add(incident.id);
UI._progressState[incident.id] = {
step: 'queued', isFirst: true, startTime: null, minimized: false,
};
await this.selectIncident(incident.id);
this._updateRefreshButton(true);
await API.refreshIncident(incident.id);
UI.showToast(`Lage "${incident.title}" angelegt. Recherche gestartet.`, 'success');
}
} catch (err) {
UI.showToast('Fehler: ' + err.message, 'error');
} finally {
submitBtn.disabled = false;
this._editingIncidentId = null;
}
},
async generateDescription() {
const title = document.getElementById('inc-title').value.trim();
const description = document.getElementById('inc-description').value.trim();
const type = document.getElementById('inc-type').value;
const btn = document.getElementById('btn-enhance-description');
const btnText = document.getElementById('enhance-btn-text');
const spinner = document.getElementById('enhance-spinner');
const textarea = document.getElementById('inc-description');
if (title.length < 3 || !btn) return;
// Vorherigen Request abbrechen falls noch aktiv
if (this._enhanceController) this._enhanceController.abort();
this._enhanceController = new AbortController();
btn.disabled = true;
btnText.textContent = '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 = 'Beschreibung konnte nicht generiert werden';
if (err.status === 503) msg = 'KI-Zugang aktuell nicht verfügbar. Bitte Administrator kontaktieren.';
else if (err.status === 429) msg = 'KI ist gerade ausgelastet. Bitte kurz warten und erneut versuchen.';
else if (err.status === 504) msg = 'KI antwortet gerade nicht. Bitte erneut versuchen.';
else if (err.status === 403) msg = err.detail || 'Zugriff verweigert.';
UI.showToast(msg, 'error');
}
} finally {
btnText.textContent = '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);
if (result && result.status === 'skipped') {
UI.showToast('Aktualisierung ist in der Warteschlange und wird ausgefuehrt, sobald die aktuelle Recherche abgeschlossen ist.', 'info');
} else {
UI.showToast('Aktualisierung gestartet.', 'success');
var _inc2 = this.incidents.find(function(i) { return i.id === this.currentIncidentId; }.bind(this));
UI.showProgress('queued', {}, this.currentIncidentId, _inc2 && !_inc2.has_summary);
}
} catch (err) {
this._refreshingIncidents.delete(this.currentIncidentId);
this._updateRefreshButton(false);
UI.hideProgress();
UI.showToast('Fehler: ' + err.message, 'error');
}
},
_geoparsePolling: null,
async triggerGeoparse() {
if (!this.currentIncidentId) return;
const btn = document.getElementById('geoparse-btn');
if (btn) { btn.disabled = true; btn.textContent = 'Wird gestartet...'; }
try {
const result = await API.triggerGeoparse(this.currentIncidentId);
if (result.status === 'done') {
UI.showToast(result.message, 'info');
if (btn) { btn.disabled = false; btn.textContent = 'Orte erkennen'; }
return;
}
UI.showToast(result.message, 'info');
this._pollGeoparse(this.currentIncidentId);
} catch (err) {
UI.showToast('Geoparsing fehlgeschlagen: ' + err.message, 'error');
if (btn) { btn.disabled = false; btn.textContent = 'Orte erkennen'; }
}
},
_pollGeoparse(incidentId) {
if (this._geoparsePolling) clearInterval(this._geoparsePolling);
const btn = document.getElementById('geoparse-btn');
this._geoparsePolling = setInterval(async () => {
try {
const st = await API.getGeoparseStatus(incidentId);
if (st.status === 'running') {
if (btn) btn.textContent = `${st.processed}/${st.total} Artikel...`;
} else {
clearInterval(this._geoparsePolling);
this._geoparsePolling = null;
if (btn) { btn.disabled = false; btn.textContent = 'Orte erkennen'; }
if (st.status === 'done' && st.locations > 0) {
UI.showToast(`${st.locations} Orte aus ${st.processed} Artikeln erkannt`, 'success');
const locResp = await API.getLocations(incidentId).catch(() => []);
let locs, catLabels;
if (Array.isArray(locResp)) { locs = locResp; catLabels = null; }
else if (locResp && locResp.locations) { locs = locResp.locations; catLabels = locResp.category_labels || null; }
else { locs = []; catLabels = null; }
UI.renderMap(locs, catLabels);
} else if (st.status === 'done') {
UI.showToast('Keine neuen Orte gefunden', 'info');
} else if (st.status === 'error') {
UI.showToast('Geoparsing fehlgeschlagen: ' + (st.error || ''), 'error');
}
}
} catch {
clearInterval(this._geoparsePolling);
this._geoparsePolling = null;
if (btn) { btn.disabled = false; btn.textContent = 'Orte erkennen'; }
}
}, 3000);
},
_formatInterval(minutes) {
if (minutes >= 10080 && minutes % 10080 === 0) {
const w = minutes / 10080;
return w === 1 ? '1 Woche' : `${w} Wochen`;
}
if (minutes >= 1440 && minutes % 1440 === 0) {
const d = minutes / 1440;
return d === 1 ? '1 Tag' : `${d} Tage`;
}
if (minutes >= 60 && minutes % 60 === 0) {
const h = minutes / 60;
return h === 1 ? '1 Stunde' : `${h} Stunden`;
}
return `${minutes} Min.`;
},
_setIntervalFields(minutes) {
let value, unit;
if (minutes >= 10080 && minutes % 10080 === 0) {
value = minutes / 10080; unit = '10080';
} else if (minutes >= 1440 && minutes % 1440 === 0) {
value = minutes / 1440; unit = '1440';
} else if (minutes >= 60 && minutes % 60 === 0) {
value = minutes / 60; unit = '60';
} else {
value = minutes; unit = '1';
}
const input = document.getElementById('inc-refresh-value');
input.value = value;
input.min = unit === '1' ? 10 : 1;
{ const _e = document.getElementById('inc-refresh-unit'); if (_e) _e.value = unit; }
},
_refreshHistoryOpen: false,
toggleRefreshHistory() {
if (this._refreshHistoryOpen) {
this.closeRefreshHistory();
} else {
this._openRefreshHistory();
}
},
async _openRefreshHistory() {
if (!this.currentIncidentId) return;
const popover = document.getElementById('refresh-history-popover');
if (!popover) return;
this._refreshHistoryOpen = true;
popover.style.display = 'flex';
// Lade Refresh-Log
const list = document.getElementById('refresh-history-list');
list.innerHTML = 'Lade...
';
try {
const logs = await API.getRefreshLog(this.currentIncidentId, 20);
this._renderRefreshHistory(logs);
} catch (e) {
list.innerHTML = 'Fehler beim Laden
';
}
// Outside-Click Listener
setTimeout(() => {
const handler = (e) => {
if (!popover.contains(e.target) && !e.target.closest('.meta-updated-link')) {
this.closeRefreshHistory();
document.removeEventListener('click', handler);
}
};
document.addEventListener('click', handler);
popover._outsideHandler = handler;
}, 0);
},
closeRefreshHistory() {
this._refreshHistoryOpen = false;
const popover = document.getElementById('refresh-history-popover');
if (popover) {
popover.style.display = 'none';
if (popover._outsideHandler) {
document.removeEventListener('click', popover._outsideHandler);
delete popover._outsideHandler;
}
}
},
_renderRefreshHistory(logs) {
const list = document.getElementById('refresh-history-list');
if (!list) return;
if (!logs || logs.length === 0) {
list.innerHTML = 'Noch keine Refreshes durchgeführt
';
return;
}
list.innerHTML = logs.map(log => {
const started = parseUTC(log.started_at) || new Date(log.started_at);
const timeStr = started.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', timeZone: TIMEZONE }) + ' ' +
started.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', timeZone: TIMEZONE });
let detail = '';
if (log.status === 'completed') {
detail = `${log.articles_found} Artikel`;
if (log.duration_seconds != null) {
detail += ` in ${this._formatDuration(log.duration_seconds)}`;
}
} else if (log.status === 'running') {
detail = 'Läuft...';
} else if (log.status === 'error') {
detail = '';
}
const retryInfo = log.retry_count > 0 ? ` (Versuch ${log.retry_count + 1})` : '';
const errorHtml = log.error_message
? `${log.error_message}
`
: '';
return `
${timeStr}${retryInfo}
${detail ? `
${detail}
` : ''}
${errorHtml}
${log.trigger_type === 'auto' ? 'Auto' : 'Manuell'}
`;
}).join('');
},
_formatDuration(seconds) {
if (seconds == null) return '';
if (seconds < 60) return `${Math.round(seconds)}s`;
const m = Math.floor(seconds / 60);
const s = Math.round(seconds % 60);
return s > 0 ? `${m}m ${s}s` : `${m}m`;
},
_timeAgo(date) {
if (!date) return '';
const now = new Date();
const diff = Math.floor((now - date) / 1000);
if (diff < 60) return 'gerade eben';
if (diff < 3600) return `vor ${Math.floor(diff / 60)}m`;
if (diff < 86400) return `vor ${Math.floor(diff / 3600)}h`;
return `vor ${Math.floor(diff / 86400)}d`;
},
_updateRefreshButton(disabled) {
const btn = document.getElementById('refresh-btn');
if (!btn) return;
// 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' ? 'Budget aufgebraucht' : 'Nur Lesezugriff';
btn.title = reason === 'budget_exceeded'
? 'Token-Budget aufgebraucht. Bitte Verwaltung kontaktieren.'
: 'Lizenz erlaubt keinen Schreibzugriff';
return;
}
btn.disabled = disabled;
btn.textContent = disabled ? 'Läuft...' : 'Aktualisieren';
btn.title = '';
},
async handleDelete() {
if (!this.currentIncidentId) return;
if (!await confirmDialog('Lage wirklich löschen? Alle gesammelten Daten gehen verloren.')) return;
try {
await API.deleteIncident(this.currentIncidentId);
this.currentIncidentId = null;
if (typeof LayoutManager !== 'undefined') LayoutManager.destroy();
document.getElementById('incident-view').style.display = 'none';
document.getElementById('empty-state').style.display = 'flex';
await this.loadIncidents();
UI.showToast('Lage gelöscht.', 'success');
} catch (err) {
UI.showToast('Fehler: ' + err.message, 'error');
}
},
async handleEdit() {
if (!this.currentIncidentId) return;
const incident = this.incidents.find(i => i.id === this.currentIncidentId);
if (!incident) return;
this._editingIncidentId = this.currentIncidentId;
// Formular mit aktuellen Werten füllen
{ const _e = document.getElementById('inc-title'); if (_e) _e.value = incident.title; }
{ const _e = document.getElementById('inc-description'); if (_e) { _e.value = incident.description || ''; _autoResizeTextarea(_e); } }
{ const _b = document.getElementById('btn-enhance-description'); if (_b) _b.disabled = (incident.title || '').trim().length < 3; }
{ const _e = document.getElementById('inc-type'); if (_e) _e.value = incident.type || 'adhoc'; }
{ const _e = document.getElementById('inc-refresh-mode'); if (_e) _e.value = incident.refresh_mode; }
App._setIntervalFields(incident.refresh_interval);
{ const _e = document.getElementById('inc-refresh-starttime'); if (_e) _e.value = incident.refresh_start_time || '07:00'; }
{ const _e = document.getElementById('inc-retention'); if (_e) _e.value = incident.retention_days; }
{ const _e = document.getElementById('inc-international'); if (_e) _e.checked = incident.international_sources !== false && incident.international_sources !== 0; }
{ const _e = document.getElementById('inc-telegram'); if (_e) _e.checked = !!incident.include_telegram; }
{ const _e = document.getElementById('inc-visibility'); if (_e) _e.checked = incident.visibility !== 'private'; }
updateVisibilityHint();
updateSourcesHint();
toggleTypeDefaults();
toggleRefreshInterval();
// Modal-Titel und Submit ändern
{ const _e = document.getElementById('modal-new-title'); if (_e) _e.textContent = 'Lage bearbeiten'; }
{ const _e = document.getElementById('modal-new-submit'); if (_e) _e.textContent = 'Speichern'; }
// E-Mail-Subscription laden
try {
const sub = await API.getSubscription(this.currentIncidentId);
{ const _e = document.getElementById('inc-notify-summary'); if (_e) _e.checked = !!sub.notify_email_summary; }
{ const _e = document.getElementById('inc-notify-new-articles'); if (_e) _e.checked = !!sub.notify_email_new_articles; }
{ const _e = document.getElementById('inc-notify-status-change'); if (_e) _e.checked = !!sub.notify_email_status_change; }
} catch (e) {
{ const _e = document.getElementById('inc-notify-summary'); if (_e) _e.checked = false; }
{ const _e = document.getElementById('inc-notify-new-articles'); if (_e) _e.checked = false; }
{ const _e = document.getElementById('inc-notify-status-change'); if (_e) _e.checked = false; }
}
openModal('modal-new');
},
async handleArchive() {
if (!this.currentIncidentId) return;
const incident = this.incidents.find(i => i.id === this.currentIncidentId);
if (!incident) return;
const isArchived = incident.status === 'archived';
const action = isArchived ? 'wiederherstellen' : 'archivieren';
if (!await confirmDialog(`Lage wirklich ${action}?`)) return;
try {
const newStatus = isArchived ? 'active' : 'archived';
await API.updateIncident(this.currentIncidentId, { status: newStatus });
await this.loadIncidents();
await this.loadIncidentDetail(this.currentIncidentId);
this._updateArchiveButton(newStatus);
UI.showToast(isArchived ? 'Lage wiederhergestellt.' : 'Lage archiviert.', 'success');
} catch (err) {
UI.showToast('Fehler: ' + err.message, 'error');
}
},
_updateSidebarDot(incidentId, mode) {
const dot = document.getElementById(`dot-${incidentId}`);
if (!dot) return;
const incident = this.incidents.find(i => i.id === incidentId);
const baseClass = incident ? (incident.status === 'active' ? 'active' : 'archived') : 'active';
if (mode === 'error') {
dot.className = `incident-dot refresh-error`;
setTimeout(() => {
dot.className = `incident-dot ${baseClass}`;
}, 3000);
} else if (this._refreshingIncidents.has(incidentId)) {
dot.className = `incident-dot refreshing`;
} else {
dot.className = `incident-dot ${baseClass}`;
}
},
_updateArchiveButton(status) {
const btn = document.getElementById('archive-incident-btn');
if (!btn) return;
btn.textContent = status === 'archived' ? 'Wiederherstellen' : 'Archivieren';
},
// === WebSocket Handlers ===
handleStatusUpdate(msg) {
const status = msg.data.status;
if (status === 'retrying') {
if (msg.incident_id === this.currentIncidentId) {
UI.showProgressError('', true, msg.data.delay || 120, msg.incident_id);
}
return;
}
if (status !== 'idle') {
this._refreshingIncidents.add(msg.incident_id);
}
this._updateSidebarDot(msg.incident_id);
// Detect first refresh: no summary means first run
const inc = this.incidents.find(i => i.id === msg.incident_id);
const isFirst = inc && !inc.has_summary;
// Update progress state for ALL incidents (sidebar + popup if current)
UI.showProgress(status, msg.data, msg.incident_id, isFirst);
// Re-render sidebar so status is baked into HTML (survives future re-renders)
this.renderSidebar();
if (msg.incident_id === this.currentIncidentId) {
this._updateRefreshButton(status !== 'idle');
}
},
async handleRefreshComplete(msg) {
this._refreshingIncidents.delete(msg.incident_id);
this._updateSidebarDot(msg.incident_id);
UI._removeSidebarRefreshStatus(msg.incident_id);
delete UI._progressState[msg.incident_id];
UI._reindexQueuePositions();
this.renderSidebar();
if (msg.incident_id === this.currentIncidentId) {
this._updateRefreshButton(false);
await this.loadIncidentDetail(msg.incident_id);
// Progress-Popup nicht sofort ausblenden — auf refresh_summary warten
this._pendingComplete = msg.incident_id;
if (this._pendingCompleteTimer) clearTimeout(this._pendingCompleteTimer);
this._pendingCompleteTimer = setTimeout(() => {
if (this._pendingComplete === msg.incident_id) {
this._pendingComplete = null;
UI.hideProgress(msg.incident_id);
}
}, 5000);
}
await this.loadIncidents();
},
handleRefreshSummary(msg) {
const d = msg.data;
const title = d.incident_title || 'Lage';
// Abschluss-Animation auslösen wenn pending
if (this._pendingComplete === msg.incident_id) {
if (this._pendingCompleteTimer) {
clearTimeout(this._pendingCompleteTimer);
this._pendingCompleteTimer = null;
}
this._pendingComplete = null;
UI.showProgressComplete(d, msg.incident_id);
}
// Toast-Text zusammenbauen
const parts = [];
if (d.new_articles > 0) {
parts.push(`${d.new_articles} neue Meldung${d.new_articles !== 1 ? 'en' : ''}`);
}
if (d.confirmed_count > 0) {
parts.push(`${d.confirmed_count} bestätigt`);
}
if (d.contradicted_count > 0) {
parts.push(`${d.contradicted_count} widersprochen`);
}
if (d.status_changes && d.status_changes.length > 0) {
parts.push(`${d.status_changes.length} Statusänderung${d.status_changes.length !== 1 ? 'en' : ''}`);
}
const summaryText = parts.length > 0
? parts.join(', ')
: 'Keine neuen Entwicklungen';
// 1 Toast statt 5-10
UI.showToast(`Recherche abgeschlossen: ${summaryText}`, 'success', 6000);
// Ins NotificationCenter eintragen
NotificationCenter.add({
incident_id: msg.incident_id,
title: title,
text: `Recherche: ${summaryText}`,
icon: d.contradicted_count > 0 ? 'warning' : 'success',
});
// Status-Änderungen als separate Einträge
if (d.status_changes) {
d.status_changes.forEach(sc => {
const oldLabel = this._translateStatus(sc.old_status);
const newLabel = this._translateStatus(sc.new_status);
NotificationCenter.add({
incident_id: msg.incident_id,
title: title,
text: `${sc.claim}: ${oldLabel} \u2192 ${newLabel}`,
icon: sc.new_status === 'contradicted' || sc.new_status === 'disputed' ? 'error' : 'success',
});
});
}
// Sidebar-Dot blinken
const dot = document.getElementById(`dot-${msg.incident_id}`);
if (dot) {
dot.classList.add('has-notification');
setTimeout(() => dot.classList.remove('has-notification'), 10000);
}
},
_translateStatus(status) {
const map = {
confirmed: 'Bestätigt',
established: 'Gesichert',
unconfirmed: 'Unbestätigt',
contradicted: 'Widersprochen',
disputed: 'Umstritten',
developing: 'In Entwicklung',
unverified: 'Ungeprüft',
};
return map[status] || status;
},
handleRefreshError(msg) {
this._refreshingIncidents.delete(msg.incident_id);
this._updateSidebarDot(msg.incident_id, 'error');
UI._removeSidebarRefreshStatus(msg.incident_id);
delete UI._progressState[msg.incident_id];
UI._reindexQueuePositions();
this.renderSidebar();
if (msg.incident_id === this.currentIncidentId) {
this._updateRefreshButton(false);
// Pending-Complete aufräumen
if (this._pendingCompleteTimer) {
clearTimeout(this._pendingCompleteTimer);
this._pendingCompleteTimer = null;
}
this._pendingComplete = null;
UI.showProgressError(msg.data.error, false, 0, msg.incident_id);
}
UI.showToast(`Recherche-Fehler: ${msg.data.error}`, 'error');
},
handleRefreshCancelled(msg) {
this._refreshingIncidents.delete(msg.incident_id);
this._updateSidebarDot(msg.incident_id);
UI._removeSidebarRefreshStatus(msg.incident_id);
delete UI._progressState[msg.incident_id];
UI._reindexQueuePositions();
this.renderSidebar();
if (msg.incident_id === this.currentIncidentId) {
this._updateRefreshButton(false);
if (this._pendingCompleteTimer) {
clearTimeout(this._pendingCompleteTimer);
this._pendingCompleteTimer = null;
}
this._pendingComplete = null;
UI.hideProgress(msg.incident_id);
}
UI.showToast('Recherche abgebrochen.', 'info');
},
/**
* Gleicht den lokalen Refresh-Status mit dem Server ab.
* Bereinigt verwaiste Status-Anzeigen, die durch verpasste WebSocket-Nachrichten entstehen.
*/
async syncRefreshStatus() {
if (this._refreshingIncidents.size === 0) return;
try {
const data = await API.getRefreshingIncidents();
const serverRefreshing = new Set(data.refreshing || []);
const serverQueued = new Set(data.queued || []);
const serverAll = new Set([...serverRefreshing, ...serverQueued]);
// Finde lokal als refreshing/queued markierte IDs, die serverseitig nicht mehr laufen
const stale = [];
this._refreshingIncidents.forEach(id => {
if (!serverAll.has(id)) stale.push(id);
});
if (stale.length > 0) {
console.log('Status-Sync: Bereinige verwaiste Refreshes:', stale);
stale.forEach(id => {
this._refreshingIncidents.delete(id);
this._updateSidebarDot(id);
UI._removeSidebarRefreshStatus(id);
delete UI._progressState[id];
if (id === this.currentIncidentId) {
this._updateRefreshButton(false);
UI.hideProgress(id);
}
});
UI._reindexQueuePositions();
this.renderSidebar();
}
} catch (e) {
// Netzwerkfehler ignorieren, naechster Zyklus probiert erneut
}
},
minimizeProgress() {
UI.minimizeProgress(this.currentIncidentId);
},
openProgressPopup() {
UI.openProgressPopup(this.currentIncidentId);
},
async cancelRefresh() {
if (!this.currentIncidentId) return;
// Temporarily hide progress popup so confirm dialog is fully visible
const progressOverlay = document.getElementById('progress-overlay');
if (progressOverlay) progressOverlay.style.display = 'none';
const ok = await confirmDialog('Laufende Recherche abbrechen?');
// Restore progress popup if not confirmed
if (!ok) {
const state = UI._progressState[this.currentIncidentId];
if (state && progressOverlay) progressOverlay.style.display = 'flex';
return;
}
// Show cancelling state in popup
if (progressOverlay) progressOverlay.style.display = 'flex';
const btn = document.getElementById('progress-cancel-btn');
if (btn) {
btn.textContent = 'Wird abgebrochen...';
btn.disabled = true;
}
const titleEl = document.getElementById('progress-popup-title');
if (titleEl) titleEl.textContent = 'Wird abgebrochen...';
try {
const result = await API.cancelRefresh(this.currentIncidentId);
if (!result) {
UI.showToast('Kein aktiver Refresh zum Abbrechen gefunden.', 'info');
if (btn) { btn.textContent = 'Abbrechen'; btn.disabled = false; }
if (titleEl) titleEl.textContent = 'Aktualisierung l\u00e4uft';
}
} catch (err) {
UI.showToast('Abbrechen fehlgeschlagen: ' + err.message, 'error');
if (btn) { btn.textContent = 'Abbrechen'; btn.disabled = false; }
if (titleEl) titleEl.textContent = 'Aktualisierung l\u00e4uft';
}
},
// === Export ===
openExportModal() {
if (!this.currentIncidentId) return;
openModal('modal-export');
},
async submitExport() {
if (!this.currentIncidentId) return;
const checked = document.querySelectorAll('input[name="export-section"]:checked');
const sections = Array.from(checked).map(cb => cb.value);
if (sections.length === 0) {
UI.showToast('Bitte mindestens einen Bereich ausw\u00e4hlen.', 'warning');
return;
}
const format = document.querySelector('input[name="export-format"]:checked').value;
const btn = document.getElementById('export-submit-btn');
const origText = btn.textContent;
btn.disabled = true;
btn.textContent = 'Wird erstellt...';
try {
const response = await API.exportReport(this.currentIncidentId, format, null, sections);
if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw new Error(err.detail || 'Fehler ' + response.status);
}
const blob = await response.blob();
const disposition = response.headers.get('Content-Disposition') || '';
let filename = 'bericht.' + format;
const match = disposition.match(/filename="?([^"]+)"?/);
if (match) filename = match[1];
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
closeModal('modal-export');
UI.showToast('Bericht heruntergeladen', 'success');
} catch (err) {
UI.showToast('Export fehlgeschlagen: ' + err.message, 'error');
} finally {
btn.disabled = false;
btn.textContent = origText;
}
},
// === Sidebar-Stats ===
async updateSidebarStats() {
try {
const stats = await API.getSourceStats();
const srcCount = document.getElementById('stat-sources-count');
const artCount = document.getElementById('stat-articles-count');
if (srcCount) srcCount.textContent = `${stats.total_sources} Quellen`;
if (artCount) artCount.textContent = `${stats.total_articles} Artikel`;
} catch {
// Fallback: aus Lagen berechnen
const totalArticles = this.incidents.reduce((sum, i) => sum + i.article_count, 0);
const totalSources = this.incidents.reduce((sum, i) => sum + i.source_count, 0);
const srcCount = document.getElementById('stat-sources-count');
const artCount = document.getElementById('stat-articles-count');
if (srcCount) srcCount.textContent = `${totalSources} Quellen`;
if (artCount) artCount.textContent = `${totalArticles} Artikel`;
}
},
// === Soft-Refresh (F5) ===
async softRefresh() {
try {
await this.loadIncidents();
if (this.currentIncidentId) {
await this.selectIncident(this.currentIncidentId);
}
UI.showToast('Daten aktualisiert.', 'success', 2000);
} catch (err) {
UI.showToast('Aktualisierung fehlgeschlagen: ' + err.message, 'error');
}
},
// === Feedback ===
openFeedback() {
const form = document.getElementById('feedback-form');
if (form) form.reset();
const counter = document.getElementById('fb-char-count');
if (counter) counter.textContent = '0';
openModal('modal-feedback');
},
async submitFeedback(e) {
e.preventDefault();
const form = document.getElementById('feedback-form');
this._clearFormErrors(form);
const btn = document.getElementById('fb-submit-btn');
const category = document.getElementById('fb-category').value;
const msgField = document.getElementById('fb-message');
const message = msgField.value.trim();
if (message.length < 10) {
this._showFieldError(msgField, 'Bitte mindestens 10 Zeichen eingeben.');
msgField.focus();
return;
}
// Dateien pruefen
const fileInput = document.getElementById('fb-files');
const files = fileInput ? Array.from(fileInput.files) : [];
if (files.length > 3) {
UI.showToast('Maximal 3 Bilder erlaubt.', 'error');
return;
}
for (const f of files) {
if (f.size > 5 * 1024 * 1024) {
UI.showToast('Datei "' + f.name + '" ist groesser als 5 MB.', 'error');
return;
}
}
btn.disabled = true;
btn.textContent = 'Wird gesendet...';
try {
const formData = new FormData();
formData.append('category', category);
formData.append('message', message);
for (const f of files) {
formData.append('files', f);
}
await API.sendFeedbackForm(formData);
closeModal('modal-feedback');
UI.showToast('Feedback gesendet. Vielen Dank!', 'success');
} catch (err) {
UI.showToast('Fehler: ' + err.message, 'error');
} finally {
btn.disabled = false;
btn.textContent = 'Absenden';
}
},
// === Sidebar Sektionen ein-/ausklappen ===
toggleSidebarSection(sectionId) {
const list = document.getElementById(sectionId);
if (!list) return;
const chevron = document.getElementById('chevron-' + sectionId);
const isHidden = list.style.display === 'none';
list.style.display = isHidden ? '' : 'none';
if (chevron) {
chevron.classList.toggle('open', isHidden);
}
// aria-expanded auf dem Section-Title synchronisieren
const title = chevron ? chevron.closest('.sidebar-section-title') : null;
if (title) title.setAttribute('aria-expanded', String(isHidden));
},
// === Quellenverwaltung ===
async openSourceManagement() {
openModal('modal-sources');
await this.loadSources();
},
async loadSources() {
try {
const [sources, stats, myExclusions] = await Promise.all([
API.listSources(),
API.getSourceStats(),
API.getMyExclusions(),
]);
this._allSources = sources;
this._sourcesOnly = sources.filter(s => s.source_type !== 'excluded');
this._myExclusions = myExclusions || [];
this.renderSourceStats(stats);
this.renderSourceList();
} catch (err) {
UI.showToast('Fehler beim Laden der Quellen: ' + err.message, 'error');
}
},
renderSourceStats(stats) {
const bar = document.getElementById('sources-stats-bar');
if (!bar) return;
const rss = stats.by_type.rss_feed || { count: 0, articles: 0 };
const web = stats.by_type.web_source || { count: 0, articles: 0 };
const tg = stats.by_type.telegram_channel || { count: 0, articles: 0 };
const excluded = this._myExclusions.length;
bar.innerHTML = `
${rss.count} RSS-Feeds
${web.count} Web-Quellen
${tg.count} Telegram
${excluded} Ausgeschlossen
${stats.total_articles} Artikel gesamt
`;
},
/**
* Quellen nach Domain gruppiert rendern.
*/
renderSourceList() {
const list = document.getElementById('sources-list');
if (!list) return;
// Filter anwenden
const typeFilter = document.getElementById('sources-filter-type')?.value || '';
const catFilter = document.getElementById('sources-filter-category')?.value || '';
const search = (document.getElementById('sources-search')?.value || '').toLowerCase();
// Alle Quellen nach Domain gruppieren
const groups = new Map();
const excludedDomains = new Set();
const excludedNotes = {};
// User-Ausschlüsse sammeln
this._myExclusions.forEach(e => {
const domain = (e.domain || '').toLowerCase();
if (domain) {
excludedDomains.add(domain);
excludedNotes[domain] = e.notes || '';
}
});
// Feeds nach Domain gruppieren
this._sourcesOnly.forEach(s => {
const domain = (s.domain || '').toLowerCase() || `_single_${s.id}`;
if (!groups.has(domain)) groups.set(domain, []);
groups.get(domain).push(s);
});
// Ausgeschlossene Domains die keine Feeds haben auch als Gruppe
this._myExclusions.forEach(e => {
const domain = (e.domain || '').toLowerCase();
if (domain && !groups.has(domain)) {
groups.set(domain, []);
}
});
// Filter auf Gruppen anwenden
let filteredGroups = [];
for (const [domain, feeds] of groups) {
const isExcluded = excludedDomains.has(domain);
const isGlobal = feeds.some(f => f.is_global);
// Typ-Filter
if (typeFilter === 'excluded' && !isExcluded) continue;
if (typeFilter && typeFilter !== 'excluded') {
const hasMatchingType = feeds.some(f => f.source_type === typeFilter);
if (!hasMatchingType) continue;
}
// Kategorie-Filter
if (catFilter) {
const hasMatchingCat = feeds.some(f => f.category === catFilter);
if (!hasMatchingCat) continue;
}
// Suche
if (search) {
const groupText = feeds.map(f =>
`${f.name} ${f.domain || ''} ${f.url || ''} ${f.notes || ''}`
).join(' ').toLowerCase() + ' ' + domain;
if (!groupText.includes(search)) continue;
}
filteredGroups.push({ domain, feeds, isExcluded, isGlobal });
}
if (filteredGroups.length === 0) {
list.innerHTML = 'Keine Quellen gefunden
';
return;
}
// Sortierung: Aktive zuerst (alphabetisch), dann ausgeschlossene
filteredGroups.sort((a, b) => {
if (a.isExcluded !== b.isExcluded) return a.isExcluded ? 1 : -1;
return a.domain.localeCompare(b.domain);
});
list.innerHTML = filteredGroups.map(g =>
UI.renderSourceGroup(g.domain, g.feeds, g.isExcluded, excludedNotes[g.domain] || '', g.isGlobal)
).join('');
// Erweiterte Gruppen wiederherstellen
this._expandedGroups.forEach(domain => {
const feedsEl = list.querySelector(`.source-group-feeds[data-domain="${CSS.escape(domain)}"]`);
if (feedsEl) {
feedsEl.classList.add('expanded');
const header = feedsEl.previousElementSibling;
if (header) header.classList.add('expanded');
}
});
},
filterSources() {
this.renderSourceList();
},
/**
* Domain-Gruppe auf-/zuklappen.
*/
toggleSourceOverview() {
const content = document.getElementById('source-overview-content');
const chevron = document.getElementById('source-overview-chevron');
if (!content) return;
const isHidden = content.style.display === 'none';
content.style.display = isHidden ? '' : 'none';
if (chevron) {
chevron.classList.toggle('open', isHidden);
chevron.title = isHidden ? 'Einklappen' : 'Aufklappen';
}
// aria-expanded auf dem Header-Toggle synchronisieren
const header = chevron ? chevron.closest('[role="button"]') : null;
if (header) header.setAttribute('aria-expanded', String(isHidden));
// Tab-Modus: Panel waechst mit Inhalt, kein Resize noetig
},
toggleGroup(domain) {
const list = document.getElementById('sources-list');
if (!list) return;
const feedsEl = list.querySelector(`.source-group-feeds[data-domain="${CSS.escape(domain)}"]`);
if (!feedsEl) return;
const isExpanded = feedsEl.classList.toggle('expanded');
const header = feedsEl.previousElementSibling;
if (header) {
header.classList.toggle('expanded', isExpanded);
header.setAttribute('aria-expanded', String(isExpanded));
}
if (isExpanded) {
this._expandedGroups.add(domain);
} else {
this._expandedGroups.delete(domain);
}
},
/**
* Domain ausschließen (aus dem Inline-Formular).
*/
async blockDomain() {
const input = document.getElementById('block-domain-input');
const domain = (input?.value || '').trim();
if (!domain) {
UI.showToast('Domain ist erforderlich.', 'warning');
return;
}
const notes = (document.getElementById('block-domain-notes')?.value || '').trim() || null;
try {
await API.blockDomain(domain, notes);
UI.showToast(`${domain} ausgeschlossen.`, 'success');
this.showBlockDomainDialog(false);
await this.loadSources();
this.updateSidebarStats();
} catch (err) {
UI.showToast('Fehler: ' + err.message, 'error');
}
},
/**
* Faktencheck-Filter umschalten.
*/
toggleFactCheckFilter(status) {
const checkbox = document.querySelector(`.fc-dropdown-item[data-status="${status}"] input`);
if (!checkbox) return;
const isActive = checkbox.checked;
document.querySelectorAll(`.factcheck-item[data-fc-status="${status}"]`).forEach(el => {
el.style.display = isActive ? '' : 'none';
});
},
toggleFcDropdown(e) {
e.stopPropagation();
const btn = e.target.closest('.fc-dropdown-toggle');
const menu = btn ? btn.nextElementSibling : document.getElementById('fc-dropdown-menu');
if (!menu) return;
const isOpen = menu.classList.toggle('open');
if (btn) btn.setAttribute('aria-expanded', String(isOpen));
if (isOpen) {
const close = (ev) => {
if (!menu.contains(ev.target)) {
menu.classList.remove('open');
document.removeEventListener('click', close);
}
};
setTimeout(() => document.addEventListener('click', close), 0);
}
},
filterModalTimeline(searchTerm) {
const filterBtn = document.querySelector('.ht-modal-filter-btn.active');
const filterType = filterBtn ? filterBtn.dataset.filter : 'all';
const body = document.getElementById('content-viewer-body');
if (!body) return;
body.innerHTML = this._buildFullVerticalTimeline(filterType, (searchTerm || '').toLowerCase());
},
filterModalTimelineType(filterType, btn) {
document.querySelectorAll('.ht-modal-filter-btn').forEach(b => b.classList.remove('active'));
if (btn) btn.classList.add('active');
const searchInput = document.querySelector('#content-viewer-header-extra .timeline-filter-input');
const searchTerm = searchInput ? searchInput.value.toLowerCase() : '';
const body = document.getElementById('content-viewer-body');
if (!body) return;
body.innerHTML = this._buildFullVerticalTimeline(filterType, searchTerm);
},
/**
* Domain direkt ausschließen (aus der Gruppenliste).
*/
async blockDomainDirect(domain) {
if (!await confirmDialog(`"${domain}" wirklich ausschließen? Artikel dieser Domain werden bei allen deinen Recherchen ignoriert. Dies betrifft nicht andere Nutzer deiner Organisation.`)) return;
try {
await API.blockDomain(domain);
UI.showToast(`${domain} ausgeschlossen.`, 'success');
await this.loadSources();
this.updateSidebarStats();
} catch (err) {
UI.showToast('Fehler: ' + err.message, 'error');
}
},
/**
* Domain-Ausschluss aufheben.
*/
async unblockDomain(domain) {
try {
await API.unblockDomain(domain);
UI.showToast(`${domain} Ausschluss aufgehoben.`, 'success');
await this.loadSources();
this.updateSidebarStats();
} catch (err) {
UI.showToast('Fehler: ' + err.message, 'error');
}
},
/**
* Alle Quellen einer Domain löschen.
*/
async deleteDomain(domain) {
if (!await confirmDialog(`Alle Quellen von "${domain}" wirklich löschen?`)) return;
try {
await API.deleteDomain(domain);
UI.showToast(`${domain} gelöscht.`, 'success');
await this.loadSources();
this.updateSidebarStats();
} catch (err) {
UI.showToast('Fehler: ' + err.message, 'error');
}
},
/**
* Einzelnen Feed löschen.
*/
async deleteSingleFeed(sourceId) {
try {
await API.deleteSource(sourceId);
this._allSources = this._allSources.filter(s => s.id !== sourceId);
this._sourcesOnly = this._sourcesOnly.filter(s => s.id !== sourceId);
this.renderSourceList();
this.updateSidebarStats();
UI.showToast('Feed gelöscht.', 'success');
} catch (err) {
UI.showToast('Fehler: ' + err.message, 'error');
}
},
/**
* "Domain ausschließen" Dialog ein-/ausblenden.
*/
showBlockDomainDialog(show) {
const form = document.getElementById('sources-block-form');
if (!form) return;
if (show === undefined || show === true) {
form.style.display = 'block';
document.getElementById('block-domain-input').value = '';
document.getElementById('block-domain-notes').value = '';
// Add-Form ausblenden
const addForm = document.getElementById('sources-add-form');
if (addForm) addForm.style.display = 'none';
} else {
form.style.display = 'none';
}
},
_discoveredData: null,
toggleSourceForm(show) {
const form = document.getElementById('sources-add-form');
if (!form) return;
if (show === undefined) {
show = form.style.display === 'none';
}
form.style.display = show ? 'block' : 'none';
if (show) {
this._editingSourceId = null;
this._discoveredData = null;
document.getElementById('src-discover-url').value = '';
document.getElementById('src-discovery-result').style.display = 'none';
document.getElementById('src-discover-btn').disabled = false;
document.getElementById('src-discover-btn').textContent = 'Erkennen';
document.getElementById('src-type-select').value = 'rss_feed';
// Save-Button Text zurücksetzen
const saveBtn = document.querySelector('#src-discovery-result .sources-discovery-actions .btn-primary');
if (saveBtn) saveBtn.textContent = 'Speichern';
// Block-Form ausblenden
const blockForm = document.getElementById('sources-block-form');
if (blockForm) blockForm.style.display = 'none';
} else {
// Beim Schließen: Bearbeitungsmodus zurücksetzen
this._editingSourceId = null;
const saveBtn = document.querySelector('#src-discovery-result .sources-discovery-actions .btn-primary');
if (saveBtn) saveBtn.textContent = 'Speichern';
}
},
async discoverSource() {
const urlInput = document.getElementById('src-discover-url');
const urlVal = urlInput.value.trim();
// Telegram-URLs direkt behandeln (kein Discovery noetig)
if (urlVal.match(/^(https?:\/\/)?(t\.me|telegram\.me)\//i)) {
const channelName = urlVal.replace(/^(https?:\/\/)?(t\.me|telegram\.me)\//, '').replace(/\/$/, '');
const tgUrl = 't.me/' + channelName;
this._discoveredData = {
name: '@' + channelName,
domain: 't.me',
source_type: 'telegram_channel',
rss_url: null,
};
document.getElementById('src-name').value = '@' + channelName;
document.getElementById('src-type-select').value = 'telegram_channel';
document.getElementById('src-type-display').value = 'Telegram';
document.getElementById('src-domain').value = tgUrl;
document.getElementById('src-rss-url-group').style.display = 'none';
document.getElementById('src-discovery-result').style.display = 'block';
const saveBtn = document.querySelector('#src-discovery-result .sources-discovery-actions .btn-primary');
if (saveBtn) { saveBtn.disabled = false; saveBtn.textContent = 'Speichern'; }
return;
}
const url = urlInput.value.trim();
if (!url) {
UI.showToast('Bitte URL oder Domain eingeben.', 'warning');
return;
}
// Prüfen ob Domain ausgeschlossen ist
const inputDomain = url.replace(/^https?:\/\//, '').replace(/^www\./, '').split('/')[0].toLowerCase();
const isBlocked = inputDomain && this._myExclusions.some(e => (e.domain || '').toLowerCase() === inputDomain);
if (isBlocked) {
if (!await confirmDialog(`"${inputDomain}" ist ausgeschlossen. Trotzdem hinzufügen? Der Ausschluss wird dabei aufgehoben.`)) return;
await API.unblockDomain(inputDomain);
}
const btn = document.getElementById('src-discover-btn');
btn.disabled = true;
btn.textContent = 'Suche Feeds...';
try {
const result = await API.discoverMulti(url);
if (result.fallback_single) {
this._discoveredData = {
name: result.domain,
domain: result.domain,
category: result.category,
source_type: result.total_found > 0 ? 'rss_feed' : 'web_source',
rss_url: result.sources.length > 0 ? result.sources[0].url : null,
};
if (result.sources.length > 0) {
this._discoveredData.name = result.sources[0].name;
}
document.getElementById('src-name').value = this._discoveredData.name || '';
document.getElementById('src-category').value = this._discoveredData.category || 'sonstige';
document.getElementById('src-domain').value = this._discoveredData.domain || '';
document.getElementById('src-notes').value = '';
const typeLabel = this._discoveredData.source_type === 'rss_feed' ? 'RSS-Feed' : this._discoveredData.source_type === 'telegram_channel' ? 'Telegram' : 'Web-Quelle';
const typeSelect = document.getElementById('src-type-select');
if (typeSelect) typeSelect.value = this._discoveredData.source_type || 'web_source';
document.getElementById('src-type-display').value = typeLabel;
const rssGroup = document.getElementById('src-rss-url-group');
const rssInput = document.getElementById('src-rss-url');
if (this._discoveredData.rss_url) {
rssInput.value = this._discoveredData.rss_url;
rssGroup.style.display = 'block';
} else {
rssInput.value = '';
rssGroup.style.display = 'none';
}
document.getElementById('src-discovery-result').style.display = 'block';
if (result.added_count > 0) {
UI.showToast(`${result.domain}: Feed wurde automatisch hinzugefügt.`, 'success');
this.toggleSourceForm(false);
await this.loadSources();
} else if (result.total_found === 0) {
UI.showToast('Kein RSS-Feed gefunden. Als Web-Quelle speichern?', 'info');
} else {
UI.showToast('Feed bereits vorhanden.', 'info');
}
} else {
document.getElementById('src-discovery-result').style.display = 'none';
if (result.added_count > 0) {
UI.showToast(`${result.domain}: ${result.added_count} Feeds hinzugefügt` +
(result.skipped_count > 0 ? ` (${result.skipped_count} bereits vorhanden)` : ''),
'success');
} else if (result.skipped_count > 0) {
UI.showToast(`${result.domain}: Alle ${result.skipped_count} Feeds bereits vorhanden.`, 'info');
} else {
UI.showToast(`${result.domain}: Keine relevanten Feeds gefunden.`, 'info');
}
this.toggleSourceForm(false);
await this.loadSources();
}
} catch (err) {
UI.showToast('Erkennung fehlgeschlagen: ' + err.message, 'error');
} finally {
btn.disabled = false;
btn.textContent = 'Erkennen';
}
},
editSource(id) {
const source = this._sourcesOnly.find(s => s.id === id);
if (!source) {
UI.showToast('Quelle nicht gefunden.', 'error');
return;
}
this._editingSourceId = id;
// Formular öffnen falls geschlossen (direkt, ohne toggleSourceForm das _editingSourceId zurücksetzt)
const form = document.getElementById('sources-add-form');
if (form) {
form.style.display = 'block';
const blockForm = document.getElementById('sources-block-form');
if (blockForm) blockForm.style.display = 'none';
}
// Discovery-URL mit vorhandener URL/Domain befüllen
const discoverUrlInput = document.getElementById('src-discover-url');
if (discoverUrlInput) {
discoverUrlInput.value = source.url || source.domain || '';
}
// Discovery-Ergebnis anzeigen und Felder befüllen
document.getElementById('src-discovery-result').style.display = 'block';
document.getElementById('src-name').value = source.name || '';
document.getElementById('src-category').value = source.category || 'sonstige';
document.getElementById('src-notes').value = source.notes || '';
document.getElementById('src-domain').value = source.domain || '';
const typeLabel = source.source_type === 'rss_feed' ? 'RSS-Feed' : source.source_type === 'telegram_channel' ? 'Telegram' : 'Web-Quelle';
const typeSelect = document.getElementById('src-type-select');
if (typeSelect) typeSelect.value = source.source_type || 'web_source';
document.getElementById('src-type-display').value = typeLabel;
const rssGroup = document.getElementById('src-rss-url-group');
const rssInput = document.getElementById('src-rss-url');
if (source.url) {
rssInput.value = source.url;
rssGroup.style.display = 'block';
} else {
rssInput.value = '';
rssGroup.style.display = 'none';
}
// _discoveredData setzen damit saveSource() die richtigen Werte nutzt
this._discoveredData = {
name: source.name,
domain: source.domain,
category: source.category,
source_type: source.source_type,
rss_url: source.url,
};
// Submit-Button-Text ändern
const saveBtn = document.querySelector('#src-discovery-result .sources-discovery-actions .btn-primary');
if (saveBtn) saveBtn.textContent = 'Quelle speichern';
// Zum Formular scrollen
if (form) form.scrollIntoView({ behavior: 'smooth', block: 'start' });
},
async saveSource() {
const name = document.getElementById('src-name').value.trim();
if (!name) {
UI.showToast('Name ist erforderlich. Bitte erst "Erkennen" klicken.', 'warning');
return;
}
const discovered = this._discoveredData || {};
const data = {
name,
source_type: discovered.source_type || 'web_source',
category: document.getElementById('src-category').value,
url: discovered.rss_url || (discovered.source_type === 'telegram_channel' ? (document.getElementById('src-domain').value || null) : null),
domain: document.getElementById('src-domain').value.trim() || discovered.domain || null,
notes: document.getElementById('src-notes').value.trim() || null,
};
if (!data.domain && discovered.domain) {
data.domain = discovered.domain;
}
try {
if (this._editingSourceId) {
await API.updateSource(this._editingSourceId, data);
UI.showToast('Quelle aktualisiert.', 'success');
} else {
await API.createSource(data);
UI.showToast('Quelle hinzugefügt.', 'success');
}
this.toggleSourceForm(false);
await this.loadSources();
this.updateSidebarStats();
} catch (err) {
UI.showToast('Fehler: ' + err.message, 'error');
}
},
// --- Global Admin: Org-Switcher (herausnehmbar) ---
async _initOrgSwitcher(currentTenantId) {
const section = document.getElementById('org-switcher-section');
const select = document.getElementById('org-switcher-select');
if (!section || !select) return;
try {
const orgs = await API.listOrganizations();
if (!orgs || orgs.length < 2) return;
section.style.display = 'block';
select.innerHTML = '';
orgs.forEach(org => {
const opt = document.createElement('option');
opt.value = org.id;
opt.textContent = org.name + (org.is_active ? '' : ' (inaktiv)');
if (org.id === currentTenantId) opt.selected = true;
select.appendChild(opt);
});
select.addEventListener('change', async () => {
const orgId = parseInt(select.value, 10);
if (orgId === currentTenantId) return;
try {
const result = await API.switchOrg(orgId);
localStorage.setItem('osint_token', result.access_token);
window.location.reload();
} catch (err) {
console.error('Org-Wechsel fehlgeschlagen:', err);
}
});
} catch {
// Kein Global Admin oder Fehler - Switcher bleibt versteckt
}
},
logout() {
localStorage.removeItem('osint_token');
localStorage.removeItem('osint_username');
this._sessionWarningShown = false;
WS.disconnect();
window.location.href = '/';
},
};
// === Barrierefreier Bestätigungsdialog ===
function confirmDialog(message) {
return new Promise((resolve) => {
// Overlay erstellen
const overlay = document.createElement('div');
overlay.className = 'modal-overlay active';
overlay.setAttribute('role', 'alertdialog');
overlay.setAttribute('aria-modal', 'true');
overlay.setAttribute('aria-labelledby', 'confirm-dialog-msg');
const modal = document.createElement('div');
modal.className = 'modal';
modal.style.maxWidth = '420px';
modal.innerHTML = `
${message.replace(//g, '>')}
`;
overlay.appendChild(modal);
document.body.appendChild(overlay);
const previousFocus = document.activeElement;
const cleanup = (result) => {
releaseFocus(overlay);
overlay.remove();
if (previousFocus) previousFocus.focus();
resolve(result);
};
modal.querySelector('#confirm-cancel').addEventListener('click', () => cleanup(false));
modal.querySelector('#confirm-ok').addEventListener('click', () => cleanup(true));
overlay.addEventListener('click', (e) => {
if (e.target === overlay) cleanup(false);
});
overlay.addEventListener('keydown', (e) => {
if (e.key === 'Escape') cleanup(false);
});
trapFocus(overlay);
});
}
// === Globale Hilfsfunktionen ===
// --- Focus-Trap für Modals (WCAG 2.4.3) ---
const FOCUSABLE_SELECTOR = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
function trapFocus(modalEl) {
const handler = (e) => {
if (e.key !== 'Tab') return;
const focusable = Array.from(modalEl.querySelectorAll(FOCUSABLE_SELECTOR)).filter(el => el.offsetParent !== null);
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
};
modalEl._focusTrapHandler = handler;
modalEl.addEventListener('keydown', handler);
// Fokus auf erstes Element setzen
requestAnimationFrame(() => {
const focusable = Array.from(modalEl.querySelectorAll(FOCUSABLE_SELECTOR)).filter(el => el.offsetParent !== null);
if (focusable.length > 0) focusable[0].focus();
});
}
function releaseFocus(modalEl) {
if (modalEl._focusTrapHandler) {
modalEl.removeEventListener('keydown', modalEl._focusTrapHandler);
delete modalEl._focusTrapHandler;
}
}
function openModal(id) {
if (id === 'modal-new' && !App._editingIncidentId) {
// Create-Modus: Formular zurücksetzen
document.getElementById('new-incident-form').reset();
document.getElementById('modal-new-title').textContent = 'Neue Lage anlegen';
document.getElementById('modal-new-submit').textContent = 'Lage anlegen';
{ const _b = document.getElementById('btn-enhance-description'); if (_b) _b.disabled = true; }
{ const _t = document.getElementById("inc-description"); if (_t) { _t.style.height = ""; _autoResizeTextarea(_t); } }
// E-Mail-Checkboxen zuruecksetzen
document.getElementById('inc-notify-summary').checked = false;
document.getElementById('inc-notify-new-articles').checked = false;
document.getElementById('inc-notify-status-change').checked = false;
toggleTypeDefaults();
toggleRefreshInterval();
}
const modal = document.getElementById(id);
modal._previousFocus = document.activeElement;
modal.classList.add('active');
trapFocus(modal);
}
function closeModal(id) {
// Laufenden Beschreibung-generieren-Request abbrechen
if (id === 'modal-new' && App._enhanceController) {
App._enhanceController.abort();
App._enhanceController = null;
const ta = document.getElementById('inc-description');
if (ta) { ta.readOnly = false; ta.classList.remove('textarea--loading'); }
}
const modal = document.getElementById(id);
releaseFocus(modal);
modal.classList.remove('active');
if (modal._previousFocus) {
modal._previousFocus.focus();
delete modal._previousFocus;
}
if (id === 'modal-new') {
App._editingIncidentId = null;
document.getElementById('modal-new-title').textContent = 'Neue Lage anlegen';
document.getElementById('modal-new-submit').textContent = 'Lage anlegen';
}
}
function openContentModal(title, sourceElementId) {
const source = document.getElementById(sourceElementId);
if (!source) return;
document.getElementById('content-viewer-title').textContent = title;
const body = document.getElementById('content-viewer-body');
const headerExtra = document.getElementById('content-viewer-header-extra');
headerExtra.innerHTML = '';
if (sourceElementId === 'factcheck-list') {
// Faktencheck: Filter in den Modal-Header, Liste in den Body
const filters = document.getElementById('fc-filters');
if (filters && filters.innerHTML.trim()) {
headerExtra.innerHTML = `${filters.innerHTML}
`;
}
body.innerHTML = source.innerHTML;
// Filter im Modal auf Modal-Items umleiten
headerExtra.querySelectorAll('.fc-dropdown-item input[type="checkbox"]').forEach(cb => {
cb.onchange = function() {
const status = this.closest('.fc-dropdown-item').dataset.status;
body.querySelectorAll(`.factcheck-item[data-fc-status="${status}"]`).forEach(el => {
el.style.display = cb.checked ? '' : 'none';
});
};
});
} else if (sourceElementId === 'source-overview-content') {
// Quellenübersicht: Detailansicht mit Suchleiste
headerExtra.innerHTML = ' ';
body.innerHTML = buildDetailedSourceOverview();
} else if (sourceElementId === 'timeline') {
// Timeline: Vollständige vertikale Timeline im Modal mit Filter + Suche
headerExtra.innerHTML = ``;
body.innerHTML = App._buildFullVerticalTimeline('all', '');
} else {
body.innerHTML = source.innerHTML;
}
openModal('modal-content-viewer');
}
App.filterModalSources = function(query) {
const q = query.toLowerCase().trim();
const details = document.querySelectorAll('#content-viewer-body details');
details.forEach(d => {
if (!q) {
d.style.display = '';
d.removeAttribute('open');
return;
}
const name = d.querySelector('summary').textContent.toLowerCase();
// Quellenname oder Artikel-Headlines durchsuchen
const articles = d.querySelectorAll('div > div');
let articleMatch = false;
articles.forEach(a => {
const text = a.textContent.toLowerCase();
const hit = text.includes(q);
a.style.display = hit ? '' : 'none';
if (hit) articleMatch = true;
});
const match = name.includes(q) || articleMatch;
d.style.display = match ? '' : 'none';
// Bei Artikeltreffer aufklappen, bei Namens-Match alle Artikel zeigen
if (match && articleMatch && !name.includes(q)) {
d.setAttribute('open', '');
} else if (name.includes(q)) {
articles.forEach(a => a.style.display = '');
}
});
};
function buildDetailedSourceOverview() {
const articles = App._currentArticles || [];
if (!articles.length) return 'Keine Artikel vorhanden
';
// Nach Quelle gruppieren
const sourceMap = {};
articles.forEach(a => {
const name = a.source || 'Unbekannt';
if (!sourceMap[name]) sourceMap[name] = { articles: [], languages: new Set() };
sourceMap[name].articles.push(a);
sourceMap[name].languages.add((a.language || 'de').toUpperCase());
});
const sources = Object.entries(sourceMap).sort((a, b) => b[1].articles.length - a[1].articles.length);
// Sprach-Statistik Header
const langCount = {};
articles.forEach(a => {
const lang = (a.language || 'de').toUpperCase();
langCount[lang] = (langCount[lang] || 0) + 1;
});
const langChips = Object.entries(langCount)
.sort((a, b) => b[1] - a[1])
.map(([lang, count]) => `${lang} ${count} `)
.join('');
let html = ``;
sources.forEach(([name, data]) => {
const langs = [...data.languages].join('/');
const escapedName = UI.escape(name);
html += `
▸
${escapedName}
${langs}
${data.articles.length}
`;
data.articles.forEach(a => {
const headline = UI.escape(a.headline_de || a.headline || 'Ohne Titel');
const time = a.collected_at
? (parseUTC(a.collected_at) || new Date(a.collected_at)).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit', timeZone: TIMEZONE })
: '';
const langBadge = a.language && a.language !== 'de'
? `
${a.language.toUpperCase()} ` : '';
const link = a.source_url
? `
↗ ` : '';
html += `
${time}
${headline}
${langBadge}
${link}
`;
});
html += `
`;
});
return html;
}
function toggleRefreshInterval() {
const mode = document.getElementById('inc-refresh-mode').value;
const field = document.getElementById('refresh-interval-field');
const startField = document.getElementById('refresh-starttime-field');
field.classList.toggle('visible', mode === 'auto');
if (startField) startField.classList.toggle('visible', mode === 'auto');
}
function updateIntervalMin() {
const unit = parseInt(document.getElementById('inc-refresh-unit').value);
const input = document.getElementById('inc-refresh-value');
if (unit === 1) {
// Minuten: Minimum 10
input.min = 10;
if (parseInt(input.value) < 10) input.value = 10;
} else {
// Stunden/Tage/Wochen: Minimum 1
input.min = 1;
if (parseInt(input.value) < 1) input.value = 1;
}
}
function updateVisibilityHint() {
const isPublic = document.getElementById('inc-visibility').checked;
const text = document.getElementById('visibility-text');
if (text) {
text.textContent = isPublic
? 'Öffentlich — für alle Nutzer sichtbar'
: 'Privat — nur für dich sichtbar';
}
}
function updateSourcesHint() {
const intl = document.getElementById('inc-international').checked;
const hint = document.getElementById('sources-hint');
if (hint) {
hint.textContent = intl
? 'DE + internationale Feeds (Reuters, BBC, Al Jazeera etc.)'
: 'Nur deutschsprachige Quellen (DE, AT, CH)';
}
}
function toggleTypeDefaults() {
const type = document.getElementById('inc-type').value;
const hint = document.getElementById('type-hint');
const refreshMode = document.getElementById('inc-refresh-mode');
if (type === 'research') {
hint.textContent = 'Recherchiert in Tiefe: Nachrichtenarchive, Parlamentsdokumente, Fachmedien, Expertenquellen. Empfohlen: Manuell starten und bei Bedarf vertiefen.';
refreshMode.value = 'manual';
toggleRefreshInterval();
} else {
hint.textContent = 'Durchsucht laufend hunderte Nachrichtenquellen nach neuen Meldungen. Empfohlen: Automatische Aktualisierung.';
}
// 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); });
}
});