commit 1efe40a03be7527ed1b7853d7e586ee30788620c Author: RobbStarkAustria <7694336+RobbStarkAustria@users.noreply.github.com> Date: Fri Oct 10 15:20:14 2025 +0000 Initial commit - copied workspace after database cleanup diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..466ec04 --- /dev/null +++ b/.env.example @@ -0,0 +1,36 @@ +# Copy this file to .env and fill in values as needed for local development. +# NOTE: No secrets should be committed. Use placeholders below. + +# General +ENV=development + +# Database (used if DB_CONN not provided) +DB_USER=your_user +DB_PASSWORD=your_password +DB_NAME=infoscreen_by_taa +DB_HOST=db +# Preferred connection string for services (overrides the above if set) +# DB_CONN=mysql+pymysql://${DB_USER}:${DB_PASSWORD}@${DB_HOST}/${DB_NAME} + +# MQTT +MQTT_BROKER_HOST=mqtt +MQTT_BROKER_PORT=1883 +# MQTT_USER=your_mqtt_user +# MQTT_PASSWORD=your_mqtt_password +MQTT_KEEPALIVE=60 + +# Dashboard +# Used when building the production dashboard image +# VITE_API_URL=https://your.api.example.com/api + +# Groups alive windows (seconds) +HEARTBEAT_GRACE_PERIOD_DEV=15 +HEARTBEAT_GRACE_PERIOD_PROD=180 + +# Scheduler +# Optional: force periodic republish even without changes +# REFRESH_SECONDS=0 + +# Default admin bootstrap (server/init_defaults.py) +DEFAULT_ADMIN_USERNAME=infoscreen_admin +DEFAULT_ADMIN_PASSWORD=Info_screen_admin25! diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..285c4dc --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,116 @@ +# Copilot instructions for infoscreen_2025 + +Use this as your shared context when proposing changes. Keep edits minimal and match existing patterns referenced below. + +## Big picture +- Multi-service app orchestrated by Docker Compose. + - API: Flask + SQLAlchemy (MariaDB), in `server/` exposed on :8000 (health: `/health`). + - Dashboard: React + Vite in `dashboard/`, dev on :5173, served via Nginx in prod. + - MQTT broker: Eclipse Mosquitto, config in `mosquitto/config/mosquitto.conf`. + - Listener: MQTT consumer handling discovery + heartbeats in `listener/listener.py`. + - Scheduler: Publishes active events (per group) to MQTT retained topics in `scheduler/scheduler.py`. + - Nginx: Reverse proxy routes `/api/*` and `/screenshots/*` to API; everything else to dashboard (`nginx.conf`). + +## Service boundaries & data flow +- Database connection string is passed as `DB_CONN` (mysql+pymysql) to Python services. + - API builds its engine in `server/database.py` (loads `.env` only in development). + - Scheduler loads `DB_CONN` in `scheduler/db_utils.py`. + - Listener also creates its own engine for writes to `clients`. +- MQTT topics (paho-mqtt v2, use Callback API v2): + - Discovery: `infoscreen/discovery` (JSON includes `uuid`, hw/ip data). ACK to `infoscreen/{uuid}/discovery_ack`. See `listener/listener.py`. + - Heartbeat: `infoscreen/{uuid}/heartbeat` updates `Client.last_alive` (UTC). + - Event lists (retained): `infoscreen/events/{group_id}` from `scheduler/scheduler.py`. + - Per-client group assignment (retained): `infoscreen/{uuid}/group_id` via `server/mqtt_helper.py`. +- Screenshots: server-side folders `server/received_screenshots/` and `server/screenshots/`; Nginx exposes `/screenshots/{uuid}.jpg` via `server/wsgi.py` route. + +- Presentation conversion (PPT/PPTX/ODP → PDF): + - Trigger: on upload in `server/routes/eventmedia.py` for media types `ppt|pptx|odp` (compute sha256, upsert `Conversion`, enqueue job). + - Worker: RQ worker runs `server.worker.convert_event_media_to_pdf`, calls Gotenberg LibreOffice endpoint, writes to `server/media/converted/`. + - Services: Redis (queue) and Gotenberg added in compose; worker service consumes the `conversions` queue. + - Env: `REDIS_URL` (default `redis://redis:6379/0`), `GOTENBERG_URL` (default `http://gotenberg:3000`). + - Endpoints: `POST /api/conversions//pdf` (ensure/enqueue), `GET /api/conversions//status`, `GET /api/files/converted/` (serve PDFs). + - Storage: originals under `server/media/…`, outputs under `server/media/converted/` (prod compose mounts a shared volume for this path). + +## Data model highlights (see `models/models.py`) +- Enums: `EventType` (presentation, website, video, message, webuntis), `MediaType` (file/website types), and `AcademicPeriodType` (schuljahr, semester, trimester). +- Tables: `clients`, `client_groups`, `events`, `event_media`, `users`, `academic_periods`, `school_holidays`. +- Academic periods: `academic_periods` table supports educational institution cycles (school years, semesters). Events and media can be optionally linked via `academic_period_id` (nullable for backward compatibility). +- Times are stored as timezone-aware; treat comparisons in UTC (see scheduler and routes/events). + +- Conversions: + - Enum `ConversionStatus`: `pending`, `processing`, `ready`, `failed`. + - Table `conversions`: `id`, `source_event_media_id` (FK→`event_media.id` ondelete CASCADE), `target_format`, `target_path`, `status`, `file_hash` (sha256), `started_at`, `completed_at`, `error_message`. + - Indexes: `(source_event_media_id, target_format)`, `(status, target_format)`; Unique: `(source_event_media_id, target_format, file_hash)`. + +## API patterns +- Blueprints live in `server/routes/*` and are registered in `server/wsgi.py` with `/api/...` prefixes. +- Session usage: instantiate `Session()` per request, commit when mutating, and always `session.close()` before returning. +- Examples: + - Clients: `server/routes/clients.py` includes bulk group updates and MQTT sync (`publish_multiple_client_groups`). + - Groups: `server/routes/groups.py` computes “alive” using a grace period that varies by `ENV`. + - Events: `server/routes/events.py` serializes enum values to strings and normalizes times to UTC. + - Media: `server/routes/eventmedia.py` implements a simple file manager API rooted at `server/media/`. + - Academic periods: `server/routes/academic_periods.py` exposes: + - `GET /api/academic_periods` — list all periods + - `GET /api/academic_periods/active` — currently active period + - `POST /api/academic_periods/active` — set active period (deactivates others) + - `GET /api/academic_periods/for_date?date=YYYY-MM-DD` — period covering given date + +## Frontend patterns (dashboard) +- Vite React app; proxies `/api` and `/screenshots` to API in dev (`vite.config.ts`). +- Uses Syncfusion components; Vite config pre-bundles specific packages to avoid alias issues. +- Environment: `VITE_API_URL` provided at build/run; in dev compose, proxy handles `/api` so local fetches can use relative `/api/...` paths. + - Scheduler (appointments page): top bar includes Group and Academic Period selectors (Syncfusion DropDownList). Selecting a period calls `POST /api/academic_periods/active`, moves the calendar to today’s month/day within the period year, and refreshes a right-aligned indicator row showing: + - Holidays present in the current view (count) + - Period label (display_name or name) with a badge indicating whether any holidays exist in that period (overlap check) + +Note: Syncfusion usage in the dashboard is already documented above; if a UI for conversion status/downloads is added later, link its routes and components here. + +## Local development +- Compose: development is `docker-compose.yml` + `docker-compose.override.yml`. + - API (dev): `server/Dockerfile.dev` with debugpy on 5678, Flask app `wsgi:app` on :8000. + - Dashboard (dev): `dashboard/Dockerfile.dev` exposes :5173 and waits for API via `dashboard/wait-for-backend.sh`. + - Mosquitto: allows anonymous in dev; WebSocket on :9001. +- Common env vars: `DB_CONN`, `DB_USER`, `DB_PASSWORD`, `DB_HOST=db`, `DB_NAME`, `ENV`, `MQTT_USER`, `MQTT_PASSWORD`. +- Alembic: prod compose runs `alembic ... upgrade head` and `server/init_defaults.py` before gunicorn. + - Use `server/init_academic_periods.py` to populate default Austrian school years after migration. + +## Production +- `docker-compose.prod.yml` uses prebuilt images (`ghcr.io/robbstarkaustria/*`). +- Nginx serves dashboard and proxies API; TLS certs expected in `certs/` and mounted to `/etc/nginx/certs`. + +## Environment variables (reference) +- DB_CONN — Preferred DB URL for services. Example: `mysql+pymysql://${DB_USER}:${DB_PASSWORD}@db/${DB_NAME}` +- DB_USER, DB_PASSWORD, DB_NAME, DB_HOST — Used to assemble DB_CONN in dev if missing; inside containers `DB_HOST=db`. +- ENV — `development` or `production`; in development, `server/database.py` loads `.env`. +- MQTT_BROKER_HOST, MQTT_BROKER_PORT — Defaults `mqtt` and `1883`; MQTT_USER/MQTT_PASSWORD optional (dev often anonymous per Mosquitto config). +- VITE_API_URL — Dashboard build-time base URL (prod); in dev the Vite proxy serves `/api` to `server:8000`. +- HEARTBEAT_GRACE_PERIOD_DEV / HEARTBEAT_GRACE_PERIOD_PROD — Groups “alive” window (defaults ~15s dev / 180s prod). +- REFRESH_SECONDS — Optional scheduler republish interval; `0` disables periodic refresh. + +## Conventions & gotchas +- Always compare datetimes in UTC; some DB values may be naive—normalize before comparing (see `routes/events.py`). +- Use retained MQTT messages for state that clients must recover after reconnect (events per group, client group_id). +- In-container DB host is `db`; do not use `localhost` inside services. +- No separate dev vs prod secret conventions: use the same env var keys across environments (e.g., `DB_CONN`, `MQTT_USER`, `MQTT_PASSWORD`). +- When adding a new route: + 1) Create a Blueprint in `server/routes/...`, + 2) Register it in `server/wsgi.py`, + 3) Manage `Session()` lifecycle, and + 4) Return JSON-safe values (serialize enums and datetimes). +- When extending media types, update `MediaType` and any logic in `eventmedia` and dashboard that depends on it. +- Academic periods: Events/media can be optionally associated with periods for educational organization. Only one period should be active at a time (`is_active=True`). + +## Quick examples +- Add client description persists to DB and publishes group via MQTT: see `PUT /api/clients//description` in `routes/clients.py`. +- Bulk group assignment emits retained messages for each client: `PUT /api/clients/group`. +- Listener heartbeat path: `infoscreen//heartbeat` → sets `clients.last_alive`. + +Questions or unclear areas? Tell us if you need: exact devcontainer debugging steps, stricter Alembic workflow, or a seed dataset beyond `init_defaults.py`. + +## Academic Periods System +- **Purpose**: Organize events and media by educational cycles (school years, semesters, trimesters). +- **Design**: Fully backward compatible - existing events/media continue to work without period assignment. +- **Usage**: New events/media can optionally reference `academic_period_id` for better organization and filtering. +- **Constraints**: Only one period can be active at a time; use `init_academic_periods.py` for Austrian school year setup. + - **UI Integration**: The dashboard highlights the currently selected period and whether a holiday plan exists within that date range. Holiday linkage currently uses date overlap with `school_holidays`; an explicit `academic_period_id` on `school_holidays` can be added later if tighter association is required. diff --git a/.stylelintrc.json b/.stylelintrc.json new file mode 100644 index 0000000..2d7638d --- /dev/null +++ b/.stylelintrc.json @@ -0,0 +1,6 @@ +{ + "extends": [ + "stylelint-config-standard", + "stylelint-config-tailwindcss" + ] +} diff --git a/AI-INSTRUCTIONS-MAINTENANCE.md b/AI-INSTRUCTIONS-MAINTENANCE.md new file mode 100644 index 0000000..e0c5361 --- /dev/null +++ b/AI-INSTRUCTIONS-MAINTENANCE.md @@ -0,0 +1,100 @@ +# Maintaining AI Assistant Instructions (copilot-instructions.md) + +This repo uses `.github/copilot-instructions.md` to brief AI coding agents about your architecture, workflows, and conventions. Keep it concise, repo-specific, and always in sync with your code. + +This guide explains when and how to update it, plus small guardrails to help—even for a solo developer. + +## When to update +Update the instructions in the same commit as your change whenever you: +- Add/rename services, ports, or container wiring (docker-compose*.yml, Nginx, Mosquitto) +- Introduce/rename MQTT topics or change retained-message behavior +- Add/rename environment variables or change defaults (`.env.example`, `deployment.md`) +- Change DB models or time/UTC handling (e.g., `models/models.py`, UTC normalization in routes/scheduler) +- Add/modify API route patterns or session lifecycle (files in `server/routes/*`, `server/wsgi.py`) +- Adjust frontend dev proxy or build settings (`dashboard/vite.config.ts`, Dockerfiles) + +## What to update (and where) +- `.github/copilot-instructions.md` + - Big picture: services and ports + - Service boundaries & data flow: DB connection rules, MQTT topics, retained messages, screenshots + - API patterns: Blueprints, Session per request, enum/datetime serialization + - Frontend patterns: Vite dev proxy and pre-bundled dependencies + - Environment variables (reference): names, purposes, example patterns + - Conventions & gotchas: UTC comparisons, retained MQTT, container hostnames +- `.env.example` + - Add new variable names with placeholders and comments (never secrets) + - Keep in-container defaults (e.g., `DB_HOST=db`, `MQTT_BROKER_HOST=mqtt`) +- `deployment.md` + - Update Quickstart URLs/ports/commands + - Document prod-specific env usage (e.g., `VITE_API_URL`, `DB_CONN`) + +## How to write good updates +- Keep it short (approx. 20–50 lines total). Link to code by path or route rather than long prose. +- Document real, present patterns—not plans. +- Use UTC consistently and call out any special handling. +- Include concrete examples from this repo when describing patterns (e.g., which route shows enum serialization). +- Never include secrets or real tokens; show only variable names and example formats. + +## Solo-friendly workflow +- Update docs in the same commit as your change: + - Code changed → docs changed (copilot-instructions, `.env.example`, `deployment.md` as needed) +- Use a quick self-checklist before pushing: + - Services/ports changed? Update “Big picture”. + - MQTT topics/retained behavior changed? Update “Service boundaries & data flow”. + - API/Session/UTC rules changed? Update “API patterns” and “Conventions & gotchas”. + - Frontend proxy/build changed? Update “Frontend patterns”. + - Env vars changed? Update “Environment variables (reference)” + `.env.example`. + - Dev/prod run steps changed? Update `deployment.md` Quickstart. +- Keep commits readable by pairing code and doc changes: + - `feat(api): add events endpoint; docs: update routes and UTC note` + - `chore(compose): rename service; docs: update ports + nginx` + - `docs(env): add MQTT_USER to .env.example + instructions` + +## Optional guardrails (even for solo) +- PR (or MR) template (useful even if you self-merge) + - Add `.github/pull_request_template.md` with: +``` +Checklist +- [ ] Updated .github/copilot-instructions.md (services/MQTT/API/UTC/env) +- [ ] Synced .env.example (new/renamed vars) +- [ ] Adjusted deployment.md (dev/prod steps, URLs/ports) +- [ ] Verified referenced files/paths in the instructions exist +``` +- Lightweight docs check (optional pre-commit hook) + - Non-blocking script that warns if referenced files/paths don’t exist. Example sketch: +``` +#!/usr/bin/env bash +set -e +FILE=.github/copilot-instructions.md +missing=0 +for path in \ + server/wsgi.py \ + server/routes/clients.py \ + server/routes/events.py \ + server/routes/groups.py \ + dashboard/vite.config.ts \ + docker-compose.yml \ + docker-compose.override.yml; do + if ! test -e "$path"; then + echo "[warn] referenced path not found: $path"; missing=1 + fi +done +exit 0 # warn only; do not block commit +``` +- Weekly 2-minute sweep + - Read `.github/copilot-instructions.md` top-to-bottom and remove anything stale. + +## FAQ +- Where do the AI assistants look? + - `.github/copilot-instructions.md` + the code you have open. Keep this file synced with the codebase. +- Is it safe to commit this file? + - Yes—no secrets. It should contain only structure, patterns, and example formats. +- How detailed should it be? + - Concise and actionable; point to exact files for details. Avoid generic advice. + +## Pointers to key files +- Compose & infra: `docker-compose*.yml`, `nginx.conf`, `mosquitto/config/mosquitto.conf` +- Backend: `server/database.py`, `server/wsgi.py`, `server/routes/*`, `models/models.py` +- MQTT workers: `listener/listener.py`, `scheduler/scheduler.py`, `server/mqtt_helper.py` +- Frontend: `dashboard/vite.config.ts`, `dashboard/package.json`, `dashboard/src/*` +- Dev/Prod docs: `deployment.md`, `.env.example` diff --git a/CLEANUP_SUMMARY.md b/CLEANUP_SUMMARY.md new file mode 100644 index 0000000..ddf06e1 --- /dev/null +++ b/CLEANUP_SUMMARY.md @@ -0,0 +1,39 @@ +# Database Cleanup Summary + +## Files Removed ✅ + +The following obsolete database initialization files have been removed: + +### Removed Files: +- **`server/init_database.py`** - Manual table creation (superseded by Alembic migrations) +- **`server/init_db.py`** - Alternative initialization (superseded by `init_defaults.py`) +- **`server/init_mariadb.py`** - Database/user creation (handled by Docker Compose) +- **`server/test_sql.py`** - Outdated connection test (used localhost instead of container) + +### Why These Were Safe to Remove: +1. **No references found** in any Docker files, scripts, or code +2. **Functionality replaced** by modern Alembic-based approach +3. **Hardcoded connection strings** that don't match current Docker setup +4. **Manual processes** now automated in production deployment + +## Current Database Management ✅ + +### Active Scripts: +- **`server/initialize_database.py`** - Complete initialization (NEW) +- **`server/init_defaults.py`** - Default data creation +- **`server/init_academic_periods.py`** - Academic periods setup +- **`alembic/`** - Schema migrations (version control) + +### Development Scripts (Kept): +- **`server/dummy_clients.py`** - Test client data generation +- **`server/dummy_events.py`** - Test event data generation +- **`server/sync_existing_clients.py`** - MQTT synchronization utility + +## Result + +- **4 obsolete files removed** +- **Documentation updated** to reflect current state +- **No breaking changes** - all functionality preserved +- **Cleaner codebase** with single initialization path + +The database initialization process is now streamlined and uses only modern, maintained approaches. \ No newline at end of file diff --git a/DATABASE_GUIDE.md b/DATABASE_GUIDE.md new file mode 100644 index 0000000..569d7c6 --- /dev/null +++ b/DATABASE_GUIDE.md @@ -0,0 +1,147 @@ +# Database Initialization and Management Guide + +## Quick Start + +Your database has been successfully initialized! Here's what you need to know: + +### ✅ Current Status +- **Database**: MariaDB 11.2 running in Docker container `infoscreen-db` +- **Schema**: Up to date (Alembic revision: `b5a6c3d4e7f8`) +- **Default Data**: Admin user and client group created +- **Academic Periods**: Austrian school years 2024/25 (active), 2025/26, 2026/27 + +### 🔐 Default Credentials +- **Admin Username**: `infoscreen_admin` +- **Admin Password**: Check your `.env` file for `DEFAULT_ADMIN_PASSWORD` +- **Database User**: `infoscreen_admin` +- **Database Name**: `infoscreen_by_taa` + +## Database Management Commands + +### Initialize/Reinitialize Database +```bash +cd /workspace/server +python initialize_database.py +``` + +### Check Migration Status +```bash +cd /workspace/server +alembic current +alembic history --verbose +``` + +### Run Migrations Manually +```bash +cd /workspace/server +alembic upgrade head # Apply all pending migrations +alembic upgrade +1 # Apply next migration +alembic downgrade -1 # Rollback one migration +``` + +### Create New Migration +```bash +cd /workspace/server +alembic revision --autogenerate -m "Description of changes" +``` + +### Database Connection Test +```bash +cd /workspace/server +python -c " +from database import Session +session = Session() +print('✅ Database connection successful') +session.close() +" +``` + +## Initialization Scripts + +### Core Scripts (recommended order): +1. **`alembic upgrade head`** - Apply database schema migrations +2. **`init_defaults.py`** - Create default user groups and admin user +3. **`init_academic_periods.py`** - Set up Austrian school year periods + +### All-in-One Script: +- **`initialize_database.py`** - Complete database initialization (runs all above scripts) + +### Development/Testing Scripts: +- **`dummy_clients.py`** - Creates test client data for development +- **`dummy_events.py`** - Creates test event data for development +- **`sync_existing_clients.py`** - One-time MQTT sync for existing clients + +## Database Schema Overview + +### Main Tables: +- **`users`** - User authentication and roles +- **`clients`** - Registered client devices +- **`client_groups`** - Client organization groups +- **`events`** - Scheduled events and presentations +- **`event_media`** - Media files for events +- **`conversions`** - File conversion jobs (PPT → PDF) +- **`academic_periods`** - School year/semester management +- **`school_holidays`** - Holiday calendar +- **`alembic_version`** - Migration tracking + +### Environment Variables: +```bash +DB_CONN=mysql+pymysql://infoscreen_admin:KqtpM7wmNdM1DamFKs@db/infoscreen_by_taa +DB_USER=infoscreen_admin +DB_PASSWORD=KqtpM7wmNdM1DamFKs +DB_NAME=infoscreen_by_taa +DB_HOST=db +``` + +## Troubleshooting + +### Database Connection Issues: +```bash +# Check if database container is running +docker ps | grep db + +# Check database logs +docker logs infoscreen-db + +# Test direct connection +docker exec -it infoscreen-db mysql -u infoscreen_admin -p infoscreen_by_taa +``` + +### Migration Issues: +```bash +# Check current state +cd /workspace/server && alembic current + +# Show migration history +cd /workspace/server && alembic history + +# Show pending migrations +cd /workspace/server && alembic show head +``` + +### Reset Database (⚠️ DESTRUCTIVE): +```bash +# Stop services +docker-compose down + +# Remove database volume +docker volume rm infoscreen_2025_db-data + +# Restart and reinitialize +docker-compose up -d db +cd /workspace/server && python initialize_database.py +``` + +## Production Deployment + +The production setup in `docker-compose.prod.yml` includes automatic database initialization: + +```yaml +server: + command: > + bash -c "alembic -c /app/server/alembic.ini upgrade head && + python /app/server/init_defaults.py && + exec gunicorn server.wsgi:app --bind 0.0.0.0:8000" +``` + +This ensures the database is properly initialized on every deployment. \ No newline at end of file diff --git a/GPU25_26_mit_Herbstferien.TXT b/GPU25_26_mit_Herbstferien.TXT new file mode 100644 index 0000000..2138b05 --- /dev/null +++ b/GPU25_26_mit_Herbstferien.TXT @@ -0,0 +1,18 @@ +"2.11.","Allerseelen",20251102,20251102, +"Ferien2","Weihnachtsferien",20251224,20260106, +"Ferien3","Semesterferien",20260216,20260222, +"Ferien4_2","Osterferien",20260328,20260406, +"Ferien4","Hl. Florian",20260504,20260504, +"26.10.","Nationalfeiertag",20251026,20251026,"F" +"27.10.","Herbstferien",20251027,20251027,"F" +"28.10.","Herbstferien",20251028,20251028,"F" +"29.10.","Herbstferien",20251029,20251029,"F" +"30.10.","Herbstferien",20251030,20251030,"F" +"31.10.","Herbstferien",20251031,20251031,"F" +"1.11.","Allerheiligen",20251101,20251101,"F" +"8.12.","Mari Empfngnis",20251208,20251208,"F" +"1.5.","Staatsfeiertag",20260501,20260501,"F" +"14.5.","Christi Himmelfahrt",20260514,20260514,"F" +"24.5.","Pfingstsonntag",20260524,20260524,"F" +"25.5.","Pfingstmontag",20260525,20260525,"F" +"4.6.","Fronleichnam",20260604,20260604,"F" diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..88c51ec --- /dev/null +++ b/Makefile @@ -0,0 +1,92 @@ +# Makefile for infoscreen_2025 +# Usage: run `make help` to see available targets. + +# Default compose files +COMPOSE_FILES=-f docker-compose.yml -f docker-compose.override.yml +COMPOSE=docker compose $(COMPOSE_FILES) + +# Registry and image names (adjust if needed) +REGISTRY=ghcr.io/robbstarkaustria +API_IMAGE=$(REGISTRY)/infoscreen-api:latest +DASH_IMAGE=$(REGISTRY)/infoscreen-dashboard:latest +LISTENER_IMAGE=$(REGISTRY)/infoscreen-listener:latest +SCHED_IMAGE=$(REGISTRY)/infoscreen-scheduler:latest + +.PHONY: help +help: + @echo "Available targets:" + @echo " up - Start dev stack (compose + override)" + @echo " down - Stop dev stack" + @echo " logs - Tail logs for all services" + @echo " logs-% - Tail logs for a specific service (e.g., make logs-server)" + @echo " build - Build all images locally" + @echo " push - Push built images to GHCR" + @echo " pull-prod - Pull prod images from GHCR" + @echo " up-prod - Start prod stack (docker-compose.prod.yml)" + @echo " down-prod - Stop prod stack" + @echo " health - Quick health checks" + @echo " fix-perms - Recursively chown workspace to current user" + + +# ---------- Development stack ---------- +.PHONY: up +up: ## Start dev stack + $(COMPOSE) up -d --build + +.PHONY: down +down: ## Stop dev stack + $(COMPOSE) down + +.PHONY: logs +logs: ## Tail logs for all services + $(COMPOSE) logs -f + +.PHONY: logs-% +logs-%: ## Tail logs for a specific service, e.g. `make logs-server` + $(COMPOSE) logs -f $* + +# ---------- Images: build/push ---------- +.PHONY: build +build: ## Build all images locally + docker build -f server/Dockerfile -t $(API_IMAGE) . + docker build -f dashboard/Dockerfile -t $(DASH_IMAGE) . + docker build -f listener/Dockerfile -t $(LISTENER_IMAGE) . + docker build -f scheduler/Dockerfile -t $(SCHED_IMAGE) . + +.PHONY: push +push: ## Push all images to GHCR + docker push $(API_IMAGE) + docker push $(DASH_IMAGE) + docker push $(LISTENER_IMAGE) + docker push $(SCHED_IMAGE) + +# ---------- Production stack ---------- +PROD_COMPOSE=docker compose -f docker-compose.prod.yml + +.PHONY: pull-prod +pull-prod: ## Pull prod images + $(PROD_COMPOSE) pull + +.PHONY: up-prod +up-prod: ## Start prod stack + $(PROD_COMPOSE) up -d + +.PHONY: down-prod +down-prod: ## Stop prod stack + $(PROD_COMPOSE) down + +# ---------- Health ---------- +.PHONY: health +health: ## Quick health checks + @echo "API health:" && curl -fsS http://localhost:8000/health || true + @echo "Dashboard (dev):" && curl -fsS http://localhost:5173/ || true + @echo "MQTT TCP 1883:" && nc -z localhost 1883 && echo OK || echo FAIL + @echo "MQTT WS 9001:" && nc -z localhost 9001 && echo OK || echo FAIL + +# ---------- Permissions ---------- +.PHONY: fix-perms +fix-perms: + @echo "Fixing ownership to current user recursively (may prompt for sudo password)..." + sudo chown -R $$(id -u):$$(id -g) . + @echo "Done. Consider adding UID and GID to your .env to prevent future root-owned files:" + @echo " echo UID=$$(id -u) >> .env && echo GID=$$(id -g) >> .env" diff --git a/README.md b/README.md new file mode 100644 index 0000000..ec3160f --- /dev/null +++ b/README.md @@ -0,0 +1,407 @@ +# Infoscreen 2025 + +[![Docker](https://img.shields.io/badge/Docker-Multi--Service-blue?logo=docker)](https://www.docker.com/) +[![React](https://img.shields.io/badge/React-19.1.0-61DAFB?logo=react)](https://reactjs.org/) +[![Flask](https://img.shields.io/badge/Flask-REST_API-green?logo=flask)](https://flask.palletsprojects.com/) +[![MariaDB](https://img.shields.io/badge/MariaDB-11.2-003545?logo=mariadb)](https://mariadb.org/) +[![MQTT](https://img.shields.io/badge/MQTT-Eclipse_Mosquitto-purple)](https://mosquitto.org/) + +A comprehensive multi-service digital signage solution for educational institutions, featuring client management, event scheduling, presentation conversion, and real-time MQTT communication. + +## 🏗️ Architecture Overview + +``` +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ Dashboard │ │ API Server │ │ Listener │ +│ (React/Vite) │◄──►│ (Flask) │◄──►│ (MQTT Client) │ +└─────────────────┘ └─────────────────┘ └─────────────────┘ + │ │ │ + │ ▼ │ + │ ┌─────────────────┐ │ + │ │ MariaDB │ │ + │ │ (Database) │ │ + │ └─────────────────┘ │ + │ │ + └────────────────────┬───────────────────────────┘ + ▼ + ┌─────────────────┐ + │ MQTT Broker │ + │ (Mosquitto) │ + └─────────────────┘ + │ + ┌────────────────────┼────────────────────┐ + ▼ ▼ ▼ +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ Scheduler │ │ Worker │ │ Infoscreen │ +│ (Events) │ │ (Conversions) │ │ Clients │ +└─────────────────┘ └─────────────────┘ └─────────────────┘ +``` + +## 🌟 Key Features + +### 📊 **Dashboard Management** +- Modern React-based web interface with Syncfusion components +- Real-time client monitoring and group management +- Event scheduling with academic period support +- Media management with presentation conversion +- Holiday calendar integration + +### 🎯 **Event System** +- **Presentations**: PowerPoint/LibreOffice → PDF conversion via Gotenberg +- **Websites**: URL-based content display +- **Videos**: Media file streaming +- **Messages**: Text announcements +- **WebUntis**: Educational schedule integration + +### 🏫 **Academic Period Management** +- Support for school years, semesters, and trimesters +- Austrian school system integration +- Holiday calendar synchronization +- Period-based event organization + +### 📡 **Real-time Communication** +- MQTT-based client discovery and heartbeat monitoring +- Retained topics for reliable state synchronization +- WebSocket support for browser clients +- Automatic client group assignment + +### 🔄 **Background Processing** +- Redis-based job queues for presentation conversion +- Gotenberg integration for LibreOffice/PowerPoint processing +- Asynchronous file processing with status tracking +- RQ (Redis Queue) worker management + +## 🚀 Quick Start + +### Prerequisites +- Docker & Docker Compose +- Git +- SSL certificates (for production) + +### Development Setup + +1. **Clone the repository** + ```bash + git clone + cd infoscreen_2025 + ``` + +2. **Environment Configuration** + ```bash + cp .env.example .env + # Edit .env with your configuration + ``` + +3. **Start the development stack** + ```bash + make up + # or: docker compose up -d --build + ``` + +4. **Access the services** + - Dashboard: http://localhost:5173 + - API: http://localhost:8000 + - Database: localhost:3306 + - MQTT: localhost:1883 (WebSocket: 9001) + +### Production Deployment + +1. **Build and push images** + ```bash + make build + make push + ``` + +2. **Deploy on server** + ```bash + make pull-prod + make up-prod + ``` + +For detailed deployment instructions, see: +- [Debian Deployment Guide](deployment-debian.md) +- [Ubuntu Deployment Guide](deployment-ubuntu.md) + +## 🛠️ Services + +### 🖥️ **Dashboard** (`dashboard/`) +- **Technology**: React 19 + TypeScript + Vite +- **UI Framework**: Syncfusion components + Tailwind CSS +- **Features**: Responsive design, real-time updates, file management +- **Port**: 5173 (dev), served via Nginx (prod) + +### 🔧 **API Server** (`server/`) +- **Technology**: Flask + SQLAlchemy + Alembic +- **Database**: MariaDB with timezone-aware timestamps +- **Features**: RESTful API, file uploads, MQTT integration +- **Port**: 8000 +- **Health Check**: `/health` + +### 👂 **Listener** (`listener/`) +- **Technology**: Python + paho-mqtt +- **Purpose**: MQTT message processing, client discovery +- **Features**: Heartbeat monitoring, automatic client registration + +### ⏰ **Scheduler** (`scheduler/`) +- **Technology**: Python + SQLAlchemy +- **Purpose**: Event publishing, group-based content distribution +- **Features**: Time-based event activation, MQTT publishing + +### 🔄 **Worker** (Conversion Service) +- **Technology**: RQ (Redis Queue) + Gotenberg +- **Purpose**: Background presentation conversion +- **Features**: PPT/PPTX/ODP → PDF conversion, status tracking + +### 🗄️ **Database** (MariaDB 11.2) +- **Features**: Health checks, automatic initialization +- **Migrations**: Alembic-based schema management +- **Timezone**: UTC-aware timestamps + +### 📡 **MQTT Broker** (Eclipse Mosquitto 2.0.21) +- **Features**: WebSocket support, health monitoring +- **Topics**: + - `infoscreen/discovery` - Client registration + - `infoscreen/{uuid}/heartbeat` - Client alive status + - `infoscreen/events/{group_id}` - Event distribution + - `infoscreen/{uuid}/group_id` - Client group assignment + +## 📁 Project Structure + +``` +infoscreen_2025/ +├── dashboard/ # React frontend +│ ├── src/ # React components and logic +│ ├── public/ # Static assets +│ └── Dockerfile # Production build +├── server/ # Flask API backend +│ ├── routes/ # API endpoints +│ ├── alembic/ # Database migrations +│ ├── media/ # File storage +│ └── worker.py # Background jobs +├── listener/ # MQTT listener service +├── scheduler/ # Event scheduling service +├── models/ # Shared database models +├── mosquitto/ # MQTT broker configuration +├── certs/ # SSL certificates +├── docker-compose.yml # Development setup +├── docker-compose.prod.yml # Production setup +└── Makefile # Development shortcuts +``` + +## 🔧 Development + +### Available Commands + +```bash +# Development +make up # Start dev stack +make down # Stop dev stack +make logs # View all logs +make logs-server # View specific service logs + +# Building & Deployment +make build # Build all images +make push # Push to registry +make pull-prod # Pull production images +make up-prod # Start production stack + +# Maintenance +make health # Health checks +make fix-perms # Fix file permissions +``` + +### Database Management + +```bash +# Access database directly +docker exec -it infoscreen-db mysql -u${DB_USER} -p${DB_PASSWORD} ${DB_NAME} + +# Run migrations +docker exec -it infoscreen-api alembic upgrade head + +# Initialize academic periods (Austrian school system) +docker exec -it infoscreen-api python init_academic_periods.py +``` + +### MQTT Testing + +```bash +# Subscribe to all topics +mosquitto_sub -h localhost -t "infoscreen/#" -v + +# Publish test message +mosquitto_pub -h localhost -t "infoscreen/test" -m "Hello World" + +# Monitor client heartbeats +mosquitto_sub -h localhost -t "infoscreen/+/heartbeat" -v +``` + +## 🌐 API Endpoints + +### Core Resources +- `GET /api/clients` - List all registered clients +- `PUT /api/clients/{uuid}/group` - Assign client to group +- `GET /api/groups` - List client groups with alive status +- `GET /api/events` - List events with filtering +- `POST /api/events` - Create new event +- `GET /api/academic_periods` - List academic periods +- `POST /api/academic_periods/active` - Set active period + +### File Management +- `POST /api/files` - Upload media files +- `GET /api/files/{path}` - Download files +- `GET /api/files/converted/{path}` - Download converted PDFs +- `POST /api/conversions/{media_id}/pdf` - Request conversion +- `GET /api/conversions/{media_id}/status` - Check conversion status + +### Health & Monitoring +- `GET /health` - Service health check +- `GET /api/screenshots/{uuid}.jpg` - Client screenshots + +## 🎨 Frontend Features + +### Syncfusion Components Used +- **Schedule**: Event calendar with drag-drop support +- **Grid**: Data tables with filtering and sorting +- **DropDownList**: Group and period selectors +- **FileManager**: Media upload and organization +- **Kanban**: Task management views +- **Notifications**: Toast messages and alerts + +### Pages Overview +- **Dashboard**: System overview and statistics +- **Clients**: Device management and monitoring +- **Groups**: Client group organization +- **Events**: Schedule management +- **Media**: File upload and conversion +- **Settings**: System configuration +- **Holidays**: Academic calendar management + +## 🔒 Security & Authentication + +- **Environment Variables**: Sensitive data via `.env` +- **SSL/TLS**: HTTPS support with custom certificates +- **MQTT Security**: Username/password authentication +- **Database**: Parameterized queries, connection pooling +- **File Uploads**: Type validation, size limits +- **CORS**: Configured for production deployment + +## 📊 Monitoring & Logging + +### Health Checks +All services include Docker health checks: +- API: HTTP endpoint monitoring +- Database: Connection and initialization status +- MQTT: Pub/sub functionality test +- Dashboard: Nginx availability + +### Logging Strategy +- **Development**: Docker Compose logs with service prefixes +- **Production**: Centralized logging via Docker log drivers +- **MQTT**: Message-level debugging available +- **Database**: Query logging in development mode + +## 🌍 Deployment Options + +### Development +- **Hot Reload**: Vite dev server + Flask debug mode +- **Volume Mounts**: Live code editing +- **Debug Ports**: Python debugger support (port 5678) +- **Local Certificates**: Self-signed SSL for testing + +### Production +- **Optimized Builds**: Multi-stage Dockerfiles +- **Reverse Proxy**: Nginx with SSL termination +- **Health Monitoring**: Comprehensive healthchecks +- **Registry**: GitHub Container Registry integration +- **Scaling**: Docker Compose for single-node deployment + +## 🤝 Contributing + +1. Fork the repository +2. Create a feature branch: `git checkout -b feature/amazing-feature` +3. Commit your changes: `git commit -m 'Add amazing feature'` +4. Push to the branch: `git push origin feature/amazing-feature` +5. Open a Pull Request + +### Development Guidelines +- Follow existing code patterns and naming conventions +- Add appropriate tests for new features +- Update documentation for API changes +- Use TypeScript for frontend development +- Follow Python PEP 8 for backend code + +## 📋 Requirements + +### System Requirements +- **CPU**: 2+ cores recommended +- **RAM**: 4GB minimum, 8GB recommended +- **Storage**: 20GB+ for media files and database +- **Network**: Reliable internet for client communication + +### Software Dependencies +- Docker 24.0+ +- Docker Compose 2.0+ +- Git 2.30+ +- Modern web browser (Chrome, Firefox, Safari, Edge) + +## 🐛 Troubleshooting + +### Common Issues + +**Services won't start** +```bash +# Check service health +make health + +# View specific service logs +make logs-server +make logs-db +``` + +**Database connection errors** +```bash +# Verify database is running +docker exec -it infoscreen-db mysqladmin ping + +# Check credentials in .env file +# Restart dependent services +``` + +**MQTT communication issues** +```bash +# Test MQTT broker +mosquitto_pub -h localhost -t test -m "hello" + +# Check client certificates and credentials +# Verify firewall settings for ports 1883/9001 +``` + +**File conversion problems** +```bash +# Check Gotenberg service +curl http://localhost:3000/health + +# Monitor worker logs +make logs-worker + +# Check Redis queue status +docker exec -it infoscreen-redis redis-cli LLEN conversions +``` + +## 📄 License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. + +## 🙏 Acknowledgments + +- **Syncfusion**: UI components for React dashboard +- **Eclipse Mosquitto**: MQTT broker implementation +- **Gotenberg**: Document conversion service +- **MariaDB**: Reliable database engine +- **Flask**: Python web framework +- **React**: Frontend user interface library + +--- + +For detailed technical documentation, deployment guides, and API specifications, please refer to the additional documentation files in this repository. diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/create_init_files.py b/create_init_files.py new file mode 100644 index 0000000..54383fc --- /dev/null +++ b/create_init_files.py @@ -0,0 +1,17 @@ +import os + +folders = [ + "server", + "dashboard", + "dashboard/callbacks", + "dashboard/utils", +] + +for folder in folders: + path = os.path.join(os.getcwd(), folder, "__init__.py") + if not os.path.exists(path): + with open(path, "w") as f: + pass # Leere Datei anlegen + print(f"Angelegt: {path}") + else: + print(f"Existiert bereits: {path}") \ No newline at end of file diff --git a/dashboard/.dockerignore b/dashboard/.dockerignore new file mode 100644 index 0000000..b512c09 --- /dev/null +++ b/dashboard/.dockerignore @@ -0,0 +1 @@ +node_modules \ No newline at end of file diff --git a/dashboard/.eslintrc.cjs b/dashboard/.eslintrc.cjs new file mode 100644 index 0000000..4dd6d54 --- /dev/null +++ b/dashboard/.eslintrc.cjs @@ -0,0 +1,34 @@ +module.exports = { + root: true, + env: { + browser: true, + es2021: true, + }, + extends: [ + 'eslint:recommended', + 'plugin:react/recommended', + 'plugin:react-hooks/recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:prettier/recommended' + ], + parser: '@typescript-eslint/parser', + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + ecmaFeatures: { + jsx: true, + }, + }, + plugins: ['react', '@typescript-eslint'], + settings: { + react: { + version: 'detect', + }, + }, + rules: { + // Beispiele für sinnvolle Anpassungen + 'react/react-in-jsx-scope': 'off', // nicht nötig mit React 17+ + '@typescript-eslint/explicit-module-boundary-types': 'off', + '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }], + }, +}; diff --git a/dashboard/.prettierrc b/dashboard/.prettierrc new file mode 100644 index 0000000..de3ce27 --- /dev/null +++ b/dashboard/.prettierrc @@ -0,0 +1,9 @@ +{ + "semi": true, + "singleQuote": true, + "trailingComma": "es5", + "tabWidth": 2, + "printWidth": 100, + "bracketSpacing": true, + "arrowParens": "avoid" +} diff --git a/dashboard/.stylelintrc.json b/dashboard/.stylelintrc.json new file mode 100644 index 0000000..32d162c --- /dev/null +++ b/dashboard/.stylelintrc.json @@ -0,0 +1,9 @@ +{ + "extends": [ + "stylelint-config-standard", + "stylelint-config-tailwindcss" + ], + "rules": { + "at-rule-no-unknown": null + } +} diff --git a/dashboard/Dockerfile b/dashboard/Dockerfile new file mode 100644 index 0000000..0db1d7e --- /dev/null +++ b/dashboard/Dockerfile @@ -0,0 +1,25 @@ +# ========================================== +# dashboard/Dockerfile (Production) +# ========================================== + +FROM node:20-alpine AS build +WORKDIR /app + +# Kopiere package.json und Lockfile aus dem Build-Kontext (./dashboard) +COPY package*.json ./ + +# Produktions-Abhängigkeiten installieren +ENV NODE_ENV=production +RUN npm ci --omit=dev + +# Quellcode kopieren und builden +COPY . . +ARG VITE_API_URL +ENV VITE_API_URL=${VITE_API_URL} +RUN npm run build + +FROM nginx:1.25-alpine +COPY --from=build /app/dist /usr/share/nginx/html +EXPOSE 80 +CMD ["nginx", " -g", "daemon off;"] + diff --git a/dashboard/Dockerfile.dev b/dashboard/Dockerfile.dev new file mode 100644 index 0000000..f607348 --- /dev/null +++ b/dashboard/Dockerfile.dev @@ -0,0 +1,28 @@ +# ========================================== +# dashboard/Dockerfile.dev (Development) +# 🔧 OPTIMIERT: Für schnelle Entwicklung mit Vite und npm +# ========================================== +FROM node:20-alpine + +# Stelle sicher, dass benötigte Tools verfügbar sind (z. B. für wait-for-backend.sh) +RUN apk add --no-cache curl + +# Setze Arbeitsverzeichnis direkt auf das Dashboard-Verzeichnis im Container +# (Der Build-Kontext ist ./dashboard, siehe docker-compose.override.yml) +WORKDIR /workspace/dashboard + +# KOPIEREN: Nur package-Dateien relativ zum Build-Kontext (KEINE /workspace-Pfade) +# package*.json deckt sowohl package.json als auch package-lock.json ab, falls vorhanden +COPY package*.json ./ + +# Installation robust machen: npm ci erfordert package-lock.json; fallback auf npm install +RUN if [ -f package-lock.json ]; then \ + npm ci --legacy-peer-deps; \ + else \ + npm install --legacy-peer-deps; \ + fi && \ + npm cache clean --force + +EXPOSE 5173 9230 + +CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5173"] diff --git a/dashboard/README.md b/dashboard/README.md new file mode 100644 index 0000000..da98444 --- /dev/null +++ b/dashboard/README.md @@ -0,0 +1,54 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: + +```js +export default tseslint.config({ + extends: [ + // Remove ...tseslint.configs.recommended and replace with this + ...tseslint.configs.recommendedTypeChecked, + // Alternatively, use this for stricter rules + ...tseslint.configs.strictTypeChecked, + // Optionally, add this for stylistic rules + ...tseslint.configs.stylisticTypeChecked, + ], + languageOptions: { + // other options... + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + }, +}) +``` + +You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: + +```js +// eslint.config.js +import reactX from 'eslint-plugin-react-x' +import reactDom from 'eslint-plugin-react-dom' + +export default tseslint.config({ + plugins: { + // Add the react-x and react-dom plugins + 'react-x': reactX, + 'react-dom': reactDom, + }, + rules: { + // other rules... + // Enable its recommended typescript rules + ...reactX.configs['recommended-typescript'].rules, + ...reactDom.configs.recommended.rules, + }, +}) +``` diff --git a/dashboard/eslint.config.js b/dashboard/eslint.config.js new file mode 100644 index 0000000..092408a --- /dev/null +++ b/dashboard/eslint.config.js @@ -0,0 +1,28 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' + +export default tseslint.config( + { ignores: ['dist'] }, + { + extends: [js.configs.recommended, ...tseslint.configs.recommended], + files: ['**/*.{ts,tsx}'], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + plugins: { + 'react-hooks': reactHooks, + 'react-refresh': reactRefresh, + }, + rules: { + ...reactHooks.configs.recommended.rules, + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + }, + }, +) diff --git a/dashboard/index.html b/dashboard/index.html new file mode 100644 index 0000000..e4b78ea --- /dev/null +++ b/dashboard/index.html @@ -0,0 +1,13 @@ + + + + + + + Vite + React + TS + + +
+ + + diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json new file mode 100644 index 0000000..d3b97e3 --- /dev/null +++ b/dashboard/package-lock.json @@ -0,0 +1,8177 @@ +{ + "name": "dashboard", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "dashboard", + "version": "0.0.0", + "dependencies": { + "@syncfusion/ej2-base": "^30.2.0", + "@syncfusion/ej2-buttons": "^30.2.0", + "@syncfusion/ej2-calendars": "^30.2.0", + "@syncfusion/ej2-dropdowns": "^30.2.0", + "@syncfusion/ej2-grids": "^30.2.0", + "@syncfusion/ej2-icons": "^30.2.0", + "@syncfusion/ej2-inputs": "^30.2.0", + "@syncfusion/ej2-kanban": "^30.2.0", + "@syncfusion/ej2-layouts": "^30.2.0", + "@syncfusion/ej2-lists": "^30.2.0", + "@syncfusion/ej2-navigations": "^30.2.0", + "@syncfusion/ej2-notifications": "^30.2.0", + "@syncfusion/ej2-popups": "^30.2.0", + "@syncfusion/ej2-react-base": "^30.2.0", + "@syncfusion/ej2-react-buttons": "^30.2.0", + "@syncfusion/ej2-react-calendars": "^30.2.0", + "@syncfusion/ej2-react-dropdowns": "^30.2.0", + "@syncfusion/ej2-react-filemanager": "^30.2.0", + "@syncfusion/ej2-react-grids": "^30.2.0", + "@syncfusion/ej2-react-inputs": "^30.2.0", + "@syncfusion/ej2-react-kanban": "^30.2.0", + "@syncfusion/ej2-react-layouts": "^30.2.0", + "@syncfusion/ej2-react-navigations": "^30.2.0", + "@syncfusion/ej2-react-notifications": "^30.2.0", + "@syncfusion/ej2-react-popups": "^30.2.0", + "@syncfusion/ej2-react-schedule": "^30.2.0", + "@syncfusion/ej2-splitbuttons": "^30.2.0", + "cldr-data": "^36.0.4", + "lucide-react": "^0.522.0", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "react-router-dom": "^7.6.2" + }, + "devDependencies": { + "@eslint/js": "^9.25.0", + "@tailwindcss/aspect-ratio": "^0.4.2", + "@tailwindcss/forms": "^0.5.10", + "@tailwindcss/typography": "^0.5.16", + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", + "@types/react-router-dom": "^5.3.3", + "@typescript-eslint/eslint-plugin": "^8.34.1", + "@typescript-eslint/parser": "^8.34.1", + "@vitejs/plugin-react": "^4.4.1", + "autoprefixer": "^10.4.21", + "eslint": "^9.29.0", + "eslint-config-prettier": "^10.1.5", + "eslint-plugin-prettier": "^5.5.0", + "eslint-plugin-react": "^7.37.5", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.19", + "globals": "^16.0.0", + "postcss": "^8.5.6", + "prettier": "^3.5.3", + "stylelint": "^16.21.0", + "stylelint-config-standard": "^38.0.0", + "stylelint-config-tailwindcss": "^1.0.0", + "tailwindcss": "^3.4.17", + "typescript": "~5.8.3", + "typescript-eslint": "^8.30.1", + "vite": "^6.3.5" + } + }, + "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.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.4.tgz", + "integrity": "sha512-YsmSKC29MJwf0gF8Rjjrg5LQCmyh+j/nD8/eP7f+BeoQTKYqs9RoWbjGOdy0+1Ekr68RJZMUOPVQaQisnIo4Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz", + "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.3", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.4", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.4", + "@babel/types": "^7.28.4", + "@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/core/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/@babel/generator": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz", + "integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.3", + "@babel/types": "^7.28.2", + "@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.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@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-compilation-targets/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/@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.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "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.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "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.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz", + "integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.4" + }, + "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.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.4.tgz", + "integrity": "sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.3", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.4", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz", + "integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/media-query-list-parser": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@csstools/media-query-list-parser/-/media-query-list-parser-4.0.3.tgz", + "integrity": "sha512-HAYH7d3TLRHDOUQK4mZKf9k9Ph/m8Akstg66ywKR4SFAigjs3yBiUeZtFxywiTm5moZMAp/5W/ZuFnNXXYLuuQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@dual-bundle/import-meta-resolve": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@dual-bundle/import-meta-resolve/-/import-meta-resolve-4.2.1.tgz", + "integrity": "sha512-id+7YRUgoUX6CgV0DtuhirQWodeeA7Lf4i2x71JS/vtA5pRb/hIGWlw+G6MeXvsM+MXrz0VAydTGElX1rAfgPg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/JounQin" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.9.tgz", + "integrity": "sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.9.tgz", + "integrity": "sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.9.tgz", + "integrity": "sha512-IDrddSmpSv51ftWslJMvl3Q2ZT98fUSL2/rlUXuVqRXHCs5EUF1/f+jbjF5+NG9UffUDMCiTyh8iec7u8RlTLg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.9.tgz", + "integrity": "sha512-I853iMZ1hWZdNllhVZKm34f4wErd4lMyeV7BLzEExGEIZYsOzqDWDf+y082izYUE8gtJnYHdeDpN/6tUdwvfiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.9.tgz", + "integrity": "sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.9.tgz", + "integrity": "sha512-jhHfBzjYTA1IQu8VyrjCX4ApJDnH+ez+IYVEoJHeqJm9VhG9Dh2BYaJritkYK3vMaXrf7Ogr/0MQ8/MeIefsPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.9.tgz", + "integrity": "sha512-z93DmbnY6fX9+KdD4Ue/H6sYs+bhFQJNCPZsi4XWJoYblUqT06MQUdBCpcSfuiN72AbqeBFu5LVQTjfXDE2A6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.9.tgz", + "integrity": "sha512-mrKX6H/vOyo5v71YfXWJxLVxgy1kyt1MQaD8wZJgJfG4gq4DpQGpgTB74e5yBeQdyMTbgxp0YtNj7NuHN0PoZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.9.tgz", + "integrity": "sha512-HBU2Xv78SMgaydBmdor38lg8YDnFKSARg1Q6AT0/y2ezUAKiZvc211RDFHlEZRFNRVhcMamiToo7bDx3VEOYQw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.9.tgz", + "integrity": "sha512-BlB7bIcLT3G26urh5Dmse7fiLmLXnRlopw4s8DalgZ8ef79Jj4aUcYbk90g8iCa2467HX8SAIidbL7gsqXHdRw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.9.tgz", + "integrity": "sha512-e7S3MOJPZGp2QW6AK6+Ly81rC7oOSerQ+P8L0ta4FhVi+/j/v2yZzx5CqqDaWjtPFfYz21Vi1S0auHrap3Ma3A==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.9.tgz", + "integrity": "sha512-Sbe10Bnn0oUAB2AalYztvGcK+o6YFFA/9829PhOCUS9vkJElXGdphz0A3DbMdP8gmKkqPmPcMJmJOrI3VYB1JQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.9.tgz", + "integrity": "sha512-YcM5br0mVyZw2jcQeLIkhWtKPeVfAerES5PvOzaDxVtIyZ2NUBZKNLjC5z3/fUlDgT6w89VsxP2qzNipOaaDyA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.9.tgz", + "integrity": "sha512-++0HQvasdo20JytyDpFvQtNrEsAgNG2CY1CLMwGXfFTKGBGQT3bOeLSYE2l1fYdvML5KUuwn9Z8L1EWe2tzs1w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.9.tgz", + "integrity": "sha512-uNIBa279Y3fkjV+2cUjx36xkx7eSjb8IvnL01eXUKXez/CBHNRw5ekCGMPM0BcmqBxBcdgUWuUXmVWwm4CH9kg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.9.tgz", + "integrity": "sha512-Mfiphvp3MjC/lctb+7D287Xw1DGzqJPb/J2aHHcHxflUo+8tmN/6d4k6I2yFR7BVo5/g7x2Monq4+Yew0EHRIA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.9.tgz", + "integrity": "sha512-iSwByxzRe48YVkmpbgoxVzn76BXjlYFXC7NvLYq+b+kDjyyk30J0JY47DIn8z1MO3K0oSl9fZoRmZPQI4Hklzg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.9.tgz", + "integrity": "sha512-9jNJl6FqaUG+COdQMjSCGW4QiMHH88xWbvZ+kRVblZsWrkXlABuGdFJ1E9L7HK+T0Yqd4akKNa/lO0+jDxQD4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.9.tgz", + "integrity": "sha512-RLLdkflmqRG8KanPGOU7Rpg829ZHu8nFy5Pqdi9U01VYtG9Y0zOG6Vr2z4/S+/3zIyOxiK6cCeYNWOFR9QP87g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.9.tgz", + "integrity": "sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.9.tgz", + "integrity": "sha512-1MkgTCuvMGWuqVtAvkpkXFmtL8XhWy+j4jaSO2wxfJtilVCi0ZE37b8uOdMItIHz4I6z1bWWtEX4CJwcKYLcuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.9.tgz", + "integrity": "sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.9.tgz", + "integrity": "sha512-WjH4s6hzo00nNezhp3wFIAfmGZ8U7KtrJNlFMRKxiI9mxEK1scOMAaa9i4crUtu+tBr+0IN6JCuAcSBJZfnphw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.9.tgz", + "integrity": "sha512-mGFrVJHmZiRqmP8xFOc6b84/7xa5y5YvR1x8djzXpJBSv/UsNK6aqec+6JDjConTgvvQefdGhFDAs2DLAds6gQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.9.tgz", + "integrity": "sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.9.tgz", + "integrity": "sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", + "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", + "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.6", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.1.tgz", + "integrity": "sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz", + "integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", + "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "9.35.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.35.0.tgz", + "integrity": "sha512-30iXE9whjlILfWobBkNerJo+TXYsgVM5ERQwMcMKCHckHflCmf7wXDAHlARoWnh0s1U72WqlbeyE7iAcCzuCPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", + "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz", + "integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.15.2", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "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/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "dev": true, + "license": "MIT" + }, + "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/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@pkgr/core": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", + "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/pkgr" + } + }, + "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.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.50.1.tgz", + "integrity": "sha512-HJXwzoZN4eYTdD8bVV22DN8gsPCAj3V20NHKOs8ezfXanGpmVPR7kalUHd+Y31IJp9stdB87VKPFbsGY3H/2ag==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.50.1.tgz", + "integrity": "sha512-PZlsJVcjHfcH53mOImyt3bc97Ep3FJDXRpk9sMdGX0qgLmY0EIWxCag6EigerGhLVuL8lDVYNnSo8qnTElO4xw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.50.1.tgz", + "integrity": "sha512-xc6i2AuWh++oGi4ylOFPmzJOEeAa2lJeGUGb4MudOtgfyyjr4UPNK+eEWTPLvmPJIY/pgw6ssFIox23SyrkkJw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.50.1.tgz", + "integrity": "sha512-2ofU89lEpDYhdLAbRdeyz/kX3Y2lpYc6ShRnDjY35bZhd2ipuDMDi6ZTQ9NIag94K28nFMofdnKeHR7BT0CATw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.50.1.tgz", + "integrity": "sha512-wOsE6H2u6PxsHY/BeFHA4VGQN3KUJFZp7QJBmDYI983fgxq5Th8FDkVuERb2l9vDMs1D5XhOrhBrnqcEY6l8ZA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.50.1.tgz", + "integrity": "sha512-A/xeqaHTlKbQggxCqispFAcNjycpUEHP52mwMQZUNqDUJFFYtPHCXS1VAG29uMlDzIVr+i00tSFWFLivMcoIBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.50.1.tgz", + "integrity": "sha512-54v4okehwl5TaSIkpp97rAHGp7t3ghinRd/vyC1iXqXMfjYUTm7TfYmCzXDoHUPTTf36L8pr0E7YsD3CfB3ZDg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.50.1.tgz", + "integrity": "sha512-p/LaFyajPN/0PUHjv8TNyxLiA7RwmDoVY3flXHPSzqrGcIp/c2FjwPPP5++u87DGHtw+5kSH5bCJz0mvXngYxw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.50.1.tgz", + "integrity": "sha512-2AbMhFFkTo6Ptna1zO7kAXXDLi7H9fGTbVaIq2AAYO7yzcAsuTNWPHhb2aTA6GPiP+JXh85Y8CiS54iZoj4opw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.50.1.tgz", + "integrity": "sha512-Cgef+5aZwuvesQNw9eX7g19FfKX5/pQRIyhoXLCiBOrWopjo7ycfB292TX9MDcDijiuIJlx1IzJz3IoCPfqs9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loongarch64-gnu": { + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.50.1.tgz", + "integrity": "sha512-RPhTwWMzpYYrHrJAS7CmpdtHNKtt2Ueo+BlLBjfZEhYBhK00OsEqM08/7f+eohiF6poe0YRDDd8nAvwtE/Y62Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.50.1.tgz", + "integrity": "sha512-eSGMVQw9iekut62O7eBdbiccRguuDgiPMsw++BVUg+1K7WjZXHOg/YOT9SWMzPZA+w98G+Fa1VqJgHZOHHnY0Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.50.1.tgz", + "integrity": "sha512-S208ojx8a4ciIPrLgazF6AgdcNJzQE4+S9rsmOmDJkusvctii+ZvEuIC4v/xFqzbuP8yDjn73oBlNDgF6YGSXQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.50.1.tgz", + "integrity": "sha512-3Ag8Ls1ggqkGUvSZWYcdgFwriy2lWo+0QlYgEFra/5JGtAd6C5Hw59oojx1DeqcA2Wds2ayRgvJ4qxVTzCHgzg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.50.1.tgz", + "integrity": "sha512-t9YrKfaxCYe7l7ldFERE1BRg/4TATxIg+YieHQ966jwvo7ddHJxPj9cNFWLAzhkVsbBvNA4qTbPVNsZKBO4NSg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.50.1.tgz", + "integrity": "sha512-MCgtFB2+SVNuQmmjHf+wfI4CMxy3Tk8XjA5Z//A0AKD7QXUYFMQcns91K6dEHBvZPCnhJSyDWLApk40Iq/H3tA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.50.1.tgz", + "integrity": "sha512-nEvqG+0jeRmqaUMuwzlfMKwcIVffy/9KGbAGyoa26iu6eSngAYQ512bMXuqqPrlTyfqdlB9FVINs93j534UJrg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.50.1.tgz", + "integrity": "sha512-RDsLm+phmT3MJd9SNxA9MNuEAO/J2fhW8GXk62G/B4G7sLVumNFbRwDL6v5NrESb48k+QMqdGbHgEtfU0LCpbA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.50.1.tgz", + "integrity": "sha512-hpZB/TImk2FlAFAIsoElM3tLzq57uxnGYwplg6WDyAxbYczSi8O2eQ+H2Lx74504rwKtZ3N2g4bCUkiamzS6TQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.50.1.tgz", + "integrity": "sha512-SXjv8JlbzKM0fTJidX4eVsH+Wmnp0/WcD8gJxIZyR6Gay5Qcsmdbi9zVtnbkGPG8v2vMR1AD06lGWy5FLMcG7A==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.50.1.tgz", + "integrity": "sha512-StxAO/8ts62KZVRAm4JZYq9+NqNsV7RvimNK+YM7ry//zebEH6meuugqW/P5OFUCjyQgui+9fUxT6d5NShvMvA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@syncfusion/ej2-base": { + "version": "30.2.6", + "resolved": "https://registry.npmjs.org/@syncfusion/ej2-base/-/ej2-base-30.2.6.tgz", + "integrity": "sha512-RC7yA4nK/1CvkQQkUnYbvsFUFcuWkbSxhyVgn4ymNCAylRARFerdO6epTJAn204+vhmzoS34q+4EdeWwtg0/PA==", + "license": "SEE LICENSE IN license", + "dependencies": { + "@syncfusion/ej2-icons": "~30.2.4" + }, + "bin": { + "syncfusion-license": "bin/syncfusion-license.js" + } + }, + "node_modules/@syncfusion/ej2-buttons": { + "version": "30.2.4", + "resolved": "https://registry.npmjs.org/@syncfusion/ej2-buttons/-/ej2-buttons-30.2.4.tgz", + "integrity": "sha512-NbZD8aI3AgVM0JEEB+CGh8e46ClxqCGrZ0AkRVifLX8Z7NmuZ9PV7d8JQDyPWWQmKIu3V4nc6ChmiYtBEkcC3w==", + "license": "SEE LICENSE IN license", + "dependencies": { + "@syncfusion/ej2-base": "~30.2.4" + } + }, + "node_modules/@syncfusion/ej2-calendars": { + "version": "30.2.4", + "resolved": "https://registry.npmjs.org/@syncfusion/ej2-calendars/-/ej2-calendars-30.2.4.tgz", + "integrity": "sha512-/CWUBcsZ4FLcQtWGxKsc3xKKfqDloG9/JFc38zqErp2bdhTaQj1N4OmYNWAjnQ146v/z17K2KH2xiXW6bJpQ8g==", + "license": "SEE LICENSE IN license", + "dependencies": { + "@syncfusion/ej2-base": "~30.2.4", + "@syncfusion/ej2-buttons": "~30.2.4", + "@syncfusion/ej2-inputs": "~30.2.4", + "@syncfusion/ej2-lists": "~30.2.4", + "@syncfusion/ej2-popups": "~30.2.4" + } + }, + "node_modules/@syncfusion/ej2-compression": { + "version": "30.2.4", + "resolved": "https://registry.npmjs.org/@syncfusion/ej2-compression/-/ej2-compression-30.2.4.tgz", + "integrity": "sha512-7h0LxmvcDcU/GHuDVblUR/FLsKb0Ru0AjTFe7cay3eEG3dr76dObVsKVBWH7lobm3G/cptlFdLqnzsUeq+crQQ==", + "license": "SEE LICENSE IN license", + "dependencies": { + "@syncfusion/ej2-file-utils": "~30.2.4" + } + }, + "node_modules/@syncfusion/ej2-data": { + "version": "30.2.4", + "resolved": "https://registry.npmjs.org/@syncfusion/ej2-data/-/ej2-data-30.2.4.tgz", + "integrity": "sha512-HYBwk6U0pxTjc8yEYAj2YyuqRsgbVIAzTgYn1/WEvysXHFhwsNA5oUlvF9BFGPBiWLSJCCv4R9jDQHGm9pbtqw==", + "license": "SEE LICENSE IN license", + "dependencies": { + "@syncfusion/ej2-base": "~30.2.4" + } + }, + "node_modules/@syncfusion/ej2-dropdowns": { + "version": "30.2.6", + "resolved": "https://registry.npmjs.org/@syncfusion/ej2-dropdowns/-/ej2-dropdowns-30.2.6.tgz", + "integrity": "sha512-pvnsxvvHJWJA8Dxg/X99C3hs1tLWvLEacfytLroejlS0mjhCeGrkGVDUTHybeYuZtWb6tocDOLxdvCxxGZGIbA==", + "license": "SEE LICENSE IN license", + "dependencies": { + "@syncfusion/ej2-base": "~30.2.4", + "@syncfusion/ej2-data": "~30.2.4", + "@syncfusion/ej2-inputs": "~30.2.6", + "@syncfusion/ej2-lists": "~30.2.4", + "@syncfusion/ej2-navigations": "~30.2.5", + "@syncfusion/ej2-notifications": "~30.2.4", + "@syncfusion/ej2-popups": "~30.2.4" + } + }, + "node_modules/@syncfusion/ej2-excel-export": { + "version": "30.2.4", + "resolved": "https://registry.npmjs.org/@syncfusion/ej2-excel-export/-/ej2-excel-export-30.2.4.tgz", + "integrity": "sha512-7hTXmVQmcWnePt6Y4VWhJGe9jeXhUOykZSX/jrjULuBQNTE5tnHo1P6Mf2iiFZX2+qd1eRCEnS8u7EyHmpLO6Q==", + "license": "SEE LICENSE IN license", + "dependencies": { + "@syncfusion/ej2-base": "~30.2.4", + "@syncfusion/ej2-compression": "~30.2.4" + } + }, + "node_modules/@syncfusion/ej2-file-utils": { + "version": "30.2.4", + "resolved": "https://registry.npmjs.org/@syncfusion/ej2-file-utils/-/ej2-file-utils-30.2.4.tgz", + "integrity": "sha512-rrMlfJdXIF069P1kSf69Cuv7oNe6jXknUUxvDaej2+5ZgS79I181ugAHBkvyIUzpq4R0AobJUKPRIAv2XtUo7g==", + "license": "SEE LICENSE IN license" + }, + "node_modules/@syncfusion/ej2-filemanager": { + "version": "30.2.4", + "resolved": "https://registry.npmjs.org/@syncfusion/ej2-filemanager/-/ej2-filemanager-30.2.4.tgz", + "integrity": "sha512-Ufk39KkbE06P+jqFJFmH2nqlYtebAvz78uZYXBKDdlnRXWnpTWV1zKqyTKl0jMHV42j6rZqosj3Wxl3Pbscftg==", + "license": "SEE LICENSE IN license", + "dependencies": { + "@syncfusion/ej2-base": "~30.2.4", + "@syncfusion/ej2-buttons": "~30.2.4", + "@syncfusion/ej2-data": "~30.2.4", + "@syncfusion/ej2-grids": "~30.2.4", + "@syncfusion/ej2-inputs": "~30.2.4", + "@syncfusion/ej2-layouts": "~30.2.4", + "@syncfusion/ej2-lists": "~30.2.4", + "@syncfusion/ej2-navigations": "~30.2.4", + "@syncfusion/ej2-popups": "~30.2.4", + "@syncfusion/ej2-splitbuttons": "~30.2.4" + } + }, + "node_modules/@syncfusion/ej2-grids": { + "version": "30.2.6", + "resolved": "https://registry.npmjs.org/@syncfusion/ej2-grids/-/ej2-grids-30.2.6.tgz", + "integrity": "sha512-KVTM7xytsp60/fvQdeK37vM3rTQqsV5TTBvpeMKHQmUNhs3qfEGYA0bYGPgITqJcjzVkiVSDOyAvKC+Ne1AgjA==", + "license": "SEE LICENSE IN license", + "dependencies": { + "@syncfusion/ej2-base": "~30.2.4", + "@syncfusion/ej2-buttons": "~30.2.4", + "@syncfusion/ej2-calendars": "~30.2.4", + "@syncfusion/ej2-compression": "~30.2.4", + "@syncfusion/ej2-data": "~30.2.4", + "@syncfusion/ej2-dropdowns": "~30.2.6", + "@syncfusion/ej2-excel-export": "~30.2.4", + "@syncfusion/ej2-file-utils": "~30.2.4", + "@syncfusion/ej2-inputs": "~30.2.6", + "@syncfusion/ej2-lists": "~30.2.4", + "@syncfusion/ej2-navigations": "~30.2.5", + "@syncfusion/ej2-notifications": "~30.2.4", + "@syncfusion/ej2-pdf-export": "~30.2.4", + "@syncfusion/ej2-popups": "~30.2.4", + "@syncfusion/ej2-splitbuttons": "~30.2.4" + } + }, + "node_modules/@syncfusion/ej2-icons": { + "version": "30.2.4", + "resolved": "https://registry.npmjs.org/@syncfusion/ej2-icons/-/ej2-icons-30.2.4.tgz", + "integrity": "sha512-zKJaCs9AhPT/xOVvjlSAQCCcQX9P+P/ajVd2Xe2UxeSuJpB8K9D1+4MHXe6lZOdY/hOyNWocpZc60UmcaAcR/Q==", + "license": "SEE LICENSE IN license" + }, + "node_modules/@syncfusion/ej2-inputs": { + "version": "30.2.6", + "resolved": "https://registry.npmjs.org/@syncfusion/ej2-inputs/-/ej2-inputs-30.2.6.tgz", + "integrity": "sha512-A6orvv+qdybR9hPHRb+XlVOOT6tB8BodxD4I/aVEGj0Vs2iAAbkJgQ+Lccn//X0r/OLoxxoVLQBRv8k8bRuBPg==", + "license": "SEE LICENSE IN license", + "dependencies": { + "@syncfusion/ej2-base": "~30.2.4", + "@syncfusion/ej2-buttons": "~30.2.4", + "@syncfusion/ej2-popups": "~30.2.4", + "@syncfusion/ej2-splitbuttons": "~30.2.4" + } + }, + "node_modules/@syncfusion/ej2-kanban": { + "version": "30.2.4", + "resolved": "https://registry.npmjs.org/@syncfusion/ej2-kanban/-/ej2-kanban-30.2.4.tgz", + "integrity": "sha512-XfDQs9X6T/NDud+YBgddZkBx2QRBhlaObrbWOSi1Utrd5a5Yv/Z9T4UN/SRztaSooLIsqgCQbgok35XD5MRFsA==", + "license": "SEE LICENSE IN license", + "dependencies": { + "@syncfusion/ej2-base": "~30.2.4", + "@syncfusion/ej2-buttons": "~30.2.4", + "@syncfusion/ej2-data": "~30.2.4", + "@syncfusion/ej2-dropdowns": "~30.2.4", + "@syncfusion/ej2-inputs": "~30.2.4", + "@syncfusion/ej2-layouts": "~30.2.4", + "@syncfusion/ej2-navigations": "~30.2.4", + "@syncfusion/ej2-notifications": "~30.2.4", + "@syncfusion/ej2-popups": "~30.2.4" + } + }, + "node_modules/@syncfusion/ej2-layouts": { + "version": "30.2.4", + "resolved": "https://registry.npmjs.org/@syncfusion/ej2-layouts/-/ej2-layouts-30.2.4.tgz", + "integrity": "sha512-WlCadhLT3wo4K4F7MP66Vts8rwE5EC3koKLnNuaMlQv29+NDU1dsVgA3mmkvcLL3GaaDUv6b5gzRPVUxzgzIHw==", + "license": "SEE LICENSE IN license", + "dependencies": { + "@syncfusion/ej2-base": "~30.2.4" + } + }, + "node_modules/@syncfusion/ej2-lists": { + "version": "30.2.4", + "resolved": "https://registry.npmjs.org/@syncfusion/ej2-lists/-/ej2-lists-30.2.4.tgz", + "integrity": "sha512-2+/tDgrR+GtuCB8u1IFKEbmduKZUHHRrudZXpWQWgFlO3KDZozDHwTwgf6xwlfr1u03L63H4AksaanfcBL4Zpw==", + "license": "SEE LICENSE IN license", + "dependencies": { + "@syncfusion/ej2-base": "~30.2.4", + "@syncfusion/ej2-buttons": "~30.2.4", + "@syncfusion/ej2-data": "~30.2.4", + "@syncfusion/ej2-popups": "~30.2.4" + } + }, + "node_modules/@syncfusion/ej2-navigations": { + "version": "30.2.7", + "resolved": "https://registry.npmjs.org/@syncfusion/ej2-navigations/-/ej2-navigations-30.2.7.tgz", + "integrity": "sha512-vaUqL3NbpEhjzK6rL1iPtoqq7Pl+1e1Q7vZTsuL3fBoowYIirMkI0Xmtz67PDMobyp/8aKYrNh4kzyEqnEdfLQ==", + "license": "SEE LICENSE IN license", + "dependencies": { + "@syncfusion/ej2-base": "~30.2.6", + "@syncfusion/ej2-buttons": "~30.2.4", + "@syncfusion/ej2-data": "~30.2.4", + "@syncfusion/ej2-inputs": "~30.2.6", + "@syncfusion/ej2-lists": "~30.2.4", + "@syncfusion/ej2-popups": "~30.2.4" + } + }, + "node_modules/@syncfusion/ej2-notifications": { + "version": "30.2.4", + "resolved": "https://registry.npmjs.org/@syncfusion/ej2-notifications/-/ej2-notifications-30.2.4.tgz", + "integrity": "sha512-DuwH/WCv6148ZNNjdRmsa/l51bBxaWJnyC+YnEJrKkBERYkSED/avjU0bkixE8Hh8c9Q1PcseTrUjOKHHGkL7w==", + "license": "SEE LICENSE IN license", + "dependencies": { + "@syncfusion/ej2-base": "~30.2.4", + "@syncfusion/ej2-buttons": "~30.2.4", + "@syncfusion/ej2-popups": "~30.2.4" + } + }, + "node_modules/@syncfusion/ej2-pdf-export": { + "version": "30.2.4", + "resolved": "https://registry.npmjs.org/@syncfusion/ej2-pdf-export/-/ej2-pdf-export-30.2.4.tgz", + "integrity": "sha512-xSv1bICsnN1P0zPZBfAt6x9ThajCBvY1nSsWI5/8iA/sUHeI7GD/fpau+w4cLRBscGxWpei6i5IvSXcgdQ+muw==", + "license": "SEE LICENSE IN license", + "dependencies": { + "@syncfusion/ej2-compression": "~30.2.4" + } + }, + "node_modules/@syncfusion/ej2-popups": { + "version": "30.2.4", + "resolved": "https://registry.npmjs.org/@syncfusion/ej2-popups/-/ej2-popups-30.2.4.tgz", + "integrity": "sha512-nlfLVgmMNK6xk/c6UN7OuLgsnMqX8cn+Ql84PVovI2cgc1MCT4Jh46MRbArXtJfeCtWIyHMVE4+hfbNAXYDhWQ==", + "license": "SEE LICENSE IN license", + "dependencies": { + "@syncfusion/ej2-base": "~30.2.4", + "@syncfusion/ej2-buttons": "~30.2.4" + } + }, + "node_modules/@syncfusion/ej2-react-base": { + "version": "30.2.4", + "resolved": "https://registry.npmjs.org/@syncfusion/ej2-react-base/-/ej2-react-base-30.2.4.tgz", + "integrity": "sha512-gctBd8lIUi0G1VhRzSguQEUA3iwaaXlhBCIhlAM4sIUJ3KA7mAiaGXc8nqWVsYNFr2KgSXKdFPvAPWFdWIyR6A==", + "license": "SEE LICENSE IN license", + "dependencies": { + "@syncfusion/ej2-base": "~30.2.4" + } + }, + "node_modules/@syncfusion/ej2-react-buttons": { + "version": "30.2.4", + "resolved": "https://registry.npmjs.org/@syncfusion/ej2-react-buttons/-/ej2-react-buttons-30.2.4.tgz", + "integrity": "sha512-RMqZ3z3PlJtJL4j7M4E7hqQBNWvZAiKI42qZDykPnR2aP1geNem4pW3Y9IikesSk6hSytvfaFx/DIoI/uaEFxQ==", + "license": "SEE LICENSE IN license", + "dependencies": { + "@syncfusion/ej2-base": "~30.2.4", + "@syncfusion/ej2-buttons": "30.2.4", + "@syncfusion/ej2-react-base": "~30.2.4" + } + }, + "node_modules/@syncfusion/ej2-react-calendars": { + "version": "30.2.4", + "resolved": "https://registry.npmjs.org/@syncfusion/ej2-react-calendars/-/ej2-react-calendars-30.2.4.tgz", + "integrity": "sha512-26j7WBGoNlL/hmVV+hWIzi4wNsMc/MvR+M4sbs4mQwragnQEWi79j4C5o43kyXT2a/mUfVkDfLIxOxbk7q/38A==", + "license": "SEE LICENSE IN license", + "dependencies": { + "@syncfusion/ej2-base": "~30.2.4", + "@syncfusion/ej2-calendars": "30.2.4", + "@syncfusion/ej2-react-base": "~30.2.4" + } + }, + "node_modules/@syncfusion/ej2-react-dropdowns": { + "version": "30.2.6", + "resolved": "https://registry.npmjs.org/@syncfusion/ej2-react-dropdowns/-/ej2-react-dropdowns-30.2.6.tgz", + "integrity": "sha512-579CIMvgcJ9W0DVa5Kf+x6xoKymBZ1T9OzO1S3VBsvIjsgKlkXsEAdYNK2Dy+6YI02sVTza//sm6vZnf8o0tQw==", + "license": "SEE LICENSE IN license", + "dependencies": { + "@syncfusion/ej2-base": "~30.2.4", + "@syncfusion/ej2-dropdowns": "30.2.6", + "@syncfusion/ej2-react-base": "~30.2.4" + } + }, + "node_modules/@syncfusion/ej2-react-filemanager": { + "version": "30.2.4", + "resolved": "https://registry.npmjs.org/@syncfusion/ej2-react-filemanager/-/ej2-react-filemanager-30.2.4.tgz", + "integrity": "sha512-vSIE2cw2a/eMM7fV8j/C94Y8N8I5smmaOEhr1Ff5HXlW4hABzeBYBhkOJbG3ZUo9a4X60MbGPTIK7oUPUj97lA==", + "license": "SEE LICENSE IN license", + "dependencies": { + "@syncfusion/ej2-base": "~30.2.4", + "@syncfusion/ej2-filemanager": "30.2.4", + "@syncfusion/ej2-react-base": "~30.2.4" + } + }, + "node_modules/@syncfusion/ej2-react-grids": { + "version": "30.2.6", + "resolved": "https://registry.npmjs.org/@syncfusion/ej2-react-grids/-/ej2-react-grids-30.2.6.tgz", + "integrity": "sha512-Crvoh6k4/bl4LICTHu5Zfyi0kQQlFzdCEfS5eWiq7ckH91eHPuog8SitA3vEXcdG3Amerbs/kOlBPFY6GYqUKQ==", + "license": "SEE LICENSE IN license", + "dependencies": { + "@syncfusion/ej2-base": "~30.2.4", + "@syncfusion/ej2-grids": "30.2.6", + "@syncfusion/ej2-react-base": "~30.2.4" + } + }, + "node_modules/@syncfusion/ej2-react-inputs": { + "version": "30.2.6", + "resolved": "https://registry.npmjs.org/@syncfusion/ej2-react-inputs/-/ej2-react-inputs-30.2.6.tgz", + "integrity": "sha512-2I9Y2AHcmtwkB82Q/SeR24PF0OmvNqxeeJ5vrvLIb1V4NkFkw6FVl+023jWqNvgUBqinsYYEznSlLqhlICqnpw==", + "license": "SEE LICENSE IN license", + "dependencies": { + "@syncfusion/ej2-base": "~30.2.4", + "@syncfusion/ej2-inputs": "30.2.6", + "@syncfusion/ej2-react-base": "~30.2.4" + } + }, + "node_modules/@syncfusion/ej2-react-kanban": { + "version": "30.2.4", + "resolved": "https://registry.npmjs.org/@syncfusion/ej2-react-kanban/-/ej2-react-kanban-30.2.4.tgz", + "integrity": "sha512-PDslaOfro0iM15SzugmYILl2TVc/cfze+fQDX13OyeW7MRPGiTEbjqW9h/vbOqqo8Z5T2/gh1hmQiND1wCJfCg==", + "license": "SEE LICENSE IN license", + "dependencies": { + "@syncfusion/ej2-base": "~30.2.4", + "@syncfusion/ej2-kanban": "30.2.4", + "@syncfusion/ej2-react-base": "~30.2.4" + } + }, + "node_modules/@syncfusion/ej2-react-layouts": { + "version": "30.2.4", + "resolved": "https://registry.npmjs.org/@syncfusion/ej2-react-layouts/-/ej2-react-layouts-30.2.4.tgz", + "integrity": "sha512-0jxSSGNgkZboc23Zaue8v0Qvgkjq78l+RAn+wI5Y3dtzWLfXphxRn0grGxEhP25wR7e+F0UcPoVxQE5gm4MWHw==", + "license": "SEE LICENSE IN license", + "dependencies": { + "@syncfusion/ej2-base": "~30.2.4", + "@syncfusion/ej2-layouts": "30.2.4", + "@syncfusion/ej2-react-base": "~30.2.4" + } + }, + "node_modules/@syncfusion/ej2-react-navigations": { + "version": "30.2.7", + "resolved": "https://registry.npmjs.org/@syncfusion/ej2-react-navigations/-/ej2-react-navigations-30.2.7.tgz", + "integrity": "sha512-WM+/Xx//zLHNCCMGvP5bcnrdmTE1F+9/kgjnsh62fojHZS5sfJbDDVYzOiuk/zkQZRCfbCArGhjpK6RDlvEq2Q==", + "license": "SEE LICENSE IN license", + "dependencies": { + "@syncfusion/ej2-base": "~30.2.6", + "@syncfusion/ej2-navigations": "30.2.7", + "@syncfusion/ej2-react-base": "~30.2.4" + } + }, + "node_modules/@syncfusion/ej2-react-notifications": { + "version": "30.2.4", + "resolved": "https://registry.npmjs.org/@syncfusion/ej2-react-notifications/-/ej2-react-notifications-30.2.4.tgz", + "integrity": "sha512-TBCwWsEZW+Ve6OXXDZJxTPMFWNAiTVh7nxcm80/GDo/I4MKoDICIqSqqQfsIP/1GsqmwI/wz7OG+GTAR/IA0QQ==", + "license": "SEE LICENSE IN license", + "dependencies": { + "@syncfusion/ej2-base": "~30.2.4", + "@syncfusion/ej2-notifications": "30.2.4", + "@syncfusion/ej2-react-base": "~30.2.4" + } + }, + "node_modules/@syncfusion/ej2-react-popups": { + "version": "30.2.4", + "resolved": "https://registry.npmjs.org/@syncfusion/ej2-react-popups/-/ej2-react-popups-30.2.4.tgz", + "integrity": "sha512-AM8/xp8phkge9tX5UaBVUdQbqfY6mSL5wQr4Uf8AA0dHXi3wl6TDuZ7q+blT57XxELKH+R8PwvBb6J5ni3BL2w==", + "license": "SEE LICENSE IN license", + "dependencies": { + "@syncfusion/ej2-base": "~30.2.4", + "@syncfusion/ej2-popups": "30.2.4", + "@syncfusion/ej2-react-base": "~30.2.4" + } + }, + "node_modules/@syncfusion/ej2-react-schedule": { + "version": "30.2.7", + "resolved": "https://registry.npmjs.org/@syncfusion/ej2-react-schedule/-/ej2-react-schedule-30.2.7.tgz", + "integrity": "sha512-Wtyhy9XLLh6rb4nZJ2fKfHPOYqd2zLek6R9ZZv+3mBgkT3ekk5KvMFmavYKBnvG6N7RJCjHplof3O52LtFTKNA==", + "license": "SEE LICENSE IN license", + "dependencies": { + "@syncfusion/ej2-base": "~30.2.6", + "@syncfusion/ej2-react-base": "~30.2.4", + "@syncfusion/ej2-schedule": "30.2.7" + } + }, + "node_modules/@syncfusion/ej2-schedule": { + "version": "30.2.7", + "resolved": "https://registry.npmjs.org/@syncfusion/ej2-schedule/-/ej2-schedule-30.2.7.tgz", + "integrity": "sha512-OhdFVCQ8xOVj+O7rJfYLxJ3CBEt6KYR/gY4t46xjsy9WRH6U0oRuJwLmcdqObKOJoAHQv25HLrmSwxY+sP1pMQ==", + "license": "SEE LICENSE IN license", + "dependencies": { + "@syncfusion/ej2-base": "~30.2.6", + "@syncfusion/ej2-buttons": "~30.2.4", + "@syncfusion/ej2-calendars": "~30.2.4", + "@syncfusion/ej2-data": "~30.2.4", + "@syncfusion/ej2-dropdowns": "~30.2.6", + "@syncfusion/ej2-excel-export": "~30.2.4", + "@syncfusion/ej2-inputs": "~30.2.6", + "@syncfusion/ej2-lists": "~30.2.4", + "@syncfusion/ej2-navigations": "~30.2.7", + "@syncfusion/ej2-popups": "~30.2.4" + } + }, + "node_modules/@syncfusion/ej2-splitbuttons": { + "version": "30.2.4", + "resolved": "https://registry.npmjs.org/@syncfusion/ej2-splitbuttons/-/ej2-splitbuttons-30.2.4.tgz", + "integrity": "sha512-9p0wMvNgZtcHJbwGh/RxLpEtu4AioqooO2d/aW72FufeSniR3/31fwSKBOzOu4EvR7EaYGXAnVxrmHTEojzlRg==", + "license": "SEE LICENSE IN license", + "dependencies": { + "@syncfusion/ej2-base": "~30.2.4", + "@syncfusion/ej2-popups": "~30.2.4" + } + }, + "node_modules/@tailwindcss/aspect-ratio": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/aspect-ratio/-/aspect-ratio-0.4.2.tgz", + "integrity": "sha512-8QPrypskfBa7QIMuKHg2TA7BqES6vhBrDLOv8Unb6FcFyd3TjKbc6lcmb9UPQHxfl24sXoJ41ux/H7qQQvfaSQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "tailwindcss": ">=2.0.0 || >=3.0.0 || >=3.0.0-alpha.1" + } + }, + "node_modules/@tailwindcss/forms": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.10.tgz", + "integrity": "sha512-utI1ONF6uf/pPNO68kmN1b8rEwNXv3czukalo8VtJH8ksIkZXr3Q3VYudZLkCsDd4Wku120uF02hYK25XGPorw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mini-svg-data-uri": "^1.2.3" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20 || >= 4.0.0-beta.1" + } + }, + "node_modules/@tailwindcss/typography": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.16.tgz", + "integrity": "sha512-0wDLwCVF5V3x3b1SGXPCDcdsbDHMBe+lkFzBRaHeLvNi+nrrnZ1lA18u+OTWO8iSWU2GxUOCvlXtDuqftc1oiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "lodash.castarray": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.merge": "^4.6.2", + "postcss-selector-parser": "6.0.10" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" + } + }, + "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/history": { + "version": "4.7.11", + "resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.11.tgz", + "integrity": "sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.1.12", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.12.tgz", + "integrity": "sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.1.9", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.9.tgz", + "integrity": "sha512-qXRuZaOsAdXKFyOhRBg6Lqqc0yay13vN7KrIg4L7N4aaHN68ma9OK3NE1BoDFgFOTfM7zg+3/8+2n8rLUH3OKQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.0.0" + } + }, + "node_modules/@types/react-router": { + "version": "5.1.20", + "resolved": "https://registry.npmjs.org/@types/react-router/-/react-router-5.1.20.tgz", + "integrity": "sha512-jGjmu/ZqS7FjSH6owMcD5qpq19+1RS9DeVRqfl1FeBMxTDQAGwlMWOcs52NDoXaNKyG3d1cYQFMs9rCrb88o9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/history": "^4.7.11", + "@types/react": "*" + } + }, + "node_modules/@types/react-router-dom": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/@types/react-router-dom/-/react-router-dom-5.3.3.tgz", + "integrity": "sha512-kpqnYK4wcdm5UaWI3fLcELopqLrHgLqNsdpHauzlQktfkHL3npOSwtj1Uz9oKBAzs7lFtVkV8j83voAz2D8fhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/history": "^4.7.11", + "@types/react": "*", + "@types/react-router": "*" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.43.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.43.0.tgz", + "integrity": "sha512-8tg+gt7ENL7KewsKMKDHXR1vm8tt9eMxjJBYINf6swonlWgkYn5NwyIgXpbbDxTNU5DgpDFfj95prcTq2clIQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.43.0", + "@typescript-eslint/type-utils": "8.43.0", + "@typescript-eslint/utils": "8.43.0", + "@typescript-eslint/visitor-keys": "8.43.0", + "graphemer": "^1.4.0", + "ignore": "^7.0.0", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.43.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.43.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.43.0.tgz", + "integrity": "sha512-B7RIQiTsCBBmY+yW4+ILd6mF5h1FUwJsVvpqkrgpszYifetQ2Ke+Z4u6aZh0CblkUGIdR59iYVyXqqZGkZ3aBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.43.0", + "@typescript-eslint/types": "8.43.0", + "@typescript-eslint/typescript-estree": "8.43.0", + "@typescript-eslint/visitor-keys": "8.43.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.43.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.43.0.tgz", + "integrity": "sha512-htB/+D/BIGoNTQYffZw4uM4NzzuolCoaA/BusuSIcC8YjmBYQioew5VUZAYdAETPjeed0hqCaW7EHg+Robq8uw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.43.0", + "@typescript-eslint/types": "^8.43.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.43.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.43.0.tgz", + "integrity": "sha512-daSWlQ87ZhsjrbMLvpuuMAt3y4ba57AuvadcR7f3nl8eS3BjRc8L9VLxFLk92RL5xdXOg6IQ+qKjjqNEimGuAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.43.0", + "@typescript-eslint/visitor-keys": "8.43.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.43.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.43.0.tgz", + "integrity": "sha512-ALC2prjZcj2YqqL5X/bwWQmHA2em6/94GcbB/KKu5SX3EBDOsqztmmX1kMkvAJHzxk7TazKzJfFiEIagNV3qEA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.43.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.43.0.tgz", + "integrity": "sha512-qaH1uLBpBuBBuRf8c1mLJ6swOfzCXryhKND04Igr4pckzSEW9JX5Aw9AgW00kwfjWJF0kk0ps9ExKTfvXfw4Qg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.43.0", + "@typescript-eslint/typescript-estree": "8.43.0", + "@typescript-eslint/utils": "8.43.0", + "debug": "^4.3.4", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.43.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.43.0.tgz", + "integrity": "sha512-vQ2FZaxJpydjSZJKiSW/LJsabFFvV7KgLC5DiLhkBcykhQj8iK9BOaDmQt74nnKdLvceM5xmhaTF+pLekrxEkw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.43.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.43.0.tgz", + "integrity": "sha512-7Vv6zlAhPb+cvEpP06WXXy/ZByph9iL6BQRBDj4kmBsW98AqEeQHlj/13X+sZOrKSo9/rNKH4Ul4f6EICREFdw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.43.0", + "@typescript-eslint/tsconfig-utils": "8.43.0", + "@typescript-eslint/types": "8.43.0", + "@typescript-eslint/visitor-keys": "8.43.0", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.43.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.43.0.tgz", + "integrity": "sha512-S1/tEmkUeeswxd0GGcnwuVQPFWo8NzZTOMxCvw8BX7OMxnNae+i8Tm7REQen/SwUIPoPqfKn7EaZ+YLpiB3k9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.43.0", + "@typescript-eslint/types": "8.43.0", + "@typescript-eslint/typescript-estree": "8.43.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.43.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.43.0.tgz", + "integrity": "sha512-T+S1KqRD4sg/bHfLwrpF/K3gQLBM1n7Rp7OjjikjTEssI2YJzQpi5WXoynOaQ93ERIuq3O8RBTOUYDKszUCEHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.43.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "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/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "license": "ISC" + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "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/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.4.21", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz", + "integrity": "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==", + "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.24.4", + "caniuse-lite": "^1.0.30001702", + "fraction.js": "^4.3.7", + "normalize-range": "^0.1.2", + "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/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/axios": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.0.tgz", + "integrity": "sha512-oXTDccv8PcfjZmPGlWsPSwtOJCZ/b6W5jAMCNcfwJbCzDckwG0jrYJFaWH1yvivfCXjVzV/SPDEhMB3Q+DSurg==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "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/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "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.25.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.4.tgz", + "integrity": "sha512-4jYpcjabC606xJ3kw2QwGEZKX0Aw7sgQdZCvIK9dhVSPh76BKo+C+btT1RRofH7B+8iNpEbgGNVWiLki5q93yg==", + "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": { + "caniuse-lite": "^1.0.30001737", + "electron-to-chromium": "^1.5.211", + "node-releases": "^2.0.19", + "update-browserslist-db": "^1.1.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/cacheable": { + "version": "1.10.4", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-1.10.4.tgz", + "integrity": "sha512-Gd7ccIUkZ9TE2odLQVS+PDjIvQCdJKUlLdJRVvZu0aipj07Qfx+XIej7hhDrKGGoIxV5m5fT/kOJNJPQhQneRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^1.11.0", + "keyv": "^5.5.0" + } + }, + "node_modules/cacheable/node_modules/keyv": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.5.1.tgz", + "integrity": "sha512-eF3cHZ40bVsjdlRi/RvKAuB0+B61Q1xWvohnrJrnaQslM3h1n79IV+mc9EGag4nrA9ZOlNyr3TUzW5c8uy8vNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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.30001741", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001741.tgz", + "integrity": "sha512-QGUGitqsc8ARjLdgAfxETDhRbJ0REsP6O3I96TAth/mVjh2cYzN2u+3AzPP3aVSm2FehEItaJw1xd+IGBXWeSw==", + "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/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "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/cldr-data": { + "version": "36.0.4", + "resolved": "https://registry.npmjs.org/cldr-data/-/cldr-data-36.0.4.tgz", + "integrity": "sha512-uwxRy5QwNdHF9nFEJmagLVwsNJG5IXDbv1b7teKnDUakyxvRrHcpEp1fU/bTvwR365wqGCC94rXCC9YMLJIi+A==", + "hasInstallScript": true, + "license": [ + { + "type": "MIT", + "url": "https://github.com/rxaviers/cldr-data-npm/blob/master/LICENSE" + } + ], + "dependencies": { + "cldr-data-downloader": "1.1.0", + "glob": "10.3.12" + } + }, + "node_modules/cldr-data-downloader": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/cldr-data-downloader/-/cldr-data-downloader-1.1.0.tgz", + "integrity": "sha512-xg1GKFP4FOe4GEDkANb8ATz67e1tqJ6GGaRMTYJNNgRwr/9WL+qvlDU4nW9/Iw8gA6NISEfd/+XFNOFkuimaOQ==", + "dependencies": { + "axios": "^1.7.2", + "mkdirp": "^1.0.4", + "nopt": "3.0.x", + "q": "1.0.1", + "yauzl": "^2.10.0" + }, + "bin": { + "cldr-data-downloader": "bin/download.sh" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/colord": { + "version": "2.9.3", + "resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz", + "integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "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/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "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/cookie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/cosmiconfig": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", + "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.1", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-functions-list": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/css-functions-list/-/css-functions-list-3.2.3.tgz", + "integrity": "sha512-IQOkD3hbR5KrN93MtcYuad6YPuTSUhntLHDuLEbFWE+ff2/XSZNdZG+LcbbIW5AXKg/WFIfYItIzVoHngHXZzA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12 || >=16" + } + }, + "node_modules/css-tree": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", + "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.12.2", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "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.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "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/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "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/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.218", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.218.tgz", + "integrity": "sha512-uwwdN0TUHs8u6iRgN8vKeWZMRll4gBkz+QMqdS7DDe49uiK68/UX92lFb61oiFPrpYZNeZIqa4bA7O6Aiasnzg==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "license": "MIT" + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-abstract": { + "version": "1.24.0", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz", + "integrity": "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-iterator-helpers": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.2.1.tgz", + "integrity": "sha512-uDn+FE1yrDzyC0pCo961B2IHbdM8y/ACZsKD4dG6WqrjV53BADjwa7D+1aom2rsNVfLyDgU/eigvlJGJ08OQ4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.0.3", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.6", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "iterator.prototype": "^1.1.4", + "safe-array-concat": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/esbuild": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.9.tgz", + "integrity": "sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.9", + "@esbuild/android-arm": "0.25.9", + "@esbuild/android-arm64": "0.25.9", + "@esbuild/android-x64": "0.25.9", + "@esbuild/darwin-arm64": "0.25.9", + "@esbuild/darwin-x64": "0.25.9", + "@esbuild/freebsd-arm64": "0.25.9", + "@esbuild/freebsd-x64": "0.25.9", + "@esbuild/linux-arm": "0.25.9", + "@esbuild/linux-arm64": "0.25.9", + "@esbuild/linux-ia32": "0.25.9", + "@esbuild/linux-loong64": "0.25.9", + "@esbuild/linux-mips64el": "0.25.9", + "@esbuild/linux-ppc64": "0.25.9", + "@esbuild/linux-riscv64": "0.25.9", + "@esbuild/linux-s390x": "0.25.9", + "@esbuild/linux-x64": "0.25.9", + "@esbuild/netbsd-arm64": "0.25.9", + "@esbuild/netbsd-x64": "0.25.9", + "@esbuild/openbsd-arm64": "0.25.9", + "@esbuild/openbsd-x64": "0.25.9", + "@esbuild/openharmony-arm64": "0.25.9", + "@esbuild/sunos-x64": "0.25.9", + "@esbuild/win32-arm64": "0.25.9", + "@esbuild/win32-ia32": "0.25.9", + "@esbuild/win32-x64": "0.25.9" + } + }, + "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/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.35.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.35.0.tgz", + "integrity": "sha512-QePbBFMJFjgmlE+cXAlbHZbHpdFVS2E/6vzCy7aKlebddvl1vadiC4JFV5u/wqTkNUwEV8WrQi257jf5f06hrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.0", + "@eslint/config-helpers": "^0.3.1", + "@eslint/core": "^0.15.2", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.35.0", + "@eslint/plugin-kit": "^0.3.5", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-plugin-prettier": { + "version": "5.5.4", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.4.tgz", + "integrity": "sha512-swNtI95SToIz05YINMA6Ox5R057IMAmWZ26GqPxusAp1TZzj+IdY9tXNWWD3vkF/wEqydCONcwjTFpxybBqZsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "prettier-linter-helpers": "^1.0.0", + "synckit": "^0.11.7" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-plugin-prettier" + }, + "peerDependencies": { + "@types/eslint": ">=8.0.0", + "eslint": ">=8.0.0", + "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0", + "prettier": ">=3.0.0" + }, + "peerDependenciesMeta": { + "@types/eslint": { + "optional": true + }, + "eslint-config-prettier": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react": { + "version": "7.37.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.9", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", + "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.20", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.20.tgz", + "integrity": "sha512-XpbHQ2q5gUF8BGOX4dHe+71qoirYMhApEPZ7sfhF/dNnOF1UXnCMGZf79SFTBO7Bz5YEIT4TMieSlJBWhP9WBA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-plugin-react/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint-plugin-react/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/eslint-plugin-react/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/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-diff": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", + "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", + "dev": true, + "license": "Apache-2.0" + }, + "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/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastest-levenshtein": { + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", + "integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.9.1" + } + }, + "node_modules/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "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/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fraction.js": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", + "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "patreon", + "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==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "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/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob": { + "version": "10.3.12", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.12.tgz", + "integrity": "sha512-TCNv8vJ+xz4QiqTpfOJA7HvYv+tNIRHKfUWw/q+v2jdgN4ebz+KY9tGx5J4rHP0o84mNP+ApH66HRX8us3Khqg==", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^2.3.6", + "minimatch": "^9.0.1", + "minipass": "^7.0.4", + "path-scurry": "^1.10.2" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "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/global-modules": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/global-modules/-/global-modules-2.0.0.tgz", + "integrity": "sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "global-prefix": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/global-prefix": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-3.0.0.tgz", + "integrity": "sha512-awConJSVCHVGND6x3tmMaKcQvwXLhjdkmomy2W+Goaui8YPgYgXJZewhg3fWC+DlfqqQuWg8AwqjGTD2nAPVWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "^1.3.5", + "kind-of": "^6.0.2", + "which": "^1.3.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/global-prefix/node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, + "node_modules/globals": { + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.4.0.tgz", + "integrity": "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globby/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/globjoin": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/globjoin/-/globjoin-0.1.4.tgz", + "integrity": "sha512-xYfnw62CKG8nLkZBfWbhWwDw02CHty86jfPcc2cr3ZfeuK9ysoVPPEUxf21bAD/rWAgk52SuBrLJlefNy8mvFg==", + "dev": true, + "license": "MIT" + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hookified": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.12.0.tgz", + "integrity": "sha512-hMr1Y9TCLshScrBbV2QxJ9BROddxZ12MX9KsCtuGGy/3SmmN5H1PllKerrVlSotur9dlE8hmUKAOSa3WDzsZmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/html-tags": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/html-tags/-/html-tags-3.3.1.tgz", + "integrity": "sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.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-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.0.tgz", + "integrity": "sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-proto": "^1.0.0", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-plain-object": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", + "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/iterator.prototype": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "get-proto": "^1.0.0", + "has-symbols": "^1.1.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/jackspeak": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-2.3.6.tgz", + "integrity": "sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.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", + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "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/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "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/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/known-css-properties": { + "version": "0.37.0", + "resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.37.0.tgz", + "integrity": "sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "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/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.castarray": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.castarray/-/lodash.castarray-4.4.0.tgz", + "integrity": "sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.truncate": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", + "integrity": "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==", + "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==", + "dev": true, + "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.522.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.522.0.tgz", + "integrity": "sha512-jnJbw974yZ7rQHHEFKJOlWAefG3ATSCZHANZxIdx8Rk/16siuwjgA4fBULpXEAWx/RlTs3FzmKW/udWUuO0aRw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mathml-tag-names": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/mathml-tag-names/-/mathml-tag-names-2.1.3.tgz", + "integrity": "sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/mdn-data": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", + "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/meow": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/meow/-/meow-13.2.0.tgz", + "integrity": "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mini-svg-data-uri": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz", + "integrity": "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==", + "dev": true, + "license": "MIT", + "bin": { + "mini-svg-data-uri": "cli.js" + } + }, + "node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "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/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.20.tgz", + "integrity": "sha512-7gK6zSXEH6neM212JgfYFXe+GmZQM+fia5SsusuBIUgnPheLFBmIPhtFoAQRj8/7wASYQnbDlHPVwY0BefoFgA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nopt": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-3.0.6.tgz", + "integrity": "sha512-4GUt3kSEYmk4ITxzB/b9vaIDfUVWN/Ml1Fwl11IlnIG2iaJ9O6WXZ9SrYM9NLI8OCBieN2Y8SWC2oJV0RQ7qYg==", + "license": "ISC", + "dependencies": { + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" + } + }, + "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/normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "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/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "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/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "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", + "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-import/node_modules/resolve": { + "version": "1.22.10", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.0", + "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/postcss-js": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz", + "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", + "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.0.0", + "yaml": "^2.3.4" + }, + "engines": { + "node": ">= 14" + }, + "peerDependencies": { + "postcss": ">=8.0.9", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "postcss": { + "optional": true + }, + "ts-node": { + "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-nested/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-resolve-nested-selector": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/postcss-resolve-nested-selector/-/postcss-resolve-nested-selector-0.1.6.tgz", + "integrity": "sha512-0sglIs9Wmkzbr8lQwEyIzlDOOC9bGmfVKcJTaxv3vMmd3uo4o4DerC3En0bnmgceeql9BfC8hRkp7cg0fjdVqw==", + "dev": true, + "license": "MIT" + }, + "node_modules/postcss-safe-parser": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-7.0.1.tgz", + "integrity": "sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss-safe-parser" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "engines": { + "node": ">=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", + "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", + "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/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", + "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-linter-helpers": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz", + "integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-diff": "^1.1.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/q": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/q/-/q-1.0.1.tgz", + "integrity": "sha512-18MnBaCeBX9sLRUdtxz/6onlb7wLzFxCylklyO8n27y5JxJYaGLPu4ccyc5zih58SpEzY8QmfwaWqguqXU6Y+A==", + "deprecated": "You or someone you depend on is using Q, the JavaScript Promise library that gave JavaScript developers strong feelings about promises. They can almost certainly migrate to the native JavaScript promise now. Thank you literally everyone for joining me in this bet against the odds. Be excellent to each other.\n\n(For a CapTP with native promises, see @endo/eventual-send and @endo/captp)", + "license": "MIT", + "engines": { + "node": ">=0.6.0", + "teleport": ">=0.2.0" + } + }, + "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": "19.1.1", + "resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz", + "integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.1.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz", + "integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.26.0" + }, + "peerDependencies": { + "react": "^19.1.1" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true, + "license": "MIT" + }, + "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/react-router": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.8.2.tgz", + "integrity": "sha512-7M2fR1JbIZ/jFWqelpvSZx+7vd7UlBTfdZqf6OSdF9g6+sfdqJDAWcak6ervbHph200ePlu+7G8LdoiC3ReyAQ==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.8.2.tgz", + "integrity": "sha512-Z4VM5mKDipal2jQ385H6UBhiiEDlnJPx6jyWsTYoZQdl5TrjxEV2a9yl3Fi60NBJxYzOTGTTHXPi0pdizvTwow==", + "license": "MIT", + "dependencies": { + "react-router": "7.8.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "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/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "2.0.0-next.5", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", + "integrity": "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "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.50.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.50.1.tgz", + "integrity": "sha512-78E9voJHwnXQMiQdiqswVLZwJIzdBKJ1GdI5Zx6XwoFKUIk09/sSrr+05QFzvYb8q6Y9pPV45zzDuYa3907TZA==", + "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.50.1", + "@rollup/rollup-android-arm64": "4.50.1", + "@rollup/rollup-darwin-arm64": "4.50.1", + "@rollup/rollup-darwin-x64": "4.50.1", + "@rollup/rollup-freebsd-arm64": "4.50.1", + "@rollup/rollup-freebsd-x64": "4.50.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.50.1", + "@rollup/rollup-linux-arm-musleabihf": "4.50.1", + "@rollup/rollup-linux-arm64-gnu": "4.50.1", + "@rollup/rollup-linux-arm64-musl": "4.50.1", + "@rollup/rollup-linux-loongarch64-gnu": "4.50.1", + "@rollup/rollup-linux-ppc64-gnu": "4.50.1", + "@rollup/rollup-linux-riscv64-gnu": "4.50.1", + "@rollup/rollup-linux-riscv64-musl": "4.50.1", + "@rollup/rollup-linux-s390x-gnu": "4.50.1", + "@rollup/rollup-linux-x64-gnu": "4.50.1", + "@rollup/rollup-linux-x64-musl": "4.50.1", + "@rollup/rollup-openharmony-arm64": "4.50.1", + "@rollup/rollup-win32-arm64-msvc": "4.50.1", + "@rollup/rollup-win32-ia32-msvc": "4.50.1", + "@rollup/rollup-win32-x64-msvc": "4.50.1", + "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/safe-array-concat": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", + "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/scheduler": { + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", + "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", + "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==", + "license": "MIT" + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "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/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string.prototype.matchall": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.3", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/stylelint": { + "version": "16.24.0", + "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-16.24.0.tgz", + "integrity": "sha512-7ksgz3zJaSbTUGr/ujMXvLVKdDhLbGl3R/3arNudH7z88+XZZGNLMTepsY28WlnvEFcuOmUe7fg40Q3lfhOfSQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/stylelint" + }, + { + "type": "github", + "url": "https://github.com/sponsors/stylelint" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "@csstools/media-query-list-parser": "^4.0.3", + "@csstools/selector-specificity": "^5.0.0", + "@dual-bundle/import-meta-resolve": "^4.1.0", + "balanced-match": "^2.0.0", + "colord": "^2.9.3", + "cosmiconfig": "^9.0.0", + "css-functions-list": "^3.2.3", + "css-tree": "^3.1.0", + "debug": "^4.4.1", + "fast-glob": "^3.3.3", + "fastest-levenshtein": "^1.0.16", + "file-entry-cache": "^10.1.4", + "global-modules": "^2.0.0", + "globby": "^11.1.0", + "globjoin": "^0.1.4", + "html-tags": "^3.3.1", + "ignore": "^7.0.5", + "imurmurhash": "^0.1.4", + "is-plain-object": "^5.0.0", + "known-css-properties": "^0.37.0", + "mathml-tag-names": "^2.1.3", + "meow": "^13.2.0", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.5.6", + "postcss-resolve-nested-selector": "^0.1.6", + "postcss-safe-parser": "^7.0.1", + "postcss-selector-parser": "^7.1.0", + "postcss-value-parser": "^4.2.0", + "resolve-from": "^5.0.0", + "string-width": "^4.2.3", + "supports-hyperlinks": "^3.2.0", + "svg-tags": "^1.0.0", + "table": "^6.9.0", + "write-file-atomic": "^5.0.1" + }, + "bin": { + "stylelint": "bin/stylelint.mjs" + }, + "engines": { + "node": ">=18.12.0" + } + }, + "node_modules/stylelint-config-recommended": { + "version": "16.0.0", + "resolved": "https://registry.npmjs.org/stylelint-config-recommended/-/stylelint-config-recommended-16.0.0.tgz", + "integrity": "sha512-4RSmPjQegF34wNcK1e1O3Uz91HN8P1aFdFzio90wNK9mjgAI19u5vsU868cVZboKzCaa5XbpvtTzAAGQAxpcXA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/stylelint" + }, + { + "type": "github", + "url": "https://github.com/sponsors/stylelint" + } + ], + "license": "MIT", + "engines": { + "node": ">=18.12.0" + }, + "peerDependencies": { + "stylelint": "^16.16.0" + } + }, + "node_modules/stylelint-config-standard": { + "version": "38.0.0", + "resolved": "https://registry.npmjs.org/stylelint-config-standard/-/stylelint-config-standard-38.0.0.tgz", + "integrity": "sha512-uj3JIX+dpFseqd/DJx8Gy3PcRAJhlEZ2IrlFOc4LUxBX/PNMEQ198x7LCOE2Q5oT9Vw8nyc4CIL78xSqPr6iag==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/stylelint" + }, + { + "type": "github", + "url": "https://github.com/sponsors/stylelint" + } + ], + "license": "MIT", + "dependencies": { + "stylelint-config-recommended": "^16.0.0" + }, + "engines": { + "node": ">=18.12.0" + }, + "peerDependencies": { + "stylelint": "^16.18.0" + } + }, + "node_modules/stylelint-config-tailwindcss": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/stylelint-config-tailwindcss/-/stylelint-config-tailwindcss-1.0.0.tgz", + "integrity": "sha512-e6WUBJeLdOZ0sy8FZ1jk5Zy9iNGqqJbrMwnnV0Hpaw/yin6QO3gVv/zvyqSty8Yg6nEB5gqcyJbN387TPhEa7Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "stylelint": ">=13.13.1", + "tailwindcss": ">=2.2.16" + } + }, + "node_modules/stylelint/node_modules/@csstools/selector-specificity": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-5.0.0.tgz", + "integrity": "sha512-PCqQV3c4CoVm3kdPhyeZ07VmBRdH2EpMFA/pd9OASpOEC3aXNGoqPDAZ80D0cLpMBxnmk0+yNhGsEx31hq7Gtw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss-selector-parser": "^7.0.0" + } + }, + "node_modules/stylelint/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/stylelint/node_modules/balanced-match": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-2.0.0.tgz", + "integrity": "sha512-1ugUSr8BHXRnK23KfuYS+gVMC3LB8QGH9W1iGtDPsNWoQbgtXSExkBu2aDR4epiGWZOjZsj6lDl/N/AqqTC3UA==", + "dev": true, + "license": "MIT" + }, + "node_modules/stylelint/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/stylelint/node_modules/file-entry-cache": { + "version": "10.1.4", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-10.1.4.tgz", + "integrity": "sha512-5XRUFc0WTtUbjfGzEwXc42tiGxQHBmtbUG1h9L2apu4SulCGN3Hqm//9D6FAolf8MYNL7f/YlJl9vy08pj5JuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^6.1.13" + } + }, + "node_modules/stylelint/node_modules/flat-cache": { + "version": "6.1.13", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.13.tgz", + "integrity": "sha512-gmtS2PaUjSPa4zjObEIn4WWliKyZzYljgxODBfxugpK6q6HU9ClXzgCJ+nlcPKY9Bt090ypTOLIFWkV0jbKFjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cacheable": "^1.10.4", + "flatted": "^3.3.3", + "hookified": "^1.11.0" + } + }, + "node_modules/stylelint/node_modules/postcss-selector-parser": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", + "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/stylelint/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/stylelint/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/stylelint/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/sucrase": { + "version": "3.35.0", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", + "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "glob": "^10.3.10", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-hyperlinks": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-3.2.0.tgz", + "integrity": "sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0", + "supports-color": "^7.0.0" + }, + "engines": { + "node": ">=14.18" + }, + "funding": { + "url": "https://github.com/chalk/supports-hyperlinks?sponsor=1" + } + }, + "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/svg-tags": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/svg-tags/-/svg-tags-1.0.0.tgz", + "integrity": "sha512-ovssysQTa+luh7A5Weu3Rta6FJlFBBbInjOh722LIt6klpU2/HtdUbszju/G4devcvk8PGt7FCLv5wftu3THUA==", + "dev": true + }, + "node_modules/synckit": { + "version": "0.11.11", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.11.tgz", + "integrity": "sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pkgr/core": "^0.2.9" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/synckit" + } + }, + "node_modules/table": { + "version": "6.9.0", + "resolved": "https://registry.npmjs.org/table/-/table-6.9.0.tgz", + "integrity": "sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "ajv": "^8.0.1", + "lodash.truncate": "^4.4.2", + "slice-ansi": "^4.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/table/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/table/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/table/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/table/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/table/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/table/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.17", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", + "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", + "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.6", + "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", + "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/tailwindcss/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/tailwindcss/node_modules/resolve": { + "version": "1.22.10", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.0", + "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/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", + "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-api-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "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/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.43.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.43.0.tgz", + "integrity": "sha512-FyRGJKUGvcFekRRcBKFBlAhnp4Ng8rhe8tuvvkR9OiU0gfd4vyvTRQHEckO6VDlH57jbeUQem2IpqPq9kLJH+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.43.0", + "@typescript-eslint/parser": "8.43.0", + "@typescript-eslint/typescript-estree": "8.43.0", + "@typescript-eslint/utils": "8.43.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "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/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.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.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.6.tgz", + "integrity": "sha512-0msEVHJEScQbhkbVTb/4iHZdJ6SXp/AvxL2sjwYQFfBqleHtnCqv1J3sa9zbWz/6kW1m9Tfzn92vW+kZ1WV6QA==", + "dev": true, + "license": "MIT", + "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", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", + "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/write-file-atomic": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-5.0.1.tgz", + "integrity": "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "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" + }, + "node_modules/yaml": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", + "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + } + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/dashboard/package.json b/dashboard/package.json new file mode 100644 index 0000000..a27d02b --- /dev/null +++ b/dashboard/package.json @@ -0,0 +1,75 @@ +{ + "name": "dashboard", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@syncfusion/ej2-base": "^30.2.0", + "@syncfusion/ej2-buttons": "^30.2.0", + "@syncfusion/ej2-calendars": "^30.2.0", + "@syncfusion/ej2-dropdowns": "^30.2.0", + "@syncfusion/ej2-grids": "^30.2.0", + "@syncfusion/ej2-icons": "^30.2.0", + "@syncfusion/ej2-inputs": "^30.2.0", + "@syncfusion/ej2-kanban": "^30.2.0", + "@syncfusion/ej2-layouts": "^30.2.0", + "@syncfusion/ej2-lists": "^30.2.0", + "@syncfusion/ej2-navigations": "^30.2.0", + "@syncfusion/ej2-notifications": "^30.2.0", + "@syncfusion/ej2-popups": "^30.2.0", + "@syncfusion/ej2-react-base": "^30.2.0", + "@syncfusion/ej2-react-buttons": "^30.2.0", + "@syncfusion/ej2-react-calendars": "^30.2.0", + "@syncfusion/ej2-react-dropdowns": "^30.2.0", + "@syncfusion/ej2-react-filemanager": "^30.2.0", + "@syncfusion/ej2-react-grids": "^30.2.0", + "@syncfusion/ej2-react-inputs": "^30.2.0", + "@syncfusion/ej2-react-kanban": "^30.2.0", + "@syncfusion/ej2-react-layouts": "^30.2.0", + "@syncfusion/ej2-react-navigations": "^30.2.0", + "@syncfusion/ej2-react-notifications": "^30.2.0", + "@syncfusion/ej2-react-popups": "^30.2.0", + "@syncfusion/ej2-react-schedule": "^30.2.0", + "@syncfusion/ej2-splitbuttons": "^30.2.0", + "cldr-data": "^36.0.4", + "lucide-react": "^0.522.0", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "react-router-dom": "^7.6.2" + }, + "devDependencies": { + "@eslint/js": "^9.25.0", + "@tailwindcss/aspect-ratio": "^0.4.2", + "@tailwindcss/forms": "^0.5.10", + "@tailwindcss/typography": "^0.5.16", + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", + "@types/react-router-dom": "^5.3.3", + "@typescript-eslint/eslint-plugin": "^8.34.1", + "@typescript-eslint/parser": "^8.34.1", + "@vitejs/plugin-react": "^4.4.1", + "autoprefixer": "^10.4.21", + "eslint": "^9.29.0", + "eslint-config-prettier": "^10.1.5", + "eslint-plugin-prettier": "^5.5.0", + "eslint-plugin-react": "^7.37.5", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.19", + "globals": "^16.0.0", + "postcss": "^8.5.6", + "prettier": "^3.5.3", + "stylelint": "^16.21.0", + "stylelint-config-standard": "^38.0.0", + "stylelint-config-tailwindcss": "^1.0.0", + "tailwindcss": "^3.4.17", + "typescript": "~5.8.3", + "typescript-eslint": "^8.30.1", + "vite": "^6.3.5" + } +} diff --git a/dashboard/postcss.config.cjs b/dashboard/postcss.config.cjs new file mode 100644 index 0000000..33ad091 --- /dev/null +++ b/dashboard/postcss.config.cjs @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/dashboard/public/program-info.json b/dashboard/public/program-info.json new file mode 100644 index 0000000..1f1a7e2 --- /dev/null +++ b/dashboard/public/program-info.json @@ -0,0 +1,96 @@ +{ + "appName": "Infoscreen-Management", + "version": "2025.1.0-alpha.7", + "copyright": "© 2025 Third-Age-Applications", + "supportContact": "support@third-age-applications.com", + "description": "Eine zentrale Verwaltungsoberfläche für digitale Informationsbildschirme.", + "techStack": { + "Frontend": "React, Vite, TypeScript", + "Backend": "Python (Flask), SQLAlchemy", + "Database": "MariaDB", + "Realtime": "Mosquitto (MQTT)", + "Containerization": "Docker" + }, + "openSourceComponents": { + "frontend": [ + { "name": "React", "license": "MIT" }, + { "name": "Vite", "license": "MIT" }, + { "name": "Lucide Icons", "license": "ISC" }, + { "name": "Syncfusion UI Components", "license": "Kommerziell / Community" } + ], + "backend": [ + { "name": "Flask", "license": "BSD" }, + { "name": "SQLAlchemy", "license": "MIT" }, + { "name": "Paho-MQTT", "license": "EPL/EDL" }, + { "name": "Alembic", "license": "MIT" } + ] + }, + "buildInfo": { + "buildDate": "2025-09-20T11:00:00Z", + "commitId": "8d1df7199cb7" + }, + "changelog": [ + { + "version": "2025.1.0-alpha.7", + "date": "2025-09-21", + "changes": [ + "🧭 UI: Periode-Auswahl (Syncfusion) neben Gruppenauswahl; kompaktes Layout", + "✅ Anzeige: Abzeichen für vorhandenen Ferienplan + Zähler ‘Ferien im Blick’", + "🛠️ API: Endpunkte für akademische Perioden (list, active GET/POST, for_date)", + "📅 Scheduler: Standardmäßig keine Terminierung in Ferien; Block-Darstellung wie Ganztagesereignis; schwarze Textfarbe", + "📤 Ferien: Upload von TXT/CSV (headless TXT nutzt Spalten 2–4)", + "🔧 UX: Schalter in einer Reihe; Dropdown-Breiten optimiert" + ] + }, + { + "version": "2025.1.0-alpha.6", + "date": "2025-09-20", + "changes": [ + "🗓️ NEU: Akademische Perioden System - Unterstützung für Schuljahre, Semester und Trimester", + "🏗️ DATENBANK: Neue 'academic_periods' Tabelle für zeitbasierte Organisation", + "🔗 ERWEITERT: Events und Medien können jetzt optional einer akademischen Periode zugeordnet werden", + "📊 ARCHITEKTUR: Vollständig rückwärtskompatible Implementierung für schrittweise Einführung", + "🎯 BILDUNG: Fokus auf Schulumgebung mit Erweiterbarkeit für Hochschulen", + "⚙️ TOOLS: Automatische Erstellung von Standard-Schuljahren für österreichische Schulen" + ] + }, + { + "version": "2025.1.0-alpha.5", + "date": "2025-09-14", + "changes": [ + "Komplettes Redesign des Backend-Handlings der Gruppenzuordnungen von neuen Clients und der Schritte bei Änderung der Gruppenzuordnung." + ] + }, + { + "version": "2025.1.0-alpha.4", + "date": "2025-09-01", + "changes": [ + "Grundstruktur für Deployment getestet und optimiert.", + "FIX: Programmfehler beim Umschalten der Ansicht auf der Medien-Seite behoben." + ] + }, + { + "version": "2025.1.0-alpha.3", + "date": "2025-08-30", + "changes": [ + "NEU: Programminfo-Seite mit dynamischen Daten, Build-Infos und Changelog.", + "NEU: Logout-Funktionalität implementiert.", + "FIX: Breite der Sidebar im eingeklappten Zustand korrigiert." + ] + }, + { + "version": "2025.1.0-alpha.2", + "date": "2025-08-29", + "changes": [ + "INFO: Analyse und Anzeige der verwendeten Open-Source-Bibliotheken." + ] + }, + { + "version": "2025.1.0-alpha.1", + "date": "2025-08-28", + "changes": [ + "Initiales Setup des Projekts und der Grundstruktur." + ] + } + ] +} diff --git a/dashboard/public/vite.svg b/dashboard/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/dashboard/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashboard/src/App.css b/dashboard/src/App.css new file mode 100644 index 0000000..0857a83 --- /dev/null +++ b/dashboard/src/App.css @@ -0,0 +1,275 @@ +@import "../node_modules/@syncfusion/ej2-base/styles/material.css"; +@import "../node_modules/@syncfusion/ej2-buttons/styles/material.css"; +@import "../node_modules/@syncfusion/ej2-calendars/styles/material.css"; +@import "../node_modules/@syncfusion/ej2-dropdowns/styles/material.css"; +@import "../node_modules/@syncfusion/ej2-inputs/styles/material.css"; +@import "../node_modules/@syncfusion/ej2-lists/styles/material.css"; +@import "../node_modules/@syncfusion/ej2-navigations/styles/material.css"; +@import "../node_modules/@syncfusion/ej2-popups/styles/material.css"; +@import "../node_modules/@syncfusion/ej2-splitbuttons/styles/material.css"; +@import "../node_modules/@syncfusion/ej2-react-schedule/styles/material.css"; +@import "../node_modules/@syncfusion/ej2-kanban/styles/material.css"; +@import "../node_modules/@syncfusion/ej2-notifications/styles/material.css"; +@import "../node_modules/@syncfusion/ej2-react-filemanager/styles/material.css"; +@import "../node_modules/@syncfusion/ej2-layouts/styles/material.css"; +@import "../node_modules/@syncfusion/ej2-grids/styles/material.css"; +@import "../node_modules/@syncfusion/ej2-icons/styles/material.css"; + +body { + font-family: Inter, 'Segoe UI', Roboto, Arial, sans-serif; + overflow: hidden; /* Verhindert den Scrollbalken auf der obersten Ebene */ +} + +:root { + --sidebar-bg: #e5d8c7; + --sidebar-fg: #78591c; + --sidebar-border: #d6c3a6; + --sidebar-text: #000; + --sidebar-hover-bg: #d1b89b; + --sidebar-hover-text: #000; + --sidebar-active-bg: #cda76b; + --sidebar-active-text: #fff; +} + +/* Layout-Container für Sidebar und Content */ +.layout-container { + display: flex; + height: 100vh; /* Feste Höhe auf die des Viewports setzen */ + overflow: hidden; /* Verhindert, dass der Scrollbalken den gesamten Container betrifft */ +} + +/* Sidebar fixieren, keine Scrollbalken, volle Höhe */ +.sidebar-theme { + background-color: var(--sidebar-bg); + color: var(--sidebar-text); + font-size: 1.15rem; + font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif; + flex-shrink: 0; + z-index: 10; /* Stellt sicher, dass die Sidebar über dem Inhalt ist */ + height: 100vh; /* Volle Browser-Höhe */ + min-height: 100vh; /* Mindesthöhe für volle Browser-Höhe */ + max-height: 100vh; /* Maximale Höhe begrenzen */ + display: flex !important; + flex-direction: column !important; + overflow: hidden !important; +} + +/* Sicherstelle vertikale Anordnung der Navigation und Footer am Ende */ +.sidebar-theme nav { + display: flex !important; + flex-direction: column !important; + flex: 1 1 auto !important; + overflow-y: auto !important; + min-height: 0 !important; /* Ermöglicht Flex-Shrinking */ +} + +/* Footer-Bereich am unteren Ende fixieren */ +.sidebar-theme > div:last-child { + margin-top: auto !important; + flex-shrink: 0 !important; + min-height: auto !important; + padding-bottom: 0.5rem !important; /* Zusätzlicher Abstand vom unteren Rand */ +} + +.sidebar-theme .sidebar-link { + text-decoration: none; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + display: flex !important; + width: 100% !important; + box-sizing: border-box; +} + +.sidebar-theme .sidebar-logout { + border: none; + cursor: pointer; + text-align: left; + width: 100%; + font-size: 1.15rem; + display: flex !important; + box-sizing: border-box; +} + + + +.sidebar-link:hover, +.sidebar-logout:hover { + background-color: var(--sidebar-hover-bg); + color: var(--sidebar-hover-text); +} + +.sidebar-link.active { + background-color: var(--sidebar-active-bg); + color: var(--sidebar-active-text); + font-weight: bold; +} + +/* === START: SYNCFUSION-KOMPATIBLES LAYOUT === */ + +/* Der Inhaltsbereich arbeitet mit Syncfusion's natürlichem Layout */ +.content-area { + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; /* Verhindert Flex-Item-Overflow */ +} + +.content-header { + flex-shrink: 0; /* Header soll nicht schrumpfen */ +} + +.page-content { + flex-grow: 1; /* Füllt den verbleibenden Platz */ + overflow-y: auto; /* NUR dieser Bereich scrollt */ + padding: 2rem; + background-color: #f3f4f6; +} + +/* === ENDE: SYNCFUSION-KOMPATIBLES LAYOUT === */ + + +/* Kanban-Karten im Sidebar-Style */ +.e-kanban .e-card, +.e-kanban .e-card .e-card-content, +.e-kanban .e-card .e-card-header { + background-color: var(--sidebar-bg) !important; + color: var(--sidebar-fg) !important; +} + +.e-kanban .e-card:hover, +.e-kanban .e-card.e-selection, +.e-kanban .e-card.e-card-active, +.e-kanban .e-card:hover .e-card-content, +.e-kanban .e-card.e-selection .e-card-content, +.e-kanban .e-card.e-card-active .e-card-content, +.e-kanban .e-card:hover .e-card-header, +.e-kanban .e-card.e-selection .e-card-header, +.e-kanban .e-card.e-card-active .e-card-header { + background-color: var(--sidebar-fg) !important; + color: var(--sidebar-bg) !important; +} + +/* Optional: Fokus-Style für Tastatur-Navigation */ +.e-kanban .e-card:focus { + outline: 2px solid var(--sidebar-fg); + outline-offset: 2px; +} + +/* Kanban-Spaltenheader: Hintergrund und Textfarbe überschreiben */ +.e-kanban .e-kanban-table .e-header-cells { + background-color: color-mix(in srgb, var(--sidebar-bg) 80%, #fff 20%) !important; + color: var(--sidebar-fg) !important; + font-weight: 700; + font-size: 1.08rem; + border-bottom: 2px solid var(--sidebar-fg); + box-shadow: 0 2px 6px 0 color-mix(in srgb, #78591c 8%, transparent); + letter-spacing: 0.02em; +} + +/* Header-Text noch spezifischer and mit !important */ +.e-kanban .e-kanban-table .e-header-cells .e-header-text { + color: color-mix(in srgb, var(--sidebar-fg) 85%, #000 15%) !important; +} + +/* Entferne den globalen Scrollbalken von .main-content! */ +.main-content { + width: 100%; + overflow-x: auto; /* Wiederherstellen des ursprünglichen Scroll-Verhaltens */ + padding-bottom: 8px; +} + +/* Entfernt - Syncfusion verwaltet das Layout selbst */ + +/* Grundlegende Sidebar-Styles - Syncfusion-kompatibel */ +#sidebar .sidebar-link, +#sidebar .sidebar-logout { + display: flex !important; + align-items: center !important; + gap: 8px !important; +} + +#sidebar .sidebar-link svg, +#sidebar .sidebar-logout svg { + flex-shrink: 0 !important; +} + +/* Text standardmäßig IMMER sichtbar */ +#sidebar .sidebar-link .sidebar-text, +#sidebar .sidebar-logout .sidebar-text { + margin-left: 0 !important; + display: inline-block !important; + opacity: 1 !important; + transition: opacity 0.3s, transform 0.3s !important; +} + +#sidebar .sidebar-link:hover, +#sidebar .sidebar-logout:hover { + background-color: var(--sidebar-hover-bg) !important; + color: var(--sidebar-hover-text) !important; +} + +/* Expanded state - Text sichtbar (Standard) */ +#sidebar .sidebar-theme.expanded .sidebar-link, +#sidebar .sidebar-theme.expanded .sidebar-logout { + justify-content: flex-start !important; + padding: 12px 24px !important; + gap: 8px !important; +} + +#sidebar .sidebar-theme.expanded .sidebar-text { + display: inline-block !important; + opacity: 1 !important; +} + +#sidebar .sidebar-theme.expanded .sidebar-link svg, +#sidebar .sidebar-theme.expanded .sidebar-logout svg { + margin-right: 8px !important; +} + +/* Collapsed state - nur Icons */ +#sidebar .sidebar-theme.collapsed .sidebar-link, +#sidebar .sidebar-theme.collapsed .sidebar-logout { + justify-content: center !important; + padding: 12px 8px !important; + gap: 0 !important; + position: relative !important; +} + +#sidebar .sidebar-theme.collapsed .sidebar-text { + display: none !important; +} + +#sidebar .sidebar-theme.collapsed .sidebar-link svg, +#sidebar .sidebar-theme.collapsed .sidebar-logout svg { + margin-right: 0 !important; +} + +/* Syncfusion TooltipComponent wird jetzt verwendet - CSS-Tooltips entfernt */ + +/* Logo und Versionsnummer im collapsed state ausblenden */ + +@keyframes fade-in { + from { + opacity: 0; + transform: translateY(-50%) translateX(-5px); + } + + to { + opacity: 1; + transform: translateY(-50%) translateX(0); + } +} + +/* Logo und Versionsnummer im collapsed state ausblenden */ +#sidebar .sidebar-theme.collapsed img { + display: none !important; +} + +#sidebar .sidebar-theme.collapsed .version-info { + display: none !important; +} + + + + diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx new file mode 100644 index 0000000..4dca5c9 --- /dev/null +++ b/dashboard/src/App.tsx @@ -0,0 +1,337 @@ +import React, { useState } from 'react'; +import { BrowserRouter as Router, Routes, Route, Link, Outlet } from 'react-router-dom'; +import { SidebarComponent } from '@syncfusion/ej2-react-navigations'; +import { ButtonComponent } from '@syncfusion/ej2-react-buttons'; +import { TooltipComponent } from '@syncfusion/ej2-react-popups'; +import logo from './assets/logo.png'; +import './App.css'; + +// Lucide Icons importieren +import { + LayoutDashboard, + Calendar, + Boxes, + Image, + User, + Settings, + Monitor, + MonitorDotIcon, + LogOut, + Wrench, + Info, +} from 'lucide-react'; +import { ToastProvider } from './components/ToastProvider'; + +const sidebarItems = [ + { name: 'Dashboard', path: '/', icon: LayoutDashboard }, + { name: 'Termine', path: '/termine', icon: Calendar }, + { name: 'Ressourcen', path: '/ressourcen', icon: Boxes }, + { name: 'Raumgruppen', path: '/infoscr_groups', icon: MonitorDotIcon }, + { name: 'Infoscreen-Clients', path: '/clients', icon: Monitor }, + { name: 'Erweiterungsmodus', path: '/setup', icon: Wrench }, + { name: 'Medien', path: '/medien', icon: Image }, + { name: 'Benutzer', path: '/benutzer', icon: User }, + { name: 'Einstellungen', path: '/einstellungen', icon: Settings }, + { name: 'Programminfo', path: '/programminfo', icon: Info }, +]; + +// Dummy Components (können in eigene Dateien ausgelagert werden) +import Dashboard from './dashboard'; +import Appointments from './appointments'; +import Ressourcen from './ressourcen'; +import Infoscreens from './clients'; +import Infoscreen_groups from './infoscreen_groups'; +import Media from './media'; +import Benutzer from './benutzer'; +import Einstellungen from './einstellungen'; +import SetupMode from './SetupMode'; +import Programminfo from './programminfo'; +import Logout from './logout'; + +// ENV aus .env holen (Platzhalter, im echten Projekt über process.env oder API) +// const ENV = import.meta.env.VITE_ENV || 'development'; + +const Layout: React.FC = () => { + const [version, setVersion] = useState(''); + const [isCollapsed, setIsCollapsed] = useState(false); + let sidebarRef: SidebarComponent | null; + + React.useEffect(() => { + fetch('/program-info.json') + .then(res => res.json()) + .then(data => setVersion(data.version)) + .catch(err => console.error('Failed to load version info:', err)); + }, []); + + const toggleSidebar = () => { + if (sidebarRef) { + sidebarRef.toggle(); + } + }; + + const onSidebarChange = () => { + // Syncfusion unterscheidet zwischen isOpen (true/false) und dem Dock-Modus + // Im Dock-Modus ist isOpen=true, aber die Sidebar ist kollabiert + const sidebar = sidebarRef?.element; + if (sidebar) { + const currentWidth = sidebar.style.width; + const newCollapsedState = currentWidth === '60px' || currentWidth.includes('60'); + + setIsCollapsed(newCollapsedState); + } + }; + + const sidebarTemplate = () => ( +
+
+ Logo +
+ +
+ {(() => { + const logoutContent = ( + + + + Abmelden + + + ); + + // Syncfusion Tooltip nur im collapsed state + return isCollapsed ? ( + + {logoutContent} + + ) : ( + logoutContent + ); + })()} + {version && ( +
+ Version {version} +
+ )} +
+
+ ); + + return ( +
+ { + sidebarRef = sidebar; + }} + width="256px" + target=".layout-container" + isOpen={true} + closeOnDocumentClick={false} + enableGestures={false} + type="Auto" + enableDock={true} + dockSize="60px" + change={onSidebarChange} + > + {sidebarTemplate()} + + +
+
+ + Logo + + Infoscreen-Management + + + [Organisationsname] + +
+
+ +
+
+
+ ); +}; + +const App: React.FC = () => { + // Automatische Navigation zu /clients bei leerer Beschreibung entfernt + + return ( + + + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + } /> + + + ); +}; + +const AppWrapper: React.FC = () => ( + + + +); + +export default AppWrapper; diff --git a/dashboard/src/SetupMode.tsx b/dashboard/src/SetupMode.tsx new file mode 100644 index 0000000..8778606 --- /dev/null +++ b/dashboard/src/SetupMode.tsx @@ -0,0 +1,174 @@ +import React, { useEffect, useState } from 'react'; +import { fetchClientsWithoutDescription, setClientDescription } from './apiClients'; +import { ButtonComponent } from '@syncfusion/ej2-react-buttons'; +import { TextBoxComponent } from '@syncfusion/ej2-react-inputs'; +import { GridComponent, ColumnsDirective, ColumnDirective } from '@syncfusion/ej2-react-grids'; +import { DialogComponent } from '@syncfusion/ej2-react-popups'; +import { useClientDelete } from './hooks/useClientDelete'; + +type Client = { + uuid: string; + hostname?: string; + ip_address?: string; + last_alive?: string; +}; + +const SetupMode: React.FC = () => { + const [clients, setClients] = useState([]); + const [descriptions, setDescriptions] = useState>({}); + const [loading /* setLoading */] = useState(false); + const [inputActive, setInputActive] = useState(false); + + // Lösch-Logik aus Hook (analog zu clients.tsx) + const { showDialog, deleteClientId, handleDelete, confirmDelete, cancelDelete } = useClientDelete( + async uuid => { + // Nach dem Löschen neu laden! + const updated = await fetchClientsWithoutDescription(); + setClients(updated); + setDescriptions(prev => { + const copy = { ...prev }; + delete copy[uuid]; + return copy; + }); + } + ); + + // Hilfsfunktion zum Vergleich der Clients + const isEqual = (a: Client[], b: Client[]) => { + if (a.length !== b.length) return false; + const aSorted = [...a].sort((x, y) => x.uuid.localeCompare(y.uuid)); + const bSorted = [...b].sort((x, y) => x.uuid.localeCompare(y.uuid)); + for (let i = 0; i < aSorted.length; i++) { + if (aSorted[i].uuid !== bSorted[i].uuid) return false; + if (aSorted[i].hostname !== bSorted[i].hostname) return false; + if (aSorted[i].ip_address !== bSorted[i].ip_address) return false; + if (aSorted[i].last_alive !== bSorted[i].last_alive) return false; + } + return true; + }; + + useEffect(() => { + let polling: ReturnType | null = null; + + const fetchClients = () => { + if (inputActive) return; + fetchClientsWithoutDescription().then(list => { + setClients(prev => (isEqual(prev, list) ? prev : list)); + }); + }; + + fetchClients(); + polling = setInterval(fetchClients, 5000); + + return () => { + if (polling) clearInterval(polling); + }; + }, [inputActive]); + + const handleDescriptionChange = (uuid: string, value: string) => { + setDescriptions(prev => ({ ...prev, [uuid]: value })); + }; + + const handleSave = (uuid: string) => { + setClientDescription(uuid, descriptions[uuid] || '') + .then(() => { + setClients(prev => prev.filter(c => c.uuid !== uuid)); + }) + .catch(err => { + console.error('Fehler beim Speichern der Beschreibung:', err); + }); + }; + + if (loading) return
Lade neue Clients ...
; + + return ( +
+

Erweiterungsmodus: Neue Clients zuordnen

+ + + + + + { + if (!props.last_alive) return ''; + let iso = props.last_alive; + if (!iso.endsWith('Z')) iso += 'Z'; + const date = new Date(iso); + const pad = (n: number) => n.toString().padStart(2, '0'); + return `${pad(date.getDate())}.${pad(date.getMonth() + 1)}.${date.getFullYear()} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; + }} + /> + ( + handleDescriptionChange(props.uuid, e.value as string)} + focus={() => setInputActive(true)} + blur={() => setInputActive(false)} + /> + )} + /> + ( +
+ handleSave(props.uuid)} + /> + { + e.stopPropagation(); + handleDelete(props.uuid); + }} + /> +
+ )} + /> +
+
+ {clients.length === 0 &&
Keine neuen Clients ohne Beschreibung.
} + + {/* Syncfusion Dialog für Sicherheitsabfrage */} + {showDialog && deleteClientId && ( + { + const client = clients.find(c => c.uuid === deleteClientId); + const hostname = client?.hostname ? ` (${client.hostname})` : ''; + return client + ? `Möchten Sie diesen Client${hostname} wirklich entfernen?` + : 'Client nicht gefunden.'; + })()} + showCloseIcon={true} + width="400px" + buttons={[ + { click: confirmDelete, buttonModel: { content: 'Ja', isPrimary: true } }, + { click: cancelDelete, buttonModel: { content: 'Abbrechen' } }, + ]} + close={cancelDelete} + /> + )} +
+ ); +}; + +export default SetupMode; diff --git a/dashboard/src/apiAcademicPeriods.ts b/dashboard/src/apiAcademicPeriods.ts new file mode 100644 index 0000000..3bf2ec5 --- /dev/null +++ b/dashboard/src/apiAcademicPeriods.ts @@ -0,0 +1,42 @@ +export type AcademicPeriod = { + id: number; + name: string; + display_name?: string | null; + start_date: string; // YYYY-MM-DD + end_date: string; // YYYY-MM-DD + period_type: 'schuljahr' | 'semester' | 'trimester'; + is_active: boolean; +}; + +async function api(url: string, init?: RequestInit): Promise { + const res = await fetch(url, { credentials: 'include', ...init }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.json(); +} + +export async function getAcademicPeriodForDate(date: Date): Promise { + const iso = date.toISOString().slice(0, 10); + const { period } = await api<{ period: AcademicPeriod | null }>( + `/api/academic_periods/for_date?date=${iso}` + ); + return period ?? null; +} + +export async function listAcademicPeriods(): Promise { + const { periods } = await api<{ periods: AcademicPeriod[] }>(`/api/academic_periods`); + return Array.isArray(periods) ? periods : []; +} + +export async function getActiveAcademicPeriod(): Promise { + const { period } = await api<{ period: AcademicPeriod | null }>(`/api/academic_periods/active`); + return period ?? null; +} + +export async function setActiveAcademicPeriod(id: number): Promise { + const { period } = await api<{ period: AcademicPeriod }>(`/api/academic_periods/active`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id }), + }); + return period; +} diff --git a/dashboard/src/apiClients.ts b/dashboard/src/apiClients.ts new file mode 100644 index 0000000..11095b2 --- /dev/null +++ b/dashboard/src/apiClients.ts @@ -0,0 +1,105 @@ +export interface Client { + uuid: string; + hardware_token?: string; + ip?: string; + type?: string; + hostname?: string; + os_version?: string; + software_version?: string; + macs?: string; + model?: string; + description?: string; + registration_time?: string; + last_alive?: string; + is_active?: boolean; + group_id?: number; + // Für Health-Status + is_alive?: boolean; +} + +export interface Group { + id: number; + name: string; + created_at?: string; + is_active?: boolean; + clients: Client[]; +} +// Liefert alle Gruppen mit zugehörigen Clients +export async function fetchGroupsWithClients(): Promise { + const response = await fetch('/api/groups/with_clients'); + if (!response.ok) { + throw new Error('Fehler beim Laden der Gruppen mit Clients'); + } + return await response.json(); +} + +export async function fetchClients(): Promise { + const response = await fetch('/api/clients'); + if (!response.ok) { + throw new Error('Fehler beim Laden der Clients'); + } + return await response.json(); +} + +export async function fetchClientsWithoutDescription(): Promise { + const response = await fetch('/api/clients/without_description'); + if (!response.ok) { + throw new Error('Fehler beim Laden der Clients ohne Beschreibung'); + } + return await response.json(); +} + +export async function setClientDescription(uuid: string, description: string) { + const res = await fetch(`/api/clients/${uuid}/description`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ description }), + }); + if (!res.ok) throw new Error('Fehler beim Setzen der Beschreibung'); + return await res.json(); +} + +export async function updateClientGroup(clientIds: string[], groupId: number) { + const res = await fetch('/api/clients/group', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ client_ids: clientIds, group_id: groupId }), + }); + if (!res.ok) throw new Error('Fehler beim Aktualisieren der Clients'); + return await res.json(); +} + +export async function updateClient(uuid: string, data: { description?: string; model?: string }) { + const res = await fetch(`/api/clients/${uuid}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + if (!res.ok) throw new Error('Fehler beim Aktualisieren des Clients'); + return await res.json(); +} + +export async function restartClient(uuid: string): Promise<{ success: boolean; message?: string }> { + const response = await fetch(`/api/clients/${uuid}/restart`, { + method: 'POST', + }); + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Fehler beim Neustart des Clients'); + } + return await response.json(); +} + +export async function deleteClient(uuid: string) { + const res = await fetch(`/api/clients/${uuid}`, { + method: 'DELETE', + }); + if (!res.ok) throw new Error('Fehler beim Entfernen des Clients'); + return await res.json(); +} + +export async function fetchMediaById(mediaId: number | string) { + const response = await fetch(`/api/eventmedia/${mediaId}`); + if (!response.ok) throw new Error('Fehler beim Laden der Mediainformationen'); + return await response.json(); +} diff --git a/dashboard/src/apiEvents.ts b/dashboard/src/apiEvents.ts new file mode 100644 index 0000000..e15d768 --- /dev/null +++ b/dashboard/src/apiEvents.ts @@ -0,0 +1,42 @@ +export interface Event { + id: string; + title: string; + start: string; + end: string; + allDay: boolean; + classNames: string[]; + extendedProps: Record; +} + +export async function fetchEvents(groupId: string, showInactive = false) { + const res = await fetch( + `/api/events?group_id=${encodeURIComponent(groupId)}&show_inactive=${showInactive ? '1' : '0'}` + ); + const data = await res.json(); + if (!res.ok || data.error) throw new Error(data.error || 'Fehler beim Laden der Termine'); + return data; +} + +export async function deleteEvent(eventId: string) { + const res = await fetch(`/api/events/${encodeURIComponent(eventId)}`, { + method: 'DELETE', + }); + const data = await res.json(); + if (!res.ok || data.error) throw new Error(data.error || 'Fehler beim Löschen des Termins'); + return data; +} + +export interface UpdateEventPayload { + [key: string]: unknown; +} + +export async function updateEvent(eventId: string, payload: UpdateEventPayload) { + const res = await fetch(`/api/events/${encodeURIComponent(eventId)}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + const data = await res.json(); + if (!res.ok || data.error) throw new Error(data.error || 'Fehler beim Aktualisieren des Termins'); + return data; +} diff --git a/dashboard/src/apiGroups.ts b/dashboard/src/apiGroups.ts new file mode 100644 index 0000000..302ea12 --- /dev/null +++ b/dashboard/src/apiGroups.ts @@ -0,0 +1,40 @@ +export async function createGroup(name: string) { + const res = await fetch('/api/groups', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name }), + }); + const data = await res.json(); + if (!res.ok || data.error) throw new Error(data.error || 'Fehler beim Erstellen der Gruppe'); + return data; +} + +export async function fetchGroups() { + const res = await fetch('/api/groups'); + const data = await res.json(); + if (!res.ok || data.error) throw new Error(data.error || 'Fehler beim Laden der Gruppen'); + return data; +} + +export async function deleteGroup(groupName: string) { + const res = await fetch(`/api/groups/byname/${encodeURIComponent(groupName)}`, { + method: 'DELETE', + }); + const data = await res.json(); + if (!res.ok || data.error) throw new Error(data.error || 'Fehler beim Löschen der Gruppe'); + return data; +} + +export async function renameGroup(oldName: string, newName: string) { + const res = await fetch(`/api/groups/byname/${encodeURIComponent(oldName)}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ newName: newName }), + }); + const data = await res.json(); + if (!res.ok || data.error) throw new Error(data.error || 'Fehler beim Umbenennen der Gruppe'); + return data; +} + +// Hier kannst du später weitere Funktionen ergänzen: +// export async function updateGroup(id: number, name: string) { ... } diff --git a/dashboard/src/apiHolidays.ts b/dashboard/src/apiHolidays.ts new file mode 100644 index 0000000..0298d0f --- /dev/null +++ b/dashboard/src/apiHolidays.ts @@ -0,0 +1,26 @@ +export type Holiday = { + id: number; + name: string; + start_date: string; + end_date: string; + region?: string | null; + source_file_name?: string | null; + imported_at?: string | null; +}; + +export async function listHolidays(region?: string) { + const url = region ? `/api/holidays?region=${encodeURIComponent(region)}` : '/api/holidays'; + const res = await fetch(url); + const data = await res.json(); + if (!res.ok || data.error) throw new Error(data.error || 'Fehler beim Laden der Ferien'); + return data as { holidays: Holiday[] }; +} + +export async function uploadHolidaysCsv(file: File) { + const form = new FormData(); + form.append('file', file); + const res = await fetch('/api/holidays/upload', { method: 'POST', body: form }); + const data = await res.json(); + if (!res.ok || data.error) throw new Error(data.error || 'Fehler beim Import der Ferien'); + return data as { success: boolean; inserted: number; updated: number }; +} diff --git a/dashboard/src/appointments.tsx b/dashboard/src/appointments.tsx new file mode 100644 index 0000000..8f3ac47 --- /dev/null +++ b/dashboard/src/appointments.tsx @@ -0,0 +1,813 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { + ScheduleComponent, + Day, + Week, + WorkWeek, + Month, + Agenda, + Inject, + ViewsDirective, + ViewDirective, +} from '@syncfusion/ej2-react-schedule'; +import { DropDownListComponent } from '@syncfusion/ej2-react-dropdowns'; +import { L10n, loadCldr, setCulture } from '@syncfusion/ej2-base'; +import type { + EventRenderedArgs, + ActionEventArgs, + RenderCellEventArgs, +} from '@syncfusion/ej2-react-schedule'; +import { fetchEvents } from './apiEvents'; +import { fetchGroups } from './apiGroups'; +import { getGroupColor } from './groupColors'; +import { deleteEvent } from './apiEvents'; +import CustomEventModal from './components/CustomEventModal'; +import { fetchMediaById } from './apiClients'; +import { listHolidays, type Holiday } from './apiHolidays'; +import { + getAcademicPeriodForDate, + listAcademicPeriods, + setActiveAcademicPeriod, +} from './apiAcademicPeriods'; +import { + Presentation, + Globe, + Video, + MessageSquare, + School, + CheckCircle, + AlertCircle, +} from 'lucide-react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import caGregorian from './cldr/ca-gregorian.json'; +import numbers from './cldr/numbers.json'; +import timeZoneNames from './cldr/timeZoneNames.json'; +import numberingSystems from './cldr/numberingSystems.json'; + +// Typ für Gruppe ergänzen +type Group = { + id: string; + name: string; +}; + +// Typ für Event ergänzen +type Event = { + Id: string; + Subject: string; + StartTime: Date; + EndTime: Date; + IsAllDay: boolean; + IsBlock?: boolean; // Syncfusion block appointment + isHoliday?: boolean; // marker for styling/logic + MediaId?: string | number; + SlideshowInterval?: number; + WebsiteUrl?: string; + Icon?: string; // <--- Icon ergänzen! + Type?: string; // <--- Typ ergänzen, falls benötigt +}; + +type RawEvent = { + Id: string; + Subject: string; + StartTime: string; + EndTime: string; + IsAllDay: boolean; + MediaId?: string | number; + Icon?: string; // <--- Icon ergänzen! + Type?: string; +}; + +// CLDR-Daten laden (direkt die JSON-Objekte übergeben) +loadCldr( + caGregorian as object, + numbers as object, + timeZoneNames as object, + numberingSystems as object +); + +// Deutsche Lokalisierung für den Scheduler +L10n.load({ + de: { + schedule: { + day: 'Tag', + week: 'Woche', + workWeek: 'Arbeitswoche', + month: 'Monat', + agenda: 'Agenda', + today: 'Heute', + noEvents: 'Keine Termine', + allDay: 'Ganztägig', + start: 'Start', + end: 'Ende', + event: 'Termin', + save: 'Speichern', + cancel: 'Abbrechen', + delete: 'Löschen', + edit: 'Bearbeiten', + newEvent: 'Neuer Termin', + title: 'Titel', + description: 'Beschreibung', + location: 'Ort', + recurrence: 'Wiederholung', + repeat: 'Wiederholen', + deleteEvent: 'Termin löschen', + deleteContent: 'Möchten Sie diesen Termin wirklich löschen?', + moreDetails: 'Mehr Details', + addTitle: 'Termintitel', + }, + }, +}); + +// Kultur setzen +setCulture('de'); + +// Mapping für Lucide-Icons +const iconMap: Record = { + Presentation, + Globe, + Video, + MessageSquare, + School, +}; + +const eventTemplate = (event: Event) => { + const IconComponent = iconMap[event.Icon ?? ''] || null; + // Zeitangabe formatieren + const start = event.StartTime instanceof Date ? event.StartTime : new Date(event.StartTime); + const end = event.EndTime instanceof Date ? event.EndTime : new Date(event.EndTime); + const timeString = `${start.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} - ${end.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`; + + return ( +
+
+ {IconComponent && ( + + + + )} + {event.Subject} +
+
{timeString}
+
+ ); +}; + +const Appointments: React.FC = () => { + const [groups, setGroups] = useState([]); + const [selectedGroupId, setSelectedGroupId] = useState(null); + const [events, setEvents] = useState([]); + const [holidays, setHolidays] = useState([]); + const [modalOpen, setModalOpen] = useState(false); + const [modalInitialData, setModalInitialData] = useState({}); + const [schedulerKey, setSchedulerKey] = useState(0); + const [editMode, setEditMode] = useState(false); // NEU: Editiermodus + const [showInactive, setShowInactive] = React.useState(true); + const [allowScheduleOnHolidays, setAllowScheduleOnHolidays] = React.useState(false); + const [showHolidayList, setShowHolidayList] = React.useState(true); + const scheduleRef = React.useRef(null); + const [holidaysInView, setHolidaysInView] = React.useState(0); + const [schoolYearLabel, setSchoolYearLabel] = React.useState(''); + const [hasSchoolYearPlan, setHasSchoolYearPlan] = React.useState(false); + const [periods, setPeriods] = React.useState<{ id: number; label: string }[]>([]); + const [activePeriodId, setActivePeriodId] = React.useState(null); + + // Gruppen laden + useEffect(() => { + fetchGroups() + .then(data => { + // Nur Gruppen mit id != 1 berücksichtigen (nicht zugeordnet ignorieren) + const filtered = Array.isArray(data) ? data.filter(g => g.id && g.name && g.id !== 1) : []; + setGroups(filtered); + if (filtered.length > 0) setSelectedGroupId(filtered[0].id); + }) + .catch(console.error); + }, []); + + // Holidays laden + useEffect(() => { + listHolidays() + .then(res => setHolidays(res.holidays || [])) + .catch(err => console.error('Ferien laden fehlgeschlagen:', err)); + }, []); + + // Perioden laden (Dropdown) + useEffect(() => { + listAcademicPeriods() + .then(all => { + setPeriods(all.map(p => ({ id: p.id, label: p.display_name || p.name }))); + const active = all.find(p => p.is_active); + setActivePeriodId(active ? active.id : null); + }) + .catch(err => console.error('Akademische Perioden laden fehlgeschlagen:', err)); + }, []); + + // fetchAndSetEvents als useCallback definieren, damit die Dependency korrekt ist: + const fetchAndSetEvents = React.useCallback(async () => { + if (!selectedGroupId) { + setEvents([]); + return; + } + try { + const data = await fetchEvents(selectedGroupId, showInactive); // selectedGroupId ist jetzt garantiert string + const mapped: Event[] = data.map((e: RawEvent) => ({ + Id: e.Id, + Subject: e.Subject, + StartTime: new Date(e.StartTime.endsWith('Z') ? e.StartTime : e.StartTime + 'Z'), + EndTime: new Date(e.EndTime.endsWith('Z') ? e.EndTime : e.EndTime + 'Z'), + IsAllDay: e.IsAllDay, + MediaId: e.MediaId, + Icon: e.Icon, // <--- Icon übernehmen! + Type: e.Type, // <--- Typ übernehmen! + })); + setEvents(mapped); + } catch (err) { + console.error('Fehler beim Laden der Termine:', err); + } + }, [selectedGroupId, showInactive]); + + React.useEffect(() => { + if (selectedGroupId) { + // selectedGroupId kann null sein, fetchEvents erwartet aber string + fetchAndSetEvents(); + } else { + setEvents([]); + } + }, [selectedGroupId, showInactive, fetchAndSetEvents]); + + // Helper: prüfe, ob Zeitraum einen Feiertag/Ferienbereich schneidet + const isWithinHolidayRange = React.useCallback( + (start: Date, end: Date) => { + // normalisiere Endzeit minimal (Syncfusion nutzt exklusive Enden in einigen Fällen) + const adjEnd = new Date(end); + // keine Änderung nötig – unsere eigenen Events sind präzise + for (const h of holidays) { + // Holiday dates are strings YYYY-MM-DD (local date) + const hs = new Date(h.start_date + 'T00:00:00'); + const he = new Date(h.end_date + 'T23:59:59'); + if ( + (start >= hs && start <= he) || + (adjEnd >= hs && adjEnd <= he) || + (start <= hs && adjEnd >= he) + ) { + return true; + } + } + return false; + }, + [holidays] + ); + + // Baue Holiday-Anzeige-Events und Block-Events + const holidayDisplayEvents: Event[] = useMemo(() => { + if (!showHolidayList) return []; + const out: Event[] = []; + for (const h of holidays) { + const start = new Date(h.start_date + 'T00:00:00'); + const end = new Date(h.end_date + 'T23:59:59'); + out.push({ + Id: `holiday-${h.id}-display`, + Subject: h.name, + StartTime: start, + EndTime: end, + IsAllDay: true, + isHoliday: true, + }); + } + return out; + }, [holidays, showHolidayList]); + + const holidayBlockEvents: Event[] = useMemo(() => { + if (allowScheduleOnHolidays) return []; + const out: Event[] = []; + for (const h of holidays) { + const start = new Date(h.start_date + 'T00:00:00'); + const end = new Date(h.end_date + 'T23:59:59'); + out.push({ + Id: `holiday-${h.id}-block`, + Subject: h.name, + StartTime: start, + EndTime: end, + IsAllDay: true, + IsBlock: true, + isHoliday: true, + }); + } + return out; + }, [holidays, allowScheduleOnHolidays]); + + const dataSource = useMemo(() => { + return [...events, ...holidayDisplayEvents, ...holidayBlockEvents]; + }, [events, holidayDisplayEvents, holidayBlockEvents]); + + // Aktive akademische Periode für Datum aus dem Backend ermitteln + const refreshAcademicPeriodFor = React.useCallback( + async (baseDate: Date) => { + try { + const p = await getAcademicPeriodForDate(baseDate); + if (!p) { + setSchoolYearLabel(''); + setHasSchoolYearPlan(false); + return; + } + // Anzeige: bevorzugt display_name, sonst name + const label = p.display_name ? p.display_name : p.name; + setSchoolYearLabel(label); + // Existiert ein Ferienplan innerhalb der Periode? + const start = new Date(p.start_date + 'T00:00:00'); + const end = new Date(p.end_date + 'T23:59:59'); + let exists = false; + for (const h of holidays) { + const hs = new Date(h.start_date + 'T00:00:00'); + const he = new Date(h.end_date + 'T23:59:59'); + if (hs <= end && he >= start) { + exists = true; + break; + } + } + setHasSchoolYearPlan(exists); + } catch (e) { + console.error('Akademische Periode laden fehlgeschlagen:', e); + setSchoolYearLabel(''); + setHasSchoolYearPlan(false); + } + }, + [holidays] + ); + + // Anzahl an Ferienzeiträumen in aktueller Ansicht ermitteln + Perioden-Indikator setzen + const updateHolidaysInView = React.useCallback(() => { + const inst = scheduleRef.current; + if (!inst) { + setHolidaysInView(0); + return; + } + const view = inst.currentView as 'Day' | 'Week' | 'WorkWeek' | 'Month' | 'Agenda'; + const baseDate = inst.selectedDate as Date; + if (!baseDate) { + setHolidaysInView(0); + return; + } + let rangeStart = new Date(baseDate); + let rangeEnd = new Date(baseDate); + if (view === 'Day' || view === 'Agenda') { + rangeStart.setHours(0, 0, 0, 0); + rangeEnd.setHours(23, 59, 59, 999); + } else if (view === 'Week' || view === 'WorkWeek') { + const day = baseDate.getDay(); + const diffToMonday = (day + 6) % 7; // Monday=0 + rangeStart = new Date(baseDate); + rangeStart.setDate(baseDate.getDate() - diffToMonday); + rangeStart.setHours(0, 0, 0, 0); + rangeEnd = new Date(rangeStart); + rangeEnd.setDate(rangeStart.getDate() + 6); + rangeEnd.setHours(23, 59, 59, 999); + } else if (view === 'Month') { + rangeStart = new Date(baseDate.getFullYear(), baseDate.getMonth(), 1, 0, 0, 0, 0); + rangeEnd = new Date(baseDate.getFullYear(), baseDate.getMonth() + 1, 0, 23, 59, 59, 999); + } + let count = 0; + for (const h of holidays) { + const hs = new Date(h.start_date + 'T00:00:00'); + const he = new Date(h.end_date + 'T23:59:59'); + const overlaps = + (hs >= rangeStart && hs <= rangeEnd) || + (he >= rangeStart && he <= rangeEnd) || + (hs <= rangeStart && he >= rangeEnd); + if (overlaps) count += 1; + } + setHolidaysInView(count); + // Perioden-Indikator über Backend prüfen + refreshAcademicPeriodFor(baseDate); + }, [holidays, refreshAcademicPeriodFor]); + + // Aktualisiere Indikator wenn Ferien oder Ansicht (Key) wechseln + React.useEffect(() => { + updateHolidaysInView(); + }, [holidays, updateHolidaysInView, schedulerKey]); + + return ( +
+

Terminmanagement

+
+ + { + // <--- Typ für e ergänzt + setEvents([]); // Events sofort leeren + setSelectedGroupId(e.value); + }} + style={{}} + /> + + {/* Akademische Periode Selector + Plan-Badge */} + Periode: + { + const id = Number(e.value); + if (!id) return; + try { + const updated = await setActiveAcademicPeriod(id); + setActivePeriodId(updated.id); + // Zum gleichen Tag/Monat (heute) innerhalb der gewählten Periode springen + const today = new Date(); + const targetYear = new Date(updated.start_date).getFullYear(); + const target = new Date(targetYear, today.getMonth(), today.getDate(), 12, 0, 0); + if (scheduleRef.current) { + scheduleRef.current.selectedDate = target; + scheduleRef.current.dataBind?.(); + } + updateHolidaysInView(); + } catch (err) { + console.error('Aktive Periode setzen fehlgeschlagen:', err); + } + }} + style={{}} + /> + {/* School-year/period plan badge (adjacent) */} + + {hasSchoolYearPlan ? ( + + ) : ( + + )} + {schoolYearLabel || 'Periode'} + +
+ +
+ + + + {/* Right-aligned indicators */} +
+ {/* Holidays-in-view badge */} + 0 ? '#ffe8cc' : '#f3f4f6', + border: holidaysInView > 0 ? '1px solid #ffcf99' : '1px solid #e5e7eb', + color: '#000', + padding: '4px 10px', + borderRadius: 16, + fontSize: 12, + }} + > + {holidaysInView > 0 ? `Ferien im Blick: ${holidaysInView}` : 'Keine Ferien in Ansicht'} + +
+
+ { + setModalOpen(false); + setEditMode(false); // Editiermodus zurücksetzen + }} + onSave={async () => { + setModalOpen(false); + setEditMode(false); + if (selectedGroupId) { + const data = await fetchEvents(selectedGroupId, showInactive); + const mapped: Event[] = data.map((e: RawEvent) => ({ + Id: e.Id, + Subject: e.Subject, + StartTime: new Date(e.StartTime.endsWith('Z') ? e.StartTime : e.StartTime + 'Z'), + EndTime: new Date(e.EndTime.endsWith('Z') ? e.EndTime : e.EndTime + 'Z'), + IsAllDay: e.IsAllDay, + MediaId: e.MediaId, + })); + setEvents(mapped); + setSchedulerKey(prev => prev + 1); // <-- Key erhöhen + } + }} + initialData={modalInitialData} + groupName={groups.find(g => g.id === selectedGroupId) ?? { id: selectedGroupId, name: '' }} + groupColor={selectedGroupId ? getGroupColor(selectedGroupId, groups) : undefined} + editMode={editMode} // NEU: Prop für Editiermodus + blockHolidays={!allowScheduleOnHolidays} + isHolidayRange={(s, e) => isWithinHolidayRange(s, e)} + /> + updateHolidaysInView()} + cellClick={args => { + if (!allowScheduleOnHolidays && isWithinHolidayRange(args.startTime, args.endTime)) { + args.cancel = true; + return; // block creation on holidays + } + // args.startTime und args.endTime sind Date-Objekte + args.cancel = true; // Verhindert die Standardaktion + setModalInitialData({ + startDate: args.startTime, + startTime: args.startTime, + endTime: args.endTime, + }); + setEditMode(false); // NEU: kein Editiermodus + setModalOpen(true); + }} + popupOpen={async args => { + if (args.type === 'Editor') { + args.cancel = true; + const event = args.data; + console.log('Event zum Bearbeiten:', event); + let media = null; + if (event.MediaId) { + try { + const mediaData = await fetchMediaById(event.MediaId); + media = { + id: mediaData.id, + path: mediaData.file_path, + name: mediaData.name || mediaData.url, + }; + } catch (err) { + console.error('Fehler beim Laden der Mediainfos:', err); + } + } + setModalInitialData({ + Id: event.Id, + title: event.Subject, + startDate: event.StartTime, + startTime: event.StartTime, + endTime: event.EndTime, + description: event.Description ?? '', + type: event.Type ?? 'presentation', + repeat: event.Repeat ?? false, + weekdays: event.Weekdays ?? [], + repeatUntil: event.RepeatUntil ?? null, + skipHolidays: event.SkipHolidays ?? false, + media, // Metadaten werden nur bei Bedarf geladen! + slideshowInterval: event.SlideshowInterval ?? 10, + websiteUrl: event.WebsiteUrl ?? '', + }); + console.log('Modal initial data:', { + Id: event.Id, + title: event.Subject, + startDate: event.StartTime, + startTime: event.StartTime, + endTime: event.EndTime, + description: event.Description ?? '', + type: event.Type ?? 'presentation', + repeat: event.Repeat ?? false, + weekdays: event.Weekdays ?? [], + repeatUntil: event.RepeatUntil ?? null, + skipHolidays: event.SkipHolidays ?? false, + media, // Metadaten werden nur bei Bedarf geladen! + slideshowInterval: event.SlideshowInterval ?? 10, + websiteUrl: event.WebsiteUrl ?? '', + }); + setEditMode(true); + setModalOpen(true); + } + }} + eventRendered={(args: EventRenderedArgs) => { + // Blende Nicht-Ferien-Events aus, falls sie in Ferien fallen und Terminieren nicht erlaubt ist + if (!allowScheduleOnHolidays && args.data && !args.data.isHoliday) { + const s = + args.data.StartTime instanceof Date + ? args.data.StartTime + : new Date(args.data.StartTime); + const e = + args.data.EndTime instanceof Date ? args.data.EndTime : new Date(args.data.EndTime); + if (isWithinHolidayRange(s, e)) { + args.cancel = true; + return; + } + } + + if (selectedGroupId && args.data && args.data.Id) { + const groupColor = getGroupColor(selectedGroupId, groups); + const now = new Date(); + + let IconComponent: React.ElementType | null = null; + switch (args.data.Type) { + case 'presentation': + IconComponent = Presentation; + break; + case 'website': + IconComponent = Globe; + break; + case 'video': + IconComponent = Video; + break; + case 'message': + IconComponent = MessageSquare; + break; + case 'webuntis': + IconComponent = School; + break; + default: + IconComponent = null; + } + + // Nur .e-subject verwenden! + const titleElement = args.element.querySelector('.e-subject'); + if (titleElement && IconComponent) { + const svgString = renderToStaticMarkup(); + // Immer nur den reinen Text nehmen, kein vorhandenes Icon! + const subjectText = (titleElement as HTMLElement).textContent ?? ''; + (titleElement as HTMLElement).innerHTML = + `${svgString}` + + subjectText; + } + + // Vergangene Termine: Raumgruppenfarbe + if (args.data.EndTime && args.data.EndTime < now) { + args.element.style.backgroundColor = groupColor ? `${groupColor}` : '#f3f3f3'; + args.element.style.color = '#000'; + } else if (groupColor) { + args.element.style.backgroundColor = groupColor; + args.element.style.color = '#000'; + } + + // Spezielle Darstellung für Ferienanzeige-Events + if (args.data.isHoliday && !args.data.IsBlock) { + args.element.style.backgroundColor = '#ffe8cc'; // sanftes Orange + args.element.style.border = '1px solid #ffcf99'; + args.element.style.color = '#000'; + } + // Gleiche Darstellung für Ferien-Block-Events + if (args.data.isHoliday && args.data.IsBlock) { + args.element.style.backgroundColor = '#ffe8cc'; + args.element.style.border = '1px solid #ffcf99'; + args.element.style.color = '#000'; + } + } + }} + actionBegin={async (args: ActionEventArgs) => { + if (args.requestType === 'eventRemove') { + // args.data ist ein Array von zu löschenden Events + const toDelete = Array.isArray(args.data) ? args.data : [args.data]; + for (const ev of toDelete) { + try { + await deleteEvent(ev.Id); // Deine API-Funktion + } catch (err) { + // Optional: Fehlerbehandlung + console.error('Fehler beim Löschen:', err); + } + } + // Events nach Löschen neu laden + if (selectedGroupId) { + fetchEvents(selectedGroupId, showInactive) + .then((data: RawEvent[]) => { + const mapped: Event[] = data.map((e: RawEvent) => ({ + Id: e.Id, + Subject: e.Subject, + StartTime: new Date(e.StartTime), + EndTime: new Date(e.EndTime), + IsAllDay: e.IsAllDay, + MediaId: e.MediaId, + })); + setEvents(mapped); + }) + .catch(console.error); + } + // Syncfusion soll das Event nicht selbst löschen + args.cancel = true; + } else if ( + (args.requestType === 'eventCreate' || args.requestType === 'eventChange') && + !allowScheduleOnHolidays + ) { + // Verhindere Erstellen/Ändern in Ferienbereichen (Failsafe, falls eingebauter Editor genutzt wird) + type PartialEventLike = { StartTime?: Date | string; EndTime?: Date | string }; + const raw = (args as ActionEventArgs).data as + | PartialEventLike + | PartialEventLike[] + | undefined; + const data = Array.isArray(raw) ? raw[0] : raw; + if (data && data.StartTime && data.EndTime) { + const s = data.StartTime instanceof Date ? data.StartTime : new Date(data.StartTime); + const e = data.EndTime instanceof Date ? data.EndTime : new Date(data.EndTime); + if (isWithinHolidayRange(s, e)) { + args.cancel = true; + return; + } + } + } + }} + firstDayOfWeek={1} + renderCell={(args: RenderCellEventArgs) => { + // Nur für Arbeitszellen (Stunden-/Tageszellen) + if (args.elementType === 'workCells') { + const now = new Date(); + // args.element ist vom Typ Element, daher als HTMLElement casten: + const cell = args.element as HTMLElement; + if (args.date && args.date < now) { + cell.style.backgroundColor = '#fff9e3'; // Hellgelb für Vergangenheit + cell.style.opacity = '0.7'; + } + } + }} + > + + + + + + + + + +
+ ); +}; + +export default Appointments; diff --git a/dashboard/src/assets/TAA_Logo.png b/dashboard/src/assets/TAA_Logo.png new file mode 100644 index 0000000..698eb92 Binary files /dev/null and b/dashboard/src/assets/TAA_Logo.png differ diff --git a/dashboard/src/assets/logo.png b/dashboard/src/assets/logo.png new file mode 100644 index 0000000..0ffb524 Binary files /dev/null and b/dashboard/src/assets/logo.png differ diff --git a/dashboard/src/assets/react.svg b/dashboard/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/dashboard/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashboard/src/benutzer.tsx b/dashboard/src/benutzer.tsx new file mode 100644 index 0000000..6241b4a --- /dev/null +++ b/dashboard/src/benutzer.tsx @@ -0,0 +1,8 @@ +import React from 'react'; +const Benutzer: React.FC = () => ( +
+

Benutzer

+

Willkommen im Infoscreen-Management Benutzer.

+
+); +export default Benutzer; diff --git a/dashboard/src/cldr/ca-gregorian.json b/dashboard/src/cldr/ca-gregorian.json new file mode 100644 index 0000000..e7b16fc --- /dev/null +++ b/dashboard/src/cldr/ca-gregorian.json @@ -0,0 +1,569 @@ +{ + "main": { + "de": { + "identity": { + "language": "de" + }, + "dates": { + "calendars": { + "gregorian": { + "months": { + "format": { + "abbreviated": { + "1": "Jan.", + "2": "Feb.", + "3": "März", + "4": "Apr.", + "5": "Mai", + "6": "Juni", + "7": "Juli", + "8": "Aug.", + "9": "Sept.", + "10": "Okt.", + "11": "Nov.", + "12": "Dez." + }, + "narrow": { + "1": "J", + "2": "F", + "3": "M", + "4": "A", + "5": "M", + "6": "J", + "7": "J", + "8": "A", + "9": "S", + "10": "O", + "11": "N", + "12": "D" + }, + "wide": { + "1": "Januar", + "2": "Februar", + "3": "März", + "4": "April", + "5": "Mai", + "6": "Juni", + "7": "Juli", + "8": "August", + "9": "September", + "10": "Oktober", + "11": "November", + "12": "Dezember" + } + }, + "stand-alone": { + "abbreviated": { + "1": "Jan", + "2": "Feb", + "3": "Mär", + "4": "Apr", + "5": "Mai", + "6": "Jun", + "7": "Jul", + "8": "Aug", + "9": "Sep", + "10": "Okt", + "11": "Nov", + "12": "Dez" + }, + "narrow": { + "1": "J", + "2": "F", + "3": "M", + "4": "A", + "5": "M", + "6": "J", + "7": "J", + "8": "A", + "9": "S", + "10": "O", + "11": "N", + "12": "D" + }, + "wide": { + "1": "Januar", + "2": "Februar", + "3": "März", + "4": "April", + "5": "Mai", + "6": "Juni", + "7": "Juli", + "8": "August", + "9": "September", + "10": "Oktober", + "11": "November", + "12": "Dezember" + } + } + }, + "days": { + "format": { + "abbreviated": { + "sun": "So.", + "mon": "Mo.", + "tue": "Di.", + "wed": "Mi.", + "thu": "Do.", + "fri": "Fr.", + "sat": "Sa." + }, + "narrow": { + "sun": "S", + "mon": "M", + "tue": "D", + "wed": "M", + "thu": "D", + "fri": "F", + "sat": "S" + }, + "short": { + "sun": "So.", + "mon": "Mo.", + "tue": "Di.", + "wed": "Mi.", + "thu": "Do.", + "fri": "Fr.", + "sat": "Sa." + }, + "wide": { + "sun": "Sonntag", + "mon": "Montag", + "tue": "Dienstag", + "wed": "Mittwoch", + "thu": "Donnerstag", + "fri": "Freitag", + "sat": "Samstag" + } + }, + "stand-alone": { + "abbreviated": { + "sun": "So", + "mon": "Mo", + "tue": "Di", + "wed": "Mi", + "thu": "Do", + "fri": "Fr", + "sat": "Sa" + }, + "narrow": { + "sun": "S", + "mon": "M", + "tue": "D", + "wed": "M", + "thu": "D", + "fri": "F", + "sat": "S" + }, + "short": { + "sun": "So.", + "mon": "Mo.", + "tue": "Di.", + "wed": "Mi.", + "thu": "Do.", + "fri": "Fr.", + "sat": "Sa." + }, + "wide": { + "sun": "Sonntag", + "mon": "Montag", + "tue": "Dienstag", + "wed": "Mittwoch", + "thu": "Donnerstag", + "fri": "Freitag", + "sat": "Samstag" + } + } + }, + "quarters": { + "format": { + "abbreviated": { + "1": "Q1", + "2": "Q2", + "3": "Q3", + "4": "Q4" + }, + "narrow": { + "1": "1", + "2": "2", + "3": "3", + "4": "4" + }, + "wide": { + "1": "1. Quartal", + "2": "2. Quartal", + "3": "3. Quartal", + "4": "4. Quartal" + } + }, + "stand-alone": { + "abbreviated": { + "1": "Q1", + "2": "Q2", + "3": "Q3", + "4": "Q4" + }, + "narrow": { + "1": "1", + "2": "2", + "3": "3", + "4": "4" + }, + "wide": { + "1": "1. Quartal", + "2": "2. Quartal", + "3": "3. Quartal", + "4": "4. Quartal" + } + } + }, + "dayPeriods": { + "format": { + "abbreviated": { + "midnight": "Mitternacht", + "am": "AM", + "pm": "PM", + "morning1": "morgens", + "morning2": "vorm.", + "afternoon1": "mittags", + "afternoon2": "nachm.", + "evening1": "abends", + "night1": "nachts" + }, + "narrow": { + "midnight": "Mitternacht", + "am": "AM", + "pm": "PM", + "morning1": "morgens", + "morning2": "vorm.", + "afternoon1": "mittags", + "afternoon2": "nachm.", + "evening1": "abends", + "night1": "nachts" + }, + "wide": { + "midnight": "Mitternacht", + "am": "AM", + "pm": "PM", + "morning1": "morgens", + "morning2": "vormittags", + "afternoon1": "mittags", + "afternoon2": "nachmittags", + "evening1": "abends", + "night1": "nachts" + } + }, + "stand-alone": { + "abbreviated": { + "midnight": "Mitternacht", + "am": "AM", + "pm": "PM", + "morning1": "Morgen", + "morning2": "Vorm.", + "afternoon1": "Mittag", + "afternoon2": "Nachm.", + "evening1": "Abend", + "night1": "Nacht" + }, + "narrow": { + "midnight": "Mitternacht", + "am": "AM", + "pm": "PM", + "morning1": "Morgen", + "morning2": "Vorm.", + "afternoon1": "Mittag", + "afternoon2": "Nachm.", + "evening1": "Abend", + "night1": "Nacht" + }, + "wide": { + "midnight": "Mitternacht", + "am": "AM", + "pm": "PM", + "morning1": "Morgen", + "morning2": "Vormittag", + "afternoon1": "Mittag", + "afternoon2": "Nachmittag", + "evening1": "Abend", + "night1": "Nacht" + } + } + }, + "eras": { + "eraNames": { + "0": "v. Chr.", + "0-alt-variant": "vor unserer Zeitrechnung", + "1": "n. Chr.", + "1-alt-variant": "unserer Zeitrechnung" + }, + "eraAbbr": { + "0": "v. Chr.", + "0-alt-variant": "v. u. Z.", + "1": "n. Chr.", + "1-alt-variant": "u. Z." + }, + "eraNarrow": { + "0": "v. Chr.", + "0-alt-variant": "v. u. Z.", + "1": "n. Chr.", + "1-alt-variant": "u. Z." + } + }, + "dateFormats": { + "full": "EEEE, d. MMMM y", + "long": "d. MMMM y", + "medium": "dd.MM.y", + "short": "dd.MM.yy" + }, + "dateSkeletons": { + "full": "yMMMMEEEEd", + "long": "yMMMMd", + "medium": "yMMdd", + "short": "yyMMdd" + }, + "timeFormats": { + "full": "HH:mm:ss zzzz", + "long": "HH:mm:ss z", + "medium": "HH:mm:ss", + "short": "HH:mm" + }, + "timeSkeletons": { + "full": "HHmmsszzzz", + "long": "HHmmssz", + "medium": "HHmmss", + "short": "HHmm" + }, + "dateTimeFormats": { + "full": "{1}, {0}", + "long": "{1}, {0}", + "medium": "{1}, {0}", + "short": "{1}, {0}", + "availableFormats": { + "Bh": "h B", + "Bhm": "h:mm B", + "Bhms": "h:mm:ss B", + "d": "d", + "E": "ccc", + "EBhm": "E h:mm B", + "EBhms": "E h:mm:ss B", + "Ed": "E, d.", + "Ehm": "E h:mm a", + "EHm": "E, HH:mm", + "Ehms": "E, h:mm:ss a", + "EHms": "E, HH:mm:ss", + "Gy": "y G", + "GyMd": "dd.MM.y G", + "GyMMM": "MMM y G", + "GyMMMd": "d. MMM y G", + "GyMMMEd": "E, d. MMM y G", + "h": "h 'Uhr' a", + "H": "HH 'Uhr'", + "hm": "h:mm a", + "Hm": "HH:mm", + "hms": "h:mm:ss a", + "Hms": "HH:mm:ss", + "hmsv": "h:mm:ss a v", + "Hmsv": "HH:mm:ss v", + "hmv": "h:mm a v", + "Hmv": "HH:mm v", + "M": "L", + "Md": "d.M.", + "MEd": "E, d.M.", + "MMd": "d.MM.", + "MMdd": "dd.MM.", + "MMM": "LLL", + "MMMd": "d. MMM", + "MMMEd": "E, d. MMM", + "MMMMd": "d. MMMM", + "MMMMEd": "E, d. MMMM", + "MMMMW-count-one": "'Woche' W 'im' MMMM", + "MMMMW-count-other": "'Woche' W 'im' MMMM", + "ms": "mm:ss", + "y": "y", + "yM": "M/y", + "yMd": "d.M.y", + "yMEd": "E, d.M.y", + "yMM": "MM.y", + "yMMdd": "dd.MM.y", + "yMMM": "MMM y", + "yMMMd": "d. MMM y", + "yMMMEd": "E, d. MMM y", + "yMMMM": "MMMM y", + "yQQQ": "QQQ y", + "yQQQQ": "QQQQ y", + "yw-count-one": "'Woche' w 'des' 'Jahres' Y", + "yw-count-other": "'Woche' w 'des' 'Jahres' Y" + }, + "appendItems": { + "Day": "{0} ({2}: {1})", + "Day-Of-Week": "{0} {1}", + "Era": "{1} {0}", + "Hour": "{0} ({2}: {1})", + "Minute": "{0} ({2}: {1})", + "Month": "{0} ({2}: {1})", + "Quarter": "{0} ({2}: {1})", + "Second": "{0} ({2}: {1})", + "Timezone": "{0} {1}", + "Week": "{0} ({2}: {1})", + "Year": "{1} {0}" + }, + "intervalFormats": { + "intervalFormatFallback": "{0} – {1}", + "Bh": { + "B": "h 'Uhr' B – h 'Uhr' B", + "h": "h–h 'Uhr' B" + }, + "Bhm": { + "B": "h:mm 'Uhr' B – h:mm 'Uhr' B", + "h": "h:mm – h:mm 'Uhr' B", + "m": "h:mm – h:mm 'Uhr' B" + }, + "d": { + "d": "d.–d." + }, + "Gy": { + "G": "y G – y G", + "y": "y–y G" + }, + "GyM": { + "G": "MM/y G – MM/y G", + "M": "MM/y – MM/y G", + "y": "MM/y – MM/y G" + }, + "GyMd": { + "d": "dd.–dd.MM.y G", + "G": "dd.MM.y G – dd.MM.y G", + "M": "dd.MM. – dd.MM.y G", + "y": "dd.MM.y – dd.MM.y G" + }, + "GyMEd": { + "d": "E, dd.MM.y – E, dd.MM.y G", + "G": "E, dd.MM.y G – E, dd.MM.y G", + "M": "E, dd.MM. – E, dd.MM.y G", + "y": "E, dd.MM.y – E, dd.MM.y G" + }, + "GyMMM": { + "G": "MMM y G – MMM y G", + "M": "MMM–MMM y G", + "y": "MMM y – MMM y G" + }, + "GyMMMd": { + "d": "d.–d. MMM y G", + "G": "d. MMM y G – d. MMM y G", + "M": "d. MMM – d. MMM y G", + "y": "d. MMM y – d. MMM y G" + }, + "GyMMMEd": { + "d": "E, d. – E, d. MMM y G", + "G": "E, d. MMM y G – E E, d. MMM y G", + "M": "E, d. MMM – E, d. MMM y G", + "y": "E, d. MMM y – E, d. MMM y G" + }, + "h": { + "a": "h 'Uhr' a – h 'Uhr' a", + "h": "h – h 'Uhr' a" + }, + "H": { + "H": "HH–HH 'Uhr'" + }, + "hm": { + "a": "h:mm a – h:mm a", + "h": "h:mm–h:mm a", + "m": "h:mm–h:mm a" + }, + "Hm": { + "H": "HH:mm–HH:mm 'Uhr'", + "m": "HH:mm–HH:mm 'Uhr'" + }, + "hmv": { + "a": "h:mm a – h:mm a v", + "h": "h:mm–h:mm a v", + "m": "h:mm–h:mm a v" + }, + "Hmv": { + "H": "HH:mm–HH:mm 'Uhr' v", + "m": "HH:mm–HH:mm 'Uhr' v" + }, + "hv": { + "a": "h a – h a v", + "h": "h–h a v" + }, + "Hv": { + "H": "HH–HH 'Uhr' v" + }, + "M": { + "M": "MM–MM" + }, + "Md": { + "d": "dd.–dd.MM.", + "M": "dd.MM. – dd.MM." + }, + "MEd": { + "d": "E, dd. – E, dd.MM.", + "M": "E, dd.MM. – E, dd.MM." + }, + "MMM": { + "M": "MMM–MMM" + }, + "MMMd": { + "d": "d.–d. MMM", + "M": "d. MMM – d. MMM" + }, + "MMMEd": { + "d": "E, d. – E, d. MMM", + "M": "E, d. MMM – E, d. MMM" + }, + "MMMM": { + "M": "LLLL–LLLL" + }, + "y": { + "y": "y–y" + }, + "yM": { + "M": "M/y – M/y", + "y": "M/y – M/y" + }, + "yMd": { + "d": "dd.–dd.MM.y", + "M": "dd.MM. – dd.MM.y", + "y": "dd.MM.y – dd.MM.y" + }, + "yMEd": { + "d": "E, dd. – E, dd.MM.y", + "M": "E, dd.MM. – E, dd.MM.y", + "y": "E, dd.MM.y – E, dd.MM.y" + }, + "yMMM": { + "M": "MMM–MMM y", + "y": "MMM y – MMM y" + }, + "yMMMd": { + "d": "d.–d. MMM y", + "M": "d. MMM – d. MMM y", + "y": "d. MMM y – d. MMM y" + }, + "yMMMEd": { + "d": "E, d. – E, d. MMM y", + "M": "E, d. MMM – E, d. MMM y", + "y": "E, d. MMM y – E, d. MMM y" + }, + "yMMMM": { + "M": "MMMM–MMMM y", + "y": "MMMM y – MMMM y" + } + } + }, + "dateTimeFormats-atTime": { + "standard": { + "full": "{1} 'um' {0}", + "long": "{1} 'um' {0}", + "medium": "{1}, {0}", + "short": "{1}, {0}" + } + } + } + } + } + } + } +} diff --git a/dashboard/src/cldr/numberingSystems.json b/dashboard/src/cldr/numberingSystems.json new file mode 100644 index 0000000..8ec1063 --- /dev/null +++ b/dashboard/src/cldr/numberingSystems.json @@ -0,0 +1,394 @@ +{ + "supplemental": { + "version": { + "_unicodeVersion": "16.0.0", + "_cldrVersion": "47" + }, + "numberingSystems": { + "adlm": { + "_digits": "𞥐𞥑𞥒𞥓𞥔𞥕𞥖𞥗𞥘𞥙", + "_type": "numeric" + }, + "ahom": { + "_digits": "𑜰𑜱𑜲𑜳𑜴𑜵𑜶𑜷𑜸𑜹", + "_type": "numeric" + }, + "arab": { + "_digits": "٠١٢٣٤٥٦٧٨٩", + "_type": "numeric" + }, + "arabext": { + "_digits": "۰۱۲۳۴۵۶۷۸۹", + "_type": "numeric" + }, + "armn": { + "_rules": "armenian-upper", + "_type": "algorithmic" + }, + "armnlow": { + "_rules": "armenian-lower", + "_type": "algorithmic" + }, + "bali": { + "_digits": "᭐᭑᭒᭓᭔᭕᭖᭗᭘᭙", + "_type": "numeric" + }, + "beng": { + "_digits": "০১২৩৪৫৬৭৮৯", + "_type": "numeric" + }, + "bhks": { + "_digits": "𑱐𑱑𑱒𑱓𑱔𑱕𑱖𑱗𑱘𑱙", + "_type": "numeric" + }, + "brah": { + "_digits": "𑁦𑁧𑁨𑁩𑁪𑁫𑁬𑁭𑁮𑁯", + "_type": "numeric" + }, + "cakm": { + "_digits": "𑄶𑄷𑄸𑄹𑄺𑄻𑄼𑄽𑄾𑄿", + "_type": "numeric" + }, + "cham": { + "_digits": "꩐꩑꩒꩓꩔꩕꩖꩗꩘꩙", + "_type": "numeric" + }, + "cyrl": { + "_rules": "cyrillic-lower", + "_type": "algorithmic" + }, + "deva": { + "_digits": "०१२३४५६७८९", + "_type": "numeric" + }, + "diak": { + "_digits": "𑥐𑥑𑥒𑥓𑥔𑥕𑥖𑥗𑥘𑥙", + "_type": "numeric" + }, + "ethi": { + "_rules": "ethiopic", + "_type": "algorithmic" + }, + "fullwide": { + "_digits": "0123456789", + "_type": "numeric" + }, + "gara": { + "_digits": "𐵀𐵁𐵂𐵃𐵄𐵅𐵆𐵇𐵈𐵉", + "_type": "numeric" + }, + "geor": { + "_rules": "georgian", + "_type": "algorithmic" + }, + "gong": { + "_digits": "𑶠𑶡𑶢𑶣𑶤𑶥𑶦𑶧𑶨𑶩", + "_type": "numeric" + }, + "gonm": { + "_digits": "𑵐𑵑𑵒𑵓𑵔𑵕𑵖𑵗𑵘𑵙", + "_type": "numeric" + }, + "grek": { + "_rules": "greek-upper", + "_type": "algorithmic" + }, + "greklow": { + "_rules": "greek-lower", + "_type": "algorithmic" + }, + "gujr": { + "_digits": "૦૧૨૩૪૫૬૭૮૯", + "_type": "numeric" + }, + "gukh": { + "_digits": "𖄰𖄱𖄲𖄳𖄴𖄵𖄶𖄷𖄸𖄹", + "_type": "numeric" + }, + "guru": { + "_digits": "੦੧੨੩੪੫੬੭੮੯", + "_type": "numeric" + }, + "hanidays": { + "_rules": "zh/SpelloutRules/spellout-numbering-days", + "_type": "algorithmic" + }, + "hanidec": { + "_digits": "〇一二三四五六七八九", + "_type": "numeric" + }, + "hans": { + "_rules": "zh/SpelloutRules/spellout-cardinal", + "_type": "algorithmic" + }, + "hansfin": { + "_rules": "zh/SpelloutRules/spellout-cardinal-financial", + "_type": "algorithmic" + }, + "hant": { + "_rules": "zh_Hant/SpelloutRules/spellout-cardinal", + "_type": "algorithmic" + }, + "hantfin": { + "_rules": "zh_Hant/SpelloutRules/spellout-cardinal-financial", + "_type": "algorithmic" + }, + "hebr": { + "_rules": "hebrew", + "_type": "algorithmic" + }, + "hmng": { + "_digits": "𖭐𖭑𖭒𖭓𖭔𖭕𖭖𖭗𖭘𖭙", + "_type": "numeric" + }, + "hmnp": { + "_digits": "𞅀𞅁𞅂𞅃𞅄𞅅𞅆𞅇𞅈𞅉", + "_type": "numeric" + }, + "java": { + "_digits": "꧐꧑꧒꧓꧔꧕꧖꧗꧘꧙", + "_type": "numeric" + }, + "jpan": { + "_rules": "ja/SpelloutRules/spellout-cardinal", + "_type": "algorithmic" + }, + "jpanfin": { + "_rules": "ja/SpelloutRules/spellout-cardinal-financial", + "_type": "algorithmic" + }, + "jpanyear": { + "_rules": "ja/SpelloutRules/spellout-numbering-year-latn", + "_type": "algorithmic" + }, + "kali": { + "_digits": "꤀꤁꤂꤃꤄꤅꤆꤇꤈꤉", + "_type": "numeric" + }, + "kawi": { + "_digits": "𑽐𑽑𑽒𑽓𑽔𑽕𑽖𑽗𑽘𑽙", + "_type": "numeric" + }, + "khmr": { + "_digits": "០១២៣៤៥៦៧៨៩", + "_type": "numeric" + }, + "knda": { + "_digits": "೦೧೨೩೪೫೬೭೮೯", + "_type": "numeric" + }, + "krai": { + "_digits": "𖵰𖵱𖵲𖵳𖵴𖵵𖵶𖵷𖵸𖵹", + "_type": "numeric" + }, + "lana": { + "_digits": "᪀᪁᪂᪃᪄᪅᪆᪇᪈᪉", + "_type": "numeric" + }, + "lanatham": { + "_digits": "᪐᪑᪒᪓᪔᪕᪖᪗᪘᪙", + "_type": "numeric" + }, + "laoo": { + "_digits": "໐໑໒໓໔໕໖໗໘໙", + "_type": "numeric" + }, + "latn": { + "_digits": "0123456789", + "_type": "numeric" + }, + "lepc": { + "_digits": "᱀᱁᱂᱃᱄᱅᱆᱇᱈᱉", + "_type": "numeric" + }, + "limb": { + "_digits": "᥆᥇᥈᥉᥊᥋᥌᥍᥎᥏", + "_type": "numeric" + }, + "mathbold": { + "_digits": "𝟎𝟏𝟐𝟑𝟒𝟓𝟔𝟕𝟖𝟗", + "_type": "numeric" + }, + "mathdbl": { + "_digits": "𝟘𝟙𝟚𝟛𝟜𝟝𝟞𝟟𝟠𝟡", + "_type": "numeric" + }, + "mathmono": { + "_digits": "𝟶𝟷𝟸𝟹𝟺𝟻𝟼𝟽𝟾𝟿", + "_type": "numeric" + }, + "mathsanb": { + "_digits": "𝟬𝟭𝟮𝟯𝟰𝟱𝟲𝟳𝟴𝟵", + "_type": "numeric" + }, + "mathsans": { + "_digits": "𝟢𝟣𝟤𝟥𝟦𝟧𝟨𝟩𝟪𝟫", + "_type": "numeric" + }, + "mlym": { + "_digits": "൦൧൨൩൪൫൬൭൮൯", + "_type": "numeric" + }, + "modi": { + "_digits": "𑙐𑙑𑙒𑙓𑙔𑙕𑙖𑙗𑙘𑙙", + "_type": "numeric" + }, + "mong": { + "_digits": "᠐᠑᠒᠓᠔᠕᠖᠗᠘᠙", + "_type": "numeric" + }, + "mroo": { + "_digits": "𖩠𖩡𖩢𖩣𖩤𖩥𖩦𖩧𖩨𖩩", + "_type": "numeric" + }, + "mtei": { + "_digits": "꯰꯱꯲꯳꯴꯵꯶꯷꯸꯹", + "_type": "numeric" + }, + "mymr": { + "_digits": "၀၁၂၃၄၅၆၇၈၉", + "_type": "numeric" + }, + "mymrepka": { + "_digits": "𑛚𑛛𑛜𑛝𑛞𑛟𑛠𑛡𑛢𑛣", + "_type": "numeric" + }, + "mymrpao": { + "_digits": "𑛐𑛑𑛒𑛓𑛔𑛕𑛖𑛗𑛘𑛙", + "_type": "numeric" + }, + "mymrshan": { + "_digits": "႐႑႒႓႔႕႖႗႘႙", + "_type": "numeric" + }, + "mymrtlng": { + "_digits": "꧰꧱꧲꧳꧴꧵꧶꧷꧸꧹", + "_type": "numeric" + }, + "nagm": { + "_digits": "𞓰𞓱𞓲𞓳𞓴𞓵𞓶𞓷𞓸𞓹", + "_type": "numeric" + }, + "newa": { + "_digits": "𑑐𑑑𑑒𑑓𑑔𑑕𑑖𑑗𑑘𑑙", + "_type": "numeric" + }, + "nkoo": { + "_digits": "߀߁߂߃߄߅߆߇߈߉", + "_type": "numeric" + }, + "olck": { + "_digits": "᱐᱑᱒᱓᱔᱕᱖᱗᱘᱙", + "_type": "numeric" + }, + "onao": { + "_digits": "𞗱𞗲𞗳𞗴𞗵𞗶𞗷𞗸𞗹𞗺", + "_type": "numeric" + }, + "orya": { + "_digits": "୦୧୨୩୪୫୬୭୮୯", + "_type": "numeric" + }, + "osma": { + "_digits": "𐒠𐒡𐒢𐒣𐒤𐒥𐒦𐒧𐒨𐒩", + "_type": "numeric" + }, + "outlined": { + "_digits": "𜳰𜳱𜳲𜳳𜳴𜳵𜳶𜳷𜳸𜳹", + "_type": "numeric" + }, + "rohg": { + "_digits": "𐴰𐴱𐴲𐴳𐴴𐴵𐴶𐴷𐴸𐴹", + "_type": "numeric" + }, + "roman": { + "_rules": "roman-upper", + "_type": "algorithmic" + }, + "romanlow": { + "_rules": "roman-lower", + "_type": "algorithmic" + }, + "saur": { + "_digits": "꣐꣑꣒꣓꣔꣕꣖꣗꣘꣙", + "_type": "numeric" + }, + "segment": { + "_digits": "🯰🯱🯲🯳🯴🯵🯶🯷🯸🯹", + "_type": "numeric" + }, + "shrd": { + "_digits": "𑇐𑇑𑇒𑇓𑇔𑇕𑇖𑇗𑇘𑇙", + "_type": "numeric" + }, + "sind": { + "_digits": "𑋰𑋱𑋲𑋳𑋴𑋵𑋶𑋷𑋸𑋹", + "_type": "numeric" + }, + "sinh": { + "_digits": "෦෧෨෩෪෫෬෭෮෯", + "_type": "numeric" + }, + "sora": { + "_digits": "𑃰𑃱𑃲𑃳𑃴𑃵𑃶𑃷𑃸𑃹", + "_type": "numeric" + }, + "sund": { + "_digits": "᮰᮱᮲᮳᮴᮵᮶᮷᮸᮹", + "_type": "numeric" + }, + "sunu": { + "_digits": "𑯰𑯱𑯲𑯳𑯴𑯵𑯶𑯷𑯸𑯹", + "_type": "numeric" + }, + "takr": { + "_digits": "𑛀𑛁𑛂𑛃𑛄𑛅𑛆𑛇𑛈𑛉", + "_type": "numeric" + }, + "talu": { + "_digits": "᧐᧑᧒᧓᧔᧕᧖᧗᧘᧙", + "_type": "numeric" + }, + "taml": { + "_rules": "tamil", + "_type": "algorithmic" + }, + "tamldec": { + "_digits": "௦௧௨௩௪௫௬௭௮௯", + "_type": "numeric" + }, + "telu": { + "_digits": "౦౧౨౩౪౫౬౭౮౯", + "_type": "numeric" + }, + "thai": { + "_digits": "๐๑๒๓๔๕๖๗๘๙", + "_type": "numeric" + }, + "tibt": { + "_digits": "༠༡༢༣༤༥༦༧༨༩", + "_type": "numeric" + }, + "tirh": { + "_digits": "𑓐𑓑𑓒𑓓𑓔𑓕𑓖𑓗𑓘𑓙", + "_type": "numeric" + }, + "tnsa": { + "_digits": "𖫀𖫁𖫂𖫃𖫄𖫅𖫆𖫇𖫈𖫉", + "_type": "numeric" + }, + "vaii": { + "_digits": "꘠꘡꘢꘣꘤꘥꘦꘧꘨꘩", + "_type": "numeric" + }, + "wara": { + "_digits": "𑣠𑣡𑣢𑣣𑣤𑣥𑣦𑣧𑣨𑣩", + "_type": "numeric" + }, + "wcho": { + "_digits": "𞋰𞋱𞋲𞋳𞋴𞋵𞋶𞋷𞋸𞋹", + "_type": "numeric" + } + } + } +} diff --git a/dashboard/src/cldr/numbers.json b/dashboard/src/cldr/numbers.json new file mode 100644 index 0000000..43ef832 --- /dev/null +++ b/dashboard/src/cldr/numbers.json @@ -0,0 +1,164 @@ +{ + "main": { + "de": { + "identity": { + "language": "de" + }, + "numbers": { + "defaultNumberingSystem": "latn", + "otherNumberingSystems": { + "native": "latn" + }, + "minimumGroupingDigits": "1", + "symbols-numberSystem-latn": { + "decimal": ",", + "group": ".", + "list": ";", + "percentSign": "%", + "plusSign": "+", + "minusSign": "-", + "approximatelySign": "≈", + "exponential": "E", + "superscriptingExponent": "·", + "perMille": "‰", + "infinity": "∞", + "nan": "NaN", + "timeSeparator": ":" + }, + "decimalFormats-numberSystem-latn": { + "standard": "#,##0.###", + "long": { + "decimalFormat": { + "1000-count-one": "0 Tausend", + "1000-count-other": "0 Tausend", + "10000-count-one": "00 Tausend", + "10000-count-other": "00 Tausend", + "100000-count-one": "000 Tausend", + "100000-count-other": "000 Tausend", + "1000000-count-one": "0 Million", + "1000000-count-other": "0 Millionen", + "10000000-count-one": "00 Millionen", + "10000000-count-other": "00 Millionen", + "100000000-count-one": "000 Millionen", + "100000000-count-other": "000 Millionen", + "1000000000-count-one": "0 Milliarde", + "1000000000-count-other": "0 Milliarden", + "10000000000-count-one": "00 Milliarden", + "10000000000-count-other": "00 Milliarden", + "100000000000-count-one": "000 Milliarden", + "100000000000-count-other": "000 Milliarden", + "1000000000000-count-one": "0 Billion", + "1000000000000-count-other": "0 Billionen", + "10000000000000-count-one": "00 Billionen", + "10000000000000-count-other": "00 Billionen", + "100000000000000-count-one": "000 Billionen", + "100000000000000-count-other": "000 Billionen" + } + }, + "short": { + "decimalFormat": { + "1000-count-one": "0", + "1000-count-other": "0", + "10000-count-one": "0", + "10000-count-other": "0", + "100000-count-one": "0", + "100000-count-other": "0", + "1000000-count-one": "0 Mio'.'", + "1000000-count-other": "0 Mio'.'", + "10000000-count-one": "00 Mio'.'", + "10000000-count-other": "00 Mio'.'", + "100000000-count-one": "000 Mio'.'", + "100000000-count-other": "000 Mio'.'", + "1000000000-count-one": "0 Mrd'.'", + "1000000000-count-other": "0 Mrd'.'", + "10000000000-count-one": "00 Mrd'.'", + "10000000000-count-other": "00 Mrd'.'", + "100000000000-count-one": "000 Mrd'.'", + "100000000000-count-other": "000 Mrd'.'", + "1000000000000-count-one": "0 Bio'.'", + "1000000000000-count-other": "0 Bio'.'", + "10000000000000-count-one": "00 Bio'.'", + "10000000000000-count-other": "00 Bio'.'", + "100000000000000-count-one": "000 Bio'.'", + "100000000000000-count-other": "000 Bio'.'" + } + } + }, + "scientificFormats-numberSystem-latn": { + "standard": "#E0" + }, + "percentFormats-numberSystem-latn": { + "standard": "#,##0 %" + }, + "currencyFormats-numberSystem-latn": { + "currencySpacing": { + "beforeCurrency": { + "currencyMatch": "[[:^S:]&[:^Z:]]", + "surroundingMatch": "[:digit:]", + "insertBetween": " " + }, + "afterCurrency": { + "currencyMatch": "[[:^S:]&[:^Z:]]", + "surroundingMatch": "[:digit:]", + "insertBetween": " " + } + }, + "standard": "#,##0.00 ¤", + "standard-alphaNextToNumber": "¤ #,##0.00", + "standard-noCurrency": "#,##0.00", + "accounting": "#,##0.00 ¤", + "accounting-alphaNextToNumber": "¤ #,##0.00", + "accounting-noCurrency": "#,##0.00", + "short": { + "standard": { + "1000-count-one": "0", + "1000-count-other": "0", + "10000-count-one": "0", + "10000-count-other": "0", + "100000-count-one": "0", + "100000-count-other": "0", + "1000000-count-one": "0 Mio'.' ¤", + "1000000-count-other": "0 Mio'.' ¤", + "10000000-count-one": "00 Mio'.' ¤", + "10000000-count-other": "00 Mio'.' ¤", + "100000000-count-one": "000 Mio'.' ¤", + "100000000-count-other": "000 Mio'.' ¤", + "1000000000-count-one": "0 Mrd'.' ¤", + "1000000000-count-other": "0 Mrd'.' ¤", + "10000000000-count-one": "00 Mrd'.' ¤", + "10000000000-count-other": "00 Mrd'.' ¤", + "100000000000-count-one": "000 Mrd'.' ¤", + "100000000000-count-other": "000 Mrd'.' ¤", + "1000000000000-count-one": "0 Bio'.' ¤", + "1000000000000-count-other": "0 Bio'.' ¤", + "10000000000000-count-one": "00 Bio'.' ¤", + "10000000000000-count-other": "00 Bio'.' ¤", + "100000000000000-count-one": "000 Bio'.' ¤", + "100000000000000-count-other": "000 Bio'.' ¤" + } + }, + "currencyPatternAppendISO": "{0} ¤¤", + "unitPattern-count-other": "{0} {1}" + }, + "miscPatterns-numberSystem-latn": { + "approximately": "≈{0}", + "atLeast": "{0}+", + "atMost": "≤{0}", + "range": "{0}–{1}" + }, + "minimalPairs": { + "pluralMinimalPairs-count-one": "{0} Tag", + "pluralMinimalPairs-count-other": "{0} Tage", + "other": "{0}. Abzweigung nach rechts nehmen", + "accusative": "… für {0} …", + "dative": "… mit {0} …", + "genitive": "Anstatt {0} …", + "nominative": "{0} kostet (kosten) € 3,50.", + "feminine": "Die {0} ist …", + "masculine": "Der {0} ist …", + "neuter": "Das {0} ist …" + } + } + } + } +} diff --git a/dashboard/src/cldr/timeZoneNames.json b/dashboard/src/cldr/timeZoneNames.json new file mode 100644 index 0000000..e7e3176 --- /dev/null +++ b/dashboard/src/cldr/timeZoneNames.json @@ -0,0 +1,1563 @@ +{ + "main": { + "de": { + "identity": { + "language": "de" + }, + "dates": { + "timeZoneNames": { + "hourFormat": "+HH:mm;-HH:mm", + "gmtFormat": "GMT{0}", + "gmtZeroFormat": "GMT", + "regionFormat": "{0} (Ortszeit)", + "regionFormat-type-daylight": "{0} (Sommerzeit)", + "regionFormat-type-standard": "{0} (Normalzeit)", + "fallbackFormat": "{1} ({0})", + "zone": { + "Pacific": { + "Honolulu": { + "_type": "zone", + "exemplarCity": "Honolulu" + }, + "Easter": { + "_type": "zone", + "exemplarCity": "Osterinsel" + }, + "Fiji": { + "_type": "zone", + "exemplarCity": "Fidschi" + }, + "Truk": { + "_type": "zone", + "exemplarCity": "Chuuk" + }, + "Ponape": { + "_type": "zone", + "exemplarCity": "Pohnpei" + }, + "Enderbury": { + "_type": "zone", + "exemplarCity": "Enderbury" + } + }, + "Etc": { + "UTC": { + "_type": "zone", + "long": { + "standard": "Koordinierte Weltzeit" + }, + "short": { + "standard": "UTC" + } + }, + "Unknown": { + "_type": "zone", + "exemplarCity": "Unbekannt" + } + }, + "Europe": { + "Tirane": { + "_type": "zone", + "exemplarCity": "Tirana" + }, + "Vienna": { + "_type": "zone", + "exemplarCity": "Wien" + }, + "Brussels": { + "_type": "zone", + "exemplarCity": "Brüssel" + }, + "Zurich": { + "_type": "zone", + "exemplarCity": "Zürich" + }, + "Prague": { + "_type": "zone", + "exemplarCity": "Prag" + }, + "Busingen": { + "_type": "zone", + "exemplarCity": "Büsingen" + }, + "Copenhagen": { + "_type": "zone", + "exemplarCity": "Kopenhagen" + }, + "London": { + "_type": "zone", + "long": { + "daylight": "Britische Sommerzeit" + } + }, + "Athens": { + "_type": "zone", + "exemplarCity": "Athen" + }, + "Dublin": { + "_type": "zone", + "long": { + "daylight": "Irische Sommerzeit" + } + }, + "Rome": { + "_type": "zone", + "exemplarCity": "Rom" + }, + "Luxembourg": { + "_type": "zone", + "exemplarCity": "Luxemburg" + }, + "Warsaw": { + "_type": "zone", + "exemplarCity": "Warschau" + }, + "Lisbon": { + "_type": "zone", + "exemplarCity": "Lissabon" + }, + "Bucharest": { + "_type": "zone", + "exemplarCity": "Bukarest" + }, + "Belgrade": { + "_type": "zone", + "exemplarCity": "Belgrad" + }, + "Moscow": { + "_type": "zone", + "exemplarCity": "Moskau" + }, + "Volgograd": { + "_type": "zone", + "exemplarCity": "Wolgograd" + }, + "Saratov": { + "_type": "zone", + "exemplarCity": "Saratow" + }, + "Astrakhan": { + "_type": "zone", + "exemplarCity": "Astrachan" + }, + "Ulyanovsk": { + "_type": "zone", + "exemplarCity": "Uljanowsk" + }, + "Kirov": { + "_type": "zone", + "exemplarCity": "Kirow" + }, + "Kiev": { + "_type": "zone", + "exemplarCity": "Kiew" + }, + "Vatican": { + "_type": "zone", + "exemplarCity": "Vatikan" + } + }, + "Asia": { + "Yerevan": { + "_type": "zone", + "exemplarCity": "Eriwan" + }, + "Brunei": { + "_type": "zone", + "exemplarCity": "Brunei Darussalam" + }, + "Urumqi": { + "_type": "zone", + "exemplarCity": "Ürümqi" + }, + "Nicosia": { + "_type": "zone", + "exemplarCity": "Nikosia" + }, + "Tbilisi": { + "_type": "zone", + "exemplarCity": "Tiflis" + }, + "Hong_Kong": { + "_type": "zone", + "exemplarCity": "Hongkong" + }, + "Calcutta": { + "_type": "zone", + "exemplarCity": "Kalkutta" + }, + "Baghdad": { + "_type": "zone", + "exemplarCity": "Bagdad" + }, + "Tehran": { + "_type": "zone", + "exemplarCity": "Teheran" + }, + "Tokyo": { + "_type": "zone", + "exemplarCity": "Tokio" + }, + "Bishkek": { + "_type": "zone", + "exemplarCity": "Bischkek" + }, + "Pyongyang": { + "_type": "zone", + "exemplarCity": "Pjöngjang" + }, + "Aqtobe": { + "_type": "zone", + "exemplarCity": "Aktobe" + }, + "Qostanay": { + "_type": "zone", + "exemplarCity": "Qostanai" + }, + "Qyzylorda": { + "_type": "zone", + "exemplarCity": "Qysylorda" + }, + "Rangoon": { + "_type": "zone", + "exemplarCity": "Rangun" + }, + "Hovd": { + "_type": "zone", + "exemplarCity": "Chowd" + }, + "Macau": { + "_type": "zone", + "exemplarCity": "Macau" + }, + "Katmandu": { + "_type": "zone", + "exemplarCity": "Kathmandu" + }, + "Muscat": { + "_type": "zone", + "exemplarCity": "Maskat" + }, + "Karachi": { + "_type": "zone", + "exemplarCity": "Karatschi" + }, + "Qatar": { + "_type": "zone", + "exemplarCity": "Katar" + }, + "Yekaterinburg": { + "_type": "zone", + "exemplarCity": "Jekaterinburg" + }, + "Novosibirsk": { + "_type": "zone", + "exemplarCity": "Nowosibirsk" + }, + "Novokuznetsk": { + "_type": "zone", + "exemplarCity": "Nowokuznetsk" + }, + "Krasnoyarsk": { + "_type": "zone", + "exemplarCity": "Krasnojarsk" + }, + "Chita": { + "_type": "zone", + "exemplarCity": "Tschita" + }, + "Yakutsk": { + "_type": "zone", + "exemplarCity": "Jakutsk" + }, + "Vladivostok": { + "_type": "zone", + "exemplarCity": "Wladiwostok" + }, + "Khandyga": { + "_type": "zone", + "exemplarCity": "Chandyga" + }, + "Sakhalin": { + "_type": "zone", + "exemplarCity": "Sachalin" + }, + "Kamchatka": { + "_type": "zone", + "exemplarCity": "Kamtschatka" + }, + "Riyadh": { + "_type": "zone", + "exemplarCity": "Riad" + }, + "Singapore": { + "_type": "zone", + "exemplarCity": "Singapur" + }, + "Damascus": { + "_type": "zone", + "exemplarCity": "Damaskus" + }, + "Dushanbe": { + "_type": "zone", + "exemplarCity": "Duschanbe" + }, + "Ashgabat": { + "_type": "zone", + "exemplarCity": "Aşgabat" + }, + "Taipei": { + "_type": "zone", + "exemplarCity": "Taipeh" + }, + "Tashkent": { + "_type": "zone", + "exemplarCity": "Taschkent" + }, + "Saigon": { + "_type": "zone", + "exemplarCity": "Ho-Chi-Minh-Stadt" + } + }, + "Antarctica": { + "Vostok": { + "_type": "zone", + "exemplarCity": "Wostok" + }, + "DumontDUrville": { + "_type": "zone", + "exemplarCity": "Dumont-d’Urville" + } + }, + "America": { + "Cordoba": { + "_type": "zone", + "exemplarCity": "Córdoba" + }, + "St_Barthelemy": { + "_type": "zone", + "exemplarCity": "Saint-Barthélemy" + }, + "Sao_Paulo": { + "_type": "zone", + "exemplarCity": "São Paulo" + }, + "Noronha": { + "_type": "zone", + "exemplarCity": "Fernando de Noronha" + }, + "Coral_Harbour": { + "_type": "zone", + "exemplarCity": "Atikokan" + }, + "St_Johns": { + "_type": "zone", + "exemplarCity": "St. John’s" + }, + "Bogota": { + "_type": "zone", + "exemplarCity": "Bogotá" + }, + "Havana": { + "_type": "zone", + "exemplarCity": "Havanna" + }, + "Curacao": { + "_type": "zone", + "exemplarCity": "Curaçao" + }, + "Godthab": { + "_type": "zone", + "exemplarCity": "Nuuk" + }, + "Scoresbysund": { + "_type": "zone", + "exemplarCity": "Ittoqqortoormiit" + }, + "Jamaica": { + "_type": "zone", + "exemplarCity": "Jamaika" + }, + "St_Kitts": { + "_type": "zone", + "exemplarCity": "St. Kitts" + }, + "Cayman": { + "_type": "zone", + "exemplarCity": "Kaimaninseln" + }, + "St_Lucia": { + "_type": "zone", + "exemplarCity": "St. Lucia" + }, + "Ciudad_Juarez": { + "_type": "zone", + "exemplarCity": "Ciudad Juárez" + }, + "Bahia_Banderas": { + "_type": "zone", + "exemplarCity": "Bahia Banderas" + }, + "Mexico_City": { + "_type": "zone", + "exemplarCity": "Mexiko-Stadt" + }, + "Merida": { + "_type": "zone", + "exemplarCity": "Merida" + }, + "Cancun": { + "_type": "zone", + "exemplarCity": "Cancún" + }, + "Asuncion": { + "_type": "zone", + "exemplarCity": "Asunción" + }, + "Lower_Princes": { + "_type": "zone", + "exemplarCity": "Lower Prince’s Quarter" + }, + "North_Dakota": { + "Beulah": { + "_type": "zone", + "exemplarCity": "Beulah, North Dakota" + }, + "New_Salem": { + "_type": "zone", + "exemplarCity": "New Salem, North Dakota" + }, + "Center": { + "_type": "zone", + "exemplarCity": "Center, North Dakota" + } + }, + "Indiana": { + "Vincennes": { + "_type": "zone", + "exemplarCity": "Vincennes, Indiana" + }, + "Petersburg": { + "_type": "zone", + "exemplarCity": "Petersburg, Indiana" + }, + "Tell_City": { + "_type": "zone", + "exemplarCity": "Tell City, Indiana" + }, + "Knox": { + "_type": "zone", + "exemplarCity": "Knox, Indiana" + }, + "Winamac": { + "_type": "zone", + "exemplarCity": "Winamac, Indiana" + }, + "Marengo": { + "_type": "zone", + "exemplarCity": "Marengo, Indiana" + }, + "Vevay": { + "_type": "zone", + "exemplarCity": "Vevay, Indiana" + } + }, + "Kentucky": { + "Monticello": { + "_type": "zone", + "exemplarCity": "Monticello, Kentucky" + } + }, + "St_Vincent": { + "_type": "zone", + "exemplarCity": "St. Vincent" + }, + "St_Thomas": { + "_type": "zone", + "exemplarCity": "St. Thomas" + } + }, + "Africa": { + "Porto-Novo": { + "_type": "zone", + "exemplarCity": "Porto Novo" + }, + "Djibouti": { + "_type": "zone", + "exemplarCity": "Dschibuti" + }, + "Algiers": { + "_type": "zone", + "exemplarCity": "Algier" + }, + "Cairo": { + "_type": "zone", + "exemplarCity": "Kairo" + }, + "El_Aaiun": { + "_type": "zone", + "exemplarCity": "El Aaiún" + }, + "Asmera": { + "_type": "zone", + "exemplarCity": "Asmara" + }, + "Addis_Ababa": { + "_type": "zone", + "exemplarCity": "Addis Abeba" + }, + "Tripoli": { + "_type": "zone", + "exemplarCity": "Tripolis" + }, + "Khartoum": { + "_type": "zone", + "exemplarCity": "Khartum" + }, + "Mogadishu": { + "_type": "zone", + "exemplarCity": "Mogadischu" + }, + "Sao_Tome": { + "_type": "zone", + "exemplarCity": "São Tomé" + }, + "Ndjamena": { + "_type": "zone", + "exemplarCity": "N’Djamena" + }, + "Lome": { + "_type": "zone", + "exemplarCity": "Lomé" + }, + "Dar_es_Salaam": { + "_type": "zone", + "exemplarCity": "Daressalam" + } + }, + "Atlantic": { + "Cape_Verde": { + "_type": "zone", + "exemplarCity": "Cabo Verde" + }, + "Canary": { + "_type": "zone", + "exemplarCity": "Kanaren" + }, + "Faeroe": { + "_type": "zone", + "exemplarCity": "Färöer" + }, + "South_Georgia": { + "_type": "zone", + "exemplarCity": "Südgeorgien" + }, + "Reykjavik": { + "_type": "zone", + "exemplarCity": "Reyk­ja­vík" + }, + "Azores": { + "_type": "zone", + "exemplarCity": "Azoren" + }, + "St_Helena": { + "_type": "zone", + "exemplarCity": "St. Helena" + } + }, + "Indian": { + "Christmas": { + "_type": "zone", + "exemplarCity": "Weihnachtsinsel" + }, + "Comoro": { + "_type": "zone", + "exemplarCity": "Komoren" + }, + "Maldives": { + "_type": "zone", + "exemplarCity": "Malediven" + }, + "Reunion": { + "_type": "zone", + "exemplarCity": "Réunion" + } + } + }, + "metazone": { + "Acre": { + "long": { + "generic": "Acre-Zeit", + "standard": "Acre-Normalzeit", + "daylight": "Acre-Sommerzeit" + } + }, + "Afghanistan": { + "long": { + "standard": "Afghanistan-Zeit" + } + }, + "Africa_Central": { + "long": { + "standard": "Zentralafrikanische Zeit" + } + }, + "Africa_Eastern": { + "long": { + "standard": "Ostafrikanische Zeit" + } + }, + "Africa_Southern": { + "long": { + "standard": "Südafrikanische Zeit" + } + }, + "Africa_Western": { + "long": { + "generic": "Westafrikanische Zeit", + "standard": "Westafrikanische Normalzeit", + "daylight": "Westafrikanische Sommerzeit" + } + }, + "Alaska": { + "long": { + "generic": "Alaska-Zeit", + "standard": "Alaska-Normalzeit", + "daylight": "Alaska-Sommerzeit" + } + }, + "Almaty": { + "long": { + "generic": "Almaty-Zeit", + "standard": "Almaty-Normalzeit", + "daylight": "Almaty-Sommerzeit" + } + }, + "Amazon": { + "long": { + "generic": "Amazonas-Zeit", + "standard": "Amazonas-Normalzeit", + "daylight": "Amazonas-Sommerzeit" + } + }, + "America_Central": { + "long": { + "generic": "Nordamerikanische Zentralzeit", + "standard": "Nordamerikanische Zentral-Normalzeit", + "daylight": "Nordamerikanische Zentral-Sommerzeit" + } + }, + "America_Eastern": { + "long": { + "generic": "Nordamerikanische Ostküstenzeit", + "standard": "Nordamerikanische Ostküsten-Normalzeit", + "daylight": "Nordamerikanische Ostküsten-Sommerzeit" + } + }, + "America_Mountain": { + "long": { + "generic": "Rocky-Mountains-Zeit", + "standard": "Rocky-Mountains-Normalzeit", + "daylight": "Rocky-Mountains-Sommerzeit" + } + }, + "America_Pacific": { + "long": { + "generic": "Nordamerikanische Westküstenzeit", + "standard": "Nordamerikanische Westküsten-Normalzeit", + "daylight": "Nordamerikanische Westküsten-Sommerzeit" + } + }, + "Anadyr": { + "long": { + "generic": "Anadyr Zeit", + "standard": "Anadyr Normalzeit", + "daylight": "Anadyr Sommerzeit" + } + }, + "Apia": { + "long": { + "generic": "Apia-Zeit", + "standard": "Apia-Normalzeit", + "daylight": "Apia-Sommerzeit" + } + }, + "Aqtau": { + "long": { + "generic": "Aqtau-Zeit", + "standard": "Aqtau-Normalzeit", + "daylight": "Aqtau-Sommerzeit" + } + }, + "Aqtobe": { + "long": { + "generic": "Aqtöbe-Zeit", + "standard": "Aqtöbe-Normalzeit", + "daylight": "Aqtöbe-Sommerzeit" + } + }, + "Arabian": { + "long": { + "generic": "Arabische Zeit", + "standard": "Arabische Normalzeit", + "daylight": "Arabische Sommerzeit" + } + }, + "Argentina": { + "long": { + "generic": "Argentinische Zeit", + "standard": "Argentinische Normalzeit", + "daylight": "Argentinische Sommerzeit" + } + }, + "Argentina_Western": { + "long": { + "generic": "Westargentinische Zeit", + "standard": "Westargentinische Normalzeit", + "daylight": "Westargentinische Sommerzeit" + } + }, + "Armenia": { + "long": { + "generic": "Armenische Zeit", + "standard": "Armenische Normalzeit", + "daylight": "Armenische Sommerzeit" + } + }, + "Atlantic": { + "long": { + "generic": "Atlantik-Zeit", + "standard": "Atlantik-Normalzeit", + "daylight": "Atlantik-Sommerzeit" + } + }, + "Australia_Central": { + "long": { + "generic": "Zentralaustralische Zeit", + "standard": "Zentralaustralische Normalzeit", + "daylight": "Zentralaustralische Sommerzeit" + } + }, + "Australia_CentralWestern": { + "long": { + "generic": "Zentral-/Westaustralische Zeit", + "standard": "Zentral-/Westaustralische Normalzeit", + "daylight": "Zentral-/Westaustralische Sommerzeit" + } + }, + "Australia_Eastern": { + "long": { + "generic": "Ostaustralische Zeit", + "standard": "Ostaustralische Normalzeit", + "daylight": "Ostaustralische Sommerzeit" + } + }, + "Australia_Western": { + "long": { + "generic": "Westaustralische Zeit", + "standard": "Westaustralische Normalzeit", + "daylight": "Westaustralische Sommerzeit" + } + }, + "Azerbaijan": { + "long": { + "generic": "Aserbaidschanische Zeit", + "standard": "Aserbeidschanische Normalzeit", + "daylight": "Aserbaidschanische Sommerzeit" + } + }, + "Azores": { + "long": { + "generic": "Azoren-Zeit", + "standard": "Azoren-Normalzeit", + "daylight": "Azoren-Sommerzeit" + } + }, + "Bangladesh": { + "long": { + "generic": "Bangladesch-Zeit", + "standard": "Bangladesch-Normalzeit", + "daylight": "Bangladesch-Sommerzeit" + } + }, + "Bhutan": { + "long": { + "standard": "Bhutan-Zeit" + } + }, + "Bolivia": { + "long": { + "standard": "Bolivianische Zeit" + } + }, + "Brasilia": { + "long": { + "generic": "Brasília-Zeit", + "standard": "Brasília-Normalzeit", + "daylight": "Brasília-Sommerzeit" + } + }, + "Brunei": { + "long": { + "standard": "Brunei-Darussalam-Zeit" + } + }, + "Cape_Verde": { + "long": { + "generic": "Cabo-Verde-Zeit", + "standard": "Cabo-Verde-Normalzeit", + "daylight": "Cabo-Verde-Sommerzeit" + } + }, + "Casey": { + "long": { + "standard": "Casey-Zeit" + } + }, + "Chamorro": { + "long": { + "standard": "Chamorro-Zeit" + } + }, + "Chatham": { + "long": { + "generic": "Chatham-Zeit", + "standard": "Chatham-Normalzeit", + "daylight": "Chatham-Sommerzeit" + } + }, + "Chile": { + "long": { + "generic": "Chilenische Zeit", + "standard": "Chilenische Normalzeit", + "daylight": "Chilenische Sommerzeit" + } + }, + "China": { + "long": { + "generic": "Chinesische Zeit", + "standard": "Chinesische Normalzeit", + "daylight": "Chinesische Sommerzeit" + } + }, + "Christmas": { + "long": { + "standard": "Weihnachtsinsel-Zeit" + } + }, + "Cocos": { + "long": { + "standard": "Kokosinseln-Zeit" + } + }, + "Colombia": { + "long": { + "generic": "Kolumbianische Zeit", + "standard": "Kolumbianische Normalzeit", + "daylight": "Kolumbianische Sommerzeit" + } + }, + "Cook": { + "long": { + "generic": "Cookinseln-Zeit", + "standard": "Cookinseln-Normalzeit", + "daylight": "Cookinseln-Sommerzeit" + } + }, + "Cuba": { + "long": { + "generic": "Kubanische Zeit", + "standard": "Kubanische Normalzeit", + "daylight": "Kubanische Sommerzeit" + } + }, + "Davis": { + "long": { + "standard": "Davis-Zeit" + } + }, + "DumontDUrville": { + "long": { + "standard": "Dumont-d’Urville-Zeit" + } + }, + "East_Timor": { + "long": { + "standard": "Osttimor-Zeit" + } + }, + "Easter": { + "long": { + "generic": "Osterinsel-Zeit", + "standard": "Osterinsel-Normalzeit", + "daylight": "Osterinsel-Sommerzeit" + } + }, + "Ecuador": { + "long": { + "standard": "Ecuadorianische Zeit" + } + }, + "Europe_Central": { + "long": { + "generic": "Mitteleuropäische Zeit", + "standard": "Mitteleuropäische Normalzeit", + "daylight": "Mitteleuropäische Sommerzeit" + }, + "short": { + "generic": "MEZ", + "standard": "MEZ", + "daylight": "MESZ" + } + }, + "Europe_Eastern": { + "long": { + "generic": "Osteuropäische Zeit", + "standard": "Osteuropäische Normalzeit", + "daylight": "Osteuropäische Sommerzeit" + }, + "short": { + "generic": "OEZ", + "standard": "OEZ", + "daylight": "OESZ" + } + }, + "Europe_Further_Eastern": { + "long": { + "standard": "Kaliningrader Zeit" + } + }, + "Europe_Western": { + "long": { + "generic": "Westeuropäische Zeit", + "standard": "Westeuropäische Normalzeit", + "daylight": "Westeuropäische Sommerzeit" + }, + "short": { + "generic": "WEZ", + "standard": "WEZ", + "daylight": "WESZ" + } + }, + "Falkland": { + "long": { + "generic": "Falklandinseln-Zeit", + "standard": "Falklandinseln-Normalzeit", + "daylight": "Falklandinseln-Sommerzeit" + } + }, + "Fiji": { + "long": { + "generic": "Fidschi-Zeit", + "standard": "Fidschi-Normalzeit", + "daylight": "Fidschi-Sommerzeit" + } + }, + "French_Guiana": { + "long": { + "standard": "Französisch-Guayana-Zeit" + } + }, + "French_Southern": { + "long": { + "standard": "Französische-Süd-und-Antarktisgebiete-Zeit" + } + }, + "Galapagos": { + "long": { + "standard": "Galapagos-Zeit" + } + }, + "Gambier": { + "long": { + "standard": "Gambier-Zeit" + } + }, + "Georgia": { + "long": { + "generic": "Georgische Zeit", + "standard": "Georgische Normalzeit", + "daylight": "Georgische Sommerzeit" + } + }, + "Gilbert_Islands": { + "long": { + "standard": "Gilbert-Inseln-Zeit" + } + }, + "GMT": { + "long": { + "standard": "Mittlere Greenwich-Zeit" + } + }, + "Greenland_Eastern": { + "long": { + "generic": "Ostgrönland-Zeit", + "standard": "Ostgrönland-Normalzeit", + "daylight": "Ostgrönland-Sommerzeit" + } + }, + "Greenland_Western": { + "long": { + "generic": "Westgrönland-Zeit", + "standard": "Westgrönland-Normalzeit", + "daylight": "Westgrönland-Sommerzeit" + } + }, + "Guam": { + "long": { + "standard": "Guam-Zeit" + } + }, + "Gulf": { + "long": { + "standard": "Golf-Zeit" + } + }, + "Guyana": { + "long": { + "standard": "Guyana-Zeit" + } + }, + "Hawaii_Aleutian": { + "long": { + "generic": "Hawaii-Aleuten-Zeit", + "standard": "Hawaii-Aleuten-Normalzeit", + "daylight": "Hawaii-Aleuten-Sommerzeit" + } + }, + "Hong_Kong": { + "long": { + "generic": "Hongkong-Zeit", + "standard": "Hongkong-Normalzeit", + "daylight": "Hongkong-Sommerzeit" + } + }, + "Hovd": { + "long": { + "generic": "Chowd-Zeit", + "standard": "Chowd-Normalzeit", + "daylight": "Chowd-Sommerzeit" + } + }, + "India": { + "long": { + "standard": "Indische Normalzeit" + } + }, + "Indian_Ocean": { + "long": { + "standard": "Indischer-Ozean-Zeit" + } + }, + "Indochina": { + "long": { + "standard": "Indochina-Zeit" + } + }, + "Indonesia_Central": { + "long": { + "standard": "Zentralindonesische Zeit" + } + }, + "Indonesia_Eastern": { + "long": { + "standard": "Ostindonesische Zeit" + } + }, + "Indonesia_Western": { + "long": { + "standard": "Westindonesische Zeit" + } + }, + "Iran": { + "long": { + "generic": "Iranische Zeit", + "standard": "Iranische Normalzeit", + "daylight": "Iranische Sommerzeit" + } + }, + "Irkutsk": { + "long": { + "generic": "Irkutsker Zeit", + "standard": "Irkutsker Normalzeit", + "daylight": "Irkutsker Sommerzeit" + } + }, + "Israel": { + "long": { + "generic": "Israelische Zeit", + "standard": "Israelische Normalzeit", + "daylight": "Israelische Sommerzeit" + } + }, + "Japan": { + "long": { + "generic": "Japanische Zeit", + "standard": "Japanische Normalzeit", + "daylight": "Japanische Sommerzeit" + } + }, + "Kamchatka": { + "long": { + "generic": "Kamtschatka-Zeit", + "standard": "Kamtschatka-Normalzeit", + "daylight": "Kamtschatka-Sommerzeit" + } + }, + "Kazakhstan": { + "long": { + "standard": "Kasachische Zeit" + } + }, + "Kazakhstan_Eastern": { + "long": { + "standard": "Ostkasachische Zeit" + } + }, + "Kazakhstan_Western": { + "long": { + "standard": "Westkasachische Zeit" + } + }, + "Korea": { + "long": { + "generic": "Koreanische Zeit", + "standard": "Koreanische Normalzeit", + "daylight": "Koreanische Sommerzeit" + } + }, + "Kosrae": { + "long": { + "standard": "Kosrae-Zeit" + } + }, + "Krasnoyarsk": { + "long": { + "generic": "Krasnojarsker Zeit", + "standard": "Krasnojarsker Normalzeit", + "daylight": "Krasnojarsker Sommerzeit" + } + }, + "Kyrgystan": { + "long": { + "standard": "Kirgisische Zeit" + } + }, + "Lanka": { + "long": { + "standard": "Sri-Lanka-Zeit" + } + }, + "Line_Islands": { + "long": { + "standard": "Linieninseln-Zeit" + } + }, + "Lord_Howe": { + "long": { + "generic": "Lord-Howe-Zeit", + "standard": "Lord-Howe-Normalzeit", + "daylight": "Lord-Howe-Sommerzeit" + } + }, + "Macau": { + "long": { + "generic": "Macau-Zeit", + "standard": "Macau-Normalzeit", + "daylight": "Macau-Sommerzeit" + } + }, + "Magadan": { + "long": { + "generic": "Magadan-Zeit", + "standard": "Magadan-Normalzeit", + "daylight": "Magadan-Sommerzeit" + } + }, + "Malaysia": { + "long": { + "standard": "Malaysische Zeit" + } + }, + "Maldives": { + "long": { + "standard": "Malediven-Zeit" + } + }, + "Marquesas": { + "long": { + "standard": "Marquesas-Zeit" + } + }, + "Marshall_Islands": { + "long": { + "standard": "Marshallinseln-Zeit" + } + }, + "Mauritius": { + "long": { + "generic": "Mauritius-Zeit", + "standard": "Mauritius-Normalzeit", + "daylight": "Mauritius-Sommerzeit" + } + }, + "Mawson": { + "long": { + "standard": "Mawson-Zeit" + } + }, + "Mexico_Pacific": { + "long": { + "generic": "Mexikanische Pazifikzeit", + "standard": "Mexikanische Pazifik-Normalzeit", + "daylight": "Mexikanische Pazifik-Sommerzeit" + } + }, + "Mongolia": { + "long": { + "generic": "Ulaanbaatar-Zeit", + "standard": "Ulaanbaatar-Normalzeit", + "daylight": "Ulaanbaatar-Sommerzeit" + } + }, + "Moscow": { + "long": { + "generic": "Moskauer Zeit", + "standard": "Moskauer Normalzeit", + "daylight": "Moskauer Sommerzeit" + } + }, + "Myanmar": { + "long": { + "standard": "Myanmar-Zeit" + } + }, + "Nauru": { + "long": { + "standard": "Nauru-Zeit" + } + }, + "Nepal": { + "long": { + "standard": "Nepalesische Zeit" + } + }, + "New_Caledonia": { + "long": { + "generic": "Neukaledonische Zeit", + "standard": "Neukaledonische Normalzeit", + "daylight": "Neukaledonische Sommerzeit" + } + }, + "New_Zealand": { + "long": { + "generic": "Neuseeland-Zeit", + "standard": "Neuseeland-Normalzeit", + "daylight": "Neuseeland-Sommerzeit" + } + }, + "Newfoundland": { + "long": { + "generic": "Neufundland-Zeit", + "standard": "Neufundland-Normalzeit", + "daylight": "Neufundland-Sommerzeit" + } + }, + "Niue": { + "long": { + "standard": "Niue-Zeit" + } + }, + "Norfolk": { + "long": { + "generic": "Norfolkinsel-Zeit", + "standard": "Norfolkinsel-Normalzeit", + "daylight": "Norfolkinsel-Sommerzeit" + } + }, + "Noronha": { + "long": { + "generic": "Fernando-de-Noronha-Zeit", + "standard": "Fernando-de-Noronha-Normalzeit", + "daylight": "Fernando-de-Noronha-Sommerzeit" + } + }, + "North_Mariana": { + "long": { + "standard": "Nördliche-Marianen-Zeit" + } + }, + "Novosibirsk": { + "long": { + "generic": "Nowosibirsker Zeit", + "standard": "Nowosibirsker Normalzeit", + "daylight": "Nowosibirsker Sommerzeit" + } + }, + "Omsk": { + "long": { + "generic": "Omsker Zeit", + "standard": "Omsker Normalzeit", + "daylight": "Omsker Sommerzeit" + } + }, + "Pakistan": { + "long": { + "generic": "Pakistanische Zeit", + "standard": "Pakistanische Normalzeit", + "daylight": "Pakistanische Sommerzeit" + } + }, + "Palau": { + "long": { + "standard": "Palau-Zeit" + } + }, + "Papua_New_Guinea": { + "long": { + "standard": "Papua-Neuguinea-Zeit" + } + }, + "Paraguay": { + "long": { + "generic": "Paraguayische Zeit", + "standard": "Paraguayische Normalzeit", + "daylight": "Paraguayische Sommerzeit" + } + }, + "Peru": { + "long": { + "generic": "Peruanische Zeit", + "standard": "Peruanische Normalzeit", + "daylight": "Peruanische Sommerzeit" + } + }, + "Philippines": { + "long": { + "generic": "Philippinische Zeit", + "standard": "Philippinische Normalzeit", + "daylight": "Philippinische Sommerzeit" + } + }, + "Phoenix_Islands": { + "long": { + "standard": "Phoenixinseln-Zeit" + } + }, + "Pierre_Miquelon": { + "long": { + "generic": "St.-Pierre-und-Miquelon-Zeit", + "standard": "St.-Pierre-und-Miquelon-Normalzeit", + "daylight": "St.-Pierre-und-Miquelon-Sommerzeit" + } + }, + "Pitcairn": { + "long": { + "standard": "Pitcairninseln-Zeit" + } + }, + "Ponape": { + "long": { + "standard": "Ponape-Zeit" + } + }, + "Pyongyang": { + "long": { + "standard": "Pjöngjang-Zeit" + } + }, + "Qyzylorda": { + "long": { + "generic": "Quysylorda-Zeit", + "standard": "Quysylorda-Normalzeit", + "daylight": "Qysylorda-Sommerzeit" + } + }, + "Reunion": { + "long": { + "standard": "Réunion-Zeit" + } + }, + "Rothera": { + "long": { + "standard": "Rothera-Zeit" + } + }, + "Sakhalin": { + "long": { + "generic": "Sachalin-Zeit", + "standard": "Sachalin-Normalzeit", + "daylight": "Sachalin-Sommerzeit" + } + }, + "Samara": { + "long": { + "generic": "Samara-Zeit", + "standard": "Samara-Normalzeit", + "daylight": "Samara-Sommerzeit" + } + }, + "Samoa": { + "long": { + "generic": "Samoa-Zeit", + "standard": "Samoa-Normalzeit", + "daylight": "Samoa-Sommerzeit" + } + }, + "Seychelles": { + "long": { + "standard": "Seychellen-Zeit" + } + }, + "Singapore": { + "long": { + "standard": "Singapurische Normalzeit" + } + }, + "Solomon": { + "long": { + "standard": "Salomonen-Zeit" + } + }, + "South_Georgia": { + "long": { + "standard": "Südgeorgische Zeit" + } + }, + "Suriname": { + "long": { + "standard": "Suriname-Zeit" + } + }, + "Syowa": { + "long": { + "standard": "Syowa-Zeit" + } + }, + "Tahiti": { + "long": { + "standard": "Tahiti-Zeit" + } + }, + "Taipei": { + "long": { + "generic": "Taipeh-Zeit", + "standard": "Taipeh-Normalzeit", + "daylight": "Taipeh-Sommerzeit" + } + }, + "Tajikistan": { + "long": { + "standard": "Tadschikische Zeit" + } + }, + "Tokelau": { + "long": { + "standard": "Tokelau-Zeit" + } + }, + "Tonga": { + "long": { + "generic": "Tongaische Zeit", + "standard": "Tongaische Normalzeit", + "daylight": "Tongaische Sommerzeit" + } + }, + "Truk": { + "long": { + "standard": "Chuuk-Zeit" + } + }, + "Turkmenistan": { + "long": { + "generic": "Turkmenistan-Zeit", + "standard": "Turkmenische Normalzeit", + "daylight": "Turkmenische Sommerzeit" + } + }, + "Tuvalu": { + "long": { + "standard": "Tuvalu-Zeit" + } + }, + "Uruguay": { + "long": { + "generic": "Uruguayische Zeit", + "standard": "Uruguayische Normalzeit", + "daylight": "Uruguayische Sommerzeit" + } + }, + "Uzbekistan": { + "long": { + "generic": "Usbekische Zeit", + "standard": "Usbekische Normalzeit", + "daylight": "Usbekische Sommerzeit" + } + }, + "Vanuatu": { + "long": { + "generic": "Vanuatu-Zeit", + "standard": "Vanuatu-Normalzeit", + "daylight": "Vanuatu-Sommerzeit" + } + }, + "Venezuela": { + "long": { + "standard": "Venezuela-Zeit" + } + }, + "Vladivostok": { + "long": { + "generic": "Wladiwostoker Zeit", + "standard": "Wladiwostoker Normalzeit", + "daylight": "Wladiwostoker Sommerzeit" + } + }, + "Volgograd": { + "long": { + "generic": "Wolgograder Zeit", + "standard": "Wolgograder Normalzeit", + "daylight": "Wolgograder Sommerzeit" + } + }, + "Vostok": { + "long": { + "standard": "Wostok-Zeit" + } + }, + "Wake": { + "long": { + "standard": "Wake-Insel-Zeit" + } + }, + "Wallis": { + "long": { + "standard": "Wallis-und-Futuna-Zeit" + } + }, + "Yakutsk": { + "long": { + "generic": "Jakutsker Zeit", + "standard": "Jakutsker Normalzeit", + "daylight": "Jakutsker Sommerzeit" + } + }, + "Yekaterinburg": { + "long": { + "generic": "Jekaterinburger Zeit", + "standard": "Jekaterinburger Normalzeit", + "daylight": "Jekaterinburger Sommerzeit" + } + }, + "Yukon": { + "long": { + "standard": "Yukon-Zeit" + } + } + } + } + } + } + } +} diff --git a/dashboard/src/clients.tsx b/dashboard/src/clients.tsx new file mode 100644 index 0000000..10014fc --- /dev/null +++ b/dashboard/src/clients.tsx @@ -0,0 +1,278 @@ +import SetupModeButton from './components/SetupModeButton'; +import React, { useEffect, useState } from 'react'; +import { useClientDelete } from './hooks/useClientDelete'; +import { fetchClients, updateClient } from './apiClients'; +import type { Client } from './apiClients'; +import { + GridComponent, + ColumnsDirective, + ColumnDirective, + Page, + Inject, + Toolbar, + Search, + Sort, + Edit, +} from '@syncfusion/ej2-react-grids'; +import { DialogComponent } from '@syncfusion/ej2-react-popups'; + +// Raumgruppen werden dynamisch aus der API geladen + +interface DetailsModalProps { + open: boolean; + client: Client | null; + groupIdToName: Record; + onClose: () => void; +} + +function DetailsModal({ open, client, groupIdToName, onClose }: DetailsModalProps) { + if (!open || !client) return null; + return ( +
+
+
+

Client-Details

+ + + {Object.entries(client) + .filter( + ([key]) => + ![ + 'index', + 'is_active', + 'type', + 'column', + 'group_name', + 'foreignKeyData', + 'hardware_token', + ].includes(key) + ) + .map(([key, value]) => ( + + + + + ))} + +
+ {key === 'group_id' + ? 'Raumgruppe' + : key === 'ip' + ? 'IP-Adresse' + : key === 'registration_time' + ? 'Registriert am' + : key === 'description' + ? 'Beschreibung' + : key === 'last_alive' + ? 'Letzter Kontakt' + : key === 'model' + ? 'Modell' + : key === 'uuid' + ? 'Client-Code' + : key === "os_version" + ? 'Betriebssystem' + : key === 'software_version' + ? 'Clientsoftware' + : key === 'macs' + ? 'MAC-Adressen' + : key.charAt(0).toUpperCase() + key.slice(1)} + : + + {key === 'group_id' + ? value !== undefined + ? groupIdToName[value as string | number] || value + : '' + : key === 'registration_time' && value + ? new Date( + (value as string).endsWith('Z') ? (value as string) : value + 'Z' + ).toLocaleString() + : key === 'last_alive' && value + ? String(value) // Wert direkt anzeigen, nicht erneut parsen + : String(value)} +
+
+ +
+
+
+
+ ); +} + +const Clients: React.FC = () => { + const [clients, setClients] = useState([]); + const [groups, setGroups] = useState<{ id: number; name: string }[]>([]); + const [detailsClient, setDetailsClient] = useState(null); + + const { showDialog, deleteClientId, handleDelete, confirmDelete, cancelDelete } = useClientDelete( + uuid => setClients(prev => prev.filter(c => c.uuid !== uuid)) + ); + + useEffect(() => { + fetchClients().then(setClients); + // Gruppen auslesen + import('./apiGroups').then(mod => mod.fetchGroups()).then(setGroups); + }, []); + + // Map group_id zu group_name + const groupIdToName: Record = {}; + groups.forEach(g => { + groupIdToName[g.id] = g.name; + }); + + // DataGrid data mit korrektem Gruppennamen und formatierten Zeitangaben + const gridData = clients.map(c => ({ + ...c, + group_name: c.group_id !== undefined ? groupIdToName[c.group_id] || String(c.group_id) : '', + last_alive: c.last_alive + ? new Date( + (c.last_alive as string).endsWith('Z') ? (c.last_alive as string) : c.last_alive + 'Z' + ).toLocaleString() + : '', + })); + + // DataGrid row template für Details- und Entfernen-Button + const detailsButtonTemplate = (props: Client) => ( +
+ + +
+ ); + + return ( +
+
+

Client-Übersicht

+ +
+ {groups.length > 0 ? ( + <> + ; + }) => { + if (args.requestType === 'save') { + const { uuid, description, model } = args.data as { + uuid: string; + description: string; + model: string; + }; + // API-Aufruf zum Speichern + await updateClient(uuid, { description, model }); + // Nach dem Speichern neu laden + fetchClients().then(setClients); + } + }} + > + + + + + + + + + + + + setDetailsClient(null)} + /> + + ) : ( +
Raumgruppen werden geladen ...
+ )} + {/* DialogComponent für Bestätigung */} + {showDialog && deleteClientId && ( + + )} +
+ ); +}; + +export default Clients; diff --git a/dashboard/src/components/CustomEventModal.tsx b/dashboard/src/components/CustomEventModal.tsx new file mode 100644 index 0000000..525b397 --- /dev/null +++ b/dashboard/src/components/CustomEventModal.tsx @@ -0,0 +1,515 @@ +import React from 'react'; +import { DialogComponent } from '@syncfusion/ej2-react-popups'; +import { TextBoxComponent } from '@syncfusion/ej2-react-inputs'; +import { DatePickerComponent, TimePickerComponent } from '@syncfusion/ej2-react-calendars'; +import { DropDownListComponent, MultiSelectComponent } from '@syncfusion/ej2-react-dropdowns'; +import { CheckBoxComponent } from '@syncfusion/ej2-react-buttons'; +import CustomSelectUploadEventModal from './CustomSelectUploadEventModal'; +import { updateEvent } from '../apiEvents'; + +type CustomEventData = { + title: string; + startDate: Date | null; + startTime: Date | null; + endTime: Date | null; + type: string; + description: string; + repeat: boolean; + weekdays: number[]; + repeatUntil: Date | null; + skipHolidays: boolean; + media?: { id: string; path: string; name: string } | null; // <--- ergänzt + slideshowInterval?: number; // <--- ergänzt + websiteUrl?: string; // <--- ergänzt +}; + +// Typ für initialData erweitern, damit Id unterstützt wird +type CustomEventModalProps = { + open: boolean; + onClose: () => void; + onSave: (eventData: CustomEventData) => void; + initialData?: Partial & { Id?: string }; // <--- Id ergänzen + groupName: string | { id: string | null; name: string }; + groupColor?: string; + editMode?: boolean; + blockHolidays?: boolean; + isHolidayRange?: (start: Date, end: Date) => boolean; +}; + +const weekdayOptions = [ + { value: 0, label: 'Montag' }, + { value: 1, label: 'Dienstag' }, + { value: 2, label: 'Mittwoch' }, + { value: 3, label: 'Donnerstag' }, + { value: 4, label: 'Freitag' }, + { value: 5, label: 'Samstag' }, + { value: 6, label: 'Sonntag' }, +]; + +const typeOptions = [ + { value: 'presentation', label: 'Präsentation' }, + { value: 'website', label: 'Website' }, + { value: 'video', label: 'Video' }, + { value: 'message', label: 'Nachricht' }, + { value: 'webuntis', label: 'WebUntis' }, +]; + +const CustomEventModal: React.FC = ({ + open, + onClose, + onSave, + initialData = {}, + groupName, + groupColor, + editMode, + blockHolidays, + isHolidayRange, +}) => { + const [title, setTitle] = React.useState(initialData.title || ''); + const [startDate, setStartDate] = React.useState(initialData.startDate || null); + const [startTime, setStartTime] = React.useState( + initialData.startTime || new Date(0, 0, 0, 9, 0) + ); + const [endTime, setEndTime] = React.useState(initialData.endTime || new Date(0, 0, 0, 9, 30)); + const [type, setType] = React.useState(initialData.type ?? 'presentation'); + const [description, setDescription] = React.useState(initialData.description || ''); + const [repeat, setRepeat] = React.useState(initialData.repeat || false); + const [weekdays, setWeekdays] = React.useState(initialData.weekdays || []); + const [repeatUntil, setRepeatUntil] = React.useState(initialData.repeatUntil || null); + const [skipHolidays, setSkipHolidays] = React.useState(initialData.skipHolidays || false); + const [errors, setErrors] = React.useState<{ [key: string]: string }>({}); + // --- KORREKTUR: Media, SlideshowInterval, WebsiteUrl aus initialData übernehmen --- + const [media, setMedia] = React.useState<{ id: string; path: string; name: string } | null>( + initialData.media ?? null + ); + const [pendingMedia, setPendingMedia] = React.useState<{ + id: string; + path: string; + name: string; + } | null>(null); + const [slideshowInterval, setSlideshowInterval] = React.useState( + initialData.slideshowInterval ?? 10 + ); + const [websiteUrl, setWebsiteUrl] = React.useState(initialData.websiteUrl ?? ''); + const [mediaModalOpen, setMediaModalOpen] = React.useState(false); + + React.useEffect(() => { + if (open) { + setTitle(initialData.title || ''); + setStartDate(initialData.startDate || null); + setStartTime(initialData.startTime || new Date(0, 0, 0, 9, 0)); + setEndTime(initialData.endTime || new Date(0, 0, 0, 9, 30)); + setType(initialData.type ?? 'presentation'); + setDescription(initialData.description || ''); + setRepeat(initialData.repeat || false); + setWeekdays(initialData.weekdays || []); + setRepeatUntil(initialData.repeatUntil || null); + setSkipHolidays(initialData.skipHolidays || false); + // --- KORREKTUR: Media, SlideshowInterval, WebsiteUrl aus initialData übernehmen --- + setMedia(initialData.media ?? null); + setSlideshowInterval(initialData.slideshowInterval ?? 10); + setWebsiteUrl(initialData.websiteUrl ?? ''); + } + }, [open, initialData]); + + React.useEffect(() => { + if (!mediaModalOpen && pendingMedia) { + setMedia(pendingMedia); + setPendingMedia(null); + } + }, [mediaModalOpen, pendingMedia]); + + const handleSave = async () => { + const newErrors: { [key: string]: string } = {}; + if (!title.trim()) newErrors.title = 'Titel ist erforderlich'; + if (!startDate) newErrors.startDate = 'Startdatum ist erforderlich'; + if (!startTime) newErrors.startTime = 'Startzeit ist erforderlich'; + if (!endTime) newErrors.endTime = 'Endzeit ist erforderlich'; + if (!type) newErrors.type = 'Termintyp ist erforderlich'; + + // Vergangenheitsprüfung + const startDateTime = + startDate && startTime + ? new Date( + startDate.getFullYear(), + startDate.getMonth(), + startDate.getDate(), + startTime.getHours(), + startTime.getMinutes() + ) + : null; + const isPast = startDateTime && startDateTime < new Date(); + + if (isPast) { + newErrors.startDate = 'Termine in der Vergangenheit sind nicht erlaubt!'; + } + + if (type === 'presentation') { + if (!media) newErrors.media = 'Bitte eine Präsentation auswählen'; + if (!slideshowInterval || slideshowInterval < 1) + newErrors.slideshowInterval = 'Intervall angeben'; + } + if (type === 'website') { + if (!websiteUrl.trim()) newErrors.websiteUrl = 'Webseiten-URL ist erforderlich'; + } + + // Holiday blocking: prevent creating when range overlaps + if ( + !editMode && + blockHolidays && + startDate && + startTime && + endTime && + typeof isHolidayRange === 'function' + ) { + const s = new Date( + startDate.getFullYear(), + startDate.getMonth(), + startDate.getDate(), + startTime.getHours(), + startTime.getMinutes() + ); + const e = new Date( + startDate.getFullYear(), + startDate.getMonth(), + startDate.getDate(), + endTime.getHours(), + endTime.getMinutes() + ); + if (isHolidayRange(s, e)) { + newErrors.startDate = 'Dieser Zeitraum liegt in den Ferien und ist gesperrt.'; + } + } + + if (Object.keys(newErrors).length > 0) { + setErrors(newErrors); + return; + } + + setErrors({}); + + const group_id = typeof groupName === 'object' && groupName !== null ? groupName.id : groupName; + + const payload: CustomEventData & { [key: string]: unknown } = { + group_id, + title, + description, + start: + startDate && startTime + ? new Date( + startDate.getFullYear(), + startDate.getMonth(), + startDate.getDate(), + startTime.getHours(), + startTime.getMinutes() + ).toISOString() + : null, + end: + startDate && endTime + ? new Date( + startDate.getFullYear(), + startDate.getMonth(), + startDate.getDate(), + endTime.getHours(), + endTime.getMinutes() + ).toISOString() + : null, + type, + startDate, + startTime, + endTime, + repeat, + weekdays, + repeatUntil, + skipHolidays, + event_type: type, + is_active: 1, + created_by: 1, + }; + + if (type === 'presentation') { + payload.event_media_id = media?.id; + payload.slideshow_interval = slideshowInterval; + } + + if (type === 'website') { + payload.website_url = websiteUrl; + } + + try { + let res; + if (editMode && initialData && typeof initialData.Id === 'string') { + // UPDATE statt CREATE + res = await updateEvent(initialData.Id, payload); + } else { + // CREATE + res = await fetch('/api/events', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + res = await res.json(); + } + if (res.success) { + onSave(payload); + onClose(); // <--- Box nach erfolgreichem Speichern schließen + } else { + setErrors({ api: res.error || 'Fehler beim Speichern' }); + } + } catch { + setErrors({ api: 'Netzwerkfehler beim Speichern' }); + } + console.log('handleSave called'); + }; + + // Vergangenheitsprüfung (außerhalb von handleSave, damit im Render verfügbar) + const startDateTime = + startDate && startTime + ? new Date( + startDate.getFullYear(), + startDate.getMonth(), + startDate.getDate(), + startTime.getHours(), + startTime.getMinutes() + ) + : null; + const isPast = !!(startDateTime && startDateTime < new Date()); + + return ( + ( +
+ {editMode ? 'Termin bearbeiten' : 'Neuen Termin anlegen'} + {groupName && ( + + für Raumgruppe: {typeof groupName === 'object' ? groupName.name : groupName} + + )} +
+ )} + showCloseIcon={true} + close={onClose} + isModal={true} + footerTemplate={() => ( +
+ + +
+ )} + > +
+
+
+ {/* ...Titel, Beschreibung, Datum, Zeit... */} +
+ setTitle(e.value)} + /> + {errors.title &&
{errors.title}
} +
+
+ setDescription(e.value)} + /> +
+
+ setStartDate(e.value)} + /> + {errors.startDate && ( +
{errors.startDate}
+ )} + {isPast && ( + + ⚠️ Termin liegt in der Vergangenheit! + + )} +
+
+
+ setStartTime(e.value)} + /> + {errors.startTime && ( +
{errors.startTime}
+ )} +
+
+ setEndTime(e.value)} + /> + {errors.endTime && ( +
{errors.endTime}
+ )} +
+
+
+
+ {/* ...Wiederholung, MultiSelect, Wiederholung bis, Ferien... */} +
+ setRepeat(e.checked)} + /> +
+
+ setWeekdays(e.value as number[])} + disabled={!repeat} + showDropDownIcon={true} + closePopupOnSelect={false} + /> +
+
+ setRepeatUntil(e.value)} + disabled={!repeat} + /> +
+
+ setSkipHolidays(e.checked)} + disabled={!repeat} + /> +
+
+
+ + {/* NEUER ZWEISPALTIGER BEREICH für Termintyp und Zusatzfelder */} +
+
+
+ setType(e.value as string)} + style={{ width: '100%' }} + /> + {errors.type &&
{errors.type}
} +
+
+
+
+ {type === 'presentation' && ( +
+
+ +
+
+ Ausgewähltes Medium:{' '} + {media ? ( + media.path + ) : ( + Kein Medium ausgewählt + )} +
+ setSlideshowInterval(Number(e.value))} + /> +
+ )} + {type === 'website' && ( +
+ setWebsiteUrl(e.value)} + /> +
+ )} +
+
+
+
+ {mediaModalOpen && ( + setMediaModalOpen(false)} + onSelect={({ id, path, name }) => { + setPendingMedia({ id, path, name }); + setMediaModalOpen(false); + }} + selectedFileId={null} + /> + )} +
+ ); +}; + +export default CustomEventModal; diff --git a/dashboard/src/components/CustomMediaInfoPanel.tsx b/dashboard/src/components/CustomMediaInfoPanel.tsx new file mode 100644 index 0000000..3e5b651 --- /dev/null +++ b/dashboard/src/components/CustomMediaInfoPanel.tsx @@ -0,0 +1,58 @@ +import React from 'react'; + +interface CustomMediaInfoPanelProps { + name: string; + size: number; + type: string; + dateModified: number; + description?: string | null; +} + +const CustomMediaInfoPanel: React.FC = ({ + name, + size, + type, + dateModified, + description, +}) => { + function formatLocalDate(timestamp: number | undefined | null) { + if (!timestamp || isNaN(timestamp)) return '-'; + const date = new Date(timestamp * 1000); + return date.toLocaleString('de-DE'); + } + return ( +
+

Datei-Eigenschaften

+
+ Name: {name || '-'} +
+
+ Typ: {type || '-'} +
+
+ Größe: {typeof size === 'number' && !isNaN(size) ? size + ' Bytes' : '-'} +
+
+ Geändert: {formatLocalDate(dateModified)} +
+
+ Beschreibung:{' '} + {description && description !== 'null' ? ( + description + ) : ( + Keine Beschreibung + )} +
+
+ ); +}; + +export default CustomMediaInfoPanel; diff --git a/dashboard/src/components/CustomSelectUploadEventModal.tsx b/dashboard/src/components/CustomSelectUploadEventModal.tsx new file mode 100644 index 0000000..7a3f122 --- /dev/null +++ b/dashboard/src/components/CustomSelectUploadEventModal.tsx @@ -0,0 +1,119 @@ +import React, { useState } from 'react'; +import { DialogComponent } from '@syncfusion/ej2-react-popups'; +import { + FileManagerComponent, + Inject, + NavigationPane, + DetailsView, + Toolbar, +} from '@syncfusion/ej2-react-filemanager'; + +const hostUrl = '/api/eventmedia/filemanager/'; + +type CustomSelectUploadEventModalProps = { + open: boolean; + onClose: () => void; + onSelect: (file: { id: string; path: string; name: string }) => void; // name ergänzt + selectedFileId?: string | null; +}; + +const CustomSelectUploadEventModal: React.FC = props => { + const { open, onClose, onSelect } = props; + + const [selectedFile, setSelectedFile] = useState<{ + id: string; + path: string; + name: string; + } | null>(null); + + // Callback für Dateiauswahl + interface FileSelectEventArgs { + fileDetails: { + name: string; + isFile: boolean; + size: number; + // weitere Felder falls benötigt + }; + } + + const handleFileSelect = async (args: FileSelectEventArgs) => { + if (args.fileDetails.isFile && args.fileDetails.size > 0) { + const filename = args.fileDetails.name; + + try { + const response = await fetch( + `/api/eventmedia/find_by_filename?filename=${encodeURIComponent(filename)}` + ); + if (response.ok) { + const data = await response.json(); + setSelectedFile({ id: data.id, path: data.file_path, name: filename }); + } else { + setSelectedFile({ id: filename, path: filename, name: filename }); + } + } catch (e) { + console.error('Error fetching file details:', e); + } + } + }; + + // Button-Handler + const handleSelectClick = () => { + if (selectedFile) { + onSelect(selectedFile); + } + }; + + return ( + ( +
+ + +
+ )} + > + + + +
+ ); +}; + +export default CustomSelectUploadEventModal; diff --git a/dashboard/src/components/SetupModeButton.tsx b/dashboard/src/components/SetupModeButton.tsx new file mode 100644 index 0000000..b254cb3 --- /dev/null +++ b/dashboard/src/components/SetupModeButton.tsx @@ -0,0 +1,19 @@ +import React from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Wrench } from 'lucide-react'; + +const SetupModeButton: React.FC = () => { + const navigate = useNavigate(); + return ( + + ); +}; + +export default SetupModeButton; diff --git a/dashboard/src/components/ToastProvider.tsx b/dashboard/src/components/ToastProvider.tsx new file mode 100644 index 0000000..4654501 --- /dev/null +++ b/dashboard/src/components/ToastProvider.tsx @@ -0,0 +1,24 @@ +import React, { createContext, useRef, useContext } from 'react'; +import { ToastComponent, type ToastModel } from '@syncfusion/ej2-react-notifications'; + +const ToastContext = createContext<{ show: (opts: ToastModel) => void }>({ show: () => {} }); + +export const useToast = () => useContext(ToastContext); + +export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const toastRef = useRef(null); + + const show = (opts: ToastModel) => toastRef.current?.show(opts); + + return ( + + {children} + + + ); +}; diff --git a/dashboard/src/dashboard.tsx b/dashboard/src/dashboard.tsx new file mode 100644 index 0000000..0e820a6 --- /dev/null +++ b/dashboard/src/dashboard.tsx @@ -0,0 +1,204 @@ +import React, { useEffect, useState, useRef } from 'react'; +import { fetchGroupsWithClients, restartClient } from './apiClients'; +import type { Group, Client } from './apiClients'; +import { + GridComponent, + ColumnsDirective, + ColumnDirective, + Page, + DetailRow, + Inject, + Sort, +} from '@syncfusion/ej2-react-grids'; + +const REFRESH_INTERVAL = 15000; // 15 Sekunden + +// Typ für Collapse-Event +// type DetailRowCollapseArgs = { +// data?: { id?: string | number }; +// }; + +// Typ für DataBound-Event +type DetailRowDataBoundArgs = { + data?: { id?: string | number }; +}; + +const Dashboard: React.FC = () => { + const [groups, setGroups] = useState([]); + const [expandedGroupIds, setExpandedGroupIds] = useState([]); + const gridRef = useRef(null); + + // Funktion für das Schließen einer Gruppe (Collapse) + // const onDetailCollapse = (args: DetailRowCollapseArgs) => { + // if (args && args.data && args.data.id) { + // const groupId = String(args.data.id); + // setExpandedGroupIds(prev => prev.filter(id => String(id) !== groupId)); + // } + // }; + + // // Registriere das Event nach dem Mount am Grid + // useEffect(() => { + // if (gridRef.current) { + // gridRef.current.detailCollapse = onDetailCollapse; + // } + // }, []); + + // Optimiertes Update: Nur bei echten Datenänderungen wird das Grid aktualisiert + useEffect(() => { + let lastGroups: Group[] = []; + const fetchAndUpdate = async () => { + const newGroups = await fetchGroupsWithClients(); + // Vergleiche nur die relevanten Felder (id, clients, is_alive) + const changed = + lastGroups.length !== newGroups.length || + lastGroups.some((g, i) => { + const ng = newGroups[i]; + if (!ng || g.id !== ng.id || g.clients.length !== ng.clients.length) return true; + // Optional: Vergleiche tiefer, z.B. Alive-Status + for (let j = 0; j < g.clients.length; j++) { + if ( + g.clients[j].uuid !== ng.clients[j].uuid || + g.clients[j].is_alive !== ng.clients[j].is_alive + ) { + return true; + } + } + return false; + }); + if (changed) { + setGroups(newGroups); + lastGroups = newGroups; + setTimeout(() => { + expandedGroupIds.forEach(id => { + const rowIndex = newGroups.findIndex(g => String(g.id) === String(id)); + if (rowIndex !== -1 && gridRef.current) { + gridRef.current.detailRowModule.expand(rowIndex); + } + }); + }, 100); + } + }; + + fetchAndUpdate(); + const interval = setInterval(fetchAndUpdate, REFRESH_INTERVAL); + return () => clearInterval(interval); + }, [expandedGroupIds]); + + // Health-Badge + const getHealthBadge = (group: Group) => { + const total = group.clients.length; + const alive = group.clients.filter((c: Client) => c.is_alive).length; + const ratio = total === 0 ? 0 : alive / total; + let color = 'danger'; + let text = `${alive} / ${total} offline`; + if (ratio === 1) { + color = 'success'; + text = `${alive} / ${total} alive`; + } else if (ratio >= 0.5) { + color = 'warning'; + text = `${alive} / ${total} teilw. alive`; + } + return {text}; + }; + + // Einfache Tabelle für Clients einer Gruppe + const getClientTable = (group: Group) => ( +
+ + + + + {/* { + if (!props.last_alive) return '-'; + const dateStr = props.last_alive.endsWith('Z') + ? props.last_alive + : props.last_alive + 'Z'; + const date = new Date(dateStr); + return isNaN(date.getTime()) ? props.last_alive : date.toLocaleString(); + }} + /> */} + ( + + {props.is_alive ? 'alive' : 'offline'} + + )} + sortComparer={(a, b) => (a === b ? 0 : a ? -1 : 1)} + /> + ( + + )} + /> + + + +
+ ); + + // Neustart-Logik + const handleRestartClient = async (uuid: string) => { + try { + const result = await restartClient(uuid); + alert(`Neustart erfolgreich: ${result.message}`); + } catch (error: unknown) { + if (error && typeof error === 'object' && 'message' in error) { + alert(`Fehler beim Neustart: ${(error as { message: string }).message}`); + } else { + alert('Unbekannter Fehler beim Neustart'); + } + } + }; + + // SyncFusion Grid liefert im Event die Zeile/Gruppe + const onDetailDataBound = (args: DetailRowDataBoundArgs) => { + if (args && args.data && args.data.id) { + const groupId = String(args.data.id); + setExpandedGroupIds(prev => (prev.includes(groupId) ? prev : [...prev, groupId])); + } + }; + + return ( +
+
+

Dashboard

+
+

Raumgruppen Übersicht

+ getClientTable(props)} + detailDataBound={onDetailDataBound} + ref={gridRef} + > + + + + getHealthBadge(props)} + /> + + + {groups.length === 0 && ( +
Keine Gruppen gefunden.
+ )} +
+ ); +}; + +export default Dashboard; diff --git a/dashboard/src/einstellungen.tsx b/dashboard/src/einstellungen.tsx new file mode 100644 index 0000000..2f6a325 --- /dev/null +++ b/dashboard/src/einstellungen.tsx @@ -0,0 +1,87 @@ +import React from 'react'; +import { listHolidays, uploadHolidaysCsv, type Holiday } from './apiHolidays'; + +const Einstellungen: React.FC = () => { + const [file, setFile] = React.useState(null); + const [busy, setBusy] = React.useState(false); + const [message, setMessage] = React.useState(null); + const [holidays, setHolidays] = React.useState([]); + + const refresh = React.useCallback(async () => { + try { + const data = await listHolidays(); + setHolidays(data.holidays); + } catch (e) { + const msg = e instanceof Error ? e.message : 'Fehler beim Laden der Ferien'; + setMessage(msg); + } + }, []); + + React.useEffect(() => { + refresh(); + }, [refresh]); + + const onUpload = async () => { + if (!file) return; + setBusy(true); + setMessage(null); + try { + const res = await uploadHolidaysCsv(file); + setMessage(`Import erfolgreich: ${res.inserted} neu, ${res.updated} aktualisiert.`); + await refresh(); + } catch (e) { + const msg = e instanceof Error ? e.message : 'Fehler beim Import.'; + setMessage(msg); + } finally { + setBusy(false); + } + }; + + return ( +
+

Einstellungen

+
+
+

Schulferien importieren

+

+ Unterstützte Formate: +
• CSV mit Kopfzeile: name, start_date,{' '} + end_date, optional region +
• TXT/CSV ohne Kopfzeile mit Spalten: interner Name, Name,{' '} + Start (YYYYMMDD), Ende (YYYYMMDD), optional interne + Info (ignoriert) +

+
+ setFile(e.target.files?.[0] ?? null)} + /> + +
+ {message &&
{message}
} +
+ +
+

Importierte Ferien

+ {holidays.length === 0 ? ( +
Keine Einträge vorhanden.
+ ) : ( +
    + {holidays.slice(0, 20).map(h => ( +
  • + {h.name}: {h.start_date} – {h.end_date} + {h.region ? ` (${h.region})` : ''} +
  • + ))} +
+ )} +
+
+
+ ); +}; + +export default Einstellungen; diff --git a/dashboard/src/groupColors.ts b/dashboard/src/groupColors.ts new file mode 100644 index 0000000..8c00bac --- /dev/null +++ b/dashboard/src/groupColors.ts @@ -0,0 +1,52 @@ +// 20 gut unterscheidbare Farben für Gruppen + +export const groupColorPalette: string[] = [ + '#1E90FF', // Blau + '#28A745', // Grün + '#FFC107', // Gelb + '#DC3545', // Rot + '#6F42C1', // Lila + '#20C997', // Türkis + '#FD7E14', // Orange + '#6610F2', // Violett + '#17A2B8', // Cyan + '#E83E8C', // Pink + '#FF5733', // Koralle + '#2ECC40', // Hellgrün + '#FFB300', // Dunkelgelb + '#00796B', // Petrol + '#C70039', // Dunkelrot + '#8D6E63', // Braun + '#607D8B', // Grau-Blau + '#00B8D4', // Türkisblau + '#FF6F00', // Dunkelorange + '#9C27B0', // Dunkellila +]; + +// Gibt für eine Gruppen-ID immer dieselbe Farbe zurück (Index basiert auf Gruppenliste) +export function getGroupColor(groupId: string, groups: { id: string }[]): string { + const colorPalette = [ + '#1E90FF', + '#28A745', + '#FFC107', + '#DC3545', + '#6F42C1', + '#20C997', + '#FD7E14', + '#6610F2', + '#17A2B8', + '#E83E8C', + '#FF5733', + '#2ECC40', + '#FFB300', + '#00796B', + '#C70039', + '#8D6E63', + '#607D8B', + '#00B8D4', + '#FF6F00', + '#9C27B0', + ]; + const idx = groups.findIndex(g => g.id === groupId); + return colorPalette[idx % colorPalette.length]; +} diff --git a/dashboard/src/hooks/useClientDelete.ts b/dashboard/src/hooks/useClientDelete.ts new file mode 100644 index 0000000..11c576a --- /dev/null +++ b/dashboard/src/hooks/useClientDelete.ts @@ -0,0 +1,36 @@ +import { useState } from 'react'; +import { deleteClient } from '../apiClients'; + +export function useClientDelete(onDeleted?: (uuid: string) => void) { + const [showDialog, setShowDialog] = useState(false); + const [deleteClientId, setDeleteClientId] = useState(null); + + // Details-Modal separat im Parent verwalten! + + const handleDelete = (uuid: string) => { + setDeleteClientId(uuid); + setShowDialog(true); + }; + + const confirmDelete = async () => { + if (deleteClientId) { + await deleteClient(deleteClientId); + setShowDialog(false); + if (onDeleted) onDeleted(deleteClientId); + setDeleteClientId(null); + } + }; + + const cancelDelete = () => { + setShowDialog(false); + setDeleteClientId(null); + }; + + return { + showDialog, + deleteClientId, + handleDelete, + confirmDelete, + cancelDelete, + }; +} diff --git a/dashboard/src/index.css b/dashboard/src/index.css new file mode 100644 index 0000000..b1235f3 --- /dev/null +++ b/dashboard/src/index.css @@ -0,0 +1,76 @@ +/* @tailwind base; +@tailwind components; */ + +/* @tailwind utilities; */ + +/* :root { + font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + color-scheme: light dark; + color: rgb(255 255 255 / 87%); + background-color: #242424; + font-synthesis: none; + text-rendering: optimizelegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +a { + font-weight: 500; + color: #646cff; + text-decoration: inherit; +} + +a:hover { + color: #535bf2; +} + +body { + margin: 0; + display: flex; + place-items: center; + min-width: 320px; + min-height: 100vh; +} + +h1 { + font-size: 3.2em; + line-height: 1.1; +} + +/* button { + border-radius: 8px; + border: 1px solid transparent; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + background-color: #1a1a1a; + cursor: pointer; + transition: border-color 0.25s; +} + +button:hover { + border-color: #646cff; +} + +button:focus, +button:focus-visible { + outline: 4px auto -webkit-focus-ring-color; +} */ + +/* @media (prefers-color-scheme: light) { + :root { + color: #213547; + background-color: #fff; + } + + a:hover { + color: #747bff; + } + + button { + background-color: #f9f9f9; + } +} */ diff --git a/dashboard/src/infoscreen_groups.tsx b/dashboard/src/infoscreen_groups.tsx new file mode 100644 index 0000000..eceeaf2 --- /dev/null +++ b/dashboard/src/infoscreen_groups.tsx @@ -0,0 +1,519 @@ +import React, { useEffect, useState, useRef } from 'react'; +import { KanbanComponent } from '@syncfusion/ej2-react-kanban'; +import { fetchClients, updateClientGroup } from './apiClients'; +import { fetchGroups, createGroup, deleteGroup, renameGroup } from './apiGroups'; +import type { Client } from './apiClients'; +import type { KanbanComponent as KanbanComponentType } from '@syncfusion/ej2-react-kanban'; +import { DialogComponent } from '@syncfusion/ej2-react-popups'; +import { useToast } from './components/ToastProvider'; +import { L10n } from '@syncfusion/ej2-base'; + +interface KanbanClient extends Client { + Id: string; + Status: string; // Raumgruppe (Gruppenname) + Summary: string; // Anzeigename +} + +interface Group { + id: number; + name: string; + // weitere Felder möglich +} + +interface KanbanDragEventArgs { + element: HTMLElement | HTMLElement[]; + data: KanbanClient | KanbanClient[]; + event?: { event?: MouseEvent }; + [key: string]: unknown; +} + +interface KanbanComponentWithClear extends KanbanComponentType { + clearSelection: () => void; +} + +const de = { + title: 'Gruppen', + newGroup: 'Neue Raumgruppe', + renameGroup: 'Gruppe umbenennen', + deleteGroup: 'Gruppe löschen', + add: 'Hinzufügen', + cancel: 'Abbrechen', + rename: 'Umbenennen', + confirmDelete: 'Löschbestätigung', + reallyDelete: (name: string) => `Möchten Sie die Gruppe ${name} wirklich löschen?`, + clientsMoved: 'Alle Clients werden in "Nicht zugeordnet" verschoben.', + groupCreated: 'Gruppe angelegt', + groupDeleted: 'Gruppe gelöscht. Clients in "Nicht zugeordnet" verschoben', + groupRenamed: 'Gruppenname geändert', + selectGroup: 'Gruppe wählen', + newName: 'Neuer Name', + warning: 'Achtung:', + yesDelete: 'Ja, löschen', +}; + +L10n.load({ + de: { + kanban: { + items: 'Clients', + addTitle: 'Neue Karte hinzufügen', + editTitle: 'Karte bearbeiten', + deleteTitle: 'Karte löschen', + edit: 'Bearbeiten', + delete: 'Löschen', + save: 'Speichern', + cancel: 'Abbrechen', + yes: 'Ja', + no: 'Nein', + noCard: 'Keine Clients vorhanden', + }, + }, +}); + +const Infoscreen_groups: React.FC = () => { + const toast = useToast(); + const [clients, setClients] = useState([]); + const [groups, setGroups] = useState<{ keyField: string; headerText: string; id?: number }[]>([]); + const [showDialog, setShowDialog] = useState(false); + const [newGroupName, setNewGroupName] = useState(''); + const [draggedCard, setDraggedCard] = useState<{ id: string; fromColumn: string } | null>(null); + const [renameDialog, setRenameDialog] = useState<{ + open: boolean; + oldName: string; + newName: string; + }>({ open: false, oldName: '', newName: '' }); + const [deleteDialog, setDeleteDialog] = useState<{ open: boolean; groupName: string }>({ + open: false, + groupName: '', + }); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const kanbanRef = useRef(null); // Ref für Kanban + + // Lade Gruppen und Clients + useEffect(() => { + let groupMap: Record = {}; + fetchGroups().then((groupData: Group[]) => { + const kanbanGroups = groupData.map(g => ({ + keyField: g.name, + headerText: g.name, + id: g.id, + })); + setGroups(kanbanGroups); + + groupMap = Object.fromEntries(groupData.map(g => [g.id, g.name])); + + fetchClients().then(data => { + setClients( + data.map((c, i) => ({ + ...c, + Id: c.uuid, + Status: + c.group_id === 1 + ? 'Nicht zugeordnet' + : typeof c.group_id === 'number' && groupMap[c.group_id] + ? groupMap[c.group_id] + : 'Nicht zugeordnet', + Summary: c.description || `Client ${i + 1}`, + })) + ); + }); + }); + }, []); + + // Neue Gruppe anlegen (persistiert per API) + const handleAddGroup = async () => { + if (!newGroupName.trim()) return; + try { + const newGroup = await createGroup(newGroupName); + toast.show({ + content: de.groupCreated, + cssClass: 'e-toast-success', + timeOut: 5000, + showCloseButton: false, + }); + setGroups([ + ...groups, + { keyField: newGroup.name, headerText: newGroup.name, id: newGroup.id }, + ]); + setNewGroupName(''); + setShowDialog(false); + } catch (err) { + toast.show({ + content: (err as Error).message, + cssClass: 'e-toast-danger', + timeOut: 0, + showCloseButton: true, + }); + } + }; + + // Löschen einer Gruppe + const handleDeleteGroup = async (groupName: string) => { + try { + // Clients der Gruppe in "Nicht zugeordnet" verschieben + const groupClients = clients.filter(c => c.Status === groupName); + if (groupClients.length > 0) { + // Ermittle die ID der Zielgruppe "Nicht zugeordnet" + const target = groups.find(g => g.headerText === 'Nicht zugeordnet'); + if (!target || !target.id) throw new Error('Zielgruppe "Nicht zugeordnet" nicht gefunden'); + await updateClientGroup( + groupClients.map(c => c.Id), + target.id + ); + } + await deleteGroup(groupName); + toast.show({ + content: de.groupDeleted, + cssClass: 'e-toast-success', + timeOut: 5000, + showCloseButton: false, + }); + // Gruppen und Clients neu laden + const groupData = await fetchGroups(); + const groupMap = Object.fromEntries(groupData.map((g: Group) => [g.id, g.name])); + setGroups(groupData.map((g: Group) => ({ keyField: g.name, headerText: g.name, id: g.id }))); + const data = await fetchClients(); + setClients( + data.map((c, i) => ({ + ...c, + Id: c.uuid, + Status: + typeof c.group_id === 'number' && groupMap[c.group_id] + ? groupMap[c.group_id] + : 'Nicht zugeordnet', + Summary: c.description || `Client ${i + 1}`, + })) + ); + } catch (err) { + toast.show({ + content: (err as Error).message, + cssClass: 'e-toast-danger', + timeOut: 0, + showCloseButton: true, + }); + } + setDeleteDialog({ open: false, groupName: '' }); + }; + + // Umbenennen einer Gruppe + const handleRenameGroup = async () => { + try { + await renameGroup(renameDialog.oldName, renameDialog.newName); + toast.show({ + content: de.groupRenamed, + cssClass: 'e-toast-success', + timeOut: 5000, + showCloseButton: false, + }); + // Gruppen und Clients neu laden + const groupData = await fetchGroups(); + const groupMap = Object.fromEntries(groupData.map((g: Group) => [g.id, g.name])); + setGroups(groupData.map((g: Group) => ({ keyField: g.name, headerText: g.name, id: g.id }))); + const data = await fetchClients(); + setClients( + data.map((c, i) => ({ + ...c, + Id: c.uuid, + Status: + typeof c.group_id === 'number' && groupMap[c.group_id] + ? groupMap[c.group_id] + : 'Nicht zugeordnet', + Summary: c.description || `Client ${i + 1}`, + })) + ); + } catch (err) { + toast.show({ + content: (err as Error).message, + cssClass: 'e-toast-danger', + timeOut: 0, + showCloseButton: true, + }); + } + setRenameDialog({ open: false, oldName: '', newName: '' }); + }; + + const handleDragStart = (args: KanbanDragEventArgs) => { + const element = Array.isArray(args.element) ? args.element[0] : args.element; + const cardId = element.getAttribute('data-id'); + const fromColumn = element.getAttribute('data-key'); + setDraggedCard({ id: cardId || '', fromColumn: fromColumn || '' }); + }; + + const handleCardDrop = async (args: KanbanDragEventArgs) => { + if (!draggedCard) return; + + const mouseEvent = args.event?.event; + let targetGroupName = ''; + + if (mouseEvent && mouseEvent.clientX && mouseEvent.clientY) { + const targetElement = document.elementFromPoint(mouseEvent.clientX, mouseEvent.clientY); + const kanbanColumn = + targetElement?.closest('[data-key]') || targetElement?.closest('.e-content-row'); + if (kanbanColumn) { + const columnKey = kanbanColumn.getAttribute('data-key'); + if (columnKey) { + targetGroupName = columnKey; + } else { + const headerElement = kanbanColumn.querySelector('.e-header-text'); + targetGroupName = headerElement?.textContent?.trim() || ''; + } + } + } + + // Fallback + if (!targetGroupName) { + const targetElement = Array.isArray(args.element) ? args.element[0] : args.element; + const cardWrapper = targetElement.closest('.e-card-wrapper'); + const contentRow = cardWrapper?.closest('.e-content-row'); + const headerText = contentRow?.querySelector('.e-header-text'); + targetGroupName = headerText?.textContent?.trim() || ''; + } + + if (!targetGroupName || targetGroupName === draggedCard.fromColumn) { + setDraggedCard(null); + return; + } + + const dropped = Array.isArray(args.data) ? args.data : [args.data]; + const clientIds = dropped.map((card: KanbanClient) => card.Id); + + try { + // Ermittle Zielgruppen-ID anhand des Namens + const target = groups.find(g => g.headerText === targetGroupName); + if (!target || !target.id) throw new Error('Zielgruppe nicht gefunden'); + await updateClientGroup(clientIds, target.id); + fetchGroups().then((groupData: Group[]) => { + const groupMap = Object.fromEntries(groupData.map(g => [g.id, g.name])); + setGroups( + groupData.map(g => ({ + keyField: g.name, + headerText: g.name, + id: g.id, + })) + ); + fetchClients().then(data => { + setClients( + data.map((c, i) => ({ + ...c, + Id: c.uuid, + Status: + typeof c.group_id === 'number' && groupMap[c.group_id] + ? groupMap[c.group_id] + : 'Nicht zugeordnet', + Summary: c.description || `Client ${i + 1}`, + })) + ); + // Nach dem Laden: Karten deselektieren + setTimeout(() => { + (kanbanRef.current as KanbanComponentWithClear)?.clearSelection(); + setTimeout(() => { + (kanbanRef.current as KanbanComponentWithClear)?.clearSelection(); + }, 100); + }, 50); + }); + }); + } catch { + alert('Fehler beim Aktualisieren der Clients'); + } + setDraggedCard(null); + }; + + // Spalten-Array ohne Header-Buttons/Template + const kanbanColumns = groups.map(group => ({ + keyField: group.keyField, + headerText: group.headerText, + })); + + return ( +
+

{de.title}

+
+ + + +
+ + {showDialog && ( +
+
+

{de.newGroup}

+ setNewGroupName(e.target.value)} + placeholder="Raumname" + /> +
+ + +
+
+
+ )} + {renameDialog.open && ( +
+
+

{de.renameGroup}

+ + setRenameDialog({ ...renameDialog, newName: e.target.value })} + placeholder={de.newName} + /> +
+ + +
+
+
+ )} + {deleteDialog.open && ( +
+
+

{de.deleteGroup}

+ +

{de.clientsMoved}

+ {deleteDialog.groupName && ( +
+ {de.warning} Möchten Sie die Gruppe {deleteDialog.groupName}{' '} + wirklich löschen? +
+ )} +
+ + +
+
+ {showDeleteConfirm && deleteDialog.groupName && ( + setShowDeleteConfirm(false)} + footerTemplate={() => ( +
+ + +
+ )} + > +
+ Möchten Sie die Gruppe {deleteDialog.groupName} wirklich löschen? +
+ {de.clientsMoved} +
+
+ )} +
+ )} +
+ ); +}; + +export default Infoscreen_groups; diff --git a/dashboard/src/logout.tsx b/dashboard/src/logout.tsx new file mode 100644 index 0000000..2aecbe1 --- /dev/null +++ b/dashboard/src/logout.tsx @@ -0,0 +1,12 @@ +import React from 'react'; + +const Logout: React.FC = () => ( +
+
+

Abmeldung

+

Sie haben sich erfolgreich abgemeldet.

+
+
+); + +export default Logout; diff --git a/dashboard/src/main.tsx b/dashboard/src/main.tsx new file mode 100644 index 0000000..201997c --- /dev/null +++ b/dashboard/src/main.tsx @@ -0,0 +1,19 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import './index.css'; +import App from './App.tsx'; +import { registerLicense } from '@syncfusion/ej2-base'; +import '@syncfusion/ej2-base/styles/material3.css'; +import '@syncfusion/ej2-navigations/styles/material3.css'; +import '@syncfusion/ej2-buttons/styles/material3.css'; + +// Setze hier deinen Lizenzschlüssel ein +registerLicense( + 'ORg4AjUWIQA/Gnt3VVhhQlJDfV5AQmBIYVp/TGpJfl96cVxMZVVBJAtUQF1hTH5VdENiXX1dcHxUQWNVWkd2' +); + +createRoot(document.getElementById('root')!).render( + + + +); diff --git a/dashboard/src/media.tsx b/dashboard/src/media.tsx new file mode 100644 index 0000000..12d8b61 --- /dev/null +++ b/dashboard/src/media.tsx @@ -0,0 +1,117 @@ +import React, { useState, useRef } from 'react'; +import CustomMediaInfoPanel from './components/CustomMediaInfoPanel'; +import { + FileManagerComponent, + Inject, + NavigationPane, + DetailsView, + Toolbar, +} from '@syncfusion/ej2-react-filemanager'; + +const hostUrl = '/api/eventmedia/filemanager/'; // Dein Backend-Endpunkt für FileManager + +const Media: React.FC = () => { + // State für die angezeigten Dateidetails + const [fileDetails] = useState(null); + // Ansicht: 'LargeIcons', 'Details' + const [viewMode, setViewMode] = useState<'LargeIcons' | 'Details'>('LargeIcons'); + const fileManagerRef = useRef(null); + + // Hilfsfunktion für Datum in Browser-Zeitzone + function formatLocalDate(timestamp: number) { + if (!timestamp) return ''; + const date = new Date(timestamp * 1000); + return date.toLocaleString('de-DE'); // Zeigt lokale Zeit des Browsers + } + + // Ansicht umschalten, ohne Remount + React.useEffect(() => { + if (fileManagerRef.current) { + const element = fileManagerRef.current.element as HTMLElement & { ej2_instances?: unknown[] }; + if (element && element.ej2_instances && element.ej2_instances[0]) { + // Typisiere Instanz als unknown, da kein offizieller Typ vorhanden + const instanz = element.ej2_instances[0] as { view: string; dataBind: () => void }; + instanz.view = viewMode; + instanz.dataBind(); + } + } + }, [viewMode]); + + return ( +
+

Medien

+ {/* Ansicht-Umschalter */} +
+ + +
+ {/* Debug-Ausgabe entfernt, da ReactNode erwartet wird */} + formatLocalDate(data.dateModified), + }, + { field: 'type', headerText: 'Typ', minWidth: '80', width: '100' }, + ], + }} + menuClick={() => {}} + > + + + {/* Details-Panel anzeigen, wenn Details verfügbar sind */} + {fileDetails && } +
+ ); +}; + +export default Media; diff --git a/dashboard/src/programminfo.tsx b/dashboard/src/programminfo.tsx new file mode 100644 index 0000000..ac4f57c --- /dev/null +++ b/dashboard/src/programminfo.tsx @@ -0,0 +1,172 @@ +import React, { useState, useEffect } from 'react'; + +interface ProgramInfo { + appName: string; + version: string; + copyright: string; + supportContact: string; + description: string; + techStack: { + [key: string]: string; + }; + openSourceComponents: { + frontend: { name: string; license: string }[]; + backend: { name: string; license: string }[]; + }; + buildInfo: { + buildDate: string; + commitId: string; + }; + changelog: { + version: string; + date: string; + changes: string[]; + }[]; +} + +const Programminfo: React.FC = () => { + const [info, setInfo] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + fetch('/program-info.json') + .then(response => { + if (!response.ok) { + throw new Error('Netzwerk-Antwort war nicht ok'); + } + return response.json(); + }) + .then(data => setInfo(data)) + .catch(error => { + console.error('Fehler beim Laden der Programminformationen:', error); + setError('Informationen konnten nicht geladen werden.'); + }); + }, []); + + if (error) { + return ( +
+

Fehler

+

{error}

+
+ ); + } + + if (!info) { + return ( +
+

Programminfo

+

Lade Informationen...

+
+ ); + } + + return ( +
+
+

{info.appName}

+

{info.description}

+
+ +
+ {/* Allgemeine Infos & Build */} +
+

Allgemein

+
+

+ Version: {info.version} +

+

+ Copyright: {info.copyright} +

+

+ Support:{' '} + + {info.supportContact} + +

+
+

Build-Informationen

+

+ Build-Datum:{' '} + {new Date(info.buildInfo.buildDate).toLocaleString('de-DE')} +

+

+ Commit-ID:{' '} + + {info.buildInfo.commitId} + +

+
+
+ + {/* Technischer Stack */} +
+

Technologie-Stack

+
    + {Object.entries(info.techStack).map(([key, value]) => ( +
  • + {key}: {value} +
  • + ))} +
+
+
+ + {/* Changelog */} +
+

Änderungsprotokoll (Changelog)

+
+ {info.changelog.map(log => ( +
+

+ Version {log.version}{' '} + + - {new Date(log.date).toLocaleDateString('de-DE')} + +

+
    + {log.changes.map((change, index) => ( +
  • {change}
  • + ))} +
+
+ ))} +
+
+ + {/* Open Source Komponenten */} +
+

Verwendete Open-Source-Komponenten

+
+ {info.openSourceComponents.frontend && ( +
+

Frontend

+
    + {info.openSourceComponents.frontend.map(item => ( +
  • + {item.name} ({item.license}-Lizenz) +
  • + ))} +
+
+ )} + {info.openSourceComponents.backend && ( +
+

Backend

+
    + {info.openSourceComponents.backend.map(item => ( +
  • + {item.name} ({item.license}-Lizenz) +
  • + ))} +
+
+ )} +
+
+
+ ); +}; + +export default Programminfo; diff --git a/dashboard/src/ressourcen.tsx b/dashboard/src/ressourcen.tsx new file mode 100644 index 0000000..d0f687d --- /dev/null +++ b/dashboard/src/ressourcen.tsx @@ -0,0 +1,8 @@ +import React from 'react'; +const Ressourcen: React.FC = () => ( +
+

Ressourcen

+

Willkommen im Infoscreen-Management Ressourcen.

+
+); +export default Ressourcen; diff --git a/dashboard/src/types/json.d.ts b/dashboard/src/types/json.d.ts new file mode 100644 index 0000000..d282b60 --- /dev/null +++ b/dashboard/src/types/json.d.ts @@ -0,0 +1,4 @@ +declare module '*.json' { + const value: unknown; + export default value; +} diff --git a/dashboard/src/vite-env.d.ts b/dashboard/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/dashboard/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/dashboard/tailwind.config.cjs b/dashboard/tailwind.config.cjs new file mode 100644 index 0000000..f065a06 --- /dev/null +++ b/dashboard/tailwind.config.cjs @@ -0,0 +1,10 @@ +module.exports = { + content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'], + corePlugins: { + preflight: false, + }, + theme: { + extend: {}, + }, + plugins: [], +}; diff --git a/dashboard/tsconfig.app.json b/dashboard/tsconfig.app.json new file mode 100644 index 0000000..c9ccbd4 --- /dev/null +++ b/dashboard/tsconfig.app.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/dashboard/tsconfig.json b/dashboard/tsconfig.json new file mode 100644 index 0000000..b1912a1 --- /dev/null +++ b/dashboard/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "typeRoots": ["./src/types", "./node_modules/@types"] + }, + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/dashboard/tsconfig.node.json b/dashboard/tsconfig.node.json new file mode 100644 index 0000000..9728af2 --- /dev/null +++ b/dashboard/tsconfig.node.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2022", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/dashboard/vite.config.ts b/dashboard/vite.config.ts new file mode 100644 index 0000000..6d59980 --- /dev/null +++ b/dashboard/vite.config.ts @@ -0,0 +1,54 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +// import path from 'path'; + +// https://vite.dev/config/ +export default defineConfig({ + cacheDir: './.vite', + plugins: [react()], + resolve: { + // 🔧 KORRIGIERT: Entferne die problematischen Aliases komplett + // Diese verursachen das "not an absolute path" Problem + // alias: { + // '@syncfusion/ej2-react-navigations': '@syncfusion/ej2-react-navigations/index.js', + // '@syncfusion/ej2-react-buttons': '@syncfusion/ej2-react-buttons/index.js', + // }, + }, + optimizeDeps: { + // 🔧 NEU: Force pre-bundling der Syncfusion Module + include: [ + '@syncfusion/ej2-react-navigations', + '@syncfusion/ej2-react-buttons', + '@syncfusion/ej2-base', + '@syncfusion/ej2-navigations', + '@syncfusion/ej2-buttons', + '@syncfusion/ej2-react-base', + ], + // 🔧 NEU: Force dependency re-optimization + force: true, + esbuildOptions: { + target: 'es2020', + }, + }, + build: { + target: 'es2020', + commonjsOptions: { + include: [/node_modules/], + transformMixedEsModules: true, + }, + }, + server: { + host: '0.0.0.0', + port: 5173, + watch: { + usePolling: true, + }, + fs: { + strict: false, + }, + proxy: { + '/api': 'http://server:8000', + '/screenshots': 'http://server:8000', + }, + }, +}); diff --git a/dashboard/wait-for-backend.sh b/dashboard/wait-for-backend.sh new file mode 100755 index 0000000..996f438 --- /dev/null +++ b/dashboard/wait-for-backend.sh @@ -0,0 +1,24 @@ +#!/bin/sh +# wait-for-backend.sh + +# Stellt sicher, dass das Skript bei einem Fehler abbricht +set -e + +# Der erste Parameter ist der Host, der erreicht werden soll +host="$1" +# Alle weiteren Parameter bilden den Befehl, der danach ausgeführt werden soll +shift +cmd="$@" + +# Schleife, die so lange läuft, bis der Host mit einem erfolgreichen HTTP-Status antwortet +# curl -s: silent mode (kein Fortschrittsbalken) +# curl -f: fail silently (gibt einen Fehlercode > 0 zurück, wenn der HTTP-Status nicht 2xx ist) +until curl -s -f "$host" > /dev/null; do + >&2 echo "Backend ist noch nicht erreichbar - schlafe für 2 Sekunden" + sleep 2 +done + +# Wenn die Schleife beendet ist, ist das Backend erreichbar +>&2 echo "Backend ist erreichbar - starte Vite-Server..." +# Führe den eigentlichen Befehl aus (z.B. npm run dev) +exec $cmd diff --git a/deployment-debian.md b/deployment-debian.md new file mode 100644 index 0000000..feefe8d --- /dev/null +++ b/deployment-debian.md @@ -0,0 +1,336 @@ +# Infoscreen Deployment Guide (Debian) + +Komplette Anleitung für das Deployment des Infoscreen-Systems auf einem Debian-Server (Bookworm/Trixie) mit GitHub Container Registry. + +## 📋 Übersicht + +- **Phase 0**: Docker Installation (optional) +- **Phase 1**: Images bauen und zur Registry pushen +- **Phase 2**: Debian-Server Vorbereitung +- **Phase 3**: System-Konfiguration und Start + +--- + +## 🐳 Phase 0: Docker Installation (optional) + +Falls Docker noch nicht installiert ist, wählen Sie eine der folgenden Optionen: + +### Option A: Debian Repository (schnell, aber oft ältere Version) + +```bash +sudo apt update +sudo apt install -y docker.io docker-compose-plugin +sudo systemctl enable docker +sudo systemctl start docker +``` + +### Option B: Offizielle Docker-Installation (empfohlen für Produktion) + +```bash +# Alte Docker-Versionen entfernen (falls vorhanden) +sudo apt remove -y docker docker-engine docker.io containerd runc + +# Abhängigkeiten installieren +sudo apt update +sudo apt install -y ca-certificates curl gnupg lsb-release + +# Repository-Schlüssel hinzufügen +sudo install -m 0755 -d /etc/apt/keyrings +curl -fsSL https://download.docker.com/linux/debian/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg +sudo chmod a+r /etc/apt/keyrings/docker.gpg + +# Debian Codename ermitteln (bookworm / trixie / bullseye ...) +source /etc/os-release +echo "Using Debian release: $VERSION_CODENAME" + +# Docker Repository hinzufügen +echo \ + "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian \ + $VERSION_CODENAME stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null + +# Docker installieren (neueste aus dem offiziellen Repo) +sudo apt update +sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin + +# Docker aktivieren +sudo systemctl enable docker +sudo systemctl start docker + +# Benutzer zur docker-Gruppe hinzufügen (für Root-losen Zugriff) +sudo usermod -aG docker $USER + +echo "Bitte neu einloggen (SSH Sitzung beenden und neu verbinden), damit die Gruppenzugehörigkeit aktiv wird." +``` + +### Docker-Installation testen + +```bash +docker run hello-world +docker --version +docker compose version +``` + +--- + +## 🏗️ Phase 1: Images bauen und pushen (lokale Entwicklungsmaschine) + +### 1. GitHub Container Registry Login + +```bash +# Personal Access Token (write:packages) verwenden +echo $GITHUB_TOKEN | docker login ghcr.io -u robbstarkaustria --password-stdin + +# Alternativ interaktiv +docker login ghcr.io +# Username: robbstarkaustria +# Password: [GITHUB_TOKEN] +``` + +### 2. Images bauen und taggen + +```bash +cd /workspace + +docker build -f server/Dockerfile -t ghcr.io/robbstarkaustria/infoscreen-api:latest . +docker build -f dashboard/Dockerfile -t ghcr.io/robbstarkaustria/infoscreen-dashboard:latest . +docker build -f listener/Dockerfile -t ghcr.io/robbstarkaustria/infoscreen-listener:latest . +docker build -f scheduler/Dockerfile -t ghcr.io/robbstarkaustria/infoscreen-scheduler:latest . +``` + +### 3. Images pushen + +```bash +docker push ghcr.io/robbstarkaustria/infoscreen-api:latest +docker push ghcr.io/robbstarkaustria/infoscreen-dashboard:latest +docker push ghcr.io/robbstarkaustria/infoscreen-listener:latest +docker push ghcr.io/robbstarkaustria/infoscreen-scheduler:latest + +docker images | grep ghcr.io +``` + +--- + +## 🖥️ Phase 2: Debian-Server Vorbereitung + +### 4. Grundsystem aktualisieren + +```bash +sudo apt update && sudo apt upgrade -y +sudo apt install -y git curl wget + +# Falls Docker noch fehlt → Phase 0 ausführen +``` + +### 5. Deployment-Dateien übertragen + +```bash +mkdir -p ~/infoscreen-deployment +cd ~/infoscreen-deployment + +scp user@dev-machine:/workspace/docker-compose.prod.yml . +scp user@dev-machine:/workspace/.env . +scp user@dev-machine:/workspace/nginx.conf . +scp -r user@dev-machine:/workspace/certs ./ +scp -r user@dev-machine:/workspace/mosquitto ./ + +# Alternative Paketierung: +# (auf Entwicklungsrechner) +# tar -czf infoscreen-deployment.tar.gz docker-compose.prod.yml .env nginx.conf certs/ mosquitto/ +# scp infoscreen-deployment.tar.gz user@server:~/ +# (auf Server) +# tar -xzf infoscreen-deployment.tar.gz -C ~/infoscreen-deployment +``` + +### 6. Mosquitto-Konfiguration (falls nicht kopiert) + +```bash +mkdir -p mosquitto/{config,data,log} + +cat > mosquitto/config/mosquitto.conf << 'EOF' +listener 1883 +allow_anonymous true + +listener 9001 +protocol websockets + +persistence true +persistence_location /mosquitto/data/ + +log_dest file /mosquitto/log/mosquitto.log +EOF + +sudo chown -R 1883:1883 mosquitto/data mosquitto/log +chmod 755 mosquitto/config mosquitto/data mosquitto/log +``` + +### 7. Environment (.env) prüfen/anpassen + +```bash +nano .env +# Prüfen u.a.: +# DB_HOST=db +# DB_CONN=mysql+pymysql://${DB_USER}:${DB_PASSWORD}@db/${DB_NAME} +# VITE_API_URL=https://YOUR_SERVER_HOST/api +# Sichere Passwörter & Secrets setzen +``` + +Hinweise: +- Vorlage `.env.example` aus dem Repository verwenden: `cp .env.example .env` (falls noch nicht vorhanden). +- In Produktion lädt der Code keine `.env` automatisch (nur bei `ENV=development`). + +--- + +## 🚀 Phase 3: System-Start und Konfiguration + +### 8. Images von Registry pullen + +```bash +echo $GITHUB_TOKEN | docker login ghcr.io -u robbstarkaustria --password-stdin +docker compose -f docker-compose.prod.yml pull +``` + +### 9. System starten + +```bash +docker compose -f docker-compose.prod.yml up -d +docker compose ps +docker compose logs -f +``` + +### 10. Firewall konfigurieren + +Debian hat standardmäßig nftables/iptables aktiv. Falls eine einfache Verwaltung gewünscht ist, kann `ufw` installiert werden: + +```bash +sudo apt install -y ufw +sudo ufw allow ssh +sudo ufw allow 80/tcp +sudo ufw allow 443/tcp +sudo ufw allow 1883/tcp +sudo ufw allow 9001/tcp +sudo ufw enable +sudo ufw status +``` + +Alternativ direkt via nftables / iptables konfigurieren (optional, nicht dargestellt). + +### 11. Installation validieren + +```bash +curl http://localhost/api/health +curl -k https://localhost # -k bei selbstsignierten Zertifikaten + +docker compose ps +docker compose logs server +docker compose logs mqtt +``` + +--- + +## 🧪 Quickstart (lokale Entwicklung) + +```bash +cp -n .env.example .env +docker compose up -d --build +docker compose ps +docker compose logs -f server +``` + +Dev-Erreichbarkeit: +- Dashboard: http://localhost:5173 +- API: http://localhost:8000/api +- Health: http://localhost:8000/health +- Screenshots: http://localhost:8000/screenshots/.jpg +- MQTT: localhost:1883 (WebSocket: 9001) + +### 12. Systemd Autostart (optional) + +```bash +sudo tee /etc/systemd/system/infoscreen.service > /dev/null << 'EOF' +[Unit] +Description=Infoscreen Application +Requires=docker.service +After=docker.service + +[Service] +Type=oneshot +RemainAfterExit=yes +WorkingDirectory=/home/$USER/infoscreen-deployment +ExecStart=/usr/bin/docker compose -f docker-compose.prod.yml up -d +ExecStop=/usr/bin/docker compose -f docker-compose.prod.yml down +TimeoutStartSec=300 + +[Install] +WantedBy=multi-user.target +EOF + +sudo systemctl enable infoscreen.service +sudo systemctl start infoscreen.service +``` + +--- + +## 🌐 Zugriff auf die Anwendung + +- HTTPS Dashboard: `https://YOUR_SERVER_IP` +- HTTP (Redirect): `http://YOUR_SERVER_IP` +- API: `http://YOUR_SERVER_IP/api/` +- MQTT: `YOUR_SERVER_IP:1883` +- MQTT WebSocket: `YOUR_SERVER_IP:9001` + +--- + +## 🔧 Troubleshooting + +```bash +docker compose ps +docker compose logs -f server +docker compose restart server +``` + +Häufige Ursachen: + +| Problem | Lösung | +|---------|--------| +| Container startet nicht | `docker compose logs ` prüfen | +| Ports belegt | `ss -tulpn | grep -E ':80|:443|:1883|:9001'` | +| Keine Berechtigung Docker | User zur Gruppe `docker` hinzufügen & neu einloggen | +| DB-Verbindung schlägt fehl | `.env` Einträge prüfen (Host = db) | +| Mosquitto Fehler | Ordner-Berechtigungen (`1883:1883`) prüfen | + +System Neustart / Update des Stacks: + +```bash +docker compose down +docker compose pull +docker compose up -d +``` + +--- + +## 📝 Wartung + +### Updates + +```bash +docker compose pull +docker compose up -d +sudo apt update && sudo apt upgrade -y +``` + +### Backup (abhängig von persistenter Datenhaltung – hier nur Mosquitto + Certs exemplarisch) + +```bash +docker compose down +sudo tar -czf infoscreen-backup-$(date +%Y%m%d).tar.gz mosquitto/data/ certs/ + +# Wiederherstellung +sudo tar -xzf infoscreen-backup-YYYYMMDD.tar.gz +docker compose up -d +``` + +--- + +**Das Infoscreen-System ist jetzt auf Ihrem Debian-Server bereitgestellt.** + +Bei Verbesserungswünschen oder Problemen: Issues / Pull Requests im Repository willkommen. diff --git a/deployment-ubuntu.md b/deployment-ubuntu.md new file mode 100644 index 0000000..17ec555 --- /dev/null +++ b/deployment-ubuntu.md @@ -0,0 +1,417 @@ +# Infoscreen Deployment Guide + +Komplette Anleitung für das Deployment des Infoscreen-Systems auf einem Ubuntu-Server mit GitHub Container Registry. + +## 📋 Übersicht + +- **Phase 0**: Docker Installation (optional) +- **Phase 1**: Images bauen und zur Registry pushen +- **Phase 2**: Ubuntu-Server Installation +- **Phase 3**: System-Konfiguration und Start + +--- + +## 🐳 Phase 0: Docker Installation (optional) + +Falls Docker noch nicht installiert ist, wählen Sie eine der folgenden Optionen: + +### Option A: Ubuntu Repository (schnell) + +```bash +# Standard Ubuntu Docker-Pakete +sudo apt update +sudo apt install docker.io docker-compose-plugin -y +sudo systemctl enable docker +sudo systemctl start docker +``` + +### Option B: Offizielle Docker-Installation (empfohlen) + +```bash +# Alte Docker-Versionen entfernen +sudo apt remove docker docker-engine docker.io containerd runc -y + +# Abhängigkeiten installieren +sudo apt update +sudo apt install ca-certificates curl gnupg lsb-release -y + +# Docker GPG-Key hinzufügen +sudo mkdir -p /etc/apt/keyrings +curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg + +# Docker Repository hinzufügen +echo \ + "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \ + $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null + +# Docker installieren (neueste Version) +sudo apt update +sudo apt install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin -y + +# Docker aktivieren und starten +sudo systemctl enable docker +sudo systemctl start docker + +# User zur Docker-Gruppe hinzufügen +sudo usermod -aG docker $USER + +# Neuanmeldung für Gruppenänderung erforderlich +exit +# Neu einloggen via SSH +``` + +### Docker-Installation testen + +```bash +# Test-Container ausführen +docker run hello-world + +# Docker-Version prüfen +docker --version +docker compose version +``` + +--- + +## 🏗️ Phase 1: Images bauen und pushen (Entwicklungsmaschine) + +### 1. GitHub Container Registry Login + +```bash +# GitHub Personal Access Token mit write:packages Berechtigung erstellen +echo $GITHUB_TOKEN | docker login ghcr.io -u robbstarkaustria --password-stdin + +# Oder interaktiv: +docker login ghcr.io +# Username: robbstarkaustria +# Password: [GITHUB_TOKEN] +``` + +### 2. Images bauen und taggen + +```bash +cd /workspace + +# Server-Image bauen +docker build -f server/Dockerfile -t ghcr.io/robbstarkaustria/infoscreen-api:latest . + +# Dashboard-Image bauen +docker build -f dashboard/Dockerfile -t ghcr.io/robbstarkaustria/infoscreen-dashboard:latest . + +# Listener-Image bauen (falls vorhanden) +docker build -f listener/Dockerfile -t ghcr.io/robbstarkaustria/infoscreen-listener:latest . + +# Scheduler-Image bauen (falls vorhanden) +docker build -f scheduler/Dockerfile -t ghcr.io/robbstarkaustria/infoscreen-scheduler:latest . +``` + +### 3. Images zur Registry pushen + +```bash +# Alle Images pushen +docker push ghcr.io/robbstarkaustria/infoscreen-api:latest +docker push ghcr.io/robbstarkaustria/infoscreen-dashboard:latest +docker push ghcr.io/robbstarkaustria/infoscreen-listener:latest +docker push ghcr.io/robbstarkaustria/infoscreen-scheduler:latest + +# Status prüfen +docker images | grep ghcr.io +``` + +--- + +## 🖥️ Phase 2: Ubuntu-Server Installation + +### 4. Ubuntu Server vorbereiten + +```bash +sudo apt update && sudo apt upgrade -y + +# Grundlegende Tools installieren +sudo apt install git curl wget -y + +# Docker installieren (siehe Phase 0) +``` + +### 5. Deployment-Dateien übertragen + +```bash +# Deployment-Ordner erstellen +mkdir -p ~/infoscreen-deployment +cd ~/infoscreen-deployment + +# Dateien vom Dev-System kopieren (über SCP) +scp user@dev-machine:/workspace/docker-compose.prod.yml . +scp user@dev-machine:/workspace/.env . +scp user@dev-machine:/workspace/nginx.conf . +scp -r user@dev-machine:/workspace/certs ./ +scp -r user@dev-machine:/workspace/mosquitto ./ + +# Alternative: Deployment-Paket verwenden +# Auf Dev-Maschine (/workspace): +# tar -czf infoscreen-deployment.tar.gz docker-compose.prod.yml .env nginx.conf certs/ mosquitto/ +# scp infoscreen-deployment.tar.gz user@server:~/ +# Auf Server: tar -xzf infoscreen-deployment.tar.gz +``` + +### 6. Mosquitto-Konfiguration vorbereiten + +```bash +# Falls mosquitto-Ordner noch nicht vollständig vorhanden: +mkdir -p mosquitto/{config,data,log} + +# Mosquitto-Konfiguration erstellen (falls nicht übertragen) +cat > mosquitto/config/mosquitto.conf << 'EOF' +# ----------------------------- +# Netzwerkkonfiguration +# ----------------------------- +listener 1883 +allow_anonymous true +# password_file /mosquitto/config/passwd + +# WebSocket (optional) +listener 9001 +protocol websockets + +# ----------------------------- +# Persistence & Pfade +# ----------------------------- +persistence true +persistence_location /mosquitto/data/ + +log_dest file /mosquitto/log/mosquitto.log +EOF + +# Berechtigungen für Mosquitto setzen +sudo chown -R 1883:1883 mosquitto/data mosquitto/log +chmod 755 mosquitto/config mosquitto/data mosquitto/log +``` + +### 7. Environment-Variablen anpassen + +```bash +# .env für Produktionsumgebung anpassen +nano .env + +# Wichtige Anpassungen: +# VITE_API_URL=https://YOUR_SERVER_HOST/api # Für Dashboard-Build (Production) +# DB_HOST=db # In Containern immer 'db' +# DB_CONN=mysql+pymysql://${DB_USER}:${DB_PASSWORD}@db/${DB_NAME} +# Alle Passwörter für Produktion ändern +``` + +Hinweise: +- Eine Vorlage `.env.example` liegt im Repo. Kopiere sie als Ausgangspunkt: `cp .env.example .env`. +- Für lokale Entwicklung lädt `server/database.py` die `.env`, wenn `ENV=development` gesetzt ist. +- In Produktion verwaltet Compose/Container die Variablen; kein automatisches `.env`-Load im Code nötig. + +--- + +## 🚀 Phase 3: System-Start und Konfiguration + +### 8. Images von Registry pullen + +```bash +# GitHub Container Registry Login (falls private Repository) +echo $GITHUB_TOKEN | docker login ghcr.io -u robbstarkaustria --password-stdin + +# Images pullen +docker compose -f docker-compose.prod.yml pull +``` + +### 9. System starten + +```bash +# Container starten +docker compose -f docker-compose.prod.yml up -d + +# Status prüfen +docker compose ps +docker compose logs -f +``` + +### 10. Firewall konfigurieren + +```bash +sudo ufw enable +sudo ufw allow ssh +sudo ufw allow 80/tcp +sudo ufw allow 443/tcp +sudo ufw allow 1883/tcp # MQTT +sudo ufw allow 9001/tcp # MQTT WebSocket +sudo ufw status +``` + +### 11. Installation validieren + +```bash +# Health-Checks +curl http://localhost/api/health +curl https://localhost -k # -k für selbstsignierte Zertifikate + +# Container-Status +docker compose ps + +# Logs bei Problemen anzeigen +docker compose logs server +docker compose logs dashboard +docker compose logs mqtt +``` + +--- + +## 🧪 Quickstart (Entwicklung) + +Schneller Start der Entwicklungsumgebung mit automatischen Proxys und Hot-Reload. + +```bash +# Im Repository-Root +# 1) .env aus Vorlage erzeugen (lokal, falls noch nicht vorhanden) +cp -n .env.example .env + +# 2) Dev-Stack starten (verwendet docker-compose.yml + docker-compose.override.yml) +docker compose up -d --build + +# 3) Status & Logs +docker compose ps +docker compose logs -f server +docker compose logs -f dashboard +docker compose logs -f mqtt + +# 4) Stack stoppen +docker compose down +``` + +Erreichbarkeit (Dev): +- Dashboard (Vite): http://localhost:5173 +- API (Flask Dev): http://localhost:8000/api +- API Health: http://localhost:8000/health +- Screenshots: http://localhost:8000/screenshots/.jpg +- MQTT: localhost:1883 (WebSocket: localhost:9001) + +Hinweise: +- `ENV=development` lädt `.env` automatisch in `server/database.py`. +- Vite proxy routet `/api` und `/screenshots` in Dev direkt auf die API (siehe `dashboard/vite.config.ts`). + +### 12. Automatischer Start (optional) + +```bash +# Systemd-Service erstellen +sudo tee /etc/systemd/system/infoscreen.service > /dev/null << 'EOF' +[Unit] +Description=Infoscreen Application +Requires=docker.service +After=docker.service + +[Service] +Type=oneshot +RemainAfterExit=yes +WorkingDirectory=/home/$USER/infoscreen-deployment +ExecStart=/usr/bin/docker compose -f docker-compose.prod.yml up -d +ExecStop=/usr/bin/docker compose -f docker-compose.prod.yml down +TimeoutStartSec=300 + +[Install] +WantedBy=multi-user.target +EOF + +# Service aktivieren +sudo systemctl enable infoscreen.service +sudo systemctl start infoscreen.service +``` + +--- + +## 🌐 Zugriff auf die Anwendung + +Nach erfolgreichem Deployment ist die Anwendung unter folgenden URLs erreichbar: + +- **HTTPS Dashboard**: `https://YOUR_SERVER_IP` +- **HTTP Dashboard**: `http://YOUR_SERVER_IP` (Redirect zu HTTPS) +- **API**: `http://YOUR_SERVER_IP/api/` +- **MQTT**: `YOUR_SERVER_IP:1883` +- **MQTT WebSocket**: `YOUR_SERVER_IP:9001` + +--- + +## 🔧 Troubleshooting + +### Container-Status prüfen + +```bash +# Alle Container anzeigen +docker compose ps + +# Spezifische Logs anzeigen +docker compose logs -f [service-name] + +# Container einzeln neustarten +docker compose restart [service-name] +``` + +### System neustarten + +```bash +# Komplett neu starten +docker compose down +docker compose up -d + +# Images neu pullen +docker compose pull +docker compose up -d +``` + +### Häufige Probleme + +| Problem | Lösung | +|---------|--------| +| Container startet nicht | `docker compose logs [service]` prüfen | +| Ports bereits belegt | `sudo netstat -tulpn \| grep :80` prüfen | +| Keine Berechtigung | User zu docker-Gruppe hinzufügen | +| DB-Verbindung fehlschlägt | Environment-Variablen in `.env` prüfen | +| Mosquitto startet nicht | Ordner-Berechtigungen für `1883:1883` setzen | + +--- + +## 📊 Docker-Version Vergleich + +| Aspekt | Ubuntu Repository | Offizielle Installation | +|--------|------------------|------------------------| +| **Installation** | ✅ Schnell (1 Befehl) | ⚠️ Mehrere Schritte | +| **Version** | ⚠️ Oft älter | ✅ Neueste Version | +| **Updates** | ✅ Via apt | ✅ Via apt (nach Setup) | +| **Stabilität** | ✅ Getestet | ✅ Aktuell | +| **Features** | ⚠️ Möglicherweise eingeschränkt | ✅ Alle Features | + +**Empfehlung:** Für Produktion die offizielle Docker-Installation verwenden. + +--- + +## 📝 Wartung + +### Regelmäßige Updates + +```bash +# Images aktualisieren +docker compose pull +docker compose up -d + +# System-Updates +sudo apt update && sudo apt upgrade -y +``` + +### Backup + +```bash +# Container-Daten sichern +docker compose down +sudo tar -czf infoscreen-backup-$(date +%Y%m%d).tar.gz mosquitto/data/ certs/ + +# Backup wiederherstellen +sudo tar -xzf infoscreen-backup-YYYYMMDD.tar.gz +docker compose up -d +``` + +--- + +**Das Infoscreen-System ist jetzt vollständig über GitHub diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..e208541 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,143 @@ +networks: + infoscreen-net: + driver: bridge + +services: + proxy: + image: nginx:1.25 + container_name: infoscreen-proxy + restart: unless-stopped + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx.conf:/etc/nginx/nginx.conf:ro + - ./certs:/etc/nginx/certs:ro + depends_on: + - server + - dashboard + networks: + - infoscreen-net + + db: + image: mariadb:11.2 + container_name: infoscreen-db + restart: unless-stopped + environment: + MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD} + MYSQL_DATABASE: ${DB_NAME} + MYSQL_USER: ${DB_USER} + MYSQL_PASSWORD: ${DB_PASSWORD} + volumes: + - db-data:/var/lib/mysql + networks: + - infoscreen-net + healthcheck: + test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 30s + + mqtt: + image: eclipse-mosquitto:2.0.21 + container_name: infoscreen-mqtt + restart: unless-stopped + volumes: + - ./mosquitto/config/mosquitto.conf:/mosquitto/config/mosquitto.conf:ro + ports: + - "1883:1883" + - "9001:9001" + networks: + - infoscreen-net + healthcheck: + test: ["CMD-SHELL", "mosquitto_pub -h localhost -t test -m 'health' || exit 1"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + + # Verwende fertige Images statt Build + server: + image: ghcr.io/robbstarkaustria/infoscreen-api:latest + container_name: infoscreen-api + restart: unless-stopped + depends_on: + db: + condition: service_healthy + mqtt: + condition: service_healthy + environment: + DB_CONN: "mysql+pymysql://${DB_USER}:${DB_PASSWORD}@db/${DB_NAME}" + DB_USER: ${DB_USER} + DB_PASSWORD: ${DB_PASSWORD} + DB_NAME: ${DB_NAME} + DB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD} + DB_HOST: db + FLASK_ENV: production + MQTT_BROKER_URL: mqtt://mqtt:1883 + MQTT_USER: ${MQTT_USER} + MQTT_PASSWORD: ${MQTT_PASSWORD} + networks: + - infoscreen-net + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 40s + command: > + bash -c "alembic -c /app/server/alembic.ini upgrade head && + python /app/server/init_defaults.py && + exec gunicorn server.wsgi:app --bind 0.0.0.0:8000" + + dashboard: + image: ghcr.io/robbstarkaustria/infoscreen-dashboard:latest # Oder wo auch immer Ihre Images liegen + container_name: infoscreen-dashboard + restart: unless-stopped + depends_on: + server: + condition: service_healthy + environment: + NODE_ENV: production + VITE_API_URL: ${API_URL} + networks: + - infoscreen-net + + listener: + image: ghcr.io/robbstarkaustria/infoscreen-listener:latest # Oder wo auch immer Ihre Images liegen + container_name: infoscreen-listener + restart: unless-stopped + depends_on: + db: + condition: service_healthy + mqtt: + condition: service_healthy + environment: + DB_CONN: "mysql+pymysql://${DB_USER}:${DB_PASSWORD}@db/${DB_NAME}" + DB_USER: ${DB_USER} + DB_PASSWORD: ${DB_PASSWORD} + DB_NAME: ${DB_NAME} + DB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD} + networks: + - infoscreen-net + + scheduler: + image: ghcr.io/robbstarkaustria/infoscreen-scheduler:latest + container_name: infoscreen-scheduler + restart: unless-stopped + depends_on: + # HINZUGEFÜGT: Stellt sicher, dass die DB vor dem Scheduler startet + db: + condition: service_healthy + mqtt: + condition: service_healthy + environment: + # HINZUGEFÜGT: Datenbank-Verbindungsstring + DB_CONN: "mysql+pymysql://${DB_USER}:${DB_PASSWORD}@db/${DB_NAME}" + MQTT_PORT: 1883 + networks: + - infoscreen-net + +volumes: + db-data: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..565e7f4 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,209 @@ +networks: + infoscreen-net: + driver: bridge + +services: + listener: + build: + context: . + dockerfile: listener/Dockerfile + image: infoscreen-listener:latest + container_name: infoscreen-listener + restart: unless-stopped + depends_on: + db: + condition: service_healthy + mqtt: + condition: service_healthy + environment: + - DB_CONN=mysql+pymysql://${DB_USER}:${DB_PASSWORD}@db/${DB_NAME} + - DB_URL=mysql+pymysql://${DB_USER}:${DB_PASSWORD}@db/${DB_NAME} + # 🔧 ENTFERNT: Volume-Mount ist nur für die Entwicklung + networks: + - infoscreen-net + + proxy: + image: nginx:1.25 # 🔧 GEÄNDERT: Spezifische Version + container_name: infoscreen-proxy + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx.conf:/etc/nginx/nginx.conf:ro # 🔧 GEÄNDERT: Relativer Pfad + - ./certs:/etc/nginx/certs:ro # 🔧 GEÄNDERT: Relativer Pfad + depends_on: + - server + - dashboard + networks: + - infoscreen-net + + db: + image: mariadb:11.2 # 🔧 GEÄNDERT: Spezifische Version + container_name: infoscreen-db + restart: unless-stopped + environment: + MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD} + MYSQL_DATABASE: ${DB_NAME} + MYSQL_USER: ${DB_USER} + MYSQL_PASSWORD: ${DB_PASSWORD} + volumes: + - db-data:/var/lib/mysql + ports: + - "3306:3306" + networks: + - infoscreen-net + healthcheck: + test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 30s + + mqtt: + image: eclipse-mosquitto:2.0.21 # ✅ GUT: Version ist bereits spezifisch + container_name: infoscreen-mqtt + restart: unless-stopped + volumes: + - ./mosquitto/config:/mosquitto/config + - ./mosquitto/data:/mosquitto/data + - ./mosquitto/log:/mosquitto/log + ports: + - "1883:1883" # Standard MQTT + - "9001:9001" # WebSocket (falls benötigt) + networks: + - infoscreen-net + healthcheck: + test: + [ + "CMD-SHELL", + "mosquitto_pub -h localhost -t test -m 'health' || exit 1", + ] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + + server: + build: + context: . + dockerfile: server/Dockerfile + image: infoscreen-api:latest + container_name: infoscreen-api + restart: unless-stopped + depends_on: + db: + condition: service_healthy + mqtt: + condition: service_healthy + environment: + DB_CONN: "mysql+pymysql://${DB_USER}:${DB_PASSWORD}@db/${DB_NAME}" + FLASK_ENV: ${FLASK_ENV} + ENV_FILE: ${ENV_FILE} + MQTT_BROKER_URL: ${MQTT_BROKER_URL} + MQTT_USER: ${MQTT_USER} + MQTT_PASSWORD: ${MQTT_PASSWORD} + REDIS_URL: "${REDIS_URL:-redis://redis:6379/0}" + GOTENBERG_URL: "${GOTENBERG_URL:-http://gotenberg:3000}" + ports: + - "8000:8000" + networks: + - infoscreen-net + volumes: + - media-data:/app/server/media + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 40s + + # ✅ GEÄNDERT: Dashboard jetzt mit Node.js/React statt Python/Dash + dashboard: + build: + context: ./dashboard + dockerfile: Dockerfile + args: + - VITE_API_URL=${API_URL} + image: infoscreen-dashboard:latest + container_name: infoscreen-dashboard + restart: unless-stopped + depends_on: + server: + condition: service_healthy + environment: + - NODE_ENV=production + - VITE_API_URL=${API_URL} + # 🔧 ENTFERNT: Port wird in Produktion nicht direkt freigegeben, Zugriff via Proxy + networks: + - infoscreen-net + healthcheck: + # 🔧 GEÄNDERT: Healthcheck prüft den Nginx-Server im Container + test: ["CMD", "curl", "-f", "http://localhost/"] + interval: 30s + timeout: 5s + retries: 3 + # 🔧 ERHÖHT: Gibt dem Backend mehr Zeit zum Starten, bevor dieser + # Container als "gesund" markiert wird. + start_period: 60s + + scheduler: + build: + context: . + dockerfile: scheduler/Dockerfile + image: infoscreen-scheduler:latest + container_name: infoscreen-scheduler + restart: unless-stopped + depends_on: + # HINZUGEFÜGT: Stellt sicher, dass die DB vor dem Scheduler startet + db: + condition: service_healthy + mqtt: + condition: service_healthy + environment: + # HINZUGEFÜGT: Datenbank-Verbindungsstring + - DB_CONN=mysql+pymysql://${DB_USER}:${DB_PASSWORD}@db/${DB_NAME} + - MQTT_PORT=1883 + networks: + - infoscreen-net + + volumes: + - ./scheduler:/app/scheduler + + redis: + image: redis:7-alpine + container_name: infoscreen-redis + restart: unless-stopped + networks: + - infoscreen-net + + gotenberg: + image: gotenberg/gotenberg:8 + container_name: infoscreen-gotenberg + restart: unless-stopped + networks: + - infoscreen-net + + worker: + build: + context: . + dockerfile: server/Dockerfile + image: infoscreen-worker:latest + container_name: infoscreen-worker + restart: unless-stopped + depends_on: + - redis + - gotenberg + - db + environment: + DB_CONN: "mysql+pymysql://${DB_USER}:${DB_PASSWORD}@db/${DB_NAME}" + REDIS_URL: "${REDIS_URL:-redis://redis:6379/0}" + GOTENBERG_URL: "${GOTENBERG_URL:-http://gotenberg:3000}" + PYTHONPATH: /app + command: ["rq", "worker", "conversions"] + networks: + - infoscreen-net + +volumes: + server-pip-cache: + db-data: + media-data: diff --git a/early-validation.sh b/early-validation.sh new file mode 100644 index 0000000..a9db448 --- /dev/null +++ b/early-validation.sh @@ -0,0 +1,100 @@ +#!/bin/bash +# Early Hardware Validation für 25% Entwicklungsstand +# Ziel: Architektur-Probleme früh erkennen, nicht Volltest + +echo "🧪 Infoscreen Early Hardware Validation" +echo "======================================" +echo "Entwicklungsstand: ~25-30%" +echo "Ziel: Basis-Deployment + Performance-Baseline" +echo "" + +# Phase 1: Quick-Setup (30 Min) +echo "📦 Phase 1: Container-Setup-Test" +echo "- Docker-Compose startet alle Services?" +echo "- Health-Checks werden grün?" +echo "- Ports sind erreichbar?" +echo "" + +# Phase 2: Connectivity-Test (1 Stunde) +echo "🌐 Phase 2: Service-Kommunikation" +echo "- Database-Connection vom Server?" +echo "- MQTT-Broker empfängt Messages?" +echo "- Nginx routet zu Services?" +echo "- API-Grundendpoints antworten?" +echo "" + +# Phase 3: Performance-Baseline (2 Stunden) +echo "📊 Phase 3: Performance-Snapshot" +echo "- Memory-Verbrauch pro Container" +echo "- CPU-Usage im Idle" +echo "- Startup-Zeiten messen" +echo "- Network-Latency zwischen Services" +echo "" + +# Phase 4: Basic Load-Test (4 Stunden) +echo "🔥 Phase 4: Basis-Belastungstest" +echo "- 10 parallele API-Requests" +echo "- 1000 MQTT-Messages senden" +echo "- Database-Insert-Performance" +echo "- Memory-Leak-Check (1h Laufzeit)" +echo "" + +# Test-Checklist erstellen +cat > early-validation-checklist.md << 'EOF' +# Early Hardware Validation Checklist + +## ✅ Container-Setup +- [ ] `docker compose up -d` erfolgreich +- [ ] Alle Services zeigen "healthy" Status +- [ ] Keine Error-Logs in den ersten 5 Minuten +- [ ] Ports 80, 8000, 3306, 1883 erreichbar + +## ✅ Service-Kommunikation +- [ ] Server kann zu Database verbinden +- [ ] MQTT-Test-Message wird empfangen +- [ ] Nginx zeigt Service-Status-Page +- [ ] API-Health-Endpoint antwortet (200 OK) + +## ✅ Performance-Baseline +- [ ] Total Memory < 4GB bei Idle +- [ ] CPU-Usage < 10% bei Idle +- [ ] Container-Startup < 60s +- [ ] API-Response-Time < 500ms + +## ✅ Basic-Load-Test +- [ ] 10 parallele Requests ohne Errors +- [ ] 1000 MQTT-Messages ohne Message-Loss +- [ ] Memory-Usage stabil über 1h +- [ ] Keine Container-Restarts + +## 📊 Baseline-Metriken (dokumentieren) +- Memory pro Container: ___MB +- CPU-Usage bei Load: ___% +- API-Response-Time: ___ms +- Database-Query-Time: ___ms +- Container-Startup-Zeit: ___s + +## 🚨 Gefundene Probleme +- [ ] Performance-Bottlenecks: ____________ +- [ ] Memory-Issues: ____________________ +- [ ] Network-Probleme: _________________ +- [ ] Container-Probleme: _______________ + +## ✅ Architektur-Validierung +- [ ] Container-Orchestrierung funktioniert +- [ ] Service-Discovery läuft +- [ ] Volume-Mounting korrekt +- [ ] Environment-Variables werden geladen +- [ ] Health-Checks sind aussagekräftig +EOF + +echo "✅ Early Validation Checklist erstellt: early-validation-checklist.md" +echo "" +echo "🎯 Erwartetes Ergebnis:" +echo "- Architektur-Probleme identifiziert" +echo "- Performance-Baseline dokumentiert" +echo "- Deployment-Prozess validiert" +echo "- Basis für spätere Tests gelegt" +echo "" +echo "⏰ Geschätzter Aufwand: 8-12 Stunden über 2-3 Tage" +echo "💰 ROI: Verhindert teure Architektur-Änderungen später" diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..e69de29 diff --git a/hardware-test-setup.sh b/hardware-test-setup.sh new file mode 100644 index 0000000..e38699a --- /dev/null +++ b/hardware-test-setup.sh @@ -0,0 +1,140 @@ +#!/bin/bash +# Infoscreen Hardware Test Setup für Quad-Core 16GB System + +echo "🖥️ Infoscreen Hardware Test Setup" +echo "==================================" +echo "System: Quad-Core, 16GB RAM, SSD" +echo "" + +# System-Info anzeigen +echo "📊 System-Information:" +echo "CPU Cores: $(nproc)" +echo "RAM Total: $(free -h | grep Mem | awk '{print $2}')" +echo "Disk Free: $(df -h / | tail -1 | awk '{print $4}')" +echo "" + +# Docker-Setup +echo "🐳 Docker-Setup..." +sudo apt update -y +sudo apt install -y docker.io docker-compose-plugin +sudo systemctl enable docker +sudo systemctl start docker +sudo usermod -aG docker $USER + +# Test-Verzeichnisse erstellen +echo "📁 Test-Umgebung erstellen..." +mkdir -p ~/infoscreen-hardware-test/{prod,dev,monitoring,scripts,backups} + +# Performance-Monitoring-Tools +echo "📊 Monitoring-Tools installieren..." +sudo apt install -y htop iotop nethogs ncdu stress-ng + +# Test-Script erstellen +cat > ~/infoscreen-hardware-test/scripts/system-monitor.sh << 'EOF' +#!/bin/bash +# System-Monitoring während Tests + +echo "=== Infoscreen System Monitor ===" +echo "Zeit: $(date)" +echo "" + +echo "🖥️ CPU-Info:" +echo "Load: $(uptime | awk -F'load average:' '{print $2}')" +echo "Cores: $(nproc) | Usage: $(top -bn1 | grep "Cpu(s)" | awk '{print $2}' | cut -d'%' -f1)%" + +echo "" +echo "💾 Memory-Info:" +free -h + +echo "" +echo "💿 Disk-Info:" +df -h / + +echo "" +echo "🐳 Docker-Info:" +docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" + +echo "" +echo "🌡️ System-Temperature (falls verfügbar):" +sensors 2>/dev/null || echo "lm-sensors nicht installiert" + +echo "" +echo "🌐 Network-Connections:" +ss -tuln | grep :80\\\|:443\\\|:8000\\\|:3306\\\|:1883 +EOF + +chmod +x ~/infoscreen-hardware-test/scripts/system-monitor.sh + +# Load-Test-Script erstellen +cat > ~/infoscreen-hardware-test/scripts/load-test.sh << 'EOF' +#!/bin/bash +# Load-Test für Infoscreen-System + +echo "🔥 Infoscreen Load-Test startet..." + +# CPU-Load erzeugen (für Thermal-Tests) +echo "CPU-Stress-Test (30s)..." +stress-ng --cpu $(nproc) --timeout 30s & + +# Memory-Test +echo "Memory-Stress-Test..." +stress-ng --vm 2 --vm-bytes 2G --timeout 30s & + +# Disk-I/O-Test +echo "Disk-I/O-Test..." +stress-ng --hdd 1 --hdd-bytes 1G --timeout 30s & + +# Warten auf Tests +wait + +echo "✅ Load-Test abgeschlossen" +EOF + +chmod +x ~/infoscreen-hardware-test/scripts/load-test.sh + +# Docker-Test-Setup +echo "🧪 Docker-Test-Setup..." +cat > ~/infoscreen-hardware-test/docker-compose.test.yml << 'EOF' +version: '3.8' + +services: + test-web: + image: nginx:alpine + ports: ["8080:80"] + deploy: + resources: + limits: + memory: 256M + reservations: + memory: 128M + + test-db: + image: mariadb:11.2 + environment: + MYSQL_ROOT_PASSWORD: test123 + MYSQL_DATABASE: testdb + deploy: + resources: + limits: + memory: 512M + reservations: + memory: 256M + + test-load: + image: alpine + command: sh -c "while true; do wget -q -O- http://test-web/ > /dev/null; sleep 0.1; done" + depends_on: [test-web] +EOF + +echo "" +echo "✅ Setup abgeschlossen!" +echo "" +echo "🚀 Nächste Schritte:" +echo "1. Logout/Login für Docker-Gruppe" +echo "2. Test: docker run hello-world" +echo "3. System-Monitor: ~/infoscreen-hardware-test/scripts/system-monitor.sh" +echo "4. Load-Test: ~/infoscreen-hardware-test/scripts/load-test.sh" +echo "5. Docker-Test: cd ~/infoscreen-hardware-test && docker compose -f docker-compose.test.yml up" +echo "" +echo "📁 Test-Verzeichnis: ~/infoscreen-hardware-test/" +echo "📊 Monitoring: Führen Sie system-monitor.sh parallel zu Tests aus" diff --git a/helpers/__init__.py b/helpers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/helpers/check_folder.py b/helpers/check_folder.py new file mode 100644 index 0000000..4557d5a --- /dev/null +++ b/helpers/check_folder.py @@ -0,0 +1,56 @@ +import os +from pathlib import Path + +def ensure_folder_exists(folder_path): + """ + Check if a folder exists and create it if it doesn't. + + Args: + folder_path (str or Path): Path to the folder to check/create + + Returns: + bool: True if folder was created, False if it already existed + + Raises: + OSError: If folder creation fails due to permissions or other issues + """ + folder_path = Path(folder_path) + + if folder_path.exists(): + if folder_path.is_dir(): + return False # Folder already exists + else: + raise OSError(f"Path '{folder_path}' exists but is not a directory") + + try: + folder_path.mkdir(parents=True, exist_ok=True) + return True # Folder was created + except OSError as e: + raise OSError(f"Failed to create folder '{folder_path}': {e}") + +# Alternative simpler version using os module +def ensure_folder_exists_simple(folder_path): + """ + Simple version using os.makedirs with exist_ok parameter. + + Args: + folder_path (str): Path to the folder to check/create + """ + os.makedirs(folder_path, exist_ok=True) + +# Usage examples +if __name__ == "__main__": + # Example 1: Create a single folder + folder_created = ensure_folder_exists("my_new_folder") + print(f"Folder created: {folder_created}") + + # Example 2: Create nested folders + ensure_folder_exists("data/processed/results") + + # Example 3: Using the simple version + ensure_folder_exists_simple("logs/2024") + + # Example 4: Using with absolute path + import tempfile + temp_dir = tempfile.gettempdir() + ensure_folder_exists(os.path.join(temp_dir, "my_app", "cache")) \ No newline at end of file diff --git a/listener/.dockerignore b/listener/.dockerignore new file mode 100644 index 0000000..371e918 --- /dev/null +++ b/listener/.dockerignore @@ -0,0 +1,4 @@ +__pycache__/ +*.pyc +*.pyo +*.log diff --git a/listener/Dockerfile b/listener/Dockerfile new file mode 100644 index 0000000..a9333fc --- /dev/null +++ b/listener/Dockerfile @@ -0,0 +1,16 @@ +# Listener Dockerfile +FROM python:3.13-slim + +WORKDIR /app + +COPY listener/requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +# Mosquitto-Tools für MQTT-Tests installieren +RUN apt-get update && apt-get install -y --no-install-recommends mosquitto-clients && rm -rf /var/lib/apt/lists/* + +COPY listener/ ./listener +COPY models/ ./models + +ENV PYTHONPATH=/app +CMD ["python", "listener/listener.py"] diff --git a/listener/listener.py b/listener/listener.py new file mode 100644 index 0000000..ce104b1 --- /dev/null +++ b/listener/listener.py @@ -0,0 +1,102 @@ +import os +import json +import logging +import datetime +import paho.mqtt.client as mqtt +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from models.models import Client + +if os.getenv("ENV", "development") == "development": + from dotenv import load_dotenv + load_dotenv(".env") + +# ENV-abhängige Konfiguration +ENV = os.getenv("ENV", "development") +LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO" if ENV == "production" else "DEBUG") +DB_URL = os.environ.get( + "DB_CONN", "mysql+pymysql://user:password@db/infoscreen") + +# Logging +logging.basicConfig(level=logging.DEBUG, + format='%(asctime)s [%(levelname)s] %(message)s') + +# DB-Konfiguration +engine = create_engine(DB_URL) +Session = sessionmaker(bind=engine) + + +def on_message(client, userdata, msg): + topic = msg.topic + logging.debug(f"Empfangene Nachricht auf Topic: {topic}") + + try: + # Heartbeat-Handling + if topic.startswith("infoscreen/") and topic.endswith("/heartbeat"): + uuid = topic.split("/")[1] + session = Session() + client_obj = session.query(Client).filter_by(uuid=uuid).first() + if client_obj: + client_obj.last_alive = datetime.datetime.now(datetime.UTC) + session.commit() + logging.info( + f"Heartbeat von {uuid} empfangen, last_alive (UTC) aktualisiert.") + session.close() + return + + # Discovery-Handling + if topic == "infoscreen/discovery": + payload = json.loads(msg.payload.decode()) + logging.info(f"Discovery empfangen: {payload}") + + if "uuid" in payload: + uuid = payload["uuid"] + session = Session() + existing = session.query(Client).filter_by(uuid=uuid).first() + + if not existing: + new_client = Client( + uuid=uuid, + hardware_token=payload.get("hardware_token"), + ip=payload.get("ip"), + type=payload.get("type"), + hostname=payload.get("hostname"), + os_version=payload.get("os_version"), + software_version=payload.get("software_version"), + macs=",".join(payload.get("macs", [])), + model=payload.get("model"), + registration_time=datetime.datetime.now(datetime.UTC), + ) + session.add(new_client) + session.commit() + logging.info(f"Neuer Client registriert: {uuid}") + else: + logging.info(f"Client bereits bekannt: {uuid}") + + session.close() + + # Discovery-ACK senden + ack_topic = f"infoscreen/{uuid}/discovery_ack" + client.publish(ack_topic, json.dumps({"status": "ok"})) + logging.info(f"Discovery-ACK gesendet an {ack_topic}") + else: + logging.warning("Discovery ohne UUID empfangen, ignoriert.") + + except Exception as e: + logging.error(f"Fehler bei Verarbeitung: {e}") + + +def main(): + mqtt_client = mqtt.Client(protocol=mqtt.MQTTv311, callback_api_version=2) + mqtt_client.on_message = on_message + mqtt_client.connect("mqtt", 1883) + mqtt_client.subscribe("infoscreen/discovery") + mqtt_client.subscribe("infoscreen/+/heartbeat") + + logging.info( + "Listener gestartet und abonniert auf infoscreen/discovery und infoscreen/+/heartbeat") + mqtt_client.loop_forever() + + +if __name__ == "__main__": + main() diff --git a/listener/requirements.txt b/listener/requirements.txt new file mode 100644 index 0000000..7b769ef --- /dev/null +++ b/listener/requirements.txt @@ -0,0 +1,4 @@ +paho-mqtt>=2.0 +SQLAlchemy>=2.0 +pymysql +python-dotenv diff --git a/models/__init__.py b/models/__init__.py new file mode 100644 index 0000000..75c0a5f --- /dev/null +++ b/models/__init__.py @@ -0,0 +1 @@ +# models package for shared SQLAlchemy models diff --git a/models/models.py b/models/models.py new file mode 100644 index 0000000..a727ff4 --- /dev/null +++ b/models/models.py @@ -0,0 +1,271 @@ +from sqlalchemy import ( + Column, Integer, String, Enum, TIMESTAMP, func, Boolean, ForeignKey, Float, Text, Index, DateTime, Date, UniqueConstraint +) +from sqlalchemy.orm import declarative_base, relationship +import enum +from datetime import datetime, timezone + +Base = declarative_base() + + +class UserRole(enum.Enum): + user = "user" + admin = "admin" + superadmin = "superadmin" + + +class AcademicPeriodType(enum.Enum): + schuljahr = "schuljahr" + semester = "semester" + trimester = "trimester" + + +class User(Base): + __tablename__ = 'users' + id = Column(Integer, primary_key=True, autoincrement=True) + username = Column(String(50), unique=True, nullable=False, index=True) + password_hash = Column(String(128), nullable=False) + role = Column(Enum(UserRole), nullable=False, default=UserRole.user) + is_active = Column(Boolean, default=True, nullable=False) + created_at = Column(TIMESTAMP(timezone=True), + server_default=func.current_timestamp()) + updated_at = Column(TIMESTAMP(timezone=True), server_default=func.current_timestamp( + ), onupdate=func.current_timestamp()) + + +class AcademicPeriod(Base): + __tablename__ = 'academic_periods' + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String(100), nullable=False) # "Schuljahr 2024/25" + display_name = Column(String(50), nullable=True) # "SJ 24/25" (kurz) + start_date = Column(Date, nullable=False, index=True) + end_date = Column(Date, nullable=False, index=True) + period_type = Column(Enum(AcademicPeriodType), + nullable=False, default=AcademicPeriodType.schuljahr) + # nur eine aktive Periode zur Zeit + is_active = Column(Boolean, default=False, nullable=False) + created_at = Column(TIMESTAMP(timezone=True), + server_default=func.current_timestamp()) + updated_at = Column(TIMESTAMP(timezone=True), server_default=func.current_timestamp( + ), onupdate=func.current_timestamp()) + + # Constraint: nur eine aktive Periode zur Zeit + __table_args__ = ( + Index('ix_academic_periods_active', 'is_active'), + UniqueConstraint('name', name='uq_academic_periods_name'), + ) + + def to_dict(self): + return { + "id": self.id, + "name": self.name, + "display_name": self.display_name, + "start_date": self.start_date.isoformat() if self.start_date else None, + "end_date": self.end_date.isoformat() if self.end_date else None, + "period_type": self.period_type.value if self.period_type else None, + "is_active": self.is_active, + "created_at": self.created_at.isoformat() if self.created_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None, + } + + +class ClientGroup(Base): + __tablename__ = 'client_groups' + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String(100), unique=True, nullable=False) + description = Column(String(255), nullable=True) # Manuell zu setzen + created_at = Column(TIMESTAMP(timezone=True), + server_default=func.current_timestamp()) + is_active = Column(Boolean, default=True, nullable=False) + + +class Client(Base): + __tablename__ = 'clients' + uuid = Column(String(36), primary_key=True, nullable=False) + hardware_token = Column(String(64), nullable=True) + ip = Column(String(45), nullable=True) + type = Column(String(50), nullable=True) + hostname = Column(String(100), nullable=True) + os_version = Column(String(100), nullable=True) + software_version = Column(String(100), nullable=True) + macs = Column(String(255), nullable=True) + model = Column(String(100), nullable=True) + description = Column(String(255), nullable=True) # Manuell zu setzen + registration_time = Column(TIMESTAMP( + timezone=True), server_default=func.current_timestamp(), nullable=False) + last_alive = Column(TIMESTAMP(timezone=True), server_default=func.current_timestamp( + ), onupdate=func.current_timestamp(), nullable=False) + is_active = Column(Boolean, default=True, nullable=False) + group_id = Column(Integer, ForeignKey( + 'client_groups.id'), nullable=False, default=1) + + +class EventType(enum.Enum): + presentation = "presentation" + website = "website" + video = "video" + message = "message" + other = "other" + webuntis = "webuntis" + + +class MediaType(enum.Enum): + # Präsentationen + pdf = "pdf" + ppt = "ppt" + pptx = "pptx" + odp = "odp" + # Videos (gängige VLC-Formate) + mp4 = "mp4" + avi = "avi" + mkv = "mkv" + mov = "mov" + wmv = "wmv" + flv = "flv" + webm = "webm" + mpg = "mpg" + mpeg = "mpeg" + ogv = "ogv" + # Bilder (benutzerfreundlich) + jpg = "jpg" + jpeg = "jpeg" + png = "png" + gif = "gif" + bmp = "bmp" + tiff = "tiff" + svg = "svg" + # HTML-Mitteilung + html = "html" + # Webseiten + website = "website" + + +class Event(Base): + __tablename__ = 'events' + id = Column(Integer, primary_key=True, autoincrement=True) + group_id = Column(Integer, ForeignKey( + 'client_groups.id'), nullable=False, index=True) + academic_period_id = Column(Integer, ForeignKey( + # Optional für Rückwärtskompatibilität + 'academic_periods.id'), nullable=True, index=True) + title = Column(String(100), nullable=False) + description = Column(Text, nullable=True) + start = Column(TIMESTAMP(timezone=True), nullable=False, index=True) + end = Column(TIMESTAMP(timezone=True), nullable=False, index=True) + event_type = Column(Enum(EventType), nullable=False) + event_media_id = Column(Integer, ForeignKey( + 'event_media.id'), nullable=True) + autoplay = Column(Boolean, nullable=True) # NEU + loop = Column(Boolean, nullable=True) # NEU + volume = Column(Float, nullable=True) # NEU + slideshow_interval = Column(Integer, nullable=True) # NEU + created_at = Column(TIMESTAMP(timezone=True), + server_default=func.current_timestamp()) + updated_at = Column(TIMESTAMP(timezone=True), server_default=func.current_timestamp( + ), onupdate=func.current_timestamp()) + created_by = Column(Integer, ForeignKey('users.id'), nullable=False) + updated_by = Column(Integer, ForeignKey('users.id'), nullable=True) + is_active = Column(Boolean, default=True, nullable=False) + + # Add relationships + academic_period = relationship( + "AcademicPeriod", foreign_keys=[academic_period_id]) + event_media = relationship("EventMedia", foreign_keys=[event_media_id]) + + +class EventMedia(Base): + __tablename__ = 'event_media' + id = Column(Integer, primary_key=True, autoincrement=True) + academic_period_id = Column(Integer, ForeignKey( + # Optional für bessere Organisation + 'academic_periods.id'), nullable=True, index=True) + media_type = Column(Enum(MediaType), nullable=False) + url = Column(String(255), nullable=False) + file_path = Column(String(255), nullable=True) + message_content = Column(Text, nullable=True) + uploaded_at = Column(TIMESTAMP, nullable=False, + default=lambda: datetime.now(timezone.utc)) + + # Add relationship + academic_period = relationship( + "AcademicPeriod", foreign_keys=[academic_period_id]) + + def to_dict(self): + return { + "id": self.id, + "academic_period_id": self.academic_period_id, + "media_type": self.media_type.value if self.media_type else None, + "url": self.url, + "file_path": self.file_path, + "message_content": self.message_content, + } + + +class SchoolHoliday(Base): + __tablename__ = 'school_holidays' + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String(150), nullable=False) + start_date = Column(Date, nullable=False, index=True) + end_date = Column(Date, nullable=False, index=True) + region = Column(String(100), nullable=True, index=True) + source_file_name = Column(String(255), nullable=True) + imported_at = Column(TIMESTAMP(timezone=True), + server_default=func.current_timestamp()) + + __table_args__ = ( + UniqueConstraint('name', 'start_date', 'end_date', + 'region', name='uq_school_holidays_unique'), + ) + + def to_dict(self): + return { + "id": self.id, + "name": self.name, + "start_date": self.start_date.isoformat() if self.start_date else None, + "end_date": self.end_date.isoformat() if self.end_date else None, + "region": self.region, + "source_file_name": self.source_file_name, + "imported_at": self.imported_at.isoformat() if self.imported_at else None, + } + +# --- Conversions: Track PPT/PPTX/ODP -> PDF processing state --- + + +class ConversionStatus(enum.Enum): + pending = "pending" + processing = "processing" + ready = "ready" + failed = "failed" + + +class Conversion(Base): + __tablename__ = 'conversions' + + id = Column(Integer, primary_key=True, autoincrement=True) + # Source media to be converted + source_event_media_id = Column( + Integer, + ForeignKey('event_media.id', ondelete='CASCADE'), + nullable=False, + index=True, + ) + target_format = Column(String(10), nullable=False, + index=True) # e.g. 'pdf' + # relative to server/media + target_path = Column(String(512), nullable=True) + status = Column(Enum(ConversionStatus), nullable=False, + default=ConversionStatus.pending) + file_hash = Column(String(64), nullable=False) # sha256 of source file + started_at = Column(TIMESTAMP(timezone=True), nullable=True) + completed_at = Column(TIMESTAMP(timezone=True), nullable=True) + error_message = Column(Text, nullable=True) + + __table_args__ = ( + # Fast lookup per media/format + Index('ix_conv_source_target', 'source_event_media_id', 'target_format'), + # Operational filtering + Index('ix_conv_status_target', 'status', 'target_format'), + # Idempotency: same source + target + file content should be unique + UniqueConstraint('source_event_media_id', 'target_format', + 'file_hash', name='uq_conv_source_target_hash'), + ) diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..4fa6785 --- /dev/null +++ b/nginx.conf @@ -0,0 +1,29 @@ +events {} +http { + upstream dashboard { + server infoscreen-dashboard:80; + } + upstream infoscreen_api { + server infoscreen-api:8000; + } + server { + listen 80; + server_name _; + + # Leitet /api/ und /screenshots/ an den API-Server weiter + location /api/ { + proxy_pass http://infoscreen_api/api/; + } + location /screenshots/ { + proxy_pass http://infoscreen_api/screenshots/; + } + # Alles andere geht ans Frontend + location / { + proxy_pass http://dashboard; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + } +} diff --git a/nginx.dev.conf b/nginx.dev.conf new file mode 100644 index 0000000..34a74c4 --- /dev/null +++ b/nginx.dev.conf @@ -0,0 +1,47 @@ +events {} +http { + upstream dashboard { + # Vite dev server inside the dashboard container + server infoscreen-dashboard:5173; + } + upstream infoscreen_api { + server infoscreen-api:8000; + } + + server { + listen 80; + server_name _; + + # Proxy /api and /screenshots to the Flask API + location /api/ { + proxy_pass http://infoscreen_api/api/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /screenshots/ { + proxy_pass http://infoscreen_api/screenshots/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # Everything else to the Vite dev server + location / { + proxy_pass http://dashboard; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + # WebSocket upgrade for Vite HMR + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } + } +} diff --git a/pptx_conversion_guide.md b/pptx_conversion_guide.md new file mode 100644 index 0000000..9e2a21a --- /dev/null +++ b/pptx_conversion_guide.md @@ -0,0 +1,477 @@ +# Recommended Implementation: PPTX-to-PDF Conversion System + +## Architecture Overview + +**Asynchronous server-side conversion with database tracking** + +``` +User Upload → API saves PPTX + DB entry → Job in Queue + ↓ +Client requests → API checks DB status → PDF ready? → Download PDF + → Pending? → "Please wait" + → Failed? → Retry/Error +``` + +## 1. Database Schema + +```sql +CREATE TABLE media_files ( + id UUID PRIMARY KEY, + filename VARCHAR(255), + original_path VARCHAR(512), + file_type VARCHAR(10), + mime_type VARCHAR(100), + uploaded_at TIMESTAMP, + updated_at TIMESTAMP +); + +CREATE TABLE conversions ( + id UUID PRIMARY KEY, + source_file_id UUID REFERENCES media_files(id) ON DELETE CASCADE, + target_format VARCHAR(10), -- 'pdf' + target_path VARCHAR(512), -- Path to generated PDF + status VARCHAR(20), -- 'pending', 'processing', 'ready', 'failed' + started_at TIMESTAMP, + completed_at TIMESTAMP, + error_message TEXT, + file_hash VARCHAR(64) -- Hash of PPTX for cache invalidation +); + +CREATE INDEX idx_conversions_source ON conversions(source_file_id, target_format); +``` + +## 2. Components + +### **API Server (existing)** +- Accepts uploads +- Creates DB entries +- Enqueues jobs +- Delivers status and files + +### **Background Worker (new)** +- Runs as separate process in **same container** as API +- Processes conversion jobs from queue +- Can run multiple worker instances in parallel +- Technology: Python RQ, Celery, or similar + +### **Message Queue** +- Redis (recommended for start - simple, fast) +- Alternative: RabbitMQ for more features + +### **Redis Container (new)** +- Separate container for Redis +- Handles job queue +- Minimal resource footprint + +## 3. Detailed Workflow + +### **Upload Process:** + +```python +@app.post("/upload") +async def upload_file(file): + # 1. Save PPTX + file_path = save_to_disk(file) + + # 2. DB entry for original file + file_record = db.create_media_file({ + 'filename': file.filename, + 'original_path': file_path, + 'file_type': 'pptx' + }) + + # 3. Create conversion record + conversion = db.create_conversion({ + 'source_file_id': file_record.id, + 'target_format': 'pdf', + 'status': 'pending', + 'file_hash': calculate_hash(file_path) + }) + + # 4. Enqueue job (asynchronous!) + queue.enqueue(convert_to_pdf, conversion.id) + + # 5. Return immediately to user + return { + 'file_id': file_record.id, + 'status': 'uploaded', + 'conversion_status': 'pending' + } +``` + +### **Worker Process:** + +```python +def convert_to_pdf(conversion_id): + conversion = db.get_conversion(conversion_id) + source_file = db.get_media_file(conversion.source_file_id) + + # Status update: processing + db.update_conversion(conversion_id, { + 'status': 'processing', + 'started_at': now() + }) + + try: + # LibreOffice Conversion + pdf_path = f"/data/converted/{conversion.id}.pdf" + subprocess.run([ + 'libreoffice', + '--headless', + '--convert-to', 'pdf', + '--outdir', '/data/converted/', + source_file.original_path + ], check=True) + + # Success + db.update_conversion(conversion_id, { + 'status': 'ready', + 'target_path': pdf_path, + 'completed_at': now() + }) + + except Exception as e: + # Error + db.update_conversion(conversion_id, { + 'status': 'failed', + 'error_message': str(e), + 'completed_at': now() + }) +``` + +### **Client Download:** + +```python +@app.get("/files/{file_id}/display") +async def get_display_file(file_id): + file = db.get_media_file(file_id) + + # Only for PPTX: check PDF conversion + if file.file_type == 'pptx': + conversion = db.get_latest_conversion(file.id, target_format='pdf') + + if not conversion: + # Shouldn't happen, but just to be safe + trigger_new_conversion(file.id) + return {'status': 'pending', 'message': 'Conversion is being created'} + + if conversion.status == 'ready': + return FileResponse(conversion.target_path) + + elif conversion.status == 'failed': + # Optional: Auto-retry + trigger_new_conversion(file.id) + return {'status': 'failed', 'error': conversion.error_message} + + else: # pending or processing + return {'status': conversion.status, 'message': 'Please wait...'} + + # Serve other file types directly + return FileResponse(file.original_path) +``` + +## 4. Docker Setup + +```yaml +version: '3.8' + +services: + # Your API Server + api: + build: ./api + command: uvicorn main:app --host 0.0.0.0 --port 8000 + ports: + - "8000:8000" + volumes: + - ./data/uploads:/data/uploads + - ./data/converted:/data/converted + environment: + - REDIS_URL=redis://redis:6379 + - DATABASE_URL=postgresql://postgres:password@postgres:5432/infoscreen + depends_on: + - redis + - postgres + restart: unless-stopped + + # Worker (same codebase as API, different command) + worker: + build: ./api # Same build as API! + command: python worker.py # or: rq worker + volumes: + - ./data/uploads:/data/uploads + - ./data/converted:/data/converted + environment: + - REDIS_URL=redis://redis:6379 + - DATABASE_URL=postgresql://postgres:password@postgres:5432/infoscreen + depends_on: + - redis + - postgres + restart: unless-stopped + # Optional: Multiple workers + deploy: + replicas: 2 + + # Redis - separate container + redis: + image: redis:7-alpine + volumes: + - redis-data:/data + # Optional: persistent configuration + command: redis-server --appendonly yes + restart: unless-stopped + + # Your existing Postgres + postgres: + image: postgres:15 + environment: + - POSTGRES_DB=infoscreen + - POSTGRES_PASSWORD=password + volumes: + - postgres-data:/var/lib/postgresql/data + restart: unless-stopped + + # Optional: Redis Commander (UI for debugging) + redis-commander: + image: rediscommander/redis-commander + environment: + - REDIS_HOSTS=local:redis:6379 + ports: + - "8081:8081" + depends_on: + - redis + +volumes: + redis-data: + postgres-data: +``` + +## 5. Container Communication + +Containers communicate via **Docker's internal network**: + +```python +# In your API/Worker code: +import redis + +# Connection to Redis +redis_client = redis.from_url('redis://redis:6379') +# ^^^^^^ +# Container name = hostname in Docker network +``` + +Docker automatically creates DNS entries, so `redis` resolves to the Redis container. + +## 6. Client Behavior (Pi5) + +```python +# On the Pi5 client +def display_file(file_id): + response = api.get(f"/files/{file_id}/display") + + if response.content_type == 'application/pdf': + # PDF is ready + download_and_display(response) + subprocess.run(['impressive', downloaded_pdf]) + + elif response.json()['status'] in ['pending', 'processing']: + # Wait and retry + show_loading_screen("Presentation is being prepared...") + time.sleep(5) + display_file(file_id) # Retry + + else: + # Error + show_error_screen("Error loading presentation") +``` + +## 7. Additional Features + +### **Cache Invalidation on PPTX Update:** + +```python +@app.put("/files/{file_id}") +async def update_file(file_id, new_file): + # Delete old conversions + db.mark_conversions_as_obsolete(file_id) + + # Update file + update_media_file(file_id, new_file) + + # Trigger new conversion + trigger_conversion(file_id, 'pdf') +``` + +### **Status API for Monitoring:** + +```python +@app.get("/admin/conversions/status") +async def get_conversion_stats(): + return { + 'pending': db.count(status='pending'), + 'processing': db.count(status='processing'), + 'failed': db.count(status='failed'), + 'avg_duration_seconds': db.avg_duration() + } +``` + +### **Cleanup Job (Cronjob):** + +```python +def cleanup_old_conversions(): + # Remove PDFs from deleted files + db.delete_orphaned_conversions() + + # Clean up old failed conversions + db.delete_old_failed_conversions(older_than_days=7) +``` + +## 8. Redis Container Details + +### **Why Separate Container?** + +✅ **Separation of Concerns**: Each service has its own responsibility +✅ **Independent Lifecycle Management**: Redis can be restarted/updated independently +✅ **Better Scaling**: Redis can be moved to different hardware +✅ **Easier Backup**: Redis data can be backed up separately +✅ **Standard Docker Pattern**: Microservices architecture + +### **Resource Usage:** +- RAM: ~10-50 MB for your use case +- CPU: Minimal +- Disk: Only for persistence (optional) + +For 10 clients with occasional PPTX uploads, this is absolutely no problem. + +## 9. Advantages of This Solution + +✅ **Scalable**: Workers can be scaled horizontally +✅ **Performant**: Clients don't wait for conversion +✅ **Robust**: Status tracking and error handling +✅ **Maintainable**: Clear separation of responsibilities +✅ **Transparent**: Status queryable at any time +✅ **Efficient**: One-time conversion per file +✅ **Future-proof**: Easily extensible for other formats +✅ **Professional**: Industry-standard architecture + +## 10. Migration Path + +### **Phase 1 (MVP):** +- 1 worker process in API container +- Redis for queue (separate container) +- Basic DB schema +- Simple retry logic + +### **Phase 2 (as needed):** +- Multiple worker instances +- Dedicated conversion service container +- Monitoring & alerting +- Prioritization logic +- Advanced caching strategies + +**Start simple, scale when needed!** + +## 11. Key Decisions Summary + +| Aspect | Decision | Reason | +|--------|----------|--------| +| **Conversion Location** | Server-side | One conversion per file, consistent results | +| **Conversion Timing** | Asynchronous (on upload) | No client waiting time, predictable performance | +| **Data Storage** | Database-tracked | Status visibility, robust error handling | +| **Queue System** | Redis (separate container) | Standard pattern, scalable, maintainable | +| **Worker Architecture** | Background process in API container | Simple start, easy to separate later | + +## 12. File Flow Diagram + +``` +┌─────────────┐ +│ User Upload │ +│ (PPTX) │ +└──────┬──────┘ + │ + ▼ +┌──────────────────┐ +│ API Server │ +│ 1. Save PPTX │ +│ 2. Create DB rec │ +│ 3. Enqueue job │ +└──────┬───────────┘ + │ + ▼ +┌──────────────────┐ +│ Redis Queue │◄─────┐ +└──────┬───────────┘ │ + │ │ + ▼ │ +┌──────────────────┐ │ +│ Worker Process │ │ +│ 1. Get job │ │ +│ 2. Convert PPTX │ │ +│ 3. Update DB │ │ +└──────┬───────────┘ │ + │ │ + ▼ │ +┌──────────────────┐ │ +│ PDF Storage │ │ +└──────┬───────────┘ │ + │ │ + ▼ │ +┌──────────────────┐ │ +│ Client Requests │ │ +│ 1. Check DB │ │ +│ 2. Download PDF │ │ +│ 3. Display │──────┘ +└──────────────────┘ + (via impressive) +``` + +## 13. Implementation Checklist + +### Database Setup +- [ ] Create `media_files` table +- [ ] Create `conversions` table +- [ ] Add indexes for performance +- [ ] Set up foreign key constraints + +### API Changes +- [ ] Modify upload endpoint to create DB records +- [ ] Add conversion job enqueueing +- [ ] Implement file download endpoint with status checking +- [ ] Add status API for monitoring +- [ ] Implement cache invalidation on file update + +### Worker Setup +- [ ] Create worker script/module +- [ ] Implement LibreOffice conversion logic +- [ ] Add error handling and retry logic +- [ ] Set up logging and monitoring + +### Docker Configuration +- [ ] Add Redis container to docker-compose.yml +- [ ] Configure worker container +- [ ] Set up volume mounts for file storage +- [ ] Configure environment variables +- [ ] Set up container dependencies + +### Client Updates +- [ ] Modify client to check conversion status +- [ ] Implement retry logic for pending conversions +- [ ] Add loading/waiting screens +- [ ] Implement error handling + +### Testing +- [ ] Test upload → conversion → download flow +- [ ] Test multiple concurrent conversions +- [ ] Test error handling (corrupted PPTX, etc.) +- [ ] Test cache invalidation on file update +- [ ] Load test with multiple clients + +### Monitoring & Operations +- [ ] Set up logging for conversions +- [ ] Implement cleanup job for old files +- [ ] Add metrics for conversion times +- [ ] Set up alerts for failed conversions +- [ ] Document backup procedures + +--- + +**This architecture provides a solid foundation that's simple to start with but scales professionally as your needs grow!** \ No newline at end of file diff --git a/pptx_conversion_guide_gotenberg.md b/pptx_conversion_guide_gotenberg.md new file mode 100644 index 0000000..259e4bb --- /dev/null +++ b/pptx_conversion_guide_gotenberg.md @@ -0,0 +1,815 @@ +# Recommended Implementation: PPTX-to-PDF Conversion System with Gotenberg + +## Architecture Overview + +**Asynchronous server-side conversion using Gotenberg with shared storage** + +``` +User Upload → API saves PPTX → Job in Queue → Worker calls Gotenberg API + ↓ + Gotenberg converts via shared volume + ↓ +Client requests → API checks DB status → PDF ready? → Download PDF from shared storage + → Pending? → "Please wait" + → Failed? → Retry/Error +``` + +## 1. Database Schema + +```sql +CREATE TABLE media_files ( + id UUID PRIMARY KEY, + filename VARCHAR(255), + original_path VARCHAR(512), + file_type VARCHAR(10), + mime_type VARCHAR(100), + uploaded_at TIMESTAMP, + updated_at TIMESTAMP +); + +CREATE TABLE conversions ( + id UUID PRIMARY KEY, + source_file_id UUID REFERENCES media_files(id) ON DELETE CASCADE, + target_format VARCHAR(10), -- 'pdf' + target_path VARCHAR(512), -- Path to generated PDF + status VARCHAR(20), -- 'pending', 'processing', 'ready', 'failed' + started_at TIMESTAMP, + completed_at TIMESTAMP, + error_message TEXT, + file_hash VARCHAR(64) -- Hash of PPTX for cache invalidation +); + +CREATE INDEX idx_conversions_source ON conversions(source_file_id, target_format); +``` + +## 2. Components + +### **API Server (existing)** +- Accepts uploads +- Creates DB entries +- Enqueues jobs +- Delivers status and files + +### **Background Worker (new)** +- Runs as separate process in **same container** as API +- Processes conversion jobs from queue +- Calls Gotenberg API for conversion +- Updates database with results +- Technology: Python RQ, Celery, or similar + +### **Gotenberg Container (new)** +- Dedicated conversion service +- HTTP API for document conversion +- Handles LibreOffice conversions internally +- Accesses files via shared volume + +### **Message Queue** +- Redis (recommended for start - simple, fast) +- Alternative: RabbitMQ for more features + +### **Redis Container (separate)** +- Handles job queue +- Minimal resource footprint + +### **Shared Storage** +- Docker volume mounted to all containers that need file access +- API, Worker, and Gotenberg all access same files +- Simplifies file exchange between services + +## 3. Detailed Workflow + +### **Upload Process:** + +```python +@app.post("/upload") +async def upload_file(file): + # 1. Save PPTX to shared volume + file_path = save_to_disk(file) # e.g., /shared/uploads/abc123.pptx + + # 2. DB entry for original file + file_record = db.create_media_file({ + 'filename': file.filename, + 'original_path': file_path, + 'file_type': 'pptx' + }) + + # 3. Create conversion record + conversion = db.create_conversion({ + 'source_file_id': file_record.id, + 'target_format': 'pdf', + 'status': 'pending', + 'file_hash': calculate_hash(file_path) + }) + + # 4. Enqueue job (asynchronous!) + queue.enqueue(convert_to_pdf_via_gotenberg, conversion.id) + + # 5. Return immediately to user + return { + 'file_id': file_record.id, + 'status': 'uploaded', + 'conversion_status': 'pending' + } +``` + +### **Worker Process (calls Gotenberg):** + +```python +import requests +import os + +GOTENBERG_URL = os.getenv('GOTENBERG_URL', 'http://gotenberg:3000') + +def convert_to_pdf_via_gotenberg(conversion_id): + conversion = db.get_conversion(conversion_id) + source_file = db.get_media_file(conversion.source_file_id) + + # Status update: processing + db.update_conversion(conversion_id, { + 'status': 'processing', + 'started_at': now() + }) + + try: + # Prepare output path + pdf_filename = f"{conversion.id}.pdf" + pdf_path = f"/shared/converted/{pdf_filename}" + + # Call Gotenberg API + # Gotenberg accesses the file via shared volume + with open(source_file.original_path, 'rb') as f: + files = { + 'files': (os.path.basename(source_file.original_path), f) + } + + response = requests.post( + f'{GOTENBERG_URL}/forms/libreoffice/convert', + files=files, + timeout=300 # 5 minutes timeout + ) + response.raise_for_status() + + # Save PDF to shared volume + with open(pdf_path, 'wb') as pdf_file: + pdf_file.write(response.content) + + # Success + db.update_conversion(conversion_id, { + 'status': 'ready', + 'target_path': pdf_path, + 'completed_at': now() + }) + + except requests.exceptions.Timeout: + db.update_conversion(conversion_id, { + 'status': 'failed', + 'error_message': 'Conversion timeout after 5 minutes', + 'completed_at': now() + }) + except requests.exceptions.RequestException as e: + db.update_conversion(conversion_id, { + 'status': 'failed', + 'error_message': f'Gotenberg API error: {str(e)}', + 'completed_at': now() + }) + except Exception as e: + db.update_conversion(conversion_id, { + 'status': 'failed', + 'error_message': str(e), + 'completed_at': now() + }) +``` + +### **Alternative: Direct File Access via Shared Volume** + +If you prefer Gotenberg to read from shared storage directly (more efficient for large files): + +```python +def convert_to_pdf_via_gotenberg_shared(conversion_id): + conversion = db.get_conversion(conversion_id) + source_file = db.get_media_file(conversion.source_file_id) + + db.update_conversion(conversion_id, { + 'status': 'processing', + 'started_at': now() + }) + + try: + pdf_filename = f"{conversion.id}.pdf" + pdf_path = f"/shared/converted/{pdf_filename}" + + # Gotenberg reads directly from shared volume + # We just tell it where to find the file + with open(source_file.original_path, 'rb') as f: + files = {'files': f} + + response = requests.post( + f'{GOTENBERG_URL}/forms/libreoffice/convert', + files=files, + timeout=300 + ) + response.raise_for_status() + + # Write result to shared volume + with open(pdf_path, 'wb') as pdf_file: + pdf_file.write(response.content) + + db.update_conversion(conversion_id, { + 'status': 'ready', + 'target_path': pdf_path, + 'completed_at': now() + }) + + except Exception as e: + db.update_conversion(conversion_id, { + 'status': 'failed', + 'error_message': str(e), + 'completed_at': now() + }) +``` + +### **Client Download:** + +```python +@app.get("/files/{file_id}/display") +async def get_display_file(file_id): + file = db.get_media_file(file_id) + + # Only for PPTX: check PDF conversion + if file.file_type == 'pptx': + conversion = db.get_latest_conversion(file.id, target_format='pdf') + + if not conversion: + # Shouldn't happen, but just to be safe + trigger_new_conversion(file.id) + return {'status': 'pending', 'message': 'Conversion is being created'} + + if conversion.status == 'ready': + # Serve PDF from shared storage + return FileResponse(conversion.target_path) + + elif conversion.status == 'failed': + # Optional: Auto-retry + trigger_new_conversion(file.id) + return {'status': 'failed', 'error': conversion.error_message} + + else: # pending or processing + return {'status': conversion.status, 'message': 'Please wait...'} + + # Serve other file types directly + return FileResponse(file.original_path) +``` + +## 4. Docker Setup + +```yaml +version: '3.8' + +services: + # Your API Server + api: + build: ./api + command: uvicorn main:app --host 0.0.0.0 --port 8000 + ports: + - "8000:8000" + volumes: + - shared-storage:/shared # Shared volume + environment: + - REDIS_URL=redis://redis:6379 + - DATABASE_URL=postgresql://postgres:password@postgres:5432/infoscreen + - GOTENBERG_URL=http://gotenberg:3000 + depends_on: + - redis + - postgres + - gotenberg + restart: unless-stopped + + # Worker (same codebase as API, different command) + worker: + build: ./api # Same build as API! + command: python worker.py # or: rq worker + volumes: + - shared-storage:/shared # Shared volume + environment: + - REDIS_URL=redis://redis:6379 + - DATABASE_URL=postgresql://postgres:password@postgres:5432/infoscreen + - GOTENBERG_URL=http://gotenberg:3000 + depends_on: + - redis + - postgres + - gotenberg + restart: unless-stopped + # Optional: Multiple workers + deploy: + replicas: 2 + + # Gotenberg - Document Conversion Service + gotenberg: + image: gotenberg/gotenberg:8 + # Gotenberg doesn't need the shared volume if files are sent via HTTP + # But mount it if you want direct file access + volumes: + - shared-storage:/shared # Optional: for direct file access + environment: + # Gotenberg configuration + - GOTENBERG_API_TIMEOUT=300s + - GOTENBERG_LOG_LEVEL=info + restart: unless-stopped + # Resource limits (optional but recommended) + deploy: + resources: + limits: + cpus: '2.0' + memory: 2G + reservations: + cpus: '0.5' + memory: 512M + + # Redis - separate container + redis: + image: redis:7-alpine + volumes: + - redis-data:/data + command: redis-server --appendonly yes + restart: unless-stopped + + # Your existing Postgres + postgres: + image: postgres:15 + environment: + - POSTGRES_DB=infoscreen + - POSTGRES_PASSWORD=password + volumes: + - postgres-data:/var/lib/postgresql/data + restart: unless-stopped + + # Optional: Redis Commander (UI for debugging) + redis-commander: + image: rediscommander/redis-commander + environment: + - REDIS_HOSTS=local:redis:6379 + ports: + - "8081:8081" + depends_on: + - redis + +volumes: + shared-storage: # New: Shared storage for all file operations + redis-data: + postgres-data: +``` + +## 5. Storage Structure + +``` +/shared/ +├── uploads/ # Original uploaded files (PPTX, etc.) +│ ├── abc123.pptx +│ ├── def456.pptx +│ └── ... +└── converted/ # Converted PDF files + ├── uuid-1.pdf + ├── uuid-2.pdf + └── ... +``` + +## 6. Gotenberg Integration Details + +### **Gotenberg API Endpoints:** + +Gotenberg provides various conversion endpoints: + +```python +# LibreOffice conversion (for PPTX, DOCX, ODT, etc.) +POST http://gotenberg:3000/forms/libreoffice/convert + +# HTML to PDF +POST http://gotenberg:3000/forms/chromium/convert/html + +# Markdown to PDF +POST http://gotenberg:3000/forms/chromium/convert/markdown + +# Merge PDFs +POST http://gotenberg:3000/forms/pdfengines/merge +``` + +### **Example Conversion Request:** + +```python +import requests + +def convert_with_gotenberg(input_file_path, output_file_path): + """ + Convert document using Gotenberg + """ + with open(input_file_path, 'rb') as f: + files = { + 'files': (os.path.basename(input_file_path), f, + 'application/vnd.openxmlformats-officedocument.presentationml.presentation') + } + + # Optional: Add conversion parameters + data = { + 'landscape': 'false', # Portrait mode + 'nativePageRanges': '1-', # All pages + } + + response = requests.post( + 'http://gotenberg:3000/forms/libreoffice/convert', + files=files, + data=data, + timeout=300 + ) + + if response.status_code == 200: + with open(output_file_path, 'wb') as out: + out.write(response.content) + return True + else: + raise Exception(f"Gotenberg error: {response.status_code} - {response.text}") +``` + +### **Advanced Options:** + +```python +# With custom PDF properties +data = { + 'landscape': 'false', + 'nativePageRanges': '1-10', # Only first 10 pages + 'pdfFormat': 'PDF/A-1a', # PDF/A format + 'exportFormFields': 'false', +} + +# With password protection +data = { + 'userPassword': 'secret123', + 'ownerPassword': 'admin456', +} +``` + +## 7. Client Behavior (Pi5) + +```python +# On the Pi5 client +def display_file(file_id): + response = api.get(f"/files/{file_id}/display") + + if response.content_type == 'application/pdf': + # PDF is ready + download_and_display(response) + subprocess.run(['impressive', downloaded_pdf]) + + elif response.json()['status'] in ['pending', 'processing']: + # Wait and retry + show_loading_screen("Presentation is being prepared...") + time.sleep(5) + display_file(file_id) # Retry + + else: + # Error + show_error_screen("Error loading presentation") +``` + +## 8. Additional Features + +### **Cache Invalidation on PPTX Update:** + +```python +@app.put("/files/{file_id}") +async def update_file(file_id, new_file): + # Delete old conversions and PDFs + conversions = db.get_conversions_for_file(file_id) + for conv in conversions: + if conv.target_path and os.path.exists(conv.target_path): + os.remove(conv.target_path) + + db.mark_conversions_as_obsolete(file_id) + + # Update file + update_media_file(file_id, new_file) + + # Trigger new conversion + trigger_conversion(file_id, 'pdf') +``` + +### **Status API for Monitoring:** + +```python +@app.get("/admin/conversions/status") +async def get_conversion_stats(): + return { + 'pending': db.count(status='pending'), + 'processing': db.count(status='processing'), + 'failed': db.count(status='failed'), + 'avg_duration_seconds': db.avg_duration(), + 'gotenberg_health': check_gotenberg_health() + } + +def check_gotenberg_health(): + try: + response = requests.get( + f'{GOTENBERG_URL}/health', + timeout=5 + ) + return response.status_code == 200 + except: + return False +``` + +### **Cleanup Job (Cronjob):** + +```python +def cleanup_old_conversions(): + # Remove PDFs from deleted files + orphaned = db.get_orphaned_conversions() + for conv in orphaned: + if conv.target_path and os.path.exists(conv.target_path): + os.remove(conv.target_path) + db.delete_conversion(conv.id) + + # Clean up old failed conversions + old_failed = db.get_old_failed_conversions(older_than_days=7) + for conv in old_failed: + db.delete_conversion(conv.id) +``` + +## 9. Advantages of Using Gotenberg + +✅ **Specialized Service**: Optimized specifically for document conversion +✅ **No LibreOffice Management**: Gotenberg handles LibreOffice lifecycle internally +✅ **Better Resource Management**: Isolated conversion process +✅ **HTTP API**: Clean, standard interface +✅ **Production Ready**: Battle-tested, actively maintained +✅ **Multiple Formats**: Supports PPTX, DOCX, ODT, HTML, Markdown, etc. +✅ **PDF Features**: Merge, encrypt, watermark PDFs +✅ **Health Checks**: Built-in health endpoint +✅ **Horizontal Scaling**: Can run multiple Gotenberg instances +✅ **Memory Safe**: Automatic cleanup and restart on issues + +## 10. Migration Path + +### **Phase 1 (MVP):** +- 1 worker process in API container +- Redis for queue (separate container) +- Gotenberg for conversion (separate container) +- Basic DB schema +- Shared volume for file exchange +- Simple retry logic + +### **Phase 2 (as needed):** +- Multiple worker instances +- Multiple Gotenberg instances (load balancing) +- Monitoring & alerting +- Prioritization logic +- Advanced caching strategies +- PDF optimization/compression + +**Start simple, scale when needed!** + +## 11. Key Decisions Summary + +| Aspect | Decision | Reason | +|--------|----------|--------| +| **Conversion Location** | Server-side (Gotenberg) | One conversion per file, consistent results | +| **Conversion Service** | Dedicated Gotenberg container | Specialized, production-ready, better isolation | +| **Conversion Timing** | Asynchronous (on upload) | No client waiting time, predictable performance | +| **Data Storage** | Database-tracked | Status visibility, robust error handling | +| **File Exchange** | Shared Docker volume | Simple, efficient, no network overhead | +| **Queue System** | Redis (separate container) | Standard pattern, scalable, maintainable | +| **Worker Architecture** | Background process in API container | Simple start, easy to separate later | + +## 12. File Flow Diagram + +``` +┌─────────────┐ +│ User Upload │ +│ (PPTX) │ +└──────┬──────┘ + │ + ▼ +┌──────────────────────┐ +│ API Server │ +│ 1. Save to /shared │ +│ 2. Create DB record │ +│ 3. Enqueue job │ +└──────┬───────────────┘ + │ + ▼ +┌──────────────────┐ +│ Redis Queue │ +└──────┬───────────┘ + │ + ▼ +┌──────────────────────┐ +│ Worker Process │ +│ 1. Get job │ +│ 2. Call Gotenberg │ +│ 3. Update DB │ +└──────┬───────────────┘ + │ + ▼ +┌──────────────────────┐ +│ Gotenberg │ +│ 1. Read from /shared │ +│ 2. Convert PPTX │ +│ 3. Return PDF │ +└──────┬───────────────┘ + │ + ▼ +┌──────────────────────┐ +│ Worker saves PDF │ +│ to /shared/converted│ +└──────┬───────────────┘ + │ + ▼ +┌──────────────────────┐ +│ Client Requests │ +│ 1. Check DB │ +│ 2. Download PDF │ +│ 3. Display │ +└──────────────────────┘ + (via impressive) +``` + +## 13. Implementation Checklist + +### Database Setup +- [ ] Create `media_files` table +- [ ] Create `conversions` table +- [ ] Add indexes for performance +- [ ] Set up foreign key constraints + +### Storage Setup +- [ ] Create shared Docker volume +- [ ] Set up directory structure (/shared/uploads, /shared/converted) +- [ ] Configure proper permissions + +### API Changes +- [ ] Modify upload endpoint to save to shared storage +- [ ] Create DB records for uploads +- [ ] Add conversion job enqueueing +- [ ] Implement file download endpoint with status checking +- [ ] Add status API for monitoring +- [ ] Implement cache invalidation on file update + +### Worker Setup +- [ ] Create worker script/module +- [ ] Implement Gotenberg API calls +- [ ] Add error handling and retry logic +- [ ] Set up logging and monitoring +- [ ] Handle timeouts and failures + +### Docker Configuration +- [ ] Add Gotenberg container to docker-compose.yml +- [ ] Add Redis container to docker-compose.yml +- [ ] Configure worker container +- [ ] Set up shared volume mounts +- [ ] Configure environment variables +- [ ] Set up container dependencies +- [ ] Configure resource limits for Gotenberg + +### Client Updates +- [ ] Modify client to check conversion status +- [ ] Implement retry logic for pending conversions +- [ ] Add loading/waiting screens +- [ ] Implement error handling + +### Testing +- [ ] Test upload → conversion → download flow +- [ ] Test multiple concurrent conversions +- [ ] Test error handling (corrupted PPTX, etc.) +- [ ] Test Gotenberg timeout handling +- [ ] Test cache invalidation on file update +- [ ] Load test with multiple clients +- [ ] Test Gotenberg health checks + +### Monitoring & Operations +- [ ] Set up logging for conversions +- [ ] Monitor Gotenberg health endpoint +- [ ] Implement cleanup job for old files +- [ ] Add metrics for conversion times +- [ ] Set up alerts for failed conversions +- [ ] Monitor shared storage disk usage +- [ ] Document backup procedures + +### Security +- [ ] Validate file types before conversion +- [ ] Set file size limits +- [ ] Sanitize filenames +- [ ] Implement rate limiting +- [ ] Secure inter-container communication + +## 14. Gotenberg Configuration Options + +### **Environment Variables:** + +```yaml +gotenberg: + image: gotenberg/gotenberg:8 + environment: + # API Configuration + - GOTENBERG_API_TIMEOUT=300s + - GOTENBERG_API_PORT=3000 + + # Logging + - GOTENBERG_LOG_LEVEL=info # debug, info, warn, error + + # LibreOffice + - GOTENBERG_LIBREOFFICE_DISABLE_ROUTES=false + - GOTENBERG_LIBREOFFICE_AUTO_START=true + + # Chromium (if needed for HTML/Markdown) + - GOTENBERG_CHROMIUM_DISABLE_ROUTES=true # Disable if not needed + + # Resource limits + - GOTENBERG_LIBREOFFICE_MAX_QUEUE_SIZE=100 +``` + +### **Custom Gotenberg Configuration:** + +For advanced configurations, create a `gotenberg.yml`: + +```yaml +api: + timeout: 300s + port: 3000 + +libreoffice: + autoStart: true + maxQueueSize: 100 + +chromium: + disableRoutes: true +``` + +Mount it in docker-compose: + +```yaml +gotenberg: + image: gotenberg/gotenberg:8 + volumes: + - ./gotenberg.yml:/etc/gotenberg/config.yml:ro + - shared-storage:/shared +``` + +## 15. Troubleshooting + +### **Common Issues:** + +**Gotenberg timeout:** +```python +# Increase timeout for large files +response = requests.post( + f'{GOTENBERG_URL}/forms/libreoffice/convert', + files=files, + timeout=600 # 10 minutes for large PPTX +) +``` + +**Memory issues:** +```yaml +# Increase Gotenberg memory limit +gotenberg: + deploy: + resources: + limits: + memory: 4G +``` + +**File permission issues:** +```bash +# Ensure proper permissions on shared volume +chmod -R 755 /shared +chown -R 1000:1000 /shared +``` + +**Gotenberg not responding:** +```python +# Check health before conversion +def ensure_gotenberg_healthy(): + try: + response = requests.get(f'{GOTENBERG_URL}/health', timeout=5) + if response.status_code != 200: + raise Exception("Gotenberg unhealthy") + except Exception as e: + logger.error(f"Gotenberg health check failed: {e}") + raise +``` + +--- + +**This architecture provides a production-ready, scalable solution using Gotenberg as a specialized conversion service with efficient file sharing via Docker volumes!** + +## 16. Best Practices Specific to Infoscreen + +- Idempotency by content: Always compute a SHA‑256 of the uploaded source and include it in the unique key (source_event_media_id, target_format, file_hash). This prevents duplicate work for identical content and auto-busts cache on change. +- Strict MIME/type validation: Accept only .ppt, .pptx, .odp for conversion. Reject unknown types early. Consider reading the first bytes (magic) for extra safety. +- Bounded retries with jitter: Retry conversions on transient HTTP 5xx or timeouts up to N times with exponential backoff. Do not retry on 4xx or clear user errors. +- Output naming: Derive deterministic output paths under media/converted/, e.g., .pdf. Ensure no path traversal and sanitize names. +- Timeouts and size limits: Enforce server-side max upload size and per-job conversion timeout (e.g., 10 minutes). Return clear errors for oversized/long-running files. +- Isolation and quotas: Set CPU/memory limits for Gotenberg; consider a concurrency cap per worker to avoid DB starvation. +- Health probes before work: Check Gotenberg /health prior to enqueue spikes; fail-fast to avoid queue pile-ups when Gotenberg is down. +- Observability: Log job IDs, file hashes, durations, and sizes. Expose a small /api/conversions/status summary for operational visibility. +- Cleanup policy: Periodically delete orphaned conversions (media deleted) and failed jobs older than X days. Keep successful PDFs aligned with DB rows. +- Security: Never trust client paths; always resolve relative to the known media root. Do not expose the shared volume directly; serve via API only. +- Backpressure: If queue length exceeds a threshold, surface 503/“try later” on new uploads or pause enqueue to protect the system. diff --git a/scheduler/Dockerfile b/scheduler/Dockerfile new file mode 100644 index 0000000..605f7fb --- /dev/null +++ b/scheduler/Dockerfile @@ -0,0 +1,8 @@ +FROM python:3.13-slim +WORKDIR /app +COPY scheduler/requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY scheduler/ ./scheduler +COPY models/ ./models +ENV PYTHONPATH=/app +CMD ["python", "-m", "scheduler.scheduler"] diff --git a/scheduler/db_utils.py b/scheduler/db_utils.py new file mode 100644 index 0000000..089428e --- /dev/null +++ b/scheduler/db_utils.py @@ -0,0 +1,113 @@ +# scheduler/db_utils.py +from dotenv import load_dotenv +import os +from datetime import datetime +from sqlalchemy.orm import sessionmaker, joinedload +from sqlalchemy import create_engine +from models.models import Event, EventMedia + +load_dotenv('/workspace/.env') + +# DB-URL aus Umgebungsvariable oder Fallback +DB_CONN = os.environ.get("DB_CONN", "mysql+pymysql://user:password@db/dbname") +engine = create_engine(DB_CONN) +Session = sessionmaker(bind=engine) + +# Base URL from .env for file URLs +API_BASE_URL = os.environ.get("API_BASE_URL", "http://server:8000") + + +def get_active_events(start: datetime, end: datetime, group_id: int = None): + session = Session() + try: + # Now this will work with the relationship defined + query = session.query(Event).options( + joinedload(Event.event_media) + ).filter(Event.is_active == True) + + if start and end: + query = query.filter(Event.start < end, Event.end > start) + if group_id: + query = query.filter(Event.group_id == group_id) + + events = query.all() + + formatted_events = [] + for event in events: + formatted_event = format_event_with_media(event) + formatted_events.append(formatted_event) + + return formatted_events + finally: + session.close() + + +def format_event_with_media(event): + """Transform Event + EventMedia into client-expected format""" + event_dict = { + "id": event.id, + "title": event.title, + "start": str(event.start), + "end": str(event.end), + "group_id": event.group_id, + } + + # Now you can directly access event.event_media + import logging + if event.event_media: + media = event.event_media + + if event.event_type.value == "presentation": + event_dict["presentation"] = { + "type": "slideshow", + "files": [], + "slide_interval": event.slideshow_interval or 5000, + "auto_advance": True + } + + # Debug: log media_type + logging.debug( + f"[Scheduler] EventMedia id={media.id} media_type={getattr(media.media_type, 'value', str(media.media_type))}") + + # Check for PDF conversion for ppt/pptx/odp + from sqlalchemy.orm import scoped_session + from models.models import Conversion, ConversionStatus + session = scoped_session(Session) + pdf_url = None + if getattr(media.media_type, 'value', str(media.media_type)) in ("ppt", "pptx", "odp"): + conversion = session.query(Conversion).filter_by( + source_event_media_id=media.id, + target_format="pdf", + status=ConversionStatus.ready + ).order_by(Conversion.completed_at.desc()).first() + logging.debug( + f"[Scheduler] Conversion lookup for media_id={media.id}: found={bool(conversion)}, path={getattr(conversion, 'target_path', None) if conversion else None}") + if conversion and conversion.target_path: + # Serve via /api/files/converted/ + pdf_url = f"{API_BASE_URL}/api/files/converted/{conversion.target_path}" + session.remove() + + if pdf_url: + filename = os.path.basename(pdf_url) + event_dict["presentation"]["files"].append({ + "name": filename, + "url": pdf_url, + "checksum": None, + "size": None + }) + logging.info( + f"[Scheduler] Using converted PDF for event_media_id={media.id}: {pdf_url}") + elif media.file_path: + filename = os.path.basename(media.file_path) + event_dict["presentation"]["files"].append({ + "name": filename, + "url": f"{API_BASE_URL}/api/files/{media.id}/{filename}", + "checksum": None, + "size": None + }) + logging.info( + f"[Scheduler] Using original file for event_media_id={media.id}: {filename}") + + # Add other event types... + + return event_dict diff --git a/scheduler/requirements.txt b/scheduler/requirements.txt new file mode 100644 index 0000000..f866e8a --- /dev/null +++ b/scheduler/requirements.txt @@ -0,0 +1,4 @@ +paho-mqtt +sqlalchemy +pymysql +python-dotenv diff --git a/scheduler/scheduler.py b/scheduler/scheduler.py new file mode 100644 index 0000000..2c83317 --- /dev/null +++ b/scheduler/scheduler.py @@ -0,0 +1,112 @@ +# scheduler/scheduler.py + +import os +import logging +from .db_utils import get_active_events +import paho.mqtt.client as mqtt +import json +import datetime +import time + +# Logging-Konfiguration +ENV = os.getenv("ENV", "development") +LOG_LEVEL = os.getenv("LOG_LEVEL", "DEBUG" if ENV == "development" else "INFO") +LOG_PATH = os.path.join(os.path.dirname(__file__), "scheduler.log") +os.makedirs(os.path.dirname(LOG_PATH), exist_ok=True) +log_handlers = [] +if ENV == "production": + from logging.handlers import RotatingFileHandler + log_handlers.append(RotatingFileHandler( + LOG_PATH, maxBytes=2*1024*1024, backupCount=5, encoding="utf-8")) +else: + log_handlers.append(logging.FileHandler(LOG_PATH, encoding="utf-8")) +if os.getenv("DEBUG_MODE", "1" if ENV == "development" else "0") in ("1", "true", "True"): + log_handlers.append(logging.StreamHandler()) +logging.basicConfig( + level=getattr(logging, LOG_LEVEL.upper(), logging.INFO), + format="%(asctime)s [%(levelname)s] %(message)s", + handlers=log_handlers +) + + +def main(): + # Fix für die veraltete API - explizit callback_api_version setzen + client = mqtt.Client(callback_api_version=mqtt.CallbackAPIVersion.VERSION2) + client.reconnect_delay_set(min_delay=1, max_delay=30) + + POLL_INTERVAL = 30 # Sekunden, Empfehlung für seltene Änderungen + # 0 = aus; z.B. 600 für alle 10 Min + REFRESH_SECONDS = int(os.getenv("REFRESH_SECONDS", "0")) + last_payloads = {} # group_id -> payload + last_published_at = {} # group_id -> epoch seconds + + # Beim (Re-)Connect alle bekannten retained Payloads erneut senden + def on_connect(client, userdata, flags, reasonCode, properties=None): + logging.info( + f"MQTT connected (reasonCode={reasonCode}) - republishing {len(last_payloads)} groups") + for gid, payload in last_payloads.items(): + topic = f"infoscreen/events/{gid}" + client.publish(topic, payload, retain=True) + + client.on_connect = on_connect + + client.connect("mqtt", 1883) + client.loop_start() + + while True: + now = datetime.datetime.now(datetime.timezone.utc) + # Hole alle aktiven Events (bereits formatierte Dictionaries) + events = get_active_events(now, now) + + # Gruppiere Events nach group_id + groups = {} + for event in events: + gid = event.get("group_id") + if gid not in groups: + groups[gid] = [] + # Event ist bereits ein Dictionary im gewünschten Format + groups[gid].append(event) + + # Sende pro Gruppe die Eventliste als retained Message, nur bei Änderung + for gid, event_list in groups.items(): + # stabile Reihenfolge, um unnötige Publishes zu vermeiden + event_list.sort(key=lambda e: (e.get("start"), e.get("id"))) + payload = json.dumps( + event_list, sort_keys=True, separators=(",", ":")) + topic = f"infoscreen/events/{gid}" + + should_send = (last_payloads.get(gid) != payload) + if not should_send and REFRESH_SECONDS: + last_ts = last_published_at.get(gid, 0) + if time.time() - last_ts >= REFRESH_SECONDS: + should_send = True + + if should_send: + result = client.publish(topic, payload, retain=True) + if result.rc != mqtt.MQTT_ERR_SUCCESS: + logging.error( + f"Fehler beim Publish für Gruppe {gid}: {mqtt.error_string(result.rc)}") + else: + logging.info(f"Events für Gruppe {gid} gesendet") + last_payloads[gid] = payload + last_published_at[gid] = time.time() + + # Entferne Gruppen, die nicht mehr existieren (leere retained Message senden) + for gid in list(last_payloads.keys()): + if gid not in groups: + topic = f"infoscreen/events/{gid}" + result = client.publish(topic, payload="[]", retain=True) + if result.rc != mqtt.MQTT_ERR_SUCCESS: + logging.error( + f"Fehler beim Entfernen für Gruppe {gid}: {mqtt.error_string(result.rc)}") + else: + logging.info( + f"Events für Gruppe {gid} entfernt (leere retained Message gesendet)") + del last_payloads[gid] + last_published_at.pop(gid, None) + + time.sleep(POLL_INTERVAL) + + +if __name__ == "__main__": + main() diff --git a/server-setup-script.sh b/server-setup-script.sh new file mode 100755 index 0000000..48867e1 --- /dev/null +++ b/server-setup-script.sh @@ -0,0 +1,58 @@ +#!/bin/bash +# Ubuntu Server Setup Script for Infoscreen Development + +set -e + +echo "🚀 Setting up Ubuntu Server for Infoscreen Development..." + +# Update system +sudo apt update && sudo apt upgrade -y + +# Install essential tools +sudo apt install -y git curl wget vim nano htop + +# Install Docker (official installation method) +echo "📦 Installing Docker..." + +# Remove old Docker packages +sudo apt remove -y docker docker-engine docker.io containerd runc 2>/dev/null || true + +# Install Docker dependencies +sudo apt install -y ca-certificates curl gnupg lsb-release + +# Add Docker GPG key +sudo mkdir -p /etc/apt/keyrings +curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg + +# Add Docker repository +echo \ + "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \ + $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null + +# Install Docker +sudo apt update +sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin + +# Start and enable Docker +sudo systemctl enable docker +sudo systemctl start docker + +# Add user to docker group +sudo usermod -aG docker $USER + +# Install Node.js (for dashboard development) +echo "📦 Installing Node.js..." +curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash - +sudo apt install -y nodejs + +# Verify installations +echo "✅ Verifying installations..." +echo "Docker version: $(docker --version)" +echo "Docker Compose version: $(docker compose version)" +echo "Node.js version: $(node --version)" +echo "npm version: $(npm --version)" + +echo "" +echo "🎉 Server setup complete!" +echo "⚠️ Please log out and log back in for Docker group changes to take effect." +echo "💡 You can now clone your repository and start development." diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 0000000..d4a2199 --- /dev/null +++ b/server/Dockerfile @@ -0,0 +1,61 @@ +# server/Dockerfile +# 🔧 OPTIMIERT: Multi-Stage-Build für ein minimales und sicheres Produktions-Image + +# Stage 1: Builder - Installiert Abhängigkeiten +FROM python:3.13-slim AS builder + +WORKDIR /app + +# Installiert nur die für den Build notwendigen Systemabhängigkeiten +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + libmariadb-dev-compat libmariadb-dev gcc \ + && rm -rf /var/lib/apt/lists/* + +# Kopiert nur die requirements.txt, um den Docker-Cache optimal zu nutzen +COPY /server/requirements.txt . + +# Installiert die Python-Pakete in ein separates Verzeichnis +RUN pip install --no-cache-dir --prefix="/install" -r requirements.txt + +# Stage 2: Final - Das eigentliche Produktions-Image +FROM python:3.13-slim + +# --- Arbeitsverzeichnis --- +WORKDIR /app + +# Installiert nur die für die Laufzeit notwendigen Systemabhängigkeiten +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + libmariadb-dev-compat locales curl \ + && rm -rf /var/lib/apt/lists/* + +# --- Locale konfigurieren --- +RUN sed -i 's/# de_DE.UTF-8 UTF-8/de_DE.UTF-8 UTF-8/' /etc/locale.gen \ + && locale-gen +ENV LANG=de_DE.UTF-8 \ + LC_ALL=de_DE.UTF-8 + +# Kopiert die installierten Pakete aus der Builder-Stage +COPY --from=builder /install /usr/local + +# --- Applikationscode --- +# Kopiert den Server-Code in das Arbeitsverzeichnis +COPY server/ ./server +COPY models/ ./models + +# --- Non-Root User anlegen und Rechte setzen --- +ARG USER_ID=1000 +ARG GROUP_ID=1000 +RUN groupadd -g ${GROUP_ID} infoscreen_taa \ + && useradd -u ${USER_ID} -g ${GROUP_ID} \ + --shell /bin/bash --create-home infoscreen_taa \ + && chown -R infoscreen_taa:infoscreen_taa /app +USER infoscreen_taa + +# --- Port für die API exposed --- +EXPOSE 8000 + +# --- Startbefehl für Gunicorn --- +CMD ["gunicorn", "-w", "4", "-b", "0.0.0.0:8000", "server.wsgi:app"] + diff --git a/server/Dockerfile.dev b/server/Dockerfile.dev new file mode 100644 index 0000000..0c7220f --- /dev/null +++ b/server/Dockerfile.dev @@ -0,0 +1,42 @@ +# Datei: server/Dockerfile.dev +# 🔧 OPTIMIERT: Für die Entwicklung im Dev-Container +# ========================================== + +FROM python:3.13-slim + +# Die Erstellung des non-root Users und die Locale-Konfiguration +# sind für den Dev-Container nicht zwingend nötig, da VS Code sich als 'root' +# verbindet (gemäß devcontainer.json). Sie schaden aber nicht. +ARG USER_ID=1000 +ARG GROUP_ID=1000 +RUN apt-get update && apt-get install -y --no-install-recommends locales curl git \ + && groupadd -g ${GROUP_ID} infoscreen_taa \ + && useradd -u ${USER_ID} -g ${GROUP_ID} --shell /bin/bash --create-home infoscreen_taa \ + && sed -i 's/# de_DE.UTF-8 UTF-8/de_DE.UTF-8 UTF-8/' /etc/locale.gen \ + && locale-gen \ + && rm -rf /var/lib/apt/lists/* + +ENV LANG=de_DE.UTF-8 \ + LC_ALL=de_DE.UTF-8 + +# Setze das Arbeitsverzeichnis auf den Workspace-Root, passend zu den Mounts. +WORKDIR /app + +# Kopiere die Anforderungsdateien in das korrekte Unterverzeichnis. +# ✅ KORRIGIERT: Pfade sind jetzt relativ zum Build-Kontext (dem 'server'-Verzeichnis) +COPY server/requirements.txt server/requirements-dev.txt ./server/ + +# Installiere die Python-Abhängigkeiten +RUN pip install --upgrade pip \ + && pip install --no-cache-dir -r server/requirements.txt \ + && pip install --no-cache-dir -r server/requirements-dev.txt + +# Das Kopieren des Codes ist nicht nötig, da das Verzeichnis gemountet wird. + +# Exponiere die Ports für die Flask API und den Debugger +EXPOSE 8000 5678 + +# Der Startbefehl wird in der docker-compose.override.yml definiert. +# Ein Standard-CMD dient als Fallback. +CMD ["python", "-m", "debugpy", "--listen", "0.0.0.0:5678", "-m", "flask", "run", "--host=0.0.0.0", "--port=8000"] + diff --git a/server/__init__.py b/server/__init__.py new file mode 100644 index 0000000..793d47d --- /dev/null +++ b/server/__init__.py @@ -0,0 +1,8 @@ +"""Server package initializer. + +Expose submodules required by external importers (e.g., RQ string paths). +""" + +# Ensure 'server.worker' is available as an attribute of the 'server' package +# so that RQ can resolve 'server.worker.convert_event_media_to_pdf'. +from . import worker # noqa: F401 diff --git a/server/alembic.ini b/server/alembic.ini new file mode 100644 index 0000000..209ea08 --- /dev/null +++ b/server/alembic.ini @@ -0,0 +1,141 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts. +# this is typically a path given in POSIX (e.g. forward slashes) +# format, relative to the token %(here)s which refers to the location of this +# ini file +script_location = %(here)s/alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. for multiple paths, the path separator +# is defined by "path_separator" below. +prepend_sys_path = . + + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library. +# Any required deps can installed by adding `alembic[tz]` to the pip requirements +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to /versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "path_separator" +# below. +# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions + +# path_separator; This indicates what character is used to split lists of file +# paths, including version_locations and prepend_sys_path within configparser +# files such as alembic.ini. +# The default rendered in new alembic.ini files is "os", which uses os.pathsep +# to provide os-dependent path splitting. +# +# Note that in order to support legacy alembic.ini files, this default does NOT +# take place if path_separator is not present in alembic.ini. If this +# option is omitted entirely, fallback logic is as follows: +# +# 1. Parsing of the version_locations option falls back to using the legacy +# "version_path_separator" key, which if absent then falls back to the legacy +# behavior of splitting on spaces and/or commas. +# 2. Parsing of the prepend_sys_path option falls back to the legacy +# behavior of splitting on spaces, commas, or colons. +# +# Valid values for path_separator are: +# +# path_separator = : +# path_separator = ; +# path_separator = space +# path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +path_separator = os + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# database URL. This is consumed by the user-maintained env.py script only. +# other means of configuring database URLs may be customized within the env.py +# file. +# sqlalchemy.url = driver://user:pass@localhost/dbname + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the exec runner, execute a binary +# hooks = ruff +# ruff.type = exec +# ruff.executable = %(here)s/.venv/bin/ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Logging configuration. This is also consumed by the user-maintained +# env.py script only. +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/server/alembic/README b/server/alembic/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/server/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/server/alembic/env.py b/server/alembic/env.py new file mode 100644 index 0000000..c17b9f6 --- /dev/null +++ b/server/alembic/env.py @@ -0,0 +1,115 @@ +# isort: skip_file +from alembic import context +from sqlalchemy import pool +from sqlalchemy import engine_from_config +from logging.config import fileConfig +from dotenv import load_dotenv +from models.models import Base +import os +import sys +sys.path.insert(0, '/workspace') +print("sys.path:", sys.path) +print("models dir exists:", os.path.isdir('/workspace/models')) +print("models/models.py exists:", os.path.isfile('/workspace/models/models.py')) +print("models/__init__.py exists:", + os.path.isfile('/workspace/models/__init__.py')) + +print("sys.path:", sys.path) +print("models dir exists:", os.path.isdir('/workspace/models')) +print("models/models.py exists:", os.path.isfile('/workspace/models/models.py')) +print("models/__init__.py exists:", + os.path.isfile('/workspace/models/__init__.py')) + +# .env laden (optional) +env_path = os.path.abspath(os.path.join( + os.path.dirname(__file__), '../../.env')) +print(f"Loading environment variables from: {env_path}") +load_dotenv(env_path) + +DB_CONN = os.getenv("DB_CONN") +if DB_CONN: + DATABASE_URL = DB_CONN +else: + # Datenbank-Zugangsdaten aus .env + DB_USER = os.getenv("DB_USER") + DB_PASSWORD = os.getenv("DB_PASSWORD") + DB_HOST = os.getenv("DB_HOST", "db") # Default jetzt 'db' + DB_PORT = os.getenv("DB_PORT", "3306") + DB_NAME = os.getenv("DB_NAME") + DATABASE_URL = f"mysql+pymysql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}" + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +print(f"Using DATABASE_URL: {DATABASE_URL}") + +config.set_main_option("sqlalchemy.url", DATABASE_URL) + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +target_metadata = Base.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, target_metadata=target_metadata + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/server/alembic/script.py.mako b/server/alembic/script.py.mako new file mode 100644 index 0000000..480b130 --- /dev/null +++ b/server/alembic/script.py.mako @@ -0,0 +1,28 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/server/alembic/versions/0c47280d3e2d_rename_location_to_description_in_.py b/server/alembic/versions/0c47280d3e2d_rename_location_to_description_in_.py new file mode 100644 index 0000000..0541ee0 --- /dev/null +++ b/server/alembic/versions/0c47280d3e2d_rename_location_to_description_in_.py @@ -0,0 +1,36 @@ +"""Rename location to description in client_groups, add description to clients + +Revision ID: 0c47280d3e2d +Revises: 3a09ef909689 +Create Date: 2025-07-16 08:47:00.355445 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision: str = '0c47280d3e2d' +down_revision: Union[str, None] = '3a09ef909689' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('client_groups', sa.Column('description', sa.String(length=255), nullable=True)) + op.drop_column('client_groups', 'location') + op.add_column('clients', sa.Column('description', sa.String(length=255), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('clients', 'description') + op.add_column('client_groups', sa.Column('location', mysql.VARCHAR(length=100), nullable=True)) + op.drop_column('client_groups', 'description') + # ### end Alembic commands ### diff --git a/server/alembic/versions/207f5b190f93_update_clients_table_for_new_fields.py b/server/alembic/versions/207f5b190f93_update_clients_table_for_new_fields.py new file mode 100644 index 0000000..e5e58da --- /dev/null +++ b/server/alembic/versions/207f5b190f93_update_clients_table_for_new_fields.py @@ -0,0 +1,56 @@ +"""Update clients table for new fields + +Revision ID: 207f5b190f93 +Revises: 3d15c3cac7b6 +Create Date: 2025-07-15 14:12:42.427274 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision: str = '207f5b190f93' +down_revision: Union[str, None] = '3d15c3cac7b6' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('clients', sa.Column('hardware_token', sa.String(length=64), nullable=True)) + op.add_column('clients', sa.Column('ip', sa.String(length=45), nullable=True)) + op.add_column('clients', sa.Column('type', sa.String(length=50), nullable=True)) + op.add_column('clients', sa.Column('hostname', sa.String(length=100), nullable=True)) + op.add_column('clients', sa.Column('os_version', sa.String(length=100), nullable=True)) + op.add_column('clients', sa.Column('software_version', sa.String(length=100), nullable=True)) + op.add_column('clients', sa.Column('macs', sa.String(length=255), nullable=True)) + op.add_column('clients', sa.Column('model', sa.String(length=100), nullable=True)) + op.drop_index(op.f('ix_clients_hardware_hash'), table_name='clients') + op.drop_index(op.f('ix_clients_ip_address'), table_name='clients') + op.drop_column('clients', 'location') + op.drop_column('clients', 'hardware_hash') + op.drop_column('clients', 'ip_address') + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('clients', sa.Column('ip_address', mysql.VARCHAR(length=45), nullable=True)) + op.add_column('clients', sa.Column('hardware_hash', mysql.VARCHAR(length=64), nullable=False)) + op.add_column('clients', sa.Column('location', mysql.VARCHAR(length=100), nullable=True)) + op.create_index(op.f('ix_clients_ip_address'), 'clients', ['ip_address'], unique=False) + op.create_index(op.f('ix_clients_hardware_hash'), 'clients', ['hardware_hash'], unique=False) + op.drop_column('clients', 'model') + op.drop_column('clients', 'macs') + op.drop_column('clients', 'software_version') + op.drop_column('clients', 'os_version') + op.drop_column('clients', 'hostname') + op.drop_column('clients', 'type') + op.drop_column('clients', 'ip') + op.drop_column('clients', 'hardware_token') + # ### end Alembic commands ### diff --git a/server/alembic/versions/216402147826_change_uploaded_at_to_timestamp_in_.py b/server/alembic/versions/216402147826_change_uploaded_at_to_timestamp_in_.py new file mode 100644 index 0000000..51be253 --- /dev/null +++ b/server/alembic/versions/216402147826_change_uploaded_at_to_timestamp_in_.py @@ -0,0 +1,38 @@ +"""Change uploaded_at to TIMESTAMP in EventMedia + +Revision ID: 216402147826 +Revises: b22d339ed2af +Create Date: 2025-09-01 10:22:55.285710 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision: str = '216402147826' +down_revision: Union[str, None] = 'b22d339ed2af' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('event_media', 'uploaded_at', + existing_type=mysql.DATETIME(), + type_=sa.TIMESTAMP(), + nullable=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('event_media', 'uploaded_at', + existing_type=sa.TIMESTAMP(), + type_=mysql.DATETIME(), + nullable=True) + # ### end Alembic commands ### diff --git a/server/alembic/versions/2b627d0885c3_merge_heads_after_conversions.py b/server/alembic/versions/2b627d0885c3_merge_heads_after_conversions.py new file mode 100644 index 0000000..610bc0d --- /dev/null +++ b/server/alembic/versions/2b627d0885c3_merge_heads_after_conversions.py @@ -0,0 +1,28 @@ +"""merge heads after conversions + +Revision ID: 2b627d0885c3 +Revises: 5b3c1a2f8d10, 8d1df7199cb7 +Create Date: 2025-10-06 20:27:53.974926 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '2b627d0885c3' +down_revision: Union[str, None] = ('5b3c1a2f8d10', '8d1df7199cb7') +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + pass + + +def downgrade() -> None: + """Downgrade schema.""" + pass diff --git a/server/alembic/versions/3a09ef909689_add_location_to_client_groups.py b/server/alembic/versions/3a09ef909689_add_location_to_client_groups.py new file mode 100644 index 0000000..5dcec5c --- /dev/null +++ b/server/alembic/versions/3a09ef909689_add_location_to_client_groups.py @@ -0,0 +1,32 @@ +"""Add location to client_groups + +Revision ID: 3a09ef909689 +Revises: 207f5b190f93 +Create Date: 2025-07-16 08:36:08.535836 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '3a09ef909689' +down_revision: Union[str, None] = '207f5b190f93' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('client_groups', sa.Column('location', sa.String(length=100), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('client_groups', 'location') + # ### end Alembic commands ### diff --git a/server/alembic/versions/3d15c3cac7b6_initial.py b/server/alembic/versions/3d15c3cac7b6_initial.py new file mode 100644 index 0000000..48a10d8 --- /dev/null +++ b/server/alembic/versions/3d15c3cac7b6_initial.py @@ -0,0 +1,109 @@ +"""initial + +Revision ID: 3d15c3cac7b6 +Revises: +Create Date: 2025-07-15 09:43:16.209294 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '3d15c3cac7b6' +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('client_groups', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('name', sa.String(length=100), nullable=False), + sa.Column('created_at', sa.TIMESTAMP(timezone=True), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name') + ) + op.create_table('event_media', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('media_type', sa.Enum('pdf', 'ppt', 'pptx', 'odp', 'mp4', 'avi', 'mkv', 'mov', 'wmv', 'flv', 'webm', 'mpg', 'mpeg', 'ogv', 'jpg', 'jpeg', 'png', 'gif', 'bmp', 'tiff', 'svg', 'html', name='mediatype'), nullable=False), + sa.Column('url', sa.String(length=255), nullable=False), + sa.Column('file_path', sa.String(length=255), nullable=True), + sa.Column('message_content', sa.Text(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('users', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('username', sa.String(length=50), nullable=False), + sa.Column('password_hash', sa.String(length=128), nullable=False), + sa.Column('role', sa.Enum('user', 'admin', 'superadmin', name='userrole'), nullable=False), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.TIMESTAMP(timezone=True), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=True), + sa.Column('updated_at', sa.TIMESTAMP(timezone=True), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_users_username'), 'users', ['username'], unique=True) + op.create_table('clients', + sa.Column('uuid', sa.String(length=36), nullable=False), + sa.Column('hardware_hash', sa.String(length=64), nullable=False), + sa.Column('location', sa.String(length=100), nullable=True), + sa.Column('ip_address', sa.String(length=45), nullable=True), + sa.Column('registration_time', sa.TIMESTAMP(timezone=True), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), + sa.Column('last_alive', sa.TIMESTAMP(timezone=True), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('group_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['group_id'], ['client_groups.id'], ), + sa.PrimaryKeyConstraint('uuid') + ) + op.create_index(op.f('ix_clients_hardware_hash'), 'clients', ['hardware_hash'], unique=False) + op.create_index(op.f('ix_clients_ip_address'), 'clients', ['ip_address'], unique=False) + op.create_table('events', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('group_id', sa.Integer(), nullable=False), + sa.Column('title', sa.String(length=100), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('start', sa.TIMESTAMP(timezone=True), nullable=False), + sa.Column('end', sa.TIMESTAMP(timezone=True), nullable=False), + sa.Column('event_type', sa.Enum('presentation', 'website', 'video', 'message', 'other', 'webuntis', name='eventtype'), nullable=False), + sa.Column('event_media_id', sa.Integer(), nullable=True), + sa.Column('autoplay', sa.Boolean(), nullable=True), + sa.Column('loop', sa.Boolean(), nullable=True), + sa.Column('volume', sa.Float(), nullable=True), + sa.Column('slideshow_interval', sa.Integer(), nullable=True), + sa.Column('created_at', sa.TIMESTAMP(timezone=True), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=True), + sa.Column('updated_at', sa.TIMESTAMP(timezone=True), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=True), + sa.Column('created_by', sa.Integer(), nullable=False), + sa.Column('updated_by', sa.Integer(), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(['created_by'], ['users.id'], ), + sa.ForeignKeyConstraint(['event_media_id'], ['event_media.id'], ), + sa.ForeignKeyConstraint(['group_id'], ['client_groups.id'], ), + sa.ForeignKeyConstraint(['updated_by'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_events_end'), 'events', ['end'], unique=False) + op.create_index(op.f('ix_events_group_id'), 'events', ['group_id'], unique=False) + op.create_index(op.f('ix_events_start'), 'events', ['start'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_events_start'), table_name='events') + op.drop_index(op.f('ix_events_group_id'), table_name='events') + op.drop_index(op.f('ix_events_end'), table_name='events') + op.drop_table('events') + op.drop_index(op.f('ix_clients_ip_address'), table_name='clients') + op.drop_index(op.f('ix_clients_hardware_hash'), table_name='clients') + op.drop_table('clients') + op.drop_index(op.f('ix_users_username'), table_name='users') + op.drop_table('users') + op.drop_table('event_media') + op.drop_table('client_groups') + # ### end Alembic commands ### diff --git a/server/alembic/versions/5b3c1a2f8d10_add_conversions_table.py b/server/alembic/versions/5b3c1a2f8d10_add_conversions_table.py new file mode 100644 index 0000000..7e68b56 --- /dev/null +++ b/server/alembic/versions/5b3c1a2f8d10_add_conversions_table.py @@ -0,0 +1,53 @@ +"""Add conversions table + +Revision ID: 5b3c1a2f8d10 +Revises: e6eaede720aa +Create Date: 2025-10-06 12:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '5b3c1a2f8d10' +down_revision: Union[str, None] = 'e6eaede720aa' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + 'conversions', + sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True), + sa.Column('source_event_media_id', sa.Integer(), nullable=False), + sa.Column('target_format', sa.String(length=10), nullable=False), + sa.Column('target_path', sa.String(length=512), nullable=True), + sa.Column('status', sa.Enum('pending', 'processing', 'ready', 'failed', name='conversionstatus'), + nullable=False, server_default='pending'), + sa.Column('file_hash', sa.String(length=64), nullable=True), + sa.Column('started_at', sa.TIMESTAMP(timezone=True), nullable=True), + sa.Column('completed_at', sa.TIMESTAMP(timezone=True), nullable=True), + sa.Column('error_message', sa.Text(), nullable=True), + sa.ForeignKeyConstraint(['source_event_media_id'], ['event_media.id'], + name='fk_conversions_event_media', ondelete='CASCADE'), + ) + + op.create_index('ix_conv_source_event_media_id', 'conversions', ['source_event_media_id']) + op.create_index('ix_conversions_target_format', 'conversions', ['target_format']) + op.create_index('ix_conv_status_target', 'conversions', ['status', 'target_format']) + op.create_index('ix_conv_source_target', 'conversions', ['source_event_media_id', 'target_format']) + + op.create_unique_constraint('uq_conv_source_target_hash', 'conversions', + ['source_event_media_id', 'target_format', 'file_hash']) + + +def downgrade() -> None: + op.drop_constraint('uq_conv_source_target_hash', 'conversions', type_='unique') + op.drop_index('ix_conv_source_target', table_name='conversions') + op.drop_index('ix_conv_status_target', table_name='conversions') + op.drop_index('ix_conversions_target_format', table_name='conversions') + op.drop_index('ix_conv_source_event_media_id', table_name='conversions') + op.drop_table('conversions') diff --git a/server/alembic/versions/71ba7ab08d84_merge_heads_after_holidays_table.py b/server/alembic/versions/71ba7ab08d84_merge_heads_after_holidays_table.py new file mode 100644 index 0000000..008240c --- /dev/null +++ b/server/alembic/versions/71ba7ab08d84_merge_heads_after_holidays_table.py @@ -0,0 +1,28 @@ +"""merge heads after holidays table + +Revision ID: 71ba7ab08d84 +Revises: 216402147826, 9b7a1f2a4d2b +Create Date: 2025-09-18 19:04:12.755422 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '71ba7ab08d84' +down_revision: Union[str, None] = ('216402147826', '9b7a1f2a4d2b') +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + pass + + +def downgrade() -> None: + """Downgrade schema.""" + pass diff --git a/server/alembic/versions/8d1df7199cb7_add_academic_periods_system.py b/server/alembic/versions/8d1df7199cb7_add_academic_periods_system.py new file mode 100644 index 0000000..d8ddc6d --- /dev/null +++ b/server/alembic/versions/8d1df7199cb7_add_academic_periods_system.py @@ -0,0 +1,62 @@ +"""add academic periods system + +Revision ID: 8d1df7199cb7 +Revises: 71ba7ab08d84 +Create Date: 2025-09-20 11:07:08.059374 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '8d1df7199cb7' +down_revision: Union[str, None] = '71ba7ab08d84' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('academic_periods', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('name', sa.String(length=100), nullable=False), + sa.Column('display_name', sa.String(length=50), nullable=True), + sa.Column('start_date', sa.Date(), nullable=False), + sa.Column('end_date', sa.Date(), nullable=False), + sa.Column('period_type', sa.Enum('schuljahr', 'semester', 'trimester', name='academicperiodtype'), nullable=False), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.TIMESTAMP(timezone=True), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=True), + sa.Column('updated_at', sa.TIMESTAMP(timezone=True), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=True), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name', name='uq_academic_periods_name') + ) + op.create_index('ix_academic_periods_active', 'academic_periods', ['is_active'], unique=False) + op.create_index(op.f('ix_academic_periods_end_date'), 'academic_periods', ['end_date'], unique=False) + op.create_index(op.f('ix_academic_periods_start_date'), 'academic_periods', ['start_date'], unique=False) + op.add_column('event_media', sa.Column('academic_period_id', sa.Integer(), nullable=True)) + op.create_index(op.f('ix_event_media_academic_period_id'), 'event_media', ['academic_period_id'], unique=False) + op.create_foreign_key(None, 'event_media', 'academic_periods', ['academic_period_id'], ['id']) + op.add_column('events', sa.Column('academic_period_id', sa.Integer(), nullable=True)) + op.create_index(op.f('ix_events_academic_period_id'), 'events', ['academic_period_id'], unique=False) + op.create_foreign_key(None, 'events', 'academic_periods', ['academic_period_id'], ['id']) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(None, 'events', type_='foreignkey') + op.drop_index(op.f('ix_events_academic_period_id'), table_name='events') + op.drop_column('events', 'academic_period_id') + op.drop_constraint(None, 'event_media', type_='foreignkey') + op.drop_index(op.f('ix_event_media_academic_period_id'), table_name='event_media') + op.drop_column('event_media', 'academic_period_id') + op.drop_index(op.f('ix_academic_periods_start_date'), table_name='academic_periods') + op.drop_index(op.f('ix_academic_periods_end_date'), table_name='academic_periods') + op.drop_index('ix_academic_periods_active', table_name='academic_periods') + op.drop_table('academic_periods') + # ### end Alembic commands ### diff --git a/server/alembic/versions/9b7a1f2a4d2b_add_school_holidays_table.py b/server/alembic/versions/9b7a1f2a4d2b_add_school_holidays_table.py new file mode 100644 index 0000000..2f8e000 --- /dev/null +++ b/server/alembic/versions/9b7a1f2a4d2b_add_school_holidays_table.py @@ -0,0 +1,47 @@ +"""add school holidays table + +Revision ID: 9b7a1f2a4d2b +Revises: e6eaede720aa +Create Date: 2025-09-18 00:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '9b7a1f2a4d2b' +down_revision = 'e6eaede720aa' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + 'school_holidays', + sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True), + sa.Column('name', sa.String(length=150), nullable=False), + sa.Column('start_date', sa.Date(), nullable=False), + sa.Column('end_date', sa.Date(), nullable=False), + sa.Column('region', sa.String(length=100), nullable=True), + sa.Column('source_file_name', sa.String(length=255), nullable=True), + sa.Column('imported_at', sa.TIMESTAMP(timezone=True), + server_default=sa.text('CURRENT_TIMESTAMP')), + ) + op.create_index('ix_school_holidays_start_date', + 'school_holidays', ['start_date']) + op.create_index('ix_school_holidays_end_date', + 'school_holidays', ['end_date']) + op.create_index('ix_school_holidays_region', 'school_holidays', ['region']) + op.create_unique_constraint('uq_school_holidays_unique', 'school_holidays', [ + 'name', 'start_date', 'end_date', 'region']) + + +def downgrade() -> None: + op.drop_constraint('uq_school_holidays_unique', + 'school_holidays', type_='unique') + op.drop_index('ix_school_holidays_region', table_name='school_holidays') + op.drop_index('ix_school_holidays_end_date', table_name='school_holidays') + op.drop_index('ix_school_holidays_start_date', + table_name='school_holidays') + op.drop_table('school_holidays') diff --git a/server/alembic/versions/b22d339ed2af_add_uploaded_at_to_eventmedia.py b/server/alembic/versions/b22d339ed2af_add_uploaded_at_to_eventmedia.py new file mode 100644 index 0000000..be4ceef --- /dev/null +++ b/server/alembic/versions/b22d339ed2af_add_uploaded_at_to_eventmedia.py @@ -0,0 +1,32 @@ +"""Add uploaded_at to EventMedia + +Revision ID: b22d339ed2af +Revises: e6eaede720aa +Create Date: 2025-09-01 10:07:46.915640 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'b22d339ed2af' +down_revision: Union[str, None] = 'e6eaede720aa' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('event_media', sa.Column('uploaded_at', sa.DateTime(timezone=True), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('event_media', 'uploaded_at') + # ### end Alembic commands ### diff --git a/server/alembic/versions/b5a6c3d4e7f8_make_file_hash_not_null.py b/server/alembic/versions/b5a6c3d4e7f8_make_file_hash_not_null.py new file mode 100644 index 0000000..358b586 --- /dev/null +++ b/server/alembic/versions/b5a6c3d4e7f8_make_file_hash_not_null.py @@ -0,0 +1,40 @@ +"""Make conversions.file_hash NOT NULL + +Revision ID: b5a6c3d4e7f8 +Revises: 2b627d0885c3 +Create Date: 2025-10-06 21:05:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "b5a6c3d4e7f8" +down_revision: Union[str, None] = "2b627d0885c3" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Ensure no NULLs remain before altering nullability + op.execute("UPDATE conversions SET file_hash = '' WHERE file_hash IS NULL") + op.alter_column( + "conversions", + "file_hash", + existing_type=sa.String(length=64), + nullable=False, + existing_nullable=True, + ) + + +def downgrade() -> None: + op.alter_column( + "conversions", + "file_hash", + existing_type=sa.String(length=64), + nullable=True, + existing_nullable=False, + ) diff --git a/server/alembic/versions/e6eaede720aa_add_website_to_mediatype_enum.py b/server/alembic/versions/e6eaede720aa_add_website_to_mediatype_enum.py new file mode 100644 index 0000000..17db3c6 --- /dev/null +++ b/server/alembic/versions/e6eaede720aa_add_website_to_mediatype_enum.py @@ -0,0 +1,34 @@ +"""Add website to MediaType enum + +Revision ID: e6eaede720aa +Revises: 0c47280d3e2d +Create Date: 2025-07-24 13:40:50.553863 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'e6eaede720aa' +down_revision: Union[str, None] = '0c47280d3e2d' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.execute( + "ALTER TABLE event_media MODIFY COLUMN media_type ENUM('pdf','ppt','pptx','odp','mp4','avi','mkv','mov','wmv','flv','webm','mpg','mpeg','ogv','jpg','jpeg','png','gif','bmp','tiff','svg','html','website') NOT NULL;" + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/server/database.py b/server/database.py new file mode 100644 index 0000000..ae83f31 --- /dev/null +++ b/server/database.py @@ -0,0 +1,23 @@ +from sqlalchemy.orm import sessionmaker +from sqlalchemy import create_engine +from dotenv import load_dotenv +import os + +# Nur im Dev-Modus .env laden +if os.getenv("ENV", "development") == "development": + load_dotenv(dotenv_path=os.path.join( + os.path.dirname(__file__), '..', '.env')) + +# Prod: DB_CONN direkt aus Umgebungsvariable (von Compose gesetzt) +DB_URL = os.getenv("DB_CONN") +if not DB_URL: + # Dev: DB-URL aus Einzelwerten bauen + DB_USER = os.getenv("DB_USER", "infoscreen_admin") + DB_PASSWORD = os.getenv("DB_PASSWORD", "KqtpM7wmNd&mFKs") + DB_HOST = os.getenv("DB_HOST", "db") # IMMER 'db' als Host im Container! + DB_NAME = os.getenv("DB_NAME", "infoscreen_by_taa") + DB_URL = f"mysql+pymysql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}/{DB_NAME}" + +print(f"Using DB_URL: {DB_URL}") # Debug-Ausgabe +engine = create_engine(DB_URL, echo=False) +Session = sessionmaker(bind=engine) diff --git a/server/dummy_clients.py b/server/dummy_clients.py new file mode 100644 index 0000000..04d588c --- /dev/null +++ b/server/dummy_clients.py @@ -0,0 +1,45 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from models.models import Client +from dotenv import load_dotenv +import os +from datetime import datetime, timedelta +import random +import uuid + +# .env laden +load_dotenv() + +DB_USER = os.getenv("DB_USER") +DB_PASSWORD = os.getenv("DB_PASSWORD") +DB_HOST = os.getenv("DB_HOST") +DB_NAME = os.getenv("DB_NAME") + +db_conn_str = f"mysql+pymysql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}/{DB_NAME}" +engine = create_engine(db_conn_str) +Session = sessionmaker(bind=engine) +session = Session() + +# Dummy-Clients erzeugen +locations = [ + "Raum 101", + "Raum 102", + "Lehrerzimmer", + "Aula", + "Bibliothek" +] + +for i in range(5): + client = Client( + uuid=str(uuid.uuid4()), + hardware_hash=f"dummyhash{i:02d}", + location=locations[i], + ip_address=f"192.168.0.{100+i}", + registration_time=datetime.now() - timedelta(days=random.randint(1, 30)), + last_alive=datetime.now(), + is_active=True + ) + session.add(client) + +session.commit() +print("5 Dummy-Clients wurden angelegt.") diff --git a/server/dummy_events.py b/server/dummy_events.py new file mode 100644 index 0000000..dec04a1 --- /dev/null +++ b/server/dummy_events.py @@ -0,0 +1,63 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from models.models import Event, EventMedia, EventType, Client +from dotenv import load_dotenv +import os +from datetime import datetime, timedelta +import random + +# .env laden +load_dotenv() + +DB_USER = os.getenv("DB_USER") +DB_PASSWORD = os.getenv("DB_PASSWORD") +DB_HOST = os.getenv("DB_HOST") +DB_NAME = os.getenv("DB_NAME") + +db_conn_str = f"mysql+pymysql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}/{DB_NAME}" +engine = create_engine(db_conn_str) +Session = sessionmaker(bind=engine) +session = Session() + +now = datetime.now() + + +def random_time_on_day(day_offset: int, duration_hours: int = 1): + """Erzeugt eine zufällige Start- und Endzeit zwischen 8 und 16 Uhr für einen Tag.""" + start_hour = random.randint(8, 15 - duration_hours + 1) + start = (now + timedelta(days=day_offset)).replace(hour=start_hour, + minute=0, second=0, microsecond=0) + end = start + timedelta(hours=duration_hours) + return start, end + + +# Hole alle Clients aus der Datenbank +clients = session.query(Client).all() +created_by = 1 # Passe ggf. an + +all_events = [] + +for client in clients: + for i in range(10): + day_offset = random.randint(0, 13) # Termine in den nächsten 14 Tagen + duration = random.choice([1, 2]) # 1 oder 2 Stunden + start, end = random_time_on_day(day_offset, duration) + event = Event( + client_uuid=client.uuid, + title=f"Termin {i+1} für {client.location or client.uuid[:8]}", + description=f"Automatisch generierter Termin {i+1} für Client {client.uuid}", + start=start, + end=end, + event_type=random.choice(list(EventType)), + created_by=created_by, + updated_by=None, + is_active=True + ) + all_events.append(event) + +# Events speichern +for event in all_events: + session.add(event) +session.commit() + +print(f"{len(all_events)} Termine für {len(clients)} Clients wurden angelegt.") diff --git a/server/init_academic_periods.py b/server/init_academic_periods.py new file mode 100644 index 0000000..3afa1a8 --- /dev/null +++ b/server/init_academic_periods.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +""" +Erstellt Standard-Schuljahre für österreichische Schulen +Führe dieses Skript nach der Migration aus, um Standard-Perioden zu erstellen. +""" + +from datetime import date +from models.models import AcademicPeriod, AcademicPeriodType +from server.database import Session +import sys +sys.path.append('/workspace') + + +def create_default_academic_periods(): + """Erstellt Standard-Schuljahre für österreichische Schulen""" + session = Session() + + try: + # Prüfe ob bereits Perioden existieren + existing = session.query(AcademicPeriod).first() + if existing: + print("Academic periods already exist. Skipping creation.") + return + + # Standard Schuljahre erstellen + periods = [ + { + 'name': 'Schuljahr 2024/25', + 'display_name': 'SJ 24/25', + 'start_date': date(2024, 9, 2), + 'end_date': date(2025, 7, 4), + 'period_type': AcademicPeriodType.schuljahr, + 'is_active': True # Aktuelles Schuljahr + }, + { + 'name': 'Schuljahr 2025/26', + 'display_name': 'SJ 25/26', + 'start_date': date(2025, 9, 1), + 'end_date': date(2026, 7, 3), + 'period_type': AcademicPeriodType.schuljahr, + 'is_active': False + }, + { + 'name': 'Schuljahr 2026/27', + 'display_name': 'SJ 26/27', + 'start_date': date(2026, 9, 7), + 'end_date': date(2027, 7, 2), + 'period_type': AcademicPeriodType.schuljahr, + 'is_active': False + } + ] + + for period_data in periods: + period = AcademicPeriod(**period_data) + session.add(period) + + session.commit() + print(f"Successfully created {len(periods)} academic periods") + + # Zeige erstellte Perioden + for period in session.query(AcademicPeriod).all(): + status = "AKTIV" if period.is_active else "inaktiv" + print( + f" - {period.name} ({period.start_date} - {period.end_date}) [{status}]") + + except Exception as e: + session.rollback() + print(f"Error creating academic periods: {e}") + finally: + session.close() + + +if __name__ == "__main__": + create_default_academic_periods() diff --git a/server/init_defaults.py b/server/init_defaults.py new file mode 100644 index 0000000..4bfee8f --- /dev/null +++ b/server/init_defaults.py @@ -0,0 +1,38 @@ +from sqlalchemy import create_engine, text +import os +from dotenv import load_dotenv +import bcrypt + +# .env laden +load_dotenv() + +DB_URL = f"mysql+pymysql://{os.getenv('DB_USER')}:{os.getenv('DB_PASSWORD')}@{os.getenv('DB_HOST')}:3306/{os.getenv('DB_NAME')}" +engine = create_engine(DB_URL, isolation_level="AUTOCOMMIT") + +with engine.connect() as conn: + # Default-Gruppe mit id=1 anlegen, falls nicht vorhanden + result = conn.execute( + text("SELECT COUNT(*) FROM client_groups WHERE id=1")) + if result.scalar() == 0: + conn.execute( + text( + "INSERT INTO client_groups (id, name, is_active) VALUES (1, 'Nicht zugeordnet', 1)") + ) + print("✅ Default-Gruppe mit id=1 angelegt.") + + # Admin-Benutzer anlegen, falls nicht vorhanden + admin_user = os.getenv("DEFAULT_ADMIN_USERNAME", "infoscreen_admin") + admin_pw = os.getenv("DEFAULT_ADMIN_PASSWORD", "Info_screen_admin25!") + # Passwort hashen mit bcrypt + hashed_pw = bcrypt.hashpw(admin_pw.encode( + 'utf-8'), bcrypt.gensalt()).decode('utf-8') + # Prüfen, ob User existiert + result = conn.execute(text( + "SELECT COUNT(*) FROM users WHERE username=:username"), {"username": admin_user}) + if result.scalar() == 0: + # Rolle: 1 = Admin (ggf. anpassen je nach Modell) + conn.execute( + text("INSERT INTO users (username, password_hash, role, is_active) VALUES (:username, :password_hash, 1, 1)"), + {"username": admin_user, "password_hash": hashed_pw} + ) + print(f"✅ Admin-Benutzer '{admin_user}' angelegt.") diff --git a/server/initialize_database.py b/server/initialize_database.py new file mode 100755 index 0000000..edd18e8 --- /dev/null +++ b/server/initialize_database.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +""" +Complete database initialization script for the infoscreen application. + +This script: +1. Runs all Alembic migrations to create/update database schema +2. Creates default user groups and admin user +3. Initializes academic periods for Austrian schools + +Usage: + python initialize_database.py +""" + +import os +import sys +import subprocess +from pathlib import Path + +# Add workspace to Python path +sys.path.insert(0, '/workspace') + +def run_command(cmd, description): + """Run a command and handle errors.""" + print(f"\n🔄 {description}...") + try: + result = subprocess.run(cmd, shell=True, check=True, capture_output=True, text=True) + if result.stdout: + print(result.stdout) + print(f"✅ {description} completed successfully") + return True + except subprocess.CalledProcessError as e: + print(f"❌ {description} failed:") + print(f"Error: {e}") + if e.stdout: + print(f"Stdout: {e.stdout}") + if e.stderr: + print(f"Stderr: {e.stderr}") + return False + +def check_database_connection(): + """Check if database is accessible.""" + print("\n🔍 Checking database connection...") + try: + from dotenv import load_dotenv + from sqlalchemy import create_engine, text + + load_dotenv('/workspace/.env') + + DB_USER = os.getenv('DB_USER') + DB_PASSWORD = os.getenv('DB_PASSWORD') + DB_HOST = os.getenv('DB_HOST', 'db') + DB_NAME = os.getenv('DB_NAME') + DB_URL = f'mysql+pymysql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}/{DB_NAME}' + + engine = create_engine(DB_URL) + with engine.connect() as conn: + result = conn.execute(text('SELECT VERSION()')) + version = result.scalar() + print(f"✅ Connected to database: {version}") + return True + except Exception as e: + print(f"❌ Database connection failed: {e}") + return False + +def check_current_migration(): + """Check current Alembic migration status.""" + print("\n🔍 Checking current migration status...") + try: + result = subprocess.run( + "cd /workspace/server && alembic current", + shell=True, + capture_output=True, + text=True + ) + if "head" in result.stdout: + print("✅ Database is up to date") + return True + elif result.stdout.strip() == "": + print("⚠️ No migrations applied yet") + return False + else: + print(f"⚠️ Current migration: {result.stdout.strip()}") + return False + except Exception as e: + print(f"❌ Migration check failed: {e}") + return False + +def main(): + """Main initialization function.""" + print("🚀 Starting database initialization for infoscreen application") + print("=" * 60) + + # Check if we're in the right directory + if not os.path.exists('/workspace/server/alembic.ini'): + print("❌ Error: alembic.ini not found. Are you in the correct directory?") + return False + + # Check database connection + if not check_database_connection(): + print("\n❌ Cannot connect to database. Please ensure:") + print(" - Database container is running") + print(" - Environment variables are set correctly") + print(" - Network connectivity is available") + return False + + # Check current migration status + needs_migration = not check_current_migration() + + # Run migrations if needed + if needs_migration: + if not run_command( + "cd /workspace/server && alembic upgrade head", + "Running Alembic migrations" + ): + return False + else: + print("⏭️ Skipping migrations (already up to date)") + + # Initialize default data + if not run_command( + "cd /workspace/server && python init_defaults.py", + "Creating default groups and admin user" + ): + return False + + # Initialize academic periods + if not run_command( + "cd /workspace/server && python init_academic_periods.py", + "Setting up academic periods" + ): + return False + + print("\n" + "=" * 60) + print("🎉 Database initialization completed successfully!") + print("\nNext steps:") + print(" 1. Start the application services") + print(" 2. Access the dashboard to verify everything works") + print(" 3. Login with admin credentials if needed") + + return True + +if __name__ == "__main__": + success = main() + sys.exit(0 if success else 1) \ No newline at end of file diff --git a/server/mqtt_helper.py b/server/mqtt_helper.py new file mode 100644 index 0000000..fb79617 --- /dev/null +++ b/server/mqtt_helper.py @@ -0,0 +1,142 @@ +""" +Einfache MQTT-Hilfsfunktion für Client-Gruppenzuordnungen +""" +import os +import json +import logging +import paho.mqtt.client as mqtt + +logger = logging.getLogger(__name__) + + +def publish_client_group(client_uuid: str, group_id: int) -> bool: + """ + Publiziert die Gruppenzuordnung eines Clients als retained message + + Args: + client_uuid: UUID des Clients + group_id: ID der Gruppe + + Returns: + bool: True bei Erfolg, False bei Fehler + """ + try: + # MQTT-Konfiguration aus .env + broker_host = os.getenv("MQTT_BROKER_HOST", "mqtt") + broker_port = int(os.getenv("MQTT_BROKER_PORT", 1883)) + username = os.getenv("MQTT_USER") + password = os.getenv("MQTT_PASSWORD") + + # Topic und Payload + topic = f"infoscreen/{client_uuid}/group_id" + payload = json.dumps({ + "group_id": group_id, + "client_uuid": client_uuid + }) + + # MQTT-Client erstellen und verbinden + client = mqtt.Client() + if username and password: + client.username_pw_set(username, password) + + client.connect(broker_host, broker_port, 60) + + # Retained message publizieren + result = client.publish(topic, payload, qos=1, retain=True) + result.wait_for_publish(timeout=5.0) + + client.disconnect() + + logger.info( + f"Group assignment published for client {client_uuid}: group_id={group_id}") + return True + + except Exception as e: + logger.error( + f"Error publishing group assignment for client {client_uuid}: {e}") + return False + + +def publish_multiple_client_groups(client_group_mappings: dict) -> tuple[int, int]: + """ + Publiziert Gruppenzuordnungen für mehrere Clients in einer Verbindung + + Args: + client_group_mappings: Dict mit {client_uuid: group_id} + + Returns: + tuple: (success_count, failed_count) + """ + try: + broker_host = os.getenv("MQTT_BROKER_HOST", "mqtt") + broker_port = int(os.getenv("MQTT_BROKER_PORT", 1883)) + username = os.getenv("MQTT_USER") + password = os.getenv("MQTT_PASSWORD") + + client = mqtt.Client() + if username and password: + client.username_pw_set(username, password) + + client.connect(broker_host, broker_port, 60) + + success_count = 0 + failed_count = 0 + + for client_uuid, group_id in client_group_mappings.items(): + try: + topic = f"infoscreen/{client_uuid}/group_id" + payload = json.dumps({ + "group_id": group_id, + "client_uuid": client_uuid + }) + + result = client.publish(topic, payload, qos=1, retain=True) + result.wait_for_publish(timeout=5.0) + success_count += 1 + + except Exception as e: + logger.error(f"Failed to publish for {client_uuid}: {e}") + failed_count += 1 + + client.disconnect() + + logger.info( + f"Bulk publish completed: {success_count} success, {failed_count} failed") + return success_count, failed_count + + except Exception as e: + logger.error(f"Error in bulk publish: {e}") + return 0, len(client_group_mappings) + + +def delete_client_group_message(client_uuid: str) -> bool: + """ + Löscht die retained message für einen Client (bei Client-Löschung) + """ + try: + broker_host = os.getenv("MQTT_BROKER_HOST", "mqtt") + broker_port = int(os.getenv("MQTT_BROKER_PORT", 1883)) + username = os.getenv("MQTT_USER") + password = os.getenv("MQTT_PASSWORD") + + topic = f"infoscreen/{client_uuid}/group_id" + + client = mqtt.Client() + if username and password: + client.username_pw_set(username, password) + + client.connect(broker_host, broker_port, 60) + + # Leere retained message löscht die vorherige + result = client.publish(topic, "", qos=1, retain=True) + result.wait_for_publish(timeout=5.0) + + client.disconnect() + + logger.info(f"Deleted retained group message for client {client_uuid}") + return True + + except Exception as e: + logger.error( + f"Error deleting group message for client {client_uuid}: {e}") + return False diff --git a/server/mqtt_multitopic_receiver.py b/server/mqtt_multitopic_receiver.py new file mode 100644 index 0000000..cb05b49 --- /dev/null +++ b/server/mqtt_multitopic_receiver.py @@ -0,0 +1,159 @@ +import sys +sys.path.append('/workspace') +import os +import json +import base64 +import glob +from datetime import datetime +from paho.mqtt import client as mqtt_client +from sqlalchemy import create_engine, func +from sqlalchemy.orm import sessionmaker +from models.models import Client, Base +from helpers.check_folder import ensure_folder_exists +import shutil + +# Basisverzeichnis relativ zum aktuellen Skript +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) + +# Konfiguration +MQTT_BROKER = os.getenv("MQTT_BROKER_HOST", "localhost") +MQTT_PORT = int(os.getenv("MQTT_BROKER_PORT", 1883)) +MQTT_USER = os.getenv("MQTT_USER") +MQTT_PASSWORD = os.getenv("MQTT_PASSWORD") +MQTT_KEEPALIVE = int(os.getenv("MQTT_KEEPALIVE")) +DB_USER = os.getenv("DB_USER") +DB_PASSWORD = os.getenv("DB_PASSWORD") +DB_HOST = os.getenv("DB_HOST") +DB_NAME = os.getenv("DB_NAME") + +topics = [ + ("infoscreen/screenshot", 0), + ("infoscreen/heartbeat", 0), + # ... weitere Topics hier +] + +# Verzeichnisse für Screenshots +RECEIVED_DIR = os.path.join(BASE_DIR, "received_screenshots") +LATEST_DIR = os.path.join(BASE_DIR, "screenshots") +MAX_PER_CLIENT = 20 + +# Ordner für empfangene Screenshots und den neuesten Screenshot anlegen +ensure_folder_exists(RECEIVED_DIR) +ensure_folder_exists(LATEST_DIR) + +# Datenbank konfigurieren (MariaDB) +DB_URL = f"mysql+pymysql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}/{DB_NAME}" +engine = create_engine(DB_URL, echo=False) +Session = sessionmaker(bind=engine) +Base.metadata.create_all(engine) + + +def prune_old_screenshots(client_id: str): + """Löscht alte Screenshots, wenn mehr als MAX_PER_CLIENT vorhanden sind.""" + pattern = os.path.join(RECEIVED_DIR, f"{client_id}_*.jpg") + files = sorted(glob.glob(pattern), key=os.path.getmtime) + while len(files) > MAX_PER_CLIENT: + oldest = files.pop(0) + try: + os.remove(oldest) + print(f"Altes Bild gelöscht: {oldest}") + except OSError as e: + print(f"Fehler beim Löschen von {oldest}: {e}") + + +def handle_screenshot(msg): + """Verarbeitet eingehende Screenshot-Payloads.""" + try: + payload = json.loads(msg.payload.decode("utf-8")) + client_id = payload.get("client_id", "unknown") + ts = datetime.fromtimestamp( + payload.get("timestamp", datetime.now().timestamp()) + ) + b64_str = payload["screenshot"] + img_data = base64.b64decode(b64_str) + + # Dateiname mit Client-ID und Zeitstempel + filename = ts.strftime(f"{client_id}_%Y%m%d_%H%M%S.jpg") + received_path = os.path.join(RECEIVED_DIR, filename) + + # Bild im Verzeichnis "received_screenshots" speichern + with open(received_path, "wb") as f: + f.write(img_data) + print(f"Bild gespeichert: {received_path}") + + # Kopiere den neuesten Screenshot in das Verzeichnis "screenshots" + latest_path = os.path.join(LATEST_DIR, f"{client_id}.jpg") + shutil.copy(received_path, latest_path) + print(f"Neuester Screenshot aktualisiert: {latest_path}") + + # Alte Screenshots beschneiden + prune_old_screenshots(client_id) + + except Exception as e: + print("Fehler beim Verarbeiten der Screenshot-Nachricht:", e) + + +def handle_heartbeat(msg): + """Verarbeitet Heartbeat und aktualisiert oder legt Clients an.""" + session = Session() + try: + payload = json.loads(msg.payload.decode("utf-8")) + uuid = payload.get("client_id") + hardware_hash = payload.get("hardware_hash") + ip_address = payload.get("ip_address") + # Versuche, Client zu finden + client = session.query(Client).filter_by(uuid=uuid).first() + if client: + # Bekannter Client: last_alive und IP aktualisieren + client.ip_address = ip_address + client.last_alive = func.now() + session.commit() + print(f"Heartbeat aktualisiert für Client {uuid}") + else: + # Neuer Client: Location per input abfragen + location = input(f"Neuer Client {uuid} gefunden. Bitte Standort eingeben: ") + new_client = Client( + uuid=uuid, + hardware_hash=hardware_hash, + location=location, + ip_address=ip_address + ) + session.add(new_client) + session.commit() + print(f"Neuer Client {uuid} angelegt mit Standort {location}") + except Exception as e: + print("Fehler beim Verarbeiten der Heartbeat-Nachricht:", e) + session.rollback() + finally: + session.close() + + +# Mapping von Topics auf Handler-Funktionen +handlers = { + "infoscreen/screenshot": handle_screenshot, + "infoscreen/heartbeat": handle_heartbeat, + # ... weitere Zuordnungen hier +} + + +def on_connect(client, userdata, flags, rc, properties): + print("Verbunden mit Code:", rc) + client.subscribe(topics) + + +def on_message(client, userdata, msg): + topic = msg.topic + if topic in handlers: + handlers[topic](msg) + else: + print(f"Unbekanntes Topic '{topic}', keine Verarbeitung definiert.") + + +if __name__ == "__main__": + client = mqtt_client.Client(callback_api_version=mqtt_client.CallbackAPIVersion.VERSION2) + client.username_pw_set(MQTT_USER, MQTT_PASSWORD) # <<<< AUTHENTIFIZIERUNG + client.on_connect = on_connect + client.on_message = on_message + + client.connect(MQTT_BROKER, MQTT_PORT, keepalive=MQTT_KEEPALIVE) + client.loop_forever() diff --git a/server/mqtt_receiver.py b/server/mqtt_receiver.py new file mode 100644 index 0000000..f07b818 --- /dev/null +++ b/server/mqtt_receiver.py @@ -0,0 +1,57 @@ +import os +import base64 +import json +from datetime import datetime +import paho.mqtt.client as mqtt + +# MQTT-Konfiguration +MQTT_BROKER = "mqtt_broker" +MQTT_PORT = 1883 +MQTT_USER = "infoscreen_taa_user" +MQTT_PASSWORD = "infoscreen_taa_MQTT25!" +TOPIC_SCREENSHOTS = "infoscreen/screenshot" +SAVE_DIR = "received_screenshots" +topics = [ + ("infoscreen/screenshot", 0), + ("infoscreen/heartbeat", 0), + # ... weitere Topics hier +] + +# Ordner für empfangene Screenshots anlegen +os.makedirs(SAVE_DIR, exist_ok=True) + +# Callback, wenn eine Nachricht eintrifft +def on_message(client, userdata, msg): + try: + payload = json.loads(msg.payload.decode('utf-8')) + b64_str = payload["screenshot"] + img_data = base64.b64decode(b64_str) + + # Dateiname mit Zeitstempel + ts = datetime.fromtimestamp(payload.get("timestamp", datetime.now().timestamp())) + filename = ts.strftime("screenshot_%Y%m%d_%H%M%S.jpg") + filepath = os.path.join(SAVE_DIR, filename) + + # Bild speichern + with open(filepath, "wb") as f: + f.write(img_data) + print(f"Bild gespeichert: {filepath}") + except Exception as e: + print("Fehler beim Verarbeiten der Nachricht:", e) + +# Callback bei erfolgreicher Verbindung +def on_connect(client, userdata, flags, rc, properties): + if rc == 0: + print("Mit MQTT-Server verbunden.") + client.subscribe(TOPIC_SCREENSHOTS, qos=1) + else: + print(f"Verbindung fehlgeschlagen (Code {rc})") + +if __name__ == "__main__": + client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2) + client.username_pw_set(MQTT_USER, MQTT_PASSWORD) # <<<< AUTHENTIFIZIERUNG + client.on_connect = on_connect + client.on_message = on_message + + client.connect(MQTT_BROKER, MQTT_PORT, keepalive=60) + client.loop_forever() diff --git a/server/requirements-dev.txt b/server/requirements-dev.txt new file mode 100644 index 0000000..63446bd --- /dev/null +++ b/server/requirements-dev.txt @@ -0,0 +1,2 @@ +python-dotenv>=1.1.0 +debugpy diff --git a/server/requirements.txt b/server/requirements.txt new file mode 100644 index 0000000..1cf2cff --- /dev/null +++ b/server/requirements.txt @@ -0,0 +1,11 @@ +alembic>=1.16.1 +bcrypt>=4.3.0 +paho-mqtt>=2.1.0 +PyMySQL>=1.1.1 +python-dotenv>=1.1.0 +SQLAlchemy>=2.0.41 +flask +gunicorn +redis>=5.0.1 +rq>=1.16.2 +requests>=2.32.3 diff --git a/server/routes/academic_periods.py b/server/routes/academic_periods.py new file mode 100644 index 0000000..588ff0c --- /dev/null +++ b/server/routes/academic_periods.py @@ -0,0 +1,84 @@ +from flask import Blueprint, jsonify, request +from server.database import Session +from models.models import AcademicPeriod +from datetime import datetime + +academic_periods_bp = Blueprint( + 'academic_periods', __name__, url_prefix='/api/academic_periods') + + +@academic_periods_bp.route('', methods=['GET']) +def list_academic_periods(): + session = Session() + try: + periods = session.query(AcademicPeriod).order_by( + AcademicPeriod.start_date.asc()).all() + return jsonify({ + 'periods': [p.to_dict() for p in periods] + }) + finally: + session.close() + + +@academic_periods_bp.route('/active', methods=['GET']) +def get_active_academic_period(): + session = Session() + try: + period = session.query(AcademicPeriod).filter( + AcademicPeriod.is_active == True).first() + if not period: + return jsonify({'period': None}), 200 + return jsonify({'period': period.to_dict()}), 200 + finally: + session.close() + + +@academic_periods_bp.route('/for_date', methods=['GET']) +def get_period_for_date(): + """ + Returns the academic period that covers the provided date (YYYY-MM-DD). + If multiple match, prefer the one with the latest start_date. + """ + date_str = request.args.get('date') + if not date_str: + return jsonify({'error': 'Missing required query param: date (YYYY-MM-DD)'}), 400 + try: + target = datetime.strptime(date_str, '%Y-%m-%d').date() + except ValueError: + return jsonify({'error': 'Invalid date format. Expected YYYY-MM-DD'}), 400 + + session = Session() + try: + period = ( + session.query(AcademicPeriod) + .filter(AcademicPeriod.start_date <= target, AcademicPeriod.end_date >= target) + .order_by(AcademicPeriod.start_date.desc()) + .first() + ) + return jsonify({'period': period.to_dict() if period else None}), 200 + finally: + session.close() + + +@academic_periods_bp.route('/active', methods=['POST']) +def set_active_academic_period(): + data = request.get_json(silent=True) or {} + period_id = data.get('id') + if period_id is None: + return jsonify({'error': 'Missing required field: id'}), 400 + session = Session() + try: + target = session.query(AcademicPeriod).get(period_id) + if not target: + return jsonify({'error': 'AcademicPeriod not found'}), 404 + + # Deactivate all, then activate target + session.query(AcademicPeriod).filter(AcademicPeriod.is_active == True).update( + {AcademicPeriod.is_active: False} + ) + target.is_active = True + session.commit() + session.refresh(target) + return jsonify({'period': target.to_dict()}), 200 + finally: + session.close() diff --git a/server/routes/clients.py b/server/routes/clients.py new file mode 100644 index 0000000..f393a0e --- /dev/null +++ b/server/routes/clients.py @@ -0,0 +1,289 @@ +from server.database import Session +from models.models import Client, ClientGroup +from flask import Blueprint, request, jsonify +from server.mqtt_helper import publish_client_group, delete_client_group_message, publish_multiple_client_groups +import sys +sys.path.append('/workspace') + +clients_bp = Blueprint("clients", __name__, url_prefix="/api/clients") + + +@clients_bp.route("/sync-all-groups", methods=["POST"]) +def sync_all_client_groups(): + """ + Administrative Route: Synchronisiert alle bestehenden Client-Gruppenzuordnungen mit MQTT + Nützlich für die einmalige Migration bestehender Clients + """ + session = Session() + try: + # Alle aktiven Clients abrufen + clients = session.query(Client).filter(Client.is_active == True).all() + + if not clients: + session.close() + return jsonify({"message": "Keine aktiven Clients gefunden", "synced": 0}) + + # Alle Clients synchronisieren + client_group_mappings = { + client.uuid: client.group_id for client in clients} + success_count, failed_count = publish_multiple_client_groups( + client_group_mappings) + + session.close() + + return jsonify({ + "success": True, + "message": f"Synchronisation abgeschlossen", + "synced": success_count, + "failed": failed_count, + "total": len(clients) + }) + + except Exception as e: + session.close() + return jsonify({"error": f"Fehler bei der Synchronisation: {str(e)}"}), 500 + + +@clients_bp.route("/without_description", methods=["GET"]) +def get_clients_without_description(): + session = Session() + clients = session.query(Client).filter( + (Client.description == None) | (Client.description == "") + ).all() + result = [ + { + "uuid": c.uuid, + "hardware_token": c.hardware_token, + "ip": c.ip, + "type": c.type, + "hostname": c.hostname, + "os_version": c.os_version, + "software_version": c.software_version, + "macs": c.macs, + "model": c.model, + "registration_time": c.registration_time.isoformat() if c.registration_time else None, + "last_alive": c.last_alive.isoformat() if c.last_alive else None, + "is_active": c.is_active, + "group_id": c.group_id, + } + for c in clients + ] + session.close() + return jsonify(result) + + +@clients_bp.route("//description", methods=["PUT"]) +def set_client_description(uuid): + data = request.get_json() + description = data.get("description", "").strip() + if not description: + return jsonify({"error": "Beschreibung darf nicht leer sein"}), 400 + session = Session() + client = session.query(Client).filter_by(uuid=uuid).first() + if not client: + session.close() + return jsonify({"error": "Client nicht gefunden"}), 404 + + client.description = description + session.commit() + + # MQTT: Gruppenzuordnung publizieren (wichtig für neue Clients aus SetupMode) + mqtt_success = publish_client_group(client.uuid, client.group_id) + + session.close() + + response = {"success": True} + if not mqtt_success: + response["warning"] = "Beschreibung gespeichert, aber MQTT-Publishing fehlgeschlagen" + + return jsonify(response) + + +@clients_bp.route("", methods=["GET"]) +def get_clients(): + session = Session() + clients = session.query(Client).all() + result = [ + { + "uuid": c.uuid, + "hardware_token": c.hardware_token, + "ip": c.ip, + "type": c.type, + "hostname": c.hostname, + "os_version": c.os_version, + "software_version": c.software_version, + "macs": c.macs, + "model": c.model, + "description": c.description, + "registration_time": c.registration_time.isoformat() if c.registration_time else None, + "last_alive": c.last_alive.isoformat() if c.last_alive else None, + "is_active": c.is_active, + "group_id": c.group_id, + } + for c in clients + ] + session.close() + return jsonify(result) + + +@clients_bp.route("/group", methods=["PUT"]) +def update_clients_group(): + data = request.get_json() + client_ids = data.get("client_ids", []) + group_id = data.get("group_id") + group_name = data.get("group_name") + + if not isinstance(client_ids, list) or len(client_ids) == 0: + return jsonify({"error": "client_ids muss eine nicht-leere Liste sein"}), 400 + + session = Session() + + # Bestimme Ziel-Gruppe: Priorität hat group_id, ansonsten group_name + group = None + if group_id is not None: + group = session.query(ClientGroup).filter_by(id=group_id).first() + if not group: + session.close() + return jsonify({"error": f"Gruppe mit id={group_id} nicht gefunden"}), 404 + elif group_name: + group = session.query(ClientGroup).filter_by(name=group_name).first() + if not group: + session.close() + return jsonify({"error": f"Gruppe '{group_name}' nicht gefunden"}), 404 + else: + session.close() + return jsonify({"error": "Entweder group_id oder group_name ist erforderlich"}), 400 + + # WICHTIG: group.id vor dem Schließen puffern, um DetachedInstanceError zu vermeiden + target_group_id = group.id + + session.query(Client).filter(Client.uuid.in_(client_ids)).update( + {Client.group_id: target_group_id}, synchronize_session=False + ) + session.commit() + session.close() + + # MQTT: Gruppenzuordnungen für alle betroffenen Clients publizieren (nutzt gecachten target_group_id) + client_group_mappings = { + client_id: target_group_id for client_id in client_ids} + success_count, failed_count = publish_multiple_client_groups( + client_group_mappings) + + response = {"success": True} + if failed_count > 0: + response[ + "warning"] = f"Gruppenzuordnung gespeichert, aber {failed_count} MQTT-Publishing(s) fehlgeschlagen" + + return jsonify(response) + + +@clients_bp.route("/", methods=["PATCH"]) +def update_client(uuid): + data = request.get_json() + session = Session() + client = session.query(Client).filter_by(uuid=uuid).first() + if not client: + session.close() + return jsonify({"error": "Client nicht gefunden"}), 404 + allowed_fields = ["description", "model"] + updated = False + for field in allowed_fields: + if field in data: + setattr(client, field, data[field]) + updated = True + if updated: + session.commit() + result = {"success": True} + else: + result = {"error": "Keine gültigen Felder zum Aktualisieren übergeben"} + session.close() + return jsonify(result) + + +# Neue Route: Liefert die aktuelle group_id für einen Client +@clients_bp.route("//group", methods=["GET"]) +def get_client_group(uuid): + session = Session() + client = session.query(Client).filter_by(uuid=uuid).first() + if not client: + session.close() + return jsonify({"error": "Client nicht gefunden"}), 404 + group_id = client.group_id + session.close() + return jsonify({"group_id": group_id}) + +# Neue Route: Liefert alle Clients mit Alive-Status + + +@clients_bp.route("/with_alive_status", methods=["GET"]) +def get_clients_with_alive_status(): + session = Session() + clients = session.query(Client).all() + result = [] + for c in clients: + result.append({ + "uuid": c.uuid, + "description": c.description, + "ip": c.ip, + "last_alive": c.last_alive.isoformat() if c.last_alive else None, + "is_active": c.is_active, + "is_alive": bool(c.last_alive and c.is_active), + }) + session.close() + return jsonify(result) + + +@clients_bp.route("//restart", methods=["POST"]) +def restart_client(uuid): + """ + Route to restart a specific client by UUID. + Sends an MQTT message to the broker to trigger the restart. + """ + import paho.mqtt.client as mqtt + import json + + # MQTT broker configuration + MQTT_BROKER = "mqtt" + MQTT_PORT = 1883 + MQTT_TOPIC = f"clients/{uuid}/restart" + + # Connect to the database to check if the client exists + session = Session() + client = session.query(Client).filter_by(uuid=uuid).first() + if not client: + session.close() + return jsonify({"error": "Client nicht gefunden"}), 404 + session.close() + + # Send MQTT message + try: + mqtt_client = mqtt.Client() + mqtt_client.connect(MQTT_BROKER, MQTT_PORT) + payload = {"action": "restart"} + mqtt_client.publish(MQTT_TOPIC, json.dumps(payload)) + mqtt_client.disconnect() + return jsonify({"success": True, "message": f"Restart signal sent to client {uuid}"}), 200 + except Exception as e: + return jsonify({"error": f"Failed to send MQTT message: {str(e)}"}), 500 + + +@clients_bp.route("/", methods=["DELETE"]) +def delete_client(uuid): + session = Session() + client = session.query(Client).filter_by(uuid=uuid).first() + if not client: + session.close() + return jsonify({"error": "Client nicht gefunden"}), 404 + + session.delete(client) + session.commit() + session.close() + + # MQTT: Retained message für gelöschten Client entfernen + mqtt_success = delete_client_group_message(uuid) + + response = {"success": True} + if not mqtt_success: + response["warning"] = "Client gelöscht, aber MQTT-Message-Löschung fehlgeschlagen" + + return jsonify(response) diff --git a/server/routes/conversions.py b/server/routes/conversions.py new file mode 100644 index 0000000..c6dc770 --- /dev/null +++ b/server/routes/conversions.py @@ -0,0 +1,94 @@ +from flask import Blueprint, jsonify, request +from server.database import Session +from models.models import Conversion, ConversionStatus, EventMedia, MediaType +from server.task_queue import get_queue +from server.worker import convert_event_media_to_pdf +from datetime import datetime, timezone +import hashlib + +conversions_bp = Blueprint("conversions", __name__, + url_prefix="/api/conversions") + + +def sha256_file(abs_path: str) -> str: + h = hashlib.sha256() + with open(abs_path, "rb") as f: + for chunk in iter(lambda: f.read(8192), b""): + h.update(chunk) + return h.hexdigest() + + +@conversions_bp.route("//pdf", methods=["POST"]) +def ensure_conversion(media_id: int): + session = Session() + try: + media = session.query(EventMedia).get(media_id) + if not media or not media.file_path: + return jsonify({"error": "Media not found or no file"}), 404 + + # Only enqueue for office presentation formats + if media.media_type not in {MediaType.ppt, MediaType.pptx, MediaType.odp}: + return jsonify({"message": "No conversion required for this media_type"}), 200 + + # Compute file hash + import os + base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + media_root = os.path.join(base_dir, "media") + abs_source = os.path.join(media_root, media.file_path) + file_hash = sha256_file(abs_source) + + # Find or create conversion row + conv = ( + session.query(Conversion) + .filter_by( + source_event_media_id=media.id, + target_format="pdf", + file_hash=file_hash, + ) + .one_or_none() + ) + if not conv: + conv = Conversion( + source_event_media_id=media.id, + target_format="pdf", + status=ConversionStatus.pending, + file_hash=file_hash, + ) + session.add(conv) + session.commit() + + # Enqueue if not already processing/ready + if conv.status in {ConversionStatus.pending, ConversionStatus.failed}: + q = get_queue() + job = q.enqueue(convert_event_media_to_pdf, conv.id) + return jsonify({"id": conv.id, "status": conv.status.value, "job_id": job.get_id()}), 202 + else: + return jsonify({"id": conv.id, "status": conv.status.value, "target_path": conv.target_path}), 200 + finally: + session.close() + + +@conversions_bp.route("//status", methods=["GET"]) +def conversion_status(media_id: int): + session = Session() + try: + conv = ( + session.query(Conversion) + .filter_by(source_event_media_id=media_id, target_format="pdf") + .order_by(Conversion.id.desc()) + .first() + ) + if not conv: + return jsonify({"status": "missing"}), 404 + return jsonify( + { + "id": conv.id, + "status": conv.status.value, + "target_path": conv.target_path, + "started_at": conv.started_at.isoformat() if conv.started_at else None, + "completed_at": conv.completed_at.isoformat() if conv.completed_at else None, + "error_message": conv.error_message, + } + ) + finally: + session.close() diff --git a/server/routes/eventmedia.py b/server/routes/eventmedia.py new file mode 100644 index 0000000..80a508b --- /dev/null +++ b/server/routes/eventmedia.py @@ -0,0 +1,261 @@ +from re import A +from flask import Blueprint, request, jsonify, send_from_directory +from server.database import Session +from models.models import EventMedia, MediaType, Conversion, ConversionStatus +from server.task_queue import get_queue +from server.worker import convert_event_media_to_pdf +import hashlib +import os + +eventmedia_bp = Blueprint('eventmedia', __name__, url_prefix='/api/eventmedia') + +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +MEDIA_ROOT = os.path.join(BASE_DIR, 'media') + + +def get_param(key, default=None): + # Reihenfolge: form > json > args + if request.form and key in request.form: + return request.form.get(key, default) + if request.is_json and request.json and key in request.json: + return request.json.get(key, default) + return request.args.get(key, default) + +# --- FileManager: List, Create Folder, Rename, Delete, Move --- + + +@eventmedia_bp.route('/filemanager/operations', methods=['GET', 'POST']) +def filemanager_operations(): + action = get_param('action') + path = get_param('path', '/') + name = get_param('name') + new_name = get_param('newName') + target_path = get_param('targetPath') + + full_path = os.path.join(MEDIA_ROOT, path.lstrip('/')) + + print(action, path, name, new_name, target_path, full_path) # Debug-Ausgabe + + if action == 'read': + # List files and folders + items = [] + session = Session() + for entry in os.scandir(full_path): + item = { + 'name': entry.name, + 'isFile': entry.is_file(), + 'size': entry.stat().st_size, + 'type': os.path.splitext(entry.name)[1][1:] if entry.is_file() else '', + 'hasChild': entry.is_dir() + } + # Wenn Datei, versuche Upload-Datum aus DB zu holen + if entry.is_file(): + media = session.query(EventMedia).filter_by( + url=entry.name).first() + if media and media.uploaded_at: + # FileManager erwartet UNIX-Timestamp (Sekunden) + item['dateModified'] = int(media.uploaded_at.timestamp()) + else: + item['dateModified'] = entry.stat().st_mtime + else: + item['dateModified'] = entry.stat().st_mtime + items.append(item) + session.close() + return jsonify({'files': items, 'cwd': {'name': os.path.basename(full_path), 'path': path}}) + + elif action == 'details': + # Details für eine oder mehrere Dateien zurückgeben + names = request.form.getlist('names[]') or (request.json.get( + 'names') if request.is_json and request.json else []) + path = get_param('path', '/') + details = [] + session = Session() + for name in names: + file_path = os.path.join(MEDIA_ROOT, path.lstrip('/'), name) + media = session.query(EventMedia).filter_by(url=name).first() + if os.path.isfile(file_path): + detail = { + 'name': name, + 'size': os.path.getsize(file_path), + 'dateModified': int(media.uploaded_at.timestamp()) if media and media.uploaded_at else int(os.path.getmtime(file_path)), + 'type': os.path.splitext(name)[1][1:], + 'hasChild': False, + 'isFile': True, + 'description': media.message_content if media else '', + # weitere Felder nach Bedarf + } + details.append(detail) + session.close() + return jsonify({'details': details}) + elif action == 'delete': + for item in request.form.getlist('names[]'): + item_path = os.path.join(full_path, item) + if os.path.isdir(item_path): + os.rmdir(item_path) + else: + os.remove(item_path) + return jsonify({'success': True}) + elif action == 'rename': + src = os.path.join(full_path, name) + dst = os.path.join(full_path, new_name) + os.rename(src, dst) + return jsonify({'success': True}) + elif action == 'move': + src = os.path.join(full_path, name) + dst = os.path.join(MEDIA_ROOT, target_path.lstrip('/'), name) + os.rename(src, dst) + return jsonify({'success': True}) + elif action == 'create': + os.makedirs(os.path.join(full_path, name), exist_ok=True) + return jsonify({'success': True}) + else: + return jsonify({'error': 'Unknown action'}), 400 + +# --- FileManager: Upload --- + + +@eventmedia_bp.route('/filemanager/upload', methods=['POST']) +def filemanager_upload(): + session = Session() + # Korrigiert: Erst aus request.form, dann aus request.args lesen + path = request.form.get('path') or request.args.get('path', '/') + upload_path = os.path.join(MEDIA_ROOT, path.lstrip('/')) + os.makedirs(upload_path, exist_ok=True) + for file in request.files.getlist('uploadFiles'): + file_path = os.path.join(upload_path, file.filename) + file.save(file_path) + ext = os.path.splitext(file.filename)[1][1:].lower() + try: + media_type = MediaType(ext) + except ValueError: + media_type = MediaType.other + from datetime import datetime, timezone + media = EventMedia( + media_type=media_type, + url=file.filename, + file_path=os.path.relpath(file_path, MEDIA_ROOT), + uploaded_at=datetime.now(timezone.utc) + ) + session.add(media) + session.commit() + + # Enqueue conversion for office presentation types + if media_type in {MediaType.ppt, MediaType.pptx, MediaType.odp}: + # compute file hash + h = hashlib.sha256() + with open(file_path, 'rb') as f: + for chunk in iter(lambda: f.read(8192), b""): + h.update(chunk) + file_hash = h.hexdigest() + + # upsert Conversion row + conv = ( + session.query(Conversion) + .filter_by( + source_event_media_id=media.id, + target_format='pdf', + file_hash=file_hash, + ) + .one_or_none() + ) + if not conv: + conv = Conversion( + source_event_media_id=media.id, + target_format='pdf', + status=ConversionStatus.pending, + file_hash=file_hash, + ) + session.add(conv) + session.commit() + + if conv.status in {ConversionStatus.pending, ConversionStatus.failed}: + q = get_queue() + q.enqueue(convert_event_media_to_pdf, conv.id) + + session.commit() + return jsonify({'success': True}) + +# --- FileManager: Download --- + + +@eventmedia_bp.route('/filemanager/download', methods=['GET']) +def filemanager_download(): + path = request.args.get('path', '/') + names = request.args.getlist('names[]') + # Nur Einzel-Download für Beispiel + if names: + file_path = os.path.join(MEDIA_ROOT, path.lstrip('/'), names[0]) + return send_from_directory(os.path.dirname(file_path), os.path.basename(file_path), as_attachment=True) + return jsonify({'error': 'No file specified'}), 400 + +# --- FileManager: Get Image (optional, für Thumbnails) --- + + +@eventmedia_bp.route('/filemanager/get-image', methods=['GET']) +def filemanager_get_image(): + path = request.args.get('path', '/') + file_path = os.path.join(MEDIA_ROOT, path.lstrip('/')) + return send_from_directory(os.path.dirname(file_path), os.path.basename(file_path)) + +# --- EventMedia-API: Metadaten-Liste (wie gehabt) --- + + +@eventmedia_bp.route('', methods=['GET']) +def list_media(): + session = Session() + media = session.query(EventMedia).all() + return jsonify([m.to_dict() for m in media]) + +# --- EventMedia-API: Metadaten-Update --- + + +@eventmedia_bp.route('/', methods=['PUT']) +def update_media(media_id): + session = Session() + media = session.query(EventMedia).get(media_id) + if not media: + return jsonify({'error': 'Not found'}), 404 + data = request.json + media.url = data.get('title', media.url) + media.message_content = data.get('description', media.message_content) + # Event-Zuordnung ggf. ergänzen + session.commit() + return jsonify(media.to_dict()) + + +@eventmedia_bp.route('/find_by_filename', methods=['GET']) +def find_by_filename(): + filename = request.args.get('filename') + if not filename: + return jsonify({'error': 'Missing filename'}), 400 + session = Session() + # Suche nach exaktem Dateinamen in url oder file_path + media = session.query(EventMedia).filter( + (EventMedia.url == filename) | ( + EventMedia.file_path.like(f"%{filename}")) + ).first() + if not media: + return jsonify({'error': 'Not found'}), 404 + return jsonify({ + 'id': media.id, + 'file_path': media.file_path, + 'url': media.url + }) + + +@eventmedia_bp.route('/', methods=['GET']) +def get_media_by_id(media_id): + session = Session() + media = session.query(EventMedia).get(media_id) + if not media: + session.close() + return jsonify({'error': 'Not found'}), 404 + result = { + 'id': media.id, + 'file_path': media.file_path, + 'url': media.url, + 'name': media.url, # oder ein anderes Feld für den Namen + 'media_type': media.media_type.name if media.media_type else None + } + session.close() + return jsonify(result) diff --git a/server/routes/events.py b/server/routes/events.py new file mode 100644 index 0000000..69b0dd8 --- /dev/null +++ b/server/routes/events.py @@ -0,0 +1,169 @@ +from flask import Blueprint, request, jsonify +from server.database import Session +from models.models import Event, EventMedia, MediaType +from datetime import datetime, timezone +from sqlalchemy import and_ +import sys +sys.path.append('/workspace') + +events_bp = Blueprint("events", __name__, url_prefix="/api/events") + + +def get_icon_for_type(event_type): + # Lucide-Icon-Namen als String + return { + "presentation": "Presentation", # <--- geändert! + "website": "Globe", + "video": "Video", + "message": "MessageSquare", + "webuntis": "School", + }.get(event_type, "") + + +@events_bp.route("", methods=["GET"]) +def get_events(): + session = Session() + start = request.args.get("start") + end = request.args.get("end") + group_id = request.args.get("group_id") + show_inactive = request.args.get( + "show_inactive", "0") == "1" # Checkbox-Logik + + now = datetime.now(timezone.utc) + events_query = session.query(Event) + if group_id: + events_query = events_query.filter(Event.group_id == int(group_id)) + events = events_query.all() + + result = [] + for e in events: + # Zeitzonen-Korrektur für e.end + if e.end and e.end.tzinfo is None: + end_dt = e.end.replace(tzinfo=timezone.utc) + else: + end_dt = e.end + + # Setze is_active auf False, wenn Termin vorbei ist + if end_dt and end_dt < now and e.is_active: + e.is_active = False + session.commit() + if show_inactive or e.is_active: + result.append({ + "Id": str(e.id), + "GroupId": e.group_id, + "Subject": e.title, + "StartTime": e.start.isoformat() if e.start else None, + "EndTime": e.end.isoformat() if e.end else None, + "IsAllDay": False, + "MediaId": e.event_media_id, + "Type": e.event_type.value if e.event_type else None, # <-- Enum zu String! + "Icon": get_icon_for_type(e.event_type.value if e.event_type else None), + }) + session.close() + return jsonify(result) + + +@events_bp.route("/", methods=["DELETE"]) +def delete_event(event_id): + session = Session() + event = session.query(Event).filter_by(id=event_id).first() + if not event: + session.close() + return jsonify({"error": "Termin nicht gefunden"}), 404 + session.delete(event) + session.commit() + session.close() + return jsonify({"success": True}) + + +@events_bp.route("", methods=["POST"]) +def create_event(): + data = request.json + session = Session() + + # Pflichtfelder prüfen + required = ["group_id", "title", "description", + "start", "end", "event_type", "created_by"] + for field in required: + if field not in data: + return jsonify({"error": f"Missing field: {field}"}), 400 + + event_type = data["event_type"] + event_media_id = None + slideshow_interval = None + + # Präsentation: event_media_id und slideshow_interval übernehmen + if event_type == "presentation": + event_media_id = data.get("event_media_id") + slideshow_interval = data.get("slideshow_interval") + if not event_media_id: + return jsonify({"error": "event_media_id required for presentation"}), 400 + + # Website: Webseite als EventMedia anlegen und ID übernehmen + if event_type == "website": + website_url = data.get("website_url") + if not website_url: + return jsonify({"error": "website_url required for website"}), 400 + # EventMedia für Webseite anlegen + media = EventMedia( + media_type=MediaType.website, + url=website_url, + file_path=website_url + ) + session.add(media) + session.commit() + event_media_id = media.id + + # created_by aus den Daten holen, Default: None + created_by = data.get("created_by") + + # Start- und Endzeit in UTC umwandeln, falls kein Zulu-Zeitstempel + start = datetime.fromisoformat(data["start"]) + end = datetime.fromisoformat(data["end"]) + if start.tzinfo is None: + start = start.astimezone(timezone.utc) + if end.tzinfo is None: + end = end.astimezone(timezone.utc) + + # Event anlegen + event = Event( + group_id=data["group_id"], + title=data["title"], + description=data["description"], + start=start, + end=end, + event_type=event_type, + is_active=True, + event_media_id=event_media_id, + slideshow_interval=slideshow_interval, + created_by=created_by # <--- HIER hinzugefügt + ) + session.add(event) + session.commit() + return jsonify({"success": True, "event_id": event.id}) + + +@events_bp.route("/", methods=["PUT"]) +def update_event(event_id): + data = request.json + session = Session() + event = session.query(Event).filter_by(id=event_id).first() + if not event: + session.close() + return jsonify({"error": "Termin nicht gefunden"}), 404 + + event.title = data.get("title", event.title) + event.description = data.get("description", event.description) + event.start = datetime.fromisoformat( + data["start"]) if "start" in data else event.start + event.end = datetime.fromisoformat( + data["end"]) if "end" in data else event.end + event.event_type = data.get("event_type", event.event_type) + event.event_media_id = data.get("event_media_id", event.event_media_id) + event.slideshow_interval = data.get( + "slideshow_interval", event.slideshow_interval) + event.created_by = data.get("created_by", event.created_by) + session.commit() + event_id_return = event.id # <-- ID vor session.close() speichern! + session.close() + return jsonify({"success": True, "event_id": event_id_return}) diff --git a/server/routes/files.py b/server/routes/files.py new file mode 100644 index 0000000..3012e2e --- /dev/null +++ b/server/routes/files.py @@ -0,0 +1,68 @@ +from flask import Blueprint, jsonify, send_from_directory +from server.database import Session +from models.models import EventMedia +import os + +# Blueprint for direct file downloads by media ID +files_bp = Blueprint("files", __name__, url_prefix="/api/files") + +# Reuse the same media root convention as eventmedia.py +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +MEDIA_ROOT = os.path.join(BASE_DIR, "media") + + +@files_bp.route("//", methods=["GET"]) +def download_media_file(media_id: int, filename: str): + """ + Download the stored media file for a given EventMedia ID. + + URL format example: + /api/files/26/LPUV4I_Folien_Nowitzki_Bewertungskriterien.pptx + + Behavior: + - Looks up EventMedia by ID + - Validates requested filename against stored metadata (best-effort) + - Serves the file from server/media using the stored relative file_path + """ + session = Session() + media = session.query(EventMedia).get(media_id) + if not media: + session.close() + return jsonify({"error": "Not found"}), 404 + + # Prefer the stored relative file_path; fall back to the URL/filename + rel_path = media.file_path or media.url + + # Basic filename consistency check to avoid leaking other files + # Only enforce if media.url is present + if media.url and os.path.basename(filename) != os.path.basename(media.url): + session.close() + return jsonify({ + "error": "Filename mismatch", + "expected": os.path.basename(media.url), + "got": os.path.basename(filename), + }), 400 + + abs_path = os.path.join(MEDIA_ROOT, rel_path) + + # Ensure file exists + if not os.path.isfile(abs_path): + session.close() + return jsonify({"error": "File not found on server"}), 404 + + # Serve as attachment (download) + directory = os.path.dirname(abs_path) + served_name = os.path.basename(abs_path) + session.close() + return send_from_directory(directory, served_name, as_attachment=True) + + +@files_bp.route("/converted/", methods=["GET"]) +def download_converted(relpath: str): + """Serve converted files (e.g., PDFs) relative to media/converted.""" + abs_path = os.path.join(MEDIA_ROOT, relpath) + if not abs_path.startswith(MEDIA_ROOT): + return jsonify({"error": "Invalid path"}), 400 + if not os.path.isfile(abs_path): + return jsonify({"error": "File not found"}), 404 + return send_from_directory(os.path.dirname(abs_path), os.path.basename(abs_path), as_attachment=True) diff --git a/server/routes/groups.py b/server/routes/groups.py new file mode 100644 index 0000000..a7d7403 --- /dev/null +++ b/server/routes/groups.py @@ -0,0 +1,189 @@ +from models.models import Client +# Neue Route: Liefert alle Gruppen mit zugehörigen Clients und deren Alive-Status + +from server.database import Session +from models.models import ClientGroup +from flask import Blueprint, request, jsonify +from sqlalchemy import func +import sys +import os +from datetime import datetime, timedelta + +sys.path.append('/workspace') + +groups_bp = Blueprint("groups", __name__, url_prefix="/api/groups") + + +def get_grace_period(): + """Wählt die Grace-Periode abhängig von ENV.""" + env = os.environ.get("ENV", "production").lower() + if env == "development" or env == "dev": + return int(os.environ.get("HEARTBEAT_GRACE_PERIOD_DEV", "15")) + return int(os.environ.get("HEARTBEAT_GRACE_PERIOD_PROD", "180")) + + +def is_client_alive(last_alive, is_active): + """Berechnet, ob ein Client als alive gilt.""" + if not last_alive or not is_active: + return False + grace_period = get_grace_period() + # last_alive kann ein String oder datetime sein + if isinstance(last_alive, str): + last_alive_str = last_alive[:- + 1] if last_alive.endswith('Z') else last_alive + try: + last_alive_dt = datetime.fromisoformat(last_alive_str) + except Exception: + return False + else: + last_alive_dt = last_alive + return datetime.utcnow() - last_alive_dt <= timedelta(seconds=grace_period) + + +@groups_bp.route("", methods=["POST"]) +def create_group(): + data = request.get_json() + name = data.get("name") + if not name or not name.strip(): + return jsonify({"error": "Gruppenname erforderlich"}), 400 + + session = Session() + if session.query(ClientGroup).filter_by(name=name).first(): + session.close() + return jsonify({"error": "Gruppe existiert bereits"}), 409 + + group = ClientGroup(name=name, is_active=True) + session.add(group) + session.commit() + result = { + "id": group.id, + "name": group.name, + "created_at": group.created_at.isoformat() if group.created_at else None, + "is_active": group.is_active, + } + session.close() + return jsonify(result), 201 + + +@groups_bp.route("", methods=["GET"]) +def get_groups(): + session = Session() + groups = session.query(ClientGroup).all() + result = [ + { + "id": g.id, + "name": g.name, + "created_at": g.created_at.isoformat() if g.created_at else None, + "is_active": g.is_active, + } + for g in groups + ] + session.close() + return jsonify(result) + + +@groups_bp.route("/", methods=["PUT"]) +def update_group(group_id): + data = request.get_json() + session = Session() + group = session.query(ClientGroup).filter_by(id=group_id).first() + if not group: + session.close() + return jsonify({"error": "Gruppe nicht gefunden"}), 404 + if "name" in data: + group.name = data["name"] + if "is_active" in data: + group.is_active = bool(data["is_active"]) + session.commit() + result = { + "id": group.id, + "name": group.name, + "created_at": group.created_at.isoformat() if group.created_at else None, + "is_active": group.is_active, + } + session.close() + return jsonify(result) + + +@groups_bp.route("/", methods=["DELETE"]) +def delete_group(group_id): + session = Session() + group = session.query(ClientGroup).filter_by(id=group_id).first() + if not group: + session.close() + return jsonify({"error": "Gruppe nicht gefunden"}), 404 + session.delete(group) + session.commit() + session.close() + return jsonify({"success": True}) + + +@groups_bp.route("/byname/", methods=["DELETE"]) +def delete_group_by_name(group_name): + session = Session() + group = session.query(ClientGroup).filter_by(name=group_name).first() + if not group: + session.close() + return jsonify({"error": "Gruppe nicht gefunden"}), 404 + session.delete(group) + session.commit() + session.close() + return jsonify({"success": True}) + + +@groups_bp.route("/byname/", methods=["PUT"]) +def rename_group_by_name(old_name): + data = request.get_json() + new_name = data.get("newName") + if not new_name or not new_name.strip(): + return jsonify({"error": "Neuer Name erforderlich"}), 400 + + session = Session() + group = session.query(ClientGroup).filter_by(name=old_name).first() + if not group: + session.close() + return jsonify({"error": "Gruppe nicht gefunden"}), 404 + + # Prüfe, ob der neue Name schon existiert + if session.query(ClientGroup).filter(func.binary(ClientGroup.name) == new_name).first(): + session.close() + return jsonify({"error": f'Gruppe mit dem Namen "{new_name}" existiert bereits', "duplicate_name": new_name}), 409 + + group.name = new_name + session.commit() + result = { + "id": group.id, + "name": group.name, + "created_at": group.created_at.isoformat() if group.created_at else None, + "is_active": group.is_active, + } + session.close() + return jsonify(result) + + +@groups_bp.route("/with_clients", methods=["GET"]) +def get_groups_with_clients(): + session = Session() + groups = session.query(ClientGroup).all() + result = [] + for g in groups: + clients = session.query(Client).filter_by(group_id=g.id).all() + client_list = [] + for c in clients: + client_list.append({ + "uuid": c.uuid, + "description": c.description, + "ip": c.ip, + "last_alive": c.last_alive.isoformat() if c.last_alive else None, + "is_active": c.is_active, + "is_alive": is_client_alive(c.last_alive, c.is_active), + }) + result.append({ + "id": g.id, + "name": g.name, + "created_at": g.created_at.isoformat() if g.created_at else None, + "is_active": g.is_active, + "clients": client_list, + }) + session.close() + return jsonify(result) diff --git a/server/routes/holidays.py b/server/routes/holidays.py new file mode 100644 index 0000000..09b2b72 --- /dev/null +++ b/server/routes/holidays.py @@ -0,0 +1,159 @@ +from flask import Blueprint, request, jsonify +from server.database import Session +from models.models import SchoolHoliday +from datetime import datetime +import csv +import io + +holidays_bp = Blueprint("holidays", __name__, url_prefix="/api/holidays") + + +@holidays_bp.route("", methods=["GET"]) +def list_holidays(): + session = Session() + region = request.args.get("region") + q = session.query(SchoolHoliday) + if region: + q = q.filter(SchoolHoliday.region == region) + rows = q.order_by(SchoolHoliday.start_date.asc()).all() + data = [r.to_dict() for r in rows] + session.close() + return jsonify({"holidays": data}) + + +@holidays_bp.route("/upload", methods=["POST"]) +def upload_holidays(): + """ + Accepts a CSV/TXT file upload (multipart/form-data). + + Supported formats: + 1) Headered CSV with columns (case-insensitive): name, start_date, end_date[, region] + - Dates: YYYY-MM-DD, DD.MM.YYYY, YYYY/MM/DD, or YYYYMMDD + 2) Headerless CSV/TXT lines with columns: + [internal, name, start_yyyymmdd, end_yyyymmdd, optional_internal] + - Only columns 2-4 are used; 1 and 5 are ignored. + """ + if "file" not in request.files: + return jsonify({"error": "No file part"}), 400 + file = request.files["file"] + if file.filename == "": + return jsonify({"error": "No selected file"}), 400 + + try: + raw = file.read() + # Try UTF-8 first (strict), then cp1252, then latin-1 as last resort + try: + content = raw.decode("utf-8") + except UnicodeDecodeError: + try: + content = raw.decode("cp1252") + except UnicodeDecodeError: + content = raw.decode("latin-1", errors="replace") + + sniffer = csv.Sniffer() + dialect = None + try: + sample = content[:2048] + # Some files may contain a lot of quotes; allow Sniffer to guess delimiter + dialect = sniffer.sniff(sample) + except Exception: + pass + + def parse_date(s: str): + s = (s or "").strip() + if not s: + return None + # Numeric YYYYMMDD + if s.isdigit() and len(s) == 8: + try: + return datetime.strptime(s, "%Y%m%d").date() + except ValueError: + pass + # Common formats + for fmt in ("%Y-%m-%d", "%d.%m.%Y", "%Y/%m/%d"): + try: + return datetime.strptime(s, fmt).date() + except ValueError: + continue + raise ValueError(f"Unsupported date format: {s}") + + session = Session() + inserted = 0 + updated = 0 + + # First, try headered CSV via DictReader + dict_reader = csv.DictReader(io.StringIO( + content), dialect=dialect) if dialect else csv.DictReader(io.StringIO(content)) + fieldnames_lower = [h.lower() for h in (dict_reader.fieldnames or [])] + has_required_headers = {"name", "start_date", + "end_date"}.issubset(set(fieldnames_lower)) + + def upsert(name: str, start_date, end_date, region=None): + nonlocal inserted, updated + if not name or not start_date or not end_date: + return + existing = ( + session.query(SchoolHoliday) + .filter( + SchoolHoliday.name == name, + SchoolHoliday.start_date == start_date, + SchoolHoliday.end_date == end_date, + SchoolHoliday.region.is_( + region) if region is None else SchoolHoliday.region == region, + ) + .first() + ) + if existing: + existing.region = region + existing.source_file_name = file.filename + updated += 1 + else: + session.add(SchoolHoliday( + name=name, + start_date=start_date, + end_date=end_date, + region=region, + source_file_name=file.filename, + )) + inserted += 1 + + if has_required_headers: + for row in dict_reader: + norm = {k.lower(): (v or "").strip() for k, v in row.items()} + name = norm.get("name") + try: + start_date = parse_date(norm.get("start_date")) + end_date = parse_date(norm.get("end_date")) + except ValueError: + # Skip rows with unparseable dates + continue + region = (norm.get("region") + or None) if "region" in norm else None + upsert(name, start_date, end_date, region) + else: + # Fallback: headerless rows -> use columns [1]=name, [2]=start, [3]=end + reader = csv.reader(io.StringIO( + content), dialect=dialect) if dialect else csv.reader(io.StringIO(content)) + for row in reader: + if not row: + continue + # tolerate varying column counts (4 or 5); ignore first and optional last + cols = [c.strip() for c in row] + if len(cols) < 4: + # Not enough data + continue + name = cols[1].strip().strip('"') + start_raw = cols[2] + end_raw = cols[3] + try: + start_date = parse_date(start_raw) + end_date = parse_date(end_raw) + except ValueError: + continue + upsert(name, start_date, end_date, None) + + session.commit() + session.close() + return jsonify({"success": True, "inserted": inserted, "updated": updated}) + except Exception as e: + return jsonify({"error": str(e)}), 400 diff --git a/server/routes/setup.py b/server/routes/setup.py new file mode 100644 index 0000000..bf18f4d --- /dev/null +++ b/server/routes/setup.py @@ -0,0 +1,21 @@ +from flask import Blueprint, jsonify +from server.database import get_db +from models.models import Client + +bp = Blueprint('setup', __name__, url_prefix='/api/setup') + +@bp.route('/clients_without_description', methods=['GET']) +def clients_without_description(): + db = get_db() + clients = db.query(Client).filter(Client.description == None).all() + result = [] + for c in clients: + result.append({ + 'uuid': c.uuid, + 'hostname': c.hostname, + 'ip_address': c.ip_address, + 'last_alive': c.last_alive, + 'created_at': c.created_at, + 'group': c.group_id, + }) + return jsonify(result) diff --git a/server/rq_worker.py b/server/rq_worker.py new file mode 100644 index 0000000..6d0cabf --- /dev/null +++ b/server/rq_worker.py @@ -0,0 +1,15 @@ +import os +from rq import Worker +from server.task_queue import get_queue, get_redis_url +import redis + + +def main(): + conn = redis.from_url(get_redis_url()) + # Single queue named 'conversions' + w = Worker([get_queue().name], connection=conn) + w.work(with_scheduler=True) + + +if __name__ == "__main__": + main() diff --git a/server/sync_existing_clients.py b/server/sync_existing_clients.py new file mode 100644 index 0000000..e835a13 --- /dev/null +++ b/server/sync_existing_clients.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +""" +Einmaliges Skript zur Synchronisation aller bestehenden Client-Gruppenzuordnungen +Verwendung: python sync_existing_clients.py +""" +from server.mqtt_helper import publish_multiple_client_groups +from models.models import Client +from server.database import Session +import sys +import os +sys.path.append('/workspace') + + +def main(): + print("Synchronisiere bestehende Client-Gruppenzuordnungen mit MQTT...") + + session = Session() + try: + # Alle aktiven Clients abrufen + clients = session.query(Client).filter(Client.is_active == True).all() + + if not clients: + print("Keine aktiven Clients gefunden.") + return + + print(f"Gefunden: {len(clients)} aktive Clients") + + # Mapping erstellen + client_group_mappings = { + client.uuid: client.group_id for client in clients} + + # Alle auf einmal publizieren + success_count, failed_count = publish_multiple_client_groups( + client_group_mappings) + + print(f"Synchronisation abgeschlossen:") + print(f" Erfolgreich: {success_count}") + print(f" Fehlgeschlagen: {failed_count}") + print(f" Gesamt: {len(clients)}") + + if failed_count == 0: + print("✅ Alle Clients erfolgreich synchronisiert!") + else: + print( + f"⚠️ {failed_count} Clients konnten nicht synchronisiert werden.") + + except Exception as e: + print(f"Fehler: {e}") + finally: + session.close() + + +if __name__ == "__main__": + main() diff --git a/server/task_queue.py b/server/task_queue.py new file mode 100644 index 0000000..9250395 --- /dev/null +++ b/server/task_queue.py @@ -0,0 +1,14 @@ +import os +import redis +from rq import Queue + + +def get_redis_url() -> str: + # Default to local Redis service name in compose network + return os.getenv("REDIS_URL", "redis://redis:6379/0") + + +def get_queue(name: str = "conversions") -> Queue: + conn = redis.from_url(get_redis_url()) + # 10 minutes default + return Queue(name, connection=conn, default_timeout=600) diff --git a/server/worker.py b/server/worker.py new file mode 100644 index 0000000..e47e03c --- /dev/null +++ b/server/worker.py @@ -0,0 +1,94 @@ +import os +import traceback +from datetime import datetime, timezone + +import requests +from sqlalchemy.orm import Session as SASession + +from server.database import Session +from models.models import Conversion, ConversionStatus, EventMedia, MediaType + +GOTENBERG_URL = os.getenv("GOTENBERG_URL", "http://gotenberg:3000") + + +def _now(): + return datetime.now(timezone.utc) + + +def convert_event_media_to_pdf(conversion_id: int): + """ + Job entry point: convert a single EventMedia to PDF using Gotenberg. + + Steps: + - Load conversion + source media + - Set status=processing, started_at + - POST to Gotenberg /forms/libreoffice/convert with the source file bytes + - Save response bytes to target_path + - Set status=ready, completed_at, target_path + - On error: set status=failed, error_message + """ + session: SASession = Session() + try: + conv: Conversion = session.query(Conversion).get(conversion_id) + if not conv: + return + + media: EventMedia = session.query( + EventMedia).get(conv.source_event_media_id) + if not media or not media.file_path: + conv.status = ConversionStatus.failed + conv.error_message = "Source media or file_path missing" + conv.completed_at = _now() + session.commit() + return + + conv.status = ConversionStatus.processing + conv.started_at = _now() + session.commit() + + # Get the server directory (where this worker.py file is located) + server_dir = os.path.dirname(os.path.abspath(__file__)) + media_root = os.path.join(server_dir, "media") + abs_source = os.path.join(media_root, media.file_path) + # Output target under media/converted + converted_dir = os.path.join(media_root, "converted") + os.makedirs(converted_dir, exist_ok=True) + filename_wo_ext = os.path.splitext( + os.path.basename(media.file_path))[0] + pdf_name = f"{filename_wo_ext}.pdf" + abs_target = os.path.join(converted_dir, pdf_name) + + # Send to Gotenberg + with open(abs_source, "rb") as f: + files = {"files": (os.path.basename(abs_source), f)} + resp = requests.post( + f"{GOTENBERG_URL}/forms/libreoffice/convert", + files=files, + timeout=600, + ) + resp.raise_for_status() + + with open(abs_target, "wb") as out: + out.write(resp.content) + + conv.status = ConversionStatus.ready + # Store relative path under media/ + conv.target_path = os.path.relpath(abs_target, media_root) + conv.completed_at = _now() + session.commit() + except requests.exceptions.Timeout: + conv = session.query(Conversion).get(conversion_id) + if conv: + conv.status = ConversionStatus.failed + conv.error_message = "Conversion timeout" + conv.completed_at = _now() + session.commit() + except Exception as e: + conv = session.query(Conversion).get(conversion_id) + if conv: + conv.status = ConversionStatus.failed + conv.error_message = f"{e}\n{traceback.format_exc()}" + conv.completed_at = _now() + session.commit() + finally: + session.close() diff --git a/server/wsgi.py b/server/wsgi.py new file mode 100644 index 0000000..cd599d5 --- /dev/null +++ b/server/wsgi.py @@ -0,0 +1,53 @@ +# server/wsgi.py +from server.routes.eventmedia import eventmedia_bp +from server.routes.files import files_bp +from server.routes.events import events_bp +from server.routes.conversions import conversions_bp +from server.routes.holidays import holidays_bp +from server.routes.academic_periods import academic_periods_bp +from server.routes.groups import groups_bp +from server.routes.clients import clients_bp +from server.database import Session, engine +from flask import Flask, jsonify, send_from_directory, request +import glob +import os +import sys +sys.path.append('/workspace') + +app = Flask(__name__) + +# Blueprints importieren und registrieren + +app.register_blueprint(clients_bp) +app.register_blueprint(groups_bp) +app.register_blueprint(events_bp) +app.register_blueprint(eventmedia_bp) +app.register_blueprint(files_bp) +app.register_blueprint(holidays_bp) +app.register_blueprint(academic_periods_bp) +app.register_blueprint(conversions_bp) + + +@app.route("/health") +def health(): + return jsonify(status="ok") + + +@app.route("/") +def index(): + return "Hello from Infoscreen‐API!" + + +@app.route("/screenshots/") +def get_screenshot(uuid): + pattern = os.path.join("screenshots", f"{uuid}*.jpg") + files = glob.glob(pattern) + if not files: + # Dummy-Bild als Redirect oder direkt als Response + return jsonify({"error": "Screenshot not found", "dummy": "https://placehold.co/400x300?text=No+Screenshot"}), 404 + filename = os.path.basename(files[0]) + return send_from_directory("screenshots", filename) + + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=8000, debug=True) diff --git a/setup-deployment.sh b/setup-deployment.sh new file mode 100644 index 0000000..2c607b1 --- /dev/null +++ b/setup-deployment.sh @@ -0,0 +1,38 @@ +#!/bin/bash + +# Minimaler Setup für Produktions-Deployment +# Dieser Script erstellt nur die nötigen Dateien für Container-Deployment + +echo "🚀 Infoscreen Production Deployment Setup" + +# 1. Deployment-Ordner erstellen +mkdir -p deployment/{certs,config} + +# 2. Produktions docker-compose kopieren +cp docker-compose.prod.yml deployment/ +cp .env deployment/ +cp nginx.conf deployment/ + +# 3. Mosquitto-Konfiguration erstellen +cat > deployment/mosquitto.conf << 'EOF' +listener 1883 +allow_anonymous true +listener 9001 +protocol websockets +EOF + +# 4. SSL-Zertifikate kopieren (falls vorhanden) +if [ -f "certs/dev.crt" ] && [ -f "certs/dev.key" ]; then + cp certs/* deployment/certs/ + echo "✅ SSL-Zertifikate kopiert" +else + echo "⚠️ SSL-Zertifikate fehlen - werden auf Zielmaschine erstellt" +fi + +echo "" +echo "📦 Deployment-Paket erstellt in ./deployment/" +echo "" +echo "Nächste Schritte:" +echo "1. Kopieren Sie den 'deployment'-Ordner auf den Zielserver" +echo "2. Images bereitstellen (Registry oder TAR-Export)" +echo "3. docker compose -f docker-compose.prod.yml up -d" diff --git a/setup-dev-environment.sh b/setup-dev-environment.sh new file mode 100755 index 0000000..a09e4ef --- /dev/null +++ b/setup-dev-environment.sh @@ -0,0 +1,39 @@ +#!/bin/bash +# Environment Setup Script for Remote Development + +set -e + +echo "🔧 Setting up development environment..." + +# Create .env file from example +if [ ! -f .env ]; then + cp .env.example .env + echo "📝 Created .env file from template" + echo "⚠️ Please edit .env with your specific configuration" +fi + +# Create necessary directories +mkdir -p certs/ +mkdir -p mosquitto/{config,data,log} +mkdir -p server/media/converted +mkdir -p server/received_screenshots +mkdir -p server/screenshots + +# Set permissions for mosquitto +sudo chown -R 1883:1883 mosquitto/data mosquitto/log 2>/dev/null || true +sudo chmod 755 mosquitto/config mosquitto/data mosquitto/log + +# Generate development SSL certificates if they don't exist +if [ ! -f certs/dev.crt ] || [ ! -f certs/dev.key ]; then + echo "🔒 Generating development SSL certificates..." + openssl req -x509 -newkey rsa:4096 -keyout certs/dev.key -out certs/dev.crt -days 365 -nodes \ + -subj "/C=AT/ST=Vienna/L=Vienna/O=Development/CN=localhost" + echo "✅ SSL certificates generated" +fi + +echo "✅ Environment setup complete!" +echo "" +echo "📋 Next steps:" +echo "1. Edit .env file with your configuration" +echo "2. Run: docker compose up -d --build" +echo "3. Access dashboard at http://localhost:5173" diff --git a/start-dev.sh b/start-dev.sh new file mode 100755 index 0000000..2735f4e --- /dev/null +++ b/start-dev.sh @@ -0,0 +1,47 @@ +#!/bin/bash +# Quick Development Start Script + +set -e + +echo "🚀 Starting Infoscreen Development Environment..." + +# Check if Docker is running +if ! docker info > /dev/null 2>&1; then + echo "❌ Docker is not running. Please start Docker first." + exit 1 +fi + +# Check if .env exists +if [ ! -f .env ]; then + echo "❌ .env file not found. Please run setup-dev-environment.sh first." + exit 1 +fi + +# Start development stack +echo "📦 Starting development containers..." +docker compose up -d --build + +# Wait for services to be ready +echo "⏳ Waiting for services to start..." +sleep 10 + +# Show status +echo "📊 Container Status:" +docker compose ps + +echo "" +echo "🌐 Services Available:" +echo " Dashboard (Vite): http://localhost:5173" +echo " API (Flask): http://localhost:8000" +echo " API Health Check: http://localhost:8000/health" +echo " Database (MariaDB): localhost:3306" +echo " MQTT Broker: localhost:1883" +echo " MQTT WebSocket: localhost:9001" +echo " Python Debugger: localhost:5678" +echo "" +echo "📝 Useful Commands:" +echo " View logs: docker compose logs -f" +echo " Stop services: docker compose down" +echo " Restart service: docker compose restart [service-name]" +echo "" +echo "🎉 Development environment is ready!" diff --git a/tar-transfer.sh b/tar-transfer.sh new file mode 100755 index 0000000..0fb4774 --- /dev/null +++ b/tar-transfer.sh @@ -0,0 +1,50 @@ +#!/bin/bash +# Create tar archive and transfer to server + +SERVER_USER="admin_n" +SERVER_IP="192.168.43.201" # Replace with your actual server IP + +echo "📦 Creating workspace archive..." + +# Get current directory +CURRENT_DIR="$(pwd)" +echo "📁 Creating archive from: ${CURRENT_DIR}" + +# Create tar archive excluding unnecessary files +tar -czf infoscreen-workspace.tar.gz \ + --exclude='node_modules' \ + --exclude='__pycache__' \ + --exclude='.git' \ + --exclude='*.log' \ + --exclude='.vscode' \ + --exclude='dashboard/.vite' \ + --exclude='server/received_screenshots' \ + --exclude='server/screenshots' \ + --exclude='mosquitto/data' \ + --exclude='mosquitto/log' \ + . + +echo "📤 Transferring archive to server..." + +# Transfer archive +scp infoscreen-workspace.tar.gz ${SERVER_USER}@${SERVER_IP}:~/ + +echo "📥 Extracting on server..." + +# Extract on server +ssh ${SERVER_USER}@${SERVER_IP} " + mkdir -p ~/infoscreen_2025 && + cd ~/infoscreen_2025 && + tar -xzf ~/infoscreen-workspace.tar.gz && + rm ~/infoscreen-workspace.tar.gz +" + +# Clean up local archive +rm infoscreen-workspace.tar.gz + +echo "✅ Transfer and extraction complete!" +echo "" +echo "📋 Next steps:" +echo "1. SSH to server: ssh ${SERVER_USER}@${SERVER_IP}" +echo "2. Go to project: cd infoscreen_2025" +echo "3. Run setup: ./setup-dev-environment.sh" diff --git a/test-vm-setup.sh b/test-vm-setup.sh new file mode 100644 index 0000000..7a7f480 --- /dev/null +++ b/test-vm-setup.sh @@ -0,0 +1,78 @@ +#!/bin/bash +# Quick VM Setup Script für Infoscreen Deployment Test + +echo "🧪 Infoscreen VM Test Setup" +echo "==========================" + +# System Update +echo "📦 System aktualisieren..." +sudo apt update -y +sudo apt upgrade -y + +# Docker Installation +echo "🐳 Docker installieren..." +sudo apt install -y docker.io docker-compose-plugin curl wget htop + +# Docker aktivieren +sudo systemctl enable docker +sudo systemctl start docker + +# User zu Docker-Gruppe hinzufügen +sudo usermod -aG docker $USER + +# Firewall konfigurieren +echo "🔥 Firewall konfigurieren..." +sudo ufw --force enable +sudo ufw allow 22/tcp # SSH +sudo ufw allow 80/tcp # HTTP +sudo ufw allow 443/tcp # HTTPS + +# Test-Verzeichnis erstellen +mkdir -p ~/infoscreen-test +cd ~/infoscreen-test + +# Basis-Konfiguration erstellen +cat > docker-compose.test.yml << 'EOF' +version: '3.8' +services: + nginx: + image: nginx:alpine + ports: + - "80:80" + volumes: + - ./nginx-test.conf:/etc/nginx/nginx.conf:ro + + test-api: + image: httpd:alpine + environment: + - TEST=true +EOF + +cat > nginx-test.conf << 'EOF' +events {} +http { + server { + listen 80; + location / { + return 200 "✅ VM Test erfolgreich!\n"; + add_header Content-Type text/plain; + } + } +} +EOF + +echo "" +echo "✅ VM Setup abgeschlossen!" +echo "" +echo "Nächste Schritte:" +echo "1. Logout/Login für Docker-Gruppe" +echo "2. Test: docker run hello-world" +echo "3. Test: docker compose -f docker-compose.test.yml up -d" +echo "4. Test: curl http://localhost" +echo "5. Echtes Deployment: Dateien übertragen und starten" +echo "" +echo "🔍 System-Info:" +echo "Docker: $(docker --version)" +echo "Compose: $(docker compose version)" +echo "RAM: $(free -h | grep Mem | awk '{print $2}')" +echo "Disk: $(df -h / | tail -1 | awk '{print $4}') frei" diff --git a/transfer-to-server.sh b/transfer-to-server.sh new file mode 100755 index 0000000..0ef4ed9 --- /dev/null +++ b/transfer-to-server.sh @@ -0,0 +1,41 @@ +#!/bin/bash +# Transfer workspace to Ubuntu server using rsync + +# Configuration +SERVER_USER="admin_n" +SERVER_IP="192.168.43.201" # Replace with your actual server IP +REMOTE_PATH="~/infoscreen_server_2025" + +echo "🚀 Transferring workspace to Ubuntu server..." + +# Check if server IP is provided +if [ "$SERVER_IP" = "your-server-ip" ]; then + echo "❌ Please edit this script and replace 'your-server-ip' with your actual server IP" + exit 1 +fi + +# Get current directory (project root) +CURRENT_DIR="$(pwd)" +echo "📁 Transferring from: ${CURRENT_DIR}" + +# Transfer files using rsync (more efficient than scp) +rsync -avz --progress \ + --exclude='node_modules/' \ + --exclude='__pycache__/' \ + --exclude='.git/' \ + --exclude='*.log' \ + --exclude='.vscode/' \ + --exclude='dashboard/.vite/' \ + --exclude='server/received_screenshots/' \ + --exclude='server/screenshots/' \ + --exclude='mosquitto/data/' \ + --exclude='mosquitto/log/' \ + ./ ${SERVER_USER}@${SERVER_IP}:${REMOTE_PATH}/ + +echo "✅ Transfer complete!" +echo "" +echo "📋 Next steps on your server:" +echo "1. SSH to your server: ssh ${SERVER_USER}@${SERVER_IP}" +echo "2. Go to project: cd infoscreen_server_2025" +echo "3. Run setup: ./setup-dev-environment.sh" +echo "4. Start development: ./start-dev.sh"