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 fastapi==0.115.6
uvicorn[standard]==0.34.0 uvicorn[standard]==0.34.0
python-jose[cryptography] python-jose[cryptography]
passlib[bcrypt] bcrypt
aiosqlite aiosqlite
feedparser feedparser
httpx 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.""" """Analyzer-Agent: Analysiert, übersetzt und fasst Meldungen zusammen."""
import asyncio
import json import json
import logging import logging
import re import re

Datei anzeigen

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

Datei anzeigen

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

Datei anzeigen

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

Datei anzeigen

@@ -3,24 +3,13 @@ import secrets
import string import string
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from jose import jwt, JWTError from jose import jwt, JWTError
import bcrypt as _bcrypt
from fastapi import Depends, HTTPException, status from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials 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() 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_ISSUER = "intelsight-osint"
JWT_AUDIENCE = "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: def generate_magic_token() -> str:
"""Generiert einen 64-Zeichen URL-safe Token.""" """Generiert einen 64-Zeichen URL-safe Token."""
return secrets.token_urlsafe(48) return secrets.token_urlsafe(48)

Datei anzeigen

@@ -21,17 +21,11 @@ JWT_EXPIRE_HOURS = 24
# Claude CLI # Claude CLI
CLAUDE_PATH = os.environ.get("CLAUDE_PATH", "/home/claude-dev/.claude/local/claude") 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) CLAUDE_TIMEOUT = 300 # Sekunden (Claude mit WebSearch braucht oft 2-3 Min)
# Ausgabesprache (Lagebilder, Faktenchecks, Zusammenfassungen) # Ausgabesprache (Lagebilder, Faktenchecks, Zusammenfassungen)
OUTPUT_LANGUAGE = "Deutsch" 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 (Fallback, primär aus DB geladen)
RSS_FEEDS = { RSS_FEEDS = {
"deutsch": [ "deutsch": [

Datei anzeigen

@@ -52,6 +52,7 @@ CREATE TABLE IF NOT EXISTS magic_links (
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 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 ( CREATE TABLE IF NOT EXISTS portal_admins (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL, username TEXT UNIQUE NOT NULL,
@@ -213,14 +214,6 @@ async def init_db():
await db.commit() await db.commit()
logger.info("Migration: tenant_id zu incidents hinzugefuegt") 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 # Migration: Token-Spalten fuer refresh_log
cursor = await db.execute("PRAGMA table_info(refresh_log)") cursor = await db.execute("PRAGMA table_info(refresh_log)")
rl_columns = [row[1] for row in await cursor.fetchall()] 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.execute("ALTER TABLE refresh_log ADD COLUMN tenant_id INTEGER REFERENCES organizations(id)")
await db.commit() 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) # Migration: notifications-Tabelle (fuer bestehende DBs)
cursor = await db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='notifications'") cursor = await db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='notifications'")
if not await cursor.fetchone(): 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.execute("ALTER TABLE users ADD COLUMN last_login_at TIMESTAMP")
await db.commit() 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 # Migration: tenant_id fuer articles
cursor = await db.execute("PRAGMA table_info(articles)") cursor = await db.execute("PRAGMA table_info(articles)")
art_columns = [row[1] for row in await cursor.fetchall()] 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 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( def incident_notification_email(
username: str, username: str,
incident_title: str, incident_title: str,

Datei anzeigen

@@ -4,11 +4,6 @@ from typing import Optional
from datetime import datetime from datetime import datetime
# Auth (Legacy)
class LoginRequest(BaseModel):
username: str
password: str
# Auth (Magic Link) # Auth (Magic Link)
class MagicLinkRequest(BaseModel): class MagicLinkRequest(BaseModel):
@@ -34,10 +29,6 @@ class TokenResponse(BaseModel):
username: str username: str
class UserResponse(BaseModel):
id: int
username: str
class UserMeResponse(BaseModel): class UserMeResponse(BaseModel):
id: int id: int
@@ -97,32 +88,6 @@ class IncidentResponse(BaseModel):
source_count: int = 0 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) # Sources (Quellenverwaltung)
@@ -191,18 +156,6 @@ class DomainActionRequest(BaseModel):
notes: Optional[str] = None 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 # Notifications
class NotificationResponse(BaseModel): class NotificationResponse(BaseModel):
@@ -239,7 +192,3 @@ class FeedbackRequest(BaseModel):
message: str = Field(min_length=10, max_length=5000) 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, get_current_user,
generate_magic_token, generate_magic_token,
generate_magic_code, generate_magic_code,
verify_password,
) )
from database import db_dependency from database import db_dependency
from config import TIMEZONE, MAGIC_LINK_EXPIRE_MINUTES, MAGIC_LINK_BASE_URL 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); 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 === */ /* === Buttons === */
.btn { .btn {
display: inline-flex; display: inline-flex;
@@ -466,6 +477,88 @@ a:hover {
color: var(--text-secondary); color: var(--text-secondary);
font-weight: 500; 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 === */
.sidebar { .sidebar {
@@ -3165,6 +3258,23 @@ a:hover {
} }
/* Delete Button */ /* 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 { .source-delete-btn {
background: none; background: none;
border: none; border: none;

Datei anzeigen

@@ -26,7 +26,14 @@
</div> </div>
<div class="header-right"> <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> <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>
<div class="header-user-info">
<div class="header-user-top">
<span class="header-user" id="header-user"></span> <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> <button class="btn btn-secondary btn-small" id="logout-btn">Abmelden</button>
</div> </div>
</header> </header>
@@ -90,7 +97,7 @@
<!-- Header Strip --> <!-- Header Strip -->
<div class="incident-header-strip" id="incident-header-strip"> <div class="incident-header-strip" id="incident-header-strip">
<div class="incident-header-row0"> <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> <span class="auto-refresh-indicator" id="meta-refresh-mode"></span>
</div> </div>
<div class="incident-header-row1"> <div class="incident-header-row1">
@@ -501,7 +508,7 @@
<div class="modal"> <div class="modal">
<div class="modal-header"> <div class="modal-header">
<div class="modal-title" id="modal-feedback-title">Feedback senden</div> <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> </div>
<form id="feedback-form"> <form id="feedback-form">
<div class="modal-body"> <div class="modal-body">

Datei anzeigen

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

Datei anzeigen

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

Datei anzeigen

@@ -370,7 +370,6 @@ const App = {
currentIncidentId: null, currentIncidentId: null,
incidents: [], incidents: [],
_originalTitle: document.title, _originalTitle: document.title,
_notificationCount: 0,
_refreshingIncidents: new Set(), _refreshingIncidents: new Set(),
_editingIncidentId: null, _editingIncidentId: null,
_currentArticles: [], _currentArticles: [],
@@ -403,6 +402,42 @@ const App = {
const user = await API.getMe(); const user = await API.getMe();
this._currentUsername = user.username; this._currentUsername = user.username;
document.getElementById('header-user').textContent = 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 { } catch {
window.location.href = '/'; window.location.href = '/';
return; return;
@@ -432,7 +467,6 @@ const App = {
WS.connect(); WS.connect();
WS.on('status_update', (msg) => this.handleStatusUpdate(msg)); WS.on('status_update', (msg) => this.handleStatusUpdate(msg));
WS.on('refresh_complete', (msg) => this.handleRefreshComplete(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_summary', (msg) => this.handleRefreshSummary(msg));
WS.on('refresh_error', (msg) => this.handleRefreshError(msg)); WS.on('refresh_error', (msg) => this.handleRefreshError(msg));
WS.on('refresh_cancelled', (msg) => this.handleRefreshCancelled(msg)); WS.on('refresh_cancelled', (msg) => this.handleRefreshCancelled(msg));
@@ -476,6 +510,7 @@ const App = {
renderSidebar() { renderSidebar() {
const activeContainer = document.getElementById('active-incidents'); const activeContainer = document.getElementById('active-incidents');
const researchContainer = document.getElementById('active-research');
const archivedContainer = document.getElementById('archived-incidents'); const archivedContainer = document.getElementById('archived-incidents');
// Filter-Buttons aktualisieren // Filter-Buttons aktualisieren
@@ -491,19 +526,34 @@ const App = {
filtered = filtered.filter(i => i.created_by_username === this._currentUsername); 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 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 activeContainer.innerHTML = activeAdhoc.length
? active.map(i => UI.renderIncidentItem(i, i.id === this.currentIncidentId)).join('') ? activeAdhoc.map(i => UI.renderIncidentItem(i, i.id === this.currentIncidentId)).join('')
: `<div style="padding:8px 12px;font-size:12px;color:var(--text-tertiary);">${emptyLabel}</div>`; : `<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 archivedContainer.innerHTML = archived.length
? archived.map(i => UI.renderIncidentItem(i, i.id === this.currentIncidentId)).join('') ? 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>'; : '<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 // Sidebar-Stats aktualisieren
this.updateSidebarStats(); this.updateSidebarStats();
}, },
@@ -1547,7 +1597,7 @@ const App = {
} }
}, },
handleEdit() { async handleEdit() {
if (!this.currentIncidentId) return; if (!this.currentIncidentId) return;
const incident = this.incidents.find(i => i.id === this.currentIncidentId); const incident = this.incidents.find(i => i.id === this.currentIncidentId);
if (!incident) return; if (!incident) return;
@@ -1677,16 +1727,7 @@ const App = {
await this.loadIncidents(); 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) { handleRefreshSummary(msg) {
const d = msg.data; const d = msg.data;
@@ -2322,9 +2363,17 @@ const App = {
document.getElementById('src-discovery-result').style.display = 'none'; document.getElementById('src-discovery-result').style.display = 'none';
document.getElementById('src-discover-btn').disabled = false; document.getElementById('src-discover-btn').disabled = false;
document.getElementById('src-discover-btn').textContent = 'Erkennen'; 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 // Block-Form ausblenden
const blockForm = document.getElementById('sources-block-form'); const blockForm = document.getElementById('sources-block-form');
if (blockForm) blockForm.style.display = 'none'; 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() { async saveSource() {
const name = document.getElementById('src-name').value.trim(); const name = document.getElementById('src-name').value.trim();
if (!name) { 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. * UI-Komponenten für das Dashboard.
*/ */
@@ -149,19 +162,6 @@ const UI = {
return `${explanationHtml}<div class="evidence-chips">${chips}</div>`; 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. * Toast-Benachrichtigung anzeigen.
*/ */
@@ -228,6 +228,7 @@ const UI = {
deep_researching: { active: 1, label: 'Tiefenrecherche läuft...' }, deep_researching: { active: 1, label: 'Tiefenrecherche läuft...' },
analyzing: { active: 2, label: 'Analysiert Meldungen...' }, analyzing: { active: 2, label: 'Analysiert Meldungen...' },
factchecking: { active: 3, label: 'Faktencheck läuft...' }, factchecking: { active: 3, label: 'Faktencheck läuft...' },
cancelling: { active: 0, label: 'Wird abgebrochen...' },
}; };
const step = steps[status] || steps.queued; 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-feed-name">${this.escape(feed.name)}</span>
<span class="source-type-badge type-${feed.source_type}">${typeLabel}</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> <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> <button class="source-delete-btn" onclick="App.deleteSingleFeed(${feed.id})" title="Löschen" aria-label="Löschen">&times;</button>
</div>`; </div>`;
}); });
@@ -572,6 +574,7 @@ const UI = {
<span class="source-category-badge cat-${feeds[0]?.category || 'sonstige'}">${catLabel}</span> <span class="source-category-badge cat-${feeds[0]?.category || 'sonstige'}">${catLabel}</span>
${feedCountBadge} ${feedCountBadge}
<div class="source-group-actions" onclick="event.stopPropagation()"> <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="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> <button class="source-delete-btn" onclick="App.deleteDomain('${escapedDomain}')" title="Löschen" aria-label="Löschen">&times;</button>
</div> </div>
@@ -593,26 +596,6 @@ const UI = {
return url.length > 50 ? url.substring(0, 47) + '...' : url; 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. * HTML escapen.
*/ */

Datei anzeigen

@@ -261,16 +261,6 @@ const LayoutManager = {
this._debouncedSave(); 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() { destroy() {
if (this._grid) { if (this._grid) {
this._grid.destroy(false); 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