Initial commit: AegisSight-Monitor (OSINT-Monitoringsystem)
BIN
src/static/apple-touch-icon.png
Normale Datei
|
Nachher Breite: | Höhe: | Größe: 8.6 KiB |
444
src/static/components.js
Normale Datei
@@ -0,0 +1,444 @@
|
||||
/**
|
||||
* 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.type === 'research' ? '<span class="badge badge-research" style="font-size:9px;">RECH</span>' : ''}
|
||||
${incident.refresh_mode === 'auto' ? '<span class="badge badge-auto" style="font-size:9px;">AUTO</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',
|
||||
},
|
||||
|
||||
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 count = statusCounts[status];
|
||||
return `<label class="fc-dropdown-item" data-status="${status}">
|
||||
<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.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.
|
||||
*/
|
||||
showToast(message, type = 'info', duration = 5000) {
|
||||
const container = document.getElementById('toast-container');
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast toast-${type}`;
|
||||
toast.setAttribute('role', 'status');
|
||||
toast.innerHTML = `<span class="toast-text">${this.escape(message)}</span>`;
|
||||
container.appendChild(toast);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.style.opacity = '0';
|
||||
toast.style.transform = 'translateX(100%)';
|
||||
toast.style.transition = 'all 0.3s ease';
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, duration);
|
||||
},
|
||||
|
||||
/**
|
||||
* Fortschrittsanzeige einblenden und Status setzen.
|
||||
*/
|
||||
showProgress(status) {
|
||||
const bar = document.getElementById('progress-bar');
|
||||
if (!bar) return;
|
||||
bar.style.display = 'block';
|
||||
|
||||
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;
|
||||
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', step.label);
|
||||
|
||||
const label = document.getElementById('progress-label');
|
||||
if (label) label.textContent = step.label;
|
||||
},
|
||||
|
||||
/**
|
||||
* Fortschrittsanzeige ausblenden.
|
||||
*/
|
||||
hideProgress() {
|
||||
const bar = document.getElementById('progress-bar');
|
||||
if (bar) bar.style.display = 'none';
|
||||
},
|
||||
|
||||
/**
|
||||
* 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;
|
||||
},
|
||||
};
|
||||
4027
src/static/css/style.css
Normale Datei
541
src/static/dashboard.html
Normale Datei
@@ -0,0 +1,541 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<script>(function(){var t=localStorage.getItem('osint_theme');if(t)document.documentElement.setAttribute('data-theme',t);try{var a=JSON.parse(localStorage.getItem('osint_a11y')||'{}');Object.keys(a).forEach(function(k){if(a[k])document.documentElement.setAttribute('data-a11y-'+k,'true');});}catch(e){}})()</script>
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png">
|
||||
<link rel="shortcut icon" href="/static/favicon.ico">
|
||||
<title>AegisSight Monitor</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/gridstack@12/dist/gridstack.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/static/css/style.css?v=20260304b">
|
||||
</head>
|
||||
<body>
|
||||
<a href="#main-content" class="skip-link">Zum Hauptinhalt springen</a>
|
||||
<div class="dashboard">
|
||||
<!-- Header -->
|
||||
<header class="header">
|
||||
<div class="header-left">
|
||||
<div class="header-logo">Aegis<span>Sight</span> Monitor</div>
|
||||
<h1 class="sr-only">AegisSight Monitor Dashboard</h1>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<button class="btn btn-secondary btn-small theme-toggle-btn" id="theme-toggle" onclick="ThemeManager.toggle()" title="Theme wechseln" aria-label="Theme wechseln">☼</button>
|
||||
<span class="header-user" id="header-user"></span>
|
||||
<button class="btn btn-secondary btn-small" id="logout-btn">Abmelden</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<nav class="sidebar" aria-label="Seitenleiste">
|
||||
<div class="sidebar-section">
|
||||
<button class="btn btn-primary btn-full btn-small" id="new-incident-btn">+ Neue Lage / Recherche</button>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-filter">
|
||||
<button class="sidebar-filter-btn active" data-filter="all" onclick="App.setSidebarFilter('all')" aria-pressed="true">Alle</button>
|
||||
<button class="sidebar-filter-btn" data-filter="mine" onclick="App.setSidebarFilter('mine')" aria-pressed="false">Eigene</button>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-section">
|
||||
<h2 class="sidebar-section-title collapsible" onclick="App.toggleSidebarSection('active-incidents')" role="button" tabindex="0">
|
||||
<span class="sidebar-chevron" id="chevron-active-incidents">▾</span>
|
||||
Aktive Lagen
|
||||
<span class="sidebar-section-count" id="count-active-incidents"></span>
|
||||
</h2>
|
||||
<div id="active-incidents" aria-live="polite"></div>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-section">
|
||||
<h2 class="sidebar-section-title collapsible" onclick="App.toggleSidebarSection('active-research')" role="button" tabindex="0">
|
||||
<span class="sidebar-chevron" id="chevron-active-research">▾</span>
|
||||
Aktive Recherchen
|
||||
<span class="sidebar-section-count" id="count-active-research"></span>
|
||||
</h2>
|
||||
<div id="active-research" aria-live="polite"></div>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-section">
|
||||
<h2 class="sidebar-section-title collapsible" onclick="App.toggleSidebarSection('archived-incidents')" role="button" tabindex="0">
|
||||
<span class="sidebar-chevron" id="chevron-archived-incidents">▾</span>
|
||||
Archiv
|
||||
<span class="sidebar-section-count" id="count-archived-incidents"></span>
|
||||
</h2>
|
||||
<div id="archived-incidents" aria-live="polite"></div>
|
||||
</div>
|
||||
<div class="sidebar-sources-link">
|
||||
<button class="btn btn-secondary btn-full btn-small" onclick="App.openSourceManagement()">Quellen verwalten</button>
|
||||
<button class="btn btn-secondary btn-full btn-small sidebar-feedback-btn" onclick="App.openFeedback()">Feedback senden</button>
|
||||
<div class="sidebar-stats-mini">
|
||||
<span id="stat-sources-count">0 Quellen</span> · <span id="stat-articles-count">0 Artikel</span>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="main-content" id="main-content">
|
||||
<div class="empty-state" id="empty-state">
|
||||
<div class="empty-state-icon">☉</div>
|
||||
<div class="empty-state-title">Kein Vorfall ausgewählt</div>
|
||||
<div class="empty-state-text">Erstelle eine neue Lage oder wähle einen bestehenden Vorfall aus der Seitenleiste.</div>
|
||||
</div>
|
||||
|
||||
<!-- Lagebild (hidden by default) -->
|
||||
<div id="incident-view" style="display:none;">
|
||||
<!-- Header Strip -->
|
||||
<div class="incident-header-strip" id="incident-header-strip">
|
||||
<div class="incident-header-row0">
|
||||
<span class="incident-type-label" id="incident-type-badge"></span>
|
||||
<span class="auto-refresh-indicator" id="meta-refresh-mode"></span>
|
||||
</div>
|
||||
<div class="incident-header-row1">
|
||||
<div class="incident-header-left">
|
||||
<h2 class="incident-header-title" id="incident-title"></h2>
|
||||
</div>
|
||||
<div class="incident-header-actions">
|
||||
<button class="btn btn-primary btn-small" id="refresh-btn">Aktualisieren</button>
|
||||
<button class="btn btn-secondary btn-small" id="edit-incident-btn">Bearbeiten</button>
|
||||
<div class="export-dropdown" id="export-dropdown">
|
||||
<button class="btn btn-secondary btn-small" onclick="App.toggleExportDropdown(event)">Exportieren ▾</button>
|
||||
<div class="export-dropdown-menu" id="export-dropdown-menu">
|
||||
<button class="export-dropdown-item" onclick="App.exportIncident('md','report')">Lagebericht (Markdown)</button>
|
||||
<button class="export-dropdown-item" onclick="App.exportIncident('json','report')">Lagebericht (JSON)</button>
|
||||
<hr class="export-dropdown-divider">
|
||||
<button class="export-dropdown-item" onclick="App.exportIncident('md','full')">Vollexport (Markdown)</button>
|
||||
<button class="export-dropdown-item" onclick="App.exportIncident('json','full')">Vollexport (JSON)</button>
|
||||
<hr class="export-dropdown-divider">
|
||||
<button class="export-dropdown-item" onclick="App.printIncident()">Drucken / PDF</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-secondary btn-small" id="archive-incident-btn">Archivieren</button>
|
||||
<button class="btn btn-danger btn-small" id="delete-incident-btn">Löschen</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="incident-header-row2">
|
||||
<div class="incident-header-row2-left">
|
||||
<span class="incident-creator-badge">von <strong id="incident-creator"></strong></span>
|
||||
<span class="intl-badge" id="intl-badge"></span>
|
||||
<span id="incident-description" class="incident-description-text"></span>
|
||||
</div>
|
||||
<div class="incident-header-row2-right">
|
||||
<div class="summary-meta" id="summary-meta">
|
||||
<span id="meta-updated" class="meta-updated-link" role="button" tabindex="0" onclick="App.toggleRefreshHistory()" onkeydown="if(event.key==='Enter')App.toggleRefreshHistory()"></span>
|
||||
</div>
|
||||
<div class="refresh-history-popover" id="refresh-history-popover" style="display:none;">
|
||||
<div class="refresh-history-header">
|
||||
<span class="refresh-history-title">Refresh-Verlauf</span>
|
||||
<button class="refresh-history-close" onclick="App.closeRefreshHistory()">×</button>
|
||||
</div>
|
||||
<div class="refresh-history-list" id="refresh-history-list">
|
||||
<div style="padding:12px;color:var(--text-disabled);font-size:12px;">Lade...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fortschrittsanzeige -->
|
||||
<div class="progress-bar" id="progress-bar" role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" aria-label="Verarbeitungsfortschritt" style="display:none;">
|
||||
<div class="progress-steps">
|
||||
<div class="progress-step" id="step-researching">
|
||||
<div class="progress-step-dot"></div>
|
||||
<span>Recherche</span>
|
||||
</div>
|
||||
<div class="progress-step" id="step-analyzing">
|
||||
<div class="progress-step-dot"></div>
|
||||
<span>Analyse</span>
|
||||
</div>
|
||||
<div class="progress-step" id="step-factchecking">
|
||||
<div class="progress-step-dot"></div>
|
||||
<span>Faktencheck</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="progress-track">
|
||||
<div class="progress-fill" id="progress-fill"></div>
|
||||
</div>
|
||||
<div class="progress-label-container">
|
||||
<span id="progress-label" class="progress-label">Warte auf Start...</span>
|
||||
<span id="progress-timer" class="progress-timer"></span>
|
||||
</div>
|
||||
<button id="progress-cancel-btn" class="progress-cancel-btn" onclick="App.cancelRefresh()">Abbrechen</button>
|
||||
</div>
|
||||
|
||||
<!-- Layout-Toolbar -->
|
||||
<div class="layout-toolbar" id="layout-toolbar" style="display:none;">
|
||||
<div class="layout-toggles">
|
||||
<button class="layout-toggle-btn active" data-tile="lagebild" onclick="LayoutManager.toggleTile('lagebild')" aria-pressed="true">Lagebild</button>
|
||||
<button class="layout-toggle-btn active" data-tile="faktencheck" onclick="LayoutManager.toggleTile('faktencheck')" aria-pressed="true">Faktencheck</button>
|
||||
<button class="layout-toggle-btn active" data-tile="quellen" onclick="LayoutManager.toggleTile('quellen')" aria-pressed="true">Quellen</button>
|
||||
<button class="layout-toggle-btn active" data-tile="timeline" onclick="LayoutManager.toggleTile('timeline')" aria-pressed="true">Timeline</button>
|
||||
</div>
|
||||
<button class="btn btn-secondary btn-small" onclick="LayoutManager.reset()">Layout zurücksetzen</button>
|
||||
</div>
|
||||
|
||||
<!-- gridstack Dashboard-Grid -->
|
||||
<div class="grid-stack">
|
||||
<div class="grid-stack-item" gs-id="lagebild" gs-x="0" gs-y="0" gs-w="6" gs-h="4" gs-min-w="4" gs-min-h="4">
|
||||
<div class="grid-stack-item-content">
|
||||
<div class="card incident-analysis-summary">
|
||||
<div class="card-header">
|
||||
<div class="card-title clickable" onclick="openContentModal('Lagebild', 'summary-content')">Lagebild</div>
|
||||
<span class="lagebild-timestamp" id="lagebild-timestamp"></span>
|
||||
</div>
|
||||
<div id="summary-content">
|
||||
<div id="summary-text" class="summary-text"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid-stack-item" gs-id="faktencheck" gs-x="6" gs-y="0" gs-w="6" gs-h="4" gs-min-w="4" gs-min-h="4">
|
||||
<div class="grid-stack-item-content">
|
||||
<div class="card incident-analysis-factcheck" id="factcheck-card">
|
||||
<div class="card-header">
|
||||
<div class="card-title clickable" onclick="openContentModal('Faktencheck', 'factcheck-list')">Faktencheck</div>
|
||||
<div class="fc-filter-bar" id="fc-filters"></div>
|
||||
</div>
|
||||
<div class="factcheck-list" id="factcheck-list">
|
||||
<div class="empty-state" style="padding:20px;">
|
||||
<div class="empty-state-text">Noch keine Fakten geprüft</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid-stack-item" gs-id="quellen" gs-x="0" gs-y="4" gs-w="12" gs-h="2" gs-min-w="6" gs-min-h="2">
|
||||
<div class="grid-stack-item-content">
|
||||
<div class="card source-overview-card">
|
||||
<div class="card-header source-overview-header-toggle" onclick="App.toggleSourceOverview()" role="button" tabindex="0" aria-expanded="false">
|
||||
<span class="source-overview-chevron" id="source-overview-chevron" title="Aufklappen" aria-hidden="true">▸</span>
|
||||
<div class="card-title clickable">Quellenübersicht</div>
|
||||
<button class="btn btn-secondary btn-small source-detail-btn" onclick="event.stopPropagation(); openContentModal('Quellenübersicht', 'source-overview-content')">Detailansicht</button>
|
||||
</div>
|
||||
<div id="source-overview-content" style="display:none;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid-stack-item" gs-id="timeline" gs-x="0" gs-y="5" gs-w="12" gs-h="4" gs-min-w="6" gs-min-h="4">
|
||||
<div class="grid-stack-item-content">
|
||||
<div class="card timeline-card">
|
||||
<div class="card-header">
|
||||
<div class="card-title clickable" onclick="openContentModal('Ereignis-Timeline', 'timeline')">Ereignis-Timeline</div>
|
||||
<div class="ht-controls">
|
||||
<div class="ht-filter-group">
|
||||
<button class="ht-filter-btn active" data-filter="all" onclick="App.setTimelineFilter('all')" aria-pressed="true">Alle</button>
|
||||
<button class="ht-filter-btn" data-filter="articles" onclick="App.setTimelineFilter('articles')" aria-pressed="false">Meldungen</button>
|
||||
<button class="ht-filter-btn" data-filter="snapshots" onclick="App.setTimelineFilter('snapshots')" aria-pressed="false">Lageberichte</button>
|
||||
</div>
|
||||
<span class="ht-count" id="article-count"></span>
|
||||
<div class="ht-range-group">
|
||||
<button class="ht-range-btn" data-range="24h" onclick="App.setTimelineRange('24h')" aria-pressed="false">24h</button>
|
||||
<button class="ht-range-btn" data-range="7d" onclick="App.setTimelineRange('7d')" aria-pressed="false">7T</button>
|
||||
<button class="ht-range-btn active" data-range="all" onclick="App.setTimelineRange('all')" aria-pressed="true">Alles</button>
|
||||
</div>
|
||||
<label for="timeline-search" class="sr-only">Timeline durchsuchen</label>
|
||||
<input type="text" id="timeline-search" class="timeline-filter-input" placeholder="Suche..." oninput="App.debouncedRerenderTimeline()">
|
||||
</div>
|
||||
</div>
|
||||
<div id="timeline" class="ht-timeline-container">
|
||||
<div class="ht-empty">Noch keine Meldungen</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Parkplatz für ausgeblendete Kacheln -->
|
||||
<div id="tile-parking" style="display:none;"></div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Modal: Neue Lage -->
|
||||
<div class="modal-overlay" id="modal-new" role="dialog" aria-modal="true" aria-labelledby="modal-new-title">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<div class="modal-title" id="modal-new-title">Neue Lage anlegen</div>
|
||||
<button class="modal-close" onclick="closeModal('modal-new')" aria-label="Schließen">×</button>
|
||||
</div>
|
||||
<form id="new-incident-form">
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label for="inc-title">Titel des Vorfalls</label>
|
||||
<input type="text" id="inc-title" required placeholder="z.B. Explosion in Madrid">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="inc-description">Beschreibung / Kontext</label>
|
||||
<textarea id="inc-description" placeholder="Weitere Details zum Vorfall (optional)"></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="inc-type">Art der Lage</label>
|
||||
<select id="inc-type" onchange="toggleTypeDefaults()">
|
||||
<option value="adhoc">Ad-hoc Lage (Breaking News)</option>
|
||||
<option value="research">Recherche (Hintergrund)</option>
|
||||
</select>
|
||||
<div class="form-hint" id="type-hint">
|
||||
RSS-Feeds + WebSearch, automatische Aktualisierung empfohlen
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Quellen</label>
|
||||
<div class="toggle-group">
|
||||
<label class="toggle-label">
|
||||
<input type="checkbox" id="inc-international" checked>
|
||||
<span class="toggle-switch"></span>
|
||||
<span class="toggle-text">Internationale Quellen einbeziehen</span>
|
||||
</label>
|
||||
<div class="form-hint" id="sources-hint">DE + internationale Feeds (Reuters, BBC, Al Jazeera etc.)</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Sichtbarkeit</label>
|
||||
<div class="toggle-group">
|
||||
<label class="toggle-label">
|
||||
<input type="checkbox" id="inc-visibility" checked>
|
||||
<span class="toggle-switch"></span>
|
||||
<span class="toggle-text" id="visibility-text">Öffentlich — für alle Nutzer sichtbar</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="inc-refresh-mode">Aktualisierung</label>
|
||||
<select id="inc-refresh-mode" onchange="toggleRefreshInterval()">
|
||||
<option value="manual">Manuell</option>
|
||||
<option value="auto">Automatisch</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group conditional-field" id="refresh-interval-field">
|
||||
<label for="inc-refresh-value">Intervall</label>
|
||||
<div class="interval-input-group">
|
||||
<input type="number" id="inc-refresh-value" min="10" value="15">
|
||||
<select id="inc-refresh-unit" onchange="updateIntervalMin()">
|
||||
<option value="1" selected>Minuten</option>
|
||||
<option value="60">Stunden</option>
|
||||
<option value="1440">Tage</option>
|
||||
<option value="10080">Wochen</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="inc-retention">Aufbewahrung (Tage)</label>
|
||||
<input type="number" id="inc-retention" min="0" max="999" value="30" placeholder="0 = Unbegrenzt">
|
||||
<div class="form-hint">0 = Unbegrenzt, max. 999 Tage</div>
|
||||
</div>
|
||||
<div class="form-group" style="margin-top: 8px;">
|
||||
<label>E-Mail-Benachrichtigungen</label>
|
||||
<div class="form-hint" style="margin-bottom: 8px;">Per E-Mail benachrichtigen bei:</div>
|
||||
<div class="toggle-group">
|
||||
<label class="toggle-label">
|
||||
<input type="checkbox" id="inc-notify-summary">
|
||||
<span class="toggle-switch"></span>
|
||||
<span class="toggle-text">Neues Lagebild</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="toggle-group" style="margin-top: 8px;">
|
||||
<label class="toggle-label">
|
||||
<input type="checkbox" id="inc-notify-new-articles">
|
||||
<span class="toggle-switch"></span>
|
||||
<span class="toggle-text">Neue Artikel</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="toggle-group" style="margin-top: 8px;">
|
||||
<label class="toggle-label">
|
||||
<input type="checkbox" id="inc-notify-status-change">
|
||||
<span class="toggle-switch"></span>
|
||||
<span class="toggle-text">Statusänderung Faktencheck</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeModal('modal-new')">Abbrechen</button>
|
||||
<button type="submit" class="btn btn-primary" id="modal-new-submit">Lage anlegen</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal: Quellenverwaltung -->
|
||||
<div class="modal-overlay" id="modal-sources" role="dialog" aria-modal="true" aria-labelledby="modal-sources-title">
|
||||
<div class="modal modal-wide">
|
||||
<div class="modal-header">
|
||||
<div class="modal-title" id="modal-sources-title">Quellenverwaltung</div>
|
||||
<button class="modal-close" onclick="closeModal('modal-sources')" aria-label="Schließen">×</button>
|
||||
</div>
|
||||
<div class="modal-body sources-modal-body">
|
||||
<!-- Stats-Leiste -->
|
||||
<div class="sources-stats-bar" id="sources-stats-bar"></div>
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="sources-toolbar">
|
||||
<div class="sources-filters">
|
||||
<label for="sources-filter-type" class="sr-only">Quellentyp filtern</label>
|
||||
<select id="sources-filter-type" class="timeline-filter-select" onchange="App.filterSources()">
|
||||
<option value="">Alle Typen</option>
|
||||
<option value="rss_feed">RSS-Feed</option>
|
||||
<option value="web_source">Web-Quelle</option>
|
||||
<option value="excluded">Gesperrt</option>
|
||||
</select>
|
||||
<label for="sources-filter-category" class="sr-only">Kategorie filtern</label>
|
||||
<select id="sources-filter-category" class="timeline-filter-select" onchange="App.filterSources()">
|
||||
<option value="">Alle Kategorien</option>
|
||||
<option value="nachrichtenagentur">Nachrichtenagentur</option>
|
||||
<option value="oeffentlich-rechtlich">Öffentlich-Rechtlich</option>
|
||||
<option value="qualitaetszeitung">Qualitätszeitung</option>
|
||||
<option value="behoerde">Behörde</option>
|
||||
<option value="fachmedien">Fachmedien</option>
|
||||
<option value="think-tank">Think Tank</option>
|
||||
<option value="international">International</option>
|
||||
<option value="regional">Regional</option>
|
||||
<option value="sonstige">Sonstige</option>
|
||||
</select>
|
||||
<label for="sources-search" class="sr-only">Quellen durchsuchen</label>
|
||||
<input type="text" id="sources-search" class="timeline-filter-input sources-search-input" placeholder="Suche..." oninput="App.filterSources()">
|
||||
</div>
|
||||
<div class="sources-toolbar-actions">
|
||||
<button class="btn btn-secondary btn-small source-block-btn" onclick="App.showBlockDomainDialog()">Domain sperren</button>
|
||||
<button class="btn btn-primary btn-small" onclick="App.toggleSourceForm()">+ Quelle</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Inline-Formular: Domain sperren (ein-/ausklappbar) -->
|
||||
<div class="sources-add-form" id="sources-block-form" style="display:none;">
|
||||
<div class="sources-form-row">
|
||||
<div class="form-group flex-1">
|
||||
<label for="block-domain-input">Domain</label>
|
||||
<input type="text" id="block-domain-input" placeholder="z.B. bild.de">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="block-domain-notes">Notizen</label>
|
||||
<input type="text" id="block-domain-notes" class="source-notes-input" placeholder="Optional">
|
||||
</div>
|
||||
<button class="btn btn-danger btn-small" onclick="App.blockDomain()">Sperren</button>
|
||||
<button class="btn btn-secondary btn-small" onclick="App.showBlockDomainDialog(false)">Abbrechen</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Inline-Formular: Quelle hinzufügen (ein-/ausklappbar) -->
|
||||
<div class="sources-add-form" id="sources-add-form" style="display:none;">
|
||||
<div class="sources-form-row">
|
||||
<div class="form-group flex-1">
|
||||
<label for="src-discover-url">URL oder Domain</label>
|
||||
<input type="text" id="src-discover-url" placeholder="z.B. netzpolitik.org">
|
||||
</div>
|
||||
<button class="btn btn-secondary btn-small" id="src-discover-btn" onclick="App.discoverSource()">Erkennen</button>
|
||||
</div>
|
||||
|
||||
<!-- Ergebnis-Anzeige (nach Discovery) -->
|
||||
<div id="src-discovery-result" class="sources-discovery-result" style="display:none;">
|
||||
<div class="sources-add-form-grid">
|
||||
<div class="form-group">
|
||||
<label for="src-name">Name</label>
|
||||
<input type="text" id="src-name" placeholder="Wird erkannt...">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="src-category">Kategorie</label>
|
||||
<select id="src-category">
|
||||
<option value="nachrichtenagentur">Nachrichtenagentur</option>
|
||||
<option value="oeffentlich-rechtlich">Öffentlich-Rechtlich</option>
|
||||
<option value="qualitaetszeitung">Qualitätszeitung</option>
|
||||
<option value="behoerde">Behörde</option>
|
||||
<option value="fachmedien">Fachmedien</option>
|
||||
<option value="think-tank">Think Tank</option>
|
||||
<option value="international">International</option>
|
||||
<option value="regional">Regional</option>
|
||||
<option value="sonstige" selected>Sonstige</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Typ</label>
|
||||
<input type="text" id="src-type-display" class="input-readonly" readonly>
|
||||
</div>
|
||||
<div class="form-group" id="src-rss-url-group">
|
||||
<label>RSS-Feed URL</label>
|
||||
<input type="text" id="src-rss-url" class="input-readonly" readonly>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Domain</label>
|
||||
<input type="text" id="src-domain" class="input-readonly" readonly>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="src-notes">Notizen</label>
|
||||
<input type="text" id="src-notes" placeholder="Optional">
|
||||
</div>
|
||||
</div>
|
||||
<div class="sources-discovery-actions">
|
||||
<button class="btn btn-primary btn-small" onclick="App.saveSource()">Speichern</button>
|
||||
<button class="btn btn-secondary btn-small" onclick="App.toggleSourceForm(false)">Abbrechen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quellen-Liste (gruppiert) -->
|
||||
<div class="sources-list" id="sources-list">
|
||||
<div class="empty-state-text" style="padding:var(--sp-3xl);text-align:center;">Lade Quellen...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal: Content-Viewer (wiederverwendbar für Lagebild, Faktencheck, Quellenübersicht, Timeline) -->
|
||||
<div class="modal-overlay" id="modal-content-viewer" role="dialog" aria-modal="true" aria-labelledby="content-viewer-title">
|
||||
<div class="modal modal-content-viewer">
|
||||
<div class="modal-header">
|
||||
<div class="modal-title" id="content-viewer-title"></div>
|
||||
<div class="modal-header-extra" id="content-viewer-header-extra"></div>
|
||||
<button class="modal-close" onclick="closeModal('modal-content-viewer')" aria-label="Schließen">×</button>
|
||||
</div>
|
||||
<div class="modal-body" id="content-viewer-body"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal: Feedback -->
|
||||
<div class="modal-overlay" id="modal-feedback" role="dialog" aria-modal="true" aria-labelledby="modal-feedback-title">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<div class="modal-title" id="modal-feedback-title">Feedback senden</div>
|
||||
<button class="modal-close" onclick="closeModal('modal-feedback')" aria-label="Schliessen">×</button>
|
||||
</div>
|
||||
<form id="feedback-form">
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label for="fb-category">Kategorie</label>
|
||||
<select id="fb-category">
|
||||
<option value="bug">Fehlerbericht</option>
|
||||
<option value="feature">Feature-Wunsch</option>
|
||||
<option value="question">Frage</option>
|
||||
<option value="other">Sonstiges</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="fb-message">Nachricht</label>
|
||||
<textarea id="fb-message" required minlength="10" maxlength="5000" rows="6" placeholder="Beschreibe dein Anliegen (mind. 10 Zeichen)..."></textarea>
|
||||
<div class="form-hint"><span id="fb-char-count">0</span> / 5.000 Zeichen</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeModal('modal-feedback')">Abbrechen</button>
|
||||
<button type="submit" class="btn btn-primary" id="fb-submit-btn">Absenden</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast Container -->
|
||||
<div class="toast-container" id="toast-container" aria-live="polite" aria-atomic="true"></div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/gridstack@12/dist/gridstack-all.js"></script>
|
||||
<script src="/static/js/api.js?v=20260304a"></script>
|
||||
<script src="/static/js/ws.js?v=20260304a"></script>
|
||||
<script src="/static/js/components.js?v=20260304a"></script>
|
||||
<script src="/static/js/layout.js?v=20260304a"></script>
|
||||
<script src="/static/js/app.js?v=20260304a"></script>
|
||||
</body>
|
||||
</html>
|
||||
BIN
src/static/favicon-16x16.png
Normale Datei
|
Nachher Breite: | Höhe: | Größe: 568 B |
BIN
src/static/favicon-192x192.png
Normale Datei
|
Nachher Breite: | Höhe: | Größe: 9.3 KiB |
BIN
src/static/favicon-32x32.png
Normale Datei
|
Nachher Breite: | Höhe: | Größe: 1.3 KiB |
BIN
src/static/favicon-48x48.png
Normale Datei
|
Nachher Breite: | Höhe: | Größe: 2.1 KiB |
BIN
src/static/favicon-512x512.png
Normale Datei
|
Nachher Breite: | Höhe: | Größe: 27 KiB |
BIN
src/static/favicon.ico
Normale Datei
|
Nachher Breite: | Höhe: | Größe: 590 B |
212
src/static/index.html
Normale Datei
@@ -0,0 +1,212 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<script>(function(){var t=localStorage.getItem('osint_theme');if(t)document.documentElement.setAttribute('data-theme',t);try{var a=JSON.parse(localStorage.getItem('osint_a11y')||'{}');Object.keys(a).forEach(function(k){if(a[k])document.documentElement.setAttribute('data-a11y-'+k,'true');});}catch(e){}})()</script>
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png">
|
||||
<link rel="shortcut icon" href="/static/favicon.ico">
|
||||
<title>AegisSight Monitor - Login</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/static/css/style.css?v=20260304a">
|
||||
</head>
|
||||
<body>
|
||||
<a href="#login-form" class="skip-link">Zum Anmeldeformular springen</a>
|
||||
<main class="login-container">
|
||||
<div class="login-box">
|
||||
<div class="login-logo">
|
||||
<h1>Aegis<span style="color: var(--accent)">Sight</span></h1>
|
||||
<div class="subtitle">Lagemonitor</div>
|
||||
</div>
|
||||
|
||||
<div id="login-error" class="login-error" role="alert" aria-live="assertive"></div>
|
||||
<div id="login-success" class="login-success" role="status" aria-live="polite" style="display:none;"></div>
|
||||
|
||||
<!-- Schritt 1: E-Mail eingeben -->
|
||||
<form id="email-form">
|
||||
<div class="form-group">
|
||||
<label for="email">E-Mail-Adresse</label>
|
||||
<input type="email" id="email" name="email" autocomplete="email" required placeholder="name@organisation.de">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-full" id="email-btn">Anmelden</button>
|
||||
</form>
|
||||
|
||||
<!-- Schritt 2: Code eingeben -->
|
||||
<form id="code-form" style="display:none;">
|
||||
<p style="color: var(--text-secondary); margin: 0 0 16px 0; font-size: 14px;">
|
||||
Ein 6-stelliger Code wurde an <strong id="sent-email"></strong> gesendet.
|
||||
</p>
|
||||
<div class="form-group">
|
||||
<label for="code">Code eingeben</label>
|
||||
<input type="text" id="code" name="code" autocomplete="one-time-code" required
|
||||
placeholder="000000" maxlength="6" pattern="[0-9]{6}"
|
||||
style="text-align:center; font-size:24px; letter-spacing:8px; font-family:monospace;">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-full" id="code-btn">Verifizieren</button>
|
||||
<button type="button" class="btn btn-secondary btn-full" id="back-btn" style="margin-top:8px;">Zurueck</button>
|
||||
</form>
|
||||
|
||||
<div style="text-align:center;margin-top:16px;">
|
||||
<button class="btn btn-secondary btn-small theme-toggle-btn" id="theme-toggle" onclick="ThemeManager.toggle()" title="Theme wechseln" aria-label="Theme wechseln">☼</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const ThemeManager = {
|
||||
_key: 'osint_theme',
|
||||
init() {
|
||||
const saved = localStorage.getItem(this._key);
|
||||
const theme = saved || 'dark';
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
this._updateIcon(theme);
|
||||
},
|
||||
toggle() {
|
||||
const current = document.documentElement.getAttribute('data-theme') || 'dark';
|
||||
const next = current === 'dark' ? 'light' : 'dark';
|
||||
document.documentElement.setAttribute('data-theme', next);
|
||||
localStorage.setItem(this._key, next);
|
||||
this._updateIcon(next);
|
||||
},
|
||||
_updateIcon(theme) {
|
||||
const btn = document.getElementById('theme-toggle');
|
||||
if (btn) btn.textContent = theme === 'dark' ? '\u2600' : '\u263D';
|
||||
}
|
||||
};
|
||||
ThemeManager.init();
|
||||
</script>
|
||||
<script>
|
||||
// Pruefen ob bereits eingeloggt
|
||||
const token = localStorage.getItem('osint_token');
|
||||
if (token) {
|
||||
fetch('/api/auth/me', {
|
||||
headers: { 'Authorization': 'Bearer ' + token }
|
||||
}).then(r => {
|
||||
if (r.ok) window.location.href = '/dashboard';
|
||||
});
|
||||
}
|
||||
|
||||
// URL-Parameter pruefen (Magic Link Token)
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const verifyToken = urlParams.get('token');
|
||||
|
||||
if (verifyToken) {
|
||||
// Direkte Token-Verifikation
|
||||
(async () => {
|
||||
try {
|
||||
const response = await fetch('/api/auth/verify', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token: verifyToken }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.detail || 'Verifikation fehlgeschlagen');
|
||||
}
|
||||
const data = await response.json();
|
||||
localStorage.setItem('osint_token', data.access_token);
|
||||
localStorage.setItem('osint_username', data.username);
|
||||
window.location.href = '/dashboard';
|
||||
} catch (err) {
|
||||
const errorEl = document.getElementById('login-error');
|
||||
errorEl.textContent = err.message;
|
||||
errorEl.style.display = 'block';
|
||||
// URL bereinigen
|
||||
window.history.replaceState({}, '', '/');
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
let currentEmail = '';
|
||||
|
||||
// Schritt 1: E-Mail senden
|
||||
document.getElementById('email-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const errorEl = document.getElementById('login-error');
|
||||
const successEl = document.getElementById('login-success');
|
||||
const btn = document.getElementById('email-btn');
|
||||
errorEl.style.display = 'none';
|
||||
successEl.style.display = 'none';
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Wird gesendet...';
|
||||
|
||||
currentEmail = document.getElementById('email').value.trim();
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth/magic-link', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: currentEmail }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.detail || 'Anfrage fehlgeschlagen');
|
||||
}
|
||||
|
||||
// Zu Code-Eingabe wechseln
|
||||
document.getElementById('email-form').style.display = 'none';
|
||||
document.getElementById('code-form').style.display = 'block';
|
||||
document.getElementById('sent-email').textContent = currentEmail;
|
||||
document.getElementById('code').focus();
|
||||
|
||||
} catch (err) {
|
||||
errorEl.textContent = err.message;
|
||||
errorEl.style.display = 'block';
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Anmelden';
|
||||
}
|
||||
});
|
||||
|
||||
// Schritt 2: Code verifizieren
|
||||
document.getElementById('code-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const errorEl = document.getElementById('login-error');
|
||||
const btn = document.getElementById('code-btn');
|
||||
errorEl.style.display = 'none';
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Wird geprueft...';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth/verify-code', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
email: currentEmail,
|
||||
code: document.getElementById('code').value.trim(),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.detail || 'Verifizierung fehlgeschlagen');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
localStorage.setItem('osint_token', data.access_token);
|
||||
localStorage.setItem('osint_username', data.username);
|
||||
window.location.href = '/dashboard';
|
||||
} catch (err) {
|
||||
errorEl.textContent = err.message;
|
||||
errorEl.style.display = 'block';
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Verifizieren';
|
||||
}
|
||||
});
|
||||
|
||||
// Zurueck-Button
|
||||
document.getElementById('back-btn').addEventListener('click', () => {
|
||||
document.getElementById('code-form').style.display = 'none';
|
||||
document.getElementById('email-form').style.display = 'block';
|
||||
document.getElementById('login-error').style.display = 'none';
|
||||
document.getElementById('code').value = '';
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
196
src/static/js/api.js
Normale Datei
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* API-Client für den OSINT Lagemonitor.
|
||||
*/
|
||||
const API = {
|
||||
baseUrl: '/api',
|
||||
|
||||
_getHeaders() {
|
||||
const token = localStorage.getItem('osint_token');
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': token ? `Bearer ${token}` : '',
|
||||
};
|
||||
},
|
||||
|
||||
async _request(method, path, body = null) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 30000);
|
||||
|
||||
const options = {
|
||||
method,
|
||||
headers: this._getHeaders(),
|
||||
signal: controller.signal,
|
||||
};
|
||||
if (body) {
|
||||
options.body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(`${this.baseUrl}${path}`, options);
|
||||
} catch (err) {
|
||||
clearTimeout(timeout);
|
||||
if (err.name === 'AbortError') {
|
||||
throw new Error('Zeitüberschreitung bei der Anfrage');
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (response.status === 401) {
|
||||
localStorage.removeItem('osint_token');
|
||||
localStorage.removeItem('osint_username');
|
||||
window.location.href = '/';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
let detail = data.detail;
|
||||
if (Array.isArray(detail)) {
|
||||
detail = detail.map(e => e.msg || JSON.stringify(e)).join('; ');
|
||||
} else if (typeof detail === 'object' && detail !== null) {
|
||||
detail = JSON.stringify(detail);
|
||||
}
|
||||
throw new Error(detail || `Fehler ${response.status}`);
|
||||
}
|
||||
|
||||
if (response.status === 204) return null;
|
||||
return response.json();
|
||||
},
|
||||
|
||||
// Auth
|
||||
getMe() {
|
||||
return this._request('GET', '/auth/me');
|
||||
},
|
||||
|
||||
// Incidents
|
||||
listIncidents(statusFilter = null) {
|
||||
const query = statusFilter ? `?status_filter=${statusFilter}` : '';
|
||||
return this._request('GET', `/incidents${query}`);
|
||||
},
|
||||
|
||||
createIncident(data) {
|
||||
return this._request('POST', '/incidents', data);
|
||||
},
|
||||
|
||||
getRefreshingIncidents() {
|
||||
return this._request('GET', '/incidents/refreshing');
|
||||
},
|
||||
|
||||
getIncident(id) {
|
||||
return this._request('GET', `/incidents/${id}`);
|
||||
},
|
||||
|
||||
updateIncident(id, data) {
|
||||
return this._request('PUT', `/incidents/${id}`, data);
|
||||
},
|
||||
|
||||
deleteIncident(id) {
|
||||
return this._request('DELETE', `/incidents/${id}`);
|
||||
},
|
||||
|
||||
getArticles(incidentId) {
|
||||
return this._request('GET', `/incidents/${incidentId}/articles`);
|
||||
},
|
||||
|
||||
getFactChecks(incidentId) {
|
||||
return this._request('GET', `/incidents/${incidentId}/factchecks`);
|
||||
},
|
||||
|
||||
getSnapshots(incidentId) {
|
||||
return this._request('GET', `/incidents/${incidentId}/snapshots`);
|
||||
},
|
||||
|
||||
refreshIncident(id) {
|
||||
return this._request('POST', `/incidents/${id}/refresh`);
|
||||
},
|
||||
|
||||
getRefreshLog(incidentId, limit = 20) {
|
||||
return this._request('GET', `/incidents/${incidentId}/refresh-log?limit=${limit}`);
|
||||
},
|
||||
|
||||
// Sources (Quellenverwaltung)
|
||||
listSources(params = {}) {
|
||||
const query = new URLSearchParams();
|
||||
if (params.source_type) query.set('source_type', params.source_type);
|
||||
if (params.category) query.set('category', params.category);
|
||||
if (params.source_status) query.set('source_status', params.source_status);
|
||||
const qs = query.toString();
|
||||
return this._request('GET', `/sources${qs ? '?' + qs : ''}`);
|
||||
},
|
||||
|
||||
createSource(data) {
|
||||
return this._request('POST', '/sources', data);
|
||||
},
|
||||
|
||||
updateSource(id, data) {
|
||||
return this._request('PUT', `/sources/${id}`, data);
|
||||
},
|
||||
|
||||
deleteSource(id) {
|
||||
return this._request('DELETE', `/sources/${id}`);
|
||||
},
|
||||
|
||||
getSourceStats() {
|
||||
return this._request('GET', '/sources/stats');
|
||||
},
|
||||
|
||||
refreshSourceCounts() {
|
||||
return this._request('POST', '/sources/refresh-counts');
|
||||
},
|
||||
|
||||
discoverSource(url) {
|
||||
return this._request('POST', '/sources/discover', { url });
|
||||
},
|
||||
|
||||
discoverMulti(url) {
|
||||
return this._request('POST', '/sources/discover-multi', { url });
|
||||
},
|
||||
|
||||
rediscoverExisting() {
|
||||
return this._request('POST', '/sources/rediscover-existing');
|
||||
},
|
||||
|
||||
blockDomain(domain, notes) {
|
||||
return this._request('POST', '/sources/block-domain', { domain, notes });
|
||||
},
|
||||
|
||||
unblockDomain(domain) {
|
||||
return this._request('POST', '/sources/unblock-domain', { domain });
|
||||
},
|
||||
|
||||
deleteDomain(domain) {
|
||||
return this._request('DELETE', `/sources/domain/${encodeURIComponent(domain)}`);
|
||||
},
|
||||
|
||||
cancelRefresh(id) {
|
||||
return this._request('POST', `/incidents/${id}/cancel-refresh`);
|
||||
},
|
||||
|
||||
// Notifications
|
||||
listNotifications(limit = 50) {
|
||||
return this._request('GET', `/notifications?limit=${limit}`);
|
||||
},
|
||||
|
||||
getUnreadCount() {
|
||||
return this._request('GET', '/notifications/unread-count');
|
||||
},
|
||||
|
||||
markNotificationsRead(ids = null) {
|
||||
return this._request('PUT', '/notifications/mark-read', { notification_ids: ids });
|
||||
},
|
||||
|
||||
// Feedback
|
||||
sendFeedback(data) {
|
||||
return this._request('POST', '/feedback', data);
|
||||
},
|
||||
|
||||
// Export
|
||||
exportIncident(id, format, scope) {
|
||||
const token = localStorage.getItem('osint_token');
|
||||
return fetch(`${this.baseUrl}/incidents/${id}/export?format=${format}&scope=${scope}`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
});
|
||||
},
|
||||
};
|
||||
2813
src/static/js/app.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;
|
||||
},
|
||||
};
|
||||
282
src/static/js/layout.js
Normale Datei
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* LayoutManager: Drag & Resize Dashboard-Layout mit gridstack.js
|
||||
* Persistenz über localStorage, Reset auf Standard-Layout möglich.
|
||||
*/
|
||||
const LayoutManager = {
|
||||
_grid: null,
|
||||
_storageKey: 'osint_layout',
|
||||
_initialized: false,
|
||||
_saveTimeout: null,
|
||||
_hiddenTiles: {},
|
||||
|
||||
DEFAULT_LAYOUT: [
|
||||
{ id: 'lagebild', x: 0, y: 0, w: 6, h: 4, minW: 4, minH: 4 },
|
||||
{ id: 'faktencheck', x: 6, y: 0, w: 6, h: 4, minW: 4, minH: 4 },
|
||||
{ id: 'quellen', x: 0, y: 4, w: 12, h: 2, minW: 6, minH: 2 },
|
||||
{ id: 'timeline', x: 0, y: 5, w: 12, h: 4, minW: 6, minH: 4 },
|
||||
],
|
||||
|
||||
TILE_MAP: {
|
||||
lagebild: '.incident-analysis-summary',
|
||||
faktencheck: '.incident-analysis-factcheck',
|
||||
quellen: '.source-overview-card',
|
||||
timeline: '.timeline-card',
|
||||
},
|
||||
|
||||
init() {
|
||||
if (this._initialized) return;
|
||||
|
||||
const container = document.querySelector('.grid-stack');
|
||||
if (!container) return;
|
||||
|
||||
this._grid = GridStack.init({
|
||||
column: 12,
|
||||
cellHeight: 80,
|
||||
margin: 12,
|
||||
animate: true,
|
||||
handle: '.card-header',
|
||||
float: false,
|
||||
disableOneColumnMode: true,
|
||||
}, container);
|
||||
|
||||
const saved = this._load();
|
||||
if (saved) {
|
||||
this._applyLayout(saved);
|
||||
}
|
||||
|
||||
this._grid.on('change', () => this._debouncedSave());
|
||||
|
||||
const toolbar = document.getElementById('layout-toolbar');
|
||||
if (toolbar) toolbar.style.display = 'flex';
|
||||
|
||||
this._syncToggles();
|
||||
this._initialized = true;
|
||||
},
|
||||
|
||||
_applyLayout(layout) {
|
||||
if (!this._grid) return;
|
||||
|
||||
this._hiddenTiles = {};
|
||||
|
||||
layout.forEach(item => {
|
||||
const el = this._grid.engine.nodes.find(n => n.el && n.el.getAttribute('gs-id') === item.id);
|
||||
if (!el) return;
|
||||
|
||||
if (item.visible === false) {
|
||||
this._hiddenTiles[item.id] = item;
|
||||
this._grid.removeWidget(el.el, true, false);
|
||||
} else {
|
||||
this._grid.update(el.el, { x: item.x, y: item.y, w: item.w, h: item.h });
|
||||
}
|
||||
});
|
||||
|
||||
this._syncToggles();
|
||||
},
|
||||
|
||||
save() {
|
||||
if (!this._grid) return;
|
||||
|
||||
const items = [];
|
||||
this._grid.engine.nodes.forEach(node => {
|
||||
const id = node.el ? node.el.getAttribute('gs-id') : null;
|
||||
if (!id) return;
|
||||
items.push({
|
||||
id, x: node.x, y: node.y, w: node.w, h: node.h, visible: true,
|
||||
});
|
||||
});
|
||||
|
||||
Object.keys(this._hiddenTiles).forEach(id => {
|
||||
items.push({ ...this._hiddenTiles[id], visible: false });
|
||||
});
|
||||
|
||||
try {
|
||||
localStorage.setItem(this._storageKey, JSON.stringify(items));
|
||||
} catch (e) { /* quota */ }
|
||||
},
|
||||
|
||||
_debouncedSave() {
|
||||
clearTimeout(this._saveTimeout);
|
||||
this._saveTimeout = setTimeout(() => this.save(), 300);
|
||||
},
|
||||
|
||||
_load() {
|
||||
try {
|
||||
const raw = localStorage.getItem(this._storageKey);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed) || parsed.length === 0) return null;
|
||||
return parsed;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
toggleTile(tileId) {
|
||||
if (!this._grid) return;
|
||||
|
||||
const selector = this.TILE_MAP[tileId];
|
||||
if (!selector) return;
|
||||
|
||||
if (this._hiddenTiles[tileId]) {
|
||||
// Kachel einblenden
|
||||
const cfg = this._hiddenTiles[tileId];
|
||||
delete this._hiddenTiles[tileId];
|
||||
|
||||
const cardEl = document.querySelector(selector);
|
||||
if (!cardEl) return;
|
||||
|
||||
// Wrapper erstellen
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'grid-stack-item';
|
||||
wrapper.setAttribute('gs-id', tileId);
|
||||
wrapper.setAttribute('gs-x', cfg.x);
|
||||
wrapper.setAttribute('gs-y', cfg.y);
|
||||
wrapper.setAttribute('gs-w', cfg.w);
|
||||
wrapper.setAttribute('gs-h', cfg.h);
|
||||
wrapper.setAttribute('gs-min-w', cfg.minW || '');
|
||||
wrapper.setAttribute('gs-min-h', cfg.minH || '');
|
||||
const content = document.createElement('div');
|
||||
content.className = 'grid-stack-item-content';
|
||||
content.appendChild(cardEl);
|
||||
wrapper.appendChild(content);
|
||||
|
||||
this._grid.addWidget(wrapper);
|
||||
} else {
|
||||
// Kachel ausblenden
|
||||
const node = this._grid.engine.nodes.find(
|
||||
n => n.el && n.el.getAttribute('gs-id') === tileId
|
||||
);
|
||||
if (!node) return;
|
||||
|
||||
const defaults = this.DEFAULT_LAYOUT.find(d => d.id === tileId);
|
||||
this._hiddenTiles[tileId] = {
|
||||
id: tileId,
|
||||
x: node.x, y: node.y, w: node.w, h: node.h,
|
||||
minW: defaults ? defaults.minW : 4,
|
||||
minH: defaults ? defaults.minH : 2,
|
||||
visible: false,
|
||||
};
|
||||
|
||||
// Card aus dem Widget retten bevor es entfernt wird
|
||||
const cardEl = node.el.querySelector(selector);
|
||||
if (cardEl) {
|
||||
// Temporär im incident-view parken (unsichtbar)
|
||||
const parking = document.getElementById('tile-parking');
|
||||
if (parking) parking.appendChild(cardEl);
|
||||
}
|
||||
|
||||
this._grid.removeWidget(node.el, true, false);
|
||||
}
|
||||
|
||||
this._syncToggles();
|
||||
this.save();
|
||||
},
|
||||
|
||||
_syncToggles() {
|
||||
document.querySelectorAll('.layout-toggle-btn').forEach(btn => {
|
||||
const tileId = btn.getAttribute('data-tile');
|
||||
const isHidden = !!this._hiddenTiles[tileId];
|
||||
btn.classList.toggle('active', !isHidden);
|
||||
btn.setAttribute('aria-pressed', String(!isHidden));
|
||||
});
|
||||
},
|
||||
|
||||
reset() {
|
||||
localStorage.removeItem(this._storageKey);
|
||||
|
||||
// Cards einsammeln BEVOR der Grid zerstört wird (aus Grid + Parking)
|
||||
const cards = {};
|
||||
Object.entries(this.TILE_MAP).forEach(([id, selector]) => {
|
||||
const card = document.querySelector(selector);
|
||||
if (card) cards[id] = card;
|
||||
});
|
||||
|
||||
this._hiddenTiles = {};
|
||||
|
||||
if (this._grid) {
|
||||
this._grid.destroy(false);
|
||||
this._grid = null;
|
||||
}
|
||||
this._initialized = false;
|
||||
|
||||
const gridEl = document.querySelector('.grid-stack');
|
||||
if (!gridEl) return;
|
||||
|
||||
// Grid leeren (Cards sind bereits in cards-Map gesichert)
|
||||
gridEl.innerHTML = '';
|
||||
|
||||
// Cards in Default-Layout neu aufbauen
|
||||
this.DEFAULT_LAYOUT.forEach(cfg => {
|
||||
const cardEl = cards[cfg.id];
|
||||
if (!cardEl) return;
|
||||
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'grid-stack-item';
|
||||
wrapper.setAttribute('gs-id', cfg.id);
|
||||
wrapper.setAttribute('gs-x', cfg.x);
|
||||
wrapper.setAttribute('gs-y', cfg.y);
|
||||
wrapper.setAttribute('gs-w', cfg.w);
|
||||
wrapper.setAttribute('gs-h', cfg.h);
|
||||
wrapper.setAttribute('gs-min-w', cfg.minW);
|
||||
wrapper.setAttribute('gs-min-h', cfg.minH);
|
||||
|
||||
const content = document.createElement('div');
|
||||
content.className = 'grid-stack-item-content';
|
||||
content.appendChild(cardEl);
|
||||
wrapper.appendChild(content);
|
||||
gridEl.appendChild(wrapper);
|
||||
});
|
||||
|
||||
this.init();
|
||||
},
|
||||
|
||||
resizeTileToContent(tileId) {
|
||||
if (!this._grid) return;
|
||||
|
||||
const node = this._grid.engine.nodes.find(
|
||||
n => n.el && n.el.getAttribute('gs-id') === tileId
|
||||
);
|
||||
if (!node || !node.el) return;
|
||||
|
||||
const wrapper = node.el.querySelector('.grid-stack-item-content');
|
||||
if (!wrapper) return;
|
||||
|
||||
const card = wrapper.firstElementChild;
|
||||
if (!card) return;
|
||||
|
||||
const cellH = this._grid.opts.cellHeight || 80;
|
||||
const margin = this._grid.opts.margin || 12;
|
||||
|
||||
// Temporär alle height-Constraints aufheben
|
||||
node.el.classList.add('gs-measuring');
|
||||
const naturalHeight = card.scrollHeight;
|
||||
node.el.classList.remove('gs-measuring');
|
||||
|
||||
// In Grid-Units umrechnen (aufrunden + 1 Puffer)
|
||||
const neededH = Math.ceil(naturalHeight / (cellH + margin)) + 1;
|
||||
const minH = node.minH || 2;
|
||||
const finalH = Math.max(neededH, minH);
|
||||
|
||||
this._grid.update(node.el, { h: finalH });
|
||||
this._debouncedSave();
|
||||
},
|
||||
|
||||
resetAllTilesToDefault() {
|
||||
if (!this._grid) return;
|
||||
this.DEFAULT_LAYOUT.forEach(cfg => {
|
||||
const node = this._grid.engine.nodes.find(
|
||||
n => n.el && n.el.getAttribute('gs-id') === cfg.id
|
||||
);
|
||||
if (node) this._grid.update(node.el, { h: cfg.h });
|
||||
});
|
||||
},
|
||||
|
||||
destroy() {
|
||||
if (this._grid) {
|
||||
this._grid.destroy(false);
|
||||
this._grid = null;
|
||||
}
|
||||
this._initialized = false;
|
||||
this._hiddenTiles = {};
|
||||
},
|
||||
};
|
||||
105
src/static/js/ws.js
Normale Datei
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* WebSocket-Client für Echtzeit-Updates.
|
||||
*/
|
||||
const WS = {
|
||||
socket: null,
|
||||
reconnectDelay: 2000,
|
||||
maxReconnectDelay: 30000,
|
||||
_handlers: {},
|
||||
_pingInterval: null,
|
||||
|
||||
connect() {
|
||||
const token = localStorage.getItem('osint_token');
|
||||
if (!token) return;
|
||||
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const url = `${protocol}//${window.location.host}/api/ws`;
|
||||
|
||||
try {
|
||||
this.socket = new WebSocket(url);
|
||||
} catch (e) {
|
||||
console.error('WebSocket-Verbindungsfehler:', e);
|
||||
this._scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
this.socket.onopen = () => {
|
||||
// Token als erste Nachricht senden (nicht in URL)
|
||||
this.socket.send(token);
|
||||
};
|
||||
|
||||
this.socket.onmessage = (event) => {
|
||||
if (event.data === 'pong') return;
|
||||
if (event.data === 'authenticated') {
|
||||
console.log('WebSocket verbunden');
|
||||
this.reconnectDelay = 2000;
|
||||
this._startPing();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
this._dispatch(msg);
|
||||
} catch (e) {
|
||||
console.error('WebSocket Parse-Fehler:', e);
|
||||
}
|
||||
};
|
||||
|
||||
this.socket.onclose = () => {
|
||||
console.log('WebSocket getrennt');
|
||||
this._stopPing();
|
||||
this._scheduleReconnect();
|
||||
};
|
||||
|
||||
this.socket.onerror = () => {};
|
||||
},
|
||||
|
||||
disconnect() {
|
||||
this._stopPing();
|
||||
if (this.socket) {
|
||||
this.socket.close();
|
||||
this.socket = null;
|
||||
}
|
||||
},
|
||||
|
||||
on(type, handler) {
|
||||
if (!this._handlers[type]) {
|
||||
this._handlers[type] = [];
|
||||
}
|
||||
this._handlers[type].push(handler);
|
||||
},
|
||||
|
||||
_dispatch(msg) {
|
||||
const handlers = this._handlers[msg.type] || [];
|
||||
handlers.forEach(h => h(msg));
|
||||
|
||||
// Globale Handler
|
||||
const allHandlers = this._handlers['*'] || [];
|
||||
allHandlers.forEach(h => h(msg));
|
||||
},
|
||||
|
||||
_startPing() {
|
||||
this._pingInterval = setInterval(() => {
|
||||
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
|
||||
this.socket.send('ping');
|
||||
}
|
||||
}, 30000);
|
||||
},
|
||||
|
||||
_stopPing() {
|
||||
if (this._pingInterval) {
|
||||
clearInterval(this._pingInterval);
|
||||
this._pingInterval = null;
|
||||
}
|
||||
},
|
||||
|
||||
_scheduleReconnect() {
|
||||
setTimeout(() => {
|
||||
if (!this.socket || this.socket.readyState === WebSocket.CLOSED) {
|
||||
this.connect();
|
||||
}
|
||||
}, this.reconnectDelay);
|
||||
|
||||
this.reconnectDelay = Math.min(this.reconnectDelay * 1.5, this.maxReconnectDelay);
|
||||
},
|
||||
|
||||
};
|
||||
272
src/static/layout.js
Normale Datei
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* LayoutManager: Drag & Resize Dashboard-Layout mit gridstack.js
|
||||
* Persistenz über localStorage, Reset auf Standard-Layout möglich.
|
||||
*/
|
||||
const LayoutManager = {
|
||||
_grid: null,
|
||||
_storageKey: 'osint_layout',
|
||||
_initialized: false,
|
||||
_saveTimeout: null,
|
||||
_hiddenTiles: {},
|
||||
|
||||
DEFAULT_LAYOUT: [
|
||||
{ id: 'lagebild', x: 0, y: 0, w: 6, h: 4, minW: 4, minH: 4 },
|
||||
{ id: 'faktencheck', x: 6, y: 0, w: 6, h: 4, minW: 4, minH: 4 },
|
||||
{ id: 'quellen', x: 0, y: 4, w: 12, h: 2, minW: 6, minH: 2 },
|
||||
{ id: 'timeline', x: 0, y: 5, w: 12, h: 4, minW: 6, minH: 4 },
|
||||
],
|
||||
|
||||
TILE_MAP: {
|
||||
lagebild: '.incident-analysis-summary',
|
||||
faktencheck: '.incident-analysis-factcheck',
|
||||
quellen: '.source-overview-card',
|
||||
timeline: '.timeline-card',
|
||||
},
|
||||
|
||||
init() {
|
||||
if (this._initialized) return;
|
||||
|
||||
const container = document.querySelector('.grid-stack');
|
||||
if (!container) return;
|
||||
|
||||
this._grid = GridStack.init({
|
||||
column: 12,
|
||||
cellHeight: 80,
|
||||
margin: 12,
|
||||
animate: true,
|
||||
handle: '.card-header',
|
||||
float: false,
|
||||
disableOneColumnMode: true,
|
||||
}, container);
|
||||
|
||||
const saved = this._load();
|
||||
if (saved) {
|
||||
this._applyLayout(saved);
|
||||
}
|
||||
|
||||
this._grid.on('change', () => this._debouncedSave());
|
||||
|
||||
const toolbar = document.getElementById('layout-toolbar');
|
||||
if (toolbar) toolbar.style.display = 'flex';
|
||||
|
||||
this._syncToggles();
|
||||
this._initialized = true;
|
||||
},
|
||||
|
||||
_applyLayout(layout) {
|
||||
if (!this._grid) return;
|
||||
|
||||
this._hiddenTiles = {};
|
||||
|
||||
layout.forEach(item => {
|
||||
const el = this._grid.engine.nodes.find(n => n.el && n.el.getAttribute('gs-id') === item.id);
|
||||
if (!el) return;
|
||||
|
||||
if (item.visible === false) {
|
||||
this._hiddenTiles[item.id] = item;
|
||||
this._grid.removeWidget(el.el, true, false);
|
||||
} else {
|
||||
this._grid.update(el.el, { x: item.x, y: item.y, w: item.w, h: item.h });
|
||||
}
|
||||
});
|
||||
|
||||
this._syncToggles();
|
||||
},
|
||||
|
||||
save() {
|
||||
if (!this._grid) return;
|
||||
|
||||
const items = [];
|
||||
this._grid.engine.nodes.forEach(node => {
|
||||
const id = node.el ? node.el.getAttribute('gs-id') : null;
|
||||
if (!id) return;
|
||||
items.push({
|
||||
id, x: node.x, y: node.y, w: node.w, h: node.h, visible: true,
|
||||
});
|
||||
});
|
||||
|
||||
Object.keys(this._hiddenTiles).forEach(id => {
|
||||
items.push({ ...this._hiddenTiles[id], visible: false });
|
||||
});
|
||||
|
||||
try {
|
||||
localStorage.setItem(this._storageKey, JSON.stringify(items));
|
||||
} catch (e) { /* quota */ }
|
||||
},
|
||||
|
||||
_debouncedSave() {
|
||||
clearTimeout(this._saveTimeout);
|
||||
this._saveTimeout = setTimeout(() => this.save(), 300);
|
||||
},
|
||||
|
||||
_load() {
|
||||
try {
|
||||
const raw = localStorage.getItem(this._storageKey);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed) || parsed.length === 0) return null;
|
||||
return parsed;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
toggleTile(tileId) {
|
||||
if (!this._grid) return;
|
||||
|
||||
const selector = this.TILE_MAP[tileId];
|
||||
if (!selector) return;
|
||||
|
||||
if (this._hiddenTiles[tileId]) {
|
||||
// Kachel einblenden
|
||||
const cfg = this._hiddenTiles[tileId];
|
||||
delete this._hiddenTiles[tileId];
|
||||
|
||||
const cardEl = document.querySelector(selector);
|
||||
if (!cardEl) return;
|
||||
|
||||
// Wrapper erstellen
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'grid-stack-item';
|
||||
wrapper.setAttribute('gs-id', tileId);
|
||||
wrapper.setAttribute('gs-x', cfg.x);
|
||||
wrapper.setAttribute('gs-y', cfg.y);
|
||||
wrapper.setAttribute('gs-w', cfg.w);
|
||||
wrapper.setAttribute('gs-h', cfg.h);
|
||||
wrapper.setAttribute('gs-min-w', cfg.minW || '');
|
||||
wrapper.setAttribute('gs-min-h', cfg.minH || '');
|
||||
const content = document.createElement('div');
|
||||
content.className = 'grid-stack-item-content';
|
||||
content.appendChild(cardEl);
|
||||
wrapper.appendChild(content);
|
||||
|
||||
this._grid.addWidget(wrapper);
|
||||
} else {
|
||||
// Kachel ausblenden
|
||||
const node = this._grid.engine.nodes.find(
|
||||
n => n.el && n.el.getAttribute('gs-id') === tileId
|
||||
);
|
||||
if (!node) return;
|
||||
|
||||
const defaults = this.DEFAULT_LAYOUT.find(d => d.id === tileId);
|
||||
this._hiddenTiles[tileId] = {
|
||||
id: tileId,
|
||||
x: node.x, y: node.y, w: node.w, h: node.h,
|
||||
minW: defaults ? defaults.minW : 4,
|
||||
minH: defaults ? defaults.minH : 2,
|
||||
visible: false,
|
||||
};
|
||||
|
||||
// Card aus dem Widget retten bevor es entfernt wird
|
||||
const cardEl = node.el.querySelector(selector);
|
||||
if (cardEl) {
|
||||
// Temporär im incident-view parken (unsichtbar)
|
||||
const parking = document.getElementById('tile-parking');
|
||||
if (parking) parking.appendChild(cardEl);
|
||||
}
|
||||
|
||||
this._grid.removeWidget(node.el, true, false);
|
||||
}
|
||||
|
||||
this._syncToggles();
|
||||
this.save();
|
||||
},
|
||||
|
||||
_syncToggles() {
|
||||
document.querySelectorAll('.layout-toggle-btn').forEach(btn => {
|
||||
const tileId = btn.getAttribute('data-tile');
|
||||
const isHidden = !!this._hiddenTiles[tileId];
|
||||
btn.classList.toggle('active', !isHidden);
|
||||
btn.setAttribute('aria-pressed', String(!isHidden));
|
||||
});
|
||||
},
|
||||
|
||||
reset() {
|
||||
localStorage.removeItem(this._storageKey);
|
||||
|
||||
// Cards einsammeln BEVOR der Grid zerstört wird (aus Grid + Parking)
|
||||
const cards = {};
|
||||
Object.entries(this.TILE_MAP).forEach(([id, selector]) => {
|
||||
const card = document.querySelector(selector);
|
||||
if (card) cards[id] = card;
|
||||
});
|
||||
|
||||
this._hiddenTiles = {};
|
||||
|
||||
if (this._grid) {
|
||||
this._grid.destroy(false);
|
||||
this._grid = null;
|
||||
}
|
||||
this._initialized = false;
|
||||
|
||||
const gridEl = document.querySelector('.grid-stack');
|
||||
if (!gridEl) return;
|
||||
|
||||
// Grid leeren (Cards sind bereits in cards-Map gesichert)
|
||||
gridEl.innerHTML = '';
|
||||
|
||||
// Cards in Default-Layout neu aufbauen
|
||||
this.DEFAULT_LAYOUT.forEach(cfg => {
|
||||
const cardEl = cards[cfg.id];
|
||||
if (!cardEl) return;
|
||||
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'grid-stack-item';
|
||||
wrapper.setAttribute('gs-id', cfg.id);
|
||||
wrapper.setAttribute('gs-x', cfg.x);
|
||||
wrapper.setAttribute('gs-y', cfg.y);
|
||||
wrapper.setAttribute('gs-w', cfg.w);
|
||||
wrapper.setAttribute('gs-h', cfg.h);
|
||||
wrapper.setAttribute('gs-min-w', cfg.minW);
|
||||
wrapper.setAttribute('gs-min-h', cfg.minH);
|
||||
|
||||
const content = document.createElement('div');
|
||||
content.className = 'grid-stack-item-content';
|
||||
content.appendChild(cardEl);
|
||||
wrapper.appendChild(content);
|
||||
gridEl.appendChild(wrapper);
|
||||
});
|
||||
|
||||
this.init();
|
||||
},
|
||||
|
||||
resizeTileToContent(tileId) {
|
||||
if (!this._grid) return;
|
||||
|
||||
const node = this._grid.engine.nodes.find(
|
||||
n => n.el && n.el.getAttribute('gs-id') === tileId
|
||||
);
|
||||
if (!node || !node.el) return;
|
||||
|
||||
const wrapper = node.el.querySelector('.grid-stack-item-content');
|
||||
if (!wrapper) return;
|
||||
|
||||
const card = wrapper.firstElementChild;
|
||||
if (!card) return;
|
||||
|
||||
const cellH = this._grid.opts.cellHeight || 80;
|
||||
const margin = this._grid.opts.margin || 12;
|
||||
|
||||
// Temporär alle height-Constraints aufheben
|
||||
node.el.classList.add('gs-measuring');
|
||||
const naturalHeight = card.scrollHeight;
|
||||
node.el.classList.remove('gs-measuring');
|
||||
|
||||
// In Grid-Units umrechnen (aufrunden + 1 Puffer)
|
||||
const neededH = Math.ceil(naturalHeight / (cellH + margin)) + 1;
|
||||
const minH = node.minH || 2;
|
||||
const finalH = Math.max(neededH, minH);
|
||||
|
||||
this._grid.update(node.el, { h: finalH });
|
||||
this._debouncedSave();
|
||||
},
|
||||
|
||||
destroy() {
|
||||
if (this._grid) {
|
||||
this._grid.destroy(false);
|
||||
this._grid = null;
|
||||
}
|
||||
this._initialized = false;
|
||||
this._hiddenTiles = {};
|
||||
},
|
||||
};
|
||||