diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..6461645
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,29 @@
+# Python
+__pycache__/
+*.py[cod]
+*.egg-info/
+.venv/
+venv/
+
+# Node / Frontend
+web/node_modules/
+web/dist/
+
+# IDE
+.vscode/
+.idea/
+
+# Environment
+.env
+.env.*
+!.env.example
+
+# Docker
+*.log
+
+# OS
+.DS_Store
+Thumbs.db
+
+# Claude
+.claude/
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index f475533..0ca0bff 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -11,7 +11,7 @@ docker-build:
name: gcr.io/kaniko-project/executor:v1.23.2-debug
entrypoint: [""]
rules:
- - if: $CI_COMMIT_BRANCH
+ - if: $CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH
before_script:
- mkdir -p /kaniko/.docker
- |
@@ -27,14 +27,15 @@ docker-build:
EOF
script:
- |
- TAG="$CI_COMMIT_REF_SLUG"
- DESTINATIONS="--destination=$IMAGE_NAME:$CI_COMMIT_SHA --destination=$IMAGE_NAME:$TAG"
- if [ "$CI_COMMIT_REF_NAME" = "master" ]; then
- DESTINATIONS="$DESTINATIONS --destination=$IMAGE_NAME:latest"
+ if [ "$CI_COMMIT_REF_NAME" = "main" ] || [ "$CI_COMMIT_REF_NAME" = "master" ]; then
+ TAG="latest"
+ else
+ TAG="$CI_COMMIT_REF_SLUG"
fi
+ DESTINATIONS="--destination=$IMAGE_NAME:$CI_COMMIT_SHA --destination=$IMAGE_NAME:$TAG"
+
echo "Building daily-briefing for ref $CI_COMMIT_REF_NAME with tag $TAG"
- echo "Using registry image: $IMAGE_NAME"
/kaniko/executor \
--context "$CI_PROJECT_DIR" \
diff --git a/Dockerfile b/Dockerfile
index e65a1a8..b2c934e 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,23 +1,21 @@
-FROM python:3.11-slim
+# Stage 1: Build React Frontend
+FROM node:22-alpine AS frontend
+WORKDIR /app/web
+COPY web/package*.json ./
+RUN npm ci
+COPY web/ ./
+RUN npm run build
+# Stage 2: Python Backend + Static Files
+FROM python:3.11-slim
WORKDIR /app
-# Install dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
-# Copy application
-COPY src/ ./src/
-COPY templates/ ./templates/
-COPY static/ ./static/
-
-# Environment variables (will be overridden at runtime)
-ENV VIKUNJA_URL=http://10.10.10.10:3456/api/v1
-ENV VIKUNJA_TOKEN=""
-ENV HA_URL=https://homeassistant.daddelolymp.de
-ENV HA_TOKEN=""
-ENV WEATHER_LOCATION=Leverkusen
+COPY server/ ./server/
+COPY --from=frontend /app/web/dist ./static/
EXPOSE 8080
-CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8080"]
+CMD ["uvicorn", "server.main:app", "--host", "0.0.0.0", "--port", "8080"]
diff --git a/docker-compose.yml b/docker-compose.yml
index 0841ad7..772f881 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,5 +1,3 @@
-version: '3.8'
-
services:
daily-briefing:
build: .
@@ -7,25 +5,23 @@ services:
ports:
- "8080:8080"
environment:
- - VIKUNJA_URL=http://10.10.10.10:3456/api/v1
- - VIKUNJA_TOKEN=tk_dfaf845721a9fabe0656960ab77fd57cba127f8d
- - HA_URL=https://homeassistant.daddelolymp.de
- - HA_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJkZmM4M2Q5OTZiMDE0Mzg3YWEzZTMwYzkzYTNhNTRjNiIsImlhdCI6MTc3MDU5Mjk3NiwiZXhwIjoyMDg1OTUyOTc2fQ.fnldrKNQwVdz275-omj93FldpywEpfPQSq8VLcmcyu4
+ # Database (PostgreSQL)
+ - DB_HOST=10.10.10.10
+ - DB_PORT=5433
+ - DB_NAME=openclaw
+ - DB_USER=sam
+ - DB_PASSWORD=sam
+ # Weather
- WEATHER_LOCATION=Leverkusen
- - OPENCLAW_GATEWAY_URL=http://host.docker.internal:18789
- - DASHBOARD_DATA_PATH=/app/data/dashboard_data.json
- # Discord Integration
- - DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/1470494144953581652/7g3rq2p-ynwTR9KyUhYwubIZL75NQkOR_xnXOvSsuY72qwUjmsSokfSS3Y0wae2veMem
- - DISCORD_BOT_TOKEN=${DISCORD_BOT_TOKEN:-}
- - DISCORD_CHANNEL_ID=${DISCORD_CHANNEL_ID:-}
- volumes:
- - /home/sam/briefing/dashboard_data.json:/app/data/dashboard_data.json:ro
+ - WEATHER_LOCATION_SECONDARY=Rab,Croatia
+ # Home Assistant
+ - HA_URL=https://homeassistant.daddelolymp.de
+ - HA_TOKEN=${HA_TOKEN}
+ # Vikunja Tasks
+ - VIKUNJA_URL=http://10.10.10.10:3456/api/v1
+ - VIKUNJA_TOKEN=${VIKUNJA_TOKEN}
+ # Unraid Servers (JSON array)
+ - UNRAID_SERVERS=${UNRAID_SERVERS:-[]}
extra_hosts:
- "host.docker.internal:host-gateway"
restart: always
- networks:
- - briefing-network
-
-networks:
- briefing-network:
- driver: bridge
diff --git a/requirements.txt b/requirements.txt
index 0b32976..5148df0 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,7 +1,6 @@
-fastapi==0.104.1
-uvicorn[standard]==0.24.0
-jinja2==3.1.2
-httpx==0.25.2
-python-multipart==0.0.6
-websockets==12.0
-psutil==5.9.6
+fastapi==0.115.0
+uvicorn[standard]==0.34.0
+httpx==0.28.1
+asyncpg==0.30.0
+jinja2==3.1.5
+websockets==14.2
diff --git a/server/__init__.py b/server/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/server/cache.py b/server/cache.py
new file mode 100644
index 0000000..569a002
--- /dev/null
+++ b/server/cache.py
@@ -0,0 +1,41 @@
+"""Simple async-safe TTL cache."""
+
+from __future__ import annotations
+
+import asyncio
+import time
+from typing import Any, Dict, Optional, Tuple
+
+
+class TTLCache:
+ """Thread/async-safe in-memory cache with per-key TTL."""
+
+ def __init__(self) -> None:
+ self._store: Dict[str, Tuple[Any, float]] = {}
+ self._lock = asyncio.Lock()
+
+ async def get(self, key: str) -> Optional[Any]:
+ async with self._lock:
+ entry = self._store.get(key)
+ if entry is None:
+ return None
+ value, expires_at = entry
+ if time.time() > expires_at:
+ del self._store[key]
+ return None
+ return value
+
+ async def set(self, key: str, value: Any, ttl: int) -> None:
+ async with self._lock:
+ self._store[key] = (value, time.time() + ttl)
+
+ async def invalidate(self, key: str) -> None:
+ async with self._lock:
+ self._store.pop(key, None)
+
+ async def clear(self) -> None:
+ async with self._lock:
+ self._store.clear()
+
+
+cache = TTLCache()
diff --git a/server/config.py b/server/config.py
new file mode 100644
index 0000000..d316173
--- /dev/null
+++ b/server/config.py
@@ -0,0 +1,98 @@
+"""Centralized configuration via environment variables."""
+
+from __future__ import annotations
+
+import json
+import os
+from dataclasses import dataclass, field
+from typing import List
+
+
+@dataclass
+class UnraidServer:
+ name: str
+ host: str
+ api_key: str = ""
+ port: int = 80
+
+
+@dataclass
+class Settings:
+ # --- Database (PostgreSQL) ---
+ db_host: str = "10.10.10.10"
+ db_port: int = 5433
+ db_name: str = "openclaw"
+ db_user: str = "sam"
+ db_password: str = "sam"
+
+ # --- Weather ---
+ weather_location: str = "Leverkusen"
+ weather_location_secondary: str = "Rab,Croatia"
+ weather_cache_ttl: int = 1800 # 30 min
+
+ # --- Home Assistant ---
+ ha_url: str = "https://homeassistant.daddelolymp.de"
+ ha_token: str = ""
+ ha_cache_ttl: int = 30
+
+ # --- Vikunja Tasks ---
+ vikunja_url: str = "http://10.10.10.10:3456/api/v1"
+ vikunja_token: str = ""
+ vikunja_cache_ttl: int = 60
+
+ # --- Unraid Servers ---
+ unraid_servers: List[UnraidServer] = field(default_factory=list)
+ unraid_cache_ttl: int = 15
+
+ # --- News ---
+ news_cache_ttl: int = 300 # 5 min
+ news_max_age_hours: int = 48
+
+ # --- Server ---
+ host: str = "0.0.0.0"
+ port: int = 8080
+ debug: bool = False
+
+ @classmethod
+ def from_env(cls) -> "Settings":
+ s = cls()
+ s.db_host = os.getenv("DB_HOST", s.db_host)
+ s.db_port = int(os.getenv("DB_PORT", str(s.db_port)))
+ s.db_name = os.getenv("DB_NAME", s.db_name)
+ s.db_user = os.getenv("DB_USER", s.db_user)
+ s.db_password = os.getenv("DB_PASSWORD", s.db_password)
+
+ s.weather_location = os.getenv("WEATHER_LOCATION", s.weather_location)
+ s.weather_location_secondary = os.getenv(
+ "WEATHER_LOCATION_SECONDARY", s.weather_location_secondary
+ )
+
+ s.ha_url = os.getenv("HA_URL", s.ha_url)
+ s.ha_token = os.getenv("HA_TOKEN", s.ha_token)
+
+ s.vikunja_url = os.getenv("VIKUNJA_URL", s.vikunja_url)
+ s.vikunja_token = os.getenv("VIKUNJA_TOKEN", s.vikunja_token)
+
+ s.debug = os.getenv("DEBUG", "").lower() in ("1", "true", "yes")
+
+ # Parse UNRAID_SERVERS JSON
+ raw = os.getenv("UNRAID_SERVERS", "[]")
+ try:
+ servers_data = json.loads(raw)
+ s.unraid_servers = [
+ UnraidServer(
+ name=srv.get("name", f"Server {i+1}"),
+ host=srv.get("host", ""),
+ api_key=srv.get("api_key", ""),
+ port=int(srv.get("port", 80)),
+ )
+ for i, srv in enumerate(servers_data)
+ if srv.get("host")
+ ]
+ except (json.JSONDecodeError, TypeError):
+ s.unraid_servers = []
+
+ return s
+
+
+settings = Settings.from_env()
diff --git a/server/main.py b/server/main.py
new file mode 100644
index 0000000..df49131
--- /dev/null
+++ b/server/main.py
@@ -0,0 +1,89 @@
+"""Daily Briefing Dashboard — FastAPI Application."""
+
+from __future__ import annotations
+
+import logging
+from contextlib import asynccontextmanager
+from pathlib import Path
+
+from fastapi import FastAPI
+from fastapi.middleware.cors import CORSMiddleware
+from fastapi.staticfiles import StaticFiles
+
+from server.config import settings
+from server.services import news_service
+
+logger = logging.getLogger("daily-briefing")
+logging.basicConfig(
+ level=logging.DEBUG if settings.debug else logging.INFO,
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
+)
+
+
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+ """Startup / shutdown lifecycle."""
+ logger.info("Starting Daily Briefing Dashboard...")
+ logger.info(
+ "Unraid servers configured: %d",
+ len(settings.unraid_servers),
+ )
+
+ # Initialize database pool
+ try:
+ await news_service.init_pool(
+ host=settings.db_host,
+ port=settings.db_port,
+ dbname=settings.db_name,
+ user=settings.db_user,
+ password=settings.db_password,
+ )
+ logger.info("Database pool initialized")
+ except Exception:
+ logger.exception("Failed to initialize database pool — news will be unavailable")
+
+ yield
+
+ # Shutdown
+ logger.info("Shutting down...")
+ await news_service.close_pool()
+
+
+app = FastAPI(
+ title="Daily Briefing",
+ version="2.0.0",
+ lifespan=lifespan,
+)
+
+# CORS — allow frontend dev server
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=["*"],
+ allow_credentials=True,
+ allow_methods=["*"],
+ allow_headers=["*"],
+)
+
+# --- Register Routers ---
+from server.routers import dashboard, homeassistant, news, servers, tasks, weather # noqa: E402
+
+app.include_router(weather.router)
+app.include_router(news.router)
+app.include_router(servers.router)
+app.include_router(homeassistant.router)
+app.include_router(tasks.router)
+app.include_router(dashboard.router)
+
+# --- Serve static frontend (production) ---
+static_dir = Path(__file__).parent.parent / "static"
+if static_dir.is_dir():
+ app.mount("/", StaticFiles(directory=str(static_dir), html=True), name="static")
+ logger.info("Serving static frontend from %s", static_dir)
+else:
+ @app.get("/")
+ async def root():
+ return {
+ "status": "ok",
+ "message": "Daily Briefing API — Frontend not built yet",
+ "endpoints": ["/api/all", "/api/weather", "/api/news", "/api/servers", "/api/ha", "/api/tasks"],
+ }
diff --git a/server/routers/__init__.py b/server/routers/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/server/routers/dashboard.py b/server/routers/dashboard.py
new file mode 100644
index 0000000..c10f9ce
--- /dev/null
+++ b/server/routers/dashboard.py
@@ -0,0 +1,123 @@
+"""Dashboard aggregate router -- combined endpoint and WebSocket push."""
+
+from __future__ import annotations
+
+import asyncio
+import json
+import logging
+from datetime import datetime, timezone
+from typing import Any, Dict, List
+
+from fastapi import APIRouter, WebSocket, WebSocketDisconnect
+
+from server.routers.homeassistant import get_ha
+from server.routers.news import get_news_articles
+from server.routers.servers import get_servers
+from server.routers.tasks import get_tasks
+from server.routers.weather import get_weather
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(tags=["dashboard"])
+
+# Connected WebSocket clients
+clients: List[WebSocket] = []
+
+
+@router.get("/api/all")
+async def get_all() -> Dict[str, Any]:
+ """Fetch every data source in parallel and return a single combined dict.
+
+ Response shape::
+
+ {
+ "weather": { ... },
+ "news": { ... },
+ "servers": { ... },
+ "ha": { ... },
+ "tasks": { ... },
+ "timestamp": "ISO-8601 string"
+ }
+
+ Individual sections that fail will contain ``{"error": true, "message": "..."}``.
+ """
+
+ results = await asyncio.gather(
+ _safe(get_weather, "weather"),
+ _safe(lambda: get_news_articles(limit=20, offset=0, category=None), "news"),
+ _safe(get_servers, "servers"),
+ _safe(get_ha, "ha"),
+ _safe(get_tasks, "tasks"),
+ )
+
+ weather_data, news_data, servers_data, ha_data, tasks_data = results
+
+ return {
+ "weather": weather_data,
+ "news": news_data,
+ "servers": servers_data,
+ "ha": ha_data,
+ "tasks": tasks_data,
+ "timestamp": datetime.now(timezone.utc).isoformat(),
+ }
+
+
+@router.websocket("/ws")
+async def ws_endpoint(ws: WebSocket) -> None:
+ """WebSocket that pushes fresh dashboard data on every client ping.
+
+ The client should send periodic text messages (e.g. ``"ping"``) to request
+ an update. If no message arrives within 20 seconds the server sends a
+ refresh anyway, keeping the connection alive.
+ """
+
+ await ws.accept()
+ clients.append(ws)
+ logger.info("WebSocket client connected (%d total)", len(clients))
+
+ try:
+ while True:
+ # Wait for a client ping / keepalive; refresh on timeout too.
+ try:
+ _msg = await asyncio.wait_for(ws.receive_text(), timeout=20.0)
+ except asyncio.TimeoutError:
+ pass
+
+ # Build and push the latest data
+ try:
+ data = await get_all()
+ await ws.send_json(data)
+ except Exception as exc:
+ logger.exception("Error sending WebSocket payload")
+ # Try to send a lightweight error frame; if that also fails the
+ # outer handler will close the connection.
+ try:
+ await ws.send_json({"error": True, "message": str(exc)})
+ except Exception:
+ break
+
+ except WebSocketDisconnect:
+ logger.info("WebSocket client disconnected")
+ except Exception as exc:
+ logger.exception("Unexpected WebSocket error")
+ finally:
+ if ws in clients:
+ clients.remove(ws)
+ logger.info("WebSocket clients remaining: %d", len(clients))
+
+
+# -- internal helpers ---------------------------------------------------------
+
+async def _safe(coro_or_callable: Any, label: str) -> Dict[str, Any]:
+ """Call an async function and return its result, or an error dict."""
+ try:
+ if asyncio.iscoroutinefunction(coro_or_callable):
+ return await coro_or_callable()
+ # Support lambdas that return coroutines
+ result = coro_or_callable()
+ if asyncio.iscoroutine(result):
+ return await result
+ return result
+ except Exception as exc:
+ logger.exception("Failed to fetch %s data for dashboard", label)
+ return {"error": True, "message": str(exc)}
diff --git a/server/routers/homeassistant.py b/server/routers/homeassistant.py
new file mode 100644
index 0000000..6e8f750
--- /dev/null
+++ b/server/routers/homeassistant.py
@@ -0,0 +1,47 @@
+"""Home Assistant data router."""
+
+from __future__ import annotations
+
+import logging
+from typing import Any, Dict
+
+from fastapi import APIRouter
+
+from server.cache import cache
+from server.config import settings
+from server.services.ha_service import fetch_ha_data
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/api", tags=["homeassistant"])
+
+CACHE_KEY = "ha"
+
+
+@router.get("/ha")
+async def get_ha() -> Dict[str, Any]:
+ """Return Home Assistant entity data.
+
+ The exact shape depends on what ``fetch_ha_data`` returns; on failure an
+ error stub is returned instead::
+
+ { "error": true, "message": "..." }
+ """
+
+ # --- cache hit? -----------------------------------------------------------
+ cached = await cache.get(CACHE_KEY)
+ if cached is not None:
+ return cached
+
+ # --- cache miss -----------------------------------------------------------
+ try:
+ data: Dict[str, Any] = await fetch_ha_data(
+ settings.ha_url,
+ settings.ha_token,
+ )
+ except Exception as exc:
+ logger.exception("Failed to fetch Home Assistant data")
+ return {"error": True, "message": str(exc)}
+
+ await cache.set(CACHE_KEY, data, settings.ha_cache_ttl)
+ return data
diff --git a/server/routers/news.py b/server/routers/news.py
new file mode 100644
index 0000000..46c47fe
--- /dev/null
+++ b/server/routers/news.py
@@ -0,0 +1,80 @@
+"""News articles router -- paginated, filterable by category."""
+
+from __future__ import annotations
+
+import logging
+from typing import Any, Dict, List, Optional
+
+from fastapi import APIRouter, Query
+
+from server.cache import cache
+from server.config import settings
+from server.services.news_service import get_news, get_news_count
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/api", tags=["news"])
+
+
+def _cache_key(limit: int, offset: int, category: Optional[str]) -> str:
+ return f"news:{limit}:{offset}:{category}"
+
+
+@router.get("/news")
+async def get_news_articles(
+ limit: int = Query(default=20, le=50, ge=1),
+ offset: int = Query(default=0, ge=0),
+ category: Optional[str] = Query(default=None),
+) -> Dict[str, Any]:
+ """Return a paginated list of news articles.
+
+ Response shape::
+
+ {
+ "articles": [ ... ],
+ "total": int,
+ "limit": int,
+ "offset": int,
+ }
+ """
+
+ key = _cache_key(limit, offset, category)
+
+ # --- cache hit? -----------------------------------------------------------
+ cached = await cache.get(key)
+ if cached is not None:
+ return cached
+
+ # --- cache miss -----------------------------------------------------------
+ articles: List[Dict[str, Any]] = []
+ total: int = 0
+
+ try:
+ articles = await get_news(limit=limit, offset=offset, category=category, max_age_hours=settings.news_max_age_hours)
+ except Exception as exc:
+ logger.exception("Failed to fetch news articles")
+ return {
+ "articles": [],
+ "total": 0,
+ "limit": limit,
+ "offset": offset,
+ "error": True,
+ "message": str(exc),
+ }
+
+ try:
+ total = await get_news_count(max_age_hours=settings.news_max_age_hours, category=category)
+ except Exception as exc:
+ logger.exception("Failed to fetch news count")
+ # We still have articles -- return them with total = len(articles)
+ total = len(articles)
+
+ payload: Dict[str, Any] = {
+ "articles": articles,
+ "total": total,
+ "limit": limit,
+ "offset": offset,
+ }
+
+ await cache.set(key, payload, settings.news_cache_ttl)
+ return payload
diff --git a/server/routers/servers.py b/server/routers/servers.py
new file mode 100644
index 0000000..d1f0caa
--- /dev/null
+++ b/server/routers/servers.py
@@ -0,0 +1,64 @@
+"""Unraid servers status router."""
+
+from __future__ import annotations
+
+import logging
+from typing import Any, Dict, List
+
+from fastapi import APIRouter
+
+from server.cache import cache
+from server.config import settings
+from server.services.unraid_service import ServerConfig, fetch_all_servers
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/api", tags=["servers"])
+
+CACHE_KEY = "servers"
+
+
+@router.get("/servers")
+async def get_servers() -> Dict[str, Any]:
+ """Return status information for all configured Unraid servers.
+
+ Response shape::
+
+ {
+ "servers": [ ... server dicts ... ]
+ }
+ """
+
+ # --- cache hit? -----------------------------------------------------------
+ cached = await cache.get(CACHE_KEY)
+ if cached is not None:
+ return cached
+
+ # --- cache miss -----------------------------------------------------------
+ server_configs: List[ServerConfig] = [
+ ServerConfig(
+ name=srv.name,
+ host=srv.host,
+ api_key=srv.api_key,
+ port=srv.port,
+ )
+ for srv in settings.unraid_servers
+ ]
+
+ servers_data: List[Dict[str, Any]] = []
+ try:
+ servers_data = await fetch_all_servers(server_configs)
+ except Exception as exc:
+ logger.exception("Failed to fetch Unraid server data")
+ return {
+ "servers": [],
+ "error": True,
+ "message": str(exc),
+ }
+
+ payload: Dict[str, Any] = {
+ "servers": servers_data,
+ }
+
+ await cache.set(CACHE_KEY, payload, settings.unraid_cache_ttl)
+ return payload
diff --git a/server/routers/tasks.py b/server/routers/tasks.py
new file mode 100644
index 0000000..366ae40
--- /dev/null
+++ b/server/routers/tasks.py
@@ -0,0 +1,47 @@
+"""Vikunja tasks router."""
+
+from __future__ import annotations
+
+import logging
+from typing import Any, Dict
+
+from fastapi import APIRouter
+
+from server.cache import cache
+from server.config import settings
+from server.services.vikunja_service import fetch_tasks
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/api", tags=["tasks"])
+
+CACHE_KEY = "tasks"
+
+
+@router.get("/tasks")
+async def get_tasks() -> Dict[str, Any]:
+ """Return Vikunja task data.
+
+ The exact shape depends on what ``fetch_tasks`` returns; on failure an
+ error stub is returned instead::
+
+ { "error": true, "message": "..." }
+ """
+
+ # --- cache hit? -----------------------------------------------------------
+ cached = await cache.get(CACHE_KEY)
+ if cached is not None:
+ return cached
+
+ # --- cache miss -----------------------------------------------------------
+ try:
+ data: Dict[str, Any] = await fetch_tasks(
+ settings.vikunja_url,
+ settings.vikunja_token,
+ )
+ except Exception as exc:
+ logger.exception("Failed to fetch Vikunja tasks")
+ return {"error": True, "message": str(exc)}
+
+ await cache.set(CACHE_KEY, data, settings.vikunja_cache_ttl)
+ return data
diff --git a/server/routers/weather.py b/server/routers/weather.py
new file mode 100644
index 0000000..24d742a
--- /dev/null
+++ b/server/routers/weather.py
@@ -0,0 +1,85 @@
+"""Weather data router -- primary + secondary locations and hourly forecast."""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+from typing import Any, Dict, List
+
+from fastapi import APIRouter
+
+from server.cache import cache
+from server.config import settings
+from server.services.weather_service import fetch_hourly_forecast, fetch_weather
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/api", tags=["weather"])
+
+CACHE_KEY = "weather"
+
+
+@router.get("/weather")
+async def get_weather() -> Dict[str, Any]:
+ """Return weather for both configured locations plus an hourly forecast.
+
+ The response shape is::
+
+ {
+ "primary": { ... weather dict or error stub },
+ "secondary": { ... weather dict or error stub },
+ "hourly": [ ... forecast entries or empty list ],
+ }
+ """
+
+ # --- cache hit? -----------------------------------------------------------
+ cached = await cache.get(CACHE_KEY)
+ if cached is not None:
+ return cached
+
+ # --- cache miss -- fetch all three in parallel ----------------------------
+ primary_data: Dict[str, Any] = {}
+ secondary_data: Dict[str, Any] = {}
+ hourly_data: List[Dict[str, Any]] = []
+
+ results = await asyncio.gather(
+ _safe_fetch_weather(settings.weather_location),
+ _safe_fetch_weather(settings.weather_location_secondary),
+ _safe_fetch_hourly(settings.weather_location),
+ return_exceptions=False, # we handle errors inside the helpers
+ )
+
+ primary_data = results[0]
+ secondary_data = results[1]
+ hourly_data = results[2]
+
+ payload: Dict[str, Any] = {
+ "primary": primary_data,
+ "secondary": secondary_data,
+ "hourly": hourly_data,
+ }
+
+ await cache.set(CACHE_KEY, payload, settings.weather_cache_ttl)
+ return payload
+
+
+# -- internal helpers ---------------------------------------------------------
+
+async def _safe_fetch_weather(location: str) -> Dict[str, Any]:
+ """Fetch weather for *location*, returning an error stub on failure."""
+ try:
+ data = await fetch_weather(location)
+ return data
+ except Exception as exc:
+ logger.exception("Failed to fetch weather for %s", location)
+ return {"error": True, "message": str(exc), "location": location}
+
+
+async def _safe_fetch_hourly(location: str) -> List[Dict[str, Any]]:
+ """Fetch hourly forecast for *location*, returning ``[]`` on failure."""
+ try:
+ data = await fetch_hourly_forecast(location)
+ return data
+ except Exception as exc:
+ logger.exception("Failed to fetch hourly forecast for %s", location)
+ return []
diff --git a/server/services/__init__.py b/server/services/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/server/services/ha_service.py b/server/services/ha_service.py
new file mode 100644
index 0000000..dd1bba6
--- /dev/null
+++ b/server/services/ha_service.py
@@ -0,0 +1,149 @@
+from __future__ import annotations
+
+import httpx
+from typing import Any, Dict, List, Optional
+
+
+def _friendly_name(entity: Dict[str, Any]) -> str:
+ """Extract the friendly name from an entity's attributes, falling back to entity_id."""
+ attrs = entity.get("attributes", {})
+ return attrs.get("friendly_name", entity.get("entity_id", "unknown"))
+
+
+def _parse_light(entity: Dict[str, Any]) -> Dict[str, Any]:
+ """Parse a light entity into a normalised dictionary."""
+ attrs = entity.get("attributes", {})
+ state = entity.get("state", "unknown")
+ brightness_raw = attrs.get("brightness")
+ brightness_pct: Optional[int] = None
+ if brightness_raw is not None:
+ try:
+ brightness_pct = round(int(brightness_raw) / 255 * 100)
+ except (ValueError, TypeError):
+ brightness_pct = None
+
+ return {
+ "entity_id": entity.get("entity_id", ""),
+ "name": _friendly_name(entity),
+ "state": state,
+ "brightness": brightness_pct,
+ "color_mode": attrs.get("color_mode"),
+ }
+
+
+def _parse_cover(entity: Dict[str, Any]) -> Dict[str, Any]:
+ """Parse a cover entity into a normalised dictionary."""
+ attrs = entity.get("attributes", {})
+ return {
+ "entity_id": entity.get("entity_id", ""),
+ "name": _friendly_name(entity),
+ "state": entity.get("state", "unknown"),
+ "current_position": attrs.get("current_position"),
+ }
+
+
+def _parse_sensor(entity: Dict[str, Any]) -> Dict[str, Any]:
+ """Parse a temperature sensor entity into a normalised dictionary."""
+ attrs = entity.get("attributes", {})
+ state_value = entity.get("state", "unknown")
+ try:
+ state_value = round(float(state_value), 1)
+ except (ValueError, TypeError):
+ pass
+
+ return {
+ "entity_id": entity.get("entity_id", ""),
+ "name": _friendly_name(entity),
+ "state": state_value,
+ "unit": attrs.get("unit_of_measurement", ""),
+ "device_class": attrs.get("device_class", ""),
+ }
+
+
+async def fetch_ha_data(url: str, token: str) -> Dict[str, Any]:
+ """Fetch and categorise entity states from a Home Assistant instance.
+
+ Args:
+ url: Base URL of the Home Assistant instance (e.g. ``http://192.168.1.100:8123``).
+ token: Long-lived access token for authentication.
+
+ Returns:
+ Dictionary containing:
+ - ``online``: Whether the HA instance is reachable.
+ - ``lights``: List of light entities with state and brightness.
+ - ``covers``: List of cover entities with state and position.
+ - ``sensors``: List of temperature sensor entities.
+ - ``lights_on``: Count of lights currently in the ``on`` state.
+ - ``lights_total``: Total number of light entities.
+ - ``error``: Error message if the request failed, else ``None``.
+ """
+ result: Dict[str, Any] = {
+ "online": False,
+ "lights": [],
+ "covers": [],
+ "sensors": [],
+ "lights_on": 0,
+ "lights_total": 0,
+ "error": None,
+ }
+
+ if not url or not token:
+ result["error"] = "Missing Home Assistant URL or token"
+ return result
+
+ base_url = url.rstrip("/")
+ headers = {
+ "Authorization": f"Bearer {token}",
+ "Content-Type": "application/json",
+ }
+
+ try:
+ async with httpx.AsyncClient(timeout=15, verify=False) as client:
+ resp = await client.get(f"{base_url}/api/states", headers=headers)
+ resp.raise_for_status()
+ entities: List[Dict[str, Any]] = resp.json()
+ except httpx.HTTPStatusError as exc:
+ result["error"] = f"HTTP {exc.response.status_code}"
+ return result
+ except httpx.RequestError as exc:
+ result["error"] = f"Connection failed: {exc}"
+ return result
+ except Exception as exc:
+ result["error"] = str(exc)
+ return result
+
+ result["online"] = True
+
+ lights: List[Dict[str, Any]] = []
+ covers: List[Dict[str, Any]] = []
+ sensors: List[Dict[str, Any]] = []
+
+ for entity in entities:
+ entity_id: str = entity.get("entity_id", "")
+ domain = entity_id.split(".")[0] if "." in entity_id else ""
+ attrs = entity.get("attributes", {})
+ state = entity.get("state", "")
+
+ if state in ("unavailable", "unknown"):
+ continue
+
+ if domain == "light":
+ lights.append(_parse_light(entity))
+
+ elif domain == "cover":
+ covers.append(_parse_cover(entity))
+
+ elif domain == "sensor":
+ device_class = attrs.get("device_class", "")
+ if device_class == "temperature":
+ sensors.append(_parse_sensor(entity))
+
+ lights_on = sum(1 for light in lights if light["state"] == "on")
+
+ result["lights"] = lights
+ result["covers"] = covers
+ result["sensors"] = sensors
+ result["lights_on"] = lights_on
+ result["lights_total"] = len(lights)
+
+ return result
diff --git a/server/services/news_service.py b/server/services/news_service.py
new file mode 100644
index 0000000..7ec4f6f
--- /dev/null
+++ b/server/services/news_service.py
@@ -0,0 +1,153 @@
+from __future__ import annotations
+
+import asyncpg
+from typing import Any, Dict, List, Optional
+
+_pool: Optional[asyncpg.Pool] = None
+
+
+async def init_pool(
+ host: str,
+ port: int,
+ dbname: str,
+ user: str,
+ password: str,
+) -> None:
+ """Initialise the global asyncpg connection pool.
+
+ Call once at application startup.
+ """
+ global _pool
+ _pool = await asyncpg.create_pool(
+ host=host,
+ port=port,
+ database=dbname,
+ user=user,
+ password=password,
+ min_size=1,
+ max_size=5,
+ )
+
+
+async def close_pool() -> None:
+ """Close the global asyncpg connection pool.
+
+ Call once at application shutdown.
+ """
+ global _pool
+ if _pool is not None:
+ await _pool.close()
+ _pool = None
+
+
+def _row_to_dict(row: asyncpg.Record) -> Dict[str, Any]:
+ """Convert an asyncpg Record to a plain dictionary with JSON-safe values."""
+ d: Dict[str, Any] = dict(row)
+ if "published_at" in d and d["published_at"] is not None:
+ d["published_at"] = d["published_at"].isoformat()
+ return d
+
+
+async def get_news(
+ limit: int = 20,
+ offset: int = 0,
+ category: Optional[str] = None,
+ max_age_hours: int = 48,
+) -> List[Dict[str, Any]]:
+ """Fetch recent news articles from the market_news table.
+
+ Args:
+ limit: Maximum number of rows to return.
+ offset: Number of rows to skip (for pagination).
+ category: Optional category filter (exact match).
+ max_age_hours: Only return articles published within this many hours.
+
+ Returns:
+ List of news article dictionaries.
+ """
+ if _pool is None:
+ raise RuntimeError("Database pool is not initialised. Call init_pool() first.")
+
+ params: List[Any] = []
+ param_idx = 1
+
+ base_query = (
+ "SELECT id, source, title, url, category, published_at "
+ "FROM market_news "
+ f"WHERE published_at > NOW() - INTERVAL '{int(max_age_hours)} hours'"
+ )
+
+ if category is not None:
+ base_query += f" AND category = ${param_idx}"
+ params.append(category)
+ param_idx += 1
+
+ base_query += f" ORDER BY published_at DESC LIMIT ${param_idx} OFFSET ${param_idx + 1}"
+ params.append(limit)
+ params.append(offset)
+
+ async with _pool.acquire() as conn:
+ rows = await conn.fetch(base_query, *params)
+
+ return [_row_to_dict(row) for row in rows]
+
+
+async def get_news_count(
+ max_age_hours: int = 48,
+ category: Optional[str] = None,
+) -> int:
+ """Return the total count of recent news articles.
+
+ Args:
+ max_age_hours: Only count articles published within this many hours.
+ category: Optional category filter.
+
+ Returns:
+ Integer count.
+ """
+ if _pool is None:
+ raise RuntimeError("Database pool is not initialised. Call init_pool() first.")
+
+ params: List[Any] = []
+ param_idx = 1
+
+ query = (
+ "SELECT COUNT(*) AS cnt "
+ "FROM market_news "
+ f"WHERE published_at > NOW() - INTERVAL '{int(max_age_hours)} hours'"
+ )
+
+ if category is not None:
+ query += f" AND category = ${param_idx}"
+ params.append(category)
+
+ async with _pool.acquire() as conn:
+ row = await conn.fetchrow(query, *params)
+
+ return int(row["cnt"]) if row else 0
+
+
+async def get_categories(max_age_hours: int = 48) -> List[str]:
+ """Return distinct categories from recent news articles.
+
+ Args:
+ max_age_hours: Only consider articles published within this many hours.
+
+ Returns:
+ Sorted list of category strings.
+ """
+ if _pool is None:
+ raise RuntimeError("Database pool is not initialised. Call init_pool() first.")
+
+ query = (
+ "SELECT DISTINCT category "
+ "FROM market_news "
+ f"WHERE published_at > NOW() - INTERVAL '{int(max_age_hours)} hours' "
+ "AND category IS NOT NULL "
+ "ORDER BY category"
+ )
+
+ async with _pool.acquire() as conn:
+ rows = await conn.fetch(query)
+
+ return [row["category"] for row in rows]
diff --git a/server/services/unraid_service.py b/server/services/unraid_service.py
new file mode 100644
index 0000000..8f0eaa8
--- /dev/null
+++ b/server/services/unraid_service.py
@@ -0,0 +1,233 @@
+from __future__ import annotations
+
+import asyncio
+import httpx
+from dataclasses import dataclass, field
+from typing import Any, Dict, List, Optional
+
+
+@dataclass
+class ServerConfig:
+ """Configuration for a single Unraid server."""
+
+ name: str
+ host: str
+ api_key: str = ""
+ port: int = 80
+
+
+def _empty_stats(server: ServerConfig) -> Dict[str, Any]:
+ """Return a default stats dictionary for a server that has not yet been queried."""
+ return {
+ "name": server.name,
+ "host": server.host,
+ "online": False,
+ "uptime": "",
+ "cpu": {"usage_pct": 0, "cores": 0, "temp_c": None},
+ "ram": {"used_gb": 0, "total_gb": 0, "pct": 0},
+ "array": {"status": "unknown", "disks": []},
+ "docker": {"running": 0, "containers": []},
+ "error": None,
+ }
+
+
+def _parse_system_info(data: Dict[str, Any], result: Dict[str, Any]) -> None:
+ """Populate *result* from a generic ``/api/system`` JSON response."""
+ result["online"] = True
+ result["uptime"] = data.get("uptime", "")
+
+ cpu_data = data.get("cpu", {})
+ result["cpu"]["usage_pct"] = cpu_data.get("usage_pct", cpu_data.get("usage", 0))
+ result["cpu"]["cores"] = cpu_data.get("cores", 0)
+ result["cpu"]["temp_c"] = cpu_data.get("temp_c", cpu_data.get("temp", None))
+
+ ram_data = data.get("ram", data.get("memory", {}))
+ result["ram"]["used_gb"] = round(ram_data.get("used_gb", ram_data.get("used", 0)), 2)
+ result["ram"]["total_gb"] = round(ram_data.get("total_gb", ram_data.get("total", 0)), 2)
+ total = result["ram"]["total_gb"]
+ if total > 0:
+ result["ram"]["pct"] = round(result["ram"]["used_gb"] / total * 100, 1)
+ else:
+ result["ram"]["pct"] = 0
+
+
+def _parse_array_info(data: Dict[str, Any], result: Dict[str, Any]) -> None:
+ """Populate array information from an API response."""
+ array_data = data.get("array", {})
+ result["array"]["status"] = array_data.get("status", "unknown")
+
+ disks_raw: List[Dict[str, Any]] = array_data.get("disks", [])
+ parsed_disks: List[Dict[str, Any]] = []
+ for disk in disks_raw:
+ parsed_disks.append({
+ "name": disk.get("name", ""),
+ "status": disk.get("status", "unknown"),
+ "size": disk.get("size", ""),
+ "used": disk.get("used", ""),
+ "temp_c": disk.get("temp_c", None),
+ })
+ result["array"]["disks"] = parsed_disks
+
+
+def _parse_docker_info(data: Dict[str, Any], result: Dict[str, Any]) -> None:
+ """Populate Docker container information from an API response."""
+ docker_data = data.get("docker", {})
+ containers_raw: List[Dict[str, Any]] = docker_data.get("containers", [])
+
+ containers: List[Dict[str, Any]] = []
+ running_count = 0
+ for container in containers_raw:
+ status = container.get("status", "unknown")
+ is_running = "running" in status.lower() if isinstance(status, str) else False
+ if is_running:
+ running_count += 1
+ containers.append({
+ "name": container.get("name", ""),
+ "status": status,
+ "image": container.get("image", ""),
+ "running": is_running,
+ })
+
+ result["docker"]["running"] = docker_data.get("running", running_count)
+ result["docker"]["containers"] = containers
+
+
+async def _try_api_endpoint(
+ client: httpx.AsyncClient,
+ server: ServerConfig,
+ result: Dict[str, Any],
+) -> bool:
+ """Attempt to fetch stats via the Unraid OS API.
+
+ Returns True if successful, False otherwise.
+ """
+ if not server.api_key:
+ return False
+
+ headers = {"Authorization": f"Bearer {server.api_key}"}
+ base = f"http://{server.host}:{server.port}"
+
+ try:
+ resp = await client.get(f"{base}/api/system", headers=headers)
+ if resp.status_code == 200:
+ data = resp.json()
+ _parse_system_info(data, result)
+ _parse_array_info(data, result)
+ _parse_docker_info(data, result)
+ return True
+ except Exception:
+ pass
+
+ # Try individual endpoints if the combined one failed
+ fetched_any = False
+
+ try:
+ resp = await client.get(f"{base}/api/cpu", headers=headers)
+ if resp.status_code == 200:
+ cpu_data = resp.json()
+ result["cpu"]["usage_pct"] = cpu_data.get("usage_pct", cpu_data.get("usage", 0))
+ result["cpu"]["cores"] = cpu_data.get("cores", 0)
+ result["cpu"]["temp_c"] = cpu_data.get("temp_c", None)
+ result["online"] = True
+ fetched_any = True
+ except Exception:
+ pass
+
+ try:
+ resp = await client.get(f"{base}/api/memory", headers=headers)
+ if resp.status_code == 200:
+ ram_data = resp.json()
+ result["ram"]["used_gb"] = round(ram_data.get("used_gb", ram_data.get("used", 0)), 2)
+ result["ram"]["total_gb"] = round(ram_data.get("total_gb", ram_data.get("total", 0)), 2)
+ total = result["ram"]["total_gb"]
+ if total > 0:
+ result["ram"]["pct"] = round(result["ram"]["used_gb"] / total * 100, 1)
+ result["online"] = True
+ fetched_any = True
+ except Exception:
+ pass
+
+ try:
+ resp = await client.get(f"{base}/api/array", headers=headers)
+ if resp.status_code == 200:
+ _parse_array_info(resp.json(), result)
+ result["online"] = True
+ fetched_any = True
+ except Exception:
+ pass
+
+ try:
+ resp = await client.get(f"{base}/api/docker", headers=headers)
+ if resp.status_code == 200:
+ _parse_docker_info(resp.json(), result)
+ result["online"] = True
+ fetched_any = True
+ except Exception:
+ pass
+
+ return fetched_any
+
+
+async def _try_connectivity_check(
+ client: httpx.AsyncClient,
+ server: ServerConfig,
+ result: Dict[str, Any],
+) -> None:
+ """Perform a basic HTTP connectivity check as a fallback."""
+ try:
+ resp = await client.get(
+ f"http://{server.host}:{server.port}/",
+ follow_redirects=True,
+ )
+ result["online"] = resp.status_code < 500
+ except Exception:
+ result["online"] = False
+
+
+async def fetch_server_stats(server: ServerConfig) -> Dict[str, Any]:
+ """Fetch system stats from an Unraid server.
+
+ Tries the Unraid API first (if ``api_key`` is configured), then falls back
+ to a simple HTTP connectivity check.
+
+ Args:
+ server: A :class:`ServerConfig` describing the target server.
+
+ Returns:
+ Dictionary with server name, host, online status, and detailed stats
+ for CPU, RAM, array, and Docker containers.
+ """
+ result = _empty_stats(server)
+
+ if not server.host:
+ result["error"] = "No host configured"
+ return result
+
+ try:
+ async with httpx.AsyncClient(timeout=10, verify=False) as client:
+ api_ok = await _try_api_endpoint(client, server, result)
+
+ if not api_ok and not result["online"]:
+ await _try_connectivity_check(client, server, result)
+
+ except Exception as exc:
+ result["online"] = False
+ result["error"] = str(exc)
+
+ return result
+
+
+async def fetch_all_servers(servers: List[ServerConfig]) -> List[Dict[str, Any]]:
+ """Fetch stats from all configured Unraid servers in parallel.
+
+ Args:
+ servers: List of :class:`ServerConfig` instances.
+
+ Returns:
+ List of stats dictionaries, one per server.
+ """
+ if not servers:
+ return []
+
+ tasks = [fetch_server_stats(srv) for srv in servers]
+ return list(await asyncio.gather(*tasks))
diff --git a/server/services/vikunja_service.py b/server/services/vikunja_service.py
new file mode 100644
index 0000000..410a61a
--- /dev/null
+++ b/server/services/vikunja_service.py
@@ -0,0 +1,215 @@
+from __future__ import annotations
+
+import asyncio
+import httpx
+from typing import Any, Dict, List, Optional
+
+# Project ID groupings
+PRIVATE_PROJECTS: List[int] = [3, 4] # Haus & Garten, Jugendeinrichtung
+SAMS_PROJECTS: List[int] = [2, 5] # OpenClaw AI, Sam's Wunderwelt
+
+# Readable names for known project IDs
+PROJECT_NAMES: Dict[int, str] = {
+ 2: "OpenClaw AI",
+ 3: "Haus & Garten",
+ 4: "Jugendeinrichtung",
+ 5: "Sam's Wunderwelt",
+}
+
+
+def _parse_task(task: Dict[str, Any], project_id: int) -> Dict[str, Any]:
+ """Normalise a raw Vikunja task into a simplified dictionary."""
+ return {
+ "id": task.get("id", 0),
+ "title": task.get("title", ""),
+ "done": bool(task.get("done", False)),
+ "priority": task.get("priority", 0),
+ "project_id": project_id,
+ "project_name": PROJECT_NAMES.get(project_id, f"Project {project_id}"),
+ "due_date": task.get("due_date") or None,
+ "created": task.get("created") or None,
+ "updated": task.get("updated") or None,
+ "labels": [
+ label.get("title", "")
+ for label in (task.get("labels") or [])
+ if label.get("title")
+ ],
+ }
+
+
+async def _fetch_project_tasks(
+ client: httpx.AsyncClient,
+ base_url: str,
+ project_id: int,
+) -> List[Dict[str, Any]]:
+ """Fetch all tasks for a single Vikunja project.
+
+ Args:
+ client: An authenticated httpx.AsyncClient.
+ base_url: Vikunja API base URL.
+ project_id: The project ID to query.
+
+ Returns:
+ List of parsed task dictionaries.
+ """
+ all_tasks: List[Dict[str, Any]] = []
+ page = 1
+ per_page = 50
+
+ while True:
+ try:
+ resp = await client.get(
+ f"{base_url}/projects/{project_id}/tasks",
+ params={"page": page, "per_page": per_page},
+ )
+ resp.raise_for_status()
+ tasks_page: List[Dict[str, Any]] = resp.json()
+ except Exception:
+ break
+
+ if not tasks_page:
+ break
+
+ for raw_task in tasks_page:
+ all_tasks.append(_parse_task(raw_task, project_id))
+
+ if len(tasks_page) < per_page:
+ break
+ page += 1
+
+ return all_tasks
+
+
+def _sort_and_split(
+ tasks: List[Dict[str, Any]],
+) -> Dict[str, Any]:
+ """Split tasks into open/done buckets and sort by priority descending."""
+ open_tasks = sorted(
+ [t for t in tasks if not t["done"]],
+ key=lambda t: t["priority"],
+ reverse=True,
+ )
+ done_tasks = sorted(
+ [t for t in tasks if t["done"]],
+ key=lambda t: t["priority"],
+ reverse=True,
+ )
+ return {
+ "open": open_tasks,
+ "done": done_tasks,
+ "open_count": len(open_tasks),
+ "done_count": len(done_tasks),
+ }
+
+
+async def fetch_tasks(base_url: str, token: str) -> Dict[str, Any]:
+ """Fetch tasks from all configured Vikunja projects.
+
+ Groups tasks into ``private`` (PRIVATE_PROJECTS) and ``sams`` (SAMS_PROJECTS).
+
+ Args:
+ base_url: Vikunja instance base URL (e.g. ``https://tasks.example.com``).
+ token: API token for Vikunja authentication.
+
+ Returns:
+ Dictionary with ``private`` and ``sams`` keys, each containing
+ ``open``, ``done``, ``open_count``, and ``done_count``.
+ """
+ result: Dict[str, Any] = {
+ "private": {"open": [], "done": [], "open_count": 0, "done_count": 0},
+ "sams": {"open": [], "done": [], "open_count": 0, "done_count": 0},
+ "error": None,
+ }
+
+ if not base_url or not token:
+ result["error"] = "Missing Vikunja base URL or token"
+ return result
+
+ clean_url = base_url.rstrip("/")
+ headers = {
+ "Authorization": f"Bearer {token}",
+ "Content-Type": "application/json",
+ }
+
+ try:
+ async with httpx.AsyncClient(
+ timeout=15,
+ headers=headers,
+ ) as client:
+ all_project_ids = list(set(PRIVATE_PROJECTS + SAMS_PROJECTS))
+ coros = [
+ _fetch_project_tasks(client, clean_url, pid)
+ for pid in all_project_ids
+ ]
+ results_by_project = await asyncio.gather(*coros, return_exceptions=True)
+
+ project_tasks_map: Dict[int, List[Dict[str, Any]]] = {}
+ for pid, tasks_or_exc in zip(all_project_ids, results_by_project):
+ if isinstance(tasks_or_exc, Exception):
+ project_tasks_map[pid] = []
+ else:
+ project_tasks_map[pid] = tasks_or_exc
+
+ private_tasks: List[Dict[str, Any]] = []
+ for pid in PRIVATE_PROJECTS:
+ private_tasks.extend(project_tasks_map.get(pid, []))
+
+ sams_tasks: List[Dict[str, Any]] = []
+ for pid in SAMS_PROJECTS:
+ sams_tasks.extend(project_tasks_map.get(pid, []))
+
+ result["private"] = _sort_and_split(private_tasks)
+ result["sams"] = _sort_and_split(sams_tasks)
+
+ except httpx.HTTPStatusError as exc:
+ result["error"] = f"HTTP {exc.response.status_code}"
+ except httpx.RequestError as exc:
+ result["error"] = f"Connection failed: {exc}"
+ except Exception as exc:
+ result["error"] = str(exc)
+
+ return result
+
+
+async def fetch_single_project(
+ base_url: str,
+ token: str,
+ project_id: int,
+) -> Dict[str, Any]:
+ """Fetch tasks for a single Vikunja project.
+
+ Args:
+ base_url: Vikunja instance base URL.
+ token: API token for authentication.
+ project_id: The project ID to query.
+
+ Returns:
+ Dictionary with ``open``, ``done``, ``open_count``, ``done_count``, and ``error``.
+ """
+ result: Dict[str, Any] = {
+ "open": [],
+ "done": [],
+ "open_count": 0,
+ "done_count": 0,
+ "error": None,
+ }
+
+ if not base_url or not token:
+ result["error"] = "Missing Vikunja base URL or token"
+ return result
+
+ clean_url = base_url.rstrip("/")
+ headers = {
+ "Authorization": f"Bearer {token}",
+ "Content-Type": "application/json",
+ }
+
+ try:
+ async with httpx.AsyncClient(timeout=15, headers=headers) as client:
+ tasks = await _fetch_project_tasks(client, clean_url, project_id)
+ split = _sort_and_split(tasks)
+ result.update(split)
+ except Exception as exc:
+ result["error"] = str(exc)
+
+ return result
diff --git a/server/services/weather_service.py b/server/services/weather_service.py
new file mode 100644
index 0000000..65b2a9f
--- /dev/null
+++ b/server/services/weather_service.py
@@ -0,0 +1,234 @@
+from __future__ import annotations
+
+import httpx
+from typing import Any, Dict, List, Optional
+
+WEATHER_ICONS: Dict[int, str] = {
+ 113: "\u2600\ufe0f", # Clear/Sunny
+ 116: "\u26c5", # Partly Cloudy
+ 119: "\u2601\ufe0f", # Cloudy
+ 122: "\u2601\ufe0f", # Overcast
+ 143: "\ud83c\udf2b\ufe0f", # Mist
+ 176: "\ud83c\udf26\ufe0f", # Patchy rain nearby
+ 179: "\ud83c\udf28\ufe0f", # Patchy snow nearby
+ 182: "\ud83c\udf28\ufe0f", # Patchy sleet nearby
+ 185: "\ud83c\udf28\ufe0f", # Patchy freezing drizzle nearby
+ 200: "\u26c8\ufe0f", # Thundery outbreaks nearby
+ 227: "\ud83c\udf28\ufe0f", # Blowing snow
+ 230: "\u2744\ufe0f", # Blizzard
+ 248: "\ud83c\udf2b\ufe0f", # Fog
+ 260: "\ud83c\udf2b\ufe0f", # Freezing fog
+ 263: "\ud83c\udf26\ufe0f", # Patchy light drizzle
+ 266: "\ud83c\udf27\ufe0f", # Light drizzle
+ 281: "\ud83c\udf28\ufe0f", # Freezing drizzle
+ 284: "\ud83c\udf28\ufe0f", # Heavy freezing drizzle
+ 293: "\ud83c\udf26\ufe0f", # Patchy light rain
+ 296: "\ud83c\udf27\ufe0f", # Light rain
+ 299: "\ud83c\udf27\ufe0f", # Moderate rain at times
+ 302: "\ud83c\udf27\ufe0f", # Moderate rain
+ 305: "\ud83c\udf27\ufe0f", # Heavy rain at times
+ 308: "\ud83c\udf27\ufe0f", # Heavy rain
+ 311: "\ud83c\udf28\ufe0f", # Light freezing rain
+ 314: "\ud83c\udf28\ufe0f", # Moderate or heavy freezing rain
+ 317: "\ud83c\udf28\ufe0f", # Light sleet
+ 320: "\ud83c\udf28\ufe0f", # Moderate or heavy sleet
+ 323: "\ud83c\udf28\ufe0f", # Patchy light snow
+ 326: "\u2744\ufe0f", # Light snow
+ 329: "\u2744\ufe0f", # Patchy moderate snow
+ 332: "\u2744\ufe0f", # Moderate snow
+ 335: "\u2744\ufe0f", # Patchy heavy snow
+ 338: "\u2744\ufe0f", # Heavy snow
+ 350: "\ud83c\udf28\ufe0f", # Ice pellets
+ 353: "\ud83c\udf26\ufe0f", # Light rain shower
+ 356: "\ud83c\udf27\ufe0f", # Moderate or heavy rain shower
+ 359: "\ud83c\udf27\ufe0f", # Torrential rain shower
+ 362: "\ud83c\udf28\ufe0f", # Light sleet showers
+ 365: "\ud83c\udf28\ufe0f", # Moderate or heavy sleet showers
+ 368: "\u2744\ufe0f", # Light snow showers
+ 371: "\u2744\ufe0f", # Moderate or heavy snow showers
+ 374: "\ud83c\udf28\ufe0f", # Light showers of ice pellets
+ 377: "\ud83c\udf28\ufe0f", # Moderate or heavy showers of ice pellets
+ 386: "\u26c8\ufe0f", # Patchy light rain with thunder
+ 389: "\u26c8\ufe0f", # Moderate or heavy rain with thunder
+ 392: "\u26c8\ufe0f", # Patchy light snow with thunder
+ 395: "\u26c8\ufe0f", # Moderate or heavy snow with thunder
+}
+
+
+def _get_weather_icon(code: int) -> str:
+ """Map a WWO weather code to an emoji icon."""
+ return WEATHER_ICONS.get(code, "\ud83c\udf24\ufe0f")
+
+
+def _parse_current_condition(condition: Dict[str, Any], location: str) -> Dict[str, Any]:
+ """Parse a single current_condition entry from the wttr.in JSON."""
+ weather_code = int(condition.get("weatherCode", 113))
+ descriptions = condition.get("weatherDesc", [])
+ description = descriptions[0].get("value", "Unknown") if descriptions else "Unknown"
+
+ return {
+ "location": location,
+ "temp": int(condition.get("temp_C", 0)),
+ "feels_like": int(condition.get("FeelsLikeC", 0)),
+ "humidity": int(condition.get("humidity", 0)),
+ "wind_kmh": int(condition.get("windspeedKmph", 0)),
+ "description": description,
+ "icon": _get_weather_icon(weather_code),
+ }
+
+
+def _parse_forecast_day(day: Dict[str, Any]) -> Dict[str, Any]:
+ """Parse a single forecast day from the wttr.in weather array."""
+ date = day.get("date", "")
+ max_temp = int(day.get("maxtempC", 0))
+ min_temp = int(day.get("mintempC", 0))
+
+ astronomy = day.get("astronomy", [])
+ sunrise = astronomy[0].get("sunrise", "") if astronomy else ""
+ sunset = astronomy[0].get("sunset", "") if astronomy else ""
+
+ hourly = day.get("hourly", [])
+ if hourly:
+ midday = hourly[len(hourly) // 2]
+ weather_code = int(midday.get("weatherCode", 113))
+ descs = midday.get("weatherDesc", [])
+ description = descs[0].get("value", "Unknown") if descs else "Unknown"
+ else:
+ weather_code = 113
+ description = "Unknown"
+
+ return {
+ "date": date,
+ "max_temp": max_temp,
+ "min_temp": min_temp,
+ "icon": _get_weather_icon(weather_code),
+ "description": description,
+ "sunrise": sunrise,
+ "sunset": sunset,
+ }
+
+
+async def fetch_weather(location: str) -> Dict[str, Any]:
+ """Fetch current weather and 3-day forecast from wttr.in.
+
+ Args:
+ location: City name or coordinates (e.g. "Berlin" or "52.52,13.405").
+
+ Returns:
+ Dictionary with current conditions and 3-day forecast.
+ """
+ fallback: Dict[str, Any] = {
+ "location": location,
+ "temp": 0,
+ "feels_like": 0,
+ "humidity": 0,
+ "wind_kmh": 0,
+ "description": "Unavailable",
+ "icon": "\u2753",
+ "forecast_3day": [],
+ "error": None,
+ }
+
+ try:
+ async with httpx.AsyncClient(timeout=10) as client:
+ resp = await client.get(
+ f"https://wttr.in/{location}",
+ params={"format": "j1"},
+ headers={"Accept": "application/json"},
+ )
+ resp.raise_for_status()
+ data = resp.json()
+ except httpx.HTTPStatusError as exc:
+ fallback["error"] = f"HTTP {exc.response.status_code}"
+ return fallback
+ except httpx.RequestError as exc:
+ fallback["error"] = f"Request failed: {exc}"
+ return fallback
+ except Exception as exc:
+ fallback["error"] = str(exc)
+ return fallback
+
+ current_conditions = data.get("current_condition", [])
+ if not current_conditions:
+ fallback["error"] = "No current condition data"
+ return fallback
+
+ result = _parse_current_condition(current_conditions[0], location)
+
+ weather_days = data.get("weather", [])
+ forecast_3day: List[Dict[str, Any]] = []
+ for day in weather_days[:3]:
+ forecast_3day.append(_parse_forecast_day(day))
+
+ result["forecast_3day"] = forecast_3day
+ result["error"] = None
+ return result
+
+
+async def fetch_hourly_forecast(location: str) -> List[Dict[str, Any]]:
+ """Fetch hourly forecast for the current day from wttr.in.
+
+ Returns the next 8 hourly slots from the current day's forecast.
+
+ Args:
+ location: City name or coordinates.
+
+ Returns:
+ List of hourly forecast dicts with time, temp, icon, and precip_chance.
+ """
+ try:
+ async with httpx.AsyncClient(timeout=10) as client:
+ resp = await client.get(
+ f"https://wttr.in/{location}",
+ params={"format": "j1"},
+ headers={"Accept": "application/json"},
+ )
+ resp.raise_for_status()
+ data = resp.json()
+ except Exception:
+ return []
+
+ weather_days = data.get("weather", [])
+ if not weather_days:
+ return []
+
+ all_hourly: List[Dict[str, Any]] = []
+ for day in weather_days[:2]:
+ hourly_entries = day.get("hourly", [])
+ for entry in hourly_entries:
+ time_raw = entry.get("time", "0")
+ time_value = int(time_raw)
+ hours = time_value // 100
+ minutes = time_value % 100
+ time_str = f"{hours:02d}:{minutes:02d}"
+
+ weather_code = int(entry.get("weatherCode", 113))
+ descs = entry.get("weatherDesc", [])
+ description = descs[0].get("value", "Unknown") if descs else "Unknown"
+
+ all_hourly.append({
+ "time": time_str,
+ "temp": int(entry.get("tempC", 0)),
+ "icon": _get_weather_icon(weather_code),
+ "description": description,
+ "precip_chance": int(entry.get("chanceofrain", 0)),
+ "wind_kmh": int(entry.get("windspeedKmph", 0)),
+ })
+
+ from datetime import datetime
+
+ now_hour = datetime.now().hour
+ upcoming: List[Dict[str, Any]] = []
+ found_start = False
+ for slot in all_hourly:
+ slot_hour = int(slot["time"].split(":")[0])
+ if not found_start:
+ if slot_hour >= now_hour:
+ found_start = True
+ else:
+ continue
+ upcoming.append(slot)
+ if len(upcoming) >= 8:
+ break
+
+ return upcoming
diff --git a/src/discord_bridge.py b/src/discord_bridge.py
deleted file mode 100644
index 9e1e9e9..0000000
--- a/src/discord_bridge.py
+++ /dev/null
@@ -1,141 +0,0 @@
-"""
-Discord Bridge for Dashboard Chat
-Sends messages from Dashboard to Discord and relays responses back
-"""
-import asyncio
-import httpx
-import os
-import time
-from datetime import datetime
-from typing import Optional, Dict, Any, Callable
-
-DISCORD_WEBHOOK_URL = os.getenv("DISCORD_WEBHOOK_URL", "")
-DISCORD_BOT_TOKEN = os.getenv("DISCORD_BOT_TOKEN", "")
-DISCORD_CHANNEL_ID = os.getenv("DISCORD_CHANNEL_ID", "")
-
-# Store pending messages waiting for responses
-pending_messages: Dict[str, Any] = {}
-message_callbacks: Dict[str, Callable] = {}
-
-
-async def send_to_discord(message: str, username: str = "Dashboard", msg_id: str = "") -> bool:
- """Send message to Discord via Webhook"""
- if not DISCORD_WEBHOOK_URL:
- print("No DISCORD_WEBHOOK_URL configured")
- return False
-
- try:
- async with httpx.AsyncClient() as client:
- response = await client.post(
- DISCORD_WEBHOOK_URL,
- json={
- "content": f"📱 **Dashboard:** {message}\n\n[MsgID:{msg_id}]",
- "username": username,
- "avatar_url": "https://cdn.discordapp.com/emojis/1064969270828195921.webp"
- },
- timeout=10.0
- )
-
- if response.status_code == 204:
- # Store pending message
- pending_messages[msg_id] = {
- "timestamp": time.time(),
- "content": message,
- "responded": False
- }
- return True
- else:
- print(f"Discord webhook returned {response.status_code}: {response.text}")
- return False
-
- except Exception as e:
- print(f"Discord webhook error: {e}")
- return False
-
-
-async def check_discord_responses() -> Optional[Dict[str, str]]:
- """Check for new responses in Discord channel (requires bot token)"""
- if not DISCORD_BOT_TOKEN or not DISCORD_CHANNEL_ID:
- return None
-
- try:
- async with httpx.AsyncClient() as client:
- response = await client.get(
- f"https://discord.com/api/v10/channels/{DISCORD_CHANNEL_ID}/messages?limit=10",
- headers={"Authorization": f"Bot {DISCORD_BOT_TOKEN}"},
- timeout=10.0
- )
-
- if response.status_code == 200:
- messages = response.json()
-
- for msg in messages:
- # Skip if it's a dashboard message itself
- if msg.get("author", {}).get("username") == "Dashboard":
- continue
-
- # Check if this is a reply
- referenced = msg.get("referenced_message")
- if referenced:
- ref_content = referenced.get("content", "")
- # Extract MsgID from referenced message
- if "[MsgID:" in ref_content:
- import re
- match = re.search(r'\[MsgID:([^\]]+)\]', ref_content)
- if match:
- msg_id = match.group(1)
- if msg_id in pending_messages and not pending_messages[msg_id]["responded"]:
- pending_messages[msg_id]["responded"] = True
- return {
- "msg_id": msg_id,
- "content": msg.get("content", ""),
- "author": msg.get("author", {}).get("username", "Unknown")
- }
-
- return None
-
- except Exception as e:
- print(f"Discord check error: {e}")
- return None
-
-
-async def poll_discord_responses(callback: Callable[[str, str], None], interval: int = 5):
- """Continuously poll for Discord responses"""
- while True:
- await asyncio.sleep(interval)
-
- # Cleanup old messages periodically
- cleanup_old_messages()
-
- response = await check_discord_responses()
- if response:
- msg_id = response["msg_id"]
- content = response["content"]
-
- # Call the callback with response
- if msg_id in message_callbacks:
- try:
- await message_callbacks[msg_id](content)
- except Exception as e:
- print(f"Callback error for {msg_id}: {e}")
- finally:
- if msg_id in message_callbacks:
- del message_callbacks[msg_id]
-
-
-def register_callback(msg_id: str, callback: Callable):
- """Register a callback for a message response"""
- message_callbacks[msg_id] = callback
-
-
-def cleanup_old_messages(max_age: int = 3600):
- """Remove old pending messages"""
- current_time = time.time()
- to_remove = [
- msg_id for msg_id, data in pending_messages.items()
- if current_time - data["timestamp"] > max_age
- ]
- for msg_id in to_remove:
- del pending_messages[msg_id]
- if msg_id in message_callbacks:
- del message_callbacks[msg_id]
diff --git a/src/main.py b/src/main.py
deleted file mode 100644
index f1c032a..0000000
--- a/src/main.py
+++ /dev/null
@@ -1,803 +0,0 @@
-from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect
-from fastapi.staticfiles import StaticFiles
-from fastapi.templating import Jinja2Templates
-from fastapi.responses import JSONResponse
-from pydantic import BaseModel
-import httpx
-import os
-import json
-import asyncio
-import psutil
-from datetime import datetime, timedelta
-from typing import Optional, Dict, Any, List
-import time
-
-# Import Discord Bridge
-try:
- from discord_bridge import send_to_discord, poll_discord_responses, register_callback, cleanup_old_messages
- DISCORD_AVAILABLE = True
-except ImportError:
- DISCORD_AVAILABLE = False
- print("Warning: discord_bridge not available")
-
-# Chat models
-class ChatMessage(BaseModel):
- message: str
-
-app = FastAPI(title="Daily Briefing")
-
-# Static files and templates
-app.mount("/static", StaticFiles(directory="static"), name="static")
-templates = Jinja2Templates(directory="templates")
-
-# Config
-VIKUNJA_URL = os.getenv("VIKUNJA_URL", "http://10.10.10.10:3456/api/v1")
-VIKUNJA_TOKEN = os.getenv("VIKUNJA_TOKEN", "")
-HA_URL = os.getenv("HA_URL", "https://homeassistant.daddelolymp.de")
-HA_TOKEN = os.getenv("HA_TOKEN", "")
-WEATHER_LOCATION = os.getenv("WEATHER_LOCATION", "Leverkusen")
-WEATHER_LOCATION_SECONDARY = os.getenv("WEATHER_LOCATION_SECONDARY", "Rab,Croatia")
-DASHBOARD_DATA_PATH = os.getenv("DASHBOARD_DATA_PATH", "/app/data/dashboard_data.json")
-
-# Caching
-class Cache:
- def __init__(self):
- self.data: Dict[str, Any] = {}
- self.timestamps: Dict[str, float] = {}
- self.ttl: Dict[str, int] = {
- "weather": 3600,
- "weather_secondary": 3600,
- "ha": 30,
- "vikunja": 30,
- "system": 10,
- "briefing_data": 300, # 5 minutes for news/hourly weather
- }
-
- def get(self, key: str) -> Optional[Any]:
- if key in self.data:
- age = time.time() - self.timestamps.get(key, 0)
- if age < self.ttl.get(key, 0):
- return self.data[key]
- return None
-
- def set(self, key: str, value: Any):
- self.data[key] = value
- self.timestamps[key] = time.time()
-
-cache = Cache()
-
-# WebSocket connections
-class ConnectionManager:
- def __init__(self):
- self.active_connections: list[WebSocket] = []
-
- async def connect(self, websocket: WebSocket):
- await websocket.accept()
- self.active_connections.append(websocket)
-
- def disconnect(self, websocket: WebSocket):
- if websocket in self.active_connections:
- self.active_connections.remove(websocket)
-
- async def broadcast(self, message: dict):
- for connection in self.active_connections.copy():
- try:
- await connection.send_json(message)
- except:
- pass
-
-manager = ConnectionManager()
-
-# Simple in-memory chat storage (resets on restart)
-chat_messages: List[Dict[str, Any]] = []
-MAX_CHAT_HISTORY = 50
-
-@app.get("/")
-async def dashboard(request: Request):
- """Main dashboard view"""
- briefing_data = await get_briefing_data()
- data = {
- "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M"),
- "weather": await get_weather(),
- "weather_secondary": await get_weather_secondary(),
- "ha_status": await get_homeassistant_status(),
- "vikunja_all": await get_vikunja_all_tasks(),
- "system_status": await get_system_status(),
- "news": briefing_data.get("news", []),
- "hourly_weather": briefing_data.get("hourly_weather", {}),
- }
- return templates.TemplateResponse("dashboard.html", {
- "request": request,
- **data
- })
-
-@app.get("/api/all")
-async def api_all():
- """Get all data at once"""
- weather, weather_secondary, ha, vikunja, system, briefing_data = await asyncio.gather(
- get_weather(),
- get_weather_secondary(),
- get_homeassistant_status(),
- get_vikunja_all_tasks(),
- get_system_status(),
- get_briefing_data()
- )
- return {
- "timestamp": datetime.now().isoformat(),
- "weather": weather,
- "weather_secondary": weather_secondary,
- "ha_status": ha,
- "vikunja_all": vikunja,
- "system_status": system,
- "news": briefing_data.get("news", []),
- "hourly_weather": briefing_data.get("hourly_weather", {})
- }
-
-async def get_briefing_data() -> dict:
- """Read briefing data (news, hourly weather) from JSON file"""
- cached = cache.get("briefing_data")
- if cached:
- return cached
-
- try:
- if os.path.exists(DASHBOARD_DATA_PATH):
- with open(DASHBOARD_DATA_PATH, "r") as f:
- data = json.load(f)
- cache.set("briefing_data", data)
- return data
- except Exception as e:
- print(f"Error reading briefing data: {e}")
-
- return {"news": [], "hourly_weather": {}}
-
-@app.websocket("/ws")
-async def websocket_endpoint(websocket: WebSocket):
- await manager.connect(websocket)
- try:
- while True:
- data = await websocket.receive_text()
- if data == "ping":
- fresh_data = await api_all()
- await websocket.send_json(fresh_data)
- await asyncio.sleep(1)
- except WebSocketDisconnect:
- manager.disconnect(websocket)
-
-def parse_forecast(weather_data: list) -> list:
- """Parse 3-day forecast from wttr.in data"""
- forecast = []
- days = ["Heute", "Morgen", "Übermorgen"]
-
- for i, day_data in enumerate(weather_data[:3]):
- hourly = day_data.get("hourly", [])
- if hourly:
- # Use midday (12:00) or first available
- midday = hourly[min(4, len(hourly)-1)] if len(hourly) > 4 else hourly[0]
-
- forecast.append({
- "day": days[i] if i < len(days) else day_data.get("date", ""),
- "temp_max": day_data.get("maxtempC", "--"),
- "temp_min": day_data.get("mintempC", "--"),
- "icon": get_weather_icon(midday.get("weatherDesc", [{}])[0].get("value", "")),
- "description": midday.get("weatherDesc", [{}])[0].get("value", "")
- })
- return forecast
-
-async def get_weather() -> dict:
- """Fetch weather for primary location (Leverkusen) with forecast"""
- cached = cache.get("weather")
- if cached:
- cached["cached"] = True
- return cached
-
- try:
- async with httpx.AsyncClient(timeout=10) as client:
- response = await client.get(
- f"https://wttr.in/{WEATHER_LOCATION}?format=j1",
- headers={"User-Agent": "curl/7.68.0"}
- )
- if response.status_code == 200:
- data = response.json()
- current = data["current_condition"][0]
-
- # Parse forecast
- forecast = parse_forecast(data.get("weather", []))
-
- result = {
- "temp": current["temp_C"],
- "feels_like": current["FeelsLikeC"],
- "description": current["weatherDesc"][0]["value"],
- "humidity": current["humidity"],
- "wind": current["windspeedKmph"],
- "icon": get_weather_icon(current["weatherDesc"][0]["value"]),
- "location": WEATHER_LOCATION,
- "forecast": forecast,
- "cached": False
- }
- cache.set("weather", result)
- return result
- except Exception as e:
- print(f"Weather error: {e}")
- return {"error": "Weather unavailable", "location": WEATHER_LOCATION}
-
-async def get_weather_secondary() -> dict:
- """Fetch weather for secondary location (Rab/Banjol) with forecast"""
- cached = cache.get("weather_secondary")
- if cached:
- cached["cached"] = True
- return cached
-
- try:
- async with httpx.AsyncClient(timeout=10) as client:
- response = await client.get(
- f"https://wttr.in/{WEATHER_LOCATION_SECONDARY}?format=j1",
- headers={"User-Agent": "curl/7.68.0"}
- )
- if response.status_code == 200:
- data = response.json()
- current = data["current_condition"][0]
-
- # Parse forecast
- forecast = parse_forecast(data.get("weather", []))
-
- result = {
- "temp": current["temp_C"],
- "feels_like": current["FeelsLikeC"],
- "description": current["weatherDesc"][0]["value"],
- "humidity": current["humidity"],
- "wind": current["windspeedKmph"],
- "icon": get_weather_icon(current["weatherDesc"][0]["value"]),
- "location": "Rab/Banjol",
- "forecast": forecast,
- "cached": False
- }
- cache.set("weather_secondary", result)
- return result
- except Exception as e:
- print(f"Weather secondary error: {e}")
- return {"error": "Weather unavailable", "location": "Rab/Banjol"}
-
-def get_weather_icon(description: str) -> str:
- """Map weather description to emoji"""
- desc = description.lower()
- if "sun" in desc or "clear" in desc:
- return "☀️"
- elif "cloud" in desc:
- return "☁️"
- elif "rain" in desc or "drizzle" in desc:
- return "🌧️"
- elif "snow" in desc:
- return "🌨️"
- elif "thunder" in desc:
- return "⛈️"
- elif "fog" in desc or "mist" in desc:
- return "🌫️"
- return "🌤️"
-
-async def get_homeassistant_status() -> dict:
- """Fetch Home Assistant status"""
- cached = cache.get("ha")
- if cached:
- cached["cached"] = True
- return cached
-
- try:
- async with httpx.AsyncClient(timeout=5) as client:
- lights_resp = await client.get(
- f"{HA_URL}/api/states",
- headers={"Authorization": f"Bearer {HA_TOKEN}"}
- )
- if lights_resp.status_code == 200:
- states = lights_resp.json()
- lights = []
- for state in states:
- if state["entity_id"].startswith("light."):
- lights.append({
- "name": state["attributes"].get("friendly_name", state["entity_id"]),
- "state": state["state"],
- "brightness": state["attributes"].get("brightness", 0)
- })
-
- covers = []
- for state in states:
- if state["entity_id"].startswith("cover."):
- covers.append({
- "name": state["attributes"].get("friendly_name", state["entity_id"]),
- "state": state["state"]
- })
-
- result = {
- "online": True,
- "lights_on": len([l for l in lights if l["state"] == "on"]),
- "lights_total": len(lights),
- "lights": lights[:5],
- "covers": covers[:3],
- "cached": False
- }
- cache.set("ha", result)
- return result
- except Exception as e:
- print(f"HA error: {e}")
- return {"online": False, "error": "Home Assistant unavailable"}
-
-async def get_vikunja_all_tasks() -> dict:
- """Fetch ALL tasks from ALL projects - separated by owner (private vs Sam's)"""
- cached = cache.get("vikunja_all")
- if cached:
- cached["cached"] = True
- return cached
-
- # Project mapping
- PRIVATE_PROJECT_IDS = [3, 4] # Haus & Garten, Jugendeinrichtung Arbeit
- SAM_PROJECT_IDS = [2, 5] # OpenClaw AI Tasks, Sam's Wunderwelt
-
- try:
- async with httpx.AsyncClient(timeout=15) as client:
- # Get all projects first
- proj_resp = await client.get(
- f"{VIKUNJA_URL}/projects",
- headers={"Authorization": f"Bearer {VIKUNJA_TOKEN}"}
- )
-
- if proj_resp.status_code != 200:
- return {"error": "Could not fetch projects", "private": {"open": [], "done": []}, "sam": {"open": [], "done": []}}
-
- projects = proj_resp.json()
-
- # Separate task lists
- private_open = []
- private_done = []
- sam_open = []
- sam_done = []
-
- for project in projects:
- project_id = project["id"]
- project_name = project["title"]
-
- # Skip if not relevant project
- if project_id not in PRIVATE_PROJECT_IDS and project_id not in SAM_PROJECT_IDS:
- continue
-
- # Get views for this project
- views_resp = await client.get(
- f"{VIKUNJA_URL}/projects/{project_id}/views",
- headers={"Authorization": f"Bearer {VIKUNJA_TOKEN}"}
- )
-
- if views_resp.status_code == 200:
- views = views_resp.json()
- if views:
- view_id = views[0]["id"]
-
- # Get ALL tasks
- tasks_resp = await client.get(
- f"{VIKUNJA_URL}/projects/{project_id}/views/{view_id}/tasks",
- headers={"Authorization": f"Bearer {VIKUNJA_TOKEN}"}
- )
-
- if tasks_resp.status_code == 200:
- tasks = tasks_resp.json()
- for task in tasks:
- task_info = {
- "id": task["id"],
- "title": task["title"],
- "project": project_name,
- "due": task.get("due_date", ""),
- "priority": task.get("priority", 0),
- "project_id": project_id
- }
-
- # Sort into correct bucket
- if project_id in PRIVATE_PROJECT_IDS:
- if task.get("done", False):
- private_done.append(task_info)
- else:
- private_open.append(task_info)
- elif project_id in SAM_PROJECT_IDS:
- if task.get("done", False):
- sam_done.append(task_info)
- else:
- sam_open.append(task_info)
-
- # Sort by priority
- for task_list in [private_open, private_done, sam_open, sam_done]:
- task_list.sort(key=lambda x: x["priority"], reverse=True)
-
- result = {
- "private": {
- "open": private_open,
- "done": private_done,
- "open_count": len(private_open),
- "done_count": len(private_done)
- },
- "sam": {
- "open": sam_open,
- "done": sam_done,
- "open_count": len(sam_open),
- "done_count": len(sam_done)
- },
- "cached": False
- }
- cache.set("vikunja_all", result)
- return result
-
- except Exception as e:
- import traceback
- print(f"Vikunja error: {e}")
- print(traceback.format_exc())
- return {"error": "Vikunja unavailable", "private": {"open": [], "done": [], "open_count": 0, "done_count": 0}, "sam": {"open": [], "done": [], "open_count": 0, "done_count": 0}}
-
-def read_meminfo():
- """Read memory info from /proc/meminfo"""
- try:
- with open('/host/proc/meminfo', 'r') as f:
- lines = f.readlines()
- meminfo = {}
- for line in lines:
- parts = line.split(':')
- if len(parts) == 2:
- key = parts[0].strip()
- value = parts[1].strip().split()[0] # Get number
- meminfo[key] = int(value)
- return meminfo
- except:
- try:
- with open('/proc/meminfo', 'r') as f:
- lines = f.readlines()
- meminfo = {}
- for line in lines:
- parts = line.split(':')
- if len(parts) == 2:
- key = parts[0].strip()
- value = parts[1].strip().split()[0]
- meminfo[key] = int(value)
- return meminfo
- except:
- return None
-
-def read_loadavg():
- """Read load average from /proc/loadavg"""
- try:
- with open('/host/proc/loadavg', 'r') as f:
- return f.read().strip()
- except:
- try:
- with open('/proc/loadavg', 'r') as f:
- return f.read().strip()
- except:
- return None
-
-def get_system_status_sync() -> dict:
- """Get real system status with CPU/RAM (synchronous)"""
- try:
- # Check processes by looking at /proc
- openclaw_running = False
- docker_running = False
- try:
- # Check if openclaw gateway is listening
- import socket
- sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
- sock.settimeout(2)
- result = sock.connect_ex(('localhost', 8080))
- openclaw_running = result == 0
- sock.close()
- except:
- pass
-
- try:
- # Check docker socket
- import os
- docker_running = os.path.exists('/var/run/docker.sock')
- # If socket exists, try a light ping
- if docker_running:
- import socket
- client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
- client.settimeout(2)
- try:
- client.connect('/var/run/docker.sock')
- docker_running = True
- except:
- docker_running = False
- finally:
- client.close()
- except:
- pass
-
- # Get CPU cores
- cpu_cores = psutil.cpu_count() or 2
-
- # Get Load Average
- loadavg_str = read_loadavg()
- if loadavg_str:
- load1 = float(loadavg_str.split()[0])
- # Estimate CPU % from load (simplified)
- cpu_percent = min(100, round((load1 / cpu_cores) * 100, 1))
- else:
- cpu_percent = 0
-
- # Get RAM from /proc/meminfo
- meminfo = read_meminfo()
- if meminfo:
- total_kb = meminfo.get('MemTotal', 0)
- available_kb = meminfo.get('MemAvailable', meminfo.get('MemFree', 0))
- used_kb = total_kb - available_kb
-
- total_gb = round(total_kb / (1024 * 1024), 1)
- used_gb = round(used_kb / (1024 * 1024), 1)
- ram_percent = round((used_kb / total_kb) * 100, 1) if total_kb > 0 else 0
- else:
- total_gb = 0
- used_gb = 0
- ram_percent = 0
-
- return {
- "openclaw": {"running": True, "status": "running"},
- "docker": {"running": True, "status": "running"},
- "cpu": {
- "percent": cpu_percent,
- "cores": cpu_cores,
- "load1": round(load1, 2) if loadavg_str else 0
- },
- "ram": {
- "percent": ram_percent,
- "used_gb": used_gb,
- "total_gb": total_gb
- },
- "briefing_version": "1.2.0-live",
- "cached": False
- }
-
- except Exception as e:
- print(f"System status error: {e}")
- import traceback
- print(traceback.format_exc())
- return {
- "openclaw": {"running": True, "status": "running"},
- "docker": {"running": True, "status": "running"},
- "cpu": {"percent": 0, "cores": 2, "load1": 0},
- "ram": {"percent": 0, "used_gb": 0, "total_gb": 0},
- "error": str(e),
- "briefing_version": "1.2.0-live"
- }
-
-async def get_system_status() -> dict:
- """Get real system status with CPU/RAM"""
- cached = cache.get("system")
- if cached:
- cached["cached"] = True
- return cached
-
- # Run synchronous psutil operations in thread pool
- result = await asyncio.to_thread(get_system_status_sync)
- cache.set("system", result)
- return result
-
-# Chat WebSocket connections
-class ChatConnectionManager:
- def __init__(self):
- self.active_connections: list[WebSocket] = []
-
- async def connect(self, websocket: WebSocket):
- await websocket.accept()
- self.active_connections.append(websocket)
-
- def disconnect(self, websocket: WebSocket):
- if websocket in self.active_connections:
- self.active_connections.remove(websocket)
-
- async def send_to_client(self, websocket: WebSocket, message: dict):
- try:
- await websocket.send_json(message)
- except:
- pass
-
- async def broadcast(self, message: dict):
- for connection in self.active_connections.copy():
- try:
- await connection.send_json(message)
- except:
- pass
-
-chat_manager = ChatConnectionManager()
-pending_chat_responses: Dict[str, Any] = {}
-
-
-@app.websocket("/ws/chat")
-async def chat_websocket_endpoint(websocket: WebSocket):
- """WebSocket for real-time chat"""
- await chat_manager.connect(websocket)
- try:
- # Send chat history
- await websocket.send_json({"type": "history", "messages": chat_messages})
-
- while True:
- data = await websocket.receive_json()
-
- if data.get("type") == "message":
- user_msg = data.get("content", "")
-
- # Store user message
- msg_entry = {
- "id": str(int(time.time() * 1000)),
- "role": "user",
- "content": user_msg,
- "timestamp": datetime.now().isoformat()
- }
- chat_messages.append(msg_entry)
-
- # Keep only last N messages
- if len(chat_messages) > MAX_CHAT_HISTORY:
- chat_messages.pop(0)
-
- # Broadcast to all connected clients
- await chat_manager.broadcast({"type": "message", "message": msg_entry})
-
- # Forward to OpenClaw Gateway
- asyncio.create_task(forward_to_openclaw(msg_entry["id"], user_msg))
-
- except WebSocketDisconnect:
- chat_manager.disconnect(websocket)
- except Exception as e:
- print(f"Chat WebSocket error: {e}")
- chat_manager.disconnect(websocket)
-
-
-async def forward_to_openclaw(msg_id: str, message: str):
- """Forward message to OpenClaw Gateway and/or Discord"""
- gateway_url = os.getenv("OPENCLAW_GATEWAY_URL", "http://host.docker.internal:18789")
- discord_sent = False
- openclaw_sent = False
-
- # Try OpenClaw Gateway first
- try:
- async with httpx.AsyncClient(timeout=60) as client:
- # Option 1: Try OpenClaw Gateway API
- try:
- response = await client.post(
- f"{gateway_url}/api/inject",
- json={
- "text": message,
- "source": "dashboard",
- "reply_to": f"dashboard:{msg_id}"
- },
- timeout=60.0
- )
- if response.status_code == 200:
- openclaw_sent = True
- except Exception as e:
- print(f"OpenClaw inject failed: {e}")
- except Exception as e:
- print(f"OpenClaw connection failed: {e}")
-
- # Send to Discord as backup/alternative
- if DISCORD_AVAILABLE:
- try:
- discord_sent = await send_to_discord(message, "Dashboard", msg_id)
- if discord_sent:
- # Register callback for Discord response
- async def on_discord_response(content: str):
- await add_assistant_response(msg_id, content)
- register_callback(msg_id, on_discord_response)
- except Exception as e:
- print(f"Discord send failed: {e}")
-
- # If neither worked, show error
- if not openclaw_sent and not discord_sent:
- await add_assistant_response(msg_id, "⚠️ Konnte keine Verbindung herstellen. Bitte versuch es später nochmal.")
- elif discord_sent and not openclaw_sent:
- # Discord works but OpenClaw doesn't - show pending message
- pending_chat_responses[msg_id] = {
- "status": "pending_discord",
- "message": message,
- "timestamp": datetime.now().isoformat()
- }
-
-
-async def add_assistant_response(reply_to_id: str, content: str):
- """Add assistant response to chat history"""
- msg_entry = {
- "id": str(int(time.time() * 1000)),
- "role": "assistant",
- "content": content,
- "reply_to": reply_to_id,
- "timestamp": datetime.now().isoformat()
- }
- chat_messages.append(msg_entry)
-
- # Keep only last N messages
- if len(chat_messages) > MAX_CHAT_HISTORY:
- chat_messages.pop(0)
-
- # Remove from pending
- if reply_to_id in pending_chat_responses:
- del pending_chat_responses[reply_to_id]
-
- # Broadcast to all connected clients
- await chat_manager.broadcast({"type": "message", "message": msg_entry})
-
-
-@app.post("/api/chat/webhook")
-async def chat_webhook(request: Request):
- """Webhook for OpenClaw to send responses back"""
- data = await request.json()
-
- reply_to = data.get("reply_to", "")
- content = data.get("text", "")
-
- if reply_to.startswith("dashboard:"):
- msg_id = reply_to.replace("dashboard:", "")
- await add_assistant_response(msg_id, content)
- return {"status": "ok"}
-
- # General message without reply_to
- await add_assistant_response("general", content)
- return {"status": "ok"}
-
-
-@app.post("/api/chat")
-async def api_chat(msg: ChatMessage):
- """HTTP fallback for chat messages"""
- msg_id = str(int(time.time() * 1000))
-
- # Store user message
- chat_messages.append({
- "id": msg_id,
- "role": "user",
- "content": msg.message,
- "timestamp": datetime.now().isoformat()
- })
-
- # Keep only last N messages
- if len(chat_messages) > MAX_CHAT_HISTORY:
- chat_messages.pop(0)
-
- # Forward to OpenClaw (non-blocking)
- asyncio.create_task(forward_to_openclaw(msg_id, msg.message))
-
- return {"status": "accepted", "message_id": msg_id}
-
-
-@app.get("/api/chat/history")
-async def api_chat_history():
- """Get chat history"""
- return {"messages": chat_messages}
-
-
-@app.get("/api/chat/pending/{msg_id}")
-async def api_chat_pending(msg_id: str):
- """Check if a response is pending"""
- if msg_id in pending_chat_responses:
- return {"status": "pending"}
- # Check if response exists in history
- for msg in chat_messages:
- if msg.get("reply_to") == msg_id:
- return {"status": "completed", "message": msg}
- return {"status": "not_found"}
-
-
-# Background task to broadcast updates
-@app.on_event("startup")
-async def startup_event():
- asyncio.create_task(broadcast_updates())
- # Start Discord polling if available
- if DISCORD_AVAILABLE:
- asyncio.create_task(start_discord_polling())
-
-async def start_discord_polling():
- """Start polling for Discord responses"""
- try:
- await poll_discord_responses(lambda msg_id, content: None, interval=5)
- except Exception as e:
- print(f"Discord polling failed: {e}")
-
-async def broadcast_updates():
- """Broadcast updates every 30 seconds"""
- while True:
- await asyncio.sleep(30)
- if manager.active_connections:
- fresh_data = await api_all()
- await manager.broadcast(fresh_data)
-
-if __name__ == "__main__":
- import uvicorn
- uvicorn.run(app, host="0.0.0.0", port=8080)
diff --git a/static/.gitkeep b/static/.gitkeep
deleted file mode 100644
index 8b13789..0000000
--- a/static/.gitkeep
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/templates/dashboard.html b/templates/dashboard.html
deleted file mode 100644
index 602d53d..0000000
--- a/templates/dashboard.html
+++ /dev/null
@@ -1,469 +0,0 @@
-
-
-
-
-
- Daily Dashboard | Live
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Dashboard
-
- LIVE
- ●
-
-
-
-
-
-
-
-
-
--:--:--
-
--. --. ----
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Leverkusen
-
- {% if weather.cached %}
cached{% endif %}
-
- {% if weather.error %}
-
{{ weather.error }}
- {% else %}
-
-
-
{{ weather.temp }}°
-
Gefühlt {{ weather.feels_like }}°
-
-
{{ weather.icon }}
-
-
- {{ weather.description }}
- 💧 {{ weather.humidity }}%
-
- {% endif %}
-
-
-
-
-
-
-
- Rab/Banjol 🇭🇷
-
- {% if weather_secondary.cached %}
cached{% endif %}
-
- {% if weather_secondary.error %}
-
{{ weather_secondary.error }}
- {% else %}
-
-
-
{{ weather_secondary.temp }}°
-
Gefühlt {{ weather_secondary.feels_like }}°
-
-
{{ weather_secondary.icon }}
-
-
- {{ weather_secondary.description }}
- 💧 {{ weather_secondary.humidity }}%
-
- {% endif %}
-
-
-
-
-
-
- Nächste Stunden
-
-
-
-
-
-
-
-
-
-
- Aktuelle Schlagzeilen
-
-
- {% if news %}
- {% for item in news[:12] %}
-
- {% endfor %}
- {% else %}
-
- Keine aktuellen Nachrichten geladen.
-
- {% endif %}
-
-
-
-
-
-
-
-
-
- System Status
-
- {% if system_status.cached %}
cached{% endif %}
-
-
-
-
-
-
-
- CPU ({{ system_status.cpu.cores }} cores)
- {{ system_status.cpu.percent }}%
-
-
-
-
-
-
- RAM
-
- {{ system_status.ram.used_gb }}/{{ system_status.ram.total_gb }} GB ({{ system_status.ram.percent }}%)
-
-
-
-
-
-
-
- v{{ system_status.briefing_version }}
-
-
-
-
-
-
-
-
- Home Assistant
-
- {% if ha_status.cached %}
cached{% endif %}
-
- {% if ha_status.online %}
-
-
- Lampen an
- {{ ha_status.lights_on }}/{{ ha_status.lights_total }}
-
-
- {% for light in ha_status.lights %}
-
- {{ light.name }}
-
- {{ "●" if light.state == 'on' else "○" }}
-
-
- {% endfor %}
-
-
- {% else %}
-
-
- Offline
-
-
{{ ha_status.error }}
- {% endif %}
-
-
-
-
-
-
-
-
-
-
- Private Aufgaben
-
-
- {{ vikunja_all.private.open_count }}
- {% if vikunja_all.cached %}cached{% endif %}
-
-
-
- {% if vikunja_all.private.open %}
- {% for task in vikunja_all.private.open %}
-
- {% endfor %}
- {% else %}
-
Keine offenen Aufgaben
- {% endif %}
-
-
-
-
-
-
-
-
- Sam's Aufgaben
-
-
- {{ vikunja_all.sam.open_count }}
- {% if vikunja_all.cached %}cached{% endif %}
-
-
-
- {% if vikunja_all.sam.open %}
- {% for task in vikunja_all.sam.open %}
-
- {% endfor %}
- {% else %}
-
Keine offenen Aufgaben
- {% endif %}
-
-
-
-
-
-
-
-
diff --git a/web/index.html b/web/index.html
new file mode 100644
index 0000000..b7c2e8b
--- /dev/null
+++ b/web/index.html
@@ -0,0 +1,15 @@
+
+
+
+
+
+ Daily Briefing
+
+
+
+
+
+
+
+
+
diff --git a/web/package-lock.json b/web/package-lock.json
new file mode 100644
index 0000000..9138684
--- /dev/null
+++ b/web/package-lock.json
@@ -0,0 +1,2785 @@
+{
+ "name": "daily-briefing-web",
+ "version": "2.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "daily-briefing-web",
+ "version": "2.0.0",
+ "dependencies": {
+ "lucide-react": "^0.468.0",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1"
+ },
+ "devDependencies": {
+ "@types/react": "^18.3.18",
+ "@types/react-dom": "^18.3.5",
+ "@vitejs/plugin-react": "^4.3.4",
+ "autoprefixer": "^10.4.20",
+ "postcss": "^8.4.49",
+ "tailwindcss": "^3.4.17",
+ "typescript": "^5.7.3",
+ "vite": "^6.0.7"
+ }
+ },
+ "node_modules/@alloc/quick-lru": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
+ "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@babel/code-frame": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
+ "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.28.5",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/compat-data": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz",
+ "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/core": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
+ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@babel/code-frame": "^7.29.0",
+ "@babel/generator": "^7.29.0",
+ "@babel/helper-compilation-targets": "^7.28.6",
+ "@babel/helper-module-transforms": "^7.28.6",
+ "@babel/helpers": "^7.28.6",
+ "@babel/parser": "^7.29.0",
+ "@babel/template": "^7.28.6",
+ "@babel/traverse": "^7.29.0",
+ "@babel/types": "^7.29.0",
+ "@jridgewell/remapping": "^2.3.5",
+ "convert-source-map": "^2.0.0",
+ "debug": "^4.1.0",
+ "gensync": "^1.0.0-beta.2",
+ "json5": "^2.2.3",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/babel"
+ }
+ },
+ "node_modules/@babel/generator": {
+ "version": "7.29.1",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
+ "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.0",
+ "@babel/types": "^7.29.0",
+ "@jridgewell/gen-mapping": "^0.3.12",
+ "@jridgewell/trace-mapping": "^0.3.28",
+ "jsesc": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-compilation-targets": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
+ "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/compat-data": "^7.28.6",
+ "@babel/helper-validator-option": "^7.27.1",
+ "browserslist": "^4.24.0",
+ "lru-cache": "^5.1.1",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-globals": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
+ "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-imports": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
+ "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/traverse": "^7.28.6",
+ "@babel/types": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-transforms": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
+ "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.28.6",
+ "@babel/helper-validator-identifier": "^7.28.5",
+ "@babel/traverse": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-plugin-utils": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz",
+ "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
+ "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
+ "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-option": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
+ "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helpers": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz",
+ "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/template": "^7.28.6",
+ "@babel/types": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz",
+ "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.0"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-self": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz",
+ "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-source": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz",
+ "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/template": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
+ "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.28.6",
+ "@babel/parser": "^7.28.6",
+ "@babel/types": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/traverse": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
+ "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.0",
+ "@babel/generator": "^7.29.0",
+ "@babel/helper-globals": "^7.28.0",
+ "@babel/parser": "^7.29.0",
+ "@babel/template": "^7.28.6",
+ "@babel/types": "^7.29.0",
+ "debug": "^4.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
+ "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.27.1",
+ "@babel/helper-validator-identifier": "^7.28.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
+ "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
+ "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
+ "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
+ "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
+ "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
+ "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
+ "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
+ "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
+ "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
+ "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
+ "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
+ "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
+ "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
+ "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
+ "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
+ "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
+ "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
+ "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
+ "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
+ "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
+ "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@nodelib/fs.scandir": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
+ "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.stat": "2.0.5",
+ "run-parallel": "^1.1.9"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.stat": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
+ "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.walk": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
+ "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.scandir": "2.1.5",
+ "fastq": "^1.6.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.0-beta.27",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
+ "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
+ "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz",
+ "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz",
+ "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz",
+ "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz",
+ "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz",
+ "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz",
+ "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz",
+ "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz",
+ "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz",
+ "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz",
+ "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz",
+ "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz",
+ "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz",
+ "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz",
+ "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz",
+ "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz",
+ "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz",
+ "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz",
+ "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz",
+ "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz",
+ "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz",
+ "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz",
+ "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz",
+ "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz",
+ "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@types/babel__core": {
+ "version": "7.20.5",
+ "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
+ "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.20.7",
+ "@babel/types": "^7.20.7",
+ "@types/babel__generator": "*",
+ "@types/babel__template": "*",
+ "@types/babel__traverse": "*"
+ }
+ },
+ "node_modules/@types/babel__generator": {
+ "version": "7.27.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
+ "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__template": {
+ "version": "7.4.4",
+ "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
+ "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.1.0",
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__traverse": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
+ "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.28.2"
+ }
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/prop-types": {
+ "version": "15.7.15",
+ "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
+ "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/react": {
+ "version": "18.3.28",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz",
+ "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@types/prop-types": "*",
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "18.3.7",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
+ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^18.0.0"
+ }
+ },
+ "node_modules/@vitejs/plugin-react": {
+ "version": "4.7.0",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
+ "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.28.0",
+ "@babel/plugin-transform-react-jsx-self": "^7.27.1",
+ "@babel/plugin-transform-react-jsx-source": "^7.27.1",
+ "@rolldown/pluginutils": "1.0.0-beta.27",
+ "@types/babel__core": "^7.20.5",
+ "react-refresh": "^0.17.0"
+ },
+ "engines": {
+ "node": "^14.18.0 || >=16.0.0"
+ },
+ "peerDependencies": {
+ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
+ }
+ },
+ "node_modules/any-promise": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
+ "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/anymatch": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
+ "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "normalize-path": "^3.0.0",
+ "picomatch": "^2.0.4"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/arg": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
+ "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/autoprefixer": {
+ "version": "10.4.27",
+ "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz",
+ "integrity": "sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/autoprefixer"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "browserslist": "^4.28.1",
+ "caniuse-lite": "^1.0.30001774",
+ "fraction.js": "^5.3.4",
+ "picocolors": "^1.1.1",
+ "postcss-value-parser": "^4.2.0"
+ },
+ "bin": {
+ "autoprefixer": "bin/autoprefixer"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ },
+ "peerDependencies": {
+ "postcss": "^8.1.0"
+ }
+ },
+ "node_modules/baseline-browser-mapping": {
+ "version": "2.10.0",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
+ "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "baseline-browser-mapping": "dist/cli.cjs"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/binary-extensions": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
+ "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/braces": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
+ "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fill-range": "^7.1.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/browserslist": {
+ "version": "4.28.1",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
+ "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "baseline-browser-mapping": "^2.9.0",
+ "caniuse-lite": "^1.0.30001759",
+ "electron-to-chromium": "^1.5.263",
+ "node-releases": "^2.0.27",
+ "update-browserslist-db": "^1.2.0"
+ },
+ "bin": {
+ "browserslist": "cli.js"
+ },
+ "engines": {
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+ }
+ },
+ "node_modules/camelcase-css": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
+ "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001775",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001775.tgz",
+ "integrity": "sha512-s3Qv7Lht9zbVKE9XoTyRG6wVDCKdtOFIjBGg3+Yhn6JaytuNKPIjBMTMIY1AnOH3seL5mvF+x33oGAyK3hVt3A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "CC-BY-4.0"
+ },
+ "node_modules/chokidar": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
+ "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "anymatch": "~3.1.2",
+ "braces": "~3.0.2",
+ "glob-parent": "~5.1.2",
+ "is-binary-path": "~2.1.0",
+ "is-glob": "~4.0.1",
+ "normalize-path": "~3.0.0",
+ "readdirp": "~3.6.0"
+ },
+ "engines": {
+ "node": ">= 8.10.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/chokidar/node_modules/glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/commander": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
+ "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/cssesc": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
+ "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "cssesc": "bin/cssesc"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/didyoumean": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
+ "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/dlv": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
+ "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/electron-to-chromium": {
+ "version": "1.5.302",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz",
+ "integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/esbuild": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
+ "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.25.12",
+ "@esbuild/android-arm": "0.25.12",
+ "@esbuild/android-arm64": "0.25.12",
+ "@esbuild/android-x64": "0.25.12",
+ "@esbuild/darwin-arm64": "0.25.12",
+ "@esbuild/darwin-x64": "0.25.12",
+ "@esbuild/freebsd-arm64": "0.25.12",
+ "@esbuild/freebsd-x64": "0.25.12",
+ "@esbuild/linux-arm": "0.25.12",
+ "@esbuild/linux-arm64": "0.25.12",
+ "@esbuild/linux-ia32": "0.25.12",
+ "@esbuild/linux-loong64": "0.25.12",
+ "@esbuild/linux-mips64el": "0.25.12",
+ "@esbuild/linux-ppc64": "0.25.12",
+ "@esbuild/linux-riscv64": "0.25.12",
+ "@esbuild/linux-s390x": "0.25.12",
+ "@esbuild/linux-x64": "0.25.12",
+ "@esbuild/netbsd-arm64": "0.25.12",
+ "@esbuild/netbsd-x64": "0.25.12",
+ "@esbuild/openbsd-arm64": "0.25.12",
+ "@esbuild/openbsd-x64": "0.25.12",
+ "@esbuild/openharmony-arm64": "0.25.12",
+ "@esbuild/sunos-x64": "0.25.12",
+ "@esbuild/win32-arm64": "0.25.12",
+ "@esbuild/win32-ia32": "0.25.12",
+ "@esbuild/win32-x64": "0.25.12"
+ }
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/fast-glob": {
+ "version": "3.3.3",
+ "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
+ "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.stat": "^2.0.2",
+ "@nodelib/fs.walk": "^1.2.3",
+ "glob-parent": "^5.1.2",
+ "merge2": "^1.3.0",
+ "micromatch": "^4.0.8"
+ },
+ "engines": {
+ "node": ">=8.6.0"
+ }
+ },
+ "node_modules/fast-glob/node_modules/glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/fastq": {
+ "version": "1.20.1",
+ "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
+ "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "reusify": "^1.0.4"
+ }
+ },
+ "node_modules/fill-range": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
+ "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "to-regex-range": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/fraction.js": {
+ "version": "5.3.4",
+ "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
+ "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/rawify"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/gensync": {
+ "version": "1.0.0-beta.2",
+ "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
+ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/glob-parent": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
+ "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
+ "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/is-binary-path": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
+ "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "binary-extensions": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-core-module": {
+ "version": "2.16.1",
+ "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
+ "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-glob": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-extglob": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-number": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.12.0"
+ }
+ },
+ "node_modules/jiti": {
+ "version": "1.21.7",
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
+ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "bin": {
+ "jiti": "bin/jiti.js"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "license": "MIT"
+ },
+ "node_modules/jsesc": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
+ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jsesc": "bin/jsesc"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/json5": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
+ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "json5": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/lilconfig": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
+ "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antonk52"
+ }
+ },
+ "node_modules/lines-and-columns": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
+ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/loose-envify": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
+ "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+ "license": "MIT",
+ "dependencies": {
+ "js-tokens": "^3.0.0 || ^4.0.0"
+ },
+ "bin": {
+ "loose-envify": "cli.js"
+ }
+ },
+ "node_modules/lru-cache": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^3.0.2"
+ }
+ },
+ "node_modules/lucide-react": {
+ "version": "0.468.0",
+ "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.468.0.tgz",
+ "integrity": "sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA==",
+ "license": "ISC",
+ "peerDependencies": {
+ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc"
+ }
+ },
+ "node_modules/merge2": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
+ "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/micromatch": {
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
+ "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "braces": "^3.0.3",
+ "picomatch": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=8.6"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/mz": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
+ "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "any-promise": "^1.0.0",
+ "object-assign": "^4.0.1",
+ "thenify-all": "^1.0.0"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.11",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+ "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/node-releases": {
+ "version": "2.0.27",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
+ "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/normalize-path": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
+ "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-assign": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-hash": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
+ "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/path-parse": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
+ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
+ "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/pify": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
+ "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/pirates": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz",
+ "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.6",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
+ "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "nanoid": "^3.3.11",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/postcss-import": {
+ "version": "15.1.0",
+ "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz",
+ "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "postcss-value-parser": "^4.0.0",
+ "read-cache": "^1.0.0",
+ "resolve": "^1.1.7"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "postcss": "^8.0.0"
+ }
+ },
+ "node_modules/postcss-js": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz",
+ "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "camelcase-css": "^2.0.1"
+ },
+ "engines": {
+ "node": "^12 || ^14 || >= 16"
+ },
+ "peerDependencies": {
+ "postcss": "^8.4.21"
+ }
+ },
+ "node_modules/postcss-load-config": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz",
+ "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "lilconfig": "^3.1.1"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "peerDependencies": {
+ "jiti": ">=1.21.0",
+ "postcss": ">=8.0.9",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "jiti": {
+ "optional": true
+ },
+ "postcss": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/postcss-nested": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz",
+ "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "postcss-selector-parser": "^6.1.1"
+ },
+ "engines": {
+ "node": ">=12.0"
+ },
+ "peerDependencies": {
+ "postcss": "^8.2.14"
+ }
+ },
+ "node_modules/postcss-selector-parser": {
+ "version": "6.1.2",
+ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
+ "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cssesc": "^3.0.0",
+ "util-deprecate": "^1.0.2"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/postcss-value-parser": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
+ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/queue-microtask": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
+ "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/react": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
+ "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
+ "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0",
+ "scheduler": "^0.23.2"
+ },
+ "peerDependencies": {
+ "react": "^18.3.1"
+ }
+ },
+ "node_modules/react-refresh": {
+ "version": "0.17.0",
+ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
+ "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/read-cache": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
+ "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "pify": "^2.3.0"
+ }
+ },
+ "node_modules/readdirp": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
+ "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "picomatch": "^2.2.1"
+ },
+ "engines": {
+ "node": ">=8.10.0"
+ }
+ },
+ "node_modules/resolve": {
+ "version": "1.22.11",
+ "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
+ "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-core-module": "^2.16.1",
+ "path-parse": "^1.0.7",
+ "supports-preserve-symlinks-flag": "^1.0.0"
+ },
+ "bin": {
+ "resolve": "bin/resolve"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/reusify": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
+ "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "iojs": ">=1.0.0",
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
+ "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.8"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.59.0",
+ "@rollup/rollup-android-arm64": "4.59.0",
+ "@rollup/rollup-darwin-arm64": "4.59.0",
+ "@rollup/rollup-darwin-x64": "4.59.0",
+ "@rollup/rollup-freebsd-arm64": "4.59.0",
+ "@rollup/rollup-freebsd-x64": "4.59.0",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.59.0",
+ "@rollup/rollup-linux-arm-musleabihf": "4.59.0",
+ "@rollup/rollup-linux-arm64-gnu": "4.59.0",
+ "@rollup/rollup-linux-arm64-musl": "4.59.0",
+ "@rollup/rollup-linux-loong64-gnu": "4.59.0",
+ "@rollup/rollup-linux-loong64-musl": "4.59.0",
+ "@rollup/rollup-linux-ppc64-gnu": "4.59.0",
+ "@rollup/rollup-linux-ppc64-musl": "4.59.0",
+ "@rollup/rollup-linux-riscv64-gnu": "4.59.0",
+ "@rollup/rollup-linux-riscv64-musl": "4.59.0",
+ "@rollup/rollup-linux-s390x-gnu": "4.59.0",
+ "@rollup/rollup-linux-x64-gnu": "4.59.0",
+ "@rollup/rollup-linux-x64-musl": "4.59.0",
+ "@rollup/rollup-openbsd-x64": "4.59.0",
+ "@rollup/rollup-openharmony-arm64": "4.59.0",
+ "@rollup/rollup-win32-arm64-msvc": "4.59.0",
+ "@rollup/rollup-win32-ia32-msvc": "4.59.0",
+ "@rollup/rollup-win32-x64-gnu": "4.59.0",
+ "@rollup/rollup-win32-x64-msvc": "4.59.0",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/run-parallel": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
+ "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "queue-microtask": "^1.2.2"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.23.2",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
+ "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ }
+ },
+ "node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/sucrase": {
+ "version": "3.35.1",
+ "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
+ "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.2",
+ "commander": "^4.0.0",
+ "lines-and-columns": "^1.1.6",
+ "mz": "^2.7.0",
+ "pirates": "^4.0.1",
+ "tinyglobby": "^0.2.11",
+ "ts-interface-checker": "^0.1.9"
+ },
+ "bin": {
+ "sucrase": "bin/sucrase",
+ "sucrase-node": "bin/sucrase-node"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ }
+ },
+ "node_modules/supports-preserve-symlinks-flag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
+ "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/tailwindcss": {
+ "version": "3.4.19",
+ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
+ "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@alloc/quick-lru": "^5.2.0",
+ "arg": "^5.0.2",
+ "chokidar": "^3.6.0",
+ "didyoumean": "^1.2.2",
+ "dlv": "^1.1.3",
+ "fast-glob": "^3.3.2",
+ "glob-parent": "^6.0.2",
+ "is-glob": "^4.0.3",
+ "jiti": "^1.21.7",
+ "lilconfig": "^3.1.3",
+ "micromatch": "^4.0.8",
+ "normalize-path": "^3.0.0",
+ "object-hash": "^3.0.0",
+ "picocolors": "^1.1.1",
+ "postcss": "^8.4.47",
+ "postcss-import": "^15.1.0",
+ "postcss-js": "^4.0.1",
+ "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0",
+ "postcss-nested": "^6.2.0",
+ "postcss-selector-parser": "^6.1.2",
+ "resolve": "^1.22.8",
+ "sucrase": "^3.35.0"
+ },
+ "bin": {
+ "tailwind": "lib/cli.js",
+ "tailwindcss": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/thenify": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
+ "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "any-promise": "^1.0.0"
+ }
+ },
+ "node_modules/thenify-all": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz",
+ "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "thenify": ">= 3.1.0 < 4"
+ },
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.15",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
+ "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tinyglobby/node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/tinyglobby/node_modules/picomatch": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
+ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/to-regex-range": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+ "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-number": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=8.0"
+ }
+ },
+ "node_modules/ts-interface-checker": {
+ "version": "0.1.13",
+ "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
+ "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/update-browserslist-db": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
+ "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "escalade": "^3.2.0",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "update-browserslist-db": "cli.js"
+ },
+ "peerDependencies": {
+ "browserslist": ">= 4.21.0"
+ }
+ },
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/vite": {
+ "version": "6.4.1",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
+ "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "esbuild": "^0.25.0",
+ "fdir": "^6.4.4",
+ "picomatch": "^4.0.2",
+ "postcss": "^8.5.3",
+ "rollup": "^4.34.9",
+ "tinyglobby": "^0.2.13"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
+ "jiti": ">=1.21.0",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "sass-embedded": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vite/node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vite/node_modules/picomatch": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
+ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/yallist": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+ "dev": true,
+ "license": "ISC"
+ }
+ }
+}
diff --git a/web/package.json b/web/package.json
new file mode 100644
index 0000000..f09a1ca
--- /dev/null
+++ b/web/package.json
@@ -0,0 +1,26 @@
+{
+ "name": "daily-briefing-web",
+ "private": true,
+ "version": "2.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc -b && vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "lucide-react": "^0.468.0",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1"
+ },
+ "devDependencies": {
+ "@types/react": "^18.3.18",
+ "@types/react-dom": "^18.3.5",
+ "@vitejs/plugin-react": "^4.3.4",
+ "autoprefixer": "^10.4.20",
+ "postcss": "^8.4.49",
+ "tailwindcss": "^3.4.17",
+ "typescript": "^5.7.3",
+ "vite": "^6.0.7"
+ }
+}
diff --git a/web/postcss.config.js b/web/postcss.config.js
new file mode 100644
index 0000000..2aa7205
--- /dev/null
+++ b/web/postcss.config.js
@@ -0,0 +1,6 @@
+export default {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+};
diff --git a/web/src/App.tsx b/web/src/App.tsx
new file mode 100644
index 0000000..f08fc85
--- /dev/null
+++ b/web/src/App.tsx
@@ -0,0 +1,171 @@
+import { useDashboard } from "./hooks/useDashboard";
+import Clock from "./components/Clock";
+import WeatherCard from "./components/WeatherCard";
+import HourlyForecast from "./components/HourlyForecast";
+import NewsGrid from "./components/NewsGrid";
+import ServerCard from "./components/ServerCard";
+import HomeAssistant from "./components/HomeAssistant";
+import TasksCard from "./components/TasksCard";
+import { RefreshCw, Wifi, WifiOff, AlertTriangle } from "lucide-react";
+
+export default function App() {
+ const { data, loading, error, connected, refresh } = useDashboard();
+
+ return (
+
+ {/* ---- Error banner ---- */}
+ {error && (
+
+
+
{error}
+
+
+ )}
+
+ {/* ---- Header bar ---- */}
+
+
+ {/* ---- Main content ---- */}
+
+ {loading && !data ? (
+
+ ) : data ? (
+ <>
+ {/* Row 1: Weather cards + Hourly forecast */}
+
+
+ {/* Row 2: Servers + Home Assistant + Tasks */}
+
+ {data.servers.servers.map((srv) => (
+
+ ))}
+
+
+
+
+ {/* Row 3: News (full width) */}
+
+
+ {/* Footer timestamp */}
+
+ >
+ ) : null}
+
+
+ );
+}
+
+/** Small pulsing dot indicating live WebSocket connection. */
+function LiveIndicator({ connected }: { connected: boolean }) {
+ return (
+
+
+
+ {connected && (
+
+ )}
+
+
+ {connected ? "Live" : "Offline"}
+
+ {connected ? (
+
+ ) : (
+
+ )}
+
+ );
+}
+
+/** Skeleton loading state displayed on first load. */
+function LoadingSkeleton() {
+ return (
+
+ {/* Row 1: Weather placeholders */}
+
+
+
+
+
+
+ {/* Row 2: Info cards */}
+
+
+
+
+
+
+
+ {/* Row 3: News */}
+
+
+
+ {Array.from({ length: 8 }).map((_, i) => (
+
+ ))}
+
+
+
+ );
+}
+
+function SkeletonCard({ className = "" }: { className?: string }) {
+ return (
+
+ );
+}
diff --git a/web/src/api.ts b/web/src/api.ts
new file mode 100644
index 0000000..d9be6cb
--- /dev/null
+++ b/web/src/api.ts
@@ -0,0 +1,120 @@
+/** API client for the Daily Briefing backend. */
+
+const API_BASE = "/api";
+
+async function fetchJSON(path: string): Promise {
+ const res = await fetch(`${API_BASE}${path}`);
+ if (!res.ok) throw new Error(`API ${path}: ${res.status}`);
+ return res.json();
+}
+
+// --- Types ---
+
+export interface WeatherData {
+ location: string;
+ temp: number;
+ feels_like: number;
+ humidity: number;
+ wind_kmh: number;
+ description: string;
+ icon: string;
+ error?: boolean;
+ forecast: { date: string; max_temp: number; min_temp: number; icon: string; description: string }[];
+}
+
+export interface HourlySlot {
+ time: string;
+ temp: number;
+ icon: string;
+ precip_chance: number;
+}
+
+export interface WeatherResponse {
+ primary: WeatherData;
+ secondary: WeatherData;
+ hourly: HourlySlot[];
+}
+
+export interface NewsArticle {
+ id: number;
+ source: string;
+ title: string;
+ url: string;
+ category: string | null;
+ published_at: string;
+}
+
+export interface NewsResponse {
+ articles: NewsArticle[];
+ total: number;
+ limit: number;
+ offset: number;
+}
+
+export interface ServerStats {
+ name: string;
+ host: string;
+ online: boolean;
+ uptime: string;
+ cpu: { usage_pct: number; cores: number; temp_c: number | null };
+ ram: { used_gb: number; total_gb: number; pct: number };
+ array: { status: string; disks: { name: string; status: string; size: string; used: string }[] };
+ docker: { running: number; containers: { name: string; status: string; image: string }[] };
+}
+
+export interface ServersResponse {
+ servers: ServerStats[];
+}
+
+export interface HAData {
+ online: boolean;
+ lights: { entity_id: string; name: string; state: string; brightness: number }[];
+ covers: { entity_id: string; name: string; state: string; position: number }[];
+ sensors: { entity_id: string; name: string; state: number; unit: string }[];
+ lights_on: number;
+ lights_total: number;
+ error?: boolean;
+}
+
+export interface Task {
+ id: number;
+ title: string;
+ done: boolean;
+ priority: number;
+ project_name: string;
+ due_date: string | null;
+}
+
+export interface TaskGroup {
+ open: Task[];
+ done: Task[];
+ open_count: number;
+}
+
+export interface TasksResponse {
+ private: TaskGroup;
+ sams: TaskGroup;
+ error?: boolean;
+}
+
+export interface DashboardData {
+ weather: WeatherResponse;
+ news: NewsResponse;
+ servers: ServersResponse;
+ ha: HAData;
+ tasks: TasksResponse;
+ timestamp: string;
+}
+
+// --- Fetch Functions ---
+
+export const fetchWeather = () => fetchJSON("/weather");
+export const fetchNews = (limit = 20, offset = 0, category?: string) => {
+ let q = `/news?limit=${limit}&offset=${offset}`;
+ if (category) q += `&category=${encodeURIComponent(category)}`;
+ return fetchJSON(q);
+};
+export const fetchServers = () => fetchJSON("/servers");
+export const fetchHA = () => fetchJSON("/ha");
+export const fetchTasks = () => fetchJSON("/tasks");
+export const fetchAll = () => fetchJSON("/all");
diff --git a/web/src/components/Clock.tsx b/web/src/components/Clock.tsx
new file mode 100644
index 0000000..e521fa3
--- /dev/null
+++ b/web/src/components/Clock.tsx
@@ -0,0 +1,46 @@
+import { useEffect, useState } from "react";
+import { Clock as ClockIcon } from "lucide-react";
+
+/** Live clock with German-locale date. Updates every second. */
+export default function Clock() {
+ const [now, setNow] = useState(new Date());
+
+ useEffect(() => {
+ const id = setInterval(() => setNow(new Date()), 1_000);
+ return () => clearInterval(id);
+ }, []);
+
+ const hours = now.getHours().toString().padStart(2, "0");
+ const minutes = now.getMinutes().toString().padStart(2, "0");
+ const seconds = now.getSeconds().toString().padStart(2, "0");
+
+ const dateStr = now.toLocaleDateString("de-DE", {
+ weekday: "short",
+ day: "2-digit",
+ month: "long",
+ year: "numeric",
+ });
+
+ return (
+
+
+
+
+
+
+ {/* Time display */}
+
+
+ {hours}:{minutes}
+
+
+ :{seconds}
+
+
+
+ {/* Date display */}
+
{dateStr}
+
+
+ );
+}
diff --git a/web/src/components/HomeAssistant.tsx b/web/src/components/HomeAssistant.tsx
new file mode 100644
index 0000000..5364e40
--- /dev/null
+++ b/web/src/components/HomeAssistant.tsx
@@ -0,0 +1,191 @@
+import { Home, Lightbulb, ArrowUp, ArrowDown, Thermometer, WifiOff, Wifi } from "lucide-react";
+import type { HAData } from "../api";
+
+interface HomeAssistantProps {
+ data: HAData;
+}
+
+export default function HomeAssistant({ data }: HomeAssistantProps) {
+ if (!data) return null;
+
+ if (data.error) {
+ return (
+
+
+
+
+
Home Assistant nicht erreichbar
+
Verbindung fehlgeschlagen
+
+
+
+ );
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
+
+ {data.online && (
+
+ )}
+
+
+ {data.online ? "Online" : "Offline"}
+
+
+
+
+
+ {/* Lights Section */}
+
+
+
+
+
+ Lichter
+
+
+
+ {data.lights_on}/{data.lights_total} an
+
+
+
+ {data.lights.length > 0 ? (
+
+ {data.lights.map((light) => {
+ const isOn = light.state === "on";
+ return (
+
+
+
+ {light.name}
+
+ {isOn && light.brightness > 0 && (
+
+ {Math.round((light.brightness / 255) * 100)}%
+
+ )}
+
+ );
+ })}
+
+ ) : (
+ Keine Lichter konfiguriert
+ )}
+
+
+ {/* Covers Section */}
+ {data.covers.length > 0 && (
+
+
+
+
+ {data.covers.map((cover) => {
+ const isOpen = cover.state === "open";
+ const isClosed = cover.state === "closed";
+ return (
+
+
+ {isOpen ? (
+
+ ) : isClosed ? (
+
+ ) : (
+
+ )}
+
{cover.name}
+
+
+
+ {cover.position > 0 && (
+
+ {cover.position}%
+
+ )}
+
+ {isOpen ? "Offen" : isClosed ? "Zu" : cover.state}
+
+
+
+ );
+ })}
+
+
+ )}
+
+ {/* Temperature Sensors Section */}
+ {data.sensors.length > 0 && (
+
+
+
+
+ Temperaturen
+
+
+
+
+ {data.sensors.map((sensor) => (
+
+ {sensor.name}
+
+ {typeof sensor.state === "number"
+ ? sensor.state.toFixed(1)
+ : sensor.state}
+ {sensor.unit}
+
+
+ ))}
+
+
+ )}
+
+
+ );
+}
diff --git a/web/src/components/HourlyForecast.tsx b/web/src/components/HourlyForecast.tsx
new file mode 100644
index 0000000..21d52ad
--- /dev/null
+++ b/web/src/components/HourlyForecast.tsx
@@ -0,0 +1,93 @@
+import { Droplets } from "lucide-react";
+import type { HourlySlot } from "../api";
+
+interface HourlyForecastProps {
+ slots: HourlySlot[];
+}
+
+export default function HourlyForecast({ slots }: HourlyForecastProps) {
+ if (!slots || slots.length === 0) return null;
+
+ return (
+
+
+ Stundenverlauf
+
+
+ {/* Horizontal scroll container - hidden scrollbar via globals.css */}
+
+ {slots.map((slot, i) => {
+ const hour = formatHour(slot.time);
+ const isNow = i === 0;
+
+ return (
+
+ {/* Time label */}
+
+ {isNow ? "Jetzt" : hour}
+
+
+ {/* Weather icon */}
+
+ {slot.icon}
+
+
+ {/* Temperature */}
+
+ {Math.round(slot.temp)}°
+
+
+ {/* Precipitation bar */}
+ {slot.precip_chance > 0 && (
+
+
+
+ {slot.precip_chance}%
+
+
+ )}
+
+ {/* Precip bar visual */}
+
+
+ );
+ })}
+
+
+ );
+}
+
+/** Extracts "HH:mm" from an ISO time string or "HH:00" format. */
+function formatHour(time: string): string {
+ try {
+ const d = new Date(time);
+ if (isNaN(d.getTime())) return time;
+ return d.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
+ } catch {
+ return time;
+ }
+}
diff --git a/web/src/components/NewsGrid.tsx b/web/src/components/NewsGrid.tsx
new file mode 100644
index 0000000..4b1a9c6
--- /dev/null
+++ b/web/src/components/NewsGrid.tsx
@@ -0,0 +1,144 @@
+import { useState, useMemo } from "react";
+import { ExternalLink } from "lucide-react";
+import type { NewsResponse, NewsArticle } from "../api";
+
+interface NewsGridProps {
+ data: NewsResponse;
+}
+
+const CATEGORIES = [
+ { key: "all", label: "Alle" },
+ { key: "tech", label: "Tech" },
+ { key: "wirtschaft", label: "Wirtschaft" },
+ { key: "politik", label: "Politik" },
+ { key: "allgemein", label: "Allgemein" },
+] as const;
+
+/** Map source names to badge colours. */
+const SOURCE_COLORS: Record = {
+ "heise": "bg-orange-500/20 text-orange-300",
+ "golem": "bg-blue-500/20 text-blue-300",
+ "spiegel": "bg-red-500/20 text-red-300",
+ "tagesschau": "bg-sky-500/20 text-sky-300",
+ "zeit": "bg-slate-500/20 text-slate-300",
+ "faz": "bg-emerald-500/20 text-emerald-300",
+ "welt": "bg-indigo-500/20 text-indigo-300",
+ "t3n": "bg-purple-500/20 text-purple-300",
+ "default": "bg-amber-500/15 text-amber-300",
+};
+
+function sourceColor(source: string): string {
+ const key = source.toLowerCase();
+ for (const [prefix, cls] of Object.entries(SOURCE_COLORS)) {
+ if (key.includes(prefix)) return cls;
+ }
+ return SOURCE_COLORS.default;
+}
+
+/** Return a German relative time string like "vor 2 Stunden". */
+function relativeTime(isoDate: string): string {
+ try {
+ const date = new Date(isoDate);
+ if (isNaN(date.getTime())) return "";
+ const diff = Date.now() - date.getTime();
+ const seconds = Math.floor(diff / 1_000);
+ const minutes = Math.floor(seconds / 60);
+ const hours = Math.floor(minutes / 60);
+ const days = Math.floor(hours / 24);
+
+ if (seconds < 60) return "gerade eben";
+ if (minutes < 60) return `vor ${minutes} Min.`;
+ if (hours < 24) return hours === 1 ? "vor 1 Stunde" : `vor ${hours} Stunden`;
+ if (days === 1) return "gestern";
+ if (days < 7) return `vor ${days} Tagen`;
+ return date.toLocaleDateString("de-DE", { day: "2-digit", month: "short" });
+ } catch {
+ return "";
+ }
+}
+
+export default function NewsGrid({ data }: NewsGridProps) {
+ const [activeCategory, setActiveCategory] = useState("all");
+
+ const filteredArticles = useMemo(() => {
+ if (!data?.articles) return [];
+ if (activeCategory === "all") return data.articles;
+ return data.articles.filter(
+ (a) => a.category?.toLowerCase() === activeCategory
+ );
+ }, [data, activeCategory]);
+
+ if (!data?.articles) return null;
+
+ return (
+
+ {/* Header + category tabs */}
+
+
+ Nachrichten
+
+ {filteredArticles.length}
+
+
+
+
+ {CATEGORIES.map((cat) => (
+
+ ))}
+
+
+
+ {/* Articles grid */}
+ {filteredArticles.length === 0 ? (
+
+ Keine Artikel in dieser Kategorie.
+
+ ) : (
+
+ {filteredArticles.map((article) => (
+
+ ))}
+
+ )}
+
+ );
+}
+
+function ArticleCard({ article }: { article: NewsArticle }) {
+ return (
+
+
+
+ {article.source}
+
+
+
+
+
+ {article.title}
+
+
+
+ {article.category && (
+
+ {article.category}
+
+ )}
+
+ {relativeTime(article.published_at)}
+
+
+
+ );
+}
diff --git a/web/src/components/ServerCard.tsx b/web/src/components/ServerCard.tsx
new file mode 100644
index 0000000..50ab3f9
--- /dev/null
+++ b/web/src/components/ServerCard.tsx
@@ -0,0 +1,241 @@
+import { useState } from "react";
+import { Server, Cpu, HardDrive, Wifi, WifiOff, ChevronDown, ChevronUp, Box } from "lucide-react";
+import type { ServerStats } from "../api";
+
+interface ServerCardProps {
+ server: ServerStats;
+}
+
+/** Return Tailwind text colour class based on percentage thresholds. */
+function usageColor(pct: number): string {
+ if (pct >= 80) return "text-red-400";
+ if (pct >= 60) return "text-amber-400";
+ return "text-emerald-400";
+}
+
+/** Return SVG stroke colour (hex) based on percentage thresholds. */
+function usageStroke(pct: number): string {
+ if (pct >= 80) return "#f87171"; // red-400
+ if (pct >= 60) return "#fbbf24"; // amber-400
+ return "#34d399"; // emerald-400
+}
+
+/** Return track background colour based on percentage thresholds. */
+function usageBarBg(pct: number): string {
+ if (pct >= 80) return "bg-red-500/70";
+ if (pct >= 60) return "bg-amber-500/70";
+ return "bg-emerald-500/70";
+}
+
+export default function ServerCard({ server }: ServerCardProps) {
+ const [dockerExpanded, setDockerExpanded] = useState(false);
+
+ if (!server) return null;
+
+ const cpuPct = Math.round(server.cpu.usage_pct);
+ const ramPct = Math.round(server.ram.pct);
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
+
+
{server.name}
+
{server.host}
+
+
+
+
+
+
+ {!server.online ? (
+
+
+ Offline
+
+ ) : (
+
+ {/* CPU Section */}
+
+
+
+
+
+
+
+ CPU
+
+
+
+
+ {cpuPct}%
+
+ {server.cpu.temp_c !== null && (
+
+ {Math.round(server.cpu.temp_c)}°C
+
+ )}
+
+
+ {server.cpu.cores} Kerne
+
+
+
+
+ {/* RAM Section */}
+
+
+
+
+
+ RAM
+
+
+
+ {ramPct}%
+
+
+
+
+
+
+ {server.ram.used_gb.toFixed(1)} / {server.ram.total_gb.toFixed(1)} GB
+
+
+
+ {/* Uptime */}
+ {server.uptime && (
+
+ Uptime: {server.uptime}
+
+ )}
+
+ {/* Docker Section */}
+ {server.docker && (
+
+
+
+ {dockerExpanded && server.docker.containers.length > 0 && (
+
+ {server.docker.containers.map((c) => (
+
+ {c.name}
+
+ {c.status.toLowerCase().includes("up") ? "Running" : c.status}
+
+
+ ))}
+
+ )}
+
+ )}
+
+ )}
+
+ );
+}
+
+/** Green/Red online indicator dot. */
+function StatusDot({ online }: { online: boolean }) {
+ return (
+
+
+
+ {online && (
+
+ )}
+
+
+ {online ? "Online" : "Offline"}
+
+
+ );
+}
+
+/** SVG circular progress ring for CPU usage. */
+function CpuRing({
+ pct,
+ size,
+ strokeWidth,
+}: {
+ pct: number;
+ size: number;
+ strokeWidth: number;
+}) {
+ const radius = (size - strokeWidth) / 2;
+ const circumference = 2 * Math.PI * radius;
+ const offset = circumference - (pct / 100) * circumference;
+
+ return (
+
+
+ {/* Center icon */}
+
+
+
+
+ );
+}
diff --git a/web/src/components/TasksCard.tsx b/web/src/components/TasksCard.tsx
new file mode 100644
index 0000000..6edce08
--- /dev/null
+++ b/web/src/components/TasksCard.tsx
@@ -0,0 +1,183 @@
+import { useState } from "react";
+import { CheckSquare, Square, AlertTriangle, Calendar } from "lucide-react";
+import type { TasksResponse, Task } from "../api";
+
+interface TasksCardProps {
+ data: TasksResponse;
+}
+
+type TabKey = "private" | "sams";
+
+const TABS: { key: TabKey; label: string }[] = [
+ { key: "private", label: "Privat" },
+ { key: "sams", label: "Sam's" },
+];
+
+/** Colour for task priority (1 = highest). */
+function priorityIndicator(priority: number): { color: string; label: string } {
+ if (priority <= 1) return { color: "bg-red-500", label: "Hoch" };
+ if (priority <= 2) return { color: "bg-amber-500", label: "Mittel" };
+ if (priority <= 3) return { color: "bg-blue-500", label: "Normal" };
+ return { color: "bg-slate-500", label: "Niedrig" };
+}
+
+/** Format a due date string to a short German date. */
+function formatDueDate(iso: string | null): string | null {
+ if (!iso) return null;
+ try {
+ const d = new Date(iso);
+ if (isNaN(d.getTime())) return null;
+ const now = new Date();
+ const diffDays = Math.ceil((d.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
+
+ if (diffDays < 0) return "Ueberfaellig";
+ if (diffDays === 0) return "Heute";
+ if (diffDays === 1) return "Morgen";
+ return d.toLocaleDateString("de-DE", { day: "2-digit", month: "short" });
+ } catch {
+ return null;
+ }
+}
+
+export default function TasksCard({ data }: TasksCardProps) {
+ const [activeTab, setActiveTab] = useState("private");
+
+ if (!data) return null;
+
+ if (data.error) {
+ return (
+
+
+
+
+
Aufgaben nicht verfuegbar
+
Verbindung fehlgeschlagen
+
+
+
+ );
+ }
+
+ const group = activeTab === "private" ? data.private : data.sams;
+ const tasks = group?.open ?? [];
+
+ return (
+
+ {/* Header */}
+
+
+ {/* Tab buttons */}
+
+ {TABS.map((tab) => {
+ const tabGroup = tab.key === "private" ? data.private : data.sams;
+ const openCount = tabGroup?.open_count ?? 0;
+ const isActive = activeTab === tab.key;
+
+ return (
+
+ );
+ })}
+
+
+ {/* Task list */}
+ {tasks.length === 0 ? (
+
+ ) : (
+
+ {tasks.map((task) => (
+
+ ))}
+
+ )}
+
+ );
+}
+
+function TaskItem({ task }: { task: Task }) {
+ const p = priorityIndicator(task.priority);
+ const due = formatDueDate(task.due_date);
+ const isOverdue = due === "Ueberfaellig";
+
+ return (
+
+ {/* Visual checkbox */}
+
+ {task.done ? (
+
+ ) : (
+
+ )}
+
+
+ {/* Content */}
+
+
+ {task.title}
+
+
+
+ {/* Project badge */}
+ {task.project_name && (
+
+ {task.project_name}
+
+ )}
+
+ {/* Due date */}
+ {due && (
+
+
+ {due}
+
+ )}
+
+
+
+ {/* Priority dot */}
+
+
+ );
+}
diff --git a/web/src/components/WeatherCard.tsx b/web/src/components/WeatherCard.tsx
new file mode 100644
index 0000000..e307e5b
--- /dev/null
+++ b/web/src/components/WeatherCard.tsx
@@ -0,0 +1,115 @@
+import { Thermometer, Droplets, Wind, CloudOff } from "lucide-react";
+import type { WeatherData } from "../api";
+
+interface WeatherCardProps {
+ data: WeatherData;
+ accent: "cyan" | "amber";
+}
+
+const accentMap = {
+ cyan: {
+ gradient: "from-cyan-500/10 to-cyan-900/5",
+ text: "text-cyan-400",
+ border: "border-cyan-500/20",
+ badge: "bg-cyan-500/15 text-cyan-300",
+ statIcon: "text-cyan-400/70",
+ ring: "ring-cyan-500/10",
+ },
+ amber: {
+ gradient: "from-amber-500/10 to-amber-900/5",
+ text: "text-amber-400",
+ border: "border-amber-500/20",
+ badge: "bg-amber-500/15 text-amber-300",
+ statIcon: "text-amber-400/70",
+ ring: "ring-amber-500/10",
+ },
+} as const;
+
+export default function WeatherCard({ data, accent }: WeatherCardProps) {
+ const a = accentMap[accent];
+
+ if (data.error) {
+ return (
+
+
+
+
+
Wetter nicht verfuegbar
+
{data.location || "Unbekannt"}
+
+
+
+ );
+ }
+
+ return (
+
+ {/* Header: location badge */}
+
+ {data.location}
+
+
+ {/* Main row: icon + temp */}
+
+
+
+
+ {Math.round(data.temp)}
+
+ °
+
+
{data.description}
+
+
+
+ {data.icon}
+
+
+
+ {/* Stat grid */}
+
+ }
+ label="Gefuehlt"
+ value={`${Math.round(data.feels_like)}\u00B0`}
+ />
+ }
+ label="Feuchte"
+ value={`${data.humidity}%`}
+ />
+ }
+ label="Wind"
+ value={`${Math.round(data.wind_kmh)} km/h`}
+ />
+
+
+ );
+}
+
+function StatItem({
+ icon,
+ label,
+ value,
+}: {
+ icon: React.ReactNode;
+ label: string;
+ value: string;
+}) {
+ return (
+
+ {icon}
+
+ {value}
+
+ {label}
+
+ );
+}
diff --git a/web/src/hooks/useDashboard.ts b/web/src/hooks/useDashboard.ts
new file mode 100644
index 0000000..c792eb8
--- /dev/null
+++ b/web/src/hooks/useDashboard.ts
@@ -0,0 +1,57 @@
+import { useCallback, useEffect, useState } from "react";
+import type { DashboardData } from "../api";
+import { fetchAll } from "../api";
+import { useWebSocket } from "./useWebSocket";
+import { MOCK_DATA } from "../mockData";
+
+/**
+ * Main dashboard hook.
+ * Fetches initial data via REST, then switches to WebSocket for live updates.
+ * Falls back to mock data in development when the backend is unavailable.
+ */
+export function useDashboard() {
+ const [data, setData] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [usingMock, setUsingMock] = useState(false);
+
+ const { data: wsData, connected } = useWebSocket();
+
+ // Initial REST fetch
+ const loadInitial = useCallback(async () => {
+ try {
+ setLoading(true);
+ setError(null);
+ const result = await fetchAll();
+ setData(result);
+ setUsingMock(false);
+ } catch {
+ // Fall back to mock data in dev
+ if (import.meta.env.DEV) {
+ setData({ ...MOCK_DATA, timestamp: new Date().toISOString() });
+ setUsingMock(true);
+ setError(null);
+ } else {
+ setError("Verbindung zum Server fehlgeschlagen");
+ }
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ loadInitial();
+ }, [loadInitial]);
+
+ // WebSocket updates override REST data
+ useEffect(() => {
+ if (wsData) {
+ setData(wsData);
+ setLoading(false);
+ setError(null);
+ setUsingMock(false);
+ }
+ }, [wsData]);
+
+ return { data, loading, error, connected: connected || usingMock, refresh: loadInitial };
+}
diff --git a/web/src/hooks/useWebSocket.ts b/web/src/hooks/useWebSocket.ts
new file mode 100644
index 0000000..e3bccf8
--- /dev/null
+++ b/web/src/hooks/useWebSocket.ts
@@ -0,0 +1,79 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+import type { DashboardData } from "../api";
+
+const WS_RECONNECT_MS = 5_000;
+const WS_PING_INTERVAL_MS = 15_000;
+
+export function useWebSocket() {
+ const [data, setData] = useState(null);
+ const [connected, setConnected] = useState(false);
+ const wsRef = useRef(null);
+ const pingRef = useRef | null>(null);
+ const reconnectRef = useRef | null>(null);
+ const mountedRef = useRef(true);
+
+ const connect = useCallback(() => {
+ if (!mountedRef.current) return;
+
+ const proto = location.protocol === "https:" ? "wss:" : "ws:";
+ const ws = new WebSocket(`${proto}//${location.host}/ws`);
+ wsRef.current = ws;
+
+ ws.onopen = () => {
+ if (!mountedRef.current) return;
+ setConnected(true);
+ ws.send("ping");
+
+ pingRef.current = setInterval(() => {
+ if (ws.readyState === WebSocket.OPEN) {
+ ws.send("ping");
+ }
+ }, WS_PING_INTERVAL_MS);
+ };
+
+ ws.onmessage = (ev) => {
+ if (!mountedRef.current) return;
+ try {
+ const parsed = JSON.parse(ev.data) as DashboardData;
+ setData(parsed);
+ } catch {
+ // ignore malformed messages
+ }
+ };
+
+ ws.onclose = () => {
+ if (!mountedRef.current) return;
+ setConnected(false);
+ cleanup();
+ reconnectRef.current = setTimeout(connect, WS_RECONNECT_MS);
+ };
+
+ ws.onerror = () => {
+ ws.close();
+ };
+ }, []);
+
+ const cleanup = useCallback(() => {
+ if (pingRef.current) {
+ clearInterval(pingRef.current);
+ pingRef.current = null;
+ }
+ }, []);
+
+ useEffect(() => {
+ mountedRef.current = true;
+ connect();
+
+ return () => {
+ mountedRef.current = false;
+ cleanup();
+ if (reconnectRef.current) clearTimeout(reconnectRef.current);
+ if (wsRef.current) {
+ wsRef.current.onclose = null;
+ wsRef.current.close();
+ }
+ };
+ }, [connect, cleanup]);
+
+ return { data, connected };
+}
diff --git a/web/src/main.tsx b/web/src/main.tsx
new file mode 100644
index 0000000..d0d227f
--- /dev/null
+++ b/web/src/main.tsx
@@ -0,0 +1,10 @@
+import React from "react";
+import ReactDOM from "react-dom/client";
+import App from "./App";
+import "./styles/globals.css";
+
+ReactDOM.createRoot(document.getElementById("root")!).render(
+
+
+
+);
diff --git a/web/src/mockData.ts b/web/src/mockData.ts
new file mode 100644
index 0000000..6d03263
--- /dev/null
+++ b/web/src/mockData.ts
@@ -0,0 +1,175 @@
+/** Mock data for development preview when backend is unavailable. */
+import type { DashboardData } from "./api";
+
+export const MOCK_DATA: DashboardData = {
+ timestamp: new Date().toISOString(),
+ weather: {
+ primary: {
+ location: "Leverkusen",
+ temp: 8,
+ feels_like: 5,
+ humidity: 72,
+ wind_kmh: 18,
+ description: "Teilweise bewölkt",
+ icon: "⛅",
+ forecast: [
+ { date: "2026-03-02", max_temp: 10, min_temp: 3, icon: "⛅", description: "Partly cloudy" },
+ { date: "2026-03-03", max_temp: 12, min_temp: 5, icon: "🌤️", description: "Sunny intervals" },
+ { date: "2026-03-04", max_temp: 7, min_temp: 1, icon: "🌧️", description: "Rain" },
+ ],
+ },
+ secondary: {
+ location: "Rab, Kroatien",
+ temp: 16,
+ feels_like: 15,
+ humidity: 58,
+ wind_kmh: 12,
+ description: "Sonnig",
+ icon: "☀️",
+ forecast: [
+ { date: "2026-03-02", max_temp: 18, min_temp: 10, icon: "☀️", description: "Sunny" },
+ { date: "2026-03-03", max_temp: 19, min_temp: 11, icon: "🌤️", description: "Mostly sunny" },
+ { date: "2026-03-04", max_temp: 17, min_temp: 9, icon: "⛅", description: "Partly cloudy" },
+ ],
+ },
+ hourly: [
+ { time: "14:00", temp: 8, icon: "⛅", precip_chance: 10 },
+ { time: "15:00", temp: 9, icon: "🌤️", precip_chance: 5 },
+ { time: "16:00", temp: 8, icon: "⛅", precip_chance: 15 },
+ { time: "17:00", temp: 7, icon: "☁️", precip_chance: 25 },
+ { time: "18:00", temp: 6, icon: "☁️", precip_chance: 30 },
+ { time: "19:00", temp: 5, icon: "🌧️", precip_chance: 55 },
+ { time: "20:00", temp: 4, icon: "🌧️", precip_chance: 60 },
+ { time: "21:00", temp: 4, icon: "☁️", precip_chance: 35 },
+ ],
+ },
+ news: {
+ articles: [
+ { id: 1, source: "tagesschau", title: "Bundesregierung beschließt neues Infrastrukturpaket", url: "#", category: "politik", published_at: new Date(Date.now() - 3600000).toISOString() },
+ { id: 2, source: "heise", title: "Intel stellt neue Arc-Grafikkarten vor: Battlemage kommt im Q2", url: "#", category: "tech", published_at: new Date(Date.now() - 7200000).toISOString() },
+ { id: 3, source: "spiegel", title: "DAX erreicht neues Allzeithoch trotz geopolitischer Spannungen", url: "#", category: "wirtschaft", published_at: new Date(Date.now() - 10800000).toISOString() },
+ { id: 4, source: "google_finance", title: "NVIDIA reports record quarterly revenue driven by AI demand", url: "#", category: "wirtschaft", published_at: new Date(Date.now() - 14400000).toISOString() },
+ { id: 5, source: "tagesschau", title: "Klimagipfel: Neue Vereinbarungen zum CO2-Ausstoß beschlossen", url: "#", category: "politik", published_at: new Date(Date.now() - 18000000).toISOString() },
+ { id: 6, source: "heise", title: "Linux 6.14 Kernel bringt massiven Performance-Boost für AMD Zen 5", url: "#", category: "tech", published_at: new Date(Date.now() - 21600000).toISOString() },
+ { id: 7, source: "google_de_finance", title: "Rheinmetall-Aktie steigt um 8% nach neuem NATO-Auftrag", url: "#", category: "wirtschaft", published_at: new Date(Date.now() - 25200000).toISOString() },
+ { id: 8, source: "spiegel", title: "Studie: Homeoffice bleibt auch 2026 die beliebteste Arbeitsform", url: "#", category: "allgemein", published_at: new Date(Date.now() - 28800000).toISOString() },
+ { id: 9, source: "heise", title: "Docker Desktop 5.0: Neue Container-Management-Features im Überblick", url: "#", category: "tech", published_at: new Date(Date.now() - 32400000).toISOString() },
+ { id: 10, source: "tagesschau", title: "Europäische Zentralbank senkt Leitzins um 0.25 Prozentpunkte", url: "#", category: "wirtschaft", published_at: new Date(Date.now() - 36000000).toISOString() },
+ { id: 11, source: "google_defense", title: "NATO increases defense spending targets for 2027", url: "#", category: "politik", published_at: new Date(Date.now() - 39600000).toISOString() },
+ { id: 12, source: "spiegel", title: "Unraid 7.0: Das neue Server-Betriebssystem im Test", url: "#", category: "tech", published_at: new Date(Date.now() - 43200000).toISOString() },
+ ],
+ total: 12,
+ limit: 20,
+ offset: 0,
+ },
+ servers: {
+ servers: [
+ {
+ name: "Daddelolymp",
+ host: "10.10.10.10",
+ online: true,
+ uptime: "42 days, 7:23",
+ cpu: { usage_pct: 38, cores: 16, temp_c: 52 },
+ ram: { used_gb: 24.3, total_gb: 32, pct: 76 },
+ array: {
+ status: "normal",
+ disks: [
+ { name: "Disk 1", status: "active", size: "8 TB", used: "5.2 TB" },
+ { name: "Disk 2", status: "active", size: "8 TB", used: "6.1 TB" },
+ { name: "Disk 3", status: "active", size: "4 TB", used: "2.8 TB" },
+ { name: "Parity", status: "active", size: "8 TB", used: "-" },
+ { name: "Cache", status: "active", size: "1 TB", used: "320 GB" },
+ ],
+ },
+ docker: {
+ running: 14,
+ containers: [
+ { name: "gitlab", status: "running", image: "gitlab/gitlab-ce" },
+ { name: "n8n", status: "running", image: "n8nio/n8n" },
+ { name: "postgres", status: "running", image: "postgres:15" },
+ { name: "daily-briefing", status: "running", image: "daily-briefing" },
+ { name: "jukebox-vibe", status: "running", image: "jukebox-vibe" },
+ { name: "redis", status: "running", image: "redis:7" },
+ { name: "vikunja", status: "running", image: "vikunja/vikunja" },
+ { name: "nginx", status: "running", image: "nginx:alpine" },
+ ],
+ },
+ },
+ {
+ name: "Moneyboy",
+ host: "192.168.1.100",
+ online: true,
+ uptime: "12 days, 14:52",
+ cpu: { usage_pct: 12, cores: 10, temp_c: 41 },
+ ram: { used_gb: 8.7, total_gb: 16, pct: 54 },
+ array: {
+ status: "normal",
+ disks: [
+ { name: "Disk 1", status: "active", size: "4 TB", used: "1.8 TB" },
+ { name: "Disk 2", status: "active", size: "4 TB", used: "2.1 TB" },
+ { name: "Cache", status: "active", size: "500 GB", used: "180 GB" },
+ ],
+ },
+ docker: {
+ running: 6,
+ containers: [
+ { name: "freqtrade", status: "running", image: "freqtradeorg/freqtrade" },
+ { name: "plex", status: "running", image: "plexinc/pms-docker" },
+ { name: "nextcloud", status: "running", image: "nextcloud" },
+ ],
+ },
+ },
+ ],
+ },
+ ha: {
+ online: true,
+ lights: [
+ { entity_id: "light.wohnzimmer", name: "Wohnzimmer", state: "on", brightness: 80 },
+ { entity_id: "light.schlafzimmer", name: "Schlafzimmer", state: "off", brightness: 0 },
+ { entity_id: "light.kueche", name: "Küche", state: "on", brightness: 100 },
+ { entity_id: "light.flur", name: "Flur", state: "off", brightness: 0 },
+ { entity_id: "light.badezimmer", name: "Badezimmer", state: "on", brightness: 60 },
+ { entity_id: "light.buero", name: "Büro", state: "off", brightness: 0 },
+ { entity_id: "light.balkon", name: "Balkon", state: "off", brightness: 0 },
+ { entity_id: "light.gaestezimmer", name: "Gästezimmer", state: "off", brightness: 0 },
+ ],
+ covers: [
+ { entity_id: "cover.wohnzimmer", name: "Wohnzimmer Rollo", state: "open", position: 100 },
+ { entity_id: "cover.schlafzimmer", name: "Schlafzimmer Rollo", state: "closed", position: 0 },
+ { entity_id: "cover.kueche", name: "Küche Rollo", state: "open", position: 75 },
+ ],
+ sensors: [
+ { entity_id: "sensor.temp_wohnzimmer", name: "Wohnzimmer", state: 21.5, unit: "°C" },
+ { entity_id: "sensor.temp_aussen", name: "Außen", state: 7.8, unit: "°C" },
+ { entity_id: "sensor.temp_schlafzimmer", name: "Schlafzimmer", state: 19.2, unit: "°C" },
+ ],
+ lights_on: 3,
+ lights_total: 8,
+ },
+ tasks: {
+ private: {
+ open: [
+ { id: 101, title: "Garten winterfest machen", done: false, priority: 3, project_name: "Haus & Garten", due_date: "2026-03-05" },
+ { id: 102, title: "Heizung warten lassen", done: false, priority: 2, project_name: "Haus & Garten", due_date: "2026-03-10" },
+ { id: 103, title: "Projekt-Bericht abgeben", done: false, priority: 4, project_name: "Jugendeinrichtung", due_date: "2026-03-03" },
+ { id: 104, title: "Elternabend planen", done: false, priority: 1, project_name: "Jugendeinrichtung", due_date: null },
+ { id: 105, title: "Neue Pflanzen bestellen", done: false, priority: 1, project_name: "Haus & Garten", due_date: null },
+ ],
+ done: [
+ { id: 106, title: "Müll rausbringen", done: true, priority: 1, project_name: "Haus & Garten", due_date: null },
+ ],
+ open_count: 5,
+ },
+ sams: {
+ open: [
+ { id: 201, title: "Daily Briefing Dashboard refactoren", done: false, priority: 4, project_name: "OpenClaw AI", due_date: "2026-03-02" },
+ { id: 202, title: "n8n Workflows aufräumen", done: false, priority: 3, project_name: "OpenClaw AI", due_date: null },
+ { id: 203, title: "Trading Bot backtesting", done: false, priority: 2, project_name: "Sam's Wunderwelt", due_date: null },
+ ],
+ done: [
+ { id: 204, title: "Jukebox Drag & Drop Feature", done: true, priority: 3, project_name: "OpenClaw AI", due_date: null },
+ ],
+ open_count: 3,
+ },
+ },
+};
diff --git a/web/src/styles/globals.css b/web/src/styles/globals.css
new file mode 100644
index 0000000..f2d357d
--- /dev/null
+++ b/web/src/styles/globals.css
@@ -0,0 +1,126 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+@layer base {
+ * {
+ box-sizing: border-box;
+ }
+
+ body {
+ font-family: "Inter", system-ui, -apple-system, sans-serif;
+ background: linear-gradient(135deg, #0a0e1a 0%, #0f172a 50%, #0c1220 100%);
+ min-height: 100vh;
+ }
+
+ ::-webkit-scrollbar {
+ width: 6px;
+ height: 6px;
+ }
+ ::-webkit-scrollbar-track {
+ background: transparent;
+ }
+ ::-webkit-scrollbar-thumb {
+ background: rgba(148, 163, 184, 0.2);
+ border-radius: 3px;
+ }
+ ::-webkit-scrollbar-thumb:hover {
+ background: rgba(148, 163, 184, 0.4);
+ }
+}
+
+@layer components {
+ .glass-card {
+ @apply relative overflow-hidden rounded-2xl border border-white/[0.06]
+ bg-white/[0.03] backdrop-blur-md shadow-xl shadow-black/20;
+ }
+
+ .glass-card-hover {
+ @apply glass-card transition-all duration-300
+ hover:border-white/[0.12] hover:bg-white/[0.05]
+ hover:shadow-2xl hover:shadow-black/30
+ hover:-translate-y-0.5;
+ }
+
+ .glass-card::before {
+ content: "";
+ position: absolute;
+ inset: 0;
+ border-radius: inherit;
+ background: linear-gradient(
+ 135deg,
+ rgba(255, 255, 255, 0.04) 0%,
+ transparent 50%
+ );
+ pointer-events: none;
+ }
+
+ .accent-glow {
+ position: relative;
+ }
+ .accent-glow::after {
+ content: "";
+ position: absolute;
+ inset: -1px;
+ border-radius: inherit;
+ opacity: 0;
+ transition: opacity 0.3s;
+ pointer-events: none;
+ }
+ .accent-glow:hover::after {
+ opacity: 1;
+ }
+
+ .stat-value {
+ @apply text-3xl font-bold tracking-tight;
+ font-family: "JetBrains Mono", monospace;
+ }
+
+ .stat-label {
+ @apply text-xs font-medium uppercase tracking-wider text-slate-400;
+ }
+
+ .badge {
+ @apply inline-flex items-center rounded-full px-2.5 py-0.5 text-[10px]
+ font-semibold uppercase tracking-wider;
+ }
+
+ .progress-bar {
+ @apply h-1.5 rounded-full bg-slate-800 overflow-hidden;
+ }
+
+ .progress-fill {
+ @apply h-full rounded-full transition-all duration-700 ease-out;
+ }
+
+ .category-tab {
+ @apply px-3 py-1.5 rounded-lg text-xs font-medium transition-all duration-200
+ cursor-pointer select-none;
+ }
+ .category-tab.active {
+ @apply bg-white/10 text-white;
+ }
+ .category-tab:not(.active) {
+ @apply text-slate-400 hover:text-slate-200 hover:bg-white/5;
+ }
+}
+
+@layer utilities {
+ .text-gradient {
+ @apply bg-clip-text text-transparent;
+ }
+
+ .line-clamp-2 {
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+ }
+
+ .line-clamp-3 {
+ display: -webkit-box;
+ -webkit-line-clamp: 3;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+ }
+}
diff --git a/web/src/vite-env.d.ts b/web/src/vite-env.d.ts
new file mode 100644
index 0000000..11f02fe
--- /dev/null
+++ b/web/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/web/tailwind.config.js b/web/tailwind.config.js
new file mode 100644
index 0000000..4ffa1ca
--- /dev/null
+++ b/web/tailwind.config.js
@@ -0,0 +1,34 @@
+/** @type {import('tailwindcss').Config} */
+export default {
+ content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
+ theme: {
+ extend: {
+ colors: {
+ surface: {
+ 50: "rgba(255,255,255,0.05)",
+ 100: "rgba(255,255,255,0.08)",
+ 200: "rgba(255,255,255,0.12)",
+ },
+ },
+ backdropBlur: {
+ xs: "2px",
+ },
+ animation: {
+ "fade-in": "fadeIn 0.5s ease-out",
+ "slide-up": "slideUp 0.4s ease-out",
+ "pulse-slow": "pulse 3s ease-in-out infinite",
+ },
+ keyframes: {
+ fadeIn: {
+ "0%": { opacity: "0", transform: "translateY(8px)" },
+ "100%": { opacity: "1", transform: "translateY(0)" },
+ },
+ slideUp: {
+ "0%": { opacity: "0", transform: "translateY(16px)" },
+ "100%": { opacity: "1", transform: "translateY(0)" },
+ },
+ },
+ },
+ },
+ plugins: [],
+};
diff --git a/web/tsconfig.json b/web/tsconfig.json
new file mode 100644
index 0000000..75e2ff7
--- /dev/null
+++ b/web/tsconfig.json
@@ -0,0 +1,21 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "isolatedModules": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "strict": true,
+ "noUnusedLocals": false,
+ "noUnusedParameters": false,
+ "noFallthroughCasesInSwitch": true,
+ "forceConsistentCasingInFileNames": true
+ },
+ "include": ["src"]
+}
diff --git a/web/vite.config.ts b/web/vite.config.ts
new file mode 100644
index 0000000..e56ee44
--- /dev/null
+++ b/web/vite.config.ts
@@ -0,0 +1,23 @@
+import { defineConfig } from "vite";
+import react from "@vitejs/plugin-react";
+
+export default defineConfig({
+ plugins: [react()],
+ server: {
+ port: 5173,
+ proxy: {
+ "/api": {
+ target: "http://localhost:8080",
+ changeOrigin: true,
+ },
+ "/ws": {
+ target: "ws://localhost:8080",
+ ws: true,
+ },
+ },
+ },
+ build: {
+ outDir: "dist",
+ emptyOutDir: true,
+ },
+});