diff --git a/web/.gitignore b/web/.gitignore
new file mode 100644
index 0000000..de9ca1d
--- /dev/null
+++ b/web/.gitignore
@@ -0,0 +1,5 @@
+node_modules/
+dist/
+*.local
+.vite/
+*.tsbuildinfo
diff --git a/web/index.html b/web/index.html
new file mode 100644
index 0000000..500d5dd
--- /dev/null
+++ b/web/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ unibus · chat
+
+
+
+
+
+
diff --git a/web/package.json b/web/package.json
new file mode 100644
index 0000000..bc95271
--- /dev/null
+++ b/web/package.json
@@ -0,0 +1,29 @@
+{
+ "name": "unibus-web",
+ "private": true,
+ "version": "0.1.0",
+ "type": "module",
+ "description": "SPA de chat para el bus unibus (rooms cifradas E2E, mensajes en vivo por SSE).",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc -b && vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "@mantine/core": "^9.3.0",
+ "@mantine/hooks": "^9.3.0",
+ "@tabler/icons-react": "^3.36.0",
+ "react": "^19.2.0",
+ "react-dom": "^19.2.0"
+ },
+ "devDependencies": {
+ "@types/react": "^19.2.0",
+ "@types/react-dom": "^19.2.0",
+ "@vitejs/plugin-react": "^4.3.4",
+ "postcss": "^8.4.49",
+ "postcss-preset-mantine": "^1.17.0",
+ "postcss-simple-vars": "^7.0.1",
+ "typescript": "^5.6.3",
+ "vite": "^6.0.3"
+ }
+}
diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml
new file mode 100644
index 0000000..6b89b20
--- /dev/null
+++ b/web/pnpm-lock.yaml
@@ -0,0 +1,1481 @@
+lockfileVersion: '9.0'
+
+settings:
+ autoInstallPeers: true
+ excludeLinksFromLockfile: false
+
+importers:
+
+ .:
+ dependencies:
+ '@mantine/core':
+ specifier: ^9.3.0
+ version: 9.3.0(@mantine/hooks@9.3.0(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
+ '@mantine/hooks':
+ specifier: ^9.3.0
+ version: 9.3.0(react@19.2.7)
+ '@tabler/icons-react':
+ specifier: ^3.36.0
+ version: 3.44.0(react@19.2.7)
+ react:
+ specifier: ^19.2.0
+ version: 19.2.7
+ react-dom:
+ specifier: ^19.2.0
+ version: 19.2.7(react@19.2.7)
+ devDependencies:
+ '@types/react':
+ specifier: ^19.2.0
+ version: 19.2.16
+ '@types/react-dom':
+ specifier: ^19.2.0
+ version: 19.2.3(@types/react@19.2.16)
+ '@vitejs/plugin-react':
+ specifier: ^4.3.4
+ version: 4.7.0(vite@6.4.3(sugarss@5.0.1(postcss@8.5.15)))
+ postcss:
+ specifier: ^8.4.49
+ version: 8.5.15
+ postcss-preset-mantine:
+ specifier: ^1.17.0
+ version: 1.18.0(postcss@8.5.15)
+ postcss-simple-vars:
+ specifier: ^7.0.1
+ version: 7.0.1(postcss@8.5.15)
+ typescript:
+ specifier: ^5.6.3
+ version: 5.9.3
+ vite:
+ specifier: ^6.0.3
+ version: 6.4.3(sugarss@5.0.1(postcss@8.5.15))
+
+packages:
+
+ '@babel/code-frame@7.29.7':
+ resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/compat-data@7.29.7':
+ resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/core@7.29.7':
+ resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/generator@7.29.7':
+ resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-compilation-targets@7.29.7':
+ resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-globals@7.29.7':
+ resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-module-imports@7.29.7':
+ resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-module-transforms@7.29.7':
+ resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0
+
+ '@babel/helper-plugin-utils@7.29.7':
+ resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-string-parser@7.29.7':
+ resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-validator-identifier@7.29.7':
+ resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-validator-option@7.29.7':
+ resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helpers@7.29.7':
+ resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/parser@7.29.7':
+ resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==}
+ engines: {node: '>=6.0.0'}
+ hasBin: true
+
+ '@babel/plugin-transform-react-jsx-self@7.29.7':
+ resolution: {integrity: sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0-0
+
+ '@babel/plugin-transform-react-jsx-source@7.29.7':
+ resolution: {integrity: sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0-0
+
+ '@babel/template@7.29.7':
+ resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/traverse@7.29.7':
+ resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/types@7.29.7':
+ resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==}
+ engines: {node: '>=6.9.0'}
+
+ '@esbuild/aix-ppc64@0.25.12':
+ resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==}
+ engines: {node: '>=18'}
+ cpu: [ppc64]
+ os: [aix]
+
+ '@esbuild/android-arm64@0.25.12':
+ resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [android]
+
+ '@esbuild/android-arm@0.25.12':
+ resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==}
+ engines: {node: '>=18'}
+ cpu: [arm]
+ os: [android]
+
+ '@esbuild/android-x64@0.25.12':
+ resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [android]
+
+ '@esbuild/darwin-arm64@0.25.12':
+ resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@esbuild/darwin-x64@0.25.12':
+ resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@esbuild/freebsd-arm64@0.25.12':
+ resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [freebsd]
+
+ '@esbuild/freebsd-x64@0.25.12':
+ resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@esbuild/linux-arm64@0.25.12':
+ resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@esbuild/linux-arm@0.25.12':
+ resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==}
+ engines: {node: '>=18'}
+ cpu: [arm]
+ os: [linux]
+
+ '@esbuild/linux-ia32@0.25.12':
+ resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==}
+ engines: {node: '>=18'}
+ cpu: [ia32]
+ os: [linux]
+
+ '@esbuild/linux-loong64@0.25.12':
+ resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==}
+ engines: {node: '>=18'}
+ cpu: [loong64]
+ os: [linux]
+
+ '@esbuild/linux-mips64el@0.25.12':
+ resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==}
+ engines: {node: '>=18'}
+ cpu: [mips64el]
+ os: [linux]
+
+ '@esbuild/linux-ppc64@0.25.12':
+ resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==}
+ engines: {node: '>=18'}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@esbuild/linux-riscv64@0.25.12':
+ resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==}
+ engines: {node: '>=18'}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@esbuild/linux-s390x@0.25.12':
+ resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==}
+ engines: {node: '>=18'}
+ cpu: [s390x]
+ os: [linux]
+
+ '@esbuild/linux-x64@0.25.12':
+ resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [linux]
+
+ '@esbuild/netbsd-arm64@0.25.12':
+ resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [netbsd]
+
+ '@esbuild/netbsd-x64@0.25.12':
+ resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [netbsd]
+
+ '@esbuild/openbsd-arm64@0.25.12':
+ resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [openbsd]
+
+ '@esbuild/openbsd-x64@0.25.12':
+ resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [openbsd]
+
+ '@esbuild/openharmony-arm64@0.25.12':
+ resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [openharmony]
+
+ '@esbuild/sunos-x64@0.25.12':
+ resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [sunos]
+
+ '@esbuild/win32-arm64@0.25.12':
+ resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@esbuild/win32-ia32@0.25.12':
+ resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==}
+ engines: {node: '>=18'}
+ cpu: [ia32]
+ os: [win32]
+
+ '@esbuild/win32-x64@0.25.12':
+ resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [win32]
+
+ '@floating-ui/core@1.7.5':
+ resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==}
+
+ '@floating-ui/dom@1.7.6':
+ resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==}
+
+ '@floating-ui/react-dom@2.1.8':
+ resolution: {integrity: sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==}
+ peerDependencies:
+ react: '>=16.8.0'
+ react-dom: '>=16.8.0'
+
+ '@floating-ui/react@0.27.19':
+ resolution: {integrity: sha512-31B8h5mm8YxotlE7/AU/PhNAl8eWxAmjL/v2QOxroDNkTFLk3Uu82u63N3b6TXa4EGJeeZLVcd/9AlNlVqzeog==}
+ peerDependencies:
+ react: '>=17.0.0'
+ react-dom: '>=17.0.0'
+
+ '@floating-ui/utils@0.2.11':
+ resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==}
+
+ '@jridgewell/gen-mapping@0.3.13':
+ resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
+
+ '@jridgewell/remapping@2.3.5':
+ resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==}
+
+ '@jridgewell/resolve-uri@3.1.2':
+ resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
+ engines: {node: '>=6.0.0'}
+
+ '@jridgewell/sourcemap-codec@1.5.5':
+ resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
+
+ '@jridgewell/trace-mapping@0.3.31':
+ resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
+
+ '@mantine/core@9.3.0':
+ resolution: {integrity: sha512-mHVCm61YVW9ipy9eHiKMqsRUm3TkOErbdw7zHs0HRw5g403nf7tSTqNGvaYE+aX1Py874qMkrUzeQfj4bjiiBA==}
+ peerDependencies:
+ '@mantine/hooks': 9.3.0
+ react: ^19.2.0
+ react-dom: ^19.2.0
+
+ '@mantine/hooks@9.3.0':
+ resolution: {integrity: sha512-QoSr9WI4WsKWrM3qFYYizHUn3+n+CVcFMYe4sdlnmFPStvs6BacPODKJSbFlYl73Z20t82JIy0eKqt4noHQI2g==}
+ peerDependencies:
+ react: ^19.2.0
+
+ '@rolldown/pluginutils@1.0.0-beta.27':
+ resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==}
+
+ '@rollup/rollup-android-arm-eabi@4.61.1':
+ resolution: {integrity: sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA==}
+ cpu: [arm]
+ os: [android]
+
+ '@rollup/rollup-android-arm64@4.61.1':
+ resolution: {integrity: sha512-Jx2g7iSjw4AOT0HDPHM9RV3GNjRXwybWtSFZiZAYUTjUwjVrYIwq3kBf+LnhqJlzXFAqTAh2F7IGI+O568exPw==}
+ cpu: [arm64]
+ os: [android]
+
+ '@rollup/rollup-darwin-arm64@4.61.1':
+ resolution: {integrity: sha512-0F1L/Z3Eqv8mT2n3dCpeO8GcTvHvVqkP5/t6DMsn0KzhYVcg+s7Ncl5DS8qjKYEeio6Az0Gt6nyBORay5qIlCA==}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@rollup/rollup-darwin-x64@4.61.1':
+ resolution: {integrity: sha512-qLttcH871ujY4YcVfUSShhOw+CsoTatYz8gRbHO7Bb92QH059/P0y5do1KMs41fY0BpD2x4AJH/gID0zFiqVKQ==}
+ cpu: [x64]
+ os: [darwin]
+
+ '@rollup/rollup-freebsd-arm64@4.61.1':
+ resolution: {integrity: sha512-fUI4RapGE0Oh3mb8mgfvC1O2nU1RpDZUKnDQm3xB1Ipg7C2wTs5Kstz7G2uWK99a8S2yTMq8/P4uycwNa0nJyw==}
+ cpu: [arm64]
+ os: [freebsd]
+
+ '@rollup/rollup-freebsd-x64@4.61.1':
+ resolution: {integrity: sha512-H5YrdvJaDtI/U9/emrD4b++xkvp3y/JvOe4rizHbxvkyMfRS/CiRYdji+Pl8D0brEaNFWUh1drQxgAGIl6Xudw==}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@rollup/rollup-linux-arm-gnueabihf@4.61.1':
+ resolution: {integrity: sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA==}
+ cpu: [arm]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-arm-musleabihf@4.61.1':
+ resolution: {integrity: sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ==}
+ cpu: [arm]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-linux-arm64-gnu@4.61.1':
+ resolution: {integrity: sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg==}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-arm64-musl@4.61.1':
+ resolution: {integrity: sha512-unMS3H73DpaoPyyEVPjGKleM/s0mkmsauTENpw4INQY8y4+IuLNjkueQ5QCtC0D3N38Y38yhAU8OoZ20S2Tm6w==}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-linux-loong64-gnu@4.61.1':
+ resolution: {integrity: sha512-zNZzGRnAhwjFEYmvphJRV5XaQGjs62cCmeYYHUT//NbvEnHauw+I85nGG+SiVg5ld4GX8D1IbKIX+ozITQnhMQ==}
+ cpu: [loong64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-loong64-musl@4.61.1':
+ resolution: {integrity: sha512-LdpWGL8X209B2SIvWjqlc8VZgM6PKfontSerGepuldQmHYrAOtnMCXeJkxXGbC+PPZVOuu5czJo7fNV6aeW8rQ==}
+ cpu: [loong64]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-linux-ppc64-gnu@4.61.1':
+ resolution: {integrity: sha512-EC5kTtNaNGOmbMGqar8dvJy6y/hg99GAwjfBz++pxZhQATXGcRjd6c5en5wcbru0vkRmiMGsQKdMJOOf6sza4g==}
+ cpu: [ppc64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-ppc64-musl@4.61.1':
+ resolution: {integrity: sha512-8hiwp6D4acEcNK78I4rP0/XtS1sknWIAMJBPdR4l6zUtyTm5KiTDr5bXmWt4foY7nAN7AThDHgkLIEZOWKbzWw==}
+ cpu: [ppc64]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-linux-riscv64-gnu@4.61.1':
+ resolution: {integrity: sha512-10dh/h/BqA7DuMPWSxkR8uks18FRwnwOEqr5zOTEl+NOwP/OMzKX8OFR/Of9xxDA7D5qef1Nzar5WDD2kCCr1g==}
+ cpu: [riscv64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-riscv64-musl@4.61.1':
+ resolution: {integrity: sha512-YKJ5lg35DP17gcAOggnihe+APw9HLyj1Xn7gsmGumBJAUDa6NGXNixJzmkWLhcK9TOuuyQjdamzvJefkO7qHZQ==}
+ cpu: [riscv64]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-linux-s390x-gnu@4.61.1':
+ resolution: {integrity: sha512-Mlil5G2Jj6a7B3LWGctg+XPL9vdXYuzCtNXfxOQ0nPjc2m6ueUktocPGH9bnAM0bNRKb/bAWTujUU7IJQdQA+g==}
+ cpu: [s390x]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-x64-gnu@4.61.1':
+ resolution: {integrity: sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q==}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-x64-musl@4.61.1':
+ resolution: {integrity: sha512-qy5pBvZbqNFheBz61R1rzsezjm0J7O2oNGoWtGoY89SZYLUfxAJTBAqDChqAIdB4rCiIbi9nF7yZ83GnNiLwSw==}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-openbsd-x64@4.61.1':
+ resolution: {integrity: sha512-E83TXjI4zm0+5f2qO+UOudaCYIhYwpJ5jq6YCZNIZ+6CbfhKrkAGezeiASBL9ElxAxFsRS9ZhESv8mfnj6TKeg==}
+ cpu: [x64]
+ os: [openbsd]
+
+ '@rollup/rollup-openharmony-arm64@4.61.1':
+ resolution: {integrity: sha512-fbWnKqVkjrJN38vNe3ahkbk6iejS/3b0Nt7EEtPpE6RBacZcGXNKbzfHN3GUUlXOPghUg0j6XUGrtjX9z1sIvA==}
+ cpu: [arm64]
+ os: [openharmony]
+
+ '@rollup/rollup-win32-arm64-msvc@4.61.1':
+ resolution: {integrity: sha512-ArMl38iVAbk0New1ogihQNY6iphLi4ZaRsa037gUzv5yeKPY8TD3Dmy4x2RNC1VztU/uqm+G+/RwFrSka3Oy2g==}
+ cpu: [arm64]
+ os: [win32]
+
+ '@rollup/rollup-win32-ia32-msvc@4.61.1':
+ resolution: {integrity: sha512-0mYtjHS9ucAbcATycCNK9IGBk/cCe/ma7EmSLGZdsxnOA8cjRIyU04wDpVAD9NiOfLUR9KTxdiO53uOkherqjQ==}
+ cpu: [ia32]
+ os: [win32]
+
+ '@rollup/rollup-win32-x64-gnu@4.61.1':
+ resolution: {integrity: sha512-gK1iCEPfpoSG9wfBihXxvBMi8ZfcWffYkEsC/Eih+iFENTaewvNcrEQ69lIOWYO5pePHKLHHO7nq5AILGO/HQQ==}
+ cpu: [x64]
+ os: [win32]
+
+ '@rollup/rollup-win32-x64-msvc@4.61.1':
+ resolution: {integrity: sha512-X+zaP2x+j4RXGfbp/seSoRHWnPxzApilDszisZxbYH5C/jTxFhCtDNdPGZb9lJyYPs24wGxruPF7Y+sIXt9Gzw==}
+ cpu: [x64]
+ os: [win32]
+
+ '@tabler/icons-react@3.44.0':
+ resolution: {integrity: sha512-8+rvzBbVm/1Z3sG3x7GUNAaxIKxwgz8xaMhRs23nrCnMTKRFAhEC+82zAIFeAA0seXdrAGX5HFCkaLpGK2rVHg==}
+ peerDependencies:
+ react: '>= 16'
+
+ '@tabler/icons@3.44.0':
+ resolution: {integrity: sha512-Wn0AOZG9sg0L+bjfMqq4eNhC6pQjIrk94LvvWYNYkY8KH8wC3YILRzQlrnVJc4FUeMxH/AK97QsYCX35H3LndA==}
+
+ '@types/babel__core@7.20.5':
+ resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
+
+ '@types/babel__generator@7.27.0':
+ resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==}
+
+ '@types/babel__template@7.4.4':
+ resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==}
+
+ '@types/babel__traverse@7.28.0':
+ resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
+
+ '@types/estree@1.0.9':
+ resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
+
+ '@types/react-dom@19.2.3':
+ resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
+ peerDependencies:
+ '@types/react': ^19.2.0
+
+ '@types/react@19.2.16':
+ resolution: {integrity: sha512-esJiCAnl0kfpNdE69f3So4WJUXy95dLZydX0KwK46riIHDzHM7O9Vtf9xCHW0PXIqvgqNrswl522kA/5yx+F4w==}
+
+ '@vitejs/plugin-react@4.7.0':
+ resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==}
+ engines: {node: ^14.18.0 || >=16.0.0}
+ peerDependencies:
+ vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
+
+ baseline-browser-mapping@2.10.33:
+ resolution: {integrity: sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw==}
+ engines: {node: '>=6.0.0'}
+ hasBin: true
+
+ browserslist@4.28.2:
+ resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==}
+ engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
+ hasBin: true
+
+ camelcase-css@2.0.1:
+ resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==}
+ engines: {node: '>= 6'}
+
+ caniuse-lite@1.0.30001793:
+ resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==}
+
+ clsx@2.1.1:
+ resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
+ engines: {node: '>=6'}
+
+ convert-source-map@2.0.0:
+ resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
+
+ cssesc@3.0.0:
+ resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
+ engines: {node: '>=4'}
+ hasBin: true
+
+ csstype@3.2.3:
+ resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
+
+ debug@4.4.3:
+ resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
+ engines: {node: '>=6.0'}
+ peerDependencies:
+ supports-color: '*'
+ peerDependenciesMeta:
+ supports-color:
+ optional: true
+
+ detect-node-es@1.1.0:
+ resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==}
+
+ electron-to-chromium@1.5.368:
+ resolution: {integrity: sha512-7RckJJK4uESJF9PxvfMWd3TGqIiieUTG4HxnKaKuIpGbcr+r2ZEB3g2gAhCP3Fqm42vJSzLfgab9eva/C4/XVw==}
+
+ esbuild@0.25.12:
+ resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==}
+ engines: {node: '>=18'}
+ hasBin: true
+
+ escalade@3.2.0:
+ resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
+ engines: {node: '>=6'}
+
+ fdir@6.5.0:
+ resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
+ engines: {node: '>=12.0.0'}
+ peerDependencies:
+ picomatch: ^3 || ^4
+ peerDependenciesMeta:
+ picomatch:
+ optional: true
+
+ fsevents@2.3.3:
+ resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
+ engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+ os: [darwin]
+
+ gensync@1.0.0-beta.2:
+ resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
+ engines: {node: '>=6.9.0'}
+
+ get-nonce@1.0.1:
+ resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==}
+ engines: {node: '>=6'}
+
+ js-tokens@4.0.0:
+ resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
+
+ jsesc@3.1.0:
+ resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
+ engines: {node: '>=6'}
+ hasBin: true
+
+ json5@2.2.3:
+ resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==}
+ engines: {node: '>=6'}
+ hasBin: true
+
+ lru-cache@5.1.1:
+ resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
+
+ ms@2.1.3:
+ resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
+
+ nanoid@3.3.12:
+ resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==}
+ engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
+ hasBin: true
+
+ node-releases@2.0.47:
+ resolution: {integrity: sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==}
+ engines: {node: '>=18'}
+
+ picocolors@1.1.1:
+ resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
+
+ picomatch@4.0.4:
+ resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
+ engines: {node: '>=12'}
+
+ postcss-js@4.1.0:
+ resolution: {integrity: sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==}
+ engines: {node: ^12 || ^14 || >= 16}
+ peerDependencies:
+ postcss: ^8.4.21
+
+ postcss-mixins@12.1.2:
+ resolution: {integrity: sha512-90pSxmZVfbX9e5xCv7tI5RV1mnjdf16y89CJKbf/hD7GyOz1FCxcYMl8ZYA8Hc56dbApTKKmU9HfvgfWdCxlwg==}
+ engines: {node: ^20.0 || ^22.0 || >=24.0}
+ peerDependencies:
+ postcss: ^8.2.14
+
+ postcss-nested@7.0.2:
+ resolution: {integrity: sha512-5osppouFc0VR9/VYzYxO03VaDa3e8F23Kfd6/9qcZTUI8P58GIYlArOET2Wq0ywSl2o2PjELhYOFI4W7l5QHKw==}
+ engines: {node: '>=18.0'}
+ peerDependencies:
+ postcss: ^8.2.14
+
+ postcss-preset-mantine@1.18.0:
+ resolution: {integrity: sha512-sP6/s1oC7cOtBdl4mw/IRKmKvYTuzpRrH/vT6v9enMU/EQEQ31eQnHcWtFghOXLH87AAthjL/Q75rLmin1oZoA==}
+ peerDependencies:
+ postcss: '>=8.0.0'
+
+ postcss-selector-parser@7.1.1:
+ resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==}
+ engines: {node: '>=4'}
+
+ postcss-simple-vars@7.0.1:
+ resolution: {integrity: sha512-5GLLXaS8qmzHMOjVxqkk1TZPf1jMqesiI7qLhnlyERalG0sMbHIbJqrcnrpmZdKCLglHnRHoEBB61RtGTsj++A==}
+ engines: {node: '>=14.0'}
+ peerDependencies:
+ postcss: ^8.2.1
+
+ postcss@8.5.15:
+ resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==}
+ engines: {node: ^10 || ^12 || >=14}
+
+ react-dom@19.2.7:
+ resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==}
+ peerDependencies:
+ react: ^19.2.7
+
+ react-number-format@5.4.5:
+ resolution: {integrity: sha512-y8O2yHHj3w0aE9XO8d2BCcUOOdQTRSVq+WIuMlLVucAm5XNjJAy+BoOJiuQMldVYVOKTMyvVNfnbl2Oqp+YxGw==}
+ peerDependencies:
+ react: ^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ react-dom: ^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+
+ react-refresh@0.17.0:
+ resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
+ engines: {node: '>=0.10.0'}
+
+ react-remove-scroll-bar@2.3.8:
+ resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==}
+ engines: {node: '>=10'}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ react-remove-scroll@2.7.2:
+ resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==}
+ engines: {node: '>=10'}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ react-style-singleton@2.2.3:
+ resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==}
+ engines: {node: '>=10'}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ react@19.2.7:
+ resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==}
+ engines: {node: '>=0.10.0'}
+
+ rollup@4.61.1:
+ resolution: {integrity: sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA==}
+ engines: {node: '>=18.0.0', npm: '>=8.0.0'}
+ hasBin: true
+
+ scheduler@0.27.0:
+ resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
+
+ semver@6.3.1:
+ resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
+ hasBin: true
+
+ source-map-js@1.2.1:
+ resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
+ engines: {node: '>=0.10.0'}
+
+ sugarss@5.0.1:
+ resolution: {integrity: sha512-ctS5RYCBVvPoZAnzIaX5QSShK8ZiZxD5HUqSxlusvEMC+QZQIPCPOIJg6aceFX+K2rf4+SH89eu++h1Zmsr2nw==}
+ engines: {node: '>=18.0'}
+ peerDependencies:
+ postcss: ^8.3.3
+
+ tabbable@6.4.0:
+ resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==}
+
+ tagged-tag@1.0.0:
+ resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==}
+ engines: {node: '>=20'}
+
+ tinyglobby@0.2.17:
+ resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
+ engines: {node: '>=12.0.0'}
+
+ tslib@2.8.1:
+ resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
+
+ type-fest@5.7.0:
+ resolution: {integrity: sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg==}
+ engines: {node: '>=20'}
+
+ typescript@5.9.3:
+ resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
+ engines: {node: '>=14.17'}
+ hasBin: true
+
+ update-browserslist-db@1.2.3:
+ resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
+ hasBin: true
+ peerDependencies:
+ browserslist: '>= 4.21.0'
+
+ use-callback-ref@1.3.3:
+ resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==}
+ engines: {node: '>=10'}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ use-sidecar@1.1.3:
+ resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==}
+ engines: {node: '>=10'}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ util-deprecate@1.0.2:
+ resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
+
+ vite@6.4.3:
+ resolution: {integrity: sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==}
+ engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
+ hasBin: true
+ peerDependencies:
+ '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0
+ jiti: '>=1.21.0'
+ less: '*'
+ lightningcss: ^1.21.0
+ sass: '*'
+ sass-embedded: '*'
+ stylus: '*'
+ sugarss: '*'
+ terser: ^5.16.0
+ tsx: ^4.8.1
+ yaml: ^2.4.2
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+ jiti:
+ optional: true
+ less:
+ optional: true
+ lightningcss:
+ optional: true
+ sass:
+ optional: true
+ sass-embedded:
+ optional: true
+ stylus:
+ optional: true
+ sugarss:
+ optional: true
+ terser:
+ optional: true
+ tsx:
+ optional: true
+ yaml:
+ optional: true
+
+ yallist@3.1.1:
+ resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
+
+snapshots:
+
+ '@babel/code-frame@7.29.7':
+ dependencies:
+ '@babel/helper-validator-identifier': 7.29.7
+ js-tokens: 4.0.0
+ picocolors: 1.1.1
+
+ '@babel/compat-data@7.29.7': {}
+
+ '@babel/core@7.29.7':
+ dependencies:
+ '@babel/code-frame': 7.29.7
+ '@babel/generator': 7.29.7
+ '@babel/helper-compilation-targets': 7.29.7
+ '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7)
+ '@babel/helpers': 7.29.7
+ '@babel/parser': 7.29.7
+ '@babel/template': 7.29.7
+ '@babel/traverse': 7.29.7
+ '@babel/types': 7.29.7
+ '@jridgewell/remapping': 2.3.5
+ convert-source-map: 2.0.0
+ debug: 4.4.3
+ gensync: 1.0.0-beta.2
+ json5: 2.2.3
+ semver: 6.3.1
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/generator@7.29.7':
+ dependencies:
+ '@babel/parser': 7.29.7
+ '@babel/types': 7.29.7
+ '@jridgewell/gen-mapping': 0.3.13
+ '@jridgewell/trace-mapping': 0.3.31
+ jsesc: 3.1.0
+
+ '@babel/helper-compilation-targets@7.29.7':
+ dependencies:
+ '@babel/compat-data': 7.29.7
+ '@babel/helper-validator-option': 7.29.7
+ browserslist: 4.28.2
+ lru-cache: 5.1.1
+ semver: 6.3.1
+
+ '@babel/helper-globals@7.29.7': {}
+
+ '@babel/helper-module-imports@7.29.7':
+ dependencies:
+ '@babel/traverse': 7.29.7
+ '@babel/types': 7.29.7
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)':
+ dependencies:
+ '@babel/core': 7.29.7
+ '@babel/helper-module-imports': 7.29.7
+ '@babel/helper-validator-identifier': 7.29.7
+ '@babel/traverse': 7.29.7
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/helper-plugin-utils@7.29.7': {}
+
+ '@babel/helper-string-parser@7.29.7': {}
+
+ '@babel/helper-validator-identifier@7.29.7': {}
+
+ '@babel/helper-validator-option@7.29.7': {}
+
+ '@babel/helpers@7.29.7':
+ dependencies:
+ '@babel/template': 7.29.7
+ '@babel/types': 7.29.7
+
+ '@babel/parser@7.29.7':
+ dependencies:
+ '@babel/types': 7.29.7
+
+ '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7)':
+ dependencies:
+ '@babel/core': 7.29.7
+ '@babel/helper-plugin-utils': 7.29.7
+
+ '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7)':
+ dependencies:
+ '@babel/core': 7.29.7
+ '@babel/helper-plugin-utils': 7.29.7
+
+ '@babel/template@7.29.7':
+ dependencies:
+ '@babel/code-frame': 7.29.7
+ '@babel/parser': 7.29.7
+ '@babel/types': 7.29.7
+
+ '@babel/traverse@7.29.7':
+ dependencies:
+ '@babel/code-frame': 7.29.7
+ '@babel/generator': 7.29.7
+ '@babel/helper-globals': 7.29.7
+ '@babel/parser': 7.29.7
+ '@babel/template': 7.29.7
+ '@babel/types': 7.29.7
+ debug: 4.4.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/types@7.29.7':
+ dependencies:
+ '@babel/helper-string-parser': 7.29.7
+ '@babel/helper-validator-identifier': 7.29.7
+
+ '@esbuild/aix-ppc64@0.25.12':
+ optional: true
+
+ '@esbuild/android-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/android-arm@0.25.12':
+ optional: true
+
+ '@esbuild/android-x64@0.25.12':
+ optional: true
+
+ '@esbuild/darwin-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/darwin-x64@0.25.12':
+ optional: true
+
+ '@esbuild/freebsd-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/freebsd-x64@0.25.12':
+ optional: true
+
+ '@esbuild/linux-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/linux-arm@0.25.12':
+ optional: true
+
+ '@esbuild/linux-ia32@0.25.12':
+ optional: true
+
+ '@esbuild/linux-loong64@0.25.12':
+ optional: true
+
+ '@esbuild/linux-mips64el@0.25.12':
+ optional: true
+
+ '@esbuild/linux-ppc64@0.25.12':
+ optional: true
+
+ '@esbuild/linux-riscv64@0.25.12':
+ optional: true
+
+ '@esbuild/linux-s390x@0.25.12':
+ optional: true
+
+ '@esbuild/linux-x64@0.25.12':
+ optional: true
+
+ '@esbuild/netbsd-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/netbsd-x64@0.25.12':
+ optional: true
+
+ '@esbuild/openbsd-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/openbsd-x64@0.25.12':
+ optional: true
+
+ '@esbuild/openharmony-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/sunos-x64@0.25.12':
+ optional: true
+
+ '@esbuild/win32-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/win32-ia32@0.25.12':
+ optional: true
+
+ '@esbuild/win32-x64@0.25.12':
+ optional: true
+
+ '@floating-ui/core@1.7.5':
+ dependencies:
+ '@floating-ui/utils': 0.2.11
+
+ '@floating-ui/dom@1.7.6':
+ dependencies:
+ '@floating-ui/core': 1.7.5
+ '@floating-ui/utils': 0.2.11
+
+ '@floating-ui/react-dom@2.1.8(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
+ dependencies:
+ '@floating-ui/dom': 1.7.6
+ react: 19.2.7
+ react-dom: 19.2.7(react@19.2.7)
+
+ '@floating-ui/react@0.27.19(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
+ dependencies:
+ '@floating-ui/react-dom': 2.1.8(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
+ '@floating-ui/utils': 0.2.11
+ react: 19.2.7
+ react-dom: 19.2.7(react@19.2.7)
+ tabbable: 6.4.0
+
+ '@floating-ui/utils@0.2.11': {}
+
+ '@jridgewell/gen-mapping@0.3.13':
+ dependencies:
+ '@jridgewell/sourcemap-codec': 1.5.5
+ '@jridgewell/trace-mapping': 0.3.31
+
+ '@jridgewell/remapping@2.3.5':
+ dependencies:
+ '@jridgewell/gen-mapping': 0.3.13
+ '@jridgewell/trace-mapping': 0.3.31
+
+ '@jridgewell/resolve-uri@3.1.2': {}
+
+ '@jridgewell/sourcemap-codec@1.5.5': {}
+
+ '@jridgewell/trace-mapping@0.3.31':
+ dependencies:
+ '@jridgewell/resolve-uri': 3.1.2
+ '@jridgewell/sourcemap-codec': 1.5.5
+
+ '@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
+ dependencies:
+ '@floating-ui/react': 0.27.19(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
+ '@mantine/hooks': 9.3.0(react@19.2.7)
+ clsx: 2.1.1
+ react: 19.2.7
+ react-dom: 19.2.7(react@19.2.7)
+ react-number-format: 5.4.5(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
+ react-remove-scroll: 2.7.2(@types/react@19.2.16)(react@19.2.7)
+ type-fest: 5.7.0
+ transitivePeerDependencies:
+ - '@types/react'
+
+ '@mantine/hooks@9.3.0(react@19.2.7)':
+ dependencies:
+ react: 19.2.7
+
+ '@rolldown/pluginutils@1.0.0-beta.27': {}
+
+ '@rollup/rollup-android-arm-eabi@4.61.1':
+ optional: true
+
+ '@rollup/rollup-android-arm64@4.61.1':
+ optional: true
+
+ '@rollup/rollup-darwin-arm64@4.61.1':
+ optional: true
+
+ '@rollup/rollup-darwin-x64@4.61.1':
+ optional: true
+
+ '@rollup/rollup-freebsd-arm64@4.61.1':
+ optional: true
+
+ '@rollup/rollup-freebsd-x64@4.61.1':
+ optional: true
+
+ '@rollup/rollup-linux-arm-gnueabihf@4.61.1':
+ optional: true
+
+ '@rollup/rollup-linux-arm-musleabihf@4.61.1':
+ optional: true
+
+ '@rollup/rollup-linux-arm64-gnu@4.61.1':
+ optional: true
+
+ '@rollup/rollup-linux-arm64-musl@4.61.1':
+ optional: true
+
+ '@rollup/rollup-linux-loong64-gnu@4.61.1':
+ optional: true
+
+ '@rollup/rollup-linux-loong64-musl@4.61.1':
+ optional: true
+
+ '@rollup/rollup-linux-ppc64-gnu@4.61.1':
+ optional: true
+
+ '@rollup/rollup-linux-ppc64-musl@4.61.1':
+ optional: true
+
+ '@rollup/rollup-linux-riscv64-gnu@4.61.1':
+ optional: true
+
+ '@rollup/rollup-linux-riscv64-musl@4.61.1':
+ optional: true
+
+ '@rollup/rollup-linux-s390x-gnu@4.61.1':
+ optional: true
+
+ '@rollup/rollup-linux-x64-gnu@4.61.1':
+ optional: true
+
+ '@rollup/rollup-linux-x64-musl@4.61.1':
+ optional: true
+
+ '@rollup/rollup-openbsd-x64@4.61.1':
+ optional: true
+
+ '@rollup/rollup-openharmony-arm64@4.61.1':
+ optional: true
+
+ '@rollup/rollup-win32-arm64-msvc@4.61.1':
+ optional: true
+
+ '@rollup/rollup-win32-ia32-msvc@4.61.1':
+ optional: true
+
+ '@rollup/rollup-win32-x64-gnu@4.61.1':
+ optional: true
+
+ '@rollup/rollup-win32-x64-msvc@4.61.1':
+ optional: true
+
+ '@tabler/icons-react@3.44.0(react@19.2.7)':
+ dependencies:
+ '@tabler/icons': 3.44.0
+ react: 19.2.7
+
+ '@tabler/icons@3.44.0': {}
+
+ '@types/babel__core@7.20.5':
+ dependencies:
+ '@babel/parser': 7.29.7
+ '@babel/types': 7.29.7
+ '@types/babel__generator': 7.27.0
+ '@types/babel__template': 7.4.4
+ '@types/babel__traverse': 7.28.0
+
+ '@types/babel__generator@7.27.0':
+ dependencies:
+ '@babel/types': 7.29.7
+
+ '@types/babel__template@7.4.4':
+ dependencies:
+ '@babel/parser': 7.29.7
+ '@babel/types': 7.29.7
+
+ '@types/babel__traverse@7.28.0':
+ dependencies:
+ '@babel/types': 7.29.7
+
+ '@types/estree@1.0.9': {}
+
+ '@types/react-dom@19.2.3(@types/react@19.2.16)':
+ dependencies:
+ '@types/react': 19.2.16
+
+ '@types/react@19.2.16':
+ dependencies:
+ csstype: 3.2.3
+
+ '@vitejs/plugin-react@4.7.0(vite@6.4.3(sugarss@5.0.1(postcss@8.5.15)))':
+ dependencies:
+ '@babel/core': 7.29.7
+ '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7)
+ '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7)
+ '@rolldown/pluginutils': 1.0.0-beta.27
+ '@types/babel__core': 7.20.5
+ react-refresh: 0.17.0
+ vite: 6.4.3(sugarss@5.0.1(postcss@8.5.15))
+ transitivePeerDependencies:
+ - supports-color
+
+ baseline-browser-mapping@2.10.33: {}
+
+ browserslist@4.28.2:
+ dependencies:
+ baseline-browser-mapping: 2.10.33
+ caniuse-lite: 1.0.30001793
+ electron-to-chromium: 1.5.368
+ node-releases: 2.0.47
+ update-browserslist-db: 1.2.3(browserslist@4.28.2)
+
+ camelcase-css@2.0.1: {}
+
+ caniuse-lite@1.0.30001793: {}
+
+ clsx@2.1.1: {}
+
+ convert-source-map@2.0.0: {}
+
+ cssesc@3.0.0: {}
+
+ csstype@3.2.3: {}
+
+ debug@4.4.3:
+ dependencies:
+ ms: 2.1.3
+
+ detect-node-es@1.1.0: {}
+
+ electron-to-chromium@1.5.368: {}
+
+ esbuild@0.25.12:
+ optionalDependencies:
+ '@esbuild/aix-ppc64': 0.25.12
+ '@esbuild/android-arm': 0.25.12
+ '@esbuild/android-arm64': 0.25.12
+ '@esbuild/android-x64': 0.25.12
+ '@esbuild/darwin-arm64': 0.25.12
+ '@esbuild/darwin-x64': 0.25.12
+ '@esbuild/freebsd-arm64': 0.25.12
+ '@esbuild/freebsd-x64': 0.25.12
+ '@esbuild/linux-arm': 0.25.12
+ '@esbuild/linux-arm64': 0.25.12
+ '@esbuild/linux-ia32': 0.25.12
+ '@esbuild/linux-loong64': 0.25.12
+ '@esbuild/linux-mips64el': 0.25.12
+ '@esbuild/linux-ppc64': 0.25.12
+ '@esbuild/linux-riscv64': 0.25.12
+ '@esbuild/linux-s390x': 0.25.12
+ '@esbuild/linux-x64': 0.25.12
+ '@esbuild/netbsd-arm64': 0.25.12
+ '@esbuild/netbsd-x64': 0.25.12
+ '@esbuild/openbsd-arm64': 0.25.12
+ '@esbuild/openbsd-x64': 0.25.12
+ '@esbuild/openharmony-arm64': 0.25.12
+ '@esbuild/sunos-x64': 0.25.12
+ '@esbuild/win32-arm64': 0.25.12
+ '@esbuild/win32-ia32': 0.25.12
+ '@esbuild/win32-x64': 0.25.12
+
+ escalade@3.2.0: {}
+
+ fdir@6.5.0(picomatch@4.0.4):
+ optionalDependencies:
+ picomatch: 4.0.4
+
+ fsevents@2.3.3:
+ optional: true
+
+ gensync@1.0.0-beta.2: {}
+
+ get-nonce@1.0.1: {}
+
+ js-tokens@4.0.0: {}
+
+ jsesc@3.1.0: {}
+
+ json5@2.2.3: {}
+
+ lru-cache@5.1.1:
+ dependencies:
+ yallist: 3.1.1
+
+ ms@2.1.3: {}
+
+ nanoid@3.3.12: {}
+
+ node-releases@2.0.47: {}
+
+ picocolors@1.1.1: {}
+
+ picomatch@4.0.4: {}
+
+ postcss-js@4.1.0(postcss@8.5.15):
+ dependencies:
+ camelcase-css: 2.0.1
+ postcss: 8.5.15
+
+ postcss-mixins@12.1.2(postcss@8.5.15):
+ dependencies:
+ postcss: 8.5.15
+ postcss-js: 4.1.0(postcss@8.5.15)
+ postcss-simple-vars: 7.0.1(postcss@8.5.15)
+ sugarss: 5.0.1(postcss@8.5.15)
+ tinyglobby: 0.2.17
+
+ postcss-nested@7.0.2(postcss@8.5.15):
+ dependencies:
+ postcss: 8.5.15
+ postcss-selector-parser: 7.1.1
+
+ postcss-preset-mantine@1.18.0(postcss@8.5.15):
+ dependencies:
+ postcss: 8.5.15
+ postcss-mixins: 12.1.2(postcss@8.5.15)
+ postcss-nested: 7.0.2(postcss@8.5.15)
+
+ postcss-selector-parser@7.1.1:
+ dependencies:
+ cssesc: 3.0.0
+ util-deprecate: 1.0.2
+
+ postcss-simple-vars@7.0.1(postcss@8.5.15):
+ dependencies:
+ postcss: 8.5.15
+
+ postcss@8.5.15:
+ dependencies:
+ nanoid: 3.3.12
+ picocolors: 1.1.1
+ source-map-js: 1.2.1
+
+ react-dom@19.2.7(react@19.2.7):
+ dependencies:
+ react: 19.2.7
+ scheduler: 0.27.0
+
+ react-number-format@5.4.5(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
+ dependencies:
+ react: 19.2.7
+ react-dom: 19.2.7(react@19.2.7)
+
+ react-refresh@0.17.0: {}
+
+ react-remove-scroll-bar@2.3.8(@types/react@19.2.16)(react@19.2.7):
+ dependencies:
+ react: 19.2.7
+ react-style-singleton: 2.2.3(@types/react@19.2.16)(react@19.2.7)
+ tslib: 2.8.1
+ optionalDependencies:
+ '@types/react': 19.2.16
+
+ react-remove-scroll@2.7.2(@types/react@19.2.16)(react@19.2.7):
+ dependencies:
+ react: 19.2.7
+ react-remove-scroll-bar: 2.3.8(@types/react@19.2.16)(react@19.2.7)
+ react-style-singleton: 2.2.3(@types/react@19.2.16)(react@19.2.7)
+ tslib: 2.8.1
+ use-callback-ref: 1.3.3(@types/react@19.2.16)(react@19.2.7)
+ use-sidecar: 1.1.3(@types/react@19.2.16)(react@19.2.7)
+ optionalDependencies:
+ '@types/react': 19.2.16
+
+ react-style-singleton@2.2.3(@types/react@19.2.16)(react@19.2.7):
+ dependencies:
+ get-nonce: 1.0.1
+ react: 19.2.7
+ tslib: 2.8.1
+ optionalDependencies:
+ '@types/react': 19.2.16
+
+ react@19.2.7: {}
+
+ rollup@4.61.1:
+ dependencies:
+ '@types/estree': 1.0.9
+ optionalDependencies:
+ '@rollup/rollup-android-arm-eabi': 4.61.1
+ '@rollup/rollup-android-arm64': 4.61.1
+ '@rollup/rollup-darwin-arm64': 4.61.1
+ '@rollup/rollup-darwin-x64': 4.61.1
+ '@rollup/rollup-freebsd-arm64': 4.61.1
+ '@rollup/rollup-freebsd-x64': 4.61.1
+ '@rollup/rollup-linux-arm-gnueabihf': 4.61.1
+ '@rollup/rollup-linux-arm-musleabihf': 4.61.1
+ '@rollup/rollup-linux-arm64-gnu': 4.61.1
+ '@rollup/rollup-linux-arm64-musl': 4.61.1
+ '@rollup/rollup-linux-loong64-gnu': 4.61.1
+ '@rollup/rollup-linux-loong64-musl': 4.61.1
+ '@rollup/rollup-linux-ppc64-gnu': 4.61.1
+ '@rollup/rollup-linux-ppc64-musl': 4.61.1
+ '@rollup/rollup-linux-riscv64-gnu': 4.61.1
+ '@rollup/rollup-linux-riscv64-musl': 4.61.1
+ '@rollup/rollup-linux-s390x-gnu': 4.61.1
+ '@rollup/rollup-linux-x64-gnu': 4.61.1
+ '@rollup/rollup-linux-x64-musl': 4.61.1
+ '@rollup/rollup-openbsd-x64': 4.61.1
+ '@rollup/rollup-openharmony-arm64': 4.61.1
+ '@rollup/rollup-win32-arm64-msvc': 4.61.1
+ '@rollup/rollup-win32-ia32-msvc': 4.61.1
+ '@rollup/rollup-win32-x64-gnu': 4.61.1
+ '@rollup/rollup-win32-x64-msvc': 4.61.1
+ fsevents: 2.3.3
+
+ scheduler@0.27.0: {}
+
+ semver@6.3.1: {}
+
+ source-map-js@1.2.1: {}
+
+ sugarss@5.0.1(postcss@8.5.15):
+ dependencies:
+ postcss: 8.5.15
+
+ tabbable@6.4.0: {}
+
+ tagged-tag@1.0.0: {}
+
+ tinyglobby@0.2.17:
+ dependencies:
+ fdir: 6.5.0(picomatch@4.0.4)
+ picomatch: 4.0.4
+
+ tslib@2.8.1: {}
+
+ type-fest@5.7.0:
+ dependencies:
+ tagged-tag: 1.0.0
+
+ typescript@5.9.3: {}
+
+ update-browserslist-db@1.2.3(browserslist@4.28.2):
+ dependencies:
+ browserslist: 4.28.2
+ escalade: 3.2.0
+ picocolors: 1.1.1
+
+ use-callback-ref@1.3.3(@types/react@19.2.16)(react@19.2.7):
+ dependencies:
+ react: 19.2.7
+ tslib: 2.8.1
+ optionalDependencies:
+ '@types/react': 19.2.16
+
+ use-sidecar@1.1.3(@types/react@19.2.16)(react@19.2.7):
+ dependencies:
+ detect-node-es: 1.1.0
+ react: 19.2.7
+ tslib: 2.8.1
+ optionalDependencies:
+ '@types/react': 19.2.16
+
+ util-deprecate@1.0.2: {}
+
+ vite@6.4.3(sugarss@5.0.1(postcss@8.5.15)):
+ dependencies:
+ esbuild: 0.25.12
+ fdir: 6.5.0(picomatch@4.0.4)
+ picomatch: 4.0.4
+ postcss: 8.5.15
+ rollup: 4.61.1
+ tinyglobby: 0.2.17
+ optionalDependencies:
+ fsevents: 2.3.3
+ sugarss: 5.0.1(postcss@8.5.15)
+
+ yallist@3.1.1: {}
diff --git a/web/pnpm-workspace.yaml b/web/pnpm-workspace.yaml
new file mode 100644
index 0000000..5ed0b5a
--- /dev/null
+++ b/web/pnpm-workspace.yaml
@@ -0,0 +1,2 @@
+allowBuilds:
+ esbuild: true
diff --git a/web/postcss.config.cjs b/web/postcss.config.cjs
new file mode 100644
index 0000000..e817f56
--- /dev/null
+++ b/web/postcss.config.cjs
@@ -0,0 +1,14 @@
+module.exports = {
+ plugins: {
+ "postcss-preset-mantine": {},
+ "postcss-simple-vars": {
+ variables: {
+ "mantine-breakpoint-xs": "36em",
+ "mantine-breakpoint-sm": "48em",
+ "mantine-breakpoint-md": "62em",
+ "mantine-breakpoint-lg": "75em",
+ "mantine-breakpoint-xl": "88em",
+ },
+ },
+ },
+};
diff --git a/web/src/App.tsx b/web/src/App.tsx
new file mode 100644
index 0000000..c268f66
--- /dev/null
+++ b/web/src/App.tsx
@@ -0,0 +1,29 @@
+import { useState } from "react";
+import { GatewayClient } from "./api";
+import type { Peer } from "./types";
+import { ConnectScreen } from "./components/ConnectScreen";
+import { ChatLayout } from "./components/ChatLayout";
+
+// Connection holds the live gateway client plus the identity it connected as.
+interface Connection {
+ client: GatewayClient;
+ peer: Peer;
+}
+
+// App is the root: it shows the connect screen until the user picks a gateway
+// URL and a peer name, then swaps to the full chat layout. Disconnecting drops
+// back to the connect screen.
+export function App() {
+ const [conn, setConn] = useState(null);
+
+ if (!conn) {
+ return setConn({ client, peer })} />;
+ }
+ return (
+ setConn(null)}
+ />
+ );
+}
diff --git a/web/src/api.ts b/web/src/api.ts
new file mode 100644
index 0000000..bc3c40e
--- /dev/null
+++ b/web/src/api.ts
@@ -0,0 +1,99 @@
+// GatewayClient is the SPA's typed wrapper over the unibus gateway HTTP API.
+// Every method is a thin fetch against the gateway, which hosts one real Go bus
+// peer per name and performs all NATS + end-to-end crypto on the browser's
+// behalf. The base URL is chosen at runtime on the connect screen.
+import type { BusEvent, Member, Peer, Room } from "./types";
+
+export class GatewayClient {
+ constructor(public readonly baseURL: string) {
+ // Normalize: drop a trailing slash so `${base}/api/...` never doubles up.
+ this.baseURL = baseURL.replace(/\/+$/, "");
+ }
+
+ private async req(method: string, path: string, body?: unknown): Promise {
+ const res = await fetch(this.baseURL + path, {
+ method,
+ headers: body !== undefined ? { "Content-Type": "application/json" } : undefined,
+ body: body !== undefined ? JSON.stringify(body) : undefined,
+ });
+ const text = await res.text();
+ if (!res.ok) {
+ let msg = text;
+ try {
+ const j = JSON.parse(text);
+ if (j && typeof j.error === "string") msg = j.error;
+ } catch {
+ // not JSON: keep the raw text
+ }
+ throw new Error(msg || `HTTP ${res.status}`);
+ }
+ return (text ? JSON.parse(text) : {}) as T;
+ }
+
+ // connect creates (or recovers) the named peer on the gateway and returns its
+ // public identity. The identity persists across gateway restarts.
+ connect(name: string): Promise {
+ return this.req("POST", "/api/peer", { name });
+ }
+
+ // peers lists every peer currently hosted by the gateway (for the invite picker
+ // and to label senders by name).
+ peers(): Promise {
+ return this.req("GET", "/api/peers");
+ }
+
+ // rooms lists the rooms the named peer knows (created or joined).
+ rooms(peer: string): Promise {
+ return this.req("GET", `/api/rooms?peer=${encodeURIComponent(peer)}`);
+ }
+
+ // members lists the participants of a room.
+ members(roomID: string): Promise {
+ return this.req("GET", `/api/members?room_id=${encodeURIComponent(roomID)}`);
+ }
+
+ // createRoom opens a room on the given subject. encrypt drives both E2E
+ // encryption and per-message signing; the peer is auto-subscribed.
+ createRoom(peer: string, subject: string, encrypt: boolean): Promise {
+ return this.req("POST", "/api/room", { peer, subject, encrypt, persist: false });
+ }
+
+ // join subscribes the peer to an existing room (must have been invited first
+ // when the room is encrypted).
+ join(peer: string, roomID: string): Promise<{ subject: string; encrypt: boolean }> {
+ return this.req("POST", "/api/join", { peer, room_id: roomID });
+ }
+
+ // invite adds another connected peer (by name) to a room, sealing the room key
+ // to it. Caller must be the room owner.
+ invite(peer: string, roomID: string, target: string): Promise<{ status: string }> {
+ return this.req("POST", "/api/invite", { peer, room_id: roomID, target });
+ }
+
+ // publish sends a text message to a room.
+ publish(peer: string, roomID: string, text: string): Promise<{ status: string }> {
+ return this.req("POST", "/api/publish", { peer, room_id: roomID, text });
+ }
+
+ // kick removes a peer (by name) from a room and rotates the key (forward
+ // secrecy). Caller must be the room owner.
+ kick(peer: string, roomID: string, target: string): Promise<{ status: string }> {
+ return this.req("POST", "/api/kick", { peer, room_id: roomID, target });
+ }
+
+ // stream opens the SSE channel for a peer. onEvent fires for each received bus
+ // message; onError fires if the stream drops. Returns the EventSource so the
+ // caller can close it.
+ stream(peer: string, onEvent: (ev: BusEvent) => void, onError?: () => void): EventSource {
+ const es = new EventSource(`${this.baseURL}/api/stream?peer=${encodeURIComponent(peer)}`);
+ es.onmessage = (e) => {
+ try {
+ onEvent(JSON.parse(e.data) as BusEvent);
+ } catch {
+ // ignore malformed frames (keepalive comments never reach onmessage)
+ }
+ };
+ if (onError) es.onerror = onError;
+ return es;
+ }
+}
diff --git a/web/src/components/ChatLayout.tsx b/web/src/components/ChatLayout.tsx
new file mode 100644
index 0000000..2c544e3
--- /dev/null
+++ b/web/src/components/ChatLayout.tsx
@@ -0,0 +1,285 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import {
+ AppShell,
+ Group,
+ Title,
+ Badge,
+ Button,
+ CopyButton,
+ Tooltip,
+ ActionIcon,
+ ThemeIcon,
+ Alert,
+ Transition,
+} from "@mantine/core";
+import {
+ IconBolt,
+ IconLogout,
+ IconCopy,
+ IconCheck,
+ IconAlertTriangle,
+} from "@tabler/icons-react";
+import { GatewayClient } from "../api";
+import type { Member, Message, Peer, Room } from "../types";
+import { RoomList } from "./RoomList";
+import { MessagePane } from "./MessagePane";
+import { MembersPane } from "./MembersPane";
+
+interface Props {
+ client: GatewayClient;
+ peer: Peer;
+ onDisconnect: () => void;
+}
+
+// short renders the first 10 chars of an endpoint id, enough to disambiguate.
+export function short(endpoint: string): string {
+ return endpoint.length > 12 ? endpoint.slice(0, 10) + "…" : endpoint;
+}
+
+// ChatLayout owns all chat state: the peer's rooms, the active room, the
+// per-room message log fed by the SSE stream, the directory of connected peers
+// (to label senders and pick invitees), and the active room's member list. Every
+// bus action goes through the gateway client.
+export function ChatLayout({ client, peer, onDisconnect }: Props) {
+ const [rooms, setRooms] = useState([]);
+ const [activeRoom, setActiveRoom] = useState(null);
+ const [messages, setMessages] = useState>({});
+ const [peers, setPeers] = useState([]);
+ const [members, setMembers] = useState([]);
+ const [error, setError] = useState(null);
+ const seq = useRef(0);
+
+ const fail = useCallback((e: unknown) => {
+ setError(e instanceof Error ? e.message : String(e));
+ }, []);
+
+ // ---- data refreshers ----------------------------------------------------
+
+ const refreshRooms = useCallback(async () => {
+ try {
+ setRooms(await client.rooms(peer.name));
+ } catch (e) {
+ fail(e);
+ }
+ }, [client, peer.name, fail]);
+
+ const refreshPeers = useCallback(async () => {
+ try {
+ setPeers(await client.peers());
+ } catch (e) {
+ fail(e);
+ }
+ }, [client, fail]);
+
+ const refreshMembers = useCallback(
+ async (roomID: string) => {
+ try {
+ setMembers(await client.members(roomID));
+ } catch (e) {
+ fail(e);
+ }
+ },
+ [client, fail],
+ );
+
+ // ---- live stream (SSE) --------------------------------------------------
+
+ useEffect(() => {
+ const es = client.stream(
+ peer.name,
+ (ev) => {
+ seq.current += 1;
+ const msg: Message = { ...ev, id: `${ev.ts}-${seq.current}` };
+ setMessages((prev) => {
+ const list = prev[ev.room_id] ?? [];
+ return { ...prev, [ev.room_id]: [...list, msg] };
+ });
+ },
+ () => setError("Se perdió la conexión con el gateway (stream SSE)"),
+ );
+ return () => es.close();
+ }, [client, peer.name]);
+
+ // Initial load.
+ useEffect(() => {
+ refreshRooms();
+ refreshPeers();
+ }, [refreshRooms, refreshPeers]);
+
+ // Refresh members whenever the active room changes.
+ useEffect(() => {
+ if (activeRoom) refreshMembers(activeRoom);
+ else setMembers([]);
+ }, [activeRoom, refreshMembers]);
+
+ // ---- actions ------------------------------------------------------------
+
+ const onCreateRoom = useCallback(
+ async (subject: string, encrypt: boolean) => {
+ try {
+ const r = await client.createRoom(peer.name, subject, encrypt);
+ await refreshRooms();
+ setActiveRoom(r.room_id);
+ } catch (e) {
+ fail(e);
+ }
+ },
+ [client, peer.name, refreshRooms, fail],
+ );
+
+ const onJoinRoom = useCallback(
+ async (roomID: string) => {
+ try {
+ await client.join(peer.name, roomID);
+ await refreshRooms();
+ setActiveRoom(roomID);
+ } catch (e) {
+ fail(e);
+ }
+ },
+ [client, peer.name, refreshRooms, fail],
+ );
+
+ const onInvite = useCallback(
+ async (target: string) => {
+ if (!activeRoom) return;
+ try {
+ await client.invite(peer.name, activeRoom, target);
+ await refreshMembers(activeRoom);
+ } catch (e) {
+ fail(e);
+ }
+ },
+ [client, peer.name, activeRoom, refreshMembers, fail],
+ );
+
+ const onKick = useCallback(
+ async (target: string) => {
+ if (!activeRoom) return;
+ try {
+ await client.kick(peer.name, activeRoom, target);
+ await refreshMembers(activeRoom);
+ } catch (e) {
+ fail(e);
+ }
+ },
+ [client, peer.name, activeRoom, refreshMembers, fail],
+ );
+
+ const onPublish = useCallback(
+ async (text: string) => {
+ if (!activeRoom) return;
+ try {
+ await client.publish(peer.name, activeRoom, text);
+ } catch (e) {
+ fail(e);
+ }
+ },
+ [client, peer.name, activeRoom, fail],
+ );
+
+ // endpoint -> display name, using the peer directory; falls back to a short id.
+ const nameFor = useMemo(() => {
+ const byEndpoint = new Map(peers.map((p) => [p.endpoint_id, p.name]));
+ return (endpoint: string) =>
+ endpoint === peer.endpoint_id ? peer.name : byEndpoint.get(endpoint) ?? short(endpoint);
+ }, [peers, peer]);
+
+ const activeRoomObj = rooms.find((r) => r.room_id === activeRoom) ?? null;
+ const iAmOwner = members.some((m) => m.endpoint === peer.endpoint_id && m.role === "owner");
+
+ return (
+
+
+
+
+
+
+
+ unibus
+
+
+
+ {peer.name}
+
+
+ {({ copied, copy }) => (
+
+
+ {copied ? : }
+
+
+ )}
+
+ }
+ onClick={onDisconnect}
+ >
+ Salir
+
+
+
+
+
+
+
+
+
+
+ {error && (
+
+ {(styles) => (
+ }
+ withCloseButton
+ onClose={() => setError(null)}
+ title="Error"
+ >
+ {error}
+
+ )}
+
+ )}
+
+
+
+
+ {activeRoomObj && (
+ activeRoom && refreshMembers(activeRoom)}
+ />
+ )}
+
+
+ );
+}
diff --git a/web/src/components/ConnectScreen.tsx b/web/src/components/ConnectScreen.tsx
new file mode 100644
index 0000000..901582a
--- /dev/null
+++ b/web/src/components/ConnectScreen.tsx
@@ -0,0 +1,116 @@
+import { useState } from "react";
+import {
+ Button,
+ Card,
+ Center,
+ Group,
+ Stack,
+ Text,
+ TextInput,
+ Title,
+ Alert,
+ ThemeIcon,
+} from "@mantine/core";
+import { IconBolt, IconPlugConnected, IconAlertTriangle } from "@tabler/icons-react";
+import { GatewayClient } from "../api";
+import type { Peer } from "../types";
+
+const LS_GATEWAY = "unibus.gateway";
+const LS_PEER = "unibus.peer";
+
+interface Props {
+ onConnect: (client: GatewayClient, peer: Peer) => void;
+}
+
+// ConnectScreen asks for the gateway URL and the identity (peer name) to connect
+// as. Both persist in localStorage so a reload reconnects with one click. The
+// gateway hosts the real Go bus peer; the browser only drives it.
+export function ConnectScreen({ onConnect }: Props) {
+ const [gateway, setGateway] = useState(
+ () => localStorage.getItem(LS_GATEWAY) ?? "http://localhost:7700",
+ );
+ const [name, setName] = useState(() => localStorage.getItem(LS_PEER) ?? "");
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState(null);
+
+ const connect = async () => {
+ const trimmed = name.trim();
+ if (!trimmed) {
+ setError("Elige un nombre de identidad");
+ return;
+ }
+ setBusy(true);
+ setError(null);
+ try {
+ const client = new GatewayClient(gateway.trim());
+ const peer = await client.connect(trimmed);
+ localStorage.setItem(LS_GATEWAY, client.baseURL);
+ localStorage.setItem(LS_PEER, trimmed);
+ onConnect(client, peer);
+ } catch (e) {
+ setError(e instanceof Error ? e.message : String(e));
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
unibus
+
+ chat cifrado extremo a extremo sobre NATS
+
+
+
+
+ setGateway(e.currentTarget.value)}
+ disabled={busy}
+ />
+ setName(e.currentTarget.value)}
+ onKeyDown={(e) => e.key === "Enter" && connect()}
+ disabled={busy}
+ data-autofocus
+ />
+
+ {error && (
+ }
+ title="No se pudo conectar"
+ >
+ {error}
+
+ )}
+
+ }
+ onClick={connect}
+ loading={busy}
+ fullWidth
+ size="md"
+ >
+ Conectar
+
+
+
+
+ );
+}
diff --git a/web/src/components/MembersPane.tsx b/web/src/components/MembersPane.tsx
new file mode 100644
index 0000000..891247c
--- /dev/null
+++ b/web/src/components/MembersPane.tsx
@@ -0,0 +1,153 @@
+import { useState } from "react";
+import {
+ Stack,
+ Group,
+ Text,
+ Badge,
+ Select,
+ Button,
+ ActionIcon,
+ Divider,
+ Box,
+ Avatar,
+ Tooltip,
+ ScrollArea,
+} from "@mantine/core";
+import { IconUserPlus, IconUserMinus, IconRefresh, IconUsers } from "@tabler/icons-react";
+import type { Member, Peer, Room } from "../types";
+
+interface Props {
+ room: Room;
+ members: Member[];
+ peers: Peer[];
+ myEndpoint: string;
+ iAmOwner: boolean;
+ nameFor: (endpoint: string) => string;
+ onInvite: (target: string) => void;
+ onKick: (target: string) => void;
+ onRefresh: () => void;
+}
+
+// MembersPane is the right column: who is in the active room, plus invite (pick a
+// connected peer) and kick (owner only). Invite/kick address peers by name; the
+// gateway resolves the name to its bus endpoint.
+export function MembersPane({
+ room,
+ members,
+ peers,
+ myEndpoint,
+ iAmOwner,
+ nameFor,
+ onInvite,
+ onKick,
+ onRefresh,
+}: Props) {
+ const [target, setTarget] = useState(null);
+
+ const memberEndpoints = new Set(members.map((m) => m.endpoint));
+ // Candidates to invite: connected peers not already in the room.
+ const candidates = peers
+ .filter((p) => !memberEndpoints.has(p.endpoint_id))
+ .map((p) => ({ value: p.name, label: p.name }));
+
+ const invite = () => {
+ if (target) {
+ onInvite(target);
+ setTarget(null);
+ }
+ };
+
+ return (
+
+
+
+
+ Miembros
+
+ {members.length}
+
+
+
+
+
+
+
+
+
+
+
+ Invitar {room.encrypt && "(reparte la clave)"}
+
+
+
+ }
+ onClick={invite}
+ disabled={!target}
+ >
+ Invitar
+
+
+
+
+
+
+
+
+ {members.map((m) => {
+ const isMe = m.endpoint === myEndpoint;
+ const name = nameFor(m.endpoint);
+ const canKick = iAmOwner && !isMe && m.role !== "owner";
+ return (
+
+
+
+ {name.slice(0, 2).toUpperCase()}
+
+
+
+ {name} {isMe && "(tú)"}
+
+
+ {m.endpoint}
+
+
+
+
+ {m.role === "owner" && (
+
+ owner
+
+ )}
+ {canKick && (
+
+ onKick(name)}
+ >
+
+
+
+ )}
+
+
+ );
+ })}
+
+
+
+ );
+}
diff --git a/web/src/components/MessagePane.tsx b/web/src/components/MessagePane.tsx
new file mode 100644
index 0000000..73a657d
--- /dev/null
+++ b/web/src/components/MessagePane.tsx
@@ -0,0 +1,153 @@
+import { useEffect, useRef, useState } from "react";
+import {
+ Stack,
+ Group,
+ Text,
+ Badge,
+ Paper,
+ ScrollArea,
+ TextInput,
+ ActionIcon,
+ Center,
+ ThemeIcon,
+ Box,
+ CopyButton,
+ Tooltip,
+} from "@mantine/core";
+import {
+ IconLock,
+ IconHash,
+ IconSend,
+ IconMessages,
+ IconCopy,
+ IconCheck,
+} from "@tabler/icons-react";
+import type { Message, Room } from "../types";
+
+interface Props {
+ room: Room | null;
+ messages: Message[];
+ myEndpoint: string;
+ nameFor: (endpoint: string) => string;
+ onPublish: (text: string) => void;
+}
+
+// formatTime renders a message timestamp as HH:mm:ss in 24h European style.
+function formatTime(ts: number): string {
+ return new Date(ts).toLocaleTimeString("es-ES", {
+ hour: "2-digit",
+ minute: "2-digit",
+ second: "2-digit",
+ });
+}
+
+// MessagePane is the center column: the active room's live message log plus the
+// composer. Own messages align right; others align left and show the sender.
+export function MessagePane({ room, messages, myEndpoint, nameFor, onPublish }: Props) {
+ const [text, setText] = useState("");
+ const viewport = useRef(null);
+
+ // Auto-scroll to the newest message.
+ useEffect(() => {
+ viewport.current?.scrollTo({ top: viewport.current.scrollHeight, behavior: "smooth" });
+ }, [messages.length]);
+
+ if (!room) {
+ return (
+
+
+
+
+
+ Elige o crea una room para empezar a chatear
+
+
+ );
+ }
+
+ const send = () => {
+ const t = text.trim();
+ if (t) {
+ onPublish(t);
+ setText("");
+ }
+ };
+
+ return (
+
+
+
+ {room.encrypt ? : }
+ {room.subject}
+ {room.encrypt && (
+
+ cifrada E2E
+
+ )}
+
+
+ {({ copied, copy }) => (
+
+
+ {copied ? : }
+
+
+ )}
+
+
+
+
+
+ {messages.length === 0 && (
+
+ No hay mensajes todavía.
+
+ )}
+ {messages.map((m) => {
+ const mine = m.sender === myEndpoint;
+ return (
+
+
+ {!mine && (
+
+ {nameFor(m.sender)}
+
+ )}
+
+ {m.text}
+
+
+ {formatTime(m.ts)}
+
+
+
+ );
+ })}
+
+
+
+
+ setText(e.currentTarget.value)}
+ onKeyDown={(e) => e.key === "Enter" && send()}
+ />
+
+
+
+
+
+ );
+}
diff --git a/web/src/components/RoomList.tsx b/web/src/components/RoomList.tsx
new file mode 100644
index 0000000..3a424b4
--- /dev/null
+++ b/web/src/components/RoomList.tsx
@@ -0,0 +1,119 @@
+import { useState } from "react";
+import {
+ Stack,
+ TextInput,
+ Checkbox,
+ Button,
+ Divider,
+ Text,
+ NavLink,
+ ScrollArea,
+ Group,
+ Box,
+} from "@mantine/core";
+import { IconLock, IconHash, IconPlus, IconDoorEnter } from "@tabler/icons-react";
+import type { Room } from "../types";
+
+interface Props {
+ rooms: Room[];
+ activeRoom: string | null;
+ onSelect: (roomID: string) => void;
+ onCreateRoom: (subject: string, encrypt: boolean) => void;
+ onJoinRoom: (roomID: string) => void;
+}
+
+// RoomList is the navbar: create a room, join one by id, and pick the active
+// room from the peer's known rooms.
+export function RoomList({ rooms, activeRoom, onSelect, onCreateRoom, onJoinRoom }: Props) {
+ const [subject, setSubject] = useState("room.general");
+ const [encrypt, setEncrypt] = useState(true);
+ const [joinID, setJoinID] = useState("");
+
+ const create = () => {
+ if (subject.trim()) onCreateRoom(subject.trim(), encrypt);
+ };
+ const join = () => {
+ if (joinID.trim()) {
+ onJoinRoom(joinID.trim());
+ setJoinID("");
+ }
+ };
+
+ return (
+
+
+
+ Crear room
+
+
+ }
+ value={subject}
+ onChange={(e) => setSubject(e.currentTarget.value)}
+ onKeyDown={(e) => e.key === "Enter" && create()}
+ />
+ setEncrypt(e.currentTarget.checked)}
+ />
+ } onClick={create}>
+ Crear
+
+
+
+
+
+
+
+
+ Unirse por id
+
+
+ setJoinID(e.currentTarget.value)}
+ onKeyDown={(e) => e.key === "Enter" && join()}
+ style={{ flex: 1 }}
+ />
+
+
+
+
+
+
+
+ Rooms ({rooms.length})
+
+
+
+ {rooms.length === 0 && (
+
+ Aún no hay rooms. Crea o únete a una.
+
+ )}
+ {rooms.map((r) => (
+ onSelect(r.room_id)}
+ label={r.subject}
+ description={r.room_id.slice(0, 14) + "…"}
+ leftSection={
+ r.encrypt ? :
+ }
+ variant="filled"
+ />
+ ))}
+
+
+
+ );
+}
diff --git a/web/src/main.tsx b/web/src/main.tsx
new file mode 100644
index 0000000..82cc289
--- /dev/null
+++ b/web/src/main.tsx
@@ -0,0 +1,14 @@
+import React from "react";
+import ReactDOM from "react-dom/client";
+import { MantineProvider } from "@mantine/core";
+import "@mantine/core/styles.css";
+import { theme } from "./theme";
+import { App } from "./App";
+
+ReactDOM.createRoot(document.getElementById("root")!).render(
+
+
+
+
+ ,
+);
diff --git a/web/src/theme.ts b/web/src/theme.ts
new file mode 100644
index 0000000..11b54d0
--- /dev/null
+++ b/web/src/theme.ts
@@ -0,0 +1,14 @@
+import { createTheme } from "@mantine/core";
+
+// The unibus theme: a single accent color and a slightly tighter default radius.
+// Mantine generates all its CSS variables from this; the SPA never hand-writes
+// CSS or color literals.
+export const theme = createTheme({
+ primaryColor: "violet",
+ defaultRadius: "md",
+ fontFamily:
+ "Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif",
+ headings: {
+ fontWeight: "650",
+ },
+});
diff --git a/web/src/types.ts b/web/src/types.ts
new file mode 100644
index 0000000..bd25a89
--- /dev/null
+++ b/web/src/types.ts
@@ -0,0 +1,41 @@
+// Domain types shared across the SPA. They mirror the JSON the unibus gateway
+// (playground/server.go) returns; the browser never speaks NATS or crypto
+// directly — the Go peer behind the gateway does, so every type here is a plain
+// view of a gateway response.
+
+// Peer is a named identity hosted by the gateway. endpoint_id is the stable bus
+// endpoint (base64url of sha256(signPub)).
+export interface Peer {
+ name: string;
+ endpoint_id: string;
+}
+
+// Room is a channel the connected peer created or joined. encrypt true means the
+// payloads are sealed end-to-end with the room key.
+export interface Room {
+ room_id: string;
+ subject: string;
+ encrypt: boolean;
+}
+
+// Member is one participant of a room as reported by the control plane.
+export interface Member {
+ endpoint: string;
+ role: string;
+}
+
+// BusEvent is one Server-Sent Event delivered on /api/stream: a message a peer
+// received on one of its subscribed rooms, already decrypted by the Go peer.
+export interface BusEvent {
+ room_id: string;
+ subject: string;
+ sender: string;
+ text: string;
+ encrypted: boolean;
+ ts: number; // unix millis
+}
+
+// Message is a BusEvent enriched with a stable local id for React keys.
+export interface Message extends BusEvent {
+ id: string;
+}
diff --git a/web/tsconfig.app.json b/web/tsconfig.app.json
new file mode 100644
index 0000000..7ac96e0
--- /dev/null
+++ b/web/tsconfig.app.json
@@ -0,0 +1,22 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "useDefineForClassFields": true,
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "isolatedModules": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "jsx": "react-jsx",
+
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": ["src"]
+}
diff --git a/web/tsconfig.json b/web/tsconfig.json
new file mode 100644
index 0000000..1ffef60
--- /dev/null
+++ b/web/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "files": [],
+ "references": [
+ { "path": "./tsconfig.app.json" },
+ { "path": "./tsconfig.node.json" }
+ ]
+}
diff --git a/web/tsconfig.node.json b/web/tsconfig.node.json
new file mode 100644
index 0000000..9c43072
--- /dev/null
+++ b/web/tsconfig.node.json
@@ -0,0 +1,17 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "lib": ["ES2023"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "isolatedModules": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/web/vite.config.ts b/web/vite.config.ts
new file mode 100644
index 0000000..8acd431
--- /dev/null
+++ b/web/vite.config.ts
@@ -0,0 +1,14 @@
+import { defineConfig } from "vite";
+import react from "@vitejs/plugin-react";
+
+// The SPA talks to the unibus gateway over plain fetch + EventSource; the
+// gateway URL is chosen at runtime on the connect screen, so nothing is proxied
+// here. The dev server runs on a fixed port so the gateway's permissive CORS is
+// predictable.
+export default defineConfig({
+ plugins: [react()],
+ server: {
+ port: 5173,
+ host: true,
+ },
+});