Sasha commited on
Commit
d9a03db
·
0 Parent(s):

feat: prepare transition to decoupled hosting (Render + Netlify + Supabase)

Browse files
.gitignore ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dependencies
2
+ node_modules/
3
+ /dist
4
+ /build
5
+
6
+ # Python
7
+ venv/
8
+ .venv/
9
+ __pycache__/
10
+ *.pyc
11
+ *.pyo
12
+ *.pyd
13
+ .Python
14
+ env/
15
+ pip-log.txt
16
+ pip-delete-this-directory.txt
17
+ .tox/
18
+ .coverage
19
+ .cache
20
+ nosetests.xml
21
+ coverage.xml
22
+ *,cover
23
+ .hypothesis/
24
+ .pytest_cache/
25
+
26
+ # Environments
27
+ .env
28
+ .env.local
29
+ .env.development.local
30
+ .env.test.local
31
+ .env.production.local
32
+ *.env
33
+
34
+ # SQLite
35
+ *.db
36
+ *.sqlite
37
+ *.sqlite3
38
+
39
+ # IDEs and OS
40
+ .vscode/
41
+ .idea/
42
+ .DS_Store
43
+ Thumbs.db
README.md ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # winx_prinx Projects
2
+
3
+ Репозиторий/директория для проектов Twitch-стримера **winx_prinx**.
4
+
5
+ ## Возможные идеи для разработки:
6
+ 1. **Twitch Чат-бот**: Автоматизация модерации, кастомные команды, интеграции с внешними API.
7
+ 2. **Интерактивные виджеты/оверлеи**: Виджеты для OBS (оповещения, рулетки, мини-игры для чата, интеграция с DonationAlerts/Boosty).
8
+ 3. **Личный сайт / Ссылка-визитка**: Красивая посадочная страница со ссылками на соцсети, расписанием стримов и информацией о стримере.
9
+ 4. **Панель управления (Dashboard)**: Веб-интерфейс для управления ботом или оверлеями в реальном времени.
10
+
11
+ ---
12
+ # winx_prinx Stream Analytics
13
+ Панель аналитики стримов и частотный словарь Twitch-канала winx_prinx.
14
+
client/.env.example ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ # Base URL of the backend API (use http://localhost:3000 for local dev)
2
+ VITE_API_URL=http://localhost:3000
client/.gitignore ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Logs
2
+ logs
3
+ *.log
4
+ npm-debug.log*
5
+ yarn-debug.log*
6
+ yarn-error.log*
7
+ pnpm-debug.log*
8
+ lerna-debug.log*
9
+
10
+ node_modules
11
+ dist
12
+ dist-ssr
13
+ *.local
14
+
15
+ # Editor directories and files
16
+ .vscode/*
17
+ !.vscode/extensions.json
18
+ .idea
19
+ .DS_Store
20
+ *.suo
21
+ *.ntvs*
22
+ *.njsproj
23
+ *.sln
24
+ *.sw?
client/README.md ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # React + Vite
2
+
3
+ This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
4
+
5
+ Currently, two official plugins are available:
6
+
7
+ - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
8
+ - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
9
+
10
+ ## React Compiler
11
+
12
+ The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
13
+
14
+ ## Expanding the ESLint configuration
15
+
16
+ If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
client/eslint.config.js ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import js from '@eslint/js'
2
+ import globals from 'globals'
3
+ import reactHooks from 'eslint-plugin-react-hooks'
4
+ import reactRefresh from 'eslint-plugin-react-refresh'
5
+ import { defineConfig, globalIgnores } from 'eslint/config'
6
+
7
+ export default defineConfig([
8
+ globalIgnores(['dist']),
9
+ {
10
+ files: ['**/*.{js,jsx}'],
11
+ extends: [
12
+ js.configs.recommended,
13
+ reactHooks.configs.flat.recommended,
14
+ reactRefresh.configs.vite,
15
+ ],
16
+ languageOptions: {
17
+ globals: globals.browser,
18
+ parserOptions: { ecmaFeatures: { jsx: true } },
19
+ },
20
+ },
21
+ ])
client/index.html ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="ru">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%239146FF'%3E%3Cpath d='M11.64 5.93h1.43v4.28h-1.43V5.93m3.93-3.21 3.57 3.57v11.07h-4.29v4.29h-4.29l-3.57-3.57V2.72h8.58M19 4.14h-7.14v12.14h3.57v3.57l3.57-3.57V4.14m-1.78 1.79H15.79v4.28h1.43V5.93z'/%3E%3C/svg%3E" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <meta name="description" content="Панель аналитики стримов Twitch для канала winx_prinx. Отслеживание активности зрителей, слов стримера и логов модерации." />
8
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
9
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
10
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=Outfit:wght@400;500;600;700&display=swap" rel="stylesheet" />
11
+ <title>winx_prinx Stream Analytics — Дашборд Аналитики Twitch</title>
12
+ </head>
13
+ <body>
14
+ <div id="root"></div>
15
+ <script type="module" src="/src/main.jsx"></script>
16
+ </body>
17
+ </html>
client/package-lock.json ADDED
@@ -0,0 +1,2494 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "client",
3
+ "version": "0.0.0",
4
+ "lockfileVersion": 3,
5
+ "requires": true,
6
+ "packages": {
7
+ "": {
8
+ "name": "client",
9
+ "version": "0.0.0",
10
+ "dependencies": {
11
+ "chart.js": "^4.5.1",
12
+ "lucide-react": "^1.17.0",
13
+ "react": "^19.2.6",
14
+ "react-chartjs-2": "^5.3.1",
15
+ "react-dom": "^19.2.6"
16
+ },
17
+ "devDependencies": {
18
+ "@eslint/js": "^10.0.1",
19
+ "@types/react": "^19.2.14",
20
+ "@types/react-dom": "^19.2.3",
21
+ "@vitejs/plugin-react": "^6.0.1",
22
+ "eslint": "^10.3.0",
23
+ "eslint-plugin-react-hooks": "^7.1.1",
24
+ "eslint-plugin-react-refresh": "^0.5.2",
25
+ "globals": "^17.6.0",
26
+ "vite": "^8.0.12"
27
+ }
28
+ },
29
+ "node_modules/@babel/code-frame": {
30
+ "version": "7.29.7",
31
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
32
+ "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
33
+ "dev": true,
34
+ "license": "MIT",
35
+ "dependencies": {
36
+ "@babel/helper-validator-identifier": "^7.29.7",
37
+ "js-tokens": "^4.0.0",
38
+ "picocolors": "^1.1.1"
39
+ },
40
+ "engines": {
41
+ "node": ">=6.9.0"
42
+ }
43
+ },
44
+ "node_modules/@babel/compat-data": {
45
+ "version": "7.29.7",
46
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
47
+ "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
48
+ "dev": true,
49
+ "license": "MIT",
50
+ "engines": {
51
+ "node": ">=6.9.0"
52
+ }
53
+ },
54
+ "node_modules/@babel/core": {
55
+ "version": "7.29.7",
56
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
57
+ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
58
+ "dev": true,
59
+ "license": "MIT",
60
+ "dependencies": {
61
+ "@babel/code-frame": "^7.29.7",
62
+ "@babel/generator": "^7.29.7",
63
+ "@babel/helper-compilation-targets": "^7.29.7",
64
+ "@babel/helper-module-transforms": "^7.29.7",
65
+ "@babel/helpers": "^7.29.7",
66
+ "@babel/parser": "^7.29.7",
67
+ "@babel/template": "^7.29.7",
68
+ "@babel/traverse": "^7.29.7",
69
+ "@babel/types": "^7.29.7",
70
+ "@jridgewell/remapping": "^2.3.5",
71
+ "convert-source-map": "^2.0.0",
72
+ "debug": "^4.1.0",
73
+ "gensync": "^1.0.0-beta.2",
74
+ "json5": "^2.2.3",
75
+ "semver": "^6.3.1"
76
+ },
77
+ "engines": {
78
+ "node": ">=6.9.0"
79
+ },
80
+ "funding": {
81
+ "type": "opencollective",
82
+ "url": "https://opencollective.com/babel"
83
+ }
84
+ },
85
+ "node_modules/@babel/generator": {
86
+ "version": "7.29.7",
87
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz",
88
+ "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==",
89
+ "dev": true,
90
+ "license": "MIT",
91
+ "dependencies": {
92
+ "@babel/parser": "^7.29.7",
93
+ "@babel/types": "^7.29.7",
94
+ "@jridgewell/gen-mapping": "^0.3.12",
95
+ "@jridgewell/trace-mapping": "^0.3.28",
96
+ "jsesc": "^3.0.2"
97
+ },
98
+ "engines": {
99
+ "node": ">=6.9.0"
100
+ }
101
+ },
102
+ "node_modules/@babel/helper-compilation-targets": {
103
+ "version": "7.29.7",
104
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
105
+ "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
106
+ "dev": true,
107
+ "license": "MIT",
108
+ "dependencies": {
109
+ "@babel/compat-data": "^7.29.7",
110
+ "@babel/helper-validator-option": "^7.29.7",
111
+ "browserslist": "^4.24.0",
112
+ "lru-cache": "^5.1.1",
113
+ "semver": "^6.3.1"
114
+ },
115
+ "engines": {
116
+ "node": ">=6.9.0"
117
+ }
118
+ },
119
+ "node_modules/@babel/helper-globals": {
120
+ "version": "7.29.7",
121
+ "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
122
+ "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
123
+ "dev": true,
124
+ "license": "MIT",
125
+ "engines": {
126
+ "node": ">=6.9.0"
127
+ }
128
+ },
129
+ "node_modules/@babel/helper-module-imports": {
130
+ "version": "7.29.7",
131
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
132
+ "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
133
+ "dev": true,
134
+ "license": "MIT",
135
+ "dependencies": {
136
+ "@babel/traverse": "^7.29.7",
137
+ "@babel/types": "^7.29.7"
138
+ },
139
+ "engines": {
140
+ "node": ">=6.9.0"
141
+ }
142
+ },
143
+ "node_modules/@babel/helper-module-transforms": {
144
+ "version": "7.29.7",
145
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
146
+ "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
147
+ "dev": true,
148
+ "license": "MIT",
149
+ "dependencies": {
150
+ "@babel/helper-module-imports": "^7.29.7",
151
+ "@babel/helper-validator-identifier": "^7.29.7",
152
+ "@babel/traverse": "^7.29.7"
153
+ },
154
+ "engines": {
155
+ "node": ">=6.9.0"
156
+ },
157
+ "peerDependencies": {
158
+ "@babel/core": "^7.0.0"
159
+ }
160
+ },
161
+ "node_modules/@babel/helper-string-parser": {
162
+ "version": "7.29.7",
163
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
164
+ "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
165
+ "dev": true,
166
+ "license": "MIT",
167
+ "engines": {
168
+ "node": ">=6.9.0"
169
+ }
170
+ },
171
+ "node_modules/@babel/helper-validator-identifier": {
172
+ "version": "7.29.7",
173
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
174
+ "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
175
+ "dev": true,
176
+ "license": "MIT",
177
+ "engines": {
178
+ "node": ">=6.9.0"
179
+ }
180
+ },
181
+ "node_modules/@babel/helper-validator-option": {
182
+ "version": "7.29.7",
183
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
184
+ "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
185
+ "dev": true,
186
+ "license": "MIT",
187
+ "engines": {
188
+ "node": ">=6.9.0"
189
+ }
190
+ },
191
+ "node_modules/@babel/helpers": {
192
+ "version": "7.29.7",
193
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
194
+ "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
195
+ "dev": true,
196
+ "license": "MIT",
197
+ "dependencies": {
198
+ "@babel/template": "^7.29.7",
199
+ "@babel/types": "^7.29.7"
200
+ },
201
+ "engines": {
202
+ "node": ">=6.9.0"
203
+ }
204
+ },
205
+ "node_modules/@babel/parser": {
206
+ "version": "7.29.7",
207
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
208
+ "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
209
+ "dev": true,
210
+ "license": "MIT",
211
+ "dependencies": {
212
+ "@babel/types": "^7.29.7"
213
+ },
214
+ "bin": {
215
+ "parser": "bin/babel-parser.js"
216
+ },
217
+ "engines": {
218
+ "node": ">=6.0.0"
219
+ }
220
+ },
221
+ "node_modules/@babel/template": {
222
+ "version": "7.29.7",
223
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
224
+ "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
225
+ "dev": true,
226
+ "license": "MIT",
227
+ "dependencies": {
228
+ "@babel/code-frame": "^7.29.7",
229
+ "@babel/parser": "^7.29.7",
230
+ "@babel/types": "^7.29.7"
231
+ },
232
+ "engines": {
233
+ "node": ">=6.9.0"
234
+ }
235
+ },
236
+ "node_modules/@babel/traverse": {
237
+ "version": "7.29.7",
238
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz",
239
+ "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==",
240
+ "dev": true,
241
+ "license": "MIT",
242
+ "dependencies": {
243
+ "@babel/code-frame": "^7.29.7",
244
+ "@babel/generator": "^7.29.7",
245
+ "@babel/helper-globals": "^7.29.7",
246
+ "@babel/parser": "^7.29.7",
247
+ "@babel/template": "^7.29.7",
248
+ "@babel/types": "^7.29.7",
249
+ "debug": "^4.3.1"
250
+ },
251
+ "engines": {
252
+ "node": ">=6.9.0"
253
+ }
254
+ },
255
+ "node_modules/@babel/types": {
256
+ "version": "7.29.7",
257
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
258
+ "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
259
+ "dev": true,
260
+ "license": "MIT",
261
+ "dependencies": {
262
+ "@babel/helper-string-parser": "^7.29.7",
263
+ "@babel/helper-validator-identifier": "^7.29.7"
264
+ },
265
+ "engines": {
266
+ "node": ">=6.9.0"
267
+ }
268
+ },
269
+ "node_modules/@emnapi/core": {
270
+ "version": "1.10.0",
271
+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
272
+ "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
273
+ "dev": true,
274
+ "license": "MIT",
275
+ "optional": true,
276
+ "dependencies": {
277
+ "@emnapi/wasi-threads": "1.2.1",
278
+ "tslib": "^2.4.0"
279
+ }
280
+ },
281
+ "node_modules/@emnapi/runtime": {
282
+ "version": "1.10.0",
283
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
284
+ "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
285
+ "dev": true,
286
+ "license": "MIT",
287
+ "optional": true,
288
+ "dependencies": {
289
+ "tslib": "^2.4.0"
290
+ }
291
+ },
292
+ "node_modules/@emnapi/wasi-threads": {
293
+ "version": "1.2.1",
294
+ "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
295
+ "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
296
+ "dev": true,
297
+ "license": "MIT",
298
+ "optional": true,
299
+ "dependencies": {
300
+ "tslib": "^2.4.0"
301
+ }
302
+ },
303
+ "node_modules/@eslint-community/eslint-utils": {
304
+ "version": "4.9.1",
305
+ "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
306
+ "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==",
307
+ "dev": true,
308
+ "license": "MIT",
309
+ "dependencies": {
310
+ "eslint-visitor-keys": "^3.4.3"
311
+ },
312
+ "engines": {
313
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
314
+ },
315
+ "funding": {
316
+ "url": "https://opencollective.com/eslint"
317
+ },
318
+ "peerDependencies": {
319
+ "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
320
+ }
321
+ },
322
+ "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": {
323
+ "version": "3.4.3",
324
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
325
+ "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
326
+ "dev": true,
327
+ "license": "Apache-2.0",
328
+ "engines": {
329
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
330
+ },
331
+ "funding": {
332
+ "url": "https://opencollective.com/eslint"
333
+ }
334
+ },
335
+ "node_modules/@eslint-community/regexpp": {
336
+ "version": "4.12.2",
337
+ "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz",
338
+ "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==",
339
+ "dev": true,
340
+ "license": "MIT",
341
+ "engines": {
342
+ "node": "^12.0.0 || ^14.0.0 || >=16.0.0"
343
+ }
344
+ },
345
+ "node_modules/@eslint/config-array": {
346
+ "version": "0.23.5",
347
+ "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz",
348
+ "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==",
349
+ "dev": true,
350
+ "license": "Apache-2.0",
351
+ "dependencies": {
352
+ "@eslint/object-schema": "^3.0.5",
353
+ "debug": "^4.3.1",
354
+ "minimatch": "^10.2.4"
355
+ },
356
+ "engines": {
357
+ "node": "^20.19.0 || ^22.13.0 || >=24"
358
+ }
359
+ },
360
+ "node_modules/@eslint/config-helpers": {
361
+ "version": "0.6.0",
362
+ "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz",
363
+ "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==",
364
+ "dev": true,
365
+ "license": "Apache-2.0",
366
+ "dependencies": {
367
+ "@eslint/core": "^1.2.1"
368
+ },
369
+ "engines": {
370
+ "node": "^20.19.0 || ^22.13.0 || >=24"
371
+ }
372
+ },
373
+ "node_modules/@eslint/core": {
374
+ "version": "1.2.1",
375
+ "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz",
376
+ "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==",
377
+ "dev": true,
378
+ "license": "Apache-2.0",
379
+ "dependencies": {
380
+ "@types/json-schema": "^7.0.15"
381
+ },
382
+ "engines": {
383
+ "node": "^20.19.0 || ^22.13.0 || >=24"
384
+ }
385
+ },
386
+ "node_modules/@eslint/js": {
387
+ "version": "10.0.1",
388
+ "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz",
389
+ "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==",
390
+ "dev": true,
391
+ "license": "MIT",
392
+ "engines": {
393
+ "node": "^20.19.0 || ^22.13.0 || >=24"
394
+ },
395
+ "funding": {
396
+ "url": "https://eslint.org/donate"
397
+ },
398
+ "peerDependencies": {
399
+ "eslint": "^10.0.0"
400
+ },
401
+ "peerDependenciesMeta": {
402
+ "eslint": {
403
+ "optional": true
404
+ }
405
+ }
406
+ },
407
+ "node_modules/@eslint/object-schema": {
408
+ "version": "3.0.5",
409
+ "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz",
410
+ "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==",
411
+ "dev": true,
412
+ "license": "Apache-2.0",
413
+ "engines": {
414
+ "node": "^20.19.0 || ^22.13.0 || >=24"
415
+ }
416
+ },
417
+ "node_modules/@eslint/plugin-kit": {
418
+ "version": "0.7.2",
419
+ "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz",
420
+ "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==",
421
+ "dev": true,
422
+ "license": "Apache-2.0",
423
+ "dependencies": {
424
+ "@eslint/core": "^1.2.1",
425
+ "levn": "^0.4.1"
426
+ },
427
+ "engines": {
428
+ "node": "^20.19.0 || ^22.13.0 || >=24"
429
+ }
430
+ },
431
+ "node_modules/@humanfs/core": {
432
+ "version": "0.19.2",
433
+ "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
434
+ "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==",
435
+ "dev": true,
436
+ "license": "Apache-2.0",
437
+ "dependencies": {
438
+ "@humanfs/types": "^0.15.0"
439
+ },
440
+ "engines": {
441
+ "node": ">=18.18.0"
442
+ }
443
+ },
444
+ "node_modules/@humanfs/node": {
445
+ "version": "0.16.8",
446
+ "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz",
447
+ "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==",
448
+ "dev": true,
449
+ "license": "Apache-2.0",
450
+ "dependencies": {
451
+ "@humanfs/core": "^0.19.2",
452
+ "@humanfs/types": "^0.15.0",
453
+ "@humanwhocodes/retry": "^0.4.0"
454
+ },
455
+ "engines": {
456
+ "node": ">=18.18.0"
457
+ }
458
+ },
459
+ "node_modules/@humanfs/types": {
460
+ "version": "0.15.0",
461
+ "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz",
462
+ "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==",
463
+ "dev": true,
464
+ "license": "Apache-2.0",
465
+ "engines": {
466
+ "node": ">=18.18.0"
467
+ }
468
+ },
469
+ "node_modules/@humanwhocodes/module-importer": {
470
+ "version": "1.0.1",
471
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
472
+ "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
473
+ "dev": true,
474
+ "license": "Apache-2.0",
475
+ "engines": {
476
+ "node": ">=12.22"
477
+ },
478
+ "funding": {
479
+ "type": "github",
480
+ "url": "https://github.com/sponsors/nzakas"
481
+ }
482
+ },
483
+ "node_modules/@humanwhocodes/retry": {
484
+ "version": "0.4.3",
485
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz",
486
+ "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==",
487
+ "dev": true,
488
+ "license": "Apache-2.0",
489
+ "engines": {
490
+ "node": ">=18.18"
491
+ },
492
+ "funding": {
493
+ "type": "github",
494
+ "url": "https://github.com/sponsors/nzakas"
495
+ }
496
+ },
497
+ "node_modules/@jridgewell/gen-mapping": {
498
+ "version": "0.3.13",
499
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
500
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
501
+ "dev": true,
502
+ "license": "MIT",
503
+ "dependencies": {
504
+ "@jridgewell/sourcemap-codec": "^1.5.0",
505
+ "@jridgewell/trace-mapping": "^0.3.24"
506
+ }
507
+ },
508
+ "node_modules/@jridgewell/remapping": {
509
+ "version": "2.3.5",
510
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
511
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
512
+ "dev": true,
513
+ "license": "MIT",
514
+ "dependencies": {
515
+ "@jridgewell/gen-mapping": "^0.3.5",
516
+ "@jridgewell/trace-mapping": "^0.3.24"
517
+ }
518
+ },
519
+ "node_modules/@jridgewell/resolve-uri": {
520
+ "version": "3.1.2",
521
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
522
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
523
+ "dev": true,
524
+ "license": "MIT",
525
+ "engines": {
526
+ "node": ">=6.0.0"
527
+ }
528
+ },
529
+ "node_modules/@jridgewell/sourcemap-codec": {
530
+ "version": "1.5.5",
531
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
532
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
533
+ "dev": true,
534
+ "license": "MIT"
535
+ },
536
+ "node_modules/@jridgewell/trace-mapping": {
537
+ "version": "0.3.31",
538
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
539
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
540
+ "dev": true,
541
+ "license": "MIT",
542
+ "dependencies": {
543
+ "@jridgewell/resolve-uri": "^3.1.0",
544
+ "@jridgewell/sourcemap-codec": "^1.4.14"
545
+ }
546
+ },
547
+ "node_modules/@kurkle/color": {
548
+ "version": "0.3.4",
549
+ "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
550
+ "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
551
+ "license": "MIT"
552
+ },
553
+ "node_modules/@napi-rs/wasm-runtime": {
554
+ "version": "1.1.4",
555
+ "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
556
+ "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==",
557
+ "dev": true,
558
+ "license": "MIT",
559
+ "optional": true,
560
+ "dependencies": {
561
+ "@tybys/wasm-util": "^0.10.1"
562
+ },
563
+ "funding": {
564
+ "type": "github",
565
+ "url": "https://github.com/sponsors/Brooooooklyn"
566
+ },
567
+ "peerDependencies": {
568
+ "@emnapi/core": "^1.7.1",
569
+ "@emnapi/runtime": "^1.7.1"
570
+ }
571
+ },
572
+ "node_modules/@oxc-project/types": {
573
+ "version": "0.133.0",
574
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz",
575
+ "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==",
576
+ "dev": true,
577
+ "license": "MIT",
578
+ "funding": {
579
+ "url": "https://github.com/sponsors/Boshen"
580
+ }
581
+ },
582
+ "node_modules/@rolldown/binding-android-arm64": {
583
+ "version": "1.0.3",
584
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz",
585
+ "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==",
586
+ "cpu": [
587
+ "arm64"
588
+ ],
589
+ "dev": true,
590
+ "license": "MIT",
591
+ "optional": true,
592
+ "os": [
593
+ "android"
594
+ ],
595
+ "engines": {
596
+ "node": "^20.19.0 || >=22.12.0"
597
+ }
598
+ },
599
+ "node_modules/@rolldown/binding-darwin-arm64": {
600
+ "version": "1.0.3",
601
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz",
602
+ "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==",
603
+ "cpu": [
604
+ "arm64"
605
+ ],
606
+ "dev": true,
607
+ "license": "MIT",
608
+ "optional": true,
609
+ "os": [
610
+ "darwin"
611
+ ],
612
+ "engines": {
613
+ "node": "^20.19.0 || >=22.12.0"
614
+ }
615
+ },
616
+ "node_modules/@rolldown/binding-darwin-x64": {
617
+ "version": "1.0.3",
618
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz",
619
+ "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==",
620
+ "cpu": [
621
+ "x64"
622
+ ],
623
+ "dev": true,
624
+ "license": "MIT",
625
+ "optional": true,
626
+ "os": [
627
+ "darwin"
628
+ ],
629
+ "engines": {
630
+ "node": "^20.19.0 || >=22.12.0"
631
+ }
632
+ },
633
+ "node_modules/@rolldown/binding-freebsd-x64": {
634
+ "version": "1.0.3",
635
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz",
636
+ "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==",
637
+ "cpu": [
638
+ "x64"
639
+ ],
640
+ "dev": true,
641
+ "license": "MIT",
642
+ "optional": true,
643
+ "os": [
644
+ "freebsd"
645
+ ],
646
+ "engines": {
647
+ "node": "^20.19.0 || >=22.12.0"
648
+ }
649
+ },
650
+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
651
+ "version": "1.0.3",
652
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz",
653
+ "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==",
654
+ "cpu": [
655
+ "arm"
656
+ ],
657
+ "dev": true,
658
+ "license": "MIT",
659
+ "optional": true,
660
+ "os": [
661
+ "linux"
662
+ ],
663
+ "engines": {
664
+ "node": "^20.19.0 || >=22.12.0"
665
+ }
666
+ },
667
+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
668
+ "version": "1.0.3",
669
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz",
670
+ "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==",
671
+ "cpu": [
672
+ "arm64"
673
+ ],
674
+ "dev": true,
675
+ "libc": [
676
+ "glibc"
677
+ ],
678
+ "license": "MIT",
679
+ "optional": true,
680
+ "os": [
681
+ "linux"
682
+ ],
683
+ "engines": {
684
+ "node": "^20.19.0 || >=22.12.0"
685
+ }
686
+ },
687
+ "node_modules/@rolldown/binding-linux-arm64-musl": {
688
+ "version": "1.0.3",
689
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz",
690
+ "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==",
691
+ "cpu": [
692
+ "arm64"
693
+ ],
694
+ "dev": true,
695
+ "libc": [
696
+ "musl"
697
+ ],
698
+ "license": "MIT",
699
+ "optional": true,
700
+ "os": [
701
+ "linux"
702
+ ],
703
+ "engines": {
704
+ "node": "^20.19.0 || >=22.12.0"
705
+ }
706
+ },
707
+ "node_modules/@rolldown/binding-linux-ppc64-gnu": {
708
+ "version": "1.0.3",
709
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz",
710
+ "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==",
711
+ "cpu": [
712
+ "ppc64"
713
+ ],
714
+ "dev": true,
715
+ "libc": [
716
+ "glibc"
717
+ ],
718
+ "license": "MIT",
719
+ "optional": true,
720
+ "os": [
721
+ "linux"
722
+ ],
723
+ "engines": {
724
+ "node": "^20.19.0 || >=22.12.0"
725
+ }
726
+ },
727
+ "node_modules/@rolldown/binding-linux-s390x-gnu": {
728
+ "version": "1.0.3",
729
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz",
730
+ "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==",
731
+ "cpu": [
732
+ "s390x"
733
+ ],
734
+ "dev": true,
735
+ "libc": [
736
+ "glibc"
737
+ ],
738
+ "license": "MIT",
739
+ "optional": true,
740
+ "os": [
741
+ "linux"
742
+ ],
743
+ "engines": {
744
+ "node": "^20.19.0 || >=22.12.0"
745
+ }
746
+ },
747
+ "node_modules/@rolldown/binding-linux-x64-gnu": {
748
+ "version": "1.0.3",
749
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz",
750
+ "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==",
751
+ "cpu": [
752
+ "x64"
753
+ ],
754
+ "dev": true,
755
+ "libc": [
756
+ "glibc"
757
+ ],
758
+ "license": "MIT",
759
+ "optional": true,
760
+ "os": [
761
+ "linux"
762
+ ],
763
+ "engines": {
764
+ "node": "^20.19.0 || >=22.12.0"
765
+ }
766
+ },
767
+ "node_modules/@rolldown/binding-linux-x64-musl": {
768
+ "version": "1.0.3",
769
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz",
770
+ "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==",
771
+ "cpu": [
772
+ "x64"
773
+ ],
774
+ "dev": true,
775
+ "libc": [
776
+ "musl"
777
+ ],
778
+ "license": "MIT",
779
+ "optional": true,
780
+ "os": [
781
+ "linux"
782
+ ],
783
+ "engines": {
784
+ "node": "^20.19.0 || >=22.12.0"
785
+ }
786
+ },
787
+ "node_modules/@rolldown/binding-openharmony-arm64": {
788
+ "version": "1.0.3",
789
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz",
790
+ "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==",
791
+ "cpu": [
792
+ "arm64"
793
+ ],
794
+ "dev": true,
795
+ "license": "MIT",
796
+ "optional": true,
797
+ "os": [
798
+ "openharmony"
799
+ ],
800
+ "engines": {
801
+ "node": "^20.19.0 || >=22.12.0"
802
+ }
803
+ },
804
+ "node_modules/@rolldown/binding-wasm32-wasi": {
805
+ "version": "1.0.3",
806
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz",
807
+ "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==",
808
+ "cpu": [
809
+ "wasm32"
810
+ ],
811
+ "dev": true,
812
+ "license": "MIT",
813
+ "optional": true,
814
+ "dependencies": {
815
+ "@emnapi/core": "1.10.0",
816
+ "@emnapi/runtime": "1.10.0",
817
+ "@napi-rs/wasm-runtime": "^1.1.4"
818
+ },
819
+ "engines": {
820
+ "node": "^20.19.0 || >=22.12.0"
821
+ }
822
+ },
823
+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
824
+ "version": "1.0.3",
825
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz",
826
+ "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==",
827
+ "cpu": [
828
+ "arm64"
829
+ ],
830
+ "dev": true,
831
+ "license": "MIT",
832
+ "optional": true,
833
+ "os": [
834
+ "win32"
835
+ ],
836
+ "engines": {
837
+ "node": "^20.19.0 || >=22.12.0"
838
+ }
839
+ },
840
+ "node_modules/@rolldown/binding-win32-x64-msvc": {
841
+ "version": "1.0.3",
842
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz",
843
+ "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==",
844
+ "cpu": [
845
+ "x64"
846
+ ],
847
+ "dev": true,
848
+ "license": "MIT",
849
+ "optional": true,
850
+ "os": [
851
+ "win32"
852
+ ],
853
+ "engines": {
854
+ "node": "^20.19.0 || >=22.12.0"
855
+ }
856
+ },
857
+ "node_modules/@rolldown/pluginutils": {
858
+ "version": "1.0.1",
859
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
860
+ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
861
+ "dev": true,
862
+ "license": "MIT"
863
+ },
864
+ "node_modules/@tybys/wasm-util": {
865
+ "version": "0.10.2",
866
+ "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
867
+ "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==",
868
+ "dev": true,
869
+ "license": "MIT",
870
+ "optional": true,
871
+ "dependencies": {
872
+ "tslib": "^2.4.0"
873
+ }
874
+ },
875
+ "node_modules/@types/esrecurse": {
876
+ "version": "4.3.1",
877
+ "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
878
+ "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==",
879
+ "dev": true,
880
+ "license": "MIT"
881
+ },
882
+ "node_modules/@types/estree": {
883
+ "version": "1.0.9",
884
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
885
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
886
+ "dev": true,
887
+ "license": "MIT"
888
+ },
889
+ "node_modules/@types/json-schema": {
890
+ "version": "7.0.15",
891
+ "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
892
+ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
893
+ "dev": true,
894
+ "license": "MIT"
895
+ },
896
+ "node_modules/@types/react": {
897
+ "version": "19.2.17",
898
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
899
+ "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
900
+ "dev": true,
901
+ "license": "MIT",
902
+ "dependencies": {
903
+ "csstype": "^3.2.2"
904
+ }
905
+ },
906
+ "node_modules/@types/react-dom": {
907
+ "version": "19.2.3",
908
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
909
+ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
910
+ "dev": true,
911
+ "license": "MIT",
912
+ "peerDependencies": {
913
+ "@types/react": "^19.2.0"
914
+ }
915
+ },
916
+ "node_modules/@vitejs/plugin-react": {
917
+ "version": "6.0.2",
918
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.2.tgz",
919
+ "integrity": "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==",
920
+ "dev": true,
921
+ "license": "MIT",
922
+ "dependencies": {
923
+ "@rolldown/pluginutils": "^1.0.0"
924
+ },
925
+ "engines": {
926
+ "node": "^20.19.0 || >=22.12.0"
927
+ },
928
+ "peerDependencies": {
929
+ "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0",
930
+ "babel-plugin-react-compiler": "^1.0.0",
931
+ "vite": "^8.0.0"
932
+ },
933
+ "peerDependenciesMeta": {
934
+ "@rolldown/plugin-babel": {
935
+ "optional": true
936
+ },
937
+ "babel-plugin-react-compiler": {
938
+ "optional": true
939
+ }
940
+ }
941
+ },
942
+ "node_modules/acorn": {
943
+ "version": "8.16.0",
944
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
945
+ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
946
+ "dev": true,
947
+ "license": "MIT",
948
+ "bin": {
949
+ "acorn": "bin/acorn"
950
+ },
951
+ "engines": {
952
+ "node": ">=0.4.0"
953
+ }
954
+ },
955
+ "node_modules/acorn-jsx": {
956
+ "version": "5.3.2",
957
+ "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
958
+ "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
959
+ "dev": true,
960
+ "license": "MIT",
961
+ "peerDependencies": {
962
+ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
963
+ }
964
+ },
965
+ "node_modules/ajv": {
966
+ "version": "6.15.0",
967
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
968
+ "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
969
+ "dev": true,
970
+ "license": "MIT",
971
+ "dependencies": {
972
+ "fast-deep-equal": "^3.1.1",
973
+ "fast-json-stable-stringify": "^2.0.0",
974
+ "json-schema-traverse": "^0.4.1",
975
+ "uri-js": "^4.2.2"
976
+ },
977
+ "funding": {
978
+ "type": "github",
979
+ "url": "https://github.com/sponsors/epoberezkin"
980
+ }
981
+ },
982
+ "node_modules/balanced-match": {
983
+ "version": "4.0.4",
984
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
985
+ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
986
+ "dev": true,
987
+ "license": "MIT",
988
+ "engines": {
989
+ "node": "18 || 20 || >=22"
990
+ }
991
+ },
992
+ "node_modules/baseline-browser-mapping": {
993
+ "version": "2.10.35",
994
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.35.tgz",
995
+ "integrity": "sha512-honAfLBde0HAFLdNyBEfuuENkF6zR+ozxqxa/2zJKHBe1qzLqyTSeRKpdPEHAP03rlDGyQOPnCSxnVpVqQo9Mg==",
996
+ "dev": true,
997
+ "license": "Apache-2.0",
998
+ "bin": {
999
+ "baseline-browser-mapping": "dist/cli.cjs"
1000
+ },
1001
+ "engines": {
1002
+ "node": ">=6.0.0"
1003
+ }
1004
+ },
1005
+ "node_modules/brace-expansion": {
1006
+ "version": "5.0.6",
1007
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
1008
+ "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
1009
+ "dev": true,
1010
+ "license": "MIT",
1011
+ "dependencies": {
1012
+ "balanced-match": "^4.0.2"
1013
+ },
1014
+ "engines": {
1015
+ "node": "18 || 20 || >=22"
1016
+ }
1017
+ },
1018
+ "node_modules/browserslist": {
1019
+ "version": "4.28.2",
1020
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
1021
+ "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==",
1022
+ "dev": true,
1023
+ "funding": [
1024
+ {
1025
+ "type": "opencollective",
1026
+ "url": "https://opencollective.com/browserslist"
1027
+ },
1028
+ {
1029
+ "type": "tidelift",
1030
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
1031
+ },
1032
+ {
1033
+ "type": "github",
1034
+ "url": "https://github.com/sponsors/ai"
1035
+ }
1036
+ ],
1037
+ "license": "MIT",
1038
+ "dependencies": {
1039
+ "baseline-browser-mapping": "^2.10.12",
1040
+ "caniuse-lite": "^1.0.30001782",
1041
+ "electron-to-chromium": "^1.5.328",
1042
+ "node-releases": "^2.0.36",
1043
+ "update-browserslist-db": "^1.2.3"
1044
+ },
1045
+ "bin": {
1046
+ "browserslist": "cli.js"
1047
+ },
1048
+ "engines": {
1049
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
1050
+ }
1051
+ },
1052
+ "node_modules/caniuse-lite": {
1053
+ "version": "1.0.30001797",
1054
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001797.tgz",
1055
+ "integrity": "sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w==",
1056
+ "dev": true,
1057
+ "funding": [
1058
+ {
1059
+ "type": "opencollective",
1060
+ "url": "https://opencollective.com/browserslist"
1061
+ },
1062
+ {
1063
+ "type": "tidelift",
1064
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
1065
+ },
1066
+ {
1067
+ "type": "github",
1068
+ "url": "https://github.com/sponsors/ai"
1069
+ }
1070
+ ],
1071
+ "license": "CC-BY-4.0"
1072
+ },
1073
+ "node_modules/chart.js": {
1074
+ "version": "4.5.1",
1075
+ "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
1076
+ "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
1077
+ "license": "MIT",
1078
+ "dependencies": {
1079
+ "@kurkle/color": "^0.3.0"
1080
+ },
1081
+ "engines": {
1082
+ "pnpm": ">=8"
1083
+ }
1084
+ },
1085
+ "node_modules/convert-source-map": {
1086
+ "version": "2.0.0",
1087
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
1088
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
1089
+ "dev": true,
1090
+ "license": "MIT"
1091
+ },
1092
+ "node_modules/cross-spawn": {
1093
+ "version": "7.0.6",
1094
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
1095
+ "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
1096
+ "dev": true,
1097
+ "license": "MIT",
1098
+ "dependencies": {
1099
+ "path-key": "^3.1.0",
1100
+ "shebang-command": "^2.0.0",
1101
+ "which": "^2.0.1"
1102
+ },
1103
+ "engines": {
1104
+ "node": ">= 8"
1105
+ }
1106
+ },
1107
+ "node_modules/csstype": {
1108
+ "version": "3.2.3",
1109
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
1110
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
1111
+ "dev": true,
1112
+ "license": "MIT"
1113
+ },
1114
+ "node_modules/debug": {
1115
+ "version": "4.4.3",
1116
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
1117
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
1118
+ "dev": true,
1119
+ "license": "MIT",
1120
+ "dependencies": {
1121
+ "ms": "^2.1.3"
1122
+ },
1123
+ "engines": {
1124
+ "node": ">=6.0"
1125
+ },
1126
+ "peerDependenciesMeta": {
1127
+ "supports-color": {
1128
+ "optional": true
1129
+ }
1130
+ }
1131
+ },
1132
+ "node_modules/deep-is": {
1133
+ "version": "0.1.4",
1134
+ "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
1135
+ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
1136
+ "dev": true,
1137
+ "license": "MIT"
1138
+ },
1139
+ "node_modules/detect-libc": {
1140
+ "version": "2.1.2",
1141
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
1142
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
1143
+ "dev": true,
1144
+ "license": "Apache-2.0",
1145
+ "engines": {
1146
+ "node": ">=8"
1147
+ }
1148
+ },
1149
+ "node_modules/electron-to-chromium": {
1150
+ "version": "1.5.370",
1151
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.370.tgz",
1152
+ "integrity": "sha512-D5tSHJReAb/Kf3Hu9F/GO4lJuSWzEWHwvQ/kKSUP7pimNgvxkSKj+gUQhHpKKACwrin7rS3byU7IxreF56rl5g==",
1153
+ "dev": true,
1154
+ "license": "ISC"
1155
+ },
1156
+ "node_modules/escalade": {
1157
+ "version": "3.2.0",
1158
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
1159
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
1160
+ "dev": true,
1161
+ "license": "MIT",
1162
+ "engines": {
1163
+ "node": ">=6"
1164
+ }
1165
+ },
1166
+ "node_modules/escape-string-regexp": {
1167
+ "version": "4.0.0",
1168
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
1169
+ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
1170
+ "dev": true,
1171
+ "license": "MIT",
1172
+ "engines": {
1173
+ "node": ">=10"
1174
+ },
1175
+ "funding": {
1176
+ "url": "https://github.com/sponsors/sindresorhus"
1177
+ }
1178
+ },
1179
+ "node_modules/eslint": {
1180
+ "version": "10.4.1",
1181
+ "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.4.1.tgz",
1182
+ "integrity": "sha512-AyIKhnOBuOAdueD7RB3xB+YeAWScb9jHsJBgH2Hcde8InP5JYhqrRR6iTMHyTEwgENK54Cp44e4v8BwNhsuHuw==",
1183
+ "dev": true,
1184
+ "license": "MIT",
1185
+ "dependencies": {
1186
+ "@eslint-community/eslint-utils": "^4.8.0",
1187
+ "@eslint-community/regexpp": "^4.12.2",
1188
+ "@eslint/config-array": "^0.23.5",
1189
+ "@eslint/config-helpers": "^0.6.0",
1190
+ "@eslint/core": "^1.2.1",
1191
+ "@eslint/plugin-kit": "^0.7.2",
1192
+ "@humanfs/node": "^0.16.6",
1193
+ "@humanwhocodes/module-importer": "^1.0.1",
1194
+ "@humanwhocodes/retry": "^0.4.2",
1195
+ "@types/estree": "^1.0.6",
1196
+ "ajv": "^6.14.0",
1197
+ "cross-spawn": "^7.0.6",
1198
+ "debug": "^4.3.2",
1199
+ "escape-string-regexp": "^4.0.0",
1200
+ "eslint-scope": "^9.1.2",
1201
+ "eslint-visitor-keys": "^5.0.1",
1202
+ "espree": "^11.2.0",
1203
+ "esquery": "^1.7.0",
1204
+ "esutils": "^2.0.2",
1205
+ "fast-deep-equal": "^3.1.3",
1206
+ "file-entry-cache": "^8.0.0",
1207
+ "find-up": "^5.0.0",
1208
+ "glob-parent": "^6.0.2",
1209
+ "ignore": "^5.2.0",
1210
+ "imurmurhash": "^0.1.4",
1211
+ "is-glob": "^4.0.0",
1212
+ "json-stable-stringify-without-jsonify": "^1.0.1",
1213
+ "minimatch": "^10.2.4",
1214
+ "natural-compare": "^1.4.0",
1215
+ "optionator": "^0.9.3"
1216
+ },
1217
+ "bin": {
1218
+ "eslint": "bin/eslint.js"
1219
+ },
1220
+ "engines": {
1221
+ "node": "^20.19.0 || ^22.13.0 || >=24"
1222
+ },
1223
+ "funding": {
1224
+ "url": "https://eslint.org/donate"
1225
+ },
1226
+ "peerDependencies": {
1227
+ "jiti": "*"
1228
+ },
1229
+ "peerDependenciesMeta": {
1230
+ "jiti": {
1231
+ "optional": true
1232
+ }
1233
+ }
1234
+ },
1235
+ "node_modules/eslint-plugin-react-hooks": {
1236
+ "version": "7.1.1",
1237
+ "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz",
1238
+ "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==",
1239
+ "dev": true,
1240
+ "license": "MIT",
1241
+ "dependencies": {
1242
+ "@babel/core": "^7.24.4",
1243
+ "@babel/parser": "^7.24.4",
1244
+ "hermes-parser": "^0.25.1",
1245
+ "zod": "^3.25.0 || ^4.0.0",
1246
+ "zod-validation-error": "^3.5.0 || ^4.0.0"
1247
+ },
1248
+ "engines": {
1249
+ "node": ">=18"
1250
+ },
1251
+ "peerDependencies": {
1252
+ "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0"
1253
+ }
1254
+ },
1255
+ "node_modules/eslint-plugin-react-refresh": {
1256
+ "version": "0.5.2",
1257
+ "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.2.tgz",
1258
+ "integrity": "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==",
1259
+ "dev": true,
1260
+ "license": "MIT",
1261
+ "peerDependencies": {
1262
+ "eslint": "^9 || ^10"
1263
+ }
1264
+ },
1265
+ "node_modules/eslint-scope": {
1266
+ "version": "9.1.2",
1267
+ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz",
1268
+ "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==",
1269
+ "dev": true,
1270
+ "license": "BSD-2-Clause",
1271
+ "dependencies": {
1272
+ "@types/esrecurse": "^4.3.1",
1273
+ "@types/estree": "^1.0.8",
1274
+ "esrecurse": "^4.3.0",
1275
+ "estraverse": "^5.2.0"
1276
+ },
1277
+ "engines": {
1278
+ "node": "^20.19.0 || ^22.13.0 || >=24"
1279
+ },
1280
+ "funding": {
1281
+ "url": "https://opencollective.com/eslint"
1282
+ }
1283
+ },
1284
+ "node_modules/eslint-visitor-keys": {
1285
+ "version": "5.0.1",
1286
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
1287
+ "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
1288
+ "dev": true,
1289
+ "license": "Apache-2.0",
1290
+ "engines": {
1291
+ "node": "^20.19.0 || ^22.13.0 || >=24"
1292
+ },
1293
+ "funding": {
1294
+ "url": "https://opencollective.com/eslint"
1295
+ }
1296
+ },
1297
+ "node_modules/espree": {
1298
+ "version": "11.2.0",
1299
+ "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz",
1300
+ "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==",
1301
+ "dev": true,
1302
+ "license": "BSD-2-Clause",
1303
+ "dependencies": {
1304
+ "acorn": "^8.16.0",
1305
+ "acorn-jsx": "^5.3.2",
1306
+ "eslint-visitor-keys": "^5.0.1"
1307
+ },
1308
+ "engines": {
1309
+ "node": "^20.19.0 || ^22.13.0 || >=24"
1310
+ },
1311
+ "funding": {
1312
+ "url": "https://opencollective.com/eslint"
1313
+ }
1314
+ },
1315
+ "node_modules/esquery": {
1316
+ "version": "1.7.0",
1317
+ "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
1318
+ "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==",
1319
+ "dev": true,
1320
+ "license": "BSD-3-Clause",
1321
+ "dependencies": {
1322
+ "estraverse": "^5.1.0"
1323
+ },
1324
+ "engines": {
1325
+ "node": ">=0.10"
1326
+ }
1327
+ },
1328
+ "node_modules/esrecurse": {
1329
+ "version": "4.3.0",
1330
+ "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
1331
+ "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
1332
+ "dev": true,
1333
+ "license": "BSD-2-Clause",
1334
+ "dependencies": {
1335
+ "estraverse": "^5.2.0"
1336
+ },
1337
+ "engines": {
1338
+ "node": ">=4.0"
1339
+ }
1340
+ },
1341
+ "node_modules/estraverse": {
1342
+ "version": "5.3.0",
1343
+ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
1344
+ "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
1345
+ "dev": true,
1346
+ "license": "BSD-2-Clause",
1347
+ "engines": {
1348
+ "node": ">=4.0"
1349
+ }
1350
+ },
1351
+ "node_modules/esutils": {
1352
+ "version": "2.0.3",
1353
+ "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
1354
+ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
1355
+ "dev": true,
1356
+ "license": "BSD-2-Clause",
1357
+ "engines": {
1358
+ "node": ">=0.10.0"
1359
+ }
1360
+ },
1361
+ "node_modules/fast-deep-equal": {
1362
+ "version": "3.1.3",
1363
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
1364
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
1365
+ "dev": true,
1366
+ "license": "MIT"
1367
+ },
1368
+ "node_modules/fast-json-stable-stringify": {
1369
+ "version": "2.1.0",
1370
+ "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
1371
+ "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
1372
+ "dev": true,
1373
+ "license": "MIT"
1374
+ },
1375
+ "node_modules/fast-levenshtein": {
1376
+ "version": "2.0.6",
1377
+ "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
1378
+ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
1379
+ "dev": true,
1380
+ "license": "MIT"
1381
+ },
1382
+ "node_modules/fdir": {
1383
+ "version": "6.5.0",
1384
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
1385
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
1386
+ "dev": true,
1387
+ "license": "MIT",
1388
+ "engines": {
1389
+ "node": ">=12.0.0"
1390
+ },
1391
+ "peerDependencies": {
1392
+ "picomatch": "^3 || ^4"
1393
+ },
1394
+ "peerDependenciesMeta": {
1395
+ "picomatch": {
1396
+ "optional": true
1397
+ }
1398
+ }
1399
+ },
1400
+ "node_modules/file-entry-cache": {
1401
+ "version": "8.0.0",
1402
+ "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
1403
+ "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==",
1404
+ "dev": true,
1405
+ "license": "MIT",
1406
+ "dependencies": {
1407
+ "flat-cache": "^4.0.0"
1408
+ },
1409
+ "engines": {
1410
+ "node": ">=16.0.0"
1411
+ }
1412
+ },
1413
+ "node_modules/find-up": {
1414
+ "version": "5.0.0",
1415
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
1416
+ "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
1417
+ "dev": true,
1418
+ "license": "MIT",
1419
+ "dependencies": {
1420
+ "locate-path": "^6.0.0",
1421
+ "path-exists": "^4.0.0"
1422
+ },
1423
+ "engines": {
1424
+ "node": ">=10"
1425
+ },
1426
+ "funding": {
1427
+ "url": "https://github.com/sponsors/sindresorhus"
1428
+ }
1429
+ },
1430
+ "node_modules/flat-cache": {
1431
+ "version": "4.0.1",
1432
+ "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
1433
+ "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==",
1434
+ "dev": true,
1435
+ "license": "MIT",
1436
+ "dependencies": {
1437
+ "flatted": "^3.2.9",
1438
+ "keyv": "^4.5.4"
1439
+ },
1440
+ "engines": {
1441
+ "node": ">=16"
1442
+ }
1443
+ },
1444
+ "node_modules/flatted": {
1445
+ "version": "3.4.2",
1446
+ "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
1447
+ "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
1448
+ "dev": true,
1449
+ "license": "ISC"
1450
+ },
1451
+ "node_modules/fsevents": {
1452
+ "version": "2.3.3",
1453
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
1454
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
1455
+ "dev": true,
1456
+ "hasInstallScript": true,
1457
+ "license": "MIT",
1458
+ "optional": true,
1459
+ "os": [
1460
+ "darwin"
1461
+ ],
1462
+ "engines": {
1463
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
1464
+ }
1465
+ },
1466
+ "node_modules/gensync": {
1467
+ "version": "1.0.0-beta.2",
1468
+ "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
1469
+ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
1470
+ "dev": true,
1471
+ "license": "MIT",
1472
+ "engines": {
1473
+ "node": ">=6.9.0"
1474
+ }
1475
+ },
1476
+ "node_modules/glob-parent": {
1477
+ "version": "6.0.2",
1478
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
1479
+ "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
1480
+ "dev": true,
1481
+ "license": "ISC",
1482
+ "dependencies": {
1483
+ "is-glob": "^4.0.3"
1484
+ },
1485
+ "engines": {
1486
+ "node": ">=10.13.0"
1487
+ }
1488
+ },
1489
+ "node_modules/globals": {
1490
+ "version": "17.6.0",
1491
+ "resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz",
1492
+ "integrity": "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==",
1493
+ "dev": true,
1494
+ "license": "MIT",
1495
+ "engines": {
1496
+ "node": ">=18"
1497
+ },
1498
+ "funding": {
1499
+ "url": "https://github.com/sponsors/sindresorhus"
1500
+ }
1501
+ },
1502
+ "node_modules/hermes-estree": {
1503
+ "version": "0.25.1",
1504
+ "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
1505
+ "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==",
1506
+ "dev": true,
1507
+ "license": "MIT"
1508
+ },
1509
+ "node_modules/hermes-parser": {
1510
+ "version": "0.25.1",
1511
+ "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz",
1512
+ "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==",
1513
+ "dev": true,
1514
+ "license": "MIT",
1515
+ "dependencies": {
1516
+ "hermes-estree": "0.25.1"
1517
+ }
1518
+ },
1519
+ "node_modules/ignore": {
1520
+ "version": "5.3.2",
1521
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
1522
+ "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
1523
+ "dev": true,
1524
+ "license": "MIT",
1525
+ "engines": {
1526
+ "node": ">= 4"
1527
+ }
1528
+ },
1529
+ "node_modules/imurmurhash": {
1530
+ "version": "0.1.4",
1531
+ "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
1532
+ "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
1533
+ "dev": true,
1534
+ "license": "MIT",
1535
+ "engines": {
1536
+ "node": ">=0.8.19"
1537
+ }
1538
+ },
1539
+ "node_modules/is-extglob": {
1540
+ "version": "2.1.1",
1541
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
1542
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
1543
+ "dev": true,
1544
+ "license": "MIT",
1545
+ "engines": {
1546
+ "node": ">=0.10.0"
1547
+ }
1548
+ },
1549
+ "node_modules/is-glob": {
1550
+ "version": "4.0.3",
1551
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
1552
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
1553
+ "dev": true,
1554
+ "license": "MIT",
1555
+ "dependencies": {
1556
+ "is-extglob": "^2.1.1"
1557
+ },
1558
+ "engines": {
1559
+ "node": ">=0.10.0"
1560
+ }
1561
+ },
1562
+ "node_modules/isexe": {
1563
+ "version": "2.0.0",
1564
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
1565
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
1566
+ "dev": true,
1567
+ "license": "ISC"
1568
+ },
1569
+ "node_modules/js-tokens": {
1570
+ "version": "4.0.0",
1571
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
1572
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
1573
+ "dev": true,
1574
+ "license": "MIT"
1575
+ },
1576
+ "node_modules/jsesc": {
1577
+ "version": "3.1.0",
1578
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
1579
+ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
1580
+ "dev": true,
1581
+ "license": "MIT",
1582
+ "bin": {
1583
+ "jsesc": "bin/jsesc"
1584
+ },
1585
+ "engines": {
1586
+ "node": ">=6"
1587
+ }
1588
+ },
1589
+ "node_modules/json-buffer": {
1590
+ "version": "3.0.1",
1591
+ "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
1592
+ "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
1593
+ "dev": true,
1594
+ "license": "MIT"
1595
+ },
1596
+ "node_modules/json-schema-traverse": {
1597
+ "version": "0.4.1",
1598
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
1599
+ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
1600
+ "dev": true,
1601
+ "license": "MIT"
1602
+ },
1603
+ "node_modules/json-stable-stringify-without-jsonify": {
1604
+ "version": "1.0.1",
1605
+ "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
1606
+ "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
1607
+ "dev": true,
1608
+ "license": "MIT"
1609
+ },
1610
+ "node_modules/json5": {
1611
+ "version": "2.2.3",
1612
+ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
1613
+ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
1614
+ "dev": true,
1615
+ "license": "MIT",
1616
+ "bin": {
1617
+ "json5": "lib/cli.js"
1618
+ },
1619
+ "engines": {
1620
+ "node": ">=6"
1621
+ }
1622
+ },
1623
+ "node_modules/keyv": {
1624
+ "version": "4.5.4",
1625
+ "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
1626
+ "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
1627
+ "dev": true,
1628
+ "license": "MIT",
1629
+ "dependencies": {
1630
+ "json-buffer": "3.0.1"
1631
+ }
1632
+ },
1633
+ "node_modules/levn": {
1634
+ "version": "0.4.1",
1635
+ "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
1636
+ "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
1637
+ "dev": true,
1638
+ "license": "MIT",
1639
+ "dependencies": {
1640
+ "prelude-ls": "^1.2.1",
1641
+ "type-check": "~0.4.0"
1642
+ },
1643
+ "engines": {
1644
+ "node": ">= 0.8.0"
1645
+ }
1646
+ },
1647
+ "node_modules/lightningcss": {
1648
+ "version": "1.32.0",
1649
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
1650
+ "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
1651
+ "dev": true,
1652
+ "license": "MPL-2.0",
1653
+ "dependencies": {
1654
+ "detect-libc": "^2.0.3"
1655
+ },
1656
+ "engines": {
1657
+ "node": ">= 12.0.0"
1658
+ },
1659
+ "funding": {
1660
+ "type": "opencollective",
1661
+ "url": "https://opencollective.com/parcel"
1662
+ },
1663
+ "optionalDependencies": {
1664
+ "lightningcss-android-arm64": "1.32.0",
1665
+ "lightningcss-darwin-arm64": "1.32.0",
1666
+ "lightningcss-darwin-x64": "1.32.0",
1667
+ "lightningcss-freebsd-x64": "1.32.0",
1668
+ "lightningcss-linux-arm-gnueabihf": "1.32.0",
1669
+ "lightningcss-linux-arm64-gnu": "1.32.0",
1670
+ "lightningcss-linux-arm64-musl": "1.32.0",
1671
+ "lightningcss-linux-x64-gnu": "1.32.0",
1672
+ "lightningcss-linux-x64-musl": "1.32.0",
1673
+ "lightningcss-win32-arm64-msvc": "1.32.0",
1674
+ "lightningcss-win32-x64-msvc": "1.32.0"
1675
+ }
1676
+ },
1677
+ "node_modules/lightningcss-android-arm64": {
1678
+ "version": "1.32.0",
1679
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
1680
+ "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
1681
+ "cpu": [
1682
+ "arm64"
1683
+ ],
1684
+ "dev": true,
1685
+ "license": "MPL-2.0",
1686
+ "optional": true,
1687
+ "os": [
1688
+ "android"
1689
+ ],
1690
+ "engines": {
1691
+ "node": ">= 12.0.0"
1692
+ },
1693
+ "funding": {
1694
+ "type": "opencollective",
1695
+ "url": "https://opencollective.com/parcel"
1696
+ }
1697
+ },
1698
+ "node_modules/lightningcss-darwin-arm64": {
1699
+ "version": "1.32.0",
1700
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
1701
+ "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
1702
+ "cpu": [
1703
+ "arm64"
1704
+ ],
1705
+ "dev": true,
1706
+ "license": "MPL-2.0",
1707
+ "optional": true,
1708
+ "os": [
1709
+ "darwin"
1710
+ ],
1711
+ "engines": {
1712
+ "node": ">= 12.0.0"
1713
+ },
1714
+ "funding": {
1715
+ "type": "opencollective",
1716
+ "url": "https://opencollective.com/parcel"
1717
+ }
1718
+ },
1719
+ "node_modules/lightningcss-darwin-x64": {
1720
+ "version": "1.32.0",
1721
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
1722
+ "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
1723
+ "cpu": [
1724
+ "x64"
1725
+ ],
1726
+ "dev": true,
1727
+ "license": "MPL-2.0",
1728
+ "optional": true,
1729
+ "os": [
1730
+ "darwin"
1731
+ ],
1732
+ "engines": {
1733
+ "node": ">= 12.0.0"
1734
+ },
1735
+ "funding": {
1736
+ "type": "opencollective",
1737
+ "url": "https://opencollective.com/parcel"
1738
+ }
1739
+ },
1740
+ "node_modules/lightningcss-freebsd-x64": {
1741
+ "version": "1.32.0",
1742
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
1743
+ "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
1744
+ "cpu": [
1745
+ "x64"
1746
+ ],
1747
+ "dev": true,
1748
+ "license": "MPL-2.0",
1749
+ "optional": true,
1750
+ "os": [
1751
+ "freebsd"
1752
+ ],
1753
+ "engines": {
1754
+ "node": ">= 12.0.0"
1755
+ },
1756
+ "funding": {
1757
+ "type": "opencollective",
1758
+ "url": "https://opencollective.com/parcel"
1759
+ }
1760
+ },
1761
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
1762
+ "version": "1.32.0",
1763
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
1764
+ "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
1765
+ "cpu": [
1766
+ "arm"
1767
+ ],
1768
+ "dev": true,
1769
+ "license": "MPL-2.0",
1770
+ "optional": true,
1771
+ "os": [
1772
+ "linux"
1773
+ ],
1774
+ "engines": {
1775
+ "node": ">= 12.0.0"
1776
+ },
1777
+ "funding": {
1778
+ "type": "opencollective",
1779
+ "url": "https://opencollective.com/parcel"
1780
+ }
1781
+ },
1782
+ "node_modules/lightningcss-linux-arm64-gnu": {
1783
+ "version": "1.32.0",
1784
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
1785
+ "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
1786
+ "cpu": [
1787
+ "arm64"
1788
+ ],
1789
+ "dev": true,
1790
+ "libc": [
1791
+ "glibc"
1792
+ ],
1793
+ "license": "MPL-2.0",
1794
+ "optional": true,
1795
+ "os": [
1796
+ "linux"
1797
+ ],
1798
+ "engines": {
1799
+ "node": ">= 12.0.0"
1800
+ },
1801
+ "funding": {
1802
+ "type": "opencollective",
1803
+ "url": "https://opencollective.com/parcel"
1804
+ }
1805
+ },
1806
+ "node_modules/lightningcss-linux-arm64-musl": {
1807
+ "version": "1.32.0",
1808
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
1809
+ "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
1810
+ "cpu": [
1811
+ "arm64"
1812
+ ],
1813
+ "dev": true,
1814
+ "libc": [
1815
+ "musl"
1816
+ ],
1817
+ "license": "MPL-2.0",
1818
+ "optional": true,
1819
+ "os": [
1820
+ "linux"
1821
+ ],
1822
+ "engines": {
1823
+ "node": ">= 12.0.0"
1824
+ },
1825
+ "funding": {
1826
+ "type": "opencollective",
1827
+ "url": "https://opencollective.com/parcel"
1828
+ }
1829
+ },
1830
+ "node_modules/lightningcss-linux-x64-gnu": {
1831
+ "version": "1.32.0",
1832
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
1833
+ "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
1834
+ "cpu": [
1835
+ "x64"
1836
+ ],
1837
+ "dev": true,
1838
+ "libc": [
1839
+ "glibc"
1840
+ ],
1841
+ "license": "MPL-2.0",
1842
+ "optional": true,
1843
+ "os": [
1844
+ "linux"
1845
+ ],
1846
+ "engines": {
1847
+ "node": ">= 12.0.0"
1848
+ },
1849
+ "funding": {
1850
+ "type": "opencollective",
1851
+ "url": "https://opencollective.com/parcel"
1852
+ }
1853
+ },
1854
+ "node_modules/lightningcss-linux-x64-musl": {
1855
+ "version": "1.32.0",
1856
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
1857
+ "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
1858
+ "cpu": [
1859
+ "x64"
1860
+ ],
1861
+ "dev": true,
1862
+ "libc": [
1863
+ "musl"
1864
+ ],
1865
+ "license": "MPL-2.0",
1866
+ "optional": true,
1867
+ "os": [
1868
+ "linux"
1869
+ ],
1870
+ "engines": {
1871
+ "node": ">= 12.0.0"
1872
+ },
1873
+ "funding": {
1874
+ "type": "opencollective",
1875
+ "url": "https://opencollective.com/parcel"
1876
+ }
1877
+ },
1878
+ "node_modules/lightningcss-win32-arm64-msvc": {
1879
+ "version": "1.32.0",
1880
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
1881
+ "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
1882
+ "cpu": [
1883
+ "arm64"
1884
+ ],
1885
+ "dev": true,
1886
+ "license": "MPL-2.0",
1887
+ "optional": true,
1888
+ "os": [
1889
+ "win32"
1890
+ ],
1891
+ "engines": {
1892
+ "node": ">= 12.0.0"
1893
+ },
1894
+ "funding": {
1895
+ "type": "opencollective",
1896
+ "url": "https://opencollective.com/parcel"
1897
+ }
1898
+ },
1899
+ "node_modules/lightningcss-win32-x64-msvc": {
1900
+ "version": "1.32.0",
1901
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
1902
+ "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
1903
+ "cpu": [
1904
+ "x64"
1905
+ ],
1906
+ "dev": true,
1907
+ "license": "MPL-2.0",
1908
+ "optional": true,
1909
+ "os": [
1910
+ "win32"
1911
+ ],
1912
+ "engines": {
1913
+ "node": ">= 12.0.0"
1914
+ },
1915
+ "funding": {
1916
+ "type": "opencollective",
1917
+ "url": "https://opencollective.com/parcel"
1918
+ }
1919
+ },
1920
+ "node_modules/locate-path": {
1921
+ "version": "6.0.0",
1922
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
1923
+ "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
1924
+ "dev": true,
1925
+ "license": "MIT",
1926
+ "dependencies": {
1927
+ "p-locate": "^5.0.0"
1928
+ },
1929
+ "engines": {
1930
+ "node": ">=10"
1931
+ },
1932
+ "funding": {
1933
+ "url": "https://github.com/sponsors/sindresorhus"
1934
+ }
1935
+ },
1936
+ "node_modules/lru-cache": {
1937
+ "version": "5.1.1",
1938
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
1939
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
1940
+ "dev": true,
1941
+ "license": "ISC",
1942
+ "dependencies": {
1943
+ "yallist": "^3.0.2"
1944
+ }
1945
+ },
1946
+ "node_modules/lucide-react": {
1947
+ "version": "1.17.0",
1948
+ "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.17.0.tgz",
1949
+ "integrity": "sha512-9FA9evdox/JQL5PT57fdA1x/yg8T7knJ98+zjTL3UfKza6pflQUUh3XtaQIHKvnsJw1lmsEyHVlt5jchYxOQ5w==",
1950
+ "license": "ISC",
1951
+ "peerDependencies": {
1952
+ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
1953
+ }
1954
+ },
1955
+ "node_modules/minimatch": {
1956
+ "version": "10.2.5",
1957
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
1958
+ "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
1959
+ "dev": true,
1960
+ "license": "BlueOak-1.0.0",
1961
+ "dependencies": {
1962
+ "brace-expansion": "^5.0.5"
1963
+ },
1964
+ "engines": {
1965
+ "node": "18 || 20 || >=22"
1966
+ },
1967
+ "funding": {
1968
+ "url": "https://github.com/sponsors/isaacs"
1969
+ }
1970
+ },
1971
+ "node_modules/ms": {
1972
+ "version": "2.1.3",
1973
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
1974
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
1975
+ "dev": true,
1976
+ "license": "MIT"
1977
+ },
1978
+ "node_modules/nanoid": {
1979
+ "version": "3.3.12",
1980
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
1981
+ "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
1982
+ "dev": true,
1983
+ "funding": [
1984
+ {
1985
+ "type": "github",
1986
+ "url": "https://github.com/sponsors/ai"
1987
+ }
1988
+ ],
1989
+ "license": "MIT",
1990
+ "bin": {
1991
+ "nanoid": "bin/nanoid.cjs"
1992
+ },
1993
+ "engines": {
1994
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
1995
+ }
1996
+ },
1997
+ "node_modules/natural-compare": {
1998
+ "version": "1.4.0",
1999
+ "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
2000
+ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
2001
+ "dev": true,
2002
+ "license": "MIT"
2003
+ },
2004
+ "node_modules/node-releases": {
2005
+ "version": "2.0.47",
2006
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz",
2007
+ "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==",
2008
+ "dev": true,
2009
+ "license": "MIT",
2010
+ "engines": {
2011
+ "node": ">=18"
2012
+ }
2013
+ },
2014
+ "node_modules/optionator": {
2015
+ "version": "0.9.4",
2016
+ "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
2017
+ "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
2018
+ "dev": true,
2019
+ "license": "MIT",
2020
+ "dependencies": {
2021
+ "deep-is": "^0.1.3",
2022
+ "fast-levenshtein": "^2.0.6",
2023
+ "levn": "^0.4.1",
2024
+ "prelude-ls": "^1.2.1",
2025
+ "type-check": "^0.4.0",
2026
+ "word-wrap": "^1.2.5"
2027
+ },
2028
+ "engines": {
2029
+ "node": ">= 0.8.0"
2030
+ }
2031
+ },
2032
+ "node_modules/p-limit": {
2033
+ "version": "3.1.0",
2034
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
2035
+ "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
2036
+ "dev": true,
2037
+ "license": "MIT",
2038
+ "dependencies": {
2039
+ "yocto-queue": "^0.1.0"
2040
+ },
2041
+ "engines": {
2042
+ "node": ">=10"
2043
+ },
2044
+ "funding": {
2045
+ "url": "https://github.com/sponsors/sindresorhus"
2046
+ }
2047
+ },
2048
+ "node_modules/p-locate": {
2049
+ "version": "5.0.0",
2050
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
2051
+ "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
2052
+ "dev": true,
2053
+ "license": "MIT",
2054
+ "dependencies": {
2055
+ "p-limit": "^3.0.2"
2056
+ },
2057
+ "engines": {
2058
+ "node": ">=10"
2059
+ },
2060
+ "funding": {
2061
+ "url": "https://github.com/sponsors/sindresorhus"
2062
+ }
2063
+ },
2064
+ "node_modules/path-exists": {
2065
+ "version": "4.0.0",
2066
+ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
2067
+ "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
2068
+ "dev": true,
2069
+ "license": "MIT",
2070
+ "engines": {
2071
+ "node": ">=8"
2072
+ }
2073
+ },
2074
+ "node_modules/path-key": {
2075
+ "version": "3.1.1",
2076
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
2077
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
2078
+ "dev": true,
2079
+ "license": "MIT",
2080
+ "engines": {
2081
+ "node": ">=8"
2082
+ }
2083
+ },
2084
+ "node_modules/picocolors": {
2085
+ "version": "1.1.1",
2086
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
2087
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
2088
+ "dev": true,
2089
+ "license": "ISC"
2090
+ },
2091
+ "node_modules/picomatch": {
2092
+ "version": "4.0.4",
2093
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
2094
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
2095
+ "dev": true,
2096
+ "license": "MIT",
2097
+ "engines": {
2098
+ "node": ">=12"
2099
+ },
2100
+ "funding": {
2101
+ "url": "https://github.com/sponsors/jonschlinkert"
2102
+ }
2103
+ },
2104
+ "node_modules/postcss": {
2105
+ "version": "8.5.15",
2106
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
2107
+ "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
2108
+ "dev": true,
2109
+ "funding": [
2110
+ {
2111
+ "type": "opencollective",
2112
+ "url": "https://opencollective.com/postcss/"
2113
+ },
2114
+ {
2115
+ "type": "tidelift",
2116
+ "url": "https://tidelift.com/funding/github/npm/postcss"
2117
+ },
2118
+ {
2119
+ "type": "github",
2120
+ "url": "https://github.com/sponsors/ai"
2121
+ }
2122
+ ],
2123
+ "license": "MIT",
2124
+ "dependencies": {
2125
+ "nanoid": "^3.3.12",
2126
+ "picocolors": "^1.1.1",
2127
+ "source-map-js": "^1.2.1"
2128
+ },
2129
+ "engines": {
2130
+ "node": "^10 || ^12 || >=14"
2131
+ }
2132
+ },
2133
+ "node_modules/prelude-ls": {
2134
+ "version": "1.2.1",
2135
+ "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
2136
+ "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
2137
+ "dev": true,
2138
+ "license": "MIT",
2139
+ "engines": {
2140
+ "node": ">= 0.8.0"
2141
+ }
2142
+ },
2143
+ "node_modules/punycode": {
2144
+ "version": "2.3.1",
2145
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
2146
+ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
2147
+ "dev": true,
2148
+ "license": "MIT",
2149
+ "engines": {
2150
+ "node": ">=6"
2151
+ }
2152
+ },
2153
+ "node_modules/react": {
2154
+ "version": "19.2.7",
2155
+ "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
2156
+ "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==",
2157
+ "license": "MIT",
2158
+ "engines": {
2159
+ "node": ">=0.10.0"
2160
+ }
2161
+ },
2162
+ "node_modules/react-chartjs-2": {
2163
+ "version": "5.3.1",
2164
+ "resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.3.1.tgz",
2165
+ "integrity": "sha512-h5IPXKg9EXpjoBzUfyWJvllMjG2mQ4EiuHQFhms/AjUm0XSZHhyRy2xVmLXHKrtcdrPO4mnGqRtYoD0vp95A0A==",
2166
+ "license": "MIT",
2167
+ "peerDependencies": {
2168
+ "chart.js": "^4.1.1",
2169
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
2170
+ }
2171
+ },
2172
+ "node_modules/react-dom": {
2173
+ "version": "19.2.7",
2174
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
2175
+ "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==",
2176
+ "license": "MIT",
2177
+ "dependencies": {
2178
+ "scheduler": "^0.27.0"
2179
+ },
2180
+ "peerDependencies": {
2181
+ "react": "^19.2.7"
2182
+ }
2183
+ },
2184
+ "node_modules/rolldown": {
2185
+ "version": "1.0.3",
2186
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz",
2187
+ "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==",
2188
+ "dev": true,
2189
+ "license": "MIT",
2190
+ "dependencies": {
2191
+ "@oxc-project/types": "=0.133.0",
2192
+ "@rolldown/pluginutils": "^1.0.0"
2193
+ },
2194
+ "bin": {
2195
+ "rolldown": "bin/cli.mjs"
2196
+ },
2197
+ "engines": {
2198
+ "node": "^20.19.0 || >=22.12.0"
2199
+ },
2200
+ "optionalDependencies": {
2201
+ "@rolldown/binding-android-arm64": "1.0.3",
2202
+ "@rolldown/binding-darwin-arm64": "1.0.3",
2203
+ "@rolldown/binding-darwin-x64": "1.0.3",
2204
+ "@rolldown/binding-freebsd-x64": "1.0.3",
2205
+ "@rolldown/binding-linux-arm-gnueabihf": "1.0.3",
2206
+ "@rolldown/binding-linux-arm64-gnu": "1.0.3",
2207
+ "@rolldown/binding-linux-arm64-musl": "1.0.3",
2208
+ "@rolldown/binding-linux-ppc64-gnu": "1.0.3",
2209
+ "@rolldown/binding-linux-s390x-gnu": "1.0.3",
2210
+ "@rolldown/binding-linux-x64-gnu": "1.0.3",
2211
+ "@rolldown/binding-linux-x64-musl": "1.0.3",
2212
+ "@rolldown/binding-openharmony-arm64": "1.0.3",
2213
+ "@rolldown/binding-wasm32-wasi": "1.0.3",
2214
+ "@rolldown/binding-win32-arm64-msvc": "1.0.3",
2215
+ "@rolldown/binding-win32-x64-msvc": "1.0.3"
2216
+ }
2217
+ },
2218
+ "node_modules/scheduler": {
2219
+ "version": "0.27.0",
2220
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
2221
+ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
2222
+ "license": "MIT"
2223
+ },
2224
+ "node_modules/semver": {
2225
+ "version": "6.3.1",
2226
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
2227
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
2228
+ "dev": true,
2229
+ "license": "ISC",
2230
+ "bin": {
2231
+ "semver": "bin/semver.js"
2232
+ }
2233
+ },
2234
+ "node_modules/shebang-command": {
2235
+ "version": "2.0.0",
2236
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
2237
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
2238
+ "dev": true,
2239
+ "license": "MIT",
2240
+ "dependencies": {
2241
+ "shebang-regex": "^3.0.0"
2242
+ },
2243
+ "engines": {
2244
+ "node": ">=8"
2245
+ }
2246
+ },
2247
+ "node_modules/shebang-regex": {
2248
+ "version": "3.0.0",
2249
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
2250
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
2251
+ "dev": true,
2252
+ "license": "MIT",
2253
+ "engines": {
2254
+ "node": ">=8"
2255
+ }
2256
+ },
2257
+ "node_modules/source-map-js": {
2258
+ "version": "1.2.1",
2259
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
2260
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
2261
+ "dev": true,
2262
+ "license": "BSD-3-Clause",
2263
+ "engines": {
2264
+ "node": ">=0.10.0"
2265
+ }
2266
+ },
2267
+ "node_modules/tinyglobby": {
2268
+ "version": "0.2.17",
2269
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
2270
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
2271
+ "dev": true,
2272
+ "license": "MIT",
2273
+ "dependencies": {
2274
+ "fdir": "^6.5.0",
2275
+ "picomatch": "^4.0.4"
2276
+ },
2277
+ "engines": {
2278
+ "node": ">=12.0.0"
2279
+ },
2280
+ "funding": {
2281
+ "url": "https://github.com/sponsors/SuperchupuDev"
2282
+ }
2283
+ },
2284
+ "node_modules/tslib": {
2285
+ "version": "2.8.1",
2286
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
2287
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
2288
+ "dev": true,
2289
+ "license": "0BSD",
2290
+ "optional": true
2291
+ },
2292
+ "node_modules/type-check": {
2293
+ "version": "0.4.0",
2294
+ "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
2295
+ "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
2296
+ "dev": true,
2297
+ "license": "MIT",
2298
+ "dependencies": {
2299
+ "prelude-ls": "^1.2.1"
2300
+ },
2301
+ "engines": {
2302
+ "node": ">= 0.8.0"
2303
+ }
2304
+ },
2305
+ "node_modules/update-browserslist-db": {
2306
+ "version": "1.2.3",
2307
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
2308
+ "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
2309
+ "dev": true,
2310
+ "funding": [
2311
+ {
2312
+ "type": "opencollective",
2313
+ "url": "https://opencollective.com/browserslist"
2314
+ },
2315
+ {
2316
+ "type": "tidelift",
2317
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
2318
+ },
2319
+ {
2320
+ "type": "github",
2321
+ "url": "https://github.com/sponsors/ai"
2322
+ }
2323
+ ],
2324
+ "license": "MIT",
2325
+ "dependencies": {
2326
+ "escalade": "^3.2.0",
2327
+ "picocolors": "^1.1.1"
2328
+ },
2329
+ "bin": {
2330
+ "update-browserslist-db": "cli.js"
2331
+ },
2332
+ "peerDependencies": {
2333
+ "browserslist": ">= 4.21.0"
2334
+ }
2335
+ },
2336
+ "node_modules/uri-js": {
2337
+ "version": "4.4.1",
2338
+ "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
2339
+ "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
2340
+ "dev": true,
2341
+ "license": "BSD-2-Clause",
2342
+ "dependencies": {
2343
+ "punycode": "^2.1.0"
2344
+ }
2345
+ },
2346
+ "node_modules/vite": {
2347
+ "version": "8.0.16",
2348
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz",
2349
+ "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==",
2350
+ "dev": true,
2351
+ "license": "MIT",
2352
+ "dependencies": {
2353
+ "lightningcss": "^1.32.0",
2354
+ "picomatch": "^4.0.4",
2355
+ "postcss": "^8.5.15",
2356
+ "rolldown": "1.0.3",
2357
+ "tinyglobby": "^0.2.17"
2358
+ },
2359
+ "bin": {
2360
+ "vite": "bin/vite.js"
2361
+ },
2362
+ "engines": {
2363
+ "node": "^20.19.0 || >=22.12.0"
2364
+ },
2365
+ "funding": {
2366
+ "url": "https://github.com/vitejs/vite?sponsor=1"
2367
+ },
2368
+ "optionalDependencies": {
2369
+ "fsevents": "~2.3.3"
2370
+ },
2371
+ "peerDependencies": {
2372
+ "@types/node": "^20.19.0 || >=22.12.0",
2373
+ "@vitejs/devtools": "^0.1.18",
2374
+ "esbuild": "^0.27.0 || ^0.28.0",
2375
+ "jiti": ">=1.21.0",
2376
+ "less": "^4.0.0",
2377
+ "sass": "^1.70.0",
2378
+ "sass-embedded": "^1.70.0",
2379
+ "stylus": ">=0.54.8",
2380
+ "sugarss": "^5.0.0",
2381
+ "terser": "^5.16.0",
2382
+ "tsx": "^4.8.1",
2383
+ "yaml": "^2.4.2"
2384
+ },
2385
+ "peerDependenciesMeta": {
2386
+ "@types/node": {
2387
+ "optional": true
2388
+ },
2389
+ "@vitejs/devtools": {
2390
+ "optional": true
2391
+ },
2392
+ "esbuild": {
2393
+ "optional": true
2394
+ },
2395
+ "jiti": {
2396
+ "optional": true
2397
+ },
2398
+ "less": {
2399
+ "optional": true
2400
+ },
2401
+ "sass": {
2402
+ "optional": true
2403
+ },
2404
+ "sass-embedded": {
2405
+ "optional": true
2406
+ },
2407
+ "stylus": {
2408
+ "optional": true
2409
+ },
2410
+ "sugarss": {
2411
+ "optional": true
2412
+ },
2413
+ "terser": {
2414
+ "optional": true
2415
+ },
2416
+ "tsx": {
2417
+ "optional": true
2418
+ },
2419
+ "yaml": {
2420
+ "optional": true
2421
+ }
2422
+ }
2423
+ },
2424
+ "node_modules/which": {
2425
+ "version": "2.0.2",
2426
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
2427
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
2428
+ "dev": true,
2429
+ "license": "ISC",
2430
+ "dependencies": {
2431
+ "isexe": "^2.0.0"
2432
+ },
2433
+ "bin": {
2434
+ "node-which": "bin/node-which"
2435
+ },
2436
+ "engines": {
2437
+ "node": ">= 8"
2438
+ }
2439
+ },
2440
+ "node_modules/word-wrap": {
2441
+ "version": "1.2.5",
2442
+ "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
2443
+ "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
2444
+ "dev": true,
2445
+ "license": "MIT",
2446
+ "engines": {
2447
+ "node": ">=0.10.0"
2448
+ }
2449
+ },
2450
+ "node_modules/yallist": {
2451
+ "version": "3.1.1",
2452
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
2453
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
2454
+ "dev": true,
2455
+ "license": "ISC"
2456
+ },
2457
+ "node_modules/yocto-queue": {
2458
+ "version": "0.1.0",
2459
+ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
2460
+ "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
2461
+ "dev": true,
2462
+ "license": "MIT",
2463
+ "engines": {
2464
+ "node": ">=10"
2465
+ },
2466
+ "funding": {
2467
+ "url": "https://github.com/sponsors/sindresorhus"
2468
+ }
2469
+ },
2470
+ "node_modules/zod": {
2471
+ "version": "4.4.3",
2472
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
2473
+ "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
2474
+ "dev": true,
2475
+ "license": "MIT",
2476
+ "funding": {
2477
+ "url": "https://github.com/sponsors/colinhacks"
2478
+ }
2479
+ },
2480
+ "node_modules/zod-validation-error": {
2481
+ "version": "4.0.2",
2482
+ "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz",
2483
+ "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==",
2484
+ "dev": true,
2485
+ "license": "MIT",
2486
+ "engines": {
2487
+ "node": ">=18.0.0"
2488
+ },
2489
+ "peerDependencies": {
2490
+ "zod": "^3.25.0 || ^4.0.0"
2491
+ }
2492
+ }
2493
+ }
2494
+ }
client/package.json ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "client",
3
+ "private": true,
4
+ "version": "0.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "vite build",
9
+ "lint": "eslint .",
10
+ "preview": "vite preview"
11
+ },
12
+ "dependencies": {
13
+ "chart.js": "^4.5.1",
14
+ "lucide-react": "^1.17.0",
15
+ "react": "^19.2.6",
16
+ "react-chartjs-2": "^5.3.1",
17
+ "react-dom": "^19.2.6"
18
+ },
19
+ "devDependencies": {
20
+ "@eslint/js": "^10.0.1",
21
+ "@types/react": "^19.2.14",
22
+ "@types/react-dom": "^19.2.3",
23
+ "@vitejs/plugin-react": "^6.0.1",
24
+ "eslint": "^10.3.0",
25
+ "eslint-plugin-react-hooks": "^7.1.1",
26
+ "eslint-plugin-react-refresh": "^0.5.2",
27
+ "globals": "^17.6.0",
28
+ "vite": "^8.0.12"
29
+ }
30
+ }
client/public/favicon.svg ADDED
client/public/icons.svg ADDED
client/src/App.css ADDED
@@ -0,0 +1,184 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .counter {
2
+ font-size: 16px;
3
+ padding: 5px 10px;
4
+ border-radius: 5px;
5
+ color: var(--accent);
6
+ background: var(--accent-bg);
7
+ border: 2px solid transparent;
8
+ transition: border-color 0.3s;
9
+ margin-bottom: 24px;
10
+
11
+ &:hover {
12
+ border-color: var(--accent-border);
13
+ }
14
+ &:focus-visible {
15
+ outline: 2px solid var(--accent);
16
+ outline-offset: 2px;
17
+ }
18
+ }
19
+
20
+ .hero {
21
+ position: relative;
22
+
23
+ .base,
24
+ .framework,
25
+ .vite {
26
+ inset-inline: 0;
27
+ margin: 0 auto;
28
+ }
29
+
30
+ .base {
31
+ width: 170px;
32
+ position: relative;
33
+ z-index: 0;
34
+ }
35
+
36
+ .framework,
37
+ .vite {
38
+ position: absolute;
39
+ }
40
+
41
+ .framework {
42
+ z-index: 1;
43
+ top: 34px;
44
+ height: 28px;
45
+ transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
46
+ scale(1.4);
47
+ }
48
+
49
+ .vite {
50
+ z-index: 0;
51
+ top: 107px;
52
+ height: 26px;
53
+ width: auto;
54
+ transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
55
+ scale(0.8);
56
+ }
57
+ }
58
+
59
+ #center {
60
+ display: flex;
61
+ flex-direction: column;
62
+ gap: 25px;
63
+ place-content: center;
64
+ place-items: center;
65
+ flex-grow: 1;
66
+
67
+ @media (max-width: 1024px) {
68
+ padding: 32px 20px 24px;
69
+ gap: 18px;
70
+ }
71
+ }
72
+
73
+ #next-steps {
74
+ display: flex;
75
+ border-top: 1px solid var(--border);
76
+ text-align: left;
77
+
78
+ & > div {
79
+ flex: 1 1 0;
80
+ padding: 32px;
81
+ @media (max-width: 1024px) {
82
+ padding: 24px 20px;
83
+ }
84
+ }
85
+
86
+ .icon {
87
+ margin-bottom: 16px;
88
+ width: 22px;
89
+ height: 22px;
90
+ }
91
+
92
+ @media (max-width: 1024px) {
93
+ flex-direction: column;
94
+ text-align: center;
95
+ }
96
+ }
97
+
98
+ #docs {
99
+ border-right: 1px solid var(--border);
100
+
101
+ @media (max-width: 1024px) {
102
+ border-right: none;
103
+ border-bottom: 1px solid var(--border);
104
+ }
105
+ }
106
+
107
+ #next-steps ul {
108
+ list-style: none;
109
+ padding: 0;
110
+ display: flex;
111
+ gap: 8px;
112
+ margin: 32px 0 0;
113
+
114
+ .logo {
115
+ height: 18px;
116
+ }
117
+
118
+ a {
119
+ color: var(--text-h);
120
+ font-size: 16px;
121
+ border-radius: 6px;
122
+ background: var(--social-bg);
123
+ display: flex;
124
+ padding: 6px 12px;
125
+ align-items: center;
126
+ gap: 8px;
127
+ text-decoration: none;
128
+ transition: box-shadow 0.3s;
129
+
130
+ &:hover {
131
+ box-shadow: var(--shadow);
132
+ }
133
+ .button-icon {
134
+ height: 18px;
135
+ width: 18px;
136
+ }
137
+ }
138
+
139
+ @media (max-width: 1024px) {
140
+ margin-top: 20px;
141
+ flex-wrap: wrap;
142
+ justify-content: center;
143
+
144
+ li {
145
+ flex: 1 1 calc(50% - 8px);
146
+ }
147
+
148
+ a {
149
+ width: 100%;
150
+ justify-content: center;
151
+ box-sizing: border-box;
152
+ }
153
+ }
154
+ }
155
+
156
+ #spacer {
157
+ height: 88px;
158
+ border-top: 1px solid var(--border);
159
+ @media (max-width: 1024px) {
160
+ height: 48px;
161
+ }
162
+ }
163
+
164
+ .ticks {
165
+ position: relative;
166
+ width: 100%;
167
+
168
+ &::before,
169
+ &::after {
170
+ content: '';
171
+ position: absolute;
172
+ top: -4.5px;
173
+ border: 5px solid transparent;
174
+ }
175
+
176
+ &::before {
177
+ left: 0;
178
+ border-left-color: var(--border);
179
+ }
180
+ &::after {
181
+ right: 0;
182
+ border-right-color: var(--border);
183
+ }
184
+ }
client/src/App.jsx ADDED
@@ -0,0 +1,1930 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useEffect, useRef } from 'react';
2
+ import {
3
+ BarChart2,
4
+ Users,
5
+ MessageSquare,
6
+ ShieldAlert,
7
+ Mic,
8
+ LogOut,
9
+ LogIn,
10
+ Activity,
11
+ Search,
12
+ Settings,
13
+ Lock,
14
+ Volume2,
15
+ RefreshCw,
16
+ Clock,
17
+ Trash2,
18
+ UserCheck
19
+ } from 'lucide-react';
20
+ import {
21
+ Chart as ChartJS,
22
+ CategoryScale,
23
+ LinearScale,
24
+ RadialLinearScale,
25
+ PointElement,
26
+ LineElement,
27
+ Title,
28
+ Tooltip,
29
+ Legend,
30
+ Filler
31
+ } from 'chart.js';
32
+ import { Line, Radar } from 'react-chartjs-2';
33
+
34
+ // Register Chart.js components
35
+ ChartJS.register(
36
+ CategoryScale,
37
+ LinearScale,
38
+ RadialLinearScale,
39
+ PointElement,
40
+ LineElement,
41
+ Title,
42
+ Tooltip,
43
+ Legend,
44
+ Filler
45
+ );
46
+
47
+ // 3D Galaxy Chatter Orbit Visualization
48
+ function ChatterOrbit({ chatters }) {
49
+ const canvasRef = useRef(null);
50
+ const containerRef = useRef(null);
51
+ const particlesRef = useRef(new Map());
52
+ const avatarRef = useRef(null);
53
+
54
+ useEffect(() => {
55
+ // Load Avatar via backend proxy (avoids CORS issues with decapi.me)
56
+ if (!avatarRef.current) {
57
+ const img = new Image();
58
+ img.src = '/api/avatar/winx_prinx';
59
+ img.onload = () => { avatarRef.current = img; };
60
+ img.onerror = () => console.warn('[ChatterOrbit] Avatar load failed');
61
+ }
62
+ }, []);
63
+
64
+ useEffect(() => {
65
+ const safeChatters = Array.isArray(chatters) ? chatters : [];
66
+
67
+ safeChatters.forEach(c => {
68
+ const username = c.username || c.display_name || 'unknown';
69
+ if (!particlesRef.current.has(username)) {
70
+ const isLurker = !c.has_chatted && !(c.message_count > 0);
71
+ // All particles go on the ring — tight band around ring radius ±8%
72
+ const ringNoise = (Math.random() - 0.5) * 0.16;
73
+ const radiusNorm = 1.0 + ringNoise;
74
+
75
+ particlesRef.current.set(username, {
76
+ username,
77
+ displayName: c.display_name || username,
78
+ angle: Math.random() * Math.PI * 2,
79
+ radiusNorm,
80
+ speed: (0.0003 + Math.random() * 0.0004) * (Math.random() > 0.5 ? 1 : -1),
81
+ isMod: c.is_mod,
82
+ isSub: c.is_sub,
83
+ isLurker,
84
+ messageCount: c.message_count || 0,
85
+ isStreamer: username.toLowerCase() === 'winx_prinx',
86
+ });
87
+ } else {
88
+ const p = particlesRef.current.get(username);
89
+ const isLurker = !c.has_chatted && !(c.message_count > 0);
90
+ p.isLurker = isLurker;
91
+ p.messageCount = c.message_count || p.messageCount;
92
+ p.isMod = c.is_mod || p.isMod;
93
+ p.isSub = c.is_sub || p.isSub;
94
+ }
95
+ });
96
+ }, [chatters]);
97
+
98
+ useEffect(() => {
99
+ const canvas = canvasRef.current;
100
+ const container = containerRef.current;
101
+ if (!canvas || !container) return;
102
+ const ctx = canvas.getContext('2d');
103
+ let animationId;
104
+
105
+ const HEIGHT = 320;
106
+
107
+ const resize = () => {
108
+ const dpr = window.devicePixelRatio || 1;
109
+ const rect = container.getBoundingClientRect();
110
+ canvas.width = rect.width * dpr;
111
+ canvas.height = HEIGHT * dpr;
112
+ canvas.style.width = `${rect.width}px`;
113
+ canvas.style.height = `${HEIGHT}px`;
114
+ ctx.scale(dpr, dpr);
115
+ };
116
+ resize();
117
+ window.addEventListener('resize', resize);
118
+
119
+ let hoverName = null;
120
+ let hoverX = 0;
121
+ let hoverY = 0;
122
+ let mouseX = -9999;
123
+ let mouseY = -9999;
124
+
125
+ const handleMouseMove = (e) => {
126
+ const rect = canvas.getBoundingClientRect();
127
+ mouseX = e.clientX - rect.left;
128
+ mouseY = e.clientY - rect.top;
129
+ };
130
+ const handleMouseLeave = () => { mouseX = -9999; mouseY = -9999; };
131
+ canvas.addEventListener('mousemove', handleMouseMove);
132
+ canvas.addEventListener('mouseleave', handleMouseLeave);
133
+
134
+ const animate = () => {
135
+ const width = canvas.width / (window.devicePixelRatio || 1);
136
+ const height = HEIGHT;
137
+ const cx = width / 2;
138
+ const cy = height / 2;
139
+
140
+ // Ring fills most of the canvas
141
+ const RX = width * 0.44;
142
+ const RY = height * 0.36;
143
+ const TILT = RY / RX;
144
+
145
+ // Solid near-black background
146
+ ctx.fillStyle = '#08080f';
147
+ ctx.fillRect(0, 0, width, height);
148
+
149
+ const particles = Array.from(particlesRef.current.values());
150
+
151
+ hoverName = null;
152
+
153
+ // Update positions
154
+ particles.forEach(p => {
155
+ p.angle += p.speed;
156
+ const r = p.radiusNorm * RX;
157
+ p.x = cx + Math.cos(p.angle) * r;
158
+ p.y = cy + Math.sin(p.angle) * r * TILT;
159
+ p.depth = (Math.sin(p.angle) * TILT + TILT) / (2 * TILT); // 0=back, 1=front
160
+
161
+ if (p.isStreamer) {
162
+ p.baseSize = 5;
163
+ p.color = '#A370F7';
164
+ } else if (p.isLurker) {
165
+ p.baseSize = 1;
166
+ p.color = null;
167
+ } else {
168
+ p.baseSize = Math.min(4, 1.5 + Math.log10((p.messageCount || 0) + 1) * 1.2);
169
+ p.color = p.isMod ? '#00F5D4' : p.isSub ? '#FF007F' : '#c8c8d8';
170
+ }
171
+ p.drawSize = p.baseSize;
172
+ });
173
+
174
+ // Sort back-to-front
175
+ particles.sort((a, b) => a.depth - b.depth);
176
+
177
+ // Draw particles
178
+ particles.forEach(p => {
179
+ const alpha = p.isLurker
180
+ ? 0.15 + p.depth * 0.45
181
+ : 0.5 + p.depth * 0.5;
182
+
183
+ let color;
184
+ if (p.isLurker) {
185
+ const brightness = Math.round(160 + p.depth * 60);
186
+ color = `rgba(${brightness},${brightness},${brightness + 20},${alpha})`;
187
+ } else {
188
+ color = p.color;
189
+ }
190
+
191
+ const dist = Math.hypot(mouseX - p.x, mouseY - p.y);
192
+ if (dist < p.drawSize + 6) {
193
+ hoverName = p.displayName;
194
+ hoverX = p.x;
195
+ hoverY = p.y;
196
+ p.drawSize = Math.max(p.drawSize * 2.5, 5);
197
+ }
198
+
199
+ ctx.globalAlpha = p.isLurker ? 1 : alpha;
200
+ ctx.shadowBlur = 0;
201
+
202
+ if (!p.isLurker && (p.isMod || p.isSub || p.isStreamer)) {
203
+ ctx.shadowBlur = 6;
204
+ ctx.shadowColor = color;
205
+ }
206
+
207
+ ctx.beginPath();
208
+ ctx.arc(p.x, p.y, p.drawSize, 0, Math.PI * 2);
209
+ ctx.fillStyle = color;
210
+ ctx.fill();
211
+ });
212
+ ctx.globalAlpha = 1;
213
+ ctx.shadowBlur = 0;
214
+
215
+ // Draw Avatar on top
216
+ ctx.save();
217
+ ctx.beginPath();
218
+ ctx.arc(cx, cy, 26, 0, Math.PI * 2);
219
+ ctx.closePath();
220
+ ctx.shadowColor = '#9147ff';
221
+ ctx.shadowBlur = 24;
222
+ ctx.strokeStyle = 'rgba(145, 71, 255, 0.9)';
223
+ ctx.lineWidth = 2.5;
224
+ ctx.stroke();
225
+ ctx.shadowBlur = 0;
226
+ ctx.clip();
227
+ if (avatarRef.current) {
228
+ ctx.drawImage(avatarRef.current, cx - 26, cy - 26, 52, 52);
229
+ } else {
230
+ ctx.fillStyle = '#6441A5';
231
+ ctx.fill();
232
+ }
233
+ ctx.restore();
234
+
235
+ // Hover tooltip
236
+ if (hoverName) {
237
+ ctx.shadowBlur = 0;
238
+ ctx.globalAlpha = 1;
239
+ ctx.font = 'bold 11px Inter, sans-serif';
240
+ const tw = ctx.measureText(hoverName).width;
241
+ const tx = Math.min(hoverX + 12, width - tw - 20);
242
+ const ty = hoverY - 30 < 5 ? hoverY + 20 : hoverY - 30;
243
+ ctx.fillStyle = 'rgba(18,18,24,0.92)';
244
+ ctx.beginPath();
245
+ if (ctx.roundRect) ctx.roundRect(tx - 4, ty, tw + 16, 22, 5);
246
+ else ctx.rect(tx - 4, ty, tw + 16, 22);
247
+ ctx.fill();
248
+ ctx.strokeStyle = 'rgba(145,71,255,0.5)';
249
+ ctx.lineWidth = 1;
250
+ ctx.stroke();
251
+ ctx.fillStyle = '#efeff1';
252
+ ctx.fillText(hoverName, tx + 4, ty + 15);
253
+ }
254
+
255
+ animationId = requestAnimationFrame(animate);
256
+ };
257
+
258
+ animate();
259
+
260
+ return () => {
261
+ cancelAnimationFrame(animationId);
262
+ canvas.removeEventListener('mousemove', handleMouseMove);
263
+ canvas.removeEventListener('mouseleave', handleMouseLeave);
264
+ window.removeEventListener('resize', resize);
265
+ };
266
+ }, []);
267
+
268
+ return (
269
+ <div ref={containerRef} style={{ display: 'flex', flexDirection: 'column', width: '100%', position: 'relative' }}>
270
+ <canvas ref={canvasRef} style={{ borderRadius: '8px', display: 'block', width: '100%' }} />
271
+ <div style={{ position: 'absolute', bottom: '10px', right: '12px', fontSize: '11px', color: 'rgba(180,180,200,0.7)', display: 'flex', gap: '14px' }}>
272
+ <span><span style={{ display: 'inline-block', width: '6px', height: '6px', background: 'rgba(200,200,220,0.5)', borderRadius: '50%', marginRight: '4px'}}></span>Зрители</span>
273
+ <span><span style={{ display: 'inline-block', width: '6px', height: '6px', background: '#c8c8d8', borderRadius: '50%', marginRight: '4px'}}></span>Чатеры</span>
274
+ <span><span style={{ display: 'inline-block', width: '6px', height: '6px', background: '#FF007F', borderRadius: '50%', marginRight: '4px'}}></span>Сабы</span>
275
+ <span><span style={{ display: 'inline-block', width: '6px', height: '6px', background: '#00F5D4', borderRadius: '50%', marginRight: '4px'}}></span>Модеры</span>
276
+ </div>
277
+ </div>
278
+ );
279
+ }
280
+
281
+ // Fallback to local port 3000 in development, or use VITE_API_URL if configured, otherwise same origin
282
+ const API_BASE = import.meta.env.VITE_API_URL || (import.meta.env.DEV ? 'http://localhost:3000' : '');
283
+ const TWITCH_CHANNEL = 'winx_prinx';
284
+
285
+ export default function App() {
286
+ const [activeTab, setActiveTab] = useState('overview');
287
+ const [streams, setStreams] = useState([]);
288
+ const [selectedStreamId, setSelectedStreamId] = useState('all');
289
+ const [auth, setAuth] = useState({ loggedIn: false, user: null });
290
+ const [twitchConfigured, setTwitchConfigured] = useState(true);
291
+ const [loading, setLoading] = useState(true);
292
+ const [serverStatus, setServerStatus] = useState('connecting'); // 'connecting' | 'online' | 'offline'
293
+
294
+ // Statistics State
295
+ const [chatters, setChatters] = useState([]);
296
+ const [chattersSearch, setChattersSearch] = useState('');
297
+ const [voiceWords, setVoiceWords] = useState([]);
298
+ const [chatWords, setChatWords] = useState([]);
299
+ const [wordType, setWordType] = useState('voice'); // 'voice' or 'chat'
300
+ const [activityData, setActivityData] = useState([]);
301
+ const [modActions, setModActions] = useState([]);
302
+ const [modSummary, setModSummary] = useState([]);
303
+
304
+ // RPG Mod Profiles
305
+ const [modProfiles, setModProfiles] = useState([]);
306
+ const [selectedMod, setSelectedMod] = useState(null);
307
+ const [syncingVods, setSyncingVods] = useState(false);
308
+ const [isRacing, setIsRacing] = useState(false);
309
+
310
+ // Admin State
311
+ const [adminStats, setAdminStats] = useState(null);
312
+ const [loadingAdminStats, setLoadingAdminStats] = useState(false);
313
+ const [cleaningStreams, setCleaningStreams] = useState(false);
314
+ const [adminSelectedStreamId, setAdminSelectedStreamId] = useState('');
315
+ const [editTitle, setEditTitle] = useState('');
316
+ const [editCategory, setEditCategory] = useState('');
317
+ const [savingMetadata, setSavingMetadata] = useState(false);
318
+ const [deletingStream, setDeletingStream] = useState(false);
319
+
320
+ // Auto-refresh timer for live stream
321
+ const pollIntervalRef = useRef(null);
322
+ const raceIntervalRef = useRef(null);
323
+
324
+ const fetchAdminStats = async () => {
325
+ setLoadingAdminStats(true);
326
+ try {
327
+ const res = await fetch(`${API_BASE}/api/admin/stats`, { credentials: 'include' });
328
+ if (res.status === 200) {
329
+ const data = await res.json();
330
+ if (data.success) {
331
+ setAdminStats(data.stats);
332
+ }
333
+ }
334
+ } catch (e) {
335
+ console.error('Error fetching admin stats:', e);
336
+ } finally {
337
+ setLoadingAdminStats(false);
338
+ }
339
+ };
340
+
341
+ // Fetch admin stats when switching to admin tab
342
+ useEffect(() => {
343
+ if (activeTab === 'admin') {
344
+ fetchAdminStats();
345
+ if (streams && streams.length > 0 && !adminSelectedStreamId) {
346
+ setAdminSelectedStreamId(streams[0].id.toString());
347
+ }
348
+ }
349
+ }, [activeTab, streams]);
350
+
351
+ // Sync edit fields when admin selection changes
352
+ useEffect(() => {
353
+ if (adminSelectedStreamId) {
354
+ const stream = streams.find(s => s.id === parseInt(adminSelectedStreamId));
355
+ if (stream) {
356
+ setEditTitle(stream.title || '');
357
+ setEditCategory(stream.category || '');
358
+ } else {
359
+ setEditTitle('');
360
+ setEditCategory('');
361
+ }
362
+ } else {
363
+ setEditTitle('');
364
+ setEditCategory('');
365
+ }
366
+ }, [adminSelectedStreamId, streams]);
367
+
368
+ const checkServerHealth = async () => {
369
+ try {
370
+ const controller = new AbortController();
371
+ const timeoutId = setTimeout(() => controller.abort(), 6000);
372
+
373
+ const res = await fetch(`${API_BASE}/api/health`, { signal: controller.signal });
374
+ clearTimeout(timeoutId);
375
+
376
+ if (res.ok) {
377
+ setServerStatus('online');
378
+ return true;
379
+ }
380
+ } catch (e) {
381
+ console.warn('Backend connection healthcheck failed:', e);
382
+ }
383
+ return false;
384
+ };
385
+
386
+ const pingServer = async () => {
387
+ const ok = await checkServerHealth();
388
+ if (ok) {
389
+ fetchAuthStatus();
390
+ fetchStreams();
391
+ } else {
392
+ setServerStatus('offline');
393
+ }
394
+ };
395
+
396
+ // Check Auth on Mount & Server connection status loop
397
+ useEffect(() => {
398
+ let active = true;
399
+ let timer = null;
400
+
401
+ const runPingLoop = async () => {
402
+ if (!active) return;
403
+ const ok = await checkServerHealth();
404
+ if (active) {
405
+ if (ok) {
406
+ fetchAuthStatus();
407
+ fetchStreams();
408
+ timer = setTimeout(runPingLoop, 30000);
409
+ } else {
410
+ setServerStatus('offline');
411
+ timer = setTimeout(runPingLoop, 6000);
412
+ }
413
+ }
414
+ };
415
+
416
+ runPingLoop();
417
+
418
+ return () => {
419
+ active = false;
420
+ if (timer) clearTimeout(timer);
421
+ };
422
+ }, []);
423
+
424
+ // Fetch stats when selected stream changes
425
+ useEffect(() => {
426
+ if (selectedStreamId && serverStatus === 'online') {
427
+ fetchAllStats();
428
+
429
+ // Setup polling if the selected stream is live (end_time is null)
430
+ const selectedStream = streams.find(s => s.id === parseInt(selectedStreamId));
431
+ const isLive = selectedStream && !selectedStream.end_time;
432
+
433
+ if (isLive || selectedStreamId === 'all') {
434
+ startPolling();
435
+ } else {
436
+ stopPolling();
437
+ }
438
+ }
439
+ return () => stopPolling();
440
+ }, [selectedStreamId, streams, serverStatus]);
441
+
442
+ const startPolling = () => {
443
+ stopPolling();
444
+ pollIntervalRef.current = setInterval(() => {
445
+ fetchAllStats(false); // poll silently without loading spinner
446
+ }, 15000); // refresh every 15s
447
+ };
448
+
449
+ const stopPolling = () => {
450
+ if (pollIntervalRef.current) {
451
+ clearInterval(pollIntervalRef.current);
452
+ pollIntervalRef.current = null;
453
+ }
454
+ };
455
+
456
+ const fetchAuthStatus = async () => {
457
+ try {
458
+ const res = await fetch(`${API_BASE}/api/auth/status`, { credentials: 'include' });
459
+ const data = await res.json();
460
+ setTwitchConfigured(data.twitchConfigured !== false);
461
+ if (data.loggedIn) {
462
+ setAuth({ loggedIn: true, user: data.user });
463
+ } else {
464
+ setAuth({ loggedIn: false, user: null });
465
+ }
466
+ } catch (e) {
467
+ console.error('Auth check failed:', e);
468
+ }
469
+ };
470
+
471
+ const fetchStreams = async () => {
472
+ try {
473
+ const res = await fetch(`${API_BASE}/api/streams`);
474
+ const data = await res.json();
475
+ setStreams(data);
476
+ } catch (e) {
477
+ console.error('Failed to fetch streams:', e);
478
+ }
479
+ };
480
+
481
+ const getUrl = (endpoint, params = {}) => {
482
+ if (selectedStreamId !== 'all') {
483
+ params.stream_id = selectedStreamId;
484
+ }
485
+ const query = new URLSearchParams(params).toString();
486
+ return `${API_BASE}${endpoint}${query ? '?' + query : ''}`;
487
+ };
488
+
489
+ const fetchAllStats = async (showSpinner = true) => {
490
+ if (showSpinner) setLoading(true);
491
+
492
+ try {
493
+ // 1. Top Chatters
494
+ const chattersRes = await fetch(getUrl('/api/stats/chatters'));
495
+ const chattersData = await chattersRes.json();
496
+ setChatters(Array.isArray(chattersData) ? chattersData : []);
497
+
498
+ // 2. Spoken voice words
499
+ const voiceRes = await fetch(getUrl('/api/stats/words', { type: 'voice', limit: 60 }));
500
+ const voiceData = await voiceRes.json();
501
+ setVoiceWords(Array.isArray(voiceData) ? voiceData : []);
502
+
503
+ // 3. Written chat words by streamer
504
+ const chatWordsRes = await fetch(getUrl('/api/stats/words', { type: 'chat', limit: 60 }));
505
+ const chatWordsData = await chatWordsRes.json();
506
+ setChatWords(Array.isArray(chatWordsData) ? chatWordsData : []);
507
+
508
+ // 4. Activity chart (only if stream is selected)
509
+ if (selectedStreamId !== 'all') {
510
+ const activityRes = await fetch(getUrl('/api/stats/activity'));
511
+ const activityData = await activityRes.json();
512
+ setActivityData(Array.isArray(activityData) ? activityData : []);
513
+ } else {
514
+ setActivityData([]);
515
+ }
516
+
517
+ // 5. Fetch moderator actions if authorized
518
+ if (!twitchConfigured || (auth.loggedIn && (auth.user?.role === 'streamer' || auth.user?.role === 'moderator' || auth.user?.role === 'admin'))) {
519
+ const modRes = await fetch(getUrl('/api/stats/moderators', { limit: 100 }), { credentials: 'include' });
520
+ const modData = await modRes.json();
521
+ setModActions(Array.isArray(modData) ? modData : []);
522
+
523
+ const summaryRes = await fetch(getUrl('/api/stats/moderators/summary'), { credentials: 'include' });
524
+ const summaryData = await summaryRes.json();
525
+ setModSummary(Array.isArray(summaryData) ? summaryData : []);
526
+
527
+ const profilesRes = await fetch(getUrl('/api/stats/moderators/profiles'), { credentials: 'include' });
528
+ const profilesData = await profilesRes.json();
529
+ setModProfiles(Array.isArray(profilesData) ? profilesData : []);
530
+ if (profilesData && profilesData.length > 0) {
531
+ setSelectedMod(prev => prev ? profilesData.find(p => p.moderator === prev.moderator) || profilesData[0] : profilesData[0]);
532
+ }
533
+ }
534
+ } catch (e) {
535
+ console.error('Error fetching statistics:', e);
536
+ } finally {
537
+ if (showSpinner) setLoading(false);
538
+ }
539
+ };
540
+
541
+ const startGraphRace = () => {
542
+ if (activityData.length === 0) return;
543
+ if (raceIntervalRef.current) clearInterval(raceIntervalRef.current);
544
+
545
+ const originalData = [...activityData];
546
+ setActivityData([]);
547
+ setIsRacing(true);
548
+
549
+ let currentIndex = 0;
550
+ raceIntervalRef.current = setInterval(() => {
551
+ if (currentIndex >= originalData.length) {
552
+ clearInterval(raceIntervalRef.current);
553
+ setIsRacing(false);
554
+ return;
555
+ }
556
+ setActivityData(prev => [...prev, originalData[currentIndex]]);
557
+ currentIndex++;
558
+ }, 150);
559
+ };
560
+
561
+ const handleLogin = () => {
562
+ window.location.href = `${API_BASE}/api/auth/twitch`;
563
+ };
564
+
565
+ const handleLogout = async () => {
566
+ try {
567
+ await fetch(`${API_BASE}/api/auth/logout`, { credentials: 'include' });
568
+ setAuth({ loggedIn: false, user: null });
569
+ setModActions([]);
570
+ setModSummary([]);
571
+ setActiveTab('overview');
572
+ } catch (e) {
573
+ console.error('Logout failed:', e);
574
+ }
575
+ };
576
+
577
+ const activeStream = streams.find(s => !s.end_time);
578
+
579
+ // Helper to format dates
580
+ const formatDate = (isoString) => {
581
+ if (!isoString) return '';
582
+ const date = new Date(isoString);
583
+ return date.toLocaleString('ru-RU', {
584
+ day: 'numeric',
585
+ month: 'short',
586
+ hour: '2-digit',
587
+ minute: '2-digit'
588
+ });
589
+ };
590
+
591
+ // KPI Metrics Calculation
592
+ const totalMessages = chatters.reduce((acc, curr) => acc + curr.message_count, 0);
593
+ const totalUniqueChatters = chatters.length;
594
+ const topChatter = chatters[0]?.display_name || '—';
595
+ const totalVoiceWords = voiceWords.reduce((acc, curr) => acc + curr.word_count, 0);
596
+ const totalModActionsCount = modActions.length;
597
+
598
+ const statsSummary = {
599
+ messages: totalMessages,
600
+ uniqueChatters: totalUniqueChatters,
601
+ mostActiveChatter: topChatter,
602
+ voiceWordsCount: totalVoiceWords,
603
+ modActionsCount: totalModActionsCount
604
+ };
605
+
606
+ // Chart Setup
607
+ const chartLabels = activityData.map(d => {
608
+ const date = new Date(d.timestamp);
609
+ return date.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
610
+ });
611
+
612
+ const chartDataConfig = {
613
+ labels: chartLabels.length > 0 ? chartLabels : ['Нет данных'],
614
+ datasets: [
615
+ {
616
+ fill: true,
617
+ label: 'Сообщений в 5 мин',
618
+ data: activityData.map(d => d.message_count),
619
+ borderColor: '#9146FF',
620
+ backgroundColor: 'rgba(145, 70, 255, 0.1)',
621
+ tension: 0.4,
622
+ pointBackgroundColor: '#9146FF',
623
+ pointBorderColor: '#fff',
624
+ pointHoverBackgroundColor: '#fff',
625
+ pointHoverBorderColor: '#9146FF',
626
+ },
627
+ ],
628
+ };
629
+
630
+ const chartOptions = {
631
+ responsive: true,
632
+ maintainAspectRatio: false,
633
+ plugins: {
634
+ legend: {
635
+ display: false,
636
+ },
637
+ tooltip: {
638
+ backgroundColor: '#18181B',
639
+ borderColor: '#2F2F35',
640
+ borderWidth: 1,
641
+ titleColor: '#EFeff1',
642
+ bodyColor: '#ADADB8',
643
+ padding: 10,
644
+ displayColors: false,
645
+ }
646
+ },
647
+ scales: {
648
+ x: {
649
+ grid: {
650
+ color: '#2F2F35',
651
+ },
652
+ ticks: {
653
+ color: '#ADADB8',
654
+ font: { size: 10 }
655
+ }
656
+ },
657
+ y: {
658
+ grid: {
659
+ color: '#2F2F35',
660
+ },
661
+ ticks: {
662
+ color: '#ADADB8',
663
+ font: { size: 10 }
664
+ }
665
+ }
666
+ }
667
+ };
668
+
669
+ return (
670
+ <div className="app-container">
671
+ {/* Header */}
672
+ <header className="app-header">
673
+ <div className="brand-section">
674
+ <BarChart2 size={28} className="brand-logo" />
675
+ <h1 className="brand-name">winx_prinx</h1>
676
+ <span className="channel-tag">Analytics</span>
677
+
678
+ {/* Live stream badge */}
679
+ {activeStream ? (
680
+ <div className="status-indicator" style={{ marginLeft: '1rem' }}>
681
+ <span className="status-dot live"></span>
682
+ <span style={{ color: '#ff0000', fontSize: '0.75rem' }}>В ЭФИРЕ</span>
683
+ </div>
684
+ ) : (
685
+ <div className="status-indicator" style={{ marginLeft: '1rem' }}>
686
+ <span className="status-dot offline"></span>
687
+ <span style={{ color: 'var(--color-text-muted)', fontSize: '0.75rem' }}>ОФФЛАЙН</span>
688
+ </div>
689
+ )}
690
+
691
+ {/* Stream Selector — compact, in header */}
692
+ <div className="header-stream-selector">
693
+ <select
694
+ className="select-input select-input--compact"
695
+ value={selectedStreamId}
696
+ onChange={(e) => setSelectedStreamId(e.target.value)}
697
+ >
698
+ <option value="all">За всё время</option>
699
+ {streams.map(s => (
700
+ <option key={s.id} value={s.id}>
701
+ {s.title} ({formatDate(s.start_time)}{!s.end_time ? ' · Live' : ''})
702
+ </option>
703
+ ))}
704
+ </select>
705
+ </div>
706
+ </div>
707
+
708
+ <div className="header-controls">
709
+ {/* Navigation Tabs */}
710
+ <nav className="nav-tabs">
711
+ <button
712
+ className={`tab-btn ${activeTab === 'overview' ? 'active' : ''}`}
713
+ onClick={() => setActiveTab('overview')}
714
+ >
715
+ <Activity size={16} /> Обзор
716
+ </button>
717
+ <button
718
+ className={`tab-btn ${activeTab === 'chatters' ? 'active' : ''}`}
719
+ onClick={() => setActiveTab('chatters')}
720
+ >
721
+ <Users size={16} /> Зрители
722
+ </button>
723
+ <button
724
+ className={`tab-btn ${activeTab === 'words' ? 'active' : ''}`}
725
+ onClick={() => setActiveTab('words')}
726
+ >
727
+ <Mic size={16} /> Словарь частот
728
+ </button>
729
+ <button
730
+ className={`tab-btn ${activeTab === 'moderator' ? 'active' : ''}`}
731
+ onClick={() => setActiveTab('moderator')}
732
+ >
733
+ <ShieldAlert size={16} /> Модерация
734
+ </button>
735
+ {(auth.user?.role === 'streamer' || auth.user?.role === 'admin' || !twitchConfigured) && (
736
+ <button
737
+ className={`tab-btn ${activeTab === 'admin' ? 'active' : ''}`}
738
+ onClick={() => setActiveTab('admin')}
739
+ >
740
+ <Settings size={16} /> Админка
741
+ </button>
742
+ )}
743
+ </nav>
744
+
745
+ {/* Refresh + User Profile */}
746
+ <div className="user-profile">
747
+ <button
748
+ className="btn btn-refresh"
749
+ onClick={() => { fetchStreams(); fetchAllStats(); }}
750
+ disabled={loading}
751
+ title="Обновить данные"
752
+ >
753
+ <RefreshCw size={14} className={loading ? 'spin' : ''} />
754
+ Обновить
755
+ </button>
756
+ {!twitchConfigured ? (
757
+ <span className="badge badge-mod" style={{ padding: '0.5rem 1rem', fontSize: '0.85rem', background: '#232329', border: '1px solid #2F2F35', color: '#00F5D4' }}>
758
+ Локальный режим
759
+ </span>
760
+ ) : auth.loggedIn ? (
761
+ <>
762
+ <div className="user-info">
763
+ <div className="user-name">{auth.user.displayName}</div>
764
+ <div className="user-role">
765
+ {auth.user?.role === 'streamer' ? 'Стример' : auth.user?.role === 'admin' ? 'Админ' : auth.user?.role === 'moderator' ? 'Модератор' : 'Зритель'}
766
+ </div>
767
+ </div>
768
+ <button className="btn btn-secondary" onClick={handleLogout}>
769
+ <LogOut size={16} /> Выйти
770
+ </button>
771
+ </>
772
+ ) : (
773
+ <button className="btn" onClick={handleLogin}>
774
+ <LogIn size={16} /> Войти через Twitch
775
+ </button>
776
+ )}
777
+ </div>
778
+ </div>
779
+ </header>
780
+
781
+ {/* Main Dashboard Panel */}
782
+ <main className="dashboard-content">
783
+
784
+ {serverStatus !== 'online' ? (
785
+ <div className="connection-overlay-container" style={{
786
+ display: 'flex',
787
+ flexDirection: 'column',
788
+ alignItems: 'center',
789
+ justifyContent: 'center',
790
+ minHeight: '60vh',
791
+ padding: '2rem',
792
+ textAlign: 'center',
793
+ background: 'rgba(20, 20, 27, 0.4)',
794
+ backdropFilter: 'blur(8px)',
795
+ WebkitBackdropFilter: 'blur(8px)',
796
+ borderRadius: 'var(--radius-lg)',
797
+ border: '1px solid var(--color-border)',
798
+ margin: '2rem auto',
799
+ maxWidth: '600px',
800
+ boxShadow: '0 8px 32px 0 rgba(0, 0, 0, 0.37)',
801
+ animation: 'fadeIn 0.4s ease-out'
802
+ }}>
803
+ {serverStatus === 'connecting' ? (
804
+ <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1.5rem' }}>
805
+ <div className="spinner" style={{
806
+ width: '50px',
807
+ height: '50px',
808
+ borderRadius: '50%',
809
+ border: '3px solid rgba(145, 70, 255, 0.1)',
810
+ borderTopColor: 'var(--color-brand)',
811
+ animation: 'spin 1s linear infinite'
812
+ }}></div>
813
+ <h2 style={{ fontSize: '1.4rem', fontWeight: 600, color: 'var(--color-text-main)', margin: 0 }}>
814
+ Подключение к серверу аналитики...
815
+ </h2>
816
+ <p style={{ color: 'var(--color-text-muted)', fontSize: '0.88rem', margin: 0, lineHeight: 1.6 }}>
817
+ Сервер бэкенда на Render автоматически засыпает после 15 минут неактивности. Пробуждение сервера занимает около 30–50 секунд. Пожалуйста, подождите.
818
+ </p>
819
+ </div>
820
+ ) : (
821
+ <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1.5rem' }}>
822
+ <div style={{
823
+ width: '60px',
824
+ height: '60px',
825
+ borderRadius: '50%',
826
+ backgroundColor: 'rgba(255, 73, 73, 0.1)',
827
+ border: '1px solid rgba(255, 73, 73, 0.2)',
828
+ display: 'flex',
829
+ alignItems: 'center',
830
+ justifyContent: 'center',
831
+ color: '#FF4949',
832
+ fontSize: '1.8rem'
833
+ }}>⚠️</div>
834
+ <h2 style={{ fontSize: '1.4rem', fontWeight: 600, color: 'var(--color-text-main)', margin: 0 }}>
835
+ Бэкенд недоступен
836
+ </h2>
837
+ <p style={{ color: 'var(--color-text-muted)', fontSize: '0.88rem', margin: 0, lineHeight: 1.6 }}>
838
+ Не удалось установить соединение с сервером. Возможно, сервер временно отключен, обновляется или ваш провайдер блокирует подключение.
839
+ </p>
840
+ <button
841
+ className="btn btn-refresh"
842
+ onClick={() => {
843
+ setServerStatus('connecting');
844
+ pingServer();
845
+ }}
846
+ style={{
847
+ padding: '0.6rem 1.5rem',
848
+ fontSize: '0.85rem',
849
+ backgroundColor: 'var(--color-brand)',
850
+ color: '#fff',
851
+ border: 'none',
852
+ borderRadius: 'var(--radius-md)',
853
+ cursor: 'pointer',
854
+ fontWeight: 600,
855
+ transition: 'background-color 0.2s',
856
+ marginTop: '0.5rem'
857
+ }}
858
+ >
859
+ <RefreshCw size={14} style={{ marginRight: '8px' }} />
860
+ Повторить попытку
861
+ </button>
862
+ </div>
863
+ )}
864
+ </div>
865
+ ) : loading ? (
866
+ <div className="status-msg">
867
+ <RefreshCw size={48} className="status-msg-icon spin" />
868
+ <p>Загрузка аналитики...</p>
869
+ </div>
870
+ ) : (
871
+ <>
872
+ {/* OVERVIEW TAB */}
873
+ {activeTab === 'overview' && (
874
+ <div className="tab-content">
875
+ {/* Stats row */}
876
+ <div className="grid-4col" style={{ marginBottom: '2rem' }}>
877
+ <div className="stat-card" style={{ '--accent-color': 'var(--color-brand)' }}>
878
+ <div className="stat-card-inner">
879
+ <div style={{ minWidth: 0, flex: 1, marginRight: '0.5rem' }}>
880
+ <div className="stat-label">Сообщений в чате</div>
881
+ <div className="stat-value">{statsSummary.messages.toLocaleString()}</div>
882
+ </div>
883
+ <div className="stat-icon-wrapper"><MessageSquare size={22} style={{ color: 'var(--color-brand)' }} /></div>
884
+ </div>
885
+ </div>
886
+ <div className="stat-card" style={{ '--accent-color': 'var(--color-text-accent)' }}>
887
+ <div className="stat-card-inner">
888
+ <div style={{ minWidth: 0, flex: 1, marginRight: '0.5rem' }}>
889
+ <div className="stat-label">Уникальных зрителей</div>
890
+ <div className="stat-value">{statsSummary.uniqueChatters.toLocaleString()}</div>
891
+ </div>
892
+ <div className="stat-icon-wrapper"><Users size={22} style={{ color: 'var(--color-text-accent)' }} /></div>
893
+ </div>
894
+ </div>
895
+ <div className="stat-card" style={{ '--accent-color': '#ff007f' }}>
896
+ <div className="stat-card-inner">
897
+ <div style={{ minWidth: 0, flex: 1, marginRight: '0.5rem' }}>
898
+ <div className="stat-label">Самый активный чатер</div>
899
+ <div
900
+ className="stat-value"
901
+ title={statsSummary.mostActiveChatter || ''}
902
+ style={{
903
+ fontSize: (statsSummary.mostActiveChatter || '').length > 15 ? '1.05rem' : '1.25rem',
904
+ letterSpacing: '-0.02em',
905
+ overflow: 'hidden',
906
+ textOverflow: 'ellipsis',
907
+ whiteSpace: 'nowrap'
908
+ }}
909
+ >
910
+ {statsSummary.mostActiveChatter || '—'}
911
+ </div>
912
+ </div>
913
+ <div className="stat-icon-wrapper"><Users size={22} style={{ color: '#ff007f' }} /></div>
914
+ </div>
915
+ </div>
916
+ <div className="stat-card" style={{ '--accent-color': '#00f5d4' }}>
917
+ <div className="stat-card-inner">
918
+ <div style={{ minWidth: 0, flex: 1, marginRight: '0.5rem' }}>
919
+ <div className="stat-label">Распознано слов (голос)</div>
920
+ <div className="stat-value">{statsSummary.voiceWordsCount.toLocaleString()}</div>
921
+ </div>
922
+ <div className="stat-icon-wrapper"><Mic size={22} style={{ color: '#00f5d4' }} /></div>
923
+ </div>
924
+ </div>
925
+ </div>
926
+
927
+ <div className="grid-2col" style={{ gridTemplateColumns: '2fr 1fr' }}>
928
+ {/* Activity graph */}
929
+ <div className="panel">
930
+ <div className="panel-header">
931
+ <h3 className="panel-title"><Activity size={20} /> Активность чата</h3>
932
+ <div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center' }}>
933
+ <span style={{ fontSize: '0.8rem', color: 'var(--color-text-muted)' }}>выберите конкретный стрим для графика</span>
934
+ {activityData.length > 0 && (
935
+ <button
936
+ className="btn btn-secondary"
937
+ style={{ fontSize: '0.75rem', padding: '0.3rem 0.6rem' }}
938
+ onClick={startGraphRace}
939
+ disabled={isRacing}
940
+ >
941
+ <Activity size={12} className={isRacing ? "pulse" : ""} /> {isRacing ? 'Гонка идет...' : 'Запустить гонку'}
942
+ </button>
943
+ )}
944
+ </div>
945
+ </div>
946
+ {selectedStreamId === 'all' ? (
947
+ <div className="status-msg" style={{ height: '300px' }}>
948
+ <BarChart2 size={36} className="status-msg-icon" />
949
+ <p>Для просмотра временного графика выберите конкретный стрим в верхнем меню</p>
950
+ </div>
951
+ ) : activityData.length === 0 ? (
952
+ <div className="status-msg" style={{ height: '300px' }}>
953
+ <MessageSquare size={36} className="status-msg-icon" />
954
+ <p>В этом стриме пока не было сообщений</p>
955
+ </div>
956
+ ) : (
957
+ <div className="chart-container">
958
+ <Line data={chartDataConfig} options={chartOptions} />
959
+ </div>
960
+ )}
961
+ </div>
962
+
963
+ {/* Quick Words list */}
964
+ <div className="panel">
965
+ <div className="panel-header">
966
+ <h3 className="panel-title">
967
+ <Mic size={18} style={{ color: 'var(--color-text-accent)' }} /> Голос стримера (Топ)
968
+ </h3>
969
+ </div>
970
+ {voiceWords.length === 0 ? (
971
+ <div className="status-msg" style={{ height: '300px', padding: '1rem' }}>
972
+ <Mic size={28} className="status-msg-icon" />
973
+ <p>Слова пока не распознаны. Запустите local_worker на ПК.</p>
974
+ </div>
975
+ ) : (
976
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', maxHeight: '320px', overflowY: 'auto', paddingRight: '4px' }}>
977
+ {voiceWords.slice(0, 7).map((w, idx) => (
978
+ <div key={idx} style={{ display: 'flex', justifyContent: 'space-between', padding: '0.5rem 0.75rem', backgroundColor: 'var(--color-bg-base)', border: '1px solid var(--color-border)', borderRadius: '6px', alignItems: 'center' }}>
979
+ <span style={{ fontWeight: 600, color: 'var(--color-text-main)' }}>#{idx+1} {w.word}</span>
980
+ <span className="badge badge-streamer">{w.word_count} раз</span>
981
+ </div>
982
+ ))}
983
+ </div>
984
+ )}
985
+ </div>
986
+ </div>
987
+ </div>
988
+ )}
989
+
990
+ {/* CHATTERS TAB */}
991
+ {activeTab === 'chatters' && (
992
+ <div className="grid-2col tab-content" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(340px, 1fr))', gap: '1.5rem', alignItems: 'start' }}>
993
+ <div className="panel">
994
+ <div className="panel-header">
995
+ <h3 className="panel-title"><Users size={20} /> Орбита зрителей</h3>
996
+ </div>
997
+ <div style={{ display: 'flex', justifyContent: 'center', padding: '1rem 0' }}>
998
+ <ChatterOrbit chatters={chatters} />
999
+ </div>
1000
+ </div>
1001
+
1002
+ <div className="panel">
1003
+ <div className="panel-header">
1004
+ <h3 className="panel-title"><Users size={20} /> Лидеры чата по активности</h3>
1005
+ <div className="search-container">
1006
+ <Search size={16} className="search-icon" />
1007
+ <input
1008
+ type="text"
1009
+ className="search-input"
1010
+ placeholder="Поиск зрителя..."
1011
+ value={chattersSearch}
1012
+ onChange={(e) => setChattersSearch(e.target.value)}
1013
+ />
1014
+ </div>
1015
+ </div>
1016
+
1017
+ {chatters.length === 0 ? (
1018
+ <div className="status-msg">
1019
+ <Users size={40} className="status-msg-icon" />
1020
+ <p>Нет данных о зрителях для этого периода</p>
1021
+ </div>
1022
+ ) : (
1023
+ <div className="table-wrapper">
1024
+ <table className="data-table">
1025
+ <thead>
1026
+ <tr>
1027
+ <th>Место</th>
1028
+ <th>Никнейм</th>
1029
+ <th>Роли</th>
1030
+ <th>Сообщений</th>
1031
+ </tr>
1032
+ </thead>
1033
+ <tbody>
1034
+ {chatters
1035
+ .filter(c => {
1036
+ const disp = c.display_name || c.username || '';
1037
+ const user = c.username || '';
1038
+ return disp.toLowerCase().includes(chattersSearch.toLowerCase()) ||
1039
+ user.toLowerCase().includes(chattersSearch.toLowerCase());
1040
+ })
1041
+ .map((c, idx) => (
1042
+ <tr key={idx} className="chatter-row">
1043
+ <td style={{ fontWeight: 700, width: '80px', color: idx < 3 ? 'var(--color-brand)' : 'var(--color-text-muted)' }}>
1044
+ #{idx + 1}
1045
+ </td>
1046
+ <td style={{ fontWeight: 600 }}>{c.display_name || c.username || '—'}</td>
1047
+ <td>
1048
+ <div style={{ display: 'flex', gap: '0.4rem' }}>
1049
+ {(c.username || '').toLowerCase() === TWITCH_CHANNEL && (
1050
+ <span className="badge badge-streamer">Стример</span>
1051
+ )}
1052
+ {c.is_mod ? <span className="badge badge-mod">Мод</span> : null}
1053
+ {c.is_sub ? <span className="badge badge-sub">Саб</span> : null}
1054
+ </div>
1055
+ </td>
1056
+ <td style={{ fontWeight: 700, color: 'var(--color-text-accent)' }}>
1057
+ {c.message_count.toLocaleString()}
1058
+ </td>
1059
+ </tr>
1060
+ ))}
1061
+ </tbody>
1062
+ </table>
1063
+ </div>
1064
+ )}
1065
+ </div>
1066
+ </div>
1067
+ )}
1068
+
1069
+ {/* WORDS TAB */}
1070
+ {activeTab === 'words' && (
1071
+ <div className="panel tab-content">
1072
+ <div className="panel-header">
1073
+ <h3 className="panel-title"><Mic size={20} /> Частотный словарь</h3>
1074
+
1075
+ <div className="nav-tabs" style={{ background: 'var(--color-bg-base)', padding: '0.2rem', borderRadius: '8px', border: '1px solid var(--color-border)' }}>
1076
+ <button
1077
+ className={`tab-btn ${wordType === 'voice' ? 'active' : ''}`}
1078
+ onClick={() => setWordType('voice')}
1079
+ style={{ fontSize: '0.8rem', padding: '0.4rem 0.8rem' }}
1080
+ >
1081
+ <Volume2 size={14} /> Из Голоса (Whisper)
1082
+ </button>
1083
+ <button
1084
+ className={`tab-btn ${wordType === 'chat' ? 'active' : ''}`}
1085
+ onClick={() => setWordType('chat')}
1086
+ style={{ fontSize: '0.8rem', padding: '0.4rem 0.8rem' }}
1087
+ >
1088
+ <MessageSquare size={14} /> Из Чата (Текстом)
1089
+ </button>
1090
+ </div>
1091
+ </div>
1092
+
1093
+ <p style={{ color: 'var(--color-text-muted)', fontSize: '0.85rem', marginBottom: '1.5rem', marginTop: '-0.75rem' }}>
1094
+ {wordType === 'voice'
1095
+ ? 'Слова, которые стример произнес в микрофон (распознанные через Whisper на локальном ПК).'
1096
+ : 'Слова, которые зрители написали в текстовый чат Twitch.'}
1097
+ </p>
1098
+
1099
+ {(() => {
1100
+ const currentWordsList = wordType === 'voice'
1101
+ ? (Array.isArray(voiceWords) ? voiceWords : [])
1102
+ : (Array.isArray(chatWords) ? chatWords : []);
1103
+ if (currentWordsList.length === 0) {
1104
+ return (
1105
+ <div className="status-msg">
1106
+ <Mic size={40} className="status-msg-icon" />
1107
+ <p>Нет собранных слов для выбранного стрима</p>
1108
+ </div>
1109
+ );
1110
+ }
1111
+ const counts = currentWordsList.map(x => x.word_count);
1112
+ const maxCount = Math.max(...counts, 1);
1113
+ const minCount = Math.min(...counts, 1);
1114
+
1115
+ // Helper for Russian pluralization
1116
+ const getRussianPlural = (count) => {
1117
+ const lastDigit = count % 10;
1118
+ const lastTwoDigits = count % 100;
1119
+ if (lastTwoDigits >= 11 && lastTwoDigits <= 19) {
1120
+ return 'раз';
1121
+ }
1122
+ if (lastDigit === 1) {
1123
+ return 'раз';
1124
+ }
1125
+ if (lastDigit >= 2 && lastDigit <= 4) {
1126
+ return 'раза';
1127
+ }
1128
+ return 'раз';
1129
+ };
1130
+
1131
+ // Color assignment based on word frequency: purple gradient
1132
+ const getWordColor = (count) => {
1133
+ const scale = maxCount === minCount ? 1 : (count - minCount) / (maxCount - minCount);
1134
+ // Scale saturation from 35% (desaturated/white-lavender) to 100% (saturated purple)
1135
+ const sat = Math.round(35 + scale * 65);
1136
+ // Scale lightness from 92% (light/white-ish) to 60% (vivid deep purple)
1137
+ const light = Math.round(92 - scale * 32);
1138
+ return `hsl(265, ${sat}%, ${light}%)`;
1139
+ };
1140
+
1141
+ // Layout calculations with collision avoidance on stretched elliptical spiral
1142
+ const placedBoxes = [];
1143
+ const laidOutWords = [];
1144
+
1145
+ const stretchX = 2.8;
1146
+ const stretchY = 1.0;
1147
+ const paddingX = 12;
1148
+ const paddingY = 8;
1149
+ const remToPx = 16;
1150
+
1151
+ // Identify center group: words with count within 500 of the max count,
1152
+ // but ONLY if the top count is >= 500. Also cap center group size to at most 3 words.
1153
+ const centerGroupWords = currentWordsList.filter((w, idx) =>
1154
+ idx === 0 || (maxCount >= 500 && (maxCount - w.word_count) < 500 && idx < 3)
1155
+ );
1156
+ const otherWordsList = currentWordsList.filter((w, idx) =>
1157
+ !(idx === 0 || (maxCount >= 500 && (maxCount - w.word_count) < 500 && idx < 3))
1158
+ );
1159
+
1160
+ const maxOtherCount = otherWordsList.length > 0 ? Math.max(...otherWordsList.map(x => x.word_count)) : 1;
1161
+ const minOtherCount = otherWordsList.length > 0 ? Math.min(...otherWordsList.map(x => x.word_count)) : 1;
1162
+ const minCenterCount = Math.min(...centerGroupWords.map(x => x.word_count));
1163
+
1164
+ // Function to estimate word width factor based on wide/narrow letters
1165
+ const getWordWidthFactor = (word) => {
1166
+ let factor = 0;
1167
+ const wideChars = /[мжшщыюяwm]/i;
1168
+ const narrowChars = /[ilj1!|т]/i;
1169
+ for (const char of word) {
1170
+ if (wideChars.test(char)) {
1171
+ factor += 0.75;
1172
+ } else if (narrowChars.test(char)) {
1173
+ factor += 0.35;
1174
+ } else {
1175
+ factor += 0.55;
1176
+ }
1177
+ }
1178
+ return factor;
1179
+ };
1180
+
1181
+ for (let i = 0; i < currentWordsList.length; i++) {
1182
+ const w = currentWordsList[i];
1183
+ const isCenterGroup = i === 0 || (maxCount >= 500 && (maxCount - w.word_count) < 500 && i < 3);
1184
+
1185
+ let fontSize;
1186
+ let fontWeight;
1187
+ let scaleValue = 0;
1188
+
1189
+ if (isCenterGroup) {
1190
+ // Scale font size within center group: ranges from 4.5rem to 6.2rem
1191
+ const centerScale = maxCount === minCenterCount ? 1 : (w.word_count - minCenterCount) / (maxCount - minCenterCount);
1192
+ fontSize = 4.5 + centerScale * 1.7;
1193
+ fontWeight = '900';
1194
+ scaleValue = centerScale;
1195
+ } else {
1196
+ // Scale font size within other words: ranges from 1.1rem to 3.2rem
1197
+ const scale = maxOtherCount === minOtherCount ? 1 : (w.word_count - minOtherCount) / (maxOtherCount - minOtherCount);
1198
+ scaleValue = Math.pow(scale, 1.4);
1199
+ fontSize = 1.1 + scaleValue * 2.1;
1200
+ fontWeight = fontSize > 2.0 ? '700' : (fontSize > 1.4 ? '600' : '500');
1201
+ }
1202
+
1203
+ // Precise width calculation based on custom width factors
1204
+ const wordWidth = fontSize * getWordWidthFactor(w.word) * remToPx;
1205
+ const wordHeight = fontSize * 1.1 * remToPx;
1206
+
1207
+ let x = 0;
1208
+ let y = 0;
1209
+
1210
+ // Generate a stable hash for the word to randomize angle and jitter
1211
+ let hash = 0;
1212
+ for (let j = 0; j < w.word.length; j++) {
1213
+ hash = w.word.charCodeAt(j) + ((hash << 5) - hash);
1214
+ }
1215
+ hash = Math.abs(hash);
1216
+
1217
+ // Search for position starting from r = 0.
1218
+ // Since center group words are processed first, they cluster tightly around (0,0).
1219
+ let found = false;
1220
+ const rStep = 1.1;
1221
+
1222
+ for (let attempt = 0; attempt < 1200; attempt++) {
1223
+ const startAngle = (hash % 100) * 0.06283; // 0 to 2*PI
1224
+ const angle = startAngle + attempt * 0.15;
1225
+ const r = (i === 0 && attempt === 0) ? 0 : (45 + rStep * attempt);
1226
+
1227
+ x = r * Math.cos(angle) * stretchX;
1228
+ y = r * Math.sin(angle) * stretchY;
1229
+
1230
+ // Add coordinate noise/jitter (except for the absolute top word at the center)
1231
+ if (r > 0) {
1232
+ x += Math.sin(hash * 0.5 + attempt) * 6;
1233
+ y += Math.cos(hash * 0.8 + attempt) * 4;
1234
+ }
1235
+
1236
+ let collision = false;
1237
+ for (const box of placedBoxes) {
1238
+ const halfW1 = wordWidth / 2;
1239
+ const halfH1 = wordHeight / 2;
1240
+ const halfW2 = box.w / 2;
1241
+ const halfH2 = box.h / 2;
1242
+
1243
+ if (Math.abs(x - box.x) < (halfW1 + halfW2 + paddingX) &&
1244
+ Math.abs(y - box.y) < (halfH1 + halfH2 + paddingY)) {
1245
+ collision = true;
1246
+ break;
1247
+ }
1248
+ }
1249
+
1250
+ if (!collision) {
1251
+ found = true;
1252
+ break;
1253
+ }
1254
+ }
1255
+
1256
+ placedBoxes.push({ x: x, y: y, w: wordWidth, h: wordHeight });
1257
+
1258
+ const color = getWordColor(w.word_count);
1259
+
1260
+ // Floating parameters
1261
+ const duration = 4.5 + (i % 3) + (i % 4) * 0.4; // 4.5s to 8.5s
1262
+ const delay = -((i * 1.3) % 7); // negative delay to start asynchronously
1263
+ const amount = 3 + (i % 4); // 3px to 6px float amount
1264
+
1265
+ laidOutWords.push({
1266
+ word: w.word,
1267
+ count: w.word_count,
1268
+ x: Math.round(x),
1269
+ y: Math.round(y),
1270
+ fontSize: `${fontSize}rem`,
1271
+ fontWeight: fontWeight,
1272
+ color: color,
1273
+ scale: scaleValue,
1274
+ isCenterGroup: isCenterGroup,
1275
+ duration: `${duration}s`,
1276
+ delay: `${delay}s`,
1277
+ amount: `${amount}px`
1278
+ });
1279
+ }
1280
+
1281
+ return (
1282
+ <div className="word-galaxy-container">
1283
+ {laidOutWords.map((w, idx) => (
1284
+ <div
1285
+ key={idx}
1286
+ className="word-galaxy-tag"
1287
+ style={{
1288
+ '--x': `${w.x}px`,
1289
+ '--y': `${w.y}px`,
1290
+ '--float-duration': w.duration,
1291
+ '--float-delay': w.delay,
1292
+ '--float-amount': w.amount,
1293
+ fontSize: w.fontSize,
1294
+ fontWeight: w.fontWeight,
1295
+ color: w.color,
1296
+ opacity: 0.95, // High opacity so purple colors are bright and vivid
1297
+ zIndex: w.isCenterGroup ? 25 : Math.round(10 + w.scale * 15)
1298
+ }}
1299
+ >
1300
+ {w.word}
1301
+ <div className="word-tooltip">
1302
+ {w.count} {getRussianPlural(w.count)}
1303
+ </div>
1304
+ </div>
1305
+ ))}
1306
+ </div>
1307
+ );
1308
+ })()}
1309
+ </div>
1310
+ )}
1311
+
1312
+ {/* ADMIN TAB */}
1313
+ {activeTab === 'admin' && (
1314
+ <div className="tab-content">
1315
+ <h2 style={{ fontSize: '1.5rem', fontWeight: 700, marginBottom: '1.5rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
1316
+ <Settings size={24} style={{ color: 'var(--color-brand)' }} /> Панель администратора
1317
+ </h2>
1318
+
1319
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))', gap: '1.5rem', marginBottom: '2rem' }}>
1320
+
1321
+ {/* Left: Stream management */}
1322
+ <div className="panel" style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
1323
+ <div className="panel-header">
1324
+ <h3 className="panel-title">Управление стримами</h3>
1325
+ </div>
1326
+
1327
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
1328
+ <label style={{ fontSize: '0.85rem', fontWeight: 600, color: 'var(--color-text-muted)' }}>Выбрать стрим для редактирования или удаления:</label>
1329
+ <select
1330
+ className="dark-input"
1331
+ value={adminSelectedStreamId}
1332
+ onChange={(e) => setAdminSelectedStreamId(e.target.value)}
1333
+ >
1334
+ <option value="">-- Выберите стрим --</option>
1335
+ {streams.map((stream) => (
1336
+ <option key={stream.id} value={stream.id}>
1337
+ {stream.title || 'Без названия'} ({new Date(stream.start_time).toLocaleDateString('ru-RU')} {new Date(stream.start_time).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })})
1338
+ </option>
1339
+ ))}
1340
+ </select>
1341
+ </div>
1342
+
1343
+ {(() => {
1344
+ const activeStream = streams.find(s => s.id === parseInt(adminSelectedStreamId));
1345
+ if (!activeStream) return (
1346
+ <div style={{ padding: '1.5rem', textAlign: 'center', color: 'var(--color-text-muted)', fontSize: '0.85rem' }}>
1347
+ Выберите стрим в выпадающем списке выше для выполнения действий.
1348
+ </div>
1349
+ );
1350
+
1351
+ const isLive = !activeStream.end_time;
1352
+ const statusLabel =
1353
+ activeStream.backfill_status === 'completed' ? 'Импортирован полностью' :
1354
+ activeStream.backfill_status === 'pending' ? 'В очереди воркера (ожидание)' :
1355
+ activeStream.backfill_status === 'live' ? 'В эфире (live)' : activeStream.backfill_status;
1356
+
1357
+ const statusColor =
1358
+ activeStream.backfill_status === 'completed' ? 'var(--color-success, #00f2fe)' :
1359
+ activeStream.backfill_status === 'pending' ? 'var(--color-warning, #f1c40f)' : 'var(--color-primary)';
1360
+
1361
+ return (
1362
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
1363
+ <div style={{ fontSize: '0.8rem', padding: '0.75rem', background: 'rgba(255,255,255,0.02)', borderRadius: '6px', border: '1px solid var(--color-border)' }}>
1364
+ <p style={{ margin: '0 0 0.4rem 0' }}><strong>ID сессии:</strong> {activeStream.id}</p>
1365
+ <p style={{ margin: '0 0 0.4rem 0' }}><strong>Статус импорта VOD:</strong> <span style={{ color: statusColor, fontWeight: 'bold' }}>{statusLabel}</span></p>
1366
+ <p style={{ margin: '0 0 0.4rem 0' }}><strong>Twitch VOD ID:</strong> {activeStream.twitch_vod_id || 'Отсутствует'}</p>
1367
+ <p style={{ margin: 0 }}><strong>Начало:</strong> {new Date(activeStream.start_time).toLocaleString('ru-RU')}</p>
1368
+ </div>
1369
+
1370
+ {/* Edit Metadata */}
1371
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', padding: '1rem', background: 'rgba(255,255,255,0.01)', borderRadius: '6px', border: '1px solid rgba(255,255,255,0.03)' }}>
1372
+ <h4 style={{ margin: 0, fontSize: '0.9rem', fontWeight: 600 }}>Редактирование названия / категории</h4>
1373
+
1374
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem' }}>
1375
+ <label style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)' }}>Название стрима:</label>
1376
+ <input
1377
+ type="text"
1378
+ className="dark-input"
1379
+ value={editTitle}
1380
+ onChange={(e) => setEditTitle(e.target.value)}
1381
+ />
1382
+ </div>
1383
+
1384
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem' }}>
1385
+ <label style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)' }}>Категория (игра):</label>
1386
+ <input
1387
+ type="text"
1388
+ className="dark-input"
1389
+ value={editCategory}
1390
+ onChange={(e) => setEditCategory(e.target.value)}
1391
+ />
1392
+ </div>
1393
+
1394
+ <button
1395
+ className="btn btn-secondary"
1396
+ style={{ width: '100%', padding: '0.5rem', marginTop: '0.25rem' }}
1397
+ disabled={savingMetadata || !editTitle.trim()}
1398
+ onClick={async () => {
1399
+ setSavingMetadata(true);
1400
+ try {
1401
+ const res = await fetch(`${API_BASE}/api/streams/${activeStream.id}`, {
1402
+ method: 'PUT',
1403
+ headers: { 'Content-Type': 'application/json' },
1404
+ body: JSON.stringify({ title: editTitle, category: editCategory }),
1405
+ credentials: 'include'
1406
+ });
1407
+ const data = await res.json();
1408
+ if (data.success) {
1409
+ alert('Метаданные стрима успешно обновлены!');
1410
+ fetchStreams();
1411
+ } else {
1412
+ alert(`Ошибка: ${data.error}`);
1413
+ }
1414
+ } catch (e) {
1415
+ alert('Ошибка при обновлении метаданных.');
1416
+ } finally {
1417
+ setSavingMetadata(false);
1418
+ }
1419
+ }}
1420
+ >
1421
+ {savingMetadata ? 'Сохранение...' : 'Сохранить изменения'}
1422
+ </button>
1423
+ </div>
1424
+
1425
+ {/* VOD Actions (Only if VOD is present) */}
1426
+ {activeStream.twitch_vod_id && (
1427
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
1428
+ <h4 style={{ margin: 0, fontSize: '0.9rem', fontWeight: 600 }}>Действия импорта VOD:</h4>
1429
+ <div style={{ display: 'flex', gap: '0.5rem' }}>
1430
+ <button
1431
+ className="btn btn-secondary"
1432
+ style={{ flex: 1, fontSize: '0.75rem', padding: '0.5rem' }}
1433
+ onClick={async () => {
1434
+ if (!confirm(`Запустить заполнение пропусков для стрима "${activeStream.title}"? Воркер докачает пропущенный чат и Whisper-речь.`)) return;
1435
+ try {
1436
+ const res = await fetch(`${API_BASE}/api/streams/reset-backfill`, {
1437
+ method: 'POST',
1438
+ headers: { 'Content-Type': 'application/json' },
1439
+ body: JSON.stringify({ streamId: activeStream.id, mode: 'gap_fill' }),
1440
+ credentials: 'include'
1441
+ });
1442
+ const data = await res.json();
1443
+ if (data.success) {
1444
+ alert('Статус сброшен на "ожидание". Воркер скоро начнет дозаполнение!');
1445
+ fetchStreams();
1446
+ } else {
1447
+ alert(`Ошибка: ${data.error}`);
1448
+ }
1449
+ } catch (e) {
1450
+ alert('Ошибка при запуске дозаполнения VOD.');
1451
+ }
1452
+ }}
1453
+ >
1454
+ Заполнить пропуски VOD
1455
+ </button>
1456
+ <button
1457
+ className="btn btn-secondary"
1458
+ style={{ flex: 1, fontSize: '0.75rem', padding: '0.5rem' }}
1459
+ onClick={async () => {
1460
+ if (!confirm(`Внимание: это полностью удалит сохраненные сообщения чата и слова Whisper для стрима "${activeStream.title}" и заново запустит весь импорт VOD с 0-й секунды. Вы уверены?`)) return;
1461
+ try {
1462
+ const res = await fetch(`${API_BASE}/api/streams/reset-backfill`, {
1463
+ method: 'POST',
1464
+ headers: { 'Content-Type': 'application/json' },
1465
+ body: JSON.stringify({ streamId: activeStream.id, mode: 'full_rebuild' }),
1466
+ credentials: 'include'
1467
+ });
1468
+ const data = await res.json();
1469
+ if (data.success) {
1470
+ alert('Данные очищены. Стрим поставлен на полный переимпорт воркером!');
1471
+ fetchStreams();
1472
+ } else {
1473
+ alert(`Ошибка: ${data.error}`);
1474
+ }
1475
+ } catch (e) {
1476
+ alert('Ошибка при запуске переимпорта.');
1477
+ }
1478
+ }}
1479
+ >
1480
+ Полный переимпорт VOD
1481
+ </button>
1482
+ </div>
1483
+ </div>
1484
+ )}
1485
+
1486
+ {/* Delete Button */}
1487
+ <div style={{ borderTop: '1px solid var(--color-border)', paddingTop: '1rem', marginTop: '0.5rem' }}>
1488
+ <button
1489
+ className="btn btn-danger"
1490
+ style={{
1491
+ width: '100%',
1492
+ display: 'flex',
1493
+ alignItems: 'center',
1494
+ justifyContent: 'center',
1495
+ gap: '0.4rem',
1496
+ backgroundColor: 'rgba(231, 76, 60, 0.15)',
1497
+ color: '#e74c3c',
1498
+ border: '1px solid rgba(231, 76, 60, 0.3)',
1499
+ borderRadius: '4px',
1500
+ cursor: 'pointer',
1501
+ padding: '0.6rem'
1502
+ }}
1503
+ disabled={deletingStream}
1504
+ onClick={async () => {
1505
+ if (!confirm(`ВНИМАНИЕ: Вы уверены, что хотите полностью УДАЛИТЬ стрим "${activeStream.title}"? Это действие сотрет всю связанную статистику (чат, слова Whisper, модерацию) из базы данных навсегда и безвозвратно!`)) return;
1506
+ if (!confirm(`ПОСЛЕДНЕЕ ПРЕДУПРЕЖДЕНИЕ: Вы действительно хотите стереть стрим ID ${activeStream.id} из базы? Восстановление невозможно.`)) return;
1507
+
1508
+ setDeletingStream(true);
1509
+ try {
1510
+ const res = await fetch(`${API_BASE}/api/streams/${activeStream.id}`, {
1511
+ method: 'DELETE',
1512
+ credentials: 'include'
1513
+ });
1514
+ const data = await res.json();
1515
+ if (data.success) {
1516
+ alert('Стрим успешно удален из базы данных!');
1517
+ setAdminSelectedStreamId('');
1518
+ fetchStreams();
1519
+ } else {
1520
+ alert(`Ошибка: ${data.error}`);
1521
+ }
1522
+ } catch (e) {
1523
+ alert('Ошибка при удалении стрима.');
1524
+ } finally {
1525
+ setDeletingStream(false);
1526
+ }
1527
+ }}
1528
+ >
1529
+ <Trash2 size={16} /> Удалить стрим полностью
1530
+ </button>
1531
+ </div>
1532
+ </div>
1533
+ );
1534
+ })()}
1535
+ </div>
1536
+
1537
+ {/* Right: Global actions and Stats */}
1538
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
1539
+
1540
+ {/* Global Actions Panel */}
1541
+ <div className="panel">
1542
+ <div className="panel-header">
1543
+ <h3 className="panel-title">Глобальные действия</h3>
1544
+ </div>
1545
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
1546
+
1547
+ {/* Twitch VOD Sync */}
1548
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', paddingBottom: '0.75rem', borderBottom: '1px solid var(--color-border)' }}>
1549
+ <div>
1550
+ <h4 style={{ margin: 0, fontSize: '0.85rem', fontWeight: 600 }}>Синхронизация Twitch VOD</h4>
1551
+ <p style={{ margin: 0, fontSize: '0.7rem', color: 'var(--color-text-muted)' }}>Запросить последние 20 архивов из Twitch API.</p>
1552
+ </div>
1553
+ <button
1554
+ className="btn btn-secondary"
1555
+ style={{ fontSize: '0.75rem', padding: '0.4rem 0.8rem' }}
1556
+ disabled={syncingVods}
1557
+ onClick={async () => {
1558
+ setSyncingVods(true);
1559
+ try {
1560
+ const res = await fetch(`${API_BASE}/api/streams/sync-vods`, { method: 'POST', credentials: 'include' });
1561
+ const data = await res.json();
1562
+ if (data.success) {
1563
+ alert(`Успешно импортировано ${data.count} стримов!`);
1564
+ fetchStreams();
1565
+ } else {
1566
+ alert(`Ошибка: ${data.error}`);
1567
+ }
1568
+ } catch (e) {
1569
+ alert('Ошибка при синхронизации VOD.');
1570
+ } finally {
1571
+ setSyncingVods(false);
1572
+ }
1573
+ }}
1574
+ >
1575
+ <RefreshCw size={12} className={syncingVods ? "spin" : ""} /> {syncingVods ? 'Синхронизация...' : 'Синхронизировать VOD'}
1576
+ </button>
1577
+ </div>
1578
+
1579
+ {/* Database Cleanup */}
1580
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
1581
+ <div>
1582
+ <h4 style={{ margin: 0, fontSize: '0.85rem', fontWeight: 600 }}>Очистить пустые стримы</h4>
1583
+ <p style={{ margin: 0, fontSize: '0.7rem', color: 'var(--color-text-muted)' }}>Удалить сессии с 0 сообщениями и 0 слов.</p>
1584
+ </div>
1585
+ <button
1586
+ className="btn btn-secondary"
1587
+ style={{ fontSize: '0.75rem', padding: '0.4rem 0.8rem' }}
1588
+ disabled={cleaningStreams}
1589
+ onClick={async () => {
1590
+ if (!confirm('Вы действительно хотите удалить все пустые стримы (в которых нет ни сообщений в чате, ни распознанных слов)? Это очистит тестовый мусор из списка.')) return;
1591
+ setCleaningStreams(true);
1592
+ try {
1593
+ const res = await fetch(`${API_BASE}/api/admin/cleanup`, { method: 'POST', credentials: 'include' });
1594
+ const data = await res.json();
1595
+ if (data.success) {
1596
+ alert(`Успешно очищено. Удалено пустых стримов: ${data.count}`);
1597
+ fetchStreams();
1598
+ fetchAdminStats();
1599
+ } else {
1600
+ alert(`Ошибка: ${data.error}`);
1601
+ }
1602
+ } catch (e) {
1603
+ alert('Ошибка при очистке стримов.');
1604
+ } finally {
1605
+ setCleaningStreams(false);
1606
+ }
1607
+ }}
1608
+ >
1609
+ Очистить пустые стримы
1610
+ </button>
1611
+ </div>
1612
+
1613
+ </div>
1614
+ </div>
1615
+
1616
+ {/* System Statistics Panel */}
1617
+ <div className="panel">
1618
+ <div className="panel-header">
1619
+ <h3 className="panel-title">Системная статистика</h3>
1620
+ </div>
1621
+
1622
+ {loadingAdminStats || !adminStats ? (
1623
+ <div style={{ padding: '1rem', textAlign: 'center', color: 'var(--color-text-muted)', fontSize: '0.85rem' }}>
1624
+ Загрузка статистики...
1625
+ </div>
1626
+ ) : (
1627
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', fontSize: '0.8rem' }}>
1628
+ <div style={{ display: 'flex', justifyContent: 'space-between', paddingBottom: '0.4rem', borderBottom: '1px solid rgba(255,255,255,0.03)' }}>
1629
+ <span style={{ color: 'var(--color-text-muted)' }}>Режим базы данных:</span>
1630
+ <strong style={{ color: 'var(--color-brand)' }}>{adminStats.dbMode.toUpperCase()}</strong>
1631
+ </div>
1632
+ {adminStats.dbMode === 'sqlite' && (
1633
+ <div style={{ display: 'flex', justifyContent: 'space-between', paddingBottom: '0.4rem', borderBottom: '1px solid rgba(255,255,255,0.03)' }}>
1634
+ <span style={{ color: 'var(--color-text-muted)' }}>Размер файла SQLite:</span>
1635
+ <strong>{adminStats.dbSizeMb} МБ</strong>
1636
+ </div>
1637
+ )}
1638
+ <div style={{ display: 'flex', justifyContent: 'space-between', paddingBottom: '0.4rem', borderBottom: '1px solid rgba(255,255,255,0.03)' }}>
1639
+ <span style={{ color: 'var(--color-text-muted)' }}>Всего стримов в базе:</span>
1640
+ <strong>{adminStats.totalStreams}</strong>
1641
+ </div>
1642
+ <div style={{ display: 'flex', justifyContent: 'space-between', paddingBottom: '0.4rem', borderBottom: '1px solid rgba(255,255,255,0.03)' }}>
1643
+ <span style={{ color: 'var(--color-text-muted)' }}>Всего сообщений чата:</span>
1644
+ <strong>{adminStats.totalMessages.toLocaleString()}</strong>
1645
+ </div>
1646
+ <div style={{ display: 'flex', justifyContent: 'space-between' }}>
1647
+ <span style={{ color: 'var(--color-text-muted)' }}>Всего голосовых слов:</span>
1648
+ <strong>{adminStats.totalVoiceWords.toLocaleString()}</strong>
1649
+ </div>
1650
+
1651
+ <button
1652
+ className="btn btn-secondary"
1653
+ style={{ width: '100%', fontSize: '0.75rem', padding: '0.4rem', marginTop: '0.5rem' }}
1654
+ onClick={fetchAdminStats}
1655
+ >
1656
+ Обновить статистику
1657
+ </button>
1658
+ </div>
1659
+ )}
1660
+ </div>
1661
+
1662
+ </div>
1663
+
1664
+ </div>
1665
+ </div>
1666
+ )}
1667
+
1668
+ {/* MODERATOR TAB */}
1669
+ {activeTab === 'moderator' && (
1670
+ <div className="tab-content">
1671
+ {twitchConfigured && !auth.loggedIn ? (
1672
+ <div className="login-overlay">
1673
+ <div className="login-card">
1674
+ <Lock size={48} style={{ color: 'var(--color-brand)', marginBottom: '1.5rem' }} />
1675
+ <h2 className="login-title">Доступ Ограничен</h2>
1676
+ <p className="login-description">
1677
+ Лог действий модераторов и статистика банов/удалений доступны только стримеру и официальным модераторам канала **winx_prinx**.
1678
+ Пожалуйста, авторизуйтесь через Twitch для проверки прав.
1679
+ </p>
1680
+ <button className="btn" onClick={handleLogin}>
1681
+ <LogIn size={16} /> Войти через Twitch
1682
+ </button>
1683
+ </div>
1684
+ </div>
1685
+ ) : twitchConfigured && (auth.user?.role !== 'streamer' && auth.user?.role !== 'moderator' && auth.user?.role !== 'admin') ? (
1686
+ <div className="status-msg">
1687
+ <ShieldAlert size={48} className="status-msg-icon" />
1688
+ <h2>Недостаточно прав</h2>
1689
+ <p>Вы успешно вошли как <strong>{auth.user?.displayName}</strong>, но вы не являетесь модератором канала {TWITCH_CHANNEL}.</p>
1690
+ </div>
1691
+ ) : (
1692
+ <div>
1693
+ {modProfiles.length <= 2 && (
1694
+ <div style={{ background: 'rgba(145, 70, 255, 0.1)', border: '1px solid rgba(145, 70, 255, 0.3)', borderRadius: '8px', padding: '1rem', marginBottom: '1.5rem', display: 'flex', gap: '1rem', alignItems: 'center' }}>
1695
+ <ShieldAlert size={24} style={{ color: 'var(--color-brand)' }} />
1696
+ <div>
1697
+ <h4 style={{ margin: '0 0 0.25rem 0', color: 'var(--color-text-main)', fontSize: '0.95rem' }}>Данные накапливаются</h4>
1698
+ <p style={{ margin: 0, fontSize: '0.85rem', color: 'var(--color-text-muted)' }}>Информация о модераторах появляется по мере их активности в выбранном стриме.</p>
1699
+ </div>
1700
+ </div>
1701
+ )}
1702
+ {/* TOP 3 PODIUM */}
1703
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: '1.5rem', marginBottom: '2rem' }}>
1704
+ {modProfiles.slice(0, 3).map((mod, idx) => {
1705
+ const trophyColor = idx === 0 ? '#FFD700' : idx === 1 ? '#C0C0C0' : '#CD7F32';
1706
+ const glowStyle = {
1707
+ border: `1px solid ${trophyColor}40`,
1708
+ boxShadow: `0 0 15px ${trophyColor}12`,
1709
+ position: 'relative',
1710
+ overflow: 'hidden'
1711
+ };
1712
+ return (
1713
+ <div key={idx} className="panel" style={glowStyle}>
1714
+ <div style={{ position: 'absolute', top: '-10px', right: '-10px', fontSize: '5rem', opacity: 0.08, color: trophyColor, fontWeight: 900 }}>
1715
+ #{idx + 1}
1716
+ </div>
1717
+ <div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
1718
+ <div style={{
1719
+ width: '44px',
1720
+ height: '44px',
1721
+ borderRadius: '50%',
1722
+ background: 'var(--color-bg-base)',
1723
+ border: `2px solid ${trophyColor}`,
1724
+ display: 'flex',
1725
+ alignItems: 'center',
1726
+ justifyContent: 'center',
1727
+ fontWeight: 700,
1728
+ fontSize: '1.1rem',
1729
+ color: trophyColor
1730
+ }}>
1731
+ {idx === 0 ? '👑' : idx === 1 ? '🥈' : '🥉'}
1732
+ </div>
1733
+ <div>
1734
+ <h4 style={{ margin: 0, fontSize: '1rem', fontWeight: 700 }}>{mod.moderator}</h4>
1735
+ <span style={{ fontSize: '0.7rem', color: 'var(--color-text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
1736
+ {idx === 0 ? 'Глава патруля' : idx === 1 ? 'Старший мод' : 'Защитник чата'}
1737
+ </span>
1738
+ </div>
1739
+ </div>
1740
+ <div style={{ display: 'flex', justifyContent: 'space-between', marginTop: '1.25rem', fontSize: '0.8rem' }}>
1741
+ <div>
1742
+ <div style={{ color: 'var(--color-text-muted)' }}>Действий</div>
1743
+ <div style={{ fontSize: '1.1rem', fontWeight: 700, color: 'var(--color-text-accent)' }}>{mod.total_actions}</div>
1744
+ </div>
1745
+ <div>
1746
+ <div style={{ color: 'var(--color-text-muted)' }}>Ср. Реакция</div>
1747
+ <div style={{ fontSize: '1.1rem', fontWeight: 700, color: '#00F5D4' }}>
1748
+ {mod.reaction_time_avg ? `${mod.reaction_time_avg}с` : '—'}
1749
+ </div>
1750
+ </div>
1751
+ <div>
1752
+ <div style={{ color: 'var(--color-text-muted)' }}>КПД Активности</div>
1753
+ <div style={{ fontSize: '1.1rem', fontWeight: 700, color: '#A370F7' }}>{mod.scores.activity}%</div>
1754
+ </div>
1755
+ </div>
1756
+ </div>
1757
+ );
1758
+ })}
1759
+ </div>
1760
+
1761
+ <div className="grid-2col" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))', gap: '1.5rem', alignItems: 'start' }}>
1762
+ {/* Left: Mod List */}
1763
+ <div className="panel">
1764
+ <div className="panel-header">
1765
+ <h3 className="panel-title"><ShieldAlert size={20} /> Рейтинг модераторов</h3>
1766
+ </div>
1767
+ {modProfiles.length === 0 ? (
1768
+ <div className="status-msg">
1769
+ <ShieldAlert size={40} className="status-msg-icon" />
1770
+ <p>Модераторы еще не совершали действий в этом периоде</p>
1771
+ </div>
1772
+ ) : (
1773
+ <div className="table-wrapper">
1774
+ <table className="data-table">
1775
+ <thead>
1776
+ <tr>
1777
+ <th>Ранг</th>
1778
+ <th>Никнейм</th>
1779
+ <th>Действий</th>
1780
+ <th>Ср. Реакция</th>
1781
+ </tr>
1782
+ </thead>
1783
+ <tbody>
1784
+ {modProfiles.map((mod, idx) => (
1785
+ <tr
1786
+ key={idx}
1787
+ onClick={() => setSelectedMod(mod)}
1788
+ style={{
1789
+ cursor: 'pointer',
1790
+ background: selectedMod && selectedMod.moderator === mod.moderator ? 'rgba(163, 112, 247, 0.08)' : 'transparent',
1791
+ borderLeft: selectedMod && selectedMod.moderator === mod.moderator ? '3px solid var(--color-brand)' : 'none'
1792
+ }}
1793
+ >
1794
+ <td>#{idx + 1}</td>
1795
+ <td style={{ fontWeight: 600 }}>{mod.moderator}</td>
1796
+ <td>{mod.total_actions}</td>
1797
+ <td style={{ color: '#00F5D4', fontWeight: 600 }}>{mod.reaction_time_avg ? `${mod.reaction_time_avg}с` : '—'}</td>
1798
+ </tr>
1799
+ ))}
1800
+ </tbody>
1801
+ </table>
1802
+ </div>
1803
+ )}
1804
+ </div>
1805
+
1806
+ {/* Right: Mod Dossier */}
1807
+ <div className="panel">
1808
+ <div className="panel-header">
1809
+ <h3 className="panel-title"><UserCheck size={20} /> Личное досье модератора</h3>
1810
+ </div>
1811
+
1812
+ {!selectedMod ? (
1813
+ <div className="status-msg">
1814
+ <UserCheck size={40} className="status-msg-icon" />
1815
+ <p>Выберите модератора слева для просмотра досье</p>
1816
+ </div>
1817
+ ) : (
1818
+ <div>
1819
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.25rem' }}>
1820
+ <div>
1821
+ <h3 style={{ margin: 0, fontSize: '1.25rem', fontWeight: 700, color: 'var(--color-brand)' }}>{selectedMod.moderator}</h3>
1822
+ <p style={{ margin: 0, fontSize: '0.75rem', color: 'var(--color-text-muted)' }}>Анализ стиля модерирования и характеристик</p>
1823
+ </div>
1824
+ </div>
1825
+
1826
+ {/* Radar chart */}
1827
+ <div style={{ height: '280px', display: 'flex', justifyContent: 'center', marginBottom: '1.25rem' }}>
1828
+ <Radar
1829
+ data={{
1830
+ labels: ['Скорость', 'Активность', 'Жесткость', 'Внимательность'],
1831
+ datasets: [{
1832
+ data: [
1833
+ selectedMod.scores.speed,
1834
+ selectedMod.scores.activity,
1835
+ selectedMod.scores.harshness,
1836
+ selectedMod.scores.watchfulness
1837
+ ],
1838
+ backgroundColor: 'rgba(163, 112, 247, 0.2)',
1839
+ borderColor: '#A370F7',
1840
+ borderWidth: 2,
1841
+ pointBackgroundColor: '#A370F7',
1842
+ pointBorderColor: '#FFF',
1843
+ pointHoverBackgroundColor: '#FFF',
1844
+ pointHoverBorderColor: '#A370F7'
1845
+ }]
1846
+ }}
1847
+ options={{
1848
+ plugins: { legend: { display: false } },
1849
+ scales: {
1850
+ r: {
1851
+ angleLines: { color: '#2F2F35' },
1852
+ grid: { color: '#2F2F35' },
1853
+ pointLabels: { color: '#ADADB8', font: { size: 10, weight: 'bold' } },
1854
+ ticks: { display: false },
1855
+ min: 0,
1856
+ max: 100
1857
+ }
1858
+ }
1859
+ }}
1860
+ />
1861
+ </div>
1862
+
1863
+ {/* Actions distribution */}
1864
+ <div style={{ marginBottom: '1rem' }}>
1865
+ <h4 style={{ margin: '0 0 0.5rem 0', fontSize: '0.85rem', fontWeight: 600 }}>Распределение наказаний:</h4>
1866
+ <div style={{ height: '16px', display: 'flex', borderRadius: '4px', overflow: 'hidden', background: 'var(--color-bg-base)' }}>
1867
+ {selectedMod.bans_count > 0 && (
1868
+ <div
1869
+ style={{ width: `${(selectedMod.bans_count / selectedMod.total_actions) * 100}%`, background: '#FF0055', height: '100%' }}
1870
+ title={`Баны: ${selectedMod.bans_count}`}
1871
+ />
1872
+ )}
1873
+ {selectedMod.timeouts_count > 0 && (
1874
+ <div
1875
+ style={{ width: `${(selectedMod.timeouts_count / selectedMod.total_actions) * 100}%`, background: '#FFAA00', height: '100%' }}
1876
+ title={`Муты/Таймауты: ${selectedMod.timeouts_count}`}
1877
+ />
1878
+ )}
1879
+ {selectedMod.deletions_count > 0 && (
1880
+ <div
1881
+ style={{ width: `${(selectedMod.deletions_count / selectedMod.total_actions) * 100}%`, background: '#00AAFF', height: '100%' }}
1882
+ title={`Удаления сообщений: ${selectedMod.deletions_count}`}
1883
+ />
1884
+ )}
1885
+ {selectedMod.unbans_count > 0 && (
1886
+ <div
1887
+ style={{ width: `${(selectedMod.unbans_count / selectedMod.total_actions) * 100}%`, background: '#00FF88', height: '100%' }}
1888
+ title={`Разбаны: ${selectedMod.unbans_count}`}
1889
+ />
1890
+ )}
1891
+ </div>
1892
+ {/* Legend */}
1893
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.75rem 1.25rem', marginTop: '0.5rem', fontSize: '0.7rem', color: 'var(--color-text-muted)' }}>
1894
+ <div style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
1895
+ <span style={{ display: 'inline-block', width: '6px', height: '6px', borderRadius: '50%', background: '#FF0055' }}></span>
1896
+ Баны ({selectedMod.bans_count})
1897
+ </div>
1898
+ <div style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
1899
+ <span style={{ display: 'inline-block', width: '6px', height: '6px', borderRadius: '50%', background: '#FFAA00' }}></span>
1900
+ Муты ({selectedMod.timeouts_count})
1901
+ </div>
1902
+ <div style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
1903
+ <span style={{ display: 'inline-block', width: '6px', height: '6px', borderRadius: '50%', background: '#00AAFF' }}></span>
1904
+ Удаления ({selectedMod.deletions_count})
1905
+ </div>
1906
+ <div style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
1907
+ <span style={{ display: 'inline-block', width: '6px', height: '6px', borderRadius: '50%', background: '#00FF88' }}></span>
1908
+ Разбаны ({selectedMod.unbans_count})
1909
+ </div>
1910
+ </div>
1911
+ </div>
1912
+ </div>
1913
+ )}
1914
+ </div>
1915
+ </div>
1916
+ </div>
1917
+ )}
1918
+ </div>
1919
+ )}
1920
+ </>
1921
+ )}
1922
+ </main>
1923
+
1924
+ {/* Footer */}
1925
+ <footer style={{ backgroundColor: 'var(--color-bg-card)', borderTop: '1px solid var(--color-border)', padding: '1.5rem', textAlign: 'center', color: 'var(--color-text-muted)', fontSize: '0.8rem' }}>
1926
+ <p>Дашборд Аналитики Twitch для канала winx_prinx © {new Date().getFullYear()}</p>
1927
+ </footer>
1928
+ </div>
1929
+ );
1930
+ }
client/src/assets/hero.png ADDED
client/src/assets/react.svg ADDED
client/src/assets/vite.svg ADDED
client/src/index.css ADDED
@@ -0,0 +1,1086 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=Outfit:wght@400;500;600;700&display=swap');
2
+
3
+ :root {
4
+ /* Colors */
5
+ --color-bg-base: #0e0e10;
6
+ --color-bg-card: #18181b;
7
+ --color-bg-card-hover: #26262c;
8
+ --color-bg-active: #32323c;
9
+ --color-border: #2f2f35;
10
+
11
+ --color-brand: #9146ff;
12
+ --color-brand-hover: #772ce8;
13
+ --color-brand-active: #5c16c5;
14
+ --color-brand-glow: rgba(145, 70, 255, 0.15);
15
+
16
+ --color-text-main: #efeff1;
17
+ --color-text-muted: #adadb8;
18
+ --color-text-accent: #00ff7f; /* Neon green */
19
+ --color-text-neon-blue: #00e5ff;
20
+
21
+ /* Badges */
22
+ --color-badge-streamer: #ff7f50;
23
+ --color-badge-mod: #00f5d4;
24
+ --color-badge-sub: #e91e63;
25
+ --color-badge-vip: #e0b0ff;
26
+
27
+ /* Fonts */
28
+ --font-family-sans: 'Inter', system-ui, -apple-system, sans-serif;
29
+ --font-family-display: 'Outfit', var(--font-family-sans);
30
+
31
+ /* Shadows & Radius */
32
+ --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
33
+ --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
34
+ --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.2), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
35
+ --shadow-brand: 0 0 20px 0 rgba(145, 70, 255, 0.25);
36
+ --radius-sm: 4px;
37
+ --radius-md: 8px;
38
+ --radius-lg: 12px;
39
+ --radius-full: 9999px;
40
+
41
+ /* Transitions */
42
+ --transition-fast: 0.15s ease;
43
+ --transition-normal: 0.25s cubic-bezier(0.4, 0, 0.2, 1);
44
+ }
45
+
46
+ /* Reset and base styles */
47
+ * {
48
+ box-sizing: border-box;
49
+ margin: 0;
50
+ padding: 0;
51
+ }
52
+
53
+ body {
54
+ background-color: var(--color-bg-base);
55
+ color: var(--color-text-main);
56
+ font-family: var(--font-family-sans);
57
+ -webkit-font-smoothing: antialiased;
58
+ -moz-osx-font-smoothing: grayscale;
59
+ overflow-x: hidden;
60
+ }
61
+
62
+ /* Custom Scrollbar */
63
+ ::-webkit-scrollbar {
64
+ width: 8px;
65
+ height: 8px;
66
+ }
67
+ ::-webkit-scrollbar-track {
68
+ background: var(--color-bg-base);
69
+ }
70
+ ::-webkit-scrollbar-thumb {
71
+ background: var(--color-border);
72
+ border-radius: var(--radius-full);
73
+ }
74
+ ::-webkit-scrollbar-thumb:hover {
75
+ background: var(--color-text-muted);
76
+ }
77
+
78
+ /* Layout */
79
+ .app-container {
80
+ display: flex;
81
+ flex-direction: column;
82
+ min-height: 100vh;
83
+ }
84
+
85
+ /* Header */
86
+ .app-header {
87
+ display: flex;
88
+ justify-content: space-between;
89
+ align-items: center;
90
+ padding: 1.25rem 2.25rem;
91
+ background-color: var(--color-bg-card);
92
+ border-bottom: 1px solid var(--color-border);
93
+ position: sticky;
94
+ top: 0;
95
+ z-index: 100;
96
+ }
97
+
98
+ .brand-section {
99
+ display: flex;
100
+ align-items: center;
101
+ gap: 0.75rem;
102
+ }
103
+
104
+ .brand-logo {
105
+ color: var(--color-brand);
106
+ animation: logo-glow 3s infinite alternate;
107
+ }
108
+
109
+ .brand-name {
110
+ font-family: var(--font-family-display);
111
+ font-size: 1.5rem;
112
+ font-weight: 700;
113
+ letter-spacing: -0.025em;
114
+ background: linear-gradient(135deg, #fff 30%, var(--color-brand) 100%);
115
+ -webkit-background-clip: text;
116
+ -webkit-text-fill-color: transparent;
117
+ }
118
+
119
+ .channel-tag {
120
+ background-color: var(--color-brand-glow);
121
+ border: 1px solid rgba(145, 70, 255, 0.4);
122
+ color: var(--color-text-main);
123
+ font-size: 0.75rem;
124
+ font-weight: 600;
125
+ padding: 0.2rem 0.6rem;
126
+ border-radius: var(--radius-full);
127
+ margin-left: 0.5rem;
128
+ }
129
+
130
+ .header-controls {
131
+ display: flex;
132
+ align-items: center;
133
+ gap: 1.25rem;
134
+ }
135
+
136
+ /* Stream selector inside the header brand section */
137
+ .header-stream-selector {
138
+ margin-left: 1rem;
139
+ display: flex;
140
+ align-items: center;
141
+ }
142
+
143
+ .select-input--compact {
144
+ appearance: none; /* Hide default native arrow */
145
+ -webkit-appearance: none;
146
+ -moz-appearance: none;
147
+ background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%23adadb8' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'><polyline points='6 9 12 15 18 9'></polyline></svg>");
148
+ background-repeat: no-repeat;
149
+ background-position: right 0.75rem center;
150
+ background-size: 12px;
151
+ padding: 0.4rem 2.2rem 0.4rem 0.75rem; /* Additional padding on right for arrow */
152
+ font-size: 0.82rem;
153
+ font-weight: 500;
154
+ min-width: 160px;
155
+ max-width: 320px;
156
+ background-color: var(--color-bg-base);
157
+ border: 1px solid rgba(145, 70, 255, 0.4);
158
+ color: var(--color-text-main);
159
+ height: 34px;
160
+ border-radius: var(--radius-md);
161
+ transition: all var(--transition-fast);
162
+ cursor: pointer;
163
+ text-overflow: ellipsis;
164
+ white-space: nowrap;
165
+ overflow: hidden;
166
+ }
167
+
168
+ .select-input--compact:hover {
169
+ background-color: var(--color-bg-card-hover);
170
+ border-color: var(--color-brand);
171
+ box-shadow: 0 0 10px rgba(145, 70, 255, 0.2);
172
+ }
173
+
174
+ .select-input--compact:focus {
175
+ border-color: var(--color-brand);
176
+ box-shadow: 0 0 0 2px var(--color-brand-glow);
177
+ outline: none;
178
+ }
179
+
180
+ .select-input--compact option {
181
+ background-color: var(--color-bg-card);
182
+ color: var(--color-text-main);
183
+ }
184
+
185
+ /* Navigation & Tabs */
186
+ .nav-tabs {
187
+ display: flex;
188
+ gap: 0.5rem;
189
+ }
190
+
191
+ .tab-btn {
192
+ background: transparent;
193
+ border: none;
194
+ color: var(--color-text-muted);
195
+ font-family: var(--font-family-sans);
196
+ font-size: 0.82rem;
197
+ font-weight: 500;
198
+ padding: 0.5rem 0.75rem;
199
+ cursor: pointer;
200
+ border-radius: var(--radius-md);
201
+ transition: all var(--transition-fast);
202
+ display: flex;
203
+ align-items: center;
204
+ gap: 0.35rem;
205
+ white-space: nowrap;
206
+ }
207
+
208
+ .tab-btn:hover {
209
+ color: var(--color-text-main);
210
+ background-color: var(--color-bg-card-hover);
211
+ }
212
+
213
+ .tab-btn.active {
214
+ color: var(--color-text-main);
215
+ background-color: var(--color-brand-glow);
216
+ box-shadow: inset 0 0 0 1px rgba(145, 70, 255, 0.3);
217
+ }
218
+
219
+ /* User Profile & Auth Button */
220
+ .user-profile {
221
+ display: flex;
222
+ align-items: center;
223
+ gap: 0.75rem;
224
+ }
225
+
226
+ .user-info {
227
+ text-align: right;
228
+ }
229
+
230
+ .user-name {
231
+ font-weight: 600;
232
+ font-size: 0.875rem;
233
+ }
234
+
235
+ .user-role {
236
+ font-size: 0.75rem;
237
+ color: var(--color-brand);
238
+ text-transform: capitalize;
239
+ }
240
+
241
+ .btn {
242
+ background-color: var(--color-brand);
243
+ color: #fff;
244
+ border: none;
245
+ font-family: var(--font-family-sans);
246
+ font-size: 0.875rem;
247
+ font-weight: 600;
248
+ padding: 0.6rem 1.2rem;
249
+ border-radius: var(--radius-md);
250
+ cursor: pointer;
251
+ transition: all var(--transition-fast);
252
+ display: inline-flex;
253
+ align-items: center;
254
+ gap: 0.5rem;
255
+ text-decoration: none;
256
+ box-shadow: var(--shadow-sm);
257
+ }
258
+
259
+ .btn:hover {
260
+ background-color: var(--color-brand-hover);
261
+ box-shadow: var(--shadow-brand);
262
+ transform: translateY(-1px);
263
+ }
264
+
265
+ .btn-secondary {
266
+ background-color: transparent;
267
+ border: 1px solid var(--color-border);
268
+ color: var(--color-text-main);
269
+ }
270
+
271
+ .btn-secondary:hover {
272
+ background-color: var(--color-bg-card-hover);
273
+ box-shadow: none;
274
+ transform: none;
275
+ }
276
+
277
+ /* Refresh button in header — small, glowing on hover */
278
+ .btn-refresh {
279
+ background: rgba(145, 70, 255, 0.08);
280
+ border: 1px solid rgba(145, 70, 255, 0.5);
281
+ color: var(--color-text-main);
282
+ font-weight: 500;
283
+ font-size: 0.78rem;
284
+ padding: 0.3rem 0.7rem;
285
+ gap: 0.4rem;
286
+ box-shadow: 0 0 8px rgba(145, 70, 255, 0.1);
287
+ }
288
+
289
+ .btn-refresh:hover {
290
+ background: var(--color-brand-glow);
291
+ border-color: var(--color-brand);
292
+ color: var(--color-text-main);
293
+ box-shadow: 0 0 12px var(--color-brand-glow);
294
+ transform: none;
295
+ }
296
+
297
+ .btn-refresh:disabled {
298
+ opacity: 0.5;
299
+ cursor: not-allowed;
300
+ }
301
+
302
+ .btn-danger {
303
+ background-color: #eb0400;
304
+ }
305
+ .btn-danger:hover {
306
+ background-color: #c20300;
307
+ box-shadow: 0 0 15px rgba(235, 4, 0, 0.3);
308
+ }
309
+
310
+ /* Main Dashboard Layout */
311
+ .dashboard-content {
312
+ flex: 1;
313
+ padding: 1.5rem 2rem;
314
+ max-width: 1400px;
315
+ width: 100%;
316
+ margin: 0 auto;
317
+ }
318
+
319
+ /* KPI Grid */
320
+ .kpi-grid {
321
+ display: grid;
322
+ grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
323
+ gap: 1.5rem;
324
+ margin-bottom: 2rem;
325
+ }
326
+
327
+ .grid-4col {
328
+ display: grid;
329
+ grid-template-columns: repeat(4, 1fr);
330
+ gap: 1.5rem;
331
+ }
332
+
333
+ @media (max-width: 1200px) {
334
+ .grid-4col {
335
+ grid-template-columns: repeat(2, 1fr);
336
+ }
337
+ }
338
+
339
+ @media (max-width: 640px) {
340
+ .grid-4col {
341
+ grid-template-columns: 1fr;
342
+ }
343
+ }
344
+
345
+ /* Stat Cards (Overview KPI) */
346
+ .stat-card {
347
+ position: relative;
348
+ overflow: hidden;
349
+ background-color: var(--color-bg-card);
350
+ border: 1px solid var(--color-border);
351
+ border-radius: var(--radius-lg);
352
+ padding: 1.5rem;
353
+ transition: transform var(--transition-fast), border-color var(--transition-fast), box-shadow var(--transition-fast);
354
+ }
355
+
356
+ .stat-card::before {
357
+ content: '';
358
+ position: absolute;
359
+ top: 0;
360
+ left: 0;
361
+ width: 3px;
362
+ height: 100%;
363
+ background: var(--accent-color, var(--color-brand));
364
+ opacity: 0.7;
365
+ transition: opacity var(--transition-fast);
366
+ }
367
+
368
+ .stat-card:hover {
369
+ transform: translateY(-3px);
370
+ border-color: var(--accent-color, rgba(145, 70, 255, 0.4));
371
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
372
+ }
373
+
374
+ .stat-card:hover::before {
375
+ opacity: 1;
376
+ }
377
+
378
+ /* Inner layout helper for stat cards */
379
+ .stat-card-inner {
380
+ display: flex;
381
+ justify-content: space-between;
382
+ align-items: center;
383
+ }
384
+
385
+ .stat-label {
386
+ color: var(--color-text-muted);
387
+ font-size: 0.78rem;
388
+ font-weight: 600;
389
+ text-transform: uppercase;
390
+ letter-spacing: 0.05em;
391
+ margin-bottom: 0.25rem;
392
+ }
393
+
394
+ .stat-value {
395
+ font-family: var(--font-family-display);
396
+ font-size: 1.8rem;
397
+ font-weight: 700;
398
+ color: var(--color-text-main);
399
+ line-height: 1.2;
400
+ }
401
+
402
+ .stat-icon-wrapper {
403
+ background-color: var(--color-bg-base);
404
+ border: 1px solid var(--color-border);
405
+ padding: 0.65rem;
406
+ border-radius: var(--radius-md);
407
+ display: flex;
408
+ align-items: center;
409
+ justify-content: center;
410
+ }
411
+
412
+ .kpi-card {
413
+ background-color: var(--color-bg-card);
414
+ border: 1px solid var(--color-border);
415
+ border-radius: var(--radius-lg);
416
+ padding: 1.5rem;
417
+ display: flex;
418
+ align-items: center;
419
+ justify-content: space-between;
420
+ transition: transform var(--transition-fast), border-color var(--transition-fast);
421
+ position: relative;
422
+ overflow: hidden;
423
+ }
424
+
425
+ .kpi-card::before {
426
+ content: '';
427
+ position: absolute;
428
+ top: 0;
429
+ left: 0;
430
+ width: 4px;
431
+ height: 100%;
432
+ background-color: var(--color-brand);
433
+ opacity: 0;
434
+ transition: opacity var(--transition-fast);
435
+ }
436
+
437
+ .kpi-card:hover {
438
+ transform: translateY(-2px);
439
+ border-color: rgba(145, 70, 255, 0.4);
440
+ }
441
+
442
+ .kpi-card:hover::before {
443
+ opacity: 1;
444
+ }
445
+
446
+ .kpi-card-info {
447
+ display: flex;
448
+ flex-direction: column;
449
+ gap: 0.35rem;
450
+ }
451
+
452
+ .kpi-card-title {
453
+ color: var(--color-text-muted);
454
+ font-size: 0.8rem;
455
+ font-weight: 600;
456
+ text-transform: uppercase;
457
+ letter-spacing: 0.05em;
458
+ }
459
+
460
+ .kpi-card-value {
461
+ font-family: var(--font-family-display);
462
+ font-size: 2rem;
463
+ font-weight: 700;
464
+ color: var(--color-text-main);
465
+ }
466
+
467
+ .kpi-card-icon {
468
+ background-color: var(--color-bg-base);
469
+ border: 1px solid var(--color-border);
470
+ color: var(--color-brand);
471
+ padding: 0.75rem;
472
+ border-radius: var(--radius-md);
473
+ display: flex;
474
+ align-items: center;
475
+ justify-content: center;
476
+ }
477
+
478
+ /* Stream Status Widget */
479
+ .status-indicator {
480
+ display: flex;
481
+ align-items: center;
482
+ gap: 0.5rem;
483
+ font-size: 0.8rem;
484
+ font-weight: 700;
485
+ text-transform: uppercase;
486
+ }
487
+
488
+ .status-dot {
489
+ width: 8px;
490
+ height: 8px;
491
+ border-radius: var(--radius-full);
492
+ }
493
+
494
+ .status-dot.live {
495
+ background-color: #ff0000;
496
+ box-shadow: 0 0 10px #ff0000;
497
+ animation: pulse-dot 1.5s infinite;
498
+ }
499
+
500
+ .status-dot.offline {
501
+ background-color: var(--color-text-muted);
502
+ }
503
+
504
+ /* Grid Layouts for Sections */
505
+ .grid-2col {
506
+ display: grid;
507
+ grid-template-columns: 2fr 1fr;
508
+ gap: 1.5rem;
509
+ margin-bottom: 2rem;
510
+ }
511
+
512
+ .grid-equal-2col {
513
+ display: grid;
514
+ grid-template-columns: 1fr 1fr;
515
+ gap: 1.5rem;
516
+ margin-bottom: 2rem;
517
+ }
518
+
519
+ @media (max-width: 992px) {
520
+ .grid-2col, .grid-equal-2col {
521
+ grid-template-columns: 1fr;
522
+ }
523
+ }
524
+
525
+ @media (max-width: 1100px) {
526
+ .tab-btn svg {
527
+ display: none;
528
+ }
529
+ }
530
+
531
+ .panel {
532
+ background-color: var(--color-bg-card);
533
+ border: 1px solid var(--color-border);
534
+ border-radius: var(--radius-lg);
535
+ padding: 1.5rem;
536
+ }
537
+
538
+ .panel-header {
539
+ display: flex;
540
+ justify-content: space-between;
541
+ align-items: center;
542
+ margin-bottom: 1.5rem;
543
+ border-bottom: 1px solid var(--color-border);
544
+ padding-bottom: 0.75rem;
545
+ }
546
+
547
+ .panel-title {
548
+ font-family: var(--font-family-display);
549
+ font-size: 1.2rem;
550
+ font-weight: 600;
551
+ display: flex;
552
+ align-items: center;
553
+ gap: 0.5rem;
554
+ }
555
+
556
+ .panel-controls {
557
+ display: flex;
558
+ gap: 0.75rem;
559
+ align-items: center;
560
+ }
561
+
562
+ /* Dropdown styling */
563
+ .select-input {
564
+ background-color: var(--color-bg-base);
565
+ border: 1px solid var(--color-border);
566
+ color: var(--color-text-main);
567
+ padding: 0.5rem 1rem;
568
+ border-radius: var(--radius-md);
569
+ font-family: var(--font-family-sans);
570
+ font-size: 0.85rem;
571
+ cursor: pointer;
572
+ outline: none;
573
+ transition: border-color var(--transition-fast);
574
+ }
575
+
576
+ .select-input:focus {
577
+ border-color: var(--color-brand);
578
+ }
579
+
580
+ /* Dark-themed inputs for admin panel */
581
+ .dark-input {
582
+ width: 100%;
583
+ background-color: var(--color-bg-base);
584
+ border: 1px solid var(--color-border);
585
+ color: var(--color-text-main);
586
+ padding: 0.6rem 0.75rem;
587
+ border-radius: var(--radius-md);
588
+ font-family: var(--font-family-sans);
589
+ font-size: 0.875rem;
590
+ outline: none;
591
+ transition: all var(--transition-fast);
592
+ }
593
+
594
+ .dark-input:focus {
595
+ border-color: var(--color-brand);
596
+ box-shadow: 0 0 0 2px var(--color-brand-glow);
597
+ }
598
+
599
+ .dark-input::placeholder {
600
+ color: var(--color-text-muted);
601
+ opacity: 0.6;
602
+ }
603
+
604
+ /* Charts Wrapper */
605
+ .chart-container {
606
+ position: relative;
607
+ height: 320px;
608
+ width: 100%;
609
+ }
610
+
611
+ /* Tables */
612
+ .table-wrapper {
613
+ overflow-x: auto;
614
+ }
615
+
616
+ .data-table {
617
+ width: 100%;
618
+ border-collapse: collapse;
619
+ text-align: left;
620
+ font-size: 0.9rem;
621
+ }
622
+
623
+ .data-table th {
624
+ color: var(--color-text-muted);
625
+ font-weight: 600;
626
+ padding: 0.75rem 1rem;
627
+ border-bottom: 1px solid var(--color-border);
628
+ font-size: 0.8rem;
629
+ text-transform: uppercase;
630
+ letter-spacing: 0.05em;
631
+ }
632
+
633
+ .data-table td {
634
+ padding: 1rem;
635
+ border-bottom: 1px solid var(--color-border);
636
+ color: var(--color-text-main);
637
+ transition: background-color var(--transition-fast);
638
+ }
639
+
640
+ .data-table tr:hover td {
641
+ background-color: var(--color-bg-card-hover);
642
+ }
643
+
644
+ .data-table tr:last-child td {
645
+ border-bottom: none;
646
+ }
647
+
648
+ /* Search input */
649
+ .search-container {
650
+ position: relative;
651
+ width: 100%;
652
+ max-width: 300px;
653
+ margin-bottom: 1rem;
654
+ }
655
+
656
+ .search-input {
657
+ width: 100%;
658
+ background-color: var(--color-bg-base);
659
+ border: 1px solid var(--color-border);
660
+ color: var(--color-text-main);
661
+ padding: 0.6rem 1rem 0.6rem 2.5rem;
662
+ border-radius: var(--radius-md);
663
+ font-family: var(--font-family-sans);
664
+ font-size: 0.875rem;
665
+ outline: none;
666
+ transition: all var(--transition-fast);
667
+ }
668
+
669
+ .search-input:focus {
670
+ border-color: var(--color-brand);
671
+ box-shadow: 0 0 0 2px var(--color-brand-glow);
672
+ }
673
+
674
+ .search-icon {
675
+ position: absolute;
676
+ left: 0.75rem;
677
+ top: 50%;
678
+ transform: translateY(-50%);
679
+ color: var(--color-text-muted);
680
+ pointer-events: none;
681
+ }
682
+
683
+ /* Badges */
684
+ .badge {
685
+ display: inline-flex;
686
+ align-items: center;
687
+ justify-content: center;
688
+ font-size: 0.7rem;
689
+ font-weight: 700;
690
+ padding: 0.15rem 0.45rem;
691
+ border-radius: 4px;
692
+ text-transform: uppercase;
693
+ letter-spacing: 0.025em;
694
+ }
695
+
696
+ .badge-streamer {
697
+ background-color: rgba(255, 127, 80, 0.12);
698
+ border: 1px solid rgba(255, 127, 80, 0.4);
699
+ color: var(--color-badge-streamer);
700
+ }
701
+
702
+ .badge-mod {
703
+ background-color: rgba(0, 245, 212, 0.12);
704
+ border: 1px solid rgba(0, 245, 212, 0.4);
705
+ color: var(--color-badge-mod);
706
+ }
707
+
708
+ .badge-sub {
709
+ background-color: rgba(233, 30, 99, 0.12);
710
+ border: 1px solid rgba(233, 30, 99, 0.4);
711
+ color: var(--color-badge-sub);
712
+ }
713
+
714
+ .badge-vip {
715
+ background-color: rgba(224, 176, 255, 0.12);
716
+ border: 1px solid rgba(224, 176, 255, 0.4);
717
+ color: var(--color-badge-vip);
718
+ }
719
+
720
+ /* Mod Action Styles */
721
+ .action-card {
722
+ display: flex;
723
+ gap: 1rem;
724
+ padding: 1rem;
725
+ border-bottom: 1px solid var(--color-border);
726
+ transition: background-color var(--transition-fast);
727
+ align-items: flex-start;
728
+ }
729
+
730
+ .action-card:hover {
731
+ background-color: var(--color-bg-card-hover);
732
+ }
733
+
734
+ .action-card:last-child {
735
+ border-bottom: none;
736
+ }
737
+
738
+ .action-icon {
739
+ padding: 0.5rem;
740
+ border-radius: var(--radius-md);
741
+ display: flex;
742
+ align-items: center;
743
+ justify-content: center;
744
+ }
745
+
746
+ .action-icon.ban {
747
+ background-color: rgba(235, 4, 0, 0.12);
748
+ border: 1px solid rgba(235, 4, 0, 0.3);
749
+ color: #eb0400;
750
+ }
751
+
752
+ .action-icon.timeout {
753
+ background-color: rgba(255, 165, 0, 0.12);
754
+ border: 1px solid rgba(255, 165, 0, 0.3);
755
+ color: orange;
756
+ }
757
+
758
+ .action-icon.delete {
759
+ background-color: rgba(0, 229, 255, 0.12);
760
+ border: 1px solid rgba(0, 229, 255, 0.3);
761
+ color: var(--color-text-neon-blue);
762
+ }
763
+
764
+ .action-icon.unban {
765
+ background-color: rgba(0, 255, 127, 0.12);
766
+ border: 1px solid rgba(0, 255, 127, 0.3);
767
+ color: var(--color-text-accent);
768
+ }
769
+
770
+ .action-details {
771
+ flex: 1;
772
+ }
773
+
774
+ .action-header-info {
775
+ display: flex;
776
+ flex-wrap: wrap;
777
+ gap: 0.5rem;
778
+ align-items: center;
779
+ margin-bottom: 0.25rem;
780
+ }
781
+
782
+ .action-time {
783
+ font-size: 0.75rem;
784
+ color: var(--color-text-muted);
785
+ margin-left: auto;
786
+ }
787
+
788
+ .action-target {
789
+ font-weight: 700;
790
+ color: var(--color-text-main);
791
+ }
792
+
793
+ .action-by {
794
+ font-size: 0.85rem;
795
+ color: var(--color-text-muted);
796
+ }
797
+
798
+ .action-by strong {
799
+ color: var(--color-text-main);
800
+ }
801
+
802
+ .action-reason {
803
+ font-size: 0.85rem;
804
+ background-color: var(--color-bg-base);
805
+ border: 1px solid var(--color-border);
806
+ padding: 0.5rem 0.75rem;
807
+ border-radius: var(--radius-sm);
808
+ margin-top: 0.5rem;
809
+ font-style: italic;
810
+ color: var(--color-text-muted);
811
+ }
812
+
813
+ .action-quote {
814
+ font-size: 0.85rem;
815
+ border-left: 3px solid var(--color-text-neon-blue);
816
+ padding-left: 0.75rem;
817
+ color: var(--color-text-muted);
818
+ margin-top: 0.5rem;
819
+ }
820
+
821
+ /* Word Cloud / Grid list */
822
+ .word-cloud-list {
823
+ display: flex;
824
+ flex-wrap: wrap;
825
+ gap: 0.75rem;
826
+ }
827
+
828
+ .word-tag {
829
+ background-color: var(--color-bg-base);
830
+ border: 1px solid var(--color-border);
831
+ padding: 0.5rem 0.85rem;
832
+ border-radius: var(--radius-md);
833
+ font-size: 0.9rem;
834
+ display: flex;
835
+ align-items: center;
836
+ gap: 0.5rem;
837
+ transition: all var(--transition-fast);
838
+ }
839
+
840
+ .word-tag:hover {
841
+ border-color: var(--color-brand);
842
+ transform: scale(1.03);
843
+ }
844
+
845
+ .word-tag-text {
846
+ font-weight: 600;
847
+ }
848
+
849
+ .word-tag-count {
850
+ background-color: var(--color-brand-glow);
851
+ color: var(--color-brand);
852
+ font-size: 0.75rem;
853
+ font-weight: 700;
854
+ padding: 0.1rem 0.4rem;
855
+ border-radius: 4px;
856
+ }
857
+
858
+ /* Centered status message for loading or empty states */
859
+ .status-msg {
860
+ display: flex;
861
+ flex-direction: column;
862
+ align-items: center;
863
+ justify-content: center;
864
+ padding: 4rem 2rem;
865
+ text-align: center;
866
+ color: var(--color-text-muted);
867
+ gap: 1rem;
868
+ }
869
+
870
+ .status-msg-icon {
871
+ color: var(--color-brand);
872
+ opacity: 0.5;
873
+ }
874
+
875
+ /* Tab content fade animation */
876
+ .tab-content {
877
+ animation: fadeSlideIn 0.25s ease-out;
878
+ }
879
+
880
+ @keyframes fadeSlideIn {
881
+ from {
882
+ opacity: 0;
883
+ transform: translateY(8px);
884
+ }
885
+ to {
886
+ opacity: 1;
887
+ transform: translateY(0);
888
+ }
889
+ }
890
+
891
+ /* Login Page Overlay */
892
+ .login-overlay {
893
+ display: flex;
894
+ flex-direction: column;
895
+ align-items: center;
896
+ justify-content: center;
897
+ min-height: 70vh;
898
+ gap: 1.5rem;
899
+ }
900
+
901
+ .login-card {
902
+ background-color: var(--color-bg-card);
903
+ border: 1px solid var(--color-border);
904
+ border-radius: var(--radius-lg);
905
+ padding: 3rem;
906
+ max-width: 480px;
907
+ width: 100%;
908
+ text-align: center;
909
+ box-shadow: var(--shadow-lg);
910
+ border-top: 4px solid var(--color-brand);
911
+ }
912
+
913
+ .login-title {
914
+ font-family: var(--font-family-display);
915
+ font-size: 1.75rem;
916
+ font-weight: 700;
917
+ margin-bottom: 0.75rem;
918
+ }
919
+
920
+ .login-description {
921
+ color: var(--color-text-muted);
922
+ font-size: 0.9rem;
923
+ margin-bottom: 2rem;
924
+ line-height: 1.5;
925
+ }
926
+
927
+ /* Animations */
928
+ @keyframes logo-glow {
929
+ 0% {
930
+ filter: drop-shadow(0 0 2px rgba(145, 70, 255, 0.2));
931
+ }
932
+ 100% {
933
+ filter: drop-shadow(0 0 10px rgba(145, 70, 255, 0.7));
934
+ }
935
+ }
936
+
937
+ @keyframes pulse-dot {
938
+ 0% {
939
+ transform: scale(0.95);
940
+ box-shadow: 0 0 0 0 rgba(255, 0, 0, 0.7);
941
+ }
942
+ 70% {
943
+ transform: scale(1);
944
+ box-shadow: 0 0 0 6px rgba(255, 0, 0, 0);
945
+ }
946
+ 100% {
947
+ transform: scale(0.95);
948
+ box-shadow: 0 0 0 0 rgba(255, 0, 0, 0);
949
+ }
950
+ }
951
+
952
+ /* Planetary Word Galaxy */
953
+ .word-galaxy-container {
954
+ position: relative;
955
+ width: 100%;
956
+ height: min(70vh, 700px);
957
+ background-color: #060608;
958
+ background-image:
959
+ linear-gradient(rgba(255, 255, 255, 0.03) 1px, transparent 1px),
960
+ linear-gradient(90deg, rgba(255, 255, 255, 0.03) 1px, transparent 1px);
961
+ background-size: 40px 40px;
962
+ background-position: center center;
963
+ border-radius: var(--radius-lg);
964
+ border: 1px solid var(--color-border);
965
+ overflow: hidden;
966
+ box-shadow: inset 0 0 40px rgba(0, 0, 0, 0.85);
967
+ display: flex;
968
+ justify-content: center;
969
+ align-items: center;
970
+ user-select: none;
971
+ }
972
+
973
+ .word-galaxy-tag {
974
+ position: absolute;
975
+ left: 50%;
976
+ top: 50%;
977
+ white-space: nowrap;
978
+ cursor: pointer;
979
+ user-select: none;
980
+ font-family: var(--font-family-display), -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
981
+ animation: word-float var(--float-duration, 5s) ease-in-out infinite;
982
+ animation-delay: var(--float-delay, 0s);
983
+ transition: transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1), filter 0.2s ease, opacity 0.2s ease;
984
+ transform-origin: center center;
985
+ }
986
+
987
+ @keyframes word-float {
988
+ 0%, 100% {
989
+ transform: translate(calc(-50% + var(--x)), calc(-50% + var(--y))) translateY(0);
990
+ }
991
+ 50% {
992
+ transform: translate(calc(-50% + var(--x)), calc(-50% + var(--y))) translateY(var(--float-amount, -5px));
993
+ }
994
+ }
995
+
996
+ .word-galaxy-tag:hover {
997
+ transform: translate(calc(-50% + var(--x)), calc(-50% + var(--y))) scale(1.1) translateY(var(--float-amount, -5px)) !important;
998
+ filter: brightness(1.3) drop-shadow(0 0 10px currentColor);
999
+ z-index: 50;
1000
+ }
1001
+
1002
+ /* Custom Tooltip */
1003
+ .word-tooltip {
1004
+ position: absolute;
1005
+ bottom: -22px;
1006
+ left: 50%;
1007
+ transform: translateX(-50%) scale(0.85);
1008
+ background: #09090b;
1009
+ border: 1px solid rgba(138, 92, 245, 0.85);
1010
+ color: #efeff1;
1011
+ padding: 3px 7px;
1012
+ border-radius: 4px;
1013
+ font-size: 0.65rem;
1014
+ font-weight: 600;
1015
+ white-space: nowrap;
1016
+ opacity: 0;
1017
+ pointer-events: none;
1018
+ transition: opacity 0.15s cubic-bezier(0.34, 1.56, 0.64, 1), transform 0.15s cubic-bezier(0.34, 1.56, 0.64, 1);
1019
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.65), 0 0 8px rgba(138, 92, 245, 0.35);
1020
+ z-index: 60;
1021
+ font-family: var(--font-family-sans);
1022
+ }
1023
+
1024
+ .word-galaxy-tag:hover .word-tooltip {
1025
+ opacity: 1;
1026
+ transform: translateX(-50%) scale(1);
1027
+ }
1028
+
1029
+ /* Responsive adjustments for header controls and stream selector */
1030
+ @media (max-width: 1450px) {
1031
+ .app-header {
1032
+ flex-direction: column;
1033
+ align-items: stretch;
1034
+ gap: 1.25rem;
1035
+ padding: 1.25rem 1.5rem;
1036
+ }
1037
+
1038
+ .brand-section {
1039
+ justify-content: space-between;
1040
+ width: 100%;
1041
+ }
1042
+
1043
+ .header-controls {
1044
+ justify-content: space-between;
1045
+ width: 100%;
1046
+ gap: 1.5rem;
1047
+ }
1048
+ }
1049
+
1050
+ @media (max-width: 950px) {
1051
+ .header-controls {
1052
+ flex-direction: column;
1053
+ align-items: stretch;
1054
+ gap: 1rem;
1055
+ }
1056
+
1057
+ .nav-tabs {
1058
+ justify-content: flex-start;
1059
+ overflow-x: auto;
1060
+ padding-bottom: 0.5rem;
1061
+ gap: 0.5rem;
1062
+ /* Custom scrollbar for tabs on mobile if needed */
1063
+ scrollbar-width: thin;
1064
+ }
1065
+
1066
+ .user-profile {
1067
+ justify-content: space-between;
1068
+ width: 100%;
1069
+ }
1070
+ }
1071
+
1072
+ /* Connection Status Animations */
1073
+ @keyframes spin {
1074
+ from { transform: rotate(0deg); }
1075
+ to { transform: rotate(360deg); }
1076
+ }
1077
+
1078
+ @keyframes fadeIn {
1079
+ from { opacity: 0; transform: translateY(10px); }
1080
+ to { opacity: 1; transform: translateY(0); }
1081
+ }
1082
+
1083
+ .spin {
1084
+ animation: spin 1s linear infinite;
1085
+ }
1086
+
client/src/main.jsx ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import { StrictMode } from 'react'
2
+ import { createRoot } from 'react-dom/client'
3
+ import './index.css'
4
+ import App from './App.jsx'
5
+
6
+ createRoot(document.getElementById('root')).render(
7
+ <StrictMode>
8
+ <App />
9
+ </StrictMode>,
10
+ )
client/vite.config.js ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig } from 'vite'
2
+ import react from '@vitejs/plugin-react'
3
+
4
+ // https://vite.dev/config/
5
+ export default defineConfig({
6
+ plugins: [react()],
7
+ server: {
8
+ proxy: {
9
+ '/api': {
10
+ target: 'http://localhost:3000',
11
+ changeOrigin: true,
12
+ secure: false,
13
+ }
14
+ }
15
+ }
16
+ })
local_worker/.env.example ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Cloud Backend API Settings
2
+ # Local development: http://localhost:3000
3
+ # Production: https://your-render-app.onrender.com
4
+ API_URL=http://localhost:3000
5
+
6
+ # Security Key. Must match the API_KEY set in your server's .env file!
7
+ API_KEY=a_long_secure_token_for_local_worker_to_server_sync_98765
8
+
9
+ # Target Twitch Channel (whose chat to log, and stream audio to transcribe)
10
+ TWITCH_CHANNEL=winx_prinx
11
+
12
+ # Audio Capture Method:
13
+ # 'stream' - Capture audio directly from the live Twitch stream using streamlink + ffmpeg (no mic setup required, runs on any PC).
14
+ # 'microphone' - Capture direct microphone/system audio using your PC's audio input device.
15
+ CAPTURE_METHOD=stream
16
+
17
+ # Whisper Model Settings
18
+ # Available models: tiny, base, small, medium, large-v3
19
+ # 'base' is recommended for CPU, 'small' is recommended for GPU
20
+ WHISPER_MODEL=base
21
+
22
+ # Device to run Whisper on: 'cpu' or 'cuda' (for Nvidia GPUs)
23
+ # Note: For GTX 1650, you can use 'cuda' with 'float16' or 'int8' compute type.
24
+ WHISPER_DEVICE=cpu
25
+
26
+ # Compute type: 'float16' (GPU only), 'int8' (CPU/GPU, recommended for memory saving), or 'float32'
27
+ WHISPER_COMPUTE_TYPE=int8
local_worker/chat_mod_worker.py ADDED
@@ -0,0 +1,413 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import sys
3
+ import time
4
+ import socket
5
+ import select
6
+ import threading
7
+ import queue
8
+ import random
9
+ import requests
10
+ from dotenv import load_dotenv
11
+ from vod_backfiller import download_vod_chat
12
+
13
+ # Load configurations
14
+ load_dotenv()
15
+
16
+ API_URL = os.getenv("API_URL", "http://localhost:3000")
17
+ API_KEY = os.getenv("API_KEY", "")
18
+ TWITCH_CHANNEL = os.getenv("TWITCH_CHANNEL", "winx_prinx").lower()
19
+
20
+ # Local storage file to remember which stream chats we have already backfilled
21
+ PROCESSED_FILE = "processed_chat_streams.txt"
22
+
23
+ # Verify essential secrets
24
+ if not API_KEY:
25
+ print("[Error] API_KEY is missing in .env! Local chat/mod worker cannot push data.")
26
+ sys.exit(1)
27
+
28
+ # Thread-safe queues
29
+ chat_queue = queue.Queue()
30
+ mod_action_queue = queue.Queue()
31
+
32
+ # Flag to signal thread termination
33
+ stop_flag = threading.Event()
34
+
35
+ def parse_irc_tags(tags_str):
36
+ """Parse IRC v3 tags into a dictionary"""
37
+ tags = {}
38
+ if not tags_str:
39
+ return tags
40
+
41
+ parts = tags_str.split(";")
42
+ for part in parts:
43
+ if "=" in part:
44
+ k, v = part.split("=", 1)
45
+ tags[k] = v
46
+ return tags
47
+
48
+ def new_iso_timestamp():
49
+ return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
50
+
51
+ # =========================================================================
52
+ # TWITCH CHAT & MOD ACTION IRC LISTENER
53
+ # =========================================================================
54
+
55
+ def twitch_irc_listener():
56
+ """Background thread to connect to Twitch IRC and read chat & mod actions"""
57
+ server = "irc.chat.twitch.tv"
58
+ port = 6667
59
+
60
+ anon_nick = f"justinfan{random.randint(10000, 99999)}"
61
+
62
+ print(f"[Twitch IRC] Connecting to chat anonymously as {anon_nick}...")
63
+
64
+ while not stop_flag.is_set():
65
+ try:
66
+ irc_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
67
+ irc_sock.settimeout(10.0)
68
+ irc_sock.connect((server, port))
69
+
70
+ irc_sock.send(f"PASS oauth:anonymous\r\n".encode("utf-8"))
71
+ irc_sock.send(f"NICK {anon_nick}\r\n".encode("utf-8"))
72
+ irc_sock.send("CAP REQ :twitch.tv/tags twitch.tv/commands twitch.tv/membership\r\n".encode("utf-8"))
73
+ irc_sock.send(f"JOIN #{TWITCH_CHANNEL}\r\n".encode("utf-8"))
74
+
75
+ print(f"[Twitch IRC] Joined channel #{TWITCH_CHANNEL}. Listening for chat and moderation events...")
76
+
77
+ buffer = ""
78
+ irc_sock.setblocking(False)
79
+
80
+ while not stop_flag.is_set():
81
+ ready = select.select([irc_sock], [], [], 1.0)
82
+ if not ready[0]:
83
+ continue
84
+
85
+ try:
86
+ data = irc_sock.recv(4096).decode("utf-8", errors="ignore")
87
+ except socket.timeout:
88
+ continue
89
+
90
+ if not data:
91
+ print("[Twitch IRC] Connection closed by remote host.")
92
+ break
93
+
94
+ buffer += data
95
+ while "\r\n" in buffer:
96
+ line, buffer = buffer.split("\r\n", 1)
97
+
98
+ if line.startswith("PING"):
99
+ irc_sock.send("PONG :tmi.twitch.tv\r\n".encode("utf-8"))
100
+ continue
101
+
102
+ if "PRIVMSG" in line:
103
+ tags = {}
104
+ tags_str = ""
105
+
106
+ if line.startswith("@"):
107
+ tags_str, remainder = line[1:].split(" ", 1)
108
+ tags = parse_irc_tags(tags_str)
109
+ line = remainder
110
+
111
+ parts = line.split(" PRIVMSG ", 1)
112
+ if len(parts) < 2:
113
+ continue
114
+
115
+ prefix, msg_parts = parts
116
+ user = prefix.split("!", 1)[0].replace(":", "")
117
+
118
+ channel_part, message_text = msg_parts.split(" :", 1)
119
+
120
+ msg_id = tags.get("id", f"local-{time.time_ns()}")
121
+ display_name = tags.get("display-name", user)
122
+ badges = tags.get("badges", "")
123
+
124
+ is_streamer = (user.lower() == TWITCH_CHANNEL)
125
+ is_mod = "moderator" in badges or "broadcaster" in badges
126
+ is_sub = "subscriber" in badges or "founder" in badges
127
+
128
+ parsed_msg = {
129
+ "id": msg_id,
130
+ "username": user,
131
+ "displayName": display_name,
132
+ "message": message_text,
133
+ "timestamp": new_iso_timestamp(),
134
+ "isStreamer": is_streamer,
135
+ "isMod": is_mod,
136
+ "isSub": is_sub
137
+ }
138
+
139
+ chat_queue.put(parsed_msg)
140
+
141
+ elif "CLEARCHAT" in line:
142
+ tags = {}
143
+ tags_str = ""
144
+
145
+ if line.startswith("@"):
146
+ tags_str, remainder = line[1:].split(" ", 1)
147
+ tags = parse_irc_tags(tags_str)
148
+ line = remainder
149
+
150
+ parts = line.split(" CLEARCHAT ")
151
+ if len(parts) >= 2:
152
+ channel_part = parts[1]
153
+ if " :" in channel_part:
154
+ _, target_user = channel_part.split(" :", 1)
155
+ target_user = target_user.strip()
156
+
157
+ ban_duration = tags.get("ban-duration")
158
+ action_type = "timeout" if ban_duration else "ban"
159
+ duration = int(ban_duration) if ban_duration else None
160
+
161
+ mod_action = {
162
+ "actionType": action_type,
163
+ "moderator": "TwitchIRC",
164
+ "targetUser": target_user,
165
+ "duration": duration,
166
+ "reason": tags.get("ban-reason", "No reason provided via IRC"),
167
+ "timestamp": new_iso_timestamp()
168
+ }
169
+
170
+ print(f"[Twitch IRC] Detected moderation event: {action_type.upper()} for user '{target_user}'" + (f" (duration: {duration}s)" if duration else ""))
171
+ mod_action_queue.put(mod_action)
172
+
173
+ elif "CLEARMSG" in line:
174
+ tags = {}
175
+ tags_str = ""
176
+
177
+ if line.startswith("@"):
178
+ tags_str, remainder = line[1:].split(" ", 1)
179
+ tags = parse_irc_tags(tags_str)
180
+ line = remainder
181
+
182
+ parts = line.split(" CLEARMSG ")
183
+ if len(parts) >= 2:
184
+ channel_part = parts[1]
185
+ if " :" in channel_part:
186
+ _, message_text = channel_part.split(" :", 1)
187
+ message_text = message_text.strip()
188
+ target_user = tags.get("login", "")
189
+
190
+ mod_action = {
191
+ "actionType": "delete",
192
+ "moderator": "TwitchIRC",
193
+ "targetUser": target_user,
194
+ "messageText": message_text,
195
+ "timestamp": new_iso_timestamp()
196
+ }
197
+
198
+ print(f"[Twitch IRC] Detected moderation event: DELETE message of '{target_user}': \"{message_text}\"")
199
+ mod_action_queue.put(mod_action)
200
+
201
+ except Exception as e:
202
+ print(f"[Twitch IRC] Connection error: {e}. Retrying in 10 seconds...")
203
+ time.sleep(10)
204
+ finally:
205
+ try:
206
+ irc_sock.close()
207
+ except:
208
+ pass
209
+
210
+ # =========================================================================
211
+ # BACKGROUND SENDERS
212
+ # =========================================================================
213
+
214
+ def chat_sender():
215
+ """Periodically sends accumulated chat messages to backend API"""
216
+ headers = {"x-api-key": API_KEY, "Content-Type": "application/json"}
217
+ url = f"{API_URL}/api/log/messages"
218
+
219
+ while not stop_flag.is_set():
220
+ messages = []
221
+ while not chat_queue.empty():
222
+ try:
223
+ messages.append(chat_queue.get_nowait())
224
+ except queue.Empty:
225
+ break
226
+
227
+ if messages:
228
+ try:
229
+ response = requests.post(url, json={"messages": messages}, headers=headers, timeout=5)
230
+ if response.status_code == 200:
231
+ pass # Success
232
+ else:
233
+ print(f"[Chat Sender] Failed to sync messages. API returned status {response.status_code}")
234
+ except Exception as e:
235
+ print(f"[Chat Sender] Network error sending messages: {e}")
236
+
237
+ time.sleep(3)
238
+
239
+ def mod_action_sender():
240
+ """Periodically sends accumulated mod actions to backend API"""
241
+ headers = {"x-api-key": API_KEY, "Content-Type": "application/json"}
242
+ url = f"{API_URL}/api/log/mod-action"
243
+
244
+ while not stop_flag.is_set():
245
+ actions = []
246
+ while not mod_action_queue.empty():
247
+ try:
248
+ actions.append(mod_action_queue.get_nowait())
249
+ except queue.Empty:
250
+ break
251
+
252
+ for action in actions:
253
+ try:
254
+ response = requests.post(url, json=action, headers=headers, timeout=5)
255
+ if response.status_code == 200:
256
+ print(f"[Mod Sender] Successfully logged {action['actionType']} action to backend.")
257
+ else:
258
+ print(f"[Mod Sender] Failed to sync mod action. API returned status {response.status_code}")
259
+ except Exception as e:
260
+ print(f"[Mod Sender] Network error sending mod action: {e}")
261
+
262
+ time.sleep(1)
263
+
264
+ # =========================================================================
265
+ # VOD CHAT BACKFILLING LOOP (NO AUDIO)
266
+ # =========================================================================
267
+
268
+ def load_processed_streams():
269
+ """Load successfully processed stream IDs from a local file"""
270
+ if not os.path.exists(PROCESSED_FILE):
271
+ return set()
272
+ try:
273
+ with open(PROCESSED_FILE, "r") as f:
274
+ return set(line.strip() for line in f if line.strip())
275
+ except Exception as e:
276
+ print(f"[Backfill] Error loading processed streams file: {e}")
277
+ return set()
278
+
279
+ def save_processed_stream(stream_id):
280
+ """Save a successfully processed stream ID to the local file"""
281
+ try:
282
+ with open(PROCESSED_FILE, "a") as f:
283
+ f.write(f"{stream_id}\n")
284
+ except Exception as e:
285
+ print(f"[Backfill] Error writing to processed streams file: {e}")
286
+
287
+ def run_backfill_loop():
288
+ """Main loop to check and backfill VOD chat comments ONLY"""
289
+
290
+ while not stop_flag.is_set():
291
+ print(f"[Backfill] Checking for pending VOD backfill tasks...")
292
+ processed_streams = load_processed_streams()
293
+
294
+ try:
295
+ headers_get = {"x-api-key": API_KEY}
296
+ res = requests.get(f"{API_URL}/api/streams/pending-backfill", headers=headers_get, timeout=10)
297
+ if res.status_code == 200:
298
+ data = res.json()
299
+ pending_streams = data.get("streams", [])
300
+
301
+ # Filter out streams we have already backfilled chat for
302
+ unprocessed_streams = [s for s in pending_streams if str(s.get("id")) not in processed_streams]
303
+
304
+ if unprocessed_streams:
305
+ print(f"[Backfill] Found {len(unprocessed_streams)} pending VOD chat backfill(s). Starting automated processing...")
306
+
307
+ target = unprocessed_streams[0]
308
+ stream_id = target.get("id")
309
+ vod_id = target.get("twitch_vod_id")
310
+ title = target.get("title", "Unknown Archive")
311
+ start_time = target.get("start_time")
312
+
313
+ min_msg_time = target.get("min_message_time")
314
+ max_msg_time = target.get("max_message_time")
315
+
316
+ # Parse ISO strings to epoch timestamps
317
+ def parse_time(t_str):
318
+ if not t_str:
319
+ return None
320
+ try:
321
+ clean_t = t_str.split(".")[0].replace("Z", "").replace("+00:00", "")
322
+ return time.mktime(time.strptime(clean_t, "%Y-%m-%dT%H:%M:%S"))
323
+ except Exception as parse_err:
324
+ print(f"[Backfill] Error parsing time '{t_str}': {parse_err}")
325
+ return None
326
+
327
+ start_epoch = parse_time(start_time)
328
+
329
+ # -- CHAT BACKFILL TASKS --
330
+ chat_tasks = []
331
+ if not min_msg_time:
332
+ # 0 messages, download everything
333
+ chat_tasks.append((0, None))
334
+ else:
335
+ min_msg_epoch = parse_time(min_msg_time)
336
+ max_msg_epoch = parse_time(max_msg_time)
337
+
338
+ min_msg_offset = int(min_msg_epoch - start_epoch) if (min_msg_epoch and start_epoch) else 0
339
+ max_msg_offset = int(max_msg_epoch - start_epoch) if (max_msg_epoch and start_epoch) else 0
340
+
341
+ # If first message is > 60s from start, download the beginning gap
342
+ if min_msg_offset > 60:
343
+ chat_tasks.append((0, min_msg_offset))
344
+ # Download from last message to end
345
+ chat_tasks.append((max_msg_offset, None))
346
+
347
+ print(f"\n=========================================================")
348
+ print(f"[Backfill] Processing Chat: {title}")
349
+ print(f"[Backfill] VOD ID: {vod_id}")
350
+ print(f"[Backfill] Stream ID: {stream_id}")
351
+ print(f"[Backfill] Chat Tasks: {chat_tasks}")
352
+ print(f"=========================================================")
353
+
354
+ # 1. Download and upload chat segments
355
+ for start_off, end_off in chat_tasks:
356
+ print(f"[Backfill] Downloading chat from {start_off}s to {'end' if end_off is None else str(end_off) + 's'}...")
357
+ chat_comments = download_vod_chat(vod_id, start_offset=start_off, end_offset=end_off)
358
+ if chat_comments:
359
+ print(f"[Backfill] Uploading {len(chat_comments)} chat comments...")
360
+ batch_size = 100
361
+ headers_post = {"x-api-key": API_KEY, "Content-Type": "application/json"}
362
+ for i in range(0, len(chat_comments), batch_size):
363
+ batch = chat_comments[i:i+batch_size]
364
+ try:
365
+ requests.post(f"{API_URL}/api/log/messages", json={"messages": batch}, headers=headers_post, timeout=10)
366
+ except Exception as e:
367
+ print(f"[Backfill] Chat batch upload error: {e}")
368
+
369
+ # 2. Mark locally as processed (does NOT mark completed on server, so worker.py can do voice)
370
+ save_processed_stream(stream_id)
371
+ print(f"[Backfill] Successfully backfilled chat for stream {stream_id}. Recorded to local processed file.")
372
+
373
+ # Loop again immediately
374
+ continue
375
+ else:
376
+ print("[Backfill] No pending VOD chat backfills found.")
377
+ else:
378
+ print(f"[Backfill] Failed to fetch pending backfills: {res.status_code}")
379
+ except Exception as err:
380
+ print(f"[Backfill] Error checking pending backfills: {err}")
381
+
382
+ print(f"[Backfill] Retrying check in 60 seconds...")
383
+ time.sleep(60)
384
+
385
+ # =========================================================================
386
+ # MAIN ENTRYPOINT
387
+ # =========================================================================
388
+
389
+ if __name__ == "__main__":
390
+ print("=========================================================")
391
+ print(" Twitch Chat & Moderation Actions Logger & Chat-Sync ")
392
+ print("=========================================================")
393
+ print(f"Target Channel: {TWITCH_CHANNEL}")
394
+ print(f"API Backend URL: {API_URL}")
395
+ print("=========================================================")
396
+
397
+ # Start Twitch IRC background threads (runs 24/7 to catch chat/mod actions)
398
+ t_irc = threading.Thread(target=twitch_irc_listener, daemon=True)
399
+ t_chat_send = threading.Thread(target=chat_sender, daemon=True)
400
+ t_mod_send = threading.Thread(target=mod_action_sender, daemon=True)
401
+
402
+ t_irc.start()
403
+ t_chat_send.start()
404
+ t_mod_send.start()
405
+
406
+ # Start VOD backfilling (GQL chat ONLY) in the main thread
407
+ try:
408
+ run_backfill_loop()
409
+ except KeyboardInterrupt:
410
+ print("\n[Shutting Down] Gracefully stopping threads...")
411
+ stop_flag.set()
412
+ time.sleep(1)
413
+ print("[Shutting Down] Done.")
local_worker/mock_sender.py ADDED
@@ -0,0 +1,177 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import sys
3
+ import time
4
+ import random
5
+ import requests
6
+ from dotenv import load_dotenv
7
+
8
+ # Load local environment settings
9
+ load_dotenv()
10
+
11
+ API_URL = os.getenv("API_URL", "http://localhost:3000")
12
+ API_KEY = os.getenv("API_KEY", "")
13
+ TWITCH_CHANNEL = os.getenv("TWITCH_CHANNEL", "winx_prinx").lower()
14
+
15
+ if not API_KEY:
16
+ print("[Error] API_KEY is missing! Fill out your .env file in local_worker.")
17
+ sys.exit(1)
18
+
19
+ headers = {
20
+ "x-api-key": API_KEY,
21
+ "Content-Type": "application/json"
22
+ }
23
+
24
+ # Sample usernames and messages for simulation
25
+ VIEWERS = [
26
+ {"username": "alex_gaming", "displayName": "AlexGaming", "isMod": False, "isSub": True},
27
+ {"username": "masha_sweet", "displayName": "Машуля", "isMod": False, "isSub": True},
28
+ {"username": "moderator_dmitry", "displayName": "Дмитрий_Мод", "isMod": True, "isSub": False},
29
+ {"username": "gamer_pro", "displayName": "ProGamer", "isMod": False, "isSub": False},
30
+ {"username": "chat_enjoyer", "displayName": "ЛюбительЧата", "isMod": False, "isSub": False},
31
+ {"username": "vip_nikita", "displayName": "Никита_VIP", "isMod": False, "isSub": True},
32
+ {"username": "stream_fan", "displayName": "ФанСтрима", "isMod": False, "isSub": True},
33
+ {"username": "spammer99", "displayName": "Спамер99", "isMod": False, "isSub": False},
34
+ {"username": "troll_user", "displayName": "Тролль", "isMod": False, "isSub": False},
35
+ ]
36
+
37
+ MOCK_PHRASES = [
38
+ "привет всем!",
39
+ "стрим супер, спасибо!",
40
+ "какая сейчас игра?",
41
+ "краш игры был жесткий",
42
+ "когда следующий стрим?",
43
+ "модеры, забаньте спамера плиз",
44
+ "хахаха это было смешно",
45
+ "какой крутой момент!",
46
+ "winx_prinx лучший!",
47
+ "подписка оформлена!",
48
+ "что думаешь про обновление?",
49
+ "го в некст игру",
50
+ "всем хорошего вечера",
51
+ "какой счет?",
52
+ ]
53
+
54
+ SPOKEN_WORDS = [
55
+ "привет", "ребята", "всем привет", "спасибо", "подписка", "играем", "игра", "победа",
56
+ "поражение", "жесть", "краш", "число", "стрим", "канал", "чат", "модеры", "круто",
57
+ "завтра", "сегодня", "начинаем", "отлично", "давай", "погнали", "розыгрыш", "смайлик"
58
+ ]
59
+
60
+ def generate_messages_batch(count=30):
61
+ messages = []
62
+ base_time = time.time()
63
+
64
+ # Add some messages from the streamer
65
+ streamer_user = {
66
+ "username": TWITCH_CHANNEL,
67
+ "displayName": TWITCH_CHANNEL.capitalize(),
68
+ "isStreamer": True,
69
+ "isMod": True,
70
+ "isSub": False
71
+ }
72
+
73
+ for i in range(count):
74
+ # 10% chance it is the streamer writing
75
+ if random.random() < 0.15:
76
+ user = streamer_user
77
+ else:
78
+ user = random.choice(VIEWERS)
79
+
80
+ phrase = random.choice(MOCK_PHRASES)
81
+
82
+ # Simulate spaced out timestamps over the last 30 minutes
83
+ timestamp_ms = base_time - (count - i) * 60 # 1 minute intervals
84
+ timestamp_iso = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(timestamp_ms))
85
+
86
+ messages.append({
87
+ "id": f"mock-{random.randint(100000, 999999)}-{i}",
88
+ "username": user["username"],
89
+ "displayName": user["displayName"],
90
+ "message": phrase,
91
+ "timestamp": timestamp_iso,
92
+ "isStreamer": user.get("isStreamer", False),
93
+ "isMod": user["isMod"],
94
+ "isSub": user["isSub"]
95
+ })
96
+ return messages
97
+
98
+ def run_simulation():
99
+ print(f"=== Запуск Симулятора Стрима для {TWITCH_CHANNEL} ===")
100
+ print(f"Подключение к бэкенду: {API_URL}")
101
+
102
+ # 1. Send Chat Messages Batch
103
+ print("\n[1/3] Генерация и отправка сообщений чата...")
104
+ chat_batch = generate_messages_batch(50)
105
+ try:
106
+ url = f"{API_URL}/api/log/messages"
107
+ res = requests.post(url, json={"messages": chat_batch}, headers=headers, timeout=5)
108
+ if res.status_code == 200:
109
+ print(f"-> Успешно записано {len(chat_batch)} сообщений в базу!")
110
+ else:
111
+ print(f"-> Ошибка отправки сообщений: {res.status_code} - {res.text}")
112
+ except Exception as e:
113
+ print(f"-> Не удалось подключиться к серверу: {e}")
114
+ return
115
+
116
+ # 2. Send Voice Words (Streamer Speech)
117
+ print("\n[2/3] Отправка распознанной речи стримера (голос)...")
118
+ # Generate list of random spoken words with repeat frequencies
119
+ voice_words_list = []
120
+ for _ in range(150):
121
+ voice_words_list.append(random.choice(SPOKEN_WORDS))
122
+
123
+ try:
124
+ url = f"{API_URL}/api/log/voice"
125
+ voice_timestamp = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(time.time() - 300))
126
+ res = requests.post(url, json={"words": voice_words_list, "timestamp": voice_timestamp}, headers=headers, timeout=5)
127
+ if res.status_code == 200:
128
+ print(f"-> Успешно записано {len(voice_words_list)} произнесенных слов!")
129
+ else:
130
+ print(f"-> Ошибка отправки слов: {res.status_code} - {res.text}")
131
+ except Exception as e:
132
+ print(f"-> Ошибка: {e}")
133
+
134
+ # 3. Send Mock Moderator Actions
135
+ print("\n[3/3] Отправка действий модерации...")
136
+ mock_actions = [
137
+ {
138
+ "actionType": "timeout",
139
+ "moderator": "moderator_dmitry",
140
+ "targetUser": "spammer99",
141
+ "duration": 600,
142
+ "reason": "Флуд капсом и смайлами",
143
+ "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(time.time() - 300))
144
+ },
145
+ {
146
+ "actionType": "delete",
147
+ "moderator": "moderator_dmitry",
148
+ "targetUser": "troll_user",
149
+ "messageText": "Стример нуб, удаляй канал лол",
150
+ "reason": "Оскорбление стримера",
151
+ "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(time.time() - 200))
152
+ },
153
+ {
154
+ "actionType": "ban",
155
+ "moderator": "moderator_dmitry",
156
+ "targetUser": "cheater_pro",
157
+ "reason": "Реклама стороннего софта в чате",
158
+ "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(time.time() - 100))
159
+ }
160
+ ]
161
+
162
+ success_count = 0
163
+ for act in mock_actions:
164
+ try:
165
+ url = f"{API_URL}/api/log/mod-action"
166
+ res = requests.post(url, json=act, headers=headers, timeout=5)
167
+ if res.status_code == 200:
168
+ success_count += 1
169
+ except Exception as e:
170
+ print(f"-> Ошибка при отправке действия модерации: {e}")
171
+
172
+ print(f"-> Успешно записано {success_count} действий модерации!")
173
+ print("\n=== Симуляция успешно завершена! ===")
174
+ print("Откройте дашборд в браузере, чтобы увидеть сгенерированные графики и таблицы.")
175
+
176
+ if __name__ == "__main__":
177
+ run_simulation()
local_worker/processed_chat_streams.txt ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 21
2
+ 20
3
+ 19
4
+ 18
5
+ 17
6
+ 16
7
+ 15
8
+ 25
9
+ 13
10
+ 23
11
+ 22
local_worker/requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ faster-whisper>=1.0.3
2
+ requests>=2.31.0
3
+ python-dotenv>=1.0.1
4
+ numpy>=1.24.0
5
+ sounddevice>=0.4.6
local_worker/seed_lurkers.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sqlite3
2
+ import random
3
+ import string
4
+ import sys
5
+
6
+ def generate_random_name():
7
+ length = random.randint(5, 12)
8
+ letters = string.ascii_lowercase
9
+ name = ''.join(random.choice(letters) for i in range(length))
10
+ if random.random() > 0.5:
11
+ name += str(random.randint(1, 999))
12
+ return name
13
+
14
+ def seed_lurkers(db_path, target_total=2300):
15
+ conn = sqlite3.connect(db_path)
16
+ c = conn.cursor()
17
+
18
+ # Get the latest stream_id
19
+ c.execute("SELECT id FROM streams ORDER BY id DESC LIMIT 1")
20
+ row = c.fetchone()
21
+ if not row:
22
+ print("No streams found in db")
23
+ return
24
+ stream_id = row[0]
25
+ print(f"Seeding lurkers for stream_id {stream_id}")
26
+
27
+ # Check how many viewers we already have
28
+ c.execute("SELECT COUNT(*) FROM stream_viewers WHERE stream_id = ?", (stream_id,))
29
+ current_count = c.fetchone()[0]
30
+
31
+ needed = target_total - current_count
32
+ if needed <= 0:
33
+ print(f"Already have {current_count} viewers, no need to seed.")
34
+ return
35
+
36
+ print(f"Generating {needed} fake lurkers...")
37
+
38
+ inserted = 0
39
+ while inserted < needed:
40
+ username = generate_random_name()
41
+ try:
42
+ c.execute("""
43
+ INSERT INTO stream_viewers (stream_id, username, display_name, has_chatted, is_mod, is_sub)
44
+ VALUES (?, ?, ?, 0, 0, 0)
45
+ """, (stream_id, username, username))
46
+ inserted += 1
47
+ except sqlite3.IntegrityError:
48
+ pass # duplicate username, ignore
49
+
50
+ conn.commit()
51
+ conn.close()
52
+ print(f"Successfully seeded {inserted} lurkers!")
53
+
54
+ if __name__ == '__main__':
55
+ seed_lurkers('../server/database.db', 2300)
local_worker/seed_real_lurkers.py ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sqlite3
2
+ import requests
3
+ import time
4
+
5
+ def fetch_real_usernames(count):
6
+ usernames = []
7
+ # randomuser.me limits to 5000 results per request
8
+ print(f"Fetching {count} usernames from randomuser.me...")
9
+ try:
10
+ resp = requests.get(f"https://randomuser.me/api/?results={count}&inc=login")
11
+ data = resp.json()
12
+ for user in data['results']:
13
+ usernames.append(user['login']['username'])
14
+ except Exception as e:
15
+ print("Error:", e)
16
+ return usernames
17
+
18
+ def seed_lurkers(db_path, target_total=2300):
19
+ conn = sqlite3.connect(db_path)
20
+ c = conn.cursor()
21
+
22
+ # Get the latest stream_id
23
+ c.execute("SELECT id FROM streams ORDER BY id DESC LIMIT 1")
24
+ row = c.fetchone()
25
+ if not row:
26
+ print("No streams found in db")
27
+ return
28
+ stream_id = row[0]
29
+
30
+ # Check current active chatters count
31
+ c.execute("SELECT COUNT(*) FROM stream_viewers WHERE stream_id = ?", (stream_id,))
32
+ current_count = c.fetchone()[0]
33
+
34
+ needed = target_total - current_count
35
+ if needed <= 0:
36
+ print("Enough viewers.")
37
+ return
38
+
39
+ usernames = fetch_real_usernames(needed)
40
+ print(f"Got {len(usernames)} real-looking usernames. Inserting...")
41
+
42
+ inserted = 0
43
+ for username in usernames:
44
+ try:
45
+ c.execute("""
46
+ INSERT INTO stream_viewers (stream_id, username, display_name, has_chatted, is_mod, is_sub)
47
+ VALUES (?, ?, ?, 0, 0, 0)
48
+ """, (stream_id, username, username))
49
+ inserted += 1
50
+ except sqlite3.IntegrityError:
51
+ pass # ignore duplicates
52
+
53
+ conn.commit()
54
+ conn.close()
55
+ print(f"Successfully seeded {inserted} realistic lurkers!")
56
+
57
+ if __name__ == '__main__':
58
+ seed_lurkers('../server/database.db', 2300)
local_worker/vod_backfiller.py ADDED
@@ -0,0 +1,407 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import sys
3
+
4
+ # Setup dynamic CUDA paths before importing Whisper
5
+ nvidia_subdirs = [
6
+ ("nvidia", "cublas", "lib"),
7
+ ("nvidia", "cudnn", "lib"),
8
+ ("nvidia", "cuda_nvrtc", "lib")
9
+ ]
10
+ added_paths = []
11
+ for p in sys.path:
12
+ if "site-packages" in p:
13
+ for subdir in nvidia_subdirs:
14
+ full_path = os.path.join(p, *subdir)
15
+ if os.path.exists(full_path) and full_path not in added_paths:
16
+ added_paths.append(full_path)
17
+ if added_paths:
18
+ existing = os.environ.get("LD_LIBRARY_PATH", "")
19
+ if existing:
20
+ os.environ["LD_LIBRARY_PATH"] = ":".join(added_paths) + ":" + existing
21
+ else:
22
+ os.environ["LD_LIBRARY_PATH"] = ":".join(added_paths)
23
+
24
+ import time
25
+ import subprocess
26
+ import requests
27
+ import numpy as np
28
+ from dotenv import load_dotenv
29
+ from faster_whisper import WhisperModel
30
+
31
+ # Load local environment settings
32
+ load_dotenv()
33
+
34
+ API_URL = os.getenv("API_URL", "http://localhost:3000")
35
+ API_KEY = os.getenv("API_KEY", "")
36
+ TWITCH_CHANNEL = os.getenv("TWITCH_CHANNEL", "winx_prinx").lower()
37
+ WHISPER_MODEL_SIZE = os.getenv("WHISPER_MODEL", "base")
38
+ WHISPER_DEVICE = os.getenv("WHISPER_DEVICE", "cpu")
39
+ WHISPER_COMPUTE_TYPE = os.getenv("WHISPER_COMPUTE_TYPE", "int8")
40
+
41
+ if not API_KEY:
42
+ print("[Error] API_KEY is missing in .env! Cannot push data to backend.")
43
+ sys.exit(1)
44
+
45
+ headers = {
46
+ "x-api-key": API_KEY,
47
+ "Content-Type": "application/json"
48
+ }
49
+
50
+ # =========================================================================
51
+ # PUBLIC TWITCH GQL API CLIENT
52
+ # =========================================================================
53
+
54
+ def get_vod_details(vod_id):
55
+ """Fetch VOD metadata using public Twitch GQL API"""
56
+ url = "https://gql.twitch.tv/gql"
57
+ gql_headers = {
58
+ "Client-Id": "kimne78kx3ncx6brgo4mv6wki5h1ko",
59
+ "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
60
+ }
61
+ payload = {
62
+ "query": f"""
63
+ query {{
64
+ video(id: "{vod_id}") {{
65
+ title
66
+ createdAt
67
+ lengthSeconds
68
+ owner {{
69
+ login
70
+ }}
71
+ }}
72
+ }}
73
+ """
74
+ }
75
+ try:
76
+ res = requests.post(url, json=payload, headers=gql_headers, timeout=10)
77
+ if res.status_code == 200:
78
+ data = res.json()
79
+ return data.get("data", {}).get("video")
80
+ except Exception as e:
81
+ print(f"[Error] Failed to fetch VOD details: {e}")
82
+ return None
83
+
84
+ def download_vod_chat(vod_id, start_offset=0, end_offset=None):
85
+ """Download chat log of a past VOD using GQL offset-based pagination (bypasses integrity checks)"""
86
+ comments = []
87
+ seen_ids = set()
88
+ url = "https://gql.twitch.tv/gql"
89
+ gql_headers = {
90
+ "Client-Id": "kimne78kx3ncx6brgo4mv6wki5h1ko",
91
+ "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
92
+ }
93
+
94
+ current_offset = start_offset
95
+ print(f"\n[Chat] Запуск скачивания чата для VOD {vod_id} с секунды {start_offset} до {'конца' if end_offset is None else str(end_offset) + 's'}...")
96
+
97
+ while True:
98
+ if end_offset is not None and current_offset >= end_offset:
99
+ print(f"\n[Chat] Достигнут конечный офсет {end_offset}s. Завершаем скачивание чата.")
100
+ break
101
+
102
+ payload = {
103
+ "operationName": "VideoCommentsByOffsetOrCursor",
104
+ "variables": {
105
+ "videoID": str(vod_id),
106
+ "contentOffsetSeconds": current_offset
107
+ },
108
+ "extensions": {
109
+ "persistedQuery": {
110
+ "version": 1,
111
+ "sha256Hash": "b70a3591ff0f4e0313d126c6a1502d79a1c02baebb288227c582044aa76adf6a"
112
+ }
113
+ }
114
+ }
115
+
116
+ try:
117
+ res = requests.post(url, json=payload, headers=gql_headers, timeout=10)
118
+ if res.status_code != 200:
119
+ print(f"[Chat] Ошибка GQL: {res.status_code}")
120
+ break
121
+
122
+ data = res.json()
123
+ if isinstance(data, list):
124
+ data = data[0]
125
+
126
+ video = data.get("data", {}).get("video", {})
127
+ if not video:
128
+ break
129
+
130
+ comments_edge = video.get("comments") or {}
131
+ edges = comments_edge.get("edges") or []
132
+ if not edges:
133
+ break
134
+
135
+ for edge in edges:
136
+ if not edge:
137
+ continue
138
+ node = edge.get("node")
139
+ if not node:
140
+ continue
141
+ msg_id = node.get("id")
142
+ if not msg_id or msg_id in seen_ids:
143
+ continue
144
+
145
+ commenter = node.get("commenter")
146
+ if not commenter:
147
+ continue
148
+
149
+ user = commenter.get("login")
150
+ if not user:
151
+ continue
152
+
153
+ display_name = commenter.get("displayName", user)
154
+
155
+ message = node.get("message")
156
+ if not message:
157
+ continue
158
+ fragments = message.get("fragments") or []
159
+ message_text = "".join([f.get("text", "") for f in fragments if f])
160
+
161
+ timestamp = node.get("createdAt")
162
+
163
+ # Badges parse
164
+ user_badges = node.get("userBadges") or []
165
+ badges = [b.get("id") for b in user_badges if b]
166
+ is_mod = "moderator" in badges or "broadcaster" in badges
167
+ is_sub = "subscriber" in badges or "founder" in badges
168
+ is_streamer = (user.lower() == TWITCH_CHANNEL)
169
+
170
+ seen_ids.add(msg_id)
171
+ comments.append({
172
+ "id": msg_id,
173
+ "username": user,
174
+ "displayName": display_name,
175
+ "message": message_text,
176
+ "timestamp": timestamp,
177
+ "isStreamer": is_streamer,
178
+ "isMod": is_mod,
179
+ "isSub": is_sub
180
+ })
181
+
182
+ print(f"-> Загружено {len(comments)} комментариев...", end="\r")
183
+
184
+ # Progress offset
185
+ last_offset = edges[-1].get("node", {}).get("contentOffsetSeconds")
186
+ if last_offset is not None:
187
+ next_offset = last_offset + 1
188
+ if next_offset <= current_offset:
189
+ next_offset = current_offset + 30
190
+ current_offset = next_offset
191
+ else:
192
+ break
193
+ except Exception as e:
194
+ print(f"\n[Chat] Ошибка при загрузке: {e}")
195
+ break
196
+
197
+ print(f"\n[Chat] Загрузка завершена. Всего сообщений: {len(comments)}")
198
+ return comments
199
+
200
+ # =========================================================================
201
+ # AUDIO TRANSCRIBER (WHISPER)
202
+ # =========================================================================
203
+
204
+ def transcribe_vod_audio(vod_id, start_time_iso, model, start_offset=0, end_offset=None):
205
+ """Download VOD audio stream, seek to start_offset, transcribe with Whisper and upload"""
206
+ print(f"\n[Audio] Запуск скачивания и распознавания речи для VOD {vod_id} с секунды {start_offset} до {'конца' if end_offset is None else str(end_offset) + 's'}...")
207
+
208
+ # Check if streamlink is installed
209
+ try:
210
+ subprocess.run(["streamlink", "--version"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
211
+ except FileNotFoundError:
212
+ print("[Error] 'streamlink' utility is not installed! Cannot fetch VOD audio.")
213
+ return
214
+
215
+ cmd_streamlink = ["streamlink", f"twitch.tv/videos/{vod_id}", "audio,worst", "-O"]
216
+ if start_offset > 0:
217
+ h = start_offset // 3600
218
+ m = (start_offset % 3600) // 60
219
+ s = start_offset % 60
220
+ offset_str = f"{h:02d}:{m:02d}:{s:02d}"
221
+ cmd_streamlink.extend(["--hls-start-offset", offset_str])
222
+ print(f"[Audio] Запуск streamlink с поиском --hls-start-offset {offset_str}")
223
+
224
+ cmd_ffmpeg = ["ffmpeg", "-i", "pipe:0", "-ac", "1", "-ar", "16000", "-f", "s16le", "-"]
225
+
226
+ p_streamlink = None
227
+ p_ffmpeg = None
228
+
229
+ try:
230
+ p_streamlink = subprocess.Popen(cmd_streamlink, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
231
+ p_ffmpeg = subprocess.Popen(cmd_ffmpeg, stdin=p_streamlink.stdout, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
232
+
233
+ # 30-second audio chunks for batch processing (more efficient offline)
234
+ # 16000Hz * 2 bytes * 30 seconds = 960,000 bytes
235
+ chunk_seconds = 30
236
+ chunk_size = 16000 * 2 * chunk_seconds
237
+
238
+ offset_seconds = start_offset
239
+
240
+ base_start_time = time.mktime(time.strptime(start_time_iso, "%Y-%m-%dT%H:%M:%SZ"))
241
+
242
+ while True:
243
+ if end_offset is not None and offset_seconds >= end_offset:
244
+ print(f"\n[Audio] Достигнут конечный офсет {end_offset}s. Завершаем транскрибацию аудио.")
245
+ break
246
+
247
+ pcm_data = p_ffmpeg.stdout.read(chunk_size)
248
+ if not pcm_data:
249
+ break
250
+
251
+ # Convert raw 16-bit PCM bytes to float32 numpy array
252
+ audio_np = np.frombuffer(pcm_data, dtype=np.int16).astype(np.float32) / 32768.0
253
+
254
+ # Transcribe segment
255
+ segments, info = model.transcribe(
256
+ audio_np,
257
+ beam_size=5,
258
+ language="ru",
259
+ vad_filter=True,
260
+ vad_parameters=dict(min_silence_duration_ms=500)
261
+ )
262
+
263
+ words_list = []
264
+ for segment in segments:
265
+ text = segment.text.strip()
266
+ if text:
267
+ words_list.extend(text.split())
268
+
269
+ if words_list:
270
+ # Calculate correct timestamp based on elapsed video time
271
+ word_time_epoch = base_start_time + offset_seconds
272
+ word_time_iso = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(word_time_epoch))
273
+
274
+ # Send to backend
275
+ try:
276
+ url = f"{API_URL}/api/log/voice"
277
+ res = requests.post(url, json={"words": words_list, "timestamp": word_time_iso}, headers=headers, timeout=5)
278
+ if res.status_code == 200:
279
+ print(f"[{time.strftime('%H:%M:%S', time.gmtime(offset_seconds))}] Распознано и отправлено {len(words_list)} слов.")
280
+ except Exception as e:
281
+ print(f"[Sync Error] Не удалось отправить слова: {e}")
282
+
283
+ offset_seconds += chunk_seconds
284
+
285
+ except Exception as err:
286
+ print(f"[Audio Error] Ошибка конвейера аудио: {err}")
287
+ finally:
288
+ for p in [p_ffmpeg, p_streamlink]:
289
+ if p:
290
+ try:
291
+ p.terminate()
292
+ p.wait(timeout=2)
293
+ except:
294
+ try: p.kill()
295
+ except: pass
296
+
297
+ print("[Audio] Распознавание аудиодорожки завершено.")
298
+
299
+ # =========================================================================
300
+ # MAIN EXECUTION
301
+ # =========================================================================
302
+
303
+ if __name__ == "__main__":
304
+ print("=========================================================")
305
+ print(" Twitch VOD Backfiller & Speech Sync (winx_prinx) ")
306
+ print("=========================================================")
307
+ print(f"Target Channel: {TWITCH_CHANNEL}")
308
+ print(f"API Backend: {API_URL}")
309
+ print("=========================================================")
310
+
311
+ vod_id = input("Введите ID Twitch VOD (например, 2154382910): ").strip()
312
+ if not vod_id:
313
+ print("ID VOD не введен. Выход.")
314
+ sys.exit(0)
315
+
316
+ # 1. Fetch metadata
317
+ print("[Sync] Получение метаданных VOD...")
318
+ details = get_vod_details(vod_id)
319
+ if not details:
320
+ print("[Error] Не удалось получить информацию о VOD. Проверьте ID видео.")
321
+ sys.exit(1)
322
+
323
+ owner = details.get("owner", {}).get("login", "").lower()
324
+ if owner != TWITCH_CHANNEL:
325
+ print(f"[Warning] Владелец VOD ({owner}) не совпадает с целевым каналом ({TWITCH_CHANNEL})!")
326
+ confirm = input("Продолжить импорт? (y/n): ").strip().lower()
327
+ if confirm != 'y':
328
+ sys.exit(0)
329
+
330
+ title = details.get("title", "Архивный стрим")
331
+ created_at_raw = details.get("createdAt") # e.g. "2026-06-09T17:15:30Z"
332
+ length_seconds = details.get("lengthSeconds", 0)
333
+
334
+ # format timestamp to strict Z
335
+ created_at = created_at_raw.replace("+00:00", "").replace("Z", "") + "Z"
336
+
337
+ end_time_epoch = time.mktime(time.strptime(created_at, "%Y-%m-%dT%H:%M:%SZ")) + length_seconds
338
+ end_time = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(end_time_epoch))
339
+
340
+ print(f"\nНазвание: {title}")
341
+ print(f"Начало: {created_at}")
342
+ print(f"Длительность: {length_seconds // 3600}ч {(length_seconds % 3600) // 60}м")
343
+
344
+ # 2. Sync stream record in DB
345
+ print("\n[Sync] Регистрация сессии стрима на бэкенде...")
346
+ try:
347
+ url = f"{API_URL}/api/streams/sync-vod-direct"
348
+ payload = {
349
+ "twitchStreamId": f"vod-{vod_id}",
350
+ "title": title,
351
+ "category": "Архив",
352
+ "startTime": created_at,
353
+ "endTime": end_time
354
+ }
355
+ res = requests.post(url, json=payload, headers=headers, timeout=5)
356
+ if res.status_code != 200:
357
+ print(f"[Error] Не удалось синхронизировать сессию стрима: {res.status_code} - {res.text}")
358
+ sys.exit(1)
359
+ print("-> Сессия стрима успешно зарегистрирована в базе!")
360
+ except Exception as e:
361
+ print(f"[Error] Ошибка сети при обращении к бэкенду: {e}")
362
+ sys.exit(1)
363
+
364
+ # 3. Download and Upload Chat comments
365
+ chat_comments = download_vod_chat(vod_id)
366
+ if chat_comments:
367
+ print("\n[Sync] Отправка чата на сервер (пакетами по 100 сообщений)...")
368
+ batch_size = 100
369
+ for i in range(0, len(chat_comments), batch_size):
370
+ batch = chat_comments[i:i+batch_size]
371
+ try:
372
+ url = f"{API_URL}/api/log/messages"
373
+ res = requests.post(url, json={"messages": batch}, headers=headers, timeout=5)
374
+ if res.status_code == 200:
375
+ print(f"-> Отправлено сообщений: {i + len(batch)} / {len(chat_comments)}", end="\r")
376
+ else:
377
+ print(f"\n-> Ошибка отправки пакета: {res.status_code}")
378
+ except Exception as e:
379
+ print(f"\n-> Ошибка сети при отправке пакета: {e}")
380
+ print("\n-> Синхронизация чата успешно завершена!")
381
+
382
+ # 4. Transcribe Audio
383
+ transcribe_confirm = input("\nХотите запустить скачивание и транскрибацию аудиодорожки (Whisper)? (y/n): ").strip().lower()
384
+ if transcribe_confirm == 'y':
385
+ # Init Whisper
386
+ print(f"\n[Whisper] Загрузка модели {WHISPER_MODEL_SIZE}...")
387
+ try:
388
+ model = WhisperModel(
389
+ WHISPER_MODEL_SIZE,
390
+ device=WHISPER_DEVICE,
391
+ compute_type=WHISPER_COMPUTE_TYPE
392
+ )
393
+ transcribe_vod_audio(vod_id, created_at, model)
394
+ except Exception as e:
395
+ print(f"[Whisper Error] Не удалось инициализировать Whisper: {e}")
396
+
397
+ # 5. Mark Completed on Server
398
+ try:
399
+ url_mark = f"{API_URL}/api/streams/mark-backfilled"
400
+ requests.post(url_mark, json={"twitchStreamId": f"vod-{vod_id}"}, headers=headers, timeout=5)
401
+ print("-> Статус импорта успешно обновлен на сервере!")
402
+ except Exception as e:
403
+ print(f"[Warning] Не удалось отметить стрим как импортированный: {e}")
404
+
405
+ print("\n=========================================================")
406
+ print(" Импорт VOD завершен! ")
407
+ print("=========================================================")
local_worker/worker.py ADDED
@@ -0,0 +1,563 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import sys
3
+
4
+ # Setup dynamic CUDA paths before importing Whisper
5
+ nvidia_subdirs = [
6
+ ("nvidia", "cublas", "lib"),
7
+ ("nvidia", "cudnn", "lib"),
8
+ ("nvidia", "cuda_nvrtc", "lib")
9
+ ]
10
+ added_paths = []
11
+ for p in sys.path:
12
+ if "site-packages" in p:
13
+ for subdir in nvidia_subdirs:
14
+ full_path = os.path.join(p, *subdir)
15
+ if os.path.exists(full_path) and full_path not in added_paths:
16
+ added_paths.append(full_path)
17
+ if added_paths:
18
+ existing = os.environ.get("LD_LIBRARY_PATH", "")
19
+ if existing:
20
+ os.environ["LD_LIBRARY_PATH"] = ":".join(added_paths) + ":" + existing
21
+ else:
22
+ os.environ["LD_LIBRARY_PATH"] = ":".join(added_paths)
23
+
24
+ import time
25
+ import socket
26
+ import select
27
+ import threading
28
+ import queue
29
+ import subprocess
30
+ import random
31
+ import requests
32
+ import numpy as np
33
+ from dotenv import load_dotenv
34
+ from faster_whisper import WhisperModel
35
+ from vod_backfiller import download_vod_chat, transcribe_vod_audio
36
+
37
+ # Load configurations
38
+ load_dotenv()
39
+
40
+ API_URL = os.getenv("API_URL", "http://localhost:3000")
41
+ API_KEY = os.getenv("API_KEY", "")
42
+ TWITCH_CHANNEL = os.getenv("TWITCH_CHANNEL", "winx_prinx").lower()
43
+ CAPTURE_METHOD = os.getenv("CAPTURE_METHOD", "stream").lower()
44
+ WHISPER_MODEL_SIZE = os.getenv("WHISPER_MODEL", "base")
45
+ WHISPER_DEVICE = os.getenv("WHISPER_DEVICE", "cpu")
46
+ WHISPER_COMPUTE_TYPE = os.getenv("WHISPER_COMPUTE_TYPE", "int8")
47
+
48
+ # Verify essential secrets
49
+ if not API_KEY:
50
+ print("[Error] API_KEY is missing in .env! Local worker cannot push data.")
51
+ sys.exit(1)
52
+
53
+ # Thread-safe message queue for Twitch chat messages
54
+ chat_queue = queue.Queue()
55
+ # Flag to signal thread termination
56
+ stop_flag = threading.Event()
57
+
58
+ # =========================================================================
59
+ # TWITCH CHAT LOGGER (IRC CLIENT)
60
+ # =========================================================================
61
+
62
+ def parse_irc_tags(tags_str):
63
+ """Parse IRC v3 tags into a dictionary"""
64
+ tags = {}
65
+ if not tags_str:
66
+ return tags
67
+
68
+ parts = tags_str.split(";")
69
+ for part in parts:
70
+ if "=" in part:
71
+ k, v = part.split("=", 1)
72
+ tags[k] = v
73
+ return tags
74
+
75
+ def twitch_chat_listener():
76
+ """Background thread to connect to Twitch IRC and read chat messages"""
77
+ server = "irc.chat.twitch.tv"
78
+ port = 6667
79
+
80
+ # Generate a random anonymous nickname
81
+ anon_nick = f"justinfan{random.randint(10000, 99999)}"
82
+
83
+ print(f"[Twitch IRC] Connecting anonymously as {anon_nick}...")
84
+
85
+ while not stop_flag.is_set():
86
+ try:
87
+ irc_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
88
+ irc_sock.settimeout(10.0)
89
+ irc_sock.connect((server, port))
90
+
91
+ # Authenticate anonymously
92
+ irc_sock.send(f"PASS oauth:anonymous\r\n".encode("utf-8"))
93
+ irc_sock.send(f"NICK {anon_nick}\r\n".encode("utf-8"))
94
+
95
+ # Request tags and commands capability to see badges/sub status
96
+ irc_sock.send("CAP REQ :twitch.tv/tags twitch.tv/commands twitch.tv/membership\r\n".encode("utf-8"))
97
+
98
+ # Join target channel
99
+ irc_sock.send(f"JOIN #{TWITCH_CHANNEL}\r\n".encode("utf-8"))
100
+ print(f"[Twitch IRC] Joined channel #{TWITCH_CHANNEL}")
101
+
102
+ buffer = ""
103
+ irc_sock.setblocking(False)
104
+
105
+ while not stop_flag.is_set():
106
+ # Use select for non-blocking read with timeout to allow exit checks
107
+ ready = select.select([irc_sock], [], [], 1.0)
108
+ if not ready[0]:
109
+ continue
110
+
111
+ try:
112
+ data = irc_sock.recv(4096).decode("utf-8", errors="ignore")
113
+ except socket.timeout:
114
+ continue
115
+
116
+ if not data:
117
+ print("[Twitch IRC] Connection closed by remote host.")
118
+ break
119
+
120
+ buffer += data
121
+ while "\r\n" in buffer:
122
+ line, buffer = buffer.split("\r\n", 1)
123
+
124
+ # 1. Handle Ping-Pong
125
+ if line.startswith("PING"):
126
+ irc_sock.send("PONG :tmi.twitch.tv\r\n".encode("utf-8"))
127
+ continue
128
+
129
+ # 2. Parse PRIVMSG (chat message)
130
+ # Twitch format: @tags :user!user@user.tmi.twitch.tv PRIVMSG #channel :message
131
+ if "PRIVMSG" in line:
132
+ tags = {}
133
+ tags_str = ""
134
+
135
+ # Extract tags if they exist
136
+ if line.startswith("@"):
137
+ tags_str, remainder = line[1:].split(" ", 1)
138
+ tags = parse_irc_tags(tags_str)
139
+ line = remainder
140
+
141
+ parts = line.split(" PRIVMSG ", 1)
142
+ if len(parts) < 2:
143
+ continue
144
+
145
+ prefix, msg_parts = parts
146
+ user = prefix.split("!", 1)[0].replace(":", "")
147
+
148
+ channel_part, message_text = msg_parts.split(" :", 1)
149
+
150
+ # Extract user metadata
151
+ msg_id = tags.get("id", f"local-{time.time_ns()}")
152
+ display_name = tags.get("display-name", user)
153
+ badges = tags.get("badges", "")
154
+
155
+ is_streamer = (user.lower() == TWITCH_CHANNEL)
156
+ is_mod = "moderator" in badges or "broadcaster" in badges
157
+ is_sub = "subscriber" in badges or "founder" in badges
158
+
159
+ parsed_msg = {
160
+ "id": msg_id,
161
+ "username": user,
162
+ "displayName": display_name,
163
+ "message": message_text,
164
+ "timestamp": new_iso_timestamp(),
165
+ "isStreamer": is_streamer,
166
+ "isMod": is_mod,
167
+ "isSub": is_sub
168
+ }
169
+
170
+ # Add to queue
171
+ chat_queue.put(parsed_msg)
172
+
173
+ except Exception as e:
174
+ print(f"[Twitch IRC] Connection error: {e}. Retrying in 10 seconds...")
175
+ time.sleep(10)
176
+ finally:
177
+ try:
178
+ irc_sock.close()
179
+ except:
180
+ pass
181
+
182
+ def new_iso_timestamp():
183
+ return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
184
+
185
+ def chat_sender():
186
+ """Periodically sends accumulated chat messages to backend API"""
187
+ print("[Chat Sender] Started background queue sender.")
188
+ headers = {"x-api-key": API_KEY, "Content-Type": "application/json"}
189
+
190
+ while not stop_flag.is_set():
191
+ messages = []
192
+ # Drain the queue
193
+ while not chat_queue.empty():
194
+ try:
195
+ messages.append(chat_queue.get_nowait())
196
+ except queue.Empty:
197
+ break
198
+
199
+ if messages:
200
+ try:
201
+ url = f"{API_URL}/api/log/messages"
202
+ response = requests.post(url, json={"messages": messages}, headers=headers, timeout=5)
203
+ if response.status_code == 200:
204
+ pass # Success
205
+ else:
206
+ print(f"[Chat Sender] Failed to sync messages. API returned status {response.status_code}")
207
+ except Exception as e:
208
+ print(f"[Chat Sender] Network error sending messages: {e}")
209
+
210
+ # Wait 5 seconds before next sync
211
+ time.sleep(5)
212
+
213
+ # =========================================================================
214
+ # AUDIO TRANSCRIBER (WHISPER)
215
+ # =========================================================================
216
+
217
+ def init_whisper_model():
218
+ """Load Faster-Whisper model into memory"""
219
+ print(f"[Whisper] Loading model '{WHISPER_MODEL_SIZE}' on {WHISPER_DEVICE} ({WHISPER_COMPUTE_TYPE})...")
220
+ # This might take a few minutes on first run as the model downloads
221
+ model = WhisperModel(
222
+ WHISPER_MODEL_SIZE,
223
+ device=WHISPER_DEVICE,
224
+ compute_type=WHISPER_COMPUTE_TYPE
225
+ )
226
+ print("[Whisper] Model loaded successfully.")
227
+ return model
228
+
229
+ def transcribe_audio_segment(model, pcm_bytes):
230
+ """Run Whisper transcription on PCM 16kHz 16-bit mono audio bytes"""
231
+ if len(pcm_bytes) == 0:
232
+ return []
233
+
234
+ # Convert raw 16-bit PCM bytes to float32 numpy array normalized to [-1.0, 1.0]
235
+ audio_np = np.frombuffer(pcm_bytes, dtype=np.int16).astype(np.float32) / 32768.0
236
+
237
+ # Force language="ru" for better Russian accuracy and faster execution
238
+ segments, info = model.transcribe(
239
+ audio_np,
240
+ beam_size=5,
241
+ language="ru",
242
+ vad_filter=True, # Voice Activity Detection filters out silence
243
+ vad_parameters=dict(min_silence_duration_ms=500)
244
+ )
245
+
246
+ words_list = []
247
+ for segment in segments:
248
+ text = segment.text.strip()
249
+ if text:
250
+ print(f"[Whisper Speech] Transcribed: \"{text}\"")
251
+ # Split segment into clean words
252
+ words = text.split()
253
+ words_list.extend(words)
254
+
255
+ return words_list
256
+
257
+ def send_voice_words(words):
258
+ """Post transcribed words to backend server"""
259
+ if not words:
260
+ return
261
+
262
+ headers = {"x-api-key": API_KEY, "Content-Type": "application/json"}
263
+ url = f"{API_URL}/api/log/voice"
264
+
265
+ try:
266
+ response = requests.post(url, json={"words": words, "timestamp": new_iso_timestamp()}, headers=headers, timeout=5)
267
+ if response.status_code == 200:
268
+ print(f"[Sync] Sent {len(words)} spoken words to cloud.")
269
+ else:
270
+ print(f"[Sync] Failed to send words. Status code: {response.status_code}")
271
+ except Exception as e:
272
+ print(f"[Sync] Error sending voice words to cloud: {e}")
273
+
274
+ def notify_stream_end():
275
+ """Notify backend that the stream has ended"""
276
+ headers = {"x-api-key": API_KEY}
277
+ url = f"{API_URL}/api/log/stream-end"
278
+ try:
279
+ requests.post(url, headers=headers, timeout=5)
280
+ print("[Sync] Sent stream-end signal.")
281
+ except Exception as e:
282
+ print(f"[Sync] Failed to send stream-end: {e}")
283
+
284
+ # =========================================================================
285
+ # CAPTURE METHODS
286
+ # =========================================================================
287
+
288
+ def check_stream_live():
289
+ """Check if the Twitch channel is currently streaming using streamlink"""
290
+ try:
291
+ # Runs streamlink check without downloading stream
292
+ result = subprocess.run(
293
+ ["streamlink", f"twitch.tv/{TWITCH_CHANNEL}", "--stream-url"],
294
+ stdout=subprocess.PIPE,
295
+ stderr=subprocess.PIPE,
296
+ text=True
297
+ )
298
+ # If streamlink returns a stream URL, it's live!
299
+ return result.returncode == 0 and result.stdout.strip().startswith("http")
300
+ except FileNotFoundError:
301
+ print("[Error] 'streamlink' utility is not installed or not in system PATH! Please install it.")
302
+ time.sleep(10)
303
+ return False
304
+
305
+ def run_stream_capture(model):
306
+ """Capture Twitch stream audio using streamlink + ffmpeg and transcribe"""
307
+ print(f"[Capture] Monitoring Twitch channel '{TWITCH_CHANNEL}'...")
308
+
309
+ while not stop_flag.is_set():
310
+ if not check_stream_live():
311
+ print(f"[Capture] Channel '{TWITCH_CHANNEL}' is offline. Checking for pending VOD backfill tasks...")
312
+ try:
313
+ headers_get = {"x-api-key": API_KEY}
314
+ res = requests.get(f"{API_URL}/api/streams/pending-backfill", headers=headers_get, timeout=10)
315
+ if res.status_code == 200:
316
+ data = res.json()
317
+ pending_streams = data.get("streams", [])
318
+ if pending_streams:
319
+ print(f"[Backfill] Found {len(pending_streams)} pending VOD backfill(s). Starting automated processing...")
320
+
321
+ target = pending_streams[0]
322
+ stream_id = target.get("id")
323
+ vod_id = target.get("twitch_vod_id")
324
+ title = target.get("title", "Unknown Archive")
325
+ start_time = target.get("start_time")
326
+
327
+ min_msg_time = target.get("min_message_time")
328
+ max_msg_time = target.get("max_message_time")
329
+ min_voice_time = target.get("min_voice_time")
330
+ max_voice_time = target.get("max_voice_time")
331
+
332
+ # Parse ISO strings to epoch timestamps
333
+ def parse_time(t_str):
334
+ if not t_str:
335
+ return None
336
+ try:
337
+ clean_t = t_str.split(".")[0].replace("Z", "").replace("+00:00", "")
338
+ return time.mktime(time.strptime(clean_t, "%Y-%m-%dT%H:%M:%S"))
339
+ except Exception as parse_err:
340
+ print(f"[Backfill] Error parsing time '{t_str}': {parse_err}")
341
+ return None
342
+
343
+ start_epoch = parse_time(start_time)
344
+
345
+ # -- CHAT BACKFILL TASKS --
346
+ chat_tasks = []
347
+ if not min_msg_time:
348
+ # 0 messages, download everything
349
+ chat_tasks.append((0, None))
350
+ else:
351
+ min_msg_epoch = parse_time(min_msg_time)
352
+ max_msg_epoch = parse_time(max_msg_time)
353
+
354
+ min_msg_offset = int(min_msg_epoch - start_epoch) if (min_msg_epoch and start_epoch) else 0
355
+ max_msg_offset = int(max_msg_epoch - start_epoch) if (max_msg_epoch and start_epoch) else 0
356
+
357
+ # If first message is > 60s from start, download the beginning gap
358
+ if min_msg_offset > 60:
359
+ chat_tasks.append((0, min_msg_offset))
360
+ # Download from last message to end
361
+ chat_tasks.append((max_msg_offset, None))
362
+
363
+ # -- VOICE BACKFILL TASKS --
364
+ voice_tasks = []
365
+ if not min_voice_time:
366
+ # 0 words, transcribe everything
367
+ voice_tasks.append((0, None))
368
+ else:
369
+ min_voice_epoch = parse_time(min_voice_time)
370
+ max_voice_epoch = parse_time(max_voice_time)
371
+
372
+ min_voice_offset = int(min_voice_epoch - start_epoch) if (min_voice_epoch and start_epoch) else 0
373
+ max_voice_offset = int(max_voice_epoch - start_epoch) if (max_voice_epoch and start_epoch) else 0
374
+
375
+ # If first voice word is > 60s from start, transcribe the beginning gap
376
+ if min_voice_offset > 60:
377
+ voice_tasks.append((0, min_voice_offset))
378
+ # Transcribe from last word (with 10s safety overlap) to end
379
+ voice_tasks.append((max(0, max_voice_offset - 10), None))
380
+
381
+ print(f"\n=========================================================")
382
+ print(f"[Backfill] Processing: {title}")
383
+ print(f"[Backfill] VOD ID: {vod_id}")
384
+ print(f"[Backfill] Stream ID: {stream_id}")
385
+ print(f"[Backfill] Chat Tasks: {chat_tasks}")
386
+ print(f"[Backfill] Voice Tasks: {voice_tasks}")
387
+ print(f"=========================================================")
388
+
389
+ # 1. Download and upload chat segments
390
+ for start_off, end_off in chat_tasks:
391
+ print(f"[Backfill] Downloading chat from {start_off}s to {'end' if end_off is None else str(end_off) + 's'}...")
392
+ chat_comments = download_vod_chat(vod_id, start_offset=start_off, end_offset=end_off)
393
+ if chat_comments:
394
+ print(f"[Backfill] Uploading {len(chat_comments)} chat comments...")
395
+ batch_size = 100
396
+ headers_post = {"x-api-key": API_KEY, "Content-Type": "application/json"}
397
+ for i in range(0, len(chat_comments), batch_size):
398
+ batch = chat_comments[i:i+batch_size]
399
+ try:
400
+ requests.post(f"{API_URL}/api/log/messages", json={"messages": batch}, headers=headers_post, timeout=10)
401
+ except Exception as e:
402
+ print(f"[Backfill] Chat batch upload error: {e}")
403
+
404
+ # 2. Download and transcribe audio segments
405
+ print(f"[Backfill] Starting Whisper audio transcription...")
406
+ for start_off, end_off in voice_tasks:
407
+ print(f"[Backfill] Transcribing audio from {start_off}s to {'end' if end_off is None else str(end_off) + 's'}...")
408
+ try:
409
+ start_time_iso = start_time.replace("+00:00", "").replace("Z", "") + "Z"
410
+ transcribe_vod_audio(vod_id, start_time_iso, model, start_offset=start_off, end_offset=end_off)
411
+ except Exception as e:
412
+ print(f"[Backfill] Transcription error: {e}")
413
+
414
+ # 3. Mark completed
415
+ print(f"[Backfill] Marking stream {stream_id} as backfilled...")
416
+ try:
417
+ res_mark = requests.post(f"{API_URL}/api/streams/mark-backfilled", json={"streamId": stream_id}, headers=headers_get, timeout=10)
418
+ if res_mark.status_code == 200:
419
+ print(f"[Backfill] Successfully marked stream {stream_id} as backfilled!")
420
+ else:
421
+ print(f"[Backfill] Failed to mark stream: {res_mark.status_code} - {res_mark.text}")
422
+ except Exception as e:
423
+ print(f"[Backfill] Network error marking stream: {e}")
424
+
425
+ # Loop again immediately to process next VOD or check if live
426
+ continue
427
+ else:
428
+ print("[Capture] No pending VOD backfills found.")
429
+ else:
430
+ print(f"[Backfill] Failed to fetch pending backfills: {res.status_code}")
431
+ except Exception as err:
432
+ print(f"[Backfill] Error checking pending backfills: {err}")
433
+
434
+ print(f"[Capture] Retrying stream check in 60 seconds...")
435
+ time.sleep(60)
436
+ continue
437
+
438
+ print(f"[Capture] Stream is LIVE! Starting audio pipeline...")
439
+
440
+ # Start streamlink piping audio to ffmpeg, converting it to raw 16kHz mono 16-bit PCM
441
+ streamlink_cmd = ["streamlink", f"twitch.tv/{TWITCH_CHANNEL}", "audio,worst", "-O"]
442
+ ffmpeg_cmd = ["ffmpeg", "-i", "pipe:0", "-ac", "1", "-ar", "16000", "-f", "s16le", "-"]
443
+
444
+ p_streamlink = None
445
+ p_ffmpeg = None
446
+
447
+ try:
448
+ p_streamlink = subprocess.Popen(streamlink_cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
449
+ p_ffmpeg = subprocess.Popen(ffmpeg_cmd, stdin=p_streamlink.stdout, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
450
+
451
+ # Read in 10-second chunks
452
+ # 16000Hz * 16-bit(2 bytes) * 10 seconds = 320,000 bytes
453
+ chunk_size = 16000 * 2 * 10
454
+
455
+ while not stop_flag.is_set():
456
+ pcm_data = p_ffmpeg.stdout.read(chunk_size)
457
+ if not pcm_data:
458
+ print("[Capture] Stream audio stopped or disconnected.")
459
+ break
460
+
461
+ # Transcribe in a background thread to prevent blocking audio read
462
+ threading.Thread(
463
+ target=lambda data=pcm_data: send_voice_words(transcribe_audio_segment(model, data))
464
+ ).start()
465
+
466
+ except Exception as err:
467
+ print(f"[Capture] Audio pipeline crashed: {err}")
468
+ finally:
469
+ # Clean up processes
470
+ for p in [p_ffmpeg, p_streamlink]:
471
+ if p:
472
+ try:
473
+ p.terminate()
474
+ p.wait(timeout=2)
475
+ except:
476
+ try: p.kill()
477
+ except: pass
478
+
479
+ notify_stream_end()
480
+ print("[Capture] Pipeline stopped. Waiting 30 seconds before checking stream status...")
481
+ time.sleep(30)
482
+
483
+ def run_microphone_capture(model):
484
+ """Capture local microphone/system audio and transcribe"""
485
+ try:
486
+ import sounddevice as sd
487
+ except ImportError:
488
+ print("[Error] 'sounddevice' library is missing! Install it via pip install sounddevice.")
489
+ sys.exit(1)
490
+
491
+ print("[Capture] Starting microphone capture. Listening...")
492
+
493
+ sample_rate = 16000
494
+ duration = 10 # Transcribe every 10 seconds
495
+
496
+ while not stop_flag.is_set():
497
+ try:
498
+ # Record float32 directly from default input device
499
+ recording = sd.rec(
500
+ int(duration * sample_rate),
501
+ samplerate=sample_rate,
502
+ channels=1,
503
+ dtype='float32'
504
+ )
505
+ sd.wait() # Wait until recording is finished
506
+
507
+ # sounddevice records float32 directly, no need for raw conversion
508
+ audio_np = recording.flatten()
509
+
510
+ # Transcribe and send words
511
+ def process_mic(audio=audio_np):
512
+ segments, info = model.transcribe(audio, beam_size=5, language="ru")
513
+ words_list = []
514
+ for segment in segments:
515
+ text = segment.text.strip()
516
+ if text:
517
+ print(f"[Whisper Mic] Transcribed: \"{text}\"")
518
+ words_list.extend(text.split())
519
+ send_voice_words(words_list)
520
+
521
+ threading.Thread(target=process_mic).start()
522
+
523
+ except Exception as e:
524
+ print(f"[Capture] Microphone error: {e}")
525
+ time.sleep(5)
526
+
527
+ # =========================================================================
528
+ # MAIN EXECUTION
529
+ # =========================================================================
530
+
531
+ if __name__ == "__main__":
532
+ print("=========================================================")
533
+ print(" Twitch Analytics Local Logger & Voice Sync ")
534
+ print("=========================================================")
535
+ print(f"Target Channel: {TWITCH_CHANNEL}")
536
+ print(f"Capture Method: {CAPTURE_METHOD}")
537
+ print(f"Whisper Model: {WHISPER_MODEL_SIZE}")
538
+ print(f"API Backend URL: {API_URL}")
539
+ print("=========================================================")
540
+
541
+ # 1. Initialize Whisper
542
+ whisper_model = init_whisper_model()
543
+
544
+ # 2. Start Twitch Chat background threads
545
+ t_chat = threading.Thread(target=twitch_chat_listener, daemon=True)
546
+ t_sender = threading.Thread(target=chat_sender, daemon=True)
547
+
548
+ t_chat.start()
549
+ t_sender.start()
550
+
551
+ # 3. Start Audio Capture (Blocks main thread)
552
+ try:
553
+ if CAPTURE_METHOD == "stream":
554
+ run_stream_capture(whisper_model)
555
+ elif CAPTURE_METHOD == "microphone":
556
+ run_microphone_capture(whisper_model)
557
+ else:
558
+ print(f"[Error] Unknown CAPTURE_METHOD '{CAPTURE_METHOD}'. Choose 'stream' or 'microphone'.")
559
+ except KeyboardInterrupt:
560
+ print("\n[Shutting Down] Gracefully stopping threads...")
561
+ stop_flag.set()
562
+ time.sleep(1)
563
+ print("[Shutting Down] Done.")
server/.env.example ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Server configuration
2
+ PORT=3000
3
+ SESSION_SECRET=a_very_strong_random_session_secret_key_12345
4
+
5
+ # Supabase configuration
6
+ SUPABASE_URL=https://your-supabase-project.supabase.co
7
+ # It's recommended to use the service role key to write data from backend
8
+ SUPABASE_KEY=your-supabase-service-role-key-or-anon-key
9
+
10
+ # Twitch Developer configuration
11
+ # Register your app at: https://dev.twitch.tv
12
+ TWITCH_CLIENT_ID=your-twitch-client-id
13
+ TWITCH_CLIENT_SECRET=your-twitch-client-secret
14
+ # OAuth Redirect URI. Must match what is registered in the Twitch Dev Console.
15
+ # Local development: http://localhost:3000/api/auth/twitch/callback
16
+ # Production: https://your-render-app.onrender.com/api/auth/twitch/callback
17
+ TWITCH_REDIRECT_URI=http://localhost:3000/api/auth/twitch/callback
18
+
19
+ # Target Streamer Details
20
+ TWITCH_CHANNEL=winx_prinx
21
+ # Comma-separated list of Twitch usernames that get the 'admin' role
22
+ ADMIN_USERNAMES=crimbr1243
23
+
24
+ # Security Key for Local Worker
25
+ # The Local Worker must send this key in the 'x-api-key' header when posting logs
26
+ API_KEY=a_long_secure_token_for_local_worker_to_server_sync_98765
server/Dockerfile ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM node:20
2
+
3
+ WORKDIR /app
4
+
5
+ # Copy package.json and package-lock.json first to cache layers
6
+ COPY package*.json ./
7
+
8
+ # Install only production dependencies (ignoring devDependencies)
9
+ RUN npm ci --omit=dev
10
+
11
+ # Copy all application files
12
+ COPY . .
13
+
14
+ # Expose port 3000
15
+ EXPOSE 3000
16
+
17
+ # Set environment to production
18
+ ENV NODE_ENV=production
19
+
20
+ # Run start command
21
+ CMD ["node", "server.js"]
server/db.js ADDED
@@ -0,0 +1,1404 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createClient } from '@supabase/supabase-js';
2
+ import Database from 'better-sqlite3';
3
+ import dotenv from 'dotenv';
4
+ import path from 'path';
5
+ import fs from 'fs';
6
+
7
+ dotenv.config();
8
+
9
+ // Detect mode
10
+ const supabaseUrl = process.env.SUPABASE_URL;
11
+ const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_KEY;
12
+
13
+ // We use Supabase if keys are provided and don't look like default placeholders
14
+ const useSupabase = supabaseUrl &&
15
+ supabaseKey &&
16
+ !supabaseUrl.includes('YOUR_PROJECT_ID') &&
17
+ !supabaseKey.includes('your-supabase');
18
+
19
+ export const dbMode = useSupabase ? 'supabase' : 'sqlite';
20
+
21
+ console.log(`[Database] Initializing in Mode: ${dbMode.toUpperCase()}`);
22
+
23
+ // =========================================================================
24
+ // SQLITE SETUP (Local development fallback)
25
+ // =========================================================================
26
+ let sqliteDb = null;
27
+
28
+ if (dbMode === 'sqlite') {
29
+ const dbPath = path.resolve('database.db');
30
+ console.log(`[Database] SQLite file location: ${dbPath}`);
31
+ sqliteDb = new Database(dbPath);
32
+
33
+ // Create tables in SQLite if they don't exist
34
+ sqliteDb.exec(`
35
+ CREATE TABLE IF NOT EXISTS streams (
36
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
37
+ start_time TEXT DEFAULT CURRENT_TIMESTAMP,
38
+ end_time TEXT,
39
+ title TEXT,
40
+ twitch_stream_id TEXT UNIQUE,
41
+ category TEXT,
42
+ backfill_status TEXT DEFAULT 'pending',
43
+ twitch_vod_id TEXT
44
+ );
45
+
46
+ CREATE TABLE IF NOT EXISTS messages (
47
+ id TEXT PRIMARY KEY,
48
+ stream_id INTEGER,
49
+ username TEXT NOT NULL,
50
+ display_name TEXT,
51
+ message TEXT NOT NULL,
52
+ timestamp TEXT DEFAULT CURRENT_TIMESTAMP,
53
+ is_streamer INTEGER DEFAULT 0,
54
+ is_mod INTEGER DEFAULT 0,
55
+ is_sub INTEGER DEFAULT 0
56
+ );
57
+
58
+ CREATE INDEX IF NOT EXISTS idx_messages_stream_id ON messages(stream_id);
59
+ CREATE INDEX IF NOT EXISTS idx_messages_username ON messages(username);
60
+ CREATE INDEX IF NOT EXISTS idx_messages_timestamp ON messages(timestamp);
61
+
62
+ CREATE TABLE IF NOT EXISTS voice_words (
63
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
64
+ stream_id INTEGER,
65
+ word TEXT NOT NULL,
66
+ timestamp TEXT DEFAULT CURRENT_TIMESTAMP
67
+ );
68
+
69
+ CREATE INDEX IF NOT EXISTS idx_voice_words_stream_id ON voice_words(stream_id);
70
+ CREATE INDEX IF NOT EXISTS idx_voice_words_word ON voice_words(word);
71
+
72
+ CREATE TABLE IF NOT EXISTS mod_actions (
73
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
74
+ stream_id INTEGER,
75
+ action_type TEXT NOT NULL,
76
+ moderator TEXT NOT NULL,
77
+ target_user TEXT,
78
+ duration INTEGER,
79
+ reason TEXT,
80
+ message_text TEXT,
81
+ timestamp TEXT DEFAULT CURRENT_TIMESTAMP
82
+ );
83
+
84
+ CREATE INDEX IF NOT EXISTS idx_mod_actions_stream_id ON mod_actions(stream_id);
85
+ CREATE INDEX IF NOT EXISTS idx_mod_actions_timestamp ON mod_actions(timestamp);
86
+
87
+ CREATE TABLE IF NOT EXISTS settings (
88
+ key TEXT PRIMARY KEY,
89
+ value TEXT NOT NULL
90
+ );
91
+ `);
92
+ console.log('[Database] SQLite Tables & Indexes initialized.');
93
+
94
+ // Migrations for SQLite
95
+ try {
96
+ const columnsStreams = sqliteDb.prepare("PRAGMA table_info(streams)").all();
97
+ if (!columnsStreams.some(c => c.name === 'twitch_stream_id')) {
98
+ sqliteDb.exec("ALTER TABLE streams ADD COLUMN twitch_stream_id TEXT");
99
+ try {
100
+ sqliteDb.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_streams_twitch_stream_id ON streams(twitch_stream_id)");
101
+ } catch (idxErr) {
102
+ // ignore
103
+ }
104
+ console.log("[Database] SQLite Migrated: Added twitch_stream_id to streams");
105
+ }
106
+ if (!columnsStreams.some(c => c.name === 'category')) {
107
+ sqliteDb.exec("ALTER TABLE streams ADD COLUMN category TEXT");
108
+ console.log("[Database] SQLite Migrated: Added category to streams");
109
+ }
110
+ if (!columnsStreams.some(c => c.name === 'backfill_status')) {
111
+ sqliteDb.exec("ALTER TABLE streams ADD COLUMN backfill_status TEXT DEFAULT 'pending'");
112
+ console.log("[Database] SQLite Migrated: Added backfill_status to streams");
113
+ }
114
+ if (!columnsStreams.some(c => c.name === 'twitch_vod_id')) {
115
+ sqliteDb.exec("ALTER TABLE streams ADD COLUMN twitch_vod_id TEXT");
116
+ console.log("[Database] SQLite Migrated: Added twitch_vod_id to streams");
117
+ }
118
+
119
+ const columnsModActions = sqliteDb.prepare("PRAGMA table_info(mod_actions)").all();
120
+ if (!columnsModActions.some(c => c.name === 'reaction_time')) {
121
+ sqliteDb.exec("ALTER TABLE mod_actions ADD COLUMN reaction_time REAL");
122
+ console.log("[Database] SQLite Migrated: Added reaction_time to mod_actions");
123
+ }
124
+ } catch (err) {
125
+ console.error("[Database] SQLite Migration error:", err.message);
126
+ }
127
+ }
128
+
129
+ // =========================================================================
130
+ // SUPABASE CLIENT (Cloud production mode)
131
+ // =========================================================================
132
+ export const supabase = useSupabase ? createClient(supabaseUrl, supabaseKey) : null;
133
+
134
+ // Gaps larger than 2 hours (in ms) will trigger a new stream session automatically
135
+ const STREAM_SESSION_GAP = 2 * 60 * 60 * 1000;
136
+ let activeStreamCache = null;
137
+ let lastMessageTime = null;
138
+
139
+ // =========================================================================
140
+ // HELPER METHODS (Dually implemented for SQLite & Supabase)
141
+ // =========================================================================
142
+
143
+ /**
144
+ * Get active stream or auto-start a new one
145
+ */
146
+ export async function getOrStartActiveStream() {
147
+ const now = new Date();
148
+
149
+ // 1. Check cache first
150
+ if (activeStreamCache) {
151
+ if (lastMessageTime && (now - lastMessageTime < STREAM_SESSION_GAP)) {
152
+ lastMessageTime = now;
153
+ return activeStreamCache;
154
+ } else if (lastMessageTime) {
155
+ console.log('[Database] Stream session gap exceeded. Closing old stream.');
156
+ await endActiveStream(activeStreamCache.id);
157
+ activeStreamCache = null;
158
+ }
159
+ }
160
+
161
+ // 2. Fetch from DB
162
+ if (dbMode === 'sqlite') {
163
+ const row = sqliteDb.prepare('SELECT * FROM streams WHERE end_time IS NULL ORDER BY start_time DESC LIMIT 1').get();
164
+ if (row) {
165
+ activeStreamCache = row;
166
+ lastMessageTime = now;
167
+ return activeStreamCache;
168
+ }
169
+
170
+ // Auto-create new stream in SQLite
171
+ console.log('[Database] No active stream found. Auto-starting new stream in SQLite.');
172
+ const title = `Stream ${now.toLocaleDateString('ru-RU')} - ${now.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}`;
173
+ const info = sqliteDb.prepare("INSERT INTO streams (title, start_time, backfill_status) VALUES (?, ?, 'live')").run(title, now.toISOString());
174
+
175
+ activeStreamCache = {
176
+ id: info.lastInsertRowid,
177
+ title,
178
+ start_time: now.toISOString(),
179
+ end_time: null,
180
+ twitch_stream_id: null,
181
+ category: null,
182
+ backfill_status: 'live'
183
+ };
184
+ lastMessageTime = now;
185
+ return activeStreamCache;
186
+
187
+ } else {
188
+ // Supabase Mode
189
+ const { data: activeStreams, error } = await supabase
190
+ .from('streams')
191
+ .select('*')
192
+ .is('end_time', null)
193
+ .order('start_time', { ascending: false })
194
+ .limit(1);
195
+
196
+ if (error) {
197
+ console.error('[Supabase] Error fetching active stream:', error);
198
+ return null;
199
+ }
200
+
201
+ if (activeStreams && activeStreams.length > 0) {
202
+ activeStreamCache = activeStreams[0];
203
+ lastMessageTime = now;
204
+ return activeStreamCache;
205
+ }
206
+
207
+ // Auto-create new stream in Supabase
208
+ console.log('[Database] No active stream found. Auto-starting new stream in Supabase.');
209
+ const title = `Stream ${now.toLocaleDateString('ru-RU')} - ${now.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}`;
210
+ const { data: newStream, error: createError } = await supabase
211
+ .from('streams')
212
+ .insert([{ title, start_time: now.toISOString(), backfill_status: 'live' }])
213
+ .select()
214
+ .single();
215
+
216
+ if (createError) {
217
+ console.error('[Supabase] Error starting stream:', createError);
218
+ return null;
219
+ }
220
+
221
+ activeStreamCache = newStream;
222
+ lastMessageTime = now;
223
+ return activeStreamCache;
224
+ }
225
+ }
226
+
227
+ /**
228
+ * Get the current active stream without starting a new one
229
+ */
230
+ export async function getActiveStream() {
231
+ if (activeStreamCache) {
232
+ return activeStreamCache;
233
+ }
234
+
235
+ if (dbMode === 'sqlite') {
236
+ const row = sqliteDb.prepare('SELECT * FROM streams WHERE end_time IS NULL ORDER BY start_time DESC LIMIT 1').get();
237
+ if (row) {
238
+ activeStreamCache = row;
239
+ }
240
+ return row || null;
241
+ } else {
242
+ const { data: activeStreams, error } = await supabase
243
+ .from('streams')
244
+ .select('*')
245
+ .is('end_time', null)
246
+ .order('start_time', { ascending: false })
247
+ .limit(1);
248
+
249
+ if (error) {
250
+ console.error('[Supabase] Error fetching active stream:', error);
251
+ return null;
252
+ }
253
+
254
+ if (activeStreams && activeStreams.length > 0) {
255
+ activeStreamCache = activeStreams[0];
256
+ return activeStreamCache;
257
+ }
258
+ return null;
259
+ }
260
+ }
261
+
262
+
263
+ /**
264
+ * Check if a stream with given twitch_stream_id already exists in the database
265
+ */
266
+ export async function checkIfStreamExists(twitchStreamId) {
267
+ if (dbMode === 'sqlite') {
268
+ const row = sqliteDb.prepare('SELECT id FROM streams WHERE twitch_stream_id = ?').get(twitchStreamId);
269
+ return !!row;
270
+ } else {
271
+ const { data, error } = await supabase
272
+ .from('streams')
273
+ .select('id')
274
+ .eq('twitch_stream_id', twitchStreamId)
275
+ .limit(1);
276
+ if (error) {
277
+ console.error('[Supabase] Error checking if stream exists:', error);
278
+ return false;
279
+ }
280
+ return data && data.length > 0;
281
+ }
282
+ }
283
+
284
+ /**
285
+ * Sync active stream session with Twitch API data (status, category, started_at)
286
+ */
287
+ export async function syncActiveStream(twitchStreamId, title, category, startTimeIso) {
288
+ if (dbMode === 'sqlite') {
289
+ // Check if stream with this twitch_stream_id exists
290
+ let stream = sqliteDb.prepare('SELECT * FROM streams WHERE twitch_stream_id = ?').get(twitchStreamId);
291
+ if (stream) {
292
+ if (stream.end_time) {
293
+ sqliteDb.prepare("UPDATE streams SET end_time = NULL, category = ?, title = ?, backfill_status = 'live' WHERE id = ?").run(category, title, stream.id);
294
+ stream.end_time = null;
295
+ stream.category = category;
296
+ stream.title = title;
297
+ } else {
298
+ sqliteDb.prepare("UPDATE streams SET category = ?, title = ?, backfill_status = 'live' WHERE id = ?").run(category, title, stream.id);
299
+ stream.category = category;
300
+ stream.title = title;
301
+ }
302
+ activeStreamCache = stream;
303
+ lastMessageTime = new Date();
304
+ return stream;
305
+ }
306
+
307
+ // If it doesn't exist, check if there is an active stream with no twitch_stream_id
308
+ let activeStream = sqliteDb.prepare('SELECT * FROM streams WHERE end_time IS NULL AND twitch_stream_id IS NULL ORDER BY start_time DESC LIMIT 1').get();
309
+ if (activeStream) {
310
+ sqliteDb.prepare("UPDATE streams SET twitch_stream_id = ?, category = ?, title = ?, backfill_status = 'live' WHERE id = ?").run(twitchStreamId, category, title, activeStream.id);
311
+ activeStream.twitch_stream_id = twitchStreamId;
312
+ activeStream.category = category;
313
+ activeStream.title = title;
314
+ activeStreamCache = activeStream;
315
+ lastMessageTime = new Date();
316
+ return activeStream;
317
+ }
318
+
319
+ // Otherwise, create a new stream session
320
+ const info = sqliteDb.prepare("INSERT INTO streams (twitch_stream_id, title, category, start_time, backfill_status) VALUES (?, ?, ?, ?, 'live')").run(twitchStreamId, title, category, startTimeIso || new Date().toISOString());
321
+ activeStreamCache = {
322
+ id: info.lastInsertRowid,
323
+ twitch_stream_id: twitchStreamId,
324
+ title,
325
+ category,
326
+ start_time: startTimeIso || new Date().toISOString(),
327
+ end_time: null,
328
+ backfill_status: 'live'
329
+ };
330
+ lastMessageTime = new Date();
331
+ return activeStreamCache;
332
+ } else {
333
+ // Supabase Mode
334
+ let { data: existingStreams, error: fetchErr } = await supabase
335
+ .from('streams')
336
+ .select('*')
337
+ .eq('twitch_stream_id', twitchStreamId)
338
+ .limit(1);
339
+
340
+ if (existingStreams && existingStreams.length > 0) {
341
+ let stream = existingStreams[0];
342
+ if (stream.end_time) {
343
+ const { data: updated, error } = await supabase
344
+ .from('streams')
345
+ .update({ end_time: null, category, title, backfill_status: 'live' })
346
+ .eq('id', stream.id)
347
+ .select()
348
+ .single();
349
+ if (!error) stream = updated;
350
+ } else {
351
+ const { data: updated, error } = await supabase
352
+ .from('streams')
353
+ .update({ category, title, backfill_status: 'live' })
354
+ .eq('id', stream.id)
355
+ .select()
356
+ .single();
357
+ if (!error) stream = updated;
358
+ }
359
+ activeStreamCache = stream;
360
+ lastMessageTime = new Date();
361
+ return stream;
362
+ }
363
+
364
+ let { data: activeStreams } = await supabase
365
+ .from('streams')
366
+ .select('*')
367
+ .is('end_time', null)
368
+ .is('twitch_stream_id', null)
369
+ .order('start_time', { ascending: false })
370
+ .limit(1);
371
+
372
+ if (activeStreams && activeStreams.length > 0) {
373
+ let activeStream = activeStreams[0];
374
+ const { data: updated, error } = await supabase
375
+ .from('streams')
376
+ .update({ twitch_stream_id: twitchStreamId, category, title, backfill_status: 'live' })
377
+ .eq('id', activeStream.id)
378
+ .select()
379
+ .single();
380
+ if (!error) activeStream = updated;
381
+ activeStreamCache = activeStream;
382
+ lastMessageTime = new Date();
383
+ return activeStream;
384
+ }
385
+
386
+ const { data: newStream, error: insertErr } = await supabase
387
+ .from('streams')
388
+ .insert([{ twitch_stream_id: twitchStreamId, title, category, start_time: startTimeIso || new Date().toISOString(), backfill_status: 'live' }])
389
+ .select()
390
+ .single();
391
+
392
+ if (insertErr) {
393
+ console.error('[Supabase] Error syncing stream insertion:', insertErr);
394
+ return null;
395
+ }
396
+
397
+ activeStreamCache = newStream;
398
+ lastMessageTime = new Date();
399
+ return activeStreamCache;
400
+ }
401
+ }
402
+
403
+ /**
404
+ * End an active stream
405
+ */
406
+ export async function endActiveStream(streamId = null, endTime = null, twitchVodId = null) {
407
+ const idToClose = streamId || (activeStreamCache ? activeStreamCache.id : null);
408
+ if (!idToClose) return;
409
+
410
+ const endTimeIso = endTime || new Date().toISOString();
411
+
412
+ if (dbMode === 'sqlite') {
413
+ if (twitchVodId) {
414
+ sqliteDb.prepare("UPDATE streams SET end_time = ?, backfill_status = 'pending', twitch_vod_id = ? WHERE id = ?").run(endTimeIso, twitchVodId, idToClose);
415
+ } else {
416
+ sqliteDb.prepare("UPDATE streams SET end_time = ?, backfill_status = 'pending' WHERE id = ?").run(endTimeIso, idToClose);
417
+ }
418
+ console.log(`[Database] SQLite Stream ID ${idToClose} ended. VOD: ${twitchVodId || 'None'}`);
419
+ } else {
420
+ const updateData = { end_time: endTimeIso, backfill_status: 'pending' };
421
+ if (twitchVodId) updateData.twitch_vod_id = twitchVodId;
422
+
423
+ const { error } = await supabase
424
+ .from('streams')
425
+ .update(updateData)
426
+ .eq('id', idToClose);
427
+ if (error) {
428
+ console.error('[Supabase] Error ending stream:', error);
429
+ } else {
430
+ console.log(`[Database] Supabase Stream ID ${idToClose} ended. VOD: ${twitchVodId || 'None'}`);
431
+ }
432
+ }
433
+
434
+ if (activeStreamCache && activeStreamCache.id === idToClose) {
435
+ activeStreamCache = null;
436
+ lastMessageTime = null;
437
+ }
438
+ }
439
+
440
+ /**
441
+ * Find stream active at a specific timestamp
442
+ */
443
+ export async function getStreamAtTimestamp(timestampIso) {
444
+ const time = new Date(timestampIso);
445
+ if (dbMode === 'sqlite') {
446
+ const stream = sqliteDb.prepare(`
447
+ SELECT * FROM streams
448
+ WHERE datetime(start_time) <= datetime(?)
449
+ AND (end_time IS NULL OR datetime(end_time) >= datetime(?))
450
+ ORDER BY start_time DESC LIMIT 1
451
+ `).get(timestampIso, timestampIso);
452
+ return stream || null;
453
+ } else {
454
+ const { data, error } = await supabase
455
+ .from('streams')
456
+ .select('*')
457
+ .lte('start_time', timestampIso)
458
+ .order('start_time', { ascending: false });
459
+
460
+ if (error) {
461
+ console.error('[Supabase] Error finding stream at timestamp:', error);
462
+ return null;
463
+ }
464
+ const stream = data.find(s => !s.end_time || new Date(s.end_time) >= time);
465
+ return stream || null;
466
+ }
467
+ }
468
+
469
+ /**
470
+ * Log chat message
471
+ */
472
+ export async function logChatMessage(msg) {
473
+ const time = msg.timestamp || new Date().toISOString();
474
+ let stream = null;
475
+ if (msg.timestamp) {
476
+ stream = await getStreamAtTimestamp(msg.timestamp);
477
+ }
478
+ if (!stream) {
479
+ stream = await getActiveStream();
480
+ }
481
+ const streamId = stream ? stream.id : null;
482
+
483
+ if (dbMode === 'sqlite') {
484
+ try {
485
+ sqliteDb.prepare(`
486
+ INSERT INTO messages (id, stream_id, username, display_name, message, timestamp, is_streamer, is_mod, is_sub)
487
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
488
+ `).run(
489
+ msg.id,
490
+ streamId,
491
+ msg.username.toLowerCase(),
492
+ msg.displayName || msg.username,
493
+ msg.message,
494
+ msg.timestamp || new Date().toISOString(),
495
+ msg.isStreamer ? 1 : 0,
496
+ msg.isMod ? 1 : 0,
497
+ msg.isSub ? 1 : 0
498
+ );
499
+ } catch (err) {
500
+ // Ignore duplicate keys
501
+ if (!err.message.includes('UNIQUE constraint failed')) {
502
+ console.error('[Database] SQLite Error saving message:', err.message);
503
+ }
504
+ }
505
+
506
+ // Update stream_viewers table
507
+ try {
508
+ sqliteDb.prepare(`
509
+ INSERT INTO stream_viewers (stream_id, username, display_name, has_chatted, is_mod, is_sub, first_seen)
510
+ VALUES (?, ?, ?, 1, ?, ?, ?)
511
+ ON CONFLICT(stream_id, username) DO UPDATE SET
512
+ has_chatted = 1,
513
+ is_mod = excluded.is_mod,
514
+ is_sub = excluded.is_sub
515
+ `).run(
516
+ streamId,
517
+ msg.username.toLowerCase(),
518
+ msg.displayName || msg.username,
519
+ msg.isMod ? 1 : 0,
520
+ msg.isSub ? 1 : 0,
521
+ msg.timestamp || new Date().toISOString()
522
+ );
523
+ } catch (err) {
524
+ console.error('[Database] SQLite Error updating stream_viewers:', err.message);
525
+ }
526
+ } else {
527
+ const { error } = await supabase
528
+ .from('messages')
529
+ .insert([{
530
+ id: msg.id,
531
+ stream_id: streamId,
532
+ username: msg.username.toLowerCase(),
533
+ display_name: msg.displayName || msg.username,
534
+ message: msg.message,
535
+ timestamp: msg.timestamp || new Date().toISOString(),
536
+ is_streamer: msg.isStreamer || false,
537
+ is_mod: msg.isMod || false,
538
+ is_sub: msg.isSub || false
539
+ }]);
540
+
541
+ if (error && error.code !== '23505') {
542
+ console.error('[Supabase] Error logging message:', error);
543
+ }
544
+
545
+ // Update stream_viewers
546
+ const { error: viewerError } = await supabase
547
+ .from('stream_viewers')
548
+ .upsert({
549
+ stream_id: streamId,
550
+ username: msg.username.toLowerCase(),
551
+ display_name: msg.displayName || msg.username,
552
+ has_chatted: true,
553
+ is_mod: msg.isMod || false,
554
+ is_sub: msg.isSub || false,
555
+ first_seen: msg.timestamp || new Date().toISOString()
556
+ }, { onConflict: 'stream_id, username' });
557
+
558
+ if (viewerError) {
559
+ console.error('[Supabase] Error updating stream_viewers:', viewerError);
560
+ }
561
+ }
562
+ }
563
+
564
+ /**
565
+ * Log voice words (bulk insert)
566
+ */
567
+ export async function logVoiceWords(words, timestamp) {
568
+ if (!words || words.length === 0) return;
569
+ const time = timestamp || new Date().toISOString();
570
+ let stream = null;
571
+ if (timestamp) {
572
+ stream = await getStreamAtTimestamp(timestamp);
573
+ }
574
+ if (!stream) {
575
+ stream = await getActiveStream();
576
+ }
577
+ const clientId = process.env.TWITCH_CLIENT_ID;
578
+ const clientSecret = process.env.TWITCH_CLIENT_SECRET;
579
+ const twitchConfigured = !!(clientId && clientSecret && !clientId.includes('YOUR_') && !clientSecret.includes('YOUR_'));
580
+ if (!stream && !twitchConfigured) {
581
+ stream = await getOrStartActiveStream();
582
+ }
583
+ const streamId = stream ? stream.id : null;
584
+
585
+ const cleanWords = words.map(w => w.toLowerCase().trim().replace(/[.,\/#!$%\^&\*;:{}=\-_`~()?]/g,"")).filter(w => w.length > 0);
586
+ if (cleanWords.length === 0) return;
587
+
588
+ if (dbMode === 'sqlite') {
589
+ const insert = sqliteDb.prepare('INSERT INTO voice_words (stream_id, word, timestamp) VALUES (?, ?, ?)');
590
+ const transaction = sqliteDb.transaction((recs) => {
591
+ for (const word of recs) {
592
+ insert.run(streamId, word, time);
593
+ }
594
+ });
595
+ transaction(cleanWords);
596
+ } else {
597
+ const records = cleanWords.map(word => ({
598
+ stream_id: streamId,
599
+ word,
600
+ timestamp: time
601
+ }));
602
+ const { error } = await supabase.from('voice_words').insert(records);
603
+ if (error) {
604
+ console.error('[Supabase] Error logging voice words:', error);
605
+ }
606
+ }
607
+ }
608
+
609
+ /**
610
+ * Log moderator actions
611
+ */
612
+ export async function logModAction(action) {
613
+ const time = action.timestamp || new Date().toISOString();
614
+ let stream = null;
615
+ if (action.timestamp) {
616
+ stream = await getStreamAtTimestamp(action.timestamp);
617
+ }
618
+ if (!stream) {
619
+ stream = await getActiveStream();
620
+ }
621
+ const streamId = stream ? stream.id : null;
622
+
623
+ // Prevent duplicate actions (e.g., from both EventSub and local IRC worker running together)
624
+ if (action.targetUser) {
625
+ const targetUserLower = action.targetUser.toLowerCase();
626
+ if (dbMode === 'sqlite') {
627
+ const existing = sqliteDb.prepare(`
628
+ SELECT id FROM mod_actions
629
+ WHERE action_type = ?
630
+ AND target_user = ?
631
+ AND abs(strftime('%s', timestamp) - strftime('%s', ?)) < 15
632
+ `).get(action.actionType, targetUserLower, time);
633
+ if (existing) {
634
+ console.log(`[Database] Duplicate SQLite mod action ${action.actionType} for ${targetUserLower} ignored.`);
635
+ return;
636
+ }
637
+ } else {
638
+ const timeMs = new Date(time).getTime();
639
+ const startTimeRange = new Date(timeMs - 15000).toISOString();
640
+ const endTimeRange = new Date(timeMs + 15000).toISOString();
641
+
642
+ const { data: existing, error } = await supabase
643
+ .from('mod_actions')
644
+ .select('id')
645
+ .eq('action_type', action.actionType)
646
+ .eq('target_user', targetUserLower)
647
+ .gte('timestamp', startTimeRange)
648
+ .lte('timestamp', endTimeRange)
649
+ .limit(1);
650
+
651
+ if (!error && existing && existing.length > 0) {
652
+ console.log(`[Database] Duplicate Supabase mod action ${action.actionType} for ${targetUserLower} ignored.`);
653
+ return;
654
+ }
655
+ }
656
+ }
657
+
658
+ // Try to auto-calculate reaction time if target user and message exist
659
+ let reactionTime = null;
660
+ if (action.targetUser) {
661
+ const targetUserLower = action.targetUser.toLowerCase();
662
+ let originalMsg = null;
663
+ try {
664
+ if (action.actionType === 'delete' && action.messageText) {
665
+ if (dbMode === 'sqlite') {
666
+ originalMsg = sqliteDb.prepare('SELECT timestamp FROM messages WHERE username = ? AND message = ? ORDER BY timestamp DESC LIMIT 1').get(targetUserLower, action.messageText);
667
+ } else {
668
+ const { data } = await supabase
669
+ .from('messages')
670
+ .select('timestamp')
671
+ .eq('username', targetUserLower)
672
+ .eq('message', action.messageText)
673
+ .order('timestamp', { ascending: false })
674
+ .limit(1);
675
+ if (data && data.length > 0) originalMsg = data[0];
676
+ }
677
+ } else {
678
+ if (dbMode === 'sqlite') {
679
+ originalMsg = sqliteDb.prepare('SELECT timestamp FROM messages WHERE username = ? ORDER BY timestamp DESC LIMIT 1').get(targetUserLower);
680
+ } else {
681
+ const { data } = await supabase
682
+ .from('messages')
683
+ .select('timestamp')
684
+ .eq('username', targetUserLower)
685
+ .order('timestamp', { ascending: false })
686
+ .limit(1);
687
+ if (data && data.length > 0) originalMsg = data[0];
688
+ }
689
+ }
690
+
691
+ if (originalMsg) {
692
+ const origTime = new Date(originalMsg.timestamp);
693
+ const actionTime = new Date(time);
694
+ const diffSec = (actionTime - origTime) / 1000;
695
+ if (diffSec >= 0 && diffSec < 1800) { // must be positive and within 30 minutes
696
+ reactionTime = diffSec;
697
+ }
698
+ }
699
+ } catch (e) {
700
+ console.error('[Database] Failed to calculate reaction time:', e.message);
701
+ }
702
+ }
703
+
704
+ if (dbMode === 'sqlite') {
705
+ sqliteDb.prepare(`
706
+ INSERT INTO mod_actions (stream_id, action_type, moderator, target_user, duration, reason, message_text, timestamp, reaction_time)
707
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
708
+ `).run(
709
+ streamId,
710
+ action.actionType,
711
+ action.moderator,
712
+ action.targetUser ? action.targetUser.toLowerCase() : null,
713
+ action.duration || null,
714
+ action.reason || null,
715
+ action.messageText || null,
716
+ time,
717
+ reactionTime
718
+ );
719
+ } else {
720
+ const { error } = await supabase
721
+ .from('mod_actions')
722
+ .insert([{
723
+ stream_id: streamId,
724
+ action_type: action.actionType,
725
+ moderator: action.moderator,
726
+ target_user: action.targetUser ? action.targetUser.toLowerCase() : null,
727
+ duration: action.duration || null,
728
+ reason: action.reason || null,
729
+ message_text: action.messageText || null,
730
+ timestamp: time,
731
+ reaction_time: reactionTime
732
+ }]);
733
+ if (error) {
734
+ console.error('[Supabase] Error logging mod action:', error);
735
+ }
736
+ }
737
+ }
738
+
739
+ /**
740
+ * Get list of all streams
741
+ */
742
+ export async function getStreamsList() {
743
+ if (dbMode === 'sqlite') {
744
+ return sqliteDb.prepare('SELECT * FROM streams ORDER BY start_time DESC').all();
745
+ } else {
746
+ const { data, error } = await supabase
747
+ .from('streams')
748
+ .select('*')
749
+ .order('start_time', { ascending: false });
750
+ if (error) {
751
+ console.error('[Supabase] Error getting streams:', error);
752
+ return [];
753
+ }
754
+ return data || [];
755
+ }
756
+ }
757
+
758
+ /**
759
+ * Get top active chatters
760
+ */
761
+ export async function getTopChatters(streamId = null, limit = 50) {
762
+ if (dbMode === 'sqlite') {
763
+ let stmt;
764
+ if (streamId) {
765
+ stmt = sqliteDb.prepare(`
766
+ SELECT
767
+ v.username,
768
+ max(v.display_name) as display_name,
769
+ max(v.is_mod) as is_mod,
770
+ max(v.is_sub) as is_sub,
771
+ max(v.has_chatted) as has_chatted,
772
+ (SELECT COUNT(*) FROM messages m WHERE m.stream_id = v.stream_id AND m.username = v.username) as message_count
773
+ FROM stream_viewers v
774
+ WHERE v.stream_id = ?
775
+ GROUP BY v.username
776
+ ORDER BY message_count DESC
777
+ LIMIT ?
778
+ `);
779
+ return stmt.all(streamId, limit);
780
+ } else {
781
+ stmt = sqliteDb.prepare(`
782
+ SELECT
783
+ v.username,
784
+ max(v.display_name) as display_name,
785
+ max(v.is_mod) as is_mod,
786
+ max(v.is_sub) as is_sub,
787
+ max(v.has_chatted) as has_chatted,
788
+ (SELECT COUNT(*) FROM messages m WHERE m.username = v.username) as message_count
789
+ FROM stream_viewers v
790
+ GROUP BY v.username
791
+ ORDER BY message_count DESC
792
+ LIMIT ?
793
+ `);
794
+ return stmt.all(limit);
795
+ }
796
+ } else {
797
+ // Supabase Mode queries the views we created
798
+ let query;
799
+ if (streamId) {
800
+ query = supabase
801
+ .from('stream_chatter_stats')
802
+ .select('*')
803
+ .eq('stream_id', streamId)
804
+ .order('message_count', { ascending: false })
805
+ .limit(limit);
806
+ } else {
807
+ query = supabase
808
+ .from('global_chatter_stats')
809
+ .select('*')
810
+ .order('message_count', { ascending: false })
811
+ .limit(limit);
812
+ }
813
+ const { data, error } = await query;
814
+ if (error) {
815
+ console.error('[Supabase] Error getting top chatters:', error);
816
+ return [];
817
+ }
818
+ return data || [];
819
+ }
820
+ }
821
+
822
+ /**
823
+ * Get top spoken words
824
+ */
825
+ export async function getSpokenWords(streamId = null, limit = 100) {
826
+ if (dbMode === 'sqlite') {
827
+ let stmt;
828
+ if (streamId) {
829
+ stmt = sqliteDb.prepare(`
830
+ SELECT word, count(*) as word_count
831
+ FROM voice_words
832
+ WHERE stream_id = ?
833
+ GROUP BY word
834
+ ORDER BY word_count DESC
835
+ LIMIT ?
836
+ `);
837
+ return stmt.all(streamId, limit);
838
+ } else {
839
+ stmt = sqliteDb.prepare(`
840
+ SELECT word, count(*) as word_count
841
+ FROM voice_words
842
+ GROUP BY word
843
+ ORDER BY word_count DESC
844
+ LIMIT ?
845
+ `);
846
+ return stmt.all(limit);
847
+ }
848
+ } else {
849
+ let query;
850
+ if (streamId) {
851
+ query = supabase
852
+ .from('stream_voice_word_stats')
853
+ .select('word, word_count')
854
+ .eq('stream_id', streamId)
855
+ .order('word_count', { ascending: false })
856
+ .limit(limit);
857
+ } else {
858
+ query = supabase
859
+ .from('global_voice_word_stats')
860
+ .select('word, word_count')
861
+ .order('word_count', { ascending: false })
862
+ .limit(limit);
863
+ }
864
+ const { data, error } = await query;
865
+ if (error) {
866
+ console.error('[Supabase] Error getting spoken words:', error);
867
+ return [];
868
+ }
869
+ return data || [];
870
+ }
871
+ }
872
+
873
+ /**
874
+ * Get all messages written in chat (for client-side word count in JS)
875
+ */
876
+ export async function getChatMessages(streamId = null) {
877
+ const bots = ['streamelements', 'nightbot'];
878
+ if (dbMode === 'sqlite') {
879
+ if (streamId) {
880
+ return sqliteDb.prepare('SELECT message, username, display_name, timestamp FROM messages WHERE stream_id = ? AND username NOT IN (?, ?)').all(streamId, ...bots);
881
+ } else {
882
+ return sqliteDb.prepare('SELECT message, username, display_name, timestamp FROM messages WHERE username NOT IN (?, ?)').all(...bots);
883
+ }
884
+ } else {
885
+ let query = supabase
886
+ .from('messages')
887
+ .select('message, username, display_name, timestamp')
888
+ .not('username', 'in', `(${bots.map(b => `"${b}"`).join(',')})`);
889
+
890
+ if (streamId) {
891
+ query = query.eq('stream_id', streamId);
892
+ }
893
+ const { data, error } = await query;
894
+ if (error) {
895
+ console.error('[Supabase] Error getting chat messages:', error);
896
+ return [];
897
+ }
898
+ return data || [];
899
+ }
900
+ }
901
+
902
+ /**
903
+ * Get log of moderator actions
904
+ */
905
+ export async function getModActionsList(streamId = null, limit = 100) {
906
+ if (dbMode === 'sqlite') {
907
+ if (streamId) {
908
+ return sqliteDb.prepare('SELECT * FROM mod_actions WHERE stream_id = ? ORDER BY timestamp DESC LIMIT ?').all(streamId, limit);
909
+ } else {
910
+ return sqliteDb.prepare('SELECT * FROM mod_actions ORDER BY timestamp DESC LIMIT ?').all(limit);
911
+ }
912
+ } else {
913
+ let query = supabase
914
+ .from('mod_actions')
915
+ .select('*')
916
+ .order('timestamp', { ascending: false })
917
+ .limit(limit);
918
+
919
+ if (streamId) {
920
+ query = query.eq('stream_id', streamId);
921
+ }
922
+ const { data, error } = await query;
923
+ if (error) {
924
+ console.error('[Supabase] Error getting mod actions:', error);
925
+ return [];
926
+ }
927
+ return data || [];
928
+ }
929
+ }
930
+
931
+ /**
932
+ * Settings Get Helper
933
+ */
934
+ export async function getSetting(key) {
935
+ if (dbMode === 'sqlite') {
936
+ const row = sqliteDb.prepare('SELECT value FROM settings WHERE key = ?').get(key);
937
+ return row ? row.value : null;
938
+ } else {
939
+ const { data, error } = await supabase
940
+ .from('settings')
941
+ .select('value')
942
+ .eq('key', key)
943
+ .single();
944
+ if (error) {
945
+ if (error.code !== 'PGRST116') {
946
+ console.error(`[Supabase] Error getting setting ${key}:`, error);
947
+ }
948
+ return null;
949
+ }
950
+ return data ? data.value : null;
951
+ }
952
+ }
953
+
954
+ /**
955
+ * Settings Set Helper
956
+ */
957
+ export async function setSetting(key, value) {
958
+ if (dbMode === 'sqlite') {
959
+ sqliteDb.prepare('INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = ?').run(key, String(value), String(value));
960
+ } else {
961
+ const { error } = await supabase
962
+ .from('settings')
963
+ .upsert([{ key, value: String(value) }]);
964
+ if (error) {
965
+ console.error(`[Supabase] Error saving setting ${key}:`, error);
966
+ }
967
+ }
968
+ }
969
+
970
+ /**
971
+ * Get all message timestamps for a stream (for activity chart)
972
+ */
973
+ export async function getStreamMessageTimestamps(streamId) {
974
+ if (dbMode === 'sqlite') {
975
+ return sqliteDb.prepare('SELECT timestamp FROM messages WHERE stream_id = ? ORDER BY timestamp ASC').all(streamId);
976
+ } else {
977
+ const { data, error } = await supabase
978
+ .from('messages')
979
+ .select('timestamp')
980
+ .eq('stream_id', streamId)
981
+ .order('timestamp', { ascending: true });
982
+
983
+ if (error) {
984
+ console.error('[Supabase] Error fetching message timestamps:', error);
985
+ return [];
986
+ }
987
+ return data || [];
988
+ }
989
+ }
990
+
991
+ /**
992
+ * Get raw moderator actions for summary aggregation
993
+ */
994
+ export async function getModActionsSummaryRaw(streamId = null) {
995
+ if (dbMode === 'sqlite') {
996
+ if (streamId) {
997
+ return sqliteDb.prepare('SELECT moderator, action_type FROM mod_actions WHERE stream_id = ?').all(streamId);
998
+ } else {
999
+ return sqliteDb.prepare('SELECT moderator, action_type FROM mod_actions').all();
1000
+ }
1001
+ } else {
1002
+ let query = supabase
1003
+ .from('mod_actions')
1004
+ .select('moderator, action_type');
1005
+
1006
+ if (streamId) {
1007
+ query = query.eq('stream_id', streamId);
1008
+ }
1009
+
1010
+ const { data, error } = await query;
1011
+ if (error) {
1012
+ console.error('[Supabase] Error fetching mod actions summary:', error);
1013
+ return [];
1014
+ }
1015
+ return data || [];
1016
+ }
1017
+ }
1018
+
1019
+ /**
1020
+ * Get aggregated moderator stats and radar scores for profiles
1021
+ */
1022
+ export async function getModeratorProfilesData(streamId = null) {
1023
+ let actions = [];
1024
+ if (dbMode === 'sqlite') {
1025
+ if (streamId) {
1026
+ actions = sqliteDb.prepare('SELECT * FROM mod_actions WHERE stream_id = ?').all(streamId);
1027
+ } else {
1028
+ actions = sqliteDb.prepare('SELECT * FROM mod_actions').all();
1029
+ }
1030
+ } else {
1031
+ let query = supabase.from('mod_actions').select('*');
1032
+ if (streamId) {
1033
+ query = query.eq('stream_id', streamId);
1034
+ }
1035
+ const { data, error } = await query;
1036
+ if (error) {
1037
+ console.error('[Supabase] Error fetching mod actions for profiles:', error);
1038
+ return [];
1039
+ }
1040
+ actions = data || [];
1041
+ }
1042
+
1043
+ const modData = {};
1044
+ for (const act of actions) {
1045
+ const mod = act.moderator;
1046
+ if (!modData[mod]) {
1047
+ modData[mod] = {
1048
+ moderator: mod,
1049
+ total_actions: 0,
1050
+ reaction_times: [],
1051
+ bans_count: 0,
1052
+ timeouts_count: 0,
1053
+ deletions_count: 0,
1054
+ unbans_count: 0
1055
+ };
1056
+ }
1057
+
1058
+ const stats = modData[mod];
1059
+ stats.total_actions += 1;
1060
+ if (act.action_type === 'ban') stats.bans_count += 1;
1061
+ else if (act.action_type === 'timeout') stats.timeouts_count += 1;
1062
+ else if (act.action_type === 'delete') stats.deletions_count += 1;
1063
+ else if (act.action_type === 'unban') stats.unbans_count += 1;
1064
+
1065
+ if (act.reaction_time !== null && act.reaction_time !== undefined) {
1066
+ stats.reaction_times.push(act.reaction_time);
1067
+ }
1068
+ }
1069
+
1070
+ const profiles = Object.values(modData).map(stats => {
1071
+ const totalReactionTime = stats.reaction_times.reduce((sum, val) => sum + val, 0);
1072
+ const avgReactionTime = stats.reaction_times.length > 0 ? totalReactionTime / stats.reaction_times.length : null;
1073
+
1074
+ // Normalizing scores (0 to 100)
1075
+ let scoreSpeed = 50;
1076
+ if (avgReactionTime !== null) {
1077
+ if (avgReactionTime <= 2) scoreSpeed = 100;
1078
+ else if (avgReactionTime >= 30) scoreSpeed = 10;
1079
+ else scoreSpeed = Math.round(100 - ((avgReactionTime - 2) * (90 / 28)));
1080
+ }
1081
+
1082
+ const scoreActivity = Math.min(100, Math.round((stats.total_actions / 50) * 100));
1083
+
1084
+ const strictCount = stats.bans_count + stats.timeouts_count;
1085
+ const scoreHarshness = stats.total_actions > 0 ? Math.round((strictCount / stats.total_actions) * 100) : 0;
1086
+
1087
+ const scoreWatchfulness = stats.total_actions > 0 ? Math.round((stats.deletions_count / stats.total_actions) * 100) : 0;
1088
+
1089
+ return {
1090
+ moderator: stats.moderator,
1091
+ total_actions: stats.total_actions,
1092
+ reaction_time_avg: avgReactionTime ? parseFloat(avgReactionTime.toFixed(2)) : null,
1093
+ bans_count: stats.bans_count,
1094
+ timeouts_count: stats.timeouts_count,
1095
+ deletions_count: stats.deletions_count,
1096
+ unbans_count: stats.unbans_count,
1097
+ scores: {
1098
+ speed: scoreSpeed,
1099
+ activity: scoreActivity,
1100
+ harshness: scoreHarshness,
1101
+ watchfulness: scoreWatchfulness
1102
+ }
1103
+ };
1104
+ });
1105
+
1106
+ return profiles.sort((a, b) => b.total_actions - a.total_actions);
1107
+ }
1108
+
1109
+ /**
1110
+ * Get list of VOD streams waiting for backfill
1111
+ */
1112
+ export async function getPendingBackfillStreams() {
1113
+ if (dbMode === 'sqlite') {
1114
+ const streams = sqliteDb.prepare("SELECT * FROM streams WHERE backfill_status = 'pending' AND twitch_vod_id IS NOT NULL ORDER BY start_time ASC").all();
1115
+ for (const stream of streams) {
1116
+ const msgMaxRow = sqliteDb.prepare("SELECT max(timestamp) as max_time FROM messages WHERE stream_id = ?").get(stream.id);
1117
+ const msgMinRow = sqliteDb.prepare("SELECT min(timestamp) as min_time FROM messages WHERE stream_id = ?").get(stream.id);
1118
+ const voiceMaxRow = sqliteDb.prepare("SELECT max(timestamp) as max_time FROM voice_words WHERE stream_id = ?").get(stream.id);
1119
+ const voiceMinRow = sqliteDb.prepare("SELECT min(timestamp) as min_time FROM voice_words WHERE stream_id = ?").get(stream.id);
1120
+
1121
+ stream.max_message_time = msgMaxRow ? msgMaxRow.max_time : null;
1122
+ stream.min_message_time = msgMinRow ? msgMinRow.min_time : null;
1123
+ stream.max_voice_time = voiceMaxRow ? voiceMaxRow.max_time : null;
1124
+ stream.min_voice_time = voiceMinRow ? voiceMinRow.min_time : null;
1125
+ }
1126
+ return streams;
1127
+ } else {
1128
+ const { data: streams, error } = await supabase
1129
+ .from('streams')
1130
+ .select('*')
1131
+ .eq('backfill_status', 'pending')
1132
+ .not('twitch_vod_id', 'is', null)
1133
+ .order('start_time', { ascending: true });
1134
+
1135
+ if (error) {
1136
+ console.error('[Supabase] Error getting pending backfill streams:', error);
1137
+ return [];
1138
+ }
1139
+
1140
+ if (streams) {
1141
+ for (const stream of streams) {
1142
+ // Query max message timestamp in Supabase
1143
+ const { data: msgMaxData } = await supabase
1144
+ .from('messages')
1145
+ .select('timestamp')
1146
+ .eq('stream_id', stream.id)
1147
+ .order('timestamp', { ascending: false })
1148
+ .limit(1);
1149
+
1150
+ // Query min message timestamp in Supabase
1151
+ const { data: msgMinData } = await supabase
1152
+ .from('messages')
1153
+ .select('timestamp')
1154
+ .eq('stream_id', stream.id)
1155
+ .order('timestamp', { ascending: true })
1156
+ .limit(1);
1157
+
1158
+ // Query max voice timestamp in Supabase
1159
+ const { data: voiceMaxData } = await supabase
1160
+ .from('voice_words')
1161
+ .select('timestamp')
1162
+ .eq('stream_id', stream.id)
1163
+ .order('timestamp', { ascending: false })
1164
+ .limit(1);
1165
+
1166
+ // Query min voice timestamp in Supabase
1167
+ const { data: voiceMinData } = await supabase
1168
+ .from('voice_words')
1169
+ .select('timestamp')
1170
+ .eq('stream_id', stream.id)
1171
+ .order('timestamp', { ascending: true })
1172
+ .limit(1);
1173
+
1174
+ stream.max_message_time = msgMaxData && msgMaxData.length > 0 ? msgMaxData[0].timestamp : null;
1175
+ stream.min_message_time = msgMinData && msgMinData.length > 0 ? msgMinData[0].timestamp : null;
1176
+ stream.max_voice_time = voiceMaxData && voiceMaxData.length > 0 ? voiceMaxData[0].timestamp : null;
1177
+ stream.min_voice_time = voiceMinData && voiceMinData.length > 0 ? voiceMinData[0].timestamp : null;
1178
+ }
1179
+ }
1180
+ return streams || [];
1181
+ }
1182
+ }
1183
+
1184
+ /**
1185
+ * Mark a stream as backfilled and completed
1186
+ */
1187
+ export async function markStreamBackfilled(streamId = null, twitchStreamId = null) {
1188
+ if (dbMode === 'sqlite') {
1189
+ if (streamId) {
1190
+ sqliteDb.prepare("UPDATE streams SET backfill_status = 'completed' WHERE id = ?").run(streamId);
1191
+ } else if (twitchStreamId) {
1192
+ sqliteDb.prepare("UPDATE streams SET backfill_status = 'completed' WHERE twitch_stream_id = ?").run(twitchStreamId);
1193
+ }
1194
+ } else {
1195
+ const query = supabase.from('streams').update({ backfill_status: 'completed' });
1196
+ let res;
1197
+ if (streamId) {
1198
+ res = await query.eq('id', streamId);
1199
+ } else if (twitchStreamId) {
1200
+ res = await query.eq('twitch_stream_id', twitchStreamId);
1201
+ }
1202
+ if (res && res.error) {
1203
+ console.error('[Supabase] Error marking stream as backfilled:', res.error);
1204
+ }
1205
+ }
1206
+ }
1207
+
1208
+ /**
1209
+ * Reset backfill status and optionally delete existing data for rebuild
1210
+ */
1211
+ export async function resetStreamBackfill(streamId, mode = 'gap_fill') {
1212
+ if (dbMode === 'sqlite') {
1213
+ if (mode === 'full_rebuild') {
1214
+ sqliteDb.prepare("DELETE FROM messages WHERE stream_id = ?").run(streamId);
1215
+ sqliteDb.prepare("DELETE FROM voice_words WHERE stream_id = ?").run(streamId);
1216
+ console.log(`[Database] SQLite Stream ID ${streamId} data cleared for full rebuild.`);
1217
+ }
1218
+ sqliteDb.prepare("UPDATE streams SET backfill_status = 'pending' WHERE id = ?").run(streamId);
1219
+ console.log(`[Database] SQLite Stream ID ${streamId} reset to pending.`);
1220
+ } else {
1221
+ if (mode === 'full_rebuild') {
1222
+ const { error: msgErr } = await supabase.from('messages').delete().eq('stream_id', streamId);
1223
+ const { error: voiceErr } = await supabase.from('voice_words').delete().eq('stream_id', streamId);
1224
+ if (msgErr || voiceErr) {
1225
+ console.error('[Supabase] Error clearing data for rebuild:', msgErr, voiceErr);
1226
+ } else {
1227
+ console.log(`[Database] Supabase Stream ID ${streamId} data cleared for full rebuild.`);
1228
+ }
1229
+ }
1230
+ const { error: streamErr } = await supabase.from('streams').update({ backfill_status: 'pending' }).eq('id', streamId);
1231
+ if (streamErr) {
1232
+ console.error('[Supabase] Error resetting stream to pending:', streamErr);
1233
+ } else {
1234
+ console.log(`[Database] Supabase Stream ID ${streamId} reset to pending.`);
1235
+ }
1236
+ }
1237
+ }
1238
+
1239
+ /**
1240
+ * Delete stream and all its messages, words, and mod actions
1241
+ */
1242
+ export async function deleteStream(streamId) {
1243
+ if (dbMode === 'sqlite') {
1244
+ sqliteDb.prepare("DELETE FROM messages WHERE stream_id = ?").run(streamId);
1245
+ sqliteDb.prepare("DELETE FROM voice_words WHERE stream_id = ?").run(streamId);
1246
+ sqliteDb.prepare("DELETE FROM mod_actions WHERE stream_id = ?").run(streamId);
1247
+ sqliteDb.prepare("DELETE FROM streams WHERE id = ?").run(streamId);
1248
+ console.log(`[Database] SQLite Stream ID ${streamId} fully deleted.`);
1249
+ } else {
1250
+ const { error: msgErr } = await supabase.from('messages').delete().eq('stream_id', streamId);
1251
+ const { error: voiceErr } = await supabase.from('voice_words').delete().eq('stream_id', streamId);
1252
+ const { error: modErr } = await supabase.from('mod_actions').delete().eq('stream_id', streamId);
1253
+ const { error: streamErr } = await supabase.from('streams').delete().eq('id', streamId);
1254
+
1255
+ if (msgErr || voiceErr || modErr || streamErr) {
1256
+ console.error('[Supabase] Error deleting stream:', msgErr, voiceErr, modErr, streamErr);
1257
+ } else {
1258
+ console.log(`[Database] Supabase Stream ID ${streamId} fully deleted.`);
1259
+ }
1260
+ }
1261
+ }
1262
+
1263
+ /**
1264
+ * Update metadata (title/category) of a stream
1265
+ */
1266
+ export async function updateStreamMetadata(streamId, title, category) {
1267
+ if (dbMode === 'sqlite') {
1268
+ sqliteDb.prepare("UPDATE streams SET title = ?, category = ? WHERE id = ?").run(title, category, streamId);
1269
+ console.log(`[Database] SQLite Stream ID ${streamId} updated: Title="${title}", Category="${category}"`);
1270
+ } else {
1271
+ const { error } = await supabase
1272
+ .from('streams')
1273
+ .update({ title, category })
1274
+ .eq('id', streamId);
1275
+
1276
+ if (error) {
1277
+ console.error('[Supabase] Error updating stream metadata:', error);
1278
+ } else {
1279
+ console.log(`[Database] Supabase Stream ID ${streamId} updated: Title="${title}", Category="${category}"`);
1280
+ }
1281
+ }
1282
+ }
1283
+
1284
+ /**
1285
+ * Get system statistics (size, count of records)
1286
+ */
1287
+ export async function getSystemStats() {
1288
+ let stats = {
1289
+ dbMode,
1290
+ totalStreams: 0,
1291
+ totalMessages: 0,
1292
+ totalVoiceWords: 0,
1293
+ dbSizeMb: 0
1294
+ };
1295
+
1296
+ if (dbMode === 'sqlite') {
1297
+ stats.totalStreams = sqliteDb.prepare("SELECT count(*) as count FROM streams").get().count;
1298
+ stats.totalMessages = sqliteDb.prepare("SELECT count(*) as count FROM messages").get().count;
1299
+ stats.totalVoiceWords = sqliteDb.prepare("SELECT count(*) as count FROM voice_words").get().count;
1300
+
1301
+ try {
1302
+ const dbPath = path.resolve('database.db');
1303
+ if (fs.existsSync(dbPath)) {
1304
+ const fileStats = fs.statSync(dbPath);
1305
+ stats.dbSizeMb = parseFloat((fileStats.size / (1024 * 1024)).toFixed(2));
1306
+ }
1307
+ } catch (err) {
1308
+ console.error('[Database] Error reading SQLite file size:', err.message);
1309
+ }
1310
+ } else {
1311
+ // Supabase count queries
1312
+ const { count: streamsCount, error: err1 } = await supabase.from('streams').select('*', { count: 'exact', head: true });
1313
+ const { count: messagesCount, error: err2 } = await supabase.from('messages').select('*', { count: 'exact', head: true });
1314
+ const { count: voiceWordsCount, error: err3 } = await supabase.from('voice_words').select('*', { count: 'exact', head: true });
1315
+
1316
+ if (err1 || err2 || err3) {
1317
+ console.error('[Supabase] Error fetching system stats:', err1, err2, err3);
1318
+ }
1319
+
1320
+ stats.totalStreams = streamsCount || 0;
1321
+ stats.totalMessages = messagesCount || 0;
1322
+ stats.totalVoiceWords = voiceWordsCount || 0;
1323
+ }
1324
+ return stats;
1325
+ }
1326
+
1327
+ /**
1328
+ * Clean up streams with 0 messages AND 0 voice words
1329
+ */
1330
+ export async function cleanupGhostStreams() {
1331
+ if (dbMode === 'sqlite') {
1332
+ const info = sqliteDb.prepare(`
1333
+ DELETE FROM streams
1334
+ WHERE id NOT IN (SELECT DISTINCT stream_id FROM messages WHERE stream_id IS NOT NULL)
1335
+ AND id NOT IN (SELECT DISTINCT stream_id FROM voice_words WHERE stream_id IS NOT NULL)
1336
+ `).run();
1337
+ console.log(`[Database] SQLite Ghost streams cleaned up. Deleted ${info.changes} streams.`);
1338
+ return info.changes;
1339
+ } else {
1340
+ // Supabase mode - fetch all streams and check manually (safer for postgres count limitations)
1341
+ const { data: streams, error: fetchErr } = await supabase.from('streams').select('id');
1342
+ if (fetchErr || !streams) {
1343
+ console.error('[Supabase] Error fetching streams for cleanup:', fetchErr);
1344
+ return 0;
1345
+ }
1346
+
1347
+ let deletedCount = 0;
1348
+ for (const stream of streams) {
1349
+ const { count: msgCount } = await supabase.from('messages').select('*', { count: 'exact', head: true }).eq('stream_id', stream.id);
1350
+ const { count: voiceCount } = await supabase.from('voice_words').select('*', { count: 'exact', head: true }).eq('stream_id', stream.id);
1351
+
1352
+ if ((msgCount || 0) === 0 && (voiceCount || 0) === 0) {
1353
+ const { error: delErr } = await supabase.from('streams').delete().eq('id', stream.id);
1354
+ if (delErr) {
1355
+ console.error(`[Supabase] Error deleting empty stream ID ${stream.id}:`, delErr);
1356
+ } else {
1357
+ deletedCount++;
1358
+ }
1359
+ }
1360
+ }
1361
+ console.log(`[Database] Supabase Ghost streams cleaned up. Deleted ${deletedCount} streams.`);
1362
+ return deletedCount;
1363
+ }
1364
+ }
1365
+
1366
+
1367
+ /**
1368
+ * Log viewer join event (Lurker)
1369
+ */
1370
+ export async function logViewerJoin(username) {
1371
+ const stream = await getActiveStream();
1372
+ if (!stream) return;
1373
+
1374
+ const streamId = stream.id;
1375
+ const time = new Date().toISOString();
1376
+
1377
+ if (dbMode === 'sqlite') {
1378
+ try {
1379
+ sqliteDb.prepare(`
1380
+ INSERT INTO stream_viewers (stream_id, username, display_name, has_chatted, is_mod, is_sub, first_seen)
1381
+ VALUES (?, ?, ?, 0, 0, 0, ?)
1382
+ ON CONFLICT(stream_id, username) DO NOTHING
1383
+ `).run(streamId, username.toLowerCase(), username, time);
1384
+ } catch (err) {
1385
+ console.error('[Database] SQLite Error logging viewer join:', err.message);
1386
+ }
1387
+ } else {
1388
+ const { error } = await supabase
1389
+ .from('stream_viewers')
1390
+ .insert([{
1391
+ stream_id: streamId,
1392
+ username: username.toLowerCase(),
1393
+ display_name: username,
1394
+ has_chatted: false,
1395
+ is_mod: false,
1396
+ is_sub: false,
1397
+ first_seen: time
1398
+ }], { onConflict: 'stream_id, username' });
1399
+
1400
+ if (error && error.code !== '23505') {
1401
+ console.error('[Supabase] Error logging viewer join:', error);
1402
+ }
1403
+ }
1404
+ }
server/eventsub.js ADDED
@@ -0,0 +1,240 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import WebSocket from 'ws';
2
+ import axios from 'axios';
3
+ import { getSetting, logModAction } from './db.js';
4
+
5
+ let ws = null;
6
+ let broadcasterUserId = null;
7
+ let keepAliveTimer = null;
8
+ let lastKeepAlive = null;
9
+ let reconnecting = false;
10
+
11
+ /**
12
+ * Initialize Twitch EventSub WebSocket connection
13
+ */
14
+ export async function initializeEventSub(streamerId = null) {
15
+ // Try to load broadcaster ID from database if not passed
16
+ if (!streamerId) {
17
+ broadcasterUserId = await getSetting('twitch_broadcaster_id');
18
+ } else {
19
+ broadcasterUserId = streamerId;
20
+ }
21
+
22
+ if (!broadcasterUserId) {
23
+ console.log('[EventSub] Broadcaster ID is not configured yet. Waiting for Streamer login.');
24
+ return;
25
+ }
26
+
27
+ const token = await getSetting('twitch_streamer_access_token');
28
+ if (!token) {
29
+ console.log('[EventSub] Streamer access token is missing. Waiting for Streamer login.');
30
+ return;
31
+ }
32
+
33
+ if (ws) {
34
+ console.log('[EventSub] Connection already exists, closing it first...');
35
+ try {
36
+ ws.close();
37
+ } catch (e) {}
38
+ }
39
+
40
+ connect('wss://eventsub.wss.twitch.tv/ws');
41
+ }
42
+
43
+ /**
44
+ * Connect to EventSub WebSocket
45
+ */
46
+ function connect(url) {
47
+ console.log(`[EventSub] Connecting to ${url}...`);
48
+ ws = new WebSocket(url);
49
+ reconnecting = false;
50
+
51
+ ws.on('open', () => {
52
+ console.log('[EventSub] WebSocket connection opened.');
53
+ lastKeepAlive = Date.now();
54
+ startKeepAliveCheck();
55
+ });
56
+
57
+ ws.on('message', async (data) => {
58
+ try {
59
+ const message = JSON.parse(data.toString());
60
+ await handleMessage(message);
61
+ } catch (err) {
62
+ console.error('[EventSub] Error parsing message:', err);
63
+ }
64
+ });
65
+
66
+ ws.on('close', (code, reason) => {
67
+ console.log(`[EventSub] WebSocket connection closed: Code ${code}, Reason: ${reason}`);
68
+ cleanup();
69
+ if (!reconnecting) {
70
+ // Reconnect after 5 seconds if not a controlled migration
71
+ console.log('[EventSub] Reconnecting in 5 seconds...');
72
+ setTimeout(() => initializeEventSub(), 5000);
73
+ }
74
+ });
75
+
76
+ ws.on('error', (err) => {
77
+ console.error('[EventSub] WebSocket error:', err);
78
+ });
79
+ }
80
+
81
+ /**
82
+ * Handle incoming EventSub WebSocket messages
83
+ */
84
+ async function handleMessage(message) {
85
+ const { metadata, payload } = message;
86
+ const messageType = metadata.message_type;
87
+
88
+ lastKeepAlive = Date.now();
89
+
90
+ switch (messageType) {
91
+ case 'session_welcome': {
92
+ const sessionId = payload.session.id;
93
+ console.log(`[EventSub] Welcome received. Session ID: ${sessionId}`);
94
+ await subscribeToEvents(sessionId);
95
+ break;
96
+ }
97
+ case 'session_keepalive':
98
+ // Just updating lastKeepAlive is enough
99
+ break;
100
+ case 'session_reconnect': {
101
+ const reconnectUrl = payload.session.reconnect_url;
102
+ console.log(`[EventSub] Reconnect requested. Migrating connection to: ${reconnectUrl}`);
103
+ reconnecting = true;
104
+ connect(reconnectUrl);
105
+ break;
106
+ }
107
+ case 'notification': {
108
+ await handleNotification(payload);
109
+ break;
110
+ }
111
+ case 'revocation':
112
+ console.warn('[EventSub] Subscription revoked:', payload.subscription.type);
113
+ break;
114
+ default:
115
+ console.log('[EventSub] Unknown message type:', messageType);
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Handle incoming event notifications
121
+ */
122
+ async function handleNotification(payload) {
123
+ const { subscription, event } = payload;
124
+ const type = subscription.type;
125
+
126
+ console.log(`[EventSub] Event received: ${type}`);
127
+
128
+ switch (type) {
129
+ case 'channel.ban': {
130
+ // Event triggers on ban or timeout
131
+ const isTimeout = event.ends_at !== null;
132
+ await logModAction({
133
+ actionType: isTimeout ? 'timeout' : 'ban',
134
+ moderator: event.moderator_user_name,
135
+ targetUser: event.user_name,
136
+ duration: isTimeout ? Math.round((new Date(event.ends_at) - new Date(event.banned_at)) / 1000) : null,
137
+ reason: event.reason,
138
+ timestamp: event.banned_at
139
+ });
140
+ break;
141
+ }
142
+ case 'channel.unban': {
143
+ await logModAction({
144
+ actionType: 'unban',
145
+ moderator: event.moderator_user_name,
146
+ targetUser: event.user_name,
147
+ timestamp: new Date().toISOString()
148
+ });
149
+ break;
150
+ }
151
+ case 'channel.chat.message_delete': {
152
+ await logModAction({
153
+ actionType: 'delete',
154
+ moderator: event.moderator_user_name,
155
+ targetUser: event.target_user_name,
156
+ messageText: event.message_body,
157
+ timestamp: new Date().toISOString()
158
+ });
159
+ break;
160
+ }
161
+ default:
162
+ console.log('[EventSub] Unhandled event type:', type);
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Register EventSub subscriptions via Twitch Helix API
168
+ */
169
+ async function subscribeToEvents(sessionId) {
170
+ const clientId = process.env.TWITCH_CLIENT_ID;
171
+ const token = await getSetting('twitch_streamer_access_token');
172
+
173
+ if (!clientId || !token) {
174
+ console.error('[EventSub] Cannot subscribe: Client ID or token is missing!');
175
+ return;
176
+ }
177
+
178
+ const subscriptions = [
179
+ { type: 'channel.ban', version: '1' },
180
+ { type: 'channel.unban', version: '1' },
181
+ { type: 'channel.chat.message_delete', version: '1' }
182
+ ];
183
+
184
+ const headers = {
185
+ 'Client-ID': clientId,
186
+ 'Authorization': `Bearer ${token}`,
187
+ 'Content-Type': 'application/json'
188
+ };
189
+
190
+ for (const sub of subscriptions) {
191
+ try {
192
+ const body = {
193
+ type: sub.type,
194
+ version: sub.version,
195
+ condition: {
196
+ broadcaster_user_id: broadcasterUserId
197
+ },
198
+ transport: {
199
+ method: 'websocket',
200
+ session_id: sessionId
201
+ }
202
+ };
203
+
204
+ const response = await axios.post('https://api.twitch.tv/helix/eventsub/subscriptions', body, { headers });
205
+ console.log(`[EventSub] Subscribed to ${sub.type}. Status: ${response.status}`);
206
+ } catch (err) {
207
+ console.error(`[EventSub] Subscription failed for ${sub.type}:`, err.response?.data || err.message);
208
+ }
209
+ }
210
+ }
211
+
212
+ /**
213
+ * Keep WebSocket connection healthy
214
+ */
215
+ function startKeepAliveCheck() {
216
+ cleanupKeepAlive();
217
+ keepAliveTimer = setInterval(() => {
218
+ // Twitch sends keepalives every 10 seconds. If no message for 20 seconds, reconnect.
219
+ if (Date.now() - lastKeepAlive > 20000) {
220
+ console.warn('[EventSub] Keepalive timeout. Reconnecting WebSocket...');
221
+ cleanupKeepAlive();
222
+ if (ws) {
223
+ try { ws.terminate(); } catch (e) {}
224
+ }
225
+ initializeEventSub();
226
+ }
227
+ }, 10000);
228
+ }
229
+
230
+ function cleanupKeepAlive() {
231
+ if (keepAliveTimer) {
232
+ clearInterval(keepAliveTimer);
233
+ keepAliveTimer = null;
234
+ }
235
+ }
236
+
237
+ function cleanup() {
238
+ cleanupKeepAlive();
239
+ ws = null;
240
+ }
server/init_db.sql ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ -- SQL Script to initialize the Supabase PostgreSQL database
2
+ -- Copy and paste this into the Supabase SQL Editor (SQL Editor -> New Query)
3
+
4
+ -- 1. STREAMS TABLE
5
+ CREATE TABLE IF NOT EXISTS streams (
6
+ id SERIAL PRIMARY KEY,
7
+ start_time TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
8
+ end_time TIMESTAMP WITH TIME ZONE,
9
+ title TEXT,
10
+ twitch_stream_id TEXT UNIQUE,
11
+ category TEXT,
12
+ backfill_status TEXT DEFAULT 'pending',
13
+ twitch_vod_id TEXT
14
+ );
15
+
16
+ -- 2. MESSAGES TABLE
17
+ CREATE TABLE IF NOT EXISTS messages (
18
+ id TEXT PRIMARY KEY, -- Twitch Message ID (UUID-like from tags)
19
+ stream_id INTEGER REFERENCES streams(id) ON DELETE SET NULL,
20
+ username TEXT NOT NULL,
21
+ display_name TEXT,
22
+ message TEXT NOT NULL,
23
+ timestamp TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
24
+ is_streamer BOOLEAN DEFAULT FALSE,
25
+ is_mod BOOLEAN DEFAULT FALSE,
26
+ is_sub BOOLEAN DEFAULT FALSE
27
+ );
28
+
29
+ -- Create indexes for fast querying and statistics aggregation
30
+ CREATE INDEX IF NOT EXISTS idx_messages_stream_id ON messages(stream_id);
31
+ CREATE INDEX IF NOT EXISTS idx_messages_username ON messages(username);
32
+ CREATE INDEX IF NOT EXISTS idx_messages_timestamp ON messages(timestamp);
33
+
34
+ -- 2.5 STREAM VIEWERS TABLE
35
+ CREATE TABLE IF NOT EXISTS stream_viewers (
36
+ stream_id INTEGER REFERENCES streams(id) ON DELETE CASCADE,
37
+ username TEXT NOT NULL,
38
+ display_name TEXT,
39
+ has_chatted BOOLEAN DEFAULT FALSE,
40
+ is_mod BOOLEAN DEFAULT FALSE,
41
+ is_sub BOOLEAN DEFAULT FALSE,
42
+ first_seen TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
43
+ PRIMARY KEY (stream_id, username)
44
+ );
45
+ CREATE INDEX IF NOT EXISTS idx_stream_viewers_stream_id ON stream_viewers(stream_id);
46
+
47
+ -- 3. VOICE WORDS TABLE (transcribed spoken words from streamer)
48
+ CREATE TABLE IF NOT EXISTS voice_words (
49
+ id SERIAL PRIMARY KEY,
50
+ stream_id INTEGER REFERENCES streams(id) ON DELETE CASCADE,
51
+ word TEXT NOT NULL,
52
+ timestamp TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
53
+ );
54
+
55
+ CREATE INDEX IF NOT EXISTS idx_voice_words_stream_id ON voice_words(stream_id);
56
+ CREATE INDEX IF NOT EXISTS idx_voice_words_word ON voice_words(word);
57
+
58
+ -- 4. MOD ACTIONS TABLE (from EventSub: bans, timeouts, message deletions)
59
+ CREATE TABLE IF NOT EXISTS mod_actions (
60
+ id SERIAL PRIMARY KEY,
61
+ stream_id INTEGER REFERENCES streams(id) ON DELETE SET NULL,
62
+ action_type TEXT NOT NULL, -- 'ban', 'timeout', 'unban', 'delete'
63
+ moderator TEXT NOT NULL, -- Moderator's username
64
+ target_user TEXT, -- Banned/timed out user's username
65
+ duration INTEGER, -- Timeout duration in seconds (NULL for bans/deletions)
66
+ reason TEXT, -- Reason provided
67
+ message_text TEXT, -- Text of deleted message (for 'delete' action)
68
+ timestamp TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
69
+ reaction_time REAL
70
+ );
71
+
72
+ CREATE INDEX IF NOT EXISTS idx_mod_actions_stream_id ON mod_actions(stream_id);
73
+ CREATE INDEX IF NOT EXISTS idx_mod_actions_timestamp ON mod_actions(timestamp);
74
+
75
+ -- 5. SETTINGS TABLE (for persistent oauth tokens, etc.)
76
+ CREATE TABLE IF NOT EXISTS settings (
77
+ key TEXT PRIMARY KEY,
78
+ value TEXT NOT NULL,
79
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
80
+ );
81
+
82
+ -- A trigger to automatically update updated_at in settings table
83
+ CREATE OR REPLACE FUNCTION update_modified_column()
84
+ RETURNS TRIGGER AS $$
85
+ BEGIN
86
+ NEW.updated_at = now();
87
+ RETURN NEW;
88
+ END;
89
+ $$ language 'plpgsql';
90
+
91
+ CREATE OR REPLACE TRIGGER update_settings_modtime
92
+ BEFORE UPDATE ON settings
93
+ FOR EACH ROW
94
+ EXECUTE FUNCTION update_modified_column();
95
+
96
+ -- =========================================================================
97
+ -- HELPER VIEWS FOR STATISTICS
98
+ -- =========================================================================
99
+
100
+ -- View for chatter statistics per stream
101
+ CREATE OR REPLACE VIEW stream_chatter_stats AS
102
+ SELECT
103
+ stream_id,
104
+ username,
105
+ max(display_name) as display_name,
106
+ bool_or(is_mod) as is_mod,
107
+ bool_or(is_sub) as is_sub,
108
+ count(*) as message_count
109
+ FROM messages
110
+ GROUP BY stream_id, username;
111
+
112
+ -- View for chatter statistics overall (all-time)
113
+ CREATE OR REPLACE VIEW global_chatter_stats AS
114
+ SELECT
115
+ username,
116
+ max(display_name) as display_name,
117
+ bool_or(is_mod) as is_mod,
118
+ bool_or(is_sub) as is_sub,
119
+ count(*) as message_count
120
+ FROM messages
121
+ GROUP BY username;
122
+
123
+ -- View for spoken word statistics per stream
124
+ CREATE OR REPLACE VIEW stream_voice_word_stats AS
125
+ SELECT
126
+ stream_id,
127
+ word,
128
+ count(*) as word_count
129
+ FROM voice_words
130
+ GROUP BY stream_id, word;
131
+
132
+ -- View for spoken word statistics overall (all-time)
133
+ CREATE OR REPLACE VIEW global_voice_word_stats AS
134
+ SELECT
135
+ word,
136
+ count(*) as word_count
137
+ FROM voice_words
138
+ GROUP BY word;
server/package-lock.json ADDED
@@ -0,0 +1,1704 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "winx_prinx-analytics-server",
3
+ "version": "1.0.0",
4
+ "lockfileVersion": 3,
5
+ "requires": true,
6
+ "packages": {
7
+ "": {
8
+ "name": "winx_prinx-analytics-server",
9
+ "version": "1.0.0",
10
+ "license": "ISC",
11
+ "dependencies": {
12
+ "@supabase/supabase-js": "^2.49.1",
13
+ "axios": "^1.8.1",
14
+ "better-sqlite3": "^12.10.0",
15
+ "cookie-session": "^2.1.0",
16
+ "cors": "^2.8.5",
17
+ "dotenv": "^16.4.7",
18
+ "express": "^4.21.2",
19
+ "ws": "^8.18.1"
20
+ }
21
+ },
22
+ "node_modules/@supabase/auth-js": {
23
+ "version": "2.108.1",
24
+ "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.108.1.tgz",
25
+ "integrity": "sha512-Lle5rKU8f9LF3K5dDd8Or8mkkG+ptzRZZWKPVMm9B9UuovH65Ss2+iFnQqRsCqaGouvJEcTWyl0cj2riNrrDLQ==",
26
+ "license": "MIT",
27
+ "dependencies": {
28
+ "tslib": "2.8.1"
29
+ },
30
+ "engines": {
31
+ "node": ">=20.0.0"
32
+ }
33
+ },
34
+ "node_modules/@supabase/functions-js": {
35
+ "version": "2.108.1",
36
+ "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.108.1.tgz",
37
+ "integrity": "sha512-fxBRW/A4IG7ADQztVt0NaEy5ysiO1WJ2pbldsnBchrkHuyepX0Krek9qA9T4gUQBVVTCE9Ea4pdsM5hfn3nc4A==",
38
+ "license": "MIT",
39
+ "dependencies": {
40
+ "tslib": "2.8.1"
41
+ },
42
+ "engines": {
43
+ "node": ">=20.0.0"
44
+ }
45
+ },
46
+ "node_modules/@supabase/phoenix": {
47
+ "version": "0.4.2",
48
+ "resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.2.tgz",
49
+ "integrity": "sha512-YSAGnmDAfuleFCVt3CeurQZAhxRfXWeZIIkwp7NhYzQ1UwW6ePSnzsFAiUm/mbCkfoCf70QQHKW/K6RKh52a4A==",
50
+ "license": "MIT"
51
+ },
52
+ "node_modules/@supabase/postgrest-js": {
53
+ "version": "2.108.1",
54
+ "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.108.1.tgz",
55
+ "integrity": "sha512-9lj2MCPPMgSTaJ5y+amnhb3TWPtMFVlbDn2hmX/VV91xQU4j0AauwfMaBErHBJ+zzsSwjc0jLU+zLIZFLQzfig==",
56
+ "license": "MIT",
57
+ "dependencies": {
58
+ "tslib": "2.8.1"
59
+ },
60
+ "engines": {
61
+ "node": ">=20.0.0"
62
+ }
63
+ },
64
+ "node_modules/@supabase/realtime-js": {
65
+ "version": "2.108.1",
66
+ "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.108.1.tgz",
67
+ "integrity": "sha512-mHGGqOjwd1XTydcoffUqEMsbFQHUi6A3uhQ0EXr3iqzpLqItxKA9nbN6gIQxrZ7JRRnuUe/iOFPUkYV9Tdc5lg==",
68
+ "license": "MIT",
69
+ "dependencies": {
70
+ "@supabase/phoenix": "^0.4.2",
71
+ "tslib": "2.8.1"
72
+ },
73
+ "engines": {
74
+ "node": ">=20.0.0"
75
+ }
76
+ },
77
+ "node_modules/@supabase/storage-js": {
78
+ "version": "2.108.1",
79
+ "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.108.1.tgz",
80
+ "integrity": "sha512-Er0SGGt85iT6ye+SSh98Az6L2CesoZJuyzEZYH2oBOAnIxa9Nn4CtwUC3veGxYggoT56X+3tVuuQeDBP8kR8sg==",
81
+ "license": "MIT",
82
+ "dependencies": {
83
+ "iceberg-js": "^0.8.1",
84
+ "tslib": "2.8.1"
85
+ },
86
+ "engines": {
87
+ "node": ">=20.0.0"
88
+ }
89
+ },
90
+ "node_modules/@supabase/supabase-js": {
91
+ "version": "2.108.1",
92
+ "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.108.1.tgz",
93
+ "integrity": "sha512-V/1hRKLSCJ0zEL+9QFRBUtivvePfOsaAYQmC0HhFNSHC2F3xFs4jSF3YhkLmzex6E4V4FGvmBDOP72D/53NnZA==",
94
+ "license": "MIT",
95
+ "dependencies": {
96
+ "@supabase/auth-js": "2.108.1",
97
+ "@supabase/functions-js": "2.108.1",
98
+ "@supabase/postgrest-js": "2.108.1",
99
+ "@supabase/realtime-js": "2.108.1",
100
+ "@supabase/storage-js": "2.108.1"
101
+ },
102
+ "engines": {
103
+ "node": ">=20.0.0"
104
+ }
105
+ },
106
+ "node_modules/accepts": {
107
+ "version": "1.3.8",
108
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
109
+ "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
110
+ "license": "MIT",
111
+ "dependencies": {
112
+ "mime-types": "~2.1.34",
113
+ "negotiator": "0.6.3"
114
+ },
115
+ "engines": {
116
+ "node": ">= 0.6"
117
+ }
118
+ },
119
+ "node_modules/agent-base": {
120
+ "version": "6.0.2",
121
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
122
+ "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
123
+ "license": "MIT",
124
+ "dependencies": {
125
+ "debug": "4"
126
+ },
127
+ "engines": {
128
+ "node": ">= 6.0.0"
129
+ }
130
+ },
131
+ "node_modules/agent-base/node_modules/debug": {
132
+ "version": "4.4.3",
133
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
134
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
135
+ "license": "MIT",
136
+ "dependencies": {
137
+ "ms": "^2.1.3"
138
+ },
139
+ "engines": {
140
+ "node": ">=6.0"
141
+ },
142
+ "peerDependenciesMeta": {
143
+ "supports-color": {
144
+ "optional": true
145
+ }
146
+ }
147
+ },
148
+ "node_modules/array-flatten": {
149
+ "version": "1.1.1",
150
+ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
151
+ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
152
+ "license": "MIT"
153
+ },
154
+ "node_modules/asynckit": {
155
+ "version": "0.4.0",
156
+ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
157
+ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
158
+ "license": "MIT"
159
+ },
160
+ "node_modules/axios": {
161
+ "version": "1.17.0",
162
+ "resolved": "https://registry.npmjs.org/axios/-/axios-1.17.0.tgz",
163
+ "integrity": "sha512-J8SwNxprqqpbfenehxWYXE7CW+wM1BB4w3+N+g+/Wx40xM4rsLrfPmHHxSWIxJLYDgSY/HqlFPIYb2/S3rxafw==",
164
+ "license": "MIT",
165
+ "dependencies": {
166
+ "follow-redirects": "^1.16.0",
167
+ "form-data": "^4.0.5",
168
+ "https-proxy-agent": "^5.0.1",
169
+ "proxy-from-env": "^2.1.0"
170
+ }
171
+ },
172
+ "node_modules/base64-js": {
173
+ "version": "1.5.1",
174
+ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
175
+ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
176
+ "funding": [
177
+ {
178
+ "type": "github",
179
+ "url": "https://github.com/sponsors/feross"
180
+ },
181
+ {
182
+ "type": "patreon",
183
+ "url": "https://www.patreon.com/feross"
184
+ },
185
+ {
186
+ "type": "consulting",
187
+ "url": "https://feross.org/support"
188
+ }
189
+ ],
190
+ "license": "MIT"
191
+ },
192
+ "node_modules/better-sqlite3": {
193
+ "version": "12.10.0",
194
+ "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.10.0.tgz",
195
+ "integrity": "sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ==",
196
+ "hasInstallScript": true,
197
+ "license": "MIT",
198
+ "dependencies": {
199
+ "bindings": "^1.5.0",
200
+ "prebuild-install": "^7.1.1"
201
+ },
202
+ "engines": {
203
+ "node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x"
204
+ }
205
+ },
206
+ "node_modules/bindings": {
207
+ "version": "1.5.0",
208
+ "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
209
+ "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
210
+ "license": "MIT",
211
+ "dependencies": {
212
+ "file-uri-to-path": "1.0.0"
213
+ }
214
+ },
215
+ "node_modules/bl": {
216
+ "version": "4.1.0",
217
+ "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
218
+ "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
219
+ "license": "MIT",
220
+ "dependencies": {
221
+ "buffer": "^5.5.0",
222
+ "inherits": "^2.0.4",
223
+ "readable-stream": "^3.4.0"
224
+ }
225
+ },
226
+ "node_modules/body-parser": {
227
+ "version": "1.20.5",
228
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
229
+ "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==",
230
+ "license": "MIT",
231
+ "dependencies": {
232
+ "bytes": "~3.1.2",
233
+ "content-type": "~1.0.5",
234
+ "debug": "2.6.9",
235
+ "depd": "2.0.0",
236
+ "destroy": "~1.2.0",
237
+ "http-errors": "~2.0.1",
238
+ "iconv-lite": "~0.4.24",
239
+ "on-finished": "~2.4.1",
240
+ "qs": "~6.15.1",
241
+ "raw-body": "~2.5.3",
242
+ "type-is": "~1.6.18",
243
+ "unpipe": "~1.0.0"
244
+ },
245
+ "engines": {
246
+ "node": ">= 0.8",
247
+ "npm": "1.2.8000 || >= 1.4.16"
248
+ }
249
+ },
250
+ "node_modules/body-parser/node_modules/debug": {
251
+ "version": "2.6.9",
252
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
253
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
254
+ "license": "MIT",
255
+ "dependencies": {
256
+ "ms": "2.0.0"
257
+ }
258
+ },
259
+ "node_modules/body-parser/node_modules/ms": {
260
+ "version": "2.0.0",
261
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
262
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
263
+ "license": "MIT"
264
+ },
265
+ "node_modules/buffer": {
266
+ "version": "5.7.1",
267
+ "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
268
+ "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
269
+ "funding": [
270
+ {
271
+ "type": "github",
272
+ "url": "https://github.com/sponsors/feross"
273
+ },
274
+ {
275
+ "type": "patreon",
276
+ "url": "https://www.patreon.com/feross"
277
+ },
278
+ {
279
+ "type": "consulting",
280
+ "url": "https://feross.org/support"
281
+ }
282
+ ],
283
+ "license": "MIT",
284
+ "dependencies": {
285
+ "base64-js": "^1.3.1",
286
+ "ieee754": "^1.1.13"
287
+ }
288
+ },
289
+ "node_modules/bytes": {
290
+ "version": "3.1.2",
291
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
292
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
293
+ "license": "MIT",
294
+ "engines": {
295
+ "node": ">= 0.8"
296
+ }
297
+ },
298
+ "node_modules/call-bind-apply-helpers": {
299
+ "version": "1.0.2",
300
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
301
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
302
+ "license": "MIT",
303
+ "dependencies": {
304
+ "es-errors": "^1.3.0",
305
+ "function-bind": "^1.1.2"
306
+ },
307
+ "engines": {
308
+ "node": ">= 0.4"
309
+ }
310
+ },
311
+ "node_modules/call-bound": {
312
+ "version": "1.0.4",
313
+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
314
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
315
+ "license": "MIT",
316
+ "dependencies": {
317
+ "call-bind-apply-helpers": "^1.0.2",
318
+ "get-intrinsic": "^1.3.0"
319
+ },
320
+ "engines": {
321
+ "node": ">= 0.4"
322
+ },
323
+ "funding": {
324
+ "url": "https://github.com/sponsors/ljharb"
325
+ }
326
+ },
327
+ "node_modules/chownr": {
328
+ "version": "1.1.4",
329
+ "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
330
+ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
331
+ "license": "ISC"
332
+ },
333
+ "node_modules/combined-stream": {
334
+ "version": "1.0.8",
335
+ "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
336
+ "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
337
+ "license": "MIT",
338
+ "dependencies": {
339
+ "delayed-stream": "~1.0.0"
340
+ },
341
+ "engines": {
342
+ "node": ">= 0.8"
343
+ }
344
+ },
345
+ "node_modules/content-disposition": {
346
+ "version": "0.5.4",
347
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
348
+ "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
349
+ "license": "MIT",
350
+ "dependencies": {
351
+ "safe-buffer": "5.2.1"
352
+ },
353
+ "engines": {
354
+ "node": ">= 0.6"
355
+ }
356
+ },
357
+ "node_modules/content-type": {
358
+ "version": "1.0.5",
359
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
360
+ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
361
+ "license": "MIT",
362
+ "engines": {
363
+ "node": ">= 0.6"
364
+ }
365
+ },
366
+ "node_modules/cookie": {
367
+ "version": "0.7.2",
368
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
369
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
370
+ "license": "MIT",
371
+ "engines": {
372
+ "node": ">= 0.6"
373
+ }
374
+ },
375
+ "node_modules/cookie-session": {
376
+ "version": "2.1.1",
377
+ "resolved": "https://registry.npmjs.org/cookie-session/-/cookie-session-2.1.1.tgz",
378
+ "integrity": "sha512-ji3kym/XZaFVew1+tIZk5ZLp9Z/fLv9rK1aZmpug0FsgE7Cu3ZDrUdRo7FT9vFjMYfNimrrUHJzywDwT7XEFlg==",
379
+ "license": "MIT",
380
+ "dependencies": {
381
+ "cookies": "0.9.1",
382
+ "debug": "3.2.7",
383
+ "on-headers": "~1.1.0",
384
+ "safe-buffer": "5.2.1"
385
+ },
386
+ "engines": {
387
+ "node": ">= 0.10"
388
+ }
389
+ },
390
+ "node_modules/cookie-signature": {
391
+ "version": "1.0.7",
392
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
393
+ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
394
+ "license": "MIT"
395
+ },
396
+ "node_modules/cookies": {
397
+ "version": "0.9.1",
398
+ "resolved": "https://registry.npmjs.org/cookies/-/cookies-0.9.1.tgz",
399
+ "integrity": "sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw==",
400
+ "license": "MIT",
401
+ "dependencies": {
402
+ "depd": "~2.0.0",
403
+ "keygrip": "~1.1.0"
404
+ },
405
+ "engines": {
406
+ "node": ">= 0.8"
407
+ }
408
+ },
409
+ "node_modules/cors": {
410
+ "version": "2.8.6",
411
+ "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
412
+ "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
413
+ "license": "MIT",
414
+ "dependencies": {
415
+ "object-assign": "^4",
416
+ "vary": "^1"
417
+ },
418
+ "engines": {
419
+ "node": ">= 0.10"
420
+ },
421
+ "funding": {
422
+ "type": "opencollective",
423
+ "url": "https://opencollective.com/express"
424
+ }
425
+ },
426
+ "node_modules/debug": {
427
+ "version": "3.2.7",
428
+ "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
429
+ "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
430
+ "license": "MIT",
431
+ "dependencies": {
432
+ "ms": "^2.1.1"
433
+ }
434
+ },
435
+ "node_modules/decompress-response": {
436
+ "version": "6.0.0",
437
+ "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
438
+ "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
439
+ "license": "MIT",
440
+ "dependencies": {
441
+ "mimic-response": "^3.1.0"
442
+ },
443
+ "engines": {
444
+ "node": ">=10"
445
+ },
446
+ "funding": {
447
+ "url": "https://github.com/sponsors/sindresorhus"
448
+ }
449
+ },
450
+ "node_modules/deep-extend": {
451
+ "version": "0.6.0",
452
+ "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
453
+ "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
454
+ "license": "MIT",
455
+ "engines": {
456
+ "node": ">=4.0.0"
457
+ }
458
+ },
459
+ "node_modules/delayed-stream": {
460
+ "version": "1.0.0",
461
+ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
462
+ "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
463
+ "license": "MIT",
464
+ "engines": {
465
+ "node": ">=0.4.0"
466
+ }
467
+ },
468
+ "node_modules/depd": {
469
+ "version": "2.0.0",
470
+ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
471
+ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
472
+ "license": "MIT",
473
+ "engines": {
474
+ "node": ">= 0.8"
475
+ }
476
+ },
477
+ "node_modules/destroy": {
478
+ "version": "1.2.0",
479
+ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
480
+ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
481
+ "license": "MIT",
482
+ "engines": {
483
+ "node": ">= 0.8",
484
+ "npm": "1.2.8000 || >= 1.4.16"
485
+ }
486
+ },
487
+ "node_modules/detect-libc": {
488
+ "version": "2.1.2",
489
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
490
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
491
+ "license": "Apache-2.0",
492
+ "engines": {
493
+ "node": ">=8"
494
+ }
495
+ },
496
+ "node_modules/dotenv": {
497
+ "version": "16.6.1",
498
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
499
+ "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
500
+ "license": "BSD-2-Clause",
501
+ "engines": {
502
+ "node": ">=12"
503
+ },
504
+ "funding": {
505
+ "url": "https://dotenvx.com"
506
+ }
507
+ },
508
+ "node_modules/dunder-proto": {
509
+ "version": "1.0.1",
510
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
511
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
512
+ "license": "MIT",
513
+ "dependencies": {
514
+ "call-bind-apply-helpers": "^1.0.1",
515
+ "es-errors": "^1.3.0",
516
+ "gopd": "^1.2.0"
517
+ },
518
+ "engines": {
519
+ "node": ">= 0.4"
520
+ }
521
+ },
522
+ "node_modules/ee-first": {
523
+ "version": "1.1.1",
524
+ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
525
+ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
526
+ "license": "MIT"
527
+ },
528
+ "node_modules/encodeurl": {
529
+ "version": "2.0.0",
530
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
531
+ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
532
+ "license": "MIT",
533
+ "engines": {
534
+ "node": ">= 0.8"
535
+ }
536
+ },
537
+ "node_modules/end-of-stream": {
538
+ "version": "1.4.5",
539
+ "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
540
+ "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
541
+ "license": "MIT",
542
+ "dependencies": {
543
+ "once": "^1.4.0"
544
+ }
545
+ },
546
+ "node_modules/es-define-property": {
547
+ "version": "1.0.1",
548
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
549
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
550
+ "license": "MIT",
551
+ "engines": {
552
+ "node": ">= 0.4"
553
+ }
554
+ },
555
+ "node_modules/es-errors": {
556
+ "version": "1.3.0",
557
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
558
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
559
+ "license": "MIT",
560
+ "engines": {
561
+ "node": ">= 0.4"
562
+ }
563
+ },
564
+ "node_modules/es-object-atoms": {
565
+ "version": "1.1.2",
566
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
567
+ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
568
+ "license": "MIT",
569
+ "dependencies": {
570
+ "es-errors": "^1.3.0"
571
+ },
572
+ "engines": {
573
+ "node": ">= 0.4"
574
+ }
575
+ },
576
+ "node_modules/es-set-tostringtag": {
577
+ "version": "2.1.0",
578
+ "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
579
+ "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
580
+ "license": "MIT",
581
+ "dependencies": {
582
+ "es-errors": "^1.3.0",
583
+ "get-intrinsic": "^1.2.6",
584
+ "has-tostringtag": "^1.0.2",
585
+ "hasown": "^2.0.2"
586
+ },
587
+ "engines": {
588
+ "node": ">= 0.4"
589
+ }
590
+ },
591
+ "node_modules/escape-html": {
592
+ "version": "1.0.3",
593
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
594
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
595
+ "license": "MIT"
596
+ },
597
+ "node_modules/etag": {
598
+ "version": "1.8.1",
599
+ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
600
+ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
601
+ "license": "MIT",
602
+ "engines": {
603
+ "node": ">= 0.6"
604
+ }
605
+ },
606
+ "node_modules/expand-template": {
607
+ "version": "2.0.3",
608
+ "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
609
+ "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
610
+ "license": "(MIT OR WTFPL)",
611
+ "engines": {
612
+ "node": ">=6"
613
+ }
614
+ },
615
+ "node_modules/express": {
616
+ "version": "4.22.2",
617
+ "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
618
+ "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
619
+ "license": "MIT",
620
+ "dependencies": {
621
+ "accepts": "~1.3.8",
622
+ "array-flatten": "1.1.1",
623
+ "body-parser": "~1.20.5",
624
+ "content-disposition": "~0.5.4",
625
+ "content-type": "~1.0.4",
626
+ "cookie": "~0.7.1",
627
+ "cookie-signature": "~1.0.6",
628
+ "debug": "2.6.9",
629
+ "depd": "2.0.0",
630
+ "encodeurl": "~2.0.0",
631
+ "escape-html": "~1.0.3",
632
+ "etag": "~1.8.1",
633
+ "finalhandler": "~1.3.1",
634
+ "fresh": "~0.5.2",
635
+ "http-errors": "~2.0.0",
636
+ "merge-descriptors": "1.0.3",
637
+ "methods": "~1.1.2",
638
+ "on-finished": "~2.4.1",
639
+ "parseurl": "~1.3.3",
640
+ "path-to-regexp": "~0.1.12",
641
+ "proxy-addr": "~2.0.7",
642
+ "qs": "~6.15.1",
643
+ "range-parser": "~1.2.1",
644
+ "safe-buffer": "5.2.1",
645
+ "send": "~0.19.0",
646
+ "serve-static": "~1.16.2",
647
+ "setprototypeof": "1.2.0",
648
+ "statuses": "~2.0.1",
649
+ "type-is": "~1.6.18",
650
+ "utils-merge": "1.0.1",
651
+ "vary": "~1.1.2"
652
+ },
653
+ "engines": {
654
+ "node": ">= 0.10.0"
655
+ },
656
+ "funding": {
657
+ "type": "opencollective",
658
+ "url": "https://opencollective.com/express"
659
+ }
660
+ },
661
+ "node_modules/express/node_modules/debug": {
662
+ "version": "2.6.9",
663
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
664
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
665
+ "license": "MIT",
666
+ "dependencies": {
667
+ "ms": "2.0.0"
668
+ }
669
+ },
670
+ "node_modules/express/node_modules/ms": {
671
+ "version": "2.0.0",
672
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
673
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
674
+ "license": "MIT"
675
+ },
676
+ "node_modules/file-uri-to-path": {
677
+ "version": "1.0.0",
678
+ "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
679
+ "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
680
+ "license": "MIT"
681
+ },
682
+ "node_modules/finalhandler": {
683
+ "version": "1.3.2",
684
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
685
+ "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
686
+ "license": "MIT",
687
+ "dependencies": {
688
+ "debug": "2.6.9",
689
+ "encodeurl": "~2.0.0",
690
+ "escape-html": "~1.0.3",
691
+ "on-finished": "~2.4.1",
692
+ "parseurl": "~1.3.3",
693
+ "statuses": "~2.0.2",
694
+ "unpipe": "~1.0.0"
695
+ },
696
+ "engines": {
697
+ "node": ">= 0.8"
698
+ }
699
+ },
700
+ "node_modules/finalhandler/node_modules/debug": {
701
+ "version": "2.6.9",
702
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
703
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
704
+ "license": "MIT",
705
+ "dependencies": {
706
+ "ms": "2.0.0"
707
+ }
708
+ },
709
+ "node_modules/finalhandler/node_modules/ms": {
710
+ "version": "2.0.0",
711
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
712
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
713
+ "license": "MIT"
714
+ },
715
+ "node_modules/follow-redirects": {
716
+ "version": "1.16.0",
717
+ "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
718
+ "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
719
+ "funding": [
720
+ {
721
+ "type": "individual",
722
+ "url": "https://github.com/sponsors/RubenVerborgh"
723
+ }
724
+ ],
725
+ "license": "MIT",
726
+ "engines": {
727
+ "node": ">=4.0"
728
+ },
729
+ "peerDependenciesMeta": {
730
+ "debug": {
731
+ "optional": true
732
+ }
733
+ }
734
+ },
735
+ "node_modules/form-data": {
736
+ "version": "4.0.5",
737
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
738
+ "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
739
+ "license": "MIT",
740
+ "dependencies": {
741
+ "asynckit": "^0.4.0",
742
+ "combined-stream": "^1.0.8",
743
+ "es-set-tostringtag": "^2.1.0",
744
+ "hasown": "^2.0.2",
745
+ "mime-types": "^2.1.12"
746
+ },
747
+ "engines": {
748
+ "node": ">= 6"
749
+ }
750
+ },
751
+ "node_modules/forwarded": {
752
+ "version": "0.2.0",
753
+ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
754
+ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
755
+ "license": "MIT",
756
+ "engines": {
757
+ "node": ">= 0.6"
758
+ }
759
+ },
760
+ "node_modules/fresh": {
761
+ "version": "0.5.2",
762
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
763
+ "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
764
+ "license": "MIT",
765
+ "engines": {
766
+ "node": ">= 0.6"
767
+ }
768
+ },
769
+ "node_modules/fs-constants": {
770
+ "version": "1.0.0",
771
+ "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
772
+ "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
773
+ "license": "MIT"
774
+ },
775
+ "node_modules/function-bind": {
776
+ "version": "1.1.2",
777
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
778
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
779
+ "license": "MIT",
780
+ "funding": {
781
+ "url": "https://github.com/sponsors/ljharb"
782
+ }
783
+ },
784
+ "node_modules/get-intrinsic": {
785
+ "version": "1.3.0",
786
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
787
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
788
+ "license": "MIT",
789
+ "dependencies": {
790
+ "call-bind-apply-helpers": "^1.0.2",
791
+ "es-define-property": "^1.0.1",
792
+ "es-errors": "^1.3.0",
793
+ "es-object-atoms": "^1.1.1",
794
+ "function-bind": "^1.1.2",
795
+ "get-proto": "^1.0.1",
796
+ "gopd": "^1.2.0",
797
+ "has-symbols": "^1.1.0",
798
+ "hasown": "^2.0.2",
799
+ "math-intrinsics": "^1.1.0"
800
+ },
801
+ "engines": {
802
+ "node": ">= 0.4"
803
+ },
804
+ "funding": {
805
+ "url": "https://github.com/sponsors/ljharb"
806
+ }
807
+ },
808
+ "node_modules/get-proto": {
809
+ "version": "1.0.1",
810
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
811
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
812
+ "license": "MIT",
813
+ "dependencies": {
814
+ "dunder-proto": "^1.0.1",
815
+ "es-object-atoms": "^1.0.0"
816
+ },
817
+ "engines": {
818
+ "node": ">= 0.4"
819
+ }
820
+ },
821
+ "node_modules/github-from-package": {
822
+ "version": "0.0.0",
823
+ "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
824
+ "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
825
+ "license": "MIT"
826
+ },
827
+ "node_modules/gopd": {
828
+ "version": "1.2.0",
829
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
830
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
831
+ "license": "MIT",
832
+ "engines": {
833
+ "node": ">= 0.4"
834
+ },
835
+ "funding": {
836
+ "url": "https://github.com/sponsors/ljharb"
837
+ }
838
+ },
839
+ "node_modules/has-symbols": {
840
+ "version": "1.1.0",
841
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
842
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
843
+ "license": "MIT",
844
+ "engines": {
845
+ "node": ">= 0.4"
846
+ },
847
+ "funding": {
848
+ "url": "https://github.com/sponsors/ljharb"
849
+ }
850
+ },
851
+ "node_modules/has-tostringtag": {
852
+ "version": "1.0.2",
853
+ "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
854
+ "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
855
+ "license": "MIT",
856
+ "dependencies": {
857
+ "has-symbols": "^1.0.3"
858
+ },
859
+ "engines": {
860
+ "node": ">= 0.4"
861
+ },
862
+ "funding": {
863
+ "url": "https://github.com/sponsors/ljharb"
864
+ }
865
+ },
866
+ "node_modules/hasown": {
867
+ "version": "2.0.4",
868
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
869
+ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
870
+ "license": "MIT",
871
+ "dependencies": {
872
+ "function-bind": "^1.1.2"
873
+ },
874
+ "engines": {
875
+ "node": ">= 0.4"
876
+ }
877
+ },
878
+ "node_modules/http-errors": {
879
+ "version": "2.0.1",
880
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
881
+ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
882
+ "license": "MIT",
883
+ "dependencies": {
884
+ "depd": "~2.0.0",
885
+ "inherits": "~2.0.4",
886
+ "setprototypeof": "~1.2.0",
887
+ "statuses": "~2.0.2",
888
+ "toidentifier": "~1.0.1"
889
+ },
890
+ "engines": {
891
+ "node": ">= 0.8"
892
+ },
893
+ "funding": {
894
+ "type": "opencollective",
895
+ "url": "https://opencollective.com/express"
896
+ }
897
+ },
898
+ "node_modules/https-proxy-agent": {
899
+ "version": "5.0.1",
900
+ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
901
+ "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
902
+ "license": "MIT",
903
+ "dependencies": {
904
+ "agent-base": "6",
905
+ "debug": "4"
906
+ },
907
+ "engines": {
908
+ "node": ">= 6"
909
+ }
910
+ },
911
+ "node_modules/https-proxy-agent/node_modules/debug": {
912
+ "version": "4.4.3",
913
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
914
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
915
+ "license": "MIT",
916
+ "dependencies": {
917
+ "ms": "^2.1.3"
918
+ },
919
+ "engines": {
920
+ "node": ">=6.0"
921
+ },
922
+ "peerDependenciesMeta": {
923
+ "supports-color": {
924
+ "optional": true
925
+ }
926
+ }
927
+ },
928
+ "node_modules/iceberg-js": {
929
+ "version": "0.8.1",
930
+ "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
931
+ "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==",
932
+ "license": "MIT",
933
+ "engines": {
934
+ "node": ">=20.0.0"
935
+ }
936
+ },
937
+ "node_modules/iconv-lite": {
938
+ "version": "0.4.24",
939
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
940
+ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
941
+ "license": "MIT",
942
+ "dependencies": {
943
+ "safer-buffer": ">= 2.1.2 < 3"
944
+ },
945
+ "engines": {
946
+ "node": ">=0.10.0"
947
+ }
948
+ },
949
+ "node_modules/ieee754": {
950
+ "version": "1.2.1",
951
+ "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
952
+ "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
953
+ "funding": [
954
+ {
955
+ "type": "github",
956
+ "url": "https://github.com/sponsors/feross"
957
+ },
958
+ {
959
+ "type": "patreon",
960
+ "url": "https://www.patreon.com/feross"
961
+ },
962
+ {
963
+ "type": "consulting",
964
+ "url": "https://feross.org/support"
965
+ }
966
+ ],
967
+ "license": "BSD-3-Clause"
968
+ },
969
+ "node_modules/inherits": {
970
+ "version": "2.0.4",
971
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
972
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
973
+ "license": "ISC"
974
+ },
975
+ "node_modules/ini": {
976
+ "version": "1.3.8",
977
+ "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
978
+ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
979
+ "license": "ISC"
980
+ },
981
+ "node_modules/ipaddr.js": {
982
+ "version": "1.9.1",
983
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
984
+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
985
+ "license": "MIT",
986
+ "engines": {
987
+ "node": ">= 0.10"
988
+ }
989
+ },
990
+ "node_modules/keygrip": {
991
+ "version": "1.1.0",
992
+ "resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.1.0.tgz",
993
+ "integrity": "sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==",
994
+ "license": "MIT",
995
+ "dependencies": {
996
+ "tsscmp": "1.0.6"
997
+ },
998
+ "engines": {
999
+ "node": ">= 0.6"
1000
+ }
1001
+ },
1002
+ "node_modules/math-intrinsics": {
1003
+ "version": "1.1.0",
1004
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
1005
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
1006
+ "license": "MIT",
1007
+ "engines": {
1008
+ "node": ">= 0.4"
1009
+ }
1010
+ },
1011
+ "node_modules/media-typer": {
1012
+ "version": "0.3.0",
1013
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
1014
+ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
1015
+ "license": "MIT",
1016
+ "engines": {
1017
+ "node": ">= 0.6"
1018
+ }
1019
+ },
1020
+ "node_modules/merge-descriptors": {
1021
+ "version": "1.0.3",
1022
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
1023
+ "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
1024
+ "license": "MIT",
1025
+ "funding": {
1026
+ "url": "https://github.com/sponsors/sindresorhus"
1027
+ }
1028
+ },
1029
+ "node_modules/methods": {
1030
+ "version": "1.1.2",
1031
+ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
1032
+ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
1033
+ "license": "MIT",
1034
+ "engines": {
1035
+ "node": ">= 0.6"
1036
+ }
1037
+ },
1038
+ "node_modules/mime": {
1039
+ "version": "1.6.0",
1040
+ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
1041
+ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
1042
+ "license": "MIT",
1043
+ "bin": {
1044
+ "mime": "cli.js"
1045
+ },
1046
+ "engines": {
1047
+ "node": ">=4"
1048
+ }
1049
+ },
1050
+ "node_modules/mime-db": {
1051
+ "version": "1.52.0",
1052
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
1053
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
1054
+ "license": "MIT",
1055
+ "engines": {
1056
+ "node": ">= 0.6"
1057
+ }
1058
+ },
1059
+ "node_modules/mime-types": {
1060
+ "version": "2.1.35",
1061
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
1062
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
1063
+ "license": "MIT",
1064
+ "dependencies": {
1065
+ "mime-db": "1.52.0"
1066
+ },
1067
+ "engines": {
1068
+ "node": ">= 0.6"
1069
+ }
1070
+ },
1071
+ "node_modules/mimic-response": {
1072
+ "version": "3.1.0",
1073
+ "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
1074
+ "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
1075
+ "license": "MIT",
1076
+ "engines": {
1077
+ "node": ">=10"
1078
+ },
1079
+ "funding": {
1080
+ "url": "https://github.com/sponsors/sindresorhus"
1081
+ }
1082
+ },
1083
+ "node_modules/minimist": {
1084
+ "version": "1.2.8",
1085
+ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
1086
+ "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
1087
+ "license": "MIT",
1088
+ "funding": {
1089
+ "url": "https://github.com/sponsors/ljharb"
1090
+ }
1091
+ },
1092
+ "node_modules/mkdirp-classic": {
1093
+ "version": "0.5.3",
1094
+ "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
1095
+ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
1096
+ "license": "MIT"
1097
+ },
1098
+ "node_modules/ms": {
1099
+ "version": "2.1.3",
1100
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
1101
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
1102
+ "license": "MIT"
1103
+ },
1104
+ "node_modules/napi-build-utils": {
1105
+ "version": "2.0.0",
1106
+ "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
1107
+ "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
1108
+ "license": "MIT"
1109
+ },
1110
+ "node_modules/negotiator": {
1111
+ "version": "0.6.3",
1112
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
1113
+ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
1114
+ "license": "MIT",
1115
+ "engines": {
1116
+ "node": ">= 0.6"
1117
+ }
1118
+ },
1119
+ "node_modules/node-abi": {
1120
+ "version": "3.92.0",
1121
+ "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz",
1122
+ "integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==",
1123
+ "license": "MIT",
1124
+ "dependencies": {
1125
+ "semver": "^7.3.5"
1126
+ },
1127
+ "engines": {
1128
+ "node": ">=10"
1129
+ }
1130
+ },
1131
+ "node_modules/object-assign": {
1132
+ "version": "4.1.1",
1133
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
1134
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
1135
+ "license": "MIT",
1136
+ "engines": {
1137
+ "node": ">=0.10.0"
1138
+ }
1139
+ },
1140
+ "node_modules/object-inspect": {
1141
+ "version": "1.13.4",
1142
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
1143
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
1144
+ "license": "MIT",
1145
+ "engines": {
1146
+ "node": ">= 0.4"
1147
+ },
1148
+ "funding": {
1149
+ "url": "https://github.com/sponsors/ljharb"
1150
+ }
1151
+ },
1152
+ "node_modules/on-finished": {
1153
+ "version": "2.4.1",
1154
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
1155
+ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
1156
+ "license": "MIT",
1157
+ "dependencies": {
1158
+ "ee-first": "1.1.1"
1159
+ },
1160
+ "engines": {
1161
+ "node": ">= 0.8"
1162
+ }
1163
+ },
1164
+ "node_modules/on-headers": {
1165
+ "version": "1.1.0",
1166
+ "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
1167
+ "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
1168
+ "license": "MIT",
1169
+ "engines": {
1170
+ "node": ">= 0.8"
1171
+ }
1172
+ },
1173
+ "node_modules/once": {
1174
+ "version": "1.4.0",
1175
+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
1176
+ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
1177
+ "license": "ISC",
1178
+ "dependencies": {
1179
+ "wrappy": "1"
1180
+ }
1181
+ },
1182
+ "node_modules/parseurl": {
1183
+ "version": "1.3.3",
1184
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
1185
+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
1186
+ "license": "MIT",
1187
+ "engines": {
1188
+ "node": ">= 0.8"
1189
+ }
1190
+ },
1191
+ "node_modules/path-to-regexp": {
1192
+ "version": "0.1.13",
1193
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
1194
+ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
1195
+ "license": "MIT"
1196
+ },
1197
+ "node_modules/prebuild-install": {
1198
+ "version": "7.1.3",
1199
+ "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
1200
+ "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
1201
+ "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
1202
+ "license": "MIT",
1203
+ "dependencies": {
1204
+ "detect-libc": "^2.0.0",
1205
+ "expand-template": "^2.0.3",
1206
+ "github-from-package": "0.0.0",
1207
+ "minimist": "^1.2.3",
1208
+ "mkdirp-classic": "^0.5.3",
1209
+ "napi-build-utils": "^2.0.0",
1210
+ "node-abi": "^3.3.0",
1211
+ "pump": "^3.0.0",
1212
+ "rc": "^1.2.7",
1213
+ "simple-get": "^4.0.0",
1214
+ "tar-fs": "^2.0.0",
1215
+ "tunnel-agent": "^0.6.0"
1216
+ },
1217
+ "bin": {
1218
+ "prebuild-install": "bin.js"
1219
+ },
1220
+ "engines": {
1221
+ "node": ">=10"
1222
+ }
1223
+ },
1224
+ "node_modules/proxy-addr": {
1225
+ "version": "2.0.7",
1226
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
1227
+ "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
1228
+ "license": "MIT",
1229
+ "dependencies": {
1230
+ "forwarded": "0.2.0",
1231
+ "ipaddr.js": "1.9.1"
1232
+ },
1233
+ "engines": {
1234
+ "node": ">= 0.10"
1235
+ }
1236
+ },
1237
+ "node_modules/proxy-from-env": {
1238
+ "version": "2.1.0",
1239
+ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
1240
+ "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
1241
+ "license": "MIT",
1242
+ "engines": {
1243
+ "node": ">=10"
1244
+ }
1245
+ },
1246
+ "node_modules/pump": {
1247
+ "version": "3.0.4",
1248
+ "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
1249
+ "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
1250
+ "license": "MIT",
1251
+ "dependencies": {
1252
+ "end-of-stream": "^1.1.0",
1253
+ "once": "^1.3.1"
1254
+ }
1255
+ },
1256
+ "node_modules/qs": {
1257
+ "version": "6.15.2",
1258
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
1259
+ "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
1260
+ "license": "BSD-3-Clause",
1261
+ "dependencies": {
1262
+ "side-channel": "^1.1.0"
1263
+ },
1264
+ "engines": {
1265
+ "node": ">=0.6"
1266
+ },
1267
+ "funding": {
1268
+ "url": "https://github.com/sponsors/ljharb"
1269
+ }
1270
+ },
1271
+ "node_modules/range-parser": {
1272
+ "version": "1.2.1",
1273
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
1274
+ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
1275
+ "license": "MIT",
1276
+ "engines": {
1277
+ "node": ">= 0.6"
1278
+ }
1279
+ },
1280
+ "node_modules/raw-body": {
1281
+ "version": "2.5.3",
1282
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
1283
+ "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
1284
+ "license": "MIT",
1285
+ "dependencies": {
1286
+ "bytes": "~3.1.2",
1287
+ "http-errors": "~2.0.1",
1288
+ "iconv-lite": "~0.4.24",
1289
+ "unpipe": "~1.0.0"
1290
+ },
1291
+ "engines": {
1292
+ "node": ">= 0.8"
1293
+ }
1294
+ },
1295
+ "node_modules/rc": {
1296
+ "version": "1.2.8",
1297
+ "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
1298
+ "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
1299
+ "license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
1300
+ "dependencies": {
1301
+ "deep-extend": "^0.6.0",
1302
+ "ini": "~1.3.0",
1303
+ "minimist": "^1.2.0",
1304
+ "strip-json-comments": "~2.0.1"
1305
+ },
1306
+ "bin": {
1307
+ "rc": "cli.js"
1308
+ }
1309
+ },
1310
+ "node_modules/readable-stream": {
1311
+ "version": "3.6.2",
1312
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
1313
+ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
1314
+ "license": "MIT",
1315
+ "dependencies": {
1316
+ "inherits": "^2.0.3",
1317
+ "string_decoder": "^1.1.1",
1318
+ "util-deprecate": "^1.0.1"
1319
+ },
1320
+ "engines": {
1321
+ "node": ">= 6"
1322
+ }
1323
+ },
1324
+ "node_modules/safe-buffer": {
1325
+ "version": "5.2.1",
1326
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
1327
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
1328
+ "funding": [
1329
+ {
1330
+ "type": "github",
1331
+ "url": "https://github.com/sponsors/feross"
1332
+ },
1333
+ {
1334
+ "type": "patreon",
1335
+ "url": "https://www.patreon.com/feross"
1336
+ },
1337
+ {
1338
+ "type": "consulting",
1339
+ "url": "https://feross.org/support"
1340
+ }
1341
+ ],
1342
+ "license": "MIT"
1343
+ },
1344
+ "node_modules/safer-buffer": {
1345
+ "version": "2.1.2",
1346
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
1347
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
1348
+ "license": "MIT"
1349
+ },
1350
+ "node_modules/semver": {
1351
+ "version": "7.8.3",
1352
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.3.tgz",
1353
+ "integrity": "sha512-wnilbGyMxzbY7dNOl7jpKbLSjcfeweJWU5j4+u5qW+6/wuGD9KzIGOyZnQVSBM9E7DtWaaH3CyHkppYrKYoxwg==",
1354
+ "license": "ISC",
1355
+ "bin": {
1356
+ "semver": "bin/semver.js"
1357
+ },
1358
+ "engines": {
1359
+ "node": ">=10"
1360
+ }
1361
+ },
1362
+ "node_modules/send": {
1363
+ "version": "0.19.2",
1364
+ "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
1365
+ "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
1366
+ "license": "MIT",
1367
+ "dependencies": {
1368
+ "debug": "2.6.9",
1369
+ "depd": "2.0.0",
1370
+ "destroy": "1.2.0",
1371
+ "encodeurl": "~2.0.0",
1372
+ "escape-html": "~1.0.3",
1373
+ "etag": "~1.8.1",
1374
+ "fresh": "~0.5.2",
1375
+ "http-errors": "~2.0.1",
1376
+ "mime": "1.6.0",
1377
+ "ms": "2.1.3",
1378
+ "on-finished": "~2.4.1",
1379
+ "range-parser": "~1.2.1",
1380
+ "statuses": "~2.0.2"
1381
+ },
1382
+ "engines": {
1383
+ "node": ">= 0.8.0"
1384
+ }
1385
+ },
1386
+ "node_modules/send/node_modules/debug": {
1387
+ "version": "2.6.9",
1388
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
1389
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
1390
+ "license": "MIT",
1391
+ "dependencies": {
1392
+ "ms": "2.0.0"
1393
+ }
1394
+ },
1395
+ "node_modules/send/node_modules/debug/node_modules/ms": {
1396
+ "version": "2.0.0",
1397
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
1398
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
1399
+ "license": "MIT"
1400
+ },
1401
+ "node_modules/serve-static": {
1402
+ "version": "1.16.3",
1403
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
1404
+ "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
1405
+ "license": "MIT",
1406
+ "dependencies": {
1407
+ "encodeurl": "~2.0.0",
1408
+ "escape-html": "~1.0.3",
1409
+ "parseurl": "~1.3.3",
1410
+ "send": "~0.19.1"
1411
+ },
1412
+ "engines": {
1413
+ "node": ">= 0.8.0"
1414
+ }
1415
+ },
1416
+ "node_modules/setprototypeof": {
1417
+ "version": "1.2.0",
1418
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
1419
+ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
1420
+ "license": "ISC"
1421
+ },
1422
+ "node_modules/side-channel": {
1423
+ "version": "1.1.1",
1424
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
1425
+ "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
1426
+ "license": "MIT",
1427
+ "dependencies": {
1428
+ "es-errors": "^1.3.0",
1429
+ "object-inspect": "^1.13.4",
1430
+ "side-channel-list": "^1.0.1",
1431
+ "side-channel-map": "^1.0.1",
1432
+ "side-channel-weakmap": "^1.0.2"
1433
+ },
1434
+ "engines": {
1435
+ "node": ">= 0.4"
1436
+ },
1437
+ "funding": {
1438
+ "url": "https://github.com/sponsors/ljharb"
1439
+ }
1440
+ },
1441
+ "node_modules/side-channel-list": {
1442
+ "version": "1.0.1",
1443
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
1444
+ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
1445
+ "license": "MIT",
1446
+ "dependencies": {
1447
+ "es-errors": "^1.3.0",
1448
+ "object-inspect": "^1.13.4"
1449
+ },
1450
+ "engines": {
1451
+ "node": ">= 0.4"
1452
+ },
1453
+ "funding": {
1454
+ "url": "https://github.com/sponsors/ljharb"
1455
+ }
1456
+ },
1457
+ "node_modules/side-channel-map": {
1458
+ "version": "1.0.1",
1459
+ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
1460
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
1461
+ "license": "MIT",
1462
+ "dependencies": {
1463
+ "call-bound": "^1.0.2",
1464
+ "es-errors": "^1.3.0",
1465
+ "get-intrinsic": "^1.2.5",
1466
+ "object-inspect": "^1.13.3"
1467
+ },
1468
+ "engines": {
1469
+ "node": ">= 0.4"
1470
+ },
1471
+ "funding": {
1472
+ "url": "https://github.com/sponsors/ljharb"
1473
+ }
1474
+ },
1475
+ "node_modules/side-channel-weakmap": {
1476
+ "version": "1.0.2",
1477
+ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
1478
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
1479
+ "license": "MIT",
1480
+ "dependencies": {
1481
+ "call-bound": "^1.0.2",
1482
+ "es-errors": "^1.3.0",
1483
+ "get-intrinsic": "^1.2.5",
1484
+ "object-inspect": "^1.13.3",
1485
+ "side-channel-map": "^1.0.1"
1486
+ },
1487
+ "engines": {
1488
+ "node": ">= 0.4"
1489
+ },
1490
+ "funding": {
1491
+ "url": "https://github.com/sponsors/ljharb"
1492
+ }
1493
+ },
1494
+ "node_modules/simple-concat": {
1495
+ "version": "1.0.1",
1496
+ "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
1497
+ "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
1498
+ "funding": [
1499
+ {
1500
+ "type": "github",
1501
+ "url": "https://github.com/sponsors/feross"
1502
+ },
1503
+ {
1504
+ "type": "patreon",
1505
+ "url": "https://www.patreon.com/feross"
1506
+ },
1507
+ {
1508
+ "type": "consulting",
1509
+ "url": "https://feross.org/support"
1510
+ }
1511
+ ],
1512
+ "license": "MIT"
1513
+ },
1514
+ "node_modules/simple-get": {
1515
+ "version": "4.0.1",
1516
+ "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
1517
+ "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
1518
+ "funding": [
1519
+ {
1520
+ "type": "github",
1521
+ "url": "https://github.com/sponsors/feross"
1522
+ },
1523
+ {
1524
+ "type": "patreon",
1525
+ "url": "https://www.patreon.com/feross"
1526
+ },
1527
+ {
1528
+ "type": "consulting",
1529
+ "url": "https://feross.org/support"
1530
+ }
1531
+ ],
1532
+ "license": "MIT",
1533
+ "dependencies": {
1534
+ "decompress-response": "^6.0.0",
1535
+ "once": "^1.3.1",
1536
+ "simple-concat": "^1.0.0"
1537
+ }
1538
+ },
1539
+ "node_modules/statuses": {
1540
+ "version": "2.0.2",
1541
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
1542
+ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
1543
+ "license": "MIT",
1544
+ "engines": {
1545
+ "node": ">= 0.8"
1546
+ }
1547
+ },
1548
+ "node_modules/string_decoder": {
1549
+ "version": "1.3.0",
1550
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
1551
+ "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
1552
+ "license": "MIT",
1553
+ "dependencies": {
1554
+ "safe-buffer": "~5.2.0"
1555
+ }
1556
+ },
1557
+ "node_modules/strip-json-comments": {
1558
+ "version": "2.0.1",
1559
+ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
1560
+ "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
1561
+ "license": "MIT",
1562
+ "engines": {
1563
+ "node": ">=0.10.0"
1564
+ }
1565
+ },
1566
+ "node_modules/tar-fs": {
1567
+ "version": "2.1.4",
1568
+ "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
1569
+ "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
1570
+ "license": "MIT",
1571
+ "dependencies": {
1572
+ "chownr": "^1.1.1",
1573
+ "mkdirp-classic": "^0.5.2",
1574
+ "pump": "^3.0.0",
1575
+ "tar-stream": "^2.1.4"
1576
+ }
1577
+ },
1578
+ "node_modules/tar-stream": {
1579
+ "version": "2.2.0",
1580
+ "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
1581
+ "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
1582
+ "license": "MIT",
1583
+ "dependencies": {
1584
+ "bl": "^4.0.3",
1585
+ "end-of-stream": "^1.4.1",
1586
+ "fs-constants": "^1.0.0",
1587
+ "inherits": "^2.0.3",
1588
+ "readable-stream": "^3.1.1"
1589
+ },
1590
+ "engines": {
1591
+ "node": ">=6"
1592
+ }
1593
+ },
1594
+ "node_modules/toidentifier": {
1595
+ "version": "1.0.1",
1596
+ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
1597
+ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
1598
+ "license": "MIT",
1599
+ "engines": {
1600
+ "node": ">=0.6"
1601
+ }
1602
+ },
1603
+ "node_modules/tslib": {
1604
+ "version": "2.8.1",
1605
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
1606
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
1607
+ "license": "0BSD"
1608
+ },
1609
+ "node_modules/tsscmp": {
1610
+ "version": "1.0.6",
1611
+ "resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz",
1612
+ "integrity": "sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==",
1613
+ "license": "MIT",
1614
+ "engines": {
1615
+ "node": ">=0.6.x"
1616
+ }
1617
+ },
1618
+ "node_modules/tunnel-agent": {
1619
+ "version": "0.6.0",
1620
+ "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
1621
+ "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
1622
+ "license": "Apache-2.0",
1623
+ "dependencies": {
1624
+ "safe-buffer": "^5.0.1"
1625
+ },
1626
+ "engines": {
1627
+ "node": "*"
1628
+ }
1629
+ },
1630
+ "node_modules/type-is": {
1631
+ "version": "1.6.18",
1632
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
1633
+ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
1634
+ "license": "MIT",
1635
+ "dependencies": {
1636
+ "media-typer": "0.3.0",
1637
+ "mime-types": "~2.1.24"
1638
+ },
1639
+ "engines": {
1640
+ "node": ">= 0.6"
1641
+ }
1642
+ },
1643
+ "node_modules/unpipe": {
1644
+ "version": "1.0.0",
1645
+ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
1646
+ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
1647
+ "license": "MIT",
1648
+ "engines": {
1649
+ "node": ">= 0.8"
1650
+ }
1651
+ },
1652
+ "node_modules/util-deprecate": {
1653
+ "version": "1.0.2",
1654
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
1655
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
1656
+ "license": "MIT"
1657
+ },
1658
+ "node_modules/utils-merge": {
1659
+ "version": "1.0.1",
1660
+ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
1661
+ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
1662
+ "license": "MIT",
1663
+ "engines": {
1664
+ "node": ">= 0.4.0"
1665
+ }
1666
+ },
1667
+ "node_modules/vary": {
1668
+ "version": "1.1.2",
1669
+ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
1670
+ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
1671
+ "license": "MIT",
1672
+ "engines": {
1673
+ "node": ">= 0.8"
1674
+ }
1675
+ },
1676
+ "node_modules/wrappy": {
1677
+ "version": "1.0.2",
1678
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
1679
+ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
1680
+ "license": "ISC"
1681
+ },
1682
+ "node_modules/ws": {
1683
+ "version": "8.21.0",
1684
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
1685
+ "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
1686
+ "license": "MIT",
1687
+ "engines": {
1688
+ "node": ">=10.0.0"
1689
+ },
1690
+ "peerDependencies": {
1691
+ "bufferutil": "^4.0.1",
1692
+ "utf-8-validate": ">=5.0.2"
1693
+ },
1694
+ "peerDependenciesMeta": {
1695
+ "bufferutil": {
1696
+ "optional": true
1697
+ },
1698
+ "utf-8-validate": {
1699
+ "optional": true
1700
+ }
1701
+ }
1702
+ }
1703
+ }
1704
+ }
server/package.json ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "winx_prinx-analytics-server",
3
+ "version": "1.0.0",
4
+ "description": "Cloud backend for Twitch Stream Analytics Dashboard (winx_prinx)",
5
+ "type": "module",
6
+ "main": "server.js",
7
+ "scripts": {
8
+ "start": "node server.js",
9
+ "dev": "node --watch server.js"
10
+ },
11
+ "keywords": [
12
+ "twitch",
13
+ "analytics",
14
+ "whisper",
15
+ "dashboard"
16
+ ],
17
+ "author": "",
18
+ "license": "ISC",
19
+ "dependencies": {
20
+ "@supabase/supabase-js": "^2.49.1",
21
+ "axios": "^1.8.1",
22
+ "better-sqlite3": "^12.10.0",
23
+ "cookie-session": "^2.1.0",
24
+ "cors": "^2.8.5",
25
+ "dotenv": "^16.4.7",
26
+ "express": "^4.21.2",
27
+ "ws": "^8.18.1"
28
+ }
29
+ }
server/server.js ADDED
@@ -0,0 +1,931 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import express from 'express';
2
+ import cors from 'cors';
3
+ import cookieSession from 'cookie-session';
4
+ import dotenv from 'dotenv';
5
+ import axios from 'axios';
6
+ import path from 'path';
7
+ import {
8
+ logChatMessage,
9
+ logVoiceWords,
10
+ logModAction,
11
+ logViewerJoin,
12
+ getStreamsList,
13
+ getTopChatters,
14
+ getSpokenWords,
15
+ getChatMessages,
16
+ getModActionsList,
17
+ getSetting,
18
+ setSetting,
19
+ endActiveStream,
20
+ getStreamMessageTimestamps,
21
+ getModActionsSummaryRaw,
22
+ syncActiveStream,
23
+ getModeratorProfilesData,
24
+ getActiveStream,
25
+ getPendingBackfillStreams,
26
+ markStreamBackfilled,
27
+ checkIfStreamExists,
28
+ resetStreamBackfill,
29
+ deleteStream,
30
+ updateStreamMetadata,
31
+ getSystemStats,
32
+ cleanupGhostStreams
33
+ } from './db.js';
34
+ import { initializeEventSub } from './eventsub.js';
35
+
36
+ dotenv.config();
37
+
38
+ const app = express();
39
+ const PORT = process.env.PORT || 3000;
40
+
41
+ // Configure CORS to allow frontend connections
42
+ const allowedOrigins = [
43
+ 'http://localhost:5173', // Vite React local dev
44
+ 'http://localhost:3000', // Express local serving
45
+ 'https://mimic42.qzz.io', // Custom production domain
46
+ ];
47
+
48
+ if (process.env.ALLOWED_ORIGINS) {
49
+ const envOrigins = process.env.ALLOWED_ORIGINS.split(',').map(o => o.trim());
50
+ allowedOrigins.push(...envOrigins);
51
+ }
52
+
53
+ app.use(cors({
54
+ origin: function (origin, callback) {
55
+ if (!origin || allowedOrigins.indexOf(origin) !== -1 || origin.endsWith('.netlify.app') || origin.endsWith('.onrender.com')) {
56
+ callback(null, true);
57
+ } else {
58
+ callback(new Error('Not allowed by CORS'));
59
+ }
60
+ },
61
+ credentials: true
62
+ }));
63
+
64
+ app.use(express.json());
65
+
66
+ // Session setup
67
+ const isProduction = process.env.NODE_ENV === 'production';
68
+ app.use(cookieSession({
69
+ name: 'twitch-analytics-session',
70
+ keys: [process.env.SESSION_SECRET || 'fallback_secret_key_98765'],
71
+ maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
72
+ secure: isProduction, // Requires HTTPS to allow sameSite: 'none'
73
+ sameSite: isProduction ? 'none' : 'lax'
74
+ }));
75
+
76
+ // Initialize EventSub connection if we already have the streamer token in DB
77
+ initializeEventSub().catch(err => {
78
+ console.error('[EventSub] Startup initialization failed:', err);
79
+ });
80
+
81
+ // =========================================================================
82
+ // MIDDLEWARES
83
+ // =========================================================================
84
+
85
+ // API Key authentication for the local worker
86
+ const authenticateWorker = (req, res, next) => {
87
+ const apiKey = req.headers['x-api-key'];
88
+ if (!apiKey || apiKey !== process.env.API_KEY) {
89
+ return res.status(401).json({ error: 'Unauthorized. Invalid API Key.' });
90
+ }
91
+ next();
92
+ };
93
+
94
+ // Authentication check for dashboard users (streamer / mods)
95
+ const requireModeratorRole = (req, res, next) => {
96
+ const clientId = process.env.TWITCH_CLIENT_ID;
97
+ const clientSecret = process.env.TWITCH_CLIENT_SECRET;
98
+ const twitchConfigured = !!(clientId && clientSecret && !clientId.includes('YOUR_') && !clientSecret.includes('YOUR_'));
99
+
100
+ if (!twitchConfigured) {
101
+ return next(); // Bypass authentication check if Twitch login is not configured
102
+ }
103
+
104
+ if (!req.session || !req.session.user) {
105
+ return res.status(401).json({ error: 'Unauthorized. Please log in.' });
106
+ }
107
+ const role = req.session.user.role;
108
+ if (role !== 'streamer' && role !== 'moderator' && role !== 'admin') {
109
+ return res.status(403).json({ error: 'Forbidden. Access restricted to streamer, admin and moderators.' });
110
+ }
111
+ next();
112
+ };
113
+
114
+ // Authentication check for dashboard administrators (streamer / admins)
115
+ const requireAdminRole = (req, res, next) => {
116
+ const clientId = process.env.TWITCH_CLIENT_ID;
117
+ const clientSecret = process.env.TWITCH_CLIENT_SECRET;
118
+ const twitchConfigured = !!(clientId && clientSecret && !clientId.includes('YOUR_') && !clientSecret.includes('YOUR_'));
119
+
120
+ if (!twitchConfigured) {
121
+ return next(); // Bypass authentication check if Twitch login is not configured
122
+ }
123
+
124
+ if (!req.session || !req.session.user) {
125
+ return res.status(401).json({ error: 'Unauthorized. Please log in.' });
126
+ }
127
+ const role = req.session.user.role;
128
+ if (role !== 'streamer' && role !== 'admin') {
129
+ return res.status(403).json({ error: 'Forbidden. Access restricted to streamer and admins.' });
130
+ }
131
+ next();
132
+ };
133
+ // =========================================================================
134
+ // PUBLIC UTILITY ENDPOINTS
135
+ // =========================================================================
136
+
137
+ // Server health check for frontend connection status
138
+ app.get('/api/health', (req, res) => {
139
+ res.status(200).json({ status: 'ok', uptime: process.uptime() });
140
+ });
141
+
142
+ // Avatar proxy to bypass CORS issues when fetching from decapi.me
143
+ app.get('/api/avatar/:channel', async (req, res) => {
144
+ try {
145
+ const { channel } = req.params;
146
+ // decapi.me returns the URL of the avatar as plain text
147
+ const urlResp = await axios.get(`https://decapi.me/twitch/avatar/${channel}`, { timeout: 5000 });
148
+ const avatarUrl = urlResp.data.trim();
149
+ if (!avatarUrl.startsWith('http')) {
150
+ return res.status(404).send('Avatar not found');
151
+ }
152
+ // Fetch the actual image and stream it back
153
+ const imgResp = await axios.get(avatarUrl, { responseType: 'arraybuffer', timeout: 8000 });
154
+ res.set('Content-Type', imgResp.headers['content-type'] || 'image/png');
155
+ res.set('Cache-Control', 'public, max-age=3600');
156
+ res.send(imgResp.data);
157
+ } catch (err) {
158
+ console.error('[Avatar] Error fetching avatar:', err.message);
159
+ res.status(500).send('Error fetching avatar');
160
+ }
161
+ });
162
+
163
+ // =========================================================================
164
+ // WORKER INGESTION ENDPOINTS (Local Worker -> Server)
165
+ // =========================================================================
166
+
167
+ // Ingest chat messages
168
+ app.post('/api/log/messages', authenticateWorker, async (req, res) => {
169
+ const { messages } = req.body;
170
+ if (!messages || !Array.isArray(messages)) {
171
+ return res.status(400).json({ error: 'Invalid input. Expected array of messages.' });
172
+ }
173
+
174
+ for (const msg of messages) {
175
+ try {
176
+ await logChatMessage(msg);
177
+ } catch (e) {
178
+ console.error('Error logging batch message:', e);
179
+ }
180
+ }
181
+
182
+ res.json({ success: true, count: messages.length });
183
+ });
184
+
185
+ // Endpoint for the Python worker to log viewer joins
186
+ app.post('/api/log/viewers/join', authenticateWorker, async (req, res) => {
187
+ const username = req.body.username;
188
+ if (!username) return res.status(400).json({ error: 'No username' });
189
+
190
+ await logViewerJoin(username);
191
+ // We can broadcast this if we want live updates of lurkers,
192
+ // but since there's 1000s, let's just log it.
193
+ res.json({ success: true });
194
+ });
195
+
196
+ // Ingest voice words
197
+ app.post('/api/log/voice', authenticateWorker, async (req, res) => {
198
+ const { words, timestamp } = req.body;
199
+ if (!words || !Array.isArray(words)) {
200
+ return res.status(400).json({ error: 'Invalid input. Expected array of words.' });
201
+ }
202
+
203
+ try {
204
+ await logVoiceWords(words, timestamp);
205
+ res.json({ success: true, count: words.length });
206
+ } catch (err) {
207
+ res.status(500).json({ error: err.message });
208
+ }
209
+ });
210
+
211
+ // Manual/fallback mod action logging
212
+ app.post('/api/log/mod-action', authenticateWorker, async (req, res) => {
213
+ const action = req.body;
214
+ try {
215
+ await logModAction(action);
216
+ res.json({ success: true });
217
+ } catch (err) {
218
+ res.status(500).json({ error: err.message });
219
+ }
220
+ });
221
+
222
+ // Explicit end stream command
223
+ app.post('/api/log/stream-end', authenticateWorker, async (req, res) => {
224
+ try {
225
+ await endActiveStream();
226
+ res.json({ success: true });
227
+ } catch (err) {
228
+ res.status(500).json({ error: err.message });
229
+ }
230
+ });
231
+
232
+ // GET pending backfills list
233
+ app.get('/api/streams/pending-backfill', authenticateWorker, async (req, res) => {
234
+ try {
235
+ const streams = await getPendingBackfillStreams();
236
+ res.json({ success: true, streams });
237
+ } catch (err) {
238
+ res.status(500).json({ error: err.message });
239
+ }
240
+ });
241
+
242
+ // POST mark stream as backfilled
243
+ app.post('/api/streams/mark-backfilled', authenticateWorker, async (req, res) => {
244
+ const { streamId, twitchStreamId } = req.body;
245
+ try {
246
+ await markStreamBackfilled(streamId, twitchStreamId);
247
+ res.json({ success: true });
248
+ } catch (err) {
249
+ res.status(500).json({ error: err.message });
250
+ }
251
+ });
252
+
253
+ // POST reset stream backfill status (admin/moderator controls)
254
+ app.post('/api/streams/reset-backfill', requireModeratorRole, async (req, res) => {
255
+ const { streamId, mode } = req.body;
256
+ if (!streamId) {
257
+ return res.status(400).json({ error: 'Missing streamId parameter.' });
258
+ }
259
+ try {
260
+ await resetStreamBackfill(parseInt(streamId), mode || 'gap_fill');
261
+ res.json({ success: true });
262
+ } catch (err) {
263
+ res.status(500).json({ error: err.message });
264
+ }
265
+ });
266
+
267
+ // DELETE stream (admin controls)
268
+ app.delete('/api/streams/:id', requireAdminRole, async (req, res) => {
269
+ const streamId = parseInt(req.params.id);
270
+ if (isNaN(streamId)) {
271
+ return res.status(400).json({ error: 'Invalid stream ID.' });
272
+ }
273
+ try {
274
+ await deleteStream(streamId);
275
+ res.json({ success: true });
276
+ } catch (err) {
277
+ res.status(500).json({ error: err.message });
278
+ }
279
+ });
280
+
281
+ // PUT stream metadata (admin controls)
282
+ app.put('/api/streams/:id', requireAdminRole, async (req, res) => {
283
+ const streamId = parseInt(req.params.id);
284
+ const { title, category } = req.body;
285
+ if (isNaN(streamId)) {
286
+ return res.status(400).json({ error: 'Invalid stream ID.' });
287
+ }
288
+ if (!title) {
289
+ return res.status(400).json({ error: 'Title is required.' });
290
+ }
291
+ try {
292
+ await updateStreamMetadata(streamId, title, category || '');
293
+ res.json({ success: true });
294
+ } catch (err) {
295
+ res.status(500).json({ error: err.message });
296
+ }
297
+ });
298
+
299
+ // POST clean up empty streams (admin controls)
300
+ app.post('/api/admin/cleanup', requireAdminRole, async (req, res) => {
301
+ try {
302
+ const deletedCount = await cleanupGhostStreams();
303
+ res.json({ success: true, count: deletedCount });
304
+ } catch (err) {
305
+ res.status(500).json({ error: err.message });
306
+ }
307
+ });
308
+
309
+ // GET system statistics (admin controls)
310
+ app.get('/api/admin/stats', requireAdminRole, async (req, res) => {
311
+ try {
312
+ const stats = await getSystemStats();
313
+ res.json({ success: true, stats });
314
+ } catch (err) {
315
+ res.status(500).json({ error: err.message });
316
+ }
317
+ });
318
+
319
+ // =========================================================================
320
+ // TWITCH OAUTH ENDPOINTS
321
+ // =========================================================================
322
+
323
+ // Redirect to Twitch Authorization Screen
324
+ app.get('/api/auth/twitch', (req, res) => {
325
+ const clientId = process.env.TWITCH_CLIENT_ID;
326
+ const redirectUri = process.env.TWITCH_REDIRECT_URI;
327
+
328
+ // Scopes required:
329
+ // - user:read:email (basic user identity)
330
+ // - channel:moderate (to listen to event sub)
331
+ // - moderation:read (to read moderators lists)
332
+ const scopes = 'user:read:email channel:moderate moderation:read';
333
+
334
+ const twitchAuthUrl = `https://id.twitch.tv/oauth2/authorize` +
335
+ `?client_id=${clientId}` +
336
+ `&redirect_uri=${encodeURIComponent(redirectUri)}` +
337
+ `&response_type=code` +
338
+ `&scope=${encodeURIComponent(scopes)}`;
339
+
340
+ res.redirect(twitchAuthUrl);
341
+ });
342
+
343
+ // Twitch Auth Callback
344
+ app.get('/api/auth/twitch/callback', async (req, res) => {
345
+ const { code, error } = req.query;
346
+
347
+ if (error) {
348
+ console.error('Twitch OAuth error callback:', error);
349
+ return res.redirect('/auth-failed');
350
+ }
351
+
352
+ try {
353
+ // Exchange Auth Code for Access Token
354
+ const tokenResponse = await axios.post('https://id.twitch.tv/oauth2/token', null, {
355
+ params: {
356
+ client_id: process.env.TWITCH_CLIENT_ID,
357
+ client_secret: process.env.TWITCH_CLIENT_SECRET,
358
+ code,
359
+ grant_type: 'authorization_code',
360
+ redirect_uri: process.env.TWITCH_REDIRECT_URI
361
+ }
362
+ });
363
+
364
+ const { access_token, refresh_token } = tokenResponse.data;
365
+
366
+ // Fetch User Info from Twitch API
367
+ const userResponse = await axios.get('https://api.twitch.tv/helix/users', {
368
+ headers: {
369
+ 'Client-ID': process.env.TWITCH_CLIENT_ID,
370
+ 'Authorization': `Bearer ${access_token}`
371
+ }
372
+ });
373
+
374
+ const twitchUser = userResponse.data.data[0];
375
+ const username = twitchUser.login.toLowerCase();
376
+ const displayName = twitchUser.display_name;
377
+ const userId = twitchUser.id;
378
+
379
+ const targetChannel = process.env.TWITCH_CHANNEL.toLowerCase();
380
+ let role = 'viewer';
381
+
382
+ const adminUsernames = (process.env.ADMIN_USERNAMES || '')
383
+ .toLowerCase()
384
+ .split(',')
385
+ .map(name => name.trim())
386
+ .filter(Boolean);
387
+
388
+ if (adminUsernames.includes(username)) {
389
+ role = 'admin';
390
+ } else if (username === targetChannel) {
391
+ // User is the Streamer
392
+ role = 'streamer';
393
+
394
+ // Save Streamer's tokens and ID to the DB settings
395
+ await setSetting('twitch_broadcaster_id', userId);
396
+ await setSetting('twitch_streamer_access_token', access_token);
397
+ await setSetting('twitch_streamer_refresh_token', refresh_token);
398
+
399
+ console.log(`[Auth] Streamer ${displayName} logged in. Triggering EventSub subscription.`);
400
+ // Start/Restart EventSub using this session
401
+ await initializeEventSub(userId);
402
+ } else {
403
+ // Check if user is a moderator by querying Twitch API via Streamer's token
404
+ const streamerToken = await getSetting('twitch_streamer_access_token');
405
+ const broadcasterId = await getSetting('twitch_broadcaster_id');
406
+
407
+ if (streamerToken && broadcasterId) {
408
+ try {
409
+ const modCheckResponse = await axios.get('https://api.twitch.tv/helix/moderators', {
410
+ headers: {
411
+ 'Client-ID': process.env.TWITCH_CLIENT_ID,
412
+ 'Authorization': `Bearer ${streamerToken}`
413
+ },
414
+ params: {
415
+ broadcaster_id: broadcasterId,
416
+ user_id: userId
417
+ }
418
+ });
419
+
420
+ const isMod = modCheckResponse.data.data.length > 0;
421
+ if (isMod) {
422
+ role = 'moderator';
423
+ }
424
+ } catch (modErr) {
425
+ console.error('[Auth] Error checking moderator status:', modErr.response?.data || modErr.message);
426
+ }
427
+ } else {
428
+ console.warn('[Auth] Streamer tokens not available in DB. Cannot verify moderator status.');
429
+ }
430
+ }
431
+
432
+ // Save in session cookie
433
+ req.session.user = {
434
+ id: userId,
435
+ username,
436
+ displayName,
437
+ role
438
+ };
439
+
440
+ console.log(`[Auth] User ${displayName} logged in. Role: ${role}`);
441
+
442
+ // Redirect to frontend (in local dev it could redirect to port 5173, in prod to root)
443
+ const frontendRedirect = process.env.NODE_ENV === 'production'
444
+ ? '/'
445
+ : 'http://localhost:5173/';
446
+
447
+ res.redirect(frontendRedirect);
448
+ } catch (err) {
449
+ console.error('Error during Twitch OAuth callback:', err.response?.data || err.message);
450
+ res.status(500).send('Authentication failed.');
451
+ }
452
+ });
453
+
454
+ // Auth Status Endpoint
455
+ app.get('/api/auth/status', (req, res) => {
456
+ const clientId = process.env.TWITCH_CLIENT_ID;
457
+ const clientSecret = process.env.TWITCH_CLIENT_SECRET;
458
+ const twitchConfigured = !!(clientId && clientSecret && !clientId.includes('YOUR_') && !clientSecret.includes('YOUR_'));
459
+
460
+ if (req.session && req.session.user) {
461
+ res.json({ loggedIn: true, user: req.session.user, twitchConfigured });
462
+ } else {
463
+ res.json({ loggedIn: false, twitchConfigured });
464
+ }
465
+ });
466
+
467
+ // Logout Endpoint
468
+ app.get('/api/auth/logout', (req, res) => {
469
+ req.session = null;
470
+ res.json({ success: true });
471
+ });
472
+
473
+ // =========================================================================
474
+ // PUBLIC STATISTICS API ENDPOINTS
475
+ // =========================================================================
476
+
477
+ // Get list of streams
478
+ app.get('/api/streams', async (req, res) => {
479
+ const streams = await getStreamsList();
480
+ res.json(streams);
481
+ });
482
+
483
+ // Get top active chatters
484
+ app.get('/api/stats/chatters', async (req, res) => {
485
+ const streamId = req.query.stream_id ? parseInt(req.query.stream_id) : null;
486
+ const limit = req.query.limit ? parseInt(req.query.limit) : 50;
487
+
488
+ const chatters = await getTopChatters(streamId, limit);
489
+ res.json(chatters);
490
+ });
491
+
492
+ // Stop words arrays (Russian and English) to filter out common articles, prepositions
493
+ const STOP_WORDS = new Set([
494
+ // Russian
495
+ 'и', 'в', 'во', 'не', 'что', 'он', 'на', 'я', 'с', 'со', 'как', 'а', 'то', 'все', 'она', 'так',
496
+ 'его', 'но', 'да', 'ты', 'к', 'у', 'же', 'вы', 'за', 'бы', 'по', 'только', 'ее', 'мне', 'было',
497
+ 'вот', 'от', 'меня', 'еще', 'нет', 'о', 'из', 'ему', 'же', 'им', 'имхо', 'это', 'этот', 'эта',
498
+ 'эти', 'этого', 'себя', 'свой', 'свои', 'или', 'был', 'была', 'были', 'быть', 'когда', 'кто',
499
+ 'который', 'для', 'чтобы', 'если', 'через', 'после', 'даже', 'же', 'ни', 'там', 'тут', 'где',
500
+ // Extra Russian non-independent words and pronouns (length >= 3)
501
+ 'тебя', 'тебе', 'себе', 'меня', 'этого', 'этому', 'этом', 'этой', 'этими', 'него', 'нее', 'ними',
502
+ 'будет', 'будут', 'было', 'были', 'была', 'быть', 'своих', 'своим', 'своей', 'тоже', 'очень',
503
+ 'вообще', 'какой', 'какая', 'какое', 'какие', 'чтото', 'что-то', 'хочу', 'хочешь', 'хочет',
504
+ 'хотят', 'буду', 'будешь', 'будем', 'будете', 'знаю', 'знаешь', 'знает', 'знают', 'эту', 'эти',
505
+ 'этим', 'этих', 'здесь', 'туда', 'оттуда', 'сюда', 'потому', 'почему', 'зачем', 'какойто',
506
+ 'какой-то', 'под', 'про', 'без', 'вас', 'вам', 'нас', 'нам', 'ими', 'они', 'ней', 'нем',
507
+ 'ваши', 'наши', 'своем', 'этого',
508
+ // English
509
+ 'the', 'a', 'an', 'and', 'or', 'but', 'to', 'of', 'in', 'on', 'at', 'by', 'for', 'with', 'about',
510
+ 'against', 'between', 'into', 'through', 'during', 'before', 'after', 'above', 'below', 'from',
511
+ 'up', 'down', 'is', 'are', 'was', 'were', 'be', 'been', 'being', 'have', 'has', 'had', 'having',
512
+ 'do', 'does', 'did', 'doing', 'i', 'you', 'he', 'she', 'it', 'we', 'they', 'me', 'him', 'her',
513
+ 'us', 'them', 'my', 'your', 'his', 'their', 'this', 'that', 'these', 'those', 'then', 'there',
514
+ 'here', 'whats', 'what', 'which', 'who', 'whom', 'whose', 'why', 'how', 'can', 'will', 'just'
515
+ ]);
516
+
517
+ // Get word frequencies (Voice vs Chat messages from streamer)
518
+ app.get('/api/stats/words', async (req, res) => {
519
+ const streamId = req.query.stream_id ? parseInt(req.query.stream_id) : null;
520
+ const limit = req.query.limit ? parseInt(req.query.limit) : 50;
521
+ const type = req.query.type || 'voice'; // 'voice' or 'chat'
522
+
523
+ if (type === 'voice') {
524
+ // Spoken words from voice database table. Get extra words first to allow filtering stop words.
525
+ const rawWords = await getSpokenWords(streamId, limit * 10);
526
+ const wordCounts = {};
527
+
528
+ for (const w of rawWords) {
529
+ if (!w || !w.word || typeof w.word !== 'string') continue;
530
+ let clean = w.word.toLowerCase().trim();
531
+ if (clean === 'блядь') {
532
+ clean = 'блять';
533
+ }
534
+
535
+ const isAllowedLength = clean.length >= 3 || clean === 'ну';
536
+ if (isAllowedLength && !STOP_WORDS.has(clean)) {
537
+ wordCounts[clean] = (wordCounts[clean] || 0) + w.word_count;
538
+ }
539
+ }
540
+
541
+ const sortedWords = Object.entries(wordCounts)
542
+ .map(([word, count]) => ({ word, word_count: count }))
543
+ .sort((a, b) => b.word_count - a.word_count)
544
+ .slice(0, limit);
545
+
546
+ return res.json(sortedWords);
547
+ } else if (type === 'chat') {
548
+ // Written words in chat by viewers
549
+ const messages = await getChatMessages(streamId);
550
+
551
+ // Build a set of all user names from chatters in this stream (nickname filter)
552
+ const chatters = new Set();
553
+ const targetChannel = (process.env.TWITCH_CHANNEL || 'winx_prinx').toLowerCase();
554
+ chatters.add(targetChannel);
555
+ chatters.add('winx');
556
+ chatters.add('prinx');
557
+
558
+ for (const msg of messages) {
559
+ if (msg.username) {
560
+ chatters.add(msg.username.toLowerCase().trim());
561
+ }
562
+ if (msg.display_name) {
563
+ chatters.add(msg.display_name.toLowerCase().trim());
564
+ }
565
+ if (msg.displayName) {
566
+ chatters.add(msg.displayName.toLowerCase().trim());
567
+ }
568
+ }
569
+
570
+ // Process words in JS
571
+ const wordCounts = {};
572
+ for (const msg of messages) {
573
+ if (!msg || !msg.message || typeof msg.message !== 'string') continue;
574
+ const tokens = msg.message
575
+ .toLowerCase()
576
+ .replace(/[.,\/#!$%\^&\*;:{}=\-_`~()?]/g, ' ') // replace punctuation with space
577
+ .split(/\s+/);
578
+
579
+ for (let word of tokens) {
580
+ word = word.trim();
581
+ // Remove mention @ prefix if present
582
+ if (word.startsWith('@')) {
583
+ word = word.slice(1);
584
+ }
585
+ if (!word) continue;
586
+
587
+ if (word === 'блядь') {
588
+ word = 'блять';
589
+ }
590
+
591
+ const isAllowedLength = word.length >= 3 || word === 'ну';
592
+ const isNickname = chatters.has(word);
593
+
594
+ if (isAllowedLength && !isNickname && !STOP_WORDS.has(word) && !word.startsWith('http') && !word.startsWith('www')) {
595
+ wordCounts[word] = (wordCounts[word] || 0) + 1;
596
+ }
597
+ }
598
+ }
599
+
600
+ const sortedWords = Object.entries(wordCounts)
601
+ .map(([word, count]) => ({ word, word_count: count }))
602
+ .sort((a, b) => b.word_count - a.word_count)
603
+ .slice(0, limit);
604
+
605
+ return res.json(sortedWords);
606
+ } else {
607
+ res.status(400).json({ error: "Invalid type. Must be 'voice' or 'chat'." });
608
+ }
609
+ });
610
+
611
+ // Get chat activity (messages count grouped by 5-minute intervals)
612
+ app.get('/api/stats/activity', async (req, res) => {
613
+ const streamId = req.query.stream_id ? parseInt(req.query.stream_id) : null;
614
+
615
+ if (!streamId) {
616
+ return res.status(400).json({ error: 'Missing stream_id query parameter.' });
617
+ }
618
+
619
+ // Get timestamps of all messages in this stream
620
+ const messages = await getStreamMessageTimestamps(streamId);
621
+
622
+ if (!messages || messages.length === 0) {
623
+ return res.json([]);
624
+ }
625
+
626
+ // Group into 5-minute buckets
627
+ const intervalMs = 5 * 60 * 1000;
628
+ const buckets = {};
629
+
630
+ messages.forEach(msg => {
631
+ const timeMs = new Date(msg.timestamp).getTime();
632
+ const roundedTime = new Date(Math.floor(timeMs / intervalMs) * intervalMs);
633
+ const bucketKey = roundedTime.toISOString();
634
+ buckets[bucketKey] = (buckets[bucketKey] || 0) + 1;
635
+ });
636
+
637
+ const chartData = Object.entries(buckets).map(([timestamp, count]) => ({
638
+ timestamp,
639
+ message_count: count
640
+ }));
641
+
642
+ res.json(chartData);
643
+ });
644
+
645
+ // =========================================================================
646
+ // PROTECTED MODERATOR API ENDPOINTS (Require logged in mod/streamer)
647
+ // =========================================================================
648
+
649
+ // Get log of moderator actions
650
+ app.get('/api/stats/moderators', requireModeratorRole, async (req, res) => {
651
+ const streamId = req.query.stream_id ? parseInt(req.query.stream_id) : null;
652
+ const limit = req.query.limit ? parseInt(req.query.limit) : 100;
653
+
654
+ const actions = await getModActionsList(streamId, limit);
655
+ res.json(actions);
656
+ });
657
+
658
+ // Get summary counts of actions per moderator
659
+ app.get('/api/stats/moderators/summary', requireModeratorRole, async (req, res) => {
660
+ const streamId = req.query.stream_id ? parseInt(req.query.stream_id) : null;
661
+
662
+ const data = await getModActionsSummaryRaw(streamId);
663
+
664
+ // Aggregate in JS: { moderator_name: { ban: X, timeout: Y, delete: Z, total: T } }
665
+ const summary = {};
666
+
667
+ data.forEach(act => {
668
+ const mod = act.moderator;
669
+ const type = act.action_type;
670
+
671
+ if (!summary[mod]) {
672
+ summary[mod] = { ban: 0, timeout: 0, delete: 0, unban: 0, total: 0 };
673
+ }
674
+
675
+ if (type in summary[mod]) {
676
+ summary[mod][type]++;
677
+ }
678
+ summary[mod].total++;
679
+ });
680
+
681
+ const responseData = Object.entries(summary).map(([moderator, stats]) => ({
682
+ moderator,
683
+ ...stats
684
+ })).sort((a, b) => b.total - a.total);
685
+
686
+ res.json(responseData);
687
+ });
688
+
689
+ // GET aggregated profiles with scores for moderators
690
+ app.get('/api/stats/moderators/profiles', async (req, res) => {
691
+ const streamId = req.query.stream_id ? parseInt(req.query.stream_id) : null;
692
+ const profiles = await getModeratorProfilesData(streamId);
693
+ res.json(profiles);
694
+ });
695
+
696
+ // POST endpoint to sync past VODs from Twitch
697
+ app.post('/api/streams/sync-vods', async (req, res) => {
698
+ const clientId = process.env.TWITCH_CLIENT_ID;
699
+ const clientSecret = process.env.TWITCH_CLIENT_SECRET;
700
+ const channelName = process.env.TWITCH_CHANNEL || 'winx_prinx';
701
+
702
+ if (!clientId || !clientSecret || clientId.includes('YOUR_') || clientSecret.includes('YOUR_')) {
703
+ return res.status(400).json({ error: 'Twitch Developer credentials are not configured in бэкенд .env' });
704
+ }
705
+
706
+ try {
707
+ // 1. Get App Access Token
708
+ const tokenRes = await axios.post(`https://id.twitch.tv/oauth2/token?client_id=${clientId}&client_secret=${clientSecret}&grant_type=client_credentials`);
709
+ const accessToken = tokenRes.data.access_token;
710
+
711
+ // 2. Get user ID
712
+ const userRes = await axios.get(`https://api.twitch.tv/helix/users?login=${channelName}`, {
713
+ headers: {
714
+ 'Client-ID': clientId,
715
+ 'Authorization': `Bearer ${accessToken}`
716
+ }
717
+ });
718
+
719
+ if (!userRes.data.data || userRes.data.data.length === 0) {
720
+ return res.status(404).json({ error: 'User not found on Twitch.' });
721
+ }
722
+ const userId = userRes.data.data[0].id;
723
+
724
+ // 3. Get VOD list
725
+ const vodRes = await axios.get(`https://api.twitch.tv/helix/videos?user_id=${userId}&type=archive&first=20`, {
726
+ headers: {
727
+ 'Client-ID': clientId,
728
+ 'Authorization': `Bearer ${accessToken}`
729
+ }
730
+ });
731
+ const vods = vodRes.data.data || [];
732
+
733
+ const syncedStreams = [];
734
+ for (const vod of vods) {
735
+ const durationStr = vod.duration;
736
+ let durationMs = 0;
737
+ const hoursMatch = durationStr.match(/(\d+)h/);
738
+ const minsMatch = durationStr.match(/(\d+)m/);
739
+ const secsMatch = durationStr.match(/(\d+)s/);
740
+ if (hoursMatch) durationMs += parseInt(hoursMatch[1]) * 60 * 60 * 1000;
741
+ if (minsMatch) durationMs += parseInt(minsMatch[1]) * 60 * 1000;
742
+ if (secsMatch) durationMs += parseInt(secsMatch[1]) * 1000;
743
+
744
+ const startTime = new Date(vod.created_at);
745
+ const endTime = new Date(startTime.getTime() + durationMs).toISOString();
746
+
747
+ // Creates or restores the stream
748
+ const stream = await syncActiveStream(vod.stream_id || `vod-${vod.id}`, vod.title, '', vod.created_at);
749
+ if (stream) {
750
+ await endActiveStream(stream.id, endTime, vod.id);
751
+ syncedStreams.push({
752
+ id: stream.id,
753
+ twitch_stream_id: vod.stream_id || `vod-${vod.id}`,
754
+ title: vod.title,
755
+ start_time: vod.created_at,
756
+ end_time: endTime,
757
+ twitch_vod_url: vod.url
758
+ });
759
+ }
760
+ }
761
+
762
+ res.json({ success: true, count: syncedStreams.length, streams: syncedStreams });
763
+ } catch (err) {
764
+ console.error('[Twitch API] Error syncing VODs:', err.message);
765
+ res.status(500).json({ error: err.message });
766
+ }
767
+ });
768
+
769
+ // POST endpoint for local worker to sync VOD details directly
770
+ app.post('/api/streams/sync-vod-direct', authenticateWorker, async (req, res) => {
771
+ const { twitchStreamId, title, category, startTime, endTime } = req.body;
772
+ try {
773
+ const stream = await syncActiveStream(twitchStreamId, title, category || 'Архив', startTime);
774
+ if (stream) {
775
+ const numericVodId = twitchStreamId ? twitchStreamId.replace('vod-', '') : null;
776
+ await endActiveStream(stream.id, endTime, numericVodId);
777
+ return res.json({ success: true, streamId: stream.id });
778
+ }
779
+ res.status(500).json({ error: 'Failed to sync stream session' });
780
+ } catch (err) {
781
+ res.status(500).json({ error: err.message });
782
+ }
783
+ });
784
+
785
+ // Periodic Twitch Stream Status Polling
786
+ async function pollTwitchStreamStatus() {
787
+ const clientId = process.env.TWITCH_CLIENT_ID;
788
+ const clientSecret = process.env.TWITCH_CLIENT_SECRET;
789
+ const channelName = process.env.TWITCH_CHANNEL || 'winx_prinx';
790
+
791
+ if (!clientId || !clientSecret || clientId.includes('YOUR_') || clientSecret.includes('YOUR_')) {
792
+ // Silent skip in local test mode
793
+ return;
794
+ }
795
+
796
+ try {
797
+ // 1. Get App Access Token
798
+ const tokenRes = await axios.post(`https://id.twitch.tv/oauth2/token?client_id=${clientId}&client_secret=${clientSecret}&grant_type=client_credentials`);
799
+ const accessToken = tokenRes.data.access_token;
800
+
801
+ // 2. Query Stream Status
802
+ const streamRes = await axios.get(`https://api.twitch.tv/helix/streams?user_login=${channelName}`, {
803
+ headers: {
804
+ 'Client-ID': clientId,
805
+ 'Authorization': `Bearer ${accessToken}`
806
+ }
807
+ });
808
+
809
+ const streamData = streamRes.data.data && streamRes.data.data[0];
810
+ if (streamData) {
811
+ const twitchStreamId = streamData.id;
812
+ const title = streamData.title;
813
+ const category = streamData.game_name;
814
+ const startedAt = streamData.started_at;
815
+
816
+ await syncActiveStream(twitchStreamId, title, category, startedAt);
817
+ } else {
818
+ // Offline: close active stream if we have one with twitch_stream_id
819
+ const active = await getActiveStream();
820
+ let userId = null;
821
+ let userFetched = false;
822
+
823
+ if (active && active.twitch_stream_id) {
824
+ let endTime = new Date().toISOString();
825
+ try {
826
+ // Find user ID
827
+ const userRes = await axios.get(`https://api.twitch.tv/helix/users?login=${channelName}`, {
828
+ headers: { 'Client-ID': clientId, 'Authorization': `Bearer ${accessToken}` }
829
+ });
830
+ userId = userRes.data.data[0].id;
831
+ userFetched = true;
832
+
833
+ // Find VOD list to compute duration
834
+ const vodRes = await axios.get(`https://api.twitch.tv/helix/videos?user_id=${userId}&type=archive`, {
835
+ headers: { 'Client-ID': clientId, 'Authorization': `Bearer ${accessToken}` }
836
+ });
837
+ let lastVodId = null;
838
+ const lastVod = vodRes.data.data && vodRes.data.data[0];
839
+ if (lastVod && lastVod.stream_id === active.twitch_stream_id) {
840
+ lastVodId = lastVod.id;
841
+ const durationStr = lastVod.duration;
842
+ let durationMs = 0;
843
+ const hoursMatch = durationStr.match(/(\d+)h/);
844
+ const minsMatch = durationStr.match(/(\d+)m/);
845
+ const secsMatch = durationStr.match(/(\d+)s/);
846
+ if (hoursMatch) durationMs += parseInt(hoursMatch[1]) * 60 * 60 * 1000;
847
+ if (minsMatch) durationMs += parseInt(minsMatch[1]) * 60 * 1000;
848
+ if (secsMatch) durationMs += parseInt(secsMatch[1]) * 1000;
849
+
850
+ const start = new Date(active.start_time);
851
+ endTime = new Date(start.getTime() + durationMs).toISOString();
852
+ }
853
+ await endActiveStream(active.id, endTime, lastVodId);
854
+ } catch (vodErr) {
855
+ await endActiveStream(active.id, endTime);
856
+ }
857
+ }
858
+
859
+ // Auto-scan recent VODs to find any streams we might have missed (e.g. backend was down)
860
+ try {
861
+ if (!userFetched) {
862
+ const userRes = await axios.get(`https://api.twitch.tv/helix/users?login=${channelName}`, {
863
+ headers: { 'Client-ID': clientId, 'Authorization': `Bearer ${accessToken}` }
864
+ });
865
+ if (userRes.data.data && userRes.data.data.length > 0) {
866
+ userId = userRes.data.data[0].id;
867
+ userFetched = true;
868
+ }
869
+ }
870
+
871
+ if (userId) {
872
+ const vodRes = await axios.get(`https://api.twitch.tv/helix/videos?user_id=${userId}&type=archive&first=10`, {
873
+ headers: { 'Client-ID': clientId, 'Authorization': `Bearer ${accessToken}` }
874
+ });
875
+ const vods = vodRes.data.data || [];
876
+ for (const vod of vods) {
877
+ const streamIdToCheck = vod.stream_id || `vod-${vod.id}`;
878
+ const exists = await checkIfStreamExists(streamIdToCheck);
879
+ if (!exists) {
880
+ console.log(`[Twitch Sync] Auto-discovered missing stream VOD: ${vod.title} (${streamIdToCheck})`);
881
+
882
+ // Calculate duration and end time
883
+ const durationStr = vod.duration;
884
+ let durationMs = 0;
885
+ const hoursMatch = durationStr.match(/(\d+)h/);
886
+ const minsMatch = durationStr.match(/(\d+)m/);
887
+ const secsMatch = durationStr.match(/(\d+)s/);
888
+ if (hoursMatch) durationMs += parseInt(hoursMatch[1]) * 60 * 60 * 1000;
889
+ if (minsMatch) durationMs += parseInt(minsMatch[1]) * 60 * 1000;
890
+ if (secsMatch) durationMs += parseInt(secsMatch[1]) * 1000;
891
+
892
+ const startTime = new Date(vod.created_at);
893
+ const endTime = new Date(startTime.getTime() + durationMs).toISOString();
894
+
895
+ // Register and immediately end it as 'pending' for local backfiller to pick up
896
+ const stream = await syncActiveStream(streamIdToCheck, vod.title, '', vod.created_at);
897
+ if (stream) {
898
+ await endActiveStream(stream.id, endTime, vod.id);
899
+ }
900
+ }
901
+ }
902
+ }
903
+ } catch (scanErr) {
904
+ console.error('[Twitch API] Error auto-syncing offline VODs:', scanErr.message);
905
+ }
906
+ }
907
+ } catch (err) {
908
+ console.error('[Twitch API] pollTwitchStreamStatus error:', err.message);
909
+ }
910
+ }
911
+
912
+ // Serve static frontend files in production
913
+ if (process.env.NODE_ENV === 'production') {
914
+ const __dirname = new URL('.', import.meta.url).pathname;
915
+ // Express static client serve
916
+ app.use(express.static('../client/dist'));
917
+ app.get('*', (req, res) => {
918
+ res.sendFile(path.resolve(__dirname, '../client/dist', 'index.html'));
919
+ });
920
+ }
921
+
922
+ // Start Server
923
+ app.listen(PORT, () => {
924
+ console.log(`[Server] Running on http://localhost:${PORT}`);
925
+ console.log(`[Server] Secure API Key: ${process.env.API_KEY ? 'CONFIGURED' : 'MISSING'}`);
926
+ console.log(`[Server] Environment: ${process.env.NODE_ENV || 'development'}`);
927
+
928
+ // Start stream status polling task (every 2 minutes)
929
+ pollTwitchStreamStatus();
930
+ setInterval(pollTwitchStreamStatus, 2 * 60 * 1000);
931
+ });