Dateien
AegisSight-Monitor/src/static/js/components.js
Claude Dev a365ef12a1 ui: Info-Icons auf Lucide SVG umgestellt und Tooltip-Styling aufgewertet
- Text-i durch Lucide info SVG ersetzt (alle 6 Stellen)
- CSS-Kreis entfernt (SVG bringt eigenen mit)
- Hover-Farbe auf Accent-Gold statt Text-Secondary
- Tooltip: bg-elevated, font-body, shadow-lg, besseres Spacing
- Konsistent mit AegisSight Design-System (Navy/Gold)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 22:38:39 +01:00

969 Zeilen
41 KiB
JavaScript

/**
* 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];
return `
<div class="incident-item ${activeClass}" data-id="${incident.id}" onclick="App.selectIncident(${incident.id})" role="button" tabindex="0">
<span class="incident-dot ${dotClass}" id="dot-${incident.id}" aria-hidden="true"></span>
<div style="flex:1;min-width:0;">
<div class="incident-name">${this.escape(incident.title)}</div>
<div class="incident-meta">${incident.article_count} Artikel &middot; ${this.escape(creator)}</div>
</div>
${incident.visibility === 'private' ? '<span class="badge badge-private" style="font-size:9px;" aria-label="Private Lage">PRIVAT</span>' : ''}
${incident.refresh_mode === 'auto' ? '<span class="badge badge-auto" role="img" aria-label="Auto-Refresh aktiv">&#x21bb;</span>' : ''}
</div>
`;
},
/**
* Faktencheck-Eintrag rendern.
*/
factCheckLabels: {
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',
},
factCheckTooltips: {
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.',
},
factCheckChipLabels: {
confirmed: 'Bestätigt',
unconfirmed: 'Unbestätigt',
contradicted: 'Widerlegt',
developing: 'Unklar',
established: 'Gesichert',
disputed: 'Umstritten',
unverified: 'Ungeprüft',
},
factCheckIcons: {
confirmed: '&#10003;',
unconfirmed: '?',
contradicted: '&#10007;',
developing: '&#8635;',
established: '&#10003;',
disputed: '&#9888;',
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 `<label class="fc-dropdown-item" data-status="${status}" title="${tooltip}">
<input type="checkbox" checked onchange="App.toggleFactCheckFilter('${status}')">
<span class="factcheck-icon ${status}">${icon}</span>
<span class="fc-dropdown-label">${chipLabel}</span>
<span class="fc-dropdown-count">${count}</span>
</label>`;
}).join('');
return `<button class="fc-dropdown-toggle" onclick="App.toggleFcDropdown(event)" aria-haspopup="true" aria-expanded="false">Filter</button>
<div class="fc-dropdown-menu" id="fc-dropdown-menu">${items}</div>`;
},
renderFactCheck(fc) {
const urls = (fc.evidence || '').match(/https?:\/\/[^\s,)]+/g) || [];
const count = urls.length;
return `
<div class="factcheck-item" data-fc-status="${fc.status}">
<div class="factcheck-icon ${fc.status}" title="${this.factCheckTooltips[fc.status] || this.factCheckLabels[fc.status] || fc.status}" aria-hidden="true">${this.factCheckIcons[fc.status] || '?'}</div>
<span class="sr-only">${this.factCheckLabels[fc.status] || fc.status}</span>
<div style="flex:1;">
<div class="factcheck-claim">${this.escape(fc.claim)}</div>
<div style="display:flex;align-items:center;gap:6px;margin-top:2px;">
<span class="factcheck-sources">${count} Quelle${count !== 1 ? 'n' : ''}</span>
</div>
<div class="evidence-block">${this.renderEvidence(fc.evidence || '')}</div>
</div>
</div>
`;
},
/**
* Evidence mit erklärenden Text UND Quellen-Chips rendern.
*/
renderEvidence(text) {
if (!text) return '<span class="evidence-empty">Keine Belege</span>';
const urls = text.match(/https?:\/\/[^\s,)]+/g) || [];
if (urls.length === 0) {
return `<span class="evidence-text">${this.escape(text)}</span>`;
}
// 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 `<a href="${this.escape(url)}" target="_blank" rel="noopener" class="evidence-chip" title="${this.escape(url)}">${this.escape(label)}</a>`;
}).join('');
const explanationHtml = explanation
? `<span class="evidence-text">${this.escape(explanation)}</span>`
: '';
return `${explanationHtml}<div class="evidence-chips">${chips}</div>`;
},
/**
* 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 = `<span class="toast-text">${this.escape(message)}</span>`;
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.
*/
showProgress(status, extra = {}) {
const bar = document.getElementById('progress-bar');
if (!bar) return;
bar.style.display = 'block';
bar.classList.remove('progress-bar--complete', 'progress-bar--error');
const steps = {
queued: { active: 0, label: 'In Warteschlange...' },
researching: { active: 1, label: 'Recherchiert Quellen...' },
deep_researching: { active: 1, label: 'Tiefenrecherche läuft...' },
analyzing: { active: 2, label: 'Analysiert Meldungen...' },
factchecking: { active: 3, label: 'Faktencheck läuft...' },
cancelling: { active: 0, label: 'Wird abgebrochen...' },
};
const step = steps[status] || steps.queued;
// Queue-Position anzeigen
let labelText = step.label;
if (status === 'queued' && extra.queue_position > 1) {
labelText = `In Warteschlange (Position ${extra.queue_position})...`;
} else if (extra.detail) {
labelText = extra.detail;
}
// Timer starten beim Übergang von queued zu aktivem Status
if (step.active > 0 && !this._progressStartTime) {
if (extra.started_at) {
// Echte Startzeit vom Server verwenden
const serverStart = parseUTC(extra.started_at);
this._progressStartTime = serverStart ? serverStart.getTime() : Date.now();
} else {
this._progressStartTime = Date.now();
}
this._startProgressTimer();
}
const stepIds = ['step-researching', 'step-analyzing', 'step-factchecking'];
stepIds.forEach((id, i) => {
const el = document.getElementById(id);
if (!el) return;
el.className = 'progress-step';
if (i + 1 < step.active) el.classList.add('done');
else if (i + 1 === step.active) el.classList.add('active');
});
const fill = document.getElementById('progress-fill');
const percent = step.active === 0 ? 5 : Math.round((step.active / 3) * 100);
if (fill) {
fill.style.width = percent + '%';
}
// ARIA-Werte auf der Progressbar aktualisieren
bar.setAttribute('aria-valuenow', String(percent));
bar.setAttribute('aria-valuetext', labelText);
const label = document.getElementById('progress-label');
if (label) label.textContent = labelText;
// Cancel-Button sichtbar machen
const cancelBtn = document.getElementById('progress-cancel-btn');
if (cancelBtn) cancelBtn.style.display = '';
},
/**
* Timer-Intervall starten (1x pro Sekunde).
*/
_startProgressTimer() {
if (this._progressTimer) return;
const timerEl = document.getElementById('progress-timer');
if (!timerEl) return;
this._progressTimer = setInterval(() => {
if (!this._progressStartTime) return;
const elapsed = Math.max(0, Math.floor((Date.now() - this._progressStartTime) / 1000));
const mins = Math.floor(elapsed / 60);
const secs = elapsed % 60;
timerEl.textContent = `${mins}:${String(secs).padStart(2, '0')}`;
}, 1000);
},
/**
* Abschluss-Animation: Grüner Balken mit Summary-Text.
*/
showProgressComplete(data) {
const bar = document.getElementById('progress-bar');
if (!bar) return;
// Timer stoppen
this._stopProgressTimer();
// Alle Steps auf done
['step-researching', 'step-analyzing', 'step-factchecking'].forEach(id => {
const el = document.getElementById(id);
if (el) { el.className = 'progress-step done'; }
});
// Fill auf 100%
const fill = document.getElementById('progress-fill');
if (fill) fill.style.width = '100%';
// Complete-Klasse
bar.classList.remove('progress-bar--error');
bar.classList.add('progress-bar--complete');
// Label mit 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ätigt`);
}
if (data.contradicted_count > 0) {
parts.push(`${data.contradicted_count} widerlegt`);
}
const summaryText = parts.length > 0 ? parts.join(', ') : 'Keine neuen Entwicklungen';
const label = document.getElementById('progress-label');
if (label) label.textContent = `Abgeschlossen: ${summaryText}`;
// Cancel-Button ausblenden
const cancelBtn = document.getElementById('progress-cancel-btn');
if (cancelBtn) cancelBtn.style.display = 'none';
bar.setAttribute('aria-valuenow', '100');
bar.setAttribute('aria-valuetext', 'Abgeschlossen');
},
/**
* Fehler-Zustand: Roter Balken mit Fehlermeldung.
*/
showProgressError(errorMsg, willRetry = false, delay = 0) {
const bar = document.getElementById('progress-bar');
if (!bar) return;
bar.style.display = 'block';
// Timer stoppen
this._stopProgressTimer();
// Error-Klasse
bar.classList.remove('progress-bar--complete');
bar.classList.add('progress-bar--error');
const label = document.getElementById('progress-label');
if (label) {
label.textContent = willRetry
? `Fehlgeschlagen \u2014 erneuter Versuch in ${delay}s...`
: `Fehlgeschlagen: ${errorMsg}`;
}
// Cancel-Button ausblenden
const cancelBtn = document.getElementById('progress-cancel-btn');
if (cancelBtn) cancelBtn.style.display = 'none';
// Bei finalem Fehler nach 6s ausblenden
if (!willRetry) {
setTimeout(() => this.hideProgress(), 6000);
}
},
/**
* Timer-Intervall stoppen und zurücksetzen.
*/
_stopProgressTimer() {
if (this._progressTimer) {
clearInterval(this._progressTimer);
this._progressTimer = null;
}
this._progressStartTime = null;
const timerEl = document.getElementById('progress-timer');
if (timerEl) timerEl.textContent = '';
},
/**
* Fortschrittsanzeige ausblenden.
*/
hideProgress() {
const bar = document.getElementById('progress-bar');
if (bar) {
bar.style.display = 'none';
bar.classList.remove('progress-bar--complete', 'progress-bar--error');
}
this._stopProgressTimer();
},
/**
* Zusammenfassung mit Inline-Zitaten und Quellenverzeichnis rendern.
*/
renderSummary(summary, sourcesJson, incidentType) {
if (!summary) return '<span style="color:var(--text-tertiary);">Noch keine Zusammenfassung.</span>';
let sources = [];
try { sources = JSON.parse(sourcesJson || '[]'); } catch(e) {}
// Markdown-Rendering
let html = this.escape(summary);
// ## Überschriften
html = html.replace(/^## (.+)$/gm, '<h3 class="briefing-heading">$1</h3>');
// **Fettdruck**
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
// Listen (- Item)
html = html.replace(/^- (.+)$/gm, '<li>$1</li>');
html = html.replace(/(<li>.*<\/li>\n?)+/gs, '<ul>$&</ul>');
// Zeilenumbrüche (aber nicht nach Headings/Listen)
html = html.replace(/\n(?!<)/g, '<br>');
// Überflüssige <br> nach Block-Elementen entfernen + doppelte <br> zusammenfassen
html = html.replace(/<\/h3>(<br>)+/g, '</h3>');
html = html.replace(/<\/ul>(<br>)+/g, '</ul>');
html = html.replace(/(<br>){2,}/g, '<br>');
// Inline-Zitate [1], [2] etc. als klickbare Links rendern
if (sources.length > 0) {
html = html.replace(/\[(\d+[a-z]?)\]/g, (match, num) => {
const src = sources.find(s => String(s.nr) === num || Number(s.nr) === Number(num));
if (src && src.url) {
return `<a href="${this.escape(src.url)}" target="_blank" rel="noopener" class="citation" title="${this.escape(src.name)}">[${num}]</a>`;
}
return match;
});
}
return `<div class="briefing-content">${html}</div>`;
},
/**
* Quellenübersicht für eine Lage rendern.
*/
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]) => `<span class="source-lang-chip">${lang} <strong>${count}</strong></span>`)
.join('');
let html = `<div class="source-overview-header">`;
html += `<span class="source-overview-stat">${articles.length} Artikel aus ${sources.length} Quellen</span>`;
html += `<div class="source-lang-chips">${langChips}</div>`;
html += `</div>`;
html += '<div class="source-overview-grid">';
sources.forEach(([name, data]) => {
const langs = [...data.languages].map(l => l.toUpperCase()).join('/');
html += `<div class="source-overview-item">
<span class="source-overview-name">${this.escape(name)}</span>
<span class="source-overview-lang">${langs}</span>
<span class="source-overview-count">${data.count}</span>
</div>`;
});
html += '</div>';
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',
'sonstige': 'Sonstige',
},
/**
* 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 ? ` <span class="source-group-notes">${this.escape(excludedNotes)}</span>` : '';
return `<div class="source-group">
<div class="source-group-header excluded">
<div class="source-group-info">
<span class="source-group-name">${this.escape(displayName)}</span>${notesHtml}
</div>
<span class="source-excluded-badge">Ausgeschlossen</span>
<div class="source-group-actions">
<button class="btn btn-small btn-secondary" onclick="App.unblockDomain('${escapedDomain}')">Ausschluss aufheben</button>
</div>
</div>
</div>`;
}
// Aktive Domain-Gruppe
const toggleAttr = hasMultiple ? `onclick="App.toggleGroup('${escapedDomain}')" role="button" tabindex="0" aria-expanded="false"` : '';
const toggleIcon = hasMultiple ? '<span class="source-group-toggle" aria-hidden="true">&#9654;</span>' : '<span class="source-group-toggle-placeholder"></span>';
let feedRows = '';
if (hasMultiple) {
const realFeeds = feeds.filter(f => f.source_type !== 'excluded');
feedRows = `<div class="source-group-feeds" data-domain="${escapedDomain}">`;
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 += `<div class="source-feed-row">
<span class="source-feed-connector">${connector}</span>
<span class="source-feed-name">${this.escape(feed.name)}</span>
<span class="source-type-badge type-${feed.source_type}">${typeLabel}</span>
<span class="source-feed-url" title="${this.escape(feed.url || '')}">${this.escape(urlDisplay)}</span>
${!feed.is_global ? `<button class="source-edit-btn" onclick="App.editSource(${feed.id})" title="Bearbeiten" aria-label="Bearbeiten">&#9998;</button>
<button class="source-delete-btn" onclick="App.deleteSingleFeed(${feed.id})" title="Löschen" aria-label="Löschen">&times;</button>` : '<span class="source-global-badge">Grundquelle</span>'}
</div>`;
});
feedRows += '</div>';
}
const feedCountBadge = feedCount > 0
? `<span class="source-feed-count">${feedCount} Feed${feedCount !== 1 ? 's' : ''}</span>`
: '';
// Info-Button mit Tooltip (Typ, Sprache, Ausrichtung)
let infoButtonHtml = '';
const firstFeed = feeds[0] || {};
const hasInfo = firstFeed.language || firstFeed.bias;
if (hasInfo) {
const typeMap = { rss_feed: 'RSS-Feed', web_source: 'Web-Quelle', telegram_channel: 'Telegram-Kanal' };
const lines = [];
lines.push('Typ: ' + (typeMap[firstFeed.source_type] || firstFeed.source_type || 'Unbekannt'));
if (firstFeed.language) lines.push('Sprache: ' + firstFeed.language);
if (firstFeed.bias) lines.push('Ausrichtung: ' + firstFeed.bias);
const tooltipText = this.escape(lines.join('\n'));
infoButtonHtml = ` <span class="info-icon tooltip-below" data-tooltip="${tooltipText}"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg></span>`;
}
return `<div class="source-group">
<div class="source-group-header" ${toggleAttr}>
${toggleIcon}
<div class="source-group-info">
<span class="source-group-name">${this.escape(displayName)}</span>${infoButtonHtml}
</div>
<span class="source-category-badge cat-${feeds[0]?.category || 'sonstige'}">${catLabel}</span>
${feedCountBadge}
<div class="source-group-actions" onclick="event.stopPropagation()">
${!isGlobal && !hasMultiple && feeds[0]?.id ? `<button class="source-edit-btn" onclick="App.editSource(${feeds[0].id})" title="Bearbeiten" aria-label="Bearbeiten">&#9998;</button>` : ''}
<button class="btn btn-small btn-secondary" onclick="App.blockDomainDirect('${escapedDomain}')">Ausschließen</button>
${!isGlobal ? `<button class="source-delete-btn" onclick="App.deleteDomain('${escapedDomain}')" title="Löschen" aria-label="Löschen">&times;</button>` : ''}
</div>
</div>
${feedRows}
</div>`;
},
/**
* 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 = `<svg xmlns="http://www.w3.org/2000/svg" width="28" height="42" viewBox="0 0 28 42">` +
`<path d="M14 0C6.27 0 0 6.27 0 14c0 10.5 14 28 14 28s14-17.5 14-28C28 6.27 21.73 0 14 0z" fill="${fillColor}" stroke="${strokeColor}" stroke-width="1.5"/>` +
`<circle cx="14" cy="14" r="7" fill="#fff" opacity="0.9"/>` +
`<circle cx="14" cy="14" r="4" fill="${fillColor}"/>` +
`</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: '<div><span>' + count + '</span></div>',
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 = `<div class="map-popup">`;
popupHtml += `<div class="map-popup-title">${this.escape(loc.location_name)}`;
if (loc.country_code) popupHtml += ` <span class="map-popup-cc">${this.escape(loc.country_code)}</span>`;
popupHtml += `</div>`;
popupHtml += `<div class="map-popup-category" style="font-size:11px;margin-bottom:4px;"><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:${catColor};margin-right:4px;vertical-align:middle;"></span>${catLabel}</div>`;
popupHtml += `<div class="map-popup-count">${loc.article_count} Artikel</div>`;
popupHtml += `<div class="map-popup-articles">`;
const maxShow = 5;
loc.articles.slice(0, maxShow).forEach(art => {
const headline = this.escape(art.headline || 'Ohne Titel');
const source = this.escape(art.source || '');
if (art.source_url) {
popupHtml += `<a href="${this.escape(art.source_url)}" target="_blank" rel="noopener" class="map-popup-article">${headline} <span class="map-popup-source">${source}</span></a>`;
} else {
popupHtml += `<div class="map-popup-article">${headline} <span class="map-popup-source">${source}</span></div>`;
}
});
if (loc.articles.length > maxShow) {
popupHtml += `<div class="map-popup-more">+${loc.articles.length - maxShow} weitere</div>`;
}
popupHtml += `</div></div>`;
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 = '<strong style="display:block;margin-bottom:6px;">Filter</strong>';
['primary', 'secondary', 'tertiary', 'mentioned'].forEach(cat => {
if (usedCategories.has(cat) && legendLabels[cat]) {
html += '<label class="map-legend-item" style="display:flex;align-items:center;gap:6px;margin:3px 0;cursor:pointer;">'
+ '<input type="checkbox" checked data-map-cat="' + cat + '" style="accent-color:' + self2._categoryColors[cat] + ';margin:0;cursor:pointer;">'
+ '<span style="width:10px;height:10px;border-radius:50%;background:' + self2._categoryColors[cat] + ';flex-shrink:0;"></span>'
+ '<span>' + legendLabels[cat] + '</span></label>';
}
});
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 = '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>';
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;
},
};