diff --git a/.env.production.local b/.env.production.local new file mode 100644 index 0000000..524bbc1 --- /dev/null +++ b/.env.production.local @@ -0,0 +1,5 @@ +# Top-level production environment pointers for local testing (do not commit secrets) +VITE_STUDIO_URL=https://avanzacast-studio.bfzqqk.easypanel.host +VITE_BROADCASTPANEL_URL=https://avanzacast-broadcastpanel.bfzqqk.easypanel.host +VITE_TOKEN_SERVER_URL=https://avanzacast-servertokens.bfzqqk.easypanel.host + diff --git a/.github/workflows/e2e-playwright.yml b/.github/workflows/e2e-playwright.yml new file mode 100644 index 0000000..836d94e --- /dev/null +++ b/.github/workflows/e2e-playwright.yml @@ -0,0 +1,59 @@ +name: E2E Playwright - Studio Panel + +on: + workflow_dispatch: {} + push: + paths: + - 'packages/studio-panel/**' + +jobs: + playwright-e2e: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Use Node.js 20 + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies for studio-panel + working-directory: packages/studio-panel + run: | + npm ci + + - name: Install Playwright browsers + working-directory: packages/studio-panel + run: | + npx playwright install --with-deps + + - name: Run Playwright E2E script + working-directory: packages/studio-panel + env: + # override the URLs here if you want to use different targets in CI + BROADCAST_URL: ${{ secrets.BROADCAST_URL }} + STUDIO_ORIGIN: ${{ secrets.STUDIO_ORIGIN }} + run: | + node --experimental-vm-modules scripts/playwright_postmessage_test.mjs + + - name: Upload Playwright debug log + if: always() + uses: actions/upload-artifact@v4 + with: + name: playwright-debug-log + path: | + /tmp/playwright_debug.log + /tmp/playwright_run_output.log + /tmp/sim_postmessage_simulator.png + /tmp/sim_postmessage_studio.png + + - name: Print small summary + if: always() + working-directory: packages/studio-panel + run: | + echo "Artifacts uploaded: /tmp/playwright_debug.log, /tmp/playwright_run_output.log, screenshots" + ls -lh /tmp/playwright_debug.log /tmp/playwright_run_output.log || true + ls -lh /tmp/sim_postmessage_simulator.png /tmp/sim_postmessage_studio.png || true + diff --git a/DEPLOY_PROD.md b/DEPLOY_PROD.md new file mode 100644 index 0000000..b51f79a --- /dev/null +++ b/DEPLOY_PROD.md @@ -0,0 +1,72 @@ +# AvanzaCast - Production Deployment (Docker Compose) + +This file shows how to build and run the production stack locally (or on a server) with Docker Compose. It sets up: +- `backend-api` (token server) on port 4000 +- `studio-panel` served by nginx on port 80 inside container +- `broadcast-panel` on port 5175 +- `reverse-proxy` (nginx) mapping the three domains to containers + +Prerequisites +- Docker and docker-compose installed on the host +- DNS or hosts entries mapping the following hostnames to the server IP: + - `avanzacast-broadcastpanel.bfzqqk.easypanel.host` + - `avanzacast-studio.bfzqqk.easypanel.host` + - `avanzacast-servertokens.bfzqqk.easypanel.host` +- TLS/SSL: this example doesn't include certificates. Use a separate step with Certbot / Let's Encrypt or your load balancer to provide SSL. Do NOT expose token server over plain HTTP in production without TLS. + +Files created +- `docker-compose.prod.yml` - compose file to build and run the stack +- `docker/nginx/default.conf` - nginx config for reverse proxy +- `packages/backend-api/.env.production` - production environment variables for backend-api (placeholder) + +Build & Run + +1. Build and start the stack + +```bash +# from repo root +docker compose -f docker-compose.prod.yml up --build -d + +# check status +docker compose -f docker-compose.prod.yml ps +``` + +2. Verify backend health and CORS + +```bash +# verify backend health +curl -i http://localhost:4000/health + +# simulate the broadcast requesting token +curl -i -H "Origin: https://avanzacast-broadcastpanel.bfzqqk.easypanel.host" \ + "http://localhost:4000/api/token?room=studio-demo&username=simulator" +``` + +You should see an `Access-Control-Allow-Origin` header in the response. If it returns 500 with `LiveKit credentials not configured`, add real `LIVEKIT_API_KEY` and `LIVEKIT_API_SECRET` to `packages/backend-api/.env.production` and restart. + +3. Verify the UIs + +Open in browser (or use Playwright): +- https://avanzacast-broadcastpanel.bfzqqk.easypanel.host +- https://avanzacast-studio.bfzqqk.easypanel.host + +4. Run E2E test locally (Playwright) + +```bash +cd packages/studio-panel +chmod +x run_playwright_test.sh +./run_playwright_test.sh + +# artifacts will be in /tmp: +ls -lh /tmp/playwright_debug.log /tmp/playwright_run_output.log /tmp/sim_postmessage_simulator.png /tmp/sim_postmessage_studio.png +``` + +Troubleshooting +- If CORS is blocked: edit `packages/backend-api/src/index.ts` and ensure allowed origins include your domains, then rebuild/restart. +- If the backend dies with EBADF or IO errors: run `npx tsx src/index.ts` in foreground to get full stack trace, paste here. +- For TLS termination: configure nginx with certificates or put the stack behind a TLS-enabled LB. + +Security +- Do not commit real secrets to the repo. Use environment variables or a secret manager. The `.env.production` file created contains placeholders; replace with real values on the server. + + diff --git a/deploy/avanzacast-stack.service b/deploy/avanzacast-stack.service new file mode 100644 index 0000000..02e3ee6 --- /dev/null +++ b/deploy/avanzacast-stack.service @@ -0,0 +1,16 @@ +[Unit] +Description=AvanzaCast Docker Compose Stack +After=docker.service +Requires=docker.service + +[Service] +Type=oneshot +RemainAfterExit=yes +WorkingDirectory=/home/xesar/Documentos/Nextream/AvanzaCast +ExecStart=/usr/bin/docker compose -f /home/xesar/Documentos/Nextream/AvanzaCast/docker-compose.prod.yml up -d --build +ExecStop=/usr/bin/docker compose -f /home/xesar/Documentos/Nextream/AvanzaCast/docker-compose.prod.yml down +TimeoutStartSec=600 + +[Install] +WantedBy=multi-user.target + diff --git a/deploy/deploy_prod.sh b/deploy/deploy_prod.sh new file mode 100644 index 0000000..b05e0d5 --- /dev/null +++ b/deploy/deploy_prod.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +COMPOSE_FILE="$REPO_ROOT/docker-compose.prod.yml" +SERVICE_UNIT="/etc/systemd/system/avanzacast-stack.service" +LOCAL_UNIT_FILE="$REPO_ROOT/deploy/avanzacast-stack.service" + +usage() { + cat </dev/null 2>&1; then + echo "docker not found; please install Docker on this host" >&2 + exit 1 + fi +} + +build_images() { + echo "[deploy] Building images with docker compose..." + docker compose -f "$COMPOSE_FILE" build --pull +} + +compose_up() { + echo "[deploy] Bringing up compose stack..." + docker compose -f "$COMPOSE_FILE" up -d --build +} + +compose_down() { + echo "[deploy] Bringing down compose stack..." + docker compose -f "$COMPOSE_FILE" down +} + +install_unit() { + if [ ! -f "$LOCAL_UNIT_FILE" ]; then + echo "unit file $LOCAL_UNIT_FILE not found" >&2 + exit 1 + fi + echo "[deploy] Installing systemd unit to $SERVICE_UNIT (requires sudo)" + sudo cp "$LOCAL_UNIT_FILE" "$SERVICE_UNIT" + sudo systemctl daemon-reload +} + +enable_start_unit() { + echo "[deploy] Enabling and starting avanzacast-stack.service" + sudo systemctl enable --now avancacast-stack.service +} + +remove_unit() { + echo "[deploy] Stopping and removing avanzacast-stack.service" + sudo systemctl stop avancacast-stack.service || true + sudo systemctl disable avancacast-stack.service || true + sudo rm -f "$SERVICE_UNIT" + sudo systemctl daemon-reload +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --build-only) ensure_docker; build_images; shift ;; + --up) ensure_docker; compose_up; shift ;; + --down) ensure_docker; compose_down; shift ;; + --install-unit) install_unit; shift ;; + --enable-start) enable_start_unit; shift ;; + --remove-unit) remove_unit; shift ;; + --help) usage; exit 0 ;; + *) echo "Unknown option: $1" >&2; usage; exit 1 ;; + esac +done + diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..d7cc13a --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,80 @@ +version: '3.8' + +services: + backend-api: + build: + context: ./packages/backend-api + dockerfile: Dockerfile + env_file: + - ./packages/backend-api/.env.production + environment: + - REDIS_URL=redis://redis:6379 + restart: unless-stopped + networks: + - webnet + expose: + - "4000" + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:4000/health || exit 1"] + interval: 10s + timeout: 5s + retries: 5 + + studio-panel: + build: + context: . + dockerfile: ./packages/studio-panel/Dockerfile.simple + environment: + - VITE_TOKEN_SERVER_URL=http://backend-api:4000 + - VITE_STUDIO_URL=https://avanzacast-studio.bfzqqk.easypanel.host + - VITE_BROADCASTPANEL_URL=https://avanzacast-broadcastpanel.bfzqqk.easypanel.host + restart: unless-stopped + networks: + - webnet + expose: + - "80" + volumes: + - ./docker/letsencrypt:/etc/letsencrypt:ro + + broadcast-panel: + build: + context: ./packages/broadcast-panel + dockerfile: Dockerfile + environment: + - VITE_TOKEN_SERVER_URL=http://backend-api:4000 + - VITE_BROADCASTPANEL_URL=https://avanzacast-broadcastpanel.bfzqqk.easypanel.host + restart: unless-stopped + networks: + - webnet + expose: + - "5175" + volumes: + - ./docker/letsencrypt:/etc/letsencrypt:ro + + redis: + image: redis:7-alpine + restart: unless-stopped + networks: + - webnet + expose: + - "6379" + + reverse-proxy: + image: nginx:stable-alpine + volumes: + - ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro + - ./docker/letsencrypt:/etc/letsencrypt:ro + ports: + - "80:80" + - "443:443" + depends_on: + - backend-api + - studio-panel + - broadcast-panel + networks: + - webnet + restart: unless-stopped + +networks: + webnet: + driver: bridge diff --git a/docker/local-nginx/docker-compose.yml b/docker/local-nginx/docker-compose.yml new file mode 100644 index 0000000..c1a96ef --- /dev/null +++ b/docker/local-nginx/docker-compose.yml @@ -0,0 +1,12 @@ +version: '3.8' +services: + nginx: + image: nginx:1.21-alpine + container_name: avz_local_nginx + volumes: + - ./nginx.conf:/etc/nginx/nginx.conf:ro + network_mode: host + restart: unless-stopped + environment: + - NGINX_HOST=local + diff --git a/docker/local-nginx/nginx.conf b/docker/local-nginx/nginx.conf new file mode 100644 index 0000000..fed68db --- /dev/null +++ b/docker/local-nginx/nginx.conf @@ -0,0 +1,72 @@ +# nginx config to proxy production hostnames to local dev servers +user nginx; +worker_processes auto; +error_log /var/log/nginx/error.log warn; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + sendfile on; + keepalive_timeout 65; + + upstream broadcast_panel { + server 127.0.0.1:5175; + } + + upstream studio_panel { + server 127.0.0.1:3020; + } + + upstream token_server { + server 127.0.0.1:4000; + } + + server { + listen 80; + server_name avanzacast-broadcastpanel.bfzqqk.easypanel.host; + + location / { + proxy_pass http://broadcast_panel; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + } + + server { + listen 80; + server_name avanzacast-studio.bfzqqk.easypanel.host; + + location / { + proxy_pass http://studio_panel; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + } + + server { + listen 80; + server_name avanzacast-servertokens.bfzqqk.easypanel.host; + + location / { + proxy_pass http://token_server; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + } + + # optional: default catch-all to show a basic message + server { + listen 80 default_server; + server_name _; + return 200 'AvanzaCast local proxy running.'; + } +} + diff --git a/docker/nginx/default.conf b/docker/nginx/default.conf new file mode 100644 index 0000000..8b3f3b4 --- /dev/null +++ b/docker/nginx/default.conf @@ -0,0 +1,39 @@ +server { + listen 80; + server_name avanzacast-broadcastpanel.bfzqqk.easypanel.host; + + location / { + proxy_pass http://broadcast-panel:5175; + 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; + } +} + +server { + listen 80; + server_name avanzacast-studio.bfzqqk.easypanel.host; + + location / { + proxy_pass http://studio-panel:80; + 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; + } +} + +server { + listen 80; + server_name avanzacast-servertokens.bfzqqk.easypanel.host; + + location / { + proxy_pass http://backend-api:4000; + 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/docs/img.png b/docs/img.png new file mode 100644 index 0000000..db422c5 Binary files /dev/null and b/docs/img.png differ diff --git a/docs/img_1.png b/docs/img_1.png new file mode 100644 index 0000000..3011a45 Binary files /dev/null and b/docs/img_1.png differ diff --git a/docs/img_2.png b/docs/img_2.png new file mode 100644 index 0000000..3da4648 Binary files /dev/null and b/docs/img_2.png differ diff --git a/docs/screenshot_streamyard.png b/docs/screenshot_streamyard.png index d6baf88..62a6686 100644 Binary files a/docs/screenshot_streamyard.png and b/docs/screenshot_streamyard.png differ diff --git a/package-lock.json b/package-lock.json index 2fffdad..02c53c5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,7 @@ }, "devDependencies": { "concurrently": "^8.2.2", - "playwright": "^1.38.0", + "playwright": "^1.51.0", "typescript": "^5.2.2" }, "engines": { @@ -3929,6 +3929,40 @@ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" } }, + "node_modules/@joshwooding/vite-plugin-react-docgen-typescript": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@joshwooding/vite-plugin-react-docgen-typescript/-/vite-plugin-react-docgen-typescript-0.5.0.tgz", + "integrity": "sha512-qYDdL7fPwLRI+bJNurVcis+tNgJmvWjH4YTBGXTA8xMuxFrnAz6E5o35iyzyKbq5J5Lr8mJGfrR5GXl+WGwhgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "glob": "^10.0.0", + "magic-string": "^0.27.0", + "react-docgen-typescript": "^2.2.2" + }, + "peerDependencies": { + "typescript": ">= 4.3.x", + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@joshwooding/vite-plugin-react-docgen-typescript/node_modules/magic-string": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.27.0.tgz", + "integrity": "sha512-8UnnX2PeRAPZuN12svgR9j7M1uWMovg/CEnIwIG0LFkXSJJe4PdfUGiTGl8V9bsBHFUtfVINcSyYxd7q+kx9fA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.13" + }, + "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", @@ -4119,6 +4153,24 @@ "resolved": "https://registry.npmjs.org/@mdi/font/-/font-7.4.47.tgz", "integrity": "sha512-43MtGpd585SNzHZPcYowu/84Vz2a2g31TvPMTm9uTiCSWzaheQySUcSyUH/46fPnuPQWof2yd0pGBtzee/IQWw==" }, + "node_modules/@mdx-js/react": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-3.1.1.tgz", + "integrity": "sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdx": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=16", + "react": ">=16" + } + }, "node_modules/@nicolo-ribaudo/eslint-scope-5-internals": { "version": "5.1.1-v1", "resolved": "https://registry.npmjs.org/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz", @@ -4307,6 +4359,7 @@ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", "dev": true, "hasInstallScript": true, + "license": "MIT", "optional": true, "os": [ "darwin" @@ -4320,6 +4373,7 @@ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz", "integrity": "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==", "dev": true, + "license": "Apache-2.0", "dependencies": { "playwright-core": "1.56.1" }, @@ -4338,6 +4392,7 @@ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz", "integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==", "dev": true, + "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" }, @@ -5541,6 +5596,947 @@ "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==" }, + "node_modules/@storybook/addon-actions": { + "version": "8.6.14", + "resolved": "https://registry.npmjs.org/@storybook/addon-actions/-/addon-actions-8.6.14.tgz", + "integrity": "sha512-mDQxylxGGCQSK7tJPkD144J8jWh9IU9ziJMHfB84PKpI/V5ZgqMDnpr2bssTrUaGDqU5e1/z8KcRF+Melhs9pQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@storybook/global": "^5.0.0", + "@types/uuid": "^9.0.1", + "dequal": "^2.0.2", + "polished": "^4.2.2", + "uuid": "^9.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "storybook": "^8.6.14" + } + }, + "node_modules/@storybook/addon-actions/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/@storybook/addon-backgrounds": { + "version": "8.6.14", + "resolved": "https://registry.npmjs.org/@storybook/addon-backgrounds/-/addon-backgrounds-8.6.14.tgz", + "integrity": "sha512-l9xS8qWe5n4tvMwth09QxH2PmJbCctEvBAc1tjjRasAfrd69f7/uFK4WhwJAstzBTNgTc8VXI4w8ZR97i1sFbg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@storybook/global": "^5.0.0", + "memoizerific": "^1.11.3", + "ts-dedent": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "storybook": "^8.6.14" + } + }, + "node_modules/@storybook/addon-controls": { + "version": "8.6.14", + "resolved": "https://registry.npmjs.org/@storybook/addon-controls/-/addon-controls-8.6.14.tgz", + "integrity": "sha512-IiQpkNJdiRyA4Mq9mzjZlvQugL/aE7hNgVxBBGPiIZG6wb6Ht9hNnBYpap5ZXXFKV9p2qVI0FZK445ONmAa+Cw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@storybook/global": "^5.0.0", + "dequal": "^2.0.2", + "ts-dedent": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "storybook": "^8.6.14" + } + }, + "node_modules/@storybook/addon-docs": { + "version": "8.6.14", + "resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-8.6.14.tgz", + "integrity": "sha512-Obpd0OhAF99JyU5pp5ci17YmpcQtMNgqW2pTXV8jAiiipWpwO++hNDeQmLmlSXB399XjtRDOcDVkoc7rc6JzdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@mdx-js/react": "^3.0.0", + "@storybook/blocks": "8.6.14", + "@storybook/csf-plugin": "8.6.14", + "@storybook/react-dom-shim": "8.6.14", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "ts-dedent": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "storybook": "^8.6.14" + } + }, + "node_modules/@storybook/addon-essentials": { + "version": "8.6.14", + "resolved": "https://registry.npmjs.org/@storybook/addon-essentials/-/addon-essentials-8.6.14.tgz", + "integrity": "sha512-5ZZSHNaW9mXMOFkoPyc3QkoNGdJHETZydI62/OASR0lmPlJ1065TNigEo5dJddmZNn0/3bkE8eKMAzLnO5eIdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@storybook/addon-actions": "8.6.14", + "@storybook/addon-backgrounds": "8.6.14", + "@storybook/addon-controls": "8.6.14", + "@storybook/addon-docs": "8.6.14", + "@storybook/addon-highlight": "8.6.14", + "@storybook/addon-measure": "8.6.14", + "@storybook/addon-outline": "8.6.14", + "@storybook/addon-toolbars": "8.6.14", + "@storybook/addon-viewport": "8.6.14", + "ts-dedent": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "storybook": "^8.6.14" + } + }, + "node_modules/@storybook/addon-highlight": { + "version": "8.6.14", + "resolved": "https://registry.npmjs.org/@storybook/addon-highlight/-/addon-highlight-8.6.14.tgz", + "integrity": "sha512-4H19OJlapkofiE9tM6K/vsepf4ir9jMm9T+zw5L85blJZxhKZIbJ6FO0TCG9PDc4iPt3L6+aq5B0X29s9zicNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@storybook/global": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "storybook": "^8.6.14" + } + }, + "node_modules/@storybook/addon-interactions": { + "version": "8.6.14", + "resolved": "https://registry.npmjs.org/@storybook/addon-interactions/-/addon-interactions-8.6.14.tgz", + "integrity": "sha512-8VmElhm2XOjh22l/dO4UmXxNOolGhNiSpBcls2pqWSraVh4a670EyYBZsHpkXqfNHo2YgKyZN3C91+9zfH79qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@storybook/global": "^5.0.0", + "@storybook/instrumenter": "8.6.14", + "@storybook/test": "8.6.14", + "polished": "^4.2.2", + "ts-dedent": "^2.2.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "storybook": "^8.6.14" + } + }, + "node_modules/@storybook/addon-links": { + "version": "8.6.14", + "resolved": "https://registry.npmjs.org/@storybook/addon-links/-/addon-links-8.6.14.tgz", + "integrity": "sha512-DRlXHIyZzOruAZkxmXfVgTF+4d6K27pFcH4cUsm3KT1AXuZbr23lb5iZHpUZoG6lmU85Sru4xCEgewSTXBIe1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@storybook/global": "^5.0.0", + "ts-dedent": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", + "storybook": "^8.6.14" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } + } + }, + "node_modules/@storybook/addon-measure": { + "version": "8.6.14", + "resolved": "https://registry.npmjs.org/@storybook/addon-measure/-/addon-measure-8.6.14.tgz", + "integrity": "sha512-1Tlyb72NX8aAqm6I6OICsUuGOP6hgnXcuFlXucyhKomPa6j3Eu2vKu561t/f0oGtAK2nO93Z70kVaEh5X+vaGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@storybook/global": "^5.0.0", + "tiny-invariant": "^1.3.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "storybook": "^8.6.14" + } + }, + "node_modules/@storybook/addon-outline": { + "version": "8.6.14", + "resolved": "https://registry.npmjs.org/@storybook/addon-outline/-/addon-outline-8.6.14.tgz", + "integrity": "sha512-CW857JvN6OxGWElqjlzJO2S69DHf+xO3WsEfT5mT3ZtIjmsvRDukdWfDU9bIYUFyA2lFvYjncBGjbK+I91XR7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@storybook/global": "^5.0.0", + "ts-dedent": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "storybook": "^8.6.14" + } + }, + "node_modules/@storybook/addon-toolbars": { + "version": "8.6.14", + "resolved": "https://registry.npmjs.org/@storybook/addon-toolbars/-/addon-toolbars-8.6.14.tgz", + "integrity": "sha512-W/wEXT8h3VyZTVfWK/84BAcjAxTdtRiAkT2KAN0nbSHxxB5KEM1MjKpKu2upyzzMa3EywITqbfy4dP6lpkVTwQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "storybook": "^8.6.14" + } + }, + "node_modules/@storybook/addon-viewport": { + "version": "8.6.14", + "resolved": "https://registry.npmjs.org/@storybook/addon-viewport/-/addon-viewport-8.6.14.tgz", + "integrity": "sha512-gNzVQbMqRC+/4uQTPI2ZrWuRHGquTMZpdgB9DrD88VTEjNudP+J6r8myLfr2VvGksBbUMHkGHMXHuIhrBEnXYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "memoizerific": "^1.11.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "storybook": "^8.6.14" + } + }, + "node_modules/@storybook/blocks": { + "version": "8.6.14", + "resolved": "https://registry.npmjs.org/@storybook/blocks/-/blocks-8.6.14.tgz", + "integrity": "sha512-rBMHAfA39AGHgkrDze4RmsnQTMw1ND5fGWobr9pDcJdnDKWQWNRD7Nrlxj0gFlN3n4D9lEZhWGdFrCbku7FVAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@storybook/icons": "^1.2.12", + "ts-dedent": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "storybook": "^8.6.14" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/@storybook/builder-vite": { + "version": "8.6.14", + "resolved": "https://registry.npmjs.org/@storybook/builder-vite/-/builder-vite-8.6.14.tgz", + "integrity": "sha512-ajWYhy32ksBWxwWHrjwZzyC0Ii5ZTeu5lsqA95Q/EQBB0P5qWlHWGM3AVyv82Mz/ND03ebGy123uVwgf6olnYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@storybook/csf-plugin": "8.6.14", + "browser-assert": "^1.2.1", + "ts-dedent": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "storybook": "^8.6.14", + "vite": "^4.0.0 || ^5.0.0 || ^6.0.0" + } + }, + "node_modules/@storybook/components": { + "version": "8.6.14", + "resolved": "https://registry.npmjs.org/@storybook/components/-/components-8.6.14.tgz", + "integrity": "sha512-HNR2mC5I4Z5ek8kTrVZlIY/B8gJGs5b3XdZPBPBopTIN6U/YHXiDyOjY3JlaS4fSG1fVhp/Qp1TpMn1w/9m1pw==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "storybook": "^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0" + } + }, + "node_modules/@storybook/core": { + "version": "8.6.14", + "resolved": "https://registry.npmjs.org/@storybook/core/-/core-8.6.14.tgz", + "integrity": "sha512-1P/w4FSNRqP8j3JQBOi3yGt8PVOgSRbP66Ok520T78eJBeqx9ukCfl912PQZ7SPbW3TIunBwLXMZOjZwBB/JmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@storybook/theming": "8.6.14", + "better-opn": "^3.0.2", + "browser-assert": "^1.2.1", + "esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0", + "esbuild-register": "^3.5.0", + "jsdoc-type-pratt-parser": "^4.0.0", + "process": "^0.11.10", + "recast": "^0.23.5", + "semver": "^7.6.2", + "util": "^0.12.5", + "ws": "^8.2.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "prettier": "^2 || ^3" + }, + "peerDependenciesMeta": { + "prettier": { + "optional": true + } + } + }, + "node_modules/@storybook/core/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@storybook/csf-plugin": { + "version": "8.6.14", + "resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-8.6.14.tgz", + "integrity": "sha512-dErtc9teAuN+eelN8FojzFE635xlq9cNGGGEu0WEmMUQ4iJ8pingvBO1N8X3scz4Ry7KnxX++NNf3J3gpxS8qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "unplugin": "^1.3.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "storybook": "^8.6.14" + } + }, + "node_modules/@storybook/global": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@storybook/global/-/global-5.0.0.tgz", + "integrity": "sha512-FcOqPAXACP0I3oJ/ws6/rrPT9WGhu915Cg8D02a9YxLo0DE9zI+a9A5gRGvmQ09fiWPukqI8ZAEoQEdWUKMQdQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@storybook/icons": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@storybook/icons/-/icons-1.6.0.tgz", + "integrity": "sha512-hcFZIjW8yQz8O8//2WTIXylm5Xsgc+lW9ISLgUk1xGmptIJQRdlhVIXCpSyLrQaaRiyhQRaVg7l3BD9S216BHw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta" + } + }, + "node_modules/@storybook/instrumenter": { + "version": "8.6.14", + "resolved": "https://registry.npmjs.org/@storybook/instrumenter/-/instrumenter-8.6.14.tgz", + "integrity": "sha512-iG4MlWCcz1L7Yu8AwgsnfVAmMbvyRSk700Mfy2g4c8y5O+Cv1ejshE1LBBsCwHgkuqU0H4R0qu4g23+6UnUemQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@storybook/global": "^5.0.0", + "@vitest/utils": "^2.1.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "storybook": "^8.6.14" + } + }, + "node_modules/@storybook/instrumenter/node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@storybook/instrumenter/node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@storybook/manager-api": { + "version": "8.6.14", + "resolved": "https://registry.npmjs.org/@storybook/manager-api/-/manager-api-8.6.14.tgz", + "integrity": "sha512-ez0Zihuy17udLbfHZQXkGqwtep0mSGgHcNzGN7iZrMP1m+VmNo+7aGCJJdvXi7+iU3yq8weXSQFWg5DqWgLS7g==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "storybook": "^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0" + } + }, + "node_modules/@storybook/preview-api": { + "version": "8.6.14", + "resolved": "https://registry.npmjs.org/@storybook/preview-api/-/preview-api-8.6.14.tgz", + "integrity": "sha512-2GhcCd4dNMrnD7eooEfvbfL4I83qAqEyO0CO7JQAmIO6Rxb9BsOLLI/GD5HkvQB73ArTJ+PT50rfaO820IExOQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "storybook": "^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0" + } + }, + "node_modules/@storybook/react": { + "version": "8.6.14", + "resolved": "https://registry.npmjs.org/@storybook/react/-/react-8.6.14.tgz", + "integrity": "sha512-BOepx5bBFwl/CPI+F+LnmMmsG1wQYmrX/UQXgUbHQUU9Tj7E2ndTnNbpIuSLc8IrM03ru+DfwSg1Co3cxWtT+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@storybook/components": "8.6.14", + "@storybook/global": "^5.0.0", + "@storybook/manager-api": "8.6.14", + "@storybook/preview-api": "8.6.14", + "@storybook/react-dom-shim": "8.6.14", + "@storybook/theming": "8.6.14" + }, + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "@storybook/test": "8.6.14", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", + "storybook": "^8.6.14", + "typescript": ">= 4.2.x" + }, + "peerDependenciesMeta": { + "@storybook/test": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@storybook/react-dom-shim": { + "version": "8.6.14", + "resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-8.6.14.tgz", + "integrity": "sha512-0hixr3dOy3f3M+HBofp3jtMQMS+sqzjKNgl7Arfuj3fvjmyXOks/yGjDImySR4imPtEllvPZfhiQNlejheaInw==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", + "storybook": "^8.6.14" + } + }, + "node_modules/@storybook/react-vite": { + "version": "8.6.14", + "resolved": "https://registry.npmjs.org/@storybook/react-vite/-/react-vite-8.6.14.tgz", + "integrity": "sha512-FZU0xMPxa4/TO87FgcWwappOxLBHZV5HSRK5K+2bJD7rFJAoNorbHvB4Q1zvIAk7eCMjkr2GPCPHx9PRB9vJFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@joshwooding/vite-plugin-react-docgen-typescript": "0.5.0", + "@rollup/pluginutils": "^5.0.2", + "@storybook/builder-vite": "8.6.14", + "@storybook/react": "8.6.14", + "find-up": "^5.0.0", + "magic-string": "^0.30.0", + "react-docgen": "^7.0.0", + "resolve": "^1.22.8", + "tsconfig-paths": "^4.2.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "@storybook/test": "8.6.14", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", + "storybook": "^8.6.14", + "vite": "^4.0.0 || ^5.0.0 || ^6.0.0" + }, + "peerDependenciesMeta": { + "@storybook/test": { + "optional": true + } + } + }, + "node_modules/@storybook/react-vite/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/@storybook/react-vite/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/@storybook/react-vite/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/@storybook/react-vite/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/@storybook/react-vite/node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/@storybook/react-vite/node_modules/tsconfig-paths": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", + "integrity": "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "json5": "^2.2.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@storybook/react-vite/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" + } + }, + "node_modules/@storybook/test": { + "version": "8.6.14", + "resolved": "https://registry.npmjs.org/@storybook/test/-/test-8.6.14.tgz", + "integrity": "sha512-GkPNBbbZmz+XRdrhMtkxPotCLOQ1BaGNp/gFZYdGDk2KmUWBKmvc5JxxOhtoXM2703IzNFlQHSSNnhrDZYuLlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@storybook/global": "^5.0.0", + "@storybook/instrumenter": "8.6.14", + "@testing-library/dom": "10.4.0", + "@testing-library/jest-dom": "6.5.0", + "@testing-library/user-event": "14.5.2", + "@vitest/expect": "2.0.5", + "@vitest/spy": "2.0.5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "storybook": "^8.6.14" + } + }, + "node_modules/@storybook/test/node_modules/@testing-library/dom": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz", + "integrity": "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "chalk": "^4.1.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@storybook/test/node_modules/@testing-library/jest-dom": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.5.0.tgz", + "integrity": "sha512-xGGHpBXYSHUUr6XsKBfs85TWlYKpTc37cSBBVrXcib2MkHLboWlkClhWF37JKlDb9KEq3dHs+f2xR7XJEWGBxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "chalk": "^3.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "lodash": "^4.17.21", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@storybook/test/node_modules/@testing-library/jest-dom/node_modules/chalk": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", + "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@storybook/test/node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@storybook/test/node_modules/@testing-library/user-event": { + "version": "14.5.2", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.5.2.tgz", + "integrity": "sha512-YAh82Wh4TIrxYLmfGcixwD18oIjyC1pFQC2Y01F2lzV2HTMiYrI0nze0FD0ocB//CKS/7jIUgae+adPqxK5yCQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@storybook/test/node_modules/@vitest/expect": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.0.5.tgz", + "integrity": "sha512-yHZtwuP7JZivj65Gxoi8upUN2OzHTi3zVfjwdpu2WrvCZPLwsJ2Ey5ILIPccoW23dd/zQBlJ4/dhi7DWNyXCpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.0.5", + "@vitest/utils": "2.0.5", + "chai": "^5.1.1", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@storybook/test/node_modules/@vitest/pretty-format": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.0.5.tgz", + "integrity": "sha512-h8k+1oWHfwTkyTkb9egzwNMfJAEx4veaPSnMeKbVSjp4euqGSbQlm5+6VHwTr7u4FJslVVsUG5nopCaAYdOmSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@storybook/test/node_modules/@vitest/spy": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.0.5.tgz", + "integrity": "sha512-c/jdthAhvJdpfVuaexSrnawxZz6pywlTPe84LUB2m/4t3rl2fTo9NFGBG4oWgaD+FTgDDV8hJ/nibT7IfH3JfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@storybook/test/node_modules/@vitest/utils": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.0.5.tgz", + "integrity": "sha512-d8HKbqIcya+GR67mkZbrzhS5kKhtp8dQLcmRZLGTscGVg7yImT82cIrhtn2L8+VujWcy6KZweApgNmPsTAO/UQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.0.5", + "estree-walker": "^3.0.3", + "loupe": "^3.1.1", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@storybook/test/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==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@storybook/test/node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@storybook/test/node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/@storybook/test/node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@storybook/test/node_modules/check-error": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", + "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/@storybook/test/node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@storybook/test/node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@storybook/test/node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/@storybook/test/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/@storybook/test/node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@storybook/theming": { + "version": "8.6.14", + "resolved": "https://registry.npmjs.org/@storybook/theming/-/theming-8.6.14.tgz", + "integrity": "sha512-r4y+LsiB37V5hzpQo+BM10PaCsp7YlZ0YcZzQP1OCkPlYXmUAFy2VvDKaFRpD8IeNPKug2u4iFm/laDEbs03dg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "storybook": "^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0" + } + }, "node_modules/@surma/rollup-plugin-off-main-thread": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz", @@ -6042,6 +7038,301 @@ "tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1" } }, + "node_modules/@tailwindcss/node": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.17.tgz", + "integrity": "sha512-csIkHIgLb3JisEFQ0vxr2Y57GUNYh447C8xzwj89U/8fdW8LhProdxvnVH6U8M2Y73QKiTIH+LWbK3V2BBZsAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "enhanced-resolve": "^5.18.3", + "jiti": "^2.6.1", + "lightningcss": "1.30.2", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.1.17" + } + }, + "node_modules/@tailwindcss/node/node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/@tailwindcss/node/node_modules/tailwindcss": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz", + "integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.17.tgz", + "integrity": "sha512-F0F7d01fmkQhsTjXezGBLdrl1KresJTcI3DB8EkScCldyKp3Msz4hub4uyYaVnk88BAS1g5DQjjF6F5qczheLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.17", + "@tailwindcss/oxide-darwin-arm64": "4.1.17", + "@tailwindcss/oxide-darwin-x64": "4.1.17", + "@tailwindcss/oxide-freebsd-x64": "4.1.17", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.17", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.17", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.17", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.17", + "@tailwindcss/oxide-linux-x64-musl": "4.1.17", + "@tailwindcss/oxide-wasm32-wasi": "4.1.17", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.17", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.17" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.17.tgz", + "integrity": "sha512-BMqpkJHgOZ5z78qqiGE6ZIRExyaHyuxjgrJ6eBO5+hfrfGkuya0lYfw8fRHG77gdTjWkNWEEm+qeG2cDMxArLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.17.tgz", + "integrity": "sha512-EquyumkQweUBNk1zGEU/wfZo2qkp/nQKRZM8bUYO0J+Lums5+wl2CcG1f9BgAjn/u9pJzdYddHWBiFXJTcxmOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.17.tgz", + "integrity": "sha512-gdhEPLzke2Pog8s12oADwYu0IAw04Y2tlmgVzIN0+046ytcgx8uZmCzEg4VcQh+AHKiS7xaL8kGo/QTiNEGRog==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.17.tgz", + "integrity": "sha512-hxGS81KskMxML9DXsaXT1H0DyA+ZBIbyG/sSAjWNe2EDl7TkPOBI42GBV3u38itzGUOmFfCzk1iAjDXds8Oh0g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.17.tgz", + "integrity": "sha512-k7jWk5E3ldAdw0cNglhjSgv501u7yrMf8oeZ0cElhxU6Y2o7f8yqelOp3fhf7evjIS6ujTI3U8pKUXV2I4iXHQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.17.tgz", + "integrity": "sha512-HVDOm/mxK6+TbARwdW17WrgDYEGzmoYayrCgmLEw7FxTPLcp/glBisuyWkFz/jb7ZfiAXAXUACfyItn+nTgsdQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.17.tgz", + "integrity": "sha512-HvZLfGr42i5anKtIeQzxdkw/wPqIbpeZqe7vd3V9vI3RQxe3xU1fLjss0TjyhxWcBaipk7NYwSrwTwK1hJARMg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.17.tgz", + "integrity": "sha512-M3XZuORCGB7VPOEDH+nzpJ21XPvK5PyjlkSFkFziNHGLc5d6g3di2McAAblmaSUNl8IOmzYwLx9NsE7bplNkwQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.17.tgz", + "integrity": "sha512-k7f+pf9eXLEey4pBlw+8dgfJHY4PZ5qOUFDyNf7SI6lHjQ9Zt7+NcscjpwdCEbYi6FI5c2KDTDWyf2iHcCSyyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.17.tgz", + "integrity": "sha512-cEytGqSSoy7zK4JRWiTCx43FsKP/zGr0CsuMawhH67ONlH+T79VteQeJQRO/X7L0juEUA8ZyuYikcRBf0vsxhg==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.6.0", + "@emnapi/runtime": "^1.6.0", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.0.7", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.17.tgz", + "integrity": "sha512-JU5AHr7gKbZlOGvMdb4722/0aYbU+tN6lv1kONx0JK2cGsh7g148zVWLM0IKR3NeKLv+L90chBVYcJ8uJWbC9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.17.tgz", + "integrity": "sha512-SKWM4waLuqx0IH+FMDUw6R66Hu4OuTALFgnleKbqhgGU30DY20NORZMZUKgLRjQXNN2TLzKvh48QXTig4h4bGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.17.tgz", + "integrity": "sha512-+nKl9N9mN5uJ+M7dBOOCzINw94MPstNR/GtIhz1fpZysxL/4a+No64jCBD6CPN+bIHWFx3KWuu8XJRrj/572Dw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.1.17", + "@tailwindcss/oxide": "4.1.17", + "postcss": "^8.4.41", + "tailwindcss": "4.1.17" + } + }, + "node_modules/@tailwindcss/postcss/node_modules/tailwindcss": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz", + "integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@tailwindcss/typography": { "version": "0.5.9", "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.9.tgz", @@ -6412,6 +7703,13 @@ "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==" }, + "node_modules/@types/doctrine": { + "version": "0.0.9", + "resolved": "https://registry.npmjs.org/@types/doctrine/-/doctrine-0.0.9.tgz", + "integrity": "sha512-eOIHzCUSH7SMfonMG1LsC2f8vxBFtho6NGBznK41R84YzPuvSBzrhEps33IsQiOW9+VL6NQ9DbjQJznk/S4uRA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/eslint": { "version": "8.56.12", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.12.tgz", @@ -6569,6 +7867,13 @@ "integrity": "sha512-a79Yc3TOk6dGdituy8hmTTJXjOkZ7zsFYV10L337ttq/rec8lRMDBpV7fL3uLx6TgbFCa5DU/h8FmIBQPSbU0w==", "license": "MIT" }, + "node_modules/@types/mdx": { + "version": "2.0.13", + "resolved": "https://registry.npmjs.org/@types/mdx/-/mdx-2.0.13.tgz", + "integrity": "sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -6827,6 +8132,13 @@ "integrity": "sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA==", "license": "MIT" }, + "node_modules/@types/uuid": { + "version": "9.0.8", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz", + "integrity": "sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", @@ -7164,6 +8476,19 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@vitest/runner": { "version": "1.6.1", "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.6.1.tgz", @@ -8208,7 +9533,7 @@ } }, "node_modules/avanza-ui": { - "resolved": "packages/ui-components", + "resolved": "packages/avanza-ui", "link": true }, "node_modules/axe-core": { @@ -8565,6 +9890,37 @@ "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==" }, + "node_modules/better-opn": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/better-opn/-/better-opn-3.0.2.tgz", + "integrity": "sha512-aVNobHnJqLiUelTaHat9DZ1qM2w0C0Eym4LPI/3JxOnSokGVdsl1T1kN7TFvsEAD8G47A6VKQ0TVHqbBnYMJlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "open": "^8.0.4" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/better-opn/node_modules/open": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", + "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-lazy-prop": "^2.0.0", + "is-docker": "^2.1.1", + "is-wsl": "^2.2.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/bfj": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/bfj/-/bfj-7.1.0.tgz", @@ -8716,6 +10072,12 @@ "resolved": "packages/broadcast-panel", "link": true }, + "node_modules/browser-assert": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/browser-assert/-/browser-assert-1.2.1.tgz", + "integrity": "sha512-nfulgvOR6S4gt9UKCeGJOuSGBPGiFT6oQ/2UBnvTY/5aQ1PnksW72fhZkM30DzoRRv2WpwZf1vHHEr3mtuXIWQ==", + "dev": true + }, "node_modules/browser-process-hrtime": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz", @@ -10948,6 +12310,16 @@ "node": ">= 0.8" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/destroy": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", @@ -10963,8 +12335,6 @@ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "dev": true, "license": "Apache-2.0", - "optional": true, - "peer": true, "engines": { "node": ">=8" } @@ -11753,6 +13123,19 @@ "@esbuild/win32-x64": "0.18.20" } }, + "node_modules/esbuild-register": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/esbuild-register/-/esbuild-register-3.6.0.tgz", + "integrity": "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4" + }, + "peerDependencies": { + "esbuild": ">=0.12 <1" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -12847,6 +14230,30 @@ "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==" }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, "node_modules/file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -13367,6 +14774,19 @@ "node": ">= 6" } }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/formik": { "version": "2.4.9", "resolved": "https://registry.npmjs.org/formik/-/formik-2.4.9.tgz", @@ -16824,6 +18244,16 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdoc-type-pratt-parser": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-4.8.0.tgz", + "integrity": "sha512-iZ8Bdb84lWRuGHamRXFyML07r21pcwBrLkHEuHgEY5UbCouBwv7ECknDRKzsQIXMiqpPymqtIf8TC/shYKB5rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/jsdom": { "version": "16.7.0", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-16.7.0.tgz", @@ -17259,8 +18689,6 @@ "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", "dev": true, "license": "MPL-2.0", - "optional": true, - "peer": true, "dependencies": { "detect-libc": "^2.0.3" }, @@ -17935,6 +19363,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/map-or-similar": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/map-or-similar/-/map-or-similar-1.5.0.tgz", + "integrity": "sha512-0aF7ZmVon1igznGI4VS30yugpduQW3y3GkcgGJOp7d8x8QrizhigUxjI/m2UojsXXto+jLAH3KSz+xOJTiORjg==", + "dev": true, + "license": "MIT" + }, "node_modules/marked": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/marked/-/marked-4.3.0.tgz", @@ -17986,6 +19421,16 @@ "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", "license": "MIT" }, + "node_modules/memoizerific": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/memoizerific/-/memoizerific-1.11.3.tgz", + "integrity": "sha512-/EuHYwAPdLtXwAwSZkh/Gutery6pD2KYd44oQLhAvQp/50mpyduZh8Q7PYHXTCJ+wuXxt7oij2LXyIJOOYFPog==", + "dev": true, + "license": "MIT", + "dependencies": { + "map-or-similar": "^1.5.0" + } + }, "node_modules/merge-descriptors": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", @@ -18483,6 +19928,27 @@ "tslib": "^2.0.3" } }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -19566,33 +21032,35 @@ } }, "node_modules/playwright": { - "version": "1.38.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.38.0.tgz", - "integrity": "sha512-fJGw+HO0YY+fU/F1N57DMO+TmXHTrmr905J05zwAQE9xkuwP/QLDk63rVhmyxh03dYnEhnRbsdbH9B0UVVRB3A==", + "version": "1.51.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.51.0.tgz", + "integrity": "sha512-442pTfGM0xxfCYxuBa/Pu6B2OqxqqaYq39JS8QDMGThUvIOCd6s0ANDog3uwA0cHavVlnTQzGCN7Id2YekDSXA==", "dev": true, + "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.38.0" + "playwright-core": "1.51.0" }, "bin": { "playwright": "cli.js" }, "engines": { - "node": ">=16" + "node": ">=18" }, "optionalDependencies": { "fsevents": "2.3.2" } }, "node_modules/playwright-core": { - "version": "1.38.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.38.0.tgz", - "integrity": "sha512-f8z1y8J9zvmHoEhKgspmCvOExF2XdcxMW8jNRuX4vkQFrzV4MlZ55iwb5QeyiFQgOFCUolXiRHgpjSEnqvO48g==", + "version": "1.51.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.51.0.tgz", + "integrity": "sha512-x47yPE3Zwhlil7wlNU/iktF7t2r/URR3VLbH6EknJd/04Qc/PSJ0EY3CMXipmglLG+zyRxW6HNo2EGbKLHPWMg==", "dev": true, + "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" }, "engines": { - "node": ">=16" + "node": ">=18" } }, "node_modules/playwright/node_modules/fsevents": { @@ -19609,6 +21077,19 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/polished": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/polished/-/polished-4.3.1.tgz", + "integrity": "sha512-OBatVyC/N7SCW/FaDHrSd+vn0o5cS855TOmYi4OkdWUMSJCET/xip//ch8xGUvtr3i44X9LVyWwQlRMTN3pwSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.17.8" + }, + "engines": { + "node": ">=10" + } + }, "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", @@ -21132,6 +22613,16 @@ "fsevents": "2.3.3" } }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", @@ -22416,6 +23907,51 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/react-docgen": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/react-docgen/-/react-docgen-7.1.1.tgz", + "integrity": "sha512-hlSJDQ2synMPKFZOsKo9Hi8WWZTC7POR8EmWvTSjow+VDgKzkmjQvFm2fk0tmRw+f0vTOIYKlarR0iL4996pdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.18.9", + "@babel/traverse": "^7.18.9", + "@babel/types": "^7.18.9", + "@types/babel__core": "^7.18.0", + "@types/babel__traverse": "^7.18.0", + "@types/doctrine": "^0.0.9", + "@types/resolve": "^1.20.2", + "doctrine": "^3.0.0", + "resolve": "^1.22.1", + "strip-indent": "^4.0.0" + }, + "engines": { + "node": ">=16.14.0" + } + }, + "node_modules/react-docgen-typescript": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/react-docgen-typescript/-/react-docgen-typescript-2.4.0.tgz", + "integrity": "sha512-ZtAp5XTO5HRzQctjPU0ybY0RRCQO19X/8fxn3w7y2VVTUbGHDKULPTL4ky3vB05euSgG5NpALhEhDPvQ56wvXg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "typescript": ">= 4.3.x" + } + }, + "node_modules/react-docgen/node_modules/strip-indent": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-4.1.1.tgz", + "integrity": "sha512-SlyRoSkdh1dYP0PzclLE7r0M9sgbFKKMFXpFRUMNuKhQSbC6VQIGzq3E0qsfvGJaUFJPGv6Ws1NZ/haTAjfbMA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/react-dom": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", @@ -22930,6 +24466,46 @@ "node": ">=8.10.0" } }, + "node_modules/recast": { + "version": "0.23.11", + "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.11.tgz", + "integrity": "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ast-types": "^0.16.1", + "esprima": "~4.0.0", + "source-map": "~0.6.1", + "tiny-invariant": "^1.3.3", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">= 4" + } + }, + "node_modules/recast/node_modules/ast-types": { + "version": "0.16.1", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz", + "integrity": "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/recast/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/recharts": { "version": "2.15.4", "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", @@ -24802,6 +26378,33 @@ "node": ">= 0.4" } }, + "node_modules/storybook": { + "version": "8.6.14", + "resolved": "https://registry.npmjs.org/storybook/-/storybook-8.6.14.tgz", + "integrity": "sha512-sVKbCj/OTx67jhmauhxc2dcr1P+yOgz/x3h0krwjyMgdc5Oubvxyg4NYDZmzAw+ym36g/lzH8N0Ccp4dwtdfxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@storybook/core": "8.6.14" + }, + "bin": { + "getstorybook": "bin/index.cjs", + "sb": "bin/index.cjs", + "storybook": "bin/index.cjs" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "prettier": "^2 || ^3" + }, + "peerDependenciesMeta": { + "prettier": { + "optional": true + } + } + }, "node_modules/streamx": { "version": "2.23.0", "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz", @@ -26133,6 +27736,16 @@ "node": ">=14.0.0" } }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tinyspy": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.1.tgz", @@ -26254,6 +27867,16 @@ "resolved": "https://registry.npmjs.org/ts-debounce/-/ts-debounce-4.0.0.tgz", "integrity": "sha512-+1iDGY6NmOGidq7i7xZGA4cm8DAa6fqdYcvO5Z6yBevH++Bdo9Qt/mN0TzHUgcCcKv1gmh9+W5dHqz8pMWbCbg==" }, + "node_modules/ts-dedent": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.2.0.tgz", + "integrity": "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.10" + } + }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", @@ -27072,6 +28695,20 @@ "node": ">= 0.8" } }, + "node_modules/unplugin": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-1.16.1.tgz", + "integrity": "sha512-4/u/j4FrCKdi17jaxuJA0jClGxB1AvU2hw/IuayPc4ay1XGaJs/rbb4v5WKwAjNifjmXK9PIFyuPiaK8azyR9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.14.0", + "webpack-virtual-modules": "^0.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/unquote": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/unquote/-/unquote-1.1.1.tgz", @@ -27251,6 +28888,20 @@ "react": "^16.8.0 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, + "node_modules/util": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", + "integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "is-arguments": "^1.0.4", + "is-generator-function": "^1.0.7", + "is-typed-array": "^1.1.3", + "which-typed-array": "^1.1.2" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -29257,6 +30908,16 @@ "minimalistic-assert": "^1.0.0" } }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/web-vitals": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-2.1.4.tgz", @@ -29530,6 +31191,13 @@ "node": ">=10.13.0" } }, + "node_modules/webpack-virtual-modules": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", + "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", + "dev": true, + "license": "MIT" + }, "node_modules/webpack/node_modules/eslint-scope": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", @@ -30589,6 +32257,380 @@ "integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==", "dev": true }, + "packages/avanza-ui": { + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "clsx": "^2.1.1" + }, + "devDependencies": { + "@rollup/plugin-commonjs": "^25.0.7", + "@rollup/plugin-node-resolve": "^15.2.3", + "@rollup/plugin-typescript": "^11.1.6", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "rollup": "^4.18.0", + "rollup-plugin-peer-deps-external": "^2.2.4", + "rollup-plugin-postcss": "^4.0.2", + "tslib": "^2.6.3", + "typescript": "^5.5.3", + "vitest": "^1.6.0" + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, + "packages/avanza-ui/node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.2.tgz", + "integrity": "sha512-yDPzwsgiFO26RJA4nZo8I+xqzh7sJTZIWQOxn+/XOdPE31lAvLIYCKqjV+lNH/vxE2L2iH3plKxDCRK6i+CwhA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "packages/avanza-ui/node_modules/@rollup/rollup-android-arm64": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.2.tgz", + "integrity": "sha512-k8FontTxIE7b0/OGKeSN5B6j25EuppBcWM33Z19JoVT7UTXFSo3D9CdU39wGTeb29NO3XxpMNauh09B+Ibw+9g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "packages/avanza-ui/node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.2.tgz", + "integrity": "sha512-A6s4gJpomNBtJ2yioj8bflM2oogDwzUiMl2yNJ2v9E7++sHrSrsQ29fOfn5DM/iCzpWcebNYEdXpaK4tr2RhfQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "packages/avanza-ui/node_modules/@rollup/rollup-darwin-x64": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.2.tgz", + "integrity": "sha512-e6XqVmXlHrBlG56obu9gDRPW3O3hLxpwHpLsBJvuI8qqnsrtSZ9ERoWUXtPOkY8c78WghyPHZdmPhHLWNdAGEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "packages/avanza-ui/node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.2.tgz", + "integrity": "sha512-v0E9lJW8VsrwPux5Qe5CwmH/CF/2mQs6xU1MF3nmUxmZUCHazCjLgYvToOk+YuuUqLQBio1qkkREhxhc656ViA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "packages/avanza-ui/node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.2.tgz", + "integrity": "sha512-ClAmAPx3ZCHtp6ysl4XEhWU69GUB1D+s7G9YjHGhIGCSrsg00nEGRRZHmINYxkdoJehde8VIsDC5t9C0gb6yqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "packages/avanza-ui/node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.2.tgz", + "integrity": "sha512-EPlb95nUsz6Dd9Qy13fI5kUPXNSljaG9FiJ4YUGU1O/Q77i5DYFW5KR8g1OzTcdZUqQQ1KdDqsTohdFVwCwjqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "packages/avanza-ui/node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.2.tgz", + "integrity": "sha512-BOmnVW+khAUX+YZvNfa0tGTEMVVEerOxN0pDk2E6N6DsEIa2Ctj48FOMfNDdrwinocKaC7YXUZ1pHlKpnkja/Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "packages/avanza-ui/node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.2.tgz", + "integrity": "sha512-Xt2byDZ+6OVNuREgBXr4+CZDJtrVso5woFtpKdGPhpTPHcNG7D8YXeQzpNbFRxzTVqJf7kvPMCub/pcGUWgBjA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "packages/avanza-ui/node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.2.tgz", + "integrity": "sha512-+LdZSldy/I9N8+klim/Y1HsKbJ3BbInHav5qE9Iy77dtHC/pibw1SR/fXlWyAk0ThnpRKoODwnAuSjqxFRDHUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "packages/avanza-ui/node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.2.tgz", + "integrity": "sha512-8ms8sjmyc1jWJS6WdNSA23rEfdjWB30LH8Wqj0Cqvv7qSHnvw6kgMMXRdop6hkmGPlyYBdRPkjJnj3KCUHV/uQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "packages/avanza-ui/node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.2.tgz", + "integrity": "sha512-3HRQLUQbpBDMmzoxPJYd3W6vrVHOo2cVW8RUo87Xz0JPJcBLBr5kZ1pGcQAhdZgX9VV7NbGNipah1omKKe23/g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "packages/avanza-ui/node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.2.tgz", + "integrity": "sha512-fMjKi+ojnmIvhk34gZP94vjogXNNUKMEYs+EDaB/5TG/wUkoeua7p7VCHnE6T2Tx+iaghAqQX8teQzcvrYpaQA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "packages/avanza-ui/node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.2.tgz", + "integrity": "sha512-XuGFGU+VwUUV5kLvoAdi0Wz5Xbh2SrjIxCtZj6Wq8MDp4bflb/+ThZsVxokM7n0pcbkEr2h5/pzqzDYI7cCgLQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "packages/avanza-ui/node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.2.tgz", + "integrity": "sha512-w6yjZF0P+NGzWR3AXWX9zc0DNEGdtvykB03uhonSHMRa+oWA6novflo2WaJr6JZakG2ucsyb+rvhrKac6NIy+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "packages/avanza-ui/node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.2.tgz", + "integrity": "sha512-yo8d6tdfdeBArzC7T/PnHd7OypfI9cbuZzPnzLJIyKYFhAQ8SvlkKtKBMbXDxe1h03Rcr7u++nFS7tqXz87Gtw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "packages/avanza-ui/node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.2.tgz", + "integrity": "sha512-ah59c1YkCxKExPP8O9PwOvs+XRLKwh/mV+3YdKqQ5AMQ0r4M4ZDuOrpWkUaqO7fzAHdINzV9tEVu8vNw48z0lA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "packages/avanza-ui/node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.2.tgz", + "integrity": "sha512-4VEd19Wmhr+Zy7hbUsFZ6YXEiP48hE//KPLCSVNY5RMGX2/7HZ+QkN55a3atM1C/BZCGIgqN+xrVgtdak2S9+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "packages/avanza-ui/node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.2.tgz", + "integrity": "sha512-IlbHFYc/pQCgew/d5fslcy1KEaYVCJ44G8pajugd8VoOEI8ODhtb/j8XMhLpwHCMB3yk2J07ctup10gpw2nyMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "packages/avanza-ui/node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.2.tgz", + "integrity": "sha512-lNlPEGgdUfSzdCWU176ku/dQRnA7W+Gp8d+cWv73jYrb8uT7HTVVxq62DUYxjbaByuf1Yk0RIIAbDzp+CnOTFg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "packages/avanza-ui/node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.2.tgz", + "integrity": "sha512-S6YojNVrHybQis2lYov1sd+uj7K0Q05NxHcGktuMMdIQ2VixGwAfbJ23NnlvvVV1bdpR2m5MsNBViHJKcA4ADw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "packages/avanza-ui/node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.2.tgz", + "integrity": "sha512-k+/Rkcyx//P6fetPoLMb8pBeqJBNGx81uuf7iljX9++yNBVRDQgD04L+SVXmXmh5ZP4/WOp4mWF0kmi06PW2tA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "packages/avanza-ui/node_modules/rollup": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.2.tgz", + "integrity": "sha512-MHngMYwGJVi6Fmnk6ISmnk7JAHRNF0UkuucA0CUW3N3a4KnONPEZz+vUanQP/ZC/iY1Qkf3bwPWzyY84wEks1g==", + "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.53.2", + "@rollup/rollup-android-arm64": "4.53.2", + "@rollup/rollup-darwin-arm64": "4.53.2", + "@rollup/rollup-darwin-x64": "4.53.2", + "@rollup/rollup-freebsd-arm64": "4.53.2", + "@rollup/rollup-freebsd-x64": "4.53.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.53.2", + "@rollup/rollup-linux-arm-musleabihf": "4.53.2", + "@rollup/rollup-linux-arm64-gnu": "4.53.2", + "@rollup/rollup-linux-arm64-musl": "4.53.2", + "@rollup/rollup-linux-loong64-gnu": "4.53.2", + "@rollup/rollup-linux-ppc64-gnu": "4.53.2", + "@rollup/rollup-linux-riscv64-gnu": "4.53.2", + "@rollup/rollup-linux-riscv64-musl": "4.53.2", + "@rollup/rollup-linux-s390x-gnu": "4.53.2", + "@rollup/rollup-linux-x64-gnu": "4.53.2", + "@rollup/rollup-linux-x64-musl": "4.53.2", + "@rollup/rollup-openharmony-arm64": "4.53.2", + "@rollup/rollup-win32-arm64-msvc": "4.53.2", + "@rollup/rollup-win32-ia32-msvc": "4.53.2", + "@rollup/rollup-win32-x64-gnu": "4.53.2", + "@rollup/rollup-win32-x64-msvc": "4.53.2", + "fsevents": "~2.3.2" + } + }, "packages/backend-api": { "name": "@avanzacast/backend-api", "version": "1.0.0", @@ -32119,49 +34161,445 @@ "packages/studio-panel": { "name": "@avanzacast/studio-panel", "version": "0.2.0", + "license": "ISC", "dependencies": { - "avanza-ui": "file:../ui-components", + "@livekit/components-react": "^2.7.2", + "@livekit/components-styles": "^1.1.5", + "avanza-ui": "file:../avanza-ui", + "livekit-client": "^2.8.2", "react": "^18.2.0", "react-dom": "^18.2.0" }, "devDependencies": { + "@playwright/test": "1.51.0", + "@storybook/addon-essentials": "^8.0.0", + "@storybook/addon-interactions": "^8.0.0", + "@storybook/addon-links": "^8.0.0", + "@storybook/blocks": "^8.0.0", + "@storybook/react": "^8.0.0", + "@storybook/react-vite": "^8.0.0", + "@tailwindcss/postcss": "^4.1.17", "@testing-library/jest-dom": "^6.0.0", "@testing-library/react": "^14.0.0", "@testing-library/user-event": "^14.4.3", "@vitejs/plugin-react": "^4.0.0", + "node-fetch": "^3.3.1", + "playwright": "^1.51.0", + "storybook": "^8.0.0", + "tailwindcss": "^4.1.17", "typescript": "^5.5.0", - "vite": "^4.1.0", + "vite": "^5.0.0", "vitest": "^1.1.8" } }, - "packages/ui-components": { - "name": "avanza-ui", - "version": "1.0.0", + "packages/studio-panel/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "clsx": "^2.1.1" - }, - "devDependencies": { - "@rollup/plugin-commonjs": "^25.0.7", - "@rollup/plugin-node-resolve": "^15.2.3", - "@rollup/plugin-typescript": "^11.1.6", - "@types/react": "^18.3.3", - "@types/react-dom": "^18.3.0", - "react": "^18.3.1", - "react-dom": "^18.3.1", - "rollup": "^4.18.0", - "rollup-plugin-peer-deps-external": "^2.2.4", - "rollup-plugin-postcss": "^4.0.2", - "tslib": "^2.6.3", - "typescript": "^5.5.3", - "vitest": "^1.6.0" - }, - "peerDependencies": { - "react": "^18.0.0", - "react-dom": "^18.0.0" + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" } }, - "packages/ui-components/node_modules/@rollup/rollup-android-arm-eabi": { + "packages/studio-panel/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "packages/studio-panel/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "packages/studio-panel/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "packages/studio-panel/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "packages/studio-panel/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "packages/studio-panel/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "packages/studio-panel/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "packages/studio-panel/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/studio-panel/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/studio-panel/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/studio-panel/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/studio-panel/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/studio-panel/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/studio-panel/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/studio-panel/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/studio-panel/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/studio-panel/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "packages/studio-panel/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "packages/studio-panel/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "packages/studio-panel/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "packages/studio-panel/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "packages/studio-panel/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "packages/studio-panel/node_modules/@playwright/test": { + "version": "1.51.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.51.0.tgz", + "integrity": "sha512-dJ0dMbZeHhI+wb77+ljx/FeC8VBP6j/rj9OAojO08JI80wTZy6vRk9KvHKiDCUh4iMpEiseMgqRBIeW+eKX6RA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.51.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "packages/studio-panel/node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.53.2", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.2.tgz", "integrity": "sha512-yDPzwsgiFO26RJA4nZo8I+xqzh7sJTZIWQOxn+/XOdPE31lAvLIYCKqjV+lNH/vxE2L2iH3plKxDCRK6i+CwhA==", @@ -32175,7 +34613,7 @@ "android" ] }, - "packages/ui-components/node_modules/@rollup/rollup-android-arm64": { + "packages/studio-panel/node_modules/@rollup/rollup-android-arm64": { "version": "4.53.2", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.2.tgz", "integrity": "sha512-k8FontTxIE7b0/OGKeSN5B6j25EuppBcWM33Z19JoVT7UTXFSo3D9CdU39wGTeb29NO3XxpMNauh09B+Ibw+9g==", @@ -32189,7 +34627,7 @@ "android" ] }, - "packages/ui-components/node_modules/@rollup/rollup-darwin-arm64": { + "packages/studio-panel/node_modules/@rollup/rollup-darwin-arm64": { "version": "4.53.2", "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.2.tgz", "integrity": "sha512-A6s4gJpomNBtJ2yioj8bflM2oogDwzUiMl2yNJ2v9E7++sHrSrsQ29fOfn5DM/iCzpWcebNYEdXpaK4tr2RhfQ==", @@ -32203,7 +34641,7 @@ "darwin" ] }, - "packages/ui-components/node_modules/@rollup/rollup-darwin-x64": { + "packages/studio-panel/node_modules/@rollup/rollup-darwin-x64": { "version": "4.53.2", "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.2.tgz", "integrity": "sha512-e6XqVmXlHrBlG56obu9gDRPW3O3hLxpwHpLsBJvuI8qqnsrtSZ9ERoWUXtPOkY8c78WghyPHZdmPhHLWNdAGEw==", @@ -32217,7 +34655,7 @@ "darwin" ] }, - "packages/ui-components/node_modules/@rollup/rollup-freebsd-arm64": { + "packages/studio-panel/node_modules/@rollup/rollup-freebsd-arm64": { "version": "4.53.2", "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.2.tgz", "integrity": "sha512-v0E9lJW8VsrwPux5Qe5CwmH/CF/2mQs6xU1MF3nmUxmZUCHazCjLgYvToOk+YuuUqLQBio1qkkREhxhc656ViA==", @@ -32231,7 +34669,7 @@ "freebsd" ] }, - "packages/ui-components/node_modules/@rollup/rollup-freebsd-x64": { + "packages/studio-panel/node_modules/@rollup/rollup-freebsd-x64": { "version": "4.53.2", "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.2.tgz", "integrity": "sha512-ClAmAPx3ZCHtp6ysl4XEhWU69GUB1D+s7G9YjHGhIGCSrsg00nEGRRZHmINYxkdoJehde8VIsDC5t9C0gb6yqA==", @@ -32245,7 +34683,7 @@ "freebsd" ] }, - "packages/ui-components/node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "packages/studio-panel/node_modules/@rollup/rollup-linux-arm-gnueabihf": { "version": "4.53.2", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.2.tgz", "integrity": "sha512-EPlb95nUsz6Dd9Qy13fI5kUPXNSljaG9FiJ4YUGU1O/Q77i5DYFW5KR8g1OzTcdZUqQQ1KdDqsTohdFVwCwjqg==", @@ -32259,7 +34697,7 @@ "linux" ] }, - "packages/ui-components/node_modules/@rollup/rollup-linux-arm-musleabihf": { + "packages/studio-panel/node_modules/@rollup/rollup-linux-arm-musleabihf": { "version": "4.53.2", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.2.tgz", "integrity": "sha512-BOmnVW+khAUX+YZvNfa0tGTEMVVEerOxN0pDk2E6N6DsEIa2Ctj48FOMfNDdrwinocKaC7YXUZ1pHlKpnkja/Q==", @@ -32273,7 +34711,7 @@ "linux" ] }, - "packages/ui-components/node_modules/@rollup/rollup-linux-arm64-gnu": { + "packages/studio-panel/node_modules/@rollup/rollup-linux-arm64-gnu": { "version": "4.53.2", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.2.tgz", "integrity": "sha512-Xt2byDZ+6OVNuREgBXr4+CZDJtrVso5woFtpKdGPhpTPHcNG7D8YXeQzpNbFRxzTVqJf7kvPMCub/pcGUWgBjA==", @@ -32287,7 +34725,7 @@ "linux" ] }, - "packages/ui-components/node_modules/@rollup/rollup-linux-arm64-musl": { + "packages/studio-panel/node_modules/@rollup/rollup-linux-arm64-musl": { "version": "4.53.2", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.2.tgz", "integrity": "sha512-+LdZSldy/I9N8+klim/Y1HsKbJ3BbInHav5qE9Iy77dtHC/pibw1SR/fXlWyAk0ThnpRKoODwnAuSjqxFRDHUQ==", @@ -32301,7 +34739,7 @@ "linux" ] }, - "packages/ui-components/node_modules/@rollup/rollup-linux-loong64-gnu": { + "packages/studio-panel/node_modules/@rollup/rollup-linux-loong64-gnu": { "version": "4.53.2", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.2.tgz", "integrity": "sha512-8ms8sjmyc1jWJS6WdNSA23rEfdjWB30LH8Wqj0Cqvv7qSHnvw6kgMMXRdop6hkmGPlyYBdRPkjJnj3KCUHV/uQ==", @@ -32315,7 +34753,7 @@ "linux" ] }, - "packages/ui-components/node_modules/@rollup/rollup-linux-ppc64-gnu": { + "packages/studio-panel/node_modules/@rollup/rollup-linux-ppc64-gnu": { "version": "4.53.2", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.2.tgz", "integrity": "sha512-3HRQLUQbpBDMmzoxPJYd3W6vrVHOo2cVW8RUo87Xz0JPJcBLBr5kZ1pGcQAhdZgX9VV7NbGNipah1omKKe23/g==", @@ -32329,7 +34767,7 @@ "linux" ] }, - "packages/ui-components/node_modules/@rollup/rollup-linux-riscv64-gnu": { + "packages/studio-panel/node_modules/@rollup/rollup-linux-riscv64-gnu": { "version": "4.53.2", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.2.tgz", "integrity": "sha512-fMjKi+ojnmIvhk34gZP94vjogXNNUKMEYs+EDaB/5TG/wUkoeua7p7VCHnE6T2Tx+iaghAqQX8teQzcvrYpaQA==", @@ -32343,7 +34781,7 @@ "linux" ] }, - "packages/ui-components/node_modules/@rollup/rollup-linux-riscv64-musl": { + "packages/studio-panel/node_modules/@rollup/rollup-linux-riscv64-musl": { "version": "4.53.2", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.2.tgz", "integrity": "sha512-XuGFGU+VwUUV5kLvoAdi0Wz5Xbh2SrjIxCtZj6Wq8MDp4bflb/+ThZsVxokM7n0pcbkEr2h5/pzqzDYI7cCgLQ==", @@ -32357,7 +34795,7 @@ "linux" ] }, - "packages/ui-components/node_modules/@rollup/rollup-linux-s390x-gnu": { + "packages/studio-panel/node_modules/@rollup/rollup-linux-s390x-gnu": { "version": "4.53.2", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.2.tgz", "integrity": "sha512-w6yjZF0P+NGzWR3AXWX9zc0DNEGdtvykB03uhonSHMRa+oWA6novflo2WaJr6JZakG2ucsyb+rvhrKac6NIy+w==", @@ -32371,7 +34809,7 @@ "linux" ] }, - "packages/ui-components/node_modules/@rollup/rollup-linux-x64-gnu": { + "packages/studio-panel/node_modules/@rollup/rollup-linux-x64-gnu": { "version": "4.53.2", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.2.tgz", "integrity": "sha512-yo8d6tdfdeBArzC7T/PnHd7OypfI9cbuZzPnzLJIyKYFhAQ8SvlkKtKBMbXDxe1h03Rcr7u++nFS7tqXz87Gtw==", @@ -32385,7 +34823,7 @@ "linux" ] }, - "packages/ui-components/node_modules/@rollup/rollup-linux-x64-musl": { + "packages/studio-panel/node_modules/@rollup/rollup-linux-x64-musl": { "version": "4.53.2", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.2.tgz", "integrity": "sha512-ah59c1YkCxKExPP8O9PwOvs+XRLKwh/mV+3YdKqQ5AMQ0r4M4ZDuOrpWkUaqO7fzAHdINzV9tEVu8vNw48z0lA==", @@ -32399,7 +34837,7 @@ "linux" ] }, - "packages/ui-components/node_modules/@rollup/rollup-openharmony-arm64": { + "packages/studio-panel/node_modules/@rollup/rollup-openharmony-arm64": { "version": "4.53.2", "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.2.tgz", "integrity": "sha512-4VEd19Wmhr+Zy7hbUsFZ6YXEiP48hE//KPLCSVNY5RMGX2/7HZ+QkN55a3atM1C/BZCGIgqN+xrVgtdak2S9+A==", @@ -32413,7 +34851,7 @@ "openharmony" ] }, - "packages/ui-components/node_modules/@rollup/rollup-win32-arm64-msvc": { + "packages/studio-panel/node_modules/@rollup/rollup-win32-arm64-msvc": { "version": "4.53.2", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.2.tgz", "integrity": "sha512-IlbHFYc/pQCgew/d5fslcy1KEaYVCJ44G8pajugd8VoOEI8ODhtb/j8XMhLpwHCMB3yk2J07ctup10gpw2nyMA==", @@ -32427,7 +34865,7 @@ "win32" ] }, - "packages/ui-components/node_modules/@rollup/rollup-win32-ia32-msvc": { + "packages/studio-panel/node_modules/@rollup/rollup-win32-ia32-msvc": { "version": "4.53.2", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.2.tgz", "integrity": "sha512-lNlPEGgdUfSzdCWU176ku/dQRnA7W+Gp8d+cWv73jYrb8uT7HTVVxq62DUYxjbaByuf1Yk0RIIAbDzp+CnOTFg==", @@ -32441,7 +34879,7 @@ "win32" ] }, - "packages/ui-components/node_modules/@rollup/rollup-win32-x64-gnu": { + "packages/studio-panel/node_modules/@rollup/rollup-win32-x64-gnu": { "version": "4.53.2", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.2.tgz", "integrity": "sha512-S6YojNVrHybQis2lYov1sd+uj7K0Q05NxHcGktuMMdIQ2VixGwAfbJ23NnlvvVV1bdpR2m5MsNBViHJKcA4ADw==", @@ -32455,7 +34893,7 @@ "win32" ] }, - "packages/ui-components/node_modules/@rollup/rollup-win32-x64-msvc": { + "packages/studio-panel/node_modules/@rollup/rollup-win32-x64-msvc": { "version": "4.53.2", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.2.tgz", "integrity": "sha512-k+/Rkcyx//P6fetPoLMb8pBeqJBNGx81uuf7iljX9++yNBVRDQgD04L+SVXmXmh5ZP4/WOp4mWF0kmi06PW2tA==", @@ -32469,7 +34907,75 @@ "win32" ] }, - "packages/ui-components/node_modules/rollup": { + "packages/studio-panel/node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "packages/studio-panel/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "packages/studio-panel/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "packages/studio-panel/node_modules/rollup": { "version": "4.53.2", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.2.tgz", "integrity": "sha512-MHngMYwGJVi6Fmnk6ISmnk7JAHRNF0UkuucA0CUW3N3a4KnONPEZz+vUanQP/ZC/iY1Qkf3bwPWzyY84wEks1g==", @@ -32511,6 +35017,101 @@ "fsevents": "~2.3.2" } }, + "packages/studio-panel/node_modules/tailwindcss": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz", + "integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==", + "dev": true, + "license": "MIT" + }, + "packages/studio-panel/node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.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", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "packages/ui-components": { + "name": "avanza-ui", + "version": "1.0.0", + "extraneous": true, + "license": "MIT", + "dependencies": { + "clsx": "^2.1.1" + }, + "devDependencies": { + "@rollup/plugin-commonjs": "^25.0.7", + "@rollup/plugin-node-resolve": "^15.2.3", + "@rollup/plugin-typescript": "^11.1.6", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "rollup": "^4.18.0", + "rollup-plugin-peer-deps-external": "^2.2.4", + "rollup-plugin-postcss": "^4.0.2", + "tslib": "^2.6.3", + "typescript": "^5.5.3", + "vitest": "^1.6.0" + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, "packages/vristo-react-main": { "name": "vristo-react-vite", "version": "0.0.0", diff --git a/package.json b/package.json index 138767b..6cb3bb3 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ }, "devDependencies": { "concurrently": "^8.2.2", - "playwright": "^1.38.0", + "playwright": "^1.51.0", "typescript": "^5.2.2" }, "engines": { diff --git a/packages/avanza-ui/README.md b/packages/avanza-ui/README.md index 1a673d0..dae1d55 100644 --- a/packages/avanza-ui/README.md +++ b/packages/avanza-ui/README.md @@ -1,227 +1,19 @@ -# Avanza-UI +# avanza-ui -Biblioteca de componentes React personalizados para AvanzaCast, basada en el diseño de StreamYard con estilos propios sin dependencias de frameworks CSS. +Librería de componentes reutilizables para AvanzaCast. -## Características +Componentes añadidos en esta entrega: -- ✅ **Sin dependencias de CSS frameworks** - Estilos propios y personalizados -- ✅ **Basado en StreamYard** - Diseño moderno y profesional -- ✅ **TypeScript** - Tipado completo -- ✅ **Tema oscuro** - Optimizado para reducir fatiga visual -- ✅ **Accesible** - Componentes accesibles por defecto -- ✅ **Reutilizable** - Se puede importar desde cualquier package +- `ControlButton` - botón redondo con icono y etiqueta opcional (tamaños: sm|md|lg) +- `IconButton` - botón icon-only para acciones rápidas +- `ControlGroup` - contenedor para agrupar controles +- `ControlBar` - barra de controles centrada que usa `ControlGroup` -## Instalación +Importar desde otros paquetes: -Como esta es una librería local dentro del monorepo, simplemente impórtala en tu package: - -```json -{ - "dependencies": { - "avanza-ui": "workspace:*" - } -} +```ts +import { ControlButton, IconButton, ControlGroup, ControlBar } from 'avanza-ui' ``` -## Uso Básico - -```tsx -import { Button } from 'avanza-ui'; - -function App() { - return ( -
- -
- ); -} -``` - -**Importante:** Asegúrate de envolver tu aplicación con la clase `studio-theme` para aplicar los estilos correctamente. - -## Componentes Disponibles - -### Button - -Botón personalizable con múltiples variantes y tamaños. - -```tsx -import { Button } from 'avanza-ui'; - -// Variantes - - - - - - -// Tamaños - - - - -// Con íconos - - - - -// Estados - - - -// Full width - -``` - -#### Props del Button - -| Prop | Tipo | Default | Descripción | -|------|------|---------|-------------| -| `variant` | `'primary' \| 'secondary' \| 'danger' \| 'success' \| 'ghost'` | `'secondary'` | Variante visual del botón | -| `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | Tamaño del botón | -| `loading` | `boolean` | `false` | Muestra spinner de carga | -| `disabled` | `boolean` | `false` | Deshabilita el botón | -| `fullWidth` | `boolean` | `false` | Ancho completo | -| `iconOnly` | `boolean` | `false` | Solo muestra ícono (sin texto) | -| `leftIcon` | `ReactNode` | - | Ícono a la izquierda | -| `rightIcon` | `ReactNode` | - | Ícono a la derecha | - -## Variables CSS (Studio Theme) - -Todas las variables CSS están definidas en `studio-theme.css` y pueden ser personalizadas: - -### Colores - -```css ---studio-bg-primary: #0f0f0f; ---studio-bg-secondary: #1a1a1a; ---studio-bg-tertiary: #242424; ---studio-accent: #3b82f6; ---studio-success: #10b981; ---studio-warning: #f59e0b; ---studio-danger: #ef4444; -``` - -### Espaciado - -```css ---studio-space-xs: 4px; ---studio-space-sm: 8px; ---studio-space-md: 12px; ---studio-space-lg: 16px; ---studio-space-xl: 24px; -``` - -### Tipografía - -```css ---studio-text-xs: 11px; ---studio-text-sm: 12px; ---studio-text-base: 14px; ---studio-text-md: 16px; ---studio-text-lg: 18px; -``` - -### Border Radius - -```css ---studio-radius-sm: 4px; ---studio-radius-md: 6px; ---studio-radius-lg: 8px; ---studio-radius-xl: 12px; -``` - -## Personalización - -Puedes sobrescribir las variables CSS en tu aplicación: - -```css -:root { - --studio-accent: #your-color; - --studio-bg-primary: #your-bg; -} -``` - -## Próximos Componentes - -- [ ] Input -- [ ] Select -- [ ] Textarea -- [ ] Checkbox -- [ ] Radio -- [ ] Switch -- [ ] Modal -- [ ] Dropdown -- [ ] Tooltip -- [ ] Card -- [ ] Badge -- [ ] Avatar -- [ ] IconButton -- [ ] Tabs -- [ ] Panel -- [ ] Layout components (StudioLayout, TopBar, BottomBar, etc.) - -## Desarrollo - -### Estructura del Proyecto - -``` -avanza-ui/ -├── src/ -│ ├── components/ -│ │ ├── Button/ -│ │ │ ├── Button.tsx -│ │ │ ├── Button.css -│ │ │ └── index.ts -│ │ └── ... (más componentes) -│ ├── styles/ -│ │ └── studio-theme.css -│ └── index.ts -├── package.json -└── README.md -``` - -### Agregar un Nuevo Componente - -1. Crea una carpeta en `src/components/` -2. Crea `ComponentName.tsx` con el componente React -3. Crea `ComponentName.css` con los estilos -4. Crea `index.ts` para exportar el componente -5. Actualiza `src/index.ts` para exportar desde la raíz - -### Convenciones de Nomenclatura - -- **Componentes**: PascalCase (ej: `Button`, `IconButton`) -- **Archivos**: PascalCase para componentes, kebab-case para estilos -- **CSS Classes**: kebab-case con prefijo `avanza-` (ej: `avanza-button`) -- **CSS Variables**: kebab-case con prefijo `--studio-` (ej: `--studio-accent`) - -## Guía de Estilo - -### CSS - -- Usa variables CSS de `studio-theme.css` en lugar de valores hardcoded -- Sigue el patrón BEM para nombres de clases -- Agrupa propiedades relacionadas -- Usa transiciones para interacciones suaves - -### TypeScript - -- Exporta interfaces de props -- Usa `React.forwardRef` para componentes que necesiten refs -- Documenta props con JSDoc -- Usa tipos estrictos (evita `any`) - -## Licencia - -Uso interno - AvanzaCast - -## Contribuidores - -- Equipo AvanzaCast - ---- - -**Versión:** 1.0.0 -**Última actualización:** 2025-11-11 +Los estilos se importan como efecto secundario al importar `avanza-ui` (archivo `controls.css`). diff --git a/packages/avanza-ui/src/components/ControlBar.tsx b/packages/avanza-ui/src/components/ControlBar.tsx new file mode 100644 index 0000000..dde6e8c --- /dev/null +++ b/packages/avanza-ui/src/components/ControlBar.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { ControlGroup } from './ControlGroup'; + +export interface ControlBarProps { + children?: React.ReactNode; + className?: string; +} + +export const ControlBar: React.FC = ({ children, className }) => { + return ( +
+ + {children} + +
+ ); +}; + +ControlBar.displayName = 'ControlBar'; + +export default ControlBar; diff --git a/packages/avanza-ui/src/components/ControlGroup.module.css b/packages/avanza-ui/src/components/ControlGroup.module.css new file mode 100644 index 0000000..d518298 --- /dev/null +++ b/packages/avanza-ui/src/components/ControlGroup.module.css @@ -0,0 +1,16 @@ +.controlGroup{ + display:flex; + align-items:center; + gap:12px; + padding:6px; + background: transparent; +} + +.controlGroup.center{ + justify-content:center; +} + +.controlGroup.right{ + justify-content:flex-end; +} + diff --git a/packages/avanza-ui/src/components/ControlGroup.tsx b/packages/avanza-ui/src/components/ControlGroup.tsx new file mode 100644 index 0000000..112aa94 --- /dev/null +++ b/packages/avanza-ui/src/components/ControlGroup.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import styles from './ControlGroup.module.css'; + +export interface ControlGroupProps { + children?: React.ReactNode; + className?: string; + style?: React.CSSProperties; +} + +export const ControlGroup: React.FC = ({ children, className, style }) => { + return ( +
+ {children} +
+ ); +}; + +ControlGroup.displayName = 'ControlGroup'; + +export default ControlGroup; + diff --git a/packages/avanza-ui/src/components/IconButton.module.css b/packages/avanza-ui/src/components/IconButton.module.css new file mode 100644 index 0000000..0fed185 --- /dev/null +++ b/packages/avanza-ui/src/components/IconButton.module.css @@ -0,0 +1,21 @@ +.iconButton{ + display:inline-flex; + align-items:center; + justify-content:center; + border-radius:9999px; + background:transparent; + border:1px solid rgba(255,255,255,0.06); + color:var(--au-text-primary, #fff); + cursor:pointer; + transition: transform 120ms ease, background 120ms ease; +} + +.iconButton.sm{width:40px;height:40px;font-size:18px} +.iconButton.md{width:56px;height:56px;font-size:22px} +.iconButton.lg{width:72px;height:72px;font-size:28px} + +.iconButton:hover:not(:disabled){transform:translateY(-3px);background:rgba(255,255,255,0.06)} +.iconButton:active:not(:disabled){transform:translateY(0)} +.iconButton:disabled{opacity:0.5;cursor:not-allowed} +.iconButton.active{box-shadow:0 6px 20px rgba(79,70,229,0.32);background:var(--au-primary)} + diff --git a/packages/avanza-ui/src/components/IconButton.tsx b/packages/avanza-ui/src/components/IconButton.tsx new file mode 100644 index 0000000..0141624 --- /dev/null +++ b/packages/avanza-ui/src/components/IconButton.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import styles from './IconButton.module.css'; +import { cn } from '../utils/helpers'; + +export interface IconButtonProps { + icon?: React.ReactNode; + onClick?: (e?: React.MouseEvent) => void; + active?: boolean; + disabled?: boolean; + title?: string; + size?: 'sm' | 'md' | 'lg'; + id?: string; + className?: string; + style?: React.CSSProperties; +} + +export const IconButton: React.FC = ({ icon, onClick, active = false, disabled = false, title, size = 'md', id, className, style }) => { + return ( + + ); +}; + +IconButton.displayName = 'IconButton'; + +export default IconButton; + diff --git a/packages/avanza-ui/src/components/StudioHeader.module.css b/packages/avanza-ui/src/components/StudioHeader.module.css index 747dea2..70f84f9 100644 --- a/packages/avanza-ui/src/components/StudioHeader.module.css +++ b/packages/avanza-ui/src/components/StudioHeader.module.css @@ -1,12 +1,26 @@ +@import '../styles/globals.css'; + +/* Fallback tokens para análisis estático */ +:root{ + --au-gray-950: #0b1220; + --au-gray-900: #0f172a; + --au-primary: #4f46e5; + --au-primary-hover: #4338ca; + --au-radius-md: 8px; + --au-font-bold: 700; + --au-text-primary: #f1f5f9; + --au-text-secondary: #cbd5e1; +} + .studioHeader { display: flex; align-items: center; justify-content: space-between; - height: 60px; - padding: 0 20px; - background: linear-gradient(180deg, var(--au-gray-800) 0%, var(--au-gray-900) 100%); - border-bottom: 1px solid var(--au-border-dark); - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); + height: 64px; + padding: 0 24px; + background: linear-gradient(180deg, var(--au-gray-900, #0f172a) 0%, var(--au-gray-950, #0b1220) 100%); + border-bottom: 1px solid rgba(255,255,255,0.03); + box-shadow: 0 6px 22px rgba(2,6,23,0.45); } .headerLeft { @@ -16,19 +30,19 @@ } .headerLogo { - width: 40px; - height: 40px; - border-radius: var(--au-radius-md); + width: 48px; + height: 48px; + border-radius: calc(var(--au-radius-md, 8px) + 4px); display: flex; align-items: center; justify-content: center; - font-weight: var(--au-font-bold); + font-weight: var(--au-font-bold, 700); font-size: 18px; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.45); } .headerLogoGradient { - background: linear-gradient(135deg, var(--au-primary) 0%, var(--au-primary-hover) 100%); + background: linear-gradient(135deg, var(--au-primary, #4f46e5) 0%, var(--au-primary-hover, #4338ca) 100%); color: white; } @@ -39,14 +53,15 @@ } .headerTitleMain { - font-weight: var(--au-font-bold); + font-weight: var(--au-font-bold, 700); font-size: 16px; - color: var(--au-text-primary); + color: var(--au-text-primary, #f1f5f9); } .headerTitleSub { font-size: 12px; - color: var(--au-text-secondary); + color: var(--au-text-secondary, #cbd5e1); + opacity: 0.9; } .headerRight { @@ -54,4 +69,3 @@ align-items: center; gap: 12px; } - diff --git a/packages/avanza-ui/src/components/VideoTile.module.css b/packages/avanza-ui/src/components/VideoTile.module.css index e185de1..efd81be 100644 --- a/packages/avanza-ui/src/components/VideoTile.module.css +++ b/packages/avanza-ui/src/components/VideoTile.module.css @@ -1,23 +1,44 @@ +@import '../styles/globals.css'; + +/* Fallback tokens para análisis estático */ +:root{ + --au-gray-950: #0b1220; + --au-gray-900: #0f172a; + --au-gray-800: #111827; + --au-gray-700: #1f2937; + --au-gray-600: #374151; + --au-primary: #4f46e5; + --au-primary-hover: #4338ca; + --au-warning-500: #f59e0b; + --au-success-500: #10b981; + --au-radius-lg: 12px; + --au-radius-md: 8px; + --au-radius-sm: 4px; + --au-radius-full: 9999px; + --au-transition-fast: 150ms ease; + --au-font-medium: 500; +} + .videoTile { position: relative; aspect-ratio: 16 / 9; - background: linear-gradient(135deg, var(--au-gray-700) 0%, var(--au-gray-800) 100%); - border-radius: var(--au-radius-lg); + background: linear-gradient(135deg, var(--au-gray-700, #1f2937) 0%, var(--au-gray-800, #111827) 100%); + border-radius: var(--au-radius-lg, 12px); overflow: hidden; border: 2px solid transparent; - transition: all var(--au-transition-fast); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + transition: all var(--au-transition-fast, 150ms ease); + box-shadow: 0 8px 28px rgba(15, 23, 42, 0.18); } .videoTile:hover { - border-color: var(--au-primary); - box-shadow: 0 6px 20px rgba(79, 70, 229, 0.3); + border-color: var(--au-primary, #4f46e5); + box-shadow: 0 10px 36px rgba(79, 70, 229, 0.28); transform: translateY(-2px); } .videoTile.speaking { - border-color: var(--au-warning-500); - box-shadow: 0 6px 20px rgba(234, 179, 8, 0.4); + border-color: var(--au-warning-500, #f59e0b); + box-shadow: 0 10px 36px rgba(234, 179, 8, 0.38); } .videoElement { @@ -26,13 +47,13 @@ width: 100%; height: 100%; object-fit: cover; - background-color: var(--au-gray-800); + background-color: var(--au-gray-800, #111827); } .videoOverlay { position: absolute; inset: 0; - background: linear-gradient(to top, rgba(0,0,0,0.6) 0%, transparent 50%); + background: linear-gradient(to top, rgba(0,0,0,0.6) 0%, rgba(0,0,0,0.18) 45%, transparent 60%); pointer-events: none; } @@ -66,28 +87,28 @@ } .videoName { - color: white; + color: var(--au-text-on-video, #fff); font-size: 14px; - font-weight: var(--au-font-medium); + font-weight: var(--au-font-medium, 500); max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5); + text-shadow: 0 2px 6px rgba(0, 0, 0, 0.6); } .videoStatus { - color: rgba(255, 255, 255, 0.9); + color: rgba(255, 255, 255, 0.95); font-size: 12px; - text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5); + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6); } .videoControl { - width: 36px; - height: 36px; - border-radius: var(--au-radius-full); + width: 40px; + height: 40px; + border-radius: var(--au-radius-full, 9999px); background: rgba(0, 0, 0, 0.6); - backdrop-filter: blur(4px); + backdrop-filter: blur(6px); color: white; border: none; cursor: pointer; @@ -95,35 +116,34 @@ align-items: center; justify-content: center; font-size: 18px; - transition: all var(--au-transition-fast); + transition: all var(--au-transition-fast, 150ms ease); } .videoControl:hover { - background: rgba(0, 0, 0, 0.8); - transform: scale(1.1); + background: rgba(0, 0, 0, 0.85); + transform: scale(1.06); } .videoControl.muted { - background: rgba(220, 38, 38, 0.9); + background: rgba(225, 29, 72, 0.95); } .qualityIndicator { display: flex; align-items: flex-end; - gap: 2px; + gap: 4px; padding: 4px 8px; background: rgba(0, 0, 0, 0.6); - border-radius: var(--au-radius-md); - backdrop-filter: blur(4px); + border-radius: var(--au-radius-md, 8px); + backdrop-filter: blur(6px); } .qualityBar { - width: 2px; - background: var(--au-success-500); - border-radius: var(--au-radius-sm); + width: 3px; + background: var(--au-success-500, #10b981); + border-radius: var(--au-radius-sm, 4px); } .qualityBar.inactive { - background: var(--au-gray-600); + background: var(--au-gray-600, #374151); } - diff --git a/packages/avanza-ui/src/index.ts b/packages/avanza-ui/src/index.ts index 66c8338..c5c57a6 100644 --- a/packages/avanza-ui/src/index.ts +++ b/packages/avanza-ui/src/index.ts @@ -1,5 +1,6 @@ // Styles import './styles/globals.css'; +import './styles/controls.css'; // Components export { Button } from './components/Button'; @@ -69,6 +70,15 @@ export type { StudioHeaderProps } from './components/StudioHeader'; export { ControlButton } from './components/ControlButton'; export type { ControlButtonProps } from './components/ControlButton'; +export { ControlGroup } from './components/ControlGroup'; +export type { ControlGroupProps } from './components/ControlGroup'; + +export { ControlBar } from './components/ControlBar'; +export type { ControlBarProps } from './components/ControlBar'; + +export { IconButton } from './components/IconButton'; +export type { IconButtonProps } from './components/IconButton'; + export { SceneCard } from './components/SceneCard'; export type { SceneCardProps } from './components/SceneCard'; @@ -80,4 +90,3 @@ export type { ButtonVariant, ButtonSize, Theme, ComponentBaseProps } from './typ // Utils export { cn, formatDate, generateId, debounce, throttle } from './utils/helpers'; - diff --git a/packages/avanza-ui/src/styles/controls.css b/packages/avanza-ui/src/styles/controls.css new file mode 100644 index 0000000..cb63b61 --- /dev/null +++ b/packages/avanza-ui/src/styles/controls.css @@ -0,0 +1,108 @@ +/* controls.css - estilos reutilizables para controles tipo Streamyard + Ubicación: packages/avanza-ui/src/styles/controls.css +*/ + +/* Container for the control bar */ +.controls-inner { + display: inline-flex; + align-items: center; + gap: 12px; + padding: 10px 14px; + background: rgba(0,0,0,0.65); + border-radius: 10px; + color: #fff; +} + +/* Individual control wrapper */ +.control-wrapper { + position: relative; + display: inline-flex; + flex-direction: column; + align-items: center; +} + +/* Button base */ +.btn-control { + display: inline-flex; + align-items: center; + justify-content: center; + width: 44px; + height: 44px; + border-radius: 9999px; + background: rgba(255,255,255,0.06); + border: 1px solid rgba(255,255,255,0.06); + color: white; + cursor: pointer; + transition: transform 120ms ease, background 120ms ease, box-shadow 120ms ease; + padding: 6px; +} + +.btn-control:hover { + transform: translateY(-2px); + background: rgba(255,255,255,0.12); +} + +.btn-control:active { + transform: translateY(0); +} + +/* Danger style (record) */ +.btn-control--danger { + background: linear-gradient(180deg, rgba(239,68,68,0.98), rgba(220,38,38,0.98)); + border: 1px solid rgba(0,0,0,0.2); +} + +.btn-control--danger.recording { + box-shadow: 0 6px 18px rgba(239,68,68,0.28), 0 2px 6px rgba(0,0,0,0.3); +} + +/* Small red dot when recording */ +.record-dot { + display: inline-block; + width: 10px; + height: 10px; + background: var(--studio-recording, #ef4444); + border-radius: 9999px; + margin-right: 8px; + box-shadow: 0 4px 10px rgba(239,68,68,0.28); +} + +/* Tooltip below controls */ +.tooltip { + position: absolute; + bottom: -30px; + left: 50%; + transform: translateX(-50%); + background: rgba(31,41,55,0.92); + color: #fff; + padding: 6px 8px; + border-radius: 6px; + font-size: 12px; + white-space: nowrap; + display: none; + opacity: 0; + transition: opacity 120ms ease, transform 120ms ease; +} + +.control-wrapper:hover .tooltip { + display: block; + opacity: 1; + transform: translateX(-50%) translateY(-4px); +} + +/* Visual hidden utility */ +.visually-hidden { + position: absolute !important; + height: 1px; width: 1px; + overflow: hidden; + clip: rect(1px, 1px, 1px, 1px); + white-space: nowrap; + border: 0; padding: 0; margin: -1px; +} + +/* Responsive placement for small screens */ +@media (max-width: 640px) { + .controls-inner { padding: 8px 10px; gap: 8px; } + .btn-control { width: 40px; height: 40px; } +} + diff --git a/packages/avanza-ui/src/styles/globals.css b/packages/avanza-ui/src/styles/globals.css index e69de29..2716f2b 100644 --- a/packages/avanza-ui/src/styles/globals.css +++ b/packages/avanza-ui/src/styles/globals.css @@ -0,0 +1,154 @@ +/* avanza-ui global tokens and resets */ +:root{ + /* Colors */ + --au-gray-950: #0b1220; + --au-gray-900: #0f172a; + --au-gray-800: #111827; + --au-gray-700: #1f2937; + --au-gray-600: #374151; + --au-gray-600-2: #4b5563; + + --au-primary: #4f46e5; + --au-primary-hover: #4338ca; + --au-success-500: #10b981; + --au-warning-500: #f59e0b; + --au-danger-500: #ef4444; + + --au-text-primary: #f1f5f9; + --au-text-secondary: #cbd5e1; + + /* Radius */ + --au-radius-sm: 4px; + --au-radius-md: 8px; + --au-radius-lg: 12px; + --au-radius-full: 9999px; + + /* Typography */ + --au-font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + --au-font-bold: 700; + --au-font-medium: 500; + --au-font-normal: 400; + + /* Transitions */ + --au-transition-fast: 150ms ease; + --au-transition-medium: 250ms ease; + --au-transition-slow: 400ms ease; + + /* Shadows */ + --au-shadow-sm: 0 4px 12px rgba(2,6,23,0.18); + --au-shadow-md: 0 8px 24px rgba(2,6,23,0.28); +} + +/* Light theme overrides (if used in non-dark mode) */ +[data-theme="light"]{ + --au-text-primary: #1f2937; + --au-text-secondary: #6b7280; + --au-gray-950: #f8fafc; + --au-gray-900: #ffffff; + --au-gray-800: #f3f4f6; + --au-gray-700: #e5e7eb; + --au-gray-600: #9ca3af; +} + +/* Basic resets for avanza-ui components */ +.au-root, .avanza-ui-root { + font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + color: var(--au-text-primary); +} + +button { font-family: inherit } + +/* Ensure modal/backdrop stacking works */ +.avanza-ui-modal-backdrop { z-index: 9999 } + +/* Compatibility tokens for other packages (broadcast-panel / studio-panel) + These map commonly used project tokens to the avanza-ui design tokens so + all packages can import the avanza-ui globals and get consistent values. +*/ +:root{ + /* Broadcast-style tokens */ + --primary-blue: var(--au-primary); + --primary-blue-hover: var(--au-primary-hover); + --background-color: var(--au-gray-950); + --surface-color: var(--au-gray-900); + --text-primary: var(--au-text-primary); + --text-secondary: var(--au-text-secondary); + --border-light: rgba(255,255,255,0.04); + --active-bg-light: rgba(79,70,229,0.06); + --shadow-sm: var(--au-shadow-sm); + --shadow-md: var(--au-shadow-md); + --skeleton-base: #e5e7eb; + --skeleton-highlight: #f3f4f6; + + /* Surface tokens used by studio-panel */ + --surface-50: #f8fafc; + --surface-900: #0f172a; + + /* Studio specific tokens (map to avanza-ui tokens) */ + --studio-bg-primary: var(--background-color); + --studio-bg-secondary: var(--surface-color); + --studio-bg-tertiary: var(--active-bg-light); + --studio-bg-elevated: var(--surface-color); + --studio-bg-hover: rgba(255,255,255,0.02); + + --studio-border: var(--border-light); + --studio-border-light: rgba(255,255,255,0.02); + --studio-border-subtle: rgba(255,255,255,0.01); + + --studio-text-primary: var(--text-primary); + --studio-text-secondary: var(--text-secondary); + --studio-text-muted: #94a3b8; + --studio-text-disabled: #9ca3af; + + --studio-accent: var(--primary-blue); + --studio-accent-hover: var(--primary-blue-hover); + --studio-accent-light: rgba(79,70,229,0.08); + + --studio-success: var(--au-success-500); + --studio-warning: var(--au-warning-500); + --studio-danger: var(--au-danger-500); + + --studio-recording: var(--au-danger-500); + --studio-recording-pulse: rgba(239, 68, 68, 0.12); + + --studio-space-xs: 4px; + --studio-space-sm: 8px; + --studio-space-md: 12px; + --studio-space-lg: 16px; + --studio-space-xl: 24px; + + --studio-radius-sm: var(--au-radius-sm); + --studio-radius-md: var(--au-radius-md); + --studio-radius-lg: var(--au-radius-lg); + --studio-radius-xl: calc(var(--au-radius-lg) + 4px); + + --studio-font-family: var(--au-font-family, 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif); + --studio-text-base: 14px; + --studio-text-sm: 12px; + + /* Additional studio tokens required by studio-theme.css and components */ + --studio-font-normal: var(--au-font-normal, 400); + --studio-leading-normal: 1.4; + --studio-radius-full: var(--au-radius-full, 9999px); + + --studio-shadow-sm: var(--au-shadow-sm); + --studio-shadow-md: var(--au-shadow-md); + --studio-shadow-lg: 0 12px 40px rgba(2,6,23,0.32); + + --studio-transition: 200ms ease; + --studio-transition-fast: 120ms ease; + --studio-transition-slow: 320ms ease; +} + +/* Light theme compatibility mapping */ +[data-theme="light"]{ + --primary-blue: var(--au-primary); + --background-color: #f7f8fa; + --surface-color: #ffffff; + --text-primary: #1f2937; + --text-secondary: #6b7280; + --surface-50: #f8fafc; + --surface-900: #0f172a; +} + +/* End of compatibility tokens */ diff --git a/packages/avanza-ui/src/styles/studio-theme.css b/packages/avanza-ui/src/styles/studio-theme.css index 0febc61..9be6ddc 100644 --- a/packages/avanza-ui/src/styles/studio-theme.css +++ b/packages/avanza-ui/src/styles/studio-theme.css @@ -1,3 +1,5 @@ +@import './globals.css'; + /** * Studio Theme - Basado en el análisis de StreamYard * Versión: 1.0 @@ -5,133 +7,117 @@ */ :root { - /* ===== COLORS ===== */ - /* Backgrounds */ - --studio-bg-primary: #0f0f0f; - --studio-bg-secondary: #1a1a1a; - --studio-bg-tertiary: #242424; - --studio-bg-elevated: #2a2a2a; - --studio-bg-hover: #333333; + /* ===== BROADCAST-PANEL COMPATIBILITY (LIGHT THEME) ===== */ + /* These variables mirror names used in broadcast-panel/src/styles.css to ease reuse */ + --primary-blue: #4f46e5; + --primary-blue-hover: #4338ca; + --background-color: #f7f8fa; + --surface-color: #ffffff; + --text-primary: #1f2937; + --text-secondary: #6b7280; + --border-light: #e5e7eb; + --active-bg-light: #eef2ff; + --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05); + --shadow-md: 0 1px 3px 0 rgba(0, 0, 0, 0.1); + --shadow-lg: 0 4px 6px -1px rgba(0, 0, 0, 0.1); + --skeleton-base: #e5e7eb; + --skeleton-highlight: #f3f4f6; + + /* ===== STUDIO TOKENS (light mode) ===== */ + --studio-bg-primary: var(--background-color); + --studio-bg-secondary: #f2f4f8; /* subtle surface */ + --studio-bg-tertiary: #eef2ff; /* accent panel */ + --studio-bg-elevated: var(--surface-color); + --studio-bg-hover: #f3f5f9; /* Borders */ - --studio-border: #333333; - --studio-border-light: #404040; - --studio-border-subtle: #2a2a2a; + --studio-border: var(--border-light); + --studio-border-light: #f1f5f9; + --studio-border-subtle: #eef2f6; /* Text */ - --studio-text-primary: #ffffff; - --studio-text-secondary: #e0e0e0; - --studio-text-muted: #999999; - --studio-text-disabled: #666666; + --studio-text-primary: var(--text-primary); + --studio-text-secondary: var(--text-secondary); + --studio-text-muted: #64748b; + --studio-text-disabled: #9ca3af; /* Accent Colors */ - --studio-accent: #3b82f6; - --studio-accent-hover: #2563eb; - --studio-accent-light: rgba(59, 130, 246, 0.1); + --studio-accent: var(--primary-blue); + --studio-accent-hover: var(--primary-blue-hover); + --studio-accent-light: rgba(79, 70, 229, 0.08); /* Status Colors */ --studio-success: #10b981; - --studio-success-hover: #059669; --studio-warning: #f59e0b; - --studio-warning-hover: #d97706; --studio-danger: #ef4444; - --studio-danger-hover: #dc2626; - /* Recording State */ + /* Recording */ --studio-recording: #ef4444; - --studio-recording-pulse: rgba(239, 68, 68, 0.4); + --studio-recording-pulse: rgba(239, 68, 68, 0.12); - /* ===== SPACING ===== */ + /* Spacing, radius, typography copied from previous tokens */ --studio-space-xs: 4px; --studio-space-sm: 8px; --studio-space-md: 12px; --studio-space-lg: 16px; --studio-space-xl: 24px; - --studio-space-2xl: 32px; - --studio-space-3xl: 48px; - /* ===== BORDER RADIUS ===== */ --studio-radius-sm: 4px; --studio-radius-md: 6px; --studio-radius-lg: 8px; --studio-radius-xl: 12px; - --studio-radius-2xl: 16px; - --studio-radius-full: 9999px; - /* ===== SHADOWS ===== */ - --studio-shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.3); - --studio-shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3); - --studio-shadow-md: 0 4px 6px rgba(0, 0, 0, 0.4); - --studio-shadow-lg: 0 10px 25px rgba(0, 0, 0, 0.5); - --studio-shadow-xl: 0 20px 40px rgba(0, 0, 0, 0.6); - - /* ===== TRANSITIONS ===== */ - --studio-transition: all 150ms cubic-bezier(0.4, 0, 0.2, 1); - --studio-transition-fast: all 100ms cubic-bezier(0.4, 0, 0.2, 1); - --studio-transition-slow: all 250ms cubic-bezier(0.4, 0, 0.2, 1); - - /* ===== TYPOGRAPHY ===== */ - --studio-font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', - 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', - 'Helvetica Neue', sans-serif; - - /* Font Sizes */ - --studio-text-xs: 11px; - --studio-text-sm: 12px; + --studio-font-family: "Inter", -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', system-ui, sans-serif; --studio-text-base: 14px; - --studio-text-md: 16px; - --studio-text-lg: 18px; - --studio-text-xl: 20px; - --studio-text-2xl: 24px; + --studio-text-sm: 12px; +} - /* Font Weights */ - --studio-font-normal: 400; - --studio-font-medium: 500; - --studio-font-semibold: 600; - --studio-font-bold: 700; +/* Dark theme preserves previous studio variables but also map broadcast-panel dark tokens for compatibility */ +[data-theme="dark"] { + /* broadcast-panel dark equivalents */ + --primary-blue: #6366f1; + --primary-blue-hover: #4f46e5; + --background-color: #0f172a; + --surface-color: #1e293b; + --text-primary: #f1f5f9; + --text-secondary: #cbd5e1; + --border-light: #334155; + --active-bg-light: #312e81; + --shadow-sm: 0 1px 2px 0 rgba(0,0,0,0.3); + --shadow-md: 0 1px 3px 0 rgba(0,0,0,0.4); + --shadow-lg: 0 4px 6px -1px rgba(0,0,0,0.5); + --skeleton-base: #334155; + --skeleton-highlight: #475569; - /* Line Heights */ - --studio-leading-tight: 1.2; - --studio-leading-normal: 1.5; - --studio-leading-relaxed: 1.75; + /* studio theme dark overrides */ + --studio-bg-primary: var(--background-color); + --studio-bg-secondary: var(--surface-color); + --studio-bg-tertiary: #111827; /* dark panel */ + --studio-bg-elevated: #0b1220; + --studio-bg-hover: #0f172a; - /* ===== SIZING ===== */ - /* Button Sizes */ - --studio-btn-sm-height: 32px; - --studio-btn-md-height: 40px; - --studio-btn-lg-height: 48px; + --studio-border: var(--border-light); + --studio-border-light: #24303b; + --studio-border-subtle: #1f2a36; - /* Icon Sizes */ - --studio-icon-xs: 14px; - --studio-icon-sm: 16px; - --studio-icon-md: 20px; - --studio-icon-lg: 24px; - --studio-icon-xl: 32px; + --studio-text-primary: var(--text-primary); + --studio-text-secondary: var(--text-secondary); + --studio-text-muted: #94a3b8; + --studio-text-disabled: #6b7280; - /* Panel Widths */ - --studio-panel-left-width: 220px; - --studio-panel-right-width: 320px; - --studio-panel-collapsed-width: 60px; - - /* ===== Z-INDEX ===== */ - --studio-z-base: 1; - --studio-z-dropdown: 1000; - --studio-z-sticky: 1020; - --studio-z-fixed: 1030; - --studio-z-overlay: 1040; - --studio-z-modal: 1050; - --studio-z-popover: 1060; - --studio-z-tooltip: 1070; + --studio-accent: var(--primary-blue); + --studio-accent-hover: var(--primary-blue-hover); + --studio-accent-light: rgba(99, 102, 241, 0.08); } /* ===== GLOBAL RESETS ===== */ .studio-theme { - font-family: var(--studio-font-family); - font-size: var(--studio-text-base); - font-weight: var(--studio-font-normal); - line-height: var(--studio-leading-normal); - color: var(--studio-text-primary); - background-color: var(--studio-bg-primary); + font-family: var(--studio-font-family, var(--au-font-family, 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif)); + font-size: var(--studio-text-base, 14px); + font-weight: var(--studio-font-normal, 400); + line-height: var(--studio-leading-normal, 1.4); + color: var(--studio-text-primary, #111827); + background-color: var(--studio-bg-primary, #f7f8fa); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } @@ -151,54 +137,54 @@ } .studio-theme ::-webkit-scrollbar-track { - background: var(--studio-bg-secondary); + background: var(--studio-bg-secondary, #f2f4f8); } .studio-theme ::-webkit-scrollbar-thumb { - background: var(--studio-bg-hover); - border-radius: var(--studio-radius-full); + background: var(--studio-bg-hover, #f3f5f9); + border-radius: var(--studio-radius-full, 9999px); } .studio-theme ::-webkit-scrollbar-thumb:hover { - background: var(--studio-border-light); + background: var(--studio-border-light, rgba(255,255,255,0.02)); } /* ===== UTILITY CLASSES ===== */ /* Backgrounds */ -.bg-primary { background-color: var(--studio-bg-primary); } -.bg-secondary { background-color: var(--studio-bg-secondary); } -.bg-tertiary { background-color: var(--studio-bg-tertiary); } -.bg-elevated { background-color: var(--studio-bg-elevated); } -.bg-hover { background-color: var(--studio-bg-hover); } +.bg-primary { background-color: var(--studio-bg-primary, #f7f8fa); } +.bg-secondary { background-color: var(--studio-bg-secondary, #f2f4f8); } +.bg-tertiary { background-color: var(--studio-bg-tertiary, #eef2ff); } +.bg-elevated { background-color: var(--studio-bg-elevated, #ffffff); } +.bg-hover { background-color: var(--studio-bg-hover, #f3f5f9); } /* Text Colors */ -.text-primary { color: var(--studio-text-primary); } -.text-secondary { color: var(--studio-text-secondary); } -.text-muted { color: var(--studio-text-muted); } -.text-disabled { color: var(--studio-text-disabled); } +.text-primary { color: var(--studio-text-primary, #1f2937); } +.text-secondary { color: var(--studio-text-secondary, #6b7280); } +.text-muted { color: var(--studio-text-muted, #64748b); } +.text-disabled { color: var(--studio-text-disabled, #9ca3af); } /* Borders */ -.border { border: 1px solid var(--studio-border); } -.border-light { border: 1px solid var(--studio-border-light); } -.border-subtle { border: 1px solid var(--studio-border-subtle); } +.border { border: 1px solid var(--studio-border, rgba(0,0,0,0.06)); } +.border-light { border: 1px solid var(--studio-border-light, rgba(0,0,0,0.03)); } +.border-subtle { border: 1px solid var(--studio-border-subtle, rgba(0,0,0,0.02)); } /* Radius */ -.rounded-sm { border-radius: var(--studio-radius-sm); } -.rounded-md { border-radius: var(--studio-radius-md); } -.rounded-lg { border-radius: var(--studio-radius-lg); } -.rounded-xl { border-radius: var(--studio-radius-xl); } -.rounded-full { border-radius: var(--studio-radius-full); } +.rounded-sm { border-radius: var(--studio-radius-sm, 4px); } +.rounded-md { border-radius: var(--studio-radius-md, 6px); } +.rounded-lg { border-radius: var(--studio-radius-lg, 8px); } +.rounded-xl { border-radius: var(--studio-radius-xl, 12px); } +.rounded-full { border-radius: var(--studio-radius-full, 9999px); } /* Shadows */ -.shadow-sm { box-shadow: var(--studio-shadow-sm); } -.shadow-md { box-shadow: var(--studio-shadow-md); } -.shadow-lg { box-shadow: var(--studio-shadow-lg); } +.shadow-sm { box-shadow: var(--studio-shadow-sm, 0 1px 2px rgba(0,0,0,0.05)); } +.shadow-md { box-shadow: var(--studio-shadow-md, 0 4px 12px rgba(0,0,0,0.08)); } +.shadow-lg { box-shadow: var(--studio-shadow-lg, 0 12px 40px rgba(2,6,23,0.12)); } /* Transitions */ -.transition { transition: var(--studio-transition); } -.transition-fast { transition: var(--studio-transition-fast); } -.transition-slow { transition: var(--studio-transition-slow); } +.transition { transition: var(--studio-transition, 200ms ease); } +.transition-fast { transition: var(--studio-transition-fast, 120ms ease); } +.transition-slow { transition: var(--studio-transition-slow, 320ms ease); } /* ===== ANIMATIONS ===== */ @keyframes pulse-recording { @@ -256,4 +242,3 @@ .animate-slide-in-left { animation: slide-in-left 250ms ease-out; } - diff --git a/packages/backend-api/.env.production b/packages/backend-api/.env.production new file mode 100644 index 0000000..f56993e --- /dev/null +++ b/packages/backend-api/.env.production @@ -0,0 +1,15 @@ +# Backend API production env (DO NOT commit secrets in public repos) +VITE_STUDIO_URL=https://avanzacast-studio.bfzqqk.easypanel.host +VITE_BROADCASTPANEL_URL=https://avanzacast-broadcastpanel.bfzqqk.easypanel.host +VITE_TOKEN_SERVER_URL=https://avanzacast-servertokens.bfzqqk.easypanel.host + +# LiveKit credentials - set real values in your production environment or CI secrets +LIVEKIT_API_KEY=devkey +LIVEKIT_API_SECRET=secretsecretsecretsecretsecretsecret +LIVEKIT_URL=wss://livekit-server.bfzqqk.easypanel.host + +# Allow frontend origins (production) +FRONTEND_URLS=https://avanzacast-broadcastpanel.bfzqqk.easypanel.host,https://avanzacast-studio.bfzqqk.easypanel.host + +PORT=4000 +NODE_ENV=production diff --git a/packages/backend-api/Dockerfile b/packages/backend-api/Dockerfile new file mode 100644 index 0000000..533dcfd --- /dev/null +++ b/packages/backend-api/Dockerfile @@ -0,0 +1,28 @@ +# Multi-stage Dockerfile for backend-api +# Build stage: install deps and compile +FROM node:20-bullseye-slim AS builder +WORKDIR /app + +# Install build dependencies +COPY package.json package-lock.json* ./ +# Try npm ci (fast & reproducible). If package-lock.json is missing, fallback to npm install +RUN apt-get update && apt-get install -y --no-install-recommends python3 build-essential ca-certificates && rm -rf /var/lib/apt/lists/* \ + && (npm ci --no-audit --no-fund || npm install --no-audit --no-fund) + +# Copy source and build +COPY . . +RUN npm run build + +# Production stage: copy built files and production deps +FROM node:20-bullseye-slim +WORKDIR /app +# Copy node_modules from builder (includes production deps) +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/dist ./dist + +ENV NODE_ENV=production +ENV HOST=0.0.0.0 +ENV PORT=4000 +EXPOSE 4000 + +CMD ["node", "dist/index.js"] diff --git a/packages/backend-api/build_and_run_backend.sh b/packages/backend-api/build_and_run_backend.sh new file mode 100755 index 0000000..e74275a --- /dev/null +++ b/packages/backend-api/build_and_run_backend.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT=$(cd "$(dirname "$0")" && pwd) +cd "$ROOT" + +IMAGE_TAG="avanzacast/backend-api:local" + +# Build the image +docker build -t "$IMAGE_TAG" . + +# Run container mapping port 4000 +CONTAINER_NAME="avz_backend_local" +# stop existing +docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true + +docker run -d --name "$CONTAINER_NAME" -p 4000:4000 \ + -e HOST=0.0.0.0 -e PORT=4000 \ + "$IMAGE_TAG" + +echo "Started container $CONTAINER_NAME (image $IMAGE_TAG)" + +echo 'Wait 2s then curl /health' +sleep 2 +curl -sS http://localhost:4000/health || true + diff --git a/packages/backend-api/package.json b/packages/backend-api/package.json index b32639e..bad9f7f 100644 --- a/packages/backend-api/package.json +++ b/packages/backend-api/package.json @@ -5,7 +5,7 @@ "description": "AvanzaCast - Backend API", "type": "module", "scripts": { - "dev": "tsx watch src/index.ts", + "dev": "npx tsx watch src/index.ts", "build": "tsc", "start": "node dist/index.js", "typecheck": "tsc --noEmit", diff --git a/packages/backend-api/src/index.ts b/packages/backend-api/src/index.ts index a35db1a..ff41bc9 100644 --- a/packages/backend-api/src/index.ts +++ b/packages/backend-api/src/index.ts @@ -2,126 +2,296 @@ import express from 'express'; import cors from 'cors'; import helmet from 'helmet'; import dotenv from 'dotenv'; +import os from 'os'; +import Redis from 'ioredis'; dotenv.config(); const app = express(); -const PORT = process.env.PORT || 4000; +const PORT = process.env.PORT ? Number(process.env.PORT) : 4000; + +// Redis setup (optional) +const REDIS_URL = process.env.REDIS_URL || process.env.REDIS || 'redis://localhost:6379'; +let redisClient: Redis | null = null; +let redisAvailable = false; +try { + redisClient = new Redis(REDIS_URL); + redisClient.on('error', (err: any) => console.warn('[Redis] error', err)); + redisClient.on('connect', () => { redisAvailable = true; console.log('[Redis] connected') }); +} catch (e) { + console.warn('Redis init failed, falling back to memory store', e); + redisClient = null; +} // Middleware app.use(helmet()); -const allowedOrigins = process.env.FRONTEND_URLS?.split(',') || ['http://localhost:3000']; -// Always allow our local dev studio ports to avoid CORS blockers during development -if (process.env.NODE_ENV !== 'production') { - if (!allowedOrigins.includes('http://localhost:3020')) allowedOrigins.push('http://localhost:3020') - if (!allowedOrigins.includes('http://localhost:3021')) allowedOrigins.push('http://localhost:3021') - if (!allowedOrigins.includes('http://localhost:5175')) allowedOrigins.push('http://localhost:5175') - if (!allowedOrigins.includes('https://avanzacast-studio.bfzqqk.easypanel.host')) allowedOrigins.push('https://avanzacast-studio.bfzqqk.easypanel.host') - if (!allowedOrigins.includes('https://avanzacast-broadcastpanel.bfzqqk.easypanel.host')) allowedOrigins.push('https://avanzacast-broadcastpanel.bfzqqk.easypanel.host') -} - -app.use(cors({ - origin: allowedOrigins, - credentials: true, -})); app.use(express.json()); app.use(express.urlencoded({ extended: true })); -// Health check -app.get('/health', (req, res) => { - res.json({ status: 'ok', timestamp: new Date().toISOString() }); +// CORS setup +function normalizeOrigin(u?: string) { + if (!u) return undefined; + try { return u.replace(/\/$/, '') } catch { return u } +} + +const allowedSet = new Set(); +const fromEnv = process.env.FRONTEND_URLS ? process.env.FRONTEND_URLS.split(',').map(s => s.trim()).filter(Boolean) : []; +fromEnv.forEach(u => { const n = normalizeOrigin(u); if (n) allowedSet.add(n) }); + +// local dev origins +['http://localhost:3000','http://localhost:3020','http://localhost:3021','http://localhost:5175'].forEach(x => allowedSet.add(x)); +const studioUrl = normalizeOrigin(process.env.VITE_STUDIO_URL); +const broadcastUrl = normalizeOrigin(process.env.VITE_BROADCASTPANEL_URL); +const tokenServerUrl = normalizeOrigin(process.env.VITE_TOKEN_SERVER_URL); +if (studioUrl) allowedSet.add(studioUrl); +if (broadcastUrl) allowedSet.add(broadcastUrl); +if (tokenServerUrl) allowedSet.add(tokenServerUrl); + +// Automatically enable allow-subdomain check when envs point to the easypanel host +let allowSubdomainEnv = process.env.ALLOW_SUBDOMAIN_EASYPANEL === '1'; +try { + const easypanelHosts = ['.bfzqqk.easypanel.host']; + const candidates = [...fromEnv, studioUrl || '', broadcastUrl || '', tokenServerUrl || '']; + const found = candidates.some(c => !!c && easypanelHosts.some(h => c.includes(h))); + if (found) { + allowSubdomainEnv = true; + // Do not overwrite explicit config, but log the auto-detection + console.log('Auto-detected easypanel host in envs — enabling ALLOW_SUBDOMAIN_EASYPANEL behavior'); + } +} catch (e) {} + +if (allowSubdomainEnv) process.env.ALLOW_SUBDOMAIN_EASYPANEL = '1'; + +console.log('CORS allowed origins:', Array.from(allowedSet)); + +function isOriginAllowed(originHeader?: string) { + if (!originHeader) return false; + const origin = originHeader.toString().replace(/\/$/, ''); + if (process.env.ALLOW_ALL_CORS === '1') return true; + if (allowedSet.has(origin)) return true; + if (process.env.ALLOW_SUBDOMAIN_EASYPANEL === '1') { + try { + const host = new URL(origin).hostname; + if (host && host.endsWith('.bfzqqk.easypanel.host')) return true; + } catch (e) { + // ignore + } + } + return false; +} + +// Handle preflight quickly +app.use((req, res, next) => { + try { + if (req.method === 'OPTIONS') { + const origin = (req.headers.origin || '').toString().replace(/\/$/, ''); + if (!origin) return res.status(204).send(); + if (isOriginAllowed(origin)) { + res.setHeader('Access-Control-Allow-Origin', process.env.ALLOW_ALL_CORS === '1' ? '*' : origin); + res.setHeader('Access-Control-Allow-Credentials', 'true'); + res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE,OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type,Authorization'); + return res.status(204).send(); + } + return res.status(403).send('CORS origin not allowed'); + } + } catch (e) { console.warn('preflight error', e) } + next(); }); -// API Routes -app.get('/api/v1', (req, res) => { - res.json({ - message: 'AvanzaCast Backend API', - version: '1.0.0', - endpoints: { - auth: '/api/v1/auth', - users: '/api/v1/users', - broadcasts: '/api/v1/broadcasts', - subscriptions: '/api/v1/subscriptions', - integrations: '/api/v1/integrations', +if (process.env.ALLOW_ALL_CORS === '1') { + console.warn('⚠️ ALLOW_ALL_CORS=1 is set — allowing all origins for debugging'); + app.use(cors({ origin: true, credentials: true })); +} else { + app.use(cors({ + origin: function(origin, callback) { + if (!origin) return callback(null, true); + if (isOriginAllowed(origin)) return callback(null, true); + console.warn('[CORS] blocked origin:', origin); + return callback(new Error('Not allowed by CORS'), false); }, - }); + credentials: true, + })); +} + +app.use((req, res, next) => { + try { + const origin = (req.headers.origin || '').toString(); + if (origin && isOriginAllowed(origin)) { + res.setHeader('Access-Control-Allow-Origin', process.env.ALLOW_ALL_CORS === '1' ? '*' : origin); + res.setHeader('Access-Control-Allow-Credentials', 'true'); + } + } catch (e) {} + next(); }); -// LiveKit token generation endpoint -app.get('/api/token', async (req, res) => { - const { room, username } = req.query; - - if (!room || typeof room !== 'string') { - return res.status(400).json({ error: 'Room name is required' }); - } - - if (!username || typeof username !== 'string') { - return res.status(400).json({ error: 'Username is required' }); - } +// Logging middleware +app.use((req, res, next) => { + try { + const bodyPreview = req.body && Object.keys(req.body).length ? JSON.stringify(req.body).slice(0, 500) : ''; + console.log(`[${new Date().toISOString()}] ${req.method} ${req.originalUrl} from ${req.ip} ${bodyPreview}`); + } catch (e) { console.warn('logging error', e) } + next(); +}); - // TODO: Implement actual LiveKit token generation - // For now, return a placeholder response +app.get('/health', (_req, res) => res.json({ status: 'ok', timestamp: new Date().toISOString() })); + +function generateShortId(len = 7) { + const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'; + let s = ''; + for (let i = 0; i < len; i++) s += chars[Math.floor(Math.random() * chars.length)]; + return s; +} + +const SESSION_TTL = Number(process.env.SESSION_TTL_SECONDS || 300); +const sessionStoreMemory = new Map(); + +async function saveSession(id: string, data: { token: string, url: string, room: string, username: string }, ttlSeconds: number) { + const payload = { ...data, expiresAt: Date.now() + ttlSeconds * 1000 }; + if (redisClient && redisAvailable) { + await redisClient.setex(`session:${id}`, ttlSeconds, JSON.stringify(payload)); + } else { + sessionStoreMemory.set(id, { ...payload }); + setTimeout(() => { sessionStoreMemory.delete(id) }, ttlSeconds * 1000 + 1000); + } +} + +async function getSession(id: string): Promise { + if (redisClient && redisAvailable) { + const raw = await redisClient.get(`session:${id}`); + if (!raw) return null; + try { return JSON.parse(raw) } catch (e) { return null } + } + const s = sessionStoreMemory.get(id); + if (!s) return null; + if (s.expiresAt <= Date.now()) { sessionStoreMemory.delete(id); return null } + return s; +} + +async function createLivekitTokenFor(room: string, username: string) { const LIVEKIT_API_KEY = process.env.LIVEKIT_API_KEY; const LIVEKIT_API_SECRET = process.env.LIVEKIT_API_SECRET; - if (!LIVEKIT_API_KEY || !LIVEKIT_API_SECRET) { - console.error('⚠️ LIVEKIT_API_KEY and LIVEKIT_API_SECRET must be set in environment variables'); - return res.status(500).json({ error: 'LiveKit credentials not configured' }); + const fakeToken = `devtoken-${Math.random().toString(36).slice(2,10)}`; + return { token: fakeToken, url: process.env.LIVEKIT_URL || 'ws://localhost:7880' }; } + const { AccessToken } = await import('livekit-server-sdk'); + const at = new AccessToken(LIVEKIT_API_KEY, LIVEKIT_API_SECRET, { identity: username, name: username }); + at.addGrant({ room, roomJoin: true, canPublish: true, canSubscribe: true }); + const token = await at.toJwt(); + return { token, url: process.env.LIVEKIT_URL || 'ws://localhost:7880' }; +} +app.get('/api/token', async (req, res) => { + const { room, username } = req.query as Record; + if (!room || typeof room !== 'string') return res.status(400).json({ error: 'Room name is required' }); + if (!username || typeof username !== 'string') return res.status(400).json({ error: 'Username is required' }); try { - // Import AccessToken from livekit-server-sdk - const { AccessToken } = await import('livekit-server-sdk'); - - const at = new AccessToken(LIVEKIT_API_KEY, LIVEKIT_API_SECRET, { - identity: username, - name: username, - }); - - at.addGrant({ - room, - roomJoin: true, - canPublish: true, - canSubscribe: true, - }); - - const token = await at.toJwt(); - - return res.json({ - token, - url: process.env.LIVEKIT_URL || 'ws://localhost:7880', - }); - } catch (error) { - console.error('Error generating LiveKit token:', error); + const { token, url } = await createLivekitTokenFor(room, username); + return res.json({ token, url }); + } catch (err) { + console.error('Error generating token', err); return res.status(500).json({ error: 'Failed to generate token' }); } }); -// Minimal LiveKit-related endpoints (placeholder implementation) -app.get('/api/v1/livekit/rooms', (req, res) => { - const roomName = typeof req.query.roomName === 'string' ? req.query.roomName : undefined; +app.post('/api/session', async (req, res) => { + try { + const body = req.body || {}; + const room = typeof body.room === 'string' ? body.room : undefined; + const username = typeof body.username === 'string' ? body.username : undefined; + const ttl = body.ttl ? Number(body.ttl) : undefined; + if (!room) return res.status(400).json({ error: 'room is required' }); + if (!username) return res.status(400).json({ error: 'username is required' }); - // If no roomName provided, return a list of rooms (empty list for now) - if (!roomName) { - return res.json({ rooms: [] }); + const { token, url } = await createLivekitTokenFor(room, username); + + let id = generateShortId(7); + let attempt = 0; + while (attempt < 6) { + const exists = await getSession(id); + if (!exists) break; + id = generateShortId(7); + attempt++; + } + const ttlSec = ttl || SESSION_TTL; + await saveSession(id, { token, url, room, username }, ttlSec); + + const studioBase = (process.env.VITE_STUDIO_URL || 'http://localhost:3020').replace(/\/$/, ''); + // Optionally include token directly in redirectUrl when env var is set (convenience for direct entry) + const includeToken = process.env.INCLUDE_TOKEN_IN_REDIRECT === '1' || process.env.INCLUDE_TOKEN_IN_REDIRECT === 'true'; + const redirectUrl = includeToken + ? `${studioBase}/?token=${encodeURIComponent(token)}&room=${encodeURIComponent(room)}&username=${encodeURIComponent(username)}` + : `${studioBase}/${id}`; + + return res.json({ + id, + studioUrl: `${studioBase}/${id}`, + redirectUrl, + ttlSeconds: ttlSec, + }); + } catch (err) { + console.error('Failed to create session', err); + return res.status(500).json({ error: String(err) }); } - - // Placeholder: return empty participants list for the requested room - return res.json({ room: { name: roomName, participants: [] } }); }); -// 404 handler -app.use((req, res) => { - res.status(404).json({ error: 'Not found' }); +app.get('/api/session/:id', async (req, res) => { + const id = req.params.id; + const s = await getSession(id); + if (!s) return res.status(404).json({ error: 'not found' }); + const ttlLeft = Math.max(0, Math.floor((s.expiresAt - Date.now()) / 1000)); + return res.json({ token: s.token, url: s.url, room: s.room, username: s.username, ttlSeconds: ttlLeft }); }); -// Error handler -app.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction) => { +app.get('/s/:id', async (req, res) => { + const id = req.params.id; + const s = await getSession(id); + if (!s) return res.status(404).send('Not found'); + const studioBase = (process.env.VITE_STUDIO_URL || 'http://localhost:3020').replace(/\/$/, ''); + const redirectTo = `${studioBase}/studio_receiver.html?token=${encodeURIComponent(s.token)}&room=${encodeURIComponent(s.room)}&username=${encodeURIComponent(s.username)}`; + return res.redirect(302, redirectTo); +}); + +// Optional: mark session as consumed to prevent replay (single-use) +app.post('/api/session/:id/consume', async (req, res) => { + const id = req.params.id; + try { + const s = await getSession(id); + if (!s) return res.status(404).json({ error: 'not found' }); + // remove from store + if (redisClient && redisAvailable) { + await redisClient.del(`session:${id}`); + } else { + sessionStoreMemory.delete(id); + } + return res.json({ ok: true }); + } catch (err) { + console.error('Error consuming session', err); + return res.status(500).json({ error: 'failed' }); + } +}); + +app.use((_req, res) => res.status(404).json({ error: 'Not found' })); + +app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => { console.error(err.stack); res.status(500).json({ error: 'Internal server error' }); }); -app.listen(PORT, () => { - console.log(`🚀 Backend API running on http://localhost:${PORT}`); +const HOST: string = process.env.HOST ? String(process.env.HOST) : '0.0.0.0'; +app.listen(Number(PORT), HOST, () => { + console.log(`🚀 Backend API running on http://${HOST}:${PORT}`); console.log(`📡 Environment: ${process.env.NODE_ENV}`); + try { + const nets = os.networkInterfaces(); + const addresses: string[] = []; + Object.keys(nets).forEach((name) => { + const net = nets[name] || []; + net.forEach((iface: any) => { + if (iface.family === 'IPv4' && !iface.internal) addresses.push(iface.address as string); + }); + }); + if (addresses.length > 0) addresses.forEach(a => console.log(`🔗 Accessible at: http://${a}:${PORT}`)); + } catch (e) { console.warn('Could not enumerate network interfaces', e) } }); diff --git a/packages/broadcast-panel/Dockerfile b/packages/broadcast-panel/Dockerfile index 553c8fa..3b918ad 100644 --- a/packages/broadcast-panel/Dockerfile +++ b/packages/broadcast-panel/Dockerfile @@ -1,30 +1,21 @@ -# Dockerfile para Broadcast Panel +# Dockerfile for Broadcast Panel (package-local context) FROM node:20-alpine AS builder WORKDIR /app -# Copiar package files +# copy package files COPY package*.json ./ -# Instalar dependencias -RUN npm install +# install deps +RUN npm ci --no-audit --no-fund || npm install --no-audit --no-fund -# Copiar código fuente -COPY . . - -# Build +# copy source and build +COPY . ./ RUN npm run build -# Etapa de producción con nginx +# production image FROM nginx:alpine - -# Copiar build a nginx COPY --from=builder /app/dist /usr/share/nginx/html - -# Copiar configuración de nginx COPY nginx.conf /etc/nginx/conf.d/default.conf - -# Exponer puerto EXPOSE 80 - CMD ["nginx", "-g", "daemon off;"] diff --git a/packages/broadcast-panel/README.md b/packages/broadcast-panel/README.md index 5ca2877..5d41b78 100644 --- a/packages/broadcast-panel/README.md +++ b/packages/broadcast-panel/README.md @@ -144,3 +144,28 @@ Este panel sigue el patrón de diseño de **StreamYard**, caracterizado por: 3. **Consistencia:** Uso uniforme de colores, tipografía y espaciado 4. **Accesibilidad:** Contraste adecuado, tamaños de fuente legibles 5. **Responsividad:** Adaptación fluida a diferentes dispositivos + +## Entrar al estudio (token flow) + +El botón "Entrar al estudio" ahora solicita un token al servidor de tokens (configurable mediante la variable de entorno `VITE_TOKEN_SERVER_URL`) y abre el `studio-panel` enviando el token vía `postMessage` (o redirige como fallback si el popup está bloqueado). + +Variables importantes: +- VITE_TOKEN_SERVER_URL - URL base del servidor que responde en `/api/token?room=...&username=...` y devuelve JSON con `{ token, url }`. +- VITE_STUDIO_URL - URL base del `studio-panel` (ej. `https://avanzacast-studio.bfzqqk.easypanel.host`) que se usa como origin para postMessage. + +Cómo probar localmente: +1. Levanta `backend-api` con tus credenciales LiveKit (LIVEKIT_API_KEY y LIVEKIT_API_SECRET en .env): +```bash +cd packages/backend-api +npm run dev +``` +2. Arranca `broadcast-panel` (por defecto corre en http://localhost:5175): +```bash +cd packages/broadcast-panel +npm run dev +``` +3. Abre la lista de transmisiones y pulsa "Entrar al estudio" en una fila; el panel abrirá el `studio-panel` en una ventana popup y enviará el token por postMessage. + +Notas de debug: +- Si no recibes confirmación (ACK) del `studio-panel`, revisa la consola del navegador para ver posibles errores de `postMessage` (origin mismatch) o bloqueos de popup. +- Para pruebas locales puedes usar `packages/studio-panel/public/simulate_postmessage.html` (simulador) para verificar el flujo sin LiveKit. diff --git a/packages/broadcast-panel/public/dump_session.html b/packages/broadcast-panel/public/dump_session.html new file mode 100644 index 0000000..95257ed --- /dev/null +++ b/packages/broadcast-panel/public/dump_session.html @@ -0,0 +1,15 @@ + + +Dump Session + +

Session Storage Dump

+
loading...
+ + + + diff --git a/packages/broadcast-panel/public/post_token_to_studio.html b/packages/broadcast-panel/public/post_token_to_studio.html new file mode 100644 index 0000000..228a27a --- /dev/null +++ b/packages/broadcast-panel/public/post_token_to_studio.html @@ -0,0 +1,133 @@ + + +Post Token to Studio (production) + +

Post Token to Studio (production)

+
+ +
+
+ + +
+
+ +
+

+  
+
+
diff --git a/packages/broadcast-panel/scripts/simulate_ack.js b/packages/broadcast-panel/scripts/simulate_ack.js
new file mode 100644
index 0000000..e69de29
diff --git a/packages/broadcast-panel/src/components/Toast.tsx b/packages/broadcast-panel/src/components/Toast.tsx
new file mode 100644
index 0000000..df906d8
--- /dev/null
+++ b/packages/broadcast-panel/src/components/Toast.tsx
@@ -0,0 +1,20 @@
+import React from 'react'
+
+export type ToastVariant = 'info'|'success'|'error'|'warning'
+
+export function Toast({ message, variant = 'info' }: { message: string, variant?: ToastVariant }) {
+  const bg = variant === 'success' ? '#d1fae5' : variant === 'error' ? '#fee2e2' : variant === 'warning' ? '#fff7ed' : '#eef2ff'
+  const color = variant === 'success' ? '#065f46' : variant === 'error' ? '#991b1b' : variant === 'warning' ? '#92400e' : '#3730a3'
+  return (
+    
{message}
+ ) +} + +export default Toast + diff --git a/packages/broadcast-panel/src/components/TransmissionsTable.tsx b/packages/broadcast-panel/src/components/TransmissionsTable.tsx index 775f13e..ff3472a 100644 --- a/packages/broadcast-panel/src/components/TransmissionsTable.tsx +++ b/packages/broadcast-panel/src/components/TransmissionsTable.tsx @@ -7,6 +7,7 @@ import styles from './TransmissionsTable.module.css' import InviteGuestsModal from './InviteGuestsModal' import { NewTransmissionModal } from '@shared/components' import type { Transmission } from '@shared/types' +import useStudioLauncher from '../hooks/useStudioLauncher' interface Props { transmissions: Transmission[] @@ -20,15 +21,17 @@ const platformIcons: Record = { 'Facebook': , 'Twitch': , 'LinkedIn': , - 'Genérico': , // Logo genérico para transmisiones sin destino + 'Generico': , // Logo genérico para transmisiones sin destino } -const TransmissionsTable: React.FC = ({ transmissions, onDelete, onUpdate, isLoading }) => { +const TransmissionsTable: React.FC = (props) => { + const { transmissions, onDelete, onUpdate, isLoading } = props const [activeTab, setActiveTab] = useState<'upcoming' | 'past'>('upcoming') const [inviteOpen, setInviteOpen] = useState(false) const [inviteLink, setInviteLink] = useState(undefined) const [editOpen, setEditOpen] = useState(false) const [editTransmission, setEditTransmission] = useState(undefined) + const { openStudio, loadingId: launcherLoadingId, error: launcherError } = useStudioLauncher() const [loadingId, setLoadingId] = useState(null) const handleEdit = (t: Transmission) => { @@ -37,7 +40,7 @@ const TransmissionsTable: React.FC = ({ transmissions, onDelete, onUpdate } // Filtrado por fechas - const filtered = transmissions.filter(t => { + const filtered = transmissions.filter((t: Transmission) => { // Si es "Próximamente" o no tiene fecha programada, siempre va a "upcoming" if (!t.scheduled || t.scheduled === 'Próximamente') return activeTab === 'upcoming' @@ -52,44 +55,20 @@ const TransmissionsTable: React.FC = ({ transmissions, onDelete, onUpdate }) const openStudioForTransmission = async (t: Transmission) => { - if (loadingId) return + if (loadingId || launcherLoadingId) return setLoadingId(t.id) try { const userRaw = localStorage.getItem('avanzacast_user') || 'Demo User' - const user = encodeURIComponent(userRaw) - const room = encodeURIComponent(t.id || 'avanzacast-studio') - - console.log('[BroadcastPanel] Solicitando token:', { room: decodeURIComponent(room), user: decodeURIComponent(user) }) - - const TOKEN_SERVER = import.meta.env.VITE_TOKEN_SERVER_URL || 'https://avanzacast-servertokens.bfzqqk.easypanel.host' - const tokenUrl = `${TOKEN_SERVER.replace(/\/$/, '')}/api/token?room=${room}&username=${user}` - const tokenRes = await fetch(tokenUrl) - if (!tokenRes.ok) throw new Error('No se pudo obtener token') - const tokenData = await tokenRes.json() - - console.log('[BroadcastPanel] Token recibido:', { - tokenLength: tokenData.token?.length || 0, - serverUrl: tokenData.serverUrl, - hasToken: !!tokenData.token, - hasServerUrl: !!tokenData.serverUrl - }) - - // Pasar solo token, room y user por URL (serverUrl se lee del .env en studio-panel) - const params = new URLSearchParams({ - token: tokenData.token, - room: decodeURIComponent(room), - user: decodeURIComponent(user) - }) - - console.log('[BroadcastPanel] Redirigiendo con parámetros en URL...') - - // Redirigir a studio-panel en la misma pestaña con los datos en la URL - const STUDIO_URL = import.meta.env.VITE_STUDIO_URL || 'https://avanzacast-studio.bfzqqk.easypanel.host' - const shortId = Math.random().toString(36).slice(2, 10) - window.location.href = `${STUDIO_URL.replace(/\/$/, '')}/${shortId}?${params.toString()}` - } catch (err) { + const user = (userRaw) + const room = (t.id || 'avanzacast-studio') + const result = await openStudio({ room, username: user }) + if (!result) { + throw new Error('No se pudo abrir el estudio') + } + setLoadingId(null) + } catch (err: any) { console.error('[BroadcastPanel] Error entrando al estudio:', err) - alert('No fue posible entrar al estudio. Revisa el servidor de tokens.') + alert(err?.message || 'No fue posible entrar al estudio. Revisa el servidor de tokens.') setLoadingId(null) } } @@ -148,7 +127,7 @@ const TransmissionsTable: React.FC = ({ transmissions, onDelete, onUpdate - {filtered.map(t => ( + {filtered.map((t: Transmission) => (
@@ -158,7 +137,7 @@ const TransmissionsTable: React.FC = ({ transmissions, onDelete, onUpdate
{t.title}
- {t.platform === 'Genérico' ? 'Solo grabación' : (t.platform || 'YouTube')} + {t.platform === 'Generico' ? 'Solo grabación' : (t.platform || 'YouTube')}
@@ -178,20 +157,23 @@ const TransmissionsTable: React.FC = ({ transmissions, onDelete, onUpdate + + {launcherError && ( +
{launcherError}
+ )} } diff --git a/packages/broadcast-panel/src/hooks/useStudioLauncher.ts b/packages/broadcast-panel/src/hooks/useStudioLauncher.ts new file mode 100644 index 0000000..3b23eb9 --- /dev/null +++ b/packages/broadcast-panel/src/hooks/useStudioLauncher.ts @@ -0,0 +1,178 @@ +import { useState } from 'react' + +export type OpenStudioOptions = { + room: string + username: string + ttl?: number +} + +type SessionData = { + studioUrl?: string + redirectUrl?: string + token?: string + room?: string + ttl?: number +} + +export default function useStudioLauncher() { + const [loadingId, setLoadingId] = useState(null) + const [error, setError] = useState(null) + + async function openStudio(opts: OpenStudioOptions) { + const { room, username, ttl } = opts + if (!room || !username) { + setError('room and username are required') + return null + } + setError(null) + setLoadingId(room) + + // Timeouts and retry config + const POST_MESSAGE_TIMEOUT = 5000 // ms + const POST_MESSAGE_INTERVAL = 300 // ms + + try { + const TOKEN_SERVER = (import.meta.env.VITE_TOKEN_SERVER_URL as string) || 'https://avanzacast-servertokens.bfzqqk.easypanel.host' + const sessionUrl = `${TOKEN_SERVER.replace(/\/$/, '')}/api/session` + + // Try to open a blank popup immediately (in direct response to user action) to reduce popup-blocker issues + let popup: Window | null = null + try { + popup = window.open('about:blank', '_blank', 'noopener,noreferrer') + } catch (e) { + popup = null + } + + // If popup failed to open, we will fallback to redirect later + + const sessionRes = await fetch(sessionUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ room, username, ttl }) + }) + if (!sessionRes.ok) { + const txt = await sessionRes.text().catch(() => '') + const msg = `No se pudo crear la sesión (${sessionRes.status}) ${txt}` + console.error('[useStudioLauncher]', msg) + setError(msg) + setLoadingId(null) + // Close popup if we opened it but will not navigate it + try { popup?.close() } catch (e) { /* ignore */ } + return null + } + const sessionData: SessionData = await sessionRes.json() + + const studioUrl = sessionData.studioUrl || sessionData.redirectUrl || null + if (!studioUrl) { + const msg = 'No studio URL returned from token server' + console.error('[useStudioLauncher]', msg) + setError(msg) + setLoadingId(null) + try { popup?.close() } catch (e) { /* ignore */ } + return null + } + + const targetUrl = sessionData.studioUrl || sessionData.redirectUrl || studioUrl + + // If popup couldn't be opened, fallback to redirecting current window to redirectUrl (may contain token) + if (!popup) { + try { + const fallback = sessionData.redirectUrl || targetUrl + window.location.href = fallback + setLoadingId(null) + return sessionData + } catch (e) { + // can't redirect, return error + const msg = 'No se pudo abrir popup ni redirigir' + (String(e) || '') + console.error('[useStudioLauncher]', msg) + setError(msg) + setLoadingId(null) + return null + } + } + + // We have a popup window. Navigate it to the studio (without token in URL if possible) + try { + popup.location.href = targetUrl + } catch (e) { + // Some browsers may block setting location for cross-origin until navigation happens + try { popup.location.assign(targetUrl) } catch (e2) { /* ignore */ } + } + + // Prepare message to send the token + const msgPayload = { type: 'LIVEKIT_TOKEN', token: sessionData.token, room: sessionData.room } + const targetOrigin = (() => { + try { return new URL(targetUrl).origin } catch (e) { return '*' } + })() + + let posted = false + let ackReceived = false + + // Listen for ACK from the studio window + function onMessage(e: MessageEvent) { + try { + const d = e.data || {} + if (d?.type === 'LIVEKIT_ACK' && d?.room === sessionData.room) { + ackReceived = true + // optional: we can close the popup opener listener + window.removeEventListener('message', onMessage) + } + } catch (err) { + // ignore malformed messages + } + } + window.addEventListener('message', onMessage) + + const start = Date.now() + // Try posting repeatedly until timeout or ACK + while (!posted && Date.now() - start < POST_MESSAGE_TIMEOUT && !ackReceived) { + try { + // postMessage itself doesn't throw for cross-origin; we still wrap it + popup.postMessage(msgPayload, targetOrigin) + posted = true // assume success; ack will confirm + } catch (e) { + // ignore and retry + } + if (!posted) await new Promise((r) => setTimeout(r, POST_MESSAGE_INTERVAL)) + } + + // If we posted but didn't receive ACK, try a short wait for ack + const waitForAck = () => new Promise((resolve) => { + const maxWait = 2000 + const t0 = Date.now() + const int = setInterval(() => { + if (ackReceived || Date.now() - t0 > maxWait) { + clearInterval(int) + resolve() + } + }, 100) + }) + + if (posted) { + await waitForAck() + } + + // If we couldn't post at all or no ACK received, fallback to redirect to redirectUrl (may include token) + if (!posted || (!ackReceived && sessionData.redirectUrl)) { + try { + // navigate popup to redirectUrl which typically contains token + const fallback = sessionData.redirectUrl || targetUrl + popup.location.href = fallback + } catch (e) { + // If navigation fails, try to navigate the current window + try { window.location.href = sessionData.redirectUrl || targetUrl } catch (e2) { /* ignore */ } + } + } + + setLoadingId(null) + return sessionData + } catch (err: any) { + console.error('[useStudioLauncher] error opening studio', err) + setError(String(err?.message || err)) + setLoadingId(null) + return null + } + } + + return { openStudio, loadingId, error } +} diff --git a/packages/broadcast-panel/src/hooks/useToast.tsx b/packages/broadcast-panel/src/hooks/useToast.tsx new file mode 100644 index 0000000..f32b9c0 --- /dev/null +++ b/packages/broadcast-panel/src/hooks/useToast.tsx @@ -0,0 +1,44 @@ +import React, { createContext, useContext, useState, useEffect } from 'react' +import Toast from '../components/Toast' + +type ToastItem = { id: string, message: string, variant?: 'info'|'success'|'error'|'warning' } + +const ToastContext = createContext<{ show: (m: string, v?: ToastItem['variant']) => void } | undefined>(undefined) + +export function ToastProvider({ children }: { children: React.ReactNode }) { + const [list, setList] = useState([]) + + function show(message: string, variant: ToastItem['variant'] = 'info') { + const id = Math.random().toString(36).slice(2,9) + setList(l => [...l, { id, message, variant }]) + setTimeout(() => setList(l => l.filter(x => x.id !== id)), 5000) + } + + useEffect(() => { + function onGlobal(e: Event) { + try { + const ce = e as CustomEvent + if (!ce?.detail) return + const { message, variant } = ce.detail as any + if (message) show(message, variant) + } catch (err) {} + } + window.addEventListener('AVZ_TOAST', onGlobal as EventListener) + return () => window.removeEventListener('AVZ_TOAST', onGlobal as EventListener) + }, []) + + return ( + + {children} +
+ {list.map(i => )} +
+
+ ) +} + +export function useToast() { + const ctx = useContext(ToastContext) + if (!ctx) throw new Error('useToast must be used within ToastProvider') + return ctx +} diff --git a/packages/broadcast-panel/src/main.tsx b/packages/broadcast-panel/src/main.tsx index 369acbe..3528ba2 100644 --- a/packages/broadcast-panel/src/main.tsx +++ b/packages/broadcast-panel/src/main.tsx @@ -2,6 +2,11 @@ import React from 'react' import { createRoot } from 'react-dom/client' import PageContainer from './components/PageContainer' import './styles.css' +import { ToastProvider } from './hooks/useToast' const root = createRoot(document.getElementById('root')!) -root.render() +root.render( + + + +) diff --git a/packages/broadcast-panel/src/utils/studioLauncher.ts b/packages/broadcast-panel/src/utils/studioLauncher.ts new file mode 100644 index 0000000..2330310 --- /dev/null +++ b/packages/broadcast-panel/src/utils/studioLauncher.ts @@ -0,0 +1,100 @@ +export interface StudioTokenPayload { + token: string + serverUrl?: string + room?: string + user?: string +} + +export async function openStudioWithToken(tokenData: StudioTokenPayload, opts?: { studioUrl?: string, shortIdLength?: number, onAck?: (ack: any) => void, forceRedirect?: boolean }) { + const STUDIO_URL = opts?.studioUrl || (import.meta.env.VITE_STUDIO_URL as string) || 'https://avanzacast-studio.bfzqqk.easypanel.host' + const shortId = Math.random().toString(36).slice(2, 2 + (opts?.shortIdLength || 8)) + const studioBase = STUDIO_URL.replace(/\/$/, '') + const studioPath = `${studioBase}/${shortId}` + + const payload = { + type: 'LIVEKIT_TOKEN', + token: tokenData.token, + url: tokenData.serverUrl || (import.meta.env.VITE_LIVEKIT_URL as string) || '', + room: tokenData.room || '', + user: tokenData.user || '', + } + + const paramsForFallback = () => { + const p = new URLSearchParams({ token: tokenData.token || '', room: tokenData.room || '', username: tokenData.user || '' }) + if (tokenData.serverUrl) p.set('serverUrl', tokenData.serverUrl) + return p.toString() + } + + const forceRedirectEnv = (import.meta.env.VITE_FORCE_STUDIO_REDIRECT as string) || '' + const forceRedirect = opts?.forceRedirect !== undefined ? opts.forceRedirect : (forceRedirectEnv === '0' ? false : true) + + try { + if (forceRedirect) { + const q = paramsForFallback() + window.location.href = `${studioPath}?${q}` + return + } + + const originAllowed = (() => { + try { return new URL(STUDIO_URL).origin } catch { return '*' } + })() + + try { + const win = window.open(studioPath, '_blank') + if (win) { + let ackTimeout: number | null = null + const ackListener = (e: MessageEvent) => { + try { + if (!e?.data) return + const d = e.data + if (d?.type === 'LIVEKIT_ACK') { + try { opts?.onAck?.(d) } catch (err) { console.error('onAck callback error', err) } + if (!opts?.onAck) { + window.dispatchEvent(new CustomEvent('AVZ_TOAST', { detail: { message: d.status === 'connected' ? 'Studio conectado' : `Studio error: ${d?.error || d?.status}`, variant: d.status === 'connected' ? 'success' : 'error' } })) + } + if (ackTimeout) { clearTimeout(ackTimeout); ackTimeout = null } + window.removeEventListener('message', ackListener as unknown as EventListener) + } + } catch (err) { console.error('[studioLauncher] ackListener error', err) } + } + + window.addEventListener('message', ackListener as unknown as EventListener) + + ackTimeout = window.setTimeout(() => { + try { + window.removeEventListener('message', ackListener as unknown as EventListener) + if (!opts?.onAck) { + window.dispatchEvent(new CustomEvent('AVZ_TOAST', { detail: { message: 'No se recibió confirmación del Studio', variant: 'warning' } })) + } + } catch (err) { console.error('[studioLauncher] ack timeout cleanup error', err) } + }, 20000) + + const post = () => { + try { + win.postMessage(payload, originAllowed) + console.debug('[studioLauncher] postMessage ->', originAllowed) + } catch (err) { + try { (win as any).postMessage(payload, '*') } catch (err2) { console.error('[studioLauncher] postMessage failed', err2) } + } + } + + setTimeout(post, 300) + setTimeout(post, 800) + setTimeout(post, 1500) + return + } + + // popup blocked -> fallback to redirect + const q = paramsForFallback() + window.location.href = `${studioPath}?${q}` + } catch (err) { + console.error('[studioLauncher] Error opening studio panel, fallback to redirect', err) + const q = paramsForFallback() + window.location.href = `${studioPath}?${q}` + } + } catch (err) { + console.error('[studioLauncher] Unexpected error', err) + } +} + +export default openStudioWithToken diff --git a/packages/landing-page/postcss.config.cjs b/packages/landing-page/postcss.config.cjs index 9bc2f2d..f5de1c1 100644 --- a/packages/landing-page/postcss.config.cjs +++ b/packages/landing-page/postcss.config.cjs @@ -1,6 +1,18 @@ module.exports = { - plugins: { - 'tailwindcss': {}, - 'autoprefixer': {}, - }, + plugins: (() => { + // Tailwind v4 changed the PostCSS plugin distribution to @tailwindcss/postcss + // Try to require it first, otherwise fall back to the older 'tailwindcss' package name + let tailwindPlugin + try { + tailwindPlugin = require('@tailwindcss/postcss') + } catch (e) { + // fallback for environments still using the legacy package name + tailwindPlugin = require('tailwindcss') + } + + return { + [tailwindPlugin.name || 'tailwindcss']: tailwindPlugin(), + autoprefixer: {}, + } + })(), } diff --git a/packages/studio-panel/.dockerignore b/packages/studio-panel/.dockerignore new file mode 100644 index 0000000..bd79bcd --- /dev/null +++ b/packages/studio-panel/.dockerignore @@ -0,0 +1,9 @@ +node_modules +dist +npm-debug.log +Dockerfile +.dockerignore +.vscode +.git +.gitignore + diff --git a/packages/studio-panel/Dockerfile b/packages/studio-panel/Dockerfile new file mode 100644 index 0000000..0df39e0 --- /dev/null +++ b/packages/studio-panel/Dockerfile @@ -0,0 +1,28 @@ +# Multi-stage Dockerfile: build with Node, serve with Nginx +# Build stage +FROM node:20-alpine AS builder +WORKDIR /app +# Install build deps +# Copy package files and workspace packages to allow local file:../avanza-ui resolution +COPY package.json package-lock.json* ./ +# Copy entire monorepo so file: references work (keeps things simple) +COPY .. /app + +WORKDIR /app/packages/studio-panel +RUN npm ci --no-audit --no-fund && npm run build + +# Production stage +FROM nginx:stable-alpine +# Remove default nginx static +RUN rm -rf /usr/share/nginx/html/* + +# Copy built static files +COPY --from=builder /app/packages/studio-panel/dist /usr/share/nginx/html + +# Copy custom nginx config if provided (optional) +# If deploy/nginx.avanzacast.conf exists, use it as default.conf +COPY packages/studio-panel/deploy/nginx.avanzacast.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] + diff --git a/packages/studio-panel/README.DOCKER.md b/packages/studio-panel/README.DOCKER.md new file mode 100644 index 0000000..2c811ab --- /dev/null +++ b/packages/studio-panel/README.DOCKER.md @@ -0,0 +1,70 @@ +# Docker build & deploy for studio-panel + +This file explains how to build the `studio-panel` Docker image locally and produce a deployable artifact. + +Prerequisites +- Docker installed and running on the host. +- Node.js and npm (for local build path if you prefer building before docker). If you use Docker multi-stage, Node is not needed locally. + +Quick build steps (preferred: multi-stage Docker build) + +From repo root or inside `packages/studio-panel`: + +```bash +# from repo root +cd packages/studio-panel +# build docker (multi-stage uses npm inside builder stage) +docker build -t avanzacast/studio-panel:latest . + +# run locally to test +docker run --rm -it -p 3020:80 avanzacast/studio-panel:latest + +# now test with curl +curl -I http://localhost:3020/ +``` + +Notes +- The Dockerfile is multi-stage: the builder stage runs `npm ci` and `npm run build` inside the container. The built `dist/` is copied to nginx in the final image. +- The Dockerfile copies the entire monorepo into the builder context to allow `file:../avanza-ui` dependency to be resolved during container build. + +If the build fails inside Docker due to strict network or registry errors, you can build locally first and then use a simpler Dockerfile that only copies the `dist/` folder into the nginx image: + +Local-build alternative: + +```bash +# build locally +cd packages/studio-panel +npm ci +npm run build + +# create a simple nginx image +cd packages/studio-panel +cat > Dockerfile.simple <<'EOF' +FROM nginx:stable-alpine +COPY dist /usr/share/nginx/html +COPY deploy/nginx.avanzacast.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] +EOF + +docker build -f Dockerfile.simple -t avanzacast/studio-panel:local . +``` + +If you want the tarball artifact to deploy on the server, run: + +```bash +# after successful build and image creation +IMAGE=avanzacast/studio-panel:latest +docker save $IMAGE -o /tmp/studio-panel-image-$(date +%s).tar +# scp that tar to the server and on server run: +# docker load -i studio-panel-image-.tar +# docker run -d --name studio-panel -p 80:80 avanzacast/studio-panel:latest +``` + +Troubleshooting +- If the multi-stage build cannot resolve `file:../avanza-ui`, ensure the entire repository is in the build context (we copy `..` into the builder in the current Dockerfile). If your Docker setup restricts context size, prefer local build + `Dockerfile.simple`. +- Inspect build logs with: + - `docker build --progress=plain -t avanzacast/studio-panel:local .` + - `docker logs ` for runtime errors. + + diff --git a/packages/studio-panel/README.E2E.md b/packages/studio-panel/README.E2E.md new file mode 100644 index 0000000..8fe6afc --- /dev/null +++ b/packages/studio-panel/README.E2E.md @@ -0,0 +1,34 @@ +# E2E Playwright tests - Studio Panel + +This guide explains how to run the Playwright E2E tests locally and in CI. The tests simulate Broadcast -> Token Server -> Studio flows and produce logs and screenshots for debugging. + +Local quick run + +```bash +cd packages/studio-panel +# optional: install playwright locally +npm install --no-audit --no-fund --no-save playwright +npx playwright install --with-deps +# run the helper script (installs playwright if missing and runs the test) +chmod +x run_playwright_test.sh +./run_playwright_test.sh + +# After run, check artifacts: +ls -lh /tmp/playwright_debug.log /tmp/playwright_run_output.log +ls -lh /tmp/sim_postmessage_simulator.png /tmp/sim_postmessage_studio.png +``` + +CI (GitHub Actions) + +A workflow has been added at `.github/workflows/e2e-playwright.yml`. It can be triggered from the Actions tab or via `workflow_dispatch`. + +Set these repository secrets to override target URLs (optional): +- `BROADCAST_URL` - e.g. `https://avanzacast-broadcastpanel.bfzqqk.easypanel.host/post_token_to_studio.html?auto=1` +- `STUDIO_ORIGIN` - e.g. `https://avanzacast-studio.bfzqqk.easypanel.host` + +The workflow will upload logs and screenshots as artifacts for download. + +Troubleshooting +- If Playwright fails to install browsers on runners, try `npx playwright install --with-deps` locally to debug. +- If tests time out, increase timeouts in `scripts/playwright_postmessage_test.mjs`. + diff --git a/packages/studio-panel/build_and_pack_docker.sh b/packages/studio-panel/build_and_pack_docker.sh new file mode 100755 index 0000000..a196fb4 --- /dev/null +++ b/packages/studio-panel/build_and_pack_docker.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR=$(cd "$(dirname "$0")" && pwd) +cd "$ROOT_DIR" + +echo "[1/5] Installing dependencies" +npm ci + +echo "[2/5] Running Vite build" +npm run build + +echo "[3/5] Creating Dockerfile.simple and building image" +cat > Dockerfile.simple <<'EOF' +FROM nginx:stable-alpine +COPY dist /usr/share/nginx/html +COPY deploy/nginx.avanzacast.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] +EOF + +IMAGE_TAG="avanzacast/studio-panel:local" +docker build -f Dockerfile.simple -t "$IMAGE_TAG" . + +TS=$(date +%s) +OUT=/tmp/studio-panel-image-${TS}.tar + +echo "[4/5] Saving docker image to $OUT" +docker save "$IMAGE_TAG" -o "$OUT" + +echo "[5/5] Done. Image saved to: $OUT" +ls -lh "$OUT" + +# keep the artifact path for caller +echo "$OUT" + diff --git a/packages/studio-panel/deploy/nginx.avanzacast.conf b/packages/studio-panel/deploy/nginx.avanzacast.conf new file mode 100644 index 0000000..9eac2bc --- /dev/null +++ b/packages/studio-panel/deploy/nginx.avanzacast.conf @@ -0,0 +1,53 @@ +# Dev nginx config for avanzacast-studio (non-SSL) +server { + listen 80; + server_name avanzacast-studio.bfzqqk.easypanel.host; + + # Proxy to Vite dev server (HTTP) + location / { + proxy_pass http://127.0.0.1:3020; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + 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; + proxy_buffering off; + proxy_set_header X-Forwarded-Host $host; + } + + # Optional: serve static built files if you run `npm run build` and serve from dist + location /static/ { + alias /home/xesar/Documentos/Nextream/AvanzaCast/packages/studio-panel/dist/; + } + + # Increase timeouts + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; +} + +# Also keep a plain HTTP server for broadcast and token-server (reverse-proxy uses root default.conf too) +server { + listen 80; + server_name avanzacast-broadcastpanel.bfzqqk.easypanel.host; + location / { + proxy_pass http://broadcast-panel:5175; + 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; + } +} + +server { + listen 80; + server_name avanzacast-servertokens.bfzqqk.easypanel.host; + location / { + proxy_pass http://backend-api:4000; + 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/packages/studio-panel/deploy/studio-panel.service b/packages/studio-panel/deploy/studio-panel.service new file mode 100644 index 0000000..e2c0719 --- /dev/null +++ b/packages/studio-panel/deploy/studio-panel.service @@ -0,0 +1,16 @@ +[Unit] +Description=AvanzaCast Studio Panel (Vite dev) +After=network.target + +[Service] +Type=simple +User=youruser +WorkingDirectory=/home/xesar/Documentos/Nextream/AvanzaCast/packages/studio-panel +Environment=NODE_ENV=development +ExecStart=/usr/bin/npm run dev +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target + diff --git a/packages/studio-panel/e2e/playwright.config.js b/packages/studio-panel/e2e/playwright.config.js new file mode 100644 index 0000000..7b76073 --- /dev/null +++ b/packages/studio-panel/e2e/playwright.config.js @@ -0,0 +1,6 @@ +// minimal Playwright config for local E2E +module.exports = { + use: { headless: true }, + timeout: 30_000, +}; + diff --git a/packages/studio-panel/e2e/playwright_test.mjs b/packages/studio-panel/e2e/playwright_test.mjs new file mode 100644 index 0000000..e4e62ea --- /dev/null +++ b/packages/studio-panel/e2e/playwright_test.mjs @@ -0,0 +1,81 @@ +#!/usr/bin/env node +// Playwright E2E test for Studio Panel (automates Broadcast -> Studio flow) +import fetch from 'node-fetch'; +import fs from 'fs'; +import { chromium } from 'playwright'; + +const TOKEN_SERVER = process.env.TOKEN_SERVER_URL || 'http://localhost:4000'; +const STUDIO_BASE = process.env.STUDIO_URL || 'http://localhost:3020'; +const TIMEOUT = Number(process.env.E2E_TIMEOUT_MS || 30000); + +function log(...args) { console.log(new Date().toISOString(), ...args); } + +async function createSession() { + log('POST /api/session ->', TOKEN_SERVER); + const res = await fetch(`${TOKEN_SERVER}/api/session`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ room: 'e2e-room', username: 'pw-runner' }) + }); + const body = await res.json(); + log('session response', res.status, body); + if (!res.ok) throw new Error('session creation failed'); + return body; +} + +(async () => { + try { + const session = await createSession(); + const studioUrl = session.redirectUrl || session.studioUrl || (session.id ? `${STUDIO_BASE}/${session.id}` : null); + if (!studioUrl) throw new Error('No studio url returned'); + + log('Launching browser'); + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext(); + const page = await context.newPage(); + + log('Opening', studioUrl); + const resp = await page.goto(studioUrl, { waitUntil: 'domcontentloaded', timeout: TIMEOUT }); + log('opened status', resp && resp.status()); + + // Wait for either a #status element or a known text + try { + const status = page.locator('#status'); + await status.waitFor({ timeout: 7000 }); + const txt = await status.textContent(); + log('#status text:', txt && txt.slice(0,200)); + } catch (e) { + log('No #status or timeout, trying heuristics'); + // look for explicit token string in page content + const content = await page.content(); + if (content.includes('Token recibido') || content.includes('LIVEKIT_TOKEN') || content.includes(session.token || '')) { + log('Token text appears in page content'); + } else { + log('Token not obviously present in page content'); + } + } + + const s1 = '/tmp/pw_e2e_studio_' + Date.now() + '.png'; + await page.screenshot({ path: s1, fullPage: true }); + log('Saved screenshot', s1); + + // Also fetch session via API to assert token present + if (session.id) { + try { + const lookup = await fetch(`${TOKEN_SERVER}/api/session/${session.id}`); + const j = await lookup.json(); + log('session lookup', lookup.status, Object.keys(j)); + const sessionFile = `/tmp/session_${session.id}.json`; + fs.writeFileSync(sessionFile, JSON.stringify(j, null, 2)); + log('Saved session JSON to', sessionFile); + } catch (err) { log('session lookup failed', err.message) } + } + + await browser.close(); + log('E2E success'); + process.exit(0); + } catch (err) { + console.error('E2E error', err.message); + process.exit(1); + } +})(); + diff --git a/packages/studio-panel/e2e/run_e2e.mjs b/packages/studio-panel/e2e/run_e2e.mjs new file mode 100644 index 0000000..a220864 --- /dev/null +++ b/packages/studio-panel/e2e/run_e2e.mjs @@ -0,0 +1,147 @@ +import { chromium } from 'playwright'; +import { spawn } from 'child_process'; +import path from 'path'; + +console.log('run_e2e: starting (pid=' + process.pid + ')'); +console.log('ENV VITE_STUDIO_URL=' + (process.env.VITE_STUDIO_URL || '')); +console.log('ENV VITE_BROADCASTPANEL_URL=' + (process.env.VITE_BROADCASTPANEL_URL || '')); +console.log('ENV VITE_TOKEN_SERVER_URL=' + (process.env.VITE_TOKEN_SERVER_URL || '')); + +const serverPath = path.resolve('./server.mjs'); +const base = 'http://localhost:5174'; + +// Allow overriding the target origin via ENV (e.g., VITE_STUDIO_URL) +const targetOrigin = process.env.VITE_STUDIO_URL || process.env.TARGET_ORIGIN || ''; +// If provided, open this as the sender page so origin matches allowed production origins +const broadcastSender = process.env.VITE_BROADCASTPANEL_URL || ''; +// Token server URL for obtaining tokens automatically +const tokenServer = process.env.VITE_TOKEN_SERVER_URL || process.env.TOKEN_SERVER_URL || ''; + +async function startServer() { + console.log('run_e2e: starting static server at', serverPath); + const proc = spawn(process.execPath, [serverPath], { env: { ...process.env, PORT: '5174' }, stdio: ['ignore', 'pipe', 'pipe'] }); + proc.stdout.on('data', d => process.stdout.write('[server] ' + d.toString())); + proc.stderr.on('data', d => process.stderr.write('[server] ' + d.toString())); + await new Promise(r => setTimeout(r, 300)); + console.log('run_e2e: static server started'); + return proc; +} + +// helper: request token from token server +async function requestTokenFromServer() { + if (!tokenServer) return null; + try { + // If SESSION_ID is provided, request that session; otherwise use a default /api/session/e2e endpoint + const sessionId = process.env.SESSION_ID || ''; + const endpoint = sessionId ? `/api/session/${encodeURIComponent(sessionId)}` : '/api/session/e2e'; + const url = `${tokenServer.replace(/\/$/, '')}${endpoint}`; + console.log('Requesting token from', url, sessionId ? `(sessionId=${sessionId})` : '(default)'); + const res = await fetch(url, { method: 'GET' }); + if (!res.ok) { + console.warn('Token server returned', res.status); + return null; + } + const data = await res.json(); + if (data && data.token) return data; + return null; + } catch (err) { + console.warn('Token request failed:', String(err)); + return null; + } +} + +async function run() { + console.log('run_e2e: launching browser'); + const server = await startServer(); + const browser = await chromium.launch({ headless: true }); + console.log('run_e2e: browser launched'); + const context = await browser.newContext(); + const page = await context.newPage(); + + let url; + if (broadcastSender) { + url = broadcastSender; // open prod broadcast panel as sender + console.log('Opening broadcast sender at', url); + } else { + url = base + '/sender.html'; + if (targetOrigin) url += '?target=' + encodeURIComponent(targetOrigin); + console.log('Opening local sender at', url); + } + + console.log('run_e2e: navigating to', url); + await page.goto(url); + console.log('run_e2e: page loaded'); + + // If we opened local sender, click UI; if we opened remote broadcast page we try to trigger postMessage via evaluate + if (!broadcastSender) { + await page.click('#open'); + } + + // Before sending a token, try to fetch a fresh token from the token server + let token = 'E2E_TEST_TOKEN'; // fallback + let tokenMeta = null; + try { + tokenMeta = await requestTokenFromServer(); + if (tokenMeta && tokenMeta.token) { + token = tokenMeta.token; + console.log('Obtained token from server, using it for handshake'); + } else { + console.log('No token from server, using fallback token'); + } + } catch (e) { + console.warn('Token fetch error, using fallback token', e); + } + + if (!broadcastSender) { + // send token from the local sender UI (click send) + await page.click('#send'); + // but override the message in the page to use the token we fetched + try { + await page.evaluate((tok) => { + if (window.__studioPopup && !window.__studioPopup.closed) { + try { + window.__studioPopup.postMessage({ type: 'LIVEKIT_TOKEN', token: tok, room: 'e2e-room' }, window.__studioPopup.location?.origin || '*'); + } catch (e) { + // fallback: send to global origin used by the page + try { window.postMessage({ type: 'LIVEKIT_TOKEN', token: tok, room: 'e2e-room' }, location.origin); } catch(e){} + } + } + }, token); + } catch (e) { console.warn('Override send failed', e); } + } else { + // For remote broadcast panel, open the studio popup and post message using the fetched token + await page.evaluate(async (studioUrl, tok) => { + // open popup + window.popupForE2E = window.open(studioUrl, 'studioPopup', 'width=800,height=600'); + // wait for a moment and then post the token + await new Promise(r => setTimeout(r, 600)); + try { + window.popupForE2E.postMessage({ type: 'LIVEKIT_TOKEN', token: tok, room: 'e2e-room' }, studioUrl); + } catch (e) { + console.error('postMessage failed inside page eval', e); + } + }, targetOrigin || '', token); + } + + // wait for token ack + await page.waitForFunction(() => { + const p = document.querySelector('pre'); + return p && p.textContent && p.textContent.includes('LIVEKIT_TOKEN_ACK'); + }, { timeout: 7000 }).catch(()=>{}); + + // wait for connected ack + await page.waitForFunction(() => { + const p = document.querySelector('pre'); + return p && p.textContent && p.textContent.includes('LIVEKIT_ACK') && p.textContent.includes('connected'); + }, { timeout: 7000 }).catch(()=>{}); + + console.log('run_e2e: finished script, collecting logs'); + const log = await page.$eval('pre', el => el.textContent).catch(()=>''); + console.log('E2E log:\n', log); + + await browser.close(); + console.log('run_e2e: browser closed, killing server'); + server.kill(); +} + +run().catch(err => { console.error('run_e2e: fatal error', err); process.exit(1); }); diff --git a/packages/studio-panel/e2e/selectors_streamyard.json b/packages/studio-panel/e2e/selectors_streamyard.json new file mode 100644 index 0000000..3d704e2 --- /dev/null +++ b/packages/studio-panel/e2e/selectors_streamyard.json @@ -0,0 +1,103 @@ +{ + "source": "streamyard.com broadcasts -> studio", + "capturedAt": "2025-11-15T00:00:00Z", + "notes": "Selectores heurísticos y alternativas (texto, xpath, css) para pruebas E2E. Usar la estrategia text (Playwright) o XPath cuando el texto es estable.", + "selectors": [ + { + "id": "broadcasts.enter_studio_link", + "description": "Link 'Entrar al estudio' en la tabla de broadcasts (fila de transmisión)", + "playwright": "text=Entrar al estudio", + "css": "a:has-text('Entrar al estudio')", + "xpath": "//a[contains(normalize-space(.), 'Entrar al estudio')]", + "page": "broadcasts" + }, + { + "id": "prejoin.start_camera_button", + "description": "Botón para iniciar la cámara en la pantalla previa al ingreso", + "playwright": "text=Iniciar cámara", + "css": "button:has-text('Iniciar cámara')", + "xpath": "//button[contains(normalize-space(.), 'Iniciar cámara')]", + "page": "studio-prejoin" + }, + { + "id": "prejoin.join_without_devices", + "description": "Botón fallback 'Entrar sin micrófono/cámara' cuando no hay dispositivos", + "playwright": "text=Entrar sin micrófono/cámara", + "css": "button:has-text('Entrar sin micrófono/cámara')", + "xpath": "//button[contains(normalize-space(.), 'Entrar sin micrófono/cámara')]", + "page": "studio-prejoin" + }, + { + "id": "prejoin.enter_studio_button", + "description": "Botón final 'Entrar al estudio' en prejoin (si existe)", + "playwright": "text=Entrar al estudio", + "css": "button:has-text('Entrar al estudio'), a:has-text('Entrar al estudio')", + "xpath": "//button[contains(normalize-space(.), 'Entrar al estudio')] | //a[contains(normalize-space(.), 'Entrar al estudio')]", + "page": "studio-prejoin" + }, + { + "id": "studio.add_guest_button", + "description": "Botón 'Agregar invitados' en el panel del estudio (green room)", + "playwright": "text=Agregar invitados", + "css": "button:has-text('Agregar invitados')", + "xpath": "//button[contains(normalize-space(.), 'Agregar invitados')]", + "page": "studio" + }, + { + "id": "studio.record_button", + "description": "Botón 'Grabar' o 'Record' en la barra de control para iniciar/detener grabación", + "playwright": "text=Grabar", + "css": "button:has-text('Grabar'), button:has-text('Record'), button:has-text('Iniciar grabación'), button:has-text('Start Recording'), button:has-text('Recording'), button[data-testid*='record']", + "xpath": "//button[contains(normalize-space(.), 'Grabar') or contains(normalize-space(.), 'Record') or contains(normalize-space(.), 'Iniciar grabación') or contains(normalize-space(.), 'Start Recording') or contains(normalize-space(.), 'Recording')]", + "page": "studio" + }, + { + "id": "studio.destination_button", + "description": "Botón 'Agregar destino' (configuración de plataformas de multistream)", + "playwright": "text=Agregar destino", + "css": "button:has-text('Agregar destino')", + "xpath": "//button[contains(normalize-space(.), 'Agregar destino')]", + "page": "studio" + }, + { + "id": "scenes.show_on_stage", + "description": "Botón 'Mostrar en el escenario' dentro del listado de escenas (thumbnail)", + "playwright": "text=Mostrar en el escenario", + "css": "button:has-text('Mostrar en el escenario')", + "xpath": "//button[contains(normalize-space(.), 'Mostrar en el escenario')]", + "page": "studio-aside-scenes" + }, + { + "id": "scenes.new_scene", + "description": "Botón 'Nueva escena' en panel de Scenes", + "playwright": "text=Nueva escena", + "css": "button:has-text('Nueva escena')", + "xpath": "//button[contains(normalize-space(.), 'Nueva escena')]", + "page": "studio-aside-scenes" + }, + { + "id": "assets.change_logo_section", + "description": "Control para cambiar la sección del logo en Assets", + "playwright": "text=Cambiar la sección del logo", + "css": "button:has-text('Cambiar la sección del logo')", + "xpath": "//button[contains(normalize-space(.), 'Cambiar la sección del logo')]", + "page": "studio-aside-assets" + }, + { + "id": "layouts.preset_individual", + "description": "Botón de preset diseño 'Individual' (uno de los presets listados)", + "playwright": "text=Diseño Individual", + "css": "button:has-text('Diseño Individual'), button:has-text('Individual Central')", + "xpath": "//button[contains(normalize-space(.), 'Diseño Individual') or contains(normalize-space(.),'Individual Central')]", + "page": "studio" + }, + { + "id": "studio.leave_link", + "description": "Link 'Salir del estudio' para cerrar la sesión del studio", + "playwright": "text=Salir del estudio", + "css": "a:has-text('Salir del estudio'), button:has-text('Salir del estudio')", + "xpath": "//a[contains(normalize-space(.), 'Salir del estudio')] | //button[contains(normalize-space(.), 'Salir del estudio')]", + "page": "studio" + } + ] +} diff --git a/packages/studio-panel/e2e/server.mjs b/packages/studio-panel/e2e/server.mjs new file mode 100644 index 0000000..ab0a333 --- /dev/null +++ b/packages/studio-panel/e2e/server.mjs @@ -0,0 +1,42 @@ +import http from 'http'; +import fs from 'fs'; +import path from 'path'; + +const port = process.env.PORT ? Number(process.env.PORT) : 5174; +const staticDir = path.resolve(process.cwd(), './static'); + +const mime = { + '.html': 'text/html', + '.js': 'application/javascript', + '.css': 'text/css', + '.json': 'application/json', + '.png': 'image/png', +}; + +const server = http.createServer((req, res) => { + try { + const reqPath = req.url === '/' ? '/sender.html' : req.url; + const filePath = path.join(staticDir, decodeURIComponent(reqPath)); + if (!filePath.startsWith(staticDir)) { + res.writeHead(403); res.end('Forbidden'); return; + } + if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) { + const ext = path.extname(filePath); + res.writeHead(200, { 'Content-Type': mime[ext] || 'text/plain' }); + fs.createReadStream(filePath).pipe(res); + } else { + res.writeHead(404); res.end('Not found'); + } + } catch (e) { + res.writeHead(500); res.end('Server error'); + } +}); + +server.listen(port, () => { + console.log(`Static server running on http://localhost:${port}`); +}); + +// Graceful shutdown +process.on('SIGINT', () => server.close(() => process.exit(0))); +process.on('SIGTERM', () => server.close(() => process.exit(0))); + diff --git a/packages/studio-panel/e2e/simulated_flow.mjs b/packages/studio-panel/e2e/simulated_flow.mjs new file mode 100644 index 0000000..0ee57ed --- /dev/null +++ b/packages/studio-panel/e2e/simulated_flow.mjs @@ -0,0 +1,95 @@ +#!/usr/bin/env node +// Simulated E2E flow for Studio Panel +// - POST /api/session to token server +// - open studio receiver URL or studio page and assert token reception +// - save screenshots to /tmp + +import fs from 'fs'; +import fetch from 'node-fetch'; + +const TOKEN_SERVER = process.env.TOKEN_SERVER_URL || 'http://localhost:4000'; +const STUDIO_BASE = process.env.STUDIO_URL || 'http://localhost:3020'; +const TIMEOUT = Number(process.env.E2E_TIMEOUT_MS || 30000); + +function log(...args) { console.log(new Date().toISOString(), ...args); } + +async function createSession() { + log('Creating session at', TOKEN_SERVER); + const res = await fetch(`${TOKEN_SERVER}/api/session`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ room: 'e2e-room', username: 'sim-scripter' }) + }); + if (!res.ok) { + const t = await res.text(); + throw new Error(`Token server returned ${res.status}: ${t}`); + } + const body = await res.json(); + log('Session created:', body.id || '(no id)', 'studioUrl=', body.studioUrl); + return body; +} + +async function runPlaywright(url) { + log('Attempting Playwright flow for', url); + let playwright; + try { playwright = await import('playwright'); } catch (e) { log('Playwright not available:', e.message); return { available: false }; } + const { chromium } = playwright; + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext(); + const page = await context.newPage(); + const resp = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: TIMEOUT }); + log('Page loaded status', resp && resp.status()); + // try to find #status text + try { + const status = page.locator('#status'); + await status.waitFor({ timeout: 5000 }); + const txt = await status.textContent(); + log('#status text:', txt && txt.slice(0,200)); + } catch (e) { + log('No #status element or timeout:', e.message); + } + const shot = `/tmp/e2e_playwright_${Date.now()}.png`; + await page.screenshot({ path: shot, fullPage: true }); + log('Saved screenshot', shot); + await browser.close(); + return { available: true, screenshot: shot }; +} + +async function fallbackHttp(url, sessionId, token) { + log('Fallback HTTP: GET', url); + try { + const r = await fetch(url); + log('GET status', r.status, 'content-type', r.headers.get('content-type')); + const txt = await r.text(); + const found = txt.includes('Token recibido') || txt.includes('LIVEKIT_TOKEN') || txt.includes(token); + log('Token present in body?', found); + const shotPath = `/tmp/e2e_http_${Date.now()}.html`; + fs.writeFileSync(shotPath, txt); + log('Saved page HTML to', shotPath); + return { ok: true, found, path: shotPath }; + } catch (e) { + log('HTTP fallback failed', e.message); + return { ok: false, error: e.message }; + } +} + +(async () => { + try { + const session = await createSession(); + const studioUrl = session.redirectUrl || session.studioUrl || (session.id ? `${STUDIO_BASE}/${session.id}` : null); + if (!studioUrl) throw new Error('No studio url returned by token server'); + + // Prefer Playwright flow + const pw = await runPlaywright(studioUrl); + if (pw.available) { log('Playwright flow done'); process.exit(0); } + + // fallback: HTTP check + const fb = await fallbackHttp(studioUrl, session.id, session.token || ''); + if (fb.ok) process.exit(0); + process.exit(2); + } catch (err) { + log('E2E error', err.message); + process.exit(1); + } +})(); + diff --git a/packages/studio-panel/e2e/static/sender.html b/packages/studio-panel/e2e/static/sender.html new file mode 100644 index 0000000..3284839 --- /dev/null +++ b/packages/studio-panel/e2e/static/sender.html @@ -0,0 +1,48 @@ + + + + + Sender (E2E) + + + + +

+  
+
+
diff --git a/packages/studio-panel/e2e/static/studio.html b/packages/studio-panel/e2e/static/studio.html
new file mode 100644
index 0000000..bf62e75
--- /dev/null
+++ b/packages/studio-panel/e2e/static/studio.html
@@ -0,0 +1,38 @@
+
+
+
+  
+  Studio (E2E)
+
+
+  
Esperando token...
+ + + diff --git a/packages/studio-panel/e2e/tests/postMessage.e2e.spec.js b/packages/studio-panel/e2e/tests/postMessage.e2e.spec.js new file mode 100644 index 0000000..15ab8ef --- /dev/null +++ b/packages/studio-panel/e2e/tests/postMessage.e2e.spec.js @@ -0,0 +1,51 @@ +const { test, expect } = require('@playwright/test'); +const { spawn } = require('child_process'); +const path = require('path'); + +const staticServerPath = path.resolve(__dirname, '..', 'server.mjs'); + +let serverProc; + +test.beforeAll(async () => { + // start static server + serverProc = spawn(process.execPath, [staticServerPath], { env: { ...process.env, PORT: '5174' }, stdio: 'inherit' }); + // wait a bit for server to start + await new Promise(resolve => setTimeout(resolve, 400)); +}); + +test.afterAll(async () => { + if (serverProc) { + serverProc.kill(); + } +}); + +test('handshake: sender -> studio via postMessage and ACKs', async ({ page, context }) => { + const base = 'http://localhost:5174'; + + // open sender page + await page.goto(base + '/sender.html'); + // open popup by clicking + await page.click('#open'); + // give popup time to open and be ready + await new Promise(r => setTimeout(r, 300)); + + // send token + await page.click('#send'); + + // wait for token ack from studio + await page.waitForFunction(() => { + const p = document.querySelector('pre'); + return p && p.textContent && p.textContent.includes('LIVEKIT_TOKEN_ACK'); + }, { timeout: 5000 }); + + // wait for connected ack + await page.waitForFunction(() => { + const p = document.querySelector('pre'); + return p && p.textContent && p.textContent.includes('LIVEKIT_ACK') && p.textContent.includes('connected'); + }, { timeout: 5000 }); + + const log = await page.$eval('pre', el => el.textContent); + expect(log).toContain('LIVEKIT_TOKEN_ACK'); + expect(log).toContain('LIVEKIT_ACK'); +}); + diff --git a/packages/studio-panel/index.html b/packages/studio-panel/index.html index 43bb313..6983c37 100644 --- a/packages/studio-panel/index.html +++ b/packages/studio-panel/index.html @@ -8,7 +8,43 @@
+ +
Esperando token...
+ - diff --git a/packages/studio-panel/package.json b/packages/studio-panel/package.json index 23aa40a..b02b133 100644 --- a/packages/studio-panel/package.json +++ b/packages/studio-panel/package.json @@ -6,34 +6,40 @@ "scripts": { "dev": "vite", "build": "vite build", - "preview": "vite preview", + "preview": "vite preview --host", "test": "vitest", "storybook": "storybook dev -p 6006", - "build-storybook": "storybook build" + "build-storybook": "storybook build", + "e2e": "node ./e2e/playwright_test.mjs" }, "dependencies": { - "react": "^18.2.0", - "react-dom": "^18.2.0", - "avanza-ui": "workspace:*", "@livekit/components-react": "^2.7.2", "@livekit/components-styles": "^1.1.5", - "livekit-client": "^2.8.2" + "avanza-ui": "file:../avanza-ui", + "livekit-client": "^2.8.2", + "react": "^18.2.0", + "react-dom": "^18.2.0" }, "devDependencies": { - "typescript": "^5.5.0", - "vite": "^5.0.0", - "@vitejs/plugin-react": "^4.0.0", - "vitest": "^1.1.8", - "@testing-library/react": "^14.0.0", - "@testing-library/jest-dom": "^6.0.0", - "@testing-library/user-event": "^14.4.3", - "@storybook/react": "^8.0.0", - "@storybook/react-vite": "^8.0.0", + "@playwright/test": "1.51.0", "@storybook/addon-essentials": "^8.0.0", "@storybook/addon-interactions": "^8.0.0", "@storybook/addon-links": "^8.0.0", "@storybook/blocks": "^8.0.0", - "storybook": "^8.0.0" + "@storybook/react": "^8.0.0", + "@storybook/react-vite": "^8.0.0", + "@tailwindcss/postcss": "^4.1.17", + "@testing-library/jest-dom": "^6.0.0", + "@testing-library/react": "^14.0.0", + "@testing-library/user-event": "^14.4.3", + "@vitejs/plugin-react": "^4.0.0", + "node-fetch": "^3.3.1", + "playwright": "^1.51.0", + "storybook": "^8.0.0", + "tailwindcss": "^4.1.17", + "typescript": "^5.5.0", + "vite": "^5.0.0", + "vitest": "^1.1.8" }, "vitest": { "test": { @@ -41,5 +47,12 @@ "setupFiles": "./src/setupTests.ts", "globals": true } - } + }, + "main": "index.js", + "directories": { + "test": "tests" + }, + "keywords": [], + "author": "", + "license": "ISC" } diff --git a/packages/studio-panel/playwright.config.ts b/packages/studio-panel/playwright.config.ts new file mode 100644 index 0000000..8d875ef --- /dev/null +++ b/packages/studio-panel/playwright.config.ts @@ -0,0 +1,19 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: 'tests/e2e', + timeout: 60_000, + expect: { timeout: 5000 }, + fullyParallel: false, + reporter: [['list'], ['html', { outputFolder: 'playwright-report' }]], + use: { + headless: true, + viewport: { width: 1280, height: 720 }, + ignoreHTTPSErrors: true, + actionTimeout: 10000, + }, + projects: [ + { name: 'chromium', use: { ...devices['Desktop Chrome'] } }, + ], +}); + diff --git a/packages/studio-panel/postcss.config.cjs b/packages/studio-panel/postcss.config.cjs index fdfd500..4155398 100644 --- a/packages/studio-panel/postcss.config.cjs +++ b/packages/studio-panel/postcss.config.cjs @@ -1,7 +1,5 @@ -module.exports = { - plugins: { - // use the PostCSS plugin package for Tailwind v4 - '@tailwindcss/postcss': {}, - autoprefixer: {}, - }, -} +// Minimal PostCSS config for studio-panel - disabled to avoid Tailwind/PostCSS plugin issues during development. +// If you want to enable Tailwind processing later, replace this file with a proper config +// that uses '@tailwindcss/postcss' or 'tailwindcss' and avoid top-level requires that may throw. + +module.exports = {}; diff --git a/packages/studio-panel/public/debug-styles.html b/packages/studio-panel/public/debug-styles.html new file mode 100644 index 0000000..23affd4 --- /dev/null +++ b/packages/studio-panel/public/debug-styles.html @@ -0,0 +1,79 @@ + + + + + + Debug Styles - Studio Panel + + + +

Debug: document.styleSheets

+
Cargando...
+
+ + + diff --git a/packages/studio-panel/public/simulate_postmessage.html b/packages/studio-panel/public/simulate_postmessage.html new file mode 100644 index 0000000..0f5b740 --- /dev/null +++ b/packages/studio-panel/public/simulate_postmessage.html @@ -0,0 +1,154 @@ + + + + + + Broadcast to Studio - Simulate (with token fetch) + + + +

Broadcast Simulator (token fetch)

+

Simula broadcast-panel: solicita token al backend y se lo envía por postMessage a la ventana del estudio.

+ + +

+ +

+ + + +
+ + +
+ +
+
Idle
+ + + + diff --git a/packages/studio-panel/public/studio_receiver.html b/packages/studio-panel/public/studio_receiver.html new file mode 100644 index 0000000..2ec7bf1 --- /dev/null +++ b/packages/studio-panel/public/studio_receiver.html @@ -0,0 +1,70 @@ + + + + + + Studio Receiver (Test) + + + +

Studio Receiver - listens for LIVEKIT_TOKEN

+
Esperando token...
+ + + diff --git a/packages/studio-panel/run_e2e_session.sh b/packages/studio-panel/run_e2e_session.sh new file mode 100755 index 0000000..c6a21b0 --- /dev/null +++ b/packages/studio-panel/run_e2e_session.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +# install deps (fast fail if missing) +npm ci --no-audit --no-fund +npx playwright install --with-deps || true + +# start a simple static server to serve public/ on port 3020 +PORT=3020 +PUBLIC_DIR="$ROOT/public" + +# prefer serve if available, else use npx http-server +if command -v serve > /dev/null 2>&1; then + serve -s "$PUBLIC_DIR" -l $PORT > /tmp/studio_static_server.log 2>&1 & + SERVER_PID=$! +else + npx http-server "$PUBLIC_DIR" -p $PORT > /tmp/studio_static_server.log 2>&1 & + SERVER_PID=$! +fi + +echo "Started static server (pid=$SERVER_PID), log: /tmp/studio_static_server.log" +sleep 1 + +# export envs for the test +export TOKEN_SERVER_URL=${TOKEN_SERVER_URL:-http://localhost:4000} +export STUDIO_URL=${STUDIO_URL:-http://localhost:3020} + +# run playwright test +npx playwright test tests/e2e/session_flow.spec.ts --project=chromium --config=playwright.config.ts || true + +# cleanup +if [ -n "${SERVER_PID:-}" ]; then + kill "$SERVER_PID" 2>/dev/null || true +fi + +echo "Artifacts (screenshots) in /tmp/e2e_*.png" +ls -la /tmp/e2e_*.png || true + +exit 0 diff --git a/packages/studio-panel/run_playwright_test.sh b/packages/studio-panel/run_playwright_test.sh new file mode 100755 index 0000000..f4d14d4 --- /dev/null +++ b/packages/studio-panel/run_playwright_test.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR=$(cd "$(dirname "$0")" && pwd) +cd "$ROOT_DIR" + +LOG=/tmp/playwright_debug.log +OUT=/tmp/playwright_run_output.log +SIMSHOT=/tmp/sim_postmessage_simulator.png +STUDIOSHOT=/tmp/sim_postmessage_studio.png + +echo "Running Playwright E2E test for studio-panel" +echo "Logs: $LOG Output: $OUT" + +# Ensure node modules exist +if [ ! -d node_modules/playwright ]; then + echo "Playwright not found in node_modules — installing as devDependency (this may modify package-lock) +" + npm install --no-audit --no-fund --no-save playwright +fi + +echo "Installing Playwright browsers (may require sudo on some systems)..." +npx playwright install --with-deps || true + +echo "Running test script... (this may take ~15s)" +# run and capture output +node --experimental-vm-modules scripts/playwright_postmessage_test.mjs > "$OUT" 2>&1 || true + +# Show summary +echo "\n=== Playwright run finished ===\n" +if [ -f "$LOG" ]; then + echo "Last 200 lines of $LOG:\n" + tail -n 200 "$LOG" +else + echo "$LOG not found" +fi + +if [ -f "$OUT" ]; then + echo "\nLast 200 lines of run output ($OUT):\n" + tail -n 200 "$OUT" +else + echo "$OUT not found" +fi + +if [ -f "$SIMSHOT" ]; then + echo "Simulator screenshot: $SIMSHOT" +else + echo "Simulator screenshot not found" +fi +if [ -f "$STUDIOSHOT" ]; then + echo "Studio screenshot: $STUDIOSHOT" +else + echo "Studio screenshot not found" +fi + +echo "\nIf the test failed, please paste the contents of the two log files above and attach the screenshots listed. +You can upload screenshots to an image host and paste URLs, or paste the relevant log sections here." + diff --git a/packages/studio-panel/scripts/check_styles_playwright.mjs b/packages/studio-panel/scripts/check_styles_playwright.mjs new file mode 100644 index 0000000..01b6e75 --- /dev/null +++ b/packages/studio-panel/scripts/check_styles_playwright.mjs @@ -0,0 +1,28 @@ +let playwright +try { + playwright = await import('playwright') +} catch (e) { + console.error('\nPlaywright no está instalado en este entorno.'); + console.error('Instálalo con: npm install -D playwright (en la raíz del repo) y luego ejecuta: npx playwright install'); + process.exitCode = 2 + throw e +} + +const { chromium } = playwright; + +(async () => { + const browser = await chromium.launch(); + const page = await browser.newPage(); + try { + // Target studio-panel dev server by default + await page.goto('http://localhost:3020/', { waitUntil: 'networkidle' , timeout: 10000}); + const sheets = await page.evaluate(() => Array.from(document.styleSheets).map(s => s.href || s.ownerNode?.textContent?.slice(0,50) || null)); + console.log('document.styleSheets count =', sheets.length); + console.log(JSON.stringify(sheets, null, 2)); + } catch (e) { + console.error('Error:', e.toString()); + process.exitCode = 2; + } finally { + await browser.close(); + } +})(); diff --git a/packages/studio-panel/scripts/check_styles_playwright_3021.mjs b/packages/studio-panel/scripts/check_styles_playwright_3021.mjs new file mode 100644 index 0000000..01c5f9e --- /dev/null +++ b/packages/studio-panel/scripts/check_styles_playwright_3021.mjs @@ -0,0 +1,29 @@ +let playwright +try { + playwright = await import('playwright') +} catch (e) { + console.error('\nPlaywright no está instalado en este entorno.'); + console.error('Instálalo con: npm install -D playwright (en la raíz del repo) y luego ejecuta: npx playwright install'); + process.exitCode = 2 + throw e +} + +const { chromium } = playwright; + +(async () => { + const browser = await chromium.launch({ headless: true }); + const page = await browser.newPage(); + try { + // Target studio-panel dev server on port 3021 + await page.goto('http://localhost:3021/', { waitUntil: 'networkidle' , timeout: 15000}); + const sheets = await page.evaluate(() => Array.from(document.styleSheets).map(s => s.href || s.ownerNode?.textContent?.slice(0,250) || null)); + console.log('document.styleSheets count =', sheets.length); + console.log(JSON.stringify(sheets, null, 2)); + } catch (e) { + console.error('Error:', e.toString()); + process.exitCode = 2; + } finally { + await browser.close(); + } +})(); + diff --git a/packages/studio-panel/scripts/e2e_portal_interaction.mjs b/packages/studio-panel/scripts/e2e_portal_interaction.mjs new file mode 100644 index 0000000..5266931 --- /dev/null +++ b/packages/studio-panel/scripts/e2e_portal_interaction.mjs @@ -0,0 +1,31 @@ +import { chromium } from 'playwright'; + +const WS = process.env.PLAYWRIGHT_WS_ENDPOINT || 'ws://192.168.1.20:3003'; +const REDIR = process.env.REDIR_URL; + +(async ()=>{ + if (!REDIR) { + console.error('REDIR_URL not provided'); + process.exit(2); + } + const browser = await chromium.connect({ wsEndpoint: WS }); + const context = await browser.newContext(); + const page = await context.newPage(); + page.on('console', m => console.log('PAGE:', m.text())); + try { + await page.goto(REDIR, { waitUntil: 'networkidle', timeout: 30000 }); + await page.waitForSelector('.studio-portal .layout-btn', { timeout: 5000 }).catch(()=>{}); + // click second layout + await page.evaluate(()=>{ const btns = document.querySelectorAll('.layout-btn'); if (btns && btns[1]) (btns[1] as HTMLElement).click(); }); + await page.waitForFunction(()=> !!document.querySelector('.studio-room')?.getAttribute('data-layout'), { timeout: 3000 }).catch(()=>{}); + await page.evaluate(()=>{ const rec = document.querySelector('.btn-record') as HTMLElement; if(rec) rec.click(); }); + await page.waitForTimeout(1500); + await page.screenshot({ path: '/tmp/e2e_portal_interaction.png', fullPage:true}); + console.log('PAGE URL', page.url()); + } catch (err) { + console.error('E2E error', err); + } finally { + await browser.close(); + } +})(); + diff --git a/packages/studio-panel/scripts/e2e_session_http_runner.mjs b/packages/studio-panel/scripts/e2e_session_http_runner.mjs new file mode 100644 index 0000000..35baeb2 --- /dev/null +++ b/packages/studio-panel/scripts/e2e_session_http_runner.mjs @@ -0,0 +1,59 @@ +import fetch from 'node-fetch'; +import fs from 'fs'; + +const LOG = '/tmp/e2e_http_runner.log'; +function writeLog(...args){ try{ fs.appendFileSync(LOG, args.map(a=>typeof a==='string'?a:JSON.stringify(a)).join(' ')+'\n') }catch(e){} + console.log(...args); +} + +const TOKEN_SERVER = process.env.TOKEN_SERVER_URL || 'http://localhost:4000'; +const STUDIO_URL = process.env.STUDIO_URL || 'http://localhost:3020'; +const TIMEOUT = Number(process.env.E2E_TIMEOUT_MS || 15000); + +(async ()=>{ + try{ + writeLog('START HTTP runner', new Date().toISOString(), 'TOKEN_SERVER='+TOKEN_SERVER); + + // 1) create session + const resp = await fetch(`${TOKEN_SERVER}/api/session`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ room: 'http-e2e-room', username: 'http-runner' }) + }); + writeLog('POST /api/session status', resp.status); + const json = await resp.json(); + writeLog('POST response', json); + + const redirectUrl = json.redirectUrl || json.studioUrl || null; + if (!redirectUrl){ writeLog('No redirectUrl in response, abort'); process.exit(2); } + + writeLog('Redirect URL:', redirectUrl); + + // 2) GET redirect URL + const r2 = await fetch(redirectUrl, { method: 'GET' }); + writeLog('GET redirect status', r2.status, 'content-type', r2.headers.get('content-type')); + const bodyText = await r2.text(); + writeLog('GET redirect body length', bodyText.length); + + // 3) checks + const hasTokenInUrl = /token=/.test(redirectUrl); + const hasTokenInBody = /token=/.test(bodyText) || /Token recibido/.test(bodyText) || /LIVEKIT_ACK/.test(bodyText); + writeLog('hasTokenInUrl', hasTokenInUrl, 'hasTokenInBody', hasTokenInBody); + + // 4) session lookup if id present + if (json.id){ + try{ + const s = await fetch(`${TOKEN_SERVER}/api/session/${json.id}`); + writeLog('/api/session/:id status', s.status); + const sjson = await s.json(); writeLog('session lookup', sjson); + }catch(e){ writeLog('session lookup error', String(e)); } + } + + // save an excerpt of body to file + const out = '/tmp/e2e_http_runner_body.html'; + try{ fs.writeFileSync(out, bodyText.slice(0, 20000)); writeLog('Saved redirect HTML excerpt to', out); }catch(e){ writeLog('Failed writing excerpt', String(e)); } + + writeLog('FINISH OK'); + process.exit(0); + }catch(err){ writeLog('Runner error', String(err)); process.exit(1); } +})(); + diff --git a/packages/studio-panel/scripts/e2e_session_runner.mjs b/packages/studio-panel/scripts/e2e_session_runner.mjs new file mode 100644 index 0000000..07fbb22 --- /dev/null +++ b/packages/studio-panel/scripts/e2e_session_runner.mjs @@ -0,0 +1,103 @@ +import fetch from 'node-fetch'; +import fs from 'fs'; + +const LOG_PATH = '/tmp/e2e_node_runner.log'; +function log(...args) { try { fs.appendFileSync(LOG_PATH, args.map(a=>typeof a==='string'?a:JSON.stringify(a)).join(' ') + '\n') }catch(e){} + console.log(...args) +} + +let playwrightAvailable = true; +let playwright = null; +try { + // dynamic import to allow fallback if playwright not installed + playwright = await import('playwright'); +} catch (err) { + playwrightAvailable = false; + log('Playwright not available, will fallback to HTTP checks:', String(err)); +} + +const TOKEN_SERVER = process.env.TOKEN_SERVER_URL || 'http://localhost:4000'; +const TIMEOUT = Number(process.env.E2E_TIMEOUT_MS || 30000); + +(async () => { + try { + log('Creating session at token server:', TOKEN_SERVER); + const res = await fetch(`${TOKEN_SERVER}/api/session`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ room: 'e2e-room', username: 'node-runner' }) + }); + if (!res.ok) { + log('Token server returned', res.status); + log(await res.text()); + process.exitCode = 2; return; + } + const body = await res.json(); + log('Session created:', body); + const url = body.redirectUrl || body.studioUrl || (body.id ? `${(process.env.STUDIO_URL||'http://localhost:3020')}/studio_receiver.html?token=${encodeURIComponent(body.token||'')}` : null); + if (!url) { log('No redirectUrl'); process.exitCode = 3; return } + + // If playwright available, open browser and verify #status text + if (playwrightAvailable && playwright) { + log('Launching Playwright chromium'); + const { chromium } = playwright; + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext(); + const page = await context.newPage(); + log('Opening studio page:', url); + const resp = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: TIMEOUT }); + log('Loaded page status:', resp && resp.status()); + + const status = page.locator('#status'); + try { + await status.waitFor({ timeout: TIMEOUT }); + const text = await status.textContent(); + log('Status text:', (text||'').slice(0,400)); + if ((text || '').includes('Token recibido')) { + log('Studio received token OK'); + } else { + log('Studio status did not include expected text'); + } + } catch (err) { + log('Timeout waiting for #status', String(err)); + } + + const shot1 = '/tmp/e2e_studio_runner.png'; + await page.screenshot({ path: shot1, fullPage: true }); + log('Screenshot saved:', shot1); + + await browser.close(); + process.exitCode = 0; + return; + } + + // Fallback: simple HTTP checks + log('Playwright not available: performing HTTP-only checks for', url); + try { + const r = await fetch(url, { method: 'GET' }); + log('GET redirectUrl status', r.status, 'content-type', r.headers.get('content-type')); + const text = await r.text(); + // look for token in body or in url + const hasTokenInBody = /token=/.test(text) || /Token recibido/.test(text) || /LIVEKIT_ACK/.test(text); + const hasTokenInUrl = /token=/.test(url); + log('hasTokenInBody', hasTokenInBody, 'hasTokenInUrl', hasTokenInUrl); + } catch (err) { + log('Error fetching redirectUrl:', String(err)); + } + + // Also check session retrieval via API + if (body.id) { + try { + const s = await fetch(`${TOKEN_SERVER}/api/session/${body.id}`); + log('/api/session/:id status', s.status); + if (s.ok) { + const js = await s.json(); log('session lookup:', js); + } else log('session lookup failed'); + } catch (err) { log('session lookup error', String(err)) } + } + + process.exitCode = 0; + } catch (err) { + log('E2E runner error', String(err)); + process.exitCode = 1; + } +})(); diff --git a/packages/studio-panel/scripts/e2e_simulate_guests.mjs b/packages/studio-panel/scripts/e2e_simulate_guests.mjs new file mode 100644 index 0000000..81b311f --- /dev/null +++ b/packages/studio-panel/scripts/e2e_simulate_guests.mjs @@ -0,0 +1,164 @@ +import { chromium } from 'playwright'; +import fs from 'fs'; + +const BACKEND = process.env.BACKEND_URL || 'http://127.0.0.1:4000'; +const GUESTS = Number(process.env.GUESTS || '2'); +const HEADLESS = process.env.HEADLESS !== 'false'; +const VERBOSE_LOG = '/tmp/e2e_sim_verbose.log'; + +function vlog(...args){ + const line = `[${new Date().toISOString()}] ` + args.map(a => (typeof a === 'string' ? a : JSON.stringify(a))).join(' '); + try { fs.appendFileSync(VERBOSE_LOG, line + '\n'); } catch(e){} + console.log(line); +} + +async function createSession(username) { + vlog('createSession ->', username); + try { + const res = await fetch(`${BACKEND}/api/session`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ room: 'studio-demo', username }), + // keepAlive not available in fetch, but backend should be reachable + }); + if (!res.ok) { + const txt = await res.text().catch(()=>''); + throw new Error(`session create failed ${res.status} ${txt}`); + } + const j = await res.json(); + vlog('session created', j.redirectUrl || j); + return j; + } catch (e) { + vlog('createSession error', String(e)); + throw e; + } +} + +async function checkBackend(){ + try { + vlog('Checking backend health at', BACKEND + '/health'); + const r = await fetch(`${BACKEND}/health`, { method: 'GET', cache: 'no-store' , redirect: 'follow' }); + if (!r.ok) { vlog('backend health not ok', r.status); return false; } + const j = await r.json().catch(()=>null); + vlog('backend health response', j || ''); + return true; + } catch (e) { vlog('backend health check failed', String(e)); return false; } +} + +(async ()=>{ + try{ + // reset verbose log + try{ fs.writeFileSync(VERBOSE_LOG, ''); } catch(e){} + vlog('E2E simulate guests - starting', { BACKEND, GUESTS, HEADLESS }); + + const ok = await checkBackend(); + if (!ok) { + vlog('Backend not available at', BACKEND, ' -> aborting'); + process.exit(2); + } + + // create sessions + const sessions = []; + try { + const mod = await createSession('moderator-e2e'); + sessions.push({ role: 'moderator', ...mod }); + for (let i=0;is.role==='moderator'); + if (!modSession) throw new Error('no moderator session'); + const modContext = await browser.newContext({ permissions: ['camera','microphone'] }); + const modPage = await modContext.newPage(); + modPage.on('console', m => vlog('MOD PAGE:', m.text())); + vlog('Opening moderator at', modSession.redirectUrl); + await modPage.goto(modSession.redirectUrl, { waitUntil: 'networkidle', timeout: 60000 }); + contexts.push(modContext); pages.push(modPage); + + // open guests + for (let s of sessions.filter(x=>x.role==='guest')){ + const ctx = await browser.newContext({ permissions: ['camera','microphone'] }); + const pg = await ctx.newPage(); + pg.on('console', m => vlog('GUEST PAGE:', m.text())); + vlog('Opening guest at', s.redirectUrl); + await pg.goto(s.redirectUrl, { waitUntil: 'networkidle', timeout: 60000 }); + contexts.push(ctx); pages.push(pg); + } + + // wait short + await modPage.waitForTimeout(2000); + + // click Conectar todos on moderator + vlog('Clicking Conectar todos'); + await modPage.evaluate(()=>{ + const btn = Array.from(document.querySelectorAll('button')).find(b => { + const txt = (b.textContent||'').trim().toLowerCase(); + return txt.includes('conectar todos') || txt.includes('connect all'); + }); + if (btn && typeof btn.click === 'function') btn.click(); + }); + + // wait for invites to be processed; wait until an svg line has green stroke + vlog('Waiting for accepted lines...'); + const accepted = await modPage.waitForFunction(()=>{ + const svg = document.querySelector('.connections-overlay'); + if (!svg) return false; + const lines = Array.from(svg.querySelectorAll('line')); + return lines.some(l => ((l.getAttribute('stroke')||'').toLowerCase() === '#34d399')); + }, { timeout: 20000 }).catch(()=>null); + + if (accepted) vlog('Accepted detected'); else vlog('No accepted detected within timeout'); + + // screenshots + const outDir = 'packages/studio-panel/.playwright-mcp'; + try{ fs.mkdirSync(outDir, { recursive: true }); } catch(e){} + const modShot = '/tmp/e2e_sim_moderator.png'; + await modPage.screenshot({ path: modShot, fullPage:true }); + fs.copyFileSync(modShot, `${outDir}/${modShot.split('/').pop()}`); + vlog('Saved moderator screenshot to', modShot); + + for (let i=0;i{ + log('Starting playwright_mcp_flow'); + // Connect to remote server if reachable; else fall back to local launch + let browser; + try { + if (PLAYWRIGHT_WS) { + log('Attempting to connect to remote Playwright WS at ' + PLAYWRIGHT_WS); + try { + browser = await chromium.connect({ wsEndpoint: PLAYWRIGHT_WS, timeout: 10000 }); + remoteBrowser = browser; + log('Connected to remote Playwright browser via WS'); + } catch (err) { + // If server responded with 428 (version mismatch) surface clear message + const emsg = (err && err.message) ? String(err.message) : String(err); + if (emsg.includes('428') || emsg.toLowerCase().includes('version')) { + log('Failed to connect to remote Playwright WS due to version mismatch: ' + emsg); + log('ACTION REQUIRED: Sync Playwright versions. Server reports a different Playwright version than the client.'); + } else { + log('Failed to connect to remote Playwright WS: ' + err + '. Falling back to local launch.'); + } + } + } + } catch (e) { log('Remote connect error: '+e); } + + if (!browser) { + log('Launching local chromium'); + launchedBrowser = await chromium.launch({ headless: true, args: ['--no-sandbox','--disable-dev-shm-usage'] }); + browser = launchedBrowser; + } + const context = await browser.newContext(); + const page = await context.newPage(); + page.on('console', m => log('PAGE LOG: ' + m.text())); + + // Load selectors JSON + // convert module URL to file path correctly + const selPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../e2e/selectors_streamyard.json'); + let SELECTORS = {}; + try{ + const raw = fs.readFileSync(selPath, 'utf8'); + const parsed = JSON.parse(raw); + (parsed.selectors || []).forEach(s => { SELECTORS[s.id] = s; }); + log(`Loaded ${Object.keys(SELECTORS).length} selectors from ${selPath}`); + }catch(err){ log('Failed to load selectors JSON: '+err); } + + function getSel(id){ + const s = SELECTORS[id]; + if (!s) return null; + // prefer Playwright selector string if present + return s.playwright || s.css || s.xpath || null; + } + + // Configurable env vars + const BROADCAST_URL = process.env.BROADCAST_URL || process.env.BROADCAST_LIST_URL || 'https://streamyard.com/broadcasts'; + const BROADCAST_TIMEOUT = Number(process.env.BROADCAST_TIMEOUT_MS || 30000); + const STUDIO_TIMEOUT = Number(process.env.STUDIO_TIMEOUT_MS || 30000); + + try{ + log('Navigating to broadcast list: ' + BROADCAST_URL); + await page.goto(BROADCAST_URL, { waitUntil: 'networkidle', timeout: BROADCAST_TIMEOUT }); + log('Broadcast page loaded: ' + page.url()); + + // If the broadcast page attempted to fetch a token but failed due to CORS, detect it in console logs + // We'll check page content for common CORS error markers and fallback to backend-api session creation. + const pageContent = await page.content().catch(()=>null); + const corsIssue = pageContent && (pageContent.includes('Failed to fetch') || pageContent.includes('blocked by CORS') || pageContent.includes('error%3A%20TypeError')); + if (corsIssue) { + log('Detected token fetch CORS issue on broadcast page — will create session via BACKEND_API_URL fallback'); + // First attempt: try to fetch token directly from token server (server-side request) + const TOKEN_SERVER = process.env.TOKEN_SERVER_URL || process.env.VITE_TOKEN_SERVER_URL || 'https://avanzacast-servertokens.bfzqqk.easypanel.host'; + try { + log('Attempting direct token request to token-server: ' + TOKEN_SERVER + '/api/token'); + const room = process.env.TEST_ROOM || 'studio-demo'; + const username = process.env.TEST_USERNAME || 'simulator'; + const tokenResp = await context.request.get(TOKEN_SERVER + `/api/token?room=${encodeURIComponent(room)}&username=${encodeURIComponent(username)}`); + if (tokenResp && tokenResp.ok()) { + const json = await tokenResp.json(); + log('Token server returned JSON: ' + JSON.stringify(Object.keys(json))); + const token = json.token || json?.token; + const serverUrl = json.url || json?.url || process.env.VITE_LIVEKIT_WS_URL || process.env.VITE_LIVEKIT_URL; + if (token) { + // compose studio URL + const studioBase = (process.env.VITE_STUDIO_URL || 'http://localhost:3020').replace(/\/$/, ''); + const redirectUrl = `${studioBase}/studio_receiver.html?token=${encodeURIComponent(token)}&room=${encodeURIComponent(room)}&username=${encodeURIComponent(username)}&serverUrl=${encodeURIComponent(serverUrl||'')}`; + log('Opening studio directly with token from token-server: ' + redirectUrl); + const studioPage = await context.newPage(); + await studioPage.goto(redirectUrl, { waitUntil: 'networkidle', timeout: 30000 }).catch(e=>log('studio goto failed: '+e)); + page = studioPage; // eslint-disable-line + // skip backend fallback + corsIssue = false; + } else { + log('Token server response did not include token'); + } + } else { + log('Token server request failed, status=' + (tokenResp && tokenResp.status())); + } + } catch (e) { + log('Direct token-server request failed: ' + e); + } + + const BACKEND_API = process.env.BACKEND_API_URL || 'http://localhost:4000'; + try { + log('Creating session via backend API: ' + BACKEND_API + '/api/session'); + // use Playwright's request to perform POST (works with remote browser) + const room = process.env.TEST_ROOM || 'studio-demo'; + const username = process.env.TEST_USERNAME || 'simulator'; + const ttl = Number(process.env.TEST_TTL || 300); + const resp = await context.request.post(BACKEND_API + '/api/session', { + data: { room, username, ttl } + }); + if (resp && resp.status() === 200) { + const json = await resp.json(); + log('Session created: ' + JSON.stringify(json)); + const redirectUrl = json.redirectUrl || json.studioUrl; + if (redirectUrl) { + log('Opening studio redirectUrl from backend session: ' + redirectUrl); + // open new page for studio + const studioPage = await context.newPage(); + await studioPage.goto(redirectUrl, { waitUntil: 'networkidle', timeout: 30000 }).catch(e=>log('studio goto failed: '+e)); + // replace studioPage variable used below + page = studioPage; // eslint-disable-line + } else { + log('Backend session response did not include redirectUrl/studioUrl'); + } + } else { + log('Backend session creation failed, status=' + (resp && resp.status())); + } + } catch (e) { + log('Error creating session via backend API fallback: ' + e); + } + } + + // --- Login handling: if redirected to /login or login form present, attempt automated login using env vars + try { + const maybeLogin = page.url().includes('/login'); + // also detect common login form inputs + const hasEmailInput = await page.locator('input[type="email"], input[name="email"], input[id*=email]').count(); + const hasPasswordInput = await page.locator('input[type="password"], input[name="password"], input[id*=password]').count(); + if (maybeLogin || hasEmailInput || hasPasswordInput) { + const TEST_USER_EMAIL = process.env.TEST_USER_EMAIL || process.env.STREAMYARD_TEST_EMAIL || ''; + const TEST_USER_PASSWORD = process.env.TEST_USER_PASSWORD || process.env.STREAMYARD_TEST_PASSWORD || ''; + log('Detected login page/form; env email present? ' + (TEST_USER_EMAIL ? 'yes' : 'no')); + if (TEST_USER_EMAIL && TEST_USER_PASSWORD) { + // fill email + const emailSelectors = ['input[type="email"]','input[name="email"]','input[id*=email]']; + for (const sel of emailSelectors) { + try { if (await page.locator(sel).count()) { await page.fill(sel, TEST_USER_EMAIL); log('Filled email using selector: '+sel); break; } } catch(e){} + } + // fill password + const passSelectors = ['input[type="password"]','input[name="password"]','input[id*=password]']; + for (const sel of passSelectors) { + try { if (await page.locator(sel).count()) { await page.fill(sel, TEST_USER_PASSWORD); log('Filled password using selector: '+sel); break; } } catch(e){} + } + // try submit with several button selectors + const submitSelectors = [ + 'button:has-text("Iniciar sesión")', + 'button:has-text("Iniciar Sesión")', + 'button:has-text("Sign in")', + 'button[type="submit"]' + ]; + let clicked = false; + for (const s of submitSelectors) { + try { + if (await page.locator(s).count()) { await page.click(s); log('Clicked submit using selector: '+s); clicked = true; break; } + } catch(e){} + } + // if no button clicked, try pressing Enter on password field + if (!clicked) { + try { await page.keyboard.press('Enter'); log('Pressed Enter to submit login form'); } catch(e){} + } + // wait for navigation away from /login or for broadcasts path + try { + await page.waitForFunction(() => !location.pathname.includes('/login'), { timeout: 20000 }); + log('Login appears to have completed; current URL: ' + page.url()); + // small delay to allow app to settle + await page.waitForLoadState('networkidle'); + } catch(e) { log('Login did not navigate away within timeout, continuing anyway: '+e); } + } else { + log('No TEST_USER_EMAIL/TEST_USER_PASSWORD provided in env; cannot auto-login.'); + } + } + } catch (e) { log('Login detection/attempt failed: '+e); } + + // Click the Enter Studio link (first match) + const enterSel = getSel('broadcasts.enter_studio_link'); + if (!enterSel) throw new Error('Selector broadcasts.enter_studio_link not found'); + try{ + log('Waiting for enter studio link: ' + enterSel); + const enterLocator = page.locator(enterSel).first(); + await enterLocator.waitFor({ timeout: 10000 }); + await enterLocator.click({ force: true }); + log('Clicked enter studio link'); + } catch(e){ log('Failed clicking enter studio link: '+e); } + + // Wait for navigation to prejoin/studio + await page.waitForLoadState('networkidle'); + log('After click current URL: ' + page.url()); + + // If prejoin appears in same page, interact; else try to find new page in context + let studioPage = page; + + // Try start camera + const startCameraSel = getSel('prejoin.start_camera_button'); + const joinWithoutSel = getSel('prejoin.join_without_devices'); + const enterStudioBtnSel = getSel('prejoin.enter_studio_button'); + + // If start camera exists, try click + if (startCameraSel) { + try{ + const loc = studioPage.locator(startCameraSel).first(); + await loc.waitFor({ timeout: 6000 }); + await loc.click({ force:true }); + log('Clicked start camera'); + }catch(e){ log('start camera not available or failed: '+e); + // try fallback + if (joinWithoutSel){ try{ const j = studioPage.locator(joinWithoutSel).first(); await j.waitFor({ timeout:2000 }); await j.click({ force:true }); log('Clicked join without devices fallback'); }catch(err){ log('join without devices not found: '+err); } } + } + } else if (joinWithoutSel) { + try{ const j = studioPage.locator(joinWithoutSel).first(); await j.waitFor({ timeout:2000 }); await j.click({ force:true }); log('Clicked join without devices fallback (no startCamera selector)'); }catch(err){ log('join without devices not found (no startCamera selector): '+err); } + } + + // Click final Enter if exists + if (enterStudioBtnSel){ try{ const ebtn = studioPage.locator(enterStudioBtnSel).first(); await ebtn.waitFor({ timeout:5000 }); await ebtn.click({ force:true }); log('Clicked final enter studio button'); }catch(e){ log('no final enter button: '+e); } } + + // Wait for studio to be ready: look for studio.record_button or studio.add_guest_button + const studioReadySel = getSel('studio.record_button') || getSel('studio.add_guest_button'); + if (studioReadySel){ + log('Waiting for studio ready selector: ' + studioReadySel); + try{ + await studioPage.waitForSelector(studioReadySel, { timeout: STUDIO_TIMEOUT }); + log('Studio ready detected on page: ' + studioReadySel + ' url=' + studioPage.url()); + }catch(e){ + // maybe the app opened in a new tab - check other pages + log('Studio ready selector not found in current page, checking other context pages'); + const pages = context.pages(); + let found = false; + for (const p of pages){ + try{ + const u = p.url(); + const loc = p.locator(studioReadySel).first(); + if (await loc.count() > 0){ + log('Found studio ready selector in page: ' + u); + studioPage = p; + found = true; + break; + } + }catch(err){} + } + if (!found) log('Studio ready not found in any page'); + } + } + + // Capture screenshots + const simShot = '/tmp/playwright_mcp_broadcast.png'; + const studioShot = '/tmp/playwright_mcp_studio.png'; + try{ await page.screenshot({ path: simShot, fullPage: true }); log('Saved broadcast screenshot: ' + simShot); }catch(e){ log('Failed saving broadcast screenshot: '+e); } + try{ await studioPage.screenshot({ path: studioShot, fullPage: true }); log('Saved studio screenshot: ' + studioShot); }catch(e){ log('Failed saving studio screenshot: '+e); } + + log('Flow finished successfully (or attempted)'); + }catch(err){ log('Unhandled error in flow: ' + (err && err.stack ? err.stack : String(err))); } + finally{ + try{ await browser.close(); log('Browser closed'); }catch(e){ log('Error closing browser: '+e); } + log('Script finished'); + } +})(); diff --git a/packages/studio-panel/scripts/playwright_postmessage_test.mjs b/packages/studio-panel/scripts/playwright_postmessage_test.mjs new file mode 100644 index 0000000..4a977db --- /dev/null +++ b/packages/studio-panel/scripts/playwright_postmessage_test.mjs @@ -0,0 +1,152 @@ +import fs from 'fs'; +let playwright; +try { playwright = await import('playwright'); } catch (e) { fs.appendFileSync('/tmp/playwright_debug.log', `Playwright import error: ${e}\n`); process.exit(2); } +const { chromium } = playwright; + +const LOG = '/tmp/playwright_debug.log'; +function log(msg){ + const line = `[${new Date().toISOString()}] ${msg}\n`; + try{ fs.appendFileSync(LOG, line); } catch(e) {} +} + +(async ()=>{ + log('Starting playwright_postmessage_test'); + let browser; + try{ + browser = await chromium.launch({ headless: true, args: ['--no-sandbox','--disable-dev-shm-usage'] }); + log('Chromium launched'); + const context = await browser.newContext(); + const page = await context.newPage(); + page.on('console', m => log('PAGE LOG: ' + m.text())); + let studioConsoleLogs = []; + + // Use environment variables if provided, otherwise default to production domains + const BROADCAST_URL = process.env.BROADCAST_URL || 'https://avanzacast-broadcastpanel.bfzqqk.easypanel.host/post_token_to_studio.html?auto=1'; + const STUDIO_ORIGIN = process.env.STUDIO_ORIGIN || 'https://avanzacast-studio.bfzqqk.easypanel.host'; + + log('Navigating to broadcast URL: ' + BROADCAST_URL); + await page.goto(BROADCAST_URL, { waitUntil: 'networkidle', timeout: 30000 }); + log('Broadcast page loaded'); + + // Wait briefly for auto-run, otherwise click the open button if present + try{ + // if the page has a button with id 'run' or 'openSend', try to click it as fallback + const runBtn = await page.$('#run') || await page.$('#openSend'); + if (runBtn) { + log('Found run/open button on page; clicking to trigger flow'); + await runBtn.click(); + } + } catch(e){ log('No run button click fallback: ' + e); } + + // Wait up to 12s for the redirect to the studio (the broadcast page may open a popup or redirect) + log('Waiting for a new page whose origin matches ' + STUDIO_ORIGIN + ' (timeout 12s)'); + let studioPage = null; + const start = Date.now(); + const timeoutMs = 12000; + while (!studioPage && (Date.now() - start) < timeoutMs){ + const pages = context.pages(); + for (const p of pages){ + try{ + const u = p.url(); + if (u && u.startsWith(STUDIO_ORIGIN)) { studioPage = p; break; } + }catch(e){} + } + if (studioPage) break; + await new Promise(r=>setTimeout(r, 300)); + } + + if (!studioPage){ + log('Studio page not opened automatically; attempting to open studio receiver directly at ' + STUDIO_ORIGIN + '/studio_receiver.html'); + // open the studio receiver in the same context + studioPage = await context.newPage(); + await studioPage.goto(STUDIO_ORIGIN + '/studio_receiver.html', { waitUntil: 'networkidle', timeout: 30000 }).catch(e=>{ log('Goto studio_receiver failed: '+e); }); + } + + if (!studioPage) { + log('ERROR: Could not open studio page'); + } else { + // capture console logs from studio page to detect connected/ACK messages + studioPage.on('console', m => { + try { const txt = m.text(); studioConsoleLogs.push(txt); log('STUDIO PAGE LOG: ' + txt); } catch(e) {} + }); + log('Studio page ready at ' + studioPage.url()); + try{ + // wait for #status element that studio_receiver exposes + // try a slightly longer wait and fallback to scanning page text + try { + await studioPage.waitForSelector('#status', { timeout: 16000 }); + } catch (e) { + log('waitForSelector #status timed out, will fallback to scanning page content'); + } + const statusText = await studioPage.evaluate(() => { + const el = document.getElementById('status'); + if (el) return el.textContent; + return document.body ? document.body.innerText || document.body.textContent : null; + }).catch(()=>null); + log('Studio #status text (initial): ' + statusText); + } catch(e){ log('No #status element or timeout: ' + (e && e.message)); } + + // Now wait for the simulator page log (broadcast) to show ACK or for studio to update + try{ + // Wait up to 10s for ACK to appear in any page logs (simulator) or studio status + const ackStart = Date.now(); + let ackSeen = false; + const ackTimeout = 20000; // increase to 20s + while (!ackSeen && (Date.now() - ackStart) < ackTimeout){ + // check studio console logs first + try{ + for (const cmsg of studioConsoleLogs){ + if (cmsg && (cmsg.toLowerCase().includes('conectado') || cmsg.toLowerCase().includes('connected') || cmsg.toLowerCase().includes('ack'))) { + ackSeen = true; log('ACK/connected found in studio console logs: ' + cmsg); break; + } + } + }catch(e){} + + if (ackSeen) break; + + // check simulator page (first page) for log element + try{ + const simulatorPages = context.pages(); + for (const sp of simulatorPages){ + try{ + const content = await sp.evaluate(()=>{ + const el = document.getElementById('log'); + if (el && el.textContent) return el.textContent; + return document.body ? (document.body.innerText || document.body.textContent) : null; + }); + if (content && (content.includes('ACK') || /connected/i.test(content) || /conectado/i.test(content))) { ackSeen = true; log('ACK/connected found in simulator page content'); break; } + }catch(e){} + } + }catch(e){ } + + // also check studio status + try{ + const s = await studioPage.evaluate(()=>{ + const st = document.getElementById('status'); + if (st && st.textContent) return st.textContent; + return document.body ? (document.body.innerText || document.body.textContent) : null; + }); + if (s && (s.toLowerCase().includes('ack') || s.toLowerCase().includes('connected') || s.toLowerCase().includes('conectado'))) { ackSeen = true; log('ACK/connected found in studio content: '+s); } + }catch(e){} + + if (!ackSeen) await new Promise(r=>setTimeout(r, 300)); + } + + if (!ackSeen) log('Timeout waiting for ACK ('+ (ackTimeout/1000) +'s)'); + } catch(e){ log('Error while waiting for ACK: '+e); } + + // take screenshots + const simShot = '/tmp/sim_postmessage_simulator.png'; + const studioShot = '/tmp/sim_postmessage_studio.png'; + try{ await page.screenshot({ path: simShot, fullPage: true }); log('Saved simulator screenshot: ' + simShot); } catch(e){ log('Failed saving simulator screenshot: ' + e); } + try{ await studioPage.screenshot({ path: studioShot, fullPage: true }); log('Saved studio screenshot: ' + studioShot); } catch(e){ log('Failed saving studio screenshot: ' + e); } + } + + } catch (err) { + log('Unhandled error: ' + (err && err.stack ? err.stack : String(err))); + process.exitCode = 2; + } finally { + try { if (browser) await browser.close(); log('Browser closed'); } catch(e) { log('Error closing browser: ' + e); } + } + log('Test finished'); +})(); diff --git a/packages/studio-panel/scripts/playwright_rec_test.mjs b/packages/studio-panel/scripts/playwright_rec_test.mjs new file mode 100644 index 0000000..906a259 --- /dev/null +++ b/packages/studio-panel/scripts/playwright_rec_test.mjs @@ -0,0 +1,34 @@ +let playwright +try { playwright = await import('playwright'); } catch (e) { console.error('Playwright no está instalado:', e); process.exit(2); } +const { chromium } = playwright; +(async ()=>{ + const browser = await chromium.launch(); + const page = await browser.newPage(); + try{ + console.log('Navegando a simulate.html...'); + await page.goto('http://localhost:3021/simulate.html', { waitUntil: 'networkidle', timeout: 15000 }); + console.log('Página cargada. Buscando botón Start...'); + const btn = await page.waitForSelector('button#recBtn, button:has-text("Start")', { timeout: 5000 }); + if(!btn) throw new Error('Botón de grabación no encontrado'); + console.log('Haciendo click en Start...'); + await btn.click(); + // esperar a que la clase recording esté presente o a que el .record-dot sea visible + await page.waitForSelector('.btn-control.recording', { timeout: 5000 }); + await page.waitForSelector('.record-dot', { timeout: 5000 }); + console.log('Recording visuals present — tomando captura...'); + await page.screenshot({ path: '/tmp/studio_record_test.png', fullPage: true }); + console.log('Screenshot guardada en /tmp/studio_record_test.png'); + // comprobar atributos + const attrs = await page.evaluate(()=>{ + const btn = document.getElementById('recBtn'); + return { + ariaPressed: btn?.getAttribute('aria-pressed'), + hasRecordingClass: btn?.classList.contains('recording'), + recordDotDisplay: window.getComputedStyle(btn?.querySelector('.record-dot') || document.createElement('span')).display + } + }); + console.log('Atributos tras click:', JSON.stringify(attrs)); + }catch(e){ console.error('Error durante la prueba:', e); process.exitCode = 2 } + await browser.close(); +})(); + diff --git a/packages/studio-panel/scripts/pp_screenshot.mjs b/packages/studio-panel/scripts/pp_screenshot.mjs new file mode 100644 index 0000000..f7cc18a --- /dev/null +++ b/packages/studio-panel/scripts/pp_screenshot.mjs @@ -0,0 +1,14 @@ +let playwright +try { playwright = await import('playwright'); } catch (e) { console.error('playwright missing', e); process.exit(2); } +const { chromium } = playwright; +(async ()=>{ + const browser = await chromium.launch(); + const page = await browser.newPage(); + try{ + await page.goto('http://localhost:3021/', { waitUntil: 'networkidle' , timeout: 10000 }); + await page.screenshot({ path: '/tmp/studio_panel_home.png', fullPage: true }); + console.log('screenshot saved to /tmp/studio_panel_home.png'); + }catch(e){ console.error('err', e.toString()); process.exitCode=2 } + await browser.close(); +})(); + diff --git a/packages/studio-panel/src/App.tsx b/packages/studio-panel/src/App.tsx index 55c32b3..e034e47 100644 --- a/packages/studio-panel/src/App.tsx +++ b/packages/studio-panel/src/App.tsx @@ -1,7 +1,9 @@ -import React, { useState } from 'react'; -import { StudioRoom } from './components/StudioRoom/StudioRoom'; +import React, { useState, useEffect, useRef } from 'react'; import { Button } from 'avanza-ui'; import './App.css'; +import StudioPortal from './components/Portal/StudioPortal'; +import { isAllowedOrigin } from './utils/postMessage'; +import { Room } from 'livekit-client'; function App() { const [isConnected, setIsConnected] = useState(false); @@ -12,6 +14,131 @@ function App() { }); const [tempToken, setTempToken] = useState(''); + const autoAttemptRef = useRef(false); + // external LiveKit Room managed by App when token is received + const roomRef = useRef(null); + const messageSourceRef = useRef(null); + // store the last validated origin that sent a token so we can ACK securely + const lastValidatedOrigin = useRef(null); + + // Called when the LiveKit room reports connected + const handleRoomConnected = () => { + setIsConnected(true); + // send connected ACK to the validated origin if available + try { + const payload = { type: 'LIVEKIT_ACK', status: 'connected' }; + const targetOrigin = lastValidatedOrigin.current || '*'; + // Prefer replying directly to the message source window if available + if (messageSourceRef.current && typeof (messageSourceRef.current as any).postMessage === 'function') { + try { (messageSourceRef.current as any).postMessage(payload, targetOrigin); } catch (e) { /* ignore */ } + } + if (window.opener && !window.opener.closed) { + try { window.opener.postMessage(payload, targetOrigin); } catch (e) { /* ignore */ } + } + if (window.parent && window.parent !== window) { + try { window.parent.postMessage(payload, targetOrigin); } catch (e) { /* ignore */ } + } + } catch (e) { /* ignore */ } + }; + + const handleRoomDisconnected = () => { + setIsConnected(false); + try { + const payload = { type: 'LIVEKIT_ACK', status: 'disconnected' }; + const targetOrigin = lastValidatedOrigin.current || '*'; + if (window.opener && !window.opener.closed) { + try { window.opener.postMessage(payload, targetOrigin); } catch (e) { /* ignore */ } + } + if (window.parent && window.parent !== window) { + try { window.parent.postMessage(payload, targetOrigin); } catch (e) { /* ignore */ } + } + } catch (e) {} + // disconnect and clear app-managed room + try { if (roomRef.current) { roomRef.current.disconnect(); roomRef.current = null; } } catch(e){} + }; + + // Cleanup app-managed room on unmount + useEffect(() => { + return () => { + try { if (roomRef.current) { roomRef.current.disconnect(); roomRef.current = null; } } catch(e){} + }; + }, []); + + // Listen for LIVEKIT_TOKEN posted via postMessage (handshake flow) + useEffect(() => { + // central token handler used by both message events and custom events + const handleIncomingToken = async (payload: any, origin?: string | null, source?: any) => { + try { + const originToUse = origin || null; + if (!isAllowedOrigin(originToUse)) { + return; + } + // store validated origin and message source for ACKs + if (originToUse) lastValidatedOrigin.current = originToUse; + if (source) messageSourceRef.current = source; + + if (payload?.url) setCredentials(prev => ({ ...prev, serverUrl: String(payload.url) })); + const receivedToken = String(payload.token || payload?.token); + setTempToken(receivedToken); + + // cleanup previous room if exists + try { if (roomRef.current) { roomRef.current.disconnect(); roomRef.current = null; } } catch(e){} + + const newRoom = new Room(); + roomRef.current = newRoom; + try { + const sUrl = payload?.url || credentials.serverUrl; + await newRoom.connect(sUrl, receivedToken); + setCredentials(prev => ({ ...prev, token: receivedToken })); + handleRoomConnected(); + } catch (err) { + console.error('App-managed room connect failed', err); + } + + if (!autoAttemptRef.current) { + autoAttemptRef.current = true; + setTimeout(() => { if (receivedToken) handleConnectWithToken(receivedToken); }, 60); + } + } catch (err) { console.debug('handleIncomingToken error', err); } + }; + + function onMessage(e: MessageEvent) { + try { + const d = e.data || {}; + if (d?.type === 'LIVEKIT_TOKEN' && d.token) { + // call central handler and pass origin/source + handleIncomingToken(d, e.origin || null, e.source); + } + } catch (err) { console.debug('postMessage in App error', err) } + } + window.addEventListener('message', onMessage); + + // Also listen for the custom event dispatched by main.tsx + function onCustomToken(e: any) { + try { + const detail = e?.detail || (window as any).__AVANZACAST_PENDING_TOKEN || null; + // attempt to recover origin/source from globals set by main.tsx if present + const lastMsg = (window as any).__AVZ_LAST_MSG_SOURCE || null; + const origin = lastMsg?.origin || null; + const source = lastMsg?.source || null; + if (detail && detail.token) handleIncomingToken(detail, origin, source); + } catch(err) { console.debug('custom token handler error', err); } + } + window.addEventListener('avz:livekit:token', onCustomToken as EventListener); + + return () => { + window.removeEventListener('message', onMessage); + window.removeEventListener('avz:livekit:token', onCustomToken as EventListener); + }; + }, []); + + + function handleConnectWithToken(tokenVal: string) { + if (tokenVal && tokenVal.trim()) { + setCredentials(prev => ({ ...prev, token: tokenVal })); + setIsConnected(true); + } + } const handleConnect = () => { if (tempToken.trim()) { @@ -20,6 +147,37 @@ function App() { } }; + // Update a global #status element and notify opener/parent when connected — helps E2E tests detect ACK/state + useEffect(() => { + try { + const setStatus = (txt: string) => { + try { + let el = document.getElementById('status'); + if (!el) { + el = document.createElement('div'); + el.id = 'status'; + el.style.position = 'fixed'; + el.style.right = '12px'; + el.style.top = '12px'; + el.style.padding = '8px 12px'; + el.style.background = 'rgba(16,185,129,0.12)'; + el.style.color = '#10B981'; + el.style.borderRadius = '6px'; + el.style.zIndex = '9999'; + document.body.appendChild(el); + } + el.textContent = txt; + } catch (e) { /* ignore */ } + }; + + if (isConnected) { + setStatus('Conectado'); + } else { + setStatus('Esperando token...'); + } + } catch (e) {} + }, [isConnected]); + if (!isConnected) { return (
console.log('Conectado a la sala')} - onDisconnected={() => { - console.log('Desconectado de la sala'); - setIsConnected(false); - }} + onRoomConnected={handleRoomConnected} + onRoomDisconnected={handleRoomDisconnected} + room={roomRef.current || undefined} /> ); } export default App; - diff --git a/packages/studio-panel/src/__tests__/StudioPortal.test.tsx b/packages/studio-panel/src/__tests__/StudioPortal.test.tsx new file mode 100644 index 0000000..f6c3268 --- /dev/null +++ b/packages/studio-panel/src/__tests__/StudioPortal.test.tsx @@ -0,0 +1,61 @@ +import React from 'react' +import { render, screen, waitFor, fireEvent } from '@testing-library/react' +import { vi, describe, it, expect, beforeEach } from 'vitest' + +// Mock livekit-client Room class +const connectMock = vi.fn(() => Promise.resolve()) +const disconnectMock = vi.fn(() => {}) +const mockRoomConstructor = vi.fn(() => ({ connect: connectMock, disconnect: disconnectMock })) + +vi.mock('livekit-client', () => { + return { Room: mockRoomConstructor } +}) + +import StudioPortal from '../components/Portal/StudioPortal' + +describe('StudioPortal', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('creates a local Room and connects when token is provided and no external room', async () => { + render() + + // wait for the connect to be called + await waitFor(() => { + expect(mockRoomConstructor).toHaveBeenCalled() + expect(connectMock).toHaveBeenCalledWith('wss://example', 'FAKE_TOKEN') + }) + }) + + it('does not create a local Room when external room is provided', async () => { + const fakeRoom = { connect: vi.fn(), disconnect: vi.fn() } + render() + + // local constructor should not be called + await new Promise((r) => setTimeout(r, 50)) + expect(mockRoomConstructor).not.toHaveBeenCalled() + }) + + it('connect/disconnect buttons call connectWithToken and disconnect', async () => { + // render without auto token to test manual connect: pass empty token first + const { rerender } = render() + + // Click connect button -> nothing happens since token empty, ensure no constructor called + const connectBtn = screen.getByText(/Conectar|Conectando...|Conectado/, { exact: false }) + fireEvent.click(connectBtn) + expect(mockRoomConstructor).not.toHaveBeenCalled() + + // Rerender with token to enable connect via button + rerender() + + // Wait for auto connect (effect) or click button to trigger connect + await waitFor(() => expect(mockRoomConstructor).toHaveBeenCalled()) + + // Now test disconnect button triggers disconnect + const disconnectBtn = screen.getByText('Desconectar') + fireEvent.click(disconnectBtn) + await waitFor(() => expect(disconnectMock).toHaveBeenCalled()) + }) +}) + diff --git a/packages/studio-panel/src/__tests__/postMessage.test.ts b/packages/studio-panel/src/__tests__/postMessage.test.ts new file mode 100644 index 0000000..dcfaa19 --- /dev/null +++ b/packages/studio-panel/src/__tests__/postMessage.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect, vi } from 'vitest'; + +// We'll dynamically import the module after stubbing import.meta.env +async function loadWithEnv(env: Record) { + // stub import.meta for the module loader + // vitest allows stubbing globals; set globalThis.import = { meta: { env } } may work + // Save original + const origImportMeta = (globalThis as any).importMeta; + try { + (globalThis as any).importMeta = { env }; + const mod = await import('../utils/postMessage'); + return mod; + } finally { + (globalThis as any).importMeta = origImportMeta; + } +} + +describe('postMessage utils', () => { + it('reads allowed origins from VITE_STUDIO_ALLOWED_ORIGINS and VITE_STUDIO_URL', async () => { + const env = { + VITE_STUDIO_ALLOWED_ORIGINS: 'https://a.example.com, https://b.example.com', + VITE_STUDIO_URL: 'https://studio.example.com' + }; + const mod = await loadWithEnv(env as any); + const allowed = (mod.getAllowedOriginsFromEnv && mod.getAllowedOriginsFromEnv()) || []; + expect(allowed).toEqual(expect.arrayContaining(['https://a.example.com', 'https://b.example.com', 'https://studio.example.com'])); + }); + + it('isAllowedOrigin returns true for allowed origins and false otherwise', async () => { + const env = { VITE_STUDIO_ALLOWED_ORIGINS: 'https://x.com', VITE_STUDIO_URL: 'https://studio.local' }; + const mod = await loadWithEnv(env as any); + expect(mod.isAllowedOrigin('https://x.com')).toBe(true); + expect(mod.isAllowedOrigin('https://studio.local')).toBe(true); + expect(mod.isAllowedOrigin('https://notallowed.com')).toBe(false); + }); +}); + diff --git a/packages/studio-panel/src/__tests__/smoke.test.tsx b/packages/studio-panel/src/__tests__/smoke.test.tsx new file mode 100644 index 0000000..f02ed5e --- /dev/null +++ b/packages/studio-panel/src/__tests__/smoke.test.tsx @@ -0,0 +1,39 @@ +import React from 'react' +import { render, waitFor } from '@testing-library/react' +import { vi, describe, it, expect, beforeEach } from 'vitest' + +// Mock livekit-client Room +const connectMock = vi.fn(() => Promise.resolve()) +const disconnectMock = vi.fn() +const MockRoom = vi.fn(() => ({ connect: connectMock, disconnect: disconnectMock })) +vi.mock('livekit-client', () => ({ Room: MockRoom })) + +import App from '../App' + +describe('smoke test - App postMessage -> Studio', () => { + beforeEach(() => { + vi.clearAllMocks() + // ensure a clean DOM for status element + document.body.innerHTML = '' + }) + + it('accepts LIVEKIT_TOKEN postMessage and shows Conectado status', async () => { + render() + + // simulate sender posting message from same-origin + const payload = { type: 'LIVEKIT_TOKEN', token: 'SMOKE_TOKEN', url: 'wss://example' } + window.dispatchEvent(new MessageEvent('message', { data: payload, origin: window.location.origin, source: window })) + + // wait for status element textContent to become 'Conectado' + await waitFor(() => { + const el = document.getElementById('status') + expect(el).toBeTruthy() + expect(el?.textContent).toMatch(/Conectado/i) + }, { timeout: 3000 }) + + // ensure we constructed a Room and called connect + expect(MockRoom).toHaveBeenCalled() + expect(connectMock).toHaveBeenCalledWith('wss://example', 'SMOKE_TOKEN') + }) +}) + diff --git a/packages/studio-panel/src/components/BottomControls.tsx b/packages/studio-panel/src/components/BottomControls.tsx index f26063c..05ab87a 100644 --- a/packages/studio-panel/src/components/BottomControls.tsx +++ b/packages/studio-panel/src/components/BottomControls.tsx @@ -1,16 +1,178 @@ -import React from 'react' +import React, { useState, useContext } from 'react' import IconCameraOn from './icons/IconCameraOn' import IconMicOff from './icons/IconMicOff' +import { RoomContext } from '@livekit/components-react' +import { Room } from 'livekit-client' +import { ControlButton, ControlGroup, IconButton } from 'avanza-ui' + +interface BottomControlsProps { + onToggleMute?: (muted: boolean) => void; + onToggleCamera?: (cameraOn: boolean) => void; + onToggleRecording?: (recording: boolean) => void; +} + +let idCounter = 0 +function uniqueId(prefix = 'id'){ + idCounter += 1 + return `${prefix}-${idCounter}` +} + +export default function BottomControls({ onToggleMute, onToggleCamera, onToggleRecording }: BottomControlsProps){ + const [muted, setMuted] = useState(false) + const [cameraOn, setCameraOn] = useState(true) + const [recording, setRecording] = useState(false) + + // Try to obtain the LiveKit Room from context when available + const ctxRoom = useContext(RoomContext) as Room | null + + // Listen for go-live events to reflect live status in controls (no recording logic here) + React.useEffect(() => { + function onGoLive(e: any) { + try { + const d = e?.detail || {}; + if (d.action === 'start') setRecording(true); + else if (d.action === 'stop') setRecording(false); + } catch (err) { console.warn('go-live handler error', err) } + } + window.addEventListener('avz:request:go-live', onGoLive as EventListener); + return () => window.removeEventListener('avz:request:go-live', onGoLive as EventListener); + }, []); + + // Pre-generate tooltip ids so aria-describedby can reference them + const muteTipId = React.useMemo(() => uniqueId('tip-mute'), []) + const camTipId = React.useMemo(() => uniqueId('tip-cam'), []) + const recTipId = React.useMemo(() => uniqueId('tip-rec'), []) + + const safeSetMic = async (enabled: boolean) => { + try { + const r = ctxRoom as any + if (!r) return + const lp = r.localParticipant + if (!lp) return + if (typeof lp.setMicrophoneEnabled === 'function') { + await lp.setMicrophoneEnabled(enabled) + return + } + // fallback: enable/disable tracks + if (lp.audioTracks && Array.isArray(lp.audioTracks)) { + for (const tpub of lp.audioTracks) { + try { tpub.track && typeof tpub.track.enable === 'function' && tpub.track.enable(enabled) } catch(e){} + } + } + } catch (e) { + console.warn('safeSetMic failed', e) + } + } + + const safeSetCamera = async (enabled: boolean) => { + try { + const r = ctxRoom as any + if (!r) return + const lp = r.localParticipant + if (!lp) return + if (typeof lp.setCameraEnabled === 'function') { + await lp.setCameraEnabled(enabled) + return + } + if (lp.videoTracks && Array.isArray(lp.videoTracks)) { + for (const tpub of lp.videoTracks) { + try { tpub.track && typeof tpub.track.enable === 'function' && tpub.track.enable(enabled) } catch(e){} + } + } + } catch (e) { + console.warn('safeSetCamera failed', e) + } + } + + const safeToggleRecording = async (start: boolean) => { + try { + const r = ctxRoom as any + if (!r) return + const lp = r.localParticipant + if (!lp) return + // Try to publish a data message as a recording signal (best-effort) + if (typeof lp.publishData === 'function') { + const payload = JSON.stringify({ type: 'RECORDING', action: start ? 'start' : 'stop', ts: Date.now() }) + const enc = new TextEncoder().encode(payload) + try { await lp.publishData(enc, { reliable: true }) } catch(e) { console.warn('publishData failed', e) } + return + } + // If publishData not available, try room.broadcast... (best-effort) + if (typeof r.sendData === 'function') { + try { r.sendData(JSON.stringify({ type: 'RECORDING', action: start ? 'start' : 'stop' })) } catch(e) {} + } + } catch (e) { + console.warn('safeToggleRecording failed', e) + } + } + + const handleToggleMute = async () => { + const next = !muted + setMuted(next) + onToggleMute?.(next) + // Try to control livekit + await safeSetMic(!next ? true : false) // note: muted=true means microphone disabled + } + + const handleToggleCamera = async () => { + const next = !cameraOn + setCameraOn(next) + onToggleCamera?.(next) + await safeSetCamera(next) + } + + const handleToggleRecording = async () => { + const next = !recording + setRecording(next) + onToggleRecording?.(next) + // no-op for recording when focusing on transmission + } -export default function BottomControls(){ return ( -
-
- - - -
+
+ + +
+ } + active={!muted} + title={muted ? 'Activar micrófono' : 'Silenciar'} + onClick={handleToggleMute} + size="sm" + /> + {muted ? 'Activar micrófono' : 'Silenciar'} +
+ +
+ } + active={cameraOn} + title={cameraOn ? 'Apagar cámara' : 'Encender cámara'} + onClick={handleToggleCamera} + size="sm" + /> + {cameraOn ? 'Apagar cámara' : 'Encender cámara'} +
+ +
+ : undefined} + label={recording ? 'Stop' : 'Start'} + active={recording} + danger={true} + title={recording ? 'Detener grabación' : 'Iniciar grabación'} + onClick={handleToggleRecording} + size="md" + /> + {recording ? 'Detener grabación' : 'Iniciar grabación'} +
+ + {recording ? 'Grabación iniciada' : 'Grabación detenida'} + +
) } - diff --git a/packages/studio-panel/src/components/ChatPanel.tsx b/packages/studio-panel/src/components/ChatPanel.tsx index 559bd6e..cbc7386 100644 --- a/packages/studio-panel/src/components/ChatPanel.tsx +++ b/packages/studio-panel/src/components/ChatPanel.tsx @@ -18,7 +18,12 @@ export function ChatPanel() { // Auto-scroll to bottom when messages change const el = listRef.current if (el) { - el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' }) + if (typeof el.scrollTo === 'function') { + el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' }) + } else { + // Fallback for environments like jsdom that don't implement scrollTo + el.scrollTop = el.scrollHeight + } } }, [messages]) @@ -131,7 +136,6 @@ export function ChatPanel() { type="submit" variant="primary" size="sm" - onClick={send} style={{ background: 'linear-gradient(135deg, #4361ee 0%, #3651d4 100%)', border: 'none', diff --git a/packages/studio-panel/src/components/LivekitConnector.tsx b/packages/studio-panel/src/components/LivekitConnector.tsx index 83e5c19..d575949 100644 --- a/packages/studio-panel/src/components/LivekitConnector.tsx +++ b/packages/studio-panel/src/components/LivekitConnector.tsx @@ -2,12 +2,155 @@ import React, { useEffect, useRef, useState } from 'react' import { Button, Input, Badge } from 'avanza-ui' export function LivekitConnector() { - const [url, setUrl] = useState('') - const [token, setToken] = useState('') + // Compatibilidad: priorizamos VITE_LIVEKIT_WS_URL (broadcast-panel), luego VITE_LIVEKIT_URL, luego window global + const defaultUrl = (import.meta.env.VITE_LIVEKIT_WS_URL as string) || (import.meta.env.VITE_LIVEKIT_URL as string) || (window as any).__LIVEKIT_URL__ || '' + // Compatibilidad para token server: VITE_TOKEN_SERVER_URL (broadcast-panel) o VITE_BACKEND_URL + const backendBase = (import.meta.env.VITE_TOKEN_SERVER_URL as string) || (import.meta.env.VITE_BACKEND_URL as string) || 'http://localhost:4000' + const [url, setUrl] = useState(defaultUrl) + const [token, setToken] = useState('') const [status, setStatus] = useState<'idle'|'connecting'|'connected'|'error'>('idle') const roomRef = useRef(null) const [participants, setParticipants] = useState([]) + // Refs to support postMessage ACK back to the opener + const messageSourceRef = useRef(null) + const messageOriginRef = useRef(null) + const lastPayloadRef = useRef(null) + const autoAttemptedRef = useRef(false) + + // Local helper fields for requesting token from backend + const [requestRoom, setRequestRoom] = useState('studio-demo') + const [requestUsername, setRequestUsername] = useState(() => { + try { return (sessionStorage.getItem('username') as string) || 'guest' } catch (e) { return 'guest' } + }) + const [isRequestingToken, setIsRequestingToken] = useState(false) + const [errorMessage, setErrorMessage] = useState(null) + const [isFetchingSessionById, setIsFetchingSessionById] = useState(false) + + useEffect(() => { + // Try to read token from query param ?token=... + try { + const params = new URLSearchParams(window.location.search) + const qtoken = params.get('token') + const qserver = params.get('serverUrl') + const qroom = params.get('room') + const quser = params.get('username') + if (qserver) setUrl(qserver) + if (qroom) setRequestRoom(qroom) + if (quser) setRequestUsername(quser) + if (qtoken) { + setToken(qtoken) + // Auto-connect immediately using defaultUrl (env) or current url state + setTimeout(() => { + try { connectToLivekit(qserver || defaultUrl || undefined, qtoken) } catch (e) { console.debug('[LivekitConnector] auto connect error', e) } + }, 20) + } + } catch (e) { console.debug('[LivekitConnector] query parse error', e) } + + // Detect a sessionId embedded in the path (e.g. /abc1234) and fetch its token from backend + try { + const pathname = window.location.pathname || '/' + const parts = pathname.split('/').filter(Boolean) + if (parts.length === 1) { + const candidate = parts[0] + // session ids generated by backend are alphanumeric short strings (7 chars by default) + if (/^[a-z0-9]{5,20}$/i.test(candidate)) { + // fetch session info from backend + const sessionId = candidate + ;(async () => { + try { + setIsFetchingSessionById(true) + setErrorMessage(null) + const resp = await fetch(`${backendBase.replace(/\/$/, '')}/api/session/${encodeURIComponent(sessionId)}`, { method: 'GET' }) + if (!resp.ok) { + const text = await resp.text() + console.warn('[LivekitConnector] session fetch failed', resp.status, text) + setErrorMessage(`No fue posible obtener la sesión: HTTP ${resp.status}`) + setIsFetchingSessionById(false) + return + } + const data = await resp.json() + if (data?.token) { + if (data.url) setUrl(String(data.url)) + if (data.room) setRequestRoom(String(data.room)) + if (data.username) setRequestUsername(String(data.username)) + setToken(String(data.token)) + // Best-effort: mark session as consumed to reduce replay risk + ;(async () => { + try { + await fetch(`${backendBase.replace(/\/$/, '')}/api/session/${encodeURIComponent(sessionId)}/consume`, { method: 'POST' }) + console.debug('[LivekitConnector] session consume requested') + } catch (consumeErr) { + console.debug('[LivekitConnector] session consume failed', consumeErr) + } + })() + // auto connect + if (!autoAttemptedRef.current) { + autoAttemptedRef.current = true + setTimeout(() => { + try { connectToLivekit(data.url || defaultUrl, data.token) } catch (err) { console.debug('[LivekitConnector] session fetch connect error', err) } + }, 30) + } + } else { + setErrorMessage('Respuesta inválida al solicitar la sesión') + } + } catch (err) { + console.error('[LivekitConnector] Error fetching session by id', err) + setErrorMessage(`Error al obtener la sesión: ${String(err)}`) + } finally { + setIsFetchingSessionById(false) + } + })() + } + } + } catch (e) { console.debug('[LivekitConnector] path session detection error', e) } + + // Listen for messages (broadcast-panel can postMessage the token) + function onMessage(e: MessageEvent) { + if (!e?.data) return + const data = e.data + // Respond to ping from opener / broadcast to indicate app is ready + if (data?.type === 'LIVEKIT_PING') { + try { + // reply to the source with READY so opener can safely send token + if (e.source && typeof (e.source as any).postMessage === 'function') { + try { (e.source as any).postMessage({ type: 'LIVEKIT_READY' }, e.origin || '*') } catch (err) { console.debug('[LivekitConnector] PING reply failed', err) } + } else if (window.opener && typeof (window.opener as any).postMessage === 'function') { + try { (window.opener as any).postMessage({ type: 'LIVEKIT_READY' }, e.origin || '*') } catch (err) { console.debug('[LivekitConnector] PING reply to opener failed', err) } + } + } catch (err) { console.debug('[LivekitConnector] ping handling error', err) } + } + if (data?.type === 'LIVEKIT_TOKEN') { + // Store source/origin and payload so we can ACK back after connect + try { + messageSourceRef.current = e.source || (window.opener || null) + } catch (err) { + messageSourceRef.current = (window.opener || null) + } + messageOriginRef.current = (e.origin as string) || null + lastPayloadRef.current = data + + if (data?.url) setUrl(data.url) + if (data?.token) setToken(data.token) + + // auto connect if both present (debounced) + if (data.token && (data.url || defaultUrl)) { + if (!autoAttemptedRef.current) { + autoAttemptedRef.current = true + setTimeout(() => { + try { connectToLivekit(data.url || defaultUrl, data.token) } catch (err) { console.debug('[LivekitConnector] postMessage connect error', err) } + }, 30) + } else { + console.debug('[LivekitConnector] auto connect already attempted, ignoring duplicate token') + } + } + } + } + + window.addEventListener('message', onMessage) + return () => window.removeEventListener('message', onMessage) + }, []) + useEffect(() => { return () => { if (roomRef.current?.disconnect) { @@ -16,66 +159,148 @@ export function LivekitConnector() { } }, []) - async function connectToLivekit() { + // Try auto-connect if token/url become available and we haven't tried yet + useEffect(() => { + if (!autoAttemptedRef.current && token && (url || defaultUrl)) { + autoAttemptedRef.current = true + setTimeout(() => { + try { connectToLivekit(url || defaultUrl, token) } catch (e) { console.debug('[LivekitConnector] auto connect effect error', e) } + }, 50) + } + }, [token, url]) + + async function requestTokenFromBackend(roomName?: string, username?: string) { + const room = roomName || requestRoom + const user = username || requestUsername + if (!room || !user) return + setIsRequestingToken(true) + setErrorMessage(null) + setStatus('connecting') + + try { + const urlReq = `${backendBase.replace(/\/$/, '')}/api/token?room=${encodeURIComponent(room)}&username=${encodeURIComponent(user)}` + const res = await fetch(urlReq, { method: 'GET' }) + const data = await res.json() + if (!res.ok) { + const msg = (data && data.error) ? String(data.error) : `HTTP ${res.status}` + console.error('Token request error', msg) + setErrorMessage(`Error obteniendo token: ${msg}`) + setStatus('error') + setIsRequestingToken(false) + return + } + + if (data.token) { + if (data.url) setUrl(data.url) + setToken(data.token) + // auto connect + if (!autoAttemptedRef.current) { + autoAttemptedRef.current = true + setTimeout(() => connectToLivekit(data.url || defaultUrl, data.token), 20) + } + setIsRequestingToken(false) + setErrorMessage(null) + return + } + + console.error('Unexpected token response', data) + setErrorMessage('Respuesta inesperada del servidor al solicitar token') + setStatus('error') + setIsRequestingToken(false) + } catch (err) { + console.error('Failed requesting token', err) + setErrorMessage(`Fallo al solicitar token: ${String(err)}`) + setStatus('error') + setIsRequestingToken(false) + } + } + + async function connectToLivekit(overrideUrl?: string, overrideToken?: string) { + const connectUrl = overrideUrl || url + const connectToken = overrideToken || token + if (!connectUrl || !connectToken) { + setStatus('error') + console.warn('Missing LiveKit URL or token') + return + } + setStatus('connecting') setParticipants([]) + try { const mod = await import('livekit-client') const lk: any = (mod as any).default ? (mod as any).default : mod - const connectFn = lk && (lk.connect || lk.createRoom || lk.Room || null) - if (!connectFn) { - setStatus('error') - console.warn('LiveKit client not available (no connect/createRoom)') - return - } - let room: any = null + // Prefer connect(url, token) if available if (typeof lk.connect === 'function') { - room = await lk.connect(url, token) - } else if (typeof lk.createRoom === 'function') { - room = await lk.createRoom() - if (room && typeof room.connect === 'function') { - await room.connect(url, token) - } - } else if (lk.Room) { - room = new lk.Room() - if (room && typeof room.connect === 'function') { - await room.connect(url, token) - } - } + const room = await lk.connect(connectUrl, connectToken) + roomRef.current = room + setStatus('connected') - if (!room) { - setStatus('error') - console.warn('Could not create or connect to LiveKit Room') + // NOTE: Do not send LIVEKIT_ACK here; App will centralize ACK sending to avoid duplicates. + + const updateParticipants = () => { + try { + const parts: any[] = [] + if (room.participants && typeof room.participants.values === 'function') { + for (const p of room.participants.values()) { + parts.push({ sid: p.sid, identity: p.identity, participant: p }) + } + } else if (Array.isArray(room.participants)) { + for (const p of room.participants) parts.push(p) + } + setParticipants(parts) + } catch (e) { + console.warn('updateParticipants error', e) + } + } + + room.on?.('participantConnected', updateParticipants) + room.on?.('participantDisconnected', updateParticipants) + room.on?.('trackPublished', updateParticipants) + updateParticipants() return } - roomRef.current = room - setStatus('connected') + // Fallback: try Room class + if (lk.Room) { + const RoomClass = lk.Room + const room = new RoomClass() + if (room && typeof room.connect === 'function') { + await room.connect(connectUrl, connectToken) + roomRef.current = room + setStatus('connected') - const updateParticipants = () => { - try { - const parts: any[] = [] - if (room.participants && typeof room.participants.values === 'function') { - for (const p of room.participants.values()) { - parts.push({ sid: p.sid, identity: p.identity, participant: p }) - } - } else if (Array.isArray(room.participants)) { - for (const p of room.participants) parts.push(p) + // NOTE: Do not send LIVEKIT_ACK here; App will centralize ACK sending to avoid duplicates. + + const updateParticipants = () => { + try { + const parts: any[] = [] + if (room.participants && typeof room.participants.values === 'function') { + for (const p of room.participants.values()) parts.push({ sid: p.sid, identity: p.identity, participant: p }) + } else if (Array.isArray(room.participants)) { + for (const p of room.participants) parts.push(p) + } + setParticipants(parts) + } catch (e) {} } - setParticipants(parts) - } catch (e) { + room.on?.('participantConnected', updateParticipants) + room.on?.('participantDisconnected', updateParticipants) + room.on?.('trackPublished', updateParticipants) + updateParticipants() + return } } - room.on?.('participantConnected', updateParticipants) - room.on?.('participantDisconnected', updateParticipants) - room.on?.('trackPublished', updateParticipants) - updateParticipants() + setStatus('error') + console.warn('LiveKit client did not expose a known connect API') } catch (err) { setStatus('error') console.error('LiveKit connect error', err) + + // NOTE: Do not send LIVEKIT_ACK/error here; App will centralize ACK sending. + } } @@ -103,6 +328,29 @@ export function LivekitConnector() { style={{ marginBottom: 'var(--au-spacing-2)' }} /> + {isFetchingSessionById && ( +
Obteniendo sesión desde el servidor...
+ )} + +
+ ) => setRequestRoom(e.target.value)} + placeholder="Room name" + size="sm" + style={{ flex: 1 }} + /> + ) => setRequestUsername(e.target.value)} + placeholder="Username" + size="sm" + style={{ width: '180px' }} + /> +
+
- +
+ {errorMessage && ( +
+ {errorMessage} +
+ )} +
Estado: void; + onRoomDisconnected?: () => void; + /** optional external LiveKit Room instance */ + room?: any; +} + +const LAYOUTS = [ + { id: 'layout-1', label: 'Individual' }, + { id: 'layout-2', label: 'Gallery' }, + { id: 'layout-3', label: 'Speaker' }, + { id: 'layout-4', label: 'Wide' }, +]; + +export default function StudioPortal({ serverUrl, token, roomName, onRoomConnected, onRoomDisconnected, room }: StudioPortalProps) { + const [activeLayout, setActiveLayout] = useState(LAYOUTS[0].id); + const [live, setLive] = useState(false); + + // Local room management when App does not provide a room prop + const localRoomRef = useRef(null); + const [isConnecting, setIsConnecting] = useState(false); + const [isConnected, setIsConnected] = useState(false); + const isExternalRoom = Boolean(room); + + // Connect function used by UI or auto when token arrives + const connectWithToken = async (useToken?: string, useServer?: string) => { + const tk = useToken || token; + const sUrl = useServer || serverUrl; + if (!tk || !sUrl) return; + try { + setIsConnecting(true); + // cleanup previous + if (localRoomRef.current) { + try { localRoomRef.current.disconnect(); } catch(e) {} + localRoomRef.current = null; + } + const r = new Room(); + localRoomRef.current = r; + await r.connect(sUrl, tk); + setIsConnected(true); + onRoomConnected && onRoomConnected(); + } catch (err) { + console.error('StudioPortal: failed to connect local room', err); + setIsConnected(false); + } finally { + setIsConnecting(false); + } + }; + + const disconnectLocalRoom = () => { + try { + if (localRoomRef.current) { + localRoomRef.current.disconnect(); + localRoomRef.current = null; + } + } catch (e) { /* ignore */ } + setIsConnected(false); + onRoomDisconnected && onRoomDisconnected(); + }; + + // Auto-connect when token becomes available and there is no external room + useEffect(() => { + if (!isExternalRoom && token && token.trim() && !isConnected && !isConnecting) { + connectWithToken(token, serverUrl); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [token, serverUrl]); + + // Cleanup on unmount + useEffect(() => { + return () => { + try { if (localRoomRef.current) { localRoomRef.current.disconnect(); localRoomRef.current = null; } } catch (e) {} + }; + }, []); + + const handleStartLive = () => { + window.dispatchEvent(new CustomEvent('avz:request:go-live', { detail: { action: 'start' } })); + setLive(true); + }; + const handleStopLive = () => { + window.dispatchEvent(new CustomEvent('avz:request:go-live', { detail: { action: 'stop' } })); + setLive(false); + }; + + const changeLayout = (id: string) => { + setActiveLayout(id); + try { + window.dispatchEvent(new CustomEvent('avz:layout:change', { detail: { layoutId: id } })); + } catch (e) { console.warn('layout dispatch failed', e); } + }; + + // Determine which room to pass into StudioRoom: external first, fallback to local + const effectiveRoom = room || localRoomRef.current || undefined; + + return ( +
+ + +
+
+
+ LiveKit: {serverUrl} +
+
+ {!isExternalRoom && ( + <> + + + + )} + {isExternalRoom && ( +
Usando Room externo
+ )} +
+
+ +
+ +
+ +
+
+ {LAYOUTS.map(l => ( + + ))} +
+ +
+ {!live ? ( + + ) : ( + + )} +
+
+
+ + +
+ ); +} diff --git a/packages/studio-panel/src/components/StudioRoom/StudioRoom.css b/packages/studio-panel/src/components/StudioRoom/StudioRoom.css index 78c7275..abe90ef 100644 --- a/packages/studio-panel/src/components/StudioRoom/StudioRoom.css +++ b/packages/studio-panel/src/components/StudioRoom/StudioRoom.css @@ -191,6 +191,35 @@ padding: var(--studio-space-lg); } +/* Floating bottom controls overrides to mimic StreamYard center placement */ +.fixed.bottom-4.left-4.right-4 { + left: 0 !important; + right: 0 !important; + display: flex; + justify-content: center; + pointer-events: none; /* inner controls manage pointer events */ + z-index: 1200; +} + +.fixed.bottom-4.left-4.right-4 .controls-inner { + pointer-events: auto; +} + +/* ensure the record button stands out */ +.btn-control--danger { + background: linear-gradient(180deg, var(--studio-recording) 0%, #d43a3a 100%); + border: none; +} + +/* smaller screens: place controls inset to the right */ +@media (max-width: 480px) { + .fixed.bottom-4.left-4.right-4 { + left: 12px !important; + right: 12px !important; + justify-content: flex-end; + } +} + /* Responsive */ @media (max-width: 768px) { .studio-room__header { @@ -214,3 +243,62 @@ } } +/* Layout presets applied via .studio-room[data-layout="..."] */ + +/* Layout 1: large single speaker */ +.studio-room[data-layout="layout-1"] .lk-grid-layout { + display: flex !important; + align-items: center; + justify-content: center; +} +.studio-room[data-layout="layout-1"] .lk-participant-tile { + width: 80% !important; + height: 80% !important; + max-width: 1200px; +} + +/* Layout 2: gallery (4-up) */ +.studio-room[data-layout="layout-2"] .lk-grid-layout { + display: flex !important; + flex-wrap: wrap !important; + align-items: stretch !important; + justify-content: flex-start !important; + gap: 12px !important; +} +.studio-room[data-layout="layout-2"] .lk-participant-tile { + flex: 0 0 calc(25% - 12px) !important; + height: calc(50% - 12px) !important; + max-width: none !important; +} + +/* Layout 3: speaker + row of participants */ +.studio-room[data-layout="layout-3"] .lk-grid-layout { + display: grid !important; + grid-template-columns: 1fr 320px !important; + gap: 12px !important; +} +.studio-room[data-layout="layout-3"] .lk-participant-tile:first-of-type { + grid-column: 1 / 2 !important; + width: 100% !important; +} +.studio-room[data-layout="layout-3"] .lk-participant-tile:not(:first-of-type) { + grid-column: 2 / 3 !important; + width: 100% !important; + height: auto !important; +} + +/* Layout 4: wide presenter with sidebar */ +.studio-room[data-layout="layout-4"] .lk-grid-layout { + display: grid !important; + grid-template-columns: 1fr 280px !important; + gap: 12px !important; +} +.studio-room[data-layout="layout-4"] .lk-participant-tile { + width: 100% !important; + height: 100% !important; +} + +/* Fallback small adjustments to ensure responsive behavior */ +.studio-room[data-layout] .lk-participant-tile { + transition: transform 220ms ease, width 220ms ease, height 220ms ease; +} diff --git a/packages/studio-panel/src/components/StudioRoom/StudioRoom.tsx b/packages/studio-panel/src/components/StudioRoom/StudioRoom.tsx index 27dbf9c..c93f2f2 100644 --- a/packages/studio-panel/src/components/StudioRoom/StudioRoom.tsx +++ b/packages/studio-panel/src/components/StudioRoom/StudioRoom.tsx @@ -1,6 +1,5 @@ import React, { useEffect, useState } from 'react'; import { - LiveKitRoom, GridLayout, ParticipantTile, ControlBar, @@ -12,6 +11,7 @@ import { Room, Track } from 'livekit-client'; import '@livekit/components-styles'; import { Button } from 'avanza-ui'; import './StudioRoom.css'; +import BottomControls from '../BottomControls'; export interface StudioRoomProps { /** LiveKit server URL */ @@ -24,6 +24,8 @@ export interface StudioRoomProps { onConnected?: () => void; /** Callback when room is disconnected */ onDisconnected?: () => void; + /** Optional externally-created LiveKit Room instance */ + room?: Room; } export const StudioRoom: React.FC = ({ @@ -32,36 +34,235 @@ export const StudioRoom: React.FC = ({ roomName, onConnected, onDisconnected, + room: externalRoom, }) => { - const [room] = useState( - () => - new Room({ - adaptiveStream: true, - dynacast: true, - }) + // If an external Room is provided, use it; otherwise create an internal Room instance + const [internalRoom] = useState(() => new Room({ adaptiveStream: true, dynacast: true })); + const room = externalRoom || internalRoom; + const isExternalRoom = !!externalRoom; + const [connectError, setConnectError] = useState(null); + const [participantsList, setParticipantsList] = useState>([]); + const connectedRef = React.useRef(false); + const connectingRef = React.useRef(false); + const previewRef = React.useRef(null); + const [lines, setLines] = useState>([]); + + // connectRoom: reusable connect logic for initial attempt and retries + const connectRoom = React.useCallback( + async (attemptToken?: string, attemptServer?: string) => { + if (connectingRef.current) return; + connectingRef.current = true; + setConnectError(null); + try { + const sUrl = attemptServer || serverUrl; + const tk = attemptToken || token; + if (!sUrl || !tk) { + // Avoid throwing inside this catch-all to keep analyzer happy; set error and return + setConnectError('Missing serverUrl or token'); + return; + } + // Only call connect if this component owns the room (internal) + if (!isExternalRoom && (room as Room).connect) { + await (room as Room).connect(sUrl, tk); + } + connectedRef.current = true; + setConnectError(null); + onConnected?.(); + } catch (err: any) { + console.error('StudioRoom connect failed', err); + setConnectError(String(err?.message || err || 'Connection failed')); + } finally { + connectingRef.current = false; + } + }, + [/* room purposely omitted to avoid re-creating callback */, serverUrl, token, onConnected, isExternalRoom] ); useEffect(() => { let mounted = true; - const connect = async () => { - if (mounted) { - await room.connect(serverUrl, token); + // Attempt initial connect once when mounted + (async () => { + if (!mounted) return; + // If we're using an internal room, attempt initial connect + if (!isExternalRoom) await connectRoom(); + else { + // If external room is already connected, notify parent + try { if ((room as any)?.state === 'connected' || (room as any)?.isConnected) { connectedRef.current = true; onConnected?.(); } } catch(e){} + } + })(); + + // If token or serverUrl changes after mount, attempt to connect (useful when token is injected later) + // No polling here: connection will be attempted on mount, and further + // attempts are triggered by an effect that watches `token`/`serverUrl`. + return () => { + mounted = false; + try { (room as any).off && (room as any).off('dataReceived', onDataReceived); } catch(e){} + try { if (onAcceptReceived) { (room as any).off && (room as any).off('dataReceived', onAcceptReceived as any); } } catch(e){} + try { + // Only disconnect if we actually connected + if (!isExternalRoom && connectedRef.current && room.disconnect) { + (room as Room).disconnect(); + } + } catch (e) { /* ignore */ } + onDisconnected?.(); + // poll removed + }; + }, [room, connectRoom, onDisconnected]); + + // Reactively attempt to connect whenever token or serverUrl changes + useEffect(() => { + try { + if (connectedRef.current) return; // already connected + if (!connectingRef.current && (token && token.trim()) && (serverUrl && serverUrl.trim())) { + // attempt connection with the latest props + connectRoom(token, serverUrl); + } + } catch (e) { console.warn('reactive connect attempt failed', e); } + }, [token, serverUrl, connectRoom]); + + // Notify parent when the room actually becomes connected + useEffect(() => { + // Poll connectedRef to know when it's set by connectRoom + const t = setInterval(() => { + if (connectedRef.current) { onConnected?.(); + clearInterval(t); + } + }, 250); + return () => clearInterval(t); + }, [onConnected]); + + // If using external room, notify parent when it becomes connected + useEffect(() => { + if (!isExternalRoom) return; + const checkInterval = setInterval(() => { + try { + if ((room as any)?.state === 'connected' || (room as any)?.isConnected) { + connectedRef.current = true; + onConnected?.(); + clearInterval(checkInterval); + } + } catch(e){} + }, 250); + return () => clearInterval(checkInterval); + }, [isExternalRoom, room, onConnected]); + + // Auto-start camera, mic, and "recording" when connected + useEffect(() => { + if (!connectedRef.current) return; + + const autoStart = async () => { + try { + const lp = room.localParticipant; + if (!lp) return; + + // Auto-enable camera + try { + await lp.setCameraEnabled(true); + console.log('Auto-enabled camera'); + } catch (e) { + console.warn('Failed to auto-enable camera:', e); + } + + // Auto-enable microphone + try { + await lp.setMicrophoneEnabled(true); + console.log('Auto-enabled microphone'); + } catch (e) { + console.warn('Failed to auto-enable microphone:', e); + } + + // NOTE: removed automatic recording/start signal per request (focus on transmission only) + + } catch (e) { + console.warn('Auto-start failed:', e); } }; - connect(); + // Small delay to ensure room is fully ready + const timer = setTimeout(autoStart, 1000); + return () => clearTimeout(timer); + }, [room]); + + useEffect(() => { + // layout change listener: apply data-layout attribute to root element + function onLayoutChange(e: any) { + try { + const layoutId = e?.detail?.layoutId; + const root = document.querySelector('.studio-room'); + if (root && layoutId) { + (root as HTMLElement).setAttribute('data-layout', String(layoutId)); + console.log('Applied layout', layoutId); + } + } catch (err) { console.warn('layout change handler error', err) } + } + window.addEventListener('avz:layout:change', onLayoutChange as EventListener); return () => { - mounted = false; - room.disconnect(); - onDisconnected?.(); + window.removeEventListener('avz:layout:change', onLayoutChange as EventListener); }; - }, [room, serverUrl, token, onConnected, onDisconnected]); + }, []); + + // Recalculate overlay lines between moderator (local) and guests + React.useEffect(() => { + function computeLines(){ + try{ + const container = previewRef.current; + if (!container) return setLines([]); + const rootRect = container.getBoundingClientRect(); + // find moderator tile by identity + const localIdentity = room.localParticipant?.identity; + let moderatorEl: Element | null = null; + if (localIdentity) { + moderatorEl = Array.from(container.querySelectorAll('.lk-participant-name')).find(el => (el.textContent || '').trim() === localIdentity) as Element || null; + } + // fallback: try first participant tile inside container + if (!moderatorEl) moderatorEl = container.querySelector('.lk-participant-tile'); + + if (!moderatorEl) return setLines([]); + const mRect = (moderatorEl as HTMLElement).getBoundingClientRect(); + const mx = mRect.left + mRect.width/2 - rootRect.left; + const my = mRect.top + mRect.height/2 - rootRect.top; + + const newLines: Array<{x1:number,y1:number,x2:number,y2:number, accepted?: boolean}> = []; + // for each participant (excluding local), find tile by name and create line + participantsList.forEach(p => { + if (p.isLocal) return; + const el = Array.from(container.querySelectorAll('.lk-participant-name')).find(el => (el.textContent||'').trim() === p.identity) as Element | undefined; + if (!el) return; + const tRect = (el as HTMLElement).closest('.lk-participant-tile')?.getBoundingClientRect(); + if (!tRect) return; + const tx = tRect.left + tRect.width/2 - rootRect.left; + const ty = tRect.top + tRect.height/2 - rootRect.top; + newLines.push({ x1: mx, y1: my, x2: tx, y2: ty, accepted: !!p.accepted }); + }); + setLines(newLines); + }catch(e){ console.warn('computeLines error', e); setLines([]) } + } + computeLines(); + const ro = new ResizeObserver(()=> computeLines()); + if (previewRef.current) ro.observe(previewRef.current); + window.addEventListener('resize', computeLines); + const interval = setInterval(computeLines, 1200); + return ()=>{ ro.disconnect(); window.removeEventListener('resize', computeLines); clearInterval(interval); }; + }, [participantsList, room]); + + return (
+ {connectError && ( +
+
Error al conectar a LiveKit
+
{connectError}
+
Server: {serverUrl}
+
+ + +
+
+ )}
@@ -82,13 +283,24 @@ export const StudioRoom: React.FC = ({
- +
+ + {/* SVG overlay for connection lines */} + + {lines.map((ln,i)=>( + + ))} + +
+ {/* Our BottomControls will consume RoomContext and control mic/cam/recording */} + +
diff --git a/packages/studio-panel/src/main.tsx b/packages/studio-panel/src/main.tsx index 0a6034e..e9fd6ec 100644 --- a/packages/studio-panel/src/main.tsx +++ b/packages/studio-panel/src/main.tsx @@ -1,14 +1,105 @@ +import './styles/globals.css'; +import './styles.css'; + import React from 'react'; import ReactDOM from 'react-dom/client'; import App from './App'; -import 'avanza-ui/dist/studio-theme.css'; // Importar estilos del tema -const rootElement = document.getElementById('root'); -if (!rootElement) throw new Error('Failed to find the root element'); +(async function bootstrap() { + const skipProblematic = Boolean(import.meta.env.VITE_DEBUG_SKIP_PROB_IMPORTS); -ReactDOM.createRoot(rootElement).render( - - - -); + // Robust fallback: try to ensure studio-theme.css is present by adding a link tag resolved against this module + try { + const themeUrl = new URL('../../avanza-ui/src/styles/studio-theme.css', import.meta.url).toString(); + // only add if not already present + if (!document.querySelector(`link[href="${themeUrl}"]`)) { + const l = document.createElement('link'); + l.rel = 'stylesheet'; + l.href = themeUrl; + l.crossOrigin = 'anonymous'; + document.head.appendChild(l); + } + } catch (e) { + console.warn('Could not add link fallback for studio-theme.css:', (e as any)?.message || String(e)); + } + if (!skipProblematic) { + // Try to import shared styles (may fail if missing dependencies) + // NOTE: Importing broadcast-panel styles brings `@tailwind` directives which force PostCSS + // to load tailwind plugin. We only import it when explicitly requested via env var. + const importBroadcast = Boolean(import.meta.env.VITE_IMPORT_BROADCAST_STYLES); + if (importBroadcast) { + try { + await import('../../broadcast-panel/src/styles.css'); + } catch (e) { + console.warn('Failed to import broadcast-panel styles:', (e as any)?.message || String(e)); + } + } + + try { + await import('@livekit/components-styles'); + } catch (e) { + console.warn('Failed to import @livekit/components-styles:', (e as any)?.message || String(e)); + } + } + + const rootElement = document.getElementById('root'); + if (!rootElement) throw new Error('Failed to find the root element'); + + ReactDOM.createRoot(rootElement).render( + + + + ); +})(); + +// Small postMessage handshake helper so the SPA can interoperate with post_token_to_studio.html +// - respond to LIVEKIT_PING with LIVEKIT_READY +// - accept LIVEKIT_TOKEN and store it on window, then respond with LIVEKIT_ACK +(function setupPostMessageHandshake(){ + try { + // ensure we only add once + if ((window as any).__AVZ_POSTMESSAGE_SETUP) return; + (window as any).__AVZ_POSTMESSAGE_SETUP = true; + + // If token present in query params, expose immediately for app to pick up + try { + const p = new URLSearchParams(window.location.search); + const qtoken = p.get('token'); + const qroom = p.get('room') || ''; + const quser = p.get('username') || ''; + const qurl = p.get('serverUrl') || p.get('serverurl') || ''; + if (qtoken) { + (window as any).__AVANZACAST_PENDING_TOKEN = { token: qtoken, room: qroom, username: quser, url: qurl }; + try { window.dispatchEvent(new CustomEvent('avz:livekit:token', { detail: (window as any).__AVANZACAST_PENDING_TOKEN })); } catch(e){} + } + } catch(e) {} + + window.addEventListener('message', (e: MessageEvent) => { + try { + const d = e.data || {}; + if (d && typeof d === 'object') { + // ping -> ready + if (d.type === 'LIVEKIT_PING') { + try { (e.source as any)?.postMessage?.({ type: 'LIVEKIT_READY' }, e.origin || '*'); } catch (err) {} + return; + } + // token received -> store and ack + if (d.type === 'LIVEKIT_TOKEN') { + try { + // store pending token for the app to pick up + (window as any).__AVANZACAST_PENDING_TOKEN = { token: d.token, room: d.room, username: d.username, url: d.url }; + // also store the source/origin so the App can ACK directly to it + try { (window as any).__AVZ_LAST_MSG_SOURCE = { origin: e.origin || null, source: e.source || null }; } catch (err) { (window as any).__AVZ_LAST_MSG_SOURCE = null; } + } catch(e){} + // Do NOT send a postMessage ACK here. ACKs are centralized in the React App + // to avoid duplicated acknowledgements back to the sender. The App will listen + // for the 'avz:livekit:token' event and send any required ACK. + try { window.dispatchEvent(new CustomEvent('avz:livekit:token', { detail: (window as any).__AVANZACAST_PENDING_TOKEN })); } catch(e){} + return; + } + } + } catch (err) { /* ignore */ } + }, false); + } catch (err) { /* ignore */ } +})(); diff --git a/packages/studio-panel/src/styles.css b/packages/studio-panel/src/styles.css index 8ae417b..d49db86 100644 --- a/packages/studio-panel/src/styles.css +++ b/packages/studio-panel/src/styles.css @@ -1,6 +1,8 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; +@import './styles/avanza-utilities.css'; + +/* Import avanza-ui theme (standalone CSS in packages/avanza-ui) */ +@import '../../avanza-ui/src/styles/globals.css'; +@import '../../avanza-ui/src/styles/studio-theme.css'; /* small design tokens to match AvanzaCast palette */ :root{ @@ -10,4 +12,3 @@ .bg-surface-50{ background-color: var(--surface-50); } .dark .bg-surface-900{ background-color: var(--surface-900); } - diff --git a/packages/studio-panel/src/styles/avanza-utilities.css b/packages/studio-panel/src/styles/avanza-utilities.css new file mode 100644 index 0000000..0a8f9c4 --- /dev/null +++ b/packages/studio-panel/src/styles/avanza-utilities.css @@ -0,0 +1,272 @@ +/* avanza-utilities.css + Minimal utility set that mimics the Tailwind classes used in studio-panel. + This lets studio-panel avoid depending on Tailwind at runtime while keeping a + similar look-and-feel compatible with avanza-ui styles. +*/ + +:root{ + --gap-3: 0.75rem; /* 12px */ + --p-2: 0.5rem; /* 8px */ + --px-4: 1rem; /* 16px */ + --py-3: 0.75rem; /* 12px */ + --rounded-md: 0.5rem; /* 8px - slightly larger */ + --rounded-full: 9999px; + --w-10: 2.5rem; /* 40px */ + --h-10: 2.5rem; + --w-12: 3rem; /* 48px */ + --h-12: 3rem; + --bg-black-70: rgba(0,0,0,0.7); + --bg-white-10: rgba(255,255,255,0.08); + --text-gray-400: #9ca3af; + --bg-red-600: #e11d48; /* slightly brighter */ + --text-white: #ffffff; + + /* streamyard-like tokens */ + --studio-control-size: 44px; + --studio-control-gap: 12px; + --studio-shadow: 0 6px 18px rgba(15,23,42,0.12); +} + +/* layout */ +.flex{display:flex} +.flex-col{flex-direction:column} +.items-center{align-items:center} +.justify-center{justify-content:center} +.gap-3{gap:var(--gap-3)} + +/* spacing */ +.p-2{padding:var(--p-2)} +.px-4{padding-left:var(--px-4);padding-right:var(--px-4)} +.py-3{padding-top:var(--py-3);padding-bottom:var(--py-3)} + +/* sizes */ +.w-10{width:var(--w-10)} +.h-10{height:var(--h-10)} +.w-12{width:var(--w-12)} +.h-12{height:var(--h-12)} +.flex-1{flex:1 1 0} + +/* rounded */ +.rounded-md{border-radius:var(--rounded-md)} +.rounded-full{border-radius:var(--rounded-full)} + +/* text */ +.font-semibold{font-weight:600} +.font-medium{font-weight:500} +.text-xs{font-size:0.75rem} +.text-gray-400{color:var(--text-gray-400)} +.mb-3{margin-bottom:0.75rem} + +/* spacing helpers used by bottom control */ +.fixed{position:fixed} +.bottom-4{bottom:1rem} +.left-4{left:1rem} +.right-4{right:1rem} + +/* background helpers - we must handle classes that contain slashes (e.g. bg-black/70) + using attribute selectors so we don't need to change component markup. +*/ +[class~="bg-black/70"]{background-color:var(--bg-black-70)} +[class~="bg-white/10"]{background-color:var(--bg-white-10)} + +/* surface tokens - these mirror variables used across project */ +.bg-surface-50{background-color:var(--surface-50)} +.dark .bg-surface-900{background-color:var(--surface-900)} + +/* specific colors */ +.bg-red-600{background-color:var(--bg-red-600)} +.text-white{color:var(--text-white)} + +/* utilities for lists and spacing */ +.space-y-3 > * + *{margin-top:var(--gap-3)} + +/* small card/video grid helpers */ +.video-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px} + +/* lower-third and studio specific helpers */ +.lower-third{background:linear-gradient(90deg, rgba(0,0,0,0.65), rgba(0,0,0,0.55));color:white;padding:10px;border-radius:8px;box-shadow:var(--studio-shadow)} +.lower-third-title{font-weight:600} +.lower-third-subtitle{font-size:0.85rem;opacity:0.9} + +/* debug helpers */ +.bg-debug{outline:1px dashed rgba(0,0,0,0.08)} + +/* ensure buttons visually match Tailwind defaults used in components */ +button{font-family:inherit} + +/* control buttons */ +.btn-control{ + width:var(--studio-control-size); + height:var(--studio-control-size); + border-radius:9999px; + display:inline-flex; + align-items:center; + justify-content:center; + background:var(--bg-white-10, rgba(255,255,255,0.08)); + color:var(--text-white); + border:1px solid rgba(255,255,255,0.04); + transition: transform 120ms ease, background 120ms ease, box-shadow 120ms ease, opacity 120ms ease; + cursor:pointer; +} + +.btn-control:hover{ + transform: translateY(-3px); + box-shadow: var(--studio-shadow, 0 6px 18px rgba(15,23,42,0.12)); +} + +.btn-control:active{ + transform: translateY(0); +} + +.btn-control:focus{ + outline: 2px solid rgba(79,70,229,0.24); + outline-offset: 3px; +} + +.btn-control[aria-pressed="true"]{ + opacity:0.95; + transform: scale(0.98); +} + +.btn-control--danger{ + background: var(--studio-recording, #ef4444); + color: var(--text-white); + border-color: rgba(0,0,0,0.12); +} + +.btn-control--danger:hover{ transform: translateY(-3px) scale(1.02); } + +/* recording dot */ +.btn-control--danger .record-dot{ + position:absolute; + top:-6px; + right:-6px; + width:12px; + height:12px; + border-radius:50%; + background: var(--studio-recording, #ef4444); + box-shadow: 0 2px 8px rgba(225,29,72,0.45); + animation: pulse-record 1.1s infinite ease-in-out; +} + +@keyframes pulse-record{ + 0%{ transform: scale(1); opacity:1 } + 50%{ transform: scale(1.5); opacity:0.5 } + 100%{ transform: scale(1); opacity:1 } +} + +/* Tooltip using data-tooltip attr */ +[data-tooltip]{ position: relative; } +[data-tooltip]::after{ + content: attr(data-tooltip); + position: absolute; + bottom: calc(100% + 10px); + left: 50%; + transform: translateX(-50%) translateY(6px); + background: rgba(20,20,20,0.9); + color:white; + padding:6px 8px; + border-radius:6px; + font-size:12px; + white-space:nowrap; + opacity:0; + pointer-events:none; + transition: opacity 120ms ease, transform 120ms ease; + z-index:9999; +} +[data-tooltip]:hover::after, +[data-tooltip]:focus::after{ + opacity:1; + transform: translateX(-50%) translateY(0); +} + +/* Accessible tooltip element (used with aria-describedby). Placed next to .btn-control */ +.control-wrapper{ position: relative; display:inline-flex; align-items:center; } +.tooltip{ + position: absolute; + bottom: calc(100% + 10px); + left: 50%; + transform: translateX(-50%) translateY(6px); + background: rgba(20,20,20,0.95); + color: #fff; + padding:6px 10px; + border-radius:8px; + font-size:12px; + white-space:nowrap; + opacity:0; + pointer-events:none; + transition: opacity 140ms ease, transform 140ms ease; + z-index:9999; + box-shadow: 0 6px 18px rgba(2,6,23,0.35); +} + +/* Show tooltip when the previous button is hovered or focused (keyboard) */ +.control-wrapper:focus-within .tooltip, +.control-wrapper:hover .tooltip{ + opacity:1; + transform: translateX(-50%) translateY(0); + pointer-events:auto; +} + +/* Visually-hidden helper for screen readers */ +.visually-hidden{ + position: absolute !important; + height: 1px; width: 1px; + overflow: hidden; + clip: rect(1px, 1px, 1px, 1px); + white-space: nowrap; /* added line */ +} + +/* Disabled state for controls */ +.btn-control[disabled]{ + opacity:0.45; + cursor:not-allowed; + transform:none; + pointer-events:none; +} + +/* Focus-visible (more explicit keyboard focus) */ +.btn-control:focus-visible{ + outline: 3px solid rgba(79,70,229,0.22); + outline-offset: 4px; +} + +/* Respect user motion preferences */ +@media (prefers-reduced-motion: reduce){ + .btn-control, + .btn-control:hover, + .btn-control:active, + .btn-control:focus{ + transition: none !important; + transform: none !important; + animation: none !important; + } + .tooltip{ transition: none !important } +} + +/* positioning helper for container to allow absolute children */ +.controls-inner{ position: relative; display:flex; align-items:center } + +/* Improve recording visuals: ring behind the button and stronger dot pulse */ +.btn-control{ position: relative; z-index: 1; } + +.btn-control.recording::after{ + content: ""; + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%) scale(0.9); + width: calc(var(--studio-control-size) + 18px); + height: calc(var(--studio-control-size) + 18px); + border-radius: 9999px; + background: rgba(239,68,68,0.12); + z-index: 0; + pointer-events: none; + animation: ring-record 1600ms ease-out infinite; +} + +@keyframes ring-record{ + 0%{ transform: translate(-50%, -50%) scale(0.9); opacity: 0.9 } + 60%{ transform: translate(-50%, -50%) scale(1.4); opacity: 0.14 } + 100%{ transform: translate(-50%, -50%) scale(1.6); opacity: 0 } +} diff --git a/packages/studio-panel/src/utils/postMessage.ts b/packages/studio-panel/src/utils/postMessage.ts new file mode 100644 index 0000000..c10cd24 --- /dev/null +++ b/packages/studio-panel/src/utils/postMessage.ts @@ -0,0 +1,39 @@ +// Utilities for postMessage origin validation and ACK helpers +export function getAllowedOriginsFromEnv(): string[] { + const allowed = new Set(); + try { + const raw = (import.meta.env.VITE_STUDIO_ALLOWED_ORIGINS as string) || ''; + if (raw) { + raw.split(',').map(s => s.trim()).filter(Boolean).forEach(o => allowed.add(o)); + } + } catch (e) { /* ignore */ } + try { + const studioUrl = (import.meta.env.VITE_STUDIO_URL as string) || ''; + if (studioUrl) { + try { + const u = new URL(studioUrl); + allowed.add(u.origin); + } catch (e) { /* ignore */ } + } + } catch (e) { /* ignore */ } + try { allowed.add(window.location.origin); } catch (e) {} + return Array.from(allowed); +} + +export function isAllowedOrigin(origin: string | null | undefined): boolean { + if (!origin) return false; + const list = getAllowedOriginsFromEnv(); + return list.includes(origin); +} + +export function safePostMessage(target: Window | null | undefined, message: any, targetOrigin: string) { + if (!target) return false; + try { + target.postMessage(message, targetOrigin); + return true; + } catch (e) { + // some window proxies can throw when cross-origin; ignore + return false; + } +} + diff --git a/packages/studio-panel/tests/e2e/session_flow.spec.ts b/packages/studio-panel/tests/e2e/session_flow.spec.ts new file mode 100644 index 0000000..98a2f1b --- /dev/null +++ b/packages/studio-panel/tests/e2e/session_flow.spec.ts @@ -0,0 +1,70 @@ +import { test, expect } from '@playwright/test'; + +// Production domains (override with env vars if needed) +const BROADCAST_URL = process.env.BROADCAST_URL || 'https://avanzacast-broadcastpanel.bfzqqk.easypanel.host/post_token_to_studio.html?auto=1'; +const STUDIO_ORIGIN = process.env.STUDIO_ORIGIN || 'https://avanzacast-studio.bfzqqk.easypanel.host'; +const TOKEN_SERVER = process.env.TOKEN_SERVER_URL || 'https://avanzacast-servertokens.bfzqqk.easypanel.host'; + +test('broadcast -> token -> studio flow (production domains)', async ({ browser }) => { + const context = await browser.newContext({ ignoreHTTPSErrors: true }); + const page = await context.newPage(); + + // Navigate to broadcast simulator / page that triggers token creation + await page.goto(BROADCAST_URL, { waitUntil: 'networkidle' }); + + // Try to trigger flow: either the page auto-runs (auto=1), opens a popup, or requires a click + // Wait for a new page with studio origin (popup) for up to 12s + let studioPage = null as (import('@playwright/test').Page | null); + try { + const popupPromise = context.waitForEvent('page', { timeout: 12000 }); + + // If there's a visible button, click it (best-effort) + const openButton = page.locator('text=Open Studio and Send Token, text=Entrar al estudio, text=Open Studio'); + if (await openButton.count() > 0) { + try { await openButton.first().click({ timeout: 3000 }); } catch (_) { /* ignore */ } + } + + // Wait for popup + studioPage = await popupPromise; + } catch (e) { + // popup not opened — maybe the page redirected the same tab + } + + // If the broadcast redirected the current page to studio origin + if (!studioPage && page.url().startsWith(STUDIO_ORIGIN)) { + studioPage = page; + } + + // Fallback: request a session from token server and open the redirectUrl directly + if (!studioPage) { + try { + const resp = await page.request.post(`${TOKEN_SERVER}/api/session`, { + data: { room: 'studio-demo', username: 'playwright-e2e' }, + timeout: 15000, + }); + const json = await resp.json(); + const redirectUrl = json?.redirectUrl; + if (redirectUrl) { + studioPage = await context.newPage(); + await studioPage.goto(redirectUrl, { waitUntil: 'networkidle' }); + } + } catch (err) { + // ignore — we'll assert later + } + } + + expect(studioPage, 'Studio page should be opened (popup or redirect or fallback)') + .not.toBeNull(); + + // Wait for receiver status element and assert token received + const status = studioPage!.locator('#status'); + await expect(status).toBeVisible({ timeout: 10000 }); + const txt = await status.innerText(); + expect(txt).toMatch(/Token recibido|Token recibido \(query\)|Token received/i); + + // Save screenshot for debugging / CI + await studioPage!.screenshot({ path: '/tmp/e2e_studio_received.png', fullPage: true }); + + await context.close(); +}); + diff --git a/packages/studio-panel/vite.config.ts b/packages/studio-panel/vite.config.ts index 34c8862..ae5c12c 100644 --- a/packages/studio-panel/vite.config.ts +++ b/packages/studio-panel/vite.config.ts @@ -2,6 +2,16 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; import path from 'path'; +// Derive HMR host from environment if available (VITE_STUDIO_URL set in broadcast-panel .env) +const studioUrl = process.env.VITE_STUDIO_URL || process.env.STUDIO_URL || 'https://avanzacast-studio.bfzqqk.easypanel.host' +let hmrHost = 'avanzacast-studio.bfzqqk.easypanel.host' +try { + const u = new URL(studioUrl) + hmrHost = u.hostname +} catch (e) { + // ignore, fallback kept +} + export default defineConfig({ plugins: [react()], resolve: { @@ -10,8 +20,17 @@ export default defineConfig({ }, }, server: { - port: 3001, + host: '0.0.0.0', // allow access from network / external proxy + port: 3020, strictPort: false, + hmr: { + protocol: 'wss', + host: hmrHost, + clientPort: 443, + }, + }, + preview: { + host: '0.0.0.0', + port: 3020, }, }); - diff --git a/packages/studio-panel/vitest.config.ts b/packages/studio-panel/vitest.config.ts new file mode 100644 index 0000000..5fdceea --- /dev/null +++ b/packages/studio-panel/vitest.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'vitest/config' +import path from 'path' + +export default defineConfig({ + resolve: { + alias: { + 'avanza-ui': path.resolve(__dirname, '../avanza-ui/src'), + }, + }, + test: { + environment: 'jsdom', + globals: true, + setupFiles: ['./src/setupTests.ts'], + include: ['src/**/*.test.{ts,tsx}', 'src/**/__tests__/**/*.ts', 'src/**/__tests__/**/*.tsx'] + } +}) diff --git a/scripts/check_cors.sh b/scripts/check_cors.sh new file mode 100755 index 0000000..b716b95 --- /dev/null +++ b/scripts/check_cors.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +set -euo pipefail + +OUT_DIR=".deploy_out" +mkdir -p "$OUT_DIR" +TS=$(date +%s) +OUT="${OUT_DIR}/cors_check_${TS}.log" + +echo "Starting CORS check - output -> $OUT" +exec &> >(tee "$OUT") + +BASE_DIR="$(cd "$(dirname "$0")/.." && pwd)" +cd "$BASE_DIR/packages/backend-api" + +# stop previous backend if recorded +if [ -f /tmp/backend_api_pid.txt ]; then + PID=$(cat /tmp/backend_api_pid.txt) || true + if [ -n "$PID" ] && ps -p "$PID" > /dev/null 2>&1; then + echo "Stopping previous backend (pid=$PID)" + kill "$PID" || kill -9 "$PID" || true + sleep 1 + fi + rm -f /tmp/backend_api_pid.txt +fi + +# move old logs +[ -f /tmp/backend_api_run.log ] && mv /tmp/backend_api_run.log /tmp/backend_api_run.log.bak || true + +# source .env.production if exists (do not leak secrets) +if [ -f ./.env.production ]; then + echo "Sourcing ./.env.production" + set -a + # shellcheck disable=SC1090 + . ./.env.production + set +a +else + echo "No ./.env.production found in packages/backend-api — ensure env vars are set if needed" +fi + +# Start backend with ALLOW_ALL_CORS=1 for debugging (background) +echo "Starting backend with ALLOW_ALL_CORS=1 (debug mode)" +nohup env ALLOW_ALL_CORS=1 npx tsx src/index.ts > /tmp/backend_api_run.log 2>&1 & +echo $! > /tmp/backend_api_pid.txt +sleep 2 + +echo "--- /tmp/backend_api_run.log (head) ---" +head -n 200 /tmp/backend_api_run.log || true + +BROADCAST_ORIGIN="https://avanzacast-broadcastpanel.bfzqqk.easypanel.host" +TOKEN_URL="http://localhost:4000/api/token?room=studio-demo&username=simulator" + +echo "--- Curl test against $TOKEN_URL with Origin: $BROADCAST_ORIGIN ---" +curl -i -s -H "Origin: $BROADCAST_ORIGIN" "$TOKEN_URL" | sed -n '1,200p' || true + +echo "\n--- backend log (tail) ---" +tail -n 200 /tmp/backend_api_run.log || true + +# show PID info +if [ -f /tmp/backend_api_pid.txt ]; then + echo "Backend PID: $(cat /tmp/backend_api_pid.txt)" + ps -p $(cat /tmp/backend_api_pid.txt) -o pid,ppid,cmd || true +fi + +# show listening sockets +echo "\n--- Listening sockets for :4000 ---" +ss -ltnp | rg 4000 || true + +cat "$OUT" + +echo "CORS check finished. Log saved at $OUT" + diff --git a/scripts/restart_backend_prod.sh b/scripts/restart_backend_prod.sh new file mode 100755 index 0000000..06a8565 --- /dev/null +++ b/scripts/restart_backend_prod.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +set -euo pipefail + +# restart_backend_prod.sh +# Usage: +# Run this on the production host where the repository is deployed. +# ./restart_backend_prod.sh [mode] +# Modes: +# docker - use docker compose to recreate backend-api (default) +# node - restart node process started with npx tsx +# systemd - restart systemd service named backend-api +# +MODE=${1:-docker} +COMPOSE_FILE=${COMPOSE_FILE:-/home/xesar/Documentos/Nextream/AvanzaCast/docker-compose.prod.yml} +REPO_DIR=${REPO_DIR:-/home/xesar/Documentos/Nextream/AvanzaCast} + +echo "--> Restart backend-api (mode=$MODE)" + +if [ "$MODE" = "docker" ]; then + echo "Using docker-compose file: $COMPOSE_FILE" + if ! command -v docker >/dev/null 2>&1; then + echo "Docker not found on PATH; cannot continue in docker mode" >&2 + exit 2 + fi + cd "$(dirname "$COMPOSE_FILE")" || cd "$REPO_DIR" || true + echo "Recreating backend-api service (no deps, force recreate)" + docker compose -f "$COMPOSE_FILE" up -d --no-deps --force-recreate backend-api + echo "Waiting 3s for startup..." + sleep 3 + echo "--- docker compose ps backend-api ---" + docker compose -f "$COMPOSE_FILE" ps backend-api || true + echo "--- docker ps (filter backend-api) ---" + docker ps --filter name=backend-api || true + echo "--- backend-api logs (last 200 lines) ---" + docker compose -f "$COMPOSE_FILE" logs --tail=200 backend-api || true +elif [ "$MODE" = "node" ]; then + echo "Restarting node process (npx tsx)..." + # This assumes the process was started with a matching pattern + pkill -f 'npx tsx src/index.ts' || true + sleep 1 + cd "$REPO_DIR/packages/backend-api" || true + # Start in background (modify as needed for your env) + nohup npx tsx src/index.ts > /tmp/backend_api_run.log 2>&1 & + echo $! > /tmp/backend_api_pid.txt + sleep 2 + tail -n 200 /tmp/backend_api_run.log || true +elif [ "$MODE" = "systemd" ]; then + echo "Restarting systemd service: backend-api" + sudo systemctl restart backend-api.service + sudo journalctl -u backend-api.service -n 200 --no-pager || true +else + echo "Unknown mode: $MODE" >&2 + exit 3 +fi + +# Basic health checks (local) +echo "\n--- Local health check: http://localhost:4000/health ---" +if command -v curl >/dev/null 2>&1; then + curl -sS http://localhost:4000/health || echo "Local health check failed or endpoint not reachable" +else + echo "curl not available to run health check" +fi + +echo "\n--- CORS quick check (OPTIONS against production token-server) ---" +PROD_HOST=${PROD_HOST:-https://avanzacast-servertokens.bfzqqk.easypanel.host} +if command -v curl >/dev/null 2>&1; then + curl -i -X OPTIONS "$PROD_HOST/api/session" \ + -H 'Origin: https://avanzacast-broadcastpanel.bfzqqk.easypanel.host' \ + -H 'Access-Control-Request-Method: POST' | sed -n '1,200p' +else + echo "curl not available to run CORS check" +fi + +cat <<'EOF' + +Done. If you ran this on the production host, please verify: +- The /health endpoint responds 200 +- The OPTIONS call returns Access-Control-Allow-Origin header for the broadcast domain +- A test POST to /api/session returns JSON with redirectUrl + +To revert a temporary ALLOW_ALL_CORS=1 deployment, restart the service without that env var, and ensure FRONTEND_URLS is set in the production environment. +EOF + diff --git a/scripts/smoke_studio_session.sh b/scripts/smoke_studio_session.sh new file mode 100755 index 0000000..64c5e7b --- /dev/null +++ b/scripts/smoke_studio_session.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Smoke test: crea una sesión en backend-api y abre el studio URL en el navegador (Linux: xdg-open) +# Usage: ./scripts/smoke_studio_session.sh [username] + +set -euo pipefail +ROOM=${1:-smoke-test} +USERNAME=${2:-smoke-user} +BACKEND=${BACKEND_API:-http://localhost:4000} + +echo "Creating session for room=$ROOM username=$USERNAME via ${BACKEND}/api/session" +resp=$(curl -s -X POST -H "Content-Type: application/json" -d "{\"room\": \"${ROOM}\", \"username\": \"${USERNAME}\"}" "${BACKEND}/api/session") +if [ -z "$resp" ]; then + echo "No response from backend" + exit 1 +fi + +echo "Response: $resp" +studioUrl=$(echo "$resp" | python3 -c "import sys, json; print(json.load(sys.stdin).get('studioUrl',''))") +if [ -z "$studioUrl" ]; then + echo "studioUrl not found in response" + exit 1 +fi + +echo "Opening studio URL: $studioUrl" +if command -v xdg-open >/dev/null 2>&1; then + xdg-open "$studioUrl" +elif command -v gnome-open >/dev/null 2>&1; then + gnome-open "$studioUrl" +else + echo "Open this URL in your browser: $studioUrl" +fi + +echo "Smoke test completed (manual verification required: studio should auto-fetch token and connect)." +