diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2b99525 --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +# Скопируйте в `.env` и при необходимости измените. +# Клиентский бандл: после правок выполните `npm run env:sync`. + +# База API (путь относительно текущего origin в браузере, для file:// — склеивается с SG_API_FALLBACK_ORIGIN) +SG_API_BASE_PATH=/api/v1 + +# Origin для разрешения относительных URL (HLS playlist и т.д.) при открытии приложения как file:// или вне основного хоста +SG_API_FALLBACK_ORIGIN=https://sparkguardian.ru + +# Только dev-сервер (`ng serve`): куда проксировать `/api/**` +SG_DEV_PROXY_TARGET=http://sparkguardian.ru:8080 diff --git a/.gitignore b/.gitignore index 854acd5..8e27dd1 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,9 @@ yarn-error.log !.vscode/mcp.json .history/* +# Local env (see .env.example) +.env + # Miscellaneous /.angular/cache .sass-cache/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..f762f44 --- /dev/null +++ b/Makefile @@ -0,0 +1,40 @@ +.DEFAULT_GOAL := help + +.PHONY: help install env-sync start serve build build-dev watch test clean + +help: + @echo "SparkGuardian" + @echo " make install Установить зависимости (npm install)" + @echo " make env-sync Сгенерировать src/environments/environment.ts из .env" + @echo " make start Dev-сервер (ng serve + прокси, перед стартом — env:sync)" + @echo " make serve то же, что start" + @echo " make build Production-сборка (перед сборкой — env:sync)" + @echo " make build-dev Сборка в режиме development" + @echo " make watch ng build --watch (development)" + @echo " make test Unit-тесты (ng test)" + @echo " make clean Удалить dist, out-tsc, кэш Angular" + +install: + npm install + +env-sync: + npm run env:sync + +start serve: + npm start + +build: + npm run build + +build-dev: + npm run env:sync + npx ng build --configuration development + +watch: + npm run watch + +test: + npm test + +clean: + rm -rf dist out-tsc .angular/cache diff --git a/angular.json b/angular.json index 1627544..cf454a8 100644 --- a/angular.json +++ b/angular.json @@ -22,14 +22,27 @@ { "glob": "**/*", "input": "public" + }, + { + "glob": "**/*", + "input": "node_modules/@taiga-ui/icons/src", + "output": "assets/taiga-ui/icons" } ], "styles": [ + "node_modules/@taiga-ui/styles/taiga-ui-theme.less", + "node_modules/@taiga-ui/styles/taiga-ui-fonts.less", "src/styles.css" ] }, "configurations": { "production": { + "fileReplacements": [ + { + "replace": "src/environments/environment.ts", + "with": "src/environments/environment.prod.ts" + } + ], "budgets": [ { "type": "initial", @@ -54,12 +67,16 @@ }, "serve": { "builder": "@angular/build:dev-server", + "options": { + "proxyConfig": "proxy.conf.cjs" + }, "configurations": { "production": { "buildTarget": "sparkguardian:build:production" }, "development": { - "buildTarget": "sparkguardian:build:development" + "buildTarget": "sparkguardian:build:development", + "proxyConfig": "proxy.conf.cjs" } }, "defaultConfiguration": "development" diff --git a/docs/doc_v1.json b/docs/doc_v1.json index 9faa529..9793aa7 100644 --- a/docs/doc_v1.json +++ b/docs/doc_v1.json @@ -162,6 +162,64 @@ } } }, + "/sessions/{id}/events": { + "get": { + "security": [ + { + "ForwardAuth": [] + } + ], + "description": "Downloads encrypted .bin chunks from S3, decrypts, and returns parsed events with optional time range filtering.", + "produces": [ + "application/json" + ], + "tags": [ + "telemetry" + ], + "summary": "Get parsed telemetry events", + "parameters": [ + { + "type": "integer", + "description": "Session ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Start timestamp (Unix ms)", + "name": "from", + "in": "query" + }, + { + "type": "integer", + "description": "End timestamp (Unix ms)", + "name": "to", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.ParsedEventsResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handler.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handler.ErrorResponse" + } + } + } + } + }, "/sessions/{id}/playlist/{stream_type}.m3u8": { "get": { "security": [ @@ -448,6 +506,44 @@ } } }, + "handler.ParsedEvent": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "integer" + } + }, + "event_type": { + "type": "string", + "example": "keyboard" + }, + "timestamp": { + "type": "integer", + "example": 1711360200000 + } + } + }, + "handler.ParsedEventsResponse": { + "type": "object", + "properties": { + "count": { + "type": "integer", + "example": 42 + }, + "events": { + "type": "array", + "items": { + "$ref": "#/definitions/handler.ParsedEvent" + } + }, + "session_id": { + "type": "integer", + "example": 1 + } + } + }, "handler.SessionDetailResponse": { "type": "object", "properties": { diff --git a/package-lock.json b/package-lock.json index 23a6a6e..14b32bc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,12 @@ "@angular/forms": "^21.2.0", "@angular/platform-browser": "^21.2.0", "@angular/router": "^21.2.0", + "@taiga-ui/cdk": "^5.1.0", + "@taiga-ui/core": "^5.1.0", + "@taiga-ui/icons": "^5.1.0", + "@taiga-ui/kit": "^5.1.0", + "@taiga-ui/styles": "^5.1.0", + "hls.js": "^1.6.15", "rxjs": "~7.8.0", "tslib": "^2.3.0" }, @@ -21,7 +27,9 @@ "@angular/build": "^21.2.6", "@angular/cli": "^21.2.6", "@angular/compiler-cli": "^21.2.0", + "dotenv": "^16.4.7", "jsdom": "^28.0.0", + "less": "^4.6.4", "prettier": "^3.8.1", "typescript": "~5.9.2", "vitest": "^4.0.8" @@ -280,8 +288,9 @@ "version": "21.2.6", "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-21.2.6.tgz", "integrity": "sha512-u5gPTAY7MC02uACQE39xxiFcm1hslF+ih/f2borMWnhER0JNTpHjLiLRXFkq7or7+VVHU30zfhK4XNAuO4WTIg==", - "dev": true, + "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "ajv": "8.18.0", "ajv-formats": "3.0.1", @@ -308,8 +317,9 @@ "version": "21.2.6", "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-21.2.6.tgz", "integrity": "sha512-hk2duJlPJyiMaI9MVWA5XpmlpD9C4n8qgquV/MJ7/n+ZRSwW3w1ndL5qUmA1ki+4Da54v/Rc8Wt5tUS955+93w==", - "dev": true, + "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@angular-devkit/core": "21.2.6", "jsonc-parser": "3.3.1", @@ -423,6 +433,23 @@ } } }, + "node_modules/@angular/cdk": { + "version": "21.2.5", + "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-21.2.5.tgz", + "integrity": "sha512-F1sVqMAGYoiJNYYaR2cerqTo7IqpxQ3ZtMDxR3rtB0rSSd5UPOIQoqpsfSd6uH8FVnuzKaBII8Mg6YrjClFsng==", + "license": "MIT", + "peer": true, + "dependencies": { + "parse5": "^8.0.0", + "tslib": "^2.3.0" + }, + "peerDependencies": { + "@angular/common": "^21.0.0 || ^22.0.0", + "@angular/core": "^21.0.0 || ^22.0.0", + "@angular/platform-browser": "^21.0.0 || ^22.0.0", + "rxjs": "^6.5.3 || ^7.4.0" + } + }, "node_modules/@angular/cli": { "version": "21.2.6", "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-21.2.6.tgz", @@ -553,6 +580,7 @@ "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-21.2.7.tgz", "integrity": "sha512-YD/h07cdEeAUs41ysTk6820T0lG/XiQmFiq02d3IsiHYI5Vaj2pg9Ti1wWZYEBM//hVAPTzV0dwdV7Q1Gxju1w==", "license": "MIT", + "peer": true, "dependencies": { "@standard-schema/spec": "^1.0.0", "tslib": "^2.3.0" @@ -595,6 +623,7 @@ "resolved": "https://registry.npmjs.org/@angular/router/-/router-21.2.7.tgz", "integrity": "sha512-Ina6XgtpvXT1OsLAomURHJGQDOkIVGrguWAOZ7+gOjsJEjUfpxTktFter+/K59KMC2yv6yneLvYSn3AswTYx7A==", "license": "MIT", + "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -686,6 +715,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1103,31 +1133,6 @@ "node": ">=20.19.0" } }, - "node_modules/@emnapi/core": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", - "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", - "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", @@ -2040,7 +2045,7 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { @@ -2169,6 +2174,50 @@ "win32" ] }, + "node_modules/@maskito/angular": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@maskito/angular/-/angular-5.2.2.tgz", + "integrity": "sha512-89J1JQE2vdmv49M/8Eow4eNZbRE6io8teM9TXplsHKurU7qnFNp8tZPY7MGsk+xbZRaL0iil102ieYJENu36Pg==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "tslib": "2.8.1" + }, + "peerDependencies": { + "@angular/core": ">=19.0.0", + "@angular/forms": ">=19.0.0", + "@maskito/core": "^5.2.2" + } + }, + "node_modules/@maskito/core": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@maskito/core/-/core-5.2.2.tgz", + "integrity": "sha512-OFLK9NMQrz4eCgNgJ74S4x6IklqGQ71pcQToNGRzkIYnlEZbGpO+pzLREKLN/iKh4oBz8PI/9JnD3HtkN5Rl+w==", + "license": "Apache-2.0", + "peer": true + }, + "node_modules/@maskito/kit": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@maskito/kit/-/kit-5.2.2.tgz", + "integrity": "sha512-ACuhzZ0EmMPBqi+RMbAqVPTMpQuhYVSBTLgBlyQppha1DurC4fN1fXL8MzHqbJbKiCbG2WELpl4mgyTgMcACIg==", + "license": "Apache-2.0", + "peer": true, + "peerDependencies": { + "@maskito/core": "^5.2.2" + } + }, + "node_modules/@maskito/phone": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@maskito/phone/-/phone-5.2.2.tgz", + "integrity": "sha512-OSldrjrK8nIb+8XfHVJ4KqhjInzw/mk8w/5YpIrroF9o85Usqyo22XlIwYmh/Sr9NWIuuKg2FwG/E2HAw2M7GA==", + "license": "Apache-2.0", + "peer": true, + "peerDependencies": { + "@maskito/core": "^5.2.2", + "@maskito/kit": "^5.2.2", + "libphonenumber-js": ">=1.0.0" + } + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.26.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz", @@ -2636,6 +2685,127 @@ "@emnapi/runtime": "^1.7.1" } }, + "node_modules/@ng-web-apis/common": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@ng-web-apis/common/-/common-5.2.0.tgz", + "integrity": "sha512-vB6axKncJP33izmcC34EjWjzqfmGv2/PEbQLdVWU88jVCE3WWO9kOfyqVSMNCaLxu3ZC6qZRzZdIgmg4xe316A==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "tslib": "^2.3.0" + }, + "peerDependencies": { + "@angular/common": ">=19.0.0", + "@angular/core": ">=19.0.0", + "@types/dom-speech-recognition": "^0.0.8", + "rxjs": ">=7.0.0" + } + }, + "node_modules/@ng-web-apis/intersection-observer": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@ng-web-apis/intersection-observer/-/intersection-observer-5.2.0.tgz", + "integrity": "sha512-Y03IirbIVvUli5v41WlnFJEARF5svAkvo7hmVtVlCiK3yQdVlxiYRc/Ix0BooGTuhgTBv6L58ehInveLKyzLqg==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "tslib": "^2.3.0" + }, + "peerDependencies": { + "@angular/core": ">=19.0.0", + "@ng-web-apis/common": "^5.2.0" + } + }, + "node_modules/@ng-web-apis/mutation-observer": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@ng-web-apis/mutation-observer/-/mutation-observer-5.2.0.tgz", + "integrity": "sha512-J82Q2/+RYwBZQU9JhbAbKE7p6MmWLQr/DPM59drYfyfMH0TvfMfxKtiYc+Reru3ByCTqqNNAIQ/w3c+11gDYcA==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "tslib": "^2.3.0" + }, + "peerDependencies": { + "@angular/core": ">=19.0.0", + "@ng-web-apis/common": "^5.2.0" + } + }, + "node_modules/@ng-web-apis/platform": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@ng-web-apis/platform/-/platform-5.2.0.tgz", + "integrity": "sha512-OMK9trN5Sc9IM8/5wnyNbFsjj0OPt3uC3Vsw4+tAiia7/FPDihJQWkd8VB9R+F/Xe7fOzqWCDC+ZnYADMp3vNw==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "tslib": "^2.3.0" + } + }, + "node_modules/@ng-web-apis/resize-observer": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@ng-web-apis/resize-observer/-/resize-observer-5.2.0.tgz", + "integrity": "sha512-lRX6vIRGjQ5+wIT8voOiIhEck+4YSe8dhsRkEHLLrqjNQS8uQXcuDyG4kSGMLcHoExirYbRM0ATv6k1F6MXLOA==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "tslib": "^2.3.0" + }, + "peerDependencies": { + "@angular/core": ">=19.0.0", + "@ng-web-apis/common": "^5.2.0" + } + }, + "node_modules/@ng-web-apis/screen-orientation": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@ng-web-apis/screen-orientation/-/screen-orientation-5.2.0.tgz", + "integrity": "sha512-a7x7QFVQL8zESG47YRmwAHCaNrurXqPbNwsxoSL05VREi+u4p3/Sfe0WlzC7SwUkHJLqZf4RzzJuCuRa4QuI4g==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "tslib": "^2.3.0" + }, + "peerDependencies": { + "@angular/core": ">=19.0.0", + "@ng-web-apis/common": "^5.2.0", + "rxjs": ">=7.0.0" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "license": "MIT", + "optional": true, + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/@npmcli/agent": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-4.0.0.tgz", @@ -3754,7 +3924,7 @@ "version": "21.2.6", "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-21.2.6.tgz", "integrity": "sha512-KpLD8R2S762jbLdNEepE+b7KjhVOKPFHHdgNqhPv0NiGLdsvXSOx1e63JvFacoCZdmP7n3/gwmyT/utcVvnsag==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@angular-devkit/core": "21.2.6", @@ -3853,6 +4023,245 @@ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", "license": "MIT" }, + "node_modules/@taiga-ui/cdk": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@taiga-ui/cdk/-/cdk-5.1.0.tgz", + "integrity": "sha512-wzf988cc5f6MTBsiKcLPZA2c8XwtEOAV9pBHjCN8tykjBMC600jVdczZHZdWY2MiqVHVfySnomlYkZM62IQmQg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "2.8.1" + }, + "optionalDependencies": { + "@angular-devkit/core": ">=19.0.0", + "@angular-devkit/schematics": ">=19.0.0", + "@schematics/angular": ">=19.0.0", + "ng-morph": "^4.8.4", + "parse5": "^7.3.0" + }, + "peerDependencies": { + "@angular/cdk": ">=19.0.0", + "@angular/common": ">=19.0.0", + "@angular/core": ">=19.0.0", + "@angular/forms": ">=19.0.0", + "@ng-web-apis/common": "^5.2.0", + "@ng-web-apis/mutation-observer": "^5.2.0", + "@ng-web-apis/platform": "^5.2.0", + "@ng-web-apis/resize-observer": "^5.2.0", + "@ng-web-apis/screen-orientation": "^5.2.0", + "@taiga-ui/event-plugins": "^5.0.0", + "@taiga-ui/font-watcher": "~0.5.0", + "@taiga-ui/polymorpheus": "^5.0.0", + "rxjs": ">=7.0.0" + } + }, + "node_modules/@taiga-ui/cdk/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "optional": true, + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/@taiga-ui/cdk/node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "optional": true, + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/@taiga-ui/core": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@taiga-ui/core/-/core-5.1.0.tgz", + "integrity": "sha512-thDWcJuDsgO9eItpa0KIplDSofmNnKSpSWflpzRst3DhIH1O+wfYBeebHk2keugOKU+bF+beX9Um4q38I89HvA==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "tslib": ">=2.8.1" + }, + "peerDependencies": { + "@angular/common": ">=19.0.0", + "@angular/core": ">=19.0.0", + "@angular/forms": ">=19.0.0", + "@angular/platform-browser": ">=19.0.0", + "@angular/router": ">=19.0.0", + "@ng-web-apis/common": "^5.2.0", + "@ng-web-apis/mutation-observer": "^5.2.0", + "@ng-web-apis/platform": "^5.2.0", + "@taiga-ui/cdk": "5.1.0", + "@taiga-ui/event-plugins": "^5.0.0", + "@taiga-ui/i18n": "5.1.0", + "@taiga-ui/polymorpheus": "^5.0.0", + "@taiga-ui/styles": "5.1.0", + "rxjs": ">=7.0.0" + } + }, + "node_modules/@taiga-ui/design-tokens": { + "version": "0.291.0", + "resolved": "https://registry.npmjs.org/@taiga-ui/design-tokens/-/design-tokens-0.291.0.tgz", + "integrity": "sha512-oGLjOh/UXgJRRnz65Wp6yeUNl0ox+IWMdjKDJ6GKUKVr4TnJILDqN+hsGKJcQ+cctjRa+34QxGZO7AUtbAY1oA==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "tslib": "^2.3.0" + } + }, + "node_modules/@taiga-ui/event-plugins": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@taiga-ui/event-plugins/-/event-plugins-5.0.0.tgz", + "integrity": "sha512-8AxIUn/lX4kwuDlIFmhElgBNtGDgQVC9/F+3O2A29IcMSt9693KRi8qKwToe9p3UuUsT9nnj5YeE11BtJMG0wA==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "tslib": "^2.3.0" + }, + "peerDependencies": { + "@angular/core": ">=16.0.0", + "@angular/platform-browser": ">=16.0.0", + "rxjs": ">=7.0.0" + } + }, + "node_modules/@taiga-ui/font-watcher": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@taiga-ui/font-watcher/-/font-watcher-0.5.0.tgz", + "integrity": "sha512-9QBUh3XT6KOS+pKVy4ggr0CxgiZtbDYlterUNcfXw8cswkIkiUl5VGDQdvmta60iPHqOrOd/Z/7aAUom35vX/g==", + "license": "Apache-2.0", + "peer": true + }, + "node_modules/@taiga-ui/i18n": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@taiga-ui/i18n/-/i18n-5.1.0.tgz", + "integrity": "sha512-NqCo1fK95w6aXHkvIZ3aqZOA2z+CnvD/eEEXZjbCs/Ik6QfWmGP8GbS5uwb7SzqNZz9QiVaJi5RNZtoInHf/tQ==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "tslib": ">=2.8.1" + }, + "peerDependencies": { + "@angular/core": ">=19.0.0", + "@ng-web-apis/common": "^5.2.0", + "rxjs": ">=7.0.0" + } + }, + "node_modules/@taiga-ui/icons": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@taiga-ui/icons/-/icons-5.1.0.tgz", + "integrity": "sha512-iRWYdYjt5PRneUSfUgPms9IHG0WbXNU9kNi/JnvMFukV/qhkBTuuvZaOv43LnBom9u/Wdsqfmi6WzUVjaxYKJw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.3.0" + } + }, + "node_modules/@taiga-ui/kit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@taiga-ui/kit/-/kit-5.1.0.tgz", + "integrity": "sha512-3k1asuOgEjDLAtkK/snO8anctfo4vmMsyr16NHcNVDRqvUeRDrWf7sdRD1uXloO0hTrJvVbKR3WnDWxnuTKm8Q==", + "license": "Apache-2.0", + "dependencies": { + "tslib": ">=2.8.1" + }, + "peerDependencies": { + "@angular/common": ">=19.0.0", + "@angular/core": ">=19.0.0", + "@angular/forms": ">=19.0.0", + "@angular/router": ">=19.0.0", + "@maskito/angular": "^5.2.2", + "@maskito/core": "^5.2.2", + "@maskito/kit": "^5.2.2", + "@maskito/phone": "^5.2.2", + "@ng-web-apis/common": "^5.2.0", + "@ng-web-apis/intersection-observer": "^5.2.0", + "@ng-web-apis/mutation-observer": "^5.2.0", + "@ng-web-apis/platform": "^5.2.0", + "@ng-web-apis/resize-observer": "^5.2.0", + "@taiga-ui/cdk": "5.1.0", + "@taiga-ui/core": "5.1.0", + "@taiga-ui/i18n": "5.1.0", + "@taiga-ui/polymorpheus": "^5.0.0", + "@taiga-ui/styles": "5.1.0", + "rxjs": ">=7.0.0" + } + }, + "node_modules/@taiga-ui/polymorpheus": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@taiga-ui/polymorpheus/-/polymorpheus-5.0.0.tgz", + "integrity": "sha512-FRus7OgxYyRuETB17g1/YXYs8PAeF4x8+K4ZDfD3Ede8Vxv/XslAYZvf/Ro0wag6Uiy0kAP4bvSaIGPS1Y8Fyg==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "tslib": "^2.8.1" + }, + "peerDependencies": { + "@angular/core": ">=19.0.0", + "@angular/platform-browser": ">=19.0.0" + } + }, + "node_modules/@taiga-ui/styles": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@taiga-ui/styles/-/styles-5.1.0.tgz", + "integrity": "sha512-wWAcSm87aA0tBRcn6BUt7nVShMZBJNsOFG7QE8T7mIhG4Ss+7FCBSbbDNksWym8l69/LMIZGeAU0tc+rRSFXnw==", + "license": "Apache-2.0", + "peer": true, + "peerDependencies": { + "@taiga-ui/design-tokens": "~0.291.0" + } + }, + "node_modules/@ts-morph/common": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.24.0.tgz", + "integrity": "sha512-c1xMmNHWpNselmpIqursHeOHHBTIsJLbB+NuovbTTRCNiTLEr/U9dbJ8qy0jd/O2x5pc3seWuOUN5R2IoOTp8A==", + "license": "MIT", + "optional": true, + "dependencies": { + "fast-glob": "^3.3.2", + "minimatch": "^9.0.4", + "mkdirp": "^3.0.1", + "path-browserify": "^1.0.1" + } + }, + "node_modules/@ts-morph/common/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT", + "optional": true + }, + "node_modules/@ts-morph/common/node_modules/brace-expansion": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "license": "MIT", + "optional": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@ts-morph/common/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "license": "ISC", + "optional": true, + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@tufjs/canonical-json": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tufjs/canonical-json/-/canonical-json-2.0.0.tgz", @@ -3906,6 +4315,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/dom-speech-recognition": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/@types/dom-speech-recognition/-/dom-speech-recognition-0.0.8.tgz", + "integrity": "sha512-JgpjlGQWGc+OjjTNfyJ8Ic2w2+NOdrmEpmdVAkg+sYblx8I5ZZTeA+cfxvgUMtXXT7/WzYYR2Rrp3ZDg+l/3vA==", + "license": "MIT", + "peer": true + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -3913,6 +4329,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/minimatch": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.5.tgz", + "integrity": "sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ==", + "license": "MIT", + "optional": true + }, "node_modules/@vitejs/plugin-basic-ssl": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-2.1.4.tgz", @@ -4091,7 +4514,7 @@ "version": "8.18.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -4108,7 +4531,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "ajv": "^8.0.0" @@ -4168,7 +4591,7 @@ "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=12" @@ -4190,6 +4613,36 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/array-differ": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/array-differ/-/array-differ-3.0.0.tgz", + "integrity": "sha512-THtfYS6KtME/yIAhKjZ2ul7XI96lQGHRputJQHO80LAWQnuGP4iCIN8vdMRboGbIEYBwU33q8Tch1os2+X0kMg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/arrify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz", + "integrity": "sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -4299,6 +4752,19 @@ "node": "18 || 20 || >=22" } }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "license": "MIT", + "optional": true, + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/browserslist": { "version": "4.28.2", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", @@ -4449,7 +4915,7 @@ "version": "5.6.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": "^12.17.0 || ^14.13 || >=16.0.0" @@ -4496,7 +4962,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "restore-cursor": "^5.0.0" @@ -4512,7 +4978,7 @@ "version": "3.4.0", "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-3.4.0.tgz", "integrity": "sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=18.20" @@ -4599,6 +5065,13 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/code-block-writer": { + "version": "13.0.3", + "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz", + "integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==", + "license": "MIT", + "optional": true + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -4626,6 +5099,13 @@ "dev": true, "license": "MIT" }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT", + "optional": true + }, "node_modules/content-disposition": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", @@ -4677,6 +5157,22 @@ "node": ">=6.6.0" } }, + "node_modules/copy-anything": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-3.0.5.tgz", + "integrity": "sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-what": "^4.1.8" + }, + "engines": { + "node": ">=12.13" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, "node_modules/cors": { "version": "2.8.6", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", @@ -4733,7 +5229,6 @@ "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" @@ -4900,6 +5395,19 @@ "url": "https://github.com/fb55/domutils?sponsor=1" } }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -4989,6 +5497,20 @@ "dev": true, "license": "MIT" }, + "node_modules/errno": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/errno/-/errno-0.1.8.tgz", + "integrity": "sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "prr": "~1.0.1" + }, + "bin": { + "errno": "cli.js" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -5223,14 +5745,31 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, + "devOptional": true, "license": "MIT" }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, "node_modules/fast-uri": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -5243,6 +5782,16 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "license": "ISC", + "optional": true, + "dependencies": { + "reusify": "^1.0.4" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -5261,6 +5810,19 @@ } } }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "license": "MIT", + "optional": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/finalhandler": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", @@ -5365,7 +5927,7 @@ "version": "1.5.0", "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=18" @@ -5431,6 +5993,19 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "optional": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/glob-to-regexp": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", @@ -5484,6 +6059,12 @@ "node": ">= 0.4" } }, + "node_modules/hls.js": { + "version": "1.6.15", + "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.15.tgz", + "integrity": "sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA==", + "license": "Apache-2.0" + }, "node_modules/hono": { "version": "4.12.11", "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.11.tgz", @@ -5650,6 +6231,20 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/image-size": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/image-size/-/image-size-0.5.5.tgz", + "integrity": "sha512-6TDAlDPZxUFCv+fuOkIoXT/V/f3Qbq8e37p+YOiYrUv3v9cc3/6x78VdfPgFVaB9dZYeLUfKgHRebpkm/oP2VQ==", + "dev": true, + "license": "MIT", + "optional": true, + "bin": { + "image-size": "bin/image-size.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/immutable": { "version": "5.1.5", "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz", @@ -5698,7 +6293,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, "license": "MIT", "optional": true, "engines": { @@ -5725,7 +6319,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -5739,7 +6332,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=12" @@ -5748,6 +6341,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.12.0" + } + }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -5766,7 +6369,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=18" @@ -5775,6 +6378,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-what": { + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/is-what/-/is-what-4.1.16.tgz", + "integrity": "sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.13" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -5895,7 +6511,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/json-schema-typed": { @@ -5922,7 +6538,7 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/jsonparse": { @@ -5935,12 +6551,58 @@ ], "license": "MIT" }, + "node_modules/less": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/less/-/less-4.6.4.tgz", + "integrity": "sha512-OJmO5+HxZLLw0RLzkqaNHzcgEAQG7C0y3aMbwtCzIUFZsLMNNq/1IdAdHEycQ58CwUO3jPTHmoN+tE5I7FQxNg==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "copy-anything": "^3.0.5", + "parse-node-version": "^1.0.1" + }, + "bin": { + "lessc": "bin/lessc" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "errno": "^0.1.1", + "graceful-fs": "^4.1.2", + "image-size": "~0.5.0", + "make-dir": "^2.1.0", + "mime": "^1.4.1", + "needle": "^3.1.0", + "source-map": "~0.6.0" + } + }, + "node_modules/less/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/libphonenumber-js": { + "version": "1.12.41", + "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.12.41.tgz", + "integrity": "sha512-lsmMmGXBxXIK/VMLEj0kL6MtUs1kBGj1nTCzi6zgQoG1DEwqwt2DQyHxcLykceIxAnfE3hya7NuIh6PpC6S3fA==", + "license": "MIT", + "peer": true + }, "node_modules/listr2": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz", "integrity": "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cli-truncate": "^5.0.0", "colorette": "^2.0.20", @@ -6022,7 +6684,7 @@ "version": "7.0.1", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz", "integrity": "sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "is-unicode-supported": "^2.0.0", @@ -6122,12 +6784,38 @@ "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/make-dir": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz", + "integrity": "sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "pify": "^4.0.1", + "semver": "^5.6.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver" + } + }, "node_modules/make-fetch-happen": { "version": "15.0.5", "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-15.0.5.tgz", @@ -6192,6 +6880,57 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "license": "MIT", + "optional": true, + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "dev": true, + "license": "MIT", + "optional": true, + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/mime-db": { "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", @@ -6223,7 +6962,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=18" @@ -6381,6 +7120,22 @@ "node": ">= 18" } }, + "node_modules/mkdirp": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", + "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", + "license": "MIT", + "optional": true, + "bin": { + "mkdirp": "dist/cjs/src/bin.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/mrmime": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", @@ -6432,6 +7187,57 @@ "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" } }, + "node_modules/multimatch": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/multimatch/-/multimatch-5.0.0.tgz", + "integrity": "sha512-ypMKuglUrZUD99Tk2bUQ+xNQj43lPEfAeX2o9cTteAmShXy2VHDJpuwu1o0xqoKCt9jLVAvwyFKdLTPXKAfJyA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/minimatch": "^3.0.3", + "array-differ": "^3.0.0", + "array-union": "^2.1.0", + "arrify": "^2.0.1", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/multimatch/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT", + "optional": true + }, + "node_modules/multimatch/node_modules/brace-expansion": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "license": "MIT", + "optional": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/multimatch/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "optional": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/mute-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", @@ -6461,6 +7267,38 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/needle": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/needle/-/needle-3.5.0.tgz", + "integrity": "sha512-jaQyPKKk2YokHrEg+vFDYxXIHTCBgiZwSHOoVx/8V3GIBS8/VN6NdVRmg8q1ERtPkMvmOvebsgga4sAj5hls/w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "iconv-lite": "^0.6.3", + "sax": "^1.2.4" + }, + "bin": { + "needle": "bin/needle" + }, + "engines": { + "node": ">= 4.4.x" + } + }, + "node_modules/needle/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/negotiator": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", @@ -6471,6 +7309,57 @@ "node": ">= 0.6" } }, + "node_modules/ng-morph": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/ng-morph/-/ng-morph-4.8.4.tgz", + "integrity": "sha512-XwL53wCOhyaAxvoekN74ONbWUK30huzp+GpZYyC01RfaG2AX9l7YlC1mGG/l7Rx7YXtFAk85VFnNJqn2e46K8g==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "jsonc-parser": "3.3.1", + "minimatch": "10.0.1", + "multimatch": "5.0.0", + "ts-morph": "23.0.0" + }, + "peerDependencies": { + "@angular-devkit/core": ">=16.0.0", + "@angular-devkit/schematics": ">=16.0.0", + "tslib": "^2.7.0" + } + }, + "node_modules/ng-morph/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT", + "optional": true + }, + "node_modules/ng-morph/node_modules/brace-expansion": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "license": "MIT", + "optional": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/ng-morph/node_modules/minimatch": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.1.tgz", + "integrity": "sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/node-addon-api": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz", @@ -6745,7 +7634,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "mimic-function": "^5.0.0" @@ -6761,7 +7650,7 @@ "version": "9.3.0", "resolved": "https://registry.npmjs.org/ora/-/ora-9.3.0.tgz", "integrity": "sha512-lBX72MWFduWEf7v7uWf5DHp9Jn5BI8bNPGuFgtXMmr2uDz2Gz2749y3am3agSDdkhHPHYmmxEGSKH85ZLGzgXw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "chalk": "^5.6.2", @@ -6833,11 +7722,20 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/parse-node-version": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parse-node-version/-/parse-node-version-1.0.1.tgz", + "integrity": "sha512-3YHlOa/JgH6Mnpr05jP9eDG254US9ek25LyIxZlDItp2iJtwyaXQb57lBYLdT3MowkUFYEV2XXNAYIPlESvJlA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/parse5": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", - "dev": true, "license": "MIT", "dependencies": { "entities": "^6.0.0" @@ -6891,7 +7789,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", - "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.12" @@ -6910,6 +7807,13 @@ "node": ">= 0.8" } }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "license": "MIT", + "optional": true + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -6976,7 +7880,7 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=12" @@ -6985,6 +7889,17 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", + "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, "node_modules/piscina": { "version": "5.1.4", "resolved": "https://registry.npmjs.org/piscina/-/piscina-5.1.4.tgz", @@ -7126,6 +8041,14 @@ "node": ">= 0.10" } }, + "node_modules/prr": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz", + "integrity": "sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -7152,6 +8075,27 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -7203,7 +8147,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -7213,7 +8157,7 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "onetime": "^7.0.0", @@ -7236,6 +8180,17 @@ "node": ">= 4" } }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "optional": true, + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, "node_modules/rfdc": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", @@ -7337,6 +8292,30 @@ "node": ">= 18" } }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, "node_modules/rxjs": { "version": "7.8.2", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", @@ -7405,6 +8384,17 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/sax": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "dev": true, + "license": "BlueOak-1.0.0", + "optional": true, + "engines": { + "node": ">=11.0.0" + } + }, "node_modules/saxes": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", @@ -7595,7 +8585,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, + "devOptional": true, "license": "ISC", "engines": { "node": ">=14" @@ -7684,7 +8674,7 @@ "version": "0.7.6", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", - "dev": true, + "devOptional": true, "license": "BSD-3-Clause", "engines": { "node": ">= 12" @@ -7787,7 +8777,7 @@ "version": "0.3.1", "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.3.1.tgz", "integrity": "sha512-reExS1kSGoElkextOcPkel4NE99S0BWxjUHQeDFnR8S993JxpPX7KU4MNmO19NXhlJp+8dmdCbKQVNgLJh2teA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=18" @@ -7800,7 +8790,7 @@ "version": "8.2.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.0.tgz", "integrity": "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "get-east-asian-width": "^1.5.0", @@ -7817,7 +8807,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "ansi-regex": "^6.2.2" @@ -7927,6 +8917,19 @@ "dev": true, "license": "MIT" }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -7963,6 +8966,17 @@ "node": ">=20" } }, + "node_modules/ts-morph": { + "version": "23.0.0", + "resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-23.0.0.tgz", + "integrity": "sha512-FcvFx7a9E8TUe6T3ShihXJLiJOiqyafzFKUO4aqIHDUCIvADdGNShcbc2W5PMr3LerXRv7mafvFZ9lRENxJmug==", + "license": "MIT", + "optional": true, + "dependencies": { + "@ts-morph/common": "~0.24.0", + "code-block-writer": "^13.0.1" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -8525,7 +9539,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=18" diff --git a/package.json b/package.json index 8d1669f..0109c7b 100644 --- a/package.json +++ b/package.json @@ -3,8 +3,12 @@ "version": "0.0.0", "scripts": { "ng": "ng", - "start": "ng serve", + "env:sync": "node scripts/sync-env.cjs", + "prestart": "npm run env:sync", + "prebuild": "npm run env:sync", + "start": "ng serve --proxy-config proxy.conf.cjs", "build": "ng build", + "prewatch": "npm run env:sync", "watch": "ng build --watch --configuration development", "test": "ng test" }, @@ -17,16 +21,24 @@ "@angular/forms": "^21.2.0", "@angular/platform-browser": "^21.2.0", "@angular/router": "^21.2.0", + "@taiga-ui/cdk": "^5.1.0", + "@taiga-ui/core": "^5.1.0", + "@taiga-ui/icons": "^5.1.0", + "@taiga-ui/kit": "^5.1.0", + "@taiga-ui/styles": "^5.1.0", + "hls.js": "^1.6.15", "rxjs": "~7.8.0", "tslib": "^2.3.0" }, "devDependencies": { + "dotenv": "^16.4.7", "@angular/build": "^21.2.6", "@angular/cli": "^21.2.6", "@angular/compiler-cli": "^21.2.0", "jsdom": "^28.0.0", + "less": "^4.6.4", "prettier": "^3.8.1", "typescript": "~5.9.2", "vitest": "^4.0.8" } -} \ No newline at end of file +} diff --git a/proxy.conf.cjs b/proxy.conf.cjs new file mode 100644 index 0000000..dc28275 --- /dev/null +++ b/proxy.conf.cjs @@ -0,0 +1,15 @@ +/** + * Прокси для `ng serve`. Целевой backend задаётся в `.env` (SG_DEV_PROXY_TARGET). + */ +const path = require('path'); +require('dotenv').config({ path: path.join(__dirname, '.env') }); + +const target = process.env.SG_DEV_PROXY_TARGET || 'http://sparkguardian.ru:8080'; + +module.exports = { + '/api/**': { + target, + secure: false, + changeOrigin: true, + }, +}; diff --git a/public/KB_USA-standard.svg b/public/KB_USA-standard.svg new file mode 100644 index 0000000..0523350 --- /dev/null +++ b/public/KB_USA-standard.svg @@ -0,0 +1,398 @@ + + + + + + + + + + + image/svg+xml + + + Meta (⌘) and Menu: Lucide icons (https://lucide.dev), ISC License, © Lucide Contributors. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Q + W + E + R + T + Y + U + I + O + P + + A + S + D + F + G + H + J + K + L + + Z + X + C + V + B + N + M + + + + + ` + 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + 0 + - + = + + [ + ] + \ + + ; + ' + + , + . + / + + + + ~ + ! + @ + # + $ + % + ^ + & + + ( + ) + _ + + + + { + } + | + + : + " + + < + > + ? + + + + Ctrl + Alt + Alt + Ctrl + + + + + + delete + + tab + + return + + caps lock + + shift + + shift + + + + diff --git a/public/fonts/TinkoffSans-Bold.ttf b/public/fonts/TinkoffSans-Bold.ttf new file mode 100644 index 0000000..e8eb528 Binary files /dev/null and b/public/fonts/TinkoffSans-Bold.ttf differ diff --git a/public/fonts/TinkoffSans-Medium.ttf b/public/fonts/TinkoffSans-Medium.ttf new file mode 100644 index 0000000..d62120c Binary files /dev/null and b/public/fonts/TinkoffSans-Medium.ttf differ diff --git a/public/fonts/TinkoffSans-Regular.ttf b/public/fonts/TinkoffSans-Regular.ttf new file mode 100644 index 0000000..13963b9 Binary files /dev/null and b/public/fonts/TinkoffSans-Regular.ttf differ diff --git a/scripts/sync-env.cjs b/scripts/sync-env.cjs new file mode 100644 index 0000000..c0da5a2 --- /dev/null +++ b/scripts/sync-env.cjs @@ -0,0 +1,33 @@ +/** + * Генерирует `src/environments/environment.ts` из переменных окружения (.env). + * Значения по умолчанию совпадают с .env.example. + */ +const fs = require('fs'); +const path = require('path'); + +require('dotenv').config({ path: path.join(__dirname, '..', '.env') }); + +const defaults = { + SG_API_FALLBACK_ORIGIN: 'https://sparkguardian.ru', + SG_API_BASE_PATH: '/api/v1', +}; + +function val(key) { + const v = process.env[key]; + if (v !== undefined && v !== '') { + return v; + } + return defaults[key]; +} + +const content = `// Сгенерировано npm run env:sync — правьте .env и снова запустите sync +export const environment = { + production: false, + apiFallbackOrigin: ${JSON.stringify(val('SG_API_FALLBACK_ORIGIN'))}, + apiBasePath: ${JSON.stringify(val('SG_API_BASE_PATH'))}, +} as const; +`; + +const outPath = path.join(__dirname, '..', 'src', 'environments', 'environment.ts'); +fs.writeFileSync(outPath, content, 'utf8'); +console.log('env:sync →', outPath); diff --git a/src/app/app.config.ts b/src/app/app.config.ts index cb1270e..c73f80d 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -1,11 +1,22 @@ +import { provideHttpClient, withInterceptors } from '@angular/common/http'; import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core'; import { provideRouter } from '@angular/router'; +import { provideTaiga } from '@taiga-ui/core'; +import { tuiNotificationOptionsProvider } from '@taiga-ui/core/components/notification'; +import { apiBaseUrlInterceptor } from './core/http/api-base-url.interceptor'; +import { devLogInterceptor } from './core/http/dev-log.interceptor'; import { routes } from './app.routes'; export const appConfig: ApplicationConfig = { providers: [ provideBrowserGlobalErrorListeners(), - provideRouter(routes) - ] + provideRouter(routes), + provideTaiga(), + tuiNotificationOptionsProvider(() => ({ + block: 'start', + inline: 'end', + })), + provideHttpClient(withInterceptors([apiBaseUrlInterceptor, devLogInterceptor])), + ], }; diff --git a/src/app/app.css b/src/app/app.css index e69de29..788b826 100644 --- a/src/app/app.css +++ b/src/app/app.css @@ -0,0 +1,40 @@ +:host { + display: block; + min-height: 100%; +} + +.shell { + min-height: 100dvh; + display: flex; + flex-direction: column; + box-sizing: border-box; +} + +.shell-header { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 0.75rem; + padding: 1rem 1.5rem; + border-bottom: 1px solid var(--tui-border-normal); + background: var(--tui-background-elevation-1); +} + +.brand { + font: var(--tui-font-heading-5); + color: var(--tui-text-primary); + text-decoration: none; +} + +.brand:hover { + color: var(--tui-text-action); +} + +.shell-sub { + font: var(--tui-font-text-s); + color: var(--sg-color-subtitle); +} + +.shell-main { + flex: 1; +} diff --git a/src/app/app.html b/src/app/app.html index a1c4296..f789a01 100644 --- a/src/app/app.html +++ b/src/app/app.html @@ -1,344 +1,14 @@ - - - - - - - - - - - -
-
-
- -

