fix(studio): Kopfzeile und Fall-Auswahl an die klassische Ansicht angeglichen

Portierung aus dem Lokal-Fork (AegisSight-Monitor-Local, Commit 9613610).
Vier Angleichungen an das klassische Dashboard.

1. "Klassische Ansicht" ist jetzt derselbe Knopf wie "Studio-Ansicht" im
   Dashboard, mit Symbol, an erster Stelle im rechten Kopfzeilen-Block.
2. Oben rechts fehlten Barrierefreiheit, Konto-Menü und Abmelden. Der
   A11yManager liegt jetzt in js/a11y.js und wird von beiden Oberflächen
   eingebunden, denn das Studio lädt app.js nicht. Konto-Menü (Organisation,
   Lizenz, Credits, Über KI-Inhalte) und Abmelden nachgezogen, der
   Theme-Schalter ist derselbe Schiebeschalter wie im Dashboard.
3. Die Fall-Liste hat jetzt die Umschaltung "Alle" / "Eigene" in der
   Knopf-Optik der klassischen Seitenleiste, der Reiter-Zähler zieht mit.
4. Der Hinweis "Wähle links einen Fall aus" endet vor der Bausteine-Spalte
   statt am Fensterrand, im gestapelten Layout nimmt er die volle Breite.

Das info@-Gating des Studios bleibt unverändert bestehen.
Versionsmarker der angefassten Dateien auf 20260725a angehoben.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Dieser Commit ist enthalten in:
claude-dev
2026-07-25 11:39:25 +00:00
Ursprung ee31702015
Commit 26b2c08138
6 geänderte Dateien mit 354 neuen und 187 gelöschten Zeilen

162
src/static/js/a11y.js Normale Datei
Datei anzeigen

@@ -0,0 +1,162 @@
/**
* Barrierefreiheits-Manager: Panel mit 4 Schaltern (Kontrast, Focus, Schrift, Animationen).
*
* Liegt seit 2026-07-21 in einer eigenen Datei, weil ihn beide Oberflaechen brauchen,
* das klassische Dashboard (.header-right) und die Studio-Ansicht (.studio-top-right).
* Vorher steckte er in app.js und fehlte im Studio deshalb komplett.
*/
const A11yManager = {
_key: 'osint_a11y',
_isOpen: false,
_settings: { contrast: false, focus: false, fontsize: false, motion: false },
init() {
// Einstellungen aus localStorage laden
try {
const saved = JSON.parse(localStorage.getItem(this._key) || '{}');
Object.keys(this._settings).forEach(k => {
if (typeof saved[k] === 'boolean') this._settings[k] = saved[k];
});
} catch (e) { /* Ungültige Daten ignorieren */ }
// Button + Panel dynamisch in die Kopfzeile einfügen (vor Theme-Toggle).
// Beide Oberflaechen benennen ihren rechten Kopfzeilen-Block anders.
const headerRight = document.querySelector('.header-right, .studio-top-right');
const themeToggle = document.getElementById('theme-toggle');
if (!headerRight) return;
if (document.getElementById('a11y-btn')) return; // schon vorhanden
const container = document.createElement('div');
container.className = 'a11y-center';
container.innerHTML = `
<button class="a11y-btn" id="a11y-btn" title="Barrierefreiheit"
aria-label="Barrierefreiheit" aria-expanded="false" aria-haspopup="true">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<circle cx="12" cy="4" r="2"/>
<path d="M12 8c-3.3 0-6 .5-6 .5v2s2.7-.5 5-.5v3l-3 7h2.5l2.5-5.5 2.5 5.5h2.5l-3-7v-3c2.3 0 5 .5 5 .5v-2S15.3 8 12 8z"/>
</svg>
</button>
<div class="a11y-panel" id="a11y-panel" role="group" aria-label="Barrierefreiheits-Einstellungen" style="display:none;">
<div class="a11y-panel-title">Barrierefreiheit</div>
<label class="a11y-option">
<input type="checkbox" id="a11y-contrast">
<span class="toggle-switch"></span>
<span>Hoher Kontrast</span>
</label>
<label class="a11y-option">
<input type="checkbox" id="a11y-focus">
<span class="toggle-switch"></span>
<span>Verstärkte Focus-Anzeige</span>
</label>
<label class="a11y-option">
<input type="checkbox" id="a11y-fontsize">
<span class="toggle-switch"></span>
<span>Größere Schrift</span>
</label>
<label class="a11y-option">
<input type="checkbox" id="a11y-motion">
<span class="toggle-switch"></span>
<span>Animationen aus</span>
</label>
</div>
`;
if (themeToggle && themeToggle.parentNode === headerRight) {
headerRight.insertBefore(container, themeToggle);
} else {
headerRight.prepend(container);
}
// Toggle-Event-Listener
['contrast', 'focus', 'fontsize', 'motion'].forEach(key => {
document.getElementById('a11y-' + key).addEventListener('change', () => this.toggle(key));
});
// Button öffnet/schließt Panel
document.getElementById('a11y-btn').addEventListener('click', (e) => {
e.stopPropagation();
this._isOpen ? this._closePanel() : this._openPanel();
});
// Klick außerhalb schließt Panel
document.addEventListener('click', (e) => {
if (this._isOpen && !container.contains(e.target)) {
this._closePanel();
}
});
// Keyboard: Esc schließt, Pfeiltasten navigieren
container.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && this._isOpen) {
e.stopPropagation();
this._closePanel();
return;
}
if (!this._isOpen) return;
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
const options = Array.from(document.querySelectorAll('.a11y-option input[type="checkbox"]'));
const idx = options.indexOf(document.activeElement);
let next;
if (e.key === 'ArrowDown') {
next = idx < options.length - 1 ? idx + 1 : 0;
} else {
next = idx > 0 ? idx - 1 : options.length - 1;
}
options[next].focus();
}
});
// Einstellungen anwenden + Checkboxen synchronisieren
this._apply();
this._syncUI();
},
toggle(key) {
this._settings[key] = !this._settings[key];
this._apply();
this._syncUI();
this._save();
},
_apply() {
const root = document.documentElement;
Object.keys(this._settings).forEach(k => {
if (this._settings[k]) {
root.setAttribute('data-a11y-' + k, 'true');
} else {
root.removeAttribute('data-a11y-' + k);
}
});
},
_syncUI() {
Object.keys(this._settings).forEach(k => {
const cb = document.getElementById('a11y-' + k);
if (cb) cb.checked = this._settings[k];
});
},
_save() {
localStorage.setItem(this._key, JSON.stringify(this._settings));
},
_openPanel() {
this._isOpen = true;
document.getElementById('a11y-panel').style.display = '';
document.getElementById('a11y-btn').setAttribute('aria-expanded', 'true');
// Fokus auf erste Option setzen
requestAnimationFrame(() => {
const first = document.querySelector('.a11y-option input[type="checkbox"]');
if (first) first.focus();
});
},
_closePanel() {
this._isOpen = false;
document.getElementById('a11y-panel').style.display = 'none';
const btn = document.getElementById('a11y-btn');
btn.setAttribute('aria-expanded', 'false');
btn.focus();
}
};

