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:
@@ -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
|
||||||
|
|||||||
@@ -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())
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
25
src/auth.py
25
src/auth.py
@@ -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)
|
||||||
|
|||||||
@@ -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": [
|
||||||
|
|||||||
@@ -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()]
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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 · ${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: '✓',
|
|
||||||
unconfirmed: '?',
|
|
||||||
contradicted: '✗',
|
|
||||||
developing: '↻',
|
|
||||||
established: '✓',
|
|
||||||
disputed: '⚠',
|
|
||||||
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">×</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">▶</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">×</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">×</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;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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">☼</button>
|
<button class="btn btn-secondary btn-small theme-toggle-btn" id="theme-toggle" onclick="ThemeManager.toggle()" title="Theme wechseln" aria-label="Theme wechseln">☼</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>
|
<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">×</button>
|
<button class="modal-close" onclick="closeModal('modal-feedback')" aria-label="Schließen">×</button>
|
||||||
</div>
|
</div>
|
||||||
<form id="feedback-form">
|
<form id="feedback-form">
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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 });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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">✎</button>
|
||||||
<button class="source-delete-btn" onclick="App.deleteSingleFeed(${feed.id})" title="Löschen" aria-label="Löschen">×</button>
|
<button class="source-delete-btn" onclick="App.deleteSingleFeed(${feed.id})" title="Löschen" aria-label="Löschen">×</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">✎</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">×</button>
|
<button class="source-delete-btn" onclick="App.deleteDomain('${escapedDomain}')" title="Löschen" aria-label="Löschen">×</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.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 = {};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
3653
src/static/style.css
3653
src/static/style.css
Datei-Diff unterdrückt, da er zu groß ist
Diff laden
In neuem Issue referenzieren
Einen Benutzer sperren