@@ -726,13 +726,13 @@
-
+
-
+
-
+
@@ -753,26 +753,26 @@
diff --git a/src/static/i18n/de.json b/src/static/i18n/de.json
index 60429d6..68a0d10 100644
--- a/src/static/i18n/de.json
+++ b/src/static/i18n/de.json
@@ -201,5 +201,63 @@
"source.type.rss_feed": "RSS-Feed",
"source.type.telegram": "Telegram",
"source.type.web": "Web-Quelle",
- "modal.hint.sources_german_only": "Nur deutschsprachige Quellen (DE, AT, CH)"
+ "modal.hint.sources_german_only": "Nur deutschsprachige Quellen (DE, AT, CH)",
+ "export.sections": "Bereiche",
+ "export.section.summary": "Zusammenfassung",
+ "export.section.report": "Recherchebericht / Lagebild",
+ "export.section.factcheck": "Faktencheck",
+ "export.section.sources": "Quellen",
+ "export.format": "Format",
+ "export.format.pdf": "PDF",
+ "export.format.docx": "Word (DOCX)",
+ "export.submit": "Exportieren",
+ "sources_modal.title": "Quellenverwaltung",
+ "sources_modal.stats.rss": "RSS-Feeds",
+ "sources_modal.stats.web": "Web-Quellen",
+ "sources_modal.stats.telegram": "Telegram",
+ "sources_modal.stats.excluded": "Ausgeschlossen",
+ "sources_modal.stats.articles": "Artikel gesamt",
+ "sources_modal.filter.type": "Quellentyp filtern",
+ "sources_modal.filter.type_all": "Alle Typen",
+ "sources_modal.filter.category": "Kategorie filtern",
+ "sources_modal.filter.category_all": "Alle Kategorien",
+ "sources_modal.filter.political": "Politische Ausrichtung filtern",
+ "sources_modal.filter.political_all": "Alle Ausrichtungen",
+ "sources_modal.filter.mediatype": "Medientyp filtern",
+ "sources_modal.filter.mediatype_all": "Alle Medientypen",
+ "sources_modal.filter.reliability": "Glaubwürdigkeit filtern",
+ "sources_modal.filter.reliability_all": "Alle Glaubwürdigkeiten",
+ "sources_modal.filter.extern": "Externe Reputation filtern",
+ "sources_modal.filter.extern_all": "Externe Reputation: alle",
+ "sources_modal.filter.alignment": "Geopolitische Nähe filtern",
+ "sources_modal.filter.alignment_all": "Alle Nähen",
+ "sources_modal.search": "Quellen durchsuchen",
+ "sources_modal.search_placeholder": "Suche...",
+ "sources_modal.add_source": "+ Quelle",
+ "sources_modal.form.url_label": "URL oder Domain",
+ "sources_modal.form.url_placeholder": "z.B. netzpolitik.org oder t.me/kanalname",
+ "sources_modal.form.discover": "Erkennen",
+ "sources_modal.form.name_placeholder": "Wird erkannt...",
+ "sources_modal.form.category": "Kategorie",
+ "sources_modal.form.type": "Typ",
+ "sources_modal.form.rss_url": "RSS-Feed URL",
+ "sources_modal.form.domain": "Domain",
+ "sources_modal.form.notes": "Notizen",
+ "sources_modal.form.notes_placeholder": "Optional",
+ "sources_modal.list.loading": "Lade Quellen...",
+ "sources_modal.excluded_badge": "Ausgeschlossen",
+ "chat.title": "AegisSight Assistent",
+ "chat.toggle_title": "Chat-Assistent",
+ "chat.toggle_aria": "Chat-Assistent öffnen",
+ "chat.new_title": "Neuer Chat",
+ "chat.new_aria": "Neuen Chat starten",
+ "chat.fullscreen_title": "Vollbild",
+ "chat.fullscreen_aria": "Vollbild umschalten",
+ "chat.close_title": "Schließen",
+ "chat.close_aria": "Chat schließen",
+ "chat.input_placeholder": "Frage stellen...",
+ "chat.send_title": "Senden",
+ "chat.send_aria": "Nachricht senden",
+ "chat.greeting": "Hallo! Ich bin der AegisSight Assistent. Stell mir gerne jede Frage rund um die Bedienung des Monitors, ich helfe dir weiter.",
+ "stats.articles_total": "Artikel gesamt"
}
diff --git a/src/static/i18n/en.json b/src/static/i18n/en.json
index a08db0b..b5eb728 100644
--- a/src/static/i18n/en.json
+++ b/src/static/i18n/en.json
@@ -201,5 +201,63 @@
"source.type.rss_feed": "RSS feed",
"source.type.telegram": "Telegram",
"source.type.web": "Web source",
- "modal.hint.sources_german_only": "Primary-language sources only"
+ "modal.hint.sources_german_only": "Primary-language sources only",
+ "export.sections": "Sections",
+ "export.section.summary": "Summary",
+ "export.section.report": "Research report / Briefing",
+ "export.section.factcheck": "Fact check",
+ "export.section.sources": "Sources",
+ "export.format": "Format",
+ "export.format.pdf": "PDF",
+ "export.format.docx": "Word (DOCX)",
+ "export.submit": "Export",
+ "sources_modal.title": "Source management",
+ "sources_modal.stats.rss": "RSS feeds",
+ "sources_modal.stats.web": "Web sources",
+ "sources_modal.stats.telegram": "Telegram",
+ "sources_modal.stats.excluded": "Excluded",
+ "sources_modal.stats.articles": "Articles total",
+ "sources_modal.filter.type": "Filter by source type",
+ "sources_modal.filter.type_all": "All types",
+ "sources_modal.filter.category": "Filter by category",
+ "sources_modal.filter.category_all": "All categories",
+ "sources_modal.filter.political": "Filter by political orientation",
+ "sources_modal.filter.political_all": "All orientations",
+ "sources_modal.filter.mediatype": "Filter by media type",
+ "sources_modal.filter.mediatype_all": "All media types",
+ "sources_modal.filter.reliability": "Filter by reliability",
+ "sources_modal.filter.reliability_all": "All reliabilities",
+ "sources_modal.filter.extern": "Filter by external reputation",
+ "sources_modal.filter.extern_all": "External reputation: any",
+ "sources_modal.filter.alignment": "Filter by geopolitical alignment",
+ "sources_modal.filter.alignment_all": "All alignments",
+ "sources_modal.search": "Search sources",
+ "sources_modal.search_placeholder": "Search...",
+ "sources_modal.add_source": "+ Source",
+ "sources_modal.form.url_label": "URL or domain",
+ "sources_modal.form.url_placeholder": "e.g. example.com or t.me/channel",
+ "sources_modal.form.discover": "Detect",
+ "sources_modal.form.name_placeholder": "Detecting...",
+ "sources_modal.form.category": "Category",
+ "sources_modal.form.type": "Type",
+ "sources_modal.form.rss_url": "RSS feed URL",
+ "sources_modal.form.domain": "Domain",
+ "sources_modal.form.notes": "Notes",
+ "sources_modal.form.notes_placeholder": "Optional",
+ "sources_modal.list.loading": "Loading sources...",
+ "sources_modal.excluded_badge": "Excluded",
+ "chat.title": "AegisSight Assistant",
+ "chat.toggle_title": "Chat assistant",
+ "chat.toggle_aria": "Open chat assistant",
+ "chat.new_title": "New chat",
+ "chat.new_aria": "Start new chat",
+ "chat.fullscreen_title": "Fullscreen",
+ "chat.fullscreen_aria": "Toggle fullscreen",
+ "chat.close_title": "Close",
+ "chat.close_aria": "Close chat",
+ "chat.input_placeholder": "Ask a question...",
+ "chat.send_title": "Send",
+ "chat.send_aria": "Send message",
+ "chat.greeting": "Hi! I'm the AegisSight Assistant. Ask me anything about how to use the monitor and I'll guide you through.",
+ "stats.articles_total": "Articles total"
}
diff --git a/src/static/js/app.js b/src/static/js/app.js
index 9d1917a..c68bbd2 100644
--- a/src/static/js/app.js
+++ b/src/static/js/app.js
@@ -2778,10 +2778,10 @@ async handleRefresh() {
const excluded = this._myExclusions.length;
bar.innerHTML = `
-
${rss.count} RSS-Feeds
-
${web.count} Web-Quellen
+
${rss.count} ${(typeof T === 'function' ? T('sources_modal.stats.rss', 'RSS-Feeds') : 'RSS-Feeds')}
+
${web.count} ${(typeof T === 'function' ? T('sources_modal.stats.web', 'Web-Quellen') : 'Web-Quellen')}
${tg.count} Telegram
-
${excluded} Ausgeschlossen
+
${excluded} ${(typeof T === 'function' ? T('sources_modal.stats.excluded', 'Ausgeschlossen') : 'Ausgeschlossen')}
${stats.total_articles} Artikel gesamt
`;
},
diff --git a/src/static/js/chat.js b/src/static/js/chat.js
index 7aaa3a2..3df5d9e 100644
--- a/src/static/js/chat.js
+++ b/src/static/js/chat.js
@@ -1,352 +1,352 @@
-/**
- * AegisSight Chat-Assistent Widget.
- */
-const Chat = {
- _conversationId: null,
- _isOpen: false,
- _isLoading: false,
- _hasGreeted: false,
- _tutorialHintDismissed: false,
- _isFullscreen: false,
-
- init() {
- const btn = document.getElementById('chat-toggle-btn');
- const closeBtn = document.getElementById('chat-close-btn');
- const form = document.getElementById('chat-form');
- const input = document.getElementById('chat-input');
-
- if (!btn || !form) return;
-
- btn.addEventListener('click', () => this.toggle());
- closeBtn.addEventListener('click', () => this.close());
-
- const resetBtn = document.getElementById('chat-reset-btn');
- if (resetBtn) resetBtn.addEventListener('click', () => this.reset());
-
- const fsBtn = document.getElementById('chat-fullscreen-btn');
- if (fsBtn) fsBtn.addEventListener('click', () => this.toggleFullscreen());
-
- form.addEventListener('submit', (e) => {
- e.preventDefault();
- this.send();
- });
-
- // Enter sendet, Shift+Enter für Zeilenumbruch
- input.addEventListener('keydown', (e) => {
- if (e.key === 'Enter' && !e.shiftKey) {
- e.preventDefault();
- this.send();
- }
- });
-
- // Auto-resize textarea
- input.addEventListener('input', () => {
- input.style.height = 'auto';
- input.style.height = Math.min(input.scrollHeight, 120) + 'px';
- });
- },
-
- toggle() {
- if (this._isOpen) {
- this.close();
- } else {
- this.open();
- }
- },
-
- open() {
- const win = document.getElementById('chat-window');
- const btn = document.getElementById('chat-toggle-btn');
- if (!win) return;
- win.classList.add('open');
- btn.classList.add('active');
- this._isOpen = true;
-
- if (!this._hasGreeted) {
- this._hasGreeted = true;
- this.addMessage('assistant', 'Hallo! Ich bin der AegisSight Assistent. Stell mir gerne jede Frage rund um die Bedienung des Monitors, ich helfe dir weiter.');
- }
-
- // Tutorial-Hinweis temporaer deaktiviert (Ueberarbeitung) - reaktivieren durch Entfernen der Kommentarzeichen:
- // if (typeof Tutorial !== 'undefined' && !this._tutorialHintDismissed) {
- // var oldHint = document.getElementById('chat-tutorial-hint');
- // if (oldHint) oldHint.remove();
- // this._showTutorialHint();
- // }
-
- // Focus auf Input
- setTimeout(() => {
- const input = document.getElementById('chat-input');
- if (input) input.focus();
- }, 200);
- },
-
- close() {
- const win = document.getElementById('chat-window');
- const btn = document.getElementById('chat-toggle-btn');
- if (!win) return;
- win.classList.remove('open');
- win.classList.remove('fullscreen');
- btn.classList.remove('active');
- this._isOpen = false;
- this._isFullscreen = false;
- const fsBtn = document.getElementById('chat-fullscreen-btn');
- if (fsBtn) {
- fsBtn.title = 'Vollbild';
- fsBtn.innerHTML = '
';
- }
- },
-
- reset() {
- this._conversationId = null;
- this._hasGreeted = false;
- this._isLoading = false;
- const container = document.getElementById('chat-messages');
- if (container) container.innerHTML = '';
- this._updateResetBtn();
- this.open();
- },
-
- toggleFullscreen() {
- const win = document.getElementById('chat-window');
- const btn = document.getElementById('chat-fullscreen-btn');
- if (!win) return;
- this._isFullscreen = !this._isFullscreen;
- win.classList.toggle('fullscreen', this._isFullscreen);
- if (btn) {
- btn.title = this._isFullscreen ? 'Vollbild beenden' : 'Vollbild';
- btn.innerHTML = this._isFullscreen
- ? '
'
- : '
';
- }
- },
-
- _updateResetBtn() {
- const btn = document.getElementById('chat-reset-btn');
- if (btn) btn.style.display = this._conversationId ? '' : 'none';
- },
-
- async send() {
- const input = document.getElementById('chat-input');
- const text = (input.value || '').trim();
- if (!text || this._isLoading) return;
-
- input.value = '';
- input.style.height = 'auto';
- this.addMessage('user', text);
- this._showTyping();
- this._isLoading = true;
-
- // Tutorial-Keywords temporaer deaktiviert (Ueberarbeitung) - reaktivieren durch Entfernen der Kommentarzeichen:
- // var lowerText = text.toLowerCase();
- // if (lowerText === 'rundgang' || lowerText === 'tutorial' || lowerText === 'tour' || lowerText === 'f\u00fchrung') {
- // this._hideTyping();
- // this._isLoading = false;
- // this.close();
- // if (typeof Tutorial !== 'undefined') Tutorial.start();
- // return;
- // }
-
- try {
- const body = {
- message: text,
- conversation_id: this._conversationId,
- };
-
- // Aktuelle Lage mitschicken falls geoeffnet
- const incidentId = this._getIncidentContext();
- if (incidentId) {
- body.incident_id = incidentId;
- }
-
- const data = await this._request(body);
- this._conversationId = data.conversation_id;
- this._updateResetBtn();
- this._hideTyping();
- this.addMessage('assistant', data.reply);
- this._highlightUI(data.reply);
- } catch (err) {
- this._hideTyping();
- const msg = err.detail || err.message || 'Etwas ist schiefgelaufen. Bitte versuche es erneut.';
- this.addMessage('assistant', msg);
- } finally {
- this._isLoading = false;
- }
- },
-
- addMessage(role, text) {
- const container = document.getElementById('chat-messages');
- if (!container) return;
-
- const bubble = document.createElement('div');
- bubble.className = 'chat-message ' + role;
-
- // Einfache Formatierung: Zeilenumbrueche und Fettschrift
- const formatted = text
- .replace(/&/g, '&')
- .replace(//g, '>')
- .replace(/\*\*(.+?)\*\*/g, '
$1')
- .replace(/\n/g, '
');
-
- bubble.innerHTML = '
' + formatted + '
';
- container.appendChild(bubble);
-
- // User-Nachrichten: nach unten scrollen. Antworten: zum Anfang der Antwort scrollen.
- if (role === 'user') {
- container.scrollTop = container.scrollHeight;
- } else {
- bubble.scrollIntoView({ behavior: 'smooth', block: 'start' });
- }
- },
-
- _showTyping() {
- const container = document.getElementById('chat-messages');
- if (!container) return;
- const el = document.createElement('div');
- el.className = 'chat-message assistant chat-typing-msg';
- el.innerHTML = '
';
- container.appendChild(el);
- container.scrollTop = container.scrollHeight;
- },
-
- _hideTyping() {
- const el = document.querySelector('.chat-typing-msg');
- if (el) el.remove();
- },
-
- _getIncidentContext() {
- if (typeof App !== 'undefined' && App.currentIncidentId) {
- return App.currentIncidentId;
- }
- return null;
- },
-
- async _request(body) {
- const token = localStorage.getItem('osint_token');
- const resp = await fetch('/api/chat', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- 'Authorization': token ? 'Bearer ' + token : '',
- },
- body: JSON.stringify(body),
- });
- if (!resp.ok) {
- const data = await resp.json().catch(() => ({}));
- throw data;
- }
- return await resp.json();
- },
- // -----------------------------------------------------------------------
- // UI-Highlight: Bedienelemente im Dashboard hervorheben wenn im Chat erwaehnt
- // -----------------------------------------------------------------------
- _UI_HIGHLIGHTS: [
- { keywords: ['neue lage', 'lage erstellen', 'lage anlegen', 'recherche erstellen', 'neuen fall'], selector: '#new-incident-btn' },
- { keywords: ['theme wechseln', 'theme-umschalter', 'farbschema', 'helles design', 'dunkles design', 'hell- und dunkel', 'hellem und dunklem', 'dark mode', 'light mode'], selector: '#theme-toggle' },
- { keywords: ['barrierefreiheit', 'accessibility', 'hoher kontrast', 'focus-anzeige', 'groessere schrift', 'animationen aus'], selector: '#a11y-btn' },
- { keywords: ['abmelden', 'logout', 'ausloggen', 'abmeldung'], selector: '#logout-btn' },
- { keywords: ['benachrichtigung', 'glocken-symbol', 'abonnieren', 'abonniert'], selector: '#notification-btn' },
- { keywords: ['aktualisieren', 'refresh starten'], selector: '#refresh-btn' },
- { keywords: ['exportieren', 'export-button', 'lagebericht exportieren'], selector: 'button[onclick*="toggleExportDropdown"]' },
- { keywords: ['faktencheck', 'factcheck'], selector: '[gs-id="factcheck"]' },
- { keywords: ['kartenansicht', 'karte angezeigt', 'interaktive karte', 'geoparsing'], selector: '[gs-id="map"]' },
- { keywords: ['quellen verwalten', 'quellenverwaltung', 'quelleneinstellung', 'quellenausschluss', 'quellen-einstellung'], selector: 'button[onclick*="openSourceManagement"]' },
- { keywords: ['sichtbarkeit', 'privat oder oeffentlich', 'lage privat'], selector: '#incident-settings-btn' },
- { keywords: ['eigene lagen', 'nur eigene'], selector: '.sidebar-filter-btn[data-filter="mine"]' },
- { keywords: ['alle lagen anzeigen'], selector: '.sidebar-filter-btn[data-filter="all"]' },
- { keywords: ['feedback senden', 'feedback geben', 'rueckmeldung'], selector: 'button[onclick*="openFeedback"]' },
- { keywords: ['lage loeschen', 'lage entfernen', 'fall loeschen'], selector: '#delete-incident-btn' },
- ],
-
- _highlightUI(text) {
- if (!text) return;
- var lower = text.toLowerCase();
- var highlighted = new Set();
- for (var i = 0; i < this._UI_HIGHLIGHTS.length; i++) {
- var entry = this._UI_HIGHLIGHTS[i];
- for (var k = 0; k < entry.keywords.length; k++) {
- var kw = entry.keywords[k];
- if (lower.indexOf(kw) !== -1) {
- var selectors = entry.selector.split(',');
- for (var s = 0; s < selectors.length; s++) {
- var sel = selectors[s].trim();
- if (highlighted.has(sel)) continue;
- var el = document.querySelector(sel);
- if (el) {
- highlighted.add(sel);
- el.scrollIntoView({ behavior: 'smooth', block: 'center' });
- (function(element) {
- setTimeout(function() {
- element.classList.add('chat-ui-highlight');
- }, 400);
- setTimeout(function() {
- element.classList.remove('chat-ui-highlight');
- }, 4400);
- })(el);
- }
- }
- break;
- }
- }
- }
- },
-
- async _showTutorialHint() {
- var container = document.getElementById('chat-messages');
- if (!container) return;
-
- // API-State laden (Fallback: Standard-Hint)
- var state = null;
- try { state = await API.getTutorialState(); } catch(e) {}
-
- var hint = document.createElement('div');
- hint.className = 'chat-tutorial-hint';
- hint.id = 'chat-tutorial-hint';
- var textDiv = document.createElement('div');
- textDiv.className = 'chat-tutorial-hint-text';
- textDiv.style.cursor = 'pointer';
-
- if (state && !state.completed && state.current_step !== null && state.current_step > 0) {
- // Mittendrin abgebrochen
- var totalSteps = (typeof Tutorial !== 'undefined') ? Tutorial._steps.length : 32;
- textDiv.innerHTML = '
Tipp: Sie haben den Rundgang bei Schritt ' + (state.current_step + 1) + '/' + totalSteps + ' unterbrochen. Klicken Sie hier, um fortzusetzen.';
- textDiv.addEventListener('click', function() {
- Chat.close();
- Chat._tutorialHintDismissed = true;
- if (typeof Tutorial !== 'undefined') Tutorial.start();
- });
- } else if (state && state.completed) {
- // Bereits abgeschlossen
- textDiv.innerHTML = '
Tipp: Sie haben den Rundgang bereits abgeschlossen.
Erneut starten?';
- textDiv.addEventListener('click', async function() {
- Chat.close();
- Chat._tutorialHintDismissed = true;
- try { await API.resetTutorialState(); } catch(e) {}
- if (typeof Tutorial !== 'undefined') Tutorial.start(true);
- });
- } else {
- // Nie gestartet
- textDiv.innerHTML = '
Tipp: Kennen Sie schon den interaktiven Rundgang? Er zeigt Ihnen Schritt für Schritt alle Funktionen des Monitors. Klicken Sie hier, um ihn zu starten.';
- textDiv.addEventListener('click', function() {
- Chat.close();
- Chat._tutorialHintDismissed = true;
- if (typeof Tutorial !== 'undefined') Tutorial.start();
- });
- }
-
- var closeBtn = document.createElement('button');
- closeBtn.className = 'chat-tutorial-hint-close';
- closeBtn.title = 'Schließen';
- closeBtn.innerHTML = '×';
- closeBtn.addEventListener('click', function(e) {
- e.stopPropagation();
- hint.remove();
- Chat._tutorialHintDismissed = true;
- });
- hint.appendChild(textDiv);
- hint.appendChild(closeBtn);
- container.appendChild(hint);
- },
-
-};
+/**
+ * AegisSight Chat-Assistent Widget.
+ */
+const Chat = {
+ _conversationId: null,
+ _isOpen: false,
+ _isLoading: false,
+ _hasGreeted: false,
+ _tutorialHintDismissed: false,
+ _isFullscreen: false,
+
+ init() {
+ const btn = document.getElementById('chat-toggle-btn');
+ const closeBtn = document.getElementById('chat-close-btn');
+ const form = document.getElementById('chat-form');
+ const input = document.getElementById('chat-input');
+
+ if (!btn || !form) return;
+
+ btn.addEventListener('click', () => this.toggle());
+ closeBtn.addEventListener('click', () => this.close());
+
+ const resetBtn = document.getElementById('chat-reset-btn');
+ if (resetBtn) resetBtn.addEventListener('click', () => this.reset());
+
+ const fsBtn = document.getElementById('chat-fullscreen-btn');
+ if (fsBtn) fsBtn.addEventListener('click', () => this.toggleFullscreen());
+
+ form.addEventListener('submit', (e) => {
+ e.preventDefault();
+ this.send();
+ });
+
+ // Enter sendet, Shift+Enter für Zeilenumbruch
+ input.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault();
+ this.send();
+ }
+ });
+
+ // Auto-resize textarea
+ input.addEventListener('input', () => {
+ input.style.height = 'auto';
+ input.style.height = Math.min(input.scrollHeight, 120) + 'px';
+ });
+ },
+
+ toggle() {
+ if (this._isOpen) {
+ this.close();
+ } else {
+ this.open();
+ }
+ },
+
+ open() {
+ const win = document.getElementById('chat-window');
+ const btn = document.getElementById('chat-toggle-btn');
+ if (!win) return;
+ win.classList.add('open');
+ btn.classList.add('active');
+ this._isOpen = true;
+
+ if (!this._hasGreeted) {
+ this._hasGreeted = true;
+ this.addMessage('assistant', (typeof T === 'function' ? T('chat.greeting', 'Hallo! Ich bin der AegisSight Assistent. Stell mir gerne jede Frage rund um die Bedienung des Monitors, ich helfe dir weiter.') : 'Hallo! Ich bin der AegisSight Assistent. Stell mir gerne jede Frage rund um die Bedienung des Monitors, ich helfe dir weiter.'));
+ }
+
+ // Tutorial-Hinweis temporaer deaktiviert (Ueberarbeitung) - reaktivieren durch Entfernen der Kommentarzeichen:
+ // if (typeof Tutorial !== 'undefined' && !this._tutorialHintDismissed) {
+ // var oldHint = document.getElementById('chat-tutorial-hint');
+ // if (oldHint) oldHint.remove();
+ // this._showTutorialHint();
+ // }
+
+ // Focus auf Input
+ setTimeout(() => {
+ const input = document.getElementById('chat-input');
+ if (input) input.focus();
+ }, 200);
+ },
+
+ close() {
+ const win = document.getElementById('chat-window');
+ const btn = document.getElementById('chat-toggle-btn');
+ if (!win) return;
+ win.classList.remove('open');
+ win.classList.remove('fullscreen');
+ btn.classList.remove('active');
+ this._isOpen = false;
+ this._isFullscreen = false;
+ const fsBtn = document.getElementById('chat-fullscreen-btn');
+ if (fsBtn) {
+ fsBtn.title = 'Vollbild';
+ fsBtn.innerHTML = '
';
+ }
+ },
+
+ reset() {
+ this._conversationId = null;
+ this._hasGreeted = false;
+ this._isLoading = false;
+ const container = document.getElementById('chat-messages');
+ if (container) container.innerHTML = '';
+ this._updateResetBtn();
+ this.open();
+ },
+
+ toggleFullscreen() {
+ const win = document.getElementById('chat-window');
+ const btn = document.getElementById('chat-fullscreen-btn');
+ if (!win) return;
+ this._isFullscreen = !this._isFullscreen;
+ win.classList.toggle('fullscreen', this._isFullscreen);
+ if (btn) {
+ btn.title = this._isFullscreen ? 'Vollbild beenden' : 'Vollbild';
+ btn.innerHTML = this._isFullscreen
+ ? '
'
+ : '
';
+ }
+ },
+
+ _updateResetBtn() {
+ const btn = document.getElementById('chat-reset-btn');
+ if (btn) btn.style.display = this._conversationId ? '' : 'none';
+ },
+
+ async send() {
+ const input = document.getElementById('chat-input');
+ const text = (input.value || '').trim();
+ if (!text || this._isLoading) return;
+
+ input.value = '';
+ input.style.height = 'auto';
+ this.addMessage('user', text);
+ this._showTyping();
+ this._isLoading = true;
+
+ // Tutorial-Keywords temporaer deaktiviert (Ueberarbeitung) - reaktivieren durch Entfernen der Kommentarzeichen:
+ // var lowerText = text.toLowerCase();
+ // if (lowerText === 'rundgang' || lowerText === 'tutorial' || lowerText === 'tour' || lowerText === 'f\u00fchrung') {
+ // this._hideTyping();
+ // this._isLoading = false;
+ // this.close();
+ // if (typeof Tutorial !== 'undefined') Tutorial.start();
+ // return;
+ // }
+
+ try {
+ const body = {
+ message: text,
+ conversation_id: this._conversationId,
+ };
+
+ // Aktuelle Lage mitschicken falls geoeffnet
+ const incidentId = this._getIncidentContext();
+ if (incidentId) {
+ body.incident_id = incidentId;
+ }
+
+ const data = await this._request(body);
+ this._conversationId = data.conversation_id;
+ this._updateResetBtn();
+ this._hideTyping();
+ this.addMessage('assistant', data.reply);
+ this._highlightUI(data.reply);
+ } catch (err) {
+ this._hideTyping();
+ const msg = err.detail || err.message || 'Etwas ist schiefgelaufen. Bitte versuche es erneut.';
+ this.addMessage('assistant', msg);
+ } finally {
+ this._isLoading = false;
+ }
+ },
+
+ addMessage(role, text) {
+ const container = document.getElementById('chat-messages');
+ if (!container) return;
+
+ const bubble = document.createElement('div');
+ bubble.className = 'chat-message ' + role;
+
+ // Einfache Formatierung: Zeilenumbrueche und Fettschrift
+ const formatted = text
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/\*\*(.+?)\*\*/g, '
$1')
+ .replace(/\n/g, '
');
+
+ bubble.innerHTML = '
' + formatted + '
';
+ container.appendChild(bubble);
+
+ // User-Nachrichten: nach unten scrollen. Antworten: zum Anfang der Antwort scrollen.
+ if (role === 'user') {
+ container.scrollTop = container.scrollHeight;
+ } else {
+ bubble.scrollIntoView({ behavior: 'smooth', block: 'start' });
+ }
+ },
+
+ _showTyping() {
+ const container = document.getElementById('chat-messages');
+ if (!container) return;
+ const el = document.createElement('div');
+ el.className = 'chat-message assistant chat-typing-msg';
+ el.innerHTML = '
';
+ container.appendChild(el);
+ container.scrollTop = container.scrollHeight;
+ },
+
+ _hideTyping() {
+ const el = document.querySelector('.chat-typing-msg');
+ if (el) el.remove();
+ },
+
+ _getIncidentContext() {
+ if (typeof App !== 'undefined' && App.currentIncidentId) {
+ return App.currentIncidentId;
+ }
+ return null;
+ },
+
+ async _request(body) {
+ const token = localStorage.getItem('osint_token');
+ const resp = await fetch('/api/chat', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': token ? 'Bearer ' + token : '',
+ },
+ body: JSON.stringify(body),
+ });
+ if (!resp.ok) {
+ const data = await resp.json().catch(() => ({}));
+ throw data;
+ }
+ return await resp.json();
+ },
+ // -----------------------------------------------------------------------
+ // UI-Highlight: Bedienelemente im Dashboard hervorheben wenn im Chat erwaehnt
+ // -----------------------------------------------------------------------
+ _UI_HIGHLIGHTS: [
+ { keywords: ['neue lage', 'lage erstellen', 'lage anlegen', 'recherche erstellen', 'neuen fall'], selector: '#new-incident-btn' },
+ { keywords: ['theme wechseln', 'theme-umschalter', 'farbschema', 'helles design', 'dunkles design', 'hell- und dunkel', 'hellem und dunklem', 'dark mode', 'light mode'], selector: '#theme-toggle' },
+ { keywords: ['barrierefreiheit', 'accessibility', 'hoher kontrast', 'focus-anzeige', 'groessere schrift', 'animationen aus'], selector: '#a11y-btn' },
+ { keywords: ['abmelden', 'logout', 'ausloggen', 'abmeldung'], selector: '#logout-btn' },
+ { keywords: ['benachrichtigung', 'glocken-symbol', 'abonnieren', 'abonniert'], selector: '#notification-btn' },
+ { keywords: ['aktualisieren', 'refresh starten'], selector: '#refresh-btn' },
+ { keywords: ['exportieren', 'export-button', 'lagebericht exportieren'], selector: 'button[onclick*="toggleExportDropdown"]' },
+ { keywords: ['faktencheck', 'factcheck'], selector: '[gs-id="factcheck"]' },
+ { keywords: ['kartenansicht', 'karte angezeigt', 'interaktive karte', 'geoparsing'], selector: '[gs-id="map"]' },
+ { keywords: ['quellen verwalten', 'quellenverwaltung', 'quelleneinstellung', 'quellenausschluss', 'quellen-einstellung'], selector: 'button[onclick*="openSourceManagement"]' },
+ { keywords: ['sichtbarkeit', 'privat oder oeffentlich', 'lage privat'], selector: '#incident-settings-btn' },
+ { keywords: ['eigene lagen', 'nur eigene'], selector: '.sidebar-filter-btn[data-filter="mine"]' },
+ { keywords: ['alle lagen anzeigen'], selector: '.sidebar-filter-btn[data-filter="all"]' },
+ { keywords: ['feedback senden', 'feedback geben', 'rueckmeldung'], selector: 'button[onclick*="openFeedback"]' },
+ { keywords: ['lage loeschen', 'lage entfernen', 'fall loeschen'], selector: '#delete-incident-btn' },
+ ],
+
+ _highlightUI(text) {
+ if (!text) return;
+ var lower = text.toLowerCase();
+ var highlighted = new Set();
+ for (var i = 0; i < this._UI_HIGHLIGHTS.length; i++) {
+ var entry = this._UI_HIGHLIGHTS[i];
+ for (var k = 0; k < entry.keywords.length; k++) {
+ var kw = entry.keywords[k];
+ if (lower.indexOf(kw) !== -1) {
+ var selectors = entry.selector.split(',');
+ for (var s = 0; s < selectors.length; s++) {
+ var sel = selectors[s].trim();
+ if (highlighted.has(sel)) continue;
+ var el = document.querySelector(sel);
+ if (el) {
+ highlighted.add(sel);
+ el.scrollIntoView({ behavior: 'smooth', block: 'center' });
+ (function(element) {
+ setTimeout(function() {
+ element.classList.add('chat-ui-highlight');
+ }, 400);
+ setTimeout(function() {
+ element.classList.remove('chat-ui-highlight');
+ }, 4400);
+ })(el);
+ }
+ }
+ break;
+ }
+ }
+ }
+ },
+
+ async _showTutorialHint() {
+ var container = document.getElementById('chat-messages');
+ if (!container) return;
+
+ // API-State laden (Fallback: Standard-Hint)
+ var state = null;
+ try { state = await API.getTutorialState(); } catch(e) {}
+
+ var hint = document.createElement('div');
+ hint.className = 'chat-tutorial-hint';
+ hint.id = 'chat-tutorial-hint';
+ var textDiv = document.createElement('div');
+ textDiv.className = 'chat-tutorial-hint-text';
+ textDiv.style.cursor = 'pointer';
+
+ if (state && !state.completed && state.current_step !== null && state.current_step > 0) {
+ // Mittendrin abgebrochen
+ var totalSteps = (typeof Tutorial !== 'undefined') ? Tutorial._steps.length : 32;
+ textDiv.innerHTML = '
Tipp: Sie haben den Rundgang bei Schritt ' + (state.current_step + 1) + '/' + totalSteps + ' unterbrochen. Klicken Sie hier, um fortzusetzen.';
+ textDiv.addEventListener('click', function() {
+ Chat.close();
+ Chat._tutorialHintDismissed = true;
+ if (typeof Tutorial !== 'undefined') Tutorial.start();
+ });
+ } else if (state && state.completed) {
+ // Bereits abgeschlossen
+ textDiv.innerHTML = '
Tipp: Sie haben den Rundgang bereits abgeschlossen.
Erneut starten?';
+ textDiv.addEventListener('click', async function() {
+ Chat.close();
+ Chat._tutorialHintDismissed = true;
+ try { await API.resetTutorialState(); } catch(e) {}
+ if (typeof Tutorial !== 'undefined') Tutorial.start(true);
+ });
+ } else {
+ // Nie gestartet
+ textDiv.innerHTML = '
Tipp: Kennen Sie schon den interaktiven Rundgang? Er zeigt Ihnen Schritt für Schritt alle Funktionen des Monitors. Klicken Sie hier, um ihn zu starten.';
+ textDiv.addEventListener('click', function() {
+ Chat.close();
+ Chat._tutorialHintDismissed = true;
+ if (typeof Tutorial !== 'undefined') Tutorial.start();
+ });
+ }
+
+ var closeBtn = document.createElement('button');
+ closeBtn.className = 'chat-tutorial-hint-close';
+ closeBtn.title = 'Schließen';
+ closeBtn.innerHTML = '×';
+ closeBtn.addEventListener('click', function(e) {
+ e.stopPropagation();
+ hint.remove();
+ Chat._tutorialHintDismissed = true;
+ });
+ hint.appendChild(textDiv);
+ hint.appendChild(closeBtn);
+ container.appendChild(hint);
+ },
+
+};
diff --git a/src/static/js/components.js b/src/static/js/components.js
index 447dd8c..4e7ee6b 100644
--- a/src/static/js/components.js
+++ b/src/static/js/components.js
@@ -1,1662 +1,1662 @@
-/**
- * Parst einen Zeitstring vom Server in ein Date-Objekt.
- * Timestamps mit 'Z' oder '+' werden direkt geparst (echtes UTC/Offset).
- * Timestamps ohne Zeitzonen-Info werden als Europe/Berlin interpretiert,
- * da die DB alle Zeiten in Lokalzeit speichert.
- */
-function parseUTC(dateStr) {
- if (!dateStr) return null;
- try {
- if (dateStr.endsWith('Z') || dateStr.includes('+')) {
- const d = new Date(dateStr);
- return isNaN(d.getTime()) ? null : d;
- }
- // DB-Timestamps sind Europe/Berlin Lokalzeit.
- // Aktuellen Berlin-UTC-Offset ermitteln und anwenden.
- const normalized = dateStr.replace(' ', 'T');
- const naive = new Date(normalized + 'Z'); // als UTC parsen
- if (isNaN(naive.getTime())) return null;
- // Berlin-Offset fuer diesen Zeitpunkt bestimmen
- const berlinStr = naive.toLocaleString('sv-SE', { timeZone: 'Europe/Berlin' });
- const berlinAsUTC = new Date(berlinStr.replace(' ', 'T') + 'Z');
- const offsetMs = naive.getTime() - berlinAsUTC.getTime();
- const d = new Date(naive.getTime() + offsetMs);
- return isNaN(d.getTime()) ? null : d;
- } catch (e) {
- return null;
- }
-}
-
-/**
- * UI-Komponenten für das Dashboard.
- */
-const UI = {
- /**
- * Sidebar-Eintrag für eine Lage rendern.
- */
- renderIncidentItem(incident, isActive) {
- const isRefreshing = App._refreshingIncidents && App._refreshingIncidents.has(incident.id);
- const dotClass = isRefreshing ? 'refreshing' : (incident.status === 'active' ? 'active' : 'archived');
- const activeClass = isActive ? 'active' : '';
- const creator = (incident.created_by_username || '').split('@')[0];
-
- // Determine refresh status for sidebar display
- let refreshClass = '';
- let refreshStatusHtml = '';
- if (isRefreshing) {
- const state = this._progressState[incident.id];
- const step = state ? state.step : 'researching';
- const isQueued = (step === 'queued');
-
- if (isQueued) {
- refreshClass = ' queued-item';
- const pos = state && state._queuePos ? ' (#' + state._queuePos + ')' : '';
- refreshStatusHtml = '';
- } else {
- refreshClass = ' refreshing-item';
- const label = this._getStepLabel(step);
- refreshStatusHtml = '';
- }
- }
-
- return `
-
-
-
-
${this.escape(incident.title)}
-
${incident.article_count} Artikel · ${this.escape(creator)}
- ${refreshStatusHtml}
-
- ${incident.visibility === 'private' ? '
PRIVAT' : ''}
- ${incident.refresh_mode === 'auto' ? '
↻' : ''}
-
- `;
- },
-
- /**
- * Faktencheck-Eintrag rendern.
- */
- // Faktencheck-Status-Labels (org-sprach-relativ via T()).
- // Die DE-Fallbacks sind die historische Quelle der Wahrheit; bei
- // englischer Org liefert T() den EN-Text aus i18n/en.json.
- _fcLabelDefaultsDE: {
- confirmed: 'Bestätigt durch mehrere Quellen',
- unconfirmed: 'Nicht unabhängig bestätigt',
- contradicted: 'Widerlegt',
- developing: 'Faktenlage noch im Fluss',
- established: 'Gesicherter Fakt (3+ Quellen)',
- disputed: 'Umstrittener Sachverhalt',
- unverified: 'Nicht unabhängig verifizierbar',
- },
- _fcTooltipDefaultsDE: {
- confirmed: 'Bestätigt: Mindestens zwei unabhängige, seriöse Quellen stützen diese Aussage übereinstimmend.',
- established: 'Gesichert: Drei oder mehr unabhängige Quellen bestätigen den Sachverhalt. Hohe Verlässlichkeit.',
- developing: 'Unklar: Die Faktenlage ist noch im Fluss. Neue Informationen können das Bild verändern.',
- unconfirmed: 'Unbestätigt: Bisher nur aus einer Quelle bekannt. Eine unabhängige Bestätigung steht aus.',
- unverified: 'Ungeprüft: Die Aussage konnte bisher nicht anhand verfügbarer Quellen überprüft werden.',
- disputed: 'Umstritten: Quellen widersprechen sich. Es gibt sowohl stützende als auch widersprechende Belege.',
- contradicted: 'Widerlegt: Zuverlässige Quellen widersprechen dieser Aussage. Wahrscheinlich falsch.',
- },
- _fcChipDefaultsDE: {
- confirmed: 'Bestätigt',
- unconfirmed: 'Unbestätigt',
- contradicted: 'Widerlegt',
- developing: 'Unklar',
- established: 'Gesichert',
- disputed: 'Umstritten',
- unverified: 'Ungeprüft',
- },
-
- get factCheckLabels() {
- const out = {};
- for (const k of Object.keys(this._fcLabelDefaultsDE)) {
- out[k] = (typeof T === 'function')
- ? T('fc.label.' + k, this._fcLabelDefaultsDE[k])
- : this._fcLabelDefaultsDE[k];
- }
- return out;
- },
- get factCheckTooltips() {
- const out = {};
- for (const k of Object.keys(this._fcTooltipDefaultsDE)) {
- out[k] = (typeof T === 'function')
- ? T('fc.tooltip.' + k, this._fcTooltipDefaultsDE[k])
- : this._fcTooltipDefaultsDE[k];
- }
- return out;
- },
- get factCheckChipLabels() {
- const out = {};
- for (const k of Object.keys(this._fcChipDefaultsDE)) {
- out[k] = (typeof T === 'function')
- ? T('fc.chip.' + k, this._fcChipDefaultsDE[k])
- : this._fcChipDefaultsDE[k];
- }
- return out;
- },
-
- factCheckIcons: {
- confirmed: '✓',
- unconfirmed: '?',
- contradicted: '✗',
- developing: '↻',
- established: '✓',
- disputed: '⚠',
- unverified: '?',
- },
-
- /**
- * Faktencheck-Filterleiste rendern.
- */
- renderFactCheckFilters(factchecks) {
- // Welche Stati kommen tatsächlich vor + Zähler
- const statusCounts = {};
- factchecks.forEach(fc => {
- statusCounts[fc.status] = (statusCounts[fc.status] || 0) + 1;
- });
- const statusOrder = ['confirmed', 'established', 'developing', 'unconfirmed', 'unverified', 'disputed', 'contradicted'];
- const usedStatuses = statusOrder.filter(s => statusCounts[s]);
- if (usedStatuses.length <= 1) return '';
-
- const items = usedStatuses.map(status => {
- const icon = this.factCheckIcons[status] || '?';
- const chipLabel = this.factCheckChipLabels[status] || status;
- const tooltip = this.factCheckTooltips[status] || '';
- const count = statusCounts[status];
- return `
`;
- }).join('');
-
- return `
- `;
- },
-
- renderFactCheck(fc) {
- const urls = (fc.evidence || '').match(/https?:\/\/[^\s,)]+/g) || [];
- const count = urls.length;
- return `
-
-
${this.factCheckIcons[fc.status] || '?'}
-
${this.factCheckLabels[fc.status] || fc.status}
-
-
${this.escape(fc.claim)}
-
- ${count} Quelle${count !== 1 ? 'n' : ''}
-
-
${this.renderEvidence(fc.evidence || '')}
-
-
- `;
- },
-
- /**
- * Evidence mit erklärenden Text UND Quellen-Chips rendern.
- */
- renderEvidence(text) {
- if (!text) return '
Keine Belege';
-
- const urls = text.match(/https?:\/\/[^\s,)]+/g) || [];
- if (urls.length === 0) {
- return `
${this.escape(text)}`;
- }
-
- // Erklärenden Text extrahieren (URLs entfernen)
- let explanation = text;
- urls.forEach(url => { explanation = explanation.replace(url, '').trim(); });
- // Aufräumen: Klammern, mehrfache Kommas/Leerzeichen
- explanation = explanation.replace(/\(\s*\)/g, '');
- explanation = explanation.replace(/,\s*,/g, ',');
- explanation = explanation.replace(/\s+/g, ' ').trim();
- explanation = explanation.replace(/[,.:;]+$/, '').trim();
-
- // Chips für jede URL
- const chips = urls.map(url => {
- let label;
- try { label = new URL(url).hostname.replace('www.', ''); } catch { label = url; }
- return `
${this.escape(label)}`;
- }).join('');
-
- const explanationHtml = explanation
- ? `
${this.escape(explanation)}`
- : '';
-
- return `${explanationHtml}
${chips}
`;
- },
-
- /**
- * Toast-Benachrichtigung anzeigen.
- */
- _toastTimers: new Map(),
-
- showToast(message, type = 'info', duration = 5000) {
- const container = document.getElementById('toast-container');
-
- // Duplikat? Bestehenden Toast neu animieren
- const existing = Array.from(container.children).find(
- t => t.dataset.msg === message && t.dataset.type === type
- );
- if (existing) {
- clearTimeout(this._toastTimers.get(existing));
- // Kurz rausschieben, dann neu reingleiten
- existing.style.transition = 'none';
- existing.style.opacity = '0';
- existing.style.transform = 'translateX(100%)';
- void existing.offsetWidth; // Reflow erzwingen
- existing.style.transition = 'all 0.3s ease';
- existing.style.opacity = '1';
- existing.style.transform = 'translateX(0)';
- const timer = setTimeout(() => {
- existing.style.opacity = '0';
- existing.style.transform = 'translateX(100%)';
- setTimeout(() => { existing.remove(); this._toastTimers.delete(existing); }, 300);
- }, duration);
- this._toastTimers.set(existing, timer);
- return;
- }
-
- const toast = document.createElement('div');
- toast.className = `toast toast-${type}`;
- toast.setAttribute('role', 'status');
- toast.dataset.msg = message;
- toast.dataset.type = type;
- toast.innerHTML = `
${this.escape(message)}`;
- container.appendChild(toast);
-
- const timer = setTimeout(() => {
- toast.style.opacity = '0';
- toast.style.transform = 'translateX(100%)';
- toast.style.transition = 'all 0.3s ease';
- setTimeout(() => { toast.remove(); this._toastTimers.delete(toast); }, 300);
- }, duration);
- this._toastTimers.set(toast, timer);
- },
-
- _progressStartTime: null,
- _progressTimer: null,
-
- /**
- * Fortschrittsanzeige einblenden und Status setzen.
- */
- // === Progress State (per-incident) ===
- _progressState: {}, // { incidentId: { step, isFirst, startTime, minimized } }
- _progressTimerInterval: null,
-
- _getStepOrder() {
- return ['queued', 'researching', 'deep_researching', 'analyzing', 'factchecking'];
- },
-
- _getStepLabel(step) {
- const fallback = {
- queued: 'In Warteschlange',
- researching: 'Recherchiert...',
- deep_researching: 'Tiefenrecherche...',
- analyzing: 'Analysiert...',
- factchecking: 'Faktencheck...',
- cancelling: 'Wird abgebrochen...',
- };
- if (!fallback[step]) return step;
- return (typeof T === 'function')
- ? T('progress.status.' + step, fallback[step])
- : fallback[step];
- },
-
- showProgress(status, extra = {}, incidentId = null, isFirstRefresh = false) {
- if (!incidentId) incidentId = App.currentIncidentId;
- if (!incidentId) return;
-
- // Init state for this incident
- if (!this._progressState[incidentId]) {
- this._progressState[incidentId] = { step: 'queued', isFirst: isFirstRefresh, startTime: null, minimized: false };
- }
- const state = this._progressState[incidentId];
- state.step = status;
- if (isFirstRefresh) state.isFirst = true;
-
- // Start timer on first non-queued status
- if (status !== 'queued' && !state.startTime) {
- if (extra.started_at) {
- const serverStart = typeof parseUTC === 'function' ? parseUTC(extra.started_at) : new Date(extra.started_at);
- state.startTime = serverStart ? serverStart.getTime() : Date.now();
- } else {
- state.startTime = Date.now();
- }
- }
-
- // Start global timer interval if not running
- if (!this._progressTimerInterval) {
- this._progressTimerInterval = setInterval(() => this._tickProgressTimers(), 1000);
- }
-
- // Store queue position
- if (status === 'queued' && extra.queue_position) {
- state._queuePos = extra.queue_position;
- }
-
- // Update sidebar status for ALL incidents (not just current)
- this._updateSidebarRefreshStatus(incidentId, status, extra);
-
- // Only show popup/mini UI for current incident
- if (incidentId !== App.currentIncidentId) return;
-
-
- if (false) { // popup always shown initially
- state.minimized = true;
- }
-
- if (state.minimized) {
- this._showMiniProgress(status, state);
- return;
- }
-
- this._showPopupProgress(status, extra, state);
- },
-
- _showPopupProgress(status, extra, state) {
- const overlay = document.getElementById('progress-overlay');
- const popup = document.getElementById('progress-popup');
- if (!overlay || !popup) return;
-
- overlay.style.display = 'flex';
- this._initClickOutside();
-
- // Blocking (no close) for first refresh
- if (state.isFirst) {
- overlay.classList.add('blocking');
- // Apply blur to incident-view (Header + Tab-Panels gemeinsam).
- const blurTarget = document.getElementById('incident-view');
- if (blurTarget) {
- blurTarget.classList.add('refresh-blurred');
- // Sicherheitsnetz: bei viel DOM-Reshuffle im selben Tick
- // (Display-Wechsel, renderSidebar, leere innerHTML) greift
- // CSS filter:blur erst beim naechsten Layout-Pass. Im
- // naechsten Frame nochmal setzen — idempotent.
- requestAnimationFrame(() => {
- if (state && state.isFirst) blurTarget.classList.add('refresh-blurred');
- });
- }
- } else {
- overlay.classList.remove('blocking');
- }
-
- // Minimize button: only for updates (not first)
- const minBtn = document.getElementById('progress-popup-minimize');
- if (minBtn) minBtn.style.display = state.isFirst ? 'none' : '';
-
- // Title - haengt von Status ab (queued = wartet, cancelling = bricht ab, sonst laeuft)
- const titleEl = document.getElementById('progress-popup-title');
- if (titleEl) {
- const _t = (k, fb) => (typeof T === 'function') ? T(k, fb) : fb;
- let title;
- if (status === 'queued') {
- const pos = (state && state._queuePos) ? ' (#' + state._queuePos + ')' : '';
- title = _t('progress.title.queued', 'In Warteschlange') + pos;
- } else if (status === 'cancelling') {
- title = _t('progress.title.cancelling', 'Wird abgebrochen\u2026');
- } else if (state.isFirst) {
- title = _t('progress.title.first_refresh', 'Erste Recherche l\u00e4uft');
- } else {
- title = _t('progress.title.refresh', 'Aktualisierung l\u00e4uft');
- }
- titleEl.textContent = title;
- }
-
- // Multi-pass info
- const passEl = document.getElementById('progress-popup-pass');
- if (passEl) {
- if (extra.research_pass && extra.research_total_passes) {
- passEl.textContent = 'Durchlauf ' + extra.research_pass + '/' + extra.research_total_passes;
- passEl.style.display = '';
- } else {
- passEl.style.display = 'none';
- }
- }
-
- // Update checklist
- const stepOrder = this._getStepOrder();
- const currentIdx = stepOrder.indexOf(status === 'deep_researching' ? 'researching' : status);
- const items = document.querySelectorAll('.progress-check-item');
- // Map checklist items to step indices: queued=0, researching=1, analyzing=3, factchecking=4
- const checkStepMap = { queued: 0, researching: 1, analyzing: 3, factchecking: 4 };
-
- items.forEach(item => {
- const step = item.dataset.step;
- const stepIdx = checkStepMap[step] !== undefined ? checkStepMap[step] : -1;
- const icon = item.querySelector('.progress-check-icon');
- const detail = item.querySelector('.progress-check-detail');
-
- item.classList.remove('active', 'done', 'error');
-
- if (stepIdx < currentIdx || (step === 'queued' && currentIdx > 0)) {
- item.classList.add('done');
- if (icon) icon.innerHTML = '\u2713';
- } else if (stepIdx === currentIdx || (step === 'researching' && (status === 'researching' || status === 'deep_researching'))) {
- item.classList.add('active');
- if (icon) icon.innerHTML = '
';
- if (detail && extra.detail) detail.textContent = extra.detail;
- else if (detail) detail.textContent = '';
- } else {
- if (icon) icon.innerHTML = '\u25cb';
- if (detail) detail.textContent = '';
- }
- });
-
- // Cancel button
- const cancelBtn = document.getElementById('progress-cancel-btn');
- if (cancelBtn) {
- cancelBtn.style.display = '';
- cancelBtn.textContent = 'Abbrechen';
- cancelBtn.disabled = false;
- }
-
- // Hide complete summary
- const summaryEl = document.getElementById('progress-complete-summary');
- if (summaryEl) summaryEl.style.display = 'none';
-
- // Hide mini bar
- const mini = document.getElementById('progress-mini');
- if (mini) mini.style.display = 'none';
-
- // Lock action buttons during first refresh
- this._lockActionsIfFirst(state.isFirst);
- },
-
- _lockActionsIfFirst(isFirst) {
- const actions = document.querySelector('.incident-header-actions');
- if (!actions) return;
- if (isFirst) {
- actions.classList.add('first-refresh-locked');
- } else {
- actions.classList.remove('first-refresh-locked');
- }
- },
-
- _showMiniProgress(status, state) {
- const mini = document.getElementById('progress-mini');
- if (!mini) return;
- mini.style.display = 'flex';
-
- const textEl = document.getElementById('progress-mini-text');
- if (textEl) textEl.textContent = this._getStepLabel(status);
-
- // Hide popup
- const overlay = document.getElementById('progress-overlay');
- if (overlay) overlay.style.display = 'none';
- },
-
- minimizeProgress(incidentId) {
- if (!incidentId) incidentId = App.currentIncidentId;
- const state = this._progressState[incidentId];
- if (!state) return;
- state.minimized = true;
- state._userOpenedPopup = false;
- this._showMiniProgress(state.step, state);
- },
-
- openProgressPopup(incidentId) {
- if (!incidentId) incidentId = App.currentIncidentId;
- const state = this._progressState[incidentId];
- if (!state) return;
- state.minimized = false;
- state._userOpenedPopup = true;
- this._showPopupProgress(state.step, {}, state);
- },
-
- showProgressComplete(data, incidentId) {
- if (!incidentId) incidentId = App.currentIncidentId;
- const state = this._progressState[incidentId];
-
- // Calculate total time
- let totalTimeStr = '';
- if (state && state.startTime) {
- const elapsed = Math.floor((Date.now() - state.startTime) / 1000);
- const mins = Math.floor(elapsed / 60);
- const secs = elapsed % 60;
- totalTimeStr = mins + ':' + String(secs).padStart(2, '0');
- }
-
- if (incidentId === App.currentIncidentId) {
- // Remove blur
- const blurTarget = document.getElementById('incident-view');
- if (blurTarget) blurTarget.classList.remove('refresh-blurred');
-
- const overlay = document.getElementById('progress-overlay');
- if (overlay) {
- overlay.style.display = 'flex';
- overlay.classList.remove('blocking');
- }
-
- // Mark all steps done
- document.querySelectorAll('.progress-check-item').forEach(item => {
- item.classList.remove('active', 'error');
- item.classList.add('done');
- const icon = item.querySelector('.progress-check-icon');
- if (icon) icon.innerHTML = '\u2713';
- });
-
- // Show summary
- const parts = [];
- if (data.new_articles > 0) parts.push(data.new_articles + ' neue Artikel');
- if (data.confirmed_count > 0) parts.push(data.confirmed_count + ' Fakten best\u00e4tigt');
- if (data.contradicted_count > 0) parts.push(data.contradicted_count + ' widerlegt');
- const summaryText = parts.length > 0 ? parts.join(', ') : 'Keine neuen Entwicklungen';
-
- const summaryEl = document.getElementById('progress-complete-summary');
- if (summaryEl) {
- summaryEl.innerHTML = '\u2713 Abgeschlossen: ' + summaryText
- + (totalTimeStr ? '
Gesamtzeit: ' + totalTimeStr + '' : '');
- summaryEl.style.display = 'block';
- }
-
- // Update title
- const titleEl = document.getElementById('progress-popup-title');
- if (titleEl) titleEl.textContent = 'Abgeschlossen';
-
- // Hide cancel, show minimize
- const cancelBtn = document.getElementById('progress-cancel-btn');
- if (cancelBtn) cancelBtn.style.display = 'none';
- const minBtn = document.getElementById('progress-popup-minimize');
- if (minBtn) minBtn.style.display = '';
-
- // Hide mini bar
- const mini = document.getElementById('progress-mini');
- if (mini) mini.style.display = 'none';
- }
-
- // Remove sidebar refresh status
- this._removeSidebarRefreshStatus(incidentId);
-
- // Clean up state after delay
- setTimeout(() => {
- this.hideProgress(incidentId);
- }, 5000);
- },
-
- showProgressError(errorMsg, willRetry = false, delay = 0, incidentId = null) {
- if (!incidentId) incidentId = App.currentIncidentId;
- if (incidentId !== App.currentIncidentId) return;
-
- const overlay = document.getElementById('progress-overlay');
- if (overlay) overlay.style.display = 'flex';
-
- // Mark current step as error
- const state = this._progressState[incidentId];
- if (state) {
- const items = document.querySelectorAll('.progress-check-item.active');
- items.forEach(item => {
- item.classList.remove('active');
- item.classList.add('error');
- const icon = item.querySelector('.progress-check-icon');
- if (icon) icon.innerHTML = '\u2717';
- });
- }
-
- const titleEl = document.getElementById('progress-popup-title');
- if (titleEl) {
- titleEl.textContent = willRetry
- ? 'Fehlgeschlagen \u2014 erneuter Versuch in ' + delay + 's...'
- : 'Fehlgeschlagen: ' + errorMsg;
- }
-
- const cancelBtn = document.getElementById('progress-cancel-btn');
- if (cancelBtn) cancelBtn.style.display = 'none';
-
- if (!willRetry) {
- this._removeSidebarRefreshStatus(incidentId);
- setTimeout(() => this.hideProgress(incidentId), 6000);
- }
- },
-
- hideProgress(incidentId) {
- if (!incidentId) incidentId = App.currentIncidentId;
-
- // Remove blur
- const blurTarget = document.getElementById('incident-view');
- if (blurTarget) blurTarget.classList.remove('refresh-blurred');
-
- if (incidentId === App.currentIncidentId) {
- const overlay = document.getElementById('progress-overlay');
- if (overlay) { overlay.style.display = 'none'; overlay.classList.remove('blocking'); }
- const mini = document.getElementById('progress-mini');
- if (mini) mini.style.display = 'none';
- }
-
- // Unlock action buttons
- this._lockActionsIfFirst(false);
-
- // Remove sidebar status
- this._removeSidebarRefreshStatus(incidentId);
-
- // Clean up state
- delete this._progressState[incidentId];
-
- // Stop timer if no more active refreshes
- if (Object.keys(this._progressState).length === 0 && this._progressTimerInterval) {
- clearInterval(this._progressTimerInterval);
- this._progressTimerInterval = null;
- }
- },
-
- _tickProgressTimers() {
- for (const [id, state] of Object.entries(this._progressState)) {
- if (!state.startTime) continue;
- const elapsed = Math.max(0, Math.floor((Date.now() - state.startTime) / 1000));
- const mins = Math.floor(elapsed / 60);
- const secs = elapsed % 60;
- const timeStr = mins + ':' + String(secs).padStart(2, '0');
-
- if (parseInt(id) === App.currentIncidentId) {
- // Update popup timer
- const timerEl = document.getElementById('progress-popup-timer');
- if (timerEl) timerEl.textContent = timeStr;
- // Update mini timer
- const miniTimer = document.getElementById('progress-mini-timer');
- if (miniTimer) miniTimer.textContent = timeStr;
- }
-
- // Update sidebar timer for this incident
- const sidebarTimer = document.getElementById('sidebar-refresh-timer-' + id);
- if (sidebarTimer) sidebarTimer.textContent = timeStr;
- }
- },
-
- // === Sidebar Refresh Status ===
- _updateSidebarRefreshStatus(incidentId, status, extra) {
- const item = document.querySelector('.incident-item[data-id="' + incidentId + '"]');
- if (!item) return;
-
- const isQueued = (status === 'queued');
-
- // Add appropriate class
- item.classList.remove('refreshing-item', 'queued-item');
- item.classList.add(isQueued ? 'queued-item' : 'refreshing-item');
-
- // Add or update status text below meta
- let statusEl = document.getElementById('sidebar-refresh-' + incidentId);
- if (!statusEl) {
- const textCol = item.querySelector('div[style*="flex:1"]');
- if (!textCol) return;
- statusEl = document.createElement('div');
- statusEl.id = 'sidebar-refresh-' + incidentId;
- textCol.appendChild(statusEl);
- }
-
- if (isQueued) {
- const pos = (extra && extra.queue_position) ? extra.queue_position : ((this._progressState[incidentId] || {})._queuePos || '');
- // Store queue position in state for renderIncidentItem
- const pState = this._progressState[incidentId];
- if (pState && pos) pState._queuePos = pos;
- statusEl.className = 'incident-refresh-status queued-status';
- statusEl.innerHTML = '
Warteschlange' + (pos ? ' (#' + pos + ')' : '') + '';
- } else {
- statusEl.className = 'incident-refresh-status';
- const label = this._getStepLabel(status);
- statusEl.innerHTML = '
' + label + '';
- }
- },
-
- _removeSidebarRefreshStatus(incidentId) {
- const statusEl = document.getElementById('sidebar-refresh-' + incidentId);
- if (statusEl) statusEl.remove();
- const item = document.querySelector('.incident-item[data-id="' + incidentId + '"]');
- if (item) item.classList.remove('refreshing-item', 'queued-item');
- },
-
- _reindexQueuePositions() {
- // Collect all queued incidents and renumber sequentially
- const queued = [];
- for (const [id, state] of Object.entries(this._progressState)) {
- if (state && state.step === 'queued') queued.push({ id: Number(id), pos: state._queuePos || 999 });
- }
- queued.sort((a, b) => a.pos - b.pos);
- queued.forEach((item, idx) => {
- const newPos = idx + 1;
- const state = this._progressState[item.id];
- if (state) state._queuePos = newPos;
- const statusEl = document.getElementById('sidebar-refresh-' + item.id);
- if (statusEl) statusEl.innerHTML = '
Warteschlange (#' + newPos + ')';
- });
- },
-
-
- // === Click-outside to auto-minimize popup ===
- _initClickOutside() {
- if (this._clickOutsideInit) return;
- this._clickOutsideInit = true;
- document.addEventListener('click', (e) => {
- const overlay = document.getElementById('progress-overlay');
- if (!overlay || overlay.style.display === 'none') return;
- const popup = document.getElementById('progress-popup');
- if (!popup) return;
- // Ignore clicks inside the popup itself
- if (popup.contains(e.target)) return;
- // Ignore clicks on the mini bar
- const mini = document.getElementById('progress-mini');
- if (mini && mini.contains(e.target)) return;
- // Don't minimize during first refresh (blocking)
- const currentId = App.currentIncidentId;
- const state = this._progressState[currentId];
- if (state && state.isFirst) return;
- // Auto-minimize
- if (state && !state.minimized) {
- this.minimizeProgress(currentId);
- }
- });
- },
-
- /**
- * Zusammenfassung mit Inline-Zitaten und Quellenverzeichnis rendern.
- */
- /**
- * Extrahiert die ZUSAMMENFASSUNG-Sektion aus einem Research-Briefing.
- * Returns: { zusammenfassung: string|null, remaining: string }
- */
- extractZusammenfassung(summary) {
- if (!summary) return { zusammenfassung: null, remaining: summary };
- const pattern = /## (?:ZUSAMMENFASSUNG|ÜBERBLICK)\s*\n(.*?)(?=\n## |$)/s;
- const match = summary.match(pattern);
- if (!match) return { zusammenfassung: null, remaining: summary };
- const zusammenfassung = match[1].trim();
- const remaining = summary.substring(0, match.index) + summary.substring(match.index + match[0].length);
- return { zusammenfassung, remaining: remaining.trim() };
- },
-
- /**
- * Parst sources: akzeptiert Array (neu, vom /sources-Endpunkt) ODER
- * JSON-String (alt, aus sources_json) fuer Rueckwaertskompatibilitaet.
- */
- _parseSources(input) {
- if (!input) return [];
- if (Array.isArray(input)) return input;
- try {
- const parsed = JSON.parse(input);
- return Array.isArray(parsed) ? parsed : [];
- } catch (e) {
- return [];
- }
- },
-
- /**
- * Rendert die Zusammenfassung als HTML (Bullet Points).
- */
- renderZusammenfassung(text, sourcesJson) {
- if (!text) return '
Noch keine Zusammenfassung.';
- const sources = this._parseSources(sourcesJson);
- // Nur Bullet-Point-Zeilen behalten, Fliesstext herausfiltern
- const bulletLines = text.split("\n").filter(line => line.trim().startsWith("- "));
- const bulletText = bulletLines.length > 0 ? bulletLines.join("\n") : text;
- let html = this.escape(bulletText);
- // Bullet points
- html = html.replace(/^- (.+)$/gm, '
$1');
- html = html.replace(/(
.*<\/li>\n?)+/gs, '');
- // Zeilenumbrueche
- html = html.replace(/\n(?!<)/g, '
');
- html = html.replace(/(
){2,}/g, '
');
- // Inline-Zitate als klickbare Links
- if (sources.length > 0) {
- html = html.replace(/\[(\d+[a-z]?)\]/g, (match, num) => {
- let src = sources.find(s => String(s.nr) === num || Number(s.nr) === Number(num));
- if ((!src || !src.url) && /[a-z]$/.test(num)) {
- const baseNum = num.replace(/[a-z]$/, '');
- const baseSrc = sources.find(s => String(s.nr) === baseNum || Number(s.nr) === Number(baseNum));
- if (baseSrc && baseSrc.url) src = baseSrc;
- }
- if (src && src.url) {
- return `[${num}]`;
- }
- return match;
- });
- }
- return html;
- },
-
- /**
- * Rendert "Neueste Entwicklungen" für Live-Monitoring (adhoc).
- * Erwartet Bullets im Format "- [DD.MM. HH:MM] Text {Quelle1, Quelle2}".
- * Legacy: Inline-[N]-Citations werden als Fallback ebenfalls erkannt.
- */
- renderLatestDevelopments(text, sourcesJson) {
- if (!text) return 'Noch keine Entwicklungen erfasst.';
- const sources = this._parseSources(sourcesJson);
-
- const bulletLines = text.split("\n").map(l => l.trim()).filter(l => l && (l.startsWith("- ") || l.startsWith("[")));
- if (bulletLines.length === 0) {
- return this.renderZusammenfassung(text, sourcesJson);
- }
-
- const bulletRe = /^(?:-\s*)?\[\s*(\d{1,2})\.(\d{1,2})\.?(?:\d{2,4})?\s+(\d{1,2}:\d{2})\s*\]\s*(.+?)\s*$/;
- const citationRe = /\[(\d+[a-z]?)\]/g;
- const trailingNamesRe = /\s*\{([^{}]+)\}\s*\.?\s*$/;
-
- const lookupByNum = (num) => {
- let src = sources.find(s => String(s.nr) === num || Number(s.nr) === Number(num));
- if (!src && /[a-z]$/.test(num)) {
- const baseNum = num.replace(/[a-z]$/, '');
- src = sources.find(s => String(s.nr) === baseNum || Number(s.nr) === Number(baseNum));
- }
- return src || null;
- };
-
- const normalize = (s) => (s || '').toLowerCase().replace(/^(der|die|das)\s+/, '').replace(/\s+/g, ' ').trim();
- const lookupByName = (name) => {
- const n = normalize(name);
- if (!n) return null;
- let src = sources.find(s => normalize(s.name) === n);
- if (src) return src;
- src = sources.find(s => {
- const sn = normalize(s.name);
- return sn.includes(n) || n.includes(sn);
- });
- return src || null;
- };
-
- const buildPill = (src, fallbackName) => {
- const displayName = src ? (src.name || fallbackName) : fallbackName;
- const url = (src && src.url) || '';
- const tgMatch = url.match(/^https?:\/\/t\.me\/([^\/?#]+)/i);
- const label = tgMatch ? displayName + ' (t.me/' + tgMatch[1] + ')' : displayName;
- const esc = this.escape(label);
- const titleEsc = this.escape(displayName);
- if (src && src.url) {
- return `${esc}`;
- }
- return `${esc}`;
- };
-
- const cards = bulletLines.map(line => {
- const m = bulletRe.exec(line);
- if (!m) {
- const body = this.escape(line.replace(/^-\s*/, ''));
- return ``;
- }
- const day = m[1].padStart(2, '0');
- const month = m[2].padStart(2, '0');
- const date = `${day}.${month}.`;
- const time = m[3];
- let rawBody = m[4];
-
- let pillsHtml = '';
-
- // Primär: {Name1|URL1, Name2|URL2} oder {Name1, Name2} am Bullet-Ende
- const trailing = trailingNamesRe.exec(rawBody);
- if (trailing) {
- rawBody = rawBody.replace(trailingNamesRe, '').trim();
- const items = trailing[1].split(',').map(s => s.trim()).filter(Boolean);
- const seen = new Set();
- pillsHtml = items.map(item => {
- // Split am ersten Pipe: "Name|URL" → Name + URL; ohne Pipe nur Name
- const pipeIdx = item.indexOf('|');
- const itemName = pipeIdx >= 0 ? item.slice(0, pipeIdx).trim() : item.trim();
- const itemUrl = pipeIdx >= 0 ? item.slice(pipeIdx + 1).trim() : '';
- if (!itemName) return '';
- const key = normalize(itemName);
- if (seen.has(key)) return '';
- seen.add(key);
- if (/^(unbekannt|unknown|n\/a|keine)$/i.test(itemName)) return '';
- // Wenn URL direkt mitgeliefert wurde: eindeutiger Link, keine Kollision mit sources_json moeglich
- if (itemUrl) {
- return buildPill({ name: itemName, url: itemUrl }, itemName);
- }
- // Fallback (Legacy-Bullets ohne URL): Name-Lookup in sources_json
- const src = lookupByName(itemName);
- return buildPill(src, itemName);
- }).filter(Boolean).join('');
- }
-
- // Fallback: Inline-[N]-Citations (Legacy-Recherche-Format)
- if (!pillsHtml) {
- const nums = [];
- let cm;
- while ((cm = citationRe.exec(rawBody)) !== null) {
- if (!nums.includes(cm[1])) nums.push(cm[1]);
- }
- citationRe.lastIndex = 0;
- if (nums.length > 0) {
- rawBody = rawBody.replace(citationRe, '').replace(/\s+/g, ' ').trim();
- pillsHtml = nums.map(num => {
- const src = lookupByNum(num);
- return src ? buildPill(src, src.name || `Quelle ${num}`) : '';
- }).filter(Boolean).join('');
- }
- }
-
- const cleanBody = this.escape(rawBody.trim());
- const sourcesHtml = pillsHtml ? `${pillsHtml}` : '';
- const timeHtml = `${this.escape(time)} \u00b7 ${this.escape(date)}`;
-
- return `${sourcesHtml}${timeHtml}
${cleanBody}
`;
- });
-
- return `${cards.join('')}
`;
- },
-
-
- renderSummary(summary, sourcesJson, incidentType) {
- if (!summary) return 'Noch keine Zusammenfassung.';
-
- const sources = this._parseSources(sourcesJson);
-
- // Markdown-Rendering
- let html = this.escape(summary);
-
- // ## Überschriften
- html = html.replace(/^## (.+)$/gm, '$1
');
- // **Fettdruck**
- html = html.replace(/\*\*(.+?)\*\*/g, '$1');
- // Listen (- Item)
- html = html.replace(/^- (.+)$/gm, '$1');
- html = html.replace(/(
.*<\/li>\n?)+/gs, '');
- // Zeilenumbrüche (aber nicht nach Headings/Listen)
- html = html.replace(/\n(?!<)/g, '
');
- // Überflüssige
nach Block-Elementen entfernen + doppelte
zusammenfassen
- html = html.replace(/<\/h3>(
)+/g, '');
- html = html.replace(/<\/ul>(
)+/g, '');
- html = html.replace(/(
){2,}/g, '
');
-
- // Markdown-Tabellen rendern
- html = html.replace(/(?:^|
)((?:\|.+\|(?:
|$))+)/g, function(match, tableBlock) {
- var rows = tableBlock.split('
').filter(function(r) { return r.trim().length > 0; });
- if (rows.length < 2) return match;
- var isSep = function(r) { return /^\|[\s\-:|]+\|$/.test(r.trim()); };
- if (!isSep(rows[1])) return match;
- var parseRow = function(r) { return r.split('|').slice(1, -1).map(function(c) { return c.trim(); }); };
- var headerCells = parseRow(rows[0]);
- var thead = '' + headerCells.map(function(c) { return '| ' + c + ' | '; }).join('') + '
';
- var tbody = '' + rows.slice(2).map(function(r) {
- if (isSep(r)) return '';
- var cells = parseRow(r);
- return '' + cells.map(function(c) { return '| ' + c + ' | '; }).join('') + '
';
- }).join('') + '';
- return '';
- });
-
- // Inline-Zitate [1], [2], [1383a] etc. als klickbare Links rendern
- if (sources.length > 0) {
- html = html.replace(/\[(\d+[a-z]?)\]/g, (match, num) => {
- // Exakte Suche (auch mit Buchstaben-Suffix)
- let src = sources.find(s => String(s.nr) === num || Number(s.nr) === Number(num));
- // Fallback: Bei Suffix wie "1383a" auf Basisnummer 1383 zurueckfallen
- if ((!src || !src.url) && /[a-z]$/.test(num)) {
- const baseNum = num.replace(/[a-z]$/, '');
- const baseSrc = sources.find(s => String(s.nr) === baseNum || Number(s.nr) === Number(baseNum));
- if (baseSrc && baseSrc.url) src = baseSrc;
- }
- if (src && src.url) {
- return `[${num}]`;
- }
- return match;
- });
- }
-
- return `${html}
`;
- },
-
- /**
- * Quellenübersicht für eine Lage rendern.
- */
- /**
- * Quellenuebersicht aus Aggregat-Endpunkt rendern (alle Artikel der Lage,
- * unabhaengig von Paginierung im Frontend).
- * data: {total, sources: [{source, article_count, languages: []}], language_counts: [{language, cnt}]}
- */
- renderSourceOverviewFromSummary(data) {
- if (!data || !data.sources || data.sources.length === 0) return '';
-
- const langChips = (data.language_counts || [])
- .map(l => `${(l.language || 'de').toUpperCase()} ${l.cnt}`)
- .join('');
-
- let html = ``;
-
- html += '';
- data.sources.forEach(s => {
- const langs = (s.languages || ['de']).map(l => (l || 'de').toUpperCase()).join('/');
- const sourceName = this.escape(s.source || 'Unbekannt');
- html += `
- ${sourceName}
- ${langs}
- ${s.article_count}
-
`;
- });
- html += '
';
-
- return html;
- },
-
- renderSourceOverview(articles) {
- if (!articles || articles.length === 0) return '';
-
- // Nach Quelle aggregieren
- const sourceMap = {};
- articles.forEach(a => {
- const name = a.source || 'Unbekannt';
- if (!sourceMap[name]) {
- sourceMap[name] = { count: 0, languages: new Set(), urls: [] };
- }
- sourceMap[name].count++;
- sourceMap[name].languages.add(a.language || 'de');
- if (a.source_url) sourceMap[name].urls.push(a.source_url);
- });
-
- const sources = Object.entries(sourceMap)
- .sort((a, b) => b[1].count - a[1].count);
-
- // Sprach-Statistik
- const langCount = {};
- articles.forEach(a => {
- const lang = (a.language || 'de').toUpperCase();
- langCount[lang] = (langCount[lang] || 0) + 1;
- });
-
- const langChips = Object.entries(langCount)
- .sort((a, b) => b[1] - a[1])
- .map(([lang, count]) => `${lang} ${count}`)
- .join('');
-
- let html = ``;
-
- html += '';
- sources.forEach(([name, data]) => {
- const langs = [...data.languages].map(l => l.toUpperCase()).join('/');
- html += `
- ${this.escape(name)}
- ${langs}
- ${data.count}
-
`;
- });
- html += '
';
-
- return html;
- },
-
- /**
- * Kategorie-Labels.
- */
- _categoryLabels: {
- 'nachrichtenagentur': 'Agentur',
- 'oeffentlich-rechtlich': 'ÖR',
- 'qualitaetszeitung': 'Qualität',
- 'behoerde': 'Behörde',
- 'fachmedien': 'Fach',
- 'think-tank': 'Think Tank',
- 'international': 'Intl.',
- 'regional': 'Regional',
- 'boulevard': 'Boulevard',
- 'telegram': 'Telegram',
- 'sonstige': 'Sonstige',
- },
-
- _politicalLabels: {
- links_extrem: { short: 'L+', full: 'Links (extrem)' },
- links: { short: 'L', full: 'Links' },
- mitte_links: { short: 'ML', full: 'Mitte-Links' },
- liberal: { short: 'LIB', full: 'Liberal' },
- mitte: { short: 'M', full: 'Mitte' },
- konservativ: { short: 'KON', full: 'Konservativ' },
- mitte_rechts: { short: 'MR', full: 'Mitte-Rechts' },
- rechts: { short: 'R', full: 'Rechts' },
- rechts_extrem: { short: 'R+', full: 'Rechts (extrem)' },
- na: { short: '?', full: 'Nicht eingeordnet' },
- },
- _reliabilityLabels: {
- sehr_hoch: 'Sehr hoch',
- hoch: 'Hoch',
- gemischt: 'Gemischt',
- niedrig: 'Niedrig',
- sehr_niedrig: 'Sehr niedrig',
- na: 'Nicht eingeordnet',
- },
- _mediaTypeLabels: {
- tageszeitung: 'Tageszeitung',
- wochenzeitung: 'Wochenzeitung',
- magazin: 'Magazin',
- tv_sender: 'TV-Sender',
- radio: 'Radio',
- oeffentlich_rechtlich: 'Öffentlich-Rechtlich',
- nachrichtenagentur: 'Nachrichtenagentur',
- online_only: 'Online-only',
- blog: 'Blog',
- telegram_kanal: 'Telegram-Kanal',
- telegram_bot: 'Telegram-Bot',
- podcast: 'Podcast',
- social_media: 'Social Media',
- imageboard: 'Imageboard',
- think_tank: 'Think Tank',
- ngo: 'NGO',
- behoerde: 'Behörde',
- staatsmedium: 'Staatsmedium',
- fachmedium: 'Fachmedium',
- sonstige: 'Sonstige',
- },
- _alignmentLabels: {
- prorussisch: 'prorussisch',
- proiranisch: 'proiranisch',
- prowestlich: 'prowestlich',
- proukrainisch: 'proukrainisch',
- prochinesisch: 'prochinesisch',
- projapanisch: 'projapanisch',
- proisraelisch: 'proisraelisch',
- propalaestinensisch: 'propalästinensisch',
- protuerkisch: 'protürkisch',
- panarabisch: 'panarabisch',
- neutral: 'neutral',
- sonstige: 'sonstige',
- },
-
- _renderClassificationBadges(feed) {
- const parts = [];
- const pol = feed.political_orientation;
- if (pol && pol !== 'na') {
- const label = this._politicalLabels[pol] || { short: pol, full: pol };
- parts.push(`${this.escape(label.short)}`);
- }
- const rel = feed.reliability;
- if (rel && rel !== 'na') {
- const relLabel = this._reliabilityLabels[rel] || rel;
- const relSource = feed.ifcn_signatory ? '(IFCN-Faktenchecker)'
- : (feed.eu_disinfo_listed ? `(EU-Desinfo, ${feed.eu_disinfo_case_count || 0} Fälle)`
- : '(LLM-Schätzung)');
- const relTitle = `Glaubwürdigkeit: ${relLabel} ${relSource}`;
- parts.push(``);
- }
- if (feed.ifcn_signatory) {
- parts.push(`✓ IFCN`);
- }
- if (feed.eu_disinfo_listed) {
- const cnt = feed.eu_disinfo_case_count || 0;
- const title = `EUvsDisinfo: ${cnt} dokumentierte Desinformations-Fälle`;
- parts.push(`⚠ EU-Desinfo (${cnt})`);
- }
- if (feed.state_affiliated) {
- parts.push(`⚑`);
- }
- const aligns = Array.isArray(feed.alignments) ? feed.alignments : [];
- aligns.forEach(a => {
- const label = this._alignmentLabels[a] || a;
- parts.push(`${this.escape(label)}`);
- });
- return parts.join('');
- },
-
- /**
- * Domain-Gruppe rendern (aufklappbar mit Feeds).
- */
- renderSourceGroup(domain, feeds, isExcluded, excludedNotes, isGlobal) {
- const catLabel = this._categoryLabels[feeds[0]?.category] || feeds[0]?.category || '';
- const feedCount = feeds.filter(f => f.source_type !== 'excluded').length;
- const hasMultiple = feedCount > 1;
- const displayName = (domain && !domain.startsWith('_single_')) ? domain : (feeds[0]?.name || 'Unbekannt');
- const escapedDomain = this.escape(domain);
-
- if (isExcluded) {
- // Ausgeschlossene Domain
- const notesHtml = excludedNotes ? ` ${this.escape(excludedNotes)}` : '';
- return `
-
-
`;
- }
-
- // Aktive Domain-Gruppe
- const toggleAttr = hasMultiple ? `onclick="App.toggleGroup('${escapedDomain}')" role="button" tabindex="0" aria-expanded="false"` : '';
- const toggleIcon = hasMultiple ? '▶' : '';
-
- let feedRows = '';
- if (hasMultiple) {
- const realFeeds = feeds.filter(f => f.source_type !== 'excluded');
- feedRows = ``;
- realFeeds.forEach((feed, i) => {
- const isLast = i === realFeeds.length - 1;
- const connector = isLast ? '\u2514\u2500' : '\u251C\u2500';
- const typeLabel = feed.source_type === 'rss_feed' ? 'RSS' : 'Web';
- const urlDisplay = feed.url ? this._shortenUrl(feed.url) : '';
- feedRows += `
- ${connector}
- ${this.escape(feed.name)}
- ${typeLabel}
- ${this.escape(urlDisplay)}
- ${!feed.is_global ? `
- ` : 'Grundquelle'}
-
`;
- });
- feedRows += '
';
- }
-
- const feedCountBadge = feedCount > 0
- ? `${feedCount} Feed${feedCount !== 1 ? 's' : ''}`
- : '';
-
- // Info-Button mit Tooltip (Typ, Sprache, Ausrichtung, Klassifikation)
- let infoButtonHtml = '';
- const firstFeed = feeds[0] || {};
- const hasInfo = firstFeed.language || firstFeed.bias
- || (firstFeed.political_orientation && firstFeed.political_orientation !== 'na')
- || (firstFeed.media_type && firstFeed.media_type !== 'sonstige')
- || (firstFeed.reliability && firstFeed.reliability !== 'na')
- || firstFeed.state_affiliated
- || firstFeed.country_code
- || (Array.isArray(firstFeed.alignments) && firstFeed.alignments.length > 0);
- if (hasInfo) {
- const typeMap = { rss_feed: 'RSS-Feed', web_source: 'Web-Quelle', telegram_channel: 'Telegram-Kanal', podcast_feed: 'Podcast' };
- const lines = [];
- lines.push('Typ: ' + (typeMap[firstFeed.source_type] || firstFeed.source_type || 'Unbekannt'));
- if (firstFeed.language) lines.push('Sprache: ' + firstFeed.language);
- if (firstFeed.country_code) lines.push('Land: ' + firstFeed.country_code);
- if (firstFeed.media_type && firstFeed.media_type !== 'sonstige') {
- lines.push('Medientyp: ' + (this._mediaTypeLabels[firstFeed.media_type] || firstFeed.media_type));
- }
- if (firstFeed.political_orientation && firstFeed.political_orientation !== 'na') {
- const pl = this._politicalLabels[firstFeed.political_orientation];
- lines.push('Politisch: ' + (pl ? pl.full : firstFeed.political_orientation));
- }
- if (firstFeed.reliability && firstFeed.reliability !== 'na') {
- const relLabel = this._reliabilityLabels[firstFeed.reliability] || firstFeed.reliability;
- const relSrc = firstFeed.ifcn_signatory ? ' (IFCN-Faktenchecker)'
- : (firstFeed.eu_disinfo_listed ? ` (EU-Desinfo, ${firstFeed.eu_disinfo_case_count || 0} Fälle)`
- : ' (LLM-Schätzung)');
- lines.push('Glaubwürdigkeit: ' + relLabel + relSrc);
- }
- if (firstFeed.ifcn_signatory) lines.push('IFCN-Faktenchecker: ja');
- if (firstFeed.eu_disinfo_listed) {
- lines.push(`EUvsDisinfo: ${firstFeed.eu_disinfo_case_count || 0} Fälle` + (firstFeed.eu_disinfo_last_seen ? ` (zuletzt ${firstFeed.eu_disinfo_last_seen})` : ''));
- }
- if (firstFeed.state_affiliated) lines.push('Staatsnah: ja');
- if (Array.isArray(firstFeed.alignments) && firstFeed.alignments.length > 0) {
- const labels = firstFeed.alignments.map(a => this._alignmentLabels[a] || a);
- lines.push('Geopolitische Nähe: ' + labels.join(', '));
- }
- if (firstFeed.bias) lines.push('Notiz: ' + firstFeed.bias);
- const tooltipText = this.escape(lines.join('\n'));
- infoButtonHtml = ` `;
- }
-
- const classificationBadges = this._renderClassificationBadges(firstFeed);
-
- return `
-
- ${feedRows}
-
`;
- },
-
- /**
- * URL kürzen für die Anzeige in Feed-Zeilen.
- */
- _shortenUrl(url) {
- try {
- const u = new URL(url);
- let path = u.pathname;
- if (path.length > 40) path = path.substring(0, 37) + '...';
- return u.hostname + path;
- } catch {
- return url.length > 50 ? url.substring(0, 47) + '...' : url;
- }
- },
- /**
- * Leaflet-Karte mit Locations rendern.
- */
- _map: null,
- _mapCluster: null,
- _mapCategoryLayers: {},
- _mapLegendControl: null,
-
- _pendingLocations: null,
-
- // Farbige Marker-Icons nach Kategorie (inline SVG, keine externen Ressourcen)
- _markerIcons: null,
- _createSvgIcon(fillColor, strokeColor) {
- const svg = ``;
- return L.divIcon({
- html: svg,
- className: 'map-marker-svg',
- iconSize: [28, 42],
- iconAnchor: [14, 42],
- popupAnchor: [0, -36],
- });
- },
- _initMarkerIcons() {
- if (this._markerIcons || typeof L === 'undefined') return;
- this._markerIcons = {
- primary: this._createSvgIcon('#dc3545', '#a71d2a'),
- secondary: this._createSvgIcon('#f39c12', '#c47d0a'),
- tertiary: this._createSvgIcon('#2a81cb', '#1a5c8f'),
- mentioned: this._createSvgIcon('#7b7b7b', '#555555'),
- };
- },
-
- _defaultCategoryLabels: {
- primary: 'Hauptgeschehen',
- secondary: 'Reaktionen',
- tertiary: 'Beteiligte',
- mentioned: 'Erwaehnt',
- },
- _categoryColors: {
- primary: '#cb2b3e',
- secondary: '#f39c12',
- tertiary: '#2a81cb',
- mentioned: '#7b7b7b',
- },
-
- _activeCategoryLabels: null,
-
- renderMap(locations, categoryLabels) {
- const container = document.getElementById('map-container');
- const emptyEl = document.getElementById('map-empty');
- const statsEl = document.getElementById('map-stats');
- if (!container) return;
-
- // Leaflet noch nicht geladen? Locations merken und spaeter rendern
- if (typeof L === 'undefined') {
- this._pendingLocations = locations;
- // Statistik trotzdem anzeigen
- if (locations && locations.length > 0) {
- const totalArticles = locations.reduce((s, l) => s + l.article_count, 0);
- if (statsEl) statsEl.textContent = `${locations.length} Orte / ${totalArticles} Artikel`;
- if (emptyEl) emptyEl.style.display = 'none';
- }
- return;
- }
-
- if (!locations || locations.length === 0) {
- if (emptyEl) emptyEl.style.display = 'flex';
- if (statsEl) statsEl.textContent = '';
- if (this._map) {
- this._map.remove();
- this._map = null;
- this._mapCluster = null;
- }
- return;
- }
-
- if (emptyEl) emptyEl.style.display = 'none';
-
- // Statistik
- const totalArticles = locations.reduce((s, l) => s + l.article_count, 0);
- if (statsEl) statsEl.textContent = `${locations.length} Orte / ${totalArticles} Artikel`;
-
- // Container-Hoehe sicherstellen (Leaflet braucht px-Hoehe)
- const gsItem = container.closest('.grid-stack-item');
- if (gsItem) {
- const headerEl = container.closest('.map-card')?.querySelector('.card-header');
- const headerH = headerEl ? headerEl.offsetHeight : 40;
- const available = gsItem.offsetHeight - headerH - 4;
- container.style.height = Math.max(available, 200) + 'px';
- } else if (container.offsetHeight < 50) {
- container.style.height = '300px';
- }
-
- // Karte initialisieren oder updaten
- if (!this._map) {
- this._map = L.map(container, {
- zoomControl: true,
- attributionControl: true,
- minZoom: 2,
- maxBounds: [[-85, -180], [85, 180]],
- maxBoundsViscosity: 1.0,
- }).setView([51.1657, 10.4515], 5); // Deutschland-Zentrum
-
- this._applyMapTiles();
- this._mapCluster = L.markerClusterGroup({
- maxClusterRadius: 40,
- iconCreateFunction: function(cluster) {
- const count = cluster.getChildCount();
- let size = 'small';
- if (count >= 10) size = 'medium';
- if (count >= 50) size = 'large';
- return L.divIcon({
- html: '' + count + '
',
- className: 'map-cluster map-cluster-' + size,
- iconSize: L.point(40, 40),
- });
- },
- });
- this._map.addLayer(this._mapCluster);
- } else {
- this._mapCluster.clearLayers();
- this._mapCategoryLayers = {};
- }
-
- // Marker hinzufuegen
- const bounds = [];
- this._initMarkerIcons();
- // Dynamische Labels verwenden (API > Default)
- const catLabels = categoryLabels || this._activeCategoryLabels || this._defaultCategoryLabels;
- this._activeCategoryLabels = catLabels;
- const usedCategories = new Set();
-
- locations.forEach(loc => {
- const cat = loc.category || 'mentioned';
- usedCategories.add(cat);
- const icon = (this._markerIcons && this._markerIcons[cat]) ? this._markerIcons[cat] : undefined;
- const markerOpts = icon ? { icon } : {};
- const marker = L.marker([loc.lat, loc.lon], markerOpts);
-
- // Popup-Inhalt
- const catLabel = catLabels[cat] || this._defaultCategoryLabels[cat] || cat;
- const catColor = this._categoryColors[cat] || '#7b7b7b';
- let popupHtml = ``;
-
- marker.bindPopup(popupHtml, { maxWidth: 300, className: 'map-popup-container' });
- if (!this._mapCategoryLayers[cat]) this._mapCategoryLayers[cat] = L.featureGroup();
- this._mapCategoryLayers[cat].addLayer(marker);
- this._mapCluster.addLayer(marker);
- bounds.push([loc.lat, loc.lon]);
- });
-
- // Ansicht auf Marker zentrieren
- if (bounds.length > 0) {
- if (bounds.length === 1) {
- this._map.setView(bounds[0], 8);
- } else {
- this._map.fitBounds(bounds, { padding: [30, 30], maxZoom: 12 });
- }
- }
-
- // Legende mit Checkbox-Filter
- if (this._map) {
- const existingLegend = document.querySelector('.map-legend-ctrl');
- if (existingLegend) existingLegend.remove();
- if (this._mapLegendControl) {
- try { this._map.removeControl(this._mapLegendControl); } catch(e) {}
- }
-
- const legend = L.control({ position: 'bottomright' });
- const self2 = this;
- const legendLabels = catLabels;
- legend.onAdd = function() {
- const div = L.DomUtil.create('div', 'map-legend-ctrl');
- L.DomEvent.disableClickPropagation(div);
- let html = 'Filter';
- ['primary', 'secondary', 'tertiary', 'mentioned'].forEach(cat => {
- if (usedCategories.has(cat) && legendLabels[cat]) {
- html += '';
- }
- });
- div.innerHTML = html;
- div.addEventListener('change', function(e) {
- const cb = e.target;
- if (!cb.dataset.mapCat) return;
- self2._toggleMapCategory(cb.dataset.mapCat, cb.checked);
- });
- return div;
- };
- legend.addTo(this._map);
- this._mapLegendControl = legend;
- }
-
- // Resize-Fix fuer gridstack (mehrere Versuche, da Container-Hoehe erst spaeter steht)
- const self = this;
- [100, 300, 800].forEach(delay => {
- setTimeout(() => {
- if (!self._map) return;
- self._map.invalidateSize();
- if (bounds.length === 1) {
- self._map.setView(bounds[0], 8);
- } else if (bounds.length > 1) {
- self._map.fitBounds(bounds, { padding: [30, 30], maxZoom: 12 });
- }
- }, delay);
- });
- },
-
- _applyMapTiles() {
- if (!this._map) return;
- // Alte Tile-Layer entfernen
- this._map.eachLayer(layer => {
- if (layer instanceof L.TileLayer) this._map.removeLayer(layer);
- });
-
- // Deutsche OSM-Kacheln: deutsche Ortsnamen, einheitlich fuer beide Themes
- const tileUrl = 'https://tile.openstreetmap.de/{z}/{x}/{y}.png';
- const attribution = '© OpenStreetMap';
-
- L.tileLayer(tileUrl, { attribution, maxZoom: 18, noWrap: true }).addTo(this._map);
- },
-
- updateMapTheme() {
- this._applyMapTiles();
- },
-
- invalidateMap() {
- if (this._map) this._map.invalidateSize();
- },
-
- retryPendingMap() {
- if (this._pendingLocations && typeof L !== 'undefined') {
- const locs = this._pendingLocations;
- this._pendingLocations = null;
- this.renderMap(locs, this._activeCategoryLabels);
- }
- },
-
- _mapFullscreen: false,
- _mapOriginalParent: null,
-
- toggleMapFullscreen() {
- const overlay = document.getElementById('map-fullscreen-overlay');
- const fsContainer = document.getElementById('map-fullscreen-container');
- const mapContainer = document.getElementById('map-container');
- const statsEl = document.getElementById('map-stats');
- const fsStatsEl = document.getElementById('map-fullscreen-stats');
-
- if (!this._mapFullscreen) {
- // Save original parent and height
- this._mapOriginalParent = mapContainer.parentElement;
- this._savedMapHeight = mapContainer.style.height || mapContainer.offsetHeight + 'px';
-
- // Move entire map-container into fullscreen overlay
- fsContainer.appendChild(mapContainer);
- mapContainer.style.height = '100%';
-
- if (statsEl && fsStatsEl) {
- fsStatsEl.textContent = statsEl.textContent;
- }
- overlay.classList.add('active');
- this._mapFullscreen = true;
-
- // Escape key to close
- this._mapFsKeyHandler = (e) => { if (e.key === 'Escape') this.toggleMapFullscreen(); };
- document.addEventListener('keydown', this._mapFsKeyHandler);
-
- setTimeout(() => { if (this._map) this._map.invalidateSize(); }, 100);
- } else {
- // Exit fullscreen: move map-container back to original parent
- overlay.classList.remove('active');
- if (this._mapOriginalParent) {
- this._mapOriginalParent.appendChild(mapContainer);
- }
- // Restore saved height
- mapContainer.style.height = this._savedMapHeight || '';
-
- this._mapFullscreen = false;
- if (this._mapFsKeyHandler) {
- document.removeEventListener('keydown', this._mapFsKeyHandler);
- this._mapFsKeyHandler = null;
- }
-
- const self = this;
- [100, 300, 600].forEach(delay => {
- setTimeout(() => { if (self._map) self._map.invalidateSize(); }, delay);
- });
- }
- },
-
- _mapFsKeyHandler: null,
-
- _toggleMapCategory(cat, visible) {
- const layers = this._mapCategoryLayers[cat];
- if (!layers || !this._mapCluster) return;
- layers.eachLayer(marker => {
- if (visible) {
- this._mapCluster.addLayer(marker);
- } else {
- this._mapCluster.removeLayer(marker);
- }
- });
- },
-
- /**
- * HTML escapen.
- */
- escape(str) {
- if (!str) return '';
- const div = document.createElement('div');
- div.textContent = str;
- return div.innerHTML;
- },
-};
+/**
+ * Parst einen Zeitstring vom Server in ein Date-Objekt.
+ * Timestamps mit 'Z' oder '+' werden direkt geparst (echtes UTC/Offset).
+ * Timestamps ohne Zeitzonen-Info werden als Europe/Berlin interpretiert,
+ * da die DB alle Zeiten in Lokalzeit speichert.
+ */
+function parseUTC(dateStr) {
+ if (!dateStr) return null;
+ try {
+ if (dateStr.endsWith('Z') || dateStr.includes('+')) {
+ const d = new Date(dateStr);
+ return isNaN(d.getTime()) ? null : d;
+ }
+ // DB-Timestamps sind Europe/Berlin Lokalzeit.
+ // Aktuellen Berlin-UTC-Offset ermitteln und anwenden.
+ const normalized = dateStr.replace(' ', 'T');
+ const naive = new Date(normalized + 'Z'); // als UTC parsen
+ if (isNaN(naive.getTime())) return null;
+ // Berlin-Offset fuer diesen Zeitpunkt bestimmen
+ const berlinStr = naive.toLocaleString('sv-SE', { timeZone: 'Europe/Berlin' });
+ const berlinAsUTC = new Date(berlinStr.replace(' ', 'T') + 'Z');
+ const offsetMs = naive.getTime() - berlinAsUTC.getTime();
+ const d = new Date(naive.getTime() + offsetMs);
+ return isNaN(d.getTime()) ? null : d;
+ } catch (e) {
+ return null;
+ }
+}
+
+/**
+ * UI-Komponenten für das Dashboard.
+ */
+const UI = {
+ /**
+ * Sidebar-Eintrag für eine Lage rendern.
+ */
+ renderIncidentItem(incident, isActive) {
+ const isRefreshing = App._refreshingIncidents && App._refreshingIncidents.has(incident.id);
+ const dotClass = isRefreshing ? 'refreshing' : (incident.status === 'active' ? 'active' : 'archived');
+ const activeClass = isActive ? 'active' : '';
+ const creator = (incident.created_by_username || '').split('@')[0];
+
+ // Determine refresh status for sidebar display
+ let refreshClass = '';
+ let refreshStatusHtml = '';
+ if (isRefreshing) {
+ const state = this._progressState[incident.id];
+ const step = state ? state.step : 'researching';
+ const isQueued = (step === 'queued');
+
+ if (isQueued) {
+ refreshClass = ' queued-item';
+ const pos = state && state._queuePos ? ' (#' + state._queuePos + ')' : '';
+ refreshStatusHtml = '';
+ } else {
+ refreshClass = ' refreshing-item';
+ const label = this._getStepLabel(step);
+ refreshStatusHtml = '';
+ }
+ }
+
+ return `
+
+
+
+
${this.escape(incident.title)}
+
${incident.article_count} Artikel · ${this.escape(creator)}
+ ${refreshStatusHtml}
+
+ ${incident.visibility === 'private' ? '
PRIVAT' : ''}
+ ${incident.refresh_mode === 'auto' ? '
↻' : ''}
+
+ `;
+ },
+
+ /**
+ * Faktencheck-Eintrag rendern.
+ */
+ // Faktencheck-Status-Labels (org-sprach-relativ via T()).
+ // Die DE-Fallbacks sind die historische Quelle der Wahrheit; bei
+ // englischer Org liefert T() den EN-Text aus i18n/en.json.
+ _fcLabelDefaultsDE: {
+ confirmed: 'Bestätigt durch mehrere Quellen',
+ unconfirmed: 'Nicht unabhängig bestätigt',
+ contradicted: 'Widerlegt',
+ developing: 'Faktenlage noch im Fluss',
+ established: 'Gesicherter Fakt (3+ Quellen)',
+ disputed: 'Umstrittener Sachverhalt',
+ unverified: 'Nicht unabhängig verifizierbar',
+ },
+ _fcTooltipDefaultsDE: {
+ confirmed: 'Bestätigt: Mindestens zwei unabhängige, seriöse Quellen stützen diese Aussage übereinstimmend.',
+ established: 'Gesichert: Drei oder mehr unabhängige Quellen bestätigen den Sachverhalt. Hohe Verlässlichkeit.',
+ developing: 'Unklar: Die Faktenlage ist noch im Fluss. Neue Informationen können das Bild verändern.',
+ unconfirmed: 'Unbestätigt: Bisher nur aus einer Quelle bekannt. Eine unabhängige Bestätigung steht aus.',
+ unverified: 'Ungeprüft: Die Aussage konnte bisher nicht anhand verfügbarer Quellen überprüft werden.',
+ disputed: 'Umstritten: Quellen widersprechen sich. Es gibt sowohl stützende als auch widersprechende Belege.',
+ contradicted: 'Widerlegt: Zuverlässige Quellen widersprechen dieser Aussage. Wahrscheinlich falsch.',
+ },
+ _fcChipDefaultsDE: {
+ confirmed: 'Bestätigt',
+ unconfirmed: 'Unbestätigt',
+ contradicted: 'Widerlegt',
+ developing: 'Unklar',
+ established: 'Gesichert',
+ disputed: 'Umstritten',
+ unverified: 'Ungeprüft',
+ },
+
+ get factCheckLabels() {
+ const out = {};
+ for (const k of Object.keys(this._fcLabelDefaultsDE)) {
+ out[k] = (typeof T === 'function')
+ ? T('fc.label.' + k, this._fcLabelDefaultsDE[k])
+ : this._fcLabelDefaultsDE[k];
+ }
+ return out;
+ },
+ get factCheckTooltips() {
+ const out = {};
+ for (const k of Object.keys(this._fcTooltipDefaultsDE)) {
+ out[k] = (typeof T === 'function')
+ ? T('fc.tooltip.' + k, this._fcTooltipDefaultsDE[k])
+ : this._fcTooltipDefaultsDE[k];
+ }
+ return out;
+ },
+ get factCheckChipLabels() {
+ const out = {};
+ for (const k of Object.keys(this._fcChipDefaultsDE)) {
+ out[k] = (typeof T === 'function')
+ ? T('fc.chip.' + k, this._fcChipDefaultsDE[k])
+ : this._fcChipDefaultsDE[k];
+ }
+ return out;
+ },
+
+ factCheckIcons: {
+ confirmed: '✓',
+ unconfirmed: '?',
+ contradicted: '✗',
+ developing: '↻',
+ established: '✓',
+ disputed: '⚠',
+ unverified: '?',
+ },
+
+ /**
+ * Faktencheck-Filterleiste rendern.
+ */
+ renderFactCheckFilters(factchecks) {
+ // Welche Stati kommen tatsächlich vor + Zähler
+ const statusCounts = {};
+ factchecks.forEach(fc => {
+ statusCounts[fc.status] = (statusCounts[fc.status] || 0) + 1;
+ });
+ const statusOrder = ['confirmed', 'established', 'developing', 'unconfirmed', 'unverified', 'disputed', 'contradicted'];
+ const usedStatuses = statusOrder.filter(s => statusCounts[s]);
+ if (usedStatuses.length <= 1) return '';
+
+ const items = usedStatuses.map(status => {
+ const icon = this.factCheckIcons[status] || '?';
+ const chipLabel = this.factCheckChipLabels[status] || status;
+ const tooltip = this.factCheckTooltips[status] || '';
+ const count = statusCounts[status];
+ return ``;
+ }).join('');
+
+ return `
+ `;
+ },
+
+ renderFactCheck(fc) {
+ const urls = (fc.evidence || '').match(/https?:\/\/[^\s,)]+/g) || [];
+ const count = urls.length;
+ return `
+
+
${this.factCheckIcons[fc.status] || '?'}
+
${this.factCheckLabels[fc.status] || fc.status}
+
+
${this.escape(fc.claim)}
+
+ ${count} Quelle${count !== 1 ? 'n' : ''}
+
+
${this.renderEvidence(fc.evidence || '')}
+
+
+ `;
+ },
+
+ /**
+ * Evidence mit erklärenden Text UND Quellen-Chips rendern.
+ */
+ renderEvidence(text) {
+ if (!text) return 'Keine Belege';
+
+ const urls = text.match(/https?:\/\/[^\s,)]+/g) || [];
+ if (urls.length === 0) {
+ return `${this.escape(text)}`;
+ }
+
+ // Erklärenden Text extrahieren (URLs entfernen)
+ let explanation = text;
+ urls.forEach(url => { explanation = explanation.replace(url, '').trim(); });
+ // Aufräumen: Klammern, mehrfache Kommas/Leerzeichen
+ explanation = explanation.replace(/\(\s*\)/g, '');
+ explanation = explanation.replace(/,\s*,/g, ',');
+ explanation = explanation.replace(/\s+/g, ' ').trim();
+ explanation = explanation.replace(/[,.:;]+$/, '').trim();
+
+ // Chips für jede URL
+ const chips = urls.map(url => {
+ let label;
+ try { label = new URL(url).hostname.replace('www.', ''); } catch { label = url; }
+ return `${this.escape(label)}`;
+ }).join('');
+
+ const explanationHtml = explanation
+ ? `${this.escape(explanation)}`
+ : '';
+
+ return `${explanationHtml}${chips}
`;
+ },
+
+ /**
+ * Toast-Benachrichtigung anzeigen.
+ */
+ _toastTimers: new Map(),
+
+ showToast(message, type = 'info', duration = 5000) {
+ const container = document.getElementById('toast-container');
+
+ // Duplikat? Bestehenden Toast neu animieren
+ const existing = Array.from(container.children).find(
+ t => t.dataset.msg === message && t.dataset.type === type
+ );
+ if (existing) {
+ clearTimeout(this._toastTimers.get(existing));
+ // Kurz rausschieben, dann neu reingleiten
+ existing.style.transition = 'none';
+ existing.style.opacity = '0';
+ existing.style.transform = 'translateX(100%)';
+ void existing.offsetWidth; // Reflow erzwingen
+ existing.style.transition = 'all 0.3s ease';
+ existing.style.opacity = '1';
+ existing.style.transform = 'translateX(0)';
+ const timer = setTimeout(() => {
+ existing.style.opacity = '0';
+ existing.style.transform = 'translateX(100%)';
+ setTimeout(() => { existing.remove(); this._toastTimers.delete(existing); }, 300);
+ }, duration);
+ this._toastTimers.set(existing, timer);
+ return;
+ }
+
+ const toast = document.createElement('div');
+ toast.className = `toast toast-${type}`;
+ toast.setAttribute('role', 'status');
+ toast.dataset.msg = message;
+ toast.dataset.type = type;
+ toast.innerHTML = `${this.escape(message)}`;
+ container.appendChild(toast);
+
+ const timer = setTimeout(() => {
+ toast.style.opacity = '0';
+ toast.style.transform = 'translateX(100%)';
+ toast.style.transition = 'all 0.3s ease';
+ setTimeout(() => { toast.remove(); this._toastTimers.delete(toast); }, 300);
+ }, duration);
+ this._toastTimers.set(toast, timer);
+ },
+
+ _progressStartTime: null,
+ _progressTimer: null,
+
+ /**
+ * Fortschrittsanzeige einblenden und Status setzen.
+ */
+ // === Progress State (per-incident) ===
+ _progressState: {}, // { incidentId: { step, isFirst, startTime, minimized } }
+ _progressTimerInterval: null,
+
+ _getStepOrder() {
+ return ['queued', 'researching', 'deep_researching', 'analyzing', 'factchecking'];
+ },
+
+ _getStepLabel(step) {
+ const fallback = {
+ queued: 'In Warteschlange',
+ researching: 'Recherchiert...',
+ deep_researching: 'Tiefenrecherche...',
+ analyzing: 'Analysiert...',
+ factchecking: 'Faktencheck...',
+ cancelling: 'Wird abgebrochen...',
+ };
+ if (!fallback[step]) return step;
+ return (typeof T === 'function')
+ ? T('progress.status.' + step, fallback[step])
+ : fallback[step];
+ },
+
+ showProgress(status, extra = {}, incidentId = null, isFirstRefresh = false) {
+ if (!incidentId) incidentId = App.currentIncidentId;
+ if (!incidentId) return;
+
+ // Init state for this incident
+ if (!this._progressState[incidentId]) {
+ this._progressState[incidentId] = { step: 'queued', isFirst: isFirstRefresh, startTime: null, minimized: false };
+ }
+ const state = this._progressState[incidentId];
+ state.step = status;
+ if (isFirstRefresh) state.isFirst = true;
+
+ // Start timer on first non-queued status
+ if (status !== 'queued' && !state.startTime) {
+ if (extra.started_at) {
+ const serverStart = typeof parseUTC === 'function' ? parseUTC(extra.started_at) : new Date(extra.started_at);
+ state.startTime = serverStart ? serverStart.getTime() : Date.now();
+ } else {
+ state.startTime = Date.now();
+ }
+ }
+
+ // Start global timer interval if not running
+ if (!this._progressTimerInterval) {
+ this._progressTimerInterval = setInterval(() => this._tickProgressTimers(), 1000);
+ }
+
+ // Store queue position
+ if (status === 'queued' && extra.queue_position) {
+ state._queuePos = extra.queue_position;
+ }
+
+ // Update sidebar status for ALL incidents (not just current)
+ this._updateSidebarRefreshStatus(incidentId, status, extra);
+
+ // Only show popup/mini UI for current incident
+ if (incidentId !== App.currentIncidentId) return;
+
+
+ if (false) { // popup always shown initially
+ state.minimized = true;
+ }
+
+ if (state.minimized) {
+ this._showMiniProgress(status, state);
+ return;
+ }
+
+ this._showPopupProgress(status, extra, state);
+ },
+
+ _showPopupProgress(status, extra, state) {
+ const overlay = document.getElementById('progress-overlay');
+ const popup = document.getElementById('progress-popup');
+ if (!overlay || !popup) return;
+
+ overlay.style.display = 'flex';
+ this._initClickOutside();
+
+ // Blocking (no close) for first refresh
+ if (state.isFirst) {
+ overlay.classList.add('blocking');
+ // Apply blur to incident-view (Header + Tab-Panels gemeinsam).
+ const blurTarget = document.getElementById('incident-view');
+ if (blurTarget) {
+ blurTarget.classList.add('refresh-blurred');
+ // Sicherheitsnetz: bei viel DOM-Reshuffle im selben Tick
+ // (Display-Wechsel, renderSidebar, leere innerHTML) greift
+ // CSS filter:blur erst beim naechsten Layout-Pass. Im
+ // naechsten Frame nochmal setzen — idempotent.
+ requestAnimationFrame(() => {
+ if (state && state.isFirst) blurTarget.classList.add('refresh-blurred');
+ });
+ }
+ } else {
+ overlay.classList.remove('blocking');
+ }
+
+ // Minimize button: only for updates (not first)
+ const minBtn = document.getElementById('progress-popup-minimize');
+ if (minBtn) minBtn.style.display = state.isFirst ? 'none' : '';
+
+ // Title - haengt von Status ab (queued = wartet, cancelling = bricht ab, sonst laeuft)
+ const titleEl = document.getElementById('progress-popup-title');
+ if (titleEl) {
+ const _t = (k, fb) => (typeof T === 'function') ? T(k, fb) : fb;
+ let title;
+ if (status === 'queued') {
+ const pos = (state && state._queuePos) ? ' (#' + state._queuePos + ')' : '';
+ title = _t('progress.title.queued', 'In Warteschlange') + pos;
+ } else if (status === 'cancelling') {
+ title = _t('progress.title.cancelling', 'Wird abgebrochen\u2026');
+ } else if (state.isFirst) {
+ title = _t('progress.title.first_refresh', 'Erste Recherche l\u00e4uft');
+ } else {
+ title = _t('progress.title.refresh', 'Aktualisierung l\u00e4uft');
+ }
+ titleEl.textContent = title;
+ }
+
+ // Multi-pass info
+ const passEl = document.getElementById('progress-popup-pass');
+ if (passEl) {
+ if (extra.research_pass && extra.research_total_passes) {
+ passEl.textContent = 'Durchlauf ' + extra.research_pass + '/' + extra.research_total_passes;
+ passEl.style.display = '';
+ } else {
+ passEl.style.display = 'none';
+ }
+ }
+
+ // Update checklist
+ const stepOrder = this._getStepOrder();
+ const currentIdx = stepOrder.indexOf(status === 'deep_researching' ? 'researching' : status);
+ const items = document.querySelectorAll('.progress-check-item');
+ // Map checklist items to step indices: queued=0, researching=1, analyzing=3, factchecking=4
+ const checkStepMap = { queued: 0, researching: 1, analyzing: 3, factchecking: 4 };
+
+ items.forEach(item => {
+ const step = item.dataset.step;
+ const stepIdx = checkStepMap[step] !== undefined ? checkStepMap[step] : -1;
+ const icon = item.querySelector('.progress-check-icon');
+ const detail = item.querySelector('.progress-check-detail');
+
+ item.classList.remove('active', 'done', 'error');
+
+ if (stepIdx < currentIdx || (step === 'queued' && currentIdx > 0)) {
+ item.classList.add('done');
+ if (icon) icon.innerHTML = '\u2713';
+ } else if (stepIdx === currentIdx || (step === 'researching' && (status === 'researching' || status === 'deep_researching'))) {
+ item.classList.add('active');
+ if (icon) icon.innerHTML = '';
+ if (detail && extra.detail) detail.textContent = extra.detail;
+ else if (detail) detail.textContent = '';
+ } else {
+ if (icon) icon.innerHTML = '\u25cb';
+ if (detail) detail.textContent = '';
+ }
+ });
+
+ // Cancel button
+ const cancelBtn = document.getElementById('progress-cancel-btn');
+ if (cancelBtn) {
+ cancelBtn.style.display = '';
+ cancelBtn.textContent = 'Abbrechen';
+ cancelBtn.disabled = false;
+ }
+
+ // Hide complete summary
+ const summaryEl = document.getElementById('progress-complete-summary');
+ if (summaryEl) summaryEl.style.display = 'none';
+
+ // Hide mini bar
+ const mini = document.getElementById('progress-mini');
+ if (mini) mini.style.display = 'none';
+
+ // Lock action buttons during first refresh
+ this._lockActionsIfFirst(state.isFirst);
+ },
+
+ _lockActionsIfFirst(isFirst) {
+ const actions = document.querySelector('.incident-header-actions');
+ if (!actions) return;
+ if (isFirst) {
+ actions.classList.add('first-refresh-locked');
+ } else {
+ actions.classList.remove('first-refresh-locked');
+ }
+ },
+
+ _showMiniProgress(status, state) {
+ const mini = document.getElementById('progress-mini');
+ if (!mini) return;
+ mini.style.display = 'flex';
+
+ const textEl = document.getElementById('progress-mini-text');
+ if (textEl) textEl.textContent = this._getStepLabel(status);
+
+ // Hide popup
+ const overlay = document.getElementById('progress-overlay');
+ if (overlay) overlay.style.display = 'none';
+ },
+
+ minimizeProgress(incidentId) {
+ if (!incidentId) incidentId = App.currentIncidentId;
+ const state = this._progressState[incidentId];
+ if (!state) return;
+ state.minimized = true;
+ state._userOpenedPopup = false;
+ this._showMiniProgress(state.step, state);
+ },
+
+ openProgressPopup(incidentId) {
+ if (!incidentId) incidentId = App.currentIncidentId;
+ const state = this._progressState[incidentId];
+ if (!state) return;
+ state.minimized = false;
+ state._userOpenedPopup = true;
+ this._showPopupProgress(state.step, {}, state);
+ },
+
+ showProgressComplete(data, incidentId) {
+ if (!incidentId) incidentId = App.currentIncidentId;
+ const state = this._progressState[incidentId];
+
+ // Calculate total time
+ let totalTimeStr = '';
+ if (state && state.startTime) {
+ const elapsed = Math.floor((Date.now() - state.startTime) / 1000);
+ const mins = Math.floor(elapsed / 60);
+ const secs = elapsed % 60;
+ totalTimeStr = mins + ':' + String(secs).padStart(2, '0');
+ }
+
+ if (incidentId === App.currentIncidentId) {
+ // Remove blur
+ const blurTarget = document.getElementById('incident-view');
+ if (blurTarget) blurTarget.classList.remove('refresh-blurred');
+
+ const overlay = document.getElementById('progress-overlay');
+ if (overlay) {
+ overlay.style.display = 'flex';
+ overlay.classList.remove('blocking');
+ }
+
+ // Mark all steps done
+ document.querySelectorAll('.progress-check-item').forEach(item => {
+ item.classList.remove('active', 'error');
+ item.classList.add('done');
+ const icon = item.querySelector('.progress-check-icon');
+ if (icon) icon.innerHTML = '\u2713';
+ });
+
+ // Show summary
+ const parts = [];
+ if (data.new_articles > 0) parts.push(data.new_articles + ' neue Artikel');
+ if (data.confirmed_count > 0) parts.push(data.confirmed_count + ' Fakten best\u00e4tigt');
+ if (data.contradicted_count > 0) parts.push(data.contradicted_count + ' widerlegt');
+ const summaryText = parts.length > 0 ? parts.join(', ') : 'Keine neuen Entwicklungen';
+
+ const summaryEl = document.getElementById('progress-complete-summary');
+ if (summaryEl) {
+ summaryEl.innerHTML = '\u2713 Abgeschlossen: ' + summaryText
+ + (totalTimeStr ? 'Gesamtzeit: ' + totalTimeStr + '' : '');
+ summaryEl.style.display = 'block';
+ }
+
+ // Update title
+ const titleEl = document.getElementById('progress-popup-title');
+ if (titleEl) titleEl.textContent = 'Abgeschlossen';
+
+ // Hide cancel, show minimize
+ const cancelBtn = document.getElementById('progress-cancel-btn');
+ if (cancelBtn) cancelBtn.style.display = 'none';
+ const minBtn = document.getElementById('progress-popup-minimize');
+ if (minBtn) minBtn.style.display = '';
+
+ // Hide mini bar
+ const mini = document.getElementById('progress-mini');
+ if (mini) mini.style.display = 'none';
+ }
+
+ // Remove sidebar refresh status
+ this._removeSidebarRefreshStatus(incidentId);
+
+ // Clean up state after delay
+ setTimeout(() => {
+ this.hideProgress(incidentId);
+ }, 5000);
+ },
+
+ showProgressError(errorMsg, willRetry = false, delay = 0, incidentId = null) {
+ if (!incidentId) incidentId = App.currentIncidentId;
+ if (incidentId !== App.currentIncidentId) return;
+
+ const overlay = document.getElementById('progress-overlay');
+ if (overlay) overlay.style.display = 'flex';
+
+ // Mark current step as error
+ const state = this._progressState[incidentId];
+ if (state) {
+ const items = document.querySelectorAll('.progress-check-item.active');
+ items.forEach(item => {
+ item.classList.remove('active');
+ item.classList.add('error');
+ const icon = item.querySelector('.progress-check-icon');
+ if (icon) icon.innerHTML = '\u2717';
+ });
+ }
+
+ const titleEl = document.getElementById('progress-popup-title');
+ if (titleEl) {
+ titleEl.textContent = willRetry
+ ? 'Fehlgeschlagen \u2014 erneuter Versuch in ' + delay + 's...'
+ : 'Fehlgeschlagen: ' + errorMsg;
+ }
+
+ const cancelBtn = document.getElementById('progress-cancel-btn');
+ if (cancelBtn) cancelBtn.style.display = 'none';
+
+ if (!willRetry) {
+ this._removeSidebarRefreshStatus(incidentId);
+ setTimeout(() => this.hideProgress(incidentId), 6000);
+ }
+ },
+
+ hideProgress(incidentId) {
+ if (!incidentId) incidentId = App.currentIncidentId;
+
+ // Remove blur
+ const blurTarget = document.getElementById('incident-view');
+ if (blurTarget) blurTarget.classList.remove('refresh-blurred');
+
+ if (incidentId === App.currentIncidentId) {
+ const overlay = document.getElementById('progress-overlay');
+ if (overlay) { overlay.style.display = 'none'; overlay.classList.remove('blocking'); }
+ const mini = document.getElementById('progress-mini');
+ if (mini) mini.style.display = 'none';
+ }
+
+ // Unlock action buttons
+ this._lockActionsIfFirst(false);
+
+ // Remove sidebar status
+ this._removeSidebarRefreshStatus(incidentId);
+
+ // Clean up state
+ delete this._progressState[incidentId];
+
+ // Stop timer if no more active refreshes
+ if (Object.keys(this._progressState).length === 0 && this._progressTimerInterval) {
+ clearInterval(this._progressTimerInterval);
+ this._progressTimerInterval = null;
+ }
+ },
+
+ _tickProgressTimers() {
+ for (const [id, state] of Object.entries(this._progressState)) {
+ if (!state.startTime) continue;
+ const elapsed = Math.max(0, Math.floor((Date.now() - state.startTime) / 1000));
+ const mins = Math.floor(elapsed / 60);
+ const secs = elapsed % 60;
+ const timeStr = mins + ':' + String(secs).padStart(2, '0');
+
+ if (parseInt(id) === App.currentIncidentId) {
+ // Update popup timer
+ const timerEl = document.getElementById('progress-popup-timer');
+ if (timerEl) timerEl.textContent = timeStr;
+ // Update mini timer
+ const miniTimer = document.getElementById('progress-mini-timer');
+ if (miniTimer) miniTimer.textContent = timeStr;
+ }
+
+ // Update sidebar timer for this incident
+ const sidebarTimer = document.getElementById('sidebar-refresh-timer-' + id);
+ if (sidebarTimer) sidebarTimer.textContent = timeStr;
+ }
+ },
+
+ // === Sidebar Refresh Status ===
+ _updateSidebarRefreshStatus(incidentId, status, extra) {
+ const item = document.querySelector('.incident-item[data-id="' + incidentId + '"]');
+ if (!item) return;
+
+ const isQueued = (status === 'queued');
+
+ // Add appropriate class
+ item.classList.remove('refreshing-item', 'queued-item');
+ item.classList.add(isQueued ? 'queued-item' : 'refreshing-item');
+
+ // Add or update status text below meta
+ let statusEl = document.getElementById('sidebar-refresh-' + incidentId);
+ if (!statusEl) {
+ const textCol = item.querySelector('div[style*="flex:1"]');
+ if (!textCol) return;
+ statusEl = document.createElement('div');
+ statusEl.id = 'sidebar-refresh-' + incidentId;
+ textCol.appendChild(statusEl);
+ }
+
+ if (isQueued) {
+ const pos = (extra && extra.queue_position) ? extra.queue_position : ((this._progressState[incidentId] || {})._queuePos || '');
+ // Store queue position in state for renderIncidentItem
+ const pState = this._progressState[incidentId];
+ if (pState && pos) pState._queuePos = pos;
+ statusEl.className = 'incident-refresh-status queued-status';
+ statusEl.innerHTML = 'Warteschlange' + (pos ? ' (#' + pos + ')' : '') + '';
+ } else {
+ statusEl.className = 'incident-refresh-status';
+ const label = this._getStepLabel(status);
+ statusEl.innerHTML = '' + label + '';
+ }
+ },
+
+ _removeSidebarRefreshStatus(incidentId) {
+ const statusEl = document.getElementById('sidebar-refresh-' + incidentId);
+ if (statusEl) statusEl.remove();
+ const item = document.querySelector('.incident-item[data-id="' + incidentId + '"]');
+ if (item) item.classList.remove('refreshing-item', 'queued-item');
+ },
+
+ _reindexQueuePositions() {
+ // Collect all queued incidents and renumber sequentially
+ const queued = [];
+ for (const [id, state] of Object.entries(this._progressState)) {
+ if (state && state.step === 'queued') queued.push({ id: Number(id), pos: state._queuePos || 999 });
+ }
+ queued.sort((a, b) => a.pos - b.pos);
+ queued.forEach((item, idx) => {
+ const newPos = idx + 1;
+ const state = this._progressState[item.id];
+ if (state) state._queuePos = newPos;
+ const statusEl = document.getElementById('sidebar-refresh-' + item.id);
+ if (statusEl) statusEl.innerHTML = 'Warteschlange (#' + newPos + ')';
+ });
+ },
+
+
+ // === Click-outside to auto-minimize popup ===
+ _initClickOutside() {
+ if (this._clickOutsideInit) return;
+ this._clickOutsideInit = true;
+ document.addEventListener('click', (e) => {
+ const overlay = document.getElementById('progress-overlay');
+ if (!overlay || overlay.style.display === 'none') return;
+ const popup = document.getElementById('progress-popup');
+ if (!popup) return;
+ // Ignore clicks inside the popup itself
+ if (popup.contains(e.target)) return;
+ // Ignore clicks on the mini bar
+ const mini = document.getElementById('progress-mini');
+ if (mini && mini.contains(e.target)) return;
+ // Don't minimize during first refresh (blocking)
+ const currentId = App.currentIncidentId;
+ const state = this._progressState[currentId];
+ if (state && state.isFirst) return;
+ // Auto-minimize
+ if (state && !state.minimized) {
+ this.minimizeProgress(currentId);
+ }
+ });
+ },
+
+ /**
+ * Zusammenfassung mit Inline-Zitaten und Quellenverzeichnis rendern.
+ */
+ /**
+ * Extrahiert die ZUSAMMENFASSUNG-Sektion aus einem Research-Briefing.
+ * Returns: { zusammenfassung: string|null, remaining: string }
+ */
+ extractZusammenfassung(summary) {
+ if (!summary) return { zusammenfassung: null, remaining: summary };
+ const pattern = /## (?:ZUSAMMENFASSUNG|ÜBERBLICK)\s*\n(.*?)(?=\n## |$)/s;
+ const match = summary.match(pattern);
+ if (!match) return { zusammenfassung: null, remaining: summary };
+ const zusammenfassung = match[1].trim();
+ const remaining = summary.substring(0, match.index) + summary.substring(match.index + match[0].length);
+ return { zusammenfassung, remaining: remaining.trim() };
+ },
+
+ /**
+ * Parst sources: akzeptiert Array (neu, vom /sources-Endpunkt) ODER
+ * JSON-String (alt, aus sources_json) fuer Rueckwaertskompatibilitaet.
+ */
+ _parseSources(input) {
+ if (!input) return [];
+ if (Array.isArray(input)) return input;
+ try {
+ const parsed = JSON.parse(input);
+ return Array.isArray(parsed) ? parsed : [];
+ } catch (e) {
+ return [];
+ }
+ },
+
+ /**
+ * Rendert die Zusammenfassung als HTML (Bullet Points).
+ */
+ renderZusammenfassung(text, sourcesJson) {
+ if (!text) return 'Noch keine Zusammenfassung.';
+ const sources = this._parseSources(sourcesJson);
+ // Nur Bullet-Point-Zeilen behalten, Fliesstext herausfiltern
+ const bulletLines = text.split("\n").filter(line => line.trim().startsWith("- "));
+ const bulletText = bulletLines.length > 0 ? bulletLines.join("\n") : text;
+ let html = this.escape(bulletText);
+ // Bullet points
+ html = html.replace(/^- (.+)$/gm, '$1');
+ html = html.replace(/(
.*<\/li>\n?)+/gs, '');
+ // Zeilenumbrueche
+ html = html.replace(/\n(?!<)/g, '
');
+ html = html.replace(/(
){2,}/g, '
');
+ // Inline-Zitate als klickbare Links
+ if (sources.length > 0) {
+ html = html.replace(/\[(\d+[a-z]?)\]/g, (match, num) => {
+ let src = sources.find(s => String(s.nr) === num || Number(s.nr) === Number(num));
+ if ((!src || !src.url) && /[a-z]$/.test(num)) {
+ const baseNum = num.replace(/[a-z]$/, '');
+ const baseSrc = sources.find(s => String(s.nr) === baseNum || Number(s.nr) === Number(baseNum));
+ if (baseSrc && baseSrc.url) src = baseSrc;
+ }
+ if (src && src.url) {
+ return `[${num}]`;
+ }
+ return match;
+ });
+ }
+ return html;
+ },
+
+ /**
+ * Rendert "Neueste Entwicklungen" für Live-Monitoring (adhoc).
+ * Erwartet Bullets im Format "- [DD.MM. HH:MM] Text {Quelle1, Quelle2}".
+ * Legacy: Inline-[N]-Citations werden als Fallback ebenfalls erkannt.
+ */
+ renderLatestDevelopments(text, sourcesJson) {
+ if (!text) return 'Noch keine Entwicklungen erfasst.';
+ const sources = this._parseSources(sourcesJson);
+
+ const bulletLines = text.split("\n").map(l => l.trim()).filter(l => l && (l.startsWith("- ") || l.startsWith("[")));
+ if (bulletLines.length === 0) {
+ return this.renderZusammenfassung(text, sourcesJson);
+ }
+
+ const bulletRe = /^(?:-\s*)?\[\s*(\d{1,2})\.(\d{1,2})\.?(?:\d{2,4})?\s+(\d{1,2}:\d{2})\s*\]\s*(.+?)\s*$/;
+ const citationRe = /\[(\d+[a-z]?)\]/g;
+ const trailingNamesRe = /\s*\{([^{}]+)\}\s*\.?\s*$/;
+
+ const lookupByNum = (num) => {
+ let src = sources.find(s => String(s.nr) === num || Number(s.nr) === Number(num));
+ if (!src && /[a-z]$/.test(num)) {
+ const baseNum = num.replace(/[a-z]$/, '');
+ src = sources.find(s => String(s.nr) === baseNum || Number(s.nr) === Number(baseNum));
+ }
+ return src || null;
+ };
+
+ const normalize = (s) => (s || '').toLowerCase().replace(/^(der|die|das)\s+/, '').replace(/\s+/g, ' ').trim();
+ const lookupByName = (name) => {
+ const n = normalize(name);
+ if (!n) return null;
+ let src = sources.find(s => normalize(s.name) === n);
+ if (src) return src;
+ src = sources.find(s => {
+ const sn = normalize(s.name);
+ return sn.includes(n) || n.includes(sn);
+ });
+ return src || null;
+ };
+
+ const buildPill = (src, fallbackName) => {
+ const displayName = src ? (src.name || fallbackName) : fallbackName;
+ const url = (src && src.url) || '';
+ const tgMatch = url.match(/^https?:\/\/t\.me\/([^\/?#]+)/i);
+ const label = tgMatch ? displayName + ' (t.me/' + tgMatch[1] + ')' : displayName;
+ const esc = this.escape(label);
+ const titleEsc = this.escape(displayName);
+ if (src && src.url) {
+ return `${esc}`;
+ }
+ return `${esc}`;
+ };
+
+ const cards = bulletLines.map(line => {
+ const m = bulletRe.exec(line);
+ if (!m) {
+ const body = this.escape(line.replace(/^-\s*/, ''));
+ return ``;
+ }
+ const day = m[1].padStart(2, '0');
+ const month = m[2].padStart(2, '0');
+ const date = `${day}.${month}.`;
+ const time = m[3];
+ let rawBody = m[4];
+
+ let pillsHtml = '';
+
+ // Primär: {Name1|URL1, Name2|URL2} oder {Name1, Name2} am Bullet-Ende
+ const trailing = trailingNamesRe.exec(rawBody);
+ if (trailing) {
+ rawBody = rawBody.replace(trailingNamesRe, '').trim();
+ const items = trailing[1].split(',').map(s => s.trim()).filter(Boolean);
+ const seen = new Set();
+ pillsHtml = items.map(item => {
+ // Split am ersten Pipe: "Name|URL" → Name + URL; ohne Pipe nur Name
+ const pipeIdx = item.indexOf('|');
+ const itemName = pipeIdx >= 0 ? item.slice(0, pipeIdx).trim() : item.trim();
+ const itemUrl = pipeIdx >= 0 ? item.slice(pipeIdx + 1).trim() : '';
+ if (!itemName) return '';
+ const key = normalize(itemName);
+ if (seen.has(key)) return '';
+ seen.add(key);
+ if (/^(unbekannt|unknown|n\/a|keine)$/i.test(itemName)) return '';
+ // Wenn URL direkt mitgeliefert wurde: eindeutiger Link, keine Kollision mit sources_json moeglich
+ if (itemUrl) {
+ return buildPill({ name: itemName, url: itemUrl }, itemName);
+ }
+ // Fallback (Legacy-Bullets ohne URL): Name-Lookup in sources_json
+ const src = lookupByName(itemName);
+ return buildPill(src, itemName);
+ }).filter(Boolean).join('');
+ }
+
+ // Fallback: Inline-[N]-Citations (Legacy-Recherche-Format)
+ if (!pillsHtml) {
+ const nums = [];
+ let cm;
+ while ((cm = citationRe.exec(rawBody)) !== null) {
+ if (!nums.includes(cm[1])) nums.push(cm[1]);
+ }
+ citationRe.lastIndex = 0;
+ if (nums.length > 0) {
+ rawBody = rawBody.replace(citationRe, '').replace(/\s+/g, ' ').trim();
+ pillsHtml = nums.map(num => {
+ const src = lookupByNum(num);
+ return src ? buildPill(src, src.name || `Quelle ${num}`) : '';
+ }).filter(Boolean).join('');
+ }
+ }
+
+ const cleanBody = this.escape(rawBody.trim());
+ const sourcesHtml = pillsHtml ? `${pillsHtml}` : '';
+ const timeHtml = `${this.escape(time)} \u00b7 ${this.escape(date)}`;
+
+ return `${sourcesHtml}${timeHtml}
${cleanBody}
`;
+ });
+
+ return `${cards.join('')}
`;
+ },
+
+
+ renderSummary(summary, sourcesJson, incidentType) {
+ if (!summary) return 'Noch keine Zusammenfassung.';
+
+ const sources = this._parseSources(sourcesJson);
+
+ // Markdown-Rendering
+ let html = this.escape(summary);
+
+ // ## Überschriften
+ html = html.replace(/^## (.+)$/gm, '$1
');
+ // **Fettdruck**
+ html = html.replace(/\*\*(.+?)\*\*/g, '$1');
+ // Listen (- Item)
+ html = html.replace(/^- (.+)$/gm, '$1');
+ html = html.replace(/(
.*<\/li>\n?)+/gs, '');
+ // Zeilenumbrüche (aber nicht nach Headings/Listen)
+ html = html.replace(/\n(?!<)/g, '
');
+ // Überflüssige
nach Block-Elementen entfernen + doppelte
zusammenfassen
+ html = html.replace(/<\/h3>(
)+/g, '');
+ html = html.replace(/<\/ul>(
)+/g, '');
+ html = html.replace(/(
){2,}/g, '
');
+
+ // Markdown-Tabellen rendern
+ html = html.replace(/(?:^|
)((?:\|.+\|(?:
|$))+)/g, function(match, tableBlock) {
+ var rows = tableBlock.split('
').filter(function(r) { return r.trim().length > 0; });
+ if (rows.length < 2) return match;
+ var isSep = function(r) { return /^\|[\s\-:|]+\|$/.test(r.trim()); };
+ if (!isSep(rows[1])) return match;
+ var parseRow = function(r) { return r.split('|').slice(1, -1).map(function(c) { return c.trim(); }); };
+ var headerCells = parseRow(rows[0]);
+ var thead = '' + headerCells.map(function(c) { return '| ' + c + ' | '; }).join('') + '
';
+ var tbody = '' + rows.slice(2).map(function(r) {
+ if (isSep(r)) return '';
+ var cells = parseRow(r);
+ return '' + cells.map(function(c) { return '| ' + c + ' | '; }).join('') + '
';
+ }).join('') + '';
+ return '';
+ });
+
+ // Inline-Zitate [1], [2], [1383a] etc. als klickbare Links rendern
+ if (sources.length > 0) {
+ html = html.replace(/\[(\d+[a-z]?)\]/g, (match, num) => {
+ // Exakte Suche (auch mit Buchstaben-Suffix)
+ let src = sources.find(s => String(s.nr) === num || Number(s.nr) === Number(num));
+ // Fallback: Bei Suffix wie "1383a" auf Basisnummer 1383 zurueckfallen
+ if ((!src || !src.url) && /[a-z]$/.test(num)) {
+ const baseNum = num.replace(/[a-z]$/, '');
+ const baseSrc = sources.find(s => String(s.nr) === baseNum || Number(s.nr) === Number(baseNum));
+ if (baseSrc && baseSrc.url) src = baseSrc;
+ }
+ if (src && src.url) {
+ return `[${num}]`;
+ }
+ return match;
+ });
+ }
+
+ return `${html}
`;
+ },
+
+ /**
+ * Quellenübersicht für eine Lage rendern.
+ */
+ /**
+ * Quellenuebersicht aus Aggregat-Endpunkt rendern (alle Artikel der Lage,
+ * unabhaengig von Paginierung im Frontend).
+ * data: {total, sources: [{source, article_count, languages: []}], language_counts: [{language, cnt}]}
+ */
+ renderSourceOverviewFromSummary(data) {
+ if (!data || !data.sources || data.sources.length === 0) return '';
+
+ const langChips = (data.language_counts || [])
+ .map(l => `${(l.language || 'de').toUpperCase()} ${l.cnt}`)
+ .join('');
+
+ let html = ``;
+
+ html += '';
+ data.sources.forEach(s => {
+ const langs = (s.languages || ['de']).map(l => (l || 'de').toUpperCase()).join('/');
+ const sourceName = this.escape(s.source || 'Unbekannt');
+ html += `
+ ${sourceName}
+ ${langs}
+ ${s.article_count}
+
`;
+ });
+ html += '
';
+
+ return html;
+ },
+
+ renderSourceOverview(articles) {
+ if (!articles || articles.length === 0) return '';
+
+ // Nach Quelle aggregieren
+ const sourceMap = {};
+ articles.forEach(a => {
+ const name = a.source || 'Unbekannt';
+ if (!sourceMap[name]) {
+ sourceMap[name] = { count: 0, languages: new Set(), urls: [] };
+ }
+ sourceMap[name].count++;
+ sourceMap[name].languages.add(a.language || 'de');
+ if (a.source_url) sourceMap[name].urls.push(a.source_url);
+ });
+
+ const sources = Object.entries(sourceMap)
+ .sort((a, b) => b[1].count - a[1].count);
+
+ // Sprach-Statistik
+ const langCount = {};
+ articles.forEach(a => {
+ const lang = (a.language || 'de').toUpperCase();
+ langCount[lang] = (langCount[lang] || 0) + 1;
+ });
+
+ const langChips = Object.entries(langCount)
+ .sort((a, b) => b[1] - a[1])
+ .map(([lang, count]) => `${lang} ${count}`)
+ .join('');
+
+ let html = ``;
+
+ html += '';
+ sources.forEach(([name, data]) => {
+ const langs = [...data.languages].map(l => l.toUpperCase()).join('/');
+ html += `
+ ${this.escape(name)}
+ ${langs}
+ ${data.count}
+
`;
+ });
+ html += '
';
+
+ return html;
+ },
+
+ /**
+ * Kategorie-Labels.
+ */
+ _categoryLabels: {
+ 'nachrichtenagentur': 'Agentur',
+ 'oeffentlich-rechtlich': 'ÖR',
+ 'qualitaetszeitung': 'Qualität',
+ 'behoerde': 'Behörde',
+ 'fachmedien': 'Fach',
+ 'think-tank': 'Think Tank',
+ 'international': 'Intl.',
+ 'regional': 'Regional',
+ 'boulevard': 'Boulevard',
+ 'telegram': 'Telegram',
+ 'sonstige': 'Sonstige',
+ },
+
+ _politicalLabels: {
+ links_extrem: { short: 'L+', full: 'Links (extrem)' },
+ links: { short: 'L', full: 'Links' },
+ mitte_links: { short: 'ML', full: 'Mitte-Links' },
+ liberal: { short: 'LIB', full: 'Liberal' },
+ mitte: { short: 'M', full: 'Mitte' },
+ konservativ: { short: 'KON', full: 'Konservativ' },
+ mitte_rechts: { short: 'MR', full: 'Mitte-Rechts' },
+ rechts: { short: 'R', full: 'Rechts' },
+ rechts_extrem: { short: 'R+', full: 'Rechts (extrem)' },
+ na: { short: '?', full: 'Nicht eingeordnet' },
+ },
+ _reliabilityLabels: {
+ sehr_hoch: 'Sehr hoch',
+ hoch: 'Hoch',
+ gemischt: 'Gemischt',
+ niedrig: 'Niedrig',
+ sehr_niedrig: 'Sehr niedrig',
+ na: 'Nicht eingeordnet',
+ },
+ _mediaTypeLabels: {
+ tageszeitung: 'Tageszeitung',
+ wochenzeitung: 'Wochenzeitung',
+ magazin: 'Magazin',
+ tv_sender: 'TV-Sender',
+ radio: 'Radio',
+ oeffentlich_rechtlich: 'Öffentlich-Rechtlich',
+ nachrichtenagentur: 'Nachrichtenagentur',
+ online_only: 'Online-only',
+ blog: 'Blog',
+ telegram_kanal: 'Telegram-Kanal',
+ telegram_bot: 'Telegram-Bot',
+ podcast: 'Podcast',
+ social_media: 'Social Media',
+ imageboard: 'Imageboard',
+ think_tank: 'Think Tank',
+ ngo: 'NGO',
+ behoerde: 'Behörde',
+ staatsmedium: 'Staatsmedium',
+ fachmedium: 'Fachmedium',
+ sonstige: 'Sonstige',
+ },
+ _alignmentLabels: {
+ prorussisch: 'prorussisch',
+ proiranisch: 'proiranisch',
+ prowestlich: 'prowestlich',
+ proukrainisch: 'proukrainisch',
+ prochinesisch: 'prochinesisch',
+ projapanisch: 'projapanisch',
+ proisraelisch: 'proisraelisch',
+ propalaestinensisch: 'propalästinensisch',
+ protuerkisch: 'protürkisch',
+ panarabisch: 'panarabisch',
+ neutral: 'neutral',
+ sonstige: 'sonstige',
+ },
+
+ _renderClassificationBadges(feed) {
+ const parts = [];
+ const pol = feed.political_orientation;
+ if (pol && pol !== 'na') {
+ const label = this._politicalLabels[pol] || { short: pol, full: pol };
+ parts.push(`${this.escape(label.short)}`);
+ }
+ const rel = feed.reliability;
+ if (rel && rel !== 'na') {
+ const relLabel = this._reliabilityLabels[rel] || rel;
+ const relSource = feed.ifcn_signatory ? '(IFCN-Faktenchecker)'
+ : (feed.eu_disinfo_listed ? `(EU-Desinfo, ${feed.eu_disinfo_case_count || 0} Fälle)`
+ : '(LLM-Schätzung)');
+ const relTitle = `Glaubwürdigkeit: ${relLabel} ${relSource}`;
+ parts.push(``);
+ }
+ if (feed.ifcn_signatory) {
+ parts.push(`✓ IFCN`);
+ }
+ if (feed.eu_disinfo_listed) {
+ const cnt = feed.eu_disinfo_case_count || 0;
+ const title = `EUvsDisinfo: ${cnt} dokumentierte Desinformations-Fälle`;
+ parts.push(`⚠ EU-Desinfo (${cnt})`);
+ }
+ if (feed.state_affiliated) {
+ parts.push(`⚑`);
+ }
+ const aligns = Array.isArray(feed.alignments) ? feed.alignments : [];
+ aligns.forEach(a => {
+ const label = this._alignmentLabels[a] || a;
+ parts.push(`${this.escape(label)}`);
+ });
+ return parts.join('');
+ },
+
+ /**
+ * Domain-Gruppe rendern (aufklappbar mit Feeds).
+ */
+ renderSourceGroup(domain, feeds, isExcluded, excludedNotes, isGlobal) {
+ const catLabel = this._categoryLabels[feeds[0]?.category] || feeds[0]?.category || '';
+ const feedCount = feeds.filter(f => f.source_type !== 'excluded').length;
+ const hasMultiple = feedCount > 1;
+ const displayName = (domain && !domain.startsWith('_single_')) ? domain : (feeds[0]?.name || 'Unbekannt');
+ const escapedDomain = this.escape(domain);
+
+ if (isExcluded) {
+ // Ausgeschlossene Domain
+ const notesHtml = excludedNotes ? ` ${this.escape(excludedNotes)}` : '';
+ return `
+
+
`;
+ }
+
+ // Aktive Domain-Gruppe
+ const toggleAttr = hasMultiple ? `onclick="App.toggleGroup('${escapedDomain}')" role="button" tabindex="0" aria-expanded="false"` : '';
+ const toggleIcon = hasMultiple ? '▶' : '';
+
+ let feedRows = '';
+ if (hasMultiple) {
+ const realFeeds = feeds.filter(f => f.source_type !== 'excluded');
+ feedRows = ``;
+ realFeeds.forEach((feed, i) => {
+ const isLast = i === realFeeds.length - 1;
+ const connector = isLast ? '\u2514\u2500' : '\u251C\u2500';
+ const typeLabel = feed.source_type === 'rss_feed' ? 'RSS' : 'Web';
+ const urlDisplay = feed.url ? this._shortenUrl(feed.url) : '';
+ feedRows += `
+ ${connector}
+ ${this.escape(feed.name)}
+ ${typeLabel}
+ ${this.escape(urlDisplay)}
+ ${!feed.is_global ? `
+ ` : 'Grundquelle'}
+
`;
+ });
+ feedRows += '
';
+ }
+
+ const feedCountBadge = feedCount > 0
+ ? `${feedCount} Feed${feedCount !== 1 ? 's' : ''}`
+ : '';
+
+ // Info-Button mit Tooltip (Typ, Sprache, Ausrichtung, Klassifikation)
+ let infoButtonHtml = '';
+ const firstFeed = feeds[0] || {};
+ const hasInfo = firstFeed.language || firstFeed.bias
+ || (firstFeed.political_orientation && firstFeed.political_orientation !== 'na')
+ || (firstFeed.media_type && firstFeed.media_type !== 'sonstige')
+ || (firstFeed.reliability && firstFeed.reliability !== 'na')
+ || firstFeed.state_affiliated
+ || firstFeed.country_code
+ || (Array.isArray(firstFeed.alignments) && firstFeed.alignments.length > 0);
+ if (hasInfo) {
+ const typeMap = { rss_feed: 'RSS-Feed', web_source: 'Web-Quelle', telegram_channel: 'Telegram-Kanal', podcast_feed: 'Podcast' };
+ const lines = [];
+ lines.push('Typ: ' + (typeMap[firstFeed.source_type] || firstFeed.source_type || 'Unbekannt'));
+ if (firstFeed.language) lines.push('Sprache: ' + firstFeed.language);
+ if (firstFeed.country_code) lines.push('Land: ' + firstFeed.country_code);
+ if (firstFeed.media_type && firstFeed.media_type !== 'sonstige') {
+ lines.push('Medientyp: ' + (this._mediaTypeLabels[firstFeed.media_type] || firstFeed.media_type));
+ }
+ if (firstFeed.political_orientation && firstFeed.political_orientation !== 'na') {
+ const pl = this._politicalLabels[firstFeed.political_orientation];
+ lines.push('Politisch: ' + (pl ? pl.full : firstFeed.political_orientation));
+ }
+ if (firstFeed.reliability && firstFeed.reliability !== 'na') {
+ const relLabel = this._reliabilityLabels[firstFeed.reliability] || firstFeed.reliability;
+ const relSrc = firstFeed.ifcn_signatory ? ' (IFCN-Faktenchecker)'
+ : (firstFeed.eu_disinfo_listed ? ` (EU-Desinfo, ${firstFeed.eu_disinfo_case_count || 0} Fälle)`
+ : ' (LLM-Schätzung)');
+ lines.push('Glaubwürdigkeit: ' + relLabel + relSrc);
+ }
+ if (firstFeed.ifcn_signatory) lines.push('IFCN-Faktenchecker: ja');
+ if (firstFeed.eu_disinfo_listed) {
+ lines.push(`EUvsDisinfo: ${firstFeed.eu_disinfo_case_count || 0} Fälle` + (firstFeed.eu_disinfo_last_seen ? ` (zuletzt ${firstFeed.eu_disinfo_last_seen})` : ''));
+ }
+ if (firstFeed.state_affiliated) lines.push('Staatsnah: ja');
+ if (Array.isArray(firstFeed.alignments) && firstFeed.alignments.length > 0) {
+ const labels = firstFeed.alignments.map(a => this._alignmentLabels[a] || a);
+ lines.push('Geopolitische Nähe: ' + labels.join(', '));
+ }
+ if (firstFeed.bias) lines.push('Notiz: ' + firstFeed.bias);
+ const tooltipText = this.escape(lines.join('\n'));
+ infoButtonHtml = ` `;
+ }
+
+ const classificationBadges = this._renderClassificationBadges(firstFeed);
+
+ return `
+
+ ${feedRows}
+
`;
+ },
+
+ /**
+ * URL kürzen für die Anzeige in Feed-Zeilen.
+ */
+ _shortenUrl(url) {
+ try {
+ const u = new URL(url);
+ let path = u.pathname;
+ if (path.length > 40) path = path.substring(0, 37) + '...';
+ return u.hostname + path;
+ } catch {
+ return url.length > 50 ? url.substring(0, 47) + '...' : url;
+ }
+ },
+ /**
+ * Leaflet-Karte mit Locations rendern.
+ */
+ _map: null,
+ _mapCluster: null,
+ _mapCategoryLayers: {},
+ _mapLegendControl: null,
+
+ _pendingLocations: null,
+
+ // Farbige Marker-Icons nach Kategorie (inline SVG, keine externen Ressourcen)
+ _markerIcons: null,
+ _createSvgIcon(fillColor, strokeColor) {
+ const svg = ``;
+ return L.divIcon({
+ html: svg,
+ className: 'map-marker-svg',
+ iconSize: [28, 42],
+ iconAnchor: [14, 42],
+ popupAnchor: [0, -36],
+ });
+ },
+ _initMarkerIcons() {
+ if (this._markerIcons || typeof L === 'undefined') return;
+ this._markerIcons = {
+ primary: this._createSvgIcon('#dc3545', '#a71d2a'),
+ secondary: this._createSvgIcon('#f39c12', '#c47d0a'),
+ tertiary: this._createSvgIcon('#2a81cb', '#1a5c8f'),
+ mentioned: this._createSvgIcon('#7b7b7b', '#555555'),
+ };
+ },
+
+ _defaultCategoryLabels: {
+ primary: 'Hauptgeschehen',
+ secondary: 'Reaktionen',
+ tertiary: 'Beteiligte',
+ mentioned: 'Erwaehnt',
+ },
+ _categoryColors: {
+ primary: '#cb2b3e',
+ secondary: '#f39c12',
+ tertiary: '#2a81cb',
+ mentioned: '#7b7b7b',
+ },
+
+ _activeCategoryLabels: null,
+
+ renderMap(locations, categoryLabels) {
+ const container = document.getElementById('map-container');
+ const emptyEl = document.getElementById('map-empty');
+ const statsEl = document.getElementById('map-stats');
+ if (!container) return;
+
+ // Leaflet noch nicht geladen? Locations merken und spaeter rendern
+ if (typeof L === 'undefined') {
+ this._pendingLocations = locations;
+ // Statistik trotzdem anzeigen
+ if (locations && locations.length > 0) {
+ const totalArticles = locations.reduce((s, l) => s + l.article_count, 0);
+ if (statsEl) statsEl.textContent = `${locations.length} Orte / ${totalArticles} Artikel`;
+ if (emptyEl) emptyEl.style.display = 'none';
+ }
+ return;
+ }
+
+ if (!locations || locations.length === 0) {
+ if (emptyEl) emptyEl.style.display = 'flex';
+ if (statsEl) statsEl.textContent = '';
+ if (this._map) {
+ this._map.remove();
+ this._map = null;
+ this._mapCluster = null;
+ }
+ return;
+ }
+
+ if (emptyEl) emptyEl.style.display = 'none';
+
+ // Statistik
+ const totalArticles = locations.reduce((s, l) => s + l.article_count, 0);
+ if (statsEl) statsEl.textContent = `${locations.length} Orte / ${totalArticles} Artikel`;
+
+ // Container-Hoehe sicherstellen (Leaflet braucht px-Hoehe)
+ const gsItem = container.closest('.grid-stack-item');
+ if (gsItem) {
+ const headerEl = container.closest('.map-card')?.querySelector('.card-header');
+ const headerH = headerEl ? headerEl.offsetHeight : 40;
+ const available = gsItem.offsetHeight - headerH - 4;
+ container.style.height = Math.max(available, 200) + 'px';
+ } else if (container.offsetHeight < 50) {
+ container.style.height = '300px';
+ }
+
+ // Karte initialisieren oder updaten
+ if (!this._map) {
+ this._map = L.map(container, {
+ zoomControl: true,
+ attributionControl: true,
+ minZoom: 2,
+ maxBounds: [[-85, -180], [85, 180]],
+ maxBoundsViscosity: 1.0,
+ }).setView([51.1657, 10.4515], 5); // Deutschland-Zentrum
+
+ this._applyMapTiles();
+ this._mapCluster = L.markerClusterGroup({
+ maxClusterRadius: 40,
+ iconCreateFunction: function(cluster) {
+ const count = cluster.getChildCount();
+ let size = 'small';
+ if (count >= 10) size = 'medium';
+ if (count >= 50) size = 'large';
+ return L.divIcon({
+ html: '' + count + '
',
+ className: 'map-cluster map-cluster-' + size,
+ iconSize: L.point(40, 40),
+ });
+ },
+ });
+ this._map.addLayer(this._mapCluster);
+ } else {
+ this._mapCluster.clearLayers();
+ this._mapCategoryLayers = {};
+ }
+
+ // Marker hinzufuegen
+ const bounds = [];
+ this._initMarkerIcons();
+ // Dynamische Labels verwenden (API > Default)
+ const catLabels = categoryLabels || this._activeCategoryLabels || this._defaultCategoryLabels;
+ this._activeCategoryLabels = catLabels;
+ const usedCategories = new Set();
+
+ locations.forEach(loc => {
+ const cat = loc.category || 'mentioned';
+ usedCategories.add(cat);
+ const icon = (this._markerIcons && this._markerIcons[cat]) ? this._markerIcons[cat] : undefined;
+ const markerOpts = icon ? { icon } : {};
+ const marker = L.marker([loc.lat, loc.lon], markerOpts);
+
+ // Popup-Inhalt
+ const catLabel = catLabels[cat] || this._defaultCategoryLabels[cat] || cat;
+ const catColor = this._categoryColors[cat] || '#7b7b7b';
+ let popupHtml = ``;
+
+ marker.bindPopup(popupHtml, { maxWidth: 300, className: 'map-popup-container' });
+ if (!this._mapCategoryLayers[cat]) this._mapCategoryLayers[cat] = L.featureGroup();
+ this._mapCategoryLayers[cat].addLayer(marker);
+ this._mapCluster.addLayer(marker);
+ bounds.push([loc.lat, loc.lon]);
+ });
+
+ // Ansicht auf Marker zentrieren
+ if (bounds.length > 0) {
+ if (bounds.length === 1) {
+ this._map.setView(bounds[0], 8);
+ } else {
+ this._map.fitBounds(bounds, { padding: [30, 30], maxZoom: 12 });
+ }
+ }
+
+ // Legende mit Checkbox-Filter
+ if (this._map) {
+ const existingLegend = document.querySelector('.map-legend-ctrl');
+ if (existingLegend) existingLegend.remove();
+ if (this._mapLegendControl) {
+ try { this._map.removeControl(this._mapLegendControl); } catch(e) {}
+ }
+
+ const legend = L.control({ position: 'bottomright' });
+ const self2 = this;
+ const legendLabels = catLabels;
+ legend.onAdd = function() {
+ const div = L.DomUtil.create('div', 'map-legend-ctrl');
+ L.DomEvent.disableClickPropagation(div);
+ let html = 'Filter';
+ ['primary', 'secondary', 'tertiary', 'mentioned'].forEach(cat => {
+ if (usedCategories.has(cat) && legendLabels[cat]) {
+ html += '';
+ }
+ });
+ div.innerHTML = html;
+ div.addEventListener('change', function(e) {
+ const cb = e.target;
+ if (!cb.dataset.mapCat) return;
+ self2._toggleMapCategory(cb.dataset.mapCat, cb.checked);
+ });
+ return div;
+ };
+ legend.addTo(this._map);
+ this._mapLegendControl = legend;
+ }
+
+ // Resize-Fix fuer gridstack (mehrere Versuche, da Container-Hoehe erst spaeter steht)
+ const self = this;
+ [100, 300, 800].forEach(delay => {
+ setTimeout(() => {
+ if (!self._map) return;
+ self._map.invalidateSize();
+ if (bounds.length === 1) {
+ self._map.setView(bounds[0], 8);
+ } else if (bounds.length > 1) {
+ self._map.fitBounds(bounds, { padding: [30, 30], maxZoom: 12 });
+ }
+ }, delay);
+ });
+ },
+
+ _applyMapTiles() {
+ if (!this._map) return;
+ // Alte Tile-Layer entfernen
+ this._map.eachLayer(layer => {
+ if (layer instanceof L.TileLayer) this._map.removeLayer(layer);
+ });
+
+ // Deutsche OSM-Kacheln: deutsche Ortsnamen, einheitlich fuer beide Themes
+ const tileUrl = 'https://tile.openstreetmap.de/{z}/{x}/{y}.png';
+ const attribution = '© OpenStreetMap';
+
+ L.tileLayer(tileUrl, { attribution, maxZoom: 18, noWrap: true }).addTo(this._map);
+ },
+
+ updateMapTheme() {
+ this._applyMapTiles();
+ },
+
+ invalidateMap() {
+ if (this._map) this._map.invalidateSize();
+ },
+
+ retryPendingMap() {
+ if (this._pendingLocations && typeof L !== 'undefined') {
+ const locs = this._pendingLocations;
+ this._pendingLocations = null;
+ this.renderMap(locs, this._activeCategoryLabels);
+ }
+ },
+
+ _mapFullscreen: false,
+ _mapOriginalParent: null,
+
+ toggleMapFullscreen() {
+ const overlay = document.getElementById('map-fullscreen-overlay');
+ const fsContainer = document.getElementById('map-fullscreen-container');
+ const mapContainer = document.getElementById('map-container');
+ const statsEl = document.getElementById('map-stats');
+ const fsStatsEl = document.getElementById('map-fullscreen-stats');
+
+ if (!this._mapFullscreen) {
+ // Save original parent and height
+ this._mapOriginalParent = mapContainer.parentElement;
+ this._savedMapHeight = mapContainer.style.height || mapContainer.offsetHeight + 'px';
+
+ // Move entire map-container into fullscreen overlay
+ fsContainer.appendChild(mapContainer);
+ mapContainer.style.height = '100%';
+
+ if (statsEl && fsStatsEl) {
+ fsStatsEl.textContent = statsEl.textContent;
+ }
+ overlay.classList.add('active');
+ this._mapFullscreen = true;
+
+ // Escape key to close
+ this._mapFsKeyHandler = (e) => { if (e.key === 'Escape') this.toggleMapFullscreen(); };
+ document.addEventListener('keydown', this._mapFsKeyHandler);
+
+ setTimeout(() => { if (this._map) this._map.invalidateSize(); }, 100);
+ } else {
+ // Exit fullscreen: move map-container back to original parent
+ overlay.classList.remove('active');
+ if (this._mapOriginalParent) {
+ this._mapOriginalParent.appendChild(mapContainer);
+ }
+ // Restore saved height
+ mapContainer.style.height = this._savedMapHeight || '';
+
+ this._mapFullscreen = false;
+ if (this._mapFsKeyHandler) {
+ document.removeEventListener('keydown', this._mapFsKeyHandler);
+ this._mapFsKeyHandler = null;
+ }
+
+ const self = this;
+ [100, 300, 600].forEach(delay => {
+ setTimeout(() => { if (self._map) self._map.invalidateSize(); }, delay);
+ });
+ }
+ },
+
+ _mapFsKeyHandler: null,
+
+ _toggleMapCategory(cat, visible) {
+ const layers = this._mapCategoryLayers[cat];
+ if (!layers || !this._mapCluster) return;
+ layers.eachLayer(marker => {
+ if (visible) {
+ this._mapCluster.addLayer(marker);
+ } else {
+ this._mapCluster.removeLayer(marker);
+ }
+ });
+ },
+
+ /**
+ * HTML escapen.
+ */
+ escape(str) {
+ if (!str) return '';
+ const div = document.createElement('div');
+ div.textContent = str;
+ return div.innerHTML;
+ },
+};