/**
* AegisSight Studio - NotebookLM-artiger Controller.
*
* Wiederverwendet die reinen Render-Funktionen aus components.js (UI.*) und den
* API-Client (API.*). Das globale `App`-Shim stellt nur die wenigen Felder/Handler
* bereit, die einige geliehene Render-Funktionen per onclick erwarten.
*/
/* --- App-Shim (Minimal-Kompatibilitaet für components.js) --- */
const App = {
currentIncidentId: null,
_fcHidden: new Set(),
// Faktencheck-Statusfilter (von UI.renderFactCheckFilters referenziert)
toggleFactCheckFilter(status) {
if (this._fcHidden.has(status)) this._fcHidden.delete(status);
else this._fcHidden.add(status);
document.querySelectorAll('#art-fc-body .factcheck-item').forEach(el => {
el.style.display = this._fcHidden.has(el.dataset.fcStatus) ? 'none' : '';
});
},
toggleFcDropdown(event) {
if (event) event.stopPropagation();
const menu = document.getElementById('fc-dropdown-menu');
if (menu) menu.classList.toggle('open');
},
// Stubs (von manchen Render-Funktionen referenziert, hier nicht genutzt)
toggleSourceOverviewDetail() {},
syncRefreshStatus() { Studio.syncRefreshStatus(); },
};
/* --- Studio-Controller --- */
const Studio = {
incidents: [],
incident: null,
articles: [],
sourcesMeta: [], // Cache von API.listSources() für Badges
selected: null, // Set aktiver Quellen-Namen; null = alle aktiv
convId: null,
_startTime: null,
_timerInt: null,
openTabs: [], // in der Mitte geöffnete Studio-Teile (Reihenfolge)
activeTab: null, // aktuell sichtbarer Tab
_closeSvg: ' ',
// Ereignistypen der Aktivitaets-Timeline: Label + Lucide-Icon
_evMeta: {
article_ingest: { label: 'Meldung', icon: ' ' },
refresh: { label: 'Aktualisierung', icon: ' ' },
analysis: { label: 'Analyse', icon: ' ' },
chat_qa: { label: 'Frage & Antwort', icon: ' ' },
source_change: { label: 'Quelle', icon: ' ' },
},
// Untersektionen der Timeline (Reihenfolge + Sektions-Ueberschrift)
_evSections: [
{ key: 'chat_qa', label: 'Konversation' },
{ key: 'source_change', label: 'Quellen' },
{ key: 'refresh', label: 'Aktualisierungen' },
{ key: 'analysis', label: 'Analyse' },
{ key: 'article_ingest', label: 'Meldungen' },
],
caseFilter: '',
caseScope: 'all', // 'all' | 'mine' - wie die Seitenleiste im klassischen Dashboard
_me: null, // Antwort von /auth/me (fuer Konto-Menue und "Eigene")
_archiveOpen: false,
caseSel: new Set(), // Mehrfachauswahl in der Fall-Liste (IDs)
caseMode: null, // null = normale Liste, 'archive' | 'delete' = Auswahlmodus
async init() {
if (!localStorage.getItem('osint_token')) { window.location.href = '/'; return; }
try {
this._me = await API.getMe();
// Studio ist vorerst nur fuer info@aegis-sight.de freigegeben.
if (!this._me || this._me.email !== 'info@aegis-sight.de') { window.location.href = '/dashboard'; return; }
} catch (e) { /* 401 leitet in api.js um */ }
this._initHeader();
WS.connect();
this._wireWs();
this._initDivider();
this._initTabDnD();
await this.loadIncidents();
const last = localStorage.getItem('studio_incident');
if (last && this.incidents.some(i => String(i.id) === String(last))) {
this.selectIncident(last);
} else {
// Ohne offenen Fall zeigt die linke Spalte die Fall-Auswahl
this._showEmpty(true);
this.leftTab('cases');
}
},
/**
* Kopfzeile rechts aufbauen. Bewusst derselbe Umfang wie im klassischen
* Dashboard, damit man beim Wechsel der Ansicht nichts verliert.
* Barrierefreiheit, Theme-Schalter, Konto-Menue und Abmelden.
*/
_initHeader() {
// Barrierefreiheits-Knopf haengt sich selbst vor den Theme-Schalter
if (window.A11yManager || typeof A11yManager !== 'undefined') A11yManager.init();
// Theme-Schalter kennt zwei Zustaende ueber seine Klasse
this._syncThemeSwitch();
const user = this._me;
if (!user) return;
const userEl = document.getElementById('header-user');
if (userEl) userEl.textContent = user.email || '';
const orgEl = document.getElementById('header-org-name');
if (orgEl) orgEl.textContent = user.org_name || '-';
const licEl = document.getElementById('header-license-info');
if (licEl) {
const labels = { trial: 'Trial', annual: 'Jahreslizenz', permanent: 'Permanent' };
licEl.textContent = user.read_only
? 'Abgelaufen'
: (labels[user.license_type] || user.license_status || '-');
}
// Credits nur zeigen, wenn die Lizenz welche fuehrt
const creditsSection = document.getElementById('credits-section');
if (creditsSection && user.credits_total) {
creditsSection.style.display = 'block';
const percentUsed = user.credits_percent_used || 0;
const percentRemaining = Math.max(0, 100 - percentUsed);
const bar = document.getElementById('credits-bar');
document.getElementById('credits-remaining').textContent = (user.credits_remaining || 0).toLocaleString('de-DE');
document.getElementById('credits-total').textContent = (user.credits_total || 0).toLocaleString('de-DE');
bar.style.width = percentRemaining + '%';
bar.classList.remove('warning', 'critical');
if (percentUsed > 80) bar.classList.add('critical');
else if (percentUsed > 50) bar.classList.add('warning');
const pct = document.getElementById('credits-percent');
if (pct) pct.textContent = percentRemaining.toFixed(0) + '% verbleibend';
}
// Konto-Menue auf- und zuklappen
const btn = document.getElementById('header-user-btn');
const dropdown = document.getElementById('header-user-dropdown');
if (btn && dropdown) {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const isOpen = dropdown.classList.toggle('open');
btn.setAttribute('aria-expanded', String(isOpen));
});
dropdown.addEventListener('click', (e) => e.stopPropagation());
document.addEventListener('click', () => {
dropdown.classList.remove('open');
btn.setAttribute('aria-expanded', 'false');
});
}
},
logout() {
localStorage.removeItem('osint_token');
localStorage.removeItem('osint_username');
localStorage.removeItem('studio_incident');
WS.disconnect();
window.location.href = '/';
},
/**
* Ohne offenen Fall bleiben Mitte und rechte Spalte leer - die LINKE Spalte
* muss aber stehen bleiben, sonst käme man an die Fall-Auswahl nicht heran.
*/
_showEmpty(on) {
// Mitte + rechte Spalte bleiben stehen, werden aber ausgegraut und
// unbedienbar (Klasse 'idle'). Nur der Hinweis legt sich darueber.
const cols = document.getElementById('studio-cols');
if (cols) cols.classList.toggle('idle', on);
document.getElementById('studio-empty').style.display = on ? '' : 'none';
// Ohne Fall wuerde die Lauf-Anzeige sonst mit dem Stand des zuletzt
// geoeffneten Falls stehen bleiben. Nur beim Leerschalten neu zeichnen,
// beim Oeffnen macht das loadDetail mit den richtigen Daten.
if (on) {
this.incident = null;
this.fresh = null;
this._renderLauf();
}
},
// === Linke Spalte: Reiter "Fälle" / "Quellen" ===
leftTab(which) {
const cases = which === 'cases';
// Beim Verlassen der Fall-Liste den Auswahlmodus nicht offen lassen
if (!cases && this.caseMode !== null) this.endCaseMode();
document.getElementById('pane-cases').hidden = !cases;
document.getElementById('pane-sources').hidden = cases;
const tc = document.getElementById('lt-cases');
const ts = document.getElementById('lt-sources');
tc.classList.toggle('active', cases);
ts.classList.toggle('active', !cases);
tc.setAttribute('aria-selected', String(cases));
ts.setAttribute('aria-selected', String(!cases));
},
// === Fall-Liste (früher das Dropdown in der Kopfzeile) ===
async loadIncidents() {
try {
this.incidents = await API.listIncidents() || [];
} catch (e) {
UI.showToast('Lagen konnten nicht geladen werden', 'error');
this.incidents = [];
}
this.renderCases();
},
filterCases(q) {
this.caseFilter = (q || '').trim().toLowerCase();
this.renderCases();
},
// "Alle" oder nur die selbst angelegten Fälle, wie in der Seitenleiste
// des klassischen Dashboards.
setCaseScope(scope) {
this.caseScope = scope === 'mine' ? 'mine' : 'all';
document.querySelectorAll('.case-scope .sidebar-filter-btn').forEach(btn => {
const on = btn.dataset.scope === this.caseScope;
btn.classList.toggle('active', on);
btn.setAttribute('aria-pressed', String(on));
});
this.renderCases();
},
// Trifft der Fall den aktuellen Suchtext und die Auswahl "Alle"/"Eigene"?
_caseMatches(i) {
const q = this.caseFilter;
if (q && !(i.title || '').toLowerCase().includes(q)) return false;
if (this.caseScope === 'mine') {
const me = this._me && this._me.email;
if (!me || i.created_by_username !== me) return false;
}
return true;
},
toggleArchive() {
this._archiveOpen = !this._archiveOpen;
this.renderCases();
},
// Fälle, die der aktuelle Filter zeigt (Archiv nur, wenn aufgeklappt) - genau
// diese Menge trifft "Alle auswählen".
_visibleCases() {
return this.incidents.filter(i => this._caseMatches(i) && (i.status === 'active' || this._archiveOpen));
},
renderCases() {
const list = document.getElementById('cases-list');
if (!list) return;
const all = this.incidents.filter(i => this._caseMatches(i));
const cur = this.incident ? String(this.incident.id) : null;
const groups = [
{ key: 'live', label: 'Live-Monitoring', items: all.filter(i => i.type !== 'research' && i.status === 'active') },
{ key: 'research', label: 'Recherchen', items: all.filter(i => i.type === 'research' && i.status === 'active') },
];
const archived = all.filter(i => i.status !== 'active');
// Haken und Kontextmenue schließen sich aus: im Auswahlmodus häkelt man,
// sonst öffnet ein Klick den Fall.
const sel = this.caseMode !== null;
const item = (i) => `
${sel ? `
` : ''}
${UI.escape(i.title)}
${Studio._aiTag(i, 'case-ai-tag')}
${i.article_count}
${sel ? '' : ``}
`;
let html = '';
groups.forEach(g => {
if (!g.items.length) return;
html += `${g.label} ${g.items.length}
`;
html += g.items.map(item).join('');
});
if (archived.length) {
html += `
Archiv ${archived.length}
`;
if (this._archiveOpen) html += archived.map(item).join('');
}
list.innerHTML = html; // ohne Treffer bleibt die Liste bewusst leer (kein Hinweistext)
// Zähler am Reiter zeigt, was die Liste gerade zeigt (also inkl. "Eigene")
const cnt = document.getElementById('cases-count');
if (cnt) cnt.textContent = all.filter(i => i.status === 'active').length || '';
this._renderBulkBar();
},
/**
* Rueckfrage im Studio-Design statt Browser-confirm(). Gibt ein Promise.
* (Das native confirm sieht nach Browser-Warnung aus und blockiert die Seite.)
*/
_confirmResolve: null,
askConfirm({ title, body, okLabel = 'Löschen', danger = true }) {
return new Promise((resolve) => {
this._confirmResolve = resolve;
document.getElementById('confirm-title').textContent = title;
document.getElementById('confirm-body').innerHTML = body;
const ok = document.getElementById('confirm-ok');
ok.textContent = okLabel;
ok.className = 'btn ' + (danger ? 'btn-danger' : 'btn-primary');
document.getElementById('modal-confirm').classList.add('active');
setTimeout(() => document.getElementById('confirm-cancel').focus(), 50);
});
},
_confirmClose(answer) {
document.getElementById('modal-confirm').classList.remove('active');
const r = this._confirmResolve;
this._confirmResolve = null;
if (r) r(answer);
},
/* === Startpanel: der frisch angelegte Fall fragt aktiv nach ===
Früher stand der Nutzer nach dem Anlegen vor einer leeren Oberflaeche und
musste die Pipeline kennen, um irgendetwas auszuloesen. Jetzt legt das Studio
die Wege offen hin - "später entscheiden" ist einer davon, aber eine bewusste
Wahl. Ein Fall, für den schon einmal gewählt wurde, fragt nicht erneut. */
_renderStartPanel() {
const panel = document.getElementById('start-panel');
if (!panel) return;
const inc = this.incident;
const leer = inc && !(this.articles || []).length && !((inc.summary || '').trim());
const schonGewaehlt = inc && localStorage.getItem('studio_start_' + inc.id);
const zeigen = !!leer && !this._runningStage && !schonGewaehlt;
panel.hidden = !zeigen;
// Der Stand-Kasten und die Startfrage sind dieselbe Aktion, nie beide
// gleichzeitig zeigen. Wird die Frage nicht mehr gestellt, ist der Kasten
// wieder da, sonst stuende man nach einem ergebnislosen Lauf ohne
// Startmoeglichkeit da. Die Kette darunter bleibt sichtbar, sie erklaert,
// was der Startdialog anbietet.
const runAll = document.getElementById('lauf-stand');
if (runAll) runAll.hidden = zeigen;
if (!zeigen) return;
// Texte an den Lage-Typ anpassen: eine Recherche läuft beim ersten Mal
// mit drei Durchläufen und dauert entsprechend laenger.
const research = inc.type === 'research';
const full = document.getElementById('sp-full-desc');
if (full) {
full.textContent = research
? 'Tiefenrecherche in drei Durchläufen (Breite Erfassung, Vertiefung, Konsolidierung), danach Analyse und Faktencheck. Dauert deutlich länger als ein einzelner Lauf.'
: 'Artikel holen, Lagebild schreiben, Fakten prüfen und Orte erkennen, nacheinander. Am Ende steht ein fertiges Lagebild.';
}
this._checkSearchStatus();
},
// Ehrlich ansagen, wenn ein Lauf gerade kaum etwas bringen kann
async _checkSearchStatus() {
const box = document.getElementById('sp-warn');
const txt = document.getElementById('sp-warn-text');
if (!box) return;
let st = null;
try { st = await API.getSearchStatus(); } catch (e) { return; }
const blind = !st || !st.reachable || !st.search_enabled;
box.hidden = !blind;
if (blind && txt) {
txt.textContent = (!st || !st.reachable)
? 'Die Websuche antwortet gerade nicht (Suchdienst nicht erreichbar). Ein Lauf bringt derzeit nur Treffer aus den RSS-Feeds.'
: 'Die Websuche ist abgeschaltet, weil kein mobiler Ausgang steht. Ein Lauf bringt derzeit nur Treffer aus den RSS-Feeds.';
}
},
async startChoice(kind) {
const panel = document.getElementById('start-panel');
const inc = this.incident;
if (!inc) return;
// Eine Wahl -> dieser Fall fragt nicht noch einmal, und der Startknopf rechts
// kommt zurück (beide sind dieselbe Aktion).
localStorage.setItem('studio_start_' + inc.id, kind);
panel.hidden = true;
const runAll = document.getElementById('lauf-stand');
if (runAll) runAll.hidden = false;
if (kind === 'ingest') {
this.leftTab('sources');
const ip = document.getElementById('ingest-panel');
if (ip && ip.hidden) this.toggleIngest();
document.getElementById('ingest-add-btn').focus();
return;
}
// Bleibt 'full'. Einzelne Schritte lassen sich nicht mehr starten, und
// die automatische Aktualisierung ist eine Einstellung, keine Startaktion.
// Sie steht im Reiter Einstellungen des Falls.
this.runStage('full');
},
// === Auswahlmodus + Massenaktionen ===
// Die Haken sind normalerweise WEG. Erst der Knopf "Archivieren" bzw. "Löschen"
// unten schaltet den Auswahlmodus ein und blendet sie ein.
startCaseMode(mode) {
this.caseMode = mode;
this.caseSel.clear();
this.renderCases();
},
endCaseMode() {
this.caseMode = null;
this.caseSel.clear();
const sa = document.getElementById('case-select-all');
if (sa) sa.checked = false;
this.renderCases();
},
toggleCaseSel(id, on) {
if (on) this.caseSel.add(id); else this.caseSel.delete(id);
this.renderCases();
},
selectAllCases(on) {
const vis = this._visibleCases();
if (on) vis.forEach(i => this.caseSel.add(i.id));
else vis.forEach(i => this.caseSel.delete(i.id));
this.renderCases();
},
_renderBulkBar() {
const mode = this.caseMode;
const n = this.caseSel.size;
// Standardleiste (zwei Startknoepfe) vs. Auswahlmodus (bestätigen/abbrechen)
const tools = document.getElementById('case-tools');
const bar = document.getElementById('bulk-bar');
const row = document.getElementById('case-select-row');
if (tools) tools.hidden = mode !== null;
if (bar) bar.hidden = mode === null;
if (row) row.hidden = mode === null;
if (mode === null) return;
const cnt = document.getElementById('bulk-count');
if (cnt) {
cnt.textContent = n === 0
? (mode === 'delete' ? 'Fälle zum Löschen anhaken' : 'Fälle zum Archivieren anhaken')
: (n === 1 ? '1 Fall ausgewählt' : `${n} Fälle ausgewählt`);
}
// Nur anbieten, was zum Modus UND zur Auswahl passt
const sel = this.incidents.filter(i => this.caseSel.has(i.id));
const anyActive = sel.some(i => i.status === 'active');
const anyArchived = sel.some(i => i.status !== 'active');
const ba = document.getElementById('bulk-archive');
const bv = document.getElementById('bulk-activate');
const bd = document.getElementById('bulk-delete');
// Beschriftung in die span, sonst wuerde textContent das Icon entfernen.
const setzeText = (el, text) => {
if (!el) return;
const ziel = el.querySelector('span');
if (ziel) ziel.textContent = text; else el.textContent = text;
};
if (ba) { ba.hidden = mode !== 'archive' || !anyActive; setzeText(ba, `Archivieren (${sel.filter(i => i.status === 'active').length})`); }
if (bv) { bv.hidden = mode !== 'archive' || !anyArchived; setzeText(bv, `Aktivieren (${sel.filter(i => i.status !== 'active').length})`); }
if (bd) { bd.hidden = mode !== 'delete' || n === 0; setzeText(bd, `Löschen (${n})`); }
// "Alle"-Haken spiegelt, ob die sichtbare Menge komplett gewählt ist
const vis = this._visibleCases();
const sa = document.getElementById('case-select-all');
if (sa) sa.checked = vis.length > 0 && vis.every(i => this.caseSel.has(i.id));
const hint = document.getElementById('case-sel-hint');
if (hint) hint.textContent = n ? `${n} ausgewählt` : '';
},
// Massenaktionen laufen sequentiell über die normalen Endpunkte (mit deren
// Rechte-Pruefung). Fehlschlaege werden gezählt statt still verschluckt.
async _bulkRun(ids, fn, verb) {
let ok = 0;
const failed = [];
for (const id of ids) {
try { await fn(id); ok++; }
catch (e) { failed.push(id); }
}
this.caseSel.clear();
this.caseMode = null; // Auswahlmodus verlassen -> Haken verschwinden wieder
await this.loadIncidents();
if (failed.length) {
UI.showToast(`${ok} ${verb}, ${failed.length} fehlgeschlagen (IDs ${failed.join(', ')})`, 'error');
} else {
UI.showToast(`${ok} ${ok === 1 ? 'Fall' : 'Fälle'} ${verb}`, 'success');
}
},
async bulkStatus(status) {
const ids = this.incidents
.filter(i => this.caseSel.has(i.id) && (status === 'archived' ? i.status === 'active' : i.status !== 'active'))
.map(i => i.id);
if (!ids.length) return;
const verb = status === 'archived' ? 'archiviert' : 'wieder aktiviert';
await this._bulkRun(ids, (id) => API.updateIncident(id, { status }), verb);
},
async bulkDelete() {
const sel = this.incidents.filter(i => this.caseSel.has(i.id));
if (!sel.length) return;
const arts = sel.reduce((n, i) => n + (i.article_count || 0), 0);
const liste = sel.slice(0, 8).map(i => `${UI.escape(i.title)} `).join('')
+ (sel.length > 8 ? `… und ${sel.length - 8} weitere ` : '');
const ok = await this.askConfirm({
title: `${sel.length} ${sel.length === 1 ? 'Fall' : 'Fälle'} endgültig löschen?`,
okLabel: `Löschen (${sel.length})`,
body: `
Damit gehen ${arts} Artikel samt Faktenchecks,
Lageberichten und Karten verloren. Das lässt sich nicht rückgängig machen.
Wenn du sie nur aus der Liste nehmen willst, nutze stattdessen „Archivieren“.
`,
});
if (!ok) return;
const ids = sel.map(i => i.id);
const offen = this.incident && ids.includes(this.incident.id);
await this._bulkRun(ids, (id) => API.deleteIncident(id), 'gelöscht');
if (offen) {
// Der offene Fall war dabei -> zurück in die leere Ansicht
this.incident = null;
localStorage.removeItem('studio_incident');
this._showEmpty(true);
this.leftTab('cases');
document.getElementById('incident-title').textContent = '';
document.getElementById('type-badge').style.display = 'none';
}
},
// Kontextmenue je Fall: archivieren / reaktivieren / löschen
caseMenu(ev, id) {
ev.stopPropagation();
this._closeCaseMenu();
const inc = this.incidents.find(i => String(i.id) === String(id));
if (!inc) return;
const archived = inc.status !== 'active';
const m = document.createElement('div');
m.className = 'case-menu';
m.id = 'case-menu';
m.innerHTML = `
${archived ? 'Wieder aktivieren' : 'Archivieren'}
Löschen … `;
document.body.appendChild(m);
const r = ev.currentTarget.getBoundingClientRect();
m.style.top = `${Math.min(r.bottom + 4, window.innerHeight - m.offsetHeight - 8)}px`;
m.style.left = `${Math.min(r.left, window.innerWidth - m.offsetWidth - 8)}px`;
this._caseMenuCloser = () => this._closeCaseMenu();
setTimeout(() => document.addEventListener('click', this._caseMenuCloser, { once: true }), 0);
},
_closeCaseMenu() {
const m = document.getElementById('case-menu');
if (m) m.remove();
if (this._caseMenuCloser) {
document.removeEventListener('click', this._caseMenuCloser);
this._caseMenuCloser = null;
}
},
async setCaseStatus(id, status) {
this._closeCaseMenu();
try {
await API.updateIncident(id, { status });
await this.loadIncidents();
UI.showToast(status === 'archived' ? 'Fall archiviert' : 'Fall wieder aktiv', 'info');
} catch (e) {
UI.showToast((e && e.message) || 'Änderung fehlgeschlagen', 'error');
}
},
async deleteCase(id) {
this._closeCaseMenu();
const inc = this.incidents.find(i => String(i.id) === String(id));
const name = inc ? inc.title : 'dieser Fall';
const arts = inc ? (inc.article_count || 0) : 0;
const ok = await this.askConfirm({
title: 'Fall endgültig löschen?',
okLabel: 'Löschen',
body: `
Damit gehen ${arts} Artikel samt Faktenchecks,
Lageberichten und Karten verloren. Das lässt sich nicht rückgängig machen.
Wenn du ihn nur aus der Liste nehmen willst, nutze stattdessen „Archivieren“.
`,
});
if (!ok) return;
try {
await API.deleteIncident(id);
if (this.incident && String(this.incident.id) === String(id)) {
this.incident = null;
localStorage.removeItem('studio_incident');
this._showEmpty(true);
this.leftTab('cases');
document.getElementById('incident-title').textContent = '';
document.getElementById('type-badge').style.display = 'none';
}
await this.loadIncidents();
UI.showToast('Fall gelöscht', 'info');
} catch (e) {
UI.showToast((e && e.message) || 'Löschen fehlgeschlagen', 'error');
}
},
// === Auswahl + Detail laden ===
async selectIncident(id) {
id = parseInt(id);
if (!id) return;
App.currentIncidentId = id;
localStorage.setItem('studio_incident', id);
this._showEmpty(false);
this.selected = null;
this.convId = null;
const si = document.getElementById('src-search-input');
if (si) si.value = '';
// Fall gewählt -> es geht mit den Quellen weiter
this.leftTab('sources');
await this.loadDetail(id);
this.renderCases(); // aktiven Fall in der Liste hervorheben
},
async loadDetail(id) {
let inc, arts, fcs, locs, srcMeta, citeSrc, fresh;
try {
[inc, arts, fcs, locs, srcMeta, citeSrc, fresh] = await Promise.all([
API.getIncident(id),
API.getArticles(id, { limit: 500 }).catch(() => ({ articles: [] })),
API.getFactChecks(id).catch(() => []),
API.getLocations(id).catch(() => []),
this.sourcesMeta.length ? Promise.resolve(this.sourcesMeta) : API.listSources().catch(() => []),
API.getIncidentSources(id).catch(() => ({ sources: [] })),
API.getFreshness(id).catch(() => null),
]);
} catch (e) {
UI.showToast('Lage-Details konnten nicht geladen werden', 'error');
return;
}
this.fresh = fresh; // Datenstand je Artefakt (Grundlage der Veraltet-Anzeige)
this.incident = inc;
this.articles = Array.isArray(arts) ? arts : (arts.articles || []);
this.sourcesMeta = Array.isArray(srcMeta) ? srcMeta : [];
// /locations antwortet mit {locations, category_labels}, nicht mit einem Array.
// Ungeprüft weitergereicht wirft UI.renderMap (locations.reduce) und bricht
// loadDetail ab. Gleiche Normalisierung wie im Dashboard (app.js).
const locList = Array.isArray(locs) ? locs : ((locs && locs.locations) || []);
const catLabels = (locs && !Array.isArray(locs) && locs.category_labels) || null;
// Zitat-Quellen (getIncident liefert sources_json bewusst nicht mit)
this.citeSources = (citeSrc && citeSrc.sources) || [];
this.renderHeader(inc);
this.renderSources();
// Quellen-Ingest: Zustand je Fall zurücksetzen + laufende/fertige Uploads laden
this._stopUploadPoll();
this._seenDone = new Set();
this.uploads = [];
const ip = document.getElementById('ingest-panel'); if (ip) ip.hidden = true;
this.loadUploads();
this.renderArtifacts(inc, fcs, locList, catLabels);
this._stagesIdle(); // Karten freischalten + Datenstand anzeigen
this._resumeStage(); // läuft gerade ein Baustein? -> Karte zurück auf "läuft"
this._renderStartPanel(); // leerer Fall? -> aktiv nach dem nächsten Schritt fragen
this.renderChatIntro(inc);
// Snapshots lazy nachladen
this._snapLoaded = false;
// Studio-Tabs zurücksetzen und Lagebild als Standard-Tab öffnen
this._initTabs();
},
_sources(inc) {
// Zitat-Quellen aus /incidents/{id}/sources (getIncident liefert sie nicht mit)
return this.citeSources || [];
},
_isResearch() { return this.incident && this.incident.type === 'research'; },
renderHeader(inc) {
const badge = document.getElementById('type-badge');
const research = inc.type === 'research';
// Der Titel stand früher im Dropdown - ohne ihn wüsste man nicht, worin man arbeitet
const t = document.getElementById('incident-title');
if (t) {
t.textContent = inc.title || '';
t.title = inc.title || '';
}
badge.textContent = research ? 'Analyse' : 'Live';
badge.className = 'studio-type-badge' + (research ? ' type-research' : '');
badge.style.display = '';
// KI-Weg nur zeigen, wenn der Fall ausdruecklich darauf steht. Ohne
// Angabe gilt die Vorgabe der Organisation, dann bleibt es unbeschriftet.
const aiBadge = document.getElementById('studio-ai-badge');
if (aiBadge) aiBadge.innerHTML = this._aiTag(inc, 'incident-ai-badge');
document.getElementById('art-summary-title').textContent = research ? 'Recherchebericht' : 'Lagebild';
document.getElementById('art-latest-title').textContent = research ? 'Zusammenfassung' : 'Neueste Entwicklungen';
},
// === Spalte 1: Quellen ===
_norm(s) { return (s || '').toLowerCase().replace(/^(der|die|das)\s+/, '').replace(/\s+/g, ' ').trim(); },
_domainOf(url) {
try { return new URL(url).hostname.replace(/^www\./, '').toLowerCase(); }
catch (e) { return ''; }
},
_reg(dom) { return (dom || '').split('.').slice(-2).join('.'); },
_feedFor(sourceName, sampleUrl) {
// 1. Domain-Match (zuverlässig) — exakter Host, dann registrierbare Domain.
// KEIN Fuzzy-Namensmatch: "Bundestag" würde sonst fälschlich die
// Telegram-Quelle "AfD-Fraktion im Bundestag" (niedrig) treffen.
const dom = this._domainOf(sampleUrl);
if (dom) {
let f = this.sourcesMeta.find(s => s.domain && s.domain.toLowerCase() === dom);
if (f) return f;
const reg = this._reg(dom);
f = this.sourcesMeta.find(s => s.domain && this._reg(s.domain.toLowerCase()) === reg);
if (f) return f;
}
// 2. Exakter (normalisierter) Namensmatch als Fallback — kein Teilstring.
const n = this._norm(sourceName);
if (!n) return null;
return this.sourcesMeta.find(s => this._norm(s.name) === n) || null;
},
renderSources() {
const list = document.getElementById('sources-list');
// Fertige Uploads je Artikel -> Kategorie + Aktionen
const upByArt = {};
(this.uploads || []).forEach(u => { if (u.status === 'done' && u.article_id) upByArt[u.article_id] = u; });
// Artikel nach Quelle gruppieren; Quelle einer Kategorie zuordnen
// (reguläre/RSS-Quellen -> "webseiten", hinzugefügte Materialien -> ihre Kategorie)
const groups = {};
this.articles.forEach(a => {
const name = a.source || 'Unbekannt';
const up = upByArt[a.id];
if (!groups[name]) groups[name] = { name, arts: [], langs: new Set(), cat: 'webseiten' };
groups[name].arts.push(a);
groups[name].langs.add((a.language || 'de').toUpperCase());
if (up) groups[name].cat = up.category || 'dokumente';
});
const arr = Object.values(groups);
// Im Reiter ist nur Platz für die Zahl; das Ausführliche steckt im Tooltip
const sc = document.getElementById('sources-count');
sc.textContent = arr.length || '';
sc.title = `${this.articles.length} Artikel aus ${arr.length} Quellen`;
if (!arr.length) {
list.innerHTML = 'Noch keine Artikel. Starte eine Aktualisierung.
';
return;
}
// nach Kategorie gliedern (feste Reihenfolge), innerhalb nach Artikelzahl
const order = ['webseiten', 'dokumente', 'bilder', 'sprachnachrichten'];
const byCat = {};
arr.forEach(g => { (byCat[g.cat] = byCat[g.cat] || []).push(g); });
let html = '';
order.filter(c => byCat[c]).forEach(cat => {
const meta = this._ingCat[cat] || { label: cat, icon: '' };
const gs = byCat[cat].sort((a, b) => b.arts.length - a.arts.length);
const nArts = gs.reduce((n, g) => n + g.arts.length, 0);
const collapsed = this._collapsedCats && this._collapsedCats.has(cat);
html += `
${meta.icon}
${meta.label} ${nArts}
`;
html += gs.map(g => this._renderSourceGroup(g, upByArt)).join('');
html += `
`;
});
list.innerHTML = html;
},
_renderSourceGroup(g, upByArt) {
const feed = this._feedFor(g.name, g.arts[0] && g.arts[0].source_url);
const badges = feed ? UI._renderClassificationBadges(feed) : '';
const nameEsc = UI.escape(g.name);
const arts = g.arts
.slice()
.sort((a, b) => this._ts(b) - this._ts(a))
.slice(0, 30)
.map(a => {
const t = this._fmtDate(a);
const h = UI.escape(a.headline_de || a.headline || a.title || 'Ohne Titel');
const up = upByArt[a.id];
if (up && up.category !== 'webseiten') {
// Datei-Quelle (Dokument/Bild/Audio): Aktionen statt Link
return `
${t} ${h}
${this._uploadActions(up)}
Entfernen
`;
}
return a.source_url
? `${t} ${h} `
: `${t} ${h}
`;
}).join('');
return ``;
},
toggleSrcOpen(nameEl) {
const item = nameEl.closest('.src-item');
if (item) item.classList.toggle('open');
},
toggleCat(cat) {
if (!this._collapsedCats) this._collapsedCats = new Set();
const el = document.querySelector(`.src-cat[data-cat="${cat}"]`);
if (this._collapsedCats.has(cat)) { this._collapsedCats.delete(cat); if (el) el.classList.remove('collapsed'); }
else { this._collapsedCats.add(cat); if (el) el.classList.add('collapsed'); }
},
toggleSource(name, on) {
if (this.selected === null) {
// bisher alle aktiv -> Set aus allen Namen bilden
this.selected = new Set(Object.values(this.articles.reduce((m, a) => {
m[a.source || 'Unbekannt'] = a.source || 'Unbekannt'; return m;
}, {})));
}
if (on) this.selected.add(name); else this.selected.delete(name);
const item = document.querySelector(`.src-item[data-source="${CSS.escape(name)}"]`);
if (item) item.classList.toggle('deselected', !on);
// "Alle"-Checkbox synchronisieren
const total = document.querySelectorAll('.src-item').length;
document.getElementById('src-select-all').checked = this.selected.size >= total;
},
toggleAllSources(on) {
document.querySelectorAll('.src-item').forEach(item => {
item.classList.toggle('deselected', !on);
const cb = item.querySelector('.src-check');
if (cb) cb.checked = on;
});
if (on) { this.selected = null; }
else { this.selected = new Set(); }
},
_activeSourceFilter() {
if (this.selected === null) return null; // alle
return [...this.selected];
},
// Live-Filter der Quellenliste (Name + Artikel-Ueberschriften)
filterSources(q) {
const term = (q || '').trim().toLowerCase();
document.querySelectorAll('#sources-list .src-item').forEach(item => {
const name = (item.dataset.source || '').toLowerCase();
let match = !term || name.includes(term);
if (!match) {
const arts = item.querySelectorAll('.src-article');
for (const a of arts) {
if (a.textContent.toLowerCase().includes(term)) { match = true; break; }
}
}
item.classList.toggle('filtered-out', !match);
});
},
// === Quellen-Ingest (Upload/URL -> Artikel) ===
_ingCat: {
webseiten: { label: 'Webseiten', icon: ' ' },
dokumente: { label: 'Dokumente', icon: ' ' },
bilder: { label: 'Bilder', icon: ' ' },
sprachnachrichten:{ label: 'Sprachnachrichten', icon: ' ' },
},
toggleIngest() {
const p = document.getElementById('ingest-panel');
if (p) p.hidden = !p.hidden;
},
dzOver(e) { e.preventDefault(); document.getElementById('dropzone').classList.add('drag'); },
dzLeave(e) { e.preventDefault(); document.getElementById('dropzone').classList.remove('drag'); },
dzDrop(e) {
e.preventDefault();
document.getElementById('dropzone').classList.remove('drag');
const files = e.dataTransfer && e.dataTransfer.files;
if (files && files.length) this.dzFiles(files);
},
dzFiles(fileList) {
const files = Array.from(fileList || []);
if (files.length) this._submitUpload(files, null);
},
urlKey(e) { if (e.key === 'Enter') { e.preventDefault(); this.addUrl(); } },
addUrl() {
const inp = document.getElementById('ingest-url');
const u = (inp.value || '').trim();
if (!u) return;
if (!/^https?:\/\//i.test(u)) { UI.showToast('URL muss mit http:// oder https:// beginnen', 'error'); return; }
inp.value = '';
this._submitUpload([], u);
},
async _submitUpload(files, url) {
if (!this.incident) return;
try {
await API.createUploads(this.incident.id, { files, url });
await this.loadUploads();
this._startUploadPoll();
} catch (e) {
UI.showToast((e && e.message) || 'Hinzufügen fehlgeschlagen', 'error');
}
},
async loadUploads() {
if (!this.incident) return;
let res;
try { res = await API.listUploads(this.incident.id); }
catch (e) { return; }
const prevDone = new Set((this.uploads || []).filter(u => u.status === 'done').map(u => u.id));
this.uploads = (res && res.uploads) || [];
this.renderUploads();
// Frisch fertige Jobs -> neue Artikel in die Quellenliste übernehmen
const freshlyDone = this.uploads.some(u => u.status === 'done' && !prevDone.has(u.id) && !this._seenDone.has(u.id));
this.uploads.forEach(u => { if (u.status === 'done') this._seenDone.add(u.id); });
if (freshlyDone) this._refreshCorpus();
const active = this.uploads.some(u => u.status === 'queued' || u.status === 'processing');
if (active) this._startUploadPoll(); else this._stopUploadPoll();
},
_startUploadPoll() {
if (this._uploadPoll) return;
this._uploadPoll = setInterval(() => this.loadUploads(), 1800);
},
_stopUploadPoll() {
if (this._uploadPoll) { clearInterval(this._uploadPoll); this._uploadPoll = null; }
},
async _refreshCorpus() {
try {
const arts = await API.getArticles(this.incident.id, { limit: 500 }).catch(() => ({ articles: [] }));
this.articles = Array.isArray(arts) ? arts : (arts.articles || []);
this.renderSources();
} catch (e) { /* nicht kritisch */ }
},
renderUploads() {
const host = document.getElementById('ingest-jobs');
if (!host) return;
// Nur laufende/fehlerhafte Jobs hier; fertige erscheinen in der Quellenliste.
const ups = (this.uploads || []).filter(u => u.status === 'queued' || u.status === 'processing' || u.status === 'error');
if (!ups.length) { host.innerHTML = ''; return; }
// nach Kategorie gruppieren (feste Reihenfolge)
const order = ['webseiten', 'dokumente', 'bilder', 'sprachnachrichten'];
const groups = {};
ups.forEach(u => { (groups[u.category] = groups[u.category] || []).push(u); });
let html = '';
order.filter(c => groups[c]).forEach(cat => {
const meta = this._ingCat[cat] || { label: cat, icon: '' };
html += `${meta.icon}${meta.label} ${groups[cat].length}
`;
groups[cat].forEach(u => { html += this._renderUploadItem(u); });
html += `
`;
});
host.innerHTML = html;
},
_renderUploadItem(u) {
const name = UI.escape(u.filename || u.source_url || 'Quelle');
const busy = (u.status === 'queued' || u.status === 'processing');
let status = '';
if (busy) {
const pct = Math.max(3, u.progress || 0);
status = `
${UI.escape(u.stage || 'In Arbeit')} · ${pct}%
`;
} else if (u.status === 'error') {
status = `Fehler: ${UI.escape(u.error || 'unbekannt')}
`;
} else if (u.status === 'done') {
status = `${this._uploadActions(u)}
`;
}
return ``;
},
_uploadActions(u) {
const aid = u.article_id || 0;
const btns = [];
if (u.category === 'sprachnachrichten') {
btns.push(`Transkription `);
btns.push(`Anhören `);
} else if (u.category === 'bilder') {
btns.push(`Text (OCR) `);
btns.push(`Bild öffnen `);
} else if (u.category === 'webseiten') {
btns.push(`Text ansehen `);
if (u.source_url) btns.push(`Link öffnen `);
} else {
btns.push(`Text ansehen `);
if (u.has_file) btns.push(`Original `);
}
return btns.join('');
},
toggleText(uid, articleId) {
const host = document.getElementById('ju-media-' + uid);
if (!host) return;
if (host.dataset.open === 'text') { host.innerHTML = ''; host.dataset.open = ''; return; }
const art = (this.articles || []).find(a => a.id === articleId);
const txt = art ? (art.content_de || art.content_original || '') : '';
host.innerHTML = `${txt ? UI.escape(txt).slice(0, 8000).replace(/\n/g, ' ') : 'Text wird vorbereitet … '}
`;
host.dataset.open = 'text';
if (!art) this._refreshCorpus();
},
async playAudio(uid) {
const host = document.getElementById('ju-media-' + uid);
if (!host) return;
if (host.dataset.open === 'audio') { host.innerHTML = ''; host.dataset.open = ''; return; }
host.innerHTML = 'Lädt … ';
host.dataset.open = 'audio';
try {
const url = await API.fetchUploadBlobUrl(this.incident.id, uid);
host.innerHTML = ` `;
} catch (e) {
host.innerHTML = `${UI.escape(e.message || 'Audio-Fehler')} `;
}
},
async openOriginal(uid) {
try {
const url = await API.fetchUploadBlobUrl(this.incident.id, uid);
window.open(url, '_blank', 'noopener');
} catch (e) { UI.showToast(e.message || 'Datei-Fehler', 'error'); }
},
async deleteUploadItem(uid) {
const ok = await this.askConfirm({
title: 'Quelle entfernen?',
okLabel: 'Entfernen',
body: 'Die hochgeladene Datei und der daraus erzeugte Text werden entfernt.
',
});
if (!ok) return;
try {
await API.deleteUpload(this.incident.id, uid);
this._seenDone.delete(uid);
await this.loadUploads();
this._refreshCorpus();
} catch (e) { UI.showToast(e.message || 'Löschen fehlgeschlagen', 'error'); }
},
// === Spalte 3: Artefakte ===
renderArtifacts(inc, fcs, locs, catLabels) {
const sources = this._sources(inc);
// Lagebild / Recherchebericht + Neueste Entwicklungen / Zusammenfassung
// Bei Recherche die Zusammenfassung/Ueberblick aus dem Bericht herauslösen,
// damit sie nicht doppelt (auch im eigenen Zusammenfassung-Tab) erscheint.
const summaryEl = document.getElementById('art-summary-body');
const latestEl = document.getElementById('art-latest-body');
if (inc.type === 'research') {
const ex = UI.extractZusammenfassung(inc.summary || '');
const reportText = ex.zusammenfassung ? ex.remaining : (inc.summary || '');
summaryEl.innerHTML = UI.renderSummary(reportText, sources, inc.type);
latestEl.innerHTML = ex.zusammenfassung
? UI.renderZusammenfassung(ex.zusammenfassung, sources)
: 'Keine separate Zusammenfassung. ';
} else {
summaryEl.innerHTML = UI.renderSummary(inc.summary || '', sources, inc.type);
latestEl.innerHTML = UI.renderLatestDevelopments(inc.latest_developments || '', sources);
}
// Faktencheck
const fcBody = document.getElementById('art-fc-body');
App._fcHidden = new Set();
if (fcs && fcs.length) {
document.getElementById('art-fc-meta').textContent = fcs.length + ' geprüft';
const filters = UI.renderFactCheckFilters(fcs);
fcBody.innerHTML = (filters ? `${filters}
` : '')
+ fcs.map(fc => UI.renderFactCheck(fc)).join('');
} else {
document.getElementById('art-fc-meta').textContent = '';
fcBody.innerHTML = 'Noch keine Faktenchecks. ';
}
// Faktencheck-Historie (frühere, komplett-neu ersetzte Läufe)
fcBody.insertAdjacentHTML('beforeend',
`
Frühere Faktencheck-Läufe
Beim Öffnen geladen.
`);
// Karte (Kategorie-Legende kommt aus /locations mit)
UI.renderMap(locs || [], catLabels || null);
// Ereignis-Timeline: lazy beim Öffnen des Tabs laden
this._tlLoaded = false;
document.getElementById('art-tl-meta').textContent = '';
document.getElementById('art-tl-body').innerHTML = 'Wird beim Öffnen geladen. ';
// Snapshots-Meta zurücksetzen
document.getElementById('art-snap-meta').textContent = '';
document.getElementById('art-snap-body').innerHTML = 'Wird beim Öffnen geladen. ';
},
// Aktivitaets-/Ereignis-Timeline (lazy beim Öffnen des Tabs)
async loadTimeline(force) {
if (this._tlLoaded && !force) return;
this._tlLoaded = true;
const el = document.getElementById('art-tl-body');
if (!this.incident) return;
el.innerHTML = 'Lade Ereignisse … ';
let events = [];
try {
const res = await API.getEvents(this.incident.id);
events = (res && res.events) || [];
} catch (e) {
el.innerHTML = 'Ereignisse konnten nicht geladen werden. ';
return;
}
document.getElementById('art-tl-meta').textContent = events.length + ' Ereignisse';
if (!events.length) { el.innerHTML = 'Noch keine Ereignisse. '; return; }
// nach Typ in Untersektionen gruppieren (Reihenfolge in der Gruppe: neueste zuerst)
const groups = {};
events.forEach(ev => { (groups[ev.type] = groups[ev.type] || []).push(ev); });
const sections = this._evSections.slice();
Object.keys(groups).forEach(k => {
if (!sections.some(s => s.key === k)) {
sections.push({ key: k, label: (this._evMeta[k] || {}).label || 'Sonstige' });
}
});
el.innerHTML = sections
.filter(s => groups[s.key] && groups[s.key].length)
.map(s => {
const icon = (this._evMeta[s.key] || {}).icon || '';
const items = groups[s.key].map(ev => this._renderEvent(ev)).join('');
return `
${icon}
${UI.escape(s.label)}
${groups[s.key].length}
${items}
`;
}).join('');
},
_fmtTs(ts) {
const d = parseUTC(ts);
return d ? this._fmt(d, true) : '';
},
_renderEvent(ev) {
const time = this._fmtTs(ev.ts);
let body;
if (ev.type === 'chat_qa') {
const d = ev.detail || '';
const marker = '— Antwort —';
const mi = d.indexOf(marker);
const q = (mi >= 0 ? d.slice(0, mi) : (ev.title || '')).trim();
const a = mi >= 0 ? d.slice(mi + marker.length).trim() : '';
body = `${UI.escape(q)}
` +
(a ? `Antwort anzeigen ${this._renderReply(a, [])}
` : '');
} else if (ev.type === 'article_ingest') {
const t = ev.url
? `${UI.escape(ev.title || '')} `
: UI.escape(ev.title || '');
body = `${t}
` + (ev.source ? `${UI.escape(ev.source)}
` : '');
} else {
body = `${UI.escape(ev.title || '')}
` +
(ev.source ? `${UI.escape(ev.source)}
` : '');
}
return ``;
},
async loadSnapshots() {
if (this._snapLoaded) return;
this._snapLoaded = true;
const body = document.getElementById('art-snap-body');
try {
const snaps = await API.getSnapshots(this.incident.id) || [];
const list = Array.isArray(snaps) ? snaps : (snaps.snapshots || []);
document.getElementById('art-snap-meta').textContent = list.length + ' Berichte';
if (!list.length) { body.innerHTML = 'Noch keine früheren Lageberichte. '; return; }
body.innerHTML = list.map(s => {
const t = s.created_at ? this._fmt(parseUTC(s.created_at)) : '';
const prev = UI.escape((s.summary_preview || '').trim());
const meta = `${s.article_count || 0} Artikel · ${s.fact_check_count || 0} Faktenchecks`;
return `
${t} ${meta}
${prev}${prev ? ' …' : ''}
`;
}).join('');
} catch (e) {
body.innerHTML = 'Konnte Lageberichte nicht laden. ';
}
},
async onSnapToggle(el, snapId) {
if (!el.open || el.dataset.loaded) return;
el.dataset.loaded = '1';
const bodyEl = el.querySelector('.hist-body');
bodyEl.innerHTML = 'Lädt … ';
try {
const snap = await API.getSnapshot(this.incident.id, snapId);
let sources = [];
try { sources = JSON.parse(snap.sources_json || '[]'); } catch (_) {}
bodyEl.innerHTML = UI.renderSummary(snap.summary || '', sources, this.incident && this.incident.type);
} catch (e) {
bodyEl.innerHTML = 'Konnte Bericht nicht laden. ';
}
},
async onFcHistoryToggle(el) {
if (!el.open || el.dataset.loaded) return;
el.dataset.loaded = '1';
const body = el.querySelector('.fc-history-body');
try {
const res = await API.listFactcheckRuns(this.incident.id);
const runs = (res && res.runs) || [];
if (!runs.length) { body.innerHTML = 'Noch keine früheren Läufe. '; return; }
body.innerHTML = runs.map(r => {
const t = r.created_at ? this._fmt(parseUTC(r.created_at)) : '';
return `
${t} ${r.fact_count || 0} Fakten
Lädt …
`;
}).join('');
} catch (e) {
body.innerHTML = 'Konnte Historie nicht laden. ';
}
},
async onFcRunToggle(el, runId) {
if (!el.open || el.dataset.loaded) return;
el.dataset.loaded = '1';
const body = el.querySelector('.hist-body');
try {
const res = await API.getFactcheckRun(this.incident.id, runId);
const facts = (res && res.facts) || [];
body.innerHTML = facts.length
? facts.map(fc => UI.renderFactCheck(fc)).join('')
: 'Keine Fakten in diesem Lauf. ';
} catch (e) {
body.innerHTML = 'Konnte Lauf nicht laden. ';
}
},
// === Verschiebbare Trennung Tab-Panel <-> Konversation ===
_initDivider() {
const divider = document.getElementById('center-divider');
const center = document.getElementById('studio-center');
if (!divider || !center) return;
const chat = center.querySelector('.chat-region');
// gespeicherte Höhe wiederherstellen
const saved = parseInt(localStorage.getItem('studio_chat_h') || '', 10);
if (saved) center.style.setProperty('--chat-h', saved + 'px');
let startY = 0, startH = 0;
const onMove = (e) => {
const dy = e.clientY - startY;
let h = startH - dy; // nach oben ziehen => Chat größer
const minChat = 140;
const maxChat = center.clientHeight - 180; // Tab-Panel behält Mindesthoehe
h = Math.max(minChat, Math.min(Math.max(minChat, maxChat), h));
center.style.setProperty('--chat-h', h + 'px');
};
const onUp = (e) => {
divider.classList.remove('dragging');
document.body.style.userSelect = '';
document.removeEventListener('pointermove', onMove);
document.removeEventListener('pointerup', onUp);
try { divider.releasePointerCapture(e.pointerId); } catch (_) {}
// Karte neu vermessen, falls Karten-Tab aktiv
if (this.activeTab === 'map' && UI.invalidateMap) UI.invalidateMap();
const cur = center.style.getPropertyValue('--chat-h');
if (cur.endsWith('px')) localStorage.setItem('studio_chat_h', parseInt(cur, 10));
};
divider.addEventListener('pointerdown', (e) => {
startY = e.clientY;
startH = chat.getBoundingClientRect().height;
divider.classList.add('dragging');
document.body.style.userSelect = 'none';
try { divider.setPointerCapture(e.pointerId); } catch (_) {}
document.addEventListener('pointermove', onMove);
document.addEventListener('pointerup', onUp);
e.preventDefault();
});
},
// Tabs per Drag&Drop umsortieren (Delegation auf der statischen Tabbar)
_initTabDnD() {
const bar = document.getElementById('center-tabbar');
if (!bar) return;
const clearMarkers = () => bar.querySelectorAll('.center-tab').forEach(t =>
t.classList.remove('drop-before', 'drop-after'));
bar.addEventListener('dragstart', (e) => {
const tab = e.target.closest('.center-tab');
if (!tab) return;
this._dragKey = tab.dataset.art;
e.dataTransfer.effectAllowed = 'move';
try { e.dataTransfer.setData('text/plain', tab.dataset.art); } catch (_) {}
tab.classList.add('dragging-tab');
});
bar.addEventListener('dragend', (e) => {
const tab = e.target.closest('.center-tab');
if (tab) tab.classList.remove('dragging-tab');
this._dragKey = null;
clearMarkers();
});
bar.addEventListener('dragover', (e) => {
if (!this._dragKey) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
clearMarkers();
const over = e.target.closest('.center-tab');
if (over && over.dataset.art !== this._dragKey) {
const r = over.getBoundingClientRect();
const after = (e.clientX - r.left) > r.width / 2;
over.classList.add(after ? 'drop-after' : 'drop-before');
}
});
bar.addEventListener('drop', (e) => {
if (!this._dragKey) return;
e.preventDefault();
const fromKey = this._dragKey;
const over = e.target.closest('.center-tab');
let toIndex;
if (over && over.dataset.art !== fromKey) {
const r = over.getBoundingClientRect();
const after = (e.clientX - r.left) > r.width / 2;
const overIdx = this.openTabs.indexOf(over.dataset.art);
toIndex = after ? overIdx + 1 : overIdx;
} else {
toIndex = this.openTabs.length;
}
const fromIdx = this.openTabs.indexOf(fromKey);
if (fromIdx === -1) return;
this.openTabs.splice(fromIdx, 1);
if (fromIdx < toIndex) toIndex--;
this.openTabs.splice(toIndex, 0, fromKey);
this._dragKey = null;
clearMarkers();
this._renderTabbar(); // baut Tabs neu, aktiver Zustand bleibt erhalten
});
},
// === Studio-Tabs in der Mitte ===
// === Einstellungen des Falls =========================================
// Der Reiter ersetzt das Bearbeiten-Modal der klassischen Ansicht. Er
// schickt beim Speichern nur, was sich wirklich geaendert hat. Zwei Gruende.
// Der Server aktualisiert feldweise, alles Mitgeschickte wuerde sonst
// ueberschrieben, auch wenn es nur unveraendert zurueckgereicht wird. Und
// eine unbeabsichtigte Aenderung am Intervall kann einen laufenden Zeitplan
// verstellen.
_setEl(id) { return document.getElementById(id); },
/** Liest das Formular. Der Rueckgabewert ist mit dem Schnappschuss vergleichbar. */
_settingsWerte() {
const g = (id) => this._setEl(id);
const einheit = parseInt(g('set-refresh-unit').value) || 1;
const zahl = parseInt(g('set-refresh-value').value) || 15;
const auto = g('set-refresh-mode').value === 'auto';
const werte = {
title: g('set-title').value.trim(),
description: g('set-description').value.trim(),
international_sources: g('set-international').checked,
include_telegram: g('set-telegram').checked,
include_x: g('set-x').checked,
visibility: g('set-visibility').checked ? 'public' : 'private',
ai_backend: g('set-ai-backend').value || '',
refresh_mode: g('set-refresh-mode').value,
refresh_interval: Math.max(10, Math.min(10080, zahl * einheit)),
retention_days: Math.max(0, Math.min(999, parseInt(g('set-retention').value) || 0)),
};
// Die Startzeit gehoert nur zur automatischen Aktualisierung. Bei
// manueller Aktualisierung bleibt der gespeicherte Wert unangetastet,
// ausgewertet wird er dort ohnehin nicht.
if (auto) werte.refresh_start_time = g('set-refresh-starttime').value || '07:00';
return werte;
},
_settingsAbo() {
return {
notify_email_summary: this._setEl('set-notify-summary').checked,
notify_email_new_articles: this._setEl('set-notify-new-articles').checked,
notify_email_status_change: this._setEl('set-notify-status-change').checked,
};
},
/** Fuellt das Formular aus dem offenen Fall. Dient auch als Verwerfen. */
async loadSettings() {
const inc = this.incident;
if (!inc || !this._setEl('set-title')) return;
const g = (id) => this._setEl(id);
g('set-title').value = inc.title || '';
g('set-description').value = inc.description || '';
g('set-international').checked = !!inc.international_sources;
g('set-telegram').checked = !!inc.include_telegram;
g('set-x').checked = !!inc.include_x;
g('set-visibility').checked = (inc.visibility || 'public') === 'public';
g('set-ai-backend').value = inc.ai_backend || '';
g('set-refresh-mode').value = inc.refresh_mode === 'auto' ? 'auto' : 'manual';
g('set-refresh-starttime').value = inc.refresh_start_time || '07:00';
g('set-retention').value = (inc.retention_days === null || inc.retention_days === undefined)
? 0 : inc.retention_days;
// Intervall in die groesste Einheit zeigen, die glatt aufgeht. 1440
// Minuten liest sich als 1 Tag besser denn als 1440 Minuten.
const iv = parseInt(inc.refresh_interval) || 15;
let einheit = 1;
for (const u of [10080, 1440, 60]) { if (iv % u === 0) { einheit = u; break; } }
g('set-refresh-unit').value = String(einheit);
g('set-refresh-value').value = String(Math.round(iv / einheit));
this.settingsRefreshToggle();
this.settingsIntervalMin();
this.settingsVisibilityHint();
this._settingsRunningNote();
// Der Schnappschuss ist die Vergleichsgrundlage fuers Speichern. Er
// wird vor dem Abo-Nachladen gesetzt, damit ein langsamer Abruf das
// Formular nicht in einem halben Zustand zuruecklaesst.
this._settingsAlt = this._settingsWerte();
this.settingsAiChanged();
this._settingsState('');
try {
const abo = await API.getSubscription(inc.id);
g('set-notify-summary').checked = !!(abo && abo.notify_email_summary);
g('set-notify-new-articles').checked = !!(abo && abo.notify_email_new_articles);
g('set-notify-status-change').checked = !!(abo && abo.notify_email_status_change);
} catch (e) {
// Ohne Abo-Stand bleiben die Haken aus. Das ist der Serverzustand
// fuer einen Fall ohne Abo.
g('set-notify-summary').checked = false;
g('set-notify-new-articles').checked = false;
g('set-notify-status-change').checked = false;
}
this._settingsAboAlt = this._settingsAbo();
// Bewusst abgewartet. Erst danach ist das Formular vollstaendig,
// vorher stuende der X-Schalter noch auf dem Ausgangswert.
await this._settingsXHint();
},
settingsRefreshToggle() {
const auto = this._setEl('set-refresh-mode').value === 'auto';
this._setEl('set-interval-field').classList.toggle('visible', auto);
this._setEl('set-starttime-field').classList.toggle('visible', auto);
},
settingsIntervalMin() {
const einheit = parseInt(this._setEl('set-refresh-unit').value);
const feld = this._setEl('set-refresh-value');
const min = einheit === 1 ? 10 : 1;
feld.min = min;
if (parseInt(feld.value) < min) feld.value = min;
},
settingsVisibilityHint() {
const oeffentlich = this._setEl('set-visibility').checked;
this._setEl('set-visibility-text').textContent = oeffentlich
? 'Öffentlich, für alle Nutzer sichtbar'
: 'Privat, nur für dich sichtbar';
},
/** Warnt, sobald der KI-Weg vom gespeicherten Stand abweicht. */
settingsAiChanged() {
const w = this._setEl('set-ai-warn');
if (!w) return;
const jetzt = this._setEl('set-ai-backend').value || '';
const alt = (this._settingsAlt && this._settingsAlt.ai_backend) || '';
w.hidden = jetzt === alt;
},
_settingsRunningNote() {
const n = this._setEl('set-running-note');
if (n) n.hidden = !this._runningStage;
},
_settingsState(text, art) {
const s = this._setEl('set-state');
if (!s) return;
s.textContent = text || '';
s.className = 'settings-state' + (art ? ' ' + art : '');
},
/** X laesst sich nur einschalten, wenn ein Zugang hinterlegt ist. */
async _settingsXHint() {
const cb = this._setEl('set-x');
const hint = this._setEl('set-x-hint');
if (!cb) return;
let aktiv = 0;
try {
const konten = await API.listXAccounts();
aktiv = (konten || []).filter(a => a.active).length;
} catch (e) {
aktiv = 0; // im Zweifel sperren
}
// Nutzt der Fall X bereits, bleibt der Schalter bedienbar. Sonst koennte
// man ihn nach dem Wegfall des letzten Zugangs nicht mehr abschalten.
cb.disabled = aktiv === 0 && !cb.checked;
if (hint) hint.hidden = aktiv !== 0;
},
async saveSettings() {
const inc = this.incident;
if (!inc || !this._setEl('set-title')) return;
const knopf = this._setEl('set-save');
const jetzt = this._settingsWerte();
if (!jetzt.title) {
UI.showToast('Ohne Titel geht es nicht', 'error');
this._setEl('set-title').focus();
return;
}
const alt = this._settingsAlt || {};
const patch = {};
Object.keys(jetzt).forEach(k => {
if (jetzt[k] !== alt[k]) patch[k] = jetzt[k];
});
const abo = this._settingsAbo();
const aboAlt = this._settingsAboAlt || {};
const aboGeaendert = Object.keys(abo).some(k => abo[k] !== aboAlt[k]);
if (!Object.keys(patch).length && !aboGeaendert) {
this._settingsState('Nichts geändert');
return;
}
knopf.disabled = true;
this._settingsState('Wird gespeichert …');
try {
if (Object.keys(patch).length) {
const neu = await API.updateIncident(inc.id, patch);
// Der Server antwortet mit der angereicherten Lage. Sie ist die
// verlaessliche Quelle, das Formular koennte abweichen.
this.incident = (neu && neu.id) ? neu : Object.assign({}, inc, patch);
const i = this.incidents.findIndex(x => x.id === inc.id);
if (i >= 0) this.incidents[i] = Object.assign({}, this.incidents[i], this.incident);
}
if (aboGeaendert) await API.updateSubscription(inc.id, abo);
this._settingsAlt = this._settingsWerte();
this._settingsAboAlt = abo;
this.settingsAiChanged();
this.renderHeader(this.incident);
this.renderCases();
this._settingsState('Gespeichert', 'ok');
UI.showToast('Einstellungen gespeichert', 'success');
} catch (e) {
this._settingsState('Nicht gespeichert', 'fehler');
UI.showToast((e && e.message) || 'Einstellungen konnten nicht gespeichert werden', 'error');
} finally {
knopf.disabled = false;
}
},
/** Kennzeichen des KI-Wegs. Leer, solange die Org-Vorgabe gilt. */
_aiTag(inc, klasse) {
const b = inc && inc.ai_backend;
if (b === 'bedrock') {
return `EU `;
}
if (b === 'cli') {
return `Anthropic `;
}
return '';
},
_tabTitle(key) {
const el = document.querySelector(`#studio-menu .studio-menu-item[data-art="${CSS.escape(key)}"] .mi-title`);
return el ? el.textContent.trim() : key;
},
_tabIcon(key) {
const el = document.querySelector(`#studio-menu .studio-menu-item[data-art="${CSS.escape(key)}"] .mi-icon`);
return el ? el.innerHTML : '';
},
_initTabs() {
this.openTabs = [];
this.activeTab = null;
document.getElementById('studio-center').classList.remove('has-tabs');
document.getElementById('center-tabbar').innerHTML = '';
document.querySelectorAll('#center-tab-content .tab-panel').forEach(p => p.classList.remove('active'));
document.querySelectorAll('#studio-menu .studio-menu-item').forEach(m => m.classList.remove('open', 'active'));
// Standard: Lagebild/Recherchebericht als erster Tab
this.openTab('summary');
},
_renderTabbar() {
const bar = document.getElementById('center-tabbar');
bar.innerHTML = this.openTabs.map(key => {
const k = key.replace(/'/g, "\\'");
return `
${this._tabIcon(key)}
${UI.escape(this._tabTitle(key))}
${this._closeSvg}
`;
}).join('');
document.querySelectorAll('#studio-menu .studio-menu-item').forEach(m => {
m.classList.toggle('open', this.openTabs.includes(m.dataset.art));
});
},
openTab(key) {
if (!this.openTabs.includes(key)) this.openTabs.push(key);
document.getElementById('studio-center').classList.add('has-tabs');
this._renderTabbar();
this.activateTab(key);
},
activateTab(key) {
this.activeTab = key;
document.querySelectorAll('#center-tab-content .tab-panel').forEach(p => {
p.classList.toggle('active', p.dataset.art === key);
});
document.querySelectorAll('#center-tabbar .center-tab').forEach(t => {
t.classList.toggle('active', t.dataset.art === key);
});
document.querySelectorAll('#studio-menu .studio-menu-item').forEach(m => {
m.classList.toggle('active', m.dataset.art === key);
});
// Nebenwirkungen: Karte neu vermessen / Snapshots lazy laden
if (key === 'map') {
setTimeout(() => {
if (UI.retryPendingMap) UI.retryPendingMap();
if (UI.invalidateMap) UI.invalidateMap();
}, 60);
}
if (key === 'snapshots') this.loadSnapshots();
if (key === 'timeline') this.loadTimeline();
if (key === 'settings') this.loadSettings();
},
closeTab(key, ev) {
if (ev) ev.stopPropagation();
const idx = this.openTabs.indexOf(key);
if (idx === -1) return;
this.openTabs.splice(idx, 1);
const mi = document.querySelector(`#studio-menu .studio-menu-item[data-art="${CSS.escape(key)}"]`);
if (mi) mi.classList.remove('open', 'active');
if (!this.openTabs.length) {
this.activeTab = null;
document.getElementById('studio-center').classList.remove('has-tabs');
this._renderTabbar();
document.querySelectorAll('#center-tab-content .tab-panel').forEach(p => p.classList.remove('active'));
return;
}
// Nachbar-Tab aktivieren, wenn der aktive geschlossen wurde
let nextKey = this.activeTab;
if (this.activeTab === key || !this.openTabs.includes(this.activeTab)) {
nextKey = this.openTabs[Math.min(idx, this.openTabs.length - 1)];
}
this._renderTabbar();
this.activateTab(nextKey);
},
// === Spalte 2: RAG-Chat ===
renderChatIntro(inc) {
const box = document.getElementById('chat-messages');
const sources = this._sources(inc);
let intro = '';
if (inc.summary) {
const ex = inc.type === 'research' ? UI.extractZusammenfassung(inc.summary) : { zusammenfassung: null };
const short = ex.zusammenfassung
? UI.renderZusammenfassung(ex.zusammenfassung, sources)
: UI.renderSummary(inc.summary, sources, inc.type);
intro = `
${inc.type === 'research' ? 'Recherchebericht' : 'Lagebild'}
${short}
`;
} else {
intro = `Zu diesem Fall gibt es noch kein Lagebild. Starte eine Aktualisierung.
`;
}
box.innerHTML = intro;
box.scrollTop = 0;
// Bewusst keine Vorschlagsfragen mehr (auch nicht bei "Neu"): der Chat
// startet leer; gestellte Fragen landen in der Ereignis-Timeline.
document.getElementById('chat-suggestions').innerHTML = '';
},
askChip(btn) {
document.getElementById('chat-input').value = btn.textContent;
this.sendChat();
},
toggleScope(on) {
this.searchAllScope = !!on;
const lbl = document.getElementById('chat-scope');
if (lbl) lbl.classList.toggle('on', this.searchAllScope);
},
autoGrow(ta) { ta.style.height = 'auto'; ta.style.height = Math.min(ta.scrollHeight, 120) + 'px'; },
chatKey(e) {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); this.sendChat(); }
},
resetChat() {
this.convId = null;
if (this.incident) this.renderChatIntro(this.incident);
},
async sendChat() {
const ta = document.getElementById('chat-input');
const msg = (ta.value || '').trim();
if (!msg || !this.incident) return;
const box = document.getElementById('chat-messages');
box.insertAdjacentHTML('beforeend',
`${UI.escape(msg)}
`);
ta.value = ''; this.autoGrow(ta);
const thinking = document.createElement('div');
thinking.className = 'chat-msg chat-msg-assistant chat-thinking';
thinking.textContent = 'Denkt nach ...';
box.appendChild(thinking);
box.scrollTop = box.scrollHeight;
document.getElementById('chat-send').disabled = true;
try {
const res = await API.askIncident(this.incident.id, msg, {
conversation_id: this.convId,
source_filter: this._activeSourceFilter(),
scope: this.searchAllScope ? 'alle' : 'fall',
});
this.convId = res.conversation_id || this.convId;
thinking.classList.remove('chat-thinking');
thinking.innerHTML = this._renderReply(res.reply || '', res.sources || []);
// Analyst bietet gezielte Recherche an, wenn die Materialien nicht reichen
if (res.offer && res.offer.needs_research) {
thinking.insertAdjacentHTML('beforeend', this._renderOffer(res.offer));
}
// Frage & Antwort wurden serverseitig protokolliert -> Timeline auffrischen
this._tlLoaded = false;
if (this.activeTab === 'timeline') this.loadTimeline(true);
} catch (e) {
thinking.classList.remove('chat-thinking');
const notReady = e && (e.status === 404 || e.status === 405);
thinking.innerHTML = notReady
? 'Der Lage-Chat ist noch nicht aktiviert (Backend-Neustart erforderlich). '
: '' + UI.escape((e && e.message) || 'Fehler bei der Anfrage.') + ' ';
} finally {
document.getElementById('chat-send').disabled = false;
box.scrollTop = box.scrollHeight;
}
},
_renderReply(text, sources) {
// Zitate [n] klickbar machen -> highlightet Quelle in Spalte 1
let html = UI.escape(text);
html = html.replace(/\*\*(.+?)\*\*/g, '$1 ');
html = html.replace(/\n/g, ' ');
html = html.replace(/\[(\d+)\]/g, (m, n) => {
const src = (sources || []).find(s => String(s.nr) === n);
const title = src ? UI.escape(src.source || src.name || '') : '';
return `[${n}] `;
});
return html;
},
gotoCite(sourceName) {
if (!sourceName) return;
const item = document.querySelector(`.src-item[data-source="${CSS.escape(sourceName)}"]`);
if (!item) { UI.showToast('Quelle nicht in der Liste', 'info'); return; }
item.classList.add('open');
item.scrollIntoView({ behavior: 'smooth', block: 'center' });
item.classList.remove('cite-flash'); void item.offsetWidth;
item.classList.add('cite-flash');
},
// Recherche-Angebot des Analysten (wenn die Materialien die Frage nicht hergeben)
_renderOffer(offer) {
const focus = UI.escape(offer.focus || '');
const add = UI.escape(offer.description_addition || '');
const payload = encodeURIComponent(JSON.stringify({
focus: offer.focus || '', description_addition: offer.description_addition || '',
}));
return `
Dazu liegt in diesem Fall noch nichts vor.
Ich kann die Fallbeschreibung um diesen Aspekt ergänzen und gezielt dazu recherchieren, ausschließlich zu dieser Frage.
Ergänzung der Fallbeschreibung (editierbar):
Suchfokus: ${focus}
Ergänzen und gezielt recherchieren
`;
},
async runClarify(btn) {
const card = btn.closest('.chat-offer');
if (!card || !this.incident) return;
let data = {};
try { data = JSON.parse(decodeURIComponent(card.dataset.offer || '%7B%7D')); } catch (e) {}
const addEl = card.querySelector('.chat-offer-add');
const description_addition = addEl ? (addEl.value || '').trim() : (data.description_addition || '');
const focus = data.focus || '';
if (!focus) return;
btn.disabled = true; btn.textContent = 'Recherche läuft …';
try {
await API.clarify(this.incident.id, { focus, description_addition });
this._pollClarify(card);
} catch (e) {
btn.disabled = false; btn.textContent = 'Ergänzen und gezielt recherchieren';
UI.showToast((e && e.message) || 'Recherche konnte nicht gestartet werden', 'error');
}
},
_pollClarify(card) {
const incId = this.incident && this.incident.id;
if (!incId) return;
const btn = card.querySelector('.chat-offer-btn');
const textEl = card.querySelector('.chat-offer-text');
const tick = async () => {
let st = null;
try { const r = await API.getRunStatus(incId); st = r && r.state; } catch (e) {}
if (st && st.status === 'running') { setTimeout(tick, 4000); return; }
if (st && st.status === 'done') {
const n = (st.result && st.result.inserted) || 0;
card.innerHTML = `Gezielte Recherche abgeschlossen.
${n} neue Meldung${n === 1 ? '' : 'en'} zur Frage erfasst. Starte Analyse oder Faktencheck neu und stelle die Frage erneut.
`;
this._tlLoaded = false;
try { this.fresh = await API.getFreshness(incId); this._renderLauf(); } catch (e) {}
} else if (st && st.status === 'error') {
if (btn) { btn.disabled = false; btn.textContent = 'Erneut versuchen'; }
if (textEl) textEl.textContent = 'Die Recherche ist fehlgeschlagen. Bitte erneut versuchen.';
} else {
setTimeout(tick, 4000);
}
};
setTimeout(tick, 3000);
},
// === Der Lauf (Spalte 3) =============================================
// Es gibt genau einen Startpunkt. Die vier Schritte darunter sind Anzeige,
// sie starten nichts. Aufklappen erklaert nur, was ein Schritt tut.
//
// Der Server kann einzelne Bausteine weiterhin starten (POST .../stage).
// Die Oberflaeche nutzt das nicht mehr, zeigt einen so gestarteten Lauf
// aber korrekt an, denn er kann aus der klassischen Ansicht kommen.
_CANCELLABLE: ['collect', 'full'],
// 'network' hat keine Karte mehr, das Label bleibt trotzdem stehen. Kaeme
// der Baustein im Server, meldete ein laufender Schritt sonst nur seinen
// technischen Schluessel.
_STAGE_LABELS: {
collect: 'Artikel holen', analyze: 'Lagebild schreiben',
factcheck: 'Fakten prüfen', geoparse: 'Orte erkennen',
network: 'Netzwerkanalyse', full: 'Kompletter Lauf',
},
_SCHRITT_ICONS: {
collect: ' ',
analyze: ' ',
factcheck: ' ',
geoparse: ' ',
},
_SCHRITT_WAS: {
collect: 'Durchsucht die ausgewählten Quellen nach neuen Meldungen. Ohne diesen Schritt haben die folgenden nichts, womit sie arbeiten können.',
analyze: 'Wertet alle Artikel aus und schreibt daraus den zusammenhängenden Bericht. Der bisherige Bericht bleibt im Verlauf erhalten.',
factcheck: 'Zieht die Tatsachenbehauptungen aus dem Bericht und sucht nach Belegen und Widersprüchen. Der bisherige Stand wird als Lauf archiviert.',
geoparse: 'Findet Ortsangaben in den Artikeln und setzt sie auf die Karte. Läuft nur über Artikel, die noch nicht geprüft wurden.',
},
_schrittOffen: null,
/** Von aussen (Tastatur, WebSocket-Resync) heisst Aktualisieren kompletter Lauf. */
async refresh() { return this.runStage('full'); },
/**
* Der eine Startknopf. Laeuft gerade ein abbrechbarer Schritt, bricht er ab,
* sonst startet er den kompletten Lauf.
*
* Wichtig ist der laufende Schritt, nicht 'full'. Ein Lauf kann aus der
* klassischen Ansicht als 'collect' gestartet worden sein. Ein Abbruch auf
* 'full' wuerde ihn dann nicht treffen, obwohl der Knopf Abbrechen anzeigt.
*/
async laufKnopf() {
const run = this._runningStage;
if (run && this._CANCELLABLE.includes(run)) return this.runStage(run);
if (run) {
UI.showToast('Dieser Schritt lässt sich nicht abbrechen.', 'info');
return;
}
return this.runStage('full');
},
_stageEls() { return Array.from(document.querySelectorAll('[data-stage]')); },
_stageEl(stage) { return document.querySelector(`[data-stage="${CSS.escape(stage)}"]`); },
_stagesIdle() {
this._runningStage = null;
this._stageEls().forEach(b => {
b.classList.remove('running');
b.disabled = !this.incident;
});
this._renderLauf();
},
_stageRunning(stage, text) {
if (!stage) return;
this._runningStage = stage;
// Sobald etwas läuft, ist die Startfrage beantwortet.
const panel = document.getElementById('start-panel');
if (panel) panel.hidden = true;
if (this.incident) localStorage.setItem('studio_start_' + this.incident.id, 'run');
const cancellable = this._CANCELLABLE.includes(stage);
this._stageEls().forEach(b => {
b.classList.add('running');
// Der Startknopf bleibt nur klickbar, wenn sich der Lauf abbrechen lässt
b.disabled = !cancellable;
});
this._renderLauf(text);
},
klappSchritt(i) {
this._schrittOffen = (this._schrittOffen === i) ? null : i;
this._renderLauf();
},
// App-Zeitstempel stehen bereits in lokaler Zeit -> nur umformatieren, NICHT als UTC lesen
_fmtStamp(ts) {
const m = String(ts || '').match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})/);
return m ? `${m[3]}.${m[2]}. ${m[4]}:${m[5]}` : '';
},
_svg(pfad, groesse) {
return `${pfad} `;
},
/**
* Zustand der vier Schritte aus /freshness ableiten.
* 'fertig' | 'veraltet' | 'offen' | 'gesperrt' | 'laeuft' | 'wartet'
*/
_schrittZustaende() {
const f = this.fresh || {};
const run = this._runningStage;
const arts = f.articles || 0;
const s = f.summary || {};
const fc = f.factcheck || {};
const geo = f.geoparse || {};
const zust = (key, ohneLauf) => {
if (run === key) return 'laeuft';
// Der komplette Lauf und das Sammeln arbeiten alle Schritte ab
if (run === 'full' || run === 'collect') return 'wartet';
if (run) return 'wartet';
return ohneLauf();
};
return [
{
key: 'collect',
zustand: zust('collect', () => (arts ? 'fertig' : 'offen')),
stand: run === 'collect' ? 'läuft gerade'
: arts ? `${arts} Artikel${f.last_collect ? ', ' + this._fmtStamp(f.last_collect) : ''}`
: 'noch nicht gelaufen',
},
{
key: 'analyze',
zustand: zust('analyze', () => !arts ? 'gesperrt'
: !s.exists ? 'offen'
: s.pending > 0 ? 'veraltet' : 'fertig'),
stand: run === 'analyze' ? 'läuft gerade'
: !arts ? 'wartet auf Artikel'
: !s.exists ? 'noch nicht erzeugt'
: s.pending > 0 ? `${s.pending} neue Artikel seit dem Bericht`
: `aktuell, ${this._fmtStamp(s.last)}`,
},
{
key: 'factcheck',
zustand: zust('factcheck', () => !arts ? 'gesperrt'
: !fc.exists ? 'offen'
: fc.pending > 0 ? 'veraltet' : 'fertig'),
stand: run === 'factcheck' ? 'läuft gerade'
: !arts ? 'wartet auf Artikel'
: !fc.exists ? 'noch nicht erzeugt'
: fc.pending > 0 ? `${fc.pending} neue Artikel seit der Prüfung`
: `${fc.facts} Fakten geprüft`,
},
{
key: 'geoparse',
zustand: zust('geoparse', () => !arts ? 'gesperrt'
: geo.pending > 0 ? 'veraltet' : 'fertig'),
stand: run === 'geoparse' ? 'läuft gerade'
: !arts ? 'wartet auf Artikel'
: geo.pending > 0 ? `${geo.pending} Artikel noch nicht verortet`
: 'alle Artikel verortet',
},
];
},
/** Stand-Kasten, Kette und Schrittliste zeichnen. */
_renderLauf(runText) {
const kasten = document.getElementById('lauf-stand');
if (!kasten) return;
const inc = this.incident;
const f = this.fresh || {};
const run = this._runningStage;
const arts = f.articles || 0;
const research = inc && inc.type === 'research';
const haupt = document.getElementById('ls-haupt');
const neben = document.getElementById('ls-neben');
const knopf = document.getElementById('ls-btn');
const text = knopf && knopf.querySelector('.ls-text');
const iconEl = knopf && knopf.querySelector('.ls-icon');
const hinweis = document.getElementById('ls-hinweis');
const ICON_LAUF = ' ';
const ICON_STOPP = ' ';
// --- Stand-Kasten ---
let ueberschrift = '';
let knopfText = 'Aktualisieren';
let abbruch = false;
let betont = false;
if (!inc) {
ueberschrift = 'Kein Fall geöffnet';
} else if (run) {
ueberschrift = (this._STAGE_LABELS[run] || 'Ein Schritt') + ' läuft gerade';
abbruch = this._CANCELLABLE.includes(run);
knopfText = abbruch ? 'Abbrechen' : (runText || 'läuft …');
} else if (!arts) {
ueberschrift = 'Noch keine Artikel gesammelt';
knopfText = research ? 'Recherche starten' : 'Lauf starten';
betont = true;
} else {
const offen = this._schrittZustaende()
.filter(s => s.zustand === 'veraltet' || s.zustand === 'offen');
if (offen.length) {
const s = f.summary || {};
ueberschrift = s.pending > 0
? `${s.pending} neue Artikel seit dem letzten Bericht`
: 'Ergebnisse sind nicht auf dem neuesten Stand';
knopfText = 'Jetzt aktualisieren';
betont = true;
} else {
ueberschrift = 'Alle Ergebnisse auf dem neuesten Stand';
knopfText = 'Erneut durchlaufen';
}
}
kasten.classList.toggle('betont', betont);
if (haupt) {
haupt.textContent = ueberschrift;
haupt.classList.toggle('warn', betont && !!arts);
}
if (neben) {
neben.textContent = !inc ? ''
: arts ? `${arts} Artikel im Bestand`
: 'Noch kein Bestand';
}
if (text) text.textContent = knopfText;
if (iconEl) iconEl.innerHTML = this._svg(abbruch ? ICON_STOPP : ICON_LAUF, 16);
if (knopf) {
knopf.classList.toggle('abbrechen', abbruch);
knopf.disabled = !inc || (!!run && !abbruch);
knopf.title = abbruch ? 'Laufenden Schritt abbrechen'
: research ? 'Alle Schritte nacheinander, am Ende steht ein fertiger Recherchebericht'
: 'Alle Schritte nacheinander, am Ende steht ein fertiges Lagebild';
}
if (hinweis) {
hinweis.textContent = (!inc || run || arts) ? ''
: 'Danach entstehen Bericht, Faktencheck und Karte von selbst.';
hinweis.hidden = !hinweis.textContent;
}
// --- Kette und Schritte ---
const schritte = inc ? this._schrittZustaende() : [];
const kette = document.getElementById('lauf-kette');
const fortschritt = document.getElementById('lauf-fortschritt');
const liste = document.getElementById('lauf-schritte');
if (kette) {
kette.innerHTML = schritte.map((s, i) => {
const kn = ` `;
if (i === schritte.length - 1) return kn;
const voll = s.zustand === 'fertig' && schritte[i + 1].zustand === 'fertig';
return kn + ` `;
}).join('');
}
if (fortschritt) {
const fertig = schritte.filter(s => s.zustand === 'fertig').length;
fortschritt.textContent = inc
? `${fertig} von ${schritte.length} Schritten auf dem neuesten Stand` : '';
}
if (liste) {
liste.innerHTML = schritte.map((s, i) => {
const auf = this._schrittOffen === i;
const name = (s.key === 'analyze' && research)
? 'Recherchebericht schreiben' : this._STAGE_LABELS[s.key];
const marke = s.zustand === 'fertig'
? this._svg(' ', 12) : String(i + 1);
return `
${marke}
${UI.escape(name)}
${UI.escape(s.stand)}
${this._svg(' ', 13)}
${UI.escape(this._SCHRITT_WAS[s.key] || '')}
`;
}).join('');
}
// --- Nebenangaben an den Ansichten ---
this._renderMeta();
},
/** Zusatzangaben an den Ansichtsknoepfen, aus /freshness. */
_renderMeta() {
const f = this.fresh;
if (!f) return;
const arts = f.articles || 0;
const s = f.summary || {};
const fc = f.factcheck || {};
const setz = (id, wert) => {
const el = document.getElementById(id);
if (el) el.textContent = wert || '';
};
setz('art-summary-meta', s.exists
? `${arts} Artikel ausgewertet, ${this._fmtStamp(s.last)}` : 'noch nicht erzeugt');
setz('art-fc-meta', fc.exists
? `${fc.facts} Fakten, ${this._fmtStamp(fc.last)}` : 'noch nicht erzeugt');
const snap = document.getElementById('art-snap-meta');
if (snap && !snap.textContent) snap.textContent = f.snapshots ? String(f.snapshots) : '';
const tl = document.getElementById('art-tl-meta');
if (tl && !tl.textContent && f.events) tl.textContent = `${f.events} Ereignisse`;
},
async runStage(stage) {
if (!this.incident) return;
// Läuft genau dieser Baustein schon? -> Klick bedeutet Abbrechen
if (this._runningStage === stage && this._CANCELLABLE.includes(stage)) {
try {
await API.cancelRefresh(this.incident.id);
UI.showToast('Abbruch angefordert …', 'info');
} catch (e) {
UI.showToast((e && e.message) || 'Abbruch fehlgeschlagen', 'error');
}
return;
}
if (this._runningStage) {
UI.showToast('Es läuft bereits ein Baustein für diese Lage.', 'info');
return;
}
const label = this._STAGE_LABELS[stage] || 'Baustein';
this._stageRunning(stage, 'startet …');
try {
if (stage === 'full') {
// Beim leeren Fall ist es eine Recherche, sonst eine Aktualisierung -
// dieselbe Aktion, aber der Nutzer soll den Namen wiedererkennen, den
// er angeklickt hat.
const erstlauf = !(this.articles || []).length;
await API.refreshIncident(this.incident.id);
this._showStatus('In Warteschlange ...');
UI.showToast(erstlauf ? 'Recherche gestartet' : 'Kompletter Lauf gestartet', 'info');
} else if (stage === 'geoparse') {
await API.triggerGeoparse(this.incident.id);
this._showStatus('Geoparsing läuft …');
this._pollGeoparse();
} else {
await API.runStage(this.incident.id, stage);
this._showStatus(label + ' läuft …');
// 'collect' läuft über die Refresh-Pipeline -> Abschluss/Reload via WebSocket
// (_wireWs: refresh_complete/refresh_error). Analyse/Faktencheck via Polling.
if (stage !== 'collect') this._pollStage();
}
this._stageRunning(stage, 'läuft …');
} catch (e) {
this._stagesIdle();
this._hideStatus();
const busy = e && e.status === 409;
UI.showToast(busy ? 'Es läuft bereits ein Baustein für diese Lage.'
: ((e && e.message) || 'Start fehlgeschlagen'), 'error');
}
},
_pollStage() {
if (this._stagePoll) return;
this._stagePoll = setInterval(async () => {
let res;
try { res = await API.getRunStatus(this.incident.id); } catch (e) { return; }
const st = res && res.state;
if (!st) { this._stopStagePoll(); this._hideStatus(); this._stagesIdle(); return; }
if (st.status === 'running') {
this._showStatus((st.label || 'Baustein') + ' läuft …');
this._stageRunning(st.stage || this._runningStage, 'läuft …');
} else {
this._stopStagePoll(); this._hideStatus(); this._stagesIdle();
if (st.status === 'done') {
UI.showToast((st.label || 'Baustein') + ' fertig', 'info');
this.softRefresh();
} else if (st.status === 'error') {
UI.showToast((st.label || 'Baustein') + ' fehlgeschlagen: ' + (st.error || ''), 'error');
}
}
}, 2000);
},
_stopStagePoll() { if (this._stagePoll) { clearInterval(this._stagePoll); this._stagePoll = null; } },
_pollGeoparse() {
if (this._geoPoll) return;
this._geoPoll = setInterval(async () => {
let st;
try { st = await API.getGeoparseStatus(this.incident.id); } catch (e) { return; }
if (st && st.status === 'done') {
this._stopGeoPoll(); this._hideStatus(); this._stagesIdle();
UI.showToast('Geoparsing fertig · ' + (st.locations || 0) + ' Orte', 'info');
this.softRefresh();
} else if (st && st.status === 'error') {
this._stopGeoPoll(); this._hideStatus(); this._stagesIdle();
UI.showToast('Geoparsing fehlgeschlagen: ' + (st.error || ''), 'error');
} else {
// running / idle
if (st && st.total) {
const prog = (st.processed || 0) + '/' + st.total;
this._showStatus('Geoparsing läuft … ' + prog);
this._stageRunning('geoparse', prog);
}
}
}, 2000);
},
_stopGeoPoll() { if (this._geoPoll) { clearInterval(this._geoPoll); this._geoPoll = null; } },
async _resumeStage() {
// Beim Öffnen einer Lage: läuft dort gerade ein Baustein? -> Kachel + Polling aufnehmen
this._stopStagePoll();
this._stopGeoPoll();
try {
const res = await API.getRunStatus(this.incident.id);
if (res && res.state && res.state.status === 'running') {
this._showStatus((res.state.label || 'Baustein') + ' läuft …');
this._stageRunning(res.state.stage, 'läuft …');
this._pollStage();
return;
}
} catch (e) { /* ignore */ }
try {
const g = await API.getGeoparseStatus(this.incident.id);
if (g && g.status === 'running') {
this._showStatus('Geoparsing läuft …');
this._stageRunning('geoparse', 'läuft …');
this._pollGeoparse();
}
} catch (e) { /* ignore */ }
},
// Soft-Refresh (F5): Daten neu laden statt die Seite neu zu laden (wie im Monitor)
async softRefresh() {
await this.loadIncidents();
if (!this.incident) return;
const openBefore = this.openTabs.slice();
const activeBefore = this.activeTab;
await this.loadDetail(this.incident.id);
// geöffnete Tabs wiederherstellen (loadDetail öffnet sonst nur das Lagebild)
if (openBefore.length) {
this.openTabs = [];
document.getElementById('center-tabbar').innerHTML = '';
openBefore.forEach(k => this.openTab(k));
if (activeBefore && this.openTabs.includes(activeBefore)) this.activateTab(activeBefore);
}
UI.showToast('Aktualisiert', 'info');
},
_showStatus(text) {
const bar = document.getElementById('live-status');
document.getElementById('live-status-text').textContent = text;
bar.classList.add('active');
if (!this._startTime) this._startTime = Date.now();
if (!this._timerInt) {
this._timerInt = setInterval(() => {
const s = Math.floor((Date.now() - this._startTime) / 1000);
document.getElementById('live-status-timer').textContent =
Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0');
}, 1000);
}
},
_hideStatus() {
document.getElementById('live-status').classList.remove('active');
if (this._timerInt) { clearInterval(this._timerInt); this._timerInt = null; }
this._startTime = null;
document.getElementById('live-status-timer').textContent = '';
},
_wireWs() {
const forCurrent = (msg) => this.incident && String(msg.incident_id) === String(this.incident.id);
WS.on('status_update', (msg) => {
if (!forCurrent(msg)) return;
const label = (typeof UI._getStepLabel === 'function') ? UI._getStepLabel(msg.status) : (msg.status || '');
this._showStatus(msg.detail || label || 'Aktualisierung läuft ...');
});
WS.on('pipeline_step', (msg) => {
if (!forCurrent(msg)) return;
if (msg.label || msg.step) this._showStatus(msg.label || msg.step);
});
WS.on('refresh_complete', (msg) => {
if (!forCurrent(msg)) return;
const wasCollect = this._runningStage === 'collect';
this._hideStatus();
this._stagesIdle();
UI.showToast(wasCollect ? 'Sammeln abgeschlossen' : 'Aktualisierung abgeschlossen', 'info');
this.loadDetail(this.incident.id);
this.loadIncidents();
});
WS.on('refresh_error', (msg) => {
if (!forCurrent(msg)) return;
this._hideStatus();
this._stagesIdle();
UI.showToast('Aktualisierung fehlgeschlagen', 'error');
});
WS.on('refresh_cancelled', (msg) => {
if (!forCurrent(msg)) return;
this._hideStatus();
this._stagesIdle();
UI.showToast('Abgebrochen', 'info');
});
},
async syncRefreshStatus() {
// Nach WS-Reconnect: laufende Refreshes abgleichen
try {
const running = await API.getRefreshingIncidents();
const ids = (running || []).map(r => String(r.incident_id || r.id));
if (this.incident && ids.includes(String(this.incident.id))) {
this._showStatus('Aktualisierung läuft ...');
// Welcher der beiden Orchestrator-Bausteine läuft, sagt uns der Server nicht;
// ohne bekannten Zustand als "Kompletter Lauf" anzeigen (beide sind abbrechbar).
this._stageRunning(this._runningStage || 'full', 'läuft …');
} else {
this._hideStatus();
if (this._runningStage === 'collect' || this._runningStage === 'full') this._stagesIdle();
}
} catch (e) { /* ignore */ }
},
// === Neuen Fall anlegen (gleicher Funktionsumfang wie im Dashboard) ===
openNewIncident() {
const form = document.getElementById('new-incident-form');
form.reset();
document.getElementById('inc-visibility').checked = true;
document.getElementById('btn-enhance-description').disabled = true;
this.incTypeDefaults();
this.incRefreshToggle();
this.incVisibilityHint();
this._gateXToggle();
const m = document.getElementById('modal-new');
m.classList.add('active');
setTimeout(() => document.getElementById('inc-title').focus(), 50);
},
closeNewIncident() {
document.getElementById('modal-new').classList.remove('active');
if (this._enhanceController) { this._enhanceController.abort(); this._enhanceController = null; }
},
// X-Kanäle nur freigeben, wenn ein X-Zugang hinterlegt ist (wie im Dashboard)
async _gateXToggle() {
const cb = document.getElementById('inc-x');
const hint = document.getElementById('inc-x-hint');
if (!cb) return;
let active = 0;
try {
const accounts = await API.listXAccounts();
active = (accounts || []).filter(a => a.active).length;
} catch (e) {
active = 0; // im Zweifel sperren
}
cb.disabled = active === 0;
if (active === 0) cb.checked = false;
if (hint) hint.style.display = active === 0 ? '' : 'none';
},
incTitleChanged() {
const t = document.getElementById('inc-title').value.trim();
document.getElementById('btn-enhance-description').disabled = t.length < 3;
},
incTypeDefaults() {
const type = document.getElementById('inc-type').value;
const hint = document.getElementById('type-hint');
const mode = document.getElementById('inc-refresh-mode');
if (type === 'research') {
hint.textContent = 'Recherchiert in Tiefe: Nachrichtenarchive, Parlamentsdokumente, Fachmedien, Expertenquellen. Empfohlen: Manuell starten und bei Bedarf vertiefen.';
mode.value = 'manual';
} else {
hint.textContent = 'Durchsucht laufend hunderte Nachrichtenquellen nach neuen Meldungen. Empfohlen: Automatische Aktualisierung.';
}
this.incRefreshToggle();
},
incRefreshToggle() {
const auto = document.getElementById('inc-refresh-mode').value === 'auto';
document.getElementById('refresh-interval-field').classList.toggle('visible', auto);
document.getElementById('refresh-starttime-field').classList.toggle('visible', auto);
},
incIntervalMin() {
const unit = parseInt(document.getElementById('inc-refresh-unit').value);
const input = document.getElementById('inc-refresh-value');
const min = unit === 1 ? 10 : 1;
input.min = min;
if (parseInt(input.value) < min) input.value = min;
},
incVisibilityHint() {
const pub = document.getElementById('inc-visibility').checked;
document.getElementById('visibility-text').textContent = pub
? 'Öffentlich : für alle Nutzer sichtbar'
: 'Privat : nur für dich sichtbar';
},
async generateDescription() {
const title = document.getElementById('inc-title').value.trim();
const description = document.getElementById('inc-description').value.trim();
const type = document.getElementById('inc-type').value;
const btn = document.getElementById('btn-enhance-description');
const btnText = document.getElementById('enhance-btn-text');
const spinner = document.getElementById('enhance-spinner');
const area = document.getElementById('inc-description');
if (title.length < 3) return;
if (this._enhanceController) this._enhanceController.abort();
this._enhanceController = new AbortController();
btn.disabled = true;
btnText.textContent = 'Wird generiert …';
spinner.style.display = '';
area.readOnly = true;
try {
const res = await API.enhanceDescription(title, description || null, type, this._enhanceController.signal);
area.value = res.description || '';
} catch (err) {
if (err && err.name !== 'AbortError') {
let msg = 'Beschreibung konnte nicht generiert werden';
if (err.status === 503) msg = 'KI-Zugang aktuell nicht verfügbar.';
else if (err.status === 429) msg = 'KI ist gerade ausgelastet. Bitte kurz warten.';
else if (err.status === 504) msg = 'KI antwortet gerade nicht. Bitte erneut versuchen.';
UI.showToast(msg, 'error');
}
} finally {
spinner.style.display = 'none';
btnText.textContent = 'Beschreibung generieren';
area.readOnly = false;
btn.disabled = title.length < 3;
this._enhanceController = null;
}
},
_incidentFormData() {
const value = parseInt(document.getElementById('inc-refresh-value').value) || 15;
const unit = parseInt(document.getElementById('inc-refresh-unit').value) || 1;
const auto = document.getElementById('inc-refresh-mode').value === 'auto';
return {
title: document.getElementById('inc-title').value.trim(),
description: document.getElementById('inc-description').value.trim() || null,
type: document.getElementById('inc-type').value,
refresh_mode: document.getElementById('inc-refresh-mode').value,
refresh_interval: Math.max(10, Math.min(10080, value * unit)),
refresh_start_time: auto ? (document.getElementById('inc-refresh-starttime').value || null) : null,
retention_days: parseInt(document.getElementById('inc-retention').value) || 0,
international_sources: document.getElementById('inc-international').checked,
include_telegram: document.getElementById('inc-telegram').checked,
include_x: document.getElementById('inc-x').checked,
visibility: document.getElementById('inc-visibility').checked ? 'public' : 'private',
ai_backend: (document.getElementById('inc-ai-backend') || {}).value || '',
};
},
async submitIncident(ev) {
ev.preventDefault();
const btn = document.getElementById('modal-new-submit');
const titleEl = document.getElementById('inc-title');
if (!titleEl.value.trim()) { titleEl.focus(); return; }
btn.disabled = true;
try {
const inc = await API.createIncident(this._incidentFormData());
// E-Mail-Abo wie im Dashboard mitspeichern
try {
await API.updateSubscription(inc.id, {
notify_email_summary: document.getElementById('inc-notify-summary').checked,
notify_email_new_articles: document.getElementById('inc-notify-new-articles').checked,
notify_email_status_change: document.getElementById('inc-notify-status-change').checked,
});
} catch (e) { /* Abo ist Beiwerk - der Fall steht */ }
this.closeNewIncident();
await this.loadIncidents();
await this.selectIncident(inc.id);
UI.showToast('Fall angelegt', 'success');
} catch (e) {
UI.showToast((e && e.message) || 'Fall konnte nicht angelegt werden', 'error');
} finally {
btn.disabled = false;
}
},
// === Export ===
async doExport() {
if (!this.incident) return;
const format = document.getElementById('export-format').value;
const sections = ['zusammenfassung', 'bericht', 'faktencheck', 'quellen'];
try {
const resp = await API.exportReport(this.incident.id, format, null, sections);
if (!resp.ok) throw new Error('Export fehlgeschlagen');
const blob = await resp.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `Lagebericht_${this.incident.id}.${format}`;
document.body.appendChild(a); a.click(); a.remove();
URL.revokeObjectURL(url);
} catch (e) {
UI.showToast('Export fehlgeschlagen', 'error');
}
},
// === Theme ===
toggleTheme() {
const cur = document.documentElement.getAttribute('data-theme') === 'light' ? 'light' : 'dark';
const next = cur === 'light' ? 'dark' : 'light';
if (next === 'light') document.documentElement.setAttribute('data-theme', 'light');
else document.documentElement.removeAttribute('data-theme');
localStorage.setItem('osint_theme', next);
this._syncThemeSwitch();
if (UI.updateMapTheme) UI.updateMapTheme();
},
// Der Schiebeschalter aus dem klassischen Dashboard zeigt seinen Zustand ueber
// die Klasse 'dark' bzw. 'light'.
_syncThemeSwitch() {
const el = document.getElementById('theme-toggle');
if (!el) return;
const theme = document.documentElement.getAttribute('data-theme') === 'light' ? 'light' : 'dark';
el.classList.remove('dark', 'light');
el.classList.add(theme);
el.setAttribute('aria-checked', theme === 'dark' ? 'true' : 'false');
},
// === Zeit-Helfer ===
_ts(a) {
const d = parseUTC(a.published_at || a.collected_at || a.created_at);
return d ? d.getTime() : 0;
},
_fmtDate(a, withTime) {
const d = parseUTC(a.published_at || a.collected_at || a.created_at);
return this._fmt(d, withTime);
},
_fmt(d, withTime) {
if (!d) return '';
const opts = withTime
? { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }
: { day: '2-digit', month: '2-digit' };
return d.toLocaleString('de-DE', opts);
},
};
document.addEventListener('DOMContentLoaded', () => Studio.init());
// F5: Daten aktualisieren statt Seite neu laden (wie im Monitor)
document.addEventListener('keydown', (e) => {
if (e.key === 'F5') {
e.preventDefault();
Studio.softRefresh();
}
// ESC schließt Rueckfrage, Anlege-Dialog, Kontextmenue bzw. den Auswahlmodus
if (e.key === 'Escape') {
const c = document.getElementById('modal-confirm');
if (c && c.classList.contains('active')) { Studio._confirmClose(false); return; }
const m = document.getElementById('modal-new');
if (m && m.classList.contains('active')) Studio.closeNewIncident();
Studio._closeCaseMenu();
if (Studio.caseMode !== null) Studio.endCaseMode();
}
});