Großes Cleanup: Bugs fixen, Features fertigstellen, toten Code entfernen
Bugs behoben: - handleEdit() async keyword hinzugefügt (E-Mail-Checkboxen funktionieren jetzt) - parseUTC() Funktion definiert (Fortschritts-Timer nutzt Server-Startzeit) - Status cancelling wird im Frontend korrekt angezeigt Features fertiggestellt: - Sidebar: Lagen nach Typ getrennt (adhoc/research) mit Zählern - Quellen-Bearbeiten: Edit-Button pro Quelle, Formular vorausfüllen - Lizenz-Info: Org-Name und Lizenzstatus im Header angezeigt Toter Code entfernt: - 5 verwaiste Dateien gelöscht (alte rss_parser, style.css, components.js, layout.js, setup_users) - 6 ungenutzte Pydantic Models entfernt - Ungenutzte Funktionen/Imports in auth.py, routers, agents, config - Tote API-Methoden, Legacy-UI-Methoden, verwaiste WS-Handler - Abgeschlossene DB-Migrationen aufgeräumt Sonstiges: - requirements.txt: passlib[bcrypt] durch bcrypt ersetzt - Umlaute korrigiert (index.html) - CSS: incident-type-label → incident-type-badge, .login-success hinzugefügt - Schließen statt Schliessen im Feedback-Modal
Dieser Commit ist enthalten in:
@@ -1,444 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
},
|
||||
};
|
||||
@@ -341,6 +341,17 @@ a:hover {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.login-success {
|
||||
display: none;
|
||||
background: var(--tint-success);
|
||||
border: 1px solid rgba(16, 185, 129, 0.3);
|
||||
border-radius: var(--radius);
|
||||
padding: var(--sp-lg) var(--sp-xl);
|
||||
margin-bottom: var(--sp-xl);
|
||||
font-size: 13px;
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
/* === Buttons === */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
@@ -466,6 +477,88 @@ a:hover {
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
/* --- Org & License Info in Header --- */
|
||||
.header-user-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.header-user-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-md);
|
||||
}
|
||||
|
||||
.header-org-name {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
font-weight: 400;
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.header-license-badge {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 1px 7px;
|
||||
border-radius: 9999px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
line-height: 1.6;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.header-license-badge.license-trial {
|
||||
background: var(--warning-bg, #fef3c7);
|
||||
color: var(--warning-text, #92400e);
|
||||
border: 1px solid var(--warning-border, #fcd34d);
|
||||
}
|
||||
|
||||
.header-license-badge.license-annual {
|
||||
background: var(--success-bg, #d1fae5);
|
||||
color: var(--success-text, #065f46);
|
||||
border: 1px solid var(--success-border, #6ee7b7);
|
||||
}
|
||||
|
||||
.header-license-badge.license-permanent {
|
||||
background: var(--info-bg, #dbeafe);
|
||||
color: var(--info-text, #1e40af);
|
||||
border: 1px solid var(--info-border, #93c5fd);
|
||||
}
|
||||
|
||||
.header-license-badge.license-expired {
|
||||
background: var(--danger-bg, #fee2e2);
|
||||
color: var(--danger-text, #991b1b);
|
||||
border: 1px solid var(--danger-border, #fca5a5);
|
||||
}
|
||||
|
||||
.header-license-badge.license-unknown {
|
||||
background: var(--bg-tertiary, #f3f4f6);
|
||||
color: var(--text-tertiary, #6b7280);
|
||||
border: 1px solid var(--border-color, #d1d5db);
|
||||
}
|
||||
|
||||
.header-license-warning {
|
||||
display: none;
|
||||
font-size: 11px;
|
||||
color: var(--danger-text, #991b1b);
|
||||
background: var(--danger-bg, #fee2e2);
|
||||
border: 1px solid var(--danger-border, #fca5a5);
|
||||
border-radius: var(--radius);
|
||||
padding: 3px 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.header-license-warning.visible {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
|
||||
/* === Sidebar === */
|
||||
.sidebar {
|
||||
@@ -3165,6 +3258,23 @@ a:hover {
|
||||
}
|
||||
|
||||
/* Delete Button */
|
||||
.source-edit-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-disabled);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--radius);
|
||||
transition: color 0.2s, background 0.2s;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.source-edit-btn:hover {
|
||||
color: var(--accent);
|
||||
background: var(--tint-accent);
|
||||
}
|
||||
|
||||
.source-delete-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
|
||||
@@ -26,7 +26,14 @@
|
||||
</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>
|
||||
<div class="header-user-info">
|
||||
<div class="header-user-top">
|
||||
<span class="header-user" id="header-user"></span>
|
||||
<span class="header-license-badge" id="header-license-badge"></span>
|
||||
</div>
|
||||
<span class="header-org-name" id="header-org-name"></span>
|
||||
</div>
|
||||
<div class="header-license-warning" id="header-license-warning"></div>
|
||||
<button class="btn btn-secondary btn-small" id="logout-btn">Abmelden</button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -90,7 +97,7 @@
|
||||
<!-- 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="incident-type-badge" id="incident-type-badge"></span>
|
||||
<span class="auto-refresh-indicator" id="meta-refresh-mode"></span>
|
||||
</div>
|
||||
<div class="incident-header-row1">
|
||||
@@ -501,7 +508,7 @@
|
||||
<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>
|
||||
<button class="modal-close" onclick="closeModal('modal-feedback')" aria-label="Schließen">×</button>
|
||||
</div>
|
||||
<form id="feedback-form">
|
||||
<div class="modal-body">
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
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>
|
||||
<button type="button" class="btn btn-secondary btn-full" id="back-btn" style="margin-top:8px;">Zurück</button>
|
||||
</form>
|
||||
|
||||
<div style="text-align:center;margin-top:16px;">
|
||||
@@ -170,7 +170,7 @@
|
||||
const btn = document.getElementById('code-btn');
|
||||
errorEl.style.display = 'none';
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Wird geprueft...';
|
||||
btn.textContent = 'Wird geprüft...';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth/verify-code', {
|
||||
@@ -200,7 +200,7 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Zurueck-Button
|
||||
// Zurück-Button
|
||||
document.getElementById('back-btn').addEventListener('click', () => {
|
||||
document.getElementById('code-form').style.display = 'none';
|
||||
document.getElementById('email-form').style.display = 'block';
|
||||
|
||||
@@ -136,22 +136,10 @@ const API = {
|
||||
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 });
|
||||
},
|
||||
@@ -173,10 +161,6 @@ const API = {
|
||||
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 });
|
||||
},
|
||||
|
||||
@@ -370,7 +370,6 @@ const App = {
|
||||
currentIncidentId: null,
|
||||
incidents: [],
|
||||
_originalTitle: document.title,
|
||||
_notificationCount: 0,
|
||||
_refreshingIncidents: new Set(),
|
||||
_editingIncidentId: null,
|
||||
_currentArticles: [],
|
||||
@@ -403,6 +402,42 @@ const App = {
|
||||
const user = await API.getMe();
|
||||
this._currentUsername = user.username;
|
||||
document.getElementById('header-user').textContent = user.username;
|
||||
|
||||
// Org-Name anzeigen
|
||||
const orgNameEl = document.getElementById('header-org-name');
|
||||
if (orgNameEl && user.org_name) {
|
||||
orgNameEl.textContent = user.org_name;
|
||||
orgNameEl.title = user.org_name;
|
||||
}
|
||||
|
||||
// Lizenz-Badge anzeigen
|
||||
const badgeEl = document.getElementById('header-license-badge');
|
||||
if (badgeEl) {
|
||||
const licenseLabels = {
|
||||
trial: 'Trial',
|
||||
annual: 'Annual',
|
||||
permanent: 'Permanent',
|
||||
expired: 'Abgelaufen',
|
||||
unknown: 'Unbekannt'
|
||||
};
|
||||
const status = user.read_only ? 'expired' : (user.license_status || 'unknown');
|
||||
const cssClass = user.read_only ? 'license-expired'
|
||||
: user.license_type === 'trial' ? 'license-trial'
|
||||
: user.license_type === 'annual' ? 'license-annual'
|
||||
: user.license_type === 'permanent' ? 'license-permanent'
|
||||
: 'license-unknown';
|
||||
const label = user.read_only ? 'Abgelaufen'
|
||||
: licenseLabels[user.license_type] || licenseLabels[user.license_status] || 'Unbekannt';
|
||||
badgeEl.textContent = label;
|
||||
badgeEl.className = 'header-license-badge ' + cssClass;
|
||||
}
|
||||
|
||||
// Warnung bei abgelaufener Lizenz
|
||||
const warningEl = document.getElementById('header-license-warning');
|
||||
if (warningEl && user.read_only) {
|
||||
warningEl.textContent = 'Lizenz abgelaufen – nur Lesezugriff';
|
||||
warningEl.classList.add('visible');
|
||||
}
|
||||
} catch {
|
||||
window.location.href = '/';
|
||||
return;
|
||||
@@ -432,7 +467,6 @@ const App = {
|
||||
WS.connect();
|
||||
WS.on('status_update', (msg) => this.handleStatusUpdate(msg));
|
||||
WS.on('refresh_complete', (msg) => this.handleRefreshComplete(msg));
|
||||
WS.on('notification', (msg) => this.handleNotification(msg));
|
||||
WS.on('refresh_summary', (msg) => this.handleRefreshSummary(msg));
|
||||
WS.on('refresh_error', (msg) => this.handleRefreshError(msg));
|
||||
WS.on('refresh_cancelled', (msg) => this.handleRefreshCancelled(msg));
|
||||
@@ -476,6 +510,7 @@ const App = {
|
||||
|
||||
renderSidebar() {
|
||||
const activeContainer = document.getElementById('active-incidents');
|
||||
const researchContainer = document.getElementById('active-research');
|
||||
const archivedContainer = document.getElementById('archived-incidents');
|
||||
|
||||
// Filter-Buttons aktualisieren
|
||||
@@ -491,19 +526,34 @@ const App = {
|
||||
filtered = filtered.filter(i => i.created_by_username === this._currentUsername);
|
||||
}
|
||||
|
||||
const active = filtered.filter(i => i.status === 'active');
|
||||
// Aktive Lagen nach Typ aufteilen
|
||||
const activeAdhoc = filtered.filter(i => i.status === 'active' && (!i.type || i.type === 'adhoc'));
|
||||
const activeResearch = filtered.filter(i => i.status === 'active' && i.type === 'research');
|
||||
const archived = filtered.filter(i => i.status === 'archived');
|
||||
|
||||
const emptyLabel = this._sidebarFilter === 'mine' ? 'Keine eigenen Lagen' : 'Keine aktiven Lagen';
|
||||
const emptyLabelAdhoc = this._sidebarFilter === 'mine' ? 'Keine eigenen Ad-hoc-Lagen' : 'Keine Ad-hoc-Lagen';
|
||||
const emptyLabelResearch = this._sidebarFilter === 'mine' ? 'Keine eigenen Recherchen' : 'Keine Recherchen';
|
||||
|
||||
activeContainer.innerHTML = active.length
|
||||
? active.map(i => UI.renderIncidentItem(i, i.id === this.currentIncidentId)).join('')
|
||||
: `<div style="padding:8px 12px;font-size:12px;color:var(--text-tertiary);">${emptyLabel}</div>`;
|
||||
activeContainer.innerHTML = activeAdhoc.length
|
||||
? activeAdhoc.map(i => UI.renderIncidentItem(i, i.id === this.currentIncidentId)).join('')
|
||||
: `<div style="padding:8px 12px;font-size:12px;color:var(--text-tertiary);">${emptyLabelAdhoc}</div>`;
|
||||
|
||||
researchContainer.innerHTML = activeResearch.length
|
||||
? activeResearch.map(i => UI.renderIncidentItem(i, i.id === this.currentIncidentId)).join('')
|
||||
: `<div style="padding:8px 12px;font-size:12px;color:var(--text-tertiary);">${emptyLabelResearch}</div>`;
|
||||
|
||||
archivedContainer.innerHTML = archived.length
|
||||
? archived.map(i => UI.renderIncidentItem(i, i.id === this.currentIncidentId)).join('')
|
||||
: '<div style="padding:8px 12px;font-size:12px;color:var(--text-tertiary);">Kein Archiv</div>';
|
||||
|
||||
// Zähler aktualisieren
|
||||
const countAdhoc = document.getElementById('count-active-incidents');
|
||||
const countResearch = document.getElementById('count-active-research');
|
||||
const countArchived = document.getElementById('count-archived-incidents');
|
||||
if (countAdhoc) countAdhoc.textContent = `(${activeAdhoc.length})`;
|
||||
if (countResearch) countResearch.textContent = `(${activeResearch.length})`;
|
||||
if (countArchived) countArchived.textContent = `(${archived.length})`;
|
||||
|
||||
// Sidebar-Stats aktualisieren
|
||||
this.updateSidebarStats();
|
||||
},
|
||||
@@ -1547,7 +1597,7 @@ const App = {
|
||||
}
|
||||
},
|
||||
|
||||
handleEdit() {
|
||||
async handleEdit() {
|
||||
if (!this.currentIncidentId) return;
|
||||
const incident = this.incidents.find(i => i.id === this.currentIncidentId);
|
||||
if (!incident) return;
|
||||
@@ -1677,16 +1727,7 @@ const App = {
|
||||
await this.loadIncidents();
|
||||
},
|
||||
|
||||
handleNotification(msg) {
|
||||
// Legacy-Fallback: Einzelne Notifications ans NotificationCenter weiterleiten
|
||||
const incident = this.incidents.find(i => i.id === msg.incident_id);
|
||||
NotificationCenter.add({
|
||||
incident_id: msg.incident_id,
|
||||
title: incident ? incident.title : 'Lage #' + msg.incident_id,
|
||||
text: msg.data.message || 'Neue Entwicklung',
|
||||
icon: 'warning',
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
handleRefreshSummary(msg) {
|
||||
const d = msg.data;
|
||||
@@ -2322,9 +2363,17 @@ const App = {
|
||||
document.getElementById('src-discovery-result').style.display = 'none';
|
||||
document.getElementById('src-discover-btn').disabled = false;
|
||||
document.getElementById('src-discover-btn').textContent = 'Erkennen';
|
||||
// Save-Button Text zurücksetzen
|
||||
const saveBtn = document.querySelector('#src-discovery-result .sources-discovery-actions .btn-primary');
|
||||
if (saveBtn) saveBtn.textContent = 'Speichern';
|
||||
// Block-Form ausblenden
|
||||
const blockForm = document.getElementById('sources-block-form');
|
||||
if (blockForm) blockForm.style.display = 'none';
|
||||
} else {
|
||||
// Beim Schließen: Bearbeitungsmodus zurücksetzen
|
||||
this._editingSourceId = null;
|
||||
const saveBtn = document.querySelector('#src-discovery-result .sources-discovery-actions .btn-primary');
|
||||
if (saveBtn) saveBtn.textContent = 'Speichern';
|
||||
}
|
||||
},
|
||||
|
||||
@@ -2417,6 +2466,66 @@ const App = {
|
||||
}
|
||||
},
|
||||
|
||||
editSource(id) {
|
||||
const source = this._sourcesOnly.find(s => s.id === id);
|
||||
if (!source) {
|
||||
UI.showToast('Quelle nicht gefunden.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
this._editingSourceId = id;
|
||||
|
||||
// Formular öffnen falls geschlossen (direkt, ohne toggleSourceForm das _editingSourceId zurücksetzt)
|
||||
const form = document.getElementById('sources-add-form');
|
||||
if (form) {
|
||||
form.style.display = 'block';
|
||||
const blockForm = document.getElementById('sources-block-form');
|
||||
if (blockForm) blockForm.style.display = 'none';
|
||||
}
|
||||
|
||||
// Discovery-URL mit vorhandener URL/Domain befüllen
|
||||
const discoverUrlInput = document.getElementById('src-discover-url');
|
||||
if (discoverUrlInput) {
|
||||
discoverUrlInput.value = source.url || source.domain || '';
|
||||
}
|
||||
|
||||
// Discovery-Ergebnis anzeigen und Felder befüllen
|
||||
document.getElementById('src-discovery-result').style.display = 'block';
|
||||
document.getElementById('src-name').value = source.name || '';
|
||||
document.getElementById('src-category').value = source.category || 'sonstige';
|
||||
document.getElementById('src-notes').value = source.notes || '';
|
||||
document.getElementById('src-domain').value = source.domain || '';
|
||||
|
||||
const typeLabel = source.source_type === 'rss_feed' ? 'RSS-Feed' : 'Web-Quelle';
|
||||
document.getElementById('src-type-display').value = typeLabel;
|
||||
|
||||
const rssGroup = document.getElementById('src-rss-url-group');
|
||||
const rssInput = document.getElementById('src-rss-url');
|
||||
if (source.url) {
|
||||
rssInput.value = source.url;
|
||||
rssGroup.style.display = 'block';
|
||||
} else {
|
||||
rssInput.value = '';
|
||||
rssGroup.style.display = 'none';
|
||||
}
|
||||
|
||||
// _discoveredData setzen damit saveSource() die richtigen Werte nutzt
|
||||
this._discoveredData = {
|
||||
name: source.name,
|
||||
domain: source.domain,
|
||||
category: source.category,
|
||||
source_type: source.source_type,
|
||||
rss_url: source.url,
|
||||
};
|
||||
|
||||
// Submit-Button-Text ändern
|
||||
const saveBtn = document.querySelector('#src-discovery-result .sources-discovery-actions .btn-primary');
|
||||
if (saveBtn) saveBtn.textContent = 'Quelle speichern';
|
||||
|
||||
// Zum Formular scrollen
|
||||
if (form) form.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
},
|
||||
|
||||
async saveSource() {
|
||||
const name = document.getElementById('src-name').value.trim();
|
||||
if (!name) {
|
||||
|
||||
@@ -1,3 +1,16 @@
|
||||
/**
|
||||
* Parst einen UTC-Zeitstring vom Server in ein Date-Objekt.
|
||||
*/
|
||||
function parseUTC(dateStr) {
|
||||
if (!dateStr) return null;
|
||||
try {
|
||||
const d = new Date(dateStr.endsWith('Z') ? dateStr : dateStr + 'Z');
|
||||
return isNaN(d.getTime()) ? null : d;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* UI-Komponenten für das Dashboard.
|
||||
*/
|
||||
@@ -149,19 +162,6 @@ const UI = {
|
||||
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.
|
||||
*/
|
||||
@@ -228,6 +228,7 @@ const UI = {
|
||||
deep_researching: { active: 1, label: 'Tiefenrecherche läuft...' },
|
||||
analyzing: { active: 2, label: 'Analysiert Meldungen...' },
|
||||
factchecking: { active: 3, label: 'Faktencheck läuft...' },
|
||||
cancelling: { active: 0, label: 'Wird abgebrochen...' },
|
||||
};
|
||||
|
||||
const step = steps[status] || steps.queued;
|
||||
@@ -553,6 +554,7 @@ const UI = {
|
||||
<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-edit-btn" onclick="App.editSource(${feed.id})" title="Bearbeiten" aria-label="Bearbeiten">✎</button>
|
||||
<button class="source-delete-btn" onclick="App.deleteSingleFeed(${feed.id})" title="Löschen" aria-label="Löschen">×</button>
|
||||
</div>`;
|
||||
});
|
||||
@@ -572,6 +574,7 @@ const UI = {
|
||||
<span class="source-category-badge cat-${feeds[0]?.category || 'sonstige'}">${catLabel}</span>
|
||||
${feedCountBadge}
|
||||
<div class="source-group-actions" onclick="event.stopPropagation()">
|
||||
${!hasMultiple && feeds[0]?.id ? `<button class="source-edit-btn" onclick="App.editSource(${feeds[0].id})" title="Bearbeiten" aria-label="Bearbeiten">✎</button>` : ''}
|
||||
<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>
|
||||
@@ -593,26 +596,6 @@ const UI = {
|
||||
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.
|
||||
*/
|
||||
|
||||
@@ -261,16 +261,6 @@ const LayoutManager = {
|
||||
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);
|
||||
|
||||
@@ -1,272 +0,0 @@
|
||||
/**
|
||||
* 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 = {};
|
||||
},
|
||||
};
|
||||
3653
src/static/style.css
3653
src/static/style.css
Datei-Diff unterdrückt, da er zu groß ist
Diff laden
In neuem Issue referenzieren
Einen Benutzer sperren