- @page margins 18mm/15mm fuer korrekte Seitenraender - Header kompakt: Titel + Lagebild direkt auf Seite 1 (keine leere Seite) - Citations/Quellenverweise als klickbare unterstrichene Links im PDF - break-inside:avoid statt page-break-inside fuer korrekte Seitenumbrueche - Kartenexport entfernt (nicht sinnvoll als PDF) - Schriftgroessen leicht reduziert fuer bessere Platznutzung
3507 Zeilen
147 KiB
JavaScript
3507 Zeilen
147 KiB
JavaScript
/**
|
|
* OSINT Lagemonitor - Hauptanwendungslogik.
|
|
*/
|
|
|
|
/** Feste Zeitzone fuer alle Anzeigen — NIEMALS aendern. */
|
|
const TIMEZONE = 'Europe/Berlin';
|
|
|
|
/** Gibt Jahr/Monat(0-basiert)/Tag/Stunde/Minute in Berliner Zeit zurueck. */
|
|
function _tz(d) {
|
|
const s = d.toLocaleString('en-CA', {
|
|
timeZone: TIMEZONE, year: 'numeric', month: '2-digit', day: '2-digit',
|
|
hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false
|
|
});
|
|
const m = s.match(/(\d{4})-(\d{2})-(\d{2}),?\s*(\d{2}):(\d{2}):(\d{2})/);
|
|
if (!m) return { year: d.getFullYear(), month: d.getMonth(), date: d.getDate(), hours: d.getHours(), minutes: d.getMinutes() };
|
|
return { year: +m[1], month: +m[2] - 1, date: +m[3], hours: +m[4], minutes: +m[5] };
|
|
}
|
|
|
|
/**
|
|
* Theme Manager: Dark/Light Theme Toggle mit localStorage-Persistenz.
|
|
*/
|
|
const ThemeManager = {
|
|
_key: 'osint_theme',
|
|
init() {
|
|
const saved = localStorage.getItem(this._key);
|
|
const theme = saved || 'dark';
|
|
document.documentElement.setAttribute('data-theme', theme);
|
|
this._updateIcon(theme);
|
|
},
|
|
toggle() {
|
|
const current = document.documentElement.getAttribute('data-theme') || 'dark';
|
|
const next = current === 'dark' ? 'light' : 'dark';
|
|
document.documentElement.setAttribute('data-theme', next);
|
|
localStorage.setItem(this._key, next);
|
|
this._updateIcon(next);
|
|
UI.updateMapTheme();
|
|
},
|
|
_updateIcon(theme) {
|
|
const el = document.getElementById('theme-toggle');
|
|
if (!el) return;
|
|
el.classList.remove('dark', 'light');
|
|
el.classList.add(theme);
|
|
el.setAttribute('aria-checked', theme === 'dark' ? 'true' : 'false');
|
|
}
|
|
};
|
|
|
|
/**
|
|
* 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 = `
|
|
<button class="a11y-btn" id="a11y-btn" title="Barrierefreiheit"
|
|
aria-label="Barrierefreiheit" aria-expanded="false" aria-haspopup="true">
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
|
<circle cx="12" cy="4" r="2"/>
|
|
<path d="M12 8c-3.3 0-6 .5-6 .5v2s2.7-.5 5-.5v3l-3 7h2.5l2.5-5.5 2.5 5.5h2.5l-3-7v-3c2.3 0 5 .5 5 .5v-2S15.3 8 12 8z"/>
|
|
</svg>
|
|
</button>
|
|
<div class="a11y-panel" id="a11y-panel" role="group" aria-label="Barrierefreiheits-Einstellungen" style="display:none;">
|
|
<div class="a11y-panel-title">Barrierefreiheit</div>
|
|
<label class="a11y-option">
|
|
<input type="checkbox" id="a11y-contrast">
|
|
<span class="toggle-switch"></span>
|
|
<span>Hoher Kontrast</span>
|
|
</label>
|
|
<label class="a11y-option">
|
|
<input type="checkbox" id="a11y-focus">
|
|
<span class="toggle-switch"></span>
|
|
<span>Verstärkte Focus-Anzeige</span>
|
|
</label>
|
|
<label class="a11y-option">
|
|
<input type="checkbox" id="a11y-fontsize">
|
|
<span class="toggle-switch"></span>
|
|
<span>Größere Schrift</span>
|
|
</label>
|
|
<label class="a11y-option">
|
|
<input type="checkbox" id="a11y-motion">
|
|
<span class="toggle-switch"></span>
|
|
<span>Animationen aus</span>
|
|
</label>
|
|
</div>
|
|
`;
|
|
|
|
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 = `
|
|
<button class="notification-bell" id="notification-bell" title="Benachrichtigungen" aria-label="Benachrichtigungen" aria-expanded="false" aria-haspopup="true">
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/>
|
|
<path d="M13.73 21a2 2 0 0 1-3.46 0"/>
|
|
</svg>
|
|
<span class="notification-badge" id="notification-badge" style="display:none;">0</span>
|
|
</button>
|
|
<div class="notification-panel" id="notification-panel" style="display:none;">
|
|
<div class="notification-panel-header">
|
|
<span class="notification-panel-title">Benachrichtigungen</span>
|
|
<button class="notification-mark-read" id="notification-mark-read">Alle gelesen</button>
|
|
</div>
|
|
<div class="notification-panel-list" id="notification-panel-list">
|
|
<div class="notification-empty">Keine Benachrichtigungen</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
headerRight.insertBefore(container, headerUser);
|
|
|
|
// Event-Listener
|
|
document.getElementById('notification-bell').addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
this.toggle();
|
|
});
|
|
document.getElementById('notification-mark-read').addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
this.markAllRead();
|
|
});
|
|
// Klick außerhalb schließt Panel
|
|
document.addEventListener('click', (e) => {
|
|
if (this._isOpen && !container.contains(e.target)) {
|
|
this.close();
|
|
}
|
|
});
|
|
|
|
// Notifications aus DB laden
|
|
await this._loadFromDB();
|
|
},
|
|
|
|
add(notification) {
|
|
// Optimistisches UI: sofort anzeigen
|
|
notification.read = false;
|
|
notification.timestamp = notification.timestamp || new Date().toISOString();
|
|
this._notifications.unshift(notification);
|
|
if (this._notifications.length > this._maxItems) {
|
|
this._notifications.pop();
|
|
}
|
|
this._unreadCount++;
|
|
this._updateBadge();
|
|
this._renderList();
|
|
|
|
// DB-Sync mit Debounce (Orchestrator schreibt parallel in DB)
|
|
clearTimeout(this._syncTimer);
|
|
this._syncTimer = setTimeout(() => this._syncFromDB(), 500);
|
|
},
|
|
|
|
toggle() {
|
|
this._isOpen ? this.close() : this.open();
|
|
},
|
|
|
|
open() {
|
|
this._isOpen = true;
|
|
const panel = document.getElementById('notification-panel');
|
|
if (panel) panel.style.display = 'flex';
|
|
const bell = document.getElementById('notification-bell');
|
|
if (bell) bell.setAttribute('aria-expanded', 'true');
|
|
},
|
|
|
|
close() {
|
|
this._isOpen = false;
|
|
const panel = document.getElementById('notification-panel');
|
|
if (panel) panel.style.display = 'none';
|
|
const bell = document.getElementById('notification-bell');
|
|
if (bell) bell.setAttribute('aria-expanded', 'false');
|
|
},
|
|
|
|
async markAllRead() {
|
|
this._notifications.forEach(n => n.read = true);
|
|
this._unreadCount = 0;
|
|
this._updateBadge();
|
|
this._renderList();
|
|
|
|
// In DB als gelesen markieren (fire-and-forget)
|
|
try {
|
|
await API.markNotificationsRead(null);
|
|
} catch (e) {
|
|
console.warn('Notifications als gelesen markieren fehlgeschlagen:', e);
|
|
}
|
|
},
|
|
|
|
_updateBadge() {
|
|
const badge = document.getElementById('notification-badge');
|
|
if (!badge) return;
|
|
if (this._unreadCount > 0) {
|
|
badge.style.display = 'flex';
|
|
badge.textContent = this._unreadCount > 99 ? '99+' : this._unreadCount;
|
|
document.title = `(${this._unreadCount}) ${App._originalTitle}`;
|
|
} else {
|
|
badge.style.display = 'none';
|
|
document.title = App._originalTitle;
|
|
}
|
|
},
|
|
|
|
_renderList() {
|
|
const list = document.getElementById('notification-panel-list');
|
|
if (!list) return;
|
|
|
|
if (this._notifications.length === 0) {
|
|
list.innerHTML = '<div class="notification-empty">Keine Benachrichtigungen</div>';
|
|
return;
|
|
}
|
|
|
|
list.innerHTML = this._notifications.map(n => {
|
|
const time = new Date(n.timestamp);
|
|
const timeStr = time.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', timeZone: TIMEZONE });
|
|
const unreadClass = n.read ? '' : ' unread';
|
|
const icon = n.icon || 'info';
|
|
return `<div class="notification-item${unreadClass}" onclick="NotificationCenter._handleClick(${n.incident_id})" data-id="${n.incident_id}" role="button" tabindex="0">
|
|
<div class="notification-item-icon ${icon}">${this._iconSymbol(icon)}</div>
|
|
<div class="notification-item-body">
|
|
<div class="notification-item-title">${this._escapeHtml(n.title)}</div>
|
|
<div class="notification-item-text">${this._escapeHtml(n.text)}</div>
|
|
</div>
|
|
<div class="notification-item-time">${timeStr}</div>
|
|
</div>`;
|
|
}).join('');
|
|
},
|
|
|
|
_handleClick(incidentId) {
|
|
this.close();
|
|
if (incidentId) {
|
|
App.selectIncident(incidentId);
|
|
}
|
|
},
|
|
|
|
_iconSymbol(type) {
|
|
switch (type) {
|
|
case 'success': return '\u2713';
|
|
case 'warning': return '!';
|
|
case 'error': return '\u2717';
|
|
default: return 'i';
|
|
}
|
|
},
|
|
|
|
_escapeHtml(text) {
|
|
const d = document.createElement('div');
|
|
d.textContent = text || '';
|
|
return d.innerHTML;
|
|
},
|
|
|
|
async _loadFromDB() {
|
|
try {
|
|
const items = await API.listNotifications(50);
|
|
this._notifications = items.map(n => ({
|
|
id: n.id,
|
|
incident_id: n.incident_id,
|
|
title: n.title,
|
|
text: n.text,
|
|
icon: n.icon || 'info',
|
|
type: n.type,
|
|
read: !!n.is_read,
|
|
timestamp: n.created_at,
|
|
}));
|
|
this._unreadCount = this._notifications.filter(n => !n.read).length;
|
|
this._updateBadge();
|
|
this._renderList();
|
|
} catch (e) {
|
|
console.warn('Notifications laden fehlgeschlagen:', e);
|
|
}
|
|
},
|
|
|
|
async _syncFromDB() {
|
|
try {
|
|
const items = await API.listNotifications(50);
|
|
this._notifications = items.map(n => ({
|
|
id: n.id,
|
|
incident_id: n.incident_id,
|
|
title: n.title,
|
|
text: n.text,
|
|
icon: n.icon || 'info',
|
|
type: n.type,
|
|
read: !!n.is_read,
|
|
timestamp: n.created_at,
|
|
}));
|
|
this._unreadCount = this._notifications.filter(n => !n.read).length;
|
|
this._updateBadge();
|
|
this._renderList();
|
|
} catch (e) {
|
|
console.warn('Notifications sync fehlgeschlagen:', e);
|
|
}
|
|
},
|
|
};
|
|
|
|
const App = {
|
|
currentIncidentId: null,
|
|
incidents: [],
|
|
_originalTitle: document.title,
|
|
_refreshingIncidents: new Set(),
|
|
_editingIncidentId: null,
|
|
_currentArticles: [],
|
|
_currentIncidentType: 'adhoc',
|
|
_sidebarFilter: 'all',
|
|
_currentUsername: '',
|
|
_allSources: [],
|
|
_sourcesOnly: [],
|
|
_myExclusions: [], // [{domain, notes, created_at}]
|
|
_expandedGroups: new Set(),
|
|
_editingSourceId: null,
|
|
_timelineFilter: 'all',
|
|
_timelineRange: 'all',
|
|
_activePointIndex: null,
|
|
_timelineSearchTimer: null,
|
|
_pendingComplete: null,
|
|
_pendingCompleteTimer: null,
|
|
|
|
async init() {
|
|
ThemeManager.init();
|
|
A11yManager.init();
|
|
// Auth prüfen
|
|
const token = localStorage.getItem('osint_token');
|
|
if (!token) {
|
|
window.location.href = '/';
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const user = await API.getMe();
|
|
this._currentUsername = user.email;
|
|
document.getElementById('header-user').textContent = user.email;
|
|
|
|
// Dropdown-Daten befuellen
|
|
const orgNameEl = document.getElementById('header-org-name');
|
|
if (orgNameEl) orgNameEl.textContent = user.org_name || '-';
|
|
|
|
const licInfoEl = document.getElementById('header-license-info');
|
|
if (licInfoEl) {
|
|
const licenseLabels = {
|
|
trial: 'Trial',
|
|
annual: 'Jahreslizenz',
|
|
permanent: 'Permanent',
|
|
};
|
|
const label = user.read_only ? 'Abgelaufen'
|
|
: licenseLabels[user.license_type] || user.license_status || '-';
|
|
licInfoEl.textContent = label;
|
|
}
|
|
|
|
// Dropdown Toggle
|
|
const userBtn = document.getElementById('header-user-btn');
|
|
const userDropdown = document.getElementById('header-user-dropdown');
|
|
if (userBtn && userDropdown) {
|
|
userBtn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
const isOpen = userDropdown.classList.toggle('open');
|
|
userBtn.setAttribute('aria-expanded', isOpen);
|
|
});
|
|
document.addEventListener('click', () => {
|
|
userDropdown.classList.remove('open');
|
|
userBtn.setAttribute('aria-expanded', 'false');
|
|
});
|
|
}
|
|
|
|
// Warnung bei abgelaufener Lizenz
|
|
const warningEl = document.getElementById('header-license-warning');
|
|
if (warningEl && user.read_only) {
|
|
warningEl.textContent = 'Lizenz abgelaufen – nur Lesezugriff';
|
|
warningEl.classList.add('visible');
|
|
}
|
|
} catch {
|
|
window.location.href = '/';
|
|
return;
|
|
}
|
|
|
|
// Event-Listener
|
|
document.getElementById('logout-btn').addEventListener('click', () => this.logout());
|
|
document.getElementById('new-incident-btn').addEventListener('click', () => openModal('modal-new'));
|
|
document.getElementById('new-incident-form').addEventListener('submit', (e) => this.handleFormSubmit(e));
|
|
document.getElementById('refresh-btn').addEventListener('click', () => this.handleRefresh());
|
|
document.getElementById('delete-incident-btn').addEventListener('click', () => this.handleDelete());
|
|
document.getElementById('edit-incident-btn').addEventListener('click', () => this.handleEdit());
|
|
document.getElementById('archive-incident-btn').addEventListener('click', () => this.handleArchive());
|
|
document.getElementById('inc-international').addEventListener('change', () => updateSourcesHint());
|
|
document.getElementById('inc-visibility').addEventListener('change', () => updateVisibilityHint());
|
|
// Telegram-Kategorien Toggle
|
|
const tgCheckbox = document.getElementById('inc-telegram');
|
|
if (tgCheckbox) {
|
|
|
|
}
|
|
|
|
|
|
// Feedback
|
|
document.getElementById('feedback-form').addEventListener('submit', (e) => this.submitFeedback(e));
|
|
document.getElementById('fb-message').addEventListener('input', (e) => {
|
|
document.getElementById('fb-char-count').textContent = e.target.value.length.toLocaleString('de-DE');
|
|
});
|
|
|
|
// Sidebar-Chevrons initial auf offen setzen (Archiv geschlossen)
|
|
document.querySelectorAll('.sidebar-chevron').forEach(c => c.classList.add('open'));
|
|
document.getElementById('chevron-archived-incidents').classList.remove('open');
|
|
var chevronNetwork = document.getElementById('chevron-network-analyses-list');
|
|
if (chevronNetwork) chevronNetwork.classList.add('open');
|
|
|
|
// Lagen laden (frueh, damit Sidebar sofort sichtbar)
|
|
await this.loadIncidents();
|
|
|
|
// Netzwerkanalysen laden
|
|
await this.loadNetworkAnalyses();
|
|
|
|
// Notification-Center initialisieren
|
|
try { await NotificationCenter.init(); } catch (e) { console.warn('NotificationCenter:', e); }
|
|
|
|
// WebSocket
|
|
WS.connect();
|
|
WS.on('status_update', (msg) => this.handleStatusUpdate(msg));
|
|
WS.on('refresh_complete', (msg) => this.handleRefreshComplete(msg));
|
|
WS.on('refresh_summary', (msg) => this.handleRefreshSummary(msg));
|
|
WS.on('refresh_error', (msg) => this.handleRefreshError(msg));
|
|
WS.on('refresh_cancelled', (msg) => this.handleRefreshCancelled(msg));
|
|
WS.on('network_status', (msg) => this._handleNetworkStatus(msg));
|
|
WS.on('network_complete', (msg) => this._handleNetworkComplete(msg));
|
|
WS.on('network_error', (msg) => this._handleNetworkError(msg));
|
|
|
|
// Laufende Refreshes wiederherstellen
|
|
try {
|
|
const data = await API.getRefreshingIncidents();
|
|
if (data.refreshing && data.refreshing.length > 0) {
|
|
data.refreshing.forEach(id => this._refreshingIncidents.add(id));
|
|
// Sidebar-Dots aktualisieren
|
|
data.refreshing.forEach(id => this._updateSidebarDot(id));
|
|
}
|
|
} catch (e) { /* Kein kritischer Fehler */ }
|
|
|
|
// Zuletzt ausgewählte Lage wiederherstellen
|
|
const savedId = localStorage.getItem('selectedIncidentId');
|
|
if (savedId) {
|
|
const id = parseInt(savedId, 10);
|
|
if (this.incidents.some(inc => inc.id === id)) {
|
|
await this.selectIncident(id);
|
|
}
|
|
}
|
|
|
|
// Zuletzt ausgewählte Netzwerkanalyse wiederherstellen
|
|
if (!savedId || !this.incidents.some(inc => inc.id === parseInt(savedId, 10))) {
|
|
const savedNetworkId = localStorage.getItem('selectedNetworkId');
|
|
if (savedNetworkId) {
|
|
const nid = parseInt(savedNetworkId, 10);
|
|
if (this.networkAnalyses.some(na => na.id === nid)) {
|
|
await this.selectNetworkAnalysis(nid);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Leaflet-Karte nachladen falls CDN langsam war
|
|
setTimeout(() => UI.retryPendingMap(), 2000);
|
|
},
|
|
|
|
async loadIncidents() {
|
|
try {
|
|
this.incidents = await API.listIncidents();
|
|
this.renderSidebar();
|
|
} catch (err) {
|
|
UI.showToast('Fehler beim Laden der Lagen: ' + err.message, 'error');
|
|
}
|
|
},
|
|
|
|
renderSidebar() {
|
|
const activeContainer = document.getElementById('active-incidents');
|
|
const researchContainer = document.getElementById('active-research');
|
|
const archivedContainer = document.getElementById('archived-incidents');
|
|
|
|
// Filter-Buttons aktualisieren
|
|
document.querySelectorAll('.sidebar-filter-btn').forEach(btn => {
|
|
const isActive = btn.dataset.filter === this._sidebarFilter;
|
|
btn.classList.toggle('active', isActive);
|
|
btn.setAttribute('aria-pressed', String(isActive));
|
|
});
|
|
|
|
// Lagen nach Filter einschränken
|
|
let filtered = this.incidents;
|
|
if (this._sidebarFilter === 'mine') {
|
|
filtered = filtered.filter(i => i.created_by_username === this._currentUsername);
|
|
}
|
|
|
|
// Aktive Lagen nach Typ aufteilen
|
|
const activeAdhoc = filtered.filter(i => i.status === 'active' && (!i.type || i.type === 'adhoc'));
|
|
const activeResearch = filtered.filter(i => i.status === 'active' && i.type === 'research');
|
|
const archived = filtered.filter(i => i.status === 'archived');
|
|
|
|
const emptyLabelAdhoc = this._sidebarFilter === 'mine' ? 'Kein eigenes Live-Monitoring' : 'Kein Live-Monitoring';
|
|
const emptyLabelResearch = this._sidebarFilter === 'mine' ? 'Keine eigenen Deep-Research' : 'Keine Deep-Research';
|
|
|
|
activeContainer.innerHTML = activeAdhoc.length
|
|
? activeAdhoc.map(i => UI.renderIncidentItem(i, i.id === this.currentIncidentId)).join('')
|
|
: `<div style="padding:8px 12px;font-size:12px;color:var(--text-tertiary);">${emptyLabelAdhoc}</div>`;
|
|
|
|
researchContainer.innerHTML = activeResearch.length
|
|
? activeResearch.map(i => UI.renderIncidentItem(i, i.id === this.currentIncidentId)).join('')
|
|
: `<div style="padding:8px 12px;font-size:12px;color:var(--text-tertiary);">${emptyLabelResearch}</div>`;
|
|
|
|
archivedContainer.innerHTML = archived.length
|
|
? archived.map(i => UI.renderIncidentItem(i, i.id === this.currentIncidentId)).join('')
|
|
: '<div style="padding:8px 12px;font-size:12px;color:var(--text-tertiary);">Kein Archiv</div>';
|
|
|
|
// Zähler aktualisieren
|
|
const countAdhoc = document.getElementById('count-active-incidents');
|
|
const countResearch = document.getElementById('count-active-research');
|
|
const countArchived = document.getElementById('count-archived-incidents');
|
|
if (countAdhoc) countAdhoc.textContent = `(${activeAdhoc.length})`;
|
|
if (countResearch) countResearch.textContent = `(${activeResearch.length})`;
|
|
if (countArchived) countArchived.textContent = `(${archived.length})`;
|
|
|
|
// Sidebar-Stats aktualisieren
|
|
this.updateSidebarStats();
|
|
},
|
|
|
|
setSidebarFilter(filter) {
|
|
this._sidebarFilter = filter;
|
|
this.renderSidebar();
|
|
},
|
|
|
|
_announceForSR(text) {
|
|
let el = document.getElementById('sr-announcement');
|
|
if (!el) {
|
|
el = document.createElement('div');
|
|
el.id = 'sr-announcement';
|
|
el.setAttribute('role', 'status');
|
|
el.setAttribute('aria-live', 'polite');
|
|
el.className = 'sr-only';
|
|
document.body.appendChild(el);
|
|
}
|
|
el.textContent = '';
|
|
requestAnimationFrame(() => { el.textContent = text; });
|
|
},
|
|
|
|
async selectIncident(id) {
|
|
this.closeRefreshHistory();
|
|
this.currentIncidentId = id;
|
|
localStorage.setItem('selectedIncidentId', id);
|
|
const inc = this.incidents.find(i => i.id === id);
|
|
if (inc) this._announceForSR('Lage ausgewählt: ' + inc.title);
|
|
this.renderSidebar();
|
|
|
|
var mc = document.getElementById("main-content");
|
|
mc.scrollTop = 0;
|
|
|
|
document.getElementById('empty-state').style.display = 'none';
|
|
document.getElementById('incident-view').style.display = 'flex';
|
|
document.getElementById('network-view').style.display = 'none';
|
|
this.currentNetworkId = null;
|
|
localStorage.removeItem('selectedNetworkId');
|
|
this.renderNetworkSidebar();
|
|
|
|
// GridStack-Animation deaktivieren und Scroll komplett sperren
|
|
// bis alle Tile-Resize-Operationen (doppeltes rAF) abgeschlossen sind
|
|
var gridEl = document.querySelector('.grid-stack');
|
|
if (gridEl) gridEl.classList.remove('grid-stack-animate');
|
|
var scrollLock = function() { mc.scrollTop = 0; };
|
|
mc.addEventListener('scroll', scrollLock);
|
|
|
|
// gridstack-Layout initialisieren (einmalig)
|
|
if (typeof LayoutManager !== 'undefined') LayoutManager.init();
|
|
|
|
// Refresh-Status fuer diese Lage wiederherstellen
|
|
const isRefreshing = this._refreshingIncidents.has(id);
|
|
this._updateRefreshButton(isRefreshing);
|
|
if (isRefreshing) {
|
|
UI.showProgress('researching');
|
|
} else {
|
|
UI.hideProgress();
|
|
}
|
|
|
|
// Alte Inhalte sofort leeren um Flackern beim Wechsel zu vermeiden
|
|
var el;
|
|
el = document.getElementById("incident-title"); if (el) el.textContent = "";
|
|
el = document.getElementById("summary-content"); if (el) el.scrollTop = 0;
|
|
el = document.getElementById("summary-text"); if (el) el.innerHTML = "";
|
|
el = document.getElementById("factcheck-filters"); if (el) el.innerHTML = "";
|
|
el = document.querySelector(".factcheck-list"); if (el) el.scrollTop = 0;
|
|
el = document.getElementById("factcheck-list"); if (el) el.innerHTML = "";
|
|
el = document.getElementById("source-overview-content"); if (el) el.innerHTML = "";
|
|
el = document.getElementById("source-overview-header-stats"); if (el) el.textContent = "";
|
|
el = document.getElementById("timeline-entries"); if (el) el.innerHTML = "";
|
|
await this.loadIncidentDetail(id);
|
|
|
|
// Scroll-Sperre nach 3 Frames aufheben (nach allen doppelten rAF-Callbacks)
|
|
mc.scrollTop = 0;
|
|
requestAnimationFrame(() => {
|
|
requestAnimationFrame(() => {
|
|
requestAnimationFrame(() => {
|
|
mc.scrollTop = 0;
|
|
mc.removeEventListener('scroll', scrollLock);
|
|
if (gridEl) gridEl.classList.add('grid-stack-animate');
|
|
});
|
|
});
|
|
});
|
|
|
|
|
|
|
|
},
|
|
|
|
async loadIncidentDetail(id) {
|
|
try {
|
|
const [incident, articles, factchecks, snapshots, locationsResponse] = await Promise.all([
|
|
API.getIncident(id),
|
|
API.getArticles(id),
|
|
API.getFactChecks(id),
|
|
API.getSnapshots(id),
|
|
API.getLocations(id).catch(() => []),
|
|
]);
|
|
|
|
// Locations-API gibt jetzt {category_labels, locations} oder Array (Rückwärtskompatibel)
|
|
let locations, categoryLabels;
|
|
if (Array.isArray(locationsResponse)) {
|
|
locations = locationsResponse;
|
|
categoryLabels = null;
|
|
} else if (locationsResponse && locationsResponse.locations) {
|
|
locations = locationsResponse.locations;
|
|
categoryLabels = locationsResponse.category_labels || null;
|
|
} else {
|
|
locations = [];
|
|
categoryLabels = null;
|
|
}
|
|
|
|
this.renderIncidentDetail(incident, articles, factchecks, snapshots, locations, categoryLabels);
|
|
} catch (err) {
|
|
console.error('loadIncidentDetail Fehler:', err);
|
|
UI.showToast('Fehler beim Laden: ' + err.message, 'error');
|
|
}
|
|
},
|
|
|
|
renderIncidentDetail(incident, articles, factchecks, snapshots, locations, categoryLabels) {
|
|
// Header Strip
|
|
{ const _e = document.getElementById('incident-title'); if (_e) _e.textContent = incident.title; }
|
|
{ const _e = document.getElementById('incident-description'); if (_e) _e.textContent = incident.description || ''; }
|
|
|
|
// Typ-Badge
|
|
const typeBadge = document.getElementById('incident-type-badge');
|
|
typeBadge.className = 'incident-type-badge ' + (incident.type === 'research' ? 'type-research' : 'type-adhoc');
|
|
typeBadge.textContent = incident.type === 'research' ? 'Analyse' : 'Live';
|
|
|
|
// Archiv-Button Text
|
|
this._updateArchiveButton(incident.status);
|
|
|
|
// Ersteller anzeigen
|
|
const creatorEl = document.getElementById('incident-creator');
|
|
if (creatorEl) {
|
|
creatorEl.textContent = (incident.created_by_username || '').split('@')[0];
|
|
}
|
|
|
|
// Delete-Button: nur Ersteller darf löschen
|
|
const deleteBtn = document.getElementById('delete-incident-btn');
|
|
const isCreator = incident.created_by_username === this._currentUsername;
|
|
deleteBtn.disabled = !isCreator;
|
|
deleteBtn.title = isCreator ? '' : `Nur ${(incident.created_by_username || '').split('@')[0]} kann diese Lage löschen`;
|
|
|
|
// Zusammenfassung mit Quellenverzeichnis
|
|
const summaryText = document.getElementById('summary-text');
|
|
if (incident.summary) {
|
|
summaryText.innerHTML = UI.renderSummary(
|
|
incident.summary,
|
|
incident.sources_json,
|
|
incident.type
|
|
);
|
|
} else {
|
|
summaryText.innerHTML = '<span style="color:var(--text-disabled);">Noch keine Zusammenfassung. Klicke auf "Aktualisieren" um die Recherche zu starten.</span>';
|
|
}
|
|
|
|
// Meta (im Header-Strip) — relative Zeitangabe mit vollem Datum als Tooltip
|
|
const updated = incident.updated_at ? parseUTC(incident.updated_at) : null;
|
|
const metaUpdated = document.getElementById('meta-updated');
|
|
if (updated) {
|
|
const fullDate = `${updated.toLocaleDateString('de-DE', { timeZone: TIMEZONE })} ${updated.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', timeZone: TIMEZONE })}`;
|
|
metaUpdated.textContent = `Stand: ${App._timeAgo(updated)}`;
|
|
metaUpdated.title = fullDate;
|
|
} else {
|
|
metaUpdated.textContent = '';
|
|
metaUpdated.title = '';
|
|
}
|
|
|
|
// Zeitstempel direkt im Lagebild-Card-Header
|
|
const lagebildTs = document.getElementById('lagebild-timestamp');
|
|
if (lagebildTs) {
|
|
lagebildTs.textContent = updated
|
|
? `Stand: ${updated.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', timeZone: TIMEZONE })} ${updated.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', timeZone: TIMEZONE })} Uhr`
|
|
: '';
|
|
}
|
|
|
|
{ const _e = document.getElementById('meta-refresh-mode'); if (_e) _e.textContent = incident.refresh_mode === 'auto'
|
|
? `Auto alle ${App._formatInterval(incident.refresh_interval)}`
|
|
: 'Manuell'; }
|
|
|
|
// International-Badge
|
|
const intlBadge = document.getElementById('intl-badge');
|
|
if (intlBadge) {
|
|
const isIntl = incident.international_sources !== false && incident.international_sources !== 0;
|
|
intlBadge.className = 'intl-badge ' + (isIntl ? 'intl-yes' : 'intl-no');
|
|
intlBadge.textContent = isIntl ? 'International' : 'Nur DE';
|
|
}
|
|
|
|
// Faktencheck
|
|
const fcFilters = document.getElementById('fc-filters');
|
|
const factcheckList = document.getElementById('factcheck-list');
|
|
if (factchecks.length > 0) {
|
|
fcFilters.innerHTML = UI.renderFactCheckFilters(factchecks);
|
|
factcheckList.innerHTML = factchecks.map(fc => UI.renderFactCheck(fc)).join('');
|
|
} else {
|
|
fcFilters.innerHTML = '';
|
|
factcheckList.innerHTML = '<div style="padding:12px;font-size:13px;color:var(--text-tertiary);">Noch keine Fakten geprüft</div>';
|
|
}
|
|
|
|
// Quellenübersicht
|
|
const sourceOverview = document.getElementById('source-overview-content');
|
|
if (sourceOverview) {
|
|
sourceOverview.innerHTML = UI.renderSourceOverview(articles);
|
|
// Stats im Header aktualisieren (sichtbar im zugeklappten Zustand)
|
|
const _soStats = document.getElementById("source-overview-header-stats");
|
|
if (_soStats) {
|
|
const _soSources = new Set(articles.map(a => a.source).filter(Boolean));
|
|
_soStats.textContent = articles.length + " Artikel aus " + _soSources.size + " Quellen";
|
|
}
|
|
// Kachel an Inhalt anpassen
|
|
if (typeof LayoutManager !== 'undefined' && LayoutManager._grid) {
|
|
if (sourceOverview.style.display !== 'none') {
|
|
// Offen → an Inhalt anpassen
|
|
requestAnimationFrame(() => requestAnimationFrame(() => {
|
|
LayoutManager.resizeTileToContent('quellen');
|
|
}));
|
|
} else {
|
|
// Geschlossen → einheitliche Default-Höhe
|
|
const defaults = LayoutManager.DEFAULT_LAYOUT.find(d => d.id === 'quellen');
|
|
if (defaults) {
|
|
const node = LayoutManager._grid.engine.nodes.find(
|
|
n => n.el && n.el.getAttribute('gs-id') === 'quellen'
|
|
);
|
|
if (node) LayoutManager._grid.update(node.el, { h: defaults.h });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Timeline - Artikel + Snapshots zwischenspeichern und rendern
|
|
this._currentArticles = articles;
|
|
this._currentSnapshots = snapshots || [];
|
|
this._currentIncidentType = incident.type;
|
|
this._timelineFilter = 'all';
|
|
this._timelineRange = 'all';
|
|
this._activePointIndex = null;
|
|
const _tsEl = document.getElementById('timeline-search'); if (_tsEl) _tsEl.value = '';
|
|
document.querySelectorAll('.ht-filter-btn').forEach(btn => {
|
|
const isActive = btn.dataset.filter === 'all';
|
|
btn.classList.toggle('active', isActive);
|
|
btn.setAttribute('aria-pressed', String(isActive));
|
|
});
|
|
document.querySelectorAll('.ht-range-btn').forEach(btn => {
|
|
const isActive = btn.dataset.range === 'all';
|
|
btn.classList.toggle('active', isActive);
|
|
btn.setAttribute('aria-pressed', String(isActive));
|
|
});
|
|
this.rerenderTimeline();
|
|
this._resizeTimelineTile();
|
|
|
|
// Karte rendern
|
|
UI.renderMap(locations || [], categoryLabels);
|
|
},
|
|
|
|
_collectEntries(filterType, searchTerm, range) {
|
|
const type = this._currentIncidentType;
|
|
const getArticleDate = (a) => (type === 'research' && a.published_at) ? a.published_at : a.collected_at;
|
|
|
|
let entries = [];
|
|
|
|
if (filterType === 'all' || filterType === 'articles') {
|
|
let articles = this._currentArticles || [];
|
|
if (searchTerm) {
|
|
articles = articles.filter(a => {
|
|
const text = `${a.headline || ''} ${a.headline_de || ''} ${a.source || ''} ${a.content_de || ''} ${a.content_original || ''}`.toLowerCase();
|
|
return text.includes(searchTerm);
|
|
});
|
|
}
|
|
articles.forEach(a => entries.push({ kind: 'article', data: a, timestamp: getArticleDate(a) || '' }));
|
|
}
|
|
|
|
if (filterType === 'all' || filterType === 'snapshots') {
|
|
let snapshots = this._currentSnapshots || [];
|
|
if (searchTerm) {
|
|
snapshots = snapshots.filter(s => (s.summary || '').toLowerCase().includes(searchTerm));
|
|
}
|
|
snapshots.forEach(s => entries.push({ kind: 'snapshot', data: s, timestamp: s.created_at || '' }));
|
|
}
|
|
|
|
if (range && range !== 'all') {
|
|
const now = Date.now();
|
|
const cutoff = range === '24h' ? now - 24 * 60 * 60 * 1000 : now - 7 * 24 * 60 * 60 * 1000;
|
|
entries = entries.filter(e => new Date(e.timestamp || 0).getTime() >= cutoff);
|
|
}
|
|
|
|
return entries;
|
|
},
|
|
|
|
_updateTimelineCount(entries) {
|
|
const articleCount = entries.filter(e => e.kind === 'article').length;
|
|
const snapshotCount = entries.filter(e => e.kind === 'snapshot').length;
|
|
const countEl = document.getElementById('article-count');
|
|
if (articleCount > 0 && snapshotCount > 0) {
|
|
countEl.innerHTML = `<span class="ht-legend-dot"></span> ${articleCount} Meldung${articleCount !== 1 ? 'en' : ''} + <span class="ht-legend-dot ht-legend-gold"></span> ${snapshotCount} Lagebericht${snapshotCount !== 1 ? 'e' : ''}`;
|
|
} else if (articleCount > 0) {
|
|
countEl.innerHTML = `<span class="ht-legend-dot"></span> ${articleCount} Meldung${articleCount !== 1 ? 'en' : ''}`;
|
|
} else if (snapshotCount > 0) {
|
|
countEl.innerHTML = `<span class="ht-legend-dot ht-legend-gold"></span> ${snapshotCount} Lagebericht${snapshotCount !== 1 ? 'e' : ''}`;
|
|
} else {
|
|
countEl.textContent = '0 Meldungen';
|
|
}
|
|
},
|
|
|
|
debouncedRerenderTimeline() {
|
|
clearTimeout(this._timelineSearchTimer);
|
|
this._timelineSearchTimer = setTimeout(() => this.rerenderTimeline(), 250);
|
|
},
|
|
|
|
rerenderTimeline() {
|
|
const container = document.getElementById('timeline');
|
|
const searchTerm = (document.getElementById('timeline-search').value || '').toLowerCase();
|
|
const filterType = this._timelineFilter;
|
|
const range = this._timelineRange;
|
|
|
|
let entries = this._collectEntries(filterType, searchTerm, range);
|
|
this._updateTimelineCount(entries);
|
|
|
|
if (entries.length === 0) {
|
|
this._activePointIndex = null;
|
|
container.innerHTML = (searchTerm || range !== 'all')
|
|
? '<div class="ht-empty">Keine Einträge im gewählten Zeitraum.</div>'
|
|
: '<div class="ht-empty">Noch keine Meldungen. Starte eine Recherche mit "Aktualisieren".</div>';
|
|
return;
|
|
}
|
|
|
|
entries.sort((a, b) => new Date(a.timestamp || 0) - new Date(b.timestamp || 0));
|
|
|
|
const granularity = this._calcGranularity(entries, range);
|
|
let buckets = this._buildBuckets(entries, granularity);
|
|
buckets = this._mergeCloseBuckets(buckets);
|
|
|
|
// Aktiven Index validieren
|
|
if (this._activePointIndex !== null && this._activePointIndex >= buckets.length) {
|
|
this._activePointIndex = null;
|
|
}
|
|
|
|
// Achsen-Bereich
|
|
const rangeStart = buckets[0].timestamp;
|
|
const rangeEnd = buckets[buckets.length - 1].timestamp;
|
|
const maxCount = Math.max(...buckets.map(b => b.entries.length));
|
|
|
|
// Stunden- vs. Tages-Granularität
|
|
const isHourly = granularity === 'hour';
|
|
const axisLabels = this._buildAxisLabels(buckets, granularity, true);
|
|
|
|
// HTML aufbauen
|
|
let html = `<div class="ht-axis${isHourly ? ' ht-axis--hourly' : ''}">`;
|
|
|
|
// Datums-Marker (immer anzeigen, ausgedünnt)
|
|
const dayMarkers = this._thinLabels(this._buildDayMarkers(buckets, rangeStart, rangeEnd), 10);
|
|
html += '<div class="ht-day-markers">';
|
|
dayMarkers.forEach(m => {
|
|
html += `<div class="ht-day-marker" style="left:${m.pos}%;">`;
|
|
html += `<div class="ht-day-marker-label">${UI.escape(m.text)}</div>`;
|
|
html += `<div class="ht-day-marker-line"></div>`;
|
|
html += `</div>`;
|
|
});
|
|
html += '</div>';
|
|
|
|
// Punkte
|
|
html += '<div class="ht-points">';
|
|
buckets.forEach((bucket, idx) => {
|
|
const pos = this._bucketPositionPercent(bucket, rangeStart, rangeEnd, buckets.length);
|
|
const size = this._calcPointSize(bucket.entries.length, maxCount);
|
|
const hasSnapshots = bucket.entries.some(e => e.kind === 'snapshot');
|
|
const hasArticles = bucket.entries.some(e => e.kind === 'article');
|
|
|
|
let pointClass = 'ht-point';
|
|
if (filterType === 'snapshots') {
|
|
pointClass += ' ht-snapshot-point';
|
|
} else if (hasSnapshots) {
|
|
pointClass += ' ht-mixed-point';
|
|
}
|
|
if (this._activePointIndex === idx) pointClass += ' active';
|
|
|
|
const tooltip = `${bucket.label}: ${bucket.entries.length} Eintr${bucket.entries.length === 1 ? 'ag' : 'äge'}`;
|
|
|
|
html += `<div class="${pointClass}" style="left:${pos}%;width:${size}px;height:${size}px;" onclick="App.openTimelineDetail(${idx})" data-idx="${idx}">`;
|
|
html += `<div class="ht-tooltip">${UI.escape(tooltip)}</div>`;
|
|
html += `</div>`;
|
|
});
|
|
html += '</div>';
|
|
|
|
// Achsenlinie
|
|
html += '<div class="ht-axis-line"></div>';
|
|
|
|
// Achsen-Labels (ausgedünnt um Überlappung zu vermeiden)
|
|
const thinned = this._thinLabels(axisLabels);
|
|
html += '<div class="ht-axis-labels">';
|
|
thinned.forEach(lbl => {
|
|
html += `<div class="ht-axis-label" style="left:${lbl.pos}%;">${UI.escape(lbl.text)}</div>`;
|
|
});
|
|
html += '</div>';
|
|
html += '</div>';
|
|
|
|
// Detail-Panel (wenn ein Punkt aktiv ist)
|
|
if (this._activePointIndex !== null && this._activePointIndex < buckets.length) {
|
|
html += this._renderDetailPanel(buckets[this._activePointIndex]);
|
|
}
|
|
|
|
container.innerHTML = html;
|
|
},
|
|
|
|
_calcGranularity(entries, range) {
|
|
if (entries.length < 2) return 'day';
|
|
const timestamps = entries.map(e => new Date(e.timestamp || 0).getTime()).filter(t => t > 0);
|
|
if (timestamps.length < 2) return 'day';
|
|
const span = Math.max(...timestamps) - Math.min(...timestamps);
|
|
if (range === '24h' || span <= 48 * 60 * 60 * 1000) return 'hour';
|
|
return 'day';
|
|
},
|
|
|
|
_buildBuckets(entries, granularity) {
|
|
const bucketMap = {};
|
|
entries.forEach(e => {
|
|
const d = new Date(e.timestamp || 0);
|
|
const b = _tz(d);
|
|
let key, label, ts;
|
|
if (granularity === 'hour') {
|
|
key = `${b.year}-${b.month + 1}-${b.date}-${b.hours}`;
|
|
label = d.toLocaleDateString('de-DE', { day: '2-digit', month: 'short', timeZone: TIMEZONE }) + ', ' + b.hours.toString().padStart(2, '0') + ':00';
|
|
ts = new Date(b.year, b.month, b.date, b.hours).getTime();
|
|
} else {
|
|
key = `${b.year}-${b.month + 1}-${b.date}`;
|
|
label = d.toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: 'short', timeZone: TIMEZONE });
|
|
ts = new Date(b.year, b.month, b.date, 12).getTime();
|
|
}
|
|
if (!bucketMap[key]) {
|
|
bucketMap[key] = { key, label, timestamp: ts, entries: [] };
|
|
}
|
|
bucketMap[key].entries.push(e);
|
|
});
|
|
return Object.values(bucketMap).sort((a, b) => a.timestamp - b.timestamp);
|
|
},
|
|
|
|
_mergeCloseBuckets(buckets) {
|
|
if (buckets.length < 2) return buckets;
|
|
const rangeStart = buckets[0].timestamp;
|
|
const rangeEnd = buckets[buckets.length - 1].timestamp;
|
|
if (rangeEnd <= rangeStart) return buckets;
|
|
|
|
const container = document.getElementById('timeline');
|
|
const axisWidth = (container ? container.offsetWidth : 800) * 0.92;
|
|
const maxCount = Math.max(...buckets.map(b => b.entries.length));
|
|
const result = [buckets[0]];
|
|
|
|
for (let i = 1; i < buckets.length; i++) {
|
|
const prev = result[result.length - 1];
|
|
const curr = buckets[i];
|
|
|
|
const distPx = ((curr.timestamp - prev.timestamp) / (rangeEnd - rangeStart)) * axisWidth;
|
|
const prevSize = Math.min(32, this._calcPointSize(prev.entries.length, maxCount));
|
|
const currSize = Math.min(32, this._calcPointSize(curr.entries.length, maxCount));
|
|
const minDistPx = (prevSize + currSize) / 2 + 6;
|
|
|
|
if (distPx < minDistPx) {
|
|
prev.entries = prev.entries.concat(curr.entries);
|
|
} else {
|
|
result.push(curr);
|
|
}
|
|
}
|
|
return result;
|
|
},
|
|
|
|
_bucketPositionPercent(bucket, rangeStart, rangeEnd, totalBuckets) {
|
|
if (totalBuckets === 1) return 50;
|
|
if (rangeEnd === rangeStart) return 50;
|
|
return ((bucket.timestamp - rangeStart) / (rangeEnd - rangeStart)) * 100;
|
|
},
|
|
|
|
_calcPointSize(count, maxCount) {
|
|
if (maxCount <= 1) return 16;
|
|
const minSize = 12;
|
|
const maxSize = 32;
|
|
const logScale = Math.log(count + 1) / Math.log(maxCount + 1);
|
|
return Math.round(minSize + logScale * (maxSize - minSize));
|
|
},
|
|
|
|
_buildAxisLabels(buckets, granularity, timeOnly) {
|
|
if (buckets.length === 0) return [];
|
|
const maxLabels = 8;
|
|
const labels = [];
|
|
const rangeStart = buckets[0].timestamp;
|
|
const rangeEnd = buckets[buckets.length - 1].timestamp;
|
|
|
|
const getLabelText = (b) => {
|
|
if (timeOnly) {
|
|
// Bei Tages-Granularität: Uhrzeit des ersten Eintrags nehmen
|
|
const ts = (granularity === 'day' && b.entries && b.entries.length > 0)
|
|
? new Date(b.entries[0].timestamp || b.timestamp)
|
|
: new Date(b.timestamp);
|
|
const tp = _tz(ts);
|
|
return tp.hours.toString().padStart(2, '0') + ':' + tp.minutes.toString().padStart(2, '0');
|
|
}
|
|
return b.label;
|
|
};
|
|
|
|
if (buckets.length <= maxLabels) {
|
|
buckets.forEach(b => {
|
|
labels.push({ text: getLabelText(b), pos: this._bucketPositionPercent(b, rangeStart, rangeEnd, buckets.length) });
|
|
});
|
|
} else {
|
|
const step = (buckets.length - 1) / (maxLabels - 1);
|
|
for (let i = 0; i < maxLabels; i++) {
|
|
const idx = Math.round(i * step);
|
|
const b = buckets[idx];
|
|
labels.push({ text: getLabelText(b), pos: this._bucketPositionPercent(b, rangeStart, rangeEnd, buckets.length) });
|
|
}
|
|
}
|
|
return labels;
|
|
},
|
|
|
|
_thinLabels(labels, minGapPercent) {
|
|
if (!labels || labels.length <= 1) return labels;
|
|
const gap = minGapPercent || 8;
|
|
const result = [labels[0]];
|
|
for (let i = 1; i < labels.length; i++) {
|
|
if (labels[i].pos - result[result.length - 1].pos >= gap) {
|
|
result.push(labels[i]);
|
|
}
|
|
}
|
|
return result;
|
|
},
|
|
|
|
_buildDayMarkers(buckets, rangeStart, rangeEnd) {
|
|
const seen = {};
|
|
const markers = [];
|
|
buckets.forEach(b => {
|
|
const d = new Date(b.timestamp);
|
|
const bp = _tz(d);
|
|
const dayKey = `${bp.year}-${bp.month}-${bp.date}`;
|
|
if (!seen[dayKey]) {
|
|
seen[dayKey] = true;
|
|
const np = _tz(new Date());
|
|
const todayKey = `${np.year}-${np.month}-${np.date}`;
|
|
const yp = _tz(new Date(Date.now() - 86400000));
|
|
const yesterdayKey = `${yp.year}-${yp.month}-${yp.date}`;
|
|
let label;
|
|
const dateStr = d.toLocaleDateString('de-DE', { day: '2-digit', month: 'short', timeZone: TIMEZONE });
|
|
if (dayKey === todayKey) {
|
|
label = 'Heute, ' + dateStr;
|
|
} else if (dayKey === yesterdayKey) {
|
|
label = 'Gestern, ' + dateStr;
|
|
} else {
|
|
label = d.toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: 'short', timeZone: TIMEZONE });
|
|
}
|
|
const pos = this._bucketPositionPercent(b, rangeStart, rangeEnd, buckets.length);
|
|
markers.push({ text: label, pos });
|
|
}
|
|
});
|
|
return markers;
|
|
},
|
|
|
|
_renderDetailPanel(bucket) {
|
|
const type = this._currentIncidentType;
|
|
const sorted = [...bucket.entries].sort((a, b) => {
|
|
if (a.kind === 'snapshot' && b.kind !== 'snapshot') return -1;
|
|
if (a.kind !== 'snapshot' && b.kind === 'snapshot') return 1;
|
|
return new Date(b.timestamp || 0) - new Date(a.timestamp || 0);
|
|
});
|
|
|
|
let entriesHtml = '';
|
|
sorted.forEach(e => {
|
|
if (e.kind === 'snapshot') {
|
|
entriesHtml += this._renderSnapshotEntry(e.data);
|
|
} else {
|
|
entriesHtml += this._renderArticleEntry(e.data, type, 0);
|
|
}
|
|
});
|
|
|
|
return `<div class="ht-detail-panel">
|
|
<div class="ht-detail-header">
|
|
<span class="ht-detail-title">${UI.escape(bucket.label)} (${bucket.entries.length} Eintr${bucket.entries.length === 1 ? 'ag' : 'äge'})</span>
|
|
<button class="ht-detail-close" onclick="App.closeTimelineDetail()">×</button>
|
|
</div>
|
|
<div class="ht-detail-content">${entriesHtml}</div>
|
|
</div>`;
|
|
},
|
|
|
|
setTimelineFilter(filter) {
|
|
this._timelineFilter = filter;
|
|
this._activePointIndex = null;
|
|
document.querySelectorAll('.ht-filter-btn').forEach(btn => {
|
|
const isActive = btn.dataset.filter === filter;
|
|
btn.classList.toggle('active', isActive);
|
|
btn.setAttribute('aria-pressed', String(isActive));
|
|
});
|
|
this.rerenderTimeline();
|
|
},
|
|
|
|
setTimelineRange(range) {
|
|
this._timelineRange = range;
|
|
this._activePointIndex = null;
|
|
document.querySelectorAll('.ht-range-btn').forEach(btn => {
|
|
const isActive = btn.dataset.range === range;
|
|
btn.classList.toggle('active', isActive);
|
|
btn.setAttribute('aria-pressed', String(isActive));
|
|
});
|
|
this.rerenderTimeline();
|
|
},
|
|
|
|
openTimelineDetail(bucketIndex) {
|
|
if (this._activePointIndex === bucketIndex) {
|
|
this._activePointIndex = null;
|
|
} else {
|
|
this._activePointIndex = bucketIndex;
|
|
}
|
|
this.rerenderTimeline();
|
|
this._resizeTimelineTile();
|
|
},
|
|
|
|
closeTimelineDetail() {
|
|
this._activePointIndex = null;
|
|
this.rerenderTimeline();
|
|
this._resizeTimelineTile();
|
|
},
|
|
|
|
_resizeTimelineTile() {
|
|
if (typeof LayoutManager === 'undefined' || !LayoutManager._grid) return;
|
|
requestAnimationFrame(() => { requestAnimationFrame(() => {
|
|
// Prüfen ob Detail-Panel oder expandierter Eintrag offen ist
|
|
const hasDetail = document.querySelector('.ht-detail-panel') !== null;
|
|
const hasExpanded = document.querySelector('.timeline-card .vt-entry.expanded') !== null;
|
|
|
|
if (hasDetail || hasExpanded) {
|
|
LayoutManager.resizeTileToContent('timeline');
|
|
} else {
|
|
// Zurück auf Default-Höhe
|
|
const defaults = LayoutManager.DEFAULT_LAYOUT.find(d => d.id === 'timeline');
|
|
if (defaults) {
|
|
const node = LayoutManager._grid.engine.nodes.find(
|
|
n => n.el && n.el.getAttribute('gs-id') === 'timeline'
|
|
);
|
|
if (node) {
|
|
LayoutManager._grid.update(node.el, { h: defaults.h });
|
|
LayoutManager._debouncedSave();
|
|
}
|
|
}
|
|
}
|
|
// Scroll in Sicht
|
|
const card = document.querySelector('.timeline-card');
|
|
const main = document.querySelector('.main-content');
|
|
if (!card || !main) return;
|
|
const cardBottom = card.getBoundingClientRect().bottom;
|
|
const mainBottom = main.getBoundingClientRect().bottom;
|
|
if (cardBottom > mainBottom) {
|
|
main.scrollBy({ top: cardBottom - mainBottom + 16, behavior: document.documentElement.dataset.a11yMotion ? 'instant' : 'smooth' });
|
|
}
|
|
}); });
|
|
},
|
|
|
|
_buildFullVerticalTimeline(filterType, searchTerm) {
|
|
let entries = this._collectEntries(filterType, searchTerm);
|
|
if (entries.length === 0) {
|
|
return '<div class="ht-empty">Keine Einträge.</div>';
|
|
}
|
|
|
|
entries.sort((a, b) => new Date(b.timestamp || 0) - new Date(a.timestamp || 0));
|
|
const granularity = this._calcGranularity(entries);
|
|
const groups = this._groupByTimePeriod(entries, granularity);
|
|
|
|
let html = '<div class="vt-timeline">';
|
|
groups.forEach(g => {
|
|
html += `<div class="vt-time-group">`;
|
|
html += `<div class="vt-time-label"><span class="vt-time-label-text">${UI.escape(g.label)}</span></div>`;
|
|
html += this._renderTimeGroupEntries(g.entries, this._currentIncidentType);
|
|
html += `</div>`;
|
|
});
|
|
html += '</div>';
|
|
return html;
|
|
},
|
|
|
|
/**
|
|
* Einträge nach Zeitperiode gruppieren.
|
|
*/
|
|
_groupByTimePeriod(entries, granularity) {
|
|
const np = _tz(new Date());
|
|
const todayKey = `${np.year}-${np.month}-${np.date}`;
|
|
const yp = _tz(new Date(Date.now() - 86400000));
|
|
const yesterdayKey = `${yp.year}-${yp.month}-${yp.date}`;
|
|
|
|
const groups = [];
|
|
let currentGroup = null;
|
|
|
|
entries.forEach(entry => {
|
|
const d = entry.timestamp ? new Date(entry.timestamp) : null;
|
|
let key, label;
|
|
|
|
if (!d || isNaN(d.getTime())) {
|
|
key = 'unknown';
|
|
label = 'Unbekannt';
|
|
} else if (granularity === 'hour') {
|
|
const ep = _tz(d);
|
|
key = `${ep.year}-${ep.month}-${ep.date}-${ep.hours}`;
|
|
label = `${ep.hours.toString().padStart(2, '0')}:00 Uhr`;
|
|
} else {
|
|
const ep = _tz(d);
|
|
key = `${ep.year}-${ep.month}-${ep.date}`;
|
|
if (key === todayKey) {
|
|
label = 'Heute';
|
|
} else if (key === yesterdayKey) {
|
|
label = 'Gestern';
|
|
} else {
|
|
label = d.toLocaleDateString('de-DE', { weekday: 'short', day: 'numeric', month: 'short', timeZone: TIMEZONE });
|
|
}
|
|
}
|
|
|
|
if (!currentGroup || currentGroup.key !== key) {
|
|
currentGroup = { key, label, entries: [] };
|
|
groups.push(currentGroup);
|
|
}
|
|
currentGroup.entries.push(entry);
|
|
});
|
|
|
|
return groups;
|
|
},
|
|
|
|
/**
|
|
* Entries einer Zeitgruppe rendern, mit Cluster-Erkennung.
|
|
*/
|
|
_renderTimeGroupEntries(entries, type) {
|
|
// Cluster-Erkennung: ≥4 Artikel pro Minute
|
|
const minuteCounts = {};
|
|
entries.forEach(e => {
|
|
if (e.kind === 'article') {
|
|
const mk = this._getMinuteKey(e.timestamp);
|
|
minuteCounts[mk] = (minuteCounts[mk] || 0) + 1;
|
|
}
|
|
});
|
|
|
|
const minuteRendered = {};
|
|
let html = '';
|
|
|
|
entries.forEach(e => {
|
|
if (e.kind === 'snapshot') {
|
|
html += this._renderSnapshotEntry(e.data);
|
|
} else {
|
|
const mk = this._getMinuteKey(e.timestamp);
|
|
const isCluster = minuteCounts[mk] >= 4;
|
|
const isFirstInCluster = isCluster && !minuteRendered[mk];
|
|
if (isFirstInCluster) minuteRendered[mk] = true;
|
|
html += this._renderArticleEntry(e.data, type, isFirstInCluster ? minuteCounts[mk] : 0);
|
|
}
|
|
});
|
|
|
|
return html;
|
|
},
|
|
|
|
/**
|
|
* Artikel-Eintrag für den Zeitstrahl rendern.
|
|
*/
|
|
_renderArticleEntry(article, type, clusterCount) {
|
|
const dateField = (type === 'research' && article.published_at)
|
|
? article.published_at : article.collected_at;
|
|
const time = dateField
|
|
? (parseUTC(dateField) || new Date(dateField)).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', timeZone: TIMEZONE })
|
|
: '--:--';
|
|
|
|
const headline = article.headline_de || article.headline;
|
|
const sourceUrl = article.source_url
|
|
? `<a href="${UI.escape(article.source_url)}" target="_blank" rel="noopener" onclick="event.stopPropagation()">${UI.escape(article.source)}</a>`
|
|
: UI.escape(article.source);
|
|
|
|
const langBadge = article.language && article.language !== 'de'
|
|
? `<span class="lang-badge">${article.language.toUpperCase()}</span>` : '';
|
|
|
|
const clusterBadge = clusterCount > 0
|
|
? `<span class="vt-cluster-count">${clusterCount}</span>` : '';
|
|
|
|
const content = article.content_de || article.content_original || '';
|
|
const hasContent = content.length > 0;
|
|
|
|
let detailHtml = '';
|
|
if (hasContent) {
|
|
const truncated = content.length > 400 ? content.substring(0, 400) + '...' : content;
|
|
detailHtml = `<div class="vt-article-detail">
|
|
<div class="vt-article-detail-content">${UI.escape(truncated)}</div>
|
|
${article.source_url ? `<a href="${UI.escape(article.source_url)}" target="_blank" rel="noopener" class="vt-article-detail-link" onclick="event.stopPropagation()">Artikel öffnen →</a>` : ''}
|
|
</div>`;
|
|
}
|
|
|
|
return `<div class="vt-entry ${hasContent ? 'expandable' : ''}" ${hasContent ? 'onclick="App.toggleTimelineEntry(this)"' : ''}>
|
|
<div class="vt-article-header">
|
|
<span class="vt-article-time">${time}</span>
|
|
<span class="vt-article-source">${sourceUrl}</span>
|
|
${langBadge}${clusterBadge}
|
|
</div>
|
|
<div class="vt-article-headline">${UI.escape(headline)}</div>
|
|
${detailHtml}
|
|
</div>`;
|
|
},
|
|
|
|
/**
|
|
* Snapshot/Lagebericht-Eintrag für den Zeitstrahl rendern.
|
|
*/
|
|
_renderSnapshotEntry(snapshot) {
|
|
const time = snapshot.created_at
|
|
? (parseUTC(snapshot.created_at) || new Date(snapshot.created_at)).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', timeZone: TIMEZONE })
|
|
: '--:--';
|
|
|
|
const stats = [];
|
|
if (snapshot.article_count) stats.push(`${snapshot.article_count} Artikel`);
|
|
if (snapshot.fact_check_count) stats.push(`${snapshot.fact_check_count} Fakten`);
|
|
const statsText = stats.join(', ');
|
|
|
|
// Vorschau: erste 200 Zeichen der Zusammenfassung
|
|
const summaryText = snapshot.summary || '';
|
|
const preview = summaryText.length > 200 ? summaryText.substring(0, 200) + '...' : summaryText;
|
|
|
|
// Vollständige Zusammenfassung via UI.renderSummary
|
|
const fullSummary = UI.renderSummary(snapshot.summary, snapshot.sources_json, this._currentIncidentType);
|
|
|
|
return `<div class="vt-entry vt-snapshot expandable" onclick="App.toggleTimelineEntry(this)">
|
|
<div class="vt-snapshot-header">
|
|
<span class="vt-snapshot-badge">Lagebericht</span>
|
|
<span class="vt-snapshot-time">${time}</span>
|
|
<span class="vt-snapshot-stats">${UI.escape(statsText)}</span>
|
|
</div>
|
|
<div class="vt-snapshot-preview">${UI.escape(preview)}</div>
|
|
<div class="vt-snapshot-detail">${fullSummary}</div>
|
|
</div>`;
|
|
},
|
|
|
|
/**
|
|
* Timeline-Eintrag auf-/zuklappen (mutual-exclusive pro Zeitgruppe).
|
|
*/
|
|
toggleTimelineEntry(el) {
|
|
const container = el.closest('.ht-detail-content') || el.closest('.vt-time-group');
|
|
if (container) {
|
|
container.querySelectorAll('.vt-entry.expanded').forEach(item => {
|
|
if (item !== el) item.classList.remove('expanded');
|
|
});
|
|
}
|
|
el.classList.toggle('expanded');
|
|
if (el.classList.contains('expanded')) {
|
|
requestAnimationFrame(() => {
|
|
var scrollParent = el.closest('.ht-detail-content');
|
|
if (scrollParent && el.classList.contains('vt-snapshot')) {
|
|
scrollParent.scrollTo({ top: 0, behavior: document.documentElement.dataset.a11yMotion ? 'instant' : 'smooth' });
|
|
} else {
|
|
el.scrollIntoView({ behavior: document.documentElement.dataset.a11yMotion ? 'instant' : 'smooth', block: 'nearest' });
|
|
}
|
|
});
|
|
}
|
|
// Timeline-Kachel an Inhalt anpassen
|
|
this._resizeTimelineTile();
|
|
},
|
|
|
|
/**
|
|
* Minutenschlüssel für Cluster-Erkennung.
|
|
*/
|
|
_getMinuteKey(timestamp) {
|
|
if (!timestamp) return 'none';
|
|
const d = new Date(timestamp);
|
|
const p = _tz(d);
|
|
return `${p.year}-${p.month}-${p.date}-${p.hours}-${p.minutes}`;
|
|
},
|
|
|
|
// === Event Handlers ===
|
|
|
|
_getFormData() {
|
|
const value = parseInt(document.getElementById('inc-refresh-value').value) || 15;
|
|
const unit = parseInt(document.getElementById('inc-refresh-unit').value) || 1;
|
|
const interval = Math.max(10, Math.min(10080, value * unit));
|
|
return {
|
|
title: document.getElementById('inc-title').value.trim(),
|
|
description: document.getElementById('inc-description').value.trim() || null,
|
|
type: document.getElementById('inc-type').value,
|
|
refresh_mode: document.getElementById('inc-refresh-mode').value,
|
|
refresh_interval: interval,
|
|
retention_days: parseInt(document.getElementById('inc-retention').value) || 0,
|
|
international_sources: document.getElementById('inc-international').checked,
|
|
include_telegram: document.getElementById('inc-telegram').checked,
|
|
visibility: document.getElementById('inc-visibility').checked ? 'public' : 'private',
|
|
};
|
|
},
|
|
|
|
_clearFormErrors(formEl) {
|
|
formEl.querySelectorAll('.form-error').forEach(el => el.remove());
|
|
formEl.querySelectorAll('[aria-invalid]').forEach(el => {
|
|
el.removeAttribute('aria-invalid');
|
|
el.removeAttribute('aria-describedby');
|
|
});
|
|
},
|
|
|
|
_showFieldError(field, message) {
|
|
field.setAttribute('aria-invalid', 'true');
|
|
const errorId = field.id + '-error';
|
|
field.setAttribute('aria-describedby', errorId);
|
|
const errorEl = document.createElement('div');
|
|
errorEl.className = 'form-error';
|
|
errorEl.id = errorId;
|
|
errorEl.setAttribute('role', 'alert');
|
|
errorEl.textContent = message;
|
|
field.parentNode.appendChild(errorEl);
|
|
},
|
|
|
|
async handleFormSubmit(e) {
|
|
e.preventDefault();
|
|
const submitBtn = document.getElementById('modal-new-submit');
|
|
const form = document.getElementById('new-incident-form');
|
|
this._clearFormErrors(form);
|
|
|
|
// Validierung
|
|
const titleField = document.getElementById('inc-title');
|
|
if (!titleField.value.trim()) {
|
|
this._showFieldError(titleField, 'Bitte einen Titel eingeben.');
|
|
titleField.focus();
|
|
return;
|
|
}
|
|
|
|
submitBtn.disabled = true;
|
|
|
|
try {
|
|
const data = this._getFormData();
|
|
|
|
if (this._editingIncidentId) {
|
|
// Edit-Modus: ID sichern bevor closeModal sie löscht
|
|
const editId = this._editingIncidentId;
|
|
await API.updateIncident(editId, data);
|
|
|
|
// E-Mail-Subscription speichern
|
|
await API.updateSubscription(editId, {
|
|
notify_email_summary: document.getElementById('inc-notify-summary').checked,
|
|
notify_email_new_articles: document.getElementById('inc-notify-new-articles').checked,
|
|
notify_email_status_change: document.getElementById('inc-notify-status-change').checked,
|
|
});
|
|
|
|
closeModal('modal-new');
|
|
await this.loadIncidents();
|
|
await this.loadIncidentDetail(editId);
|
|
UI.showToast('Lage aktualisiert.', 'success');
|
|
} else {
|
|
// Create-Modus
|
|
const incident = await API.createIncident(data);
|
|
|
|
// E-Mail-Subscription speichern
|
|
await API.updateSubscription(incident.id, {
|
|
notify_email_summary: document.getElementById('inc-notify-summary').checked,
|
|
notify_email_new_articles: document.getElementById('inc-notify-new-articles').checked,
|
|
notify_email_status_change: document.getElementById('inc-notify-status-change').checked,
|
|
});
|
|
|
|
closeModal('modal-new');
|
|
|
|
await this.loadIncidents();
|
|
await this.selectIncident(incident.id);
|
|
|
|
// Sofort ersten Refresh starten
|
|
this._refreshingIncidents.add(incident.id);
|
|
this._updateRefreshButton(true);
|
|
UI.showProgress('queued');
|
|
await API.refreshIncident(incident.id);
|
|
UI.showToast(`Lage "${incident.title}" angelegt. Recherche gestartet.`, 'success');
|
|
}
|
|
} catch (err) {
|
|
UI.showToast('Fehler: ' + err.message, 'error');
|
|
} finally {
|
|
submitBtn.disabled = false;
|
|
this._editingIncidentId = null;
|
|
}
|
|
},
|
|
|
|
async handleRefresh() {
|
|
if (!this.currentIncidentId) return;
|
|
if (this._refreshingIncidents.has(this.currentIncidentId)) {
|
|
UI.showToast('Recherche läuft bereits...', 'warning');
|
|
return;
|
|
}
|
|
try {
|
|
this._refreshingIncidents.add(this.currentIncidentId);
|
|
this._updateRefreshButton(true);
|
|
UI.showProgress('queued');
|
|
const result = await API.refreshIncident(this.currentIncidentId);
|
|
if (result && result.status === 'skipped') {
|
|
this._refreshingIncidents.delete(this.currentIncidentId);
|
|
this._updateRefreshButton(false);
|
|
UI.hideProgress();
|
|
UI.showToast('Recherche läuft bereits oder ist in der Warteschlange.', 'warning');
|
|
}
|
|
} catch (err) {
|
|
this._refreshingIncidents.delete(this.currentIncidentId);
|
|
this._updateRefreshButton(false);
|
|
UI.hideProgress();
|
|
UI.showToast('Fehler: ' + err.message, 'error');
|
|
}
|
|
},
|
|
|
|
_geoparsePolling: null,
|
|
|
|
async triggerGeoparse() {
|
|
if (!this.currentIncidentId) return;
|
|
const btn = document.getElementById('geoparse-btn');
|
|
if (btn) { btn.disabled = true; btn.textContent = 'Wird gestartet...'; }
|
|
try {
|
|
const result = await API.triggerGeoparse(this.currentIncidentId);
|
|
if (result.status === 'done') {
|
|
UI.showToast(result.message, 'info');
|
|
if (btn) { btn.disabled = false; btn.textContent = 'Orte erkennen'; }
|
|
return;
|
|
}
|
|
UI.showToast(result.message, 'info');
|
|
this._pollGeoparse(this.currentIncidentId);
|
|
} catch (err) {
|
|
UI.showToast('Geoparsing fehlgeschlagen: ' + err.message, 'error');
|
|
if (btn) { btn.disabled = false; btn.textContent = 'Orte erkennen'; }
|
|
}
|
|
},
|
|
|
|
_pollGeoparse(incidentId) {
|
|
if (this._geoparsePolling) clearInterval(this._geoparsePolling);
|
|
const btn = document.getElementById('geoparse-btn');
|
|
this._geoparsePolling = setInterval(async () => {
|
|
try {
|
|
const st = await API.getGeoparseStatus(incidentId);
|
|
if (st.status === 'running') {
|
|
if (btn) btn.textContent = `${st.processed}/${st.total} Artikel...`;
|
|
} else {
|
|
clearInterval(this._geoparsePolling);
|
|
this._geoparsePolling = null;
|
|
if (btn) { btn.disabled = false; btn.textContent = 'Orte erkennen'; }
|
|
if (st.status === 'done' && st.locations > 0) {
|
|
UI.showToast(`${st.locations} Orte aus ${st.processed} Artikeln erkannt`, 'success');
|
|
const locResp = await API.getLocations(incidentId).catch(() => []);
|
|
let locs, catLabels;
|
|
if (Array.isArray(locResp)) { locs = locResp; catLabels = null; }
|
|
else if (locResp && locResp.locations) { locs = locResp.locations; catLabels = locResp.category_labels || null; }
|
|
else { locs = []; catLabels = null; }
|
|
UI.renderMap(locs, catLabels);
|
|
} else if (st.status === 'done') {
|
|
UI.showToast('Keine neuen Orte gefunden', 'info');
|
|
} else if (st.status === 'error') {
|
|
UI.showToast('Geoparsing fehlgeschlagen: ' + (st.error || ''), 'error');
|
|
}
|
|
}
|
|
} catch {
|
|
clearInterval(this._geoparsePolling);
|
|
this._geoparsePolling = null;
|
|
if (btn) { btn.disabled = false; btn.textContent = 'Orte erkennen'; }
|
|
}
|
|
}, 3000);
|
|
},
|
|
|
|
_formatInterval(minutes) {
|
|
if (minutes >= 10080 && minutes % 10080 === 0) {
|
|
const w = minutes / 10080;
|
|
return w === 1 ? '1 Woche' : `${w} Wochen`;
|
|
}
|
|
if (minutes >= 1440 && minutes % 1440 === 0) {
|
|
const d = minutes / 1440;
|
|
return d === 1 ? '1 Tag' : `${d} Tage`;
|
|
}
|
|
if (minutes >= 60 && minutes % 60 === 0) {
|
|
const h = minutes / 60;
|
|
return h === 1 ? '1 Stunde' : `${h} Stunden`;
|
|
}
|
|
return `${minutes} Min.`;
|
|
},
|
|
|
|
_setIntervalFields(minutes) {
|
|
let value, unit;
|
|
if (minutes >= 10080 && minutes % 10080 === 0) {
|
|
value = minutes / 10080; unit = '10080';
|
|
} else if (minutes >= 1440 && minutes % 1440 === 0) {
|
|
value = minutes / 1440; unit = '1440';
|
|
} else if (minutes >= 60 && minutes % 60 === 0) {
|
|
value = minutes / 60; unit = '60';
|
|
} else {
|
|
value = minutes; unit = '1';
|
|
}
|
|
const input = document.getElementById('inc-refresh-value');
|
|
input.value = value;
|
|
input.min = unit === '1' ? 10 : 1;
|
|
{ const _e = document.getElementById('inc-refresh-unit'); if (_e) _e.value = unit; }
|
|
},
|
|
|
|
_refreshHistoryOpen: false,
|
|
|
|
toggleRefreshHistory() {
|
|
if (this._refreshHistoryOpen) {
|
|
this.closeRefreshHistory();
|
|
} else {
|
|
this._openRefreshHistory();
|
|
}
|
|
},
|
|
|
|
async _openRefreshHistory() {
|
|
if (!this.currentIncidentId) return;
|
|
const popover = document.getElementById('refresh-history-popover');
|
|
if (!popover) return;
|
|
|
|
this._refreshHistoryOpen = true;
|
|
popover.style.display = 'flex';
|
|
|
|
// Lade Refresh-Log
|
|
const list = document.getElementById('refresh-history-list');
|
|
list.innerHTML = '<div style="padding:12px;color:var(--text-disabled);font-size:12px;">Lade...</div>';
|
|
|
|
try {
|
|
const logs = await API.getRefreshLog(this.currentIncidentId, 20);
|
|
this._renderRefreshHistory(logs);
|
|
} catch (e) {
|
|
list.innerHTML = '<div style="padding:12px;color:var(--error);font-size:12px;">Fehler beim Laden</div>';
|
|
}
|
|
|
|
// Outside-Click Listener
|
|
setTimeout(() => {
|
|
const handler = (e) => {
|
|
if (!popover.contains(e.target) && !e.target.closest('.meta-updated-link')) {
|
|
this.closeRefreshHistory();
|
|
document.removeEventListener('click', handler);
|
|
}
|
|
};
|
|
document.addEventListener('click', handler);
|
|
popover._outsideHandler = handler;
|
|
}, 0);
|
|
},
|
|
|
|
closeRefreshHistory() {
|
|
this._refreshHistoryOpen = false;
|
|
const popover = document.getElementById('refresh-history-popover');
|
|
if (popover) {
|
|
popover.style.display = 'none';
|
|
if (popover._outsideHandler) {
|
|
document.removeEventListener('click', popover._outsideHandler);
|
|
delete popover._outsideHandler;
|
|
}
|
|
}
|
|
},
|
|
|
|
_renderRefreshHistory(logs) {
|
|
const list = document.getElementById('refresh-history-list');
|
|
if (!list) return;
|
|
|
|
if (!logs || logs.length === 0) {
|
|
list.innerHTML = '<div style="padding:12px;color:var(--text-disabled);font-size:12px;">Noch keine Refreshes durchgeführt</div>';
|
|
return;
|
|
}
|
|
|
|
list.innerHTML = logs.map(log => {
|
|
const started = parseUTC(log.started_at) || new Date(log.started_at);
|
|
const timeStr = started.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', timeZone: TIMEZONE }) + ' ' +
|
|
started.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', timeZone: TIMEZONE });
|
|
|
|
let detail = '';
|
|
if (log.status === 'completed') {
|
|
detail = `${log.articles_found} Artikel`;
|
|
if (log.duration_seconds != null) {
|
|
detail += ` in ${this._formatDuration(log.duration_seconds)}`;
|
|
}
|
|
} else if (log.status === 'running') {
|
|
detail = 'Läuft...';
|
|
} else if (log.status === 'error') {
|
|
detail = '';
|
|
}
|
|
|
|
const retryInfo = log.retry_count > 0 ? ` (Versuch ${log.retry_count + 1})` : '';
|
|
const errorHtml = log.error_message
|
|
? `<div class="rh-info-error" title="${log.error_message.replace(/"/g, '"')}">${log.error_message}</div>`
|
|
: '';
|
|
|
|
return `<div class="refresh-history-entry">
|
|
<div class="rh-status-dot ${log.status}"></div>
|
|
<div class="rh-info">
|
|
<div class="rh-info-time">${timeStr}${retryInfo}</div>
|
|
${detail ? `<div class="rh-info-detail">${detail}</div>` : ''}
|
|
${errorHtml}
|
|
</div>
|
|
<span class="rh-trigger-badge ${log.trigger_type}">${log.trigger_type === 'auto' ? 'Auto' : 'Manuell'}</span>
|
|
</div>`;
|
|
}).join('');
|
|
},
|
|
|
|
_formatDuration(seconds) {
|
|
if (seconds == null) return '';
|
|
if (seconds < 60) return `${Math.round(seconds)}s`;
|
|
const m = Math.floor(seconds / 60);
|
|
const s = Math.round(seconds % 60);
|
|
return s > 0 ? `${m}m ${s}s` : `${m}m`;
|
|
},
|
|
|
|
_timeAgo(date) {
|
|
if (!date) return '';
|
|
const now = new Date();
|
|
const diff = Math.floor((now - date) / 1000);
|
|
if (diff < 60) return 'gerade eben';
|
|
if (diff < 3600) return `vor ${Math.floor(diff / 60)}m`;
|
|
if (diff < 86400) return `vor ${Math.floor(diff / 3600)}h`;
|
|
return `vor ${Math.floor(diff / 86400)}d`;
|
|
},
|
|
|
|
_updateRefreshButton(disabled) {
|
|
const btn = document.getElementById('refresh-btn');
|
|
if (!btn) return;
|
|
btn.disabled = disabled;
|
|
btn.textContent = disabled ? 'Läuft...' : 'Aktualisieren';
|
|
},
|
|
|
|
async handleDelete() {
|
|
if (!this.currentIncidentId) return;
|
|
if (!await confirmDialog('Lage wirklich löschen? Alle gesammelten Daten gehen verloren.')) return;
|
|
|
|
try {
|
|
await API.deleteIncident(this.currentIncidentId);
|
|
this.currentIncidentId = null;
|
|
if (typeof LayoutManager !== 'undefined') LayoutManager.destroy();
|
|
document.getElementById('incident-view').style.display = 'none';
|
|
document.getElementById('empty-state').style.display = 'flex';
|
|
await this.loadIncidents();
|
|
UI.showToast('Lage gelöscht.', 'success');
|
|
} catch (err) {
|
|
UI.showToast('Fehler: ' + err.message, 'error');
|
|
}
|
|
},
|
|
|
|
async handleEdit() {
|
|
if (!this.currentIncidentId) return;
|
|
const incident = this.incidents.find(i => i.id === this.currentIncidentId);
|
|
if (!incident) return;
|
|
|
|
this._editingIncidentId = this.currentIncidentId;
|
|
|
|
// Formular mit aktuellen Werten füllen
|
|
{ const _e = document.getElementById('inc-title'); if (_e) _e.value = incident.title; }
|
|
{ const _e = document.getElementById('inc-description'); if (_e) _e.value = incident.description || ''; }
|
|
{ const _e = document.getElementById('inc-type'); if (_e) _e.value = incident.type || 'adhoc'; }
|
|
{ const _e = document.getElementById('inc-refresh-mode'); if (_e) _e.value = incident.refresh_mode; }
|
|
App._setIntervalFields(incident.refresh_interval);
|
|
{ const _e = document.getElementById('inc-retention'); if (_e) _e.value = incident.retention_days; }
|
|
{ const _e = document.getElementById('inc-international'); if (_e) _e.checked = incident.international_sources !== false && incident.international_sources !== 0; }
|
|
{ const _e = document.getElementById('inc-telegram'); if (_e) _e.checked = !!incident.include_telegram; }
|
|
|
|
{ const _e = document.getElementById('inc-visibility'); if (_e) _e.checked = incident.visibility !== 'private'; }
|
|
updateVisibilityHint();
|
|
updateSourcesHint();
|
|
toggleTypeDefaults();
|
|
toggleRefreshInterval();
|
|
|
|
// Modal-Titel und Submit ändern
|
|
{ const _e = document.getElementById('modal-new-title'); if (_e) _e.textContent = 'Lage bearbeiten'; }
|
|
{ const _e = document.getElementById('modal-new-submit'); if (_e) _e.textContent = 'Speichern'; }
|
|
|
|
// E-Mail-Subscription laden
|
|
try {
|
|
const sub = await API.getSubscription(this.currentIncidentId);
|
|
{ const _e = document.getElementById('inc-notify-summary'); if (_e) _e.checked = !!sub.notify_email_summary; }
|
|
{ const _e = document.getElementById('inc-notify-new-articles'); if (_e) _e.checked = !!sub.notify_email_new_articles; }
|
|
{ const _e = document.getElementById('inc-notify-status-change'); if (_e) _e.checked = !!sub.notify_email_status_change; }
|
|
} catch (e) {
|
|
{ const _e = document.getElementById('inc-notify-summary'); if (_e) _e.checked = false; }
|
|
{ const _e = document.getElementById('inc-notify-new-articles'); if (_e) _e.checked = false; }
|
|
{ const _e = document.getElementById('inc-notify-status-change'); if (_e) _e.checked = false; }
|
|
}
|
|
|
|
openModal('modal-new');
|
|
},
|
|
|
|
async handleArchive() {
|
|
if (!this.currentIncidentId) return;
|
|
const incident = this.incidents.find(i => i.id === this.currentIncidentId);
|
|
if (!incident) return;
|
|
|
|
const isArchived = incident.status === 'archived';
|
|
const action = isArchived ? 'wiederherstellen' : 'archivieren';
|
|
|
|
if (!await confirmDialog(`Lage wirklich ${action}?`)) return;
|
|
|
|
try {
|
|
const newStatus = isArchived ? 'active' : 'archived';
|
|
await API.updateIncident(this.currentIncidentId, { status: newStatus });
|
|
await this.loadIncidents();
|
|
await this.loadIncidentDetail(this.currentIncidentId);
|
|
this._updateArchiveButton(newStatus);
|
|
UI.showToast(isArchived ? 'Lage wiederhergestellt.' : 'Lage archiviert.', 'success');
|
|
} catch (err) {
|
|
UI.showToast('Fehler: ' + err.message, 'error');
|
|
}
|
|
},
|
|
|
|
_updateSidebarDot(incidentId, mode) {
|
|
const dot = document.getElementById(`dot-${incidentId}`);
|
|
if (!dot) return;
|
|
const incident = this.incidents.find(i => i.id === incidentId);
|
|
const baseClass = incident ? (incident.status === 'active' ? 'active' : 'archived') : 'active';
|
|
|
|
if (mode === 'error') {
|
|
dot.className = `incident-dot refresh-error`;
|
|
setTimeout(() => {
|
|
dot.className = `incident-dot ${baseClass}`;
|
|
}, 3000);
|
|
} else if (this._refreshingIncidents.has(incidentId)) {
|
|
dot.className = `incident-dot refreshing`;
|
|
} else {
|
|
dot.className = `incident-dot ${baseClass}`;
|
|
}
|
|
},
|
|
|
|
_updateArchiveButton(status) {
|
|
const btn = document.getElementById('archive-incident-btn');
|
|
if (!btn) return;
|
|
btn.textContent = status === 'archived' ? 'Wiederherstellen' : 'Archivieren';
|
|
},
|
|
|
|
// === WebSocket Handlers ===
|
|
|
|
handleStatusUpdate(msg) {
|
|
const status = msg.data.status;
|
|
if (status === 'retrying') {
|
|
// Retry-Status → Fehleranzeige mit Retry-Info
|
|
if (msg.incident_id === this.currentIncidentId) {
|
|
UI.showProgressError('', true, msg.data.delay || 120);
|
|
}
|
|
return;
|
|
}
|
|
if (status !== 'idle') {
|
|
this._refreshingIncidents.add(msg.incident_id);
|
|
}
|
|
this._updateSidebarDot(msg.incident_id);
|
|
if (msg.incident_id === this.currentIncidentId) {
|
|
UI.showProgress(status, msg.data);
|
|
this._updateRefreshButton(status !== 'idle');
|
|
}
|
|
},
|
|
|
|
async handleRefreshComplete(msg) {
|
|
this._refreshingIncidents.delete(msg.incident_id);
|
|
this._updateSidebarDot(msg.incident_id);
|
|
|
|
if (msg.incident_id === this.currentIncidentId) {
|
|
this._updateRefreshButton(false);
|
|
await this.loadIncidentDetail(msg.incident_id);
|
|
|
|
// Progress-Bar nicht sofort ausblenden — auf refresh_summary warten
|
|
this._pendingComplete = msg.incident_id;
|
|
// Fallback: Wenn nach 5s kein refresh_summary kommt → direkt ausblenden
|
|
if (this._pendingCompleteTimer) clearTimeout(this._pendingCompleteTimer);
|
|
this._pendingCompleteTimer = setTimeout(() => {
|
|
if (this._pendingComplete === msg.incident_id) {
|
|
this._pendingComplete = null;
|
|
UI.hideProgress();
|
|
}
|
|
}, 5000);
|
|
}
|
|
|
|
await this.loadIncidents();
|
|
},
|
|
|
|
|
|
|
|
handleRefreshSummary(msg) {
|
|
const d = msg.data;
|
|
const title = d.incident_title || 'Lage';
|
|
|
|
// Abschluss-Animation auslösen wenn pending
|
|
if (this._pendingComplete === msg.incident_id) {
|
|
if (this._pendingCompleteTimer) {
|
|
clearTimeout(this._pendingCompleteTimer);
|
|
this._pendingCompleteTimer = null;
|
|
}
|
|
this._pendingComplete = null;
|
|
UI.showProgressComplete(d);
|
|
setTimeout(() => UI.hideProgress(), 4000);
|
|
}
|
|
|
|
// Toast-Text zusammenbauen
|
|
const parts = [];
|
|
if (d.new_articles > 0) {
|
|
parts.push(`${d.new_articles} neue Meldung${d.new_articles !== 1 ? 'en' : ''}`);
|
|
}
|
|
if (d.confirmed_count > 0) {
|
|
parts.push(`${d.confirmed_count} bestätigt`);
|
|
}
|
|
if (d.contradicted_count > 0) {
|
|
parts.push(`${d.contradicted_count} widersprochen`);
|
|
}
|
|
if (d.status_changes && d.status_changes.length > 0) {
|
|
parts.push(`${d.status_changes.length} Statusänderung${d.status_changes.length !== 1 ? 'en' : ''}`);
|
|
}
|
|
|
|
const summaryText = parts.length > 0
|
|
? parts.join(', ')
|
|
: 'Keine neuen Entwicklungen';
|
|
|
|
// 1 Toast statt 5-10
|
|
UI.showToast(`Recherche abgeschlossen: ${summaryText}`, 'success', 6000);
|
|
|
|
// Ins NotificationCenter eintragen
|
|
NotificationCenter.add({
|
|
incident_id: msg.incident_id,
|
|
title: title,
|
|
text: `Recherche: ${summaryText}`,
|
|
icon: d.contradicted_count > 0 ? 'warning' : 'success',
|
|
});
|
|
|
|
// Status-Änderungen als separate Einträge
|
|
if (d.status_changes) {
|
|
d.status_changes.forEach(sc => {
|
|
const oldLabel = this._translateStatus(sc.old_status);
|
|
const newLabel = this._translateStatus(sc.new_status);
|
|
NotificationCenter.add({
|
|
incident_id: msg.incident_id,
|
|
title: title,
|
|
text: `${sc.claim}: ${oldLabel} \u2192 ${newLabel}`,
|
|
icon: sc.new_status === 'contradicted' || sc.new_status === 'disputed' ? 'error' : 'success',
|
|
});
|
|
});
|
|
}
|
|
|
|
// Sidebar-Dot blinken
|
|
const dot = document.getElementById(`dot-${msg.incident_id}`);
|
|
if (dot) {
|
|
dot.classList.add('has-notification');
|
|
setTimeout(() => dot.classList.remove('has-notification'), 10000);
|
|
}
|
|
},
|
|
|
|
_translateStatus(status) {
|
|
const map = {
|
|
confirmed: 'Bestätigt',
|
|
established: 'Gesichert',
|
|
unconfirmed: 'Unbestätigt',
|
|
contradicted: 'Widersprochen',
|
|
disputed: 'Umstritten',
|
|
developing: 'In Entwicklung',
|
|
unverified: 'Ungeprüft',
|
|
};
|
|
return map[status] || status;
|
|
},
|
|
|
|
handleRefreshError(msg) {
|
|
this._refreshingIncidents.delete(msg.incident_id);
|
|
this._updateSidebarDot(msg.incident_id, 'error');
|
|
if (msg.incident_id === this.currentIncidentId) {
|
|
this._updateRefreshButton(false);
|
|
// Pending-Complete aufräumen
|
|
if (this._pendingCompleteTimer) {
|
|
clearTimeout(this._pendingCompleteTimer);
|
|
this._pendingCompleteTimer = null;
|
|
}
|
|
this._pendingComplete = null;
|
|
UI.showProgressError(msg.data.error, false);
|
|
}
|
|
UI.showToast(`Recherche-Fehler: ${msg.data.error}`, 'error');
|
|
},
|
|
|
|
handleRefreshCancelled(msg) {
|
|
this._refreshingIncidents.delete(msg.incident_id);
|
|
this._updateSidebarDot(msg.incident_id);
|
|
if (msg.incident_id === this.currentIncidentId) {
|
|
this._updateRefreshButton(false);
|
|
if (this._pendingCompleteTimer) {
|
|
clearTimeout(this._pendingCompleteTimer);
|
|
this._pendingCompleteTimer = null;
|
|
}
|
|
this._pendingComplete = null;
|
|
UI.hideProgress();
|
|
}
|
|
UI.showToast('Recherche abgebrochen.', 'info');
|
|
},
|
|
|
|
async cancelRefresh() {
|
|
if (!this.currentIncidentId) return;
|
|
const ok = await confirmDialog('Laufende Recherche abbrechen?');
|
|
if (!ok) return;
|
|
|
|
const btn = document.getElementById('progress-cancel-btn');
|
|
if (btn) {
|
|
btn.textContent = 'Wird abgebrochen...';
|
|
btn.disabled = true;
|
|
}
|
|
|
|
try {
|
|
await API.cancelRefresh(this.currentIncidentId);
|
|
} catch (err) {
|
|
UI.showToast('Abbrechen fehlgeschlagen: ' + err.message, 'error');
|
|
if (btn) {
|
|
btn.textContent = 'Abbrechen';
|
|
btn.disabled = false;
|
|
}
|
|
}
|
|
},
|
|
|
|
// === Export ===
|
|
|
|
toggleExportDropdown(event) {
|
|
event.stopPropagation();
|
|
const menu = document.getElementById('export-dropdown-menu');
|
|
if (!menu) return;
|
|
const isOpen = menu.classList.toggle('show');
|
|
const btn = menu.previousElementSibling;
|
|
if (btn) btn.setAttribute('aria-expanded', String(isOpen));
|
|
},
|
|
|
|
_closeExportDropdown() {
|
|
const menu = document.getElementById('export-dropdown-menu');
|
|
if (menu) {
|
|
menu.classList.remove('show');
|
|
const btn = menu.previousElementSibling;
|
|
if (btn) btn.setAttribute('aria-expanded', 'false');
|
|
}
|
|
},
|
|
|
|
openPdfExportDialog() {
|
|
this._closeExportDropdown();
|
|
if (!this.currentIncidentId) return;
|
|
openModal('modal-pdf-export');
|
|
},
|
|
|
|
executePdfExport() {
|
|
closeModal('modal-pdf-export');
|
|
const checked = [...document.querySelectorAll('#pdf-export-tiles input:checked')].map(c => c.value);
|
|
if (!checked.length) { UI.showToast('Keine Kacheln ausgewählt', 'warning'); return; }
|
|
this._generatePdf(checked);
|
|
},
|
|
|
|
_generatePdf(tiles) {
|
|
const title = document.getElementById('incident-title')?.textContent || 'Export';
|
|
const now = new Date().toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' });
|
|
|
|
let sections = '';
|
|
const esc = (s) => s ? s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"') : '';
|
|
|
|
// === Lagebild ===
|
|
if (tiles.includes('lagebild')) {
|
|
const summaryEl = document.getElementById('summary-text');
|
|
const timestamp = document.getElementById('lagebild-timestamp')?.textContent || '';
|
|
if (summaryEl && summaryEl.innerHTML.trim()) {
|
|
// Clone innerHTML and make citation links clickable with full URL visible
|
|
let summaryHtml = summaryEl.innerHTML;
|
|
// Ensure citation links are styled for print (underlined, blue)
|
|
summaryHtml = summaryHtml.replace(/<a\s+href="([^"]*)"[^>]*class="citation"[^>]*>(\[[^\]]+\])<\/a>/g,
|
|
'<a href="$1" class="citation">$2</a>');
|
|
sections += '<div class="pdf-section">'
|
|
+ '<h2>Lagebild</h2>'
|
|
+ (timestamp ? '<p class="pdf-meta">' + esc(timestamp) + '</p>' : '')
|
|
+ '<div class="pdf-content">' + summaryHtml + '</div>'
|
|
+ '</div>';
|
|
}
|
|
}
|
|
|
|
// === Quellen ===
|
|
if (tiles.includes('quellen')) {
|
|
const articles = this._currentArticles || [];
|
|
if (articles.length) {
|
|
const sourceMap = {};
|
|
articles.forEach(a => {
|
|
const name = a.source || 'Unbekannt';
|
|
if (!sourceMap[name]) sourceMap[name] = [];
|
|
sourceMap[name].push(a);
|
|
});
|
|
const sources = Object.entries(sourceMap).sort((a,b) => b[1].length - a[1].length);
|
|
let s = '<p class="pdf-meta">' + articles.length + ' Artikel aus ' + sources.length + ' Quellen</p>';
|
|
s += '<table class="pdf-table"><thead><tr><th>Quelle</th><th>Artikel</th><th>Sprache</th></tr></thead><tbody>';
|
|
sources.forEach(([name, arts]) => {
|
|
const langs = [...new Set(arts.map(a => (a.language || 'de').toUpperCase()))].join(', ');
|
|
s += '<tr><td><strong>' + esc(name) + '</strong></td><td>' + arts.length + '</td><td>' + langs + '</td></tr>';
|
|
});
|
|
s += '</tbody></table>';
|
|
s += '<div class="pdf-article-list">';
|
|
sources.forEach(([name, arts]) => {
|
|
s += '<h4>' + esc(name) + ' (' + arts.length + ')</h4>';
|
|
arts.forEach(a => {
|
|
const hl = esc(a.headline_de || a.headline || 'Ohne Titel');
|
|
const url = a.source_url || '';
|
|
const dateStr = a.published_at ? new Date(a.published_at).toLocaleDateString('de-DE') : '';
|
|
s += '<div class="pdf-article-item">';
|
|
s += url ? '<a href="' + esc(url) + '">' + hl + '</a>' : '<span>' + hl + '</span>';
|
|
if (dateStr) s += ' <span class="pdf-date">(' + dateStr + ')</span>';
|
|
s += '</div>';
|
|
});
|
|
});
|
|
s += '</div>';
|
|
sections += '<div class="pdf-section"><h2>Quellenübersicht</h2>' + s + '</div>';
|
|
}
|
|
}
|
|
|
|
// === Faktencheck ===
|
|
if (tiles.includes('faktencheck')) {
|
|
const fcItems = document.querySelectorAll('#factcheck-list .factcheck-item');
|
|
if (fcItems.length) {
|
|
let s = '<div class="pdf-fc-list">';
|
|
fcItems.forEach(item => {
|
|
const status = item.dataset.fcStatus || '';
|
|
const statusEl = item.querySelector('.fc-status-text, .factcheck-status');
|
|
const claimEl = item.querySelector('.fc-claim-text, .factcheck-claim');
|
|
const evidenceEls = item.querySelectorAll('.fc-evidence-chip, .evidence-chip');
|
|
const statusText = statusEl ? statusEl.textContent.trim() : status;
|
|
const claim = claimEl ? claimEl.textContent.trim() : '';
|
|
const statusClass = (status.includes('confirmed') || status.includes('verified')) ? 'confirmed'
|
|
: (status.includes('refuted') || status.includes('disputed')) ? 'refuted'
|
|
: 'unverified';
|
|
s += '<div class="pdf-fc-item">'
|
|
+ '<span class="pdf-fc-badge pdf-fc-' + statusClass + '">' + esc(statusText) + '</span>'
|
|
+ '<div class="pdf-fc-claim">' + esc(claim) + '</div>';
|
|
if (evidenceEls.length) {
|
|
s += '<div class="pdf-fc-evidence">';
|
|
evidenceEls.forEach(ev => {
|
|
const link = ev.closest('a');
|
|
const href = link ? link.href : '';
|
|
const text = ev.textContent.trim();
|
|
s += href
|
|
? '<a href="' + esc(href) + '" class="pdf-fc-ev-link">' + esc(text) + '</a> '
|
|
: '<span class="pdf-fc-ev-tag">' + esc(text) + '</span> ';
|
|
});
|
|
s += '</div>';
|
|
}
|
|
s += '</div>';
|
|
});
|
|
s += '</div>';
|
|
sections += '<div class="pdf-section"><h2>Faktencheck</h2>' + s + '</div>';
|
|
}
|
|
}
|
|
|
|
// === Timeline ===
|
|
if (tiles.includes('timeline')) {
|
|
const buckets = document.querySelectorAll('#timeline .ht-bucket');
|
|
if (buckets.length) {
|
|
let s = '<div class="pdf-timeline">';
|
|
buckets.forEach(bucket => {
|
|
const label = bucket.querySelector('.ht-bucket-label');
|
|
const items = bucket.querySelectorAll('.ht-item');
|
|
if (label) s += '<h4>' + esc(label.textContent.trim()) + '</h4>';
|
|
items.forEach(item => {
|
|
const time = item.querySelector('.ht-item-time');
|
|
const ttl = item.querySelector('.ht-item-title');
|
|
const src = item.querySelector('.ht-item-source');
|
|
s += '<div class="pdf-tl-item">';
|
|
if (time) s += '<span class="pdf-tl-time">' + esc(time.textContent.trim()) + '</span> ';
|
|
if (ttl) s += '<span class="pdf-tl-title">' + esc(ttl.textContent.trim()) + '</span>';
|
|
if (src) s += ' <span class="pdf-tl-source">' + esc(src.textContent.trim()) + '</span>';
|
|
s += '</div>';
|
|
});
|
|
});
|
|
s += '</div>';
|
|
sections += '<div class="pdf-section"><h2>Ereignis-Timeline</h2>' + s + '</div>';
|
|
}
|
|
}
|
|
|
|
if (!sections) { UI.showToast('Keine Inhalte zum Exportieren', 'warning'); return; }
|
|
|
|
const css = `
|
|
@page { margin: 18mm 15mm 18mm 15mm; size: A4; }
|
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
body { font-family: -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; font-size: 11pt; line-height: 1.55; color: #1a1a1a; background: #fff; padding: 0; }
|
|
a { color: #1a5276; }
|
|
|
|
/* Header: compact, inline with content */
|
|
.pdf-header { border-bottom: 2px solid #2c3e50; padding-bottom: 10px; margin-bottom: 16px; }
|
|
.pdf-header h1 { font-size: 18pt; font-weight: 700; color: #2c3e50; margin-bottom: 2px; }
|
|
.pdf-header .pdf-subtitle { font-size: 9pt; color: #666; }
|
|
|
|
/* Sections */
|
|
.pdf-section { margin-bottom: 22px; }
|
|
.pdf-section h2 { font-size: 13pt; font-weight: 600; color: #2c3e50; border-bottom: 1px solid #ccc; padding-bottom: 4px; margin-bottom: 10px; }
|
|
.pdf-section h4 { font-size: 10pt; font-weight: 600; color: #444; margin: 10px 0 3px; }
|
|
.pdf-meta { font-size: 9pt; color: #888; margin-bottom: 8px; }
|
|
|
|
/* Lagebild content */
|
|
.pdf-content { font-size: 10.5pt; line-height: 1.6; }
|
|
.pdf-content h3 { font-size: 11.5pt; font-weight: 600; color: #2c3e50; margin: 12px 0 5px; }
|
|
.pdf-content strong { font-weight: 600; }
|
|
.pdf-content ul { margin: 4px 0 4px 18px; }
|
|
.pdf-content li { margin-bottom: 2px; }
|
|
.pdf-content a, .pdf-content .citation { color: #1a5276; font-weight: 600; text-decoration: underline; cursor: pointer; }
|
|
|
|
/* Quellen table */
|
|
.pdf-table { width: 100%; border-collapse: collapse; font-size: 9.5pt; margin-bottom: 14px; }
|
|
.pdf-table th { background: #f0f0f0; text-align: left; padding: 5px 8px; border: 1px solid #ddd; font-weight: 600; font-size: 8.5pt; text-transform: uppercase; color: #555; }
|
|
.pdf-table td { padding: 4px 8px; border: 1px solid #ddd; }
|
|
.pdf-table tr:nth-child(even) { background: #fafafa; }
|
|
.pdf-article-list { font-size: 9.5pt; }
|
|
.pdf-article-item { padding: 1px 0; break-inside: avoid; }
|
|
.pdf-article-item a { color: #1a5276; text-decoration: none; }
|
|
.pdf-article-item a:hover { text-decoration: underline; }
|
|
.pdf-date { color: #888; font-size: 8.5pt; }
|
|
|
|
/* Faktencheck */
|
|
.pdf-fc-list { display: flex; flex-direction: column; gap: 10px; }
|
|
.pdf-fc-item { border: 1px solid #ddd; border-radius: 4px; padding: 8px 12px; break-inside: avoid; }
|
|
.pdf-fc-badge { display: inline-block; font-size: 7.5pt; font-weight: 700; text-transform: uppercase; letter-spacing: 0.4px; padding: 1px 7px; border-radius: 3px; margin-bottom: 3px; }
|
|
.pdf-fc-confirmed { background: #d4edda; color: #155724; }
|
|
.pdf-fc-refuted { background: #f8d7da; color: #721c24; }
|
|
.pdf-fc-unverified { background: #fff3cd; color: #856404; }
|
|
.pdf-fc-claim { font-size: 10.5pt; margin-top: 3px; }
|
|
.pdf-fc-evidence { margin-top: 5px; font-size: 8.5pt; }
|
|
.pdf-fc-ev-link { color: #1a5276; text-decoration: underline; margin-right: 5px; }
|
|
.pdf-fc-ev-tag { background: #eee; padding: 1px 5px; border-radius: 3px; margin-right: 3px; }
|
|
|
|
/* Timeline */
|
|
.pdf-timeline h4 { color: #2c3e50; border-bottom: 1px solid #eee; padding-bottom: 2px; margin-top: 8px; }
|
|
.pdf-tl-item { padding: 1px 0; font-size: 9.5pt; break-inside: avoid; }
|
|
.pdf-tl-time { color: #888; font-size: 8.5pt; min-width: 36px; display: inline-block; }
|
|
.pdf-tl-source { color: #888; font-size: 8.5pt; }
|
|
|
|
/* Footer */
|
|
.pdf-footer { margin-top: 24px; padding-top: 8px; border-top: 1px solid #ddd; font-size: 8pt; color: #999; text-align: center; }
|
|
`;
|
|
|
|
const printHtml = '<!DOCTYPE html>\n<html lang="de">\n<head>\n<meta charset="utf-8">\n'
|
|
+ '<title>' + esc(title) + ' \u2014 AegisSight Export</title>\n'
|
|
+ '<style>' + css + '</style>\n'
|
|
+ '</head>\n<body>\n'
|
|
+ '<div class="pdf-header">\n'
|
|
+ ' <h1>' + esc(title) + '</h1>\n'
|
|
+ ' <div class="pdf-subtitle">AegisSight Monitor \u2014 Exportiert am ' + esc(now) + '</div>\n'
|
|
+ '</div>\n'
|
|
+ sections + '\n'
|
|
+ '<div class="pdf-footer">Erstellt mit AegisSight Monitor \u2014 aegis-sight.de</div>\n'
|
|
+ '</body></html>';
|
|
|
|
const printWin = window.open('', '_blank', 'width=800,height=600');
|
|
if (!printWin) { UI.showToast('Popup blockiert \u2014 bitte Popup-Blocker deaktivieren', 'error'); return; }
|
|
printWin.document.write(printHtml);
|
|
printWin.document.close();
|
|
printWin.onload = function() { printWin.focus(); printWin.print(); };
|
|
setTimeout(function() { try { printWin.focus(); printWin.print(); } catch(e) {} }, 500);
|
|
},
|
|
|
|
async exportIncident(format, scope) {
|
|
this._closeExportDropdown();
|
|
if (!this.currentIncidentId) return;
|
|
try {
|
|
const response = await API.exportIncident(this.currentIncidentId, format, scope);
|
|
if (!response.ok) {
|
|
const err = await response.json().catch(() => ({}));
|
|
throw new Error(err.detail || 'Fehler ' + response.status);
|
|
}
|
|
const blob = await response.blob();
|
|
const disposition = response.headers.get('Content-Disposition') || '';
|
|
let filename = 'export.' + format;
|
|
const match = disposition.match(/filename="?([^"]+)"?/);
|
|
if (match) filename = match[1];
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = filename;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
UI.showToast('Export heruntergeladen', 'success');
|
|
} catch (err) {
|
|
UI.showToast('Export fehlgeschlagen: ' + err.message, 'error');
|
|
}
|
|
},
|
|
|
|
|
|
|
|
// === Sidebar-Stats ===
|
|
|
|
async updateSidebarStats() {
|
|
try {
|
|
const stats = await API.getSourceStats();
|
|
const srcCount = document.getElementById('stat-sources-count');
|
|
const artCount = document.getElementById('stat-articles-count');
|
|
if (srcCount) srcCount.textContent = `${stats.total_sources} Quellen`;
|
|
if (artCount) artCount.textContent = `${stats.total_articles} Artikel`;
|
|
} catch {
|
|
// Fallback: aus Lagen berechnen
|
|
const totalArticles = this.incidents.reduce((sum, i) => sum + i.article_count, 0);
|
|
const totalSources = this.incidents.reduce((sum, i) => sum + i.source_count, 0);
|
|
const srcCount = document.getElementById('stat-sources-count');
|
|
const artCount = document.getElementById('stat-articles-count');
|
|
if (srcCount) srcCount.textContent = `${totalSources} Quellen`;
|
|
if (artCount) artCount.textContent = `${totalArticles} Artikel`;
|
|
}
|
|
},
|
|
|
|
// === Soft-Refresh (F5) ===
|
|
|
|
async softRefresh() {
|
|
try {
|
|
await this.loadIncidents();
|
|
if (this.currentIncidentId) {
|
|
await this.selectIncident(this.currentIncidentId);
|
|
}
|
|
UI.showToast('Daten aktualisiert.', 'success', 2000);
|
|
} catch (err) {
|
|
UI.showToast('Aktualisierung fehlgeschlagen: ' + err.message, 'error');
|
|
}
|
|
},
|
|
|
|
// === Feedback ===
|
|
|
|
openFeedback() {
|
|
const form = document.getElementById('feedback-form');
|
|
if (form) form.reset();
|
|
const counter = document.getElementById('fb-char-count');
|
|
if (counter) counter.textContent = '0';
|
|
openModal('modal-feedback');
|
|
},
|
|
|
|
async submitFeedback(e) {
|
|
e.preventDefault();
|
|
const form = document.getElementById('feedback-form');
|
|
this._clearFormErrors(form);
|
|
|
|
const btn = document.getElementById('fb-submit-btn');
|
|
const category = document.getElementById('fb-category').value;
|
|
const msgField = document.getElementById('fb-message');
|
|
const message = msgField.value.trim();
|
|
|
|
if (message.length < 10) {
|
|
this._showFieldError(msgField, 'Bitte mindestens 10 Zeichen eingeben.');
|
|
msgField.focus();
|
|
return;
|
|
}
|
|
|
|
// Dateien pruefen
|
|
const fileInput = document.getElementById('fb-files');
|
|
const files = fileInput ? Array.from(fileInput.files) : [];
|
|
if (files.length > 3) {
|
|
UI.showToast('Maximal 3 Bilder erlaubt.', 'error');
|
|
return;
|
|
}
|
|
for (const f of files) {
|
|
if (f.size > 5 * 1024 * 1024) {
|
|
UI.showToast('Datei "' + f.name + '" ist groesser als 5 MB.', 'error');
|
|
return;
|
|
}
|
|
}
|
|
|
|
btn.disabled = true;
|
|
btn.textContent = 'Wird gesendet...';
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append('category', category);
|
|
formData.append('message', message);
|
|
for (const f of files) {
|
|
formData.append('files', f);
|
|
}
|
|
await API.sendFeedbackForm(formData);
|
|
closeModal('modal-feedback');
|
|
UI.showToast('Feedback gesendet. Vielen Dank!', 'success');
|
|
} catch (err) {
|
|
UI.showToast('Fehler: ' + err.message, 'error');
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.textContent = 'Absenden';
|
|
}
|
|
},
|
|
|
|
// === Sidebar Sektionen ein-/ausklappen ===
|
|
|
|
toggleSidebarSection(sectionId) {
|
|
const list = document.getElementById(sectionId);
|
|
if (!list) return;
|
|
const chevron = document.getElementById('chevron-' + sectionId);
|
|
const isHidden = list.style.display === 'none';
|
|
list.style.display = isHidden ? '' : 'none';
|
|
if (chevron) {
|
|
chevron.classList.toggle('open', isHidden);
|
|
}
|
|
// aria-expanded auf dem Section-Title synchronisieren
|
|
const title = chevron ? chevron.closest('.sidebar-section-title') : null;
|
|
if (title) title.setAttribute('aria-expanded', String(isHidden));
|
|
},
|
|
|
|
// === Quellenverwaltung ===
|
|
|
|
async openSourceManagement() {
|
|
openModal('modal-sources');
|
|
await this.loadSources();
|
|
},
|
|
|
|
async loadSources() {
|
|
try {
|
|
const [sources, stats, myExclusions] = await Promise.all([
|
|
API.listSources(),
|
|
API.getSourceStats(),
|
|
API.getMyExclusions(),
|
|
]);
|
|
this._allSources = sources;
|
|
this._sourcesOnly = sources.filter(s => s.source_type !== 'excluded');
|
|
this._myExclusions = myExclusions || [];
|
|
|
|
this.renderSourceStats(stats);
|
|
this.renderSourceList();
|
|
} catch (err) {
|
|
UI.showToast('Fehler beim Laden der Quellen: ' + err.message, 'error');
|
|
}
|
|
},
|
|
|
|
renderSourceStats(stats) {
|
|
const bar = document.getElementById('sources-stats-bar');
|
|
if (!bar) return;
|
|
|
|
const rss = stats.by_type.rss_feed || { count: 0, articles: 0 };
|
|
const web = stats.by_type.web_source || { count: 0, articles: 0 };
|
|
const tg = stats.by_type.telegram_channel || { count: 0, articles: 0 };
|
|
const excluded = this._myExclusions.length;
|
|
|
|
bar.innerHTML = `
|
|
<span class="sources-stat-item"><span class="sources-stat-value">${rss.count}</span> RSS-Feeds</span>
|
|
<span class="sources-stat-item"><span class="sources-stat-value">${web.count}</span> Web-Quellen</span>
|
|
<span class="sources-stat-item"><span class="sources-stat-value">${tg.count}</span> Telegram</span>
|
|
<span class="sources-stat-item"><span class="sources-stat-value">${excluded}</span> Ausgeschlossen</span>
|
|
<span class="sources-stat-item"><span class="sources-stat-value">${stats.total_articles}</span> Artikel gesamt</span>
|
|
`;
|
|
},
|
|
|
|
/**
|
|
* Quellen nach Domain gruppiert rendern.
|
|
*/
|
|
renderSourceList() {
|
|
const list = document.getElementById('sources-list');
|
|
if (!list) return;
|
|
|
|
// Filter anwenden
|
|
const typeFilter = document.getElementById('sources-filter-type')?.value || '';
|
|
const catFilter = document.getElementById('sources-filter-category')?.value || '';
|
|
const 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 = '<div style="padding:24px;text-align:center;font-size:13px;color:var(--text-disabled);">Keine Quellen gefunden</div>';
|
|
return;
|
|
}
|
|
|
|
// Sortierung: Aktive zuerst (alphabetisch), dann ausgeschlossene
|
|
filteredGroups.sort((a, b) => {
|
|
if (a.isExcluded !== b.isExcluded) return a.isExcluded ? 1 : -1;
|
|
return a.domain.localeCompare(b.domain);
|
|
});
|
|
|
|
list.innerHTML = filteredGroups.map(g =>
|
|
UI.renderSourceGroup(g.domain, g.feeds, g.isExcluded, excludedNotes[g.domain] || '', g.isGlobal)
|
|
).join('');
|
|
|
|
// Erweiterte Gruppen wiederherstellen
|
|
this._expandedGroups.forEach(domain => {
|
|
const feedsEl = list.querySelector(`.source-group-feeds[data-domain="${CSS.escape(domain)}"]`);
|
|
if (feedsEl) {
|
|
feedsEl.classList.add('expanded');
|
|
const header = feedsEl.previousElementSibling;
|
|
if (header) header.classList.add('expanded');
|
|
}
|
|
});
|
|
},
|
|
|
|
filterSources() {
|
|
this.renderSourceList();
|
|
},
|
|
|
|
/**
|
|
* Domain-Gruppe auf-/zuklappen.
|
|
*/
|
|
toggleSourceOverview() {
|
|
const content = document.getElementById('source-overview-content');
|
|
const chevron = document.getElementById('source-overview-chevron');
|
|
if (!content) return;
|
|
const isHidden = content.style.display === 'none';
|
|
content.style.display = isHidden ? '' : 'none';
|
|
if (chevron) {
|
|
chevron.classList.toggle('open', isHidden);
|
|
chevron.title = isHidden ? 'Einklappen' : 'Aufklappen';
|
|
}
|
|
// aria-expanded auf dem Header-Toggle synchronisieren
|
|
const header = chevron ? chevron.closest('[role="button"]') : null;
|
|
if (header) header.setAttribute('aria-expanded', String(isHidden));
|
|
// gridstack-Kachel an Inhalt anpassen (doppelter rAF für vollständiges Layout)
|
|
if (typeof LayoutManager !== 'undefined' && LayoutManager._grid) {
|
|
if (isHidden) {
|
|
// Aufgeklappt → Inhalt muss erst layouten
|
|
requestAnimationFrame(() => requestAnimationFrame(() => {
|
|
LayoutManager.resizeTileToContent('quellen');
|
|
}));
|
|
} else {
|
|
// Zugeklappt → auf Default-Höhe zurück
|
|
const defaults = LayoutManager.DEFAULT_LAYOUT.find(d => d.id === 'quellen');
|
|
if (defaults) {
|
|
const node = LayoutManager._grid.engine.nodes.find(
|
|
n => n.el && n.el.getAttribute('gs-id') === 'quellen'
|
|
);
|
|
if (node) {
|
|
LayoutManager._grid.update(node.el, { h: defaults.h });
|
|
LayoutManager._debouncedSave();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
|
|
toggleGroup(domain) {
|
|
const list = document.getElementById('sources-list');
|
|
if (!list) return;
|
|
const feedsEl = list.querySelector(`.source-group-feeds[data-domain="${CSS.escape(domain)}"]`);
|
|
if (!feedsEl) return;
|
|
|
|
const isExpanded = feedsEl.classList.toggle('expanded');
|
|
const header = feedsEl.previousElementSibling;
|
|
if (header) {
|
|
header.classList.toggle('expanded', isExpanded);
|
|
header.setAttribute('aria-expanded', String(isExpanded));
|
|
}
|
|
|
|
if (isExpanded) {
|
|
this._expandedGroups.add(domain);
|
|
} else {
|
|
this._expandedGroups.delete(domain);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Domain ausschließen (aus dem Inline-Formular).
|
|
*/
|
|
async blockDomain() {
|
|
const input = document.getElementById('block-domain-input');
|
|
const domain = (input?.value || '').trim();
|
|
if (!domain) {
|
|
UI.showToast('Domain ist erforderlich.', 'warning');
|
|
return;
|
|
}
|
|
|
|
const notes = (document.getElementById('block-domain-notes')?.value || '').trim() || null;
|
|
|
|
try {
|
|
await API.blockDomain(domain, notes);
|
|
UI.showToast(`${domain} ausgeschlossen.`, 'success');
|
|
this.showBlockDomainDialog(false);
|
|
await this.loadSources();
|
|
this.updateSidebarStats();
|
|
} catch (err) {
|
|
UI.showToast('Fehler: ' + err.message, 'error');
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Faktencheck-Filter umschalten.
|
|
*/
|
|
toggleFactCheckFilter(status) {
|
|
const checkbox = document.querySelector(`.fc-dropdown-item[data-status="${status}"] input`);
|
|
if (!checkbox) return;
|
|
const isActive = checkbox.checked;
|
|
|
|
document.querySelectorAll(`.factcheck-item[data-fc-status="${status}"]`).forEach(el => {
|
|
el.style.display = isActive ? '' : 'none';
|
|
});
|
|
},
|
|
|
|
toggleFcDropdown(e) {
|
|
e.stopPropagation();
|
|
const btn = e.target.closest('.fc-dropdown-toggle');
|
|
const menu = btn ? btn.nextElementSibling : document.getElementById('fc-dropdown-menu');
|
|
if (!menu) return;
|
|
const isOpen = menu.classList.toggle('open');
|
|
if (btn) btn.setAttribute('aria-expanded', String(isOpen));
|
|
if (isOpen) {
|
|
const close = (ev) => {
|
|
if (!menu.contains(ev.target)) {
|
|
menu.classList.remove('open');
|
|
document.removeEventListener('click', close);
|
|
}
|
|
};
|
|
setTimeout(() => document.addEventListener('click', close), 0);
|
|
}
|
|
},
|
|
|
|
filterModalTimeline(searchTerm) {
|
|
const filterBtn = document.querySelector('.ht-modal-filter-btn.active');
|
|
const filterType = filterBtn ? filterBtn.dataset.filter : 'all';
|
|
const body = document.getElementById('content-viewer-body');
|
|
if (!body) return;
|
|
body.innerHTML = this._buildFullVerticalTimeline(filterType, (searchTerm || '').toLowerCase());
|
|
},
|
|
|
|
filterModalTimelineType(filterType, btn) {
|
|
document.querySelectorAll('.ht-modal-filter-btn').forEach(b => b.classList.remove('active'));
|
|
if (btn) btn.classList.add('active');
|
|
const searchInput = document.querySelector('#content-viewer-header-extra .timeline-filter-input');
|
|
const searchTerm = searchInput ? searchInput.value.toLowerCase() : '';
|
|
const body = document.getElementById('content-viewer-body');
|
|
if (!body) return;
|
|
body.innerHTML = this._buildFullVerticalTimeline(filterType, searchTerm);
|
|
},
|
|
|
|
/**
|
|
* Domain direkt ausschließen (aus der Gruppenliste).
|
|
*/
|
|
async blockDomainDirect(domain) {
|
|
if (!await confirmDialog(`"${domain}" wirklich ausschließen? Artikel dieser Domain werden bei allen deinen Recherchen ignoriert. Dies betrifft nicht andere Nutzer deiner Organisation.`)) return;
|
|
|
|
try {
|
|
await API.blockDomain(domain);
|
|
UI.showToast(`${domain} ausgeschlossen.`, 'success');
|
|
await this.loadSources();
|
|
this.updateSidebarStats();
|
|
} catch (err) {
|
|
UI.showToast('Fehler: ' + err.message, 'error');
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Domain-Ausschluss aufheben.
|
|
*/
|
|
async unblockDomain(domain) {
|
|
try {
|
|
await API.unblockDomain(domain);
|
|
UI.showToast(`${domain} Ausschluss aufgehoben.`, 'success');
|
|
await this.loadSources();
|
|
this.updateSidebarStats();
|
|
} catch (err) {
|
|
UI.showToast('Fehler: ' + err.message, 'error');
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Alle Quellen einer Domain löschen.
|
|
*/
|
|
async deleteDomain(domain) {
|
|
if (!await confirmDialog(`Alle Quellen von "${domain}" wirklich löschen?`)) return;
|
|
|
|
try {
|
|
await API.deleteDomain(domain);
|
|
UI.showToast(`${domain} gelöscht.`, 'success');
|
|
await this.loadSources();
|
|
this.updateSidebarStats();
|
|
} catch (err) {
|
|
UI.showToast('Fehler: ' + err.message, 'error');
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Einzelnen Feed löschen.
|
|
*/
|
|
async deleteSingleFeed(sourceId) {
|
|
try {
|
|
await API.deleteSource(sourceId);
|
|
this._allSources = this._allSources.filter(s => s.id !== sourceId);
|
|
this._sourcesOnly = this._sourcesOnly.filter(s => s.id !== sourceId);
|
|
this.renderSourceList();
|
|
this.updateSidebarStats();
|
|
UI.showToast('Feed gelöscht.', 'success');
|
|
} catch (err) {
|
|
UI.showToast('Fehler: ' + err.message, 'error');
|
|
}
|
|
},
|
|
|
|
/**
|
|
* "Domain ausschließen" Dialog ein-/ausblenden.
|
|
*/
|
|
showBlockDomainDialog(show) {
|
|
const form = document.getElementById('sources-block-form');
|
|
if (!form) return;
|
|
|
|
if (show === undefined || show === true) {
|
|
form.style.display = 'block';
|
|
document.getElementById('block-domain-input').value = '';
|
|
document.getElementById('block-domain-notes').value = '';
|
|
// Add-Form ausblenden
|
|
const addForm = document.getElementById('sources-add-form');
|
|
if (addForm) addForm.style.display = 'none';
|
|
} else {
|
|
form.style.display = 'none';
|
|
}
|
|
},
|
|
|
|
_discoveredData: null,
|
|
|
|
toggleSourceForm(show) {
|
|
const form = document.getElementById('sources-add-form');
|
|
if (!form) return;
|
|
|
|
if (show === undefined) {
|
|
show = form.style.display === 'none';
|
|
}
|
|
|
|
form.style.display = show ? 'block' : 'none';
|
|
|
|
if (show) {
|
|
this._editingSourceId = null;
|
|
this._discoveredData = null;
|
|
document.getElementById('src-discover-url').value = '';
|
|
document.getElementById('src-discovery-result').style.display = 'none';
|
|
document.getElementById('src-discover-btn').disabled = false;
|
|
document.getElementById('src-discover-btn').textContent = 'Erkennen';
|
|
document.getElementById('src-type-select').value = 'rss_feed';
|
|
// Save-Button Text zurücksetzen
|
|
const saveBtn = document.querySelector('#src-discovery-result .sources-discovery-actions .btn-primary');
|
|
if (saveBtn) saveBtn.textContent = 'Speichern';
|
|
// Block-Form ausblenden
|
|
const blockForm = document.getElementById('sources-block-form');
|
|
if (blockForm) blockForm.style.display = 'none';
|
|
} else {
|
|
// Beim Schließen: Bearbeitungsmodus zurücksetzen
|
|
this._editingSourceId = null;
|
|
const saveBtn = document.querySelector('#src-discovery-result .sources-discovery-actions .btn-primary');
|
|
if (saveBtn) saveBtn.textContent = 'Speichern';
|
|
}
|
|
},
|
|
|
|
async discoverSource() {
|
|
const urlInput = document.getElementById('src-discover-url');
|
|
const urlVal = urlInput.value.trim();
|
|
|
|
// Telegram-URLs direkt behandeln (kein Discovery noetig)
|
|
if (urlVal.match(/^(https?:\/\/)?(t\.me|telegram\.me)\//i)) {
|
|
const channelName = urlVal.replace(/^(https?:\/\/)?(t\.me|telegram\.me)\//, '').replace(/\/$/, '');
|
|
const tgUrl = 't.me/' + channelName;
|
|
this._discoveredData = {
|
|
name: '@' + channelName,
|
|
domain: 't.me',
|
|
source_type: 'telegram_channel',
|
|
rss_url: null,
|
|
};
|
|
document.getElementById('src-name').value = '@' + channelName;
|
|
document.getElementById('src-type-select').value = 'telegram_channel';
|
|
document.getElementById('src-type-display').value = 'Telegram';
|
|
document.getElementById('src-domain').value = tgUrl;
|
|
document.getElementById('src-rss-url-group').style.display = 'none';
|
|
document.getElementById('src-discovery-result').style.display = 'block';
|
|
const saveBtn = document.querySelector('#src-discovery-result .sources-discovery-actions .btn-primary');
|
|
if (saveBtn) { saveBtn.disabled = false; saveBtn.textContent = 'Speichern'; }
|
|
return;
|
|
}
|
|
const url = urlInput.value.trim();
|
|
if (!url) {
|
|
UI.showToast('Bitte URL oder Domain eingeben.', 'warning');
|
|
return;
|
|
}
|
|
|
|
// Prüfen ob Domain ausgeschlossen ist
|
|
const inputDomain = url.replace(/^https?:\/\//, '').replace(/^www\./, '').split('/')[0].toLowerCase();
|
|
const isBlocked = inputDomain && this._myExclusions.some(e => (e.domain || '').toLowerCase() === inputDomain);
|
|
|
|
if (isBlocked) {
|
|
if (!await confirmDialog(`"${inputDomain}" ist ausgeschlossen. Trotzdem hinzufügen? Der Ausschluss wird dabei aufgehoben.`)) return;
|
|
await API.unblockDomain(inputDomain);
|
|
}
|
|
|
|
const btn = document.getElementById('src-discover-btn');
|
|
btn.disabled = true;
|
|
btn.textContent = 'Suche Feeds...';
|
|
|
|
try {
|
|
const result = await API.discoverMulti(url);
|
|
|
|
if (result.fallback_single) {
|
|
this._discoveredData = {
|
|
name: result.domain,
|
|
domain: result.domain,
|
|
category: result.category,
|
|
source_type: result.total_found > 0 ? 'rss_feed' : 'web_source',
|
|
rss_url: result.sources.length > 0 ? result.sources[0].url : null,
|
|
};
|
|
if (result.sources.length > 0) {
|
|
this._discoveredData.name = result.sources[0].name;
|
|
}
|
|
|
|
document.getElementById('src-name').value = this._discoveredData.name || '';
|
|
document.getElementById('src-category').value = this._discoveredData.category || 'sonstige';
|
|
document.getElementById('src-domain').value = this._discoveredData.domain || '';
|
|
document.getElementById('src-notes').value = '';
|
|
|
|
const typeLabel = this._discoveredData.source_type === 'rss_feed' ? 'RSS-Feed' : this._discoveredData.source_type === 'telegram_channel' ? 'Telegram' : 'Web-Quelle';
|
|
const typeSelect = document.getElementById('src-type-select');
|
|
if (typeSelect) typeSelect.value = this._discoveredData.source_type || 'web_source';
|
|
document.getElementById('src-type-display').value = typeLabel;
|
|
|
|
const rssGroup = document.getElementById('src-rss-url-group');
|
|
const rssInput = document.getElementById('src-rss-url');
|
|
if (this._discoveredData.rss_url) {
|
|
rssInput.value = this._discoveredData.rss_url;
|
|
rssGroup.style.display = 'block';
|
|
} else {
|
|
rssInput.value = '';
|
|
rssGroup.style.display = 'none';
|
|
}
|
|
|
|
document.getElementById('src-discovery-result').style.display = 'block';
|
|
|
|
if (result.added_count > 0) {
|
|
UI.showToast(`${result.domain}: Feed wurde automatisch hinzugefügt.`, 'success');
|
|
this.toggleSourceForm(false);
|
|
await this.loadSources();
|
|
} else if (result.total_found === 0) {
|
|
UI.showToast('Kein RSS-Feed gefunden. Als Web-Quelle speichern?', 'info');
|
|
} else {
|
|
UI.showToast('Feed bereits vorhanden.', 'info');
|
|
}
|
|
} else {
|
|
document.getElementById('src-discovery-result').style.display = 'none';
|
|
|
|
if (result.added_count > 0) {
|
|
UI.showToast(`${result.domain}: ${result.added_count} Feeds hinzugefügt` +
|
|
(result.skipped_count > 0 ? ` (${result.skipped_count} bereits vorhanden)` : ''),
|
|
'success');
|
|
} else if (result.skipped_count > 0) {
|
|
UI.showToast(`${result.domain}: Alle ${result.skipped_count} Feeds bereits vorhanden.`, 'info');
|
|
} else {
|
|
UI.showToast(`${result.domain}: Keine relevanten Feeds gefunden.`, 'info');
|
|
}
|
|
|
|
this.toggleSourceForm(false);
|
|
await this.loadSources();
|
|
}
|
|
} catch (err) {
|
|
UI.showToast('Erkennung fehlgeschlagen: ' + err.message, 'error');
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.textContent = 'Erkennen';
|
|
}
|
|
},
|
|
|
|
editSource(id) {
|
|
const source = this._sourcesOnly.find(s => s.id === id);
|
|
if (!source) {
|
|
UI.showToast('Quelle nicht gefunden.', 'error');
|
|
return;
|
|
}
|
|
|
|
this._editingSourceId = id;
|
|
|
|
// Formular öffnen falls geschlossen (direkt, ohne toggleSourceForm das _editingSourceId zurücksetzt)
|
|
const form = document.getElementById('sources-add-form');
|
|
if (form) {
|
|
form.style.display = 'block';
|
|
const blockForm = document.getElementById('sources-block-form');
|
|
if (blockForm) blockForm.style.display = 'none';
|
|
}
|
|
|
|
// Discovery-URL mit vorhandener URL/Domain befüllen
|
|
const discoverUrlInput = document.getElementById('src-discover-url');
|
|
if (discoverUrlInput) {
|
|
discoverUrlInput.value = source.url || source.domain || '';
|
|
}
|
|
|
|
// Discovery-Ergebnis anzeigen und Felder befüllen
|
|
document.getElementById('src-discovery-result').style.display = 'block';
|
|
document.getElementById('src-name').value = source.name || '';
|
|
document.getElementById('src-category').value = source.category || 'sonstige';
|
|
document.getElementById('src-notes').value = source.notes || '';
|
|
document.getElementById('src-domain').value = source.domain || '';
|
|
|
|
const typeLabel = source.source_type === 'rss_feed' ? 'RSS-Feed' : source.source_type === 'telegram_channel' ? 'Telegram' : 'Web-Quelle';
|
|
const typeSelect = document.getElementById('src-type-select');
|
|
if (typeSelect) typeSelect.value = source.source_type || 'web_source';
|
|
document.getElementById('src-type-display').value = typeLabel;
|
|
|
|
const rssGroup = document.getElementById('src-rss-url-group');
|
|
const rssInput = document.getElementById('src-rss-url');
|
|
if (source.url) {
|
|
rssInput.value = source.url;
|
|
rssGroup.style.display = 'block';
|
|
} else {
|
|
rssInput.value = '';
|
|
rssGroup.style.display = 'none';
|
|
}
|
|
|
|
// _discoveredData setzen damit saveSource() die richtigen Werte nutzt
|
|
this._discoveredData = {
|
|
name: source.name,
|
|
domain: source.domain,
|
|
category: source.category,
|
|
source_type: source.source_type,
|
|
rss_url: source.url,
|
|
};
|
|
|
|
// Submit-Button-Text ändern
|
|
const saveBtn = document.querySelector('#src-discovery-result .sources-discovery-actions .btn-primary');
|
|
if (saveBtn) saveBtn.textContent = 'Quelle speichern';
|
|
|
|
// Zum Formular scrollen
|
|
if (form) form.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
},
|
|
|
|
async saveSource() {
|
|
const name = document.getElementById('src-name').value.trim();
|
|
if (!name) {
|
|
UI.showToast('Name ist erforderlich. Bitte erst "Erkennen" klicken.', 'warning');
|
|
return;
|
|
}
|
|
|
|
const discovered = this._discoveredData || {};
|
|
const data = {
|
|
name,
|
|
source_type: discovered.source_type || 'web_source',
|
|
category: document.getElementById('src-category').value,
|
|
url: discovered.rss_url || (discovered.source_type === 'telegram_channel' ? (document.getElementById('src-domain').value || null) : null),
|
|
domain: document.getElementById('src-domain').value.trim() || discovered.domain || null,
|
|
notes: document.getElementById('src-notes').value.trim() || null,
|
|
};
|
|
|
|
if (!data.domain && discovered.domain) {
|
|
data.domain = discovered.domain;
|
|
}
|
|
|
|
try {
|
|
if (this._editingSourceId) {
|
|
await API.updateSource(this._editingSourceId, data);
|
|
UI.showToast('Quelle aktualisiert.', 'success');
|
|
} else {
|
|
await API.createSource(data);
|
|
UI.showToast('Quelle hinzugefügt.', 'success');
|
|
}
|
|
|
|
this.toggleSourceForm(false);
|
|
await this.loadSources();
|
|
this.updateSidebarStats();
|
|
} catch (err) {
|
|
UI.showToast('Fehler: ' + err.message, 'error');
|
|
}
|
|
},
|
|
|
|
logout() {
|
|
localStorage.removeItem('osint_token');
|
|
localStorage.removeItem('osint_username');
|
|
this._sessionWarningShown = false;
|
|
WS.disconnect();
|
|
window.location.href = '/';
|
|
},
|
|
};
|
|
|
|
// === Barrierefreier Bestätigungsdialog ===
|
|
|
|
function confirmDialog(message) {
|
|
return new Promise((resolve) => {
|
|
// Overlay erstellen
|
|
const overlay = document.createElement('div');
|
|
overlay.className = 'modal-overlay active';
|
|
overlay.setAttribute('role', 'alertdialog');
|
|
overlay.setAttribute('aria-modal', 'true');
|
|
overlay.setAttribute('aria-labelledby', 'confirm-dialog-msg');
|
|
|
|
const modal = document.createElement('div');
|
|
modal.className = 'modal';
|
|
modal.style.maxWidth = '420px';
|
|
modal.innerHTML = `
|
|
<div class="modal-header">
|
|
<div class="modal-title">Bestätigung</div>
|
|
</div>
|
|
<div class="modal-body" style="padding:16px 24px;">
|
|
<p id="confirm-dialog-msg" style="margin:0;font-size:14px;color:var(--text-primary);line-height:1.5;">${message.replace(/</g, '<').replace(/>/g, '>')}</p>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button class="btn btn-secondary" id="confirm-cancel">Abbrechen</button>
|
|
<button class="btn btn-primary" id="confirm-ok">Bestätigen</button>
|
|
</div>
|
|
`;
|
|
overlay.appendChild(modal);
|
|
document.body.appendChild(overlay);
|
|
|
|
const previousFocus = document.activeElement;
|
|
|
|
const cleanup = (result) => {
|
|
releaseFocus(overlay);
|
|
overlay.remove();
|
|
if (previousFocus) previousFocus.focus();
|
|
resolve(result);
|
|
};
|
|
|
|
modal.querySelector('#confirm-cancel').addEventListener('click', () => cleanup(false));
|
|
modal.querySelector('#confirm-ok').addEventListener('click', () => cleanup(true));
|
|
overlay.addEventListener('click', (e) => {
|
|
if (e.target === overlay) cleanup(false);
|
|
});
|
|
overlay.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Escape') cleanup(false);
|
|
});
|
|
|
|
trapFocus(overlay);
|
|
});
|
|
}
|
|
|
|
// === Globale Hilfsfunktionen ===
|
|
|
|
// --- Focus-Trap für Modals (WCAG 2.4.3) ---
|
|
const FOCUSABLE_SELECTOR = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
|
|
function trapFocus(modalEl) {
|
|
const handler = (e) => {
|
|
if (e.key !== 'Tab') return;
|
|
const focusable = Array.from(modalEl.querySelectorAll(FOCUSABLE_SELECTOR)).filter(el => el.offsetParent !== null);
|
|
if (focusable.length === 0) return;
|
|
const first = focusable[0];
|
|
const last = focusable[focusable.length - 1];
|
|
if (e.shiftKey && document.activeElement === first) {
|
|
e.preventDefault();
|
|
last.focus();
|
|
} else if (!e.shiftKey && document.activeElement === last) {
|
|
e.preventDefault();
|
|
first.focus();
|
|
}
|
|
};
|
|
modalEl._focusTrapHandler = handler;
|
|
modalEl.addEventListener('keydown', handler);
|
|
// Fokus auf erstes Element setzen
|
|
requestAnimationFrame(() => {
|
|
const focusable = Array.from(modalEl.querySelectorAll(FOCUSABLE_SELECTOR)).filter(el => el.offsetParent !== null);
|
|
if (focusable.length > 0) focusable[0].focus();
|
|
});
|
|
}
|
|
|
|
function releaseFocus(modalEl) {
|
|
if (modalEl._focusTrapHandler) {
|
|
modalEl.removeEventListener('keydown', modalEl._focusTrapHandler);
|
|
delete modalEl._focusTrapHandler;
|
|
}
|
|
}
|
|
|
|
function openModal(id) {
|
|
if (id === 'modal-new' && !App._editingIncidentId) {
|
|
// Create-Modus: Formular zurücksetzen
|
|
document.getElementById('new-incident-form').reset();
|
|
document.getElementById('modal-new-title').textContent = 'Neue Lage anlegen';
|
|
document.getElementById('modal-new-submit').textContent = 'Lage anlegen';
|
|
// E-Mail-Checkboxen zuruecksetzen
|
|
document.getElementById('inc-notify-summary').checked = false;
|
|
document.getElementById('inc-notify-new-articles').checked = false;
|
|
document.getElementById('inc-notify-status-change').checked = false;
|
|
toggleTypeDefaults();
|
|
toggleRefreshInterval();
|
|
}
|
|
const modal = document.getElementById(id);
|
|
modal._previousFocus = document.activeElement;
|
|
modal.classList.add('active');
|
|
trapFocus(modal);
|
|
}
|
|
|
|
function closeModal(id) {
|
|
const modal = document.getElementById(id);
|
|
releaseFocus(modal);
|
|
modal.classList.remove('active');
|
|
if (modal._previousFocus) {
|
|
modal._previousFocus.focus();
|
|
delete modal._previousFocus;
|
|
}
|
|
if (id === 'modal-new') {
|
|
App._editingIncidentId = null;
|
|
document.getElementById('modal-new-title').textContent = 'Neue Lage anlegen';
|
|
document.getElementById('modal-new-submit').textContent = 'Lage anlegen';
|
|
}
|
|
}
|
|
|
|
function openContentModal(title, sourceElementId) {
|
|
const source = document.getElementById(sourceElementId);
|
|
if (!source) return;
|
|
|
|
document.getElementById('content-viewer-title').textContent = title;
|
|
const body = document.getElementById('content-viewer-body');
|
|
const headerExtra = document.getElementById('content-viewer-header-extra');
|
|
headerExtra.innerHTML = '';
|
|
|
|
if (sourceElementId === 'factcheck-list') {
|
|
// Faktencheck: Filter in den Modal-Header, Liste in den Body
|
|
const filters = document.getElementById('fc-filters');
|
|
if (filters && filters.innerHTML.trim()) {
|
|
headerExtra.innerHTML = `<div class="fc-filter-bar">${filters.innerHTML}</div>`;
|
|
}
|
|
body.innerHTML = source.innerHTML;
|
|
// Filter im Modal auf Modal-Items umleiten
|
|
headerExtra.querySelectorAll('.fc-dropdown-item input[type="checkbox"]').forEach(cb => {
|
|
cb.onchange = function() {
|
|
const status = this.closest('.fc-dropdown-item').dataset.status;
|
|
body.querySelectorAll(`.factcheck-item[data-fc-status="${status}"]`).forEach(el => {
|
|
el.style.display = cb.checked ? '' : 'none';
|
|
});
|
|
};
|
|
});
|
|
} else if (sourceElementId === 'source-overview-content') {
|
|
// Quellenübersicht: Detailansicht mit Suchleiste
|
|
headerExtra.innerHTML = '<input type="text" class="timeline-filter-input" placeholder="Quellen durchsuchen..." oninput="App.filterModalSources(this.value)" style="width:200px;">';
|
|
body.innerHTML = buildDetailedSourceOverview();
|
|
} else if (sourceElementId === 'timeline') {
|
|
// Timeline: Vollständige vertikale Timeline im Modal mit Filter + Suche
|
|
headerExtra.innerHTML = `<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
|
|
<div class="ht-filter-group">
|
|
<button class="ht-modal-filter-btn active" data-filter="all" onclick="App.filterModalTimelineType('all', this)">Alle</button>
|
|
<button class="ht-modal-filter-btn" data-filter="articles" onclick="App.filterModalTimelineType('articles', this)">Meldungen</button>
|
|
<button class="ht-modal-filter-btn" data-filter="snapshots" onclick="App.filterModalTimelineType('snapshots', this)">Lageberichte</button>
|
|
</div>
|
|
<input type="text" class="timeline-filter-input" placeholder="Suche..." oninput="App.filterModalTimeline(this.value)" style="width:180px;">
|
|
</div>`;
|
|
body.innerHTML = App._buildFullVerticalTimeline('all', '');
|
|
} else {
|
|
body.innerHTML = source.innerHTML;
|
|
}
|
|
|
|
openModal('modal-content-viewer');
|
|
}
|
|
|
|
App.filterModalSources = function(query) {
|
|
const q = query.toLowerCase().trim();
|
|
const details = document.querySelectorAll('#content-viewer-body details');
|
|
details.forEach(d => {
|
|
if (!q) {
|
|
d.style.display = '';
|
|
d.removeAttribute('open');
|
|
return;
|
|
}
|
|
const name = d.querySelector('summary').textContent.toLowerCase();
|
|
// Quellenname oder Artikel-Headlines durchsuchen
|
|
const articles = d.querySelectorAll('div > div');
|
|
let articleMatch = false;
|
|
articles.forEach(a => {
|
|
const text = a.textContent.toLowerCase();
|
|
const hit = text.includes(q);
|
|
a.style.display = hit ? '' : 'none';
|
|
if (hit) articleMatch = true;
|
|
});
|
|
const match = name.includes(q) || articleMatch;
|
|
d.style.display = match ? '' : 'none';
|
|
// Bei Artikeltreffer aufklappen, bei Namens-Match alle Artikel zeigen
|
|
if (match && articleMatch && !name.includes(q)) {
|
|
d.setAttribute('open', '');
|
|
} else if (name.includes(q)) {
|
|
articles.forEach(a => a.style.display = '');
|
|
}
|
|
});
|
|
};
|
|
|
|
function buildDetailedSourceOverview() {
|
|
const articles = App._currentArticles || [];
|
|
if (!articles.length) return '<div style="padding:24px;color:var(--text-disabled);">Keine Artikel vorhanden</div>';
|
|
|
|
// Nach Quelle gruppieren
|
|
const sourceMap = {};
|
|
articles.forEach(a => {
|
|
const name = a.source || 'Unbekannt';
|
|
if (!sourceMap[name]) sourceMap[name] = { articles: [], languages: new Set() };
|
|
sourceMap[name].articles.push(a);
|
|
sourceMap[name].languages.add((a.language || 'de').toUpperCase());
|
|
});
|
|
|
|
const sources = Object.entries(sourceMap).sort((a, b) => b[1].articles.length - a[1].articles.length);
|
|
|
|
// Sprach-Statistik Header
|
|
const langCount = {};
|
|
articles.forEach(a => {
|
|
const lang = (a.language || 'de').toUpperCase();
|
|
langCount[lang] = (langCount[lang] || 0) + 1;
|
|
});
|
|
const langChips = Object.entries(langCount)
|
|
.sort((a, b) => b[1] - a[1])
|
|
.map(([lang, count]) => `<span class="source-lang-chip">${lang} <strong>${count}</strong></span>`)
|
|
.join('');
|
|
|
|
let html = `<div class="source-overview-header">
|
|
<span class="source-overview-stat">${articles.length} Artikel aus ${sources.length} Quellen</span>
|
|
<div class="source-lang-chips">${langChips}</div>
|
|
</div>`;
|
|
|
|
sources.forEach(([name, data]) => {
|
|
const langs = [...data.languages].join('/');
|
|
const escapedName = UI.escape(name);
|
|
html += `<details style="border:1px solid var(--border);border-radius:4px;margin-bottom:6px;">
|
|
<summary style="display:flex;align-items:center;gap:8px;padding:10px 14px;cursor:pointer;background:var(--bg-secondary);color:var(--text-primary);font-size:13px;list-style:none;">
|
|
<span style="color:var(--text-secondary);font-size:12px;flex-shrink:0;">▸</span>
|
|
<span style="flex:1;font-weight:500;">${escapedName}</span>
|
|
<span style="font-size:10px;color:var(--text-secondary);flex-shrink:0;">${langs}</span>
|
|
<span style="font-size:12px;font-weight:700;color:var(--accent);background:var(--tint-accent);padding:1px 6px;border-radius:4px;flex-shrink:0;">${data.articles.length}</span>
|
|
</summary>
|
|
<div style="border-top:1px solid var(--border);">`;
|
|
data.articles.forEach(a => {
|
|
const headline = UI.escape(a.headline_de || a.headline || 'Ohne Titel');
|
|
const time = a.collected_at
|
|
? (parseUTC(a.collected_at) || new Date(a.collected_at)).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit', timeZone: TIMEZONE })
|
|
: '';
|
|
const langBadge = a.language && a.language !== 'de'
|
|
? `<span class="lang-badge">${a.language.toUpperCase()}</span>` : '';
|
|
const link = a.source_url
|
|
? `<a href="${UI.escape(a.source_url)}" target="_blank" rel="noopener" style="color:var(--accent);text-decoration:none;font-size:14px;flex-shrink:0;opacity:0.6;" title="Artikel öffnen">↗</a>` : '';
|
|
html += `<div style="display:flex;align-items:center;gap:10px;padding:8px 14px 8px 36px;font-size:12px;border-bottom:1px solid var(--border);">
|
|
<span style="color:var(--text-secondary);flex-shrink:0;min-width:90px;font-size:11px;">${time}</span>
|
|
<span style="flex:1;color:var(--text-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${headline}</span>
|
|
${langBadge}
|
|
${link}
|
|
</div>`;
|
|
});
|
|
html += `</div></details>`;
|
|
});
|
|
|
|
return html;
|
|
}
|
|
|
|
|
|
|
|
|
|
function toggleRefreshInterval() {
|
|
const mode = document.getElementById('inc-refresh-mode').value;
|
|
const field = document.getElementById('refresh-interval-field');
|
|
field.classList.toggle('visible', mode === 'auto');
|
|
}
|
|
|
|
function updateIntervalMin() {
|
|
const unit = parseInt(document.getElementById('inc-refresh-unit').value);
|
|
const input = document.getElementById('inc-refresh-value');
|
|
if (unit === 1) {
|
|
// Minuten: Minimum 10
|
|
input.min = 10;
|
|
if (parseInt(input.value) < 10) input.value = 10;
|
|
} else {
|
|
// Stunden/Tage/Wochen: Minimum 1
|
|
input.min = 1;
|
|
if (parseInt(input.value) < 1) input.value = 1;
|
|
}
|
|
}
|
|
|
|
function updateVisibilityHint() {
|
|
const isPublic = document.getElementById('inc-visibility').checked;
|
|
const text = document.getElementById('visibility-text');
|
|
if (text) {
|
|
text.textContent = isPublic
|
|
? 'Öffentlich — für alle Nutzer sichtbar'
|
|
: 'Privat — nur für dich sichtbar';
|
|
}
|
|
}
|
|
|
|
function updateSourcesHint() {
|
|
const intl = document.getElementById('inc-international').checked;
|
|
const hint = document.getElementById('sources-hint');
|
|
if (hint) {
|
|
hint.textContent = intl
|
|
? 'DE + internationale Feeds (Reuters, BBC, Al Jazeera etc.)'
|
|
: 'Nur deutschsprachige Quellen (DE, AT, CH)';
|
|
}
|
|
}
|
|
|
|
function toggleTypeDefaults() {
|
|
const type = document.getElementById('inc-type').value;
|
|
const hint = document.getElementById('type-hint');
|
|
const refreshMode = document.getElementById('inc-refresh-mode');
|
|
|
|
if (type === 'research') {
|
|
hint.textContent = 'Recherchiert in Tiefe: Nachrichtenarchive, Parlamentsdokumente, Fachmedien, Expertenquellen. Empfohlen: Manuell starten und bei Bedarf vertiefen.';
|
|
refreshMode.value = 'manual';
|
|
toggleRefreshInterval();
|
|
} else {
|
|
hint.textContent = 'Durchsucht laufend hunderte Nachrichtenquellen nach neuen Meldungen. Empfohlen: Automatische Aktualisierung.';
|
|
}
|
|
}
|
|
|
|
// Tab-Fokus: Nur Tab-Badge (Titel-Counter) zurücksetzen, nicht alle Notifications
|
|
window.addEventListener('focus', () => {
|
|
document.title = App._originalTitle;
|
|
});
|
|
|
|
// ESC schließt Modals
|
|
// F5: Daten aktualisieren statt Seite neu laden
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key === 'F5') {
|
|
e.preventDefault();
|
|
App.softRefresh();
|
|
}
|
|
});
|
|
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Escape') {
|
|
// Schließ-Reihenfolge: A11y-Panel > Notification-Panel > Export-Dropdown > FC-Dropdown > Modals
|
|
if (A11yManager._isOpen) {
|
|
A11yManager._closePanel();
|
|
return;
|
|
}
|
|
if (NotificationCenter._isOpen) {
|
|
NotificationCenter.close();
|
|
return;
|
|
}
|
|
const exportMenu = document.getElementById('export-dropdown-menu');
|
|
if (exportMenu && exportMenu.classList.contains('show')) {
|
|
App._closeExportDropdown();
|
|
return;
|
|
}
|
|
const fcMenu = document.querySelector('.fc-dropdown-menu.open');
|
|
if (fcMenu) {
|
|
fcMenu.classList.remove('open');
|
|
const fcBtn = fcMenu.previousElementSibling;
|
|
if (fcBtn) fcBtn.setAttribute('aria-expanded', 'false');
|
|
return;
|
|
}
|
|
document.querySelectorAll('.modal-overlay.active').forEach(m => {
|
|
closeModal(m.id);
|
|
});
|
|
}
|
|
});
|
|
|
|
// Keyboard-Handler: Enter/Space auf [role="button"] löst click aus (WCAG 2.1.1)
|
|
document.addEventListener('keydown', (e) => {
|
|
if ((e.key === 'Enter' || e.key === ' ') && e.target.matches('[role="button"]')) {
|
|
e.preventDefault();
|
|
e.target.click();
|
|
}
|
|
});
|
|
|
|
// Session-Ablauf prüfen (alle 60 Sekunden)
|
|
setInterval(() => {
|
|
const token = localStorage.getItem('osint_token');
|
|
if (!token) return;
|
|
try {
|
|
const payload = JSON.parse(atob(token.split('.')[1]));
|
|
const expiresAt = payload.exp * 1000;
|
|
const remaining = expiresAt - Date.now();
|
|
const fiveMinutes = 5 * 60 * 1000;
|
|
if (remaining <= 0) {
|
|
App.logout();
|
|
} else if (remaining <= fiveMinutes && !App._sessionWarningShown) {
|
|
App._sessionWarningShown = true;
|
|
const mins = Math.ceil(remaining / 60000);
|
|
UI.showToast(`Session läuft in ${mins} Minute${mins !== 1 ? 'n' : ''} ab. Bitte erneut anmelden.`, 'warning', 15000);
|
|
}
|
|
} catch (e) { /* Token nicht parsbar */ }
|
|
}, 60000);
|
|
|
|
// Modal-Overlays: Klick auf Backdrop schließt NICHT mehr (nur X-Button)
|
|
document.addEventListener('click', (e) => {
|
|
if (e.target.classList.contains('modal-overlay') && e.target.classList.contains('active')) {
|
|
// closeModal deaktiviert - Modal nur ueber X-Button schliessbar
|
|
}
|
|
});
|
|
|
|
// App starten
|
|
document.addEventListener('click', (e) => {
|
|
if (!e.target.closest('.export-dropdown')) {
|
|
App._closeExportDropdown();
|
|
}
|
|
});
|
|
document.addEventListener('DOMContentLoaded', () => App.init());
|