From d96c152ae3a06b1ecc43b4dca7a081f91c823a60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D0=BA=D0=B0=D1=8D=D0=BB=20=D0=9E=D0=B3=D0=B0?= =?UTF-8?q?=D0=BD=D0=B5=D1=81=D1=8F=D0=BD?= Date: Thu, 9 Apr 2026 00:26:47 +0300 Subject: [PATCH] add new functional --- .gitea/workflows/ci.yml | 44 + .gitignore | 6 + angular.json | 6 + eslint.config.js | 82 ++ package-lock.json | 1165 ++++++++++++++++- package.json | 7 +- public/svg/visual/keyboard.svg | 8 +- public/svg/visual/mouse.svg | 19 + src/app/app.css | 6 +- src/app/app.spec.ts | 8 +- .../core/keyboard/keyboard-payload.util.ts | 21 +- .../keyboard-svg-highlight.service.ts | 38 +- src/app/core/mouse/mouse-payload.util.ts | 53 + .../core/mouse/mouse-svg-highlight.service.ts | 78 ++ .../user-error-notify.service.ts | 2 +- .../hls-player/hls-player.component.ts | 92 +- .../session-detail.component.ts | 244 +--- .../session-detail/session-detail.css | 348 +---- .../session-detail/session-detail.html | 331 +---- .../session-detail/session-detail.types.ts | 14 + .../session-info-tab.component.ts | 49 + .../session-info-tab/session-info-tab.css | 54 + .../session-info-tab/session-info-tab.html | 96 ++ .../session-interactive-tab.component.ts | 265 ++++ .../session-interactive-tab.css | 220 ++++ .../session-interactive-tab.html | 151 +++ .../session-view-tab.component.ts | 200 +++ .../session-view-tab/session-view-tab.css | 142 ++ .../session-view-tab/session-view-tab.html | 197 +++ .../sessions/sessions-list/sessions-list.css | 28 - .../sessions/sessions-list/sessions-list.html | 3 +- .../stream-selector.component.ts | 31 + .../telemetry-event-detail.css | 2 +- .../telemetry-event-detail.html | 4 +- src/app/shared/utils/date-time.util.ts | 18 + src/app/shared/utils/json.util.ts | 10 + src/styles.css | 2 + src/styles/color-tokens.css | 4 + src/styles/filter-chips.css | 99 ++ src/styles/page-common.css | 41 + 40 files changed, 3243 insertions(+), 945 deletions(-) create mode 100644 .gitea/workflows/ci.yml create mode 100644 eslint.config.js create mode 100644 public/svg/visual/mouse.svg create mode 100644 src/app/core/mouse/mouse-payload.util.ts create mode 100644 src/app/core/mouse/mouse-svg-highlight.service.ts create mode 100644 src/app/features/sessions/session-detail/session-detail.types.ts create mode 100644 src/app/features/sessions/session-detail/session-info-tab/session-info-tab.component.ts create mode 100644 src/app/features/sessions/session-detail/session-info-tab/session-info-tab.css create mode 100644 src/app/features/sessions/session-detail/session-info-tab/session-info-tab.html create mode 100644 src/app/features/sessions/session-detail/session-interactive-tab/session-interactive-tab.component.ts create mode 100644 src/app/features/sessions/session-detail/session-interactive-tab/session-interactive-tab.css create mode 100644 src/app/features/sessions/session-detail/session-interactive-tab/session-interactive-tab.html create mode 100644 src/app/features/sessions/session-detail/session-view-tab/session-view-tab.component.ts create mode 100644 src/app/features/sessions/session-detail/session-view-tab/session-view-tab.css create mode 100644 src/app/features/sessions/session-detail/session-view-tab/session-view-tab.html create mode 100644 src/app/features/sessions/stream-selector/stream-selector.component.ts create mode 100644 src/app/shared/utils/json.util.ts create mode 100644 src/styles/filter-chips.css create mode 100644 src/styles/page-common.css diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..afb7940 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,44 @@ +# Gitea Actions: линт, unit-тесты (Vitest/jsdom), production build. +# Нужны: включённые Actions в репозитории и зарегистрированный act_runner. +# Если шаги с `uses:` не резолвятся, в настройках Gitea укажите прокси GitHub Actions +# или замените на полные URL, например: https://github.com/actions/checkout@v4 + +name: CI + +on: + push: + branches: + - '**' + pull_request: + branches: + - '**' + workflow_dispatch: + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + checks: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Lint + run: npm run lint + + - name: Unit tests + run: npm run test -- --watch=false + + - name: Production build + run: npm run build diff --git a/.gitignore b/.gitignore index 8e27dd1..1cdf4a9 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,9 @@ __screenshots__/ # System files .DS_Store Thumbs.db + +# Claude Code / Cursor — правила исключения из контекста +.claudeignore +.cursorignore + +CLAUDE.md \ No newline at end of file diff --git a/angular.json b/angular.json index cf454a8..6cd9db5 100644 --- a/angular.json +++ b/angular.json @@ -83,6 +83,12 @@ }, "test": { "builder": "@angular/build:unit-test" + }, + "lint": { + "builder": "@angular-eslint/builder:lint", + "options": { + "lintFilePatterns": ["src/**/*.ts", "src/**/*.html"] + } } } } diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..eb5a03e --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,82 @@ +// @ts-check +const eslint = require('@eslint/js'); +const { defineConfig } = require('eslint/config'); +const tseslint = require('typescript-eslint'); +const angular = require('angular-eslint'); + +module.exports = defineConfig([ + { + ignores: [ + '**/dist/**', + '**/.angular/**', + '**/coverage/**', + '**/node_modules/**', + '**/*.min.js', + ], + }, + { + files: ['**/*.ts'], + extends: [ + eslint.configs.recommended, + tseslint.configs.recommended, + tseslint.configs.stylistic, + angular.configs.tsRecommended, + ], + processor: angular.processInlineTemplates, + rules: { + // Production safety rules: strict on bugs, neutral on visual style. + 'no-alert': 'error', + 'no-debugger': 'error', + 'no-unsafe-finally': 'error', + 'no-console': ['warn', { allow: ['warn', 'error'] }], + '@typescript-eslint/no-unused-vars': [ + 'error', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_', + }, + ], + '@angular-eslint/directive-selector': [ + 'error', + { + type: 'attribute', + prefix: 'app', + style: 'camelCase', + }, + ], + '@angular-eslint/component-selector': [ + 'error', + { + type: 'element', + prefix: 'app', + style: 'kebab-case', + }, + ], + '@angular-eslint/contextual-lifecycle': 'error', + '@angular-eslint/no-empty-lifecycle-method': 'error', + '@angular-eslint/no-input-rename': 'error', + '@angular-eslint/no-output-native': 'error', + '@angular-eslint/use-lifecycle-interface': 'error', + }, + }, + { + files: ['**/*.html'], + extends: [angular.configs.templateRecommended, angular.configs.templateAccessibility], + rules: { + '@angular-eslint/template/no-call-expression': 'off', + }, + }, + { + files: ['**/*.spec.ts'], + rules: { + 'no-console': 'off', + '@typescript-eslint/no-empty-function': 'off', + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-unsafe-assignment': 'off', + '@typescript-eslint/no-unsafe-member-access': 'off', + '@typescript-eslint/no-unsafe-return': 'off', + '@typescript-eslint/no-unsafe-call': 'off', + }, + }, +]); diff --git a/package-lock.json b/package-lock.json index 06f799a..6a761b4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,11 +28,15 @@ "@angular/build": "^21.2.6", "@angular/cli": "^21.2.6", "@angular/compiler-cli": "^21.2.0", + "@eslint/js": "^10.0.1", + "angular-eslint": "21.3.1", "dotenv": "^16.4.7", + "eslint": "^10.2.0", "jsdom": "^28.0.0", "less": "^4.6.4", "prettier": "^3.8.1", "typescript": "~5.9.2", + "typescript-eslint": "^8.58.1", "vitest": "^4.0.8" } }, @@ -334,6 +338,116 @@ "yarn": ">= 1.13.0" } }, + "node_modules/@angular-eslint/builder": { + "version": "21.3.1", + "resolved": "https://registry.npmjs.org/@angular-eslint/builder/-/builder-21.3.1.tgz", + "integrity": "sha512-1f1Lyp5e7OH6txiV224HaY3G1uRCj91OSKq7hT2Vw9NRw6zWFc1anBpDeLVjpL9ptUxzUGIQR5jEV54hOPayoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/architect": ">= 0.2100.0 < 0.2200.0", + "@angular-devkit/core": ">= 21.0.0 < 22.0.0" + }, + "peerDependencies": { + "@angular/cli": ">= 21.0.0 < 22.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": "*" + } + }, + "node_modules/@angular-eslint/bundled-angular-compiler": { + "version": "21.3.1", + "resolved": "https://registry.npmjs.org/@angular-eslint/bundled-angular-compiler/-/bundled-angular-compiler-21.3.1.tgz", + "integrity": "sha512-jjbnJPUXQeQBJ8RM+ahlbt4GH2emVN8JvG3AhFbPci1FrqXi9cOOfkbwLmvpoyTli4LF8gy7g4ctFqnlRgqryw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@angular-eslint/eslint-plugin": { + "version": "21.3.1", + "resolved": "https://registry.npmjs.org/@angular-eslint/eslint-plugin/-/eslint-plugin-21.3.1.tgz", + "integrity": "sha512-08NNTxwawRLTWPLl8dg1BnXMwimx93y4wMEwx2aWQpJbIt4pmNvwJzd+NgoD/Ag2VdLS/gOMadhJH5fgaYKsPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-eslint/bundled-angular-compiler": "21.3.1", + "@angular-eslint/utils": "21.3.1", + "ts-api-utils": "^2.1.0" + }, + "peerDependencies": { + "@typescript-eslint/utils": "^7.11.0 || ^8.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": "*" + } + }, + "node_modules/@angular-eslint/eslint-plugin-template": { + "version": "21.3.1", + "resolved": "https://registry.npmjs.org/@angular-eslint/eslint-plugin-template/-/eslint-plugin-template-21.3.1.tgz", + "integrity": "sha512-ndPWJodkcEOu2PVUxlUwyz4D2u3r9KO7veWmStVNOLeNrICJA+nQvrz2BWCu0l48rO0K5ezsy0JFcQDVwE/5mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-eslint/bundled-angular-compiler": "21.3.1", + "@angular-eslint/utils": "21.3.1", + "aria-query": "5.3.2", + "axobject-query": "4.1.0" + }, + "peerDependencies": { + "@angular-eslint/template-parser": "21.3.1", + "@typescript-eslint/types": "^7.11.0 || ^8.0.0", + "@typescript-eslint/utils": "^7.11.0 || ^8.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": "*" + } + }, + "node_modules/@angular-eslint/schematics": { + "version": "21.3.1", + "resolved": "https://registry.npmjs.org/@angular-eslint/schematics/-/schematics-21.3.1.tgz", + "integrity": "sha512-1U2u4ZsZvwT30aXRLsIJf6tULIiioo9BtASNsldpYecU3/m/1+F61lCYG79qt7YWbif9KABPYZlFTJUFGN8HWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": ">= 21.0.0 < 22.0.0", + "@angular-devkit/schematics": ">= 21.0.0 < 22.0.0", + "@angular-eslint/eslint-plugin": "21.3.1", + "@angular-eslint/eslint-plugin-template": "21.3.1", + "ignore": "7.0.5", + "semver": "7.7.4", + "strip-json-comments": "3.1.1" + }, + "peerDependencies": { + "@angular/cli": ">= 21.0.0 < 22.0.0" + } + }, + "node_modules/@angular-eslint/template-parser": { + "version": "21.3.1", + "resolved": "https://registry.npmjs.org/@angular-eslint/template-parser/-/template-parser-21.3.1.tgz", + "integrity": "sha512-moERVCTekQKOvR8RMuEOtWSO3VS1qrzA3keI1dPto/JVB8Nqp9w3R5ZpEoXHzh4zgEryosxmPgdi6UczJe2ouQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@angular-eslint/bundled-angular-compiler": "21.3.1", + "eslint-scope": "9.1.2" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": "*" + } + }, + "node_modules/@angular-eslint/utils": { + "version": "21.3.1", + "resolved": "https://registry.npmjs.org/@angular-eslint/utils/-/utils-21.3.1.tgz", + "integrity": "sha512-Q3SGA1/36phZhmsp1mYrKzp/jcmqofRr861MYn46FaWIKSYXBYRzl+H3FIJKBu5CE36Bggu6hbNpwGPuUp+MCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-eslint/bundled-angular-compiler": "21.3.1" + }, + "peerDependencies": { + "@typescript-eslint/utils": "^7.11.0 || ^8.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": "*" + } + }, "node_modules/@angular/animations": { "version": "21.2.7", "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-21.2.7.tgz", @@ -473,6 +587,7 @@ "integrity": "sha512-I5DOFcIT1HKymyy2f78fjgD0Iv6jG46GbBZ/VxejcnhjubFpuN4CwPdugXf9rIDs8KZQqBzDBFUbq11vnk8h0A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@angular-devkit/architect": "0.2102.6", "@angular-devkit/core": "21.2.6", @@ -1603,6 +1718,121 @@ "node": ">=18" } }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.5.tgz", + "integrity": "sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.1.tgz", + "integrity": "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, "node_modules/@exodus/bytes": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", @@ -1652,6 +1882,58 @@ "hono": "^4" } }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, "node_modules/@inquirer/ansi": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", @@ -4184,7 +4466,6 @@ "resolved": "https://registry.npmjs.org/@taiga-ui/kit/-/kit-5.1.0.tgz", "integrity": "sha512-3k1asuOgEjDLAtkK/snO8anctfo4vmMsyr16NHcNVDRqvUeRDrWf7sdRD1uXloO0hTrJvVbKR3WnDWxnuTKm8Q==", "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": ">=2.8.1" }, @@ -4340,6 +4621,13 @@ "license": "MIT", "peer": true }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -4347,6 +4635,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/minimatch": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.5.tgz", @@ -4354,6 +4649,242 @@ "license": "MIT", "optional": true }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.1.tgz", + "integrity": "sha512-eSkwoemjo76bdXl2MYqtxg51HNwUSkWfODUOQ3PaTLZGh9uIWWFZIjyjaJnex7wXDu+TRx+ATsnSxdN9YWfRTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.58.1", + "@typescript-eslint/type-utils": "8.58.1", + "@typescript-eslint/utils": "8.58.1", + "@typescript-eslint/visitor-keys": "8.58.1", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.58.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.1.tgz", + "integrity": "sha512-gGkiNMPqerb2cJSVcruigx9eHBlLG14fSdPdqMoOcBfh+vvn4iCq2C8MzUB89PrxOXk0y3GZ1yIWb9aOzL93bw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@typescript-eslint/scope-manager": "8.58.1", + "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/typescript-estree": "8.58.1", + "@typescript-eslint/visitor-keys": "8.58.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.1.tgz", + "integrity": "sha512-gfQ8fk6cxhtptek+/8ZIqw8YrRW5048Gug8Ts5IYcMLCw18iUgrZAEY/D7s4hkI0FxEfGakKuPK/XUMPzPxi5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.58.1", + "@typescript-eslint/types": "^8.58.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.1.tgz", + "integrity": "sha512-TPYUEqJK6avLcEjumWsIuTpuYODTTDAtoMdt8ZZa93uWMTX13Nb8L5leSje1NluammvU+oI3QRr5lLXPgihX3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/visitor-keys": "8.58.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.1.tgz", + "integrity": "sha512-JAr2hOIct2Q+qk3G+8YFfqkqi7sC86uNryT+2i5HzMa2MPjw4qNFvtjnw1IiA1rP7QhNKVe21mSSLaSjwA1Olw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.1.tgz", + "integrity": "sha512-HUFxvTJVroT+0rXVJC7eD5zol6ID+Sn5npVPWoFuHGg9Ncq5Q4EYstqR+UOqaNRFXi5TYkpXXkLhoCHe3G0+7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/typescript-estree": "8.58.1", + "@typescript-eslint/utils": "8.58.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.1.tgz", + "integrity": "sha512-io/dV5Aw5ezwzfPBBWLoT+5QfVtP8O7q4Kftjn5azJ88bYyp/ZMCsyW1lpKK46EXJcaYMZ1JtYj+s/7TdzmQMw==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.1.tgz", + "integrity": "sha512-w4w7WR7GHOjqqPnvAYbazq+Y5oS68b9CzasGtnd6jIeOIeKUzYzupGTB2T4LTPSv4d+WPeccbxuneTFHYgAAWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.58.1", + "@typescript-eslint/tsconfig-utils": "8.58.1", + "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/visitor-keys": "8.58.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.1.tgz", + "integrity": "sha512-Ln8R0tmWC7pTtLOzgJzYTXSCjJ9rDNHAqTaVONF4FEi2qwce8mD9iSOxOpLFFvWp/wBFlew0mjM1L1ihYWfBdQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.58.1", + "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/typescript-estree": "8.58.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.1.tgz", + "integrity": "sha512-y+vH7QE8ycjoa0bWciFg7OpFcipUuem1ujhrdLtq1gByKwfbC7bPeKsiny9e0urg93DqwGcHey+bGRKCnF1nZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.1", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "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", @@ -4518,6 +5049,30 @@ "node": ">= 0.6" } }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -4589,6 +5144,30 @@ "node": ">= 14.0.0" } }, + "node_modules/angular-eslint": { + "version": "21.3.1", + "resolved": "https://registry.npmjs.org/angular-eslint/-/angular-eslint-21.3.1.tgz", + "integrity": "sha512-VGQWTyuPAEO/AnZuqHxGBJMYSiZ0tbrHx/OgPCRTKHfbrFU4x+zivS84h9UWoDpDtius1RyD+ZReFjTAEWptiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": ">= 21.0.0 < 22.0.0", + "@angular-devkit/schematics": ">= 21.0.0 < 22.0.0", + "@angular-eslint/builder": "21.3.1", + "@angular-eslint/eslint-plugin": "21.3.1", + "@angular-eslint/eslint-plugin-template": "21.3.1", + "@angular-eslint/schematics": "21.3.1", + "@angular-eslint/template-parser": "21.3.1", + "@typescript-eslint/types": "^8.0.0", + "@typescript-eslint/utils": "^8.0.0" + }, + "peerDependencies": { + "@angular/cli": ">= 21.0.0 < 22.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": "*", + "typescript-eslint": "^8.0.0" + } + }, "node_modules/ansi-escapes": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", @@ -4631,6 +5210,16 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/array-differ": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/array-differ/-/array-differ-3.0.0.tgz", @@ -4671,6 +5260,16 @@ "node": ">=12" } }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -4955,7 +5554,6 @@ "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "readdirp": "^5.0.0" }, @@ -5333,6 +5931,13 @@ "dev": true, "license": "MIT" }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -5628,6 +6233,235 @@ "dev": true, "license": "MIT" }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.2.0.tgz", + "integrity": "sha512-+L0vBFYGIpSNIt/KWTpFonPrqYvgKw1eUI5Vn7mEogrQcWtWYtNQ7dNqC+px/J0idT3BAkiWrhfS7k+Tum8TUA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.4", + "@eslint/config-helpers": "^0.5.4", + "@eslint/core": "^1.2.0", + "@eslint/plugin-kit": "^0.7.0", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/eslint/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -5638,6 +6472,16 @@ "@types/estree": "^1.0.0" } }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -5783,6 +6627,20 @@ "node": ">=8.6.0" } }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-uri": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", @@ -5828,6 +6686,19 @@ } } }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -5863,6 +6734,44 @@ "url": "https://opencollective.com/express" } }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -6236,6 +7145,16 @@ "url": "https://opencollective.com/express" } }, + "node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/ignore-walk": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-8.0.0.tgz", @@ -6270,6 +7189,16 @@ "dev": true, "license": "MIT" }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -6311,8 +7240,8 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "devOptional": true, "license": "MIT", - "optional": true, "engines": { "node": ">=0.10.0" } @@ -6337,8 +7266,8 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "devOptional": true, "license": "MIT", - "optional": true, "dependencies": { "is-extglob": "^2.1.1" }, @@ -6515,6 +7444,13 @@ "node": ">=6" } }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, "node_modules/json-parse-even-better-errors": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-5.0.0.tgz", @@ -6539,6 +7475,13 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -6569,6 +7512,16 @@ ], "license": "MIT" }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, "node_modules/less": { "version": "4.6.4", "resolved": "https://registry.npmjs.org/less/-/less-4.6.4.tgz", @@ -6607,6 +7560,20 @@ "node": ">=0.10.0" } }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/libphonenumber-js": { "version": "1.12.41", "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.12.41.tgz", @@ -6698,6 +7665,22 @@ "@lmdb/lmdb-win32-x64": "3.5.1" } }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/log-symbols": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz", @@ -7285,6 +8268,13 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, "node_modules/needle": { "version": "3.5.0", "resolved": "https://registry.npmjs.org/needle/-/needle-3.5.0.tgz", @@ -7664,6 +8654,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/ora": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/ora/-/ora-9.3.0.tgz", @@ -7695,6 +8703,38 @@ "license": "MIT", "optional": true }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/p-map": { "version": "7.0.4", "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz", @@ -7832,6 +8872,16 @@ "license": "MIT", "optional": true }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -8005,6 +9055,16 @@ "postcss": "^8.4.31" } }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/prettier": { "version": "3.8.1", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", @@ -8837,6 +9897,19 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -8984,6 +10057,19 @@ "node": ">=20" } }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, "node_modules/ts-morph": { "version": "23.0.0", "resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-23.0.0.tgz", @@ -9017,6 +10103,19 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/type-is": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", @@ -9047,6 +10146,31 @@ "node": ">=14.17" } }, + "node_modules/typescript-eslint": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.58.1.tgz", + "integrity": "sha512-gf6/oHChByg9HJvhMO1iBexJh12AqqTfnuxscMDOVqfJW3htsdRJI/GfPpHTTcyeB8cSTUY2JcZmVgoyPqcrDg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.58.1", + "@typescript-eslint/parser": "8.58.1", + "@typescript-eslint/typescript-estree": "8.58.1", + "@typescript-eslint/utils": "8.58.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, "node_modules/undici": { "version": "7.24.4", "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.4.tgz", @@ -9098,6 +10222,16 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/validate-npm-package-name": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-7.0.2.tgz", @@ -9380,6 +10514,16 @@ "node": ">=8" } }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/wrap-ansi": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", @@ -9553,6 +10697,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/yoctocolors": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", diff --git a/package.json b/package.json index 5015b10..60a448e 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "build": "ng build", "prewatch": "npm run env:sync", "watch": "ng build --watch --configuration development", - "test": "ng test" + "test": "ng test", + "lint": "ng lint" }, "private": true, "packageManager": "npm@11.6.2", @@ -35,11 +36,15 @@ "@angular/build": "^21.2.6", "@angular/cli": "^21.2.6", "@angular/compiler-cli": "^21.2.0", + "@eslint/js": "^10.0.1", + "angular-eslint": "21.3.1", "dotenv": "^16.4.7", + "eslint": "^10.2.0", "jsdom": "^28.0.0", "less": "^4.6.4", "prettier": "^3.8.1", "typescript": "~5.9.2", + "typescript-eslint": "^8.58.1", "vitest": "^4.0.8" } } diff --git a/public/svg/visual/keyboard.svg b/public/svg/visual/keyboard.svg index eabcfe7..1ed81f9 100644 --- a/public/svg/visual/keyboard.svg +++ b/public/svg/visual/keyboard.svg @@ -366,10 +366,10 @@ - Ctrl - Alt - Alt - Ctrl + ctrl + alt + alt + ctrl diff --git a/public/svg/visual/mouse.svg b/public/svg/visual/mouse.svg new file mode 100644 index 0000000..4340778 --- /dev/null +++ b/public/svg/visual/mouse.svg @@ -0,0 +1,19 @@ + + + + + + +ЛКМ +ПКМ + diff --git a/src/app/app.css b/src/app/app.css index 479d706..5bc648b 100644 --- a/src/app/app.css +++ b/src/app/app.css @@ -28,12 +28,12 @@ .brand { display: inline-flex; align-items: center; - gap: 0.75rem; + gap: 0.5rem; font-family: inherit; - font-size: clamp(1.35rem, 2.4vw, 1.65rem); + font-size: clamp(1.15rem, 2vw, 1.4rem); font-weight: 500; line-height: 1.15; - letter-spacing: 0.035em; + letter-spacing: 0.015em; color: var(--tui-text-primary); text-decoration: none; } diff --git a/src/app/app.spec.ts b/src/app/app.spec.ts index 3b8e3e3..3092353 100644 --- a/src/app/app.spec.ts +++ b/src/app/app.spec.ts @@ -10,10 +10,10 @@ describe('App', () => { matches: false, media: query, onchange: null, - addListener: () => {}, - removeListener: () => {}, - addEventListener: () => {}, - removeEventListener: () => {}, + addListener: () => void 0, + removeListener: () => void 0, + addEventListener: () => void 0, + removeEventListener: () => void 0, dispatchEvent: () => false, }), }); diff --git a/src/app/core/keyboard/keyboard-payload.util.ts b/src/app/core/keyboard/keyboard-payload.util.ts index 4b93a80..b9e0a7d 100644 --- a/src/app/core/keyboard/keyboard-payload.util.ts +++ b/src/app/core/keyboard/keyboard-payload.util.ts @@ -1,4 +1,5 @@ import type { ParsedEvent } from '../models/api.types'; +import { unwrapJsonPayload } from '../../shared/utils/json.util'; import { keyNameModifiersPayloadToSvgIds } from './keyboard-key-name-map'; import { normalizeVirtualKey, vkToKeyboardSvgKeyId } from './vk-to-keyboard-svg-id'; @@ -51,15 +52,21 @@ export function eventPayloadJson(data: unknown): string { } } -function unwrapJsonPayload(data: unknown): unknown { - if (typeof data === 'string') { - try { - return JSON.parse(data) as unknown; - } catch { - return data; +export function parseKeyboardAction(data: unknown): 'press' | 'release' | null { + const raw = unwrapJsonPayload(data); + if (raw != null && typeof raw === 'object' && !Array.isArray(raw)) { + const action = (raw as Record)['action']; + if (typeof action === 'string') { + const normalized = action.toLowerCase(); + if (normalized === 'press') { + return 'press'; + } + if (normalized === 'release') { + return 'release'; + } } } - return data; + return null; } export function parseKeyboardHighlightKeyIds(data: unknown): string[] { diff --git a/src/app/core/keyboard/keyboard-svg-highlight.service.ts b/src/app/core/keyboard/keyboard-svg-highlight.service.ts index 3945414..d72594c 100644 --- a/src/app/core/keyboard/keyboard-svg-highlight.service.ts +++ b/src/app/core/keyboard/keyboard-svg-highlight.service.ts @@ -24,28 +24,48 @@ export class KeyboardSvgHighlightService { ), ).pipe(shareReplay({ bufferSize: 1, refCount: false })); - svgWithHighlight(keyIds: string[] | null): Observable { + svgWithHighlight(keyIds: string[] | null, animated = true): Observable { return this.baseSvg$.pipe( - map((svg) => this.injectHighlight(svg, keyIds ?? [])), + map((svg) => this.injectHighlight(svg, keyIds ?? [], animated)), map((html) => this.sanitizer.bypassSecurityTrustHtml(html)), ); } - private injectHighlight(svgText: string, keyIds: string[]): string { - const valid = keyIds.filter((id) => /^K_kb[0-9a-z]+$/i.test(id)); + private injectHighlight(svgText: string, keyIds: string[], animated: boolean): string { + const valid = [...new Set(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); + if (animated) { + rules.push( + `#${id}{fill:var(--sg-keyboard-key-pressed-fill)!important;stroke:none!important;transform-box:fill-box;transform-origin:center;animation:sgInputHighlightPop var(--sg-input-highlight-duration) var(--sg-input-highlight-easing);}`, + ); + rules.push( + `[id^="T_${suffix}"]{fill:var(--sg-keyboard-key-pressed-ink)!important;animation:sgInputHighlightFade var(--sg-input-highlight-duration) var(--sg-input-highlight-easing);}`, + ); + 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;transform-box:fill-box;transform-origin:center;animation:sgInputHighlightFade var(--sg-input-highlight-duration) var(--sg-input-highlight-easing);}`, + ); + } else { + 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;}`, + ); + } + } + if (animated) { 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;}`, + `@keyframes sgInputHighlightPop{0%{opacity:.45;transform:scale(var(--sg-input-highlight-from-scale));}100%{opacity:1;transform:scale(1);}}`, ); + rules.push(`@keyframes sgInputHighlightFade{0%{opacity:.45;}100%{opacity:1;}}`); } const styleBlock = ``; return svgText.replace(/]*>/i, (open) => `${open}${styleBlock}`); diff --git a/src/app/core/mouse/mouse-payload.util.ts b/src/app/core/mouse/mouse-payload.util.ts new file mode 100644 index 0000000..88fdbbe --- /dev/null +++ b/src/app/core/mouse/mouse-payload.util.ts @@ -0,0 +1,53 @@ +import type { ParsedEvent } from '../models/api.types'; +import { unwrapJsonPayload } from '../../shared/utils/json.util'; + +export type MouseHighlightTarget = 'left' | 'middle' | 'right' | 'wheel'; + +function readNumber(o: Record, key: string): number | null { + const v = o[key]; + if (typeof v === 'number' && Number.isFinite(v)) { + return v; + } + if (typeof v === 'string' && v.trim() !== '') { + const parsed = Number(v); + return Number.isFinite(parsed) ? parsed : null; + } + return null; +} + +export function isMouseTelemetryEvent(event: ParsedEvent): boolean { + const t = (event.event_type ?? '').toLowerCase(); + return t.includes('mouse'); +} + +export function parseMouseHighlightTargets(data: unknown): MouseHighlightTarget[] { + const raw = unwrapJsonPayload(data); + if (raw == null || typeof raw !== 'object' || Array.isArray(raw)) { + return []; + } + const o = raw as Record; + const action = typeof o['action'] === 'string' ? o['action'].toLowerCase() : ''; + + if (action.includes('wheel') || action.includes('scroll')) { + return ['wheel']; + } + + const button = readNumber(o, 'button'); + const isDown = o['is_down']; + if (button == null) { + return []; + } + if (isDown === false) { + return []; + } + if (button === 0) { + return ['left']; + } + if (button === 1) { + return ['middle']; + } + if (button === 2) { + return ['right']; + } + return []; +} diff --git a/src/app/core/mouse/mouse-svg-highlight.service.ts b/src/app/core/mouse/mouse-svg-highlight.service.ts new file mode 100644 index 0000000..f0e6b9a --- /dev/null +++ b/src/app/core/mouse/mouse-svg-highlight.service.ts @@ -0,0 +1,78 @@ +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'; + +import type { MouseHighlightTarget } from './mouse-payload.util'; + +const MOUSE_SVG_PATH = '/svg/visual/mouse.svg'; + +@Injectable({ providedIn: 'root' }) +export class MouseSvgHighlightService { + private readonly sanitizer = inject(DomSanitizer); + + private readonly baseSvg$ = defer(() => + from( + fetch(MOUSE_SVG_PATH).then((r) => { + if (!r.ok) { + throw new Error(`Не удалось загрузить мышь: ${r.status} ${r.statusText}`); + } + return r.text(); + }), + ), + ).pipe(shareReplay({ bufferSize: 1, refCount: false })); + + svgWithHighlight(targets: MouseHighlightTarget[] | null, animated = true): Observable { + return this.baseSvg$.pipe( + map((svg) => this.injectHighlight(svg, targets ?? [], animated)), + map((html) => this.sanitizer.bypassSecurityTrustHtml(html)), + ); + } + + private injectHighlight(svgText: string, targets: MouseHighlightTarget[], animated: boolean): string { + let normalized = this.ensureMouseIds(svgText); + const uniq = [...new Set(targets)]; + const hasLeft = uniq.includes('left'); + const hasRight = uniq.includes('right'); + const hasMiddle = uniq.includes('middle') || uniq.includes('wheel'); + const hasAny = hasLeft || hasRight || hasMiddle; + + let surfaceFill = 'var(--sg-keyboard-key-surface-idle)'; + if (hasLeft && hasRight) { + surfaceFill = 'var(--sg-keyboard-key-pressed-fill)'; + } else if (hasLeft) { + surfaceFill = 'url(#sg-mouse-left-fill)'; + } else if (hasRight) { + surfaceFill = 'url(#sg-mouse-right-fill)'; + } + + const wheelFill = hasMiddle + ? 'var(--sg-keyboard-key-pressed-fill)' + : 'var(--sg-keyboard-key-surface-idle)'; + const wheelOpacity = hasAny || hasMiddle ? '1' : '0.98'; + const surfaceAnim = animated && hasAny + ? 'sgInputHighlightFill var(--sg-input-highlight-duration) var(--sg-input-highlight-easing)' + : 'none'; + const wheelAnim = animated && hasMiddle + ? 'sgInputHighlightPop var(--sg-input-highlight-duration) var(--sg-input-highlight-easing)' + : 'none'; + const keyframes = animated + ? `@keyframes sgInputHighlightPop{0%{opacity:.45;transform:scale(var(--sg-input-highlight-from-scale));}100%{opacity:1;transform:scale(1);}}@keyframes sgInputHighlightFill{0%{opacity:.72;}100%{opacity:1;}}` + : ''; + + const defsAndStyles = ``; + normalized = normalized.replace(/]*>/i, (open) => `${open}${defsAndStyles}`); + return normalized; + } + + private ensureMouseIds(svgText: string): string { + let next = svgText; + if (!next.includes('id="SG_MOUSE_SURFACE"')) { + next = next.replace(/]*\bid=)/i, ']*\bid=)/i, '(); + readonly seekToSec = input(null); + readonly isPlaying = input(null); + readonly currentTimeSecChange = output(); + readonly durationSecChange = output(); + readonly playingChange = output(); private readonly videoRef = viewChild>('videoEl'); + private pendingSeekSec: number | null = null; constructor() { effect((onCleanup) => { @@ -28,20 +35,103 @@ export class HlsPlayerComponent { } const video = ref.nativeElement; let hls: Hls | null = null; + const emitCurrentTime = () => { + this.currentTimeSecChange.emit(video.currentTime || 0); + }; + const emitPlaying = () => { + this.playingChange.emit(!video.paused); + }; + const emitDuration = () => { + if (Number.isFinite(video.duration) && video.duration > 0) { + this.durationSecChange.emit(video.duration); + } + }; + + const applyPendingSeek = () => { + if (this.pendingSeekSec == null) { + return; + } + const duration = Number.isFinite(video.duration) ? video.duration : null; + const clamped = + duration && duration > 0 + ? Math.max(0, Math.min(this.pendingSeekSec, duration)) + : Math.max(0, this.pendingSeekSec); + video.currentTime = clamped; + this.pendingSeekSec = null; + this.currentTimeSecChange.emit(video.currentTime || 0); + }; if (Hls.isSupported()) { - hls = new Hls({ enableWorker: true, lowLatencyMode: true }); + hls = new Hls({ + enableWorker: true, + lowLatencyMode: false, + }); hls.loadSource(url); hls.attachMedia(video); } else if (video.canPlayType('application/vnd.apple.mpegurl')) { video.src = url; } + video.addEventListener('timeupdate', emitCurrentTime); + video.addEventListener('seeking', emitCurrentTime); + video.addEventListener('loadedmetadata', emitDuration); + video.addEventListener('durationchange', emitDuration); + video.addEventListener('play', emitPlaying); + video.addEventListener('pause', emitPlaying); + video.addEventListener('loadedmetadata', applyPendingSeek); + video.addEventListener('canplay', applyPendingSeek); + video.addEventListener('seeked', emitCurrentTime); onCleanup(() => { + video.removeEventListener('timeupdate', emitCurrentTime); + video.removeEventListener('seeking', emitCurrentTime); + video.removeEventListener('loadedmetadata', emitDuration); + video.removeEventListener('durationchange', emitDuration); + video.removeEventListener('play', emitPlaying); + video.removeEventListener('pause', emitPlaying); + video.removeEventListener('loadedmetadata', applyPendingSeek); + video.removeEventListener('canplay', applyPendingSeek); + video.removeEventListener('seeked', emitCurrentTime); hls?.destroy(); video.removeAttribute('src'); video.load(); + this.pendingSeekSec = null; }); }); + + effect(() => { + const seekTo = this.seekToSec(); + const ref = this.videoRef(); + if (seekTo == null || !ref) { + return; + } + const video = ref.nativeElement; + this.pendingSeekSec = seekTo; + if (video.readyState < 1) { + return; + } + const duration = Number.isFinite(video.duration) ? video.duration : null; + const clamped = duration && duration > 0 ? Math.max(0, Math.min(seekTo, duration)) : Math.max(0, seekTo); + if (Math.abs(video.currentTime - clamped) > 0.01) { + video.currentTime = clamped; + this.currentTimeSecChange.emit(video.currentTime || 0); + } + this.pendingSeekSec = null; + }); + + effect(() => { + const shouldPlay = this.isPlaying(); + const ref = this.videoRef(); + if (shouldPlay == null || !ref) { + return; + } + const video = ref.nativeElement; + if (shouldPlay) { + void video.play().catch(() => { + this.playingChange.emit(false); + }); + } else { + video.pause(); + } + }); } } diff --git a/src/app/features/sessions/session-detail/session-detail.component.ts b/src/app/features/sessions/session-detail/session-detail.component.ts index b7af02a..6167716 100644 --- a/src/app/features/sessions/session-detail/session-detail.component.ts +++ b/src/app/features/sessions/session-detail/session-detail.component.ts @@ -1,93 +1,48 @@ -import { animate, style, transition, trigger } from '@angular/animations'; -import { AsyncPipe, NgClass } from '@angular/common'; +import { AsyncPipe } 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 { TelemetryEventTypePipe } from '../../../core/sessions/telemetry-event-type.pipe'; -import type { ParsedEvent, SessionDetailResponse, StreamInfo } from '../../../core/models/api.types'; -import { summarizeTelemetryData } from '../../../core/sessions/telemetry-event-summary.engine'; +import type { ParsedEvent } from '../../../core/models/api.types'; import { SessionsApiService } from '../../../core/services/sessions-api.service'; -import { formatTimestamp } from '../../../shared/utils/date-time.util'; -import { formatDurationMsHuman } from '../../../shared/utils/duration.util'; -import { HlsPlayerComponent } from '../hls-player/hls-player.component'; -import { TelemetryEventDetailComponent } from '../telemetry-event-detail/telemetry-event-detail.component'; - -type TelemetryRangeSelection = - | { type: 'preset'; seconds: number } - | { type: 'end' } - | { type: 'custom' }; +import { SessionViewTabComponent } from './session-view-tab/session-view-tab.component'; +import { SessionInteractiveTabComponent } from './session-interactive-tab/session-interactive-tab.component'; +import { SessionInfoTabComponent } from './session-info-tab/session-info-tab.component'; @Component({ selector: 'app-session-detail', imports: [ AsyncPipe, - NgClass, RouterLink, - HlsPlayerComponent, - TuiButton, - TuiChip, - ...TuiTabs, TuiLink, TuiLoader, TuiTitle, - SessionStatusChipClassesPipe, - SessionStatusPipe, - TelemetryEventTypePipe, - TelemetryEventDetailComponent, + ...TuiTabs, + SessionViewTabComponent, + SessionInteractiveTabComponent, + SessionInfoTabComponent, ], templateUrl: './session-detail.html', styleUrl: './session-detail.css', changeDetection: ChangeDetectionStrategy.OnPush, - animations: [ - trigger('telemetryEventDetail', [ - transition(':enter', [ - style({ opacity: 0, transform: 'translateY(-0.4rem)' }), - animate( - '220ms cubic-bezier(0.33, 1, 0.68, 1)', - style({ opacity: 1, transform: 'translateY(0)' }), - ), - ]), - transition(':leave', [ - animate( - '170ms cubic-bezier(0.4, 0, 1, 1)', - style({ opacity: 0, transform: 'translateY(-0.3rem)' }), - ), - ]), - ]), - ], }) 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(''); - - private readonly telemetryRangeSelection = signal({ type: 'end' }); - - protected readonly selectedStreamType = signal(null); - + protected readonly telemetryToMs = signal(null); + protected readonly recordingStartMs = signal(null); + protected readonly recordingEndMs = signal(null); protected readonly activeTabIndex = model(0); - protected readonly telemetryEventTypeFilter = signal(null); - - protected readonly expandedTelemetryRowKey = signal(null); - private readonly sessionId$ = this.route.paramMap.pipe( map((p) => Number(p.get('id'))), distinctUntilChanged(), @@ -99,10 +54,7 @@ export class SessionDetailComponent { 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) => { @@ -113,12 +65,7 @@ export class SessionDetailComponent { const end = this.toUnixMs(state.detail.session.ended_at); this.recordingStartMs.set(start); this.recordingEndMs.set(end); - this.telemetryRangeSelection.set({ type: 'end' }); - this.customToLocal.set(''); - // Дефолт для телеметрии: до текущего момента (или конца записи, если завершена). - if (this.telemetryToMs() === null) { - this.telemetryToMs.set(end ?? Date.now()); - } + this.telemetryToMs.set(end ?? Date.now()); }), catchError((e: HttpErrorResponse) => { this.userErrors.notifyError(e, 'Сессия'); @@ -139,8 +86,8 @@ export class SessionDetailComponent { if (!Number.isFinite(id)) { return of({ status: 'error' as const, - toMs, - fromMs: recordingStartMs ?? 0, + toMs: 0, + fromMs: 0, telemetry: [] as ParsedEvent[], parsedMeta: undefined, }); @@ -150,11 +97,7 @@ export class SessionDetailComponent { const upperBound = recordingEndMs ?? now; const normalizedTo = this.clamp(toMs ?? upperBound, lowerBound, upperBound); return this.api - .getParsedEvents( - id, - lowerBound, - normalizedTo, - ) + .getParsedEvents(id, lowerBound, normalizedTo) .pipe( timeout(12000), map((resp) => ({ @@ -188,160 +131,6 @@ export class SessionDetailComponent { }), ); - 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 telemetryEventTypeKey(event: ParsedEvent): string { - const t = event.event_type; - if (t == null || t === '') { - return ''; - } - return String(t).trim().toLowerCase(); - } - - protected telemetryRowKey(row: ParsedEvent, index: number): string { - return `${row.timestamp}\u0000${this.telemetryEventTypeKey(row)}\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); - } - - protected uniqueTelemetryEventTypes(events: ParsedEvent[]): string[] { - const set = new Set(); - for (const e of events) { - set.add(this.telemetryEventTypeKey(e)); - } - return [...set].sort((a, b) => a.localeCompare(b, 'ru')); - } - - protected telemetryEventsOfType(events: ParsedEvent[], typeKey: string): number { - return events.filter((e) => this.telemetryEventTypeKey(e) === typeKey).length; - } - - protected filteredTelemetryEvents(events: ParsedEvent[]): ParsedEvent[] { - const filter = this.telemetryEventTypeFilter(); - if (filter === null) { - return events; - } - return events.filter((e) => this.telemetryEventTypeKey(e) === filter); - } - - protected telemetryEventSummary(event: ParsedEvent): string { - return summarizeTelemetryData(event.data); - } - - protected selectRecentWindow(seconds: number): void { - this.customToLocal.set(''); - this.telemetryRangeSelection.set({ type: 'preset', seconds }); - 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(''); - this.telemetryRangeSelection.set({ type: 'end' }); - 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)) { - this.telemetryRangeSelection.set({ type: 'custom' }); - const start = this.recordingStartMs() ?? Date.now(); - const end = this.recordingEndMs() ?? Date.now(); - this.telemetryToMs.set(this.clamp(ms, start, end)); - } - } - - protected telemetryRangePresetIs(seconds: number): boolean { - const s = this.telemetryRangeSelection(); - return s.type === 'preset' && s.seconds === seconds; - } - - protected telemetryRangeIsEnd(): boolean { - return this.telemetryRangeSelection().type === 'end'; - } - - protected telemetryRangeLabel(toMs: number | null): string { - return `С ${this.formatUnixMs(this.recordingStartMs())} до ${this.formatUnixMs(toMs)}`; - } - - private detailPayloadJson(detail: SessionDetailResponse): string { - try { - return JSON.stringify(detail, null, 2); - } catch { - return '{}'; - } - } - - protected async copyDetailPayloadJson(detail: SessionDetailResponse): Promise { - const text = this.detailPayloadJson(detail); - try { - await navigator.clipboard.writeText(text); - this.userErrors.notifySuccess('JSON сессии скопирован в буфер обмена.', 'Готово'); - } catch { - // clipboard - } - } - - protected formatDurationMs(ms: number | null | undefined): string { - return formatDurationMsHuman(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; @@ -356,5 +145,4 @@ export class SessionDetailComponent { } 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 index ef50cb8..b92ad04 100644 --- a/src/app/features/sessions/session-detail/session-detail.css +++ b/src/app/features/sessions/session-detail/session-detail.css @@ -1,8 +1,3 @@ -.page { - padding-top: 1.5rem; - padding-bottom: 3rem; -} - .back { margin-bottom: 1rem; } @@ -11,7 +6,7 @@ margin-bottom: 1.25rem; } -/* Не задавать display: block — ломает горизонтальный flex у tui-tabs и переносит вкладки. */ +/* Do not set display: block — it breaks horizontal flex on tui-tabs and wraps tabs. */ .session-tabs { display: flex; flex-wrap: nowrap; @@ -22,351 +17,14 @@ -webkit-overflow-scrolling: touch; } -/* Воздух между подписью и нижней линией (у Taiga у [tuiTab] padding: 0). */ +/* Padding between label and underline (Taiga [tuiTab] has padding: 0 by default). */ .session-tabs [tuiTab] { padding-block-end: 0.5rem; } /* - * У Taiga для неактивной вкладки: box-shadow inset 0 -.125rem — линия hover оказывается - * выше общей нижней границы полосы (inset 0 -1px на tui-tabs). Убираем дублирующую линию. + * Taiga's inactive tab hover shadow is above the tab bar bottom border — remove it. */ .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-copy-row { - display: flex; - flex-wrap: wrap; - align-items: center; - justify-content: space-between; - gap: 0.75rem 1rem; -} - -.json-copy-row .section-title { - margin: 0; -} - -.meta-table { - width: 100%; - font: var(--tui-font-text-s); -} - -.meta-table tbody td { - padding: 0.5rem 0.75rem; - text-align: left; - vertical-align: top; -} - -.meta-table th { - text-align: left; - font-weight: 600; - color: var(--tui-text-primary); -} - -.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; - align-items: flex-start; - gap: 0.75rem; - margin-bottom: 0.75rem; -} - -.telemetry-content { - display: flex; - flex-direction: column; - min-height: 520px; -} - -.telemetry-content > .loading-wrap { - flex: 1; - display: flex; - align-items: center; - justify-content: center; - min-height: 0; -} - -.telemetry-head__main { - display: flex; - flex-direction: column; - gap: 0.35rem; - min-width: 0; - flex: 1; -} - -.telemetry-head .section-title { - margin: 0; -} - -.telemetry-head .telemetry-range { - 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, -.telemetry-presets button { - transition: - background-color 0.15s ease, - color 0.15s ease, - border-color 0.15s ease, - box-shadow 0.15s ease; -} - -/* Неактивный чип */ -.stream-tabs button[tuiButton][tuiAppearance][data-appearance='secondary']:not(.stream-active), -.telemetry-presets button[tuiButton][tuiAppearance][data-appearance='secondary']:not(.stream-active) { - border-radius: 624.9375rem; - font-weight: 400; - background: var(--sg-filter-chip-bg); - color: var(--sg-filter-chip-fg); - border: 1px solid transparent; - outline: none; - box-shadow: none; -} - -.stream-tabs - button[tuiButton][tuiAppearance][data-appearance='secondary']:not(.stream-active):hover:not(:disabled):not( - [data-state='disabled'] - ), -.telemetry-presets - button[tuiButton][tuiAppearance][data-appearance='secondary']:not(.stream-active):hover:not(:disabled):not( - [data-state='disabled'] - ) { - background: var(--sg-filter-chip-bg-hover); - color: var(--sg-filter-chip-fg); - border-color: transparent; - outline: none !important; - box-shadow: none !important; -} - -/* Активный чип */ -.stream-tabs button.stream-active[tuiButton][tuiAppearance][data-appearance='secondary'], -.telemetry-presets button.stream-active[tuiButton][tuiAppearance][data-appearance='secondary'] { - border-radius: 624.9375rem; - font-weight: 400; - background: var(--sg-filter-chip-active-bg); - color: var(--sg-filter-chip-active-fg); - border: 1px solid transparent; - outline: none; - box-shadow: none; -} - -.stream-tabs - button.stream-active[tuiButton][tuiAppearance][data-appearance='secondary']:hover:not(:disabled):not( - [data-state='disabled'] - ), -.telemetry-presets - button.stream-active[tuiButton][tuiAppearance][data-appearance='secondary']:hover:not(:disabled):not( - [data-state='disabled'] - ) { - background: var(--sg-filter-chip-active-bg-hover); - color: var(--sg-filter-chip-active-fg); - border-color: transparent; - outline: none !important; - box-shadow: none !important; -} - -/* - * Taiga оставляет :focus после клика и может показать обводку/тень с задержкой — убираем для мыши. - * Клавиатура: лёгкое кольцо только при :focus-visible. - */ -.stream-tabs button[tuiButton][tuiAppearance][data-appearance='secondary']:focus:not(:focus-visible), -.telemetry-presets button[tuiButton][tuiAppearance][data-appearance='secondary']:focus:not(:focus-visible) { - outline: none !important; - box-shadow: none !important; -} - -.stream-tabs - button[tuiButton][tuiAppearance][data-appearance='secondary']:active:not(:disabled):not([data-state='disabled']), -.telemetry-presets - button[tuiButton][tuiAppearance][data-appearance='secondary']:active:not(:disabled):not([data-state='disabled']) { - outline: none !important; - box-shadow: none !important; -} - -.stream-tabs button[tuiButton][tuiAppearance][data-appearance='secondary']:focus-visible, -.telemetry-presets button[tuiButton][tuiAppearance][data-appearance='secondary']:focus-visible { - outline: 2px solid var(--sg-filter-chip-active-bg); - outline-offset: 2px; - box-shadow: none; -} - -.stream-tabs button.stream-active[tuiButton][tuiAppearance][data-appearance='secondary']:focus-visible, -.telemetry-presets button.stream-active[tuiButton][tuiAppearance][data-appearance='secondary']:focus-visible { - outline-color: var(--sg-filter-chip-active-bg-hover); -} - -.table-wrap { - overflow: auto; - max-height: min(520px, 70vh); -} - -.table-wrap:not(.table-wrap_flat) { - min-height: 520px; -} - -.telemetry { - width: 100%; - font: var(--tui-font-text-s); -} - -.telemetry th { - text-align: left; - color: var(--tui-text-primary); -} - -.telemetry tbody td { - padding: 0.5rem 0.75rem; - text-align: left; - border-bottom: 1px solid var(--tui-border-normal); - vertical-align: top; -} - -/* Длинные неизвестные event_type — перенос, без разъезда таблицы */ -.telemetry-col-type { - min-width: 0; - max-width: 14rem; - overflow-wrap: anywhere; - word-break: break-word; - vertical-align: top; -} - -.telemetry-type-tabs { - overflow-wrap: anywhere; -} - -.telemetry-type-tabs button { - max-width: 100%; - overflow-wrap: anywhere; - word-break: break-word; - text-align: start; -} - -.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; -} - -.telemetry-col-summary { - min-width: 0; - overflow-wrap: anywhere; - word-break: break-word; - vertical-align: top; -} diff --git a/src/app/features/sessions/session-detail/session-detail.html b/src/app/features/sessions/session-detail/session-detail.html index 3a972b4..61d44f9 100644 --- a/src/app/features/sessions/session-detail/session-detail.html +++ b/src/app/features/sessions/session-detail/session-detail.html @@ -18,316 +18,35 @@ + @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 $index; - let i = $index - ) { - - - - - - @if (isTelemetryRowExpanded(row, i)) { - - - - } - } - -
ВремяТипСводка
{{ formatUnixMs(row.timestamp) }} - {{ row.event_type | telemetryEventType }} - {{ telemetryEventSummary(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 (s of state.detail.streams; track s.stream_type) { - - - - - - - } - -
ТипЧанкиДлительностьURL видеозаписи
{{ s.stream_type }}{{ s.chunk_count ?? '—' }}{{ formatDurationMs(s.duration_ms) }}{{ streamResolvedPlaylistUrl(s) }}
-
- } -
- -
-

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

-
-
От (мс)
-
{{ 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

- -
-
+ @switch (activeTabIndex()) { + @case (0) { + + } + @case (1) { + + } + @case (2) { + + } } } } diff --git a/src/app/features/sessions/session-detail/session-detail.types.ts b/src/app/features/sessions/session-detail/session-detail.types.ts new file mode 100644 index 0000000..c3db4b0 --- /dev/null +++ b/src/app/features/sessions/session-detail/session-detail.types.ts @@ -0,0 +1,14 @@ +import type { ParsedEvent } from '../../../core/models/api.types'; + +export type TelemetryRangeSelection = + | { type: 'preset'; seconds: number } + | { type: 'end' } + | { type: 'custom' }; + +export interface TelemetryLoadState { + status: 'loading' | 'ok' | 'error'; + toMs: number; + fromMs: number; + telemetry: ParsedEvent[]; + parsedMeta?: { session_id: number; count: number }; +} diff --git a/src/app/features/sessions/session-detail/session-info-tab/session-info-tab.component.ts b/src/app/features/sessions/session-detail/session-info-tab/session-info-tab.component.ts new file mode 100644 index 0000000..f6bdda5 --- /dev/null +++ b/src/app/features/sessions/session-detail/session-info-tab/session-info-tab.component.ts @@ -0,0 +1,49 @@ +import { ChangeDetectionStrategy, Component, inject, input } from '@angular/core'; +import { TuiButton } from '@taiga-ui/core/components/button'; + +import { UserErrorNotifyService } from '../../../../core/notifications/user-error-notify.service'; +import { SessionsApiService } from '../../../../core/services/sessions-api.service'; +import type { SessionDetailResponse, StreamInfo } from '../../../../core/models/api.types'; +import { formatDurationMsHuman } from '../../../../shared/utils/duration.util'; +import type { TelemetryLoadState } from '../session-detail.types'; + +@Component({ + selector: 'app-session-info-tab', + imports: [TuiButton], + templateUrl: './session-info-tab.html', + styleUrl: './session-info-tab.css', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SessionInfoTabComponent { + private readonly api = inject(SessionsApiService); + private readonly userErrors = inject(UserErrorNotifyService); + + readonly detail = input.required(); + readonly telemetryState = input.required(); + + protected formatDurationMs(ms: number | null | undefined): string { + return formatDurationMsHuman(ms); + } + + protected streamResolvedPlaylistUrl(stream: StreamInfo): string { + return this.api.resolvePlaylistUrl(stream.playlist_url); + } + + protected async copyDetailPayloadJson(): Promise { + const text = this.detailPayloadJson(); + try { + await navigator.clipboard.writeText(text); + this.userErrors.notifySuccess('JSON сессии скопирован в буфер обмена.', 'Готово'); + } catch { + // clipboard + } + } + + private detailPayloadJson(): string { + try { + return JSON.stringify(this.detail(), null, 2); + } catch { + return '{}'; + } + } +} diff --git a/src/app/features/sessions/session-detail/session-info-tab/session-info-tab.css b/src/app/features/sessions/session-detail/session-info-tab/session-info-tab.css new file mode 100644 index 0000000..de25237 --- /dev/null +++ b/src/app/features/sessions/session-detail/session-info-tab/session-info-tab.css @@ -0,0 +1,54 @@ +.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; +} + +.json-copy-row { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.75rem 1rem; +} + +.json-copy-row .section-title { + margin: 0; +} + +.meta-table { + width: 100%; + font: var(--tui-font-text-s); +} + +.meta-table tbody td { + padding: 0.5rem 0.75rem; + text-align: left; + vertical-align: top; +} + +.meta-table th { + text-align: left; + font-weight: 600; + color: var(--tui-text-primary); +} + +.table-wrap { + overflow: auto; +} + +.table-wrap_flat { + max-height: none; +} diff --git a/src/app/features/sessions/session-detail/session-info-tab/session-info-tab.html b/src/app/features/sessions/session-detail/session-info-tab/session-info-tab.html new file mode 100644 index 0000000..0e2d7a9 --- /dev/null +++ b/src/app/features/sessions/session-detail/session-info-tab/session-info-tab.html @@ -0,0 +1,96 @@ +
+

Сессия

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

Потоки

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

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

+ } @else { +
+ + + + + + + + + + + @for (s of detail().streams; track s.stream_type) { + + + + + + + } + +
ТипЧанкиДлительностьURL видеозаписи
{{ s.stream_type }}{{ s.chunk_count ?? '—' }}{{ formatDurationMs(s.duration_ms) }}{{ streamResolvedPlaylistUrl(s) }}
+
+ } +
+ +
+

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

+
+
От (мс)
+
{{ 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

+ +
+
diff --git a/src/app/features/sessions/session-detail/session-interactive-tab/session-interactive-tab.component.ts b/src/app/features/sessions/session-detail/session-interactive-tab/session-interactive-tab.component.ts new file mode 100644 index 0000000..d20e523 --- /dev/null +++ b/src/app/features/sessions/session-detail/session-interactive-tab/session-interactive-tab.component.ts @@ -0,0 +1,265 @@ +import { AsyncPipe } from '@angular/common'; +import { ChangeDetectionStrategy, Component, computed, inject, input, signal } from '@angular/core'; +import { toObservable } from '@angular/core/rxjs-interop'; +import { TuiButton } from '@taiga-ui/core/components/button'; +import { TuiLoader } from '@taiga-ui/core/components/loader'; +import { distinctUntilChanged, switchMap } from 'rxjs'; + +import { + isKeyboardTelemetryEvent, + parseKeyboardAction, + parseKeyboardHighlightKeyIds, +} from '../../../../core/keyboard/keyboard-payload.util'; +import { KeyboardSvgHighlightService } from '../../../../core/keyboard/keyboard-svg-highlight.service'; +import { isMouseTelemetryEvent, parseMouseHighlightTargets } from '../../../../core/mouse/mouse-payload.util'; +import type { MouseHighlightTarget } from '../../../../core/mouse/mouse-payload.util'; +import { MouseSvgHighlightService } from '../../../../core/mouse/mouse-svg-highlight.service'; +import { SessionsApiService } from '../../../../core/services/sessions-api.service'; +import type { ParsedEvent, SessionDetailResponse } from '../../../../core/models/api.types'; +import { formatClockTime, formatUnixMs } from '../../../../shared/utils/date-time.util'; +import { HlsPlayerComponent } from '../../hls-player/hls-player.component'; +import { StreamSelectorComponent } from '../../stream-selector/stream-selector.component'; + +@Component({ + selector: 'app-session-interactive-tab', + imports: [ + AsyncPipe, + TuiButton, + TuiLoader, + HlsPlayerComponent, + StreamSelectorComponent, + ], + templateUrl: './session-interactive-tab.html', + styleUrl: './session-interactive-tab.css', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SessionInteractiveTabComponent { + private readonly api = inject(SessionsApiService); + private readonly keyboardSvg = inject(KeyboardSvgHighlightService); + private readonly mouseSvg = inject(MouseSvgHighlightService); + + readonly detail = input.required(); + readonly telemetryEvents = input.required(); + readonly recordingStartMs = input.required(); + readonly recordingEndMs = input.required(); + + protected readonly selectedStreamType = signal(null); + protected readonly timelineSec = signal(0); + protected readonly durationSec = signal(null); + protected readonly isPlaying = signal(false); + + protected readonly timelineMaxSec = computed(() => { + const videoDuration = this.durationSec(); + if (videoDuration != null && Number.isFinite(videoDuration) && videoDuration > 0) { + return videoDuration; + } + const start = this.recordingStartMs(); + const end = this.recordingEndMs(); + if (start == null || end == null || end <= start) { + return 0; + } + return (end - start) / 1000; + }); + + private readonly cursorMs = computed(() => { + const start = this.recordingStartMs(); + if (start == null) { + return null; + } + return start + this.timelineSec() * 1000; + }); + + /** + * Pre-sorted keyboard events — recomputed only when telemetryEvents changes, + * not on every slider tick. + */ + private readonly sortedKeyboardEvents = computed(() => + this.telemetryEvents() + .filter(isKeyboardTelemetryEvent) + .sort((a, b) => a.timestamp - b.timestamp), + ); + + /** + * Pre-sorted mouse events — recomputed only when telemetryEvents changes. + */ + private readonly sortedMouseEvents = computed(() => + this.telemetryEvents() + .filter(isMouseTelemetryEvent) + .sort((a, b) => a.timestamp - b.timestamp), + ); + + private readonly keyboardKeyIds = computed(() => { + const cursorMs = this.cursorMs(); + if (cursorMs == null) { + return []; + } + const active = new Set(); + // Events are sorted; break early once we exceed cursorMs. + for (const event of this.sortedKeyboardEvents()) { + if (event.timestamp > cursorMs) { + break; + } + const keyIds = parseKeyboardHighlightKeyIds(event.data); + if (keyIds.length === 0) { + continue; + } + const action = parseKeyboardAction(event.data); + if (action === 'release') { + for (const keyId of keyIds) { + active.delete(keyId); + } + } else { + for (const keyId of keyIds) { + active.add(keyId); + } + } + } + return [...active]; + }); + + private readonly mouseTargets = computed(() => { + const cursorMs = this.cursorMs(); + if (cursorMs == null) { + return []; + } + // Find the last mouse event at or before cursorMs. + // Events are sorted ascending; iterate backwards for efficiency. + const events = this.sortedMouseEvents(); + for (let i = events.length - 1; i >= 0; i--) { + const event = events[i]!; + if (event.timestamp <= cursorMs) { + return parseMouseHighlightTargets(event.data); + } + } + return [] as MouseHighlightTarget[]; + }); + + /** + * Keyboard SVG without animations — interactive mode shows state, not events. + * distinctUntilChanged prevents SVG regeneration when the key set hasn't changed, + * which eliminates animation restarts during slider movement. + */ + protected readonly keyboardSvg$ = toObservable(this.keyboardKeyIds).pipe( + distinctUntilChanged(keySetEqual), + switchMap((keyIds) => this.keyboardSvg.svgWithHighlight(keyIds, false)), + ); + + protected readonly mouseSvg$ = toObservable(this.mouseTargets).pipe( + distinctUntilChanged(targetSetEqual), + switchMap((targets) => this.mouseSvg.svgWithHighlight(targets, false)), + ); + + protected activeStreamType(): string | null { + const streams = this.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(): string | null { + const t = this.activeStreamType(); + if (!t) { + return null; + } + const stream = this.detail().streams.find((s) => s.stream_type === t); + return stream ? this.api.resolvePlaylistUrl(stream.playlist_url) : null; + } + + protected pickStream(type: string): void { + this.selectedStreamType.set(type); + } + + protected setTimelineSec(value: string): void { + const parsed = Number(value); + if (!Number.isFinite(parsed)) { + return; + } + this.timelineSec.set(this.clamp(parsed, 0, this.timelineMaxSec())); + } + + protected onPlayerCurrentTimeChange(seconds: number): void { + if (!Number.isFinite(seconds)) { + return; + } + this.timelineSec.set(this.clamp(seconds, 0, this.timelineMaxSec())); + } + + protected onPlayerDurationChange(seconds: number): void { + if (!Number.isFinite(seconds) || seconds <= 0) { + return; + } + this.durationSec.set(seconds); + this.timelineSec.update((current) => this.clamp(current, 0, this.timelineMaxSec())); + } + + protected shiftTimeline(deltaSec: number): void { + this.timelineSec.update((current) => this.clamp(current + deltaSec, 0, this.timelineMaxSec())); + } + + protected togglePlayback(): void { + this.isPlaying.update((v) => !v); + } + + protected onPlayerPlayingChange(playing: boolean): void { + this.isPlaying.set(playing); + } + + protected currentPositionLabel(): string { + return `${Math.max(0, Math.round(this.timelineSec()))}с`; + } + + protected endPositionLabel(): string { + return `${Math.max(0, Math.round(this.timelineMaxSec()))}с`; + } + + protected startTimeLabel(): string { + return formatClockTime(this.recordingStartMs()); + } + + protected endTimeLabel(): string { + const end = this.recordingEndMs(); + const start = this.recordingStartMs(); + if (end != null) { + return formatClockTime(end); + } + const max = this.timelineMaxSec(); + if (start != null && Number.isFinite(max)) { + return formatClockTime(start + max * 1000); + } + return '—'; + } + + protected cursorLabel(): string { + return formatUnixMs(this.cursorMs()); + } + + private clamp(value: number, min: number, max: number): number { + if (max < min) { + return min; + } + return Math.min(max, Math.max(min, value)); + } +} + +/** Compares two key-id arrays regardless of order. */ +function keySetEqual(a: string[], b: string[]): boolean { + if (a.length !== b.length) { + return false; + } + const setB = new Set(b); + return a.every((v) => setB.has(v)); +} + +/** Compares two mouse-target arrays regardless of order. */ +function targetSetEqual(a: MouseHighlightTarget[], b: MouseHighlightTarget[]): boolean { + if (a.length !== b.length) { + return false; + } + const setB = new Set(b); + return a.every((v) => setB.has(v)); +} diff --git a/src/app/features/sessions/session-detail/session-interactive-tab/session-interactive-tab.css b/src/app/features/sessions/session-detail/session-interactive-tab/session-interactive-tab.css new file mode 100644 index 0000000..58cc6c7 --- /dev/null +++ b/src/app/features/sessions/session-detail/session-interactive-tab/session-interactive-tab.css @@ -0,0 +1,220 @@ +.controls-row { + margin-top: 0.85rem; + display: grid; + grid-template-columns: minmax(8rem, 1fr) auto minmax(8rem, 1fr); + align-items: center; + gap: 1rem; +} + +.timeline-controls { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; +} + +.timeline-controls_center { + justify-content: center; +} + +.timeline-range { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.timeline-slider { + width: 100%; + -webkit-appearance: none; + appearance: none; + background: transparent; + border: 0; + outline: none; + box-shadow: none; +} + +.timeline-slider::-webkit-slider-runnable-track { + height: 0.35rem; + border-radius: 999px; + background: color-mix(in srgb, var(--sg-color-text) 12%, transparent); + border: 0; +} + +.timeline-slider::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + margin-top: -0.33rem; + width: 1rem; + height: 1rem; + border-radius: 50%; + border: 2px solid var(--sg-color-card-bg); + background: var(--sg-filter-chip-active-bg); +} + +.timeline-slider::-moz-range-track { + height: 0.35rem; + border-radius: 999px; + background: color-mix(in srgb, var(--sg-color-text) 12%, transparent); + border: 0; +} + +.timeline-slider::-moz-range-thumb { + width: 1rem; + height: 1rem; + border-radius: 50%; + border: 2px solid var(--sg-color-card-bg); + background: var(--sg-filter-chip-active-bg); +} + +.timeline-slider:focus-visible::-webkit-slider-thumb, +.timeline-slider:focus-visible::-moz-range-thumb { + box-shadow: 0 0 0 3px color-mix(in srgb, var(--sg-filter-chip-active-bg) 24%, transparent); +} + +.control-btn { + display: inline-flex; + align-items: center; + gap: 0.35rem; + min-width: 4.25rem; + justify-content: center; +} + +.control-btn_primary[tuiButton][tuiAppearance][data-appearance='primary'] { + background: var(--sg-filter-chip-active-bg); + color: var(--sg-filter-chip-active-fg); +} + +.control-btn_primary[tuiButton][tuiAppearance][data-appearance='primary']:hover:not( + [data-state='disabled'] + ) { + background: var(--sg-filter-chip-active-bg-hover); +} + +.control-icon { + width: 1rem; + height: 1rem; + flex: 0 0 auto; +} + +.time-meta { + display: flex; + align-items: flex-start; + justify-content: flex-start; + gap: 1rem; +} + +.time-meta_start { + width: 100%; + justify-content: flex-start; + justify-self: start; +} + +.time-meta_end { + width: 100%; + justify-content: flex-end; + justify-self: end; +} + +.time-meta__col { + display: flex; + flex-direction: column; + gap: 0.1rem; + min-width: 0; +} + +.time-meta__col_end { + align-items: flex-end; + text-align: right; +} + +@media (max-width: 720px) { + .controls-row { + grid-template-columns: 1fr; + gap: 0.75rem; + } + + .time-meta_start, + .timeline-controls_center, + .time-meta_end { + justify-content: center; + } + + .time-meta__col, + .time-meta__col_end { + align-items: center; + text-align: center; + } +} + +.time-meta__position { + font: var(--tui-font-text-m); + font-weight: 600; + color: var(--tui-text-primary); +} + +.time-meta__clock { + font: var(--tui-font-text-s); + color: var(--tui-text-tertiary); +} + +.keyboard-svg-host { + max-width: 100%; + overflow: auto; + border-radius: var(--tui-radius-m); + background: transparent; +} + +.keyboard-svg-host_compact { + max-width: min(700px, 100%); +} + +.keyboard-svg-host ::ng-deep svg { + display: block; + width: 100%; + height: auto; + max-width: 800px; +} + +.keyboard-loading { + display: flex; + justify-content: center; + padding: 1rem; +} + +.input-preview { + display: flex; + align-items: center; + justify-content: center; + gap: 2.75rem; + width: 100%; +} + +.mouse-svg-host { + width: 144px; + flex: 0 0 144px; + border-radius: var(--tui-radius-m); + overflow: hidden; +} + +.mouse-svg-host ::ng-deep svg { + display: block; + width: 100%; + height: auto; +} + +.mouse-loading { + width: 144px; + flex: 0 0 144px; +} + +@media (max-width: 980px) { + .input-preview { + flex-direction: column; + } + + .mouse-svg-host, + .mouse-loading { + width: min(220px, 100%); + flex-basis: auto; + } +} diff --git a/src/app/features/sessions/session-detail/session-interactive-tab/session-interactive-tab.html b/src/app/features/sessions/session-detail/session-interactive-tab/session-interactive-tab.html new file mode 100644 index 0000000..88f53b2 --- /dev/null +++ b/src/app/features/sessions/session-detail/session-interactive-tab/session-interactive-tab.html @@ -0,0 +1,151 @@ +
+ @if (detail().streams.length === 0) { +

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

+ } @else { + + @if (playlistUrl(); as src) { + + } + } +
+ +
+
+ +
+
+
+
+ {{ currentPositionLabel() }} + {{ startTimeLabel() }} +
+
+
+ + + + + +
+
+
+ {{ endPositionLabel() }} + {{ endTimeLabel() }} +
+
+
+
+ +
+
+ @if (keyboardSvg$ | async; as keyboardSvg) { +
+ } @else { +
+ +
+ } + @if (mouseSvg$ | async; as mouseSvg) { +
+ } @else { +
+ +
+ } +
+
diff --git a/src/app/features/sessions/session-detail/session-view-tab/session-view-tab.component.ts b/src/app/features/sessions/session-detail/session-view-tab/session-view-tab.component.ts new file mode 100644 index 0000000..edca59f --- /dev/null +++ b/src/app/features/sessions/session-detail/session-view-tab/session-view-tab.component.ts @@ -0,0 +1,200 @@ +import { animate, style, transition, trigger } from '@angular/animations'; +import { NgClass } from '@angular/common'; +import { ChangeDetectionStrategy, Component, inject, input, output, signal } from '@angular/core'; +import { TuiButton } from '@taiga-ui/core/components/button'; +import { TuiLoader } from '@taiga-ui/core/components/loader'; +import { TuiChip } from '@taiga-ui/kit/components/chip'; + +import { SessionStatusChipClassesPipe } from '../../../../core/sessions/session-status-chip-classes.pipe'; +import { SessionStatusPipe } from '../../../../core/sessions/session-status.pipe'; +import { TelemetryEventTypePipe } from '../../../../core/sessions/telemetry-event-type.pipe'; +import { summarizeTelemetryData } from '../../../../core/sessions/telemetry-event-summary.engine'; +import { SessionsApiService } from '../../../../core/services/sessions-api.service'; +import type { ParsedEvent, SessionDetailResponse } from '../../../../core/models/api.types'; +import { formatTimestamp, formatUnixMs as formatUnixMsUtil } from '../../../../shared/utils/date-time.util'; +import { HlsPlayerComponent } from '../../hls-player/hls-player.component'; +import { StreamSelectorComponent } from '../../stream-selector/stream-selector.component'; +import { TelemetryEventDetailComponent } from '../../telemetry-event-detail/telemetry-event-detail.component'; +import type { TelemetryLoadState, TelemetryRangeSelection } from '../session-detail.types'; + +@Component({ + selector: 'app-session-view-tab', + imports: [ + NgClass, + TuiButton, + TuiChip, + TuiLoader, + SessionStatusChipClassesPipe, + SessionStatusPipe, + TelemetryEventTypePipe, + HlsPlayerComponent, + StreamSelectorComponent, + TelemetryEventDetailComponent, + ], + templateUrl: './session-view-tab.html', + styleUrl: './session-view-tab.css', + changeDetection: ChangeDetectionStrategy.OnPush, + animations: [ + trigger('telemetryEventDetail', [ + transition(':enter', [ + style({ opacity: 0, transform: 'translateY(-0.4rem)' }), + animate( + '220ms cubic-bezier(0.33, 1, 0.68, 1)', + style({ opacity: 1, transform: 'translateY(0)' }), + ), + ]), + transition(':leave', [ + animate( + '170ms cubic-bezier(0.4, 0, 1, 1)', + style({ opacity: 0, transform: 'translateY(-0.3rem)' }), + ), + ]), + ]), + ], +}) +export class SessionViewTabComponent { + private readonly api = inject(SessionsApiService); + + readonly detail = input.required(); + readonly telemetryState = input.required(); + readonly recordingStartMs = input.required(); + readonly recordingEndMs = input.required(); + readonly telemetryToMsChange = output(); + + protected readonly selectedStreamType = signal(null); + protected readonly telemetryEventTypeFilter = signal(null); + protected readonly expandedTelemetryRowKey = signal(null); + protected readonly customToLocal = signal(''); + private readonly telemetryRangeSelection = signal({ type: 'end' }); + + protected activeStreamType(): string | null { + const streams = this.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(): string | null { + const t = this.activeStreamType(); + if (!t) { + return null; + } + const stream = this.detail().streams.find((s) => s.stream_type === t); + return stream ? this.api.resolvePlaylistUrl(stream.playlist_url) : null; + } + + protected pickStream(type: string): void { + this.selectedStreamType.set(type); + } + + protected pickTelemetryEventTypeFilter(type: string | null): void { + this.telemetryEventTypeFilter.set(type); + this.expandedTelemetryRowKey.set(null); + } + + protected telemetryEventTypeKey(event: ParsedEvent): string { + const t = event.event_type; + if (t == null || t === '') { + return ''; + } + return String(t).trim().toLowerCase(); + } + + protected uniqueTelemetryEventTypes(events: ParsedEvent[]): string[] { + const set = new Set(); + for (const e of events) { + set.add(this.telemetryEventTypeKey(e)); + } + return [...set].sort((a, b) => a.localeCompare(b, 'ru')); + } + + protected telemetryEventsOfType(events: ParsedEvent[], typeKey: string): number { + return events.filter((e) => this.telemetryEventTypeKey(e) === typeKey).length; + } + + protected filteredTelemetryEvents(events: ParsedEvent[]): ParsedEvent[] { + const filter = this.telemetryEventTypeFilter(); + if (filter === null) { + return events; + } + return events.filter((e) => this.telemetryEventTypeKey(e) === filter); + } + + protected telemetryEventSummary(event: ParsedEvent): string { + return summarizeTelemetryData(event.data); + } + + protected telemetryRowKey(row: ParsedEvent, index: number): string { + return `${row.timestamp}\u0000${this.telemetryEventTypeKey(row)}\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); + } + + protected selectRecentWindow(seconds: number): void { + this.customToLocal.set(''); + this.telemetryRangeSelection.set({ type: 'preset', seconds }); + const start = this.recordingStartMs() ?? Date.now(); + const end = this.recordingEndMs() ?? Date.now(); + this.telemetryToMsChange.emit(this.clamp(start + seconds * 1000, start, end)); + } + + protected loadUntilEndTelemetry(): void { + this.customToLocal.set(''); + this.telemetryRangeSelection.set({ type: 'end' }); + this.telemetryToMsChange.emit(this.recordingEndMs() ?? Date.now()); + } + + protected applyCustomTo(value: string): void { + this.customToLocal.set(value); + if (!value) { + return; + } + const ms = new Date(value).getTime(); + if (Number.isFinite(ms)) { + this.telemetryRangeSelection.set({ type: 'custom' }); + const start = this.recordingStartMs() ?? Date.now(); + const end = this.recordingEndMs() ?? Date.now(); + this.telemetryToMsChange.emit(this.clamp(ms, start, end)); + } + } + + protected telemetryRangePresetIs(seconds: number): boolean { + const s = this.telemetryRangeSelection(); + return s.type === 'preset' && s.seconds === seconds; + } + + protected telemetryRangeIsEnd(): boolean { + return this.telemetryRangeSelection().type === 'end'; + } + + protected telemetryRangeLabel(toMs: number): string { + return `С ${formatUnixMsUtil(this.recordingStartMs())} до ${formatUnixMsUtil(toMs)}`; + } + + protected formatDate(value: string | null | undefined): string { + return formatTimestamp(value); + } + + protected formatUnixMs(value: number | null | undefined): string { + return formatUnixMsUtil(value); + } + + 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-view-tab/session-view-tab.css b/src/app/features/sessions/session-detail/session-view-tab/session-view-tab.css new file mode 100644 index 0000000..50be279 --- /dev/null +++ b/src/app/features/sessions/session-detail/session-view-tab/session-view-tab.css @@ -0,0 +1,142 @@ +.session-title { + margin: 0 0 0.75rem; + font: var(--tui-font-heading-6); + color: var(--tui-text-primary); +} + +.summary-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 1rem; + margin-bottom: 0.5rem; +} + +.telemetry-head { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: flex-start; + gap: 0.75rem; + margin-bottom: 0.75rem; +} + +.telemetry-content { + display: flex; + flex-direction: column; + min-height: 520px; +} + +.telemetry-content > .loading-wrap { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + min-height: 0; +} + +.telemetry-head__main { + display: flex; + flex-direction: column; + gap: 0.35rem; + min-width: 0; + flex: 1; +} + +.telemetry-head .section-title { + margin: 0; +} + +.telemetry-head .telemetry-range { + 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; +} + +.telemetry-type-tabs { + overflow-wrap: anywhere; +} + +.telemetry-type-tabs button { + max-width: 100%; + overflow-wrap: anywhere; + word-break: break-word; + text-align: start; +} + +.table-wrap { + overflow: auto; + max-height: min(520px, 70vh); +} + +.table-wrap:not(.table-wrap_flat) { + min-height: 520px; +} + +.telemetry { + width: 100%; + font: var(--tui-font-text-s); +} + +.telemetry th { + text-align: left; + color: var(--tui-text-primary); +} + +.telemetry tbody td { + padding: 0.5rem 0.75rem; + text-align: left; + border-bottom: 1px solid var(--tui-border-normal); + vertical-align: top; +} + +.telemetry-col-type { + min-width: 0; + max-width: 14rem; + overflow-wrap: anywhere; + word-break: break-word; + vertical-align: top; +} + +.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; +} + +.telemetry-col-summary { + min-width: 0; + overflow-wrap: anywhere; + word-break: break-word; + vertical-align: top; +} diff --git a/src/app/features/sessions/session-detail/session-view-tab/session-view-tab.html b/src/app/features/sessions/session-detail/session-view-tab/session-view-tab.html new file mode 100644 index 0000000..4faf13d --- /dev/null +++ b/src/app/features/sessions/session-detail/session-view-tab/session-view-tab.html @@ -0,0 +1,197 @@ +
+ @if (detail().session.title) { +

{{ detail().session.title }}

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

Трансляция

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

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

+ } @else { + + @if (playlistUrl(); 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 $index; + let i = $index + ) { + + + + + + @if (isTelemetryRowExpanded(row, i)) { + + + + } + } + +
ВремяТипСводка
{{ formatUnixMs(row.timestamp) }} + {{ row.event_type | telemetryEventType }} + {{ telemetryEventSummary(row) }}
+ +
+
+ } + } + } +
+
diff --git a/src/app/features/sessions/sessions-list/sessions-list.css b/src/app/features/sessions/sessions-list/sessions-list.css index 7d17d6b..307385d 100644 --- a/src/app/features/sessions/sessions-list/sessions-list.css +++ b/src/app/features/sessions/sessions-list/sessions-list.css @@ -1,25 +1,7 @@ -.page { - padding-top: 1.5rem; - padding-bottom: 3rem; -} - .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; @@ -46,21 +28,11 @@ button.accent-cta[tuiAppearance][data-appearance='primary']:hover:not([data-stat 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; diff --git a/src/app/features/sessions/sessions-list/sessions-list.html b/src/app/features/sessions/sessions-list/sessions-list.html index 6c6c492..7a67acd 100644 --- a/src/app/features/sessions/sessions-list/sessions-list.html +++ b/src/app/features/sessions/sessions-list/sessions-list.html @@ -5,8 +5,9 @@

Новая сессия

- + + @for (s of streams(); track s.stream_type) { + + } +
+ `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class StreamSelectorComponent { + readonly streams = input.required(); + readonly activeType = input.required(); + readonly typeChange = output(); +} 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 index 357441f..15077a2 100644 --- a/src/app/features/sessions/telemetry-event-detail/telemetry-event-detail.css +++ b/src/app/features/sessions/telemetry-event-detail/telemetry-event-detail.css @@ -37,7 +37,7 @@ /* Подраздел «Служебные данные» внутри «Подробности» */ .detail-subsection { - margin: 0; + margin: 0 0 0.5rem; padding-left: 0.65rem; border-left: 2px solid color-mix(in srgb, var(--tui-border-normal) 85%, var(--tui-text-tertiary)); } 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 index 2e95449..7bc6b3f 100644 --- a/src/app/features/sessions/telemetry-event-detail/telemetry-event-detail.html +++ b/src/app/features/sessions/telemetry-event-detail/telemetry-event-detail.html @@ -36,7 +36,7 @@
Виртуальный код (VK)
- @if (keyboardModel().vk != null) { + @if (keyboardModel().vk !== null && keyboardModel().vk !== undefined) { {{ keyboardModel().vk }} (0x{{ keyboardModel().vk!.toString(16).toUpperCase() }}) } @else { — @@ -49,7 +49,7 @@
}
- Данные события (JSON) + Исходный JSON события