Datei anzeigen

@@ -44,162 +44,10 @@ const ThemeManager = {
}
};
/**
* Barrierefreiheits-Manager: Panel mit 4 Schaltern (Kontrast, Focus, Schrift, Animationen).
/*
* Der A11yManager (Barrierefreiheits-Panel) liegt seit 2026-07-25 in
* js/a11y.js, weil ihn die Studio-Ansicht ebenfalls einbindet.
*/
const A11yManager = {
_key: 'osint_a11y',
_isOpen: false,
_settings: { contrast: false, focus: false, fontsize: false, motion: false },
init() {
// Einstellungen aus localStorage laden
try {
const saved = JSON.parse(localStorage.getItem(this._key) || '{}');
Object.keys(this._settings).forEach(k => {
if (typeof saved[k] === 'boolean') this._settings[k] = saved[k];
});
} catch (e) { /* Ungültige Daten ignorieren */ }
// Button + Panel dynamisch in .header-right einfügen (vor Theme-Toggle)
const headerRight = document.querySelector('.header-right');
const themeToggle = document.getElementById('theme-toggle');
if (!headerRight) return;
const container = document.createElement('div');
container.className = 'a11y-center';
container.innerHTML = `
<button class="a11y-btn" id="a11y-btn" title="Barrierefreiheit"
aria-label="Barrierefreiheit" aria-expanded="false" aria-haspopup="true">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<circle cx="12" cy="4" r="2"/>
<path d="M12 8c-3.3 0-6 .5-6 .5v2s2.7-.5 5-.5v3l-3 7h2.5l2.5-5.5 2.5 5.5h2.5l-3-7v-3c2.3 0 5 .5 5 .5v-2S15.3 8 12 8z"/>
</svg>
</button>
<div class="a11y-panel" id="a11y-panel" role="group" aria-label="Barrierefreiheits-Einstellungen" style="display:none;">
<div class="a11y-panel-title">Barrierefreiheit</div>
<label class="a11y-option">
<input type="checkbox" id="a11y-contrast">
<span class="toggle-switch"></span>
<span>Hoher Kontrast</span>
</label>
<label class="a11y-option">
<input type="checkbox" id="a11y-focus">
<span class="toggle-switch"></span>
<span>Verstärkte Focus-Anzeige</span>
</label>
<label class="a11y-option">
<input type="checkbox" id="a11y-fontsize">
<span class="toggle-switch"></span>
<span>Größere Schrift</span>
</label>
<label class="a11y-option">
<input type="checkbox" id="a11y-motion">
<span class="toggle-switch"></span>
<span>Animationen aus</span>
</label>
</div>
`;
if (themeToggle) {
headerRight.insertBefore(container, themeToggle);
} else {
headerRight.prepend(container);
}
// Toggle-Event-Listener
['contrast', 'focus', 'fontsize', 'motion'].forEach(key => {
document.getElementById('a11y-' + key).addEventListener('change', () => this.toggle(key));
});
// Button öffnet/schließt Panel
document.getElementById('a11y-btn').addEventListener('click', (e) => {
e.stopPropagation();
this._isOpen ? this._closePanel() : this._openPanel();
});
// Klick außerhalb schließt Panel
document.addEventListener('click', (e) => {
if (this._isOpen && !container.contains(e.target)) {
this._closePanel();
}
});
// Keyboard: Esc schließt, Pfeiltasten navigieren
container.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && this._isOpen) {
e.stopPropagation();
this._closePanel();
return;
}
if (!this._isOpen) return;
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
const options = Array.from(document.querySelectorAll('.a11y-option input[type="checkbox"]'));
const idx = options.indexOf(document.activeElement);
let next;
if (e.key === 'ArrowDown') {
next = idx < options.length - 1 ? idx + 1 : 0;
} else {
next = idx > 0 ? idx - 1 : options.length - 1;
}
options[next].focus();
}
});
// Einstellungen anwenden + Checkboxen synchronisieren
this._apply();
this._syncUI();
},
toggle(key) {
this._settings[key] = !this._settings[key];
this._apply();
this._syncUI();
this._save();
},
_apply() {
const root = document.documentElement;
Object.keys(this._settings).forEach(k => {
if (this._settings[k]) {
root.setAttribute('data-a11y-' + k, 'true');
} else {
root.removeAttribute('data-a11y-' + k);
}
});
},
_syncUI() {
Object.keys(this._settings).forEach(k => {
const cb = document.getElementById('a11y-' + k);
if (cb) cb.checked = this._settings[k];
});
},
_save() {
localStorage.setItem(this._key, JSON.stringify(this._settings));
},
_openPanel() {
this._isOpen = true;
document.getElementById('a11y-panel').style.display = '';
document.getElementById('a11y-btn').setAttribute('aria-expanded', 'true');
// Fokus auf erste Option setzen
requestAnimationFrame(() => {
const first = document.querySelector('.a11y-option input[type="checkbox"]');
if (first) first.focus();
});
},
_closePanel() {
this._isOpen = false;
document.getElementById('a11y-panel').style.display = 'none';
const btn = document.getElementById('a11y-btn');
btn.setAttribute('aria-expanded', 'false');
btn.focus();
}
};
/**
* Notification-Center: Glocke mit Badge + History-Panel.

Datei anzeigen

@@ -60,6 +60,8 @@ const Studio = {
],
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
@@ -67,10 +69,11 @@ const Studio = {
async init() {
if (!localStorage.getItem('osint_token')) { window.location.href = '/'; return; }
try {
const me = await API.getMe();
this._me = await API.getMe();
// Studio ist vorerst nur fuer info@aegis-sight.de freigegeben.
if (!me || me.email !== 'info@aegis-sight.de') { window.location.href = '/dashboard'; return; }
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();
@@ -86,6 +89,77 @@ const Studio = {
}
},
/**
* 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.
@@ -129,6 +203,29 @@ const Studio = {
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();
@@ -137,17 +234,13 @@ const Studio = {
// Fälle, die der aktuelle Filter zeigt (Archiv nur, wenn aufgeklappt) - genau
// diese Menge trifft "Alle auswählen".
_visibleCases() {
const q = this.caseFilter;
const match = (i) => !q || (i.title || '').toLowerCase().includes(q);
return this.incidents.filter(i => match(i) && (i.status === 'active' || this._archiveOpen));
return this.incidents.filter(i => this._caseMatches(i) && (i.status === 'active' || this._archiveOpen));
},
renderCases() {
const list = document.getElementById('cases-list');
if (!list) return;
const q = this.caseFilter;
const match = (i) => !q || (i.title || '').toLowerCase().includes(q);
const all = this.incidents.filter(match);
const all = this.incidents.filter(i => this._caseMatches(i));
const cur = this.incident ? String(this.incident.id) : null;
const groups = [
@@ -189,8 +282,9 @@ const Studio = {
}
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 = this.incidents.filter(i => i.status === 'active').length || '';
if (cnt) cnt.textContent = all.filter(i => i.status === 'active').length || '';
this._renderBulkBar();
},
@@ -2047,9 +2141,21 @@ const Studio = {
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);