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 -

-
- {% if hourly_weather and hourly_weather.Leverkusen %} - {% for hour in hourly_weather.Leverkusen[:8] %} -
-
{{ hour.time }}
-
{{ hour.icon }}
-
{{ hour.temp }}°
-
{{ hour.precip }}%
-
- {% endfor %} - {% else %} -
Keine Stundendaten verfügbar.
- {% endif %} -
-
-
-
- - -
-

- - - - Aktuelle Schlagzeilen -

-
- {% if news %} - {% for item in news[:12] %} -
-
- {{ item.source }} - {{ item.time }} -
-

- {{ item.title }} -

- - Mehr lesen - - - - -
- {% 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 %} -
- -
- {{ task.title }} -
- {{ task.project }} -
-
-
- {% 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 %} -
- -
- {{ task.title }} -
- {{ task.project }} -
-
-
- {% 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 ---- */} +
+
+ {/* Left: title + live indicator */} +
+

+ Daily Briefing +

+ +
+ + {/* Right: clock + refresh */} +
+ + +
+
+
+ + {/* ---- 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 */} +
+
+
+ +
+

Home Assistant

+
+ +
+
+
+ {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 && ( +
+
+ + + Rollos + +
+ +
+ {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 ( +
+ + {/* Track */} + + {/* Progress */} + + + {/* 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 */} +
+
+
+ +
+

Aufgaben

+
+
+ + {/* 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 ? ( +
+ +

Alles erledigt!

+
+ ) : ( +
+ {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, + }, +});