Dashboard: GridStack durch Tab-Navigation ersetzen

Der Monitor-Dashboard zeigte bisher alle sechs Kacheln gleichzeitig in
einem GridStack-Layout (Drag/Resize, je Kachel eigenes Scrolling). Nutzer-
wunsch: Analog zur Lagebild-Seite nur ein Tab-Panel gleichzeitig, maximiert
auf volle Breite, Seiten-Scroll statt interne Scrollbars.

Aenderungen:
- dashboard.html: Layout-Toolbar + grid-stack-Wrapper entfernt; neue tab-nav
  mit 6 Buttons + tab-panels mit 6 Panels. GridStack CDN-Links raus.
- layout.js: GridStack-Init/toggleTile/reset komplett entfernt. Neu:
  switchTab(tabId) + restoreTabFor(incidentId) mit localStorage-Persistenz
  pro Lage osint_tab_id. applyTypeLabels fuer adhoc vs. research. Legacy-
  Methoden sind No-Op-Stubs.
- app.js: renderIncidentDetail ruft LayoutManager.restoreTabFor und
  applyTypeLabels auf. openContentModal-Trigger aus Card-Titeln raus.
  Tile-Resize-Bloecke fuer Quellen und Timeline entfernt.
- components.js: Telegram-Pills bekommen Suffix Telegram-Link, wenn die
  URL auf t.me verweist.
- style.css: grid-stack/layout-toggle Klassen raus; neue tab-nav/tab-btn/
  tab-panel Klassen. Internes Scrolling entfernt. map-container 600px.

Alte osint_layout-Eintraege werden ignoriert.
Dieser Commit ist enthalten in:
claude-dev
2026-04-18 22:34:36 +00:00
Ursprung 3b9e9e25c2
Commit e15ed0c21e
5 geänderte Dateien mit 206 neuen und 571 gelöschten Zeilen

Datei anzeigen

@@ -1,295 +1,75 @@
/**
* LayoutManager: Drag & Resize Dashboard-Layout mit gridstack.js
* Persistenz über localStorage, Reset auf Standard-Layout möglich.
* LayoutManager: Tab-Navigation fuer das Monitor-Dashboard.
* Nur ein Tab-Panel gleichzeitig sichtbar, pro Lage gemerkt in localStorage.
*/
const LayoutManager = {
_grid: null,
_storageKey: 'osint_layout',
TAB_ORDER: ['zusammenfassung', 'lagebild', 'timeline', 'karte', 'faktencheck', 'quellen'],
_currentIncidentId: null,
_initialized: false,
_saveTimeout: null,
_hiddenTiles: {},
DEFAULT_LAYOUT: [
{ id: 'zusammenfassung', x: 0, y: 0, w: 12, h: 2, minW: 4, minH: 2 },
{ id: 'lagebild', x: 0, y: 2, 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 },
{ id: 'karte', x: 0, y: 9, w: 12, h: 8, minW: 6, minH: 3 },
],
TILE_MAP: {
zusammenfassung: '#zusammenfassung-card',
lagebild: '.incident-analysis-summary',
faktencheck: '.incident-analysis-factcheck',
quellen: '.source-overview-card',
timeline: '.timeline-card',
karte: '.map-card',
},
init() {
if (this._initialized) return;
const nav = document.getElementById('tab-nav');
if (!nav) 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) {
// Migration: Neue Kacheln ergaenzen die in alten Layouts fehlen
this.DEFAULT_LAYOUT.forEach(def => {
if (!saved.find(s => s.id === def.id)) {
saved.unshift({ id: def.id, x: def.x, y: def.y, w: def.w, h: def.h, visible: true });
}
nav.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', () => {
const tab = btn.getAttribute('data-tab');
if (tab) this.switchTab(tab);
});
this._applyLayout(saved);
}
this._grid.on('change', () => {
this._debouncedSave();
// Leaflet-Map bei Resize invalidieren
if (typeof UI !== 'undefined') UI.invalidateMap();
});
const toolbar = document.getElementById('layout-toolbar');
if (toolbar) toolbar.style.display = 'flex';
this._syncToggles();
nav.style.display = '';
this._initialized = true;
},
_applyLayout(layout) {
if (!this._grid) return;
switchTab(tabId, save = true) {
if (!this.TAB_ORDER.includes(tabId)) tabId = 'zusammenfassung';
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;
// Card in tile-parking retten bevor Widget entfernt wird
const selector = this.TILE_MAP[item.id];
if (selector) {
const cardEl = el.el.querySelector(selector);
if (cardEl) {
const parking = document.getElementById("tile-parking");
if (parking) parking.appendChild(cardEl);
}
}
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 });
}
document.querySelectorAll('#tab-nav .tab-btn').forEach(b => {
b.classList.toggle('active', b.getAttribute('data-tab') === tabId);
});
document.querySelectorAll('.tab-panel').forEach(p => {
p.classList.toggle('active', p.id === 'panel-' + tabId);
});
this._syncToggles();
// Leaflet-Karte: invalidateSize nach Panel-Wechsel, damit Tiles korrekt rendern
if (tabId === 'karte' && typeof UI !== 'undefined' && UI._map) {
setTimeout(() => { try { UI._map.invalidateSize(); } catch (e) { /* ignore */ } }, 50);
}
if (save && this._currentIncidentId != null) {
try {
localStorage.setItem('osint_tab_' + this._currentIncidentId, tabId);
} catch (e) { /* quota */ }
}
},
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 });
});
restoreTabFor(incidentId) {
this._currentIncidentId = incidentId;
let target = 'zusammenfassung';
try {
localStorage.setItem(this._storageKey, JSON.stringify(items));
} catch (e) { /* quota */ }
const saved = localStorage.getItem('osint_tab_' + incidentId);
if (saved && this.TAB_ORDER.includes(saved)) target = saved;
} catch (e) { /* ignore */ }
this.switchTab(target, false);
},
_debouncedSave() {
clearTimeout(this._saveTimeout);
this._saveTimeout = setTimeout(() => this.save(), 300);
/** Tab-Labels je Incident-Typ anpassen (adhoc vs. research). */
applyTypeLabels(incidentType) {
const isResearch = incidentType === 'research';
const zf = document.querySelector('#tab-nav .tab-btn[data-tab="zusammenfassung"]');
const lb = document.querySelector('#tab-nav .tab-btn[data-tab="lagebild"]');
if (zf) zf.textContent = isResearch ? 'Zusammenfassung' : 'Neueste Entwicklungen';
if (lb) lb.textContent = isResearch ? 'Recherchebericht' : 'Lagebild';
},
_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 = {};
},
// Legacy-API-Stubs: falls alte Aufrufe im Code liegen, stumm schlucken statt crashen.
toggleTile() { /* legacy no-op */ },
reset() { /* legacy no-op */ },
save() { /* legacy no-op */ },
resizeTileToContent() { /* legacy no-op */ },
destroy() { /* legacy no-op */ },
};
document.addEventListener('DOMContentLoaded', () => LayoutManager.init());