Hello, {{ title() }}

-

Congratulations! Your app is running. 🎉

-
- -
-
- @for (item of [ - { title: 'Explore the Docs', link: 'https://angular.dev' }, - { title: 'Learn with Tutorials', link: 'https://angular.dev/tutorials' }, - { title: 'Prompt and best practices for AI', link: 'https://angular.dev/ai/develop-with-ai'}, - { title: 'CLI Docs', link: 'https://angular.dev/tools/cli' }, - { title: 'Angular Language Service', link: 'https://angular.dev/tools/language-service' }, - { title: 'Angular DevTools', link: 'https://angular.dev/tools/devtools' }, - ]; track item.title) { - - {{ item.title }} - - - - - } -
- -
+ +
+
+ SparkGuardian + Прокторинг +
+
+ +
-
- - - - - - - - - - - + @if (isDev) { + + } + diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index dc39edb..2c3d309 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -1,3 +1,17 @@ import { Routes } from '@angular/router'; -export const routes: Routes = []; +export const routes: Routes = [ + { + path: '', + loadComponent: () => + import('./features/sessions/sessions-list/sessions-list.component').then((m) => m.SessionsListComponent), + }, + { + path: 'sessions/:id', + loadComponent: () => + import('./features/sessions/session-detail/session-detail.component').then( + (m) => m.SessionDetailComponent, + ), + }, + { path: '**', redirectTo: '' }, +]; diff --git a/src/app/app.spec.ts b/src/app/app.spec.ts index 5de2561..5e1837f 100644 --- a/src/app/app.spec.ts +++ b/src/app/app.spec.ts @@ -1,10 +1,26 @@ import { TestBed } from '@angular/core/testing'; import { App } from './app'; +import { appConfig } from './app.config'; describe('App', () => { beforeEach(async () => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: (query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + }), + }); + await TestBed.configureTestingModule({ imports: [App], + providers: [...appConfig.providers], }).compileComponents(); }); @@ -14,10 +30,10 @@ describe('App', () => { expect(app).toBeTruthy(); }); - it('should render title', async () => { + it('should show app name in header', async () => { const fixture = TestBed.createComponent(App); await fixture.whenStable(); const compiled = fixture.nativeElement as HTMLElement; - expect(compiled.querySelector('h1')?.textContent).toContain('Hello, sparkguardian'); + expect(compiled.querySelector('.brand')?.textContent).toContain('SparkGuardian'); }); }); diff --git a/src/app/app.ts b/src/app/app.ts index 1188fce..91a460a 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -1,12 +1,14 @@ -import { Component, signal } from '@angular/core'; -import { RouterOutlet } from '@angular/router'; +import { TuiRoot } from '@taiga-ui/core'; +import { Component, isDevMode } from '@angular/core'; +import { RouterLink, RouterOutlet } from '@angular/router'; +import { DevConsoleComponent } from './features/devtools/dev-console/dev-console.component'; @Component({ selector: 'app-root', - imports: [RouterOutlet], + imports: [RouterLink, RouterOutlet, TuiRoot, DevConsoleComponent], templateUrl: './app.html', - styleUrl: './app.css' + styleUrl: './app.css', }) export class App { - protected readonly title = signal('sparkguardian'); + protected readonly isDev = isDevMode(); } diff --git a/src/app/core/config/api.tokens.ts b/src/app/core/config/api.tokens.ts new file mode 100644 index 0000000..0ec98b5 --- /dev/null +++ b/src/app/core/config/api.tokens.ts @@ -0,0 +1,34 @@ +import { DOCUMENT } from '@angular/common'; +import { inject, InjectionToken } from '@angular/core'; + +import { environment } from '../../../environments/environment'; + +/** + * Origin для разрешения относительных `playlist_url` (HLS). + * При `ng serve` — origin dev-сервера; API проксируется (см. `proxy.conf.cjs`, `SG_DEV_PROXY_TARGET` в `.env`). + */ +export const API_ORIGIN = new InjectionToken('API_ORIGIN', { + factory: () => { + const doc = inject(DOCUMENT); + const loc = doc.defaultView?.location; + if (!loc || loc.protocol === 'file:') { + return environment.apiFallbackOrigin; + } + return loc.origin; + }, +}); + +/** + * Базовый путь API. Относительный путь работает в dev (прокси) и при деплое на тот же домен. + * Для `file://` — абсолютный URL из `environment`. + */ +export const API_BASE_URL = new InjectionToken('API_BASE_URL', { + factory: () => { + const doc = inject(DOCUMENT); + const loc = doc.defaultView?.location; + if (loc?.protocol === 'file:') { + return `${environment.apiFallbackOrigin}${environment.apiBasePath}`; + } + return environment.apiBasePath; + }, +}); diff --git a/src/app/core/devtools/dev-log.service.ts b/src/app/core/devtools/dev-log.service.ts new file mode 100644 index 0000000..9642894 --- /dev/null +++ b/src/app/core/devtools/dev-log.service.ts @@ -0,0 +1,52 @@ +import { Injectable, signal } from '@angular/core'; + +export type DevLogLevel = 'info' | 'warn' | 'error'; +export type DevLogStatus = 'pending' | 'ok' | 'error'; + +export interface DevHttpLogDetails { + method: string; + url: string; + requestHeaders?: Record; + requestBody?: unknown; + statusCode?: number; + durationMs?: number; + responseHeaders?: Record; + responseBody?: unknown; + error?: string; +} + +export interface DevLogEntry { + id: number; + time: string; + level: DevLogLevel; + source: 'http' | 'system'; + message: string; + status?: DevLogStatus; + details?: DevHttpLogDetails; +} + +@Injectable({ providedIn: 'root' }) +export class DevLogService { + private seq = 0; + private readonly max = 300; + readonly entries = signal([]); + + add(entry: Omit): void { + const withMeta: DevLogEntry = { + id: ++this.seq, + time: new Date().toISOString(), + ...entry, + }; + this.entries.update((curr) => [...curr.slice(-(this.max - 1)), withMeta]); + } + + update(id: number, patch: Partial>): void { + this.entries.update((curr) => + curr.map((item) => (item.id === id ? { ...item, ...patch } : item)), + ); + } + + clear(): void { + this.entries.set([]); + } +} diff --git a/src/app/core/http/api-base-url.interceptor.ts b/src/app/core/http/api-base-url.interceptor.ts new file mode 100644 index 0000000..7e5aff3 --- /dev/null +++ b/src/app/core/http/api-base-url.interceptor.ts @@ -0,0 +1,17 @@ +import { HttpInterceptorFn } from '@angular/common/http'; +import { inject } from '@angular/core'; + +import { API_BASE_URL } from '../config/api.tokens'; + +/** + * Подставляет базовый URL ко всем относительным запросам. + * Абсолютные URL (`http…`) не трогаем — как в Axios с `baseURL`, только через перехватчик. + */ +export const apiBaseUrlInterceptor: HttpInterceptorFn = (req, next) => { + const base = inject(API_BASE_URL); + if (/^https?:\/\//i.test(req.url)) { + return next(req); + } + const path = req.url.startsWith('/') ? req.url : `/${req.url}`; + return next(req.clone({ url: `${base}${path}` })); +}; diff --git a/src/app/core/http/dev-log.interceptor.ts b/src/app/core/http/dev-log.interceptor.ts new file mode 100644 index 0000000..f84a22b --- /dev/null +++ b/src/app/core/http/dev-log.interceptor.ts @@ -0,0 +1,117 @@ +import { + HttpErrorResponse, + HttpEvent, + HttpHeaders, + HttpInterceptorFn, + HttpResponse, +} from '@angular/common/http'; +import { inject, isDevMode } from '@angular/core'; +import { tap } from 'rxjs'; + +import { httpErrorMessage } from './http-error.util'; +import { DevLogService } from '../devtools/dev-log.service'; + +function headersToObject(headers: HttpHeaders): Record { + const out: Record = {}; + for (const key of headers.keys()) { + out[key] = headers.get(key); + } + return out; +} + +function normalizeBody(value: unknown): unknown { + if (value === undefined) { + return null; + } + if (value === null || typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + return value; + } + if (value instanceof Blob) { + return `[Blob ${value.type || 'unknown'} ${value.size} bytes]`; + } + if (value instanceof FormData) { + const fields: Record = {}; + value.forEach((v, k) => { + fields[k] = typeof v === 'string' ? v : `[File ${(v as File).name}]`; + }); + return fields; + } + if (value instanceof ArrayBuffer) { + return `[ArrayBuffer ${value.byteLength} bytes]`; + } + return value; +} + +export const devLogInterceptor: HttpInterceptorFn = (req, next) => { + if (!isDevMode()) { + return next(req); + } + + const logs = inject(DevLogService); + const started = performance.now(); + logs.add({ + level: 'info', + source: 'http', + status: 'pending', + message: `→ ${req.method} ${req.urlWithParams} (pending)`, + details: { + method: req.method, + url: req.urlWithParams, + requestHeaders: headersToObject(req.headers), + requestBody: normalizeBody(req.body), + }, + }); + const entryId = logs.entries().at(-1)?.id; + + return next(req).pipe( + tap({ + next: (event: HttpEvent) => { + if (!(event instanceof HttpResponse)) { + return; + } + const ms = Math.round(performance.now() - started); + if (entryId) { + logs.update(entryId, { + level: 'info', + status: 'ok', + message: `✓ ${req.method} ${req.urlWithParams} [${event.status}] ${ms}ms`, + details: { + method: req.method, + url: req.urlWithParams, + requestHeaders: headersToObject(req.headers), + requestBody: normalizeBody(req.body), + statusCode: event.status, + durationMs: ms, + responseHeaders: headersToObject(event.headers), + responseBody: normalizeBody(event.body), + }, + }); + } + }, + error: (error: unknown) => { + const ms = Math.round(performance.now() - started); + const status = error instanceof HttpErrorResponse ? error.status : undefined; + if (entryId) { + logs.update(entryId, { + level: 'error', + status: 'error', + message: `✕ ${req.method} ${req.urlWithParams}${status ? ` [${status}]` : ''} ${ms}ms: ${httpErrorMessage(error)}`, + details: { + method: req.method, + url: req.urlWithParams, + requestHeaders: headersToObject(req.headers), + requestBody: normalizeBody(req.body), + statusCode: status, + durationMs: ms, + responseHeaders: + error instanceof HttpErrorResponse ? headersToObject(error.headers) : undefined, + responseBody: + error instanceof HttpErrorResponse ? normalizeBody(error.error) : undefined, + error: httpErrorMessage(error), + }, + }); + } + }, + }), + ); +}; diff --git a/src/app/core/http/error-classification.util.ts b/src/app/core/http/error-classification.util.ts new file mode 100644 index 0000000..726d016 --- /dev/null +++ b/src/app/core/http/error-classification.util.ts @@ -0,0 +1,67 @@ +import { HttpErrorResponse } from '@angular/common/http'; +import { TimeoutError } from 'rxjs'; + +import type { UserErrorKind } from '../notifications/user-error-messages.config'; + +function isParseFailureMessage(message: string | undefined): boolean { + return !!message?.includes('Http failure during parsing'); +} + +/** + * Определяет категорию ошибки для пользовательского сообщения (без утечки технических деталей). + */ +export function classifyUserError(err: unknown): UserErrorKind { + if (err instanceof TimeoutError) { + return 'timeout'; + } + + if (err instanceof HttpErrorResponse) { + if (isParseFailureMessage(err.message)) { + return 'parse_error'; + } + const s = err.status; + if (s === 0) { + return 'network'; + } + if (s === 408) { + return 'timeout'; + } + if (s === 401) { + return 'unauthorized'; + } + if (s === 403) { + return 'forbidden'; + } + if (s === 404) { + return 'not_found'; + } + if (s === 400) { + return 'bad_request'; + } + if (s >= 500) { + return 'server_error'; + } + if (s >= 400 && s < 500) { + return 'client_error'; + } + return 'unknown'; + } + + if (err instanceof Error) { + const m = err.message; + if (isParseFailureMessage(m)) { + return 'parse_error'; + } + if (/идентификатор|некорректн/i.test(m)) { + return 'invalid_input'; + } + if (/timeout|timed out/i.test(m)) { + return 'timeout'; + } + if (/network|failed to fetch|load failed|net::/i.test(m)) { + return 'network'; + } + } + + return 'unknown'; +} diff --git a/src/app/core/http/http-error.util.ts b/src/app/core/http/http-error.util.ts new file mode 100644 index 0000000..3f0fb23 --- /dev/null +++ b/src/app/core/http/http-error.util.ts @@ -0,0 +1,56 @@ +import { HttpErrorResponse } from '@angular/common/http'; + +import type { ApiErrorBody } from '../models/api.types'; + +/** Безопасная подстановка текста в разметку с innerHTML (например, Taiga notification). */ +export function escapeHtml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function summarizeHtmlError(text: string): string | null { + const t = text.trim(); + if (!t.startsWith(']*>([\s\S]*?)<\/pre>/i); + if (pre?.[1]) { + return pre[1].trim(); + } + const title = t.match(/]*>([\s\S]*?)<\/title>/i); + if (title?.[1]) { + return title[1].trim(); + } + return 'Сервер вернул HTML вместо JSON (часто редирект на вход или ошибка прокси).'; +} + +export function httpErrorMessage(err: unknown): string { + if (err instanceof HttpErrorResponse) { + const body = err.error as ApiErrorBody | string | null | undefined; + if (body && typeof body === 'object' && typeof body.error === 'string') { + return body.error; + } + if (typeof body === 'string' && body.length > 0) { + const fromHtml = summarizeHtmlError(body); + if (fromHtml) { + return fromHtml; + } + return body.length > 200 ? `${body.slice(0, 200)}…` : body; + } + if (err.message?.includes('Http failure during parsing')) { + return 'Ответ не JSON (часто HTML страницы входа или ошибка прокси /api). Перезапустите `npm start` и проверьте proxy.conf.cjs и `SG_DEV_PROXY_TARGET` в `.env`.'; + } + return err.message || `HTTP ${err.status}`; + } + if (err instanceof Error) { + if (err.message.includes('Http failure during parsing')) { + return 'Ответ не JSON — проверьте авторизацию и прокси для API.'; + } + return err.message; + } + return 'Неизвестная ошибка'; +} diff --git a/src/app/core/keyboard/keyboard-key-name-map.ts b/src/app/core/keyboard/keyboard-key-name-map.ts new file mode 100644 index 0000000..c5c6106 --- /dev/null +++ b/src/app/core/keyboard/keyboard-key-name-map.ts @@ -0,0 +1,71 @@ +import { charKeyNameToSvgKeyId } from './vk-to-keyboard-svg-id'; + +/** + * Токены из `key_name` и из `modifiers` (через "+"), в нижнем регистре. + * Соответствие имён бэкенда → id прямоугольников в KB_USA-standard.svg. + */ +function tokenToSvgIds(token: string): string[] { + const t = token.trim().toLowerCase(); + if (!t) { + return []; + } + + const named: Record = { + shift: ['K_kb5a'], + meta: ['K_kb6b'], + super: ['K_kb6b'], + win: ['K_kb6b'], + windows: ['K_kb6b'], + control: ['K_kb6a'], + ctrl: ['K_kb6a'], + alt: ['K_kb6c'], + tab: ['K_kb3a'], + enter: ['K_kb4n'], + return: ['K_kb4n'], + backspace: ['K_kb2n'], + space: ['K_kb6d'], + caps: ['K_kb4a'], + menu: ['K_kb6m'], + }; + + if (named[t]) { + return named[t]!; + } + + const single = charKeyNameToSvgKeyId(t); + if (single) { + return [single]; + } + + return []; +} + +/** + * Формат бэкенда: `{ key_name, modifiers: "Shift+Meta", action }` и т.п. + * Собирает уникальные id клавиш для подсветки (модификаторы + основная клавиша). + */ +export function keyNameModifiersPayloadToSvgIds(o: Record): string[] { + const out: string[] = []; + const seen = new Set(); + + const add = (ids: string[]) => { + for (const id of ids) { + if (id && !seen.has(id)) { + seen.add(id); + out.push(id); + } + } + }; + + const modifiers = typeof o['modifiers'] === 'string' ? o['modifiers'] : ''; + const keyName = typeof o['key_name'] === 'string' ? o['key_name'] : ''; + + for (const part of modifiers.split('+').map((s) => s.trim()).filter(Boolean)) { + add(tokenToSvgIds(part)); + } + if (keyName.trim()) { + add(tokenToSvgIds(keyName)); + } + + return out; +} diff --git a/src/app/core/keyboard/keyboard-payload.util.ts b/src/app/core/keyboard/keyboard-payload.util.ts new file mode 100644 index 0000000..9f7c3d5 --- /dev/null +++ b/src/app/core/keyboard/keyboard-payload.util.ts @@ -0,0 +1,88 @@ +import type { ParsedEvent } from '../models/api.types'; + +import { keyNameModifiersPayloadToSvgIds } from './keyboard-key-name-map'; +import { normalizeVirtualKey, vkToKeyboardSvgKeyId } from './vk-to-keyboard-svg-id'; + +export function isKeyboardTelemetryEvent(event: ParsedEvent): boolean { + const t = (event.event_type ?? '').toLowerCase(); + return t.includes('keyboard') || t.includes('key'); +} + +/** + * Извлекает виртуальный код клавиши из payload (массив чисел, объект с полями vk/keyCode и т.д.). + */ +export function parseKeyboardVirtualKey(data: unknown): number | null { + if (data == null) { + return null; + } + if (Array.isArray(data)) { + if (data.length >= 1) { + const v = data[0]; + if (typeof v === 'number' && Number.isFinite(v)) { + return v; + } + if (typeof v === 'string') { + const n = parseInt(v, 10); + return Number.isFinite(n) ? n : null; + } + } + return null; + } + if (typeof data === 'object') { + const o = data as Record; + for (const key of ['vk', 'virtualKey', 'VirtualKey', 'keyCode', 'KeyCode', 'wParam']) { + const v = o[key]; + if (typeof v === 'number' && Number.isFinite(v)) { + return v; + } + if (typeof v === 'string') { + const n = parseInt(v, 10); + if (Number.isFinite(n)) { + return n; + } + } + } + } + return null; +} + +export function eventPayloadJson(data: unknown): string { + try { + return JSON.stringify(data, null, 2); + } catch { + return String(data); + } +} + +function unwrapJsonPayload(data: unknown): unknown { + if (typeof data === 'string') { + try { + return JSON.parse(data) as unknown; + } catch { + return data; + } + } + return data; +} + +/** + * Id элементов `K_kb*` для подсветки: сначала объект `key_name` / `modifiers`, иначе VK из массива/полей. + */ +export function parseKeyboardHighlightKeyIds(data: unknown): string[] { + const raw = unwrapJsonPayload(data); + if (raw != null && typeof raw === 'object' && !Array.isArray(raw)) { + const o = raw as Record; + if (typeof o['key_name'] === 'string' || typeof o['modifiers'] === 'string') { + const fromNames = keyNameModifiersPayloadToSvgIds(o); + if (fromNames.length > 0) { + return fromNames; + } + } + } + const vk = parseKeyboardVirtualKey(raw); + if (vk != null) { + const id = vkToKeyboardSvgKeyId(normalizeVirtualKey(vk)); + return id ? [id] : []; + } + return []; +} diff --git a/src/app/core/keyboard/keyboard-svg-highlight.service.ts b/src/app/core/keyboard/keyboard-svg-highlight.service.ts new file mode 100644 index 0000000..8dc0168 --- /dev/null +++ b/src/app/core/keyboard/keyboard-svg-highlight.service.ts @@ -0,0 +1,54 @@ +import { Injectable, inject } from '@angular/core'; +import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; +import { Observable, defer, from, shareReplay } from 'rxjs'; +import { map } from 'rxjs/operators'; + +/** + * Файл из `public/KB_USA-standard.svg` — отдаётся с корня приложения (`/KB_USA-standard.svg`), + * не через API. HttpClient нельзя: `apiBaseUrlInterceptor` превратил бы путь в `/api/v1/...`. + */ +const KEYBOARD_SVG_PATH = '/KB_USA-standard.svg'; + +@Injectable({ providedIn: 'root' }) +export class KeyboardSvgHighlightService { + private readonly sanitizer = inject(DomSanitizer); + + private readonly baseSvg$ = defer(() => + from( + fetch(KEYBOARD_SVG_PATH).then((r) => { + if (!r.ok) { + throw new Error(`Не удалось загрузить клавиатуру: ${r.status} ${r.statusText}`); + } + return r.text(); + }), + ), + ).pipe(shareReplay({ bufferSize: 1, refCount: false })); + + svgWithHighlight(keyIds: string[] | null): Observable { + return this.baseSvg$.pipe( + map((svg) => this.injectHighlight(svg, keyIds ?? [])), + map((html) => this.sanitizer.bypassSecurityTrustHtml(html)), + ); + } + + /** Подсветка: заливка `--sg-keyboard-key-pressed-fill` (акцент), подписи/иконки `--sg-keyboard-key-pressed-ink`. */ + private injectHighlight(svgText: string, keyIds: string[]): string { + const valid = keyIds.filter((id) => /^K_kb[0-9a-z]+$/i.test(id)); + if (valid.length === 0) { + return svgText; + } + const rules: string[] = []; + for (const id of valid) { + const suffix = id.slice(2); + rules.push( + `#${id}{fill:var(--sg-keyboard-key-pressed-fill)!important;stroke:none!important;}`, + ); + rules.push(`[id^="T_${suffix}"]{fill:var(--sg-keyboard-key-pressed-ink)!important;}`); + rules.push( + `#S_${suffix} path,#S_${suffix} line,#S_${suffix} polyline,#S_${suffix} rect{stroke:var(--sg-keyboard-key-pressed-ink)!important;fill:none!important;}`, + ); + } + const styleBlock = ``; + return svgText.replace(/]*>/i, (open) => `${open}${styleBlock}`); + } +} diff --git a/src/app/core/keyboard/vk-to-keyboard-svg-id.ts b/src/app/core/keyboard/vk-to-keyboard-svg-id.ts new file mode 100644 index 0000000..994dd55 --- /dev/null +++ b/src/app/core/keyboard/vk-to-keyboard-svg-id.ts @@ -0,0 +1,115 @@ +/** + * Соответствие Windows Virtual Key → id элемента в `public/KB_USA-standard.svg` (K_kb*). + * Раскладка US QWERTY, без блока F1–F12 (на схеме их нет). + */ +export function normalizeVirtualKey(vk: number): number { + if (vk >= 0x61 && vk <= 0x7a) { + return vk - 0x20; + } + return vk; +} + +function buildDigitMap(): Map { + const ids = ['K_kb2k', 'K_kb2b', 'K_kb2c', 'K_kb2d', 'K_kb2e', 'K_kb2f', 'K_kb2g', 'K_kb2h', 'K_kb2i', 'K_kb2j']; + const m = new Map(); + for (let d = 0; d <= 9; d++) { + m.set(0x30 + d, ids[d]!); + } + return m; +} + +const DIGIT_TO_ID = buildDigitMap(); + +const LETTER_TO_ID: Record = { + A: 'K_kb4c', + B: 'K_kb5g', + C: 'K_kb5e', + D: 'K_kb4e', + E: 'K_kb3d', + F: 'K_kb4f', + G: 'K_kb4g', + H: 'K_kb4h', + I: 'K_kb3i', + J: 'K_kb4i', + K: 'K_kb4j', + L: 'K_kb4l', + M: 'K_kb5i', + N: 'K_kb5h', + O: 'K_kb3j', + P: 'K_kb3k', + Q: 'K_kb3b', + R: 'K_kb3e', + S: 'K_kb4d', + T: 'K_kb3f', + U: 'K_kb3h', + V: 'K_kb5f', + W: 'K_kb3c', + X: 'K_kb5d', + Y: 'K_kb3g', + Z: 'K_kb5c', +}; + +/** OEM и прочие VK (Windows). */ +const EXTRA_VK: Record = { + 0x08: 'K_kb2n', + 0x09: 'K_kb3a', + 0x0d: 'K_kb4n', + 0x10: 'K_kb5a', + 0x11: 'K_kb6a', + 0x12: 'K_kb6c', + 0x14: 'K_kb4a', + 0x20: 'K_kb6d', + 0x5b: 'K_kb6b', + 0x5c: 'K_kb6l', + 0x5d: 'K_kb6m', + 0xa0: 'K_kb5a', + 0xa1: 'K_kb5m', + 0xa2: 'K_kb6a', + 0xa3: 'K_kb6n', + 0xa4: 'K_kb6c', + 0xa5: 'K_kb6k', + 0xba: 'K_kb4l', + 0xbb: 'K_kb2m', + 0xbc: 'K_kb5j', + 0xbd: 'K_kb2l', + 0xbe: 'K_kb5k', + 0xbf: 'K_kb5l', + 0xc0: 'K_kb2a', + 0xdb: 'K_kb3l', + 0xdc: 'K_kb3n', + 0xdd: 'K_kb3m', + 0xde: 'K_kb4m', +}; + +export function vkToKeyboardSvgKeyId(vk: number): string | null { + const k = normalizeVirtualKey(vk); + const fromExtra = EXTRA_VK[k]; + if (fromExtra) { + return fromExtra; + } + const digit = DIGIT_TO_ID.get(k); + if (digit) { + return digit; + } + if (k >= 0x41 && k <= 0x5a) { + const ch = String.fromCharCode(k); + return LETTER_TO_ID[ch] ?? null; + } + return null; +} + +/** Одна буква A–Z или цифра 0–9 как в подписи клавиши (`key_name`). */ +export function charKeyNameToSvgKeyId(name: string): string | null { + const c = name.trim(); + if (c.length !== 1) { + return null; + } + if (c >= '0' && c <= '9') { + return DIGIT_TO_ID.get(c.charCodeAt(0)) ?? null; + } + const ch = c.toUpperCase(); + if (ch >= 'A' && ch <= 'Z') { + return LETTER_TO_ID[ch] ?? null; + } + return null; +} diff --git a/src/app/core/models/api.types.ts b/src/app/core/models/api.types.ts new file mode 100644 index 0000000..55433bc --- /dev/null +++ b/src/app/core/models/api.types.ts @@ -0,0 +1,71 @@ +/** Соответствует `handler.ErrorResponse` из OpenAPI. */ +export interface ApiErrorBody { + code?: string; + error?: string; +} + +export interface CreateSessionRequest { + title: string; +} + +export interface CreateSessionResponse { + id: number; + session_key: string; + status: string; + title: string; + created_at: string; +} + +export interface SessionSummary { + id: string; + user_id?: string; + status: string; + started_at?: string; + ended_at?: string; + chunks_total?: number; + events_total?: number; + /** Не в swagger, но бэкенд может отдавать для списка. */ + title?: string; +} + +export interface SessionListResponse { + sessions: SessionSummary[]; + total: number; + limit: number; + offset: number; +} + +export interface StreamInfo { + stream_type: string; + chunk_count?: number; + duration_ms?: number; + playlist_url: string; +} + +export interface SessionDetailResponse { + session: SessionSummary; + streams: StreamInfo[]; +} + +export interface TelemetryEvent { + id: number; + session_id: number; + user_id?: number; + event_type: string; + created_at: string; + payload?: number[]; +} + +export interface ParsedEvent { + event_type: string; + timestamp: number; + data?: unknown; +} + +export interface ParsedEventsResponse { + session_id: number; + count: number; + events: ParsedEvent[]; +} + +export type StreamType = 'screen' | 'webcam'; diff --git a/src/app/core/notifications/user-error-messages.config.ts b/src/app/core/notifications/user-error-messages.config.ts new file mode 100644 index 0000000..b555ca5 --- /dev/null +++ b/src/app/core/notifications/user-error-messages.config.ts @@ -0,0 +1,34 @@ +/** + * Типы ошибок для подбора дружелюбного текста во всплывающих уведомлениях. + * Соответствие «тип → фраза» задаётся здесь; технические детали — только в Dev Console. + */ +export const USER_ERROR_FRIENDLY_MESSAGES = { + /** Нет сети / соединение сброшено (часто HTTP 0). */ + network: 'Проверьте интернет-соединение.', + /** Таймаут запроса (в т.ч. RxJS timeout, HTTP 408). */ + timeout: 'Запрос занял слишком много времени. Попробуйте позже.', + /** Ответ сервера 5xx. */ + server_error: 'Уже работаем над этим.', + /** 404. */ + not_found: 'Не удалось найти запрашиваемые данные.', + /** 401. */ + unauthorized: 'Требуется вход в систему.', + /** 403. */ + forbidden: 'Недостаточно прав для этого действия.', + /** 400. */ + bad_request: 'Некорректный запрос. Попробуйте позже.', + /** Прочие 4xx (кроме перечисленных выше). */ + client_error: 'Не удалось выполнить запрос. Попробуйте позже.', + /** Не JSON / ошибка разбора ответа. */ + parse_error: 'Получен неожиданный ответ сервера. Попробуйте позже.', + /** Локальная валидация (неверный id и т.п.). */ + invalid_input: 'Проверьте введённые данные.', + /** Не удалось отнести к категории. */ + unknown: 'Попробуйте позже.', +} as const; + +export type UserErrorKind = keyof typeof USER_ERROR_FRIENDLY_MESSAGES; + +export function friendlyMessageForUserError(kind: UserErrorKind): string { + return USER_ERROR_FRIENDLY_MESSAGES[kind]; +} diff --git a/src/app/core/notifications/user-error-notify.service.ts b/src/app/core/notifications/user-error-notify.service.ts new file mode 100644 index 0000000..5eff160 --- /dev/null +++ b/src/app/core/notifications/user-error-notify.service.ts @@ -0,0 +1,40 @@ +import { isDevMode, inject, Injectable } from '@angular/core'; +import { TuiNotificationService } from '@taiga-ui/core/components/notification'; + +import { DevLogService } from '../devtools/dev-log.service'; +import { classifyUserError } from '../http/error-classification.util'; +import { escapeHtml, httpErrorMessage } from '../http/http-error.util'; +import { friendlyMessageForUserError } from './user-error-messages.config'; + +const ERROR_TOAST_TITLE = 'Что-то пошло не так...'; + +/** + * Показывает пользователю обобщённое уведомление; точный текст ошибки — только в DevLog (в dev). + */ +@Injectable({ providedIn: 'root' }) +export class UserErrorNotifyService { + private readonly notifications = inject(TuiNotificationService); + private readonly devLog = inject(DevLogService); + + notifyError(err: unknown, source: string): void { + const kind = classifyUserError(err); + const userSubtitle = friendlyMessageForUserError(kind); + const technical = httpErrorMessage(err); + + if (isDevMode()) { + this.devLog.add({ + level: 'error', + source: 'system', + message: `${source}: ${technical}`, + }); + } + + this.notifications + .open(escapeHtml(userSubtitle), { + label: ERROR_TOAST_TITLE, + appearance: 'negative', + autoClose: 0, + }) + .subscribe(); + } +} diff --git a/src/app/core/services/sessions-api.service.ts b/src/app/core/services/sessions-api.service.ts new file mode 100644 index 0000000..ff82622 --- /dev/null +++ b/src/app/core/services/sessions-api.service.ts @@ -0,0 +1,80 @@ +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; +import { Observable } from 'rxjs'; + +import { API_ORIGIN } from '../config/api.tokens'; +import type { + CreateSessionRequest, + CreateSessionResponse, + ParsedEventsResponse, + SessionDetailResponse, + SessionListResponse, + StreamType, + TelemetryEvent, +} from '../models/api.types'; + +@Injectable({ providedIn: 'root' }) +export class SessionsApiService { + private readonly http = inject(HttpClient); + private readonly apiOrigin = inject(API_ORIGIN); + + listSessions(limit = 50, offset = 0): Observable { + const params = new HttpParams() + .set('limit', String(limit)) + .set('offset', String(offset)); + return this.http.get('/sessions', { params }); + } + + createSession(body: CreateSessionRequest): Observable { + return this.http.post('/sessions', body); + } + + getSession(id: number): Observable { + return this.http.get(`/sessions/${id}`); + } + + getTelemetry(sessionId: number): Observable { + return this.http.get(`/sessions/${sessionId}/telemetry`); + } + + getParsedEvents(sessionId: number, from?: number, to?: number): Observable { + let params = new HttpParams(); + if (typeof from === 'number') { + params = params.set('from', String(from)); + } + if (typeof to === 'number') { + params = params.set('to', String(to)); + } + return this.http.get(`/sessions/${sessionId}/events`, { params }); + } + + /** + * Относительный `playlist_url` из API нужно разрешить относительно хоста (`API_ORIGIN`). + */ + resolvePlaylistUrl(playlistUrl: string): string { + if (/^https?:\/\//i.test(playlistUrl)) { + return playlistUrl; + } + return new URL(playlistUrl, `${this.apiOrigin}/`).href; + } + + /** + * Ручка для агента (Bearer). В веб-интерфейсе обычно не нужна — оставлена для отладки/скриптов. + */ + uploadChunk( + sessionId: number, + chunkIdx: number, + file: File, + streamType: StreamType = 'screen', + bearerToken: string, + ): Observable { + const form = new FormData(); + form.set('session_id', String(sessionId)); + form.set('chunk_idx', String(chunkIdx)); + form.set('stream_type', streamType); + form.set('file', file, file.name); + return this.http.post('/upload', form, { + headers: { Authorization: `Bearer ${bearerToken}` }, + }); + } +} diff --git a/src/app/core/sessions/session-status-chip-classes.pipe.ts b/src/app/core/sessions/session-status-chip-classes.pipe.ts new file mode 100644 index 0000000..53fb74f --- /dev/null +++ b/src/app/core/sessions/session-status-chip-classes.pipe.ts @@ -0,0 +1,24 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +/** + * Классы-модификаторы для чипа статуса. `finished` — без модификатора (дефолтный вид Taiga). + */ +@Pipe({ + name: 'sessionStatusChipClasses', + standalone: true, +}) +export class SessionStatusChipClassesPipe implements PipeTransform { + transform(value: string | null | undefined): Record { + const k = value?.trim().toLowerCase() ?? ''; + if (k === '' || k === 'finished') { + return {}; + } + if (k === 'active') { + return { 'status-chip--active': true }; + } + if (k === 'pending') { + return { 'status-chip--pending': true }; + } + return { 'status-chip--unknown': true }; + } +} diff --git a/src/app/core/sessions/session-status-labels.config.ts b/src/app/core/sessions/session-status-labels.config.ts new file mode 100644 index 0000000..c94b1ea --- /dev/null +++ b/src/app/core/sessions/session-status-labels.config.ts @@ -0,0 +1,11 @@ +/** + * Отображаемые подписи для статусов сессий (значения с API — обычно в нижнем регистре). + * Неизвестный ключ в UI показывается как есть; в dev — предупреждение в консоли разработчика. + */ +export const SESSION_STATUS_LABELS: Readonly> = { + active: 'Активная', + pending: 'Ожидается', + finished: 'Завершена', + paused: 'Приостановлена', + failed: 'Ошибка', +}; diff --git a/src/app/core/sessions/session-status.pipe.ts b/src/app/core/sessions/session-status.pipe.ts new file mode 100644 index 0000000..b604b8d --- /dev/null +++ b/src/app/core/sessions/session-status.pipe.ts @@ -0,0 +1,33 @@ +import { isDevMode, Pipe, PipeTransform, inject } from '@angular/core'; + +import { DevLogService } from '../devtools/dev-log.service'; +import { SESSION_STATUS_LABELS } from './session-status-labels.config'; + +@Pipe({ + name: 'sessionStatus', + standalone: true, +}) +export class SessionStatusPipe implements PipeTransform { + private readonly devLog = inject(DevLogService); + private readonly warnedUnknown = new Set(); + + transform(value: string | null | undefined): string { + if (value == null || value === '') { + return '—'; + } + const key = value.trim(); + const mapped = SESSION_STATUS_LABELS[key.toLowerCase()]; + if (mapped !== undefined) { + return mapped; + } + if (isDevMode() && !this.warnedUnknown.has(key)) { + this.warnedUnknown.add(key); + this.devLog.add({ + level: 'warn', + source: 'system', + message: `Неизвестный статус сессии (нет подписи в session-status-labels.config): ${key}`, + }); + } + return key; + } +} diff --git a/src/app/features/devtools/dev-console/dev-console.component.ts b/src/app/features/devtools/dev-console/dev-console.component.ts new file mode 100644 index 0000000..6db4871 --- /dev/null +++ b/src/app/features/devtools/dev-console/dev-console.component.ts @@ -0,0 +1,89 @@ +import { DatePipe } from '@angular/common'; +import { ChangeDetectionStrategy, Component, computed, inject, isDevMode, signal } from '@angular/core'; + +import { DevLogService } from '../../../core/devtools/dev-log.service'; + +@Component({ + selector: 'app-dev-console', + imports: [DatePipe], + templateUrl: './dev-console.html', + styleUrl: './dev-console.css', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class DevConsoleComponent { + private readonly logs = inject(DevLogService); + protected readonly isDev = isDevMode(); + protected readonly collapsed = signal(false); + protected readonly minimized = signal(false); + protected readonly entries = this.logs.entries; + protected readonly count = computed(() => this.entries().length); + protected readonly expandedIds = signal>({}); + + constructor() { + if (!this.isDev || typeof window === 'undefined') { + return; + } + + window.addEventListener('error', (e) => { + this.logs.add({ + level: 'error', + source: 'system', + message: `${e.message} (${e.filename}:${e.lineno})`, + }); + }); + + window.addEventListener('unhandledrejection', (e: PromiseRejectionEvent) => { + const reason = + typeof e.reason === 'string' + ? e.reason + : e.reason instanceof Error + ? e.reason.message + : 'Unhandled promise rejection'; + this.logs.add({ + level: 'warn', + source: 'system', + message: reason, + }); + }); + } + + protected toggleCollapsed(): void { + this.collapsed.update((v) => !v); + } + + protected minimize(): void { + this.minimized.set(true); + this.collapsed.set(false); + } + + protected restore(): void { + this.minimized.set(false); + } + + protected clear(): void { + this.logs.clear(); + this.expandedIds.set({}); + } + + protected toggleExpanded(id: number): void { + this.expandedIds.update((curr) => ({ ...curr, [id]: !curr[id] })); + } + + protected isExpanded(id: number): boolean { + return !!this.expandedIds()[id]; + } + + protected pretty(value: unknown): string { + if (value === undefined) { + return ''; + } + if (typeof value === 'string') { + return value; + } + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } + } +} diff --git a/src/app/features/devtools/dev-console/dev-console.css b/src/app/features/devtools/dev-console/dev-console.css new file mode 100644 index 0000000..ab13b83 --- /dev/null +++ b/src/app/features/devtools/dev-console/dev-console.css @@ -0,0 +1,144 @@ +:host { + position: fixed; + right: 1rem; + bottom: 1rem; + z-index: 1000; +} + +.dev-console-mini { + border: 1px solid var(--tui-border-normal); + border-radius: 999px; + background: #1b1d22; + color: #e8edf1; + padding: 0.45rem 0.7rem; + font-size: 0.78rem; + line-height: 1; + cursor: pointer; + box-shadow: 0 8px 24px rgb(0 0 0 / 28%); +} + +.dev-console { + width: min(760px, calc(100vw - 2rem)); + max-height: min(46vh, 380px); + border: 1px solid var(--tui-border-normal); + border-radius: 0.75rem; + background: #121317; + color: #e8edf1; + box-shadow: 0 8px 24px rgb(0 0 0 / 28%); + overflow: hidden; +} + +.dev-console_collapsed { + max-height: 2.5rem; +} + +.dev-console__header { + display: flex; + gap: 0.5rem; + align-items: center; + padding: 0.4rem 0.55rem; + background: #1b1d22; + border-bottom: 1px solid rgb(255 255 255 / 8%); + font-size: 0.8rem; +} + +.dev-console__count { + margin-right: auto; + color: #9aa4b2; +} + +.dev-console__header button { + border: 1px solid rgb(255 255 255 / 18%); + background: transparent; + color: inherit; + border-radius: 0.35rem; + font-size: 0.75rem; + line-height: 1; + padding: 0.35rem 0.45rem; + cursor: pointer; +} + +.dev-console__list { + overflow: auto; + max-height: calc(min(46vh, 380px) - 2.5rem); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 0.76rem; +} + +.dev-console__entry { + display: grid; + grid-template-columns: 6rem 3.2rem 1fr; + gap: 0.5rem; + padding: 0.35rem 0.55rem; + border-bottom: 1px solid rgb(255 255 255 / 6%); +} + +.dev-console__main { + min-width: 0; +} + +.dev-console__entry[data-level='warn'] { + background: rgb(255 199 0 / 8%); +} + +.dev-console__entry[data-level='error'] { + background: rgb(217 45 32 / 12%); +} + +.dev-console__time { + color: #9aa4b2; +} + +.dev-console__source { + color: #c7cbd1; + text-transform: uppercase; +} + +.dev-console__message { + word-break: break-word; +} + +.dev-console__expand { + margin-top: 0.25rem; + border: 1px solid rgb(255 255 255 / 18%); + background: transparent; + color: #d6dde7; + border-radius: 0.3rem; + font-size: 0.72rem; + line-height: 1.2; + padding: 0.2rem 0.35rem; + cursor: pointer; +} + +.dev-console__details { + margin-top: 0.35rem; + padding: 0.4rem; + border: 1px solid rgb(255 255 255 / 12%); + border-radius: 0.4rem; + background: rgb(0 0 0 / 18%); +} + +.dev-console__meta { + margin-bottom: 0.35rem; + display: flex; + flex-wrap: wrap; + gap: 0.6rem; +} + +.dev-console__details details { + margin: 0.35rem 0; +} + +.dev-console__details summary { + cursor: pointer; + color: #d4d9e0; +} + +.dev-console__details pre { + margin: 0.35rem 0 0; + padding: 0.35rem; + border-radius: 0.35rem; + background: rgb(255 255 255 / 4%); + white-space: pre-wrap; + word-break: break-word; +} diff --git a/src/app/features/devtools/dev-console/dev-console.html b/src/app/features/devtools/dev-console/dev-console.html new file mode 100644 index 0000000..e2b11ee --- /dev/null +++ b/src/app/features/devtools/dev-console/dev-console.html @@ -0,0 +1,79 @@ +@if (isDev) { + @if (minimized()) { + + } @else { + + } +} diff --git a/src/app/features/sessions/hls-player/hls-player.component.ts b/src/app/features/sessions/hls-player/hls-player.component.ts new file mode 100644 index 0000000..08140fe --- /dev/null +++ b/src/app/features/sessions/hls-player/hls-player.component.ts @@ -0,0 +1,48 @@ +import { + ChangeDetectionStrategy, + Component, + effect, + ElementRef, + input, + viewChild, +} from '@angular/core'; +import Hls from 'hls.js'; + +@Component({ + selector: 'app-hls-player', + templateUrl: './hls-player.html', + styleUrl: './hls-player.css', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class HlsPlayerComponent { + /** Полный URL плейлиста `.m3u8`. */ + readonly src = input.required(); + + private readonly videoRef = viewChild>('videoEl'); + + constructor() { + effect((onCleanup) => { + const url = this.src(); + const ref = this.videoRef(); + if (!ref) { + return; + } + const video = ref.nativeElement; + let hls: Hls | null = null; + + if (Hls.isSupported()) { + hls = new Hls({ enableWorker: true, lowLatencyMode: true }); + hls.loadSource(url); + hls.attachMedia(video); + } else if (video.canPlayType('application/vnd.apple.mpegurl')) { + video.src = url; + } + + onCleanup(() => { + hls?.destroy(); + video.removeAttribute('src'); + video.load(); + }); + }); + } +} diff --git a/src/app/features/sessions/hls-player/hls-player.css b/src/app/features/sessions/hls-player/hls-player.css new file mode 100644 index 0000000..056c86a --- /dev/null +++ b/src/app/features/sessions/hls-player/hls-player.css @@ -0,0 +1,11 @@ +:host { + display: block; + width: 100%; + max-width: 960px; +} + +.player { + width: 100%; + border-radius: var(--tui-radius-l, 0.75rem); + background: var(--tui-background-neutral-1, #000); +} diff --git a/src/app/features/sessions/hls-player/hls-player.html b/src/app/features/sessions/hls-player/hls-player.html new file mode 100644 index 0000000..34fd645 --- /dev/null +++ b/src/app/features/sessions/hls-player/hls-player.html @@ -0,0 +1 @@ + diff --git a/src/app/features/sessions/session-detail/session-detail.component.ts b/src/app/features/sessions/session-detail/session-detail.component.ts new file mode 100644 index 0000000..6115703 --- /dev/null +++ b/src/app/features/sessions/session-detail/session-detail.component.ts @@ -0,0 +1,325 @@ +import { AsyncPipe, NgClass } from '@angular/common'; +import { HttpErrorResponse } from '@angular/common/http'; +import { ChangeDetectionStrategy, Component, inject, model, signal } from '@angular/core'; +import { toObservable } from '@angular/core/rxjs-interop'; +import { ActivatedRoute, RouterLink } from '@angular/router'; +import { TuiButton } from '@taiga-ui/core/components/button'; +import { TuiLink } from '@taiga-ui/core/components/link'; +import { TuiLoader } from '@taiga-ui/core/components/loader'; +import { TuiTitle } from '@taiga-ui/core/components/title'; +import { TuiChip } from '@taiga-ui/kit/components/chip'; +import { TuiTabs } from '@taiga-ui/kit/components/tabs'; +import { catchError, combineLatest, distinctUntilChanged, map, of, startWith, switchMap, tap, timeout } from 'rxjs'; + +import { UserErrorNotifyService } from '../../../core/notifications/user-error-notify.service'; +import { SessionStatusChipClassesPipe } from '../../../core/sessions/session-status-chip-classes.pipe'; +import { SessionStatusPipe } from '../../../core/sessions/session-status.pipe'; +import type { ParsedEvent, SessionDetailResponse, StreamInfo } from '../../../core/models/api.types'; +import { SessionsApiService } from '../../../core/services/sessions-api.service'; +import { formatTimestamp } from '../../../shared/utils/date-time.util'; +import { HlsPlayerComponent } from '../hls-player/hls-player.component'; +import { TelemetryEventDetailComponent } from '../telemetry-event-detail/telemetry-event-detail.component'; + +@Component({ + selector: 'app-session-detail', + imports: [ + AsyncPipe, + NgClass, + RouterLink, + HlsPlayerComponent, + TuiButton, + TuiChip, + ...TuiTabs, + TuiLink, + TuiLoader, + TuiTitle, + SessionStatusChipClassesPipe, + SessionStatusPipe, + TelemetryEventDetailComponent, + ], + templateUrl: './session-detail.html', + styleUrl: './session-detail.css', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SessionDetailComponent { + private readonly route = inject(ActivatedRoute); + private readonly api = inject(SessionsApiService); + private readonly userErrors = inject(UserErrorNotifyService); + + private readonly telemetryToMs = signal(null); + private readonly recordingStartMs = signal(null); + private readonly recordingEndMs = signal(null); + protected readonly customToLocal = signal(''); + + /** Выбранный тип потока (или первая вкладка по умолчанию в шаблоне). */ + protected readonly selectedStreamType = signal(null); + + /** 0 — просмотр, 1 — служебная информация. */ + protected readonly activeTabIndex = model(0); + + /** null — все типы событий в таблице телеметрии. */ + protected readonly telemetryEventTypeFilter = signal(null); + + /** Раскрытая строка телеметрии: ключ или null. */ + protected readonly expandedTelemetryRowKey = signal(null); + + private readonly sessionId$ = this.route.paramMap.pipe( + map((p) => Number(p.get('id'))), + distinctUntilChanged(), + ); + + protected readonly vm$ = this.sessionId$.pipe( + switchMap((id) => { + if (!Number.isFinite(id)) { + this.userErrors.notifyError(new Error('Некорректный идентификатор сессии'), 'Сессия'); + return of({ status: 'error' as const }); + } + this.selectedStreamType.set(null); + this.activeTabIndex.set(0); + this.telemetryEventTypeFilter.set(null); + this.expandedTelemetryRowKey.set(null); + return this.api.getSession(id).pipe( + map((detail) => ({ status: 'ok' as const, id, detail })), + tap((state) => { + if (state.status !== 'ok') { + return; + } + const start = this.toUnixMs(state.detail.session.started_at); + const end = this.toUnixMs(state.detail.session.ended_at); + this.recordingStartMs.set(start); + this.recordingEndMs.set(end); + // Дефолт для телеметрии: до текущего момента (или конца записи, если завершена). + if (this.telemetryToMs() === null) { + this.telemetryToMs.set(end ?? Date.now()); + } + }), + catchError((e: HttpErrorResponse) => { + this.userErrors.notifyError(e, 'Сессия'); + return of({ status: 'error' as const }); + }), + startWith({ status: 'loading' as const }), + ); + }), + ); + + protected readonly telemetry$ = combineLatest([ + this.sessionId$, + toObservable(this.telemetryToMs), + toObservable(this.recordingStartMs), + toObservable(this.recordingEndMs), + ]).pipe( + switchMap(([id, toMs, recordingStartMs, recordingEndMs]) => { + if (!Number.isFinite(id)) { + return of({ + status: 'error' as const, + toMs, + fromMs: recordingStartMs ?? 0, + telemetry: [] as ParsedEvent[], + parsedMeta: undefined, + }); + } + const now = Date.now(); + const lowerBound = recordingStartMs ?? 0; + const upperBound = recordingEndMs ?? now; + const normalizedTo = this.clamp(toMs ?? upperBound, lowerBound, upperBound); + return this.api + .getParsedEvents( + id, + lowerBound, + normalizedTo, + ) + .pipe( + timeout(12000), + map((resp) => ({ + status: 'ok' as const, + toMs: normalizedTo, + fromMs: lowerBound, + telemetry: Array.isArray(resp.events) ? resp.events : [], + parsedMeta: { + session_id: resp.session_id, + count: resp.count, + }, + })), + catchError((e: unknown) => { + this.userErrors.notifyError(e, 'Телеметрия'); + return of({ + status: 'error' as const, + toMs: normalizedTo, + fromMs: lowerBound, + telemetry: [] as ParsedEvent[], + parsedMeta: undefined, + }); + }), + startWith({ + status: 'loading' as const, + toMs: normalizedTo, + fromMs: lowerBound, + telemetry: [] as ParsedEvent[], + parsedMeta: undefined, + }), + ); + }), + ); + + protected pickStream(type: string): void { + this.selectedStreamType.set(type); + } + + protected activeStreamType(detail: SessionDetailResponse): string | null { + const streams = detail.streams; + if (!streams?.length) { + return null; + } + const picked = this.selectedStreamType(); + if (picked && streams.some((s) => s.stream_type === picked)) { + return picked; + } + return streams[0].stream_type; + } + + protected playlistUrl(detail: SessionDetailResponse): string | null { + const t = this.activeStreamType(detail); + if (!t) { + return null; + } + const stream = detail.streams.find((s) => s.stream_type === t); + return stream ? this.api.resolvePlaylistUrl(stream.playlist_url) : null; + } + + protected formatDate(value: string | null | undefined): string { + return formatTimestamp(value); + } + + protected formatUnixMs(value: number | null | undefined): string { + if (!value) { + return '—'; + } + return formatTimestamp(new Date(value).toISOString()); + } + + protected pickTelemetryEventTypeFilter(type: string | null): void { + this.telemetryEventTypeFilter.set(type); + this.expandedTelemetryRowKey.set(null); + } + + protected telemetryRowKey(row: ParsedEvent, index: number): string { + return `${row.timestamp}\u0000${row.event_type}\u0000${index}`; + } + + protected toggleTelemetryRow(row: ParsedEvent, index: number): void { + const key = this.telemetryRowKey(row, index); + this.expandedTelemetryRowKey.update((cur) => (cur === key ? null : key)); + } + + protected isTelemetryRowExpanded(row: ParsedEvent, index: number): boolean { + return this.expandedTelemetryRowKey() === this.telemetryRowKey(row, index); + } + + /** Уникальные значения `event_type` (пустая строка — отдельная вкладка). */ + protected uniqueTelemetryEventTypes(events: ParsedEvent[]): string[] { + const set = new Set(); + for (const e of events) { + set.add(e.event_type ?? ''); + } + return [...set].sort((a, b) => a.localeCompare(b, 'ru')); + } + + protected telemetryEventsOfType(events: ParsedEvent[], typeKey: string): number { + return events.filter((e) => (e.event_type ?? '') === typeKey).length; + } + + protected filteredTelemetryEvents(events: ParsedEvent[]): ParsedEvent[] { + const filter = this.telemetryEventTypeFilter(); + if (filter === null) { + return events; + } + return events.filter((e) => (e.event_type ?? '') === filter); + } + + protected telemetryTypeTabLabel(typeKey: string): string { + return typeKey === '' ? 'Без типа' : typeKey; + } + + protected eventDataPreview(event: ParsedEvent): string { + const data = event.data; + if (data === null || data === undefined) { + return '—'; + } + if (Array.isArray(data)) { + return data.map((v) => String(v)).join(', '); + } + if (typeof data === 'object') { + try { + return JSON.stringify(data); + } catch { + return '[object]'; + } + } + return String(data); + } + + protected selectRecentWindow(seconds: number): void { + this.customToLocal.set(''); + const start = this.recordingStartMs() ?? Date.now(); + const end = this.recordingEndMs() ?? Date.now(); + this.telemetryToMs.set(this.clamp(start + seconds * 1000, start, end)); + } + + protected loadUntilEndTelemetry(): void { + this.customToLocal.set(''); + const end = this.recordingEndMs() ?? Date.now(); + this.telemetryToMs.set(end); + } + + protected applyCustomTo(value: string): void { + this.customToLocal.set(value); + if (!value) { + return; + } + const ms = new Date(value).getTime(); + if (Number.isFinite(ms)) { + const start = this.recordingStartMs() ?? Date.now(); + const end = this.recordingEndMs() ?? Date.now(); + this.telemetryToMs.set(this.clamp(ms, start, end)); + } + } + + protected telemetryRangeLabel(toMs: number | null): string { + return `С ${this.formatUnixMs(this.recordingStartMs())} до ${this.formatUnixMs(toMs)}`; + } + + protected detailPayloadJson(detail: SessionDetailResponse): string { + try { + return JSON.stringify(detail, null, 2); + } catch { + return '{}'; + } + } + + protected formatDurationMs(ms: number | null | undefined): string { + if (ms === null || ms === undefined || !Number.isFinite(ms)) { + return '—'; + } + return `${ms} ms`; + } + + protected streamResolvedPlaylistUrl(stream: StreamInfo): string { + return this.api.resolvePlaylistUrl(stream.playlist_url); + } + + private toUnixMs(value: string | null | undefined): number | null { + if (!value) { + return null; + } + const ms = new Date(value).getTime(); + return Number.isFinite(ms) ? ms : null; + } + + private clamp(value: number, min: number, max: number): number { + if (max < min) { + return min; + } + return Math.min(max, Math.max(min, value)); + } + +} diff --git a/src/app/features/sessions/session-detail/session-detail.css b/src/app/features/sessions/session-detail/session-detail.css new file mode 100644 index 0000000..6ad8a09 --- /dev/null +++ b/src/app/features/sessions/session-detail/session-detail.css @@ -0,0 +1,258 @@ +.page { + max-width: 960px; + margin: 0 auto; + padding: 1.5rem 1rem 3rem; + box-sizing: border-box; +} + +.back { + margin-bottom: 1rem; +} + +.heading { + margin-bottom: 1.25rem; +} + +/* Не задавать display: block — ломает горизонтальный flex у tui-tabs и переносит вкладки. */ +.session-tabs { + display: flex; + flex-wrap: nowrap; + align-items: flex-end; + margin-bottom: 1.25rem; + min-width: 0; + overflow-x: auto; + -webkit-overflow-scrolling: touch; +} + +/* Воздух между подписью и нижней линией (у Taiga у [tuiTab] padding: 0). */ +.session-tabs [tuiTab] { + padding-block-end: 0.5rem; +} + +/* + * У Taiga для неактивной вкладки: box-shadow inset 0 -.125rem — линия hover оказывается + * выше общей нижней границы полосы (inset 0 -1px на tui-tabs). Убираем дублирующую линию. + */ +.session-tabs [tuiTab]:hover:not(._active) { + box-shadow: none; +} + +.session-title { + margin: 0 0 0.75rem; + font: var(--tui-font-heading-6); + color: var(--tui-text-primary); +} + +.kv { + display: grid; + grid-template-columns: minmax(10rem, 14rem) 1fr; + gap: 0.35rem 1rem; + margin: 0; + font: var(--tui-font-text-s); +} + +.kv dt { + margin: 0; + color: var(--tui-text-tertiary); +} + +.kv dd { + margin: 0; + word-break: break-word; +} + +.mono { + font-family: ui-monospace, monospace; + font-size: 0.92em; +} + +.json-block { + margin: 0.75rem 0 0; + padding: 1rem; + max-height: min(360px, 50vh); + overflow: auto; + border-radius: var(--tui-radius-m); + background: color-mix(in srgb, var(--tui-background-base) 88%, var(--tui-text-primary)); + font-size: 0.8rem; + line-height: 1.45; + white-space: pre-wrap; + word-break: break-word; +} + +.meta-table { + width: 100%; + border-collapse: collapse; + font: var(--tui-font-text-s); +} + +.meta-table th, +.meta-table td { + padding: 0.5rem 0.75rem; + text-align: left; + vertical-align: top; + border-bottom: 1px solid var(--tui-border-normal); +} + +.meta-table th { + font-weight: 600; + color: var(--tui-text-secondary); +} + +.table-wrap_flat { + max-height: none; +} + +.loading-wrap { + display: flex; + justify-content: center; + padding: 3rem; +} + +.loading-wrap_small { + padding: 1rem 0; +} + +.error { + color: var(--tui-status-negative); +} + +.card { + padding: 1.25rem 1.5rem; + border-radius: var(--tui-radius-l); + background: var(--tui-background-elevation-1); + margin-bottom: 1.5rem; +} + +.section-title { + margin: 0 0 1rem; + font: var(--tui-font-heading-6); + color: var(--sg-color-subtitle); +} + +.telemetry-head { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: 0.75rem; + margin-bottom: 0.75rem; +} + +.telemetry-head .section-title { + margin: 0; +} + +.telemetry-actions { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.5rem; +} + +.telemetry-presets { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; +} + +.from-picker { + display: inline-flex; + align-items: center; + gap: 0.35rem; +} + +.summary-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 1rem; + margin-bottom: 0.5rem; +} + +.small { + font: var(--tui-font-text-s); + margin-top: 0.35rem; +} + +.muted { + color: var(--tui-text-tertiary); +} + +.stream-tabs { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-bottom: 1rem; +} + +.stream-tabs button { + transition: + background-color 0.15s ease, + color 0.15s ease, + border-color 0.15s ease, + outline-color 0.15s ease; +} + +/* Контраст к карточке — жёлтый акцент при наведении (специфичнее secondary appearance Taiga) */ +.stream-tabs + button[tuiButton][tuiAppearance][data-appearance='secondary']:hover:not(:disabled):not([data-state='disabled']) { + background: var(--sg-color-accent); + color: var(--sg-color-text); + border-color: color-mix(in srgb, var(--sg-color-accent) 78%, black); + outline: 2px solid transparent; +} + +.stream-tabs button.stream-active[tuiButton][tuiAppearance][data-appearance='secondary'] { + outline: 2px solid var(--sg-color-accent); + background: color-mix(in srgb, var(--sg-color-accent) 22%, white); + border-color: var(--sg-color-accent); + color: var(--sg-color-text); +} + +.table-wrap { + overflow: auto; + max-height: min(480px, 70vh); +} + +.telemetry { + width: 100%; + border-collapse: collapse; + font: var(--tui-font-text-s); +} + +.telemetry th, +.telemetry td { + padding: 0.5rem 0.75rem; + text-align: left; + border-bottom: 1px solid var(--tui-border-normal); +} + +.telemetry th { + position: sticky; + top: 0; + background: var(--tui-background-elevation-1); + z-index: 1; +} + +.telemetry-row { + cursor: pointer; + transition: background-color 0.12s ease; +} + +.telemetry-row:hover { + background: color-mix(in srgb, var(--tui-background-accent-1) 12%, transparent); +} + +.telemetry-row.telemetry-row_expanded { + background: color-mix(in srgb, var(--tui-background-accent-1) 18%, transparent); +} + +.telemetry-row-detail td { + padding: 0 0.75rem 1rem; + border-bottom: 1px solid var(--tui-border-normal); + vertical-align: top; +} + +.payload { + font-family: ui-monospace, monospace; + word-break: break-all; +} diff --git a/src/app/features/sessions/session-detail/session-detail.html b/src/app/features/sessions/session-detail/session-detail.html new file mode 100644 index 0000000..df2a686 --- /dev/null +++ b/src/app/features/sessions/session-detail/session-detail.html @@ -0,0 +1,271 @@ +
+ + + @if (vm$ | async; as state) { + @switch (state.status) { + @case ('loading') { +
+ +
+ } + @case ('error') { +

Не удалось загрузить сессию.

+ } + @case ('ok') { +

Сессия {{ state.id }}

+ + + + + + + @if (telemetry$ | async; as telemetryState) { + @if (activeTabIndex() === 0) { +
+ @if (state.detail.session.title) { +

{{ state.detail.session.title }}

+ } +
+ {{ state.detail.session.status | sessionStatus }} +
+
+ Начало: {{ formatDate(state.detail.session.started_at) }} · Окончание: + {{ formatDate(state.detail.session.ended_at) }} +
+
+ +
+

Трансляция

+ @if (state.detail.streams.length === 0) { +

Потоки ещё не готовы.

+ } @else { +
+ @for (s of state.detail.streams; track s.stream_type) { + + } +
+ @if (playlistUrl(state.detail); as src) { + + } + } +
+ +
+
+

+ События телеметрии ({{ filteredTelemetryEvents(telemetryState.telemetry).length }}) +

+
+ {{ telemetryRangeLabel(telemetryState.toMs) }} +
+ + + + + + +
+ +
+
+ + @if (telemetryState.status === 'loading') { +
+ +
+ } @else if (telemetryState.status === 'error') { +

События телеметрии временно недоступны.

+ } @else { + @if (telemetryState.telemetry.length === 0) { +

Событий пока нет.

+ } @else { +
+ + @for (t of uniqueTelemetryEventTypes(telemetryState.telemetry); track t) { + + } +
+ @if (filteredTelemetryEvents(telemetryState.telemetry).length === 0) { +

Нет событий выбранного типа.

+ } @else { +
+ + + + + + + + + + @for ( + row of filteredTelemetryEvents(telemetryState.telemetry); + track row.timestamp + '-' + row.event_type + '-' + $index; + let i = $index + ) { + + + + + + @if (isTelemetryRowExpanded(row, i)) { + + + + } + } + +
ВремяТипPayload
{{ formatUnixMs(row.timestamp) }}{{ row.event_type || '—' }}{{ eventDataPreview(row) }}
+ +
+
+ } + } + } +
+ } + + @if (activeTabIndex() === 1) { +
+

Сессия

+
+
Идентификатор
+
{{ state.detail.session.id }}
+
ID пользователя
+
{{ state.detail.session.user_id ?? '—' }}
+
Статус
+
{{ state.detail.session.status }}
+
Начало
+
{{ state.detail.session.started_at ?? '—' }}
+
Окончание
+
{{ state.detail.session.ended_at ?? '—' }}
+
Всего чанков
+
{{ state.detail.session.chunks_total ?? '—' }}
+
Всего событий
+
{{ state.detail.session.events_total ?? '—' }}
+
Название
+
{{ state.detail.session.title ?? '—' }}
+
+
+ +
+

Потоки

+ @if (state.detail.streams.length === 0) { +

Нет записей о потоках.

+ } @else { +
+ + + + + + + + + + + + @for (stream of state.detail.streams; track stream.stream_type) { + + + + + + + + } + +
Тип потокаЧанковДлительность (мс)URL плейлиста (как в API)URL плейлиста (абсолютный)
{{ stream.stream_type }}{{ stream.chunk_count ?? '—' }}{{ formatDurationMs(stream.duration_ms) }}{{ stream.playlist_url }}{{ streamResolvedPlaylistUrl(stream) }}
+
+ } +
+ +
+

Запрос событий

+
+
От (мс)
+
{{ telemetryState.fromMs }}
+
До (мс)
+
{{ telemetryState.toMs }}
+
ID сессии
+
+ @if (telemetryState.parsedMeta) { + {{ telemetryState.parsedMeta.session_id }} + } @else { + — + } +
+
Количество
+
+ @if (telemetryState.parsedMeta) { + {{ telemetryState.parsedMeta.count }} + } @else { + — + } +
+
Строк в выборке
+
{{ telemetryState.telemetry.length }}
+
Статус загрузки
+
{{ telemetryState.status }}
+
+
+ +
+

Исходный JSON

+
{{ detailPayloadJson(state.detail) }}
+
+ } + } + } + } + } +
diff --git a/src/app/features/sessions/sessions-list/sessions-list.component.ts b/src/app/features/sessions/sessions-list/sessions-list.component.ts new file mode 100644 index 0000000..274073d --- /dev/null +++ b/src/app/features/sessions/sessions-list/sessions-list.component.ts @@ -0,0 +1,106 @@ +import { AsyncPipe, NgClass } from '@angular/common'; +import { HttpErrorResponse } from '@angular/common/http'; +import { + ChangeDetectionStrategy, + Component, + inject, + signal, +} from '@angular/core'; +import { toObservable } from '@angular/core/rxjs-interop'; +import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms'; +import { Router, RouterLink } from '@angular/router'; +import { TuiButton } from '@taiga-ui/core/components/button'; +import { TuiLink } from '@taiga-ui/core/components/link'; +import { TuiLoader } from '@taiga-ui/core/components/loader'; +import { TuiTitle } from '@taiga-ui/core/components/title'; +import { TuiInput } from '@taiga-ui/core/components/input'; +import { TuiChip } from '@taiga-ui/kit/components/chip'; +import { TuiPagination } from '@taiga-ui/kit/components/pagination'; +import { catchError, map, of, startWith, switchMap } from 'rxjs'; + +import { UserErrorNotifyService } from '../../../core/notifications/user-error-notify.service'; +import { SessionStatusChipClassesPipe } from '../../../core/sessions/session-status-chip-classes.pipe'; +import { SessionStatusPipe } from '../../../core/sessions/session-status.pipe'; +import { SessionsApiService } from '../../../core/services/sessions-api.service'; +import { formatTimestamp } from '../../../shared/utils/date-time.util'; + +@Component({ + selector: 'app-sessions-list', + imports: [ + AsyncPipe, + NgClass, + ReactiveFormsModule, + RouterLink, + TuiButton, + TuiChip, + ...TuiInput, + TuiLink, + TuiLoader, + TuiPagination, + TuiTitle, + SessionStatusChipClassesPipe, + SessionStatusPipe, + ], + templateUrl: './sessions-list.html', + styleUrl: './sessions-list.css', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SessionsListComponent { + private readonly api = inject(SessionsApiService); + private readonly router = inject(Router); + private readonly userErrors = inject(UserErrorNotifyService); + + protected readonly limit = 10; + protected readonly pageIndex = signal(0); + + protected readonly titleControl = new FormControl('', { + nonNullable: true, + validators: [Validators.required, Validators.minLength(1)], + }); + + protected readonly creating = signal(false); + + protected readonly listState$ = toObservable(this.pageIndex).pipe( + switchMap((page) => + this.api.listSessions(this.limit, page * this.limit).pipe( + map((data) => ({ status: 'ok' as const, data })), + catchError((e: HttpErrorResponse) => { + this.userErrors.notifyError(e, 'Список сессий'); + return of({ status: 'error' as const }); + }), + startWith({ status: 'loading' as const }), + ), + ), + ); + + protected onPageIndexChange(index: number): void { + this.pageIndex.set(index); + } + + protected pageCount(total: number): number { + return Math.max(1, Math.ceil(total / this.limit)); + } + + protected formatDate(value: string | null | undefined): string { + return formatTimestamp(value); + } + + protected createSession(): void { + if (this.titleControl.invalid || this.creating()) { + this.titleControl.markAsTouched(); + return; + } + const title = this.titleControl.value.trim(); + this.creating.set(true); + this.api.createSession({ title }).subscribe({ + next: (res) => { + this.creating.set(false); + void this.router.navigate(['/sessions', res.id]); + }, + error: (e: HttpErrorResponse) => { + this.creating.set(false); + this.userErrors.notifyError(e, 'Создание сессии'); + }, + }); + } +} diff --git a/src/app/features/sessions/sessions-list/sessions-list.css b/src/app/features/sessions/sessions-list/sessions-list.css new file mode 100644 index 0000000..c99a397 --- /dev/null +++ b/src/app/features/sessions/sessions-list/sessions-list.css @@ -0,0 +1,119 @@ +.page { + max-width: 960px; + margin: 0 auto; + padding: 1.5rem 1rem 3rem; + box-sizing: border-box; +} + +.heading { + margin-bottom: 1.5rem; +} + +.card { + padding: 1.25rem 1.5rem; + border-radius: var(--tui-radius-l); + background: var(--tui-background-elevation-1); + margin-bottom: 1.5rem; +} + +.section-title { + margin: 0 0 1rem; + font: var(--tui-font-heading-6); + color: var(--sg-color-subtitle); +} + +.create-row { + display: flex; + flex-wrap: wrap; + gap: 1rem; + align-items: flex-end; +} + +.create-field { + flex: 1 1 240px; + min-width: 0; +} + +button.accent-cta[tuiAppearance][data-appearance='primary'] { + min-inline-size: var(--sg-primary-action-min-inline-size); + --t-bg: var(--sg-color-accent); + background: var(--t-bg); + border-color: var(--sg-color-accent); + color: var(--sg-color-text); + font-weight: 400; +} + +button.accent-cta[tuiAppearance][data-appearance='primary']:hover:not([data-state='disabled']):not(:disabled) { + --t-bg: var(--tui-background-accent-1-hover); + filter: brightness(0.96); +} + +.loading-wrap { + display: flex; + justify-content: center; + padding: 3rem; +} + +.error { + color: var(--tui-status-negative); + margin: 0.75rem 0 0; +} + +.muted { + color: var(--tui-text-tertiary); +} + +.session-list { + list-style: none; + margin: 0; + padding: 0; +} + +.session-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 0; + border-bottom: 1px solid var(--tui-border-normal); +} + +.session-row:last-child { + border-bottom: none; +} + +.session-link { + font: var(--tui-font-text-m); +} + +.status-chip { + flex-shrink: 0; +} + +.meta { + margin-left: auto; + font: var(--tui-font-text-s); +} + +.pager { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 1rem; + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid var(--tui-border-normal); +} + +.total { + font: var(--tui-font-text-s); +} + +/* Активная страница пагинации (Taiga 5: активная кнопка — appearance primary) */ +tui-pagination button.t-button[tuiButton][tuiAppearance][data-appearance='primary'] { + --t-bg: var(--sg-color-accent); + background: var(--t-bg); + border-color: var(--sg-color-accent); + color: var(--sg-color-text); +} diff --git a/src/app/features/sessions/sessions-list/sessions-list.html b/src/app/features/sessions/sessions-list/sessions-list.html new file mode 100644 index 0000000..4ce9341 --- /dev/null +++ b/src/app/features/sessions/sessions-list/sessions-list.html @@ -0,0 +1,82 @@ +
+

Сессии прокторинга

+ +
+

Новая сессия

+
+ + + + + +
+
+ + @if (listState$ | async; as state) { + @switch (state.status) { + @case ('loading') { +
+ +
+ } + @case ('error') { +
+

Список временно недоступен.

+
+ } + @case ('ok') { +
+ @if (state.data.sessions.length === 0) { +

Сессий пока нет.

+ } @else { + + } +
+ Всего: {{ state.data.total }} + +
+
+ } + } + } +
diff --git a/src/app/features/sessions/telemetry-event-detail/telemetry-event-detail.component.ts b/src/app/features/sessions/telemetry-event-detail/telemetry-event-detail.component.ts new file mode 100644 index 0000000..e4d8990 --- /dev/null +++ b/src/app/features/sessions/telemetry-event-detail/telemetry-event-detail.component.ts @@ -0,0 +1,58 @@ +import { AsyncPipe } from '@angular/common'; +import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core'; +import { toObservable } from '@angular/core/rxjs-interop'; +import { SafeHtml } from '@angular/platform-browser'; +import { TuiLoader } from '@taiga-ui/core/components/loader'; +import { Observable, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { + eventPayloadJson, + isKeyboardTelemetryEvent, + parseKeyboardHighlightKeyIds, + parseKeyboardVirtualKey, +} from '../../../core/keyboard/keyboard-payload.util'; +import { KeyboardSvgHighlightService } from '../../../core/keyboard/keyboard-svg-highlight.service'; +import type { ParsedEvent } from '../../../core/models/api.types'; +import { formatTimestamp } from '../../../shared/utils/date-time.util'; + +@Component({ + selector: 'app-telemetry-event-detail', + imports: [AsyncPipe, TuiLoader], + templateUrl: './telemetry-event-detail.html', + styleUrl: './telemetry-event-detail.css', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class TelemetryEventDetailComponent { + private readonly keyboardSvg = inject(KeyboardSvgHighlightService); + + readonly event = input.required(); + + protected readonly keyboardModel = computed(() => { + const e = this.event(); + if (!isKeyboardTelemetryEvent(e)) { + return { kind: 'none' as const }; + } + const keyIds = parseKeyboardHighlightKeyIds(e.data); + const vk = parseKeyboardVirtualKey(e.data); + return { kind: 'keyboard' as const, vk, keyIds }; + }); + + protected readonly keyboardSvg$: Observable = toObservable(this.keyboardModel).pipe( + switchMap((m) => (m.kind === 'keyboard' ? this.keyboardSvg.svgWithHighlight(m.keyIds) : of(null))), + ); + + protected formatTime(ms: number): string { + return formatTimestamp(new Date(ms).toISOString()); + } + + protected payloadText(e: ParsedEvent): string { + return eventPayloadJson(e.data); + } + + /** Удобно в шаблоне, где нет сужения типа для `keyboardModel()`. */ + protected keyboardKeyIds(): string[] { + const m = this.keyboardModel(); + return m.kind === 'keyboard' ? m.keyIds : []; + } +} diff --git a/src/app/features/sessions/telemetry-event-detail/telemetry-event-detail.css b/src/app/features/sessions/telemetry-event-detail/telemetry-event-detail.css new file mode 100644 index 0000000..f25a43d --- /dev/null +++ b/src/app/features/sessions/telemetry-event-detail/telemetry-event-detail.css @@ -0,0 +1,88 @@ +.detail { + padding: 0.5rem 0 0; +} + +.detail-title { + margin: 0 0 0.75rem; + font: var(--tui-font-text-m); + font-weight: 600; + color: var(--tui-text-primary); +} + +.detail-kv { + display: grid; + grid-template-columns: minmax(8rem, 12rem) 1fr; + gap: 0.35rem 1rem; + margin: 0 0 1rem; + font: var(--tui-font-text-s); +} + +.detail-kv dt { + margin: 0; + color: var(--tui-text-tertiary); +} + +.detail-kv dd { + margin: 0; + word-break: break-word; +} + +.payload-json { + margin: 0; + padding: 0.75rem 1rem; + max-height: 200px; + overflow: auto; + border-radius: var(--tui-radius-m); + background: color-mix(in srgb, var(--tui-background-base) 88%, var(--tui-text-primary)); + font-size: 0.8rem; + line-height: 1.45; + white-space: pre-wrap; + word-break: break-word; +} + +.mono { + font-family: ui-monospace, monospace; + font-size: 0.92em; +} + +.keyboard-block { + margin-top: 0.75rem; + padding-top: 0.75rem; + border-top: 1px solid var(--tui-border-normal); +} + +.keyboard-title { + margin: 0 0 0.5rem; + font: var(--tui-font-text-s); + font-weight: 600; + color: var(--tui-text-secondary); +} + +.keyboard-svg-host { + max-width: 100%; + overflow: auto; + border-radius: var(--tui-radius-m); + background: transparent; +} + +.keyboard-svg-host ::ng-deep svg { + display: block; + width: 100%; + height: auto; + max-width: 800px; +} + +.keyboard-loading { + display: flex; + justify-content: center; + padding: 1rem; +} + +.muted { + color: var(--tui-text-tertiary); +} + +.small { + font: var(--tui-font-text-s); + margin: 0.5rem 0 0; +} diff --git a/src/app/features/sessions/telemetry-event-detail/telemetry-event-detail.html b/src/app/features/sessions/telemetry-event-detail/telemetry-event-detail.html new file mode 100644 index 0000000..50a563a --- /dev/null +++ b/src/app/features/sessions/telemetry-event-detail/telemetry-event-detail.html @@ -0,0 +1,44 @@ +
+

Подробности

+
+
Тип
+
{{ event().event_type || '—' }}
+
Время
+
{{ formatTime(event().timestamp) }}
+ @if (keyboardModel().kind === 'keyboard') { +
Виртуальный код (VK)
+
+ @if (keyboardModel().vk != null) { + {{ keyboardModel().vk }} (0x{{ keyboardModel().vk!.toString(16).toUpperCase() }}) + } @else { + — + } +
+ @if (keyboardKeyIds().length > 0) { +
Клавиши на схеме
+
{{ keyboardKeyIds().join(', ') }}
+ } + } +
Данные
+
{{ payloadText(event()) }}
+
+ + @if (keyboardModel().kind === 'keyboard') { +
+

Клавиатура (US)

+ @if (keyboardSvg$ | async; as svg) { +
+ } @else { +
+ +
+ } + @if (keyboardKeyIds().length === 0) { +

+ Не удалось сопоставить событие с клавишами на схеме (проверьте поля key_name / + modifiers или VK). +

+ } +
+ } +
diff --git a/src/app/shared/utils/date-time.util.ts b/src/app/shared/utils/date-time.util.ts new file mode 100644 index 0000000..82cb377 --- /dev/null +++ b/src/app/shared/utils/date-time.util.ts @@ -0,0 +1,23 @@ +/** + * Преобразует ISO-время (timestamptz), например `2026-04-06T23:58:27Z`, + * в человекочитаемый локальный формат. + */ +export function formatTimestamp(value: string | null | undefined): string { + if (!value) { + return '—'; + } + + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return value; + } + + return new Intl.DateTimeFormat('ru-RU', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }).format(date); +} diff --git a/src/environments/environment.prod.ts b/src/environments/environment.prod.ts new file mode 100644 index 0000000..9e11d42 --- /dev/null +++ b/src/environments/environment.prod.ts @@ -0,0 +1,6 @@ +/** Production-сборка (см. fileReplacements в angular.json). При деплое при необходимости меняйте здесь или генерируйте шагом CI. */ +export const environment = { + production: true, + apiFallbackOrigin: 'https://sparkguardian.ru', + apiBasePath: '/api/v1', +} as const; diff --git a/src/environments/environment.ts b/src/environments/environment.ts new file mode 100644 index 0000000..17719a0 --- /dev/null +++ b/src/environments/environment.ts @@ -0,0 +1,6 @@ +// Сгенерировано npm run env:sync — правьте .env и снова запустите sync +export const environment = { + production: false, + apiFallbackOrigin: "https://sparkguardian.ru", + apiBasePath: "/api/v1", +} as const; diff --git a/src/styles.css b/src/styles.css index 90d4ee0..03ada7a 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1 +1,68 @@ -/* You can add global styles to this file, and also import other style files */ +@import './styles/color-tokens.css'; +@import './styles/session-status-chips.css'; +@import './styles/sg-input-fields.css'; + +/* Базовая вёрстка под Taiga UI: тема и шрифты подключаются в angular.json */ +@font-face { + font-family: 'Tinkoff Sans'; + src: url('/fonts/TinkoffSans-Regular.ttf') format('truetype'); + font-weight: 400; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'Tinkoff Sans'; + src: url('/fonts/TinkoffSans-Medium.ttf') format('truetype'); + font-weight: 500; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'Tinkoff Sans'; + src: url('/fonts/TinkoffSans-Bold.ttf') format('truetype'); + font-weight: 700; + font-style: normal; + font-display: swap; +} + +html, +body { + height: 100%; + margin: 0; +} + +body { + box-sizing: border-box; + background: var(--sg-color-bg); + color: var(--sg-color-text); + font: var(--tui-font-text-m, 15px/1.5 'Tinkoff Sans', sans-serif); + font-family: 'Tinkoff Sans', sans-serif; +} + +*, +*::before, +*::after { + box-sizing: inherit; +} + +/* + * Taiga задаёт font через shorthand на кнопках/инпутах; без !important родной шрифт + * перебивает body. Трогать только при смене темы через переменные Taiga. + */ +* { + font-family: 'Tinkoff Sans', sans-serif !important; +} + +/* Form defaults */ +input::placeholder, +textarea::placeholder { + color: var(--sg-color-placeholder); + opacity: 1; +} + +/* Уведомления об ошибках: заголовок — Medium (500) */ +tui-notification-alert [tuiTitle] { + font-weight: 500; +} diff --git a/src/styles/color-tokens.css b/src/styles/color-tokens.css new file mode 100644 index 0000000..70979e5 --- /dev/null +++ b/src/styles/color-tokens.css @@ -0,0 +1,80 @@ +:root { + /* SparkGuardian color tokens */ + --sg-color-accent: #ffdb00; + --sg-color-bg: #f6f7f8; + --sg-color-text: #383839; + --sg-color-subtitle: #313132; + --sg-color-form-bg: #e8edf1; + --sg-color-placeholder: #6b6d6f; + + /* Поля ввода: .sg-tui-textfield на tui-textfield, .sg-native-input на нативных input */ + --sg-color-textfield-bg: #f3f4f7; + --sg-color-textfield-hover-bg: #eaeff3; + --sg-color-textfield-focus-bg: #ffffff; + --sg-color-textfield-focus-border: #333333; + --sg-color-textfield-focus-label: #333333; + --sg-textfield-radius: var(--tui-radius-l); + --sg-native-input-min-height: 2rem; + --sg-native-input-padding: 0.35rem 0.5rem; + /* Основные кнопки (например «Создать»): минимальная ширина без !important */ + --sg-primary-action-min-inline-size: 11rem; + + /* Semantic aliases */ + --sg-color-card-bg: #ffffff; + --sg-color-border: color-mix(in srgb, var(--sg-color-text) 12%, transparent); + --sg-color-danger: #d92d20; + + /* Taiga accent palette override (used by primary/secondary appearances, including pagination active item) */ + --tui-background-accent-1: var(--sg-color-accent); + --tui-background-accent-1-hover: color-mix(in srgb, var(--sg-color-accent) 92%, black); + --tui-background-accent-1-pressed: color-mix(in srgb, var(--sg-color-accent) 84%, black); + --tui-text-primary-on-accent-1: var(--sg-color-text); + + /* + * Bridge to Taiga tokens used in the app. + * This keeps styling centralized and avoids hardcoding colors in components. + */ + --tui-background-base: var(--sg-color-bg); + --tui-background-elevation-1: var(--sg-color-card-bg); + --tui-text-primary: var(--sg-color-text); + --tui-text-tertiary: color-mix(in srgb, var(--sg-color-text) 70%, white); + --tui-text-action: color-mix(in srgb, var(--sg-color-text) 80%, black); + --tui-border-normal: var(--sg-color-border); + --tui-focus: var(--sg-color-accent); + --tui-status-negative: var(--sg-color-danger); + + /* Чипы статусов сессии (кроме «Завершена» — без отдельной раскраски) */ + --sg-session-status-active-bg: color-mix(in srgb, #16a34a 18%, var(--sg-color-card-bg)); + --sg-session-status-active-fg: #166534; + --sg-session-status-active-border: color-mix(in srgb, #16a34a 38%, transparent); + + --sg-session-status-pending-bg: color-mix(in srgb, #d97706 18%, var(--sg-color-card-bg)); + --sg-session-status-pending-fg: #92400e; + --sg-session-status-pending-border: color-mix(in srgb, #d97706 34%, transparent); + + --sg-session-status-unknown-bg: color-mix(in srgb, var(--sg-color-text) 10%, var(--sg-color-card-bg)); + --sg-session-status-unknown-fg: var(--sg-color-text); + --sg-session-status-unknown-border: var(--sg-color-border); + + /* Встроенная SVG-клавиатура (public/KB_USA-standard.svg) */ + --sg-keyboard-font-family: 'Tinkoff Sans', system-ui, sans-serif; + --sg-keyboard-font-weight: 400; + --sg-keyboard-letter-spacing: 0.03em; + /* Не нажатые клавиши — светлый «фоновый» тон (как подложка интерфейса) */ + --sg-keyboard-key-surface-idle: color-mix( + in srgb, + var(--sg-color-form-bg) 78%, + var(--sg-color-border) + ); + --sg-keyboard-key-main: var(--sg-keyboard-key-surface-idle); + --sg-keyboard-key-mod: var(--sg-keyboard-key-surface-idle); + --sg-keyboard-key-other: var(--sg-keyboard-key-surface-idle); + --sg-keyboard-key-stroke: color-mix(in srgb, var(--sg-color-border) 65%, transparent); + /* Символы на клавишах — основной тёмный текст (тот же оттенок, что раньше был заливкой нажатой) */ + --sg-keyboard-ink: var(--sg-color-text); + --sg-keyboard-ink-soft: var(--tui-text-tertiary); + /* Нажатие: контрастный жёлтый акцент, символы на жёлтом — тёмные */ + --sg-keyboard-key-pressed-fill: var(--sg-color-accent); + --sg-keyboard-key-pressed-ink: var(--sg-color-text); + --sg-keyboard-highlight: var(--sg-keyboard-key-pressed-fill); +} diff --git a/src/styles/session-status-chips.css b/src/styles/session-status-chips.css new file mode 100644 index 0000000..eba2ce3 --- /dev/null +++ b/src/styles/session-status-chips.css @@ -0,0 +1,18 @@ +/* Раскраска чипов статуса сессии (см. SessionStatusChipClassesPipe). «Завершена» — без этих классов. */ +[tuiChip].status-chip.status-chip--active { + background: var(--sg-session-status-active-bg); + color: var(--sg-session-status-active-fg); + border-color: var(--sg-session-status-active-border); +} + +[tuiChip].status-chip.status-chip--pending { + background: var(--sg-session-status-pending-bg); + color: var(--sg-session-status-pending-fg); + border-color: var(--sg-session-status-pending-border); +} + +[tuiChip].status-chip.status-chip--unknown { + background: var(--sg-session-status-unknown-bg); + color: var(--sg-session-status-unknown-fg); + border-color: var(--sg-session-status-unknown-border); +} diff --git a/src/styles/sg-input-fields.css b/src/styles/sg-input-fields.css new file mode 100644 index 0000000..e95fe2e --- /dev/null +++ b/src/styles/sg-input-fields.css @@ -0,0 +1,110 @@ +/* + * Единый вид полей ввода SparkGuardian. + * Taiga: добавьте class="sg-tui-textfield" на . + * Нативные input/textarea/select: class="sg-native-input". + */ + +/* --- tui-textfield (Taiga Input) --- */ +tui-textfield.sg-tui-textfield[tuiAppearance][data-appearance='textfield'] { + --tui-focus: var(--sg-color-textfield-focus-border); + --t-shadow: none; + box-shadow: none; + background-color: var(--sg-color-textfield-bg); + outline: 1px solid transparent; + outline-offset: -1px; + border-width: 0; + border-radius: var(--sg-textfield-radius, var(--tui-radius-l)); + filter: none; + transition: + background-color 0.15s ease, + outline-color 0.15s ease; +} + +tui-textfield.sg-tui-textfield[tuiAppearance][data-appearance='textfield']::before, +tui-textfield.sg-tui-textfield[tuiAppearance][data-appearance='textfield']::after { + box-shadow: none; +} + +tui-textfield.sg-tui-textfield[tuiAppearance][data-appearance='textfield']:hover:not(:focus-within):not( + [data-state='disabled'] + ):not(._disabled), +tui-textfield.sg-tui-textfield[tuiAppearance][data-appearance='textfield'][data-state='hover']:not(:focus-within) { + --t-shadow: none; + box-shadow: none; + background-color: var(--sg-color-textfield-hover-bg); + outline: 1px solid transparent; + outline-offset: -1px; +} + +tui-textfield.sg-tui-textfield[tuiAppearance][data-appearance='textfield']:focus-within, +tui-textfield.sg-tui-textfield[tuiAppearance][data-appearance='textfield'][data-focus='true'], +tui-textfield.sg-tui-textfield[tuiAppearance][data-appearance='textfield']:focus-visible:not([data-focus='false']) { + --t-shadow: none; + box-shadow: none; + background-color: var(--sg-color-textfield-focus-bg); + outline: 1px solid var(--sg-color-textfield-focus-border); + outline-offset: -1px; +} + +tui-textfield.sg-tui-textfield[tuiAppearance][data-appearance='textfield'] input:not(.t-filler) { + background: transparent; + outline: none; + box-shadow: none; +} + +tui-textfield.sg-tui-textfield[tuiAppearance][data-appearance='textfield']:focus-within input:not(.t-filler), +tui-textfield.sg-tui-textfield[tuiAppearance][data-appearance='textfield'][data-focus='true'] input:not(.t-filler) { + outline: none; + box-shadow: none; +} + +tui-textfield.sg-tui-textfield[tuiAppearance][data-appearance='textfield']:focus-within [tuiLabel], +tui-textfield.sg-tui-textfield[tuiAppearance][data-appearance='textfield'][data-focus='true'] [tuiLabel] { + color: var(--sg-color-textfield-focus-label) !important; /* Taiga: color … !important на [tuiLabel] */ +} + +tui-textfield.sg-tui-textfield[tuiAppearance][data-appearance='textfield']:focus-within input:not(.t-filler)::placeholder, +tui-textfield.sg-tui-textfield[tuiAppearance][data-appearance='textfield'][data-focus='true'] + input:not(.t-filler)::placeholder { + color: var(--sg-color-textfield-focus-label); +} + +/* --- нативные поля (datetime-local, text, …) --- */ +.sg-native-input { + box-sizing: border-box; + min-block-size: var(--sg-native-input-min-height, 2rem); + padding: var(--sg-native-input-padding, 0.35rem 0.5rem); + border: 1px solid transparent; + outline: 1px solid transparent; + outline-offset: -1px; + border-radius: var(--sg-textfield-radius, var(--tui-radius-l)); + background: var(--sg-color-textfield-bg); + color: var(--sg-color-text); + font: inherit; + box-shadow: none; + transition: + background-color 0.15s ease, + outline-color 0.15s ease; +} + +.sg-native-input:hover:not(:disabled) { + background: var(--sg-color-textfield-hover-bg); + outline: 1px solid transparent; + outline-offset: -1px; +} + +.sg-native-input:focus { + outline: 1px solid var(--sg-color-textfield-focus-border); + outline-offset: -1px; + background: var(--sg-color-textfield-focus-bg); + border-color: transparent; +} + +.sg-native-input:focus-visible { + outline: 1px solid var(--sg-color-textfield-focus-border); + outline-offset: -1px; +} + +.sg-native-input:disabled { + opacity: var(--tui-disabled-opacity); +}