Initial commit: AegisSight-Monitor (OSINT-Monitoringsystem)
Dieser Commit ist enthalten in:
625
src/static/js/components.js
Normale Datei
625
src/static/js/components.js
Normale Datei
@@ -0,0 +1,625 @@
|
||||
/**
|
||||
* 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 || '';
|
||||
|
||||
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}"></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 · ${this.escape(creator)}</div>
|
||||
</div>
|
||||
${incident.visibility === 'private' ? '<span class="badge badge-private" style="font-size:9px;">PRIVAT</span>' : ''}
|
||||
${incident.refresh_mode === 'auto' ? '<span class="badge badge-auto" title="Auto-Refresh aktiv">↻</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: '✓',
|
||||
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 `<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)">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>`;
|
||||
},
|
||||
|
||||
/**
|
||||
* Verifizierungs-Badge.
|
||||
*/
|
||||
verificationBadge(status) {
|
||||
const map = {
|
||||
verified: { class: 'badge-verified', text: 'Verifiziert' },
|
||||
unverified: { class: 'badge-unverified', text: 'Offen' },
|
||||
contradicted: { class: 'badge-contradicted', text: 'Widerlegt' },
|
||||
};
|
||||
const badge = map[status] || map.unverified;
|
||||
return `<span class="badge ${badge.class}">${badge.text}</span>`;
|
||||
},
|
||||
|
||||
/**
|
||||
* 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...' },
|
||||
};
|
||||
|
||||
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.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+)\]/g, (match, num) => {
|
||||
const src = sources.find(s => s.nr === parseInt(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',
|
||||
'sonstige': 'Sonstige',
|
||||
},
|
||||
|
||||
/**
|
||||
* Domain-Gruppe rendern (aufklappbar mit Feeds).
|
||||
*/
|
||||
renderSourceGroup(domain, feeds, isExcluded, excludedNotes) {
|
||||
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 || feeds[0]?.name || 'Unbekannt';
|
||||
const escapedDomain = this.escape(domain);
|
||||
|
||||
if (isExcluded) {
|
||||
// Gesperrte 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">Gesperrt</span>
|
||||
<div class="source-group-actions">
|
||||
<button class="btn btn-small btn-secondary" onclick="App.unblockDomain('${escapedDomain}')">Entsperren</button>
|
||||
<button class="source-delete-btn" onclick="App.deleteDomain('${escapedDomain}')" title="Löschen" aria-label="Löschen">×</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">▶</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>
|
||||
<button class="source-delete-btn" onclick="App.deleteSingleFeed(${feed.id})" title="Löschen" aria-label="Löschen">×</button>
|
||||
</div>`;
|
||||
});
|
||||
feedRows += '</div>';
|
||||
}
|
||||
|
||||
const feedCountBadge = feedCount > 0
|
||||
? `<span class="source-feed-count">${feedCount} Feed${feedCount !== 1 ? 's' : ''}</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>
|
||||
</div>
|
||||
<span class="source-category-badge cat-${feeds[0]?.category || 'sonstige'}">${catLabel}</span>
|
||||
${feedCountBadge}
|
||||
<div class="source-group-actions" onclick="event.stopPropagation()">
|
||||
<button class="btn btn-small btn-secondary" onclick="App.blockDomainDirect('${escapedDomain}')">Sperren</button>
|
||||
<button class="source-delete-btn" onclick="App.deleteDomain('${escapedDomain}')" title="Löschen" aria-label="Löschen">×</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;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* URLs in Evidence-Text als kompakte Hostname-Chips rendern (Legacy-Fallback).
|
||||
*/
|
||||
renderEvidenceChips(text) {
|
||||
return this.renderEvidence(text);
|
||||
},
|
||||
|
||||
/**
|
||||
* URLs in Evidence-Text als klickbare Links rendern (Legacy).
|
||||
*/
|
||||
linkifyEvidence(text) {
|
||||
if (!text) return '';
|
||||
const escaped = this.escape(text);
|
||||
return escaped.replace(
|
||||
/(https?:\/\/[^\s,)]+)/g,
|
||||
'<a href="$1" target="_blank" rel="noopener">$1</a>'
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* HTML escapen.
|
||||
*/
|
||||
escape(str) {
|
||||
if (!str) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
},
|
||||
};
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren