Großes Cleanup: Bugs fixen, Features fertigstellen, toten Code entfernen

Bugs behoben:
- handleEdit() async keyword hinzugefügt (E-Mail-Checkboxen funktionieren jetzt)
- parseUTC() Funktion definiert (Fortschritts-Timer nutzt Server-Startzeit)
- Status cancelling wird im Frontend korrekt angezeigt

Features fertiggestellt:
- Sidebar: Lagen nach Typ getrennt (adhoc/research) mit Zählern
- Quellen-Bearbeiten: Edit-Button pro Quelle, Formular vorausfüllen
- Lizenz-Info: Org-Name und Lizenzstatus im Header angezeigt

Toter Code entfernt:
- 5 verwaiste Dateien gelöscht (alte rss_parser, style.css, components.js, layout.js, setup_users)
- 6 ungenutzte Pydantic Models entfernt
- Ungenutzte Funktionen/Imports in auth.py, routers, agents, config
- Tote API-Methoden, Legacy-UI-Methoden, verwaiste WS-Handler
- Abgeschlossene DB-Migrationen aufgeräumt

Sonstiges:
- requirements.txt: passlib[bcrypt] durch bcrypt ersetzt
- Umlaute korrigiert (index.html)
- CSS: incident-type-label → incident-type-badge, .login-success hinzugefügt
- Schließen statt Schliessen im Feedback-Modal
Dieser Commit ist enthalten in:
claude-dev
2026-03-04 18:45:38 +01:00
Ursprung 2a155c084d
Commit 71296edb97
23 geänderte Dateien mit 269 neuen und 4768 gelöschten Zeilen

Datei anzeigen

@@ -1,7 +1,7 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
python-jose[cryptography]
passlib[bcrypt]
bcrypt
aiosqlite
feedparser
httpx

Datei anzeigen

@@ -1,63 +0,0 @@
"""Erstellt die initialen Nutzer für den OSINT Lagemonitor."""
import asyncio
import os
import sys
import secrets
import string
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "src"))
from database import init_db, get_db
from auth import hash_password
def generate_password(length=16):
"""Generiert ein sicheres Passwort."""
alphabet = string.ascii_letters + string.digits + "!@#$%&*"
return ''.join(secrets.choice(alphabet) for _ in range(length))
async def main():
await init_db()
db = await get_db()
users = [
{"username": "rac00n", "password": generate_password()},
{"username": "ch33tah", "password": generate_password()},
]
print("\n=== OSINT Lagemonitor - Nutzer-Setup ===\n")
for user in users:
cursor = await db.execute(
"SELECT id FROM users WHERE username = ?", (user["username"],)
)
existing = await cursor.fetchone()
if existing:
# Passwort aktualisieren
pw_hash = hash_password(user["password"])
await db.execute(
"UPDATE users SET password_hash = ? WHERE username = ?",
(pw_hash, user["username"]),
)
print(f" Nutzer '{user['username']}' - Passwort aktualisiert")
else:
pw_hash = hash_password(user["password"])
await db.execute(
"INSERT INTO users (username, password_hash) VALUES (?, ?)",
(user["username"], pw_hash),
)
print(f" Nutzer '{user['username']}' - Erstellt")
print(f" Passwort: {user['password']}")
print()
await db.commit()
await db.close()
print("WICHTIG: Passwörter jetzt notieren! Sie werden nicht erneut angezeigt.\n")
if __name__ == "__main__":
asyncio.run(main())

Datei anzeigen

@@ -1,5 +1,4 @@
"""Analyzer-Agent: Analysiert, übersetzt und fasst Meldungen zusammen."""
import asyncio
import json
import logging
import re

Datei anzeigen

@@ -1,5 +1,4 @@
"""Factchecker-Agent: Prüft Fakten gegen mehrere unabhängige Quellen."""
import asyncio
import json
import logging
import re

Datei anzeigen

@@ -10,7 +10,6 @@ from urllib.parse import urlparse, urlunparse
from agents.claude_client import UsageAccumulator
from source_rules import (
DOMAIN_CATEGORY_MAP,
_detect_category,
_extract_domain,
discover_source,

Datei anzeigen

@@ -1,5 +1,4 @@
"""Researcher-Agent: Sucht nach Informationen via Claude WebSearch."""
import asyncio
import json
import logging
import re

Datei anzeigen

@@ -3,24 +3,13 @@ import secrets
import string
from datetime import datetime, timedelta, timezone
from jose import jwt, JWTError
import bcrypt as _bcrypt
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from config import JWT_SECRET, JWT_ALGORITHM, JWT_EXPIRE_HOURS, MAGIC_LINK_EXPIRE_MINUTES
from config import JWT_SECRET, JWT_ALGORITHM, JWT_EXPIRE_HOURS
security = HTTPBearer()
def hash_password(password: str) -> str:
"""Passwort hashen mit bcrypt."""
return _bcrypt.hashpw(password.encode("utf-8"), _bcrypt.gensalt()).decode("utf-8")
def verify_password(password: str, password_hash: str) -> bool:
"""Passwort gegen Hash pruefen."""
return _bcrypt.checkpw(password.encode("utf-8"), password_hash.encode("utf-8"))
JWT_ISSUER = "intelsight-osint"
JWT_AUDIENCE = "intelsight-osint"
@@ -84,18 +73,6 @@ async def get_current_user(
}
async def require_org_member(
current_user: dict = Depends(get_current_user),
) -> dict:
"""FastAPI Dependency: Erfordert Org-Mitgliedschaft."""
if not current_user.get("tenant_id"):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Keine Organisation zugeordnet",
)
return current_user
def generate_magic_token() -> str:
"""Generiert einen 64-Zeichen URL-safe Token."""
return secrets.token_urlsafe(48)

Datei anzeigen

@@ -21,17 +21,11 @@ JWT_EXPIRE_HOURS = 24
# Claude CLI
CLAUDE_PATH = os.environ.get("CLAUDE_PATH", "/home/claude-dev/.claude/local/claude")
CLAUDE_MAX_CONCURRENT = 1
CLAUDE_TIMEOUT = 300 # Sekunden (Claude mit WebSearch braucht oft 2-3 Min)
# Ausgabesprache (Lagebilder, Faktenchecks, Zusammenfassungen)
OUTPUT_LANGUAGE = "Deutsch"
# Auto-Refresh
REFRESH_MIN_INTERVAL = 10 # Minuten
REFRESH_MAX_INTERVAL = 10080 # 1 Woche
REFRESH_DEFAULT_INTERVAL = 15
# RSS-Feeds (Fallback, primär aus DB geladen)
RSS_FEEDS = {
"deutsch": [

Datei anzeigen

@@ -52,6 +52,7 @@ CREATE TABLE IF NOT EXISTS magic_links (
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Hinweis: portal_admins wird von der Verwaltungs-App (Admin-Portal) genutzt, die dieselbe DB teilt.
CREATE TABLE IF NOT EXISTS portal_admins (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
@@ -213,14 +214,6 @@ async def init_db():
await db.commit()
logger.info("Migration: tenant_id zu incidents hinzugefuegt")
# Migration: E-Mail-Benachrichtigungs-Praeferenzen pro Lage
if "notify_email_summary" not in columns:
await db.execute("ALTER TABLE incidents ADD COLUMN notify_email_summary INTEGER DEFAULT 0")
await db.execute("ALTER TABLE incidents ADD COLUMN notify_email_new_articles INTEGER DEFAULT 0")
await db.execute("ALTER TABLE incidents ADD COLUMN notify_email_status_change INTEGER DEFAULT 0")
await db.commit()
logger.info("Migration: E-Mail-Benachrichtigungs-Spalten zu incidents hinzugefuegt")
# Migration: Token-Spalten fuer refresh_log
cursor = await db.execute("PRAGMA table_info(refresh_log)")
rl_columns = [row[1] for row in await cursor.fetchall()]
@@ -246,19 +239,6 @@ async def init_db():
await db.execute("ALTER TABLE refresh_log ADD COLUMN tenant_id INTEGER REFERENCES organizations(id)")
await db.commit()
# Migration: reliability_score entfernen (falls noch vorhanden)
cursor = await db.execute("PRAGMA table_info(incidents)")
inc_columns = [row[1] for row in await cursor.fetchall()]
if "reliability_score" in inc_columns:
await db.execute("ALTER TABLE incidents DROP COLUMN reliability_score")
await db.commit()
cursor = await db.execute("PRAGMA table_info(incident_snapshots)")
snap_columns = [row[1] for row in await cursor.fetchall()]
if "reliability_score" in snap_columns:
await db.execute("ALTER TABLE incident_snapshots DROP COLUMN reliability_score")
await db.commit()
# Migration: notifications-Tabelle (fuer bestehende DBs)
cursor = await db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='notifications'")
if not await cursor.fetchone():
@@ -340,14 +320,6 @@ async def init_db():
await db.execute("ALTER TABLE users ADD COLUMN last_login_at TIMESTAMP")
await db.commit()
# Migration: E-Mail-Benachrichtigungs-Praeferenzen fuer users
if "notify_email_summary" not in user_columns:
await db.execute("ALTER TABLE users ADD COLUMN notify_email_summary INTEGER DEFAULT 0")
await db.execute("ALTER TABLE users ADD COLUMN notify_email_new_articles INTEGER DEFAULT 0")
await db.execute("ALTER TABLE users ADD COLUMN notify_email_status_change INTEGER DEFAULT 0")
await db.commit()
logger.info("Migration: E-Mail-Benachrichtigungs-Spalten zu users hinzugefuegt")
# Migration: tenant_id fuer articles
cursor = await db.execute("PRAGMA table_info(articles)")
art_columns = [row[1] for row in await cursor.fetchall()]

Datei anzeigen

@@ -34,41 +34,6 @@ def magic_link_login_email(username: str, code: str, link: str) -> tuple[str, st
return subject, html
def invite_email(username: str, org_name: str, code: str, link: str) -> tuple[str, str]:
"""Erzeugt Einladungs-E-Mail fuer neue Nutzer.
Returns:
(subject, html_body)
"""
subject = f"Einladung zum AegisSight Monitor - {org_name}"
html = f"""<!DOCTYPE html>
<html>
<head><meta charset="UTF-8"></head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f172a; color: #e2e8f0; padding: 40px 20px;">
<div style="max-width: 480px; margin: 0 auto; background: #1e293b; border-radius: 12px; padding: 32px; border: 1px solid #334155;">
<h1 style="color: #f0b429; font-size: 20px; margin: 0 0 24px 0;">AegisSight Monitor</h1>
<p style="margin: 0 0 16px 0;">Hallo {username},</p>
<p style="margin: 0 0 16px 0;">Sie wurden zur Organisation <strong>{org_name}</strong> im AegisSight Monitor eingeladen.</p>
<p style="margin: 0 0 24px 0;">Klicken Sie auf den Link, um Ihren Zugang zu aktivieren:</p>
<div style="background: #0f172a; border-radius: 8px; padding: 20px; text-align: center; margin: 0 0 24px 0;">
<div style="font-size: 32px; font-weight: 700; letter-spacing: 8px; color: #f0b429; font-family: monospace;">{code}</div>
</div>
<div style="text-align: center; margin: 0 0 24px 0;">
<a href="{link}" style="display: inline-block; background: #f0b429; color: #0f172a; padding: 12px 32px; border-radius: 6px; text-decoration: none; font-weight: 600;">Einladung annehmen</a>
</div>
<p style="color: #94a3b8; font-size: 13px; margin: 0;">Dieser Link ist 48 Stunden gueltig.</p>
</div>
</body>
</html>"""
return subject, html
def incident_notification_email(
username: str,
incident_title: str,

Datei anzeigen

@@ -4,11 +4,6 @@ from typing import Optional
from datetime import datetime
# Auth (Legacy)
class LoginRequest(BaseModel):
username: str
password: str
# Auth (Magic Link)
class MagicLinkRequest(BaseModel):
@@ -34,10 +29,6 @@ class TokenResponse(BaseModel):
username: str
class UserResponse(BaseModel):
id: int
username: str
class UserMeResponse(BaseModel):
id: int
@@ -97,32 +88,6 @@ class IncidentResponse(BaseModel):
source_count: int = 0
# Articles
class ArticleResponse(BaseModel):
id: int
incident_id: int
headline: str
headline_de: Optional[str]
source: str
source_url: Optional[str]
content_original: Optional[str]
content_de: Optional[str]
language: str
published_at: Optional[str]
collected_at: str
verification_status: str
# Fact Checks
class FactCheckResponse(BaseModel):
id: int
incident_id: int
claim: str
status: str
sources_count: int
evidence: Optional[str]
is_notification: bool
checked_at: str
# Sources (Quellenverwaltung)
@@ -191,18 +156,6 @@ class DomainActionRequest(BaseModel):
notes: Optional[str] = None
# Refresh-Log
class RefreshLogResponse(BaseModel):
id: int
started_at: str
completed_at: Optional[str] = None
articles_found: int = 0
status: str
trigger_type: str = "manual"
retry_count: int = 0
error_message: Optional[str] = None
duration_seconds: Optional[float] = None
# Notifications
class NotificationResponse(BaseModel):
@@ -239,7 +192,3 @@ class FeedbackRequest(BaseModel):
message: str = Field(min_length=10, max_length=5000)
class WSMessage(BaseModel):
type: str # new_article, status_update, notification, refresh_complete
incident_id: int
data: dict

Datei anzeigen

@@ -15,7 +15,6 @@ from auth import (
get_current_user,
generate_magic_token,
generate_magic_code,
verify_password,
)
from database import db_dependency
from config import TIMEZONE, MAGIC_LINK_EXPIRE_MINUTES, MAGIC_LINK_BASE_URL

Datei anzeigen

@@ -1,102 +0,0 @@
"""RSS-Feed Parser: Durchsucht vorkonfigurierte Feeds nach relevanten Meldungen."""
import asyncio
import logging
import feedparser
import httpx
from datetime import datetime, timezone
from config import RSS_FEEDS
logger = logging.getLogger("osint.rss")
class RSSParser:
"""Durchsucht RSS-Feeds nach relevanten Artikeln."""
# Stoppwörter die bei der RSS-Suche ignoriert werden
STOP_WORDS = {
"und", "oder", "der", "die", "das", "ein", "eine", "in", "im", "am", "an",
"auf", "für", "mit", "von", "zu", "zum", "zur", "bei", "nach", "vor",
"über", "unter", "ist", "sind", "hat", "the", "and", "for", "with", "from",
}
async def search_feeds(self, search_term: str) -> list[dict]:
"""Durchsucht alle konfigurierten RSS-Feeds nach einem Suchbegriff."""
all_articles = []
# Stoppwörter und kurze Wörter (< 3 Zeichen) filtern
search_words = [
w for w in search_term.lower().split()
if w not in self.STOP_WORDS and len(w) >= 3
]
if not search_words:
search_words = search_term.lower().split()[:2]
tasks = []
for category, feeds in RSS_FEEDS.items():
for feed_config in feeds:
tasks.append(self._fetch_feed(feed_config, search_words))
results = await asyncio.gather(*tasks, return_exceptions=True)
for result in results:
if isinstance(result, Exception):
logger.warning(f"Feed-Fehler: {result}")
continue
all_articles.extend(result)
logger.info(f"RSS-Suche nach '{search_term}': {len(all_articles)} Treffer")
return all_articles
async def _fetch_feed(self, feed_config: dict, search_words: list[str]) -> list[dict]:
"""Einzelnen RSS-Feed abrufen und durchsuchen."""
name = feed_config["name"]
url = feed_config["url"]
articles = []
try:
async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client:
response = await client.get(url, headers={
"User-Agent": "OSINT-Monitor/1.0 (News Aggregator)"
})
response.raise_for_status()
feed = await asyncio.to_thread(feedparser.parse, response.text)
for entry in feed.entries[:50]:
title = entry.get("title", "")
summary = entry.get("summary", "")
text = f"{title} {summary}".lower()
# Prüfe ob mindestens ein Suchwort vorkommt
if any(word in text for word in search_words):
published = None
if hasattr(entry, "published_parsed") and entry.published_parsed:
try:
published = datetime(*entry.published_parsed[:6], tzinfo=timezone.utc).isoformat()
except (TypeError, ValueError):
pass
articles.append({
"headline": title,
"headline_de": title if self._is_german(title) else None,
"source": name,
"source_url": entry.get("link", ""),
"content_original": summary[:1000] if summary else None,
"content_de": summary[:1000] if summary and self._is_german(summary) else None,
"language": "de" if self._is_german(title) else "en",
"published_at": published,
})
except Exception as e:
logger.debug(f"Feed {name} ({url}): {e}")
return articles
def _is_german(self, text: str) -> bool:
"""Einfache Heuristik ob ein Text deutsch ist."""
german_words = {"der", "die", "das", "und", "ist", "von", "mit", "für", "auf", "ein",
"eine", "den", "dem", "des", "sich", "wird", "nach", "bei", "auch",
"über", "wie", "aus", "hat", "zum", "zur", "als", "noch", "mehr",
"nicht", "aber", "oder", "sind", "vor", "einem", "einer", "wurde"}
words = set(text.lower().split())
matches = words & german_words
return len(matches) >= 2

Datei anzeigen

@@ -1,444 +0,0 @@
/**
* UI-Komponenten für das Dashboard.
*/
const UI = {
/**
* Sidebar-Eintrag für eine Lage rendern.
*/
renderIncidentItem(incident, isActive) {
const isRefreshing = App._refreshingIncidents && App._refreshingIncidents.has(incident.id);
const dotClass = isRefreshing ? 'refreshing' : (incident.status === 'active' ? 'active' : 'archived');
const activeClass = isActive ? 'active' : '';
const creator = incident.created_by_username || '';
return `
<div class="incident-item ${activeClass}" data-id="${incident.id}" onclick="App.selectIncident(${incident.id})" role="button" tabindex="0">
<span class="incident-dot ${dotClass}" id="dot-${incident.id}"></span>
<div style="flex:1;min-width:0;">
<div class="incident-name">${this.escape(incident.title)}</div>
<div class="incident-meta">${incident.article_count} Artikel &middot; ${this.escape(creator)}</div>
</div>
${incident.visibility === 'private' ? '<span class="badge badge-private" style="font-size:9px;">PRIVAT</span>' : ''}
${incident.type === 'research' ? '<span class="badge badge-research" style="font-size:9px;">RECH</span>' : ''}
${incident.refresh_mode === 'auto' ? '<span class="badge badge-auto" style="font-size:9px;">AUTO</span>' : ''}
</div>
`;
},
/**
* Faktencheck-Eintrag rendern.
*/
factCheckLabels: {
confirmed: 'Bestätigt durch mehrere Quellen',
unconfirmed: 'Nicht unabhängig bestätigt',
contradicted: 'Widerlegt',
developing: 'Faktenlage noch im Fluss',
established: 'Gesicherter Fakt (3+ Quellen)',
disputed: 'Umstrittener Sachverhalt',
unverified: 'Nicht unabhängig verifizierbar',
},
factCheckChipLabels: {
confirmed: 'Bestätigt',
unconfirmed: 'Unbestätigt',
contradicted: 'Widerlegt',
developing: 'Unklar',
established: 'Gesichert',
disputed: 'Umstritten',
unverified: 'Ungeprüft',
},
factCheckIcons: {
confirmed: '&#10003;',
unconfirmed: '?',
contradicted: '&#10007;',
developing: '&#8635;',
established: '&#10003;',
disputed: '&#9888;',
unverified: '?',
},
/**
* Faktencheck-Filterleiste rendern.
*/
renderFactCheckFilters(factchecks) {
// Welche Stati kommen tatsächlich vor + Zähler
const statusCounts = {};
factchecks.forEach(fc => {
statusCounts[fc.status] = (statusCounts[fc.status] || 0) + 1;
});
const statusOrder = ['confirmed', 'established', 'developing', 'unconfirmed', 'unverified', 'disputed', 'contradicted'];
const usedStatuses = statusOrder.filter(s => statusCounts[s]);
if (usedStatuses.length <= 1) return '';
const items = usedStatuses.map(status => {
const icon = this.factCheckIcons[status] || '?';
const chipLabel = this.factCheckChipLabels[status] || status;
const count = statusCounts[status];
return `<label class="fc-dropdown-item" data-status="${status}">
<input type="checkbox" checked onchange="App.toggleFactCheckFilter('${status}')">
<span class="factcheck-icon ${status}">${icon}</span>
<span class="fc-dropdown-label">${chipLabel}</span>
<span class="fc-dropdown-count">${count}</span>
</label>`;
}).join('');
return `<button class="fc-dropdown-toggle" onclick="App.toggleFcDropdown(event)">Filter</button>
<div class="fc-dropdown-menu" id="fc-dropdown-menu">${items}</div>`;
},
renderFactCheck(fc) {
const urls = (fc.evidence || '').match(/https?:\/\/[^\s,)]+/g) || [];
const count = urls.length;
return `
<div class="factcheck-item" data-fc-status="${fc.status}">
<div class="factcheck-icon ${fc.status}" title="${this.factCheckLabels[fc.status] || fc.status}" aria-hidden="true">${this.factCheckIcons[fc.status] || '?'}</div>
<span class="sr-only">${this.factCheckLabels[fc.status] || fc.status}</span>
<div style="flex:1;">
<div class="factcheck-claim">${this.escape(fc.claim)}</div>
<div style="display:flex;align-items:center;gap:6px;margin-top:2px;">
<span class="factcheck-sources">${count} Quelle${count !== 1 ? 'n' : ''}</span>
</div>
<div class="evidence-block">${this.renderEvidence(fc.evidence || '')}</div>
</div>
</div>
`;
},
/**
* Evidence mit erklärenden Text UND Quellen-Chips rendern.
*/
renderEvidence(text) {
if (!text) return '<span class="evidence-empty">Keine Belege</span>';
const urls = text.match(/https?:\/\/[^\s,)]+/g) || [];
if (urls.length === 0) {
return `<span class="evidence-text">${this.escape(text)}</span>`;
}
// Erklärenden Text extrahieren (URLs entfernen)
let explanation = text;
urls.forEach(url => { explanation = explanation.replace(url, '').trim(); });
// Aufräumen: Klammern, mehrfache Kommas/Leerzeichen
explanation = explanation.replace(/\(\s*\)/g, '');
explanation = explanation.replace(/,\s*,/g, ',');
explanation = explanation.replace(/\s+/g, ' ').trim();
explanation = explanation.replace(/[,.:;]+$/, '').trim();
// Chips für jede URL
const chips = urls.map(url => {
let label;
try { label = new URL(url).hostname.replace('www.', ''); } catch { label = url; }
return `<a href="${this.escape(url)}" target="_blank" rel="noopener" class="evidence-chip" title="${this.escape(url)}">${this.escape(label)}</a>`;
}).join('');
const explanationHtml = explanation
? `<span class="evidence-text">${this.escape(explanation)}</span>`
: '';
return `${explanationHtml}<div class="evidence-chips">${chips}</div>`;
},
/**
* Verifizierungs-Badge.
*/
verificationBadge(status) {
const map = {
verified: { class: 'badge-verified', text: 'Verifiziert' },
unverified: { class: 'badge-unverified', text: 'Offen' },
contradicted: { class: 'badge-contradicted', text: 'Widerlegt' },
};
const badge = map[status] || map.unverified;
return `<span class="badge ${badge.class}">${badge.text}</span>`;
},
/**
* Toast-Benachrichtigung anzeigen.
*/
showToast(message, type = 'info', duration = 5000) {
const container = document.getElementById('toast-container');
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
toast.setAttribute('role', 'status');
toast.innerHTML = `<span class="toast-text">${this.escape(message)}</span>`;
container.appendChild(toast);
setTimeout(() => {
toast.style.opacity = '0';
toast.style.transform = 'translateX(100%)';
toast.style.transition = 'all 0.3s ease';
setTimeout(() => toast.remove(), 300);
}, duration);
},
/**
* Fortschrittsanzeige einblenden und Status setzen.
*/
showProgress(status) {
const bar = document.getElementById('progress-bar');
if (!bar) return;
bar.style.display = 'block';
const steps = {
queued: { active: 0, label: 'In Warteschlange...' },
researching: { active: 1, label: 'Recherchiert Quellen...' },
deep_researching: { active: 1, label: 'Tiefenrecherche läuft...' },
analyzing: { active: 2, label: 'Analysiert Meldungen...' },
factchecking: { active: 3, label: 'Faktencheck läuft...' },
};
const step = steps[status] || steps.queued;
const stepIds = ['step-researching', 'step-analyzing', 'step-factchecking'];
stepIds.forEach((id, i) => {
const el = document.getElementById(id);
if (!el) return;
el.className = 'progress-step';
if (i + 1 < step.active) el.classList.add('done');
else if (i + 1 === step.active) el.classList.add('active');
});
const fill = document.getElementById('progress-fill');
const percent = step.active === 0 ? 5 : Math.round((step.active / 3) * 100);
if (fill) {
fill.style.width = percent + '%';
}
// ARIA-Werte auf der Progressbar aktualisieren
bar.setAttribute('aria-valuenow', String(percent));
bar.setAttribute('aria-valuetext', step.label);
const label = document.getElementById('progress-label');
if (label) label.textContent = step.label;
},
/**
* Fortschrittsanzeige ausblenden.
*/
hideProgress() {
const bar = document.getElementById('progress-bar');
if (bar) bar.style.display = 'none';
},
/**
* Zusammenfassung mit Inline-Zitaten und Quellenverzeichnis rendern.
*/
renderSummary(summary, sourcesJson, incidentType) {
if (!summary) return '<span style="color:var(--text-tertiary);">Noch keine Zusammenfassung.</span>';
let sources = [];
try { sources = JSON.parse(sourcesJson || '[]'); } catch(e) {}
// Markdown-Rendering
let html = this.escape(summary);
// ## Überschriften
html = html.replace(/^## (.+)$/gm, '<h3 class="briefing-heading">$1</h3>');
// **Fettdruck**
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
// Listen (- Item)
html = html.replace(/^- (.+)$/gm, '<li>$1</li>');
html = html.replace(/(<li>.*<\/li>\n?)+/gs, '<ul>$&</ul>');
// Zeilenumbrüche (aber nicht nach Headings/Listen)
html = html.replace(/\n(?!<)/g, '<br>');
// Überflüssige <br> nach Block-Elementen entfernen + doppelte <br> zusammenfassen
html = html.replace(/<\/h3>(<br>)+/g, '</h3>');
html = html.replace(/<\/ul>(<br>)+/g, '</ul>');
html = html.replace(/(<br>){2,}/g, '<br>');
// Inline-Zitate [1], [2] etc. als klickbare Links rendern
if (sources.length > 0) {
html = html.replace(/\[(\d+)\]/g, (match, num) => {
const src = sources.find(s => s.nr === parseInt(num));
if (src && src.url) {
return `<a href="${this.escape(src.url)}" target="_blank" rel="noopener" class="citation" title="${this.escape(src.name)}">[${num}]</a>`;
}
return match;
});
}
return `<div class="briefing-content">${html}</div>`;
},
/**
* Quellenübersicht für eine Lage rendern.
*/
renderSourceOverview(articles) {
if (!articles || articles.length === 0) return '';
// Nach Quelle aggregieren
const sourceMap = {};
articles.forEach(a => {
const name = a.source || 'Unbekannt';
if (!sourceMap[name]) {
sourceMap[name] = { count: 0, languages: new Set(), urls: [] };
}
sourceMap[name].count++;
sourceMap[name].languages.add(a.language || 'de');
if (a.source_url) sourceMap[name].urls.push(a.source_url);
});
const sources = Object.entries(sourceMap)
.sort((a, b) => b[1].count - a[1].count);
// Sprach-Statistik
const langCount = {};
articles.forEach(a => {
const lang = (a.language || 'de').toUpperCase();
langCount[lang] = (langCount[lang] || 0) + 1;
});
const langChips = Object.entries(langCount)
.sort((a, b) => b[1] - a[1])
.map(([lang, count]) => `<span class="source-lang-chip">${lang} <strong>${count}</strong></span>`)
.join('');
let html = `<div class="source-overview-header">`;
html += `<span class="source-overview-stat">${articles.length} Artikel aus ${sources.length} Quellen</span>`;
html += `<div class="source-lang-chips">${langChips}</div>`;
html += `</div>`;
html += '<div class="source-overview-grid">';
sources.forEach(([name, data]) => {
const langs = [...data.languages].map(l => l.toUpperCase()).join('/');
html += `<div class="source-overview-item">
<span class="source-overview-name">${this.escape(name)}</span>
<span class="source-overview-lang">${langs}</span>
<span class="source-overview-count">${data.count}</span>
</div>`;
});
html += '</div>';
return html;
},
/**
* Kategorie-Labels.
*/
_categoryLabels: {
'nachrichtenagentur': 'Agentur',
'oeffentlich-rechtlich': 'ÖR',
'qualitaetszeitung': 'Qualität',
'behoerde': 'Behörde',
'fachmedien': 'Fach',
'think-tank': 'Think Tank',
'international': 'Intl.',
'regional': 'Regional',
'sonstige': 'Sonstige',
},
/**
* Domain-Gruppe rendern (aufklappbar mit Feeds).
*/
renderSourceGroup(domain, feeds, isExcluded, excludedNotes) {
const catLabel = this._categoryLabels[feeds[0]?.category] || feeds[0]?.category || '';
const feedCount = feeds.filter(f => f.source_type !== 'excluded').length;
const hasMultiple = feedCount > 1;
const displayName = domain || feeds[0]?.name || 'Unbekannt';
const escapedDomain = this.escape(domain);
if (isExcluded) {
// Gesperrte Domain
const notesHtml = excludedNotes ? ` <span class="source-group-notes">${this.escape(excludedNotes)}</span>` : '';
return `<div class="source-group">
<div class="source-group-header excluded">
<div class="source-group-info">
<span class="source-group-name">${this.escape(displayName)}</span>${notesHtml}
</div>
<span class="source-excluded-badge">Gesperrt</span>
<div class="source-group-actions">
<button class="btn btn-small btn-secondary" onclick="App.unblockDomain('${escapedDomain}')">Entsperren</button>
<button class="source-delete-btn" onclick="App.deleteDomain('${escapedDomain}')" title="Löschen" aria-label="Löschen">&times;</button>
</div>
</div>
</div>`;
}
// Aktive Domain-Gruppe
const toggleAttr = hasMultiple ? `onclick="App.toggleGroup('${escapedDomain}')" role="button" tabindex="0" aria-expanded="false"` : '';
const toggleIcon = hasMultiple ? '<span class="source-group-toggle" aria-hidden="true">&#9654;</span>' : '<span class="source-group-toggle-placeholder"></span>';
let feedRows = '';
if (hasMultiple) {
const realFeeds = feeds.filter(f => f.source_type !== 'excluded');
feedRows = `<div class="source-group-feeds" data-domain="${escapedDomain}">`;
realFeeds.forEach((feed, i) => {
const isLast = i === realFeeds.length - 1;
const connector = isLast ? '\u2514\u2500' : '\u251C\u2500';
const typeLabel = feed.source_type === 'rss_feed' ? 'RSS' : 'Web';
const urlDisplay = feed.url ? this._shortenUrl(feed.url) : '';
feedRows += `<div class="source-feed-row">
<span class="source-feed-connector">${connector}</span>
<span class="source-feed-name">${this.escape(feed.name)}</span>
<span class="source-type-badge type-${feed.source_type}">${typeLabel}</span>
<span class="source-feed-url" title="${this.escape(feed.url || '')}">${this.escape(urlDisplay)}</span>
<button class="source-delete-btn" onclick="App.deleteSingleFeed(${feed.id})" title="Löschen" aria-label="Löschen">&times;</button>
</div>`;
});
feedRows += '</div>';
}
const feedCountBadge = feedCount > 0
? `<span class="source-feed-count">${feedCount} Feed${feedCount !== 1 ? 's' : ''}</span>`
: '';
return `<div class="source-group">
<div class="source-group-header" ${toggleAttr}>
${toggleIcon}
<div class="source-group-info">
<span class="source-group-name">${this.escape(displayName)}</span>
</div>
<span class="source-category-badge cat-${feeds[0]?.category || 'sonstige'}">${catLabel}</span>
${feedCountBadge}
<div class="source-group-actions" onclick="event.stopPropagation()">
<button class="btn btn-small btn-secondary" onclick="App.blockDomainDirect('${escapedDomain}')">Sperren</button>
<button class="source-delete-btn" onclick="App.deleteDomain('${escapedDomain}')" title="Löschen" aria-label="Löschen">&times;</button>
</div>
</div>
${feedRows}
</div>`;
},
/**
* URL kürzen für die Anzeige in Feed-Zeilen.
*/
_shortenUrl(url) {
try {
const u = new URL(url);
let path = u.pathname;
if (path.length > 40) path = path.substring(0, 37) + '...';
return u.hostname + path;
} catch {
return url.length > 50 ? url.substring(0, 47) + '...' : url;
}
},
/**
* URLs in Evidence-Text als kompakte Hostname-Chips rendern (Legacy-Fallback).
*/
renderEvidenceChips(text) {
return this.renderEvidence(text);
},
/**
* URLs in Evidence-Text als klickbare Links rendern (Legacy).
*/
linkifyEvidence(text) {
if (!text) return '';
const escaped = this.escape(text);
return escaped.replace(
/(https?:\/\/[^\s,)]+)/g,
'<a href="$1" target="_blank" rel="noopener">$1</a>'
);
},
/**
* HTML escapen.
*/
escape(str) {
if (!str) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
},
};

Datei anzeigen

@@ -341,6 +341,17 @@ a:hover {
color: var(--error);
}
.login-success {
display: none;
background: var(--tint-success);
border: 1px solid rgba(16, 185, 129, 0.3);
border-radius: var(--radius);
padding: var(--sp-lg) var(--sp-xl);
margin-bottom: var(--sp-xl);
font-size: 13px;
color: var(--success);
}
/* === Buttons === */
.btn {
display: inline-flex;
@@ -466,6 +477,88 @@ a:hover {
color: var(--text-secondary);
font-weight: 500;
}
/* --- Org & License Info in Header --- */
.header-user-info {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
line-height: 1.3;
}
.header-user-top {
display: flex;
align-items: center;
gap: var(--sp-md);
}
.header-org-name {
font-size: 11px;
color: var(--text-tertiary);
font-weight: 400;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.header-license-badge {
display: inline-block;
font-size: 10px;
font-weight: 600;
padding: 1px 7px;
border-radius: 9999px;
text-transform: uppercase;
letter-spacing: 0.03em;
line-height: 1.6;
white-space: nowrap;
}
.header-license-badge.license-trial {
background: var(--warning-bg, #fef3c7);
color: var(--warning-text, #92400e);
border: 1px solid var(--warning-border, #fcd34d);
}
.header-license-badge.license-annual {
background: var(--success-bg, #d1fae5);
color: var(--success-text, #065f46);
border: 1px solid var(--success-border, #6ee7b7);
}
.header-license-badge.license-permanent {
background: var(--info-bg, #dbeafe);
color: var(--info-text, #1e40af);
border: 1px solid var(--info-border, #93c5fd);
}
.header-license-badge.license-expired {
background: var(--danger-bg, #fee2e2);
color: var(--danger-text, #991b1b);
border: 1px solid var(--danger-border, #fca5a5);
}
.header-license-badge.license-unknown {
background: var(--bg-tertiary, #f3f4f6);
color: var(--text-tertiary, #6b7280);
border: 1px solid var(--border-color, #d1d5db);
}
.header-license-warning {
display: none;
font-size: 11px;
color: var(--danger-text, #991b1b);
background: var(--danger-bg, #fee2e2);
border: 1px solid var(--danger-border, #fca5a5);
border-radius: var(--radius);
padding: 3px 10px;
white-space: nowrap;
}
.header-license-warning.visible {
display: inline-block;
}
/* === Sidebar === */
.sidebar {
@@ -3165,6 +3258,23 @@ a:hover {
}
/* Delete Button */
.source-edit-btn {
background: none;
border: none;
color: var(--text-disabled);
cursor: pointer;
font-size: 13px;
padding: 2px 6px;
border-radius: var(--radius);
transition: color 0.2s, background 0.2s;
line-height: 1;
}
.source-edit-btn:hover {
color: var(--accent);
background: var(--tint-accent);
}
.source-delete-btn {
background: none;
border: none;

Datei anzeigen

@@ -26,7 +26,14 @@
</div>
<div class="header-right">
<button class="btn btn-secondary btn-small theme-toggle-btn" id="theme-toggle" onclick="ThemeManager.toggle()" title="Theme wechseln" aria-label="Theme wechseln">&#9788;</button>
<span class="header-user" id="header-user"></span>
<div class="header-user-info">
<div class="header-user-top">
<span class="header-user" id="header-user"></span>
<span class="header-license-badge" id="header-license-badge"></span>
</div>
<span class="header-org-name" id="header-org-name"></span>
</div>
<div class="header-license-warning" id="header-license-warning"></div>
<button class="btn btn-secondary btn-small" id="logout-btn">Abmelden</button>
</div>
</header>
@@ -90,7 +97,7 @@
<!-- Header Strip -->
<div class="incident-header-strip" id="incident-header-strip">
<div class="incident-header-row0">
<span class="incident-type-label" id="incident-type-badge"></span>
<span class="incident-type-badge" id="incident-type-badge"></span>
<span class="auto-refresh-indicator" id="meta-refresh-mode"></span>
</div>
<div class="incident-header-row1">
@@ -501,7 +508,7 @@
<div class="modal">
<div class="modal-header">
<div class="modal-title" id="modal-feedback-title">Feedback senden</div>
<button class="modal-close" onclick="closeModal('modal-feedback')" aria-label="Schliessen">&times;</button>
<button class="modal-close" onclick="closeModal('modal-feedback')" aria-label="Schließen">&times;</button>
</div>
<form id="feedback-form">
<div class="modal-body">

Datei anzeigen

@@ -47,7 +47,7 @@
style="text-align:center; font-size:24px; letter-spacing:8px; font-family:monospace;">
</div>
<button type="submit" class="btn btn-primary btn-full" id="code-btn">Verifizieren</button>
<button type="button" class="btn btn-secondary btn-full" id="back-btn" style="margin-top:8px;">Zurueck</button>
<button type="button" class="btn btn-secondary btn-full" id="back-btn" style="margin-top:8px;">Zurück</button>
</form>
<div style="text-align:center;margin-top:16px;">
@@ -170,7 +170,7 @@
const btn = document.getElementById('code-btn');
errorEl.style.display = 'none';
btn.disabled = true;
btn.textContent = 'Wird geprueft...';
btn.textContent = 'Wird geprüft...';
try {
const response = await fetch('/api/auth/verify-code', {
@@ -200,7 +200,7 @@
}
});
// Zurueck-Button
// Zurück-Button
document.getElementById('back-btn').addEventListener('click', () => {
document.getElementById('code-form').style.display = 'none';
document.getElementById('email-form').style.display = 'block';

Datei anzeigen

@@ -136,22 +136,10 @@ const API = {
return this._request('GET', '/sources/stats');
},
refreshSourceCounts() {
return this._request('POST', '/sources/refresh-counts');
},
discoverSource(url) {
return this._request('POST', '/sources/discover', { url });
},
discoverMulti(url) {
return this._request('POST', '/sources/discover-multi', { url });
},
rediscoverExisting() {
return this._request('POST', '/sources/rediscover-existing');
},
blockDomain(domain, notes) {
return this._request('POST', '/sources/block-domain', { domain, notes });
},
@@ -173,10 +161,6 @@ const API = {
return this._request('GET', `/notifications?limit=${limit}`);
},
getUnreadCount() {
return this._request('GET', '/notifications/unread-count');
},
markNotificationsRead(ids = null) {
return this._request('PUT', '/notifications/mark-read', { notification_ids: ids });
},

Datei anzeigen

@@ -370,7 +370,6 @@ const App = {
currentIncidentId: null,
incidents: [],
_originalTitle: document.title,
_notificationCount: 0,
_refreshingIncidents: new Set(),
_editingIncidentId: null,
_currentArticles: [],
@@ -403,6 +402,42 @@ const App = {
const user = await API.getMe();
this._currentUsername = user.username;
document.getElementById('header-user').textContent = user.username;
// Org-Name anzeigen
const orgNameEl = document.getElementById('header-org-name');
if (orgNameEl && user.org_name) {
orgNameEl.textContent = user.org_name;
orgNameEl.title = user.org_name;
}
// Lizenz-Badge anzeigen
const badgeEl = document.getElementById('header-license-badge');
if (badgeEl) {
const licenseLabels = {
trial: 'Trial',
annual: 'Annual',
permanent: 'Permanent',
expired: 'Abgelaufen',
unknown: 'Unbekannt'
};
const status = user.read_only ? 'expired' : (user.license_status || 'unknown');
const cssClass = user.read_only ? 'license-expired'
: user.license_type === 'trial' ? 'license-trial'
: user.license_type === 'annual' ? 'license-annual'
: user.license_type === 'permanent' ? 'license-permanent'
: 'license-unknown';
const label = user.read_only ? 'Abgelaufen'
: licenseLabels[user.license_type] || licenseLabels[user.license_status] || 'Unbekannt';
badgeEl.textContent = label;
badgeEl.className = 'header-license-badge ' + cssClass;
}
// Warnung bei abgelaufener Lizenz
const warningEl = document.getElementById('header-license-warning');
if (warningEl && user.read_only) {
warningEl.textContent = 'Lizenz abgelaufen – nur Lesezugriff';
warningEl.classList.add('visible');
}
} catch {
window.location.href = '/';
return;
@@ -432,7 +467,6 @@ const App = {
WS.connect();
WS.on('status_update', (msg) => this.handleStatusUpdate(msg));
WS.on('refresh_complete', (msg) => this.handleRefreshComplete(msg));
WS.on('notification', (msg) => this.handleNotification(msg));
WS.on('refresh_summary', (msg) => this.handleRefreshSummary(msg));
WS.on('refresh_error', (msg) => this.handleRefreshError(msg));
WS.on('refresh_cancelled', (msg) => this.handleRefreshCancelled(msg));
@@ -476,6 +510,7 @@ const App = {
renderSidebar() {
const activeContainer = document.getElementById('active-incidents');
const researchContainer = document.getElementById('active-research');
const archivedContainer = document.getElementById('archived-incidents');
// Filter-Buttons aktualisieren
@@ -491,19 +526,34 @@ const App = {
filtered = filtered.filter(i => i.created_by_username === this._currentUsername);
}
const active = filtered.filter(i => i.status === 'active');
// Aktive Lagen nach Typ aufteilen
const activeAdhoc = filtered.filter(i => i.status === 'active' && (!i.type || i.type === 'adhoc'));
const activeResearch = filtered.filter(i => i.status === 'active' && i.type === 'research');
const archived = filtered.filter(i => i.status === 'archived');
const emptyLabel = this._sidebarFilter === 'mine' ? 'Keine eigenen Lagen' : 'Keine aktiven Lagen';
const emptyLabelAdhoc = this._sidebarFilter === 'mine' ? 'Keine eigenen Ad-hoc-Lagen' : 'Keine Ad-hoc-Lagen';
const emptyLabelResearch = this._sidebarFilter === 'mine' ? 'Keine eigenen Recherchen' : 'Keine Recherchen';
activeContainer.innerHTML = active.length
? active.map(i => UI.renderIncidentItem(i, i.id === this.currentIncidentId)).join('')
: `<div style="padding:8px 12px;font-size:12px;color:var(--text-tertiary);">${emptyLabel}</div>`;
activeContainer.innerHTML = activeAdhoc.length
? activeAdhoc.map(i => UI.renderIncidentItem(i, i.id === this.currentIncidentId)).join('')
: `<div style="padding:8px 12px;font-size:12px;color:var(--text-tertiary);">${emptyLabelAdhoc}</div>`;
researchContainer.innerHTML = activeResearch.length
? activeResearch.map(i => UI.renderIncidentItem(i, i.id === this.currentIncidentId)).join('')
: `<div style="padding:8px 12px;font-size:12px;color:var(--text-tertiary);">${emptyLabelResearch}</div>`;
archivedContainer.innerHTML = archived.length
? archived.map(i => UI.renderIncidentItem(i, i.id === this.currentIncidentId)).join('')
: '<div style="padding:8px 12px;font-size:12px;color:var(--text-tertiary);">Kein Archiv</div>';
// Zähler aktualisieren
const countAdhoc = document.getElementById('count-active-incidents');
const countResearch = document.getElementById('count-active-research');
const countArchived = document.getElementById('count-archived-incidents');
if (countAdhoc) countAdhoc.textContent = `(${activeAdhoc.length})`;
if (countResearch) countResearch.textContent = `(${activeResearch.length})`;
if (countArchived) countArchived.textContent = `(${archived.length})`;
// Sidebar-Stats aktualisieren
this.updateSidebarStats();
},
@@ -1547,7 +1597,7 @@ const App = {
}
},
handleEdit() {
async handleEdit() {
if (!this.currentIncidentId) return;
const incident = this.incidents.find(i => i.id === this.currentIncidentId);
if (!incident) return;
@@ -1677,16 +1727,7 @@ const App = {
await this.loadIncidents();
},
handleNotification(msg) {
// Legacy-Fallback: Einzelne Notifications ans NotificationCenter weiterleiten
const incident = this.incidents.find(i => i.id === msg.incident_id);
NotificationCenter.add({
incident_id: msg.incident_id,
title: incident ? incident.title : 'Lage #' + msg.incident_id,
text: msg.data.message || 'Neue Entwicklung',
icon: 'warning',
});
},
handleRefreshSummary(msg) {
const d = msg.data;
@@ -2322,9 +2363,17 @@ const App = {
document.getElementById('src-discovery-result').style.display = 'none';
document.getElementById('src-discover-btn').disabled = false;
document.getElementById('src-discover-btn').textContent = 'Erkennen';
// Save-Button Text zurücksetzen
const saveBtn = document.querySelector('#src-discovery-result .sources-discovery-actions .btn-primary');
if (saveBtn) saveBtn.textContent = 'Speichern';
// Block-Form ausblenden
const blockForm = document.getElementById('sources-block-form');
if (blockForm) blockForm.style.display = 'none';
} else {
// Beim Schließen: Bearbeitungsmodus zurücksetzen
this._editingSourceId = null;
const saveBtn = document.querySelector('#src-discovery-result .sources-discovery-actions .btn-primary');
if (saveBtn) saveBtn.textContent = 'Speichern';
}
},
@@ -2417,6 +2466,66 @@ const App = {
}
},
editSource(id) {
const source = this._sourcesOnly.find(s => s.id === id);
if (!source) {
UI.showToast('Quelle nicht gefunden.', 'error');
return;
}
this._editingSourceId = id;
// Formular öffnen falls geschlossen (direkt, ohne toggleSourceForm das _editingSourceId zurücksetzt)
const form = document.getElementById('sources-add-form');
if (form) {
form.style.display = 'block';
const blockForm = document.getElementById('sources-block-form');
if (blockForm) blockForm.style.display = 'none';
}
// Discovery-URL mit vorhandener URL/Domain befüllen
const discoverUrlInput = document.getElementById('src-discover-url');
if (discoverUrlInput) {
discoverUrlInput.value = source.url || source.domain || '';
}
// Discovery-Ergebnis anzeigen und Felder befüllen
document.getElementById('src-discovery-result').style.display = 'block';
document.getElementById('src-name').value = source.name || '';
document.getElementById('src-category').value = source.category || 'sonstige';
document.getElementById('src-notes').value = source.notes || '';
document.getElementById('src-domain').value = source.domain || '';
const typeLabel = source.source_type === 'rss_feed' ? 'RSS-Feed' : 'Web-Quelle';
document.getElementById('src-type-display').value = typeLabel;
const rssGroup = document.getElementById('src-rss-url-group');
const rssInput = document.getElementById('src-rss-url');
if (source.url) {
rssInput.value = source.url;
rssGroup.style.display = 'block';
} else {
rssInput.value = '';
rssGroup.style.display = 'none';
}
// _discoveredData setzen damit saveSource() die richtigen Werte nutzt
this._discoveredData = {
name: source.name,
domain: source.domain,
category: source.category,
source_type: source.source_type,
rss_url: source.url,
};
// Submit-Button-Text ändern
const saveBtn = document.querySelector('#src-discovery-result .sources-discovery-actions .btn-primary');
if (saveBtn) saveBtn.textContent = 'Quelle speichern';
// Zum Formular scrollen
if (form) form.scrollIntoView({ behavior: 'smooth', block: 'start' });
},
async saveSource() {
const name = document.getElementById('src-name').value.trim();
if (!name) {

Datei anzeigen

@@ -1,3 +1,16 @@
/**
* Parst einen UTC-Zeitstring vom Server in ein Date-Objekt.
*/
function parseUTC(dateStr) {
if (!dateStr) return null;
try {
const d = new Date(dateStr.endsWith('Z') ? dateStr : dateStr + 'Z');
return isNaN(d.getTime()) ? null : d;
} catch (e) {
return null;
}
}
/**
* UI-Komponenten für das Dashboard.
*/
@@ -149,19 +162,6 @@ const UI = {
return `${explanationHtml}<div class="evidence-chips">${chips}</div>`;
},
/**
* Verifizierungs-Badge.
*/
verificationBadge(status) {
const map = {
verified: { class: 'badge-verified', text: 'Verifiziert' },
unverified: { class: 'badge-unverified', text: 'Offen' },
contradicted: { class: 'badge-contradicted', text: 'Widerlegt' },
};
const badge = map[status] || map.unverified;
return `<span class="badge ${badge.class}">${badge.text}</span>`;
},
/**
* Toast-Benachrichtigung anzeigen.
*/
@@ -228,6 +228,7 @@ const UI = {
deep_researching: { active: 1, label: 'Tiefenrecherche läuft...' },
analyzing: { active: 2, label: 'Analysiert Meldungen...' },
factchecking: { active: 3, label: 'Faktencheck läuft...' },
cancelling: { active: 0, label: 'Wird abgebrochen...' },
};
const step = steps[status] || steps.queued;
@@ -553,6 +554,7 @@ const UI = {
<span class="source-feed-name">${this.escape(feed.name)}</span>
<span class="source-type-badge type-${feed.source_type}">${typeLabel}</span>
<span class="source-feed-url" title="${this.escape(feed.url || '')}">${this.escape(urlDisplay)}</span>
<button class="source-edit-btn" onclick="App.editSource(${feed.id})" title="Bearbeiten" aria-label="Bearbeiten">&#9998;</button>
<button class="source-delete-btn" onclick="App.deleteSingleFeed(${feed.id})" title="Löschen" aria-label="Löschen">&times;</button>
</div>`;
});
@@ -572,6 +574,7 @@ const UI = {
<span class="source-category-badge cat-${feeds[0]?.category || 'sonstige'}">${catLabel}</span>
${feedCountBadge}
<div class="source-group-actions" onclick="event.stopPropagation()">
${!hasMultiple && feeds[0]?.id ? `<button class="source-edit-btn" onclick="App.editSource(${feeds[0].id})" title="Bearbeiten" aria-label="Bearbeiten">&#9998;</button>` : ''}
<button class="btn btn-small btn-secondary" onclick="App.blockDomainDirect('${escapedDomain}')">Sperren</button>
<button class="source-delete-btn" onclick="App.deleteDomain('${escapedDomain}')" title="Löschen" aria-label="Löschen">&times;</button>
</div>
@@ -593,26 +596,6 @@ const UI = {
return url.length > 50 ? url.substring(0, 47) + '...' : url;
}
},
/**
* URLs in Evidence-Text als kompakte Hostname-Chips rendern (Legacy-Fallback).
*/
renderEvidenceChips(text) {
return this.renderEvidence(text);
},
/**
* URLs in Evidence-Text als klickbare Links rendern (Legacy).
*/
linkifyEvidence(text) {
if (!text) return '';
const escaped = this.escape(text);
return escaped.replace(
/(https?:\/\/[^\s,)]+)/g,
'<a href="$1" target="_blank" rel="noopener">$1</a>'
);
},
/**
* HTML escapen.
*/

Datei anzeigen

@@ -261,16 +261,6 @@ const LayoutManager = {
this._debouncedSave();
},
resetAllTilesToDefault() {
if (!this._grid) return;
this.DEFAULT_LAYOUT.forEach(cfg => {
const node = this._grid.engine.nodes.find(
n => n.el && n.el.getAttribute('gs-id') === cfg.id
);
if (node) this._grid.update(node.el, { h: cfg.h });
});
},
destroy() {
if (this._grid) {
this._grid.destroy(false);

Datei anzeigen

@@ -1,272 +0,0 @@
/**
* LayoutManager: Drag & Resize Dashboard-Layout mit gridstack.js
* Persistenz über localStorage, Reset auf Standard-Layout möglich.
*/
const LayoutManager = {
_grid: null,
_storageKey: 'osint_layout',
_initialized: false,
_saveTimeout: null,
_hiddenTiles: {},
DEFAULT_LAYOUT: [
{ id: 'lagebild', x: 0, y: 0, w: 6, h: 4, minW: 4, minH: 4 },
{ id: 'faktencheck', x: 6, y: 0, w: 6, h: 4, minW: 4, minH: 4 },
{ id: 'quellen', x: 0, y: 4, w: 12, h: 2, minW: 6, minH: 2 },
{ id: 'timeline', x: 0, y: 5, w: 12, h: 4, minW: 6, minH: 4 },
],
TILE_MAP: {
lagebild: '.incident-analysis-summary',
faktencheck: '.incident-analysis-factcheck',
quellen: '.source-overview-card',
timeline: '.timeline-card',
},
init() {
if (this._initialized) return;
const container = document.querySelector('.grid-stack');
if (!container) return;
this._grid = GridStack.init({
column: 12,
cellHeight: 80,
margin: 12,
animate: true,
handle: '.card-header',
float: false,
disableOneColumnMode: true,
}, container);
const saved = this._load();
if (saved) {
this._applyLayout(saved);
}
this._grid.on('change', () => this._debouncedSave());
const toolbar = document.getElementById('layout-toolbar');
if (toolbar) toolbar.style.display = 'flex';
this._syncToggles();
this._initialized = true;
},
_applyLayout(layout) {
if (!this._grid) return;
this._hiddenTiles = {};
layout.forEach(item => {
const el = this._grid.engine.nodes.find(n => n.el && n.el.getAttribute('gs-id') === item.id);
if (!el) return;
if (item.visible === false) {
this._hiddenTiles[item.id] = item;
this._grid.removeWidget(el.el, true, false);
} else {
this._grid.update(el.el, { x: item.x, y: item.y, w: item.w, h: item.h });
}
});
this._syncToggles();
},
save() {
if (!this._grid) return;
const items = [];
this._grid.engine.nodes.forEach(node => {
const id = node.el ? node.el.getAttribute('gs-id') : null;
if (!id) return;
items.push({
id, x: node.x, y: node.y, w: node.w, h: node.h, visible: true,
});
});
Object.keys(this._hiddenTiles).forEach(id => {
items.push({ ...this._hiddenTiles[id], visible: false });
});
try {
localStorage.setItem(this._storageKey, JSON.stringify(items));
} catch (e) { /* quota */ }
},
_debouncedSave() {
clearTimeout(this._saveTimeout);
this._saveTimeout = setTimeout(() => this.save(), 300);
},
_load() {
try {
const raw = localStorage.getItem(this._storageKey);
if (!raw) return null;
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed) || parsed.length === 0) return null;
return parsed;
} catch (e) {
return null;
}
},
toggleTile(tileId) {
if (!this._grid) return;
const selector = this.TILE_MAP[tileId];
if (!selector) return;
if (this._hiddenTiles[tileId]) {
// Kachel einblenden
const cfg = this._hiddenTiles[tileId];
delete this._hiddenTiles[tileId];
const cardEl = document.querySelector(selector);
if (!cardEl) return;
// Wrapper erstellen
const wrapper = document.createElement('div');
wrapper.className = 'grid-stack-item';
wrapper.setAttribute('gs-id', tileId);
wrapper.setAttribute('gs-x', cfg.x);
wrapper.setAttribute('gs-y', cfg.y);
wrapper.setAttribute('gs-w', cfg.w);
wrapper.setAttribute('gs-h', cfg.h);
wrapper.setAttribute('gs-min-w', cfg.minW || '');
wrapper.setAttribute('gs-min-h', cfg.minH || '');
const content = document.createElement('div');
content.className = 'grid-stack-item-content';
content.appendChild(cardEl);
wrapper.appendChild(content);
this._grid.addWidget(wrapper);
} else {
// Kachel ausblenden
const node = this._grid.engine.nodes.find(
n => n.el && n.el.getAttribute('gs-id') === tileId
);
if (!node) return;
const defaults = this.DEFAULT_LAYOUT.find(d => d.id === tileId);
this._hiddenTiles[tileId] = {
id: tileId,
x: node.x, y: node.y, w: node.w, h: node.h,
minW: defaults ? defaults.minW : 4,
minH: defaults ? defaults.minH : 2,
visible: false,
};
// Card aus dem Widget retten bevor es entfernt wird
const cardEl = node.el.querySelector(selector);
if (cardEl) {
// Temporär im incident-view parken (unsichtbar)
const parking = document.getElementById('tile-parking');
if (parking) parking.appendChild(cardEl);
}
this._grid.removeWidget(node.el, true, false);
}
this._syncToggles();
this.save();
},
_syncToggles() {
document.querySelectorAll('.layout-toggle-btn').forEach(btn => {
const tileId = btn.getAttribute('data-tile');
const isHidden = !!this._hiddenTiles[tileId];
btn.classList.toggle('active', !isHidden);
btn.setAttribute('aria-pressed', String(!isHidden));
});
},
reset() {
localStorage.removeItem(this._storageKey);
// Cards einsammeln BEVOR der Grid zerstört wird (aus Grid + Parking)
const cards = {};
Object.entries(this.TILE_MAP).forEach(([id, selector]) => {
const card = document.querySelector(selector);
if (card) cards[id] = card;
});
this._hiddenTiles = {};
if (this._grid) {
this._grid.destroy(false);
this._grid = null;
}
this._initialized = false;
const gridEl = document.querySelector('.grid-stack');
if (!gridEl) return;
// Grid leeren (Cards sind bereits in cards-Map gesichert)
gridEl.innerHTML = '';
// Cards in Default-Layout neu aufbauen
this.DEFAULT_LAYOUT.forEach(cfg => {
const cardEl = cards[cfg.id];
if (!cardEl) return;
const wrapper = document.createElement('div');
wrapper.className = 'grid-stack-item';
wrapper.setAttribute('gs-id', cfg.id);
wrapper.setAttribute('gs-x', cfg.x);
wrapper.setAttribute('gs-y', cfg.y);
wrapper.setAttribute('gs-w', cfg.w);
wrapper.setAttribute('gs-h', cfg.h);
wrapper.setAttribute('gs-min-w', cfg.minW);
wrapper.setAttribute('gs-min-h', cfg.minH);
const content = document.createElement('div');
content.className = 'grid-stack-item-content';
content.appendChild(cardEl);
wrapper.appendChild(content);
gridEl.appendChild(wrapper);
});
this.init();
},
resizeTileToContent(tileId) {
if (!this._grid) return;
const node = this._grid.engine.nodes.find(
n => n.el && n.el.getAttribute('gs-id') === tileId
);
if (!node || !node.el) return;
const wrapper = node.el.querySelector('.grid-stack-item-content');
if (!wrapper) return;
const card = wrapper.firstElementChild;
if (!card) return;
const cellH = this._grid.opts.cellHeight || 80;
const margin = this._grid.opts.margin || 12;
// Temporär alle height-Constraints aufheben
node.el.classList.add('gs-measuring');
const naturalHeight = card.scrollHeight;
node.el.classList.remove('gs-measuring');
// In Grid-Units umrechnen (aufrunden + 1 Puffer)
const neededH = Math.ceil(naturalHeight / (cellH + margin)) + 1;
const minH = node.minH || 2;
const finalH = Math.max(neededH, minH);
this._grid.update(node.el, { h: finalH });
this._debouncedSave();
},
destroy() {
if (this._grid) {
this._grid.destroy(false);
this._grid = null;
}
this._initialized = false;
this._hiddenTiles = {};
},
};

Datei-Diff unterdrückt, da er zu groß ist Diff laden