Spaces:
Sleeping
Sleeping
Sasha commited on
Commit ·
d9a03db
0
Parent(s):
feat: prepare transition to decoupled hosting (Render + Netlify + Supabase)
Browse files- .gitignore +43 -0
- README.md +14 -0
- client/.env.example +2 -0
- client/.gitignore +24 -0
- client/README.md +16 -0
- client/eslint.config.js +21 -0
- client/index.html +17 -0
- client/package-lock.json +2494 -0
- client/package.json +30 -0
- client/public/favicon.svg +1 -0
- client/public/icons.svg +24 -0
- client/src/App.css +184 -0
- client/src/App.jsx +1930 -0
- client/src/assets/hero.png +0 -0
- client/src/assets/react.svg +1 -0
- client/src/assets/vite.svg +1 -0
- client/src/index.css +1086 -0
- client/src/main.jsx +10 -0
- client/vite.config.js +16 -0
- local_worker/.env.example +27 -0
- local_worker/chat_mod_worker.py +413 -0
- local_worker/mock_sender.py +177 -0
- local_worker/processed_chat_streams.txt +11 -0
- local_worker/requirements.txt +5 -0
- local_worker/seed_lurkers.py +55 -0
- local_worker/seed_real_lurkers.py +58 -0
- local_worker/vod_backfiller.py +407 -0
- local_worker/worker.py +563 -0
- server/.env.example +26 -0
- server/Dockerfile +21 -0
- server/db.js +1404 -0
- server/eventsub.js +240 -0
- server/init_db.sql +138 -0
- server/package-lock.json +1704 -0
- server/package.json +29 -0
- server/server.js +931 -0
.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 |
+
});
|