From 75ea7432937c46c371f13b01015f5c589f095a32 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 01:29:32 +0200 Subject: [PATCH 1/5] chore(deps): bump the npm-minor-patch group across 1 directory with 41 updates (#1916) Bumps the npm-minor-patch group with 41 updates in the / directory: | Package | From | To | | --- | --- | --- | | [@angular/animations](https://github.com/angular/angular/tree/HEAD/packages/animations) | `22.1.6` | `22.2.2` | | [@angular/cdk](https://github.com/angular/components) | `22.1.6` | `22.2.2` | | [@angular/common](https://github.com/angular/angular/tree/HEAD/packages/common) | `22.1.6` | `22.2.2` | | [@angular/compiler](https://github.com/angular/angular/tree/HEAD/packages/compiler) | `22.1.6` | `22.2.2` | | [@angular/core](https://github.com/angular/angular/tree/HEAD/packages/core) | `22.1.6` | `22.2.2` | | [@angular/forms](https://github.com/angular/angular/tree/HEAD/packages/forms) | `22.1.6` | `22.2.2` | | [@angular/material](https://github.com/angular/components) | `22.1.6` | `22.2.2` | | [@angular/platform-browser](https://github.com/angular/angular/tree/HEAD/packages/platform-browser) | `22.1.6` | `22.2.2` | | [@angular/platform-browser-dynamic](https://github.com/angular/angular/tree/HEAD/packages/platform-browser-dynamic) | `22.1.6` | `22.2.2` | | [@angular/router](https://github.com/angular/angular/tree/HEAD/packages/router) | `22.1.6` | `22.2.2` | | [drizzle-orm](https://github.com/drizzle-team/drizzle-orm) | `0.45.2` | `0.45.4` | | [hls.js](https://github.com/video-dev/hls.js) | `1.7.1` | `1.7.3` | | [marked](https://github.com/markedjs/marked) | `18.0.11` | `18.1.0` | | [video.js](https://github.com/videojs/video.js) | `8.24.0` | `8.24.1` | | [@angular-devkit/core](https://github.com/angular/angular-cli) | `22.1.8` | `22.2.2` | | [@angular-devkit/schematics](https://github.com/angular/angular-cli) | `22.1.8` | `22.2.2` | | [@angular/cli](https://github.com/angular/angular-cli) | `22.1.8` | `22.2.2` | | [@angular/compiler-cli](https://github.com/angular/angular/tree/HEAD/packages/compiler-cli) | `22.1.6` | `22.2.2` | | [@angular/language-service](https://github.com/angular/angular/tree/HEAD/packages/language-service) | `22.1.6` | `22.2.2` | | [@angular/service-worker](https://github.com/angular/angular/tree/HEAD/packages/service-worker) | `22.1.6` | `22.2.2` | | [@playwright/test](https://github.com/microsoft/playwright) | `1.62.1` | `1.64.0` | | [@schematics/angular](https://github.com/angular/angular-cli) | `22.1.8` | `22.2.2` | | [@swc/core](https://github.com/swc-project/swc/tree/HEAD/packages/core) | `1.16.1` | `1.16.13` | | [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin) | `8.69.0` | `8.71.1` | | [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser) | `8.69.0` | `8.71.1` | | [@typescript-eslint/utils](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/utils) | `8.69.0` | `8.71.1` | | [autoprefixer](https://github.com/postcss/autoprefixer) | `10.5.4` | `10.6.1` | | [axe-core](https://github.com/dequelabs/axe-core) | `4.13.0` | `4.14.0` | | [drizzle-kit](https://github.com/drizzle-team/drizzle-orm) | `0.31.10` | `0.31.11` | | [express](https://github.com/expressjs/express) | `5.2.1` | `5.3.0` | | [jest](https://github.com/jestjs/jest/tree/HEAD/packages/jest) | `30.5.1` | `30.5.2` | | [jest-environment-jsdom](https://github.com/jestjs/jest/tree/HEAD/packages/jest-environment-jsdom) | `30.5.1` | `30.5.2` | | [jest-environment-node](https://github.com/jestjs/jest/tree/HEAD/packages/jest-environment-node) | `30.5.1` | `30.5.2` | | [jest-preset-angular](https://github.com/thymikee/jest-preset-angular) | `17.0.0` | `17.0.2` | | [ng-mocks](https://github.com/help-me-mom/ng-mocks) | `14.17.6` | `14.18.1` | | [prettier](https://github.com/prettier/prettier) | `3.9.6` | `3.9.9` | | [ts-jest](https://github.com/kulshekhar/ts-jest) | `29.4.12` | `29.4.14` | | [tsx](https://github.com/privatenumber/tsx) | `4.23.13` | `4.23.15` | | [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.69.0` | `8.71.1` | | [yaml](https://github.com/eemeli/yaml) | `2.9.0` | `2.9.1` | | [zod](https://github.com/colinhacks/zod) | `4.5.4` | `4.6.5` | Updates `@angular/animations` from 22.1.6 to 22.2.2 - [Release notes](https://github.com/angular/angular/releases) - [Commits](https://github.com/angular/angular/commits/v22.2.2/packages/animations) Updates `@angular/cdk` from 22.1.6 to 22.2.2 - [Release notes](https://github.com/angular/components/releases) - [Changelog](https://github.com/angular/components/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/components/compare/v22.1.6...v22.2.2) Updates `@angular/common` from 22.1.6 to 22.2.2 - [Release notes](https://github.com/angular/angular/releases) - [Commits](https://github.com/angular/angular/commits/v22.2.2/packages/common) Updates `@angular/compiler` from 22.1.6 to 22.2.2 - [Release notes](https://github.com/angular/angular/releases) - [Commits](https://github.com/angular/angular/commits/v22.2.2/packages/compiler) Updates `@angular/core` from 22.1.6 to 22.2.2 - [Release notes](https://github.com/angular/angular/releases) - [Commits](https://github.com/angular/angular/commits/v22.2.2/packages/core) Updates `@angular/forms` from 22.1.6 to 22.2.2 - [Release notes](https://github.com/angular/angular/releases) - [Commits](https://github.com/angular/angular/commits/v22.2.2/packages/forms) Updates `@angular/material` from 22.1.6 to 22.2.2 - [Release notes](https://github.com/angular/components/releases) - [Changelog](https://github.com/angular/components/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/components/compare/v22.1.6...v22.2.2) Updates `@angular/platform-browser` from 22.1.6 to 22.2.2 - [Release notes](https://github.com/angular/angular/releases) - [Commits](https://github.com/angular/angular/commits/v22.2.2/packages/platform-browser) Updates `@angular/platform-browser-dynamic` from 22.1.6 to 22.2.2 - [Release notes](https://github.com/angular/angular/releases) - [Commits](https://github.com/angular/angular/commits/v22.2.2/packages/platform-browser-dynamic) Updates `@angular/router` from 22.1.6 to 22.2.2 - [Release notes](https://github.com/angular/angular/releases) - [Commits](https://github.com/angular/angular/commits/v22.2.2/packages/router) Updates `drizzle-orm` from 0.45.2 to 0.45.4 - [Release notes](https://github.com/drizzle-team/drizzle-orm/releases) - [Commits](https://github.com/drizzle-team/drizzle-orm/compare/0.45.2...0.45.4) Updates `hls.js` from 1.7.1 to 1.7.3 - [Release notes](https://github.com/video-dev/hls.js/releases) - [Changelog](https://github.com/video-dev/hls.js/blob/master/docs/release-process.md) - [Commits](https://github.com/video-dev/hls.js/compare/v1.7.1...v1.7.3) Updates `marked` from 18.0.11 to 18.1.0 - [Release notes](https://github.com/markedjs/marked/releases) - [Commits](https://github.com/markedjs/marked/compare/v18.0.11...v18.1.0) Updates `video.js` from 8.24.0 to 8.24.1 - [Release notes](https://github.com/videojs/video.js/releases) - [Changelog](https://github.com/videojs/video.js/blob/main/CHANGELOG.md) - [Commits](https://github.com/videojs/video.js/compare/v8.24.0...v8.24.1) Updates `@angular-devkit/core` from 22.1.8 to 22.2.2 - [Release notes](https://github.com/angular/angular-cli/releases) - [Changelog](https://github.com/angular/angular-cli/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular-cli/compare/v22.1.8...v22.2.2) Updates `@angular-devkit/schematics` from 22.1.8 to 22.2.2 - [Release notes](https://github.com/angular/angular-cli/releases) - [Changelog](https://github.com/angular/angular-cli/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular-cli/compare/v22.1.8...v22.2.2) Updates `@angular/cli` from 22.1.8 to 22.2.2 - [Release notes](https://github.com/angular/angular-cli/releases) - [Changelog](https://github.com/angular/angular-cli/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular-cli/compare/v22.1.8...v22.2.2) Updates `@angular/compiler-cli` from 22.1.6 to 22.2.2 - [Release notes](https://github.com/angular/angular/releases) - [Commits](https://github.com/angular/angular/commits/v22.2.2/packages/compiler-cli) Updates `@angular/language-service` from 22.1.6 to 22.2.2 - [Release notes](https://github.com/angular/angular/releases) - [Commits](https://github.com/angular/angular/commits/v22.2.2/packages/language-service) Updates `@angular/service-worker` from 22.1.6 to 22.2.2 - [Release notes](https://github.com/angular/angular/releases) - [Commits](https://github.com/angular/angular/commits/v22.2.2/packages/service-worker) Updates `@playwright/test` from 1.62.1 to 1.64.0 - [Release notes](https://github.com/microsoft/playwright/releases) - [Commits](https://github.com/microsoft/playwright/compare/v1.62.1...v1.64.0) Updates `@schematics/angular` from 22.1.8 to 22.2.2 - [Release notes](https://github.com/angular/angular-cli/releases) - [Changelog](https://github.com/angular/angular-cli/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular-cli/compare/v22.1.8...v22.2.2) Updates `@swc/core` from 1.16.1 to 1.16.13 - [Release notes](https://github.com/swc-project/swc/releases) - [Changelog](https://github.com/swc-project/swc/blob/main/CHANGELOG.md) - [Commits](https://github.com/swc-project/swc/commits/v1.16.13/packages/core) Updates `@typescript-eslint/eslint-plugin` from 8.69.0 to 8.71.1 - [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases) - [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md) - [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.71.1/packages/eslint-plugin) Updates `@typescript-eslint/parser` from 8.69.0 to 8.71.1 - [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases) - [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md) - [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.71.1/packages/parser) Updates `@typescript-eslint/utils` from 8.69.0 to 8.71.1 - [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases) - [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/utils/CHANGELOG.md) - [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.71.1/packages/utils) Updates `autoprefixer` from 10.5.4 to 10.6.1 - [Release notes](https://github.com/postcss/autoprefixer/releases) - [Changelog](https://github.com/postcss/autoprefixer/blob/main/CHANGELOG.md) - [Commits](https://github.com/postcss/autoprefixer/compare/10.5.4...10.6.1) Updates `axe-core` from 4.13.0 to 4.14.0 - [Release notes](https://github.com/dequelabs/axe-core/releases) - [Changelog](https://github.com/dequelabs/axe-core/blob/develop/CHANGELOG.md) - [Commits](https://github.com/dequelabs/axe-core/compare/v4.13.0...v4.14.0) Updates `drizzle-kit` from 0.31.10 to 0.31.11 - [Release notes](https://github.com/drizzle-team/drizzle-orm/releases) - [Commits](https://github.com/drizzle-team/drizzle-orm/compare/drizzle-kit@0.31.10...drizzle-kit@0.31.11) Updates `express` from 5.2.1 to 5.3.0 - [Release notes](https://github.com/expressjs/express/releases) - [Changelog](https://github.com/expressjs/express/blob/master/History.md) - [Commits](https://github.com/expressjs/express/compare/v5.2.1...v5.3.0) Updates `jest` from 30.5.1 to 30.5.2 - [Release notes](https://github.com/jestjs/jest/releases) - [Changelog](https://github.com/jestjs/jest/blob/main/CHANGELOG.md) - [Commits](https://github.com/jestjs/jest/commits/v30.5.2/packages/jest) Updates `jest-environment-jsdom` from 30.5.1 to 30.5.2 - [Release notes](https://github.com/jestjs/jest/releases) - [Changelog](https://github.com/jestjs/jest/blob/main/CHANGELOG.md) - [Commits](https://github.com/jestjs/jest/commits/v30.5.2/packages/jest-environment-jsdom) Updates `jest-environment-node` from 30.5.1 to 30.5.2 - [Release notes](https://github.com/jestjs/jest/releases) - [Changelog](https://github.com/jestjs/jest/blob/main/CHANGELOG.md) - [Commits](https://github.com/jestjs/jest/commits/v30.5.2/packages/jest-environment-node) Updates `jest-preset-angular` from 17.0.0 to 17.0.2 - [Release notes](https://github.com/thymikee/jest-preset-angular/releases) - [Changelog](https://github.com/thymikee/jest-preset-angular/blob/main/CHANGELOG.md) - [Commits](https://github.com/thymikee/jest-preset-angular/compare/v17.0.0...v17.0.2) Updates `ng-mocks` from 14.17.6 to 14.18.1 - [Release notes](https://github.com/help-me-mom/ng-mocks/releases) - [Changelog](https://github.com/help-me-mom/ng-mocks/blob/main/CHANGELOG.md) - [Commits](https://github.com/help-me-mom/ng-mocks/compare/v14.17.6...v14.18.1) Updates `prettier` from 3.9.6 to 3.9.9 - [Release notes](https://github.com/prettier/prettier/releases) - [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md) - [Commits](https://github.com/prettier/prettier/compare/3.9.6...3.9.9) Updates `ts-jest` from 29.4.12 to 29.4.14 - [Release notes](https://github.com/kulshekhar/ts-jest/releases) - [Changelog](https://github.com/kulshekhar/ts-jest/blob/main/CHANGELOG.md) - [Commits](https://github.com/kulshekhar/ts-jest/compare/v29.4.12...v29.4.14) Updates `tsx` from 4.23.13 to 4.23.15 - [Release notes](https://github.com/privatenumber/tsx/releases) - [Changelog](https://github.com/privatenumber/tsx/blob/master/release.config.cjs) - [Commits](https://github.com/privatenumber/tsx/compare/v4.23.13...v4.23.15) Updates `typescript-eslint` from 8.69.0 to 8.71.1 - [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases) - [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md) - [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.71.1/packages/typescript-eslint) Updates `yaml` from 2.9.0 to 2.9.1 - [Release notes](https://github.com/eemeli/yaml/releases) - [Commits](https://github.com/eemeli/yaml/compare/v2.9.0...v2.9.1) Updates `zod` from 4.5.4 to 4.6.5 - [Release notes](https://github.com/colinhacks/zod/releases) - [Commits](https://github.com/colinhacks/zod/compare/v4.5.4...v4.6.5) --- updated-dependencies: - dependency-name: "@angular/animations" dependency-version: 22.2.2 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: npm-minor-patch - dependency-name: "@angular/cdk" dependency-version: 22.2.2 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: npm-minor-patch - dependency-name: "@angular/common" dependency-version: 22.2.2 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: npm-minor-patch - dependency-name: "@angular/compiler" dependency-version: 22.2.2 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: npm-minor-patch - dependency-name: "@angular/core" dependency-version: 22.2.2 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: npm-minor-patch - dependency-name: "@angular/forms" dependency-version: 22.2.2 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: npm-minor-patch - dependency-name: "@angular/material" dependency-version: 22.2.2 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: npm-minor-patch - dependency-name: "@angular/platform-browser" dependency-version: 22.2.2 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: npm-minor-patch - dependency-name: "@angular/platform-browser-dynamic" dependency-version: 22.2.2 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: npm-minor-patch - dependency-name: "@angular/router" dependency-version: 22.2.2 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: npm-minor-patch - dependency-name: drizzle-orm dependency-version: 0.45.4 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: npm-minor-patch - dependency-name: hls.js dependency-version: 1.7.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: npm-minor-patch - dependency-name: marked dependency-version: 18.1.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: npm-minor-patch - dependency-name: video.js dependency-version: 8.24.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: npm-minor-patch - dependency-name: "@angular-devkit/core" dependency-version: 22.2.2 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: npm-minor-patch - dependency-name: "@angular-devkit/schematics" dependency-version: 22.2.2 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: npm-minor-patch - dependency-name: "@angular/cli" dependency-version: 22.2.2 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: npm-minor-patch - dependency-name: "@angular/compiler-cli" dependency-version: 22.2.2 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: npm-minor-patch - dependency-name: "@angular/language-service" dependency-version: 22.2.2 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: npm-minor-patch - dependency-name: "@angular/service-worker" dependency-version: 22.2.2 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: npm-minor-patch - dependency-name: "@playwright/test" dependency-version: 1.64.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: npm-minor-patch - dependency-name: "@schematics/angular" dependency-version: 22.2.2 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: npm-minor-patch - dependency-name: "@swc/core" dependency-version: 1.16.13 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: npm-minor-patch - dependency-name: "@typescript-eslint/eslint-plugin" dependency-version: 8.71.1 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: npm-minor-patch - dependency-name: "@typescript-eslint/parser" dependency-version: 8.71.1 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: npm-minor-patch - dependency-name: "@typescript-eslint/utils" dependency-version: 8.71.1 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: npm-minor-patch - dependency-name: autoprefixer dependency-version: 10.6.1 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: npm-minor-patch - dependency-name: axe-core dependency-version: 4.14.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: npm-minor-patch - dependency-name: drizzle-kit dependency-version: 0.31.11 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: npm-minor-patch - dependency-name: express dependency-version: 5.3.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: npm-minor-patch - dependency-name: jest dependency-version: 30.5.2 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: npm-minor-patch - dependency-name: jest-environment-jsdom dependency-version: 30.5.2 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: npm-minor-patch - dependency-name: jest-environment-node dependency-version: 30.5.2 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: npm-minor-patch - dependency-name: jest-preset-angular dependency-version: 17.0.2 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: npm-minor-patch - dependency-name: ng-mocks dependency-version: 14.18.1 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: npm-minor-patch - dependency-name: prettier dependency-version: 3.9.9 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: npm-minor-patch - dependency-name: ts-jest dependency-version: 29.4.14 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: npm-minor-patch - dependency-name: tsx dependency-version: 4.23.15 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: npm-minor-patch - dependency-name: typescript-eslint dependency-version: 8.71.1 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: npm-minor-patch - dependency-name: yaml dependency-version: 2.9.1 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: npm-minor-patch - dependency-name: zod dependency-version: 4.6.5 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: npm-minor-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: 4gray <4gray@users.noreply.github.com> --- package.json | 96 +- pnpm-lock.yaml | 2999 +++++++++++++++++++++++++++++++----------------- 2 files changed, 2007 insertions(+), 1088 deletions(-) diff --git a/package.json b/package.json index 3e5fc999c..ee80e71d6 100644 --- a/package.json +++ b/package.json @@ -133,16 +133,16 @@ "private": true, "packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319", "dependencies": { - "@angular/animations": "22.1.6", - "@angular/cdk": "22.1.6", - "@angular/common": "22.1.6", - "@angular/compiler": "22.1.6", - "@angular/core": "22.1.6", - "@angular/forms": "22.1.6", - "@angular/material": "22.1.6", - "@angular/platform-browser": "22.1.6", - "@angular/platform-browser-dynamic": "22.1.6", - "@angular/router": "22.1.6", + "@angular/animations": "22.2.2", + "@angular/cdk": "22.2.2", + "@angular/common": "22.2.2", + "@angular/compiler": "22.2.2", + "@angular/core": "22.2.2", + "@angular/forms": "22.2.2", + "@angular/material": "22.2.2", + "@angular/platform-browser": "22.2.2", + "@angular/platform-browser-dynamic": "22.2.2", + "@angular/router": "22.2.2", "@ngrx/effects": "22.0.1", "@ngrx/entity": "22.0.1", "@ngrx/router-store": "22.0.1", @@ -156,13 +156,13 @@ "axios": "1.20.0", "better-sqlite3": "13.0.3", "date-fns": "4.4.0", - "drizzle-orm": "0.45.2", + "drizzle-orm": "0.45.4", "electron-conf": "1.3.0", "electron-updater": "6.8.9", "epg-parser": "^0.5.0", - "hls.js": "1.7.1", + "hls.js": "1.7.3", "iptv-playlist-parser": "github:4gray/iptv-playlist-parser#v0.15.2-iptvnator.2", - "marked": "18.0.11", + "marked": "18.1.0", "mpegts.js": "1.8.2", "ms": "2.1.3", "ngx-indexed-db": "22.0.0", @@ -172,31 +172,31 @@ "saxes": "6.0.0", "shaka-player": "5.2.12", "shell-path": "3.1.0", - "video.js": "8.24.0", + "video.js": "8.24.1", "videojs-contrib-quality-levels": "4.1.0", "videojs-quality-selector-hls": "1.1.1", "zone.js": "0.16.3" }, "devDependencies": { - "@angular-devkit/core": "22.1.8", - "@angular-devkit/schematics": "22.1.8", + "@angular-devkit/core": "22.2.2", + "@angular-devkit/schematics": "22.2.2", "@angular-eslint/builder": "22.5.0", "@angular-eslint/eslint-plugin": "22.5.0", "@angular-eslint/eslint-plugin-template": "22.5.0", "@angular-eslint/schematics": "22.5.0", "@angular-eslint/template-parser": "22.5.0", "@angular/build": "22.1.8", - "@angular/cli": "22.1.8", - "@angular/common": "22.1.6", - "@angular/compiler": "22.1.6", - "@angular/compiler-cli": "22.1.6", - "@angular/core": "22.1.6", - "@angular/forms": "22.1.6", - "@angular/language-service": "22.1.6", - "@angular/platform-browser": "22.1.6", - "@angular/platform-browser-dynamic": "22.1.6", - "@angular/router": "22.1.6", - "@angular/service-worker": "22.1.6", + "@angular/cli": "22.2.2", + "@angular/common": "22.2.2", + "@angular/compiler": "22.2.2", + "@angular/compiler-cli": "22.2.2", + "@angular/core": "22.2.2", + "@angular/forms": "22.2.2", + "@angular/language-service": "22.2.2", + "@angular/platform-browser": "22.2.2", + "@angular/platform-browser-dynamic": "22.2.2", + "@angular/router": "22.2.2", + "@angular/service-worker": "22.2.2", "@astrojs/mdx": "7.0.7", "@astrojs/sitemap": "3.7.4", "@babel/core": "7.29.7", @@ -222,10 +222,10 @@ "@nx/playwright": "23.2.1", "@nx/web": "23.2.1", "@nx/workspace": "23.2.1", - "@playwright/test": "^1.62.1", - "@schematics/angular": "22.1.8", + "@playwright/test": "^1.64.0", + "@schematics/angular": "22.2.2", "@swc-node/register": "1.12.1", - "@swc/core": "1.16.1", + "@swc/core": "1.16.13", "@swc/helpers": "0.5.23", "@tailwindcss/typography": "0.5.20", "@types/better-sqlite3": "^9.6.0", @@ -237,16 +237,16 @@ "@types/node": "20.19.9", "@types/proxy-from-env": "1.0.4", "@types/video.js": "7.3.58", - "@typescript-eslint/eslint-plugin": "^8.69.0", - "@typescript-eslint/parser": "^8.69.0", - "@typescript-eslint/utils": "^8.69.0", + "@typescript-eslint/eslint-plugin": "^8.71.1", + "@typescript-eslint/parser": "^8.71.1", + "@typescript-eslint/utils": "^8.71.1", "angular-eslint": "22.5.0", "astro": "7.2.10", - "autoprefixer": "10.5.4", - "axe-core": "4.13.0", + "autoprefixer": "10.6.1", + "axe-core": "4.14.0", "cookie": "2.0.1", "cors": "2.8.6", - "drizzle-kit": "0.31.10", + "drizzle-kit": "0.31.11", "electron": "^43.3.0", "electron-builder": "^26.15.7", "electron-playwright-helpers": "1.8.2", @@ -255,39 +255,39 @@ "eslint-config-prettier": "^10.1.8", "eslint-plugin-import": "2.32.0", "eslint-plugin-playwright": "^1.6.2", - "express": "5.2.1", + "express": "5.3.0", "github-slugger": "2.0.0", "globals": "15.9.0", "html-escaper": "3.0.3", "istanbul-lib-coverage": "3.2.2", "istanbul-lib-report": "3.0.1", "istanbul-reports": "3.2.0", - "jest": "^30.5.1", - "jest-environment-jsdom": "^30.5.1", - "jest-environment-node": "^30.5.1", - "jest-preset-angular": "17.0.0", + "jest": "^30.5.2", + "jest-environment-jsdom": "^30.5.2", + "jest-environment-node": "^30.5.2", + "jest-preset-angular": "17.0.2", "jest-util": "^30.5.1", "jsdom": "26.1.0", "jsonc-eslint-parser": "^2.1.0", "material-design-icons-iconfont": "6.7.0", "mrmime": "2.0.1", - "ng-mocks": "14.17.6", + "ng-mocks": "14.18.1", "node-gyp": "12.4.0", "nx": "23.2.1", "nx-electron": "22.0.0", "parse-srcset": "1.0.2", "parse5": "8.0.1", - "prettier": "^3.9.6", + "prettier": "^3.9.9", "sharp": "0.35.5", "tailwindcss": "^3.4.19", - "ts-jest": "^29.4.12", + "ts-jest": "^29.4.14", "ts-node": "10.9.2", "tslib": "^2.8.1", - "tsx": "4.23.13", + "tsx": "4.23.15", "typescript": "6.0.3", - "typescript-eslint": "^8.69.0", - "yaml": "2.9.0", - "zod": "4.5.4" + "typescript-eslint": "^8.71.1", + "yaml": "2.9.1", + "zod": "4.6.5" }, "pnpm": { "overrides": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2da0209f9..c523accc0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -65,62 +65,62 @@ importers: .: dependencies: '@angular/animations': - specifier: 22.1.6 - version: 22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3)) + specifier: 22.2.2 + version: 22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3)) '@angular/cdk': - specifier: 22.1.6 - version: 22.1.6(d84494623d562fa967d8dbca5e8215e7) + specifier: 22.2.2 + version: 22.2.2(a2e0e17c73c6aceb9301ae9aaf890120) '@angular/common': - specifier: 22.1.6 - version: 22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2) + specifier: 22.2.2 + version: 22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2) '@angular/compiler': - specifier: 22.1.6 - version: 22.1.6 + specifier: 22.2.2 + version: 22.2.2 '@angular/core': - specifier: 22.1.6 - version: 22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3) + specifier: 22.2.2 + version: 22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3) '@angular/forms': - specifier: 22.1.6 - version: 22.1.6(@angular/common@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(@angular/platform-browser@22.1.6(@angular/animations@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/common@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3)))(rxjs@7.8.2) + specifier: 22.2.2 + version: 22.2.2(@angular/common@22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(@angular/platform-browser@22.2.2(@angular/animations@22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/common@22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3)))(rxjs@7.8.2) '@angular/material': - specifier: 22.1.6 - version: 22.1.6(42f78374544ce4ecfab2a610740b5d20) + specifier: 22.2.2 + version: 22.2.2(641510506f6736c740497081311accfd) '@angular/platform-browser': - specifier: 22.1.6 - version: 22.1.6(@angular/animations@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/common@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3)) + specifier: 22.2.2 + version: 22.2.2(@angular/animations@22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/common@22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3)) '@angular/platform-browser-dynamic': - specifier: 22.1.6 - version: 22.1.6(@angular/common@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/compiler@22.1.6)(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(@angular/platform-browser@22.1.6(@angular/animations@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/common@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))) + specifier: 22.2.2 + version: 22.2.2(@angular/common@22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/compiler@22.2.2)(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(@angular/platform-browser@22.2.2(@angular/animations@22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/common@22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))) '@angular/router': - specifier: 22.1.6 - version: 22.1.6(@angular/common@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(@angular/platform-browser@22.1.6(@angular/animations@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/common@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3)))(rxjs@7.8.2) + specifier: 22.2.2 + version: 22.2.2(@angular/common@22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(@angular/platform-browser@22.2.2(@angular/animations@22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/common@22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3)))(rxjs@7.8.2) '@ngrx/effects': specifier: 22.0.1 - version: 22.0.1(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(@ngrx/store@22.0.1(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(rxjs@7.8.2) + version: 22.0.1(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(@ngrx/store@22.0.1(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(rxjs@7.8.2) '@ngrx/entity': specifier: 22.0.1 - version: 22.0.1(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(@ngrx/store@22.0.1(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(rxjs@7.8.2) + version: 22.0.1(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(@ngrx/store@22.0.1(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(rxjs@7.8.2) '@ngrx/router-store': specifier: 22.0.1 - version: 22.0.1(@angular/common@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(@angular/router@22.1.6(@angular/common@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(@angular/platform-browser@22.1.6(@angular/animations@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/common@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3)))(rxjs@7.8.2))(@ngrx/store@22.0.1(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(rxjs@7.8.2) + version: 22.0.1(@angular/common@22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(@angular/router@22.2.2(@angular/common@22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(@angular/platform-browser@22.2.2(@angular/animations@22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/common@22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3)))(rxjs@7.8.2))(@ngrx/store@22.0.1(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(rxjs@7.8.2) '@ngrx/signals': specifier: 22.0.1 - version: 22.0.1(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2) + version: 22.0.1(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2) '@ngrx/store': specifier: 22.0.1 - version: 22.0.1(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2) + version: 22.0.1(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2) '@ngrx/store-devtools': specifier: 22.0.1 - version: 22.0.1(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(@ngrx/store@22.0.1(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(rxjs@7.8.2) + version: 22.0.1(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(@ngrx/store@22.0.1(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(rxjs@7.8.2) '@ngx-pwa/local-storage': specifier: 22.0.0 - version: 22.0.0(@angular/common@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2) + version: 22.0.0(@angular/common@22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2) '@yangkghjh/videojs-aspect-ratio-panel': specifier: 0.0.1 version: 0.0.1 angularx-qrcode: specifier: 22.0.1 - version: 22.0.1(@angular/common@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3)) + version: 22.0.1(@angular/common@22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3)) artplayer: specifier: 5.4.0 version: 5.4.0 @@ -134,8 +134,8 @@ importers: specifier: 4.4.0 version: 4.4.0 drizzle-orm: - specifier: 0.45.2 - version: 0.45.2(@types/better-sqlite3@9.6.0)(better-sqlite3@13.0.3) + specifier: 0.45.4 + version: 0.45.4(@types/better-sqlite3@9.6.0)(better-sqlite3@13.0.3) electron-conf: specifier: 1.3.0 version: 1.3.0(electron@43.3.0) @@ -146,14 +146,14 @@ importers: specifier: ^0.5.0 version: 0.5.0 hls.js: - specifier: 1.7.1 - version: 1.7.1 + specifier: 1.7.3 + version: 1.7.3 iptv-playlist-parser: specifier: github:4gray/iptv-playlist-parser#v0.15.2-iptvnator.2 version: https://codeload.github.com/4gray/iptv-playlist-parser/tar.gz/d547b6be8a44431c85fbcbcdefb41f28c0dadf6d marked: - specifier: 18.0.11 - version: 18.0.11 + specifier: 18.1.0 + version: 18.1.0 mpegts.js: specifier: 1.8.2 version: 1.8.2 @@ -162,10 +162,10 @@ importers: version: 2.1.3 ngx-indexed-db: specifier: 22.0.0 - version: 22.0.0(@angular/common@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3)) + version: 22.0.0(@angular/common@22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3)) ngx-skeleton-loader: specifier: 11.3.0 - version: 11.3.0(@angular/common@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3)) + version: 11.3.0(@angular/common@22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3)) proxy-from-env: specifier: 2.1.0 version: 2.1.0 @@ -182,11 +182,11 @@ importers: specifier: 3.1.0 version: 3.1.0 video.js: - specifier: 8.24.0 - version: 8.24.0 + specifier: 8.24.1 + version: 8.24.1 videojs-contrib-quality-levels: specifier: 4.1.0 - version: 4.1.0(video.js@8.24.0) + version: 4.1.0(video.js@8.24.1) videojs-quality-selector-hls: specifier: 1.1.1 version: 1.1.1 @@ -195,44 +195,44 @@ importers: version: 0.16.3 devDependencies: '@angular-devkit/core': - specifier: 22.1.8 - version: 22.1.8(chokidar@5.0.0) + specifier: 22.2.2 + version: 22.2.2(chokidar@5.0.0) '@angular-devkit/schematics': - specifier: 22.1.8 - version: 22.1.8(chokidar@5.0.0) + specifier: 22.2.2 + version: 22.2.2(chokidar@5.0.0) '@angular-eslint/builder': specifier: 22.5.0 version: 22.5.0(chokidar@5.0.0)(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3) '@angular-eslint/eslint-plugin': specifier: 22.5.0 - version: 22.5.0(@typescript-eslint/utils@8.69.0(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3))(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3) + version: 22.5.0(@typescript-eslint/utils@8.71.1(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3))(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3) '@angular-eslint/eslint-plugin-template': specifier: 22.5.0 - version: 22.5.0(@angular-eslint/template-parser@22.5.0(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3))(@typescript-eslint/types@8.69.0)(@typescript-eslint/utils@8.69.0(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3))(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3) + version: 22.5.0(@angular-eslint/template-parser@22.5.0(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3))(@typescript-eslint/types@8.71.1)(@typescript-eslint/utils@8.71.1(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3))(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3) '@angular-eslint/schematics': specifier: 22.5.0 - version: 22.5.0(@angular-eslint/template-parser@22.5.0(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3))(@typescript-eslint/types@8.69.0)(@typescript-eslint/utils@8.69.0(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3))(chokidar@5.0.0)(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3) + version: 22.5.0(@angular-eslint/template-parser@22.5.0(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3))(@typescript-eslint/types@8.71.1)(@typescript-eslint/utils@8.71.1(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3))(chokidar@5.0.0)(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3) '@angular-eslint/template-parser': specifier: 22.5.0 version: 22.5.0(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3) '@angular/build': specifier: 22.1.8 - version: 22.1.8(@angular/compiler-cli@22.1.6(@angular/compiler@22.1.6)(typescript@6.0.3))(@angular/compiler@22.1.6)(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(@angular/platform-browser@22.1.6(@angular/animations@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/common@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/service-worker@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@types/node@20.19.9)(chokidar@5.0.0)(istanbul-lib-instrument@6.0.3)(jiti@1.21.7)(less@4.5.1)(postcss@8.5.26)(rollup@4.59.0)(sass-embedded@1.100.0)(tailwindcss@3.4.19(tsx@4.23.13)(yaml@2.9.0))(terser@5.49.2)(tslib@2.8.1)(tsx@4.23.13)(typescript@6.0.3)(yaml@2.9.0) + version: 22.1.8(@angular/compiler-cli@22.2.2(@angular/compiler@22.2.2)(typescript@6.0.3))(@angular/compiler@22.2.2)(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(@angular/platform-browser@22.2.2(@angular/animations@22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/common@22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/service-worker@22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@types/node@20.19.9)(chokidar@5.0.0)(istanbul-lib-instrument@6.0.3)(jiti@1.21.7)(less@4.5.1)(postcss@8.5.26)(rollup@4.59.0)(sass-embedded@1.100.0)(tailwindcss@3.4.19(tsx@4.23.15)(yaml@2.9.1))(terser@5.49.2)(tslib@2.8.1)(tsx@4.23.15)(typescript@6.0.3)(yaml@2.9.1) '@angular/cli': - specifier: 22.1.8 - version: 22.1.8(@types/node@20.19.9)(chokidar@5.0.0) + specifier: 22.2.2 + version: 22.2.2(@types/node@20.19.9)(chokidar@5.0.0) '@angular/compiler-cli': - specifier: 22.1.6 - version: 22.1.6(@angular/compiler@22.1.6)(typescript@6.0.3) + specifier: 22.2.2 + version: 22.2.2(@angular/compiler@22.2.2)(typescript@6.0.3) '@angular/language-service': - specifier: 22.1.6 - version: 22.1.6 + specifier: 22.2.2 + version: 22.2.2 '@angular/service-worker': - specifier: 22.1.6 - version: 22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2) + specifier: 22.2.2 + version: 22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2) '@astrojs/mdx': specifier: 7.0.7 - version: 7.0.7(@astrojs/markdown-satteri@0.3.7)(astro@7.2.10(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.49.2)(tsx@4.23.13)(yaml@2.9.0)) + version: 7.0.7(@astrojs/markdown-satteri@0.3.7)(astro@7.2.10(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.1)) '@astrojs/sitemap': specifier: 3.7.4 version: 3.7.4 @@ -268,61 +268,61 @@ importers: version: 5.3.0 '@ngrx/eslint-plugin': specifier: 22.0.1 - version: 22.0.1(@typescript-eslint/utils@8.69.0(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3))(eslint@9.39.2(jiti@1.21.7))(typescript-eslint@8.69.0(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3))(typescript@6.0.3) + version: 22.0.1(@typescript-eslint/utils@8.71.1(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3))(eslint@9.39.2(jiti@1.21.7))(typescript-eslint@8.71.1(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3))(typescript@6.0.3) '@ngx-translate/core': specifier: 16.0.4 - version: 16.0.4(@angular/common@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3)) + version: 16.0.4(@angular/common@22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3)) '@ngx-translate/http-loader': specifier: 16.0.1 - version: 16.0.1(@angular/common@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3)) + version: 16.0.1(@angular/common@22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3)) '@nx/angular': specifier: 23.2.1 - version: 23.2.1(e86c4a4fb085aa7a209327ce2b2f688a) + version: 23.2.1(eefa38d48d90431e98f5616ec43eae75) '@nx/devkit': specifier: 23.2.1 - version: 23.2.1(nx@23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.1(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.1(@swc/helpers@0.5.23))) + version: 23.2.1(nx@23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.13(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.13(@swc/helpers@0.5.23))) '@nx/esbuild': specifier: 23.2.1 - version: 23.2.1(@babel/traverse@7.29.8)(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.1(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.1(@swc/helpers@0.5.23))(esbuild@0.28.2)(nx@23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.1(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.1(@swc/helpers@0.5.23))) + version: 23.2.1(@babel/traverse@7.29.10)(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.13(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.13(@swc/helpers@0.5.23))(esbuild@0.28.2)(nx@23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.13(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.13(@swc/helpers@0.5.23))) '@nx/eslint': specifier: 23.2.1 - version: 23.2.1(@babel/traverse@7.29.8)(@nx/jest@23.2.1(72e9877c63da59ac93e1f6ba5cf055e8))(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.1(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.1(@swc/helpers@0.5.23))(@zkochan/js-yaml@0.0.7)(eslint@9.39.2(jiti@1.21.7))(nx@23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.1(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.1(@swc/helpers@0.5.23))) + version: 23.2.1(@babel/traverse@7.29.10)(@nx/jest@23.2.1(45be1c4077e405ec0f48ec85376a2be8))(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.13(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.13(@swc/helpers@0.5.23))(@zkochan/js-yaml@0.0.7)(eslint@9.39.2(jiti@1.21.7))(nx@23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.13(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.13(@swc/helpers@0.5.23))) '@nx/eslint-plugin': specifier: 23.2.1 - version: 23.2.1(@babel/traverse@7.29.8)(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.1(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.1(@swc/helpers@0.5.23))(@typescript-eslint/parser@8.69.0(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3))(eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@1.21.7)))(eslint@9.39.2(jiti@1.21.7))(nx@23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.1(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.1(@swc/helpers@0.5.23)))(typescript@6.0.3) + version: 23.2.1(@babel/traverse@7.29.10)(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.13(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.13(@swc/helpers@0.5.23))(@typescript-eslint/parser@8.71.1(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3))(eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@1.21.7)))(eslint@9.39.2(jiti@1.21.7))(nx@23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.13(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.13(@swc/helpers@0.5.23)))(typescript@6.0.3) '@nx/jest': specifier: 23.2.1 - version: 23.2.1(72e9877c63da59ac93e1f6ba5cf055e8) + version: 23.2.1(45be1c4077e405ec0f48ec85376a2be8) '@nx/js': specifier: 23.2.1 - version: 23.2.1(@babel/traverse@7.29.8)(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.1(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.1(@swc/helpers@0.5.23))(nx@23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.1(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.1(@swc/helpers@0.5.23))) + version: 23.2.1(@babel/traverse@7.29.10)(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.13(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.13(@swc/helpers@0.5.23))(nx@23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.13(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.13(@swc/helpers@0.5.23))) '@nx/playwright': specifier: 23.2.1 - version: 23.2.1(@babel/traverse@7.29.8)(@nx/jest@23.2.1(72e9877c63da59ac93e1f6ba5cf055e8))(@playwright/test@1.62.1)(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.1(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.1(@swc/helpers@0.5.23))(@zkochan/js-yaml@0.0.7)(eslint@9.39.2(jiti@1.21.7))(nx@23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.1(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.1(@swc/helpers@0.5.23))) + version: 23.2.1(@babel/traverse@7.29.10)(@nx/jest@23.2.1(45be1c4077e405ec0f48ec85376a2be8))(@playwright/test@1.64.0)(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.13(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.13(@swc/helpers@0.5.23))(@zkochan/js-yaml@0.0.7)(eslint@9.39.2(jiti@1.21.7))(nx@23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.13(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.13(@swc/helpers@0.5.23))) '@nx/web': specifier: 23.2.1 - version: 23.2.1(f806492ec758094179990070b023c41c) + version: 23.2.1(33e69643271612c9d9b31a956c0e020d) '@nx/workspace': specifier: 23.2.1 - version: 23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.1(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.1(@swc/helpers@0.5.23)) + version: 23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.13(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.13(@swc/helpers@0.5.23)) '@playwright/test': - specifier: ^1.62.1 - version: 1.62.1 + specifier: ^1.64.0 + version: 1.64.0 '@schematics/angular': - specifier: 22.1.8 - version: 22.1.8(chokidar@5.0.0) + specifier: 22.2.2 + version: 22.2.2(chokidar@5.0.0) '@swc-node/register': specifier: 1.12.1 - version: 1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.1(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3) + version: 1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.13(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3) '@swc/core': - specifier: 1.16.1 - version: 1.16.1(@swc/helpers@0.5.23) + specifier: 1.16.13 + version: 1.16.13(@swc/helpers@0.5.23) '@swc/helpers': specifier: 0.5.23 version: 0.5.23 '@tailwindcss/typography': specifier: 0.5.20 - version: 0.5.20(tailwindcss@3.4.19(tsx@4.23.13)(yaml@2.9.0)) + version: 0.5.20(tailwindcss@3.4.19(tsx@4.23.15)(yaml@2.9.1)) '@types/better-sqlite3': specifier: ^9.6.0 version: 9.6.0 @@ -351,26 +351,26 @@ importers: specifier: 7.3.58 version: 7.3.58 '@typescript-eslint/eslint-plugin': - specifier: ^8.69.0 - version: 8.69.0(@typescript-eslint/parser@8.69.0(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3))(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3) + specifier: ^8.71.1 + version: 8.71.1(@typescript-eslint/parser@8.71.1(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3))(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3) '@typescript-eslint/parser': - specifier: ^8.69.0 - version: 8.69.0(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3) + specifier: ^8.71.1 + version: 8.71.1(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3) '@typescript-eslint/utils': - specifier: ^8.69.0 - version: 8.69.0(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3) + specifier: ^8.71.1 + version: 8.71.1(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3) angular-eslint: specifier: 22.5.0 - version: 22.5.0(chokidar@5.0.0)(eslint@9.39.2(jiti@1.21.7))(typescript-eslint@8.69.0(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3))(typescript@6.0.3) + version: 22.5.0(chokidar@5.0.0)(eslint@9.39.2(jiti@1.21.7))(typescript-eslint@8.71.1(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3))(typescript@6.0.3) astro: specifier: 7.2.10 - version: 7.2.10(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.49.2)(tsx@4.23.13)(yaml@2.9.0) + version: 7.2.10(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.1) autoprefixer: - specifier: 10.5.4 - version: 10.5.4(postcss@8.5.26) + specifier: 10.6.1 + version: 10.6.1(postcss@8.5.26) axe-core: - specifier: 4.13.0 - version: 4.13.0 + specifier: 4.14.0 + version: 4.14.0 cookie: specifier: 2.0.1 version: 2.0.1 @@ -378,8 +378,8 @@ importers: specifier: 2.8.6 version: 2.8.6 drizzle-kit: - specifier: 0.31.10 - version: 0.31.10 + specifier: 0.31.11 + version: 0.31.11 electron: specifier: ^43.3.0 version: 43.3.0 @@ -400,13 +400,13 @@ importers: version: 10.1.8(eslint@9.39.2(jiti@1.21.7)) eslint-plugin-import: specifier: 2.32.0 - version: 2.32.0(@typescript-eslint/parser@8.69.0(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3))(eslint@9.39.2(jiti@1.21.7)) + version: 2.32.0(@typescript-eslint/parser@8.71.1(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3))(eslint@9.39.2(jiti@1.21.7)) eslint-plugin-playwright: specifier: ^1.6.2 version: 1.8.3(eslint@9.39.2(jiti@1.21.7)) express: - specifier: 5.2.1 - version: 5.2.1 + specifier: 5.3.0 + version: 5.3.0 github-slugger: specifier: 2.0.0 version: 2.0.0 @@ -426,17 +426,17 @@ importers: specifier: 3.2.0 version: 3.2.0 jest: - specifier: ^30.5.1 - version: 30.5.1(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.2))(ts-node@10.9.2(@swc/core@1.16.1(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@6.0.3)) + specifier: ^30.5.2 + version: 30.5.2(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.2))(ts-node@10.9.2(@swc/core@1.16.13(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@6.0.3)) jest-environment-jsdom: - specifier: ^30.5.1 - version: 30.5.1 + specifier: ^30.5.2 + version: 30.5.2 jest-environment-node: - specifier: ^30.5.1 - version: 30.5.1 + specifier: ^30.5.2 + version: 30.5.2 jest-preset-angular: - specifier: 17.0.0 - version: 17.0.0(@angular/compiler-cli@22.1.6(@angular/compiler@22.1.6)(typescript@6.0.3))(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(@angular/platform-browser@22.1.6(@angular/animations@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/common@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3)))(@babel/core@7.29.7)(@jest/transform@30.5.1)(@jest/types@30.5.1)(@types/jsdom@21.1.7)(babel-jest@30.5.1(@babel/core@7.29.7))(jest@30.5.1(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.2))(ts-node@10.9.2(@swc/core@1.16.1(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@6.0.3)))(jsdom@26.1.0)(typescript@6.0.3) + specifier: 17.0.2 + version: 17.0.2(@angular/compiler-cli@22.2.2(@angular/compiler@22.2.2)(typescript@6.0.3))(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(@angular/platform-browser@22.2.2(@angular/animations@22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/common@22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3)))(@babel/core@7.29.7)(@jest/transform@30.5.2)(@jest/types@30.5.1)(@types/jsdom@21.1.7)(babel-jest@30.5.2(@babel/core@7.29.7))(jest@30.5.2(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.2))(ts-node@10.9.2(@swc/core@1.16.13(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@6.0.3)))(jsdom@26.1.0)(typescript@6.0.3) jest-util: specifier: ^30.5.1 version: 30.5.1 @@ -453,17 +453,17 @@ importers: specifier: 2.0.1 version: 2.0.1 ng-mocks: - specifier: 14.17.6 - version: 14.17.6(c5ca909b0ec8f50b848b5bca41287c00) + specifier: 14.18.1 + version: 14.18.1(706c0063418d3ed3bcf96ef54b4c3dc3) node-gyp: specifier: 12.4.0 version: 12.4.0 nx: specifier: 23.2.1 - version: 23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.1(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.1(@swc/helpers@0.5.23)) + version: 23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.13(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.13(@swc/helpers@0.5.23)) nx-electron: specifier: 22.0.0 - version: 22.0.0(patch_hash=4d5ac9c5b10268dcc40998d7a2b166d004105ae7875fa182130d6810871a1e84)(@nx/devkit@23.2.1(nx@23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.1(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.1(@swc/helpers@0.5.23))))(@nx/workspace@23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.1(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.1(@swc/helpers@0.5.23)))(@swc/core@1.16.1(@swc/helpers@0.5.23))(electron-builder-squirrel-windows@26.15.7)(electron@43.3.0)(esbuild@0.28.2)(rxjs@7.8.2)(typescript@6.0.3) + version: 22.0.0(patch_hash=4d5ac9c5b10268dcc40998d7a2b166d004105ae7875fa182130d6810871a1e84)(@nx/devkit@23.2.1(nx@23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.13(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.13(@swc/helpers@0.5.23))))(@nx/workspace@23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.13(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.13(@swc/helpers@0.5.23)))(@swc/core@1.16.13(@swc/helpers@0.5.23))(electron-builder-squirrel-windows@26.15.7)(electron@43.3.0)(esbuild@0.28.2)(rxjs@7.8.2)(typescript@6.0.3) parse-srcset: specifier: 1.0.2 version: 1.0.2 @@ -471,38 +471,38 @@ importers: specifier: 8.0.1 version: 8.0.1 prettier: - specifier: ^3.9.6 - version: 3.9.6 + specifier: ^3.9.9 + version: 3.9.9 sharp: specifier: 0.35.5 version: 0.35.5(@types/node@20.19.9) tailwindcss: specifier: ^3.4.19 - version: 3.4.19(tsx@4.23.13)(yaml@2.9.0) + version: 3.4.19(tsx@4.23.15)(yaml@2.9.1) ts-jest: - specifier: ^29.4.12 - version: 29.4.12(@babel/core@7.29.7)(@jest/transform@30.5.1)(@jest/types@30.5.1)(babel-jest@30.5.1(@babel/core@7.29.7))(esbuild@0.28.2)(jest-util@30.5.1)(jest@30.5.1(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.2))(ts-node@10.9.2(@swc/core@1.16.1(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@6.0.3)))(typescript@6.0.3) + specifier: ^29.4.14 + version: 29.4.14(@babel/core@7.29.7)(@jest/transform@30.5.2)(@jest/types@30.5.1)(babel-jest@30.5.2(@babel/core@7.29.7))(esbuild@0.28.2)(jest-util@30.5.1)(jest@30.5.2(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.2))(ts-node@10.9.2(@swc/core@1.16.13(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@6.0.3)))(typescript@6.0.3) ts-node: specifier: 10.9.2 - version: 10.9.2(@swc/core@1.16.1(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@6.0.3) + version: 10.9.2(@swc/core@1.16.13(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@6.0.3) tslib: specifier: ^2.8.1 version: 2.8.1 tsx: - specifier: 4.23.13 - version: 4.23.13 + specifier: 4.23.15 + version: 4.23.15 typescript: specifier: 6.0.3 version: 6.0.3 typescript-eslint: - specifier: ^8.69.0 - version: 8.69.0(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3) + specifier: ^8.71.1 + version: 8.71.1(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3) yaml: - specifier: 2.9.0 - version: 2.9.0 + specifier: 2.9.1 + version: 2.9.1 zod: - specifier: 4.5.4 - version: 4.5.4 + specifier: 4.6.5 + version: 4.6.5 packages: @@ -519,6 +519,11 @@ packages: engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} hasBin: true + '@angular-devkit/architect@0.2202.2': + resolution: {integrity: sha512-7ScBo47haHyRGG5M5ZEpbhwHgO2LEDbCLwNCjpjwhtRFDIEmAFbULM1OQjIybN3x+wOyY1kWRK7KvihCbs371g==} + engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + hasBin: true + '@angular-devkit/core@22.1.8': resolution: {integrity: sha512-34lsgg2FwMVBX7nR/ZqzRUcdvdYPvSB8XAEr2GudXAyZwLI9ehzfQlagAWR/gEb8p4uCFZW0r7uU0toxydq4Rw==} engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} @@ -528,8 +533,17 @@ packages: chokidar: optional: true - '@angular-devkit/schematics@22.1.8': - resolution: {integrity: sha512-Pv3cPa/44kvEwYcqwx4Ns5dhIDmcFNSHhbpheqiEG1Z/u4e2t4zHKDtE3eHZB+8o+IcC7xJj+d+AqGR44RoZDA==} + '@angular-devkit/core@22.2.2': + resolution: {integrity: sha512-iQexb5xUON9AkTzvBiMftrHWLguIlYv6M/BOH/sj7U5L8eOPMu8N269WF74xPo4n/X5tusFLqniGI0zolT2Sjg==} + engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + peerDependencies: + chokidar: ^5.0.0 + peerDependenciesMeta: + chokidar: + optional: true + + '@angular-devkit/schematics@22.2.2': + resolution: {integrity: sha512-uCpFZbQgFUrOjKHLeYSCaYCJdufauYom8yFGggm67yetMAGKPbmRvUEko8i8auNFoBQ+1w1CivjSTwtmXQzZkA==} engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} '@angular-eslint/builder@22.5.0': @@ -573,12 +587,12 @@ packages: eslint: ^9.0.0 || ^10.0.0 typescript: '*' - '@angular/animations@22.1.6': - resolution: {integrity: sha512-o3tad5WSlcq80aFvZGQ1VrNtY2fieJWbYn1h2AxJXE1Mj0h3atvw4zgQ+qx9ZRCYsZvsluW88YxA1Zk0bCuZvQ==} + '@angular/animations@22.2.2': + resolution: {integrity: sha512-N2VTjsH9xHGWsKlyeWrODcp4b2UEkrT/lGkHMxL4yr+ie0sKpxtZzzwzOQMMCRyv39ff2sfPQjb7q56K8RZ78w==} engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0} deprecated: '@angular/animations is deprecated. Use `animate.enter` and `animate.leave` instead. For more information see: https://v22.angular.dev/guide/animations.' peerDependencies: - '@angular/core': 22.1.6 + '@angular/core': 22.2.2 '@angular/build@22.1.8': resolution: {integrity: sha512-tw+Evk0EITb8p8dTu743ONoFztCEiyDkdRbtZsh7C0vBS7Q9ElsOcFeg2Z9OAAwrmtZMIBuW3jrdPRQp7OCSdw==} @@ -632,8 +646,8 @@ packages: vitest: optional: true - '@angular/cdk@22.1.6': - resolution: {integrity: sha512-4eHSs/sIIp1mesNKFDkDTYDhfB/3/Bpfjzv/PbybsKVd642FTa3IAbjWf3uqHS/V7mpucF3Q5gSpirZHDgxwrg==} + '@angular/cdk@22.2.2': + resolution: {integrity: sha512-d5PWfIargIEV0rlwZea2pYDGb8FhF9df6a6XFRLThla6NKiJRs8utqW+7pyY9wCPFR3SSCGHC05xFh7qv91hdw==} peerDependencies: '@angular/common': ^22.0.0 || ^23.0.0 '@angular/core': ^22.0.0 || ^23.0.0 @@ -641,38 +655,38 @@ packages: '@angular/platform-browser': ^22.0.0 || ^23.0.0 rxjs: ^6.5.3 || ^7.4.0 - '@angular/cli@22.1.8': - resolution: {integrity: sha512-MFN/FydqDndai8vF12hDxG5RlBt1HUUheeJgJNmuHYIne4bOAfOT+xuMpIK8+gLSRwCemdqR3OMM38pnsFJ/nA==} + '@angular/cli@22.2.2': + resolution: {integrity: sha512-AALIN+zai0CiA7HgSCGVPHyZyiw3yFWliCfbaS4ueHDJHVZ8QhVjRUrnfGZc3EJnigcyQ0w2DKeTxjwqpDjKew==} engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} hasBin: true - '@angular/common@22.1.6': - resolution: {integrity: sha512-giuH+jJvo6YbBxbKofJCXvq6k8g1Z/xCAvh4piNFSSS6/toXTLCeZ+snr6Stw5b2wRbArM5Q5nDeuto3NqVPnQ==} + '@angular/common@22.2.2': + resolution: {integrity: sha512-d+mbF1UQLpsoJiC7h4pl58G7XeLHKU8DdLbq9u7TuOK0AnC1t2rrphi8M8WqUrZ+bfmFKCU4i1i4+vOF1N5GtA==} engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0} peerDependencies: - '@angular/core': 22.1.6 + '@angular/core': 22.2.2 rxjs: ^6.5.3 || ^7.4.0 - '@angular/compiler-cli@22.1.6': - resolution: {integrity: sha512-C1fQuaSLnibhfbb7Im/vurdBEcfQk+/GqPkY4+dgEKd4EPleO0xWlD+k5DwyNFX8a9w7HebWfo0Zl66HuaEwOQ==} + '@angular/compiler-cli@22.2.2': + resolution: {integrity: sha512-7PCIBTVIzG4oyAkDeBRPJpGcgSDLBCKn+2Pp9+gXnXOEETdWhV34so1fpWZ2GWBZsxXav7Ry0K93vFwF93yWoQ==} engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0} hasBin: true peerDependencies: - '@angular/compiler': 22.1.6 + '@angular/compiler': 22.2.2 typescript: '>=6.0 <6.1' peerDependenciesMeta: typescript: optional: true - '@angular/compiler@22.1.6': - resolution: {integrity: sha512-JjOUm/qD338+fGfZvxSNn/vTUiVqNwOiPzacInVUq1eVp7Jev+cnvEwXA2cFJkYZoy3Imz6wHoMvTUZNN8cbKQ==} + '@angular/compiler@22.2.2': + resolution: {integrity: sha512-hg8Kvhq29w97vs3O5KsSeqA+oobrAKpr/GvsWBIqjRzfrxxeFdjRRoEv1/MVgMSJK36VLigNCjB1qPF3MB17uA==} engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0} - '@angular/core@22.1.6': - resolution: {integrity: sha512-3Ln9YYOhsaU2vPufnpcu6C4dlmX4e/nJTlggVcKMT7bGZH5KlEtw3h0uh9YfANv8YhQCEk1AcuRI7KpBnh5ing==} + '@angular/core@22.2.2': + resolution: {integrity: sha512-mFiSoAs7Gp/7VlbdSxDyV2I/FEyYeNZRaHH7QrMWSB9t+k7wm37uCaYOt9fy6fgXU/UbCgBESp09fV9bUTQcmA==} engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0} peerDependencies: - '@angular/compiler': 22.1.6 + '@angular/compiler': 22.2.2 rxjs: ^6.5.3 || ^7.4.0 zone.js: ~0.15.0 || ~0.16.0 peerDependenciesMeta: @@ -681,65 +695,65 @@ packages: zone.js: optional: true - '@angular/forms@22.1.6': - resolution: {integrity: sha512-rfV4G4UB4l69yXSRvhaHPzTMIruvBlRO+ak9NtTcYMnsoj8O5ZCyPv0ledGIrLTBwGSI3X1j4nr9aoV+Rj/n+Q==} + '@angular/forms@22.2.2': + resolution: {integrity: sha512-Fgh+EnDoULSFzuDy44da/5Chi+4Sd2Qc4SLjFj23bktvMJ1yfuBPWkvpR9xgWg9Auq+9x84wJVK6WyNwIjXYWA==} engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0} peerDependencies: - '@angular/common': 22.1.6 - '@angular/core': 22.1.6 - '@angular/platform-browser': 22.1.6 + '@angular/common': 22.2.2 + '@angular/core': 22.2.2 + '@angular/platform-browser': 22.2.2 rxjs: ^6.5.3 || ^7.4.0 - '@angular/language-service@22.1.6': - resolution: {integrity: sha512-qsOgDUGR62lsdyJF+hoLx1kTSZtBoc+r2GJ64o6O+HqlsTPw0Q6aI6Y1qQkAurQbGa0s7qfhuXEHtKmG9MFiXA==} + '@angular/language-service@22.2.2': + resolution: {integrity: sha512-IJWdJFEJ/1o62rsd/mfWDxy0iYbuQtW/wRgwdIpRcrzLv64Fw/ICRzZQddkl9TyUEAmtqFTMejwHsLZrHMljDg==} engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0} - '@angular/material@22.1.6': - resolution: {integrity: sha512-NpURtI21B7D6+DVltihVLxQWOm/cshlbJKqlUuSmyoGGO8Fvf4RxMtx6IZ5YAfVSQsuZvCvdXPc4BKUlJrqnHw==} + '@angular/material@22.2.2': + resolution: {integrity: sha512-G8nnt6yO7z64yZZbeF0l0fSztgZYv1TBFdGlexweyPfBlRn7N2X14Uv/TWht46vrXvExGuZOiR3c05XELd80fw==} peerDependencies: - '@angular/cdk': 22.1.6 + '@angular/cdk': 22.2.2 '@angular/common': ^22.0.0 || ^23.0.0 '@angular/core': ^22.0.0 || ^23.0.0 '@angular/forms': ^22.0.0 || ^23.0.0 '@angular/platform-browser': ^22.0.0 || ^23.0.0 rxjs: ^6.5.3 || ^7.4.0 - '@angular/platform-browser-dynamic@22.1.6': - resolution: {integrity: sha512-ixLGQoRkF1ctIN01opG8XaT84TR2/RngMvA0DKsbCjoz0+ECCq3RtsrXUB5Qdny7AXzPvsJchxQ+ZubtOX9MYQ==} + '@angular/platform-browser-dynamic@22.2.2': + resolution: {integrity: sha512-dfZ25N7RuImSpw7kfW3POcc0FvBbznAZrMDOwISpqFbmOuLlsCgYJtYOMh3cEjNBkvOjBlvpU64ra367sau0hw==} engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0} deprecated: '@angular/platform-browser-dynamic is deprecated. Use `@angular/platform-browser` instead.' peerDependencies: - '@angular/common': 22.1.6 - '@angular/compiler': 22.1.6 - '@angular/core': 22.1.6 - '@angular/platform-browser': 22.1.6 + '@angular/common': 22.2.2 + '@angular/compiler': 22.2.2 + '@angular/core': 22.2.2 + '@angular/platform-browser': 22.2.2 - '@angular/platform-browser@22.1.6': - resolution: {integrity: sha512-jrRi6zpdz+jOle5l0OW7QL0a8xPgdnxWT5FrJabbiNKfYzQfqKcaAu02l8uJoJxtGb8fLQ8pbjkPpZEvKO2z+w==} + '@angular/platform-browser@22.2.2': + resolution: {integrity: sha512-DnAyZTLty5bVNeDhcSPNQABqb9SmNvYfYhvgU3dKoGEpONrrECfYo/X4MN0g7Z9Dn/DkgBCNRoC9AZWxbO9HDQ==} engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0} peerDependencies: - '@angular/animations': 22.1.6 - '@angular/common': 22.1.6 - '@angular/core': 22.1.6 + '@angular/animations': 22.2.2 + '@angular/common': 22.2.2 + '@angular/core': 22.2.2 peerDependenciesMeta: '@angular/animations': optional: true - '@angular/router@22.1.6': - resolution: {integrity: sha512-ex0vrkcVyJJdn7NzZXun+5ctaPPLTZJ/gE7A3g1dun7BM3g4Z6zT738mODFeJgtPtJNf4bTs2gpgvyegvzWH7w==} + '@angular/router@22.2.2': + resolution: {integrity: sha512-45HP/henqbPRTCBHzwF4ZV8JU75u2TmBMR4BtwcXcKF5SWragKWE0rMz4NpHnCQIy3bIy5SVTvawpf7EJ/kRyw==} engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0} peerDependencies: - '@angular/common': 22.1.6 - '@angular/core': 22.1.6 - '@angular/platform-browser': 22.1.6 + '@angular/common': 22.2.2 + '@angular/core': 22.2.2 + '@angular/platform-browser': 22.2.2 rxjs: ^6.5.3 || ^7.4.0 - '@angular/service-worker@22.1.6': - resolution: {integrity: sha512-l++I/g8Bic8DwTgvIXNxrU0rIzfs2taep8+EgIdbUCGcqIl+Y4oFgCafJBYMBBjH6PMIiEcp7y8xUu5ZFz4muA==} + '@angular/service-worker@22.2.2': + resolution: {integrity: sha512-60Knjdvr80OSsqUQ7LYc+ibeLN495uj+loeCdmowO4DvKyoPFlW/KW7sKZq7OuZcnKIln+ujHNViYxs4wf23zQ==} engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0} hasBin: true peerDependencies: - '@angular/core': 22.1.6 + '@angular/core': 22.2.2 rxjs: ^6.5.3 || ^7.4.0 '@asamuzakjp/css-color@3.2.0': @@ -860,8 +874,8 @@ packages: resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} - '@babel/code-frame@8.0.0': - resolution: {integrity: sha512-dYYg153EyN2Ekbqw2zAsbd6/JR+9N2SEoC7YV2GyyqMM7x9bLDTjBD6XBhSMLH0wtIVyJj03jWNriQhaN+eoCw==} + '@babel/code-frame@8.0.6': + resolution: {integrity: sha512-uMHhvmDmwFiLuiozaoRYTZHqMFuu3fem/X9JY78lohAuLoh8LSGGC8JqUzo2cVuBsBxkjMZNk56CE7ock8Xxkw==} engines: {node: ^22.18.0 || >=24.11.0} '@babel/compat-data@7.29.7': @@ -884,8 +898,8 @@ packages: resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==} engines: {node: '>=6.9.0'} - '@babel/generator@8.0.5': - resolution: {integrity: sha512-f/TuhuMAxJqhwxEGNsJrswuG9VHmh0oNFoQoo6TbpgtFAz9wYZXcTAcWZMHfp7ljesr0RG04bp3Aos9GI59L7w==} + '@babel/generator@8.0.6': + resolution: {integrity: sha512-/pcanjdRsonCexCuxgGD8fKnBLLzk2i9uM4kmcmGyyev2R5YDs+rafMYZ89RvaGY1mW8T7C5au0TyYaEeHBGng==} engines: {node: ^22.18.0 || >=24.11.0} '@babel/helper-annotate-as-pure@7.29.7': @@ -900,8 +914,8 @@ packages: resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} engines: {node: '>=6.9.0'} - '@babel/helper-compilation-targets@8.0.5': - resolution: {integrity: sha512-Qk8ahMGooH5mz6uuhoDvfZGkUf/Mf3RTBucVVl4MKx4LKMTv872TeW8O92h15iVtlN8wAROBIpI1aV6x1z0LCQ==} + '@babel/helper-compilation-targets@8.0.7': + resolution: {integrity: sha512-ilAZCdOOlOVA7dQbaFNw2f/59p6GW33NxTopc3DvEE3efp3ZXTrGX/yZScaKw8/vd9bq31Ag0EcXiLw32bAgJg==} engines: {node: ^22.18.0 || >=24.11.0} '@babel/helper-create-class-features-plugin@7.29.7': @@ -925,8 +939,8 @@ packages: resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} engines: {node: '>=6.9.0'} - '@babel/helper-globals@8.0.0': - resolution: {integrity: sha512-lLozHOM6sWWlxNo8CYqHy4MBZeTvHXNgVPBfPOGsjPKUzHC2Az9QwB6gxdQmpwHl6GlQtbGgS+lj5887guDiLw==} + '@babel/helper-globals@8.0.6': + resolution: {integrity: sha512-OdnA/N52LhzFpeUuVz5YGuSWjc7TPHiFmVYZbPlSgExocO2GwbxVpSni9MVs5wwW+wZ4kPuwBEBN9pIPDYz1mA==} engines: {node: ^22.18.0 || >=24.11.0} '@babel/helper-member-expression-to-functions@7.29.7': @@ -979,6 +993,10 @@ packages: resolution: {integrity: sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg==} engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helper-string-parser@8.0.6': + resolution: {integrity: sha512-+6HYEBnsDRZHJtUTAEfMQd0zMDqdFHS7C+5vAsEZqBUr3vEMWOWlHJ0Jz1gS8jJIlCvLsuA6ivmeoqzH936YcA==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helper-validator-identifier@7.28.5': resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} engines: {node: '>=6.9.0'} @@ -991,6 +1009,10 @@ packages: resolution: {integrity: sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg==} engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helper-validator-identifier@8.0.6': + resolution: {integrity: sha512-HYx0YmuchRxRSpnjvcNffnPS8DCauKNmnumIuHG3jnQAVewRA7kfJGwF13ayEpSZs7s4R/oBH4eppuXcAQd5JQ==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helper-validator-option@7.29.7': resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} engines: {node: '>=6.9.0'} @@ -1007,8 +1029,8 @@ packages: resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} engines: {node: '>=6.9.0'} - '@babel/helpers@8.0.5': - resolution: {integrity: sha512-fQtPOXjYOYv85PIdwotp2TJGVYOycX0PQq+l844fFAxOULtBy8BVF35GyeueX0r4KvDthqPH5xAI1clQPk/2uA==} + '@babel/helpers@8.0.7': + resolution: {integrity: sha512-5dMj5kFwS2AY7Dt4PZp4px3c5ThMbbEa7tZfuHTncCGSFYq8uoA1ufDE1IS/lYpQedE+3+GQ3cPZUiplIu195w==} engines: {node: ^22.18.0 || >=24.11.0} '@babel/parser@7.29.8': @@ -1016,8 +1038,13 @@ packages: engines: {node: '>=6.0.0'} hasBin: true - '@babel/parser@8.0.5': - resolution: {integrity: sha512-51RXvQNFakaS0bTpYiGkxNbUVwkPO4kONv6EVLorZABxsx+KZ6Z7uSYvi/wmKS/+X+rfj9RvOw0/ZNh+cmI0Rw==} + '@babel/parser@7.29.9': + resolution: {integrity: sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/parser@8.0.7': + resolution: {integrity: sha512-UkDwEbPO1px2JQSrdh4qIgaH1JgG7GO/pFeJkpxhDxIETkT/QvGpnigXIw75ViHYPtIgKITmVUJqLjiO+JuWjA==} engines: {node: ^22.18.0 || >=24.11.0} hasBin: true @@ -1513,6 +1540,10 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/runtime@7.29.10': + resolution: {integrity: sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==} + engines: {node: '>=6.9.0'} + '@babel/runtime@7.29.7': resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} engines: {node: '>=6.9.0'} @@ -1525,12 +1556,16 @@ packages: resolution: {integrity: sha512-eAD0QW/AlbamBbw0FeGiwasbCVPq5ncW0HNVyLP3B9czqLyh4gvw+5JTSNt6le9+ziAU7mqDZsKTHf3jTb4chQ==} engines: {node: ^22.18.0 || >=24.11.0} + '@babel/traverse@7.29.10': + resolution: {integrity: sha512-B3lYAs2nkKskbgQ1nfy8Ma9ao9Q1rntTAQHrzzyDuj+TSO+X3le6PfWwqDvodsb+OumE10eMFfuMCTQSQ5iuIQ==} + engines: {node: '>=6.9.0'} + '@babel/traverse@7.29.8': resolution: {integrity: sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==} engines: {node: '>=6.9.0'} - '@babel/traverse@8.0.5': - resolution: {integrity: sha512-XFfnuvapSc/vJOcUO7kwORSvpBIvraofKEZ2dhT0PjiF21BRCD7YbAFC8UEeDJNeLoQz82/gVqzgX5hCzkCbdg==} + '@babel/traverse@8.0.7': + resolution: {integrity: sha512-bVV0brinsTWgc6ptSIoQo3GbITtl0VZAqEUJbHHcyfDPEu+q/fEvlYEa+6H85HoK8IdksKBFtKJbJFZ6Kff+zg==} engines: {node: ^22.18.0 || >=24.11.0} '@babel/types@7.29.8': @@ -1541,6 +1576,10 @@ packages: resolution: {integrity: sha512-eVdMqi3ej5aHhyQ2Si6yD2cAWeV8FJK9UrhK5aL0Sd8hu5GhT+YswhVNbVheOGVYMg8kuGuMaUpkB3stjj4z8A==} engines: {node: ^22.18.0 || >=24.11.0} + '@babel/types@8.0.6': + resolution: {integrity: sha512-c+xgSWboV2pdwXP67BUWDVRHi2BO5Q3KwxyFVs7taVTY+fsK47/L51ZXQoV9rozHJDNHvJ4v4WEsI/IlxV2l2A==} + engines: {node: ^22.18.0 || >=24.11.0} + '@bcoe/v8-coverage@0.2.3': resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} @@ -2115,12 +2154,6 @@ packages: '@harperfast/extended-iterable@1.0.3': resolution: {integrity: sha512-sSAYhQca3rDWtQUHSAPeO7axFIUJOI6hn1gjRC5APVE1a90tuyT8f5WIgRsFhhWA7htNkju2veB9eWL6YHi/Lw==} - '@hono/node-server@1.19.17': - resolution: {integrity: sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==} - engines: {node: '>=18.14.1'} - peerDependencies: - hono: 4.13.5 - '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} @@ -2307,8 +2340,12 @@ packages: resolution: {integrity: sha512-WpQM+Ti6Z40EFwwt+uL2p4UabT+W179zHp6HhLVOzfbwnVn05IPO/eXIZXGNqcT1jbQ15SujNLzQ39k4QPPxBQ==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} - '@inquirer/checkbox@5.2.5': - resolution: {integrity: sha512-bRt8J8m+Fot9CXv+zNQGXUq2ET0MggR1fPz7v6edN6MFYmsbfGnMmkmWZJEegMKqrAC8ej/o1sqisHZXZJMAfQ==} + '@inquirer/ansi@2.0.9': + resolution: {integrity: sha512-0Ywt3S8tqM6TWUcsRlBFQ1x+X3/o8kktX0ZiKAnByYb9GFDknnOhse1PVtfiD2BXPI8ar+BWx9PV9R0lQIcygw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + + '@inquirer/checkbox@5.2.6': + resolution: {integrity: sha512-XkOardciF3tow2NUlY6/Qjk1QqYa+6WpmVLTpxXl97Uv4erczDEZ+xg1j6LqBf0ekcRSUiICQA4nQXHHkVKQ9w==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' @@ -2325,8 +2362,8 @@ packages: '@types/node': optional: true - '@inquirer/confirm@6.3.2': - resolution: {integrity: sha512-Xvr/0HggjddPtGppuqVmxhTw+Hr8PvsZ/k0HmOEaAqQEt80OITNkFWnsdNmyT0/eM4Ab+iJLx2R8rctlEyfSVg==} + '@inquirer/confirm@6.3.3': + resolution: {integrity: sha512-ImOBgpl/sJTFp8naeSkNu4S0Sf8F8pxv3/CUx+LoBAdQvCVqFz6rSB2A/oZYsJge3+yphbEDxEkfaRNYq1OU0A==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' @@ -2343,8 +2380,8 @@ packages: '@types/node': optional: true - '@inquirer/core@12.0.3': - resolution: {integrity: sha512-wsSy0sznmXwkty+2PzZwx00Cazc/E0r0B7mAzdGROz2Ct+DFZXaK7WDjGZvgjRldxH5ZhFVfF2lgkYrqgOw2KA==} + '@inquirer/core@12.0.4': + resolution: {integrity: sha512-M5fB67y7jPObI45vhD82RskWwCSm0scOvenV/I6K8tIi2gfr9DXqQqJ+iw+Wi6SgapikdOiJibYzz1gW47fsDg==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' @@ -2352,8 +2389,8 @@ packages: '@types/node': optional: true - '@inquirer/editor@5.3.3': - resolution: {integrity: sha512-YsKkS2q63IiLtaDK/9nqzdComN97SDQrmKiyNggN+ceP4ty+Z6VwyTz3FpjeUWeW1Efss2xHFKCC9sx7hnrsxg==} + '@inquirer/editor@5.3.4': + resolution: {integrity: sha512-imzxQDfw26NpcJAubMj/dE5RjU9Mp80IcT+FzsD6YoxXmL8L1QYnAFdCNv7ccGWh//73+0YP+1kfhwnExdl2VQ==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' @@ -2361,8 +2398,8 @@ packages: '@types/node': optional: true - '@inquirer/expand@5.1.5': - resolution: {integrity: sha512-uHuXLmXW+TtIfT/9vSBotypAkqn1n34Ul+CLGPos/xANyO4Ff5xZzkYhbKR4NEcfVK4a9mHQOpwVZzluSHFRGw==} + '@inquirer/expand@5.1.6': + resolution: {integrity: sha512-JfiLKi7qGuHmcQO/DqR10UFEp054Y1FXZaszP5ZEfHhgHeuFoT5nNFQIXFhed/5/p07SbsV2tIJFwG/dg5+NmQ==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' @@ -2370,8 +2407,8 @@ packages: '@types/node': optional: true - '@inquirer/external-editor@3.0.5': - resolution: {integrity: sha512-f3QQJRIX5ZEneBHNUIuPjmbdzHnmRFJA8r2dkcb8q+OM5Uv5KtnuAttQumnrjcBVBM3mcTX1CkmtAkU58VRZxg==} + '@inquirer/external-editor@3.0.6': + resolution: {integrity: sha512-rWmw6WG8JZ7LMn/srXEylYGhL6EXXJeROE7/4x4GREh6NgzqJBDbIgpNGsYoLPfobeb4VZtEYzPGnT5EFNYhiQ==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' @@ -2383,8 +2420,8 @@ packages: resolution: {integrity: sha512-EAWgUTGQ/Umgga51dE3B2PUHbufuXarDfg86uVgoSgNHNNQnyFKcOrQLWVqYMghuSyHh8+2HUH0Js9cTC1WAdg==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} - '@inquirer/input@5.1.6': - resolution: {integrity: sha512-HtcJhB2QFVXbLuJ5S3syhNbTUVxYvwqV4VRBDkQceBloC9bmTViUoRFP5PbSaDZb3HzfPmpuU/gG4ybVBz4FHA==} + '@inquirer/input@5.1.7': + resolution: {integrity: sha512-Icrs9tuMBCxGI+lFYNO71VFOnqGO8IIrLIGqnTGh07X8jZ2la9QLAYxXcikHZQjjVhIZUtU2xyH89DVN/pPhVg==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' @@ -2392,8 +2429,8 @@ packages: '@types/node': optional: true - '@inquirer/number@4.2.3': - resolution: {integrity: sha512-6Yuwh1NGSbu1Lo4N1EWjXs1jKRntLg/ZCwhmeorEHde90v1XxAozdbd4Iu30eOQLW+6h1hp2O9ujNfLSbTPJnA==} + '@inquirer/number@4.2.4': + resolution: {integrity: sha512-C6g80U4U2+w2Vxa1CB0MN3a2WnQIfzLSUG0lduO3Au9GsirJsKATZkOwZPK+vZ1BlAlz+/Z5ParfQCS9E0svmg==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' @@ -2401,8 +2438,8 @@ packages: '@types/node': optional: true - '@inquirer/password@5.2.2': - resolution: {integrity: sha512-W9zYdyzogK+6110mqwaSJWCBu2yA5Q/OfnGSjjZB1bNpHlmUozXxTl0+QOZBNeVd6Qo81/qT75gW05gLAtITxw==} + '@inquirer/password@5.2.3': + resolution: {integrity: sha512-1Uo2qfI2K3wXbfunMpvwAzoZPIUf5iEETwDGqRVbswoorXGqYQJ/bfIJFeLj+pxjq+e2UFPNOmx+fsIl4Urigw==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' @@ -2410,8 +2447,8 @@ packages: '@types/node': optional: true - '@inquirer/prompts@8.5.2': - resolution: {integrity: sha512-IYR/3C/paEVVQYQvdDlFZVjRCJVYHHON0XXMH91KO9GSxs0TdKYWlUdvfQl2EfAHDxUaN3IBffkE/BDTh5nJ6g==} + '@inquirer/prompts@8.7.2': + resolution: {integrity: sha512-QoRB4wFIjgH5iOhSjoIKMkTvSHDuV+O3OITlIqAYO0oK5x364GJILXiMBvlPiE+klg7Xx9tq5XVqQHcGUDYYPA==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' @@ -2419,8 +2456,8 @@ packages: '@types/node': optional: true - '@inquirer/rawlist@5.3.5': - resolution: {integrity: sha512-1oHky1ONfCOwNrnkQGDE1oaSij/3fI6HFMSf2H/WsGO2lEyDX9My82iggITSy9ddSZ8yk8j9v41OI0fVoSIoaA==} + '@inquirer/rawlist@5.3.6': + resolution: {integrity: sha512-2exExhQ5xTBqfN/rRlZkvM1Gm3b0eiLcWe2NqxWxe7qYmnU4M3j/n9TwzfPUb+Q7dAAz9TmBtDWWdF3jbp4ZLA==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' @@ -2428,8 +2465,8 @@ packages: '@types/node': optional: true - '@inquirer/search@4.3.3': - resolution: {integrity: sha512-fyuIU1Nbpvwlikjg3gXwJFDI11+EFjqQ7P+iByfmivIKQ1vmaykNrD/vy5unHuUqUpsOsnvJ25//tPF7E/RBRA==} + '@inquirer/search@4.3.4': + resolution: {integrity: sha512-xsk0UeFP04jsbEeFMzghjUaH2aHXyt+WykSajQzq/8qyBNHsgGYHea/sFwv5T08kW7Sp+KDX8aF124zPUIs1Ww==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' @@ -2437,8 +2474,8 @@ packages: '@types/node': optional: true - '@inquirer/select@5.2.5': - resolution: {integrity: sha512-9kc15hr8r/kI+3DO/xLog5nOzTz1jqsHXa6JBFzmQKhkoJ8Slda1I1L/uD8ZSZ9tF1yp79wwXe7mclvX1rqR2Q==} + '@inquirer/select@5.2.6': + resolution: {integrity: sha512-jFu7CFghlr0vpi/326a4wz6BGkJH9X3H/iIu+ahyV2Ja52ktqQE3fC9BYP47JKeSPQYGUv5xquE2Vp9PbepZ3g==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' @@ -2455,6 +2492,15 @@ packages: '@types/node': optional: true + '@inquirer/type@4.2.0': + resolution: {integrity: sha512-2rnHX8RPo4glJYqnFc/v3nQeIhDcjyRRyaT7MGcfL/8xv/8K03SXYoA2tX06WVSKywWOE/ncbG3dtuzLjR5TCQ==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -2475,8 +2521,12 @@ packages: resolution: {integrity: sha512-u5Ncuc+gXVUwjNMFQOnphHo2Qx2DxyC8Dvpmf4HCx4y0kvSkRRNiQYRixrvOP4V7y52JcSuNxqochCt0PpdHVg==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - '@jest/core@30.5.1': - resolution: {integrity: sha512-BL9g6CJUUhIbdoAflz/Va658erSSXUIvU8XUYiWNTbZljJjZ5yaC9EJ9/dQ19rpbYV3ZOK4sBACqhPaTyhoUIA==} + '@jest/console@30.5.2': + resolution: {integrity: sha512-e8sQC4pCMHPBrPX7Hk8TJr+DM5dkUnImSTNag8SEWFAFAF6xIz4AJvCKapnrdHnZxep4bxw4PhG+ZbimYBMyKw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/core@30.5.2': + resolution: {integrity: sha512-/jqvFWoJF7LV1a6AUpYMbMm+2yIYsHJfR474H0SCzr9k8tnO/jl9wRg6zSG6lxgf6EozNlCG4amFzWsbZSvvZA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} peerDependencies: node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 @@ -2492,8 +2542,8 @@ packages: resolution: {integrity: sha512-OsqBjHXCn8cadasoAZBP6nWYvMsRhpMzGXTpxJ5aO04NlbdhIz+FVe3q49l0AwVhsz/cEmIpBes6gAFl1/dWQg==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - '@jest/environment-jsdom-abstract@30.5.1': - resolution: {integrity: sha512-J395vmP3Fb2Te0JmF7pe4si4jpfbXef1YsY4UpHYL6OOxS2molu9Dsie1VmIiUalXdtmz1P5QRgc+5hBD+ssBg==} + '@jest/environment-jsdom-abstract@30.5.2': + resolution: {integrity: sha512-8/P+8hKv7L4mhFoB6gpYLj/fuzXk8PWrde7TDHh8tJz5pE1ktlMbg0y6hDY/V8MfAnispFlPl/vPifowLy3JGQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} peerDependencies: '@types/jsdom': '*' @@ -2507,6 +2557,10 @@ packages: resolution: {integrity: sha512-eYJAkOsrpwDXPcoLNEG6lN9Zo3Cy35pxnVQD74vyIfsi/Q8wB/lZFsEjU4wrecOgT4ZviQDZaX/cFIJyMdMxTw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/environment@30.5.2': + resolution: {integrity: sha512-nwFPOUMbmsjNNRCpnJpB9HbSZu4C07wDDds++5j+j1MezzPLxMPDdbdUVjr2o4RULDOValnDd70taPFI1EtxAg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/expect-utils@30.2.0': resolution: {integrity: sha512-1JnRfhqpD8HGpOmQp180Fo9Zt69zNtC+9lR+kT7NVL05tNXIi+QC8Csz7lfidMoVLPD3FnOtcmp0CEFnxExGEA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -2515,14 +2569,26 @@ packages: resolution: {integrity: sha512-WcRWhHQdTMRDpyWKZ/6MINBmovI7zeD+bL8wFjCncRV3NQOwKy1X45IfyblfHR4k/XciIlNEdFL9QjFO+HNKOg==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/expect-utils@30.5.2': + resolution: {integrity: sha512-d/HOLSrJUlBIl6n3LzOa7cjW3xdePm5H5gQw2lixZ/0h157l9sw3mLblzFqnPcTbhDzhRQdBOX3A3KcG73nm5Q==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/expect@30.5.1': resolution: {integrity: sha512-uOGd40P/COyUp9xHf5jeGiGJC2/ANg+2+Tk9/xN5/LxmlY/r/gxsPrx3DTEtaRZsLAX4wgNSLEDELZ5bmVs2bA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/expect@30.5.2': + resolution: {integrity: sha512-2E+1Pg6jJxbRjLNLpciC71iri/3qsKJL222/aXJEFwC0VdUSvfS+JZQQLEVjUYYc8t8pQu2MGE3cF2+bNTYRsg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/fake-timers@30.5.1': resolution: {integrity: sha512-rEkV6YzpBXo/L9cnj2ibyuYZuyGiNWaPUsyCu3HYJbqCV4jnEiKmf7hId20z8eKjZ0JQ9jtIYKxRSmYB/OYSsA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/fake-timers@30.5.2': + resolution: {integrity: sha512-GKk0h0tKT5SpDPxDhPg3r9mm5s8/YCBzVNMOGdRiQXwUiMsjU10mVPlMaQstH6/cu50vOb8N9oQUMETHuwwxKw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/get-type@30.1.0': resolution: {integrity: sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -2535,6 +2601,10 @@ packages: resolution: {integrity: sha512-VhqvQ251XIC7pk46YymI3HCeBLOdvriDw9zibekodAHEwoVvbVVgpU5H13GvmyOAzxtZCfsm1uvzqkaqJf2I+g==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/globals@30.5.2': + resolution: {integrity: sha512-62zfS+dL+M5aTNHXCNLyqD+j4oqxTIzDYrZbpqMP8Yn+gvlAGyzC6BSPNb1ZmFOakV1RtFYX/O3FCQXSWmQdgg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/pattern@30.0.1': resolution: {integrity: sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -2552,6 +2622,15 @@ packages: node-notifier: optional: true + '@jest/reporters@30.5.2': + resolution: {integrity: sha512-ev6E9iG73twQcfAfoTnviuYkr3yOrYbzAtMFCimv2fxHXPIp9PBt2u1wNMt0aYWgl8FPwIcS8oqRJg6alnht4Q==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + '@jest/schemas@30.0.5': resolution: {integrity: sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -2568,18 +2647,34 @@ packages: resolution: {integrity: sha512-xWpTJP9D0bDFGbPGT8XuWSwwha/iHADyyKzUnMx4UbdgnHugxrDaQFO4RZ8x4ZsFzRP6pNii8uvlgKCDxCIuDg==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/source-map@30.5.2': + resolution: {integrity: sha512-c5CehvkbfHX6yyNg7bMn2ylqlp9AHXuZ/3hPH/Lh5bxD/vefd/lV9HBjMqXef9yQNqkdwyZvkLSVSwB38hy8Qw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/test-result@30.5.1': resolution: {integrity: sha512-A/1S6ZBdpic50E0pxLgvaB9XNPL4k7AksmG69OO2oiotxciWZwHkbOec+qbIi+uUtIIByOVlXJUl97oZUsz+Jw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/test-result@30.5.2': + resolution: {integrity: sha512-eO6YU5rsLfs60ne694e0IAmRgeBnoA8mu0nlxXDbl/1j5km0o2vO9/VbbUIKIH+WRKKEI+ELgwraRGn2Lhep8g==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/test-sequencer@30.5.1': resolution: {integrity: sha512-SHcPnrjdVRYJv6y6l4JUTy4Jccu7zmO6BmiOfOA34UiVBto22s19WiDC0A/2qfM7MWB/qdcoqfSIRMitpjTT4A==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/test-sequencer@30.5.2': + resolution: {integrity: sha512-bmij8jZyYAC47ExT2GeP7PcrlwSNS+Bp3KeRuBH88gpcxqDv6fljN8PemS4J/lLZ79xb6mpaennQTTq0zUAVVg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/transform@30.5.1': resolution: {integrity: sha512-EDnDhn0jleU9ZhpVoA4gvqw+Ev0iw/r5upNT2b79RiwiaTiYAMMhvNJP3lmocjIe5J2ZkoNr0B3K/J6O5GIm4Q==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/transform@30.5.2': + resolution: {integrity: sha512-LBGwmaE886C41jDZ65eqR1YbXM7642MK3ZhTowZd5+xDlrDLuf4ePuDdDmVSvcr8NTVJ3MqxLkMbUCzuigUaoQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/types@30.2.0': resolution: {integrity: sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -2604,9 +2699,6 @@ packages: '@jridgewell/source-map@0.3.11': resolution: {integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==} - '@jridgewell/sourcemap-codec@1.5.5': - resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - '@jridgewell/sourcemap-codec@1.6.0': resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} @@ -2616,12 +2708,12 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} - '@listr2/prompt-adapter-inquirer@4.2.5': - resolution: {integrity: sha512-pYGy9dTdTwXdasPgyohkr0HoQ4FrkAzFnsUZl/gcnadDArbpZ8e+fgr+F9WBdNEl2y00mb9bCM4WgmoBkZJ27A==} + '@listr2/prompt-adapter-inquirer@4.2.7': + resolution: {integrity: sha512-k180vxSv2KqmnDxoato6pCqEJsov4U5C0EVmldZkAdNeVBxY48A/LM6TCNPGIzDK/iZSfsZP0Mw+gbWc5hXLyA==} engines: {node: '>=22.13.0'} peerDependencies: '@inquirer/prompts': '>= 3 < 9' - listr2: 11.0.0 + listr2: 11.1.0 '@lmdb/lmdb-darwin-arm64@3.5.6': resolution: {integrity: sha512-mY5FG4TjPAkY4P0w+OhHaUka5mDh2TX2WKYIwuKzJ1zeW3VvRgxdam/lGJTquI+bthTx5CSHDW+BAQCnNAzkEA==} @@ -2669,15 +2761,13 @@ packages: '@mdx-js/mdx@3.1.1': resolution: {integrity: sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==} - '@modelcontextprotocol/sdk@1.30.0': - resolution: {integrity: sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==} - engines: {node: '>=18'} - peerDependencies: - '@cfworker/json-schema': ^4.1.1 - zod: ^3.25 || ^4.0 - peerDependenciesMeta: - '@cfworker/json-schema': - optional: true + '@modelcontextprotocol/core@2.3.1': + resolution: {integrity: sha512-laVmIhPGpWi7rG9q+0UigblRN0Hws/Yjlb6qLFtVqryXVLkc4h+yN8z731POD2LnAGQlFe7/tXhUm7Bi0YB91g==} + engines: {node: '>=20'} + + '@modelcontextprotocol/server@2.3.1': + resolution: {integrity: sha512-e59MfWuSssj6DoQ96JCTqdW1AXT5/XtnTl0WWd6MU1Ka434fFUGGosCLJbGNySDhHGvxQWoRWK137FXYFkYNWw==} + engines: {node: '>=20'} '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': resolution: {integrity: sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==} @@ -3216,42 +3306,84 @@ packages: cpu: [arm] os: [android] + '@oxc-parser/binding-android-arm-eabi@0.150.0': + resolution: {integrity: sha512-oQef2Zu4Prz1KLKznz3HqZzU9uVoA5PMoDZuuLmqms7hKmKSAPzlaMnLllJq3t+rgKmfJJ2siPrpZfFrW06btw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + '@oxc-parser/binding-android-arm64@0.142.0': resolution: {integrity: sha512-WZkvGRLNQTz8lR9zP5nLjUdlroRCopBu3g9zF1p/laE6DzT1UbQo8Rdz5MWhaJUPYg/6gp+jo7HUgsyKaN1FtQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] + '@oxc-parser/binding-android-arm64@0.150.0': + resolution: {integrity: sha512-B6ofpoFiAUwIZ0MJ2IgHPvZK8FAtL4qzSZRwOkAKIfxEobE1mQC8nDdiFZj7pUaJiVUVCsfkMsBJKedzO8rZvA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + '@oxc-parser/binding-darwin-arm64@0.142.0': resolution: {integrity: sha512-l4khS8LQOOVYsGRVARo1gSaCT/aBSceUVXgtovWc2+drnxVuDr082WA3OCHVdVzIz5JIrP/y9CWsSKxBDNmYGg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] + '@oxc-parser/binding-darwin-arm64@0.150.0': + resolution: {integrity: sha512-J+9IHKzx/bSz1JetOfD4zKXSK9sOm4/a7+0qomJODcTL6JsRXGeV/ZdkAPSIDydfFmWzYCraMlQEosSZEyBYkQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + '@oxc-parser/binding-darwin-x64@0.142.0': resolution: {integrity: sha512-QBsNF3nqlXmcH2B1YOPqQYmCJoy4HuIjUxGbBO/k5JAJUl68ghU2psRY2zPk+RyBaWqKP/qfL4oaFgEMCdwskA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] + '@oxc-parser/binding-darwin-x64@0.150.0': + resolution: {integrity: sha512-v6IPfcAcSYrBWXV5Tce1DmxDMXLhEYpIIWiRFPJopgCVscZdLaV4MRQOUxvL3usQlw+yrwYOjBFB9IwBx+jUiQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + '@oxc-parser/binding-freebsd-x64@0.142.0': resolution: {integrity: sha512-b7Q7m4Cqc6XqNhri3R+QhU+GVy646Pn+bkdhrDdWym/Fdi0ZUa+d73H9dm5H91JtbtAQ/z1d8XKMW3oOV8a4tQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] + '@oxc-parser/binding-freebsd-x64@0.150.0': + resolution: {integrity: sha512-AoR/4jD02HET0KO0yNT4nbTE+XJUxiO9jB1X/ycEjUE3WrIJ3IjV/Vf+gskhWLcqqeXfvNnkDtBR7epllAm88A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + '@oxc-parser/binding-linux-arm-gnueabihf@0.142.0': resolution: {integrity: sha512-3riVS5IhdH3uCZj1Y9ftDQlR0dvLsIlw/edrRqk8JhgNd5K0XSs+UBtgh50N13CAlW9/TXj6sVGXaKNBocd0Yg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] + '@oxc-parser/binding-linux-arm-gnueabihf@0.150.0': + resolution: {integrity: sha512-A/hycCFjLUrCmLoL5O/vBp40ohlaXO1Ta3v5hqYZxicYFs129wZmgZJggcW4mYGYbZebQLhup+N8JjeQl8qwyw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + '@oxc-parser/binding-linux-arm-musleabihf@0.142.0': resolution: {integrity: sha512-NmXUOpgpTSkhl795TiXmWppTwmSJ92RC1qvD6e4XOF+slgmo3e6Ah+kEu+6AN8s7NAOEwqGmir58MgSQSWmBSA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] + '@oxc-parser/binding-linux-arm-musleabihf@0.150.0': + resolution: {integrity: sha512-pbqahg1Pkz7J4RKmXm30s/iQu99hv64ayXCu8P4p75HcEH5azOdR4eGqiB6lFGkFR3mMpzaYsSKLkS8oJbjmeQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + '@oxc-parser/binding-linux-arm64-gnu@0.142.0': resolution: {integrity: sha512-gc0EXsKtXgerujmU2Bql3u1L1HsSQ2774R83idq/FoNMPVV/RY/1ErFsvnit7KoiP/sLvzQixeUo4Ut0ic0wmw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3259,6 +3391,13 @@ packages: os: [linux] libc: [glibc] + '@oxc-parser/binding-linux-arm64-gnu@0.150.0': + resolution: {integrity: sha512-HV11aRbBQGwqv8bEo+K/6qr88uB4fEe1mhXncMhlVCrj+WpBSraxrUzHaPVmeQRU6x6x8v/3DuCbbo93rpp+fw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@oxc-parser/binding-linux-arm64-musl@0.142.0': resolution: {integrity: sha512-F2XvmWSE0uWpie+jHKKIFgdVOe9ypGhkEZxKx5DuW215K6cbAC274yYaPkcM7EqY4Df3Weyhpcz3lsURyH2LVg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3266,6 +3405,13 @@ packages: os: [linux] libc: [musl] + '@oxc-parser/binding-linux-arm64-musl@0.150.0': + resolution: {integrity: sha512-k6pVkJqALwtEuP4zukVhGRhdIy4+ofChUIUUsAHHyqDZlNsknmel3JjbggrJiWGaes6h/dIcWmEXsKW4SmK9oQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + '@oxc-parser/binding-linux-ppc64-gnu@0.142.0': resolution: {integrity: sha512-wLMbT21U/QxknQsk+VvNF0b9D2/aGWhcaQQQ+VYlE8FwD5+GoWZIPPXNzyHmkYyhm0KB3itL+TBavjMatqNnYA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3273,6 +3419,13 @@ packages: os: [linux] libc: [glibc] + '@oxc-parser/binding-linux-ppc64-gnu@0.150.0': + resolution: {integrity: sha512-tEFg39mw/rHO5n8GK2DR4ZMFfsPQZtnQIPbsIpjoQRomJc/2tRfsCSmCT6gNit+NZGoeJG5aH9ATDH5bf0WnoQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + '@oxc-parser/binding-linux-riscv64-gnu@0.142.0': resolution: {integrity: sha512-+G8F/4ckwT7FCJV4H2bt09xEzJbjNCfuL4Sp1AYNaFtFMVtgIGMuJlteT82U+K0UIZ/DzAR/LDlMFnEuajG7Kw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3280,6 +3433,13 @@ packages: os: [linux] libc: [glibc] + '@oxc-parser/binding-linux-riscv64-gnu@0.150.0': + resolution: {integrity: sha512-0JO7IFkoek6HqV589Menx3hJySNXi6quRhO4tq2P7kpEHKEXoDATnoPVUpn5B7gDH2KLkdo751N0uIrGJFCTCw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + '@oxc-parser/binding-linux-riscv64-musl@0.142.0': resolution: {integrity: sha512-hTsHtTLxMAfCo+rpF5K3qZJKW2NpPN/CHd4mYB3y7XlSdspHkd2gehDIofP64AacA9nWQw2tY3O7wR6UY8IVOA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3287,6 +3447,13 @@ packages: os: [linux] libc: [musl] + '@oxc-parser/binding-linux-riscv64-musl@0.150.0': + resolution: {integrity: sha512-7XPzREnyAS5wHU9aB+49Uaqgoo1gVCklHc3DRlc3fJy2tXD/eAJFN1QFz/crj+Ulup5P1+axNREF+/RGaB/IRA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + '@oxc-parser/binding-linux-s390x-gnu@0.142.0': resolution: {integrity: sha512-6y7qYY3TCUDYjqswImdTGl92y+KA/80twALegQPN27kfY+bG7Ib1+L3jbmrCZQx6wrVnai9IPsEZp07I0hx7JQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3294,6 +3461,13 @@ packages: os: [linux] libc: [glibc] + '@oxc-parser/binding-linux-s390x-gnu@0.150.0': + resolution: {integrity: sha512-7n7ZxcbRWDFaTdyj8M8p0O9OfzoMKm8O6syBAIgcSutbOALq0Bb9G52Me+XH0anMLZV+NgXc726gqgG2q39vcQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + '@oxc-parser/binding-linux-x64-gnu@0.142.0': resolution: {integrity: sha512-i69kAWU+2LgoH5bR+zWiiu+UzAw7Oxkwv7COeJTeY19pn4e70nKQcr9Pm6cL2Z0Z54d+gl9qADlK/0yyuCPiBA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3301,6 +3475,13 @@ packages: os: [linux] libc: [glibc] + '@oxc-parser/binding-linux-x64-gnu@0.150.0': + resolution: {integrity: sha512-Vx0GSA9ZCRTUiczoEQnIIyXkMvtEY8uc6IiwLQtMe5ci2sXPqjrry0Ek5fsPP1k2kCzkML3ow9UOOEgnEDi7Bw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + '@oxc-parser/binding-linux-x64-musl@0.142.0': resolution: {integrity: sha512-4SQs678MmjYVrmhAgCWD4o0vpaFszXw9xLX5p2Z9MMFcltxiLkA88wQjh80YHjPrXtpyZ2CWI5m+1yNKM0m2Pw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3308,12 +3489,25 @@ packages: os: [linux] libc: [musl] + '@oxc-parser/binding-linux-x64-musl@0.150.0': + resolution: {integrity: sha512-ii4/9m3viDLssMnfXU7/Pni/3nYplApuGayceX4qsVGaqqQJCx3tHIH9M/HWIeSty5i8LjS21zPYT6lVIkwLxw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + '@oxc-parser/binding-openharmony-arm64@0.142.0': resolution: {integrity: sha512-YHpx9N7Ln3a++Tc8rv+H7mrK1zyJQOAwCFg8LZ3lTs1T5afGWeZrLPhPT9HLnIwSjCyJqPWVMIrMxbjcmBr2oQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] + '@oxc-parser/binding-openharmony-arm64@0.150.0': + resolution: {integrity: sha512-ZtoxX5rez36ZWkwtiEhjkCPnlTPoQ2I7lIL0IYZ1yjxMYohbvoOjBmUIZzrf9W/HouONq0omIfTeCWEY+R380w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + '@oxc-parser/binding-wasm32-wasi@0.142.0': resolution: {integrity: sha512-3pLDyY3+oogW73RM5uehNgAiR/Xfb7fvO2Q1Z1gIqZ2+50XDVQmBVlRkHXZTU4gKnQHpwETNsYQVsJ3joVB2iA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3325,18 +3519,36 @@ packages: cpu: [arm64] os: [win32] + '@oxc-parser/binding-win32-arm64-msvc@0.150.0': + resolution: {integrity: sha512-VqeRb5JX/bKYBrff7TUDvJyKY0914UzuCkuXIGxJS4FUmvMNfqkCbc7Sq90iMz9DlmAtlggwaBYQ9eg3h3ltiw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + '@oxc-parser/binding-win32-ia32-msvc@0.142.0': resolution: {integrity: sha512-GGi3+YphVHavvgs6gum2UXoNCqzHAmPt/nXkn8ZQZstV2Q1qZD1Mn8fz/nWrDkefHQtrG/+1/XrbMxsBTo6Svw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] + '@oxc-parser/binding-win32-ia32-msvc@0.150.0': + resolution: {integrity: sha512-EPqJfeZ4Pgg2BJSsCV8GozxV0FDltRzpVtGVa1r2noJu1iu+opOw3gtBWXwIo9odCT/MdFfyHxdy0/CRqs3OqA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + '@oxc-parser/binding-win32-x64-msvc@0.142.0': resolution: {integrity: sha512-Ny/Wv4Us1LGC/ljwNTp+Hx3r/pH15EFfeDF0p+n898gt+TtRd6C9SccHcuUhDiNTb8s5tt7jdeAMDRQZ4Vq6hg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] + '@oxc-parser/binding-win32-x64-msvc@0.150.0': + resolution: {integrity: sha512-n5YMzbqwPQqozbkrexi0lNZrUz5cnPHalYpFWe6Dau7AmKIWNo+y24IwzDuZEQtEdUHuKhXmMMih/MPjOI+CMw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + '@oxc-project/types@0.139.0': resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==} @@ -3349,6 +3561,9 @@ packages: '@oxc-project/types@0.146.0': resolution: {integrity: sha512-XC0QsnnhVe7sLIWmYmdPw7x5P0h4W8vUU3Nv1ySgWXtvCz8NizoAEpGXA0sOYoJQV2Rl13LgURAHQ5cI5ILCSA==} + '@oxc-project/types@0.150.0': + resolution: {integrity: sha512-rDS5/31E9HfPl/CIzGrn0DOlvBbXFseQ5URJ9sYMfstbKLD/c6Gm9vmRzRGDdAXyOIL4zmO37lc9RIwYqVruZw==} + '@oxc-resolver/binding-android-arm-eabi@11.17.1': resolution: {integrity: sha512-+VuZyMYYaap5uDAU1xDU3Kul0FekLqpBS8kI5JozlWfYQKnc/HsZg2gHPkQrj0SC9lt74WMNCfOzZZJlYXSdEQ==} cpu: [arm] @@ -3566,8 +3781,8 @@ packages: resolution: {integrity: sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==} engines: {node: ^14.18.0 || >=16.0.0} - '@playwright/test@1.62.1': - resolution: {integrity: sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==} + '@playwright/test@1.64.0': + resolution: {integrity: sha512-oTN9FNYuN6bqQMwiho6jJKLCG2/QOYCixGXuCjRmzOq23eODeukuY2XiAVoVpAECbuezMt1MHQjAiQxK8F+Y+g==} engines: {node: '>=20'} hasBin: true @@ -4001,8 +4216,8 @@ packages: '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} - '@schematics/angular@22.1.8': - resolution: {integrity: sha512-V37T9uHOQVHyxxOqwcJ9xjSIW/mW9UuSfjOc7WJE4V8+3zj0abDJLHuoxDZKe0icYGapgOcvHyYjtNOjSeSivw==} + '@schematics/angular@22.2.2': + resolution: {integrity: sha512-JxpXKpxdmQcRsJbriWJOhB7HeVTzz9CEsPNw6fQ0LOuPFnvtyUIXLxyFbKv/LfoFUz8wFADCtNU5sjowME5Mvg==} engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} '@shikijs/core@4.4.3': @@ -4075,86 +4290,86 @@ packages: '@swc-node/sourcemap-support@0.6.1': resolution: {integrity: sha512-ovltDVH5QpdHXZkW138vG4+dgcNsxfwxHVoV6BtmTbz2KKl1A8ZSlbdtxzzfNjCjbpayda8Us9eMtcHobm38dA==} - '@swc/core-darwin-arm64@1.16.1': - resolution: {integrity: sha512-zlJblJ8ncErD43lKdxjbUaUskJQf+LxiPXYcWXD8/8ZMV+7uuAT+CwjciLXpyZBd5Pq/S726bMpeeAwSeL1hhg==} + '@swc/core-darwin-arm64@1.16.13': + resolution: {integrity: sha512-JrMH2h7ohd4mOIneNRx/diqTmCDSdWfp9uOdQ8VJ2V2CA7jOJ1jWb+qp8RoZLqgiVmwDDC7eySKB1kbvZ+jVXQ==} engines: {node: '>=10'} cpu: [arm64] os: [darwin] - '@swc/core-darwin-x64@1.16.1': - resolution: {integrity: sha512-IN0BmPWb0YAh/17mmlWB/HDBtTw2MfuW4hulf/tQAgTQBRH17l+z499bNJLK6LizSjqs0P7V+jU38Zj+vJC1DA==} + '@swc/core-darwin-x64@1.16.13': + resolution: {integrity: sha512-HauZMRxpOImRxgY6GXjw5gbD8ts2NcPZuLMM8htY8Mic7j1Izt/lxkXrKYUwqyU56YLwQZ4dHRT+6jFHkztcag==} engines: {node: '>=10'} cpu: [x64] os: [darwin] - '@swc/core-linux-arm-gnueabihf@1.16.1': - resolution: {integrity: sha512-EYgrx2YOCQ2Twz2S793kqNjPkpvYVUPzzR95bIb7by+VQcyaai4lZZ2iz/tZvcFVKSNcN3/JTKwx+aBn2ZL52A==} + '@swc/core-linux-arm-gnueabihf@1.16.13': + resolution: {integrity: sha512-tUSojGIythnHJKrElDM9OvuhBnK+FF39eM9iIBgj9kdfbAm6PhAM7dKbaSDcnX2gOLG2slkasjD0Z8m/YUlzOA==} engines: {node: '>=10'} cpu: [arm] os: [linux] - '@swc/core-linux-arm64-gnu@1.16.1': - resolution: {integrity: sha512-moyKm0YZlHdHohzm1YwgAyesqnE853rO0REMfJLFAova51wF9BNi+3ZW2PeS7Vqvn6HeJuepLpAHbBdZctxpHA==} + '@swc/core-linux-arm64-gnu@1.16.13': + resolution: {integrity: sha512-qS3bk/ezRrLXGn1b764S/EG3/oKj6uRhnTNRAALpnuu8d9I0YwSiUkQghnX7lOH9X/yRr+qrFI3R0wAruO47Tg==} engines: {node: '>=10'} cpu: [arm64] os: [linux] libc: [glibc] - '@swc/core-linux-arm64-musl@1.16.1': - resolution: {integrity: sha512-kKGBO9wdapiSzuf5ZzZ2fYtlu1BNSYtIIUxvH1ir/gcelTOREEHGDCLTDFx/2Knf878nU11A40z7LxwasEFxqA==} + '@swc/core-linux-arm64-musl@1.16.13': + resolution: {integrity: sha512-aeoYoG6fQsOS3mFOuTKvnJo1aaaxYNI0aRRlhdHaGP4q1YQx4XOc5Hkd35IFduWCBG3eZl3fPTjRuYvxpl7eTA==} engines: {node: '>=10'} cpu: [arm64] os: [linux] libc: [musl] - '@swc/core-linux-ppc64-gnu@1.16.1': - resolution: {integrity: sha512-nZ6qahtLxC3PM54cWOQZHxt4lTCF/3J4LIoWWzz6v7A+rLs8Dx54anYQf7mH3eIi8KlNpgKci/ie8ZSqFN8O7A==} + '@swc/core-linux-ppc64-gnu@1.16.13': + resolution: {integrity: sha512-eEFoueZXWZ2S1lsqL8Ve2C8RjhzH9+Ktj8U4IGTUQL6CNG04+VhQiYQWJOf5xfVBU6I+PbH80yvsYegqZOOSZQ==} engines: {node: '>=10'} cpu: [ppc64] os: [linux] libc: [glibc] - '@swc/core-linux-s390x-gnu@1.16.1': - resolution: {integrity: sha512-4ji5PNzhYq193Z4/4xUaSoNJza6iCkDJSzhetrbB6KOYxsr+kxtQr8ePWhMJUiMt6JUWtXaZ1PYT8FhtED+nGA==} + '@swc/core-linux-s390x-gnu@1.16.13': + resolution: {integrity: sha512-K0q1ReVXL3d5bzBgZxS+yJq5q2xRjg4k2XXGj9yiCEpE5ZqJGQ4Gtk1sRrk8W2lo3KY0DCJD3Myt6x2SMvGArQ==} engines: {node: '>=10'} cpu: [s390x] os: [linux] libc: [glibc] - '@swc/core-linux-x64-gnu@1.16.1': - resolution: {integrity: sha512-VJQxqrisHV+B394IgrOu8YsIIXZgffnf5tO+yc9Z/hoUpuZEvuQTjWwlnpZdpyD+0nx6LTD1/3k646JYm43yJA==} + '@swc/core-linux-x64-gnu@1.16.13': + resolution: {integrity: sha512-Qvpihw30irhIjy2f91BR1pamcJBCgf8hmr9XWenztAX1YLkneRkkTB/PFGWQubhaBqQhY2uZKViMj/xV61uMBQ==} engines: {node: '>=10'} cpu: [x64] os: [linux] libc: [glibc] - '@swc/core-linux-x64-musl@1.16.1': - resolution: {integrity: sha512-r9oV1mwxxsIGcLV1IQ/tw76MW3doatKze1QFWuC+a7QqJUkhY/bKTSVk6NpKKUGm2LDsE33Va8VqSClfA7vSiQ==} + '@swc/core-linux-x64-musl@1.16.13': + resolution: {integrity: sha512-iCGxHbh3Cq5vqRvCPlw3XkkDz4RlkPSyQwfFKbOKTMj5tiEuojY/rLmf4BMfI9Z8yPJTf9VB07t3NbKInoC97g==} engines: {node: '>=10'} cpu: [x64] os: [linux] libc: [musl] - '@swc/core-win32-arm64-msvc@1.16.1': - resolution: {integrity: sha512-6huNRessoBLxWEqBm5zJXyCQ27TO7anvkdiuQ5MDO4CJni0nOXEqKtV9RllQ2TdyENKKsUMXVnIfW2hIXx/R5Q==} + '@swc/core-win32-arm64-msvc@1.16.13': + resolution: {integrity: sha512-iOabn6cd+hg3cnoKlAC8j3Yufv8nQHBVU5OIDX8dCPA95PjAZ0B0TlDn9sWivk8ezo/cLJux7p9n0B9vRej+1Q==} engines: {node: '>=10'} cpu: [arm64] os: [win32] - '@swc/core-win32-ia32-msvc@1.16.1': - resolution: {integrity: sha512-OVKJFUzphrGmsh+BGtcZDesx0YryV7/Yvy5XGgTqnrZfjnyfcr5uaqYQugCckdIlupc5Vs3XtDjRAj12z4ZPlw==} + '@swc/core-win32-ia32-msvc@1.16.13': + resolution: {integrity: sha512-+1b/3xBNuVbpnJIZUCAaSdscOPYRWq85eohKx44y/3qbPp56UMiCJWBiWtyr7K5F1nf1JENXm2IBSEJaLgWq6Q==} engines: {node: '>=10'} cpu: [ia32] os: [win32] - '@swc/core-win32-x64-msvc@1.16.1': - resolution: {integrity: sha512-Bt+VIhWYCGk4urklnkkteLUOeLv1VxigwTCeB/xC6rBZxY6IIKdDwCJf6on3E3SUGsIqmQS6QqtuJQc1VxF4Aw==} + '@swc/core-win32-x64-msvc@1.16.13': + resolution: {integrity: sha512-6kEJhYshk7OVoVIWryiHqjCNuwZeOlPTS6et+bKV/WhtHs8fLA01wyTLrQGi+4ultgRe/o0M4b25K/KKdvl0dQ==} engines: {node: '>=10'} cpu: [x64] os: [win32] - '@swc/core@1.16.1': - resolution: {integrity: sha512-nUaeu91O5QZKrQdaDCHd402ogUIoNOOjpkZNq0UomWK0G6gDaGmLhvddF1/3BXf5O8aLyo6ZPY/aMDWvaJQ/hg==} + '@swc/core@1.16.13': + resolution: {integrity: sha512-k4uUsIMS0IwOKVsrQs48IZK75O4roV3FnuJEdL7ES+DFESftdgvz73QBqN3VUAbUmUr78We4tzG0kgYj36M22Q==} engines: {node: '>=10'} peerDependencies: '@swc/helpers': '>=0.5.17' @@ -4321,6 +4536,9 @@ packages: '@types/node@24.13.3': resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} + '@types/node@24.19.1': + resolution: {integrity: sha512-aS3/DG0oM05K0RIXXP+hKjinGG5IgSSVGzswZxW3O0sS3pH4/fycXundUC9XsszgKCk4gHXylTEK6hyFxVxnoQ==} + '@types/parse-json@4.0.2': resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} @@ -4366,16 +4584,16 @@ packages: '@types/yargs@17.0.35': resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} - '@typescript-eslint/eslint-plugin@8.69.0': - resolution: {integrity: sha512-t5jQTKPIgVW1PE6dR6H6Qz5gm8zjMlX5/2gRaOGd9eO6V7J+tQc6iWKukEe7dY8u9HyYasQ0yfF0/FSSTEO2gA==} + '@typescript-eslint/eslint-plugin@8.71.1': + resolution: {integrity: sha512-nNlBf6HuotqMgZvSkWD5hNdemiBIxQ2NmrlgfcUIeZRs+BbakjscCQRq/Hb74EFVnFiSWWdL12JruqwBy3ERDw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.69.0 + '@typescript-eslint/parser': ^8.71.1 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/parser@8.69.0': - resolution: {integrity: sha512-l4b0DhWioGg6Gt2ebGlvfkFMOjRsauxtsnDRwUSRX1qHq3HdTfQHV8wW9zEXeciai6HfeaKOedQn2Zoofx3WBw==} + '@typescript-eslint/parser@8.71.1': + resolution: {integrity: sha512-oM/5sAqz/l1v/zYK1qTOPrn2+JIrBGC1V6uNYucRO5gh6eT9vWJ/RhPbGE59byvZEUQ4VP6XkLBaS5Jti5jpvw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -4387,16 +4605,32 @@ packages: peerDependencies: typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/project-service@8.71.1': + resolution: {integrity: sha512-Oijc9RUsohHjncg6iGi7gCtjnEjYt9FWv9u6ygK8uKcHK5eLjLAkqERQ9BaytbpceOFdk6kifPCS/zAZE+A99g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/scope-manager@8.69.0': resolution: {integrity: sha512-ewfspqWvSxKSOaplqAUNbaSFO0eB6w1EtQ+esfYFRm3614Ty4uNtExkcbgd6nWsXphbqKyf9ZYdbZdv2xEoWEQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/scope-manager@8.71.1': + resolution: {integrity: sha512-1YUDZXdTnXLlob03SFHBipdyMBsDVm7zoSz0ytTJ39dk5J1TgsTK03VlR/dLXG/RLLUrPcYbD4rp23BmHxHP0w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/tsconfig-utils@8.69.0': resolution: {integrity: sha512-xNqK7YTDZsLniQMV/4rpFR8Z5JlqeRvVjuG1YgF/mdPVH84HSD19L8CczMA0qg2RfwEV231GHH3VnToJDo4MfQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/tsconfig-utils@8.71.1': + resolution: {integrity: sha512-oTvkml5SxXhgg+WWKhod5qWQICDXtOB+/Hi9VdPKFAANJ2b7YOgMffgvzXuqog7R6A/JtrzRf128dsxummyWhg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/type-utils@8.69.0': resolution: {integrity: sha512-ZfoJAVg3JZndQEpEl9petVlxau3lRuElc4HRMuAlLCf8to04/iHz692RUSNmXKDjEuJmIL+KZ2/BsOcBc16dsA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -4404,16 +4638,33 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/type-utils@8.71.1': + resolution: {integrity: sha512-kH3t3jZCYv2PZSBEWPKTariLrCaoF1CLoPitYFBJOhAGEwWnSRAtGEifrfrpBa4K9JlwWNZotgA5w3/wLSk/FQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/types@8.69.0': resolution: {integrity: sha512-K3VrubUPhlo9VDBS6QdI8YB5j7ClpqLRdefcz6PFrhnwicehBweqQ9Evhl4l+FYz0HdDmMqIiSX0aldGRYtDCA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/types@8.71.1': + resolution: {integrity: sha512-ybYnPTUwg3VQQOFEiBPLs8Jxl1ry2vXdn1kVKguomBbvxpetdu3MKTNDqFNUrlG8ylP9MpZosmXMFYlGQHk1oQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/typescript-estree@8.69.0': resolution: {integrity: sha512-AdFkgqck3Vudb/kWnxlyafU/4aBhHrbQ9locP2N4psXTy5mOBg0SHJumnLvx7r6g1gV4DKvUFwV2nJZBoqOD8w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/typescript-estree@8.71.1': + resolution: {integrity: sha512-pSKSEK1JJpHs6KiKVtABCE4iJK5d7FLFzDXQNBQ2oRY7LEd3UwOt3TZemTgu9syTIf56dJWlTn/pdmrO9oKUXQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/utils@8.69.0': resolution: {integrity: sha512-tUbx60BBqQa31kXF5MCsOOLL5E/WzUuxIn7YpAvq+eaUlqvk8/NXnXMBNAdLCr0icjkzem7iUA5QqWHe/hJ1aw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -4421,10 +4672,21 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/utils@8.71.1': + resolution: {integrity: sha512-CBjT6gfAz3DW2j9Q4moFnqt208Aq+506sVGXfpRcyAeqn0EpayL0mjdWoCO2t3RC7GewNrFxdeehuEaPg6DMzQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/visitor-keys@8.69.0': resolution: {integrity: sha512-+rmdgPA+EXkNgKYvHvFfhrs35utXbwaC5PGpDquSXcoXQDKUA5UjV0LmTucG/4JXkM31BTu4TilHtrN8IVBe8w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/visitor-keys@8.71.1': + resolution: {integrity: sha512-0GxiUGqMU0qXJt58cBsiG3DP8lUmBaFo2vChtUX+CT7PnR3v/VGLLrzVP4K9r1lNiZXjHYefMBbPunplr09v6w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@ungap/structured-clone@1.3.3': resolution: {integrity: sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==} @@ -4885,8 +5147,8 @@ packages: resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==} engines: {node: '>= 4.0.0'} - autoprefixer@10.5.4: - resolution: {integrity: sha512-MaU0U/za7N3r6brxD4YB/l4NSrFzLPlANv6wEuQVaIPlD3L4W9rFcQPbL/EilY9BHhHvhfcz3gInDLrEtWT4EA==} + autoprefixer@10.6.1: + resolution: {integrity: sha512-cL1Qz6ADZhcEbny/8HPfe99J6HhNoYtpX2LFLIbhgGE7Q1hlQVkYFdetDN7Id3KiQxhDrHwzlHr/YQCnZ8+xSA==} engines: {node: ^10 || ^12 || >=14} hasBin: true peerDependencies: @@ -4899,8 +5161,8 @@ packages: aws4@1.13.2: resolution: {integrity: sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==} - axe-core@4.13.0: - resolution: {integrity: sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==} + axe-core@4.14.0: + resolution: {integrity: sha512-9WTZxEjsZ7b13TH8JPmbV2z8CHbl80/2hm3XPEG4JgNdQLK81IBRXmSxHfMAOkSqQeRxT/0dwNDz2GOm3zzpcQ==} engines: {node: '>=4'} axios@1.18.1: @@ -4919,6 +5181,12 @@ packages: peerDependencies: '@babel/core': ^7.11.0 || ^8.0.0-0 + babel-jest@30.5.2: + resolution: {integrity: sha512-ES8WeJ2fNWPEDunyAtUfJ12nHyaWUloCHdkfK2oW5uSoQSUmjNKdWiGaXJITJwvFEWoD/evHEg/QoCXAvLaQqQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + peerDependencies: + '@babel/core': ^7.11.0 || ^8.0.0-0 + babel-plugin-const-enum@1.2.0: resolution: {integrity: sha512-o1m/6iyyFnp9MRsK1dHF3bneqyf3AlM2q3A/YbgQr2pCat6B6XJVDv2TXqzfY2RYUi4mak6WAksSBPlyYGx9dg==} peerDependencies: @@ -4998,6 +5266,11 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + baseline-browser-mapping@2.11.28: + resolution: {integrity: sha512-br0LOmjJqr6Rt4AEkadbcNf67Zpo7EPwKsWbjnRrJIa1QYFHQBqM4C7F2WTbV8ahTEcg1Eo+Y0aWjjORInYFyA==} + engines: {node: '>=6.0.0'} + hasBin: true + basic-auth@2.0.1: resolution: {integrity: sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==} engines: {node: '>= 0.8'} @@ -5020,8 +5293,8 @@ packages: bluebird@3.7.2: resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} - body-parser@2.2.1: - resolution: {integrity: sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==} + body-parser@2.3.0: + resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} engines: {node: '>=18'} boolbase@1.0.0: @@ -5034,8 +5307,12 @@ packages: brace-expansion@1.1.18: resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==} - brace-expansion@2.1.4: - resolution: {integrity: sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==} + brace-expansion@2.1.7: + resolution: {integrity: sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==} + + brace-expansion@5.0.12: + resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==} + engines: {node: 20 || >=22} brace-expansion@5.0.9: resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} @@ -5050,12 +5327,17 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + browserslist@4.29.3: + resolution: {integrity: sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + bs-logger@0.2.6: resolution: {integrity: sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==} engines: {node: '>= 6'} - bser@2.1.1: - resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} + bser@2.1.2: + resolution: {integrity: sha512-V2o8YS2EhrKMO9AumnkK0ynynfFlsajpYupwq7S46H5mhcanwxDkNTw3K67wPIcKTH1ffkA4vhMOtAc7gRmPZw==} buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -5119,8 +5401,8 @@ packages: resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} engines: {node: '>=10'} - caniuse-lite@1.0.30001809: - resolution: {integrity: sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==} + caniuse-lite@1.0.30001815: + resolution: {integrity: sha512-uDybUEyfgbyJJ93J1vikTylBX31AkdVYeVPeffMQpf8XN7hSfr0oZOn4VFOIMNvtU5+rHvC5Udg2TgtJKHm23Q==} ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -5183,8 +5465,8 @@ packages: resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} engines: {node: '>=8'} - cjs-module-lexer@2.2.1: - resolution: {integrity: sha512-Ca8swihM+/4yKecYHY52kgJd300hi2lADU/a1RxNTRe+RJ9jvqQlESpbz9DnG9mowez8qwXHB8qYdIUw9e+F5Q==} + cjs-module-lexer@2.3.0: + resolution: {integrity: sha512-lmNyBzi6iYiqyrExTctfmjd8tInx+dHu9PBomTfH5RXS53c27JMFJgzNv5TyAnm3PgGf+MLy0+Ws41/HlVxdKQ==} cli-cursor@3.1.0: resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} @@ -5303,13 +5585,13 @@ packages: confusing-browser-globals@1.0.11: resolution: {integrity: sha512-JsPKdmh8ZkmnHxDk55FZ1TqVLvEQTvoByJZRN9jzI0UjxK/QgAmsphz7PGtqgPieQZ/CQcHWXCR7ATDNhGe+YA==} - content-disposition@1.0.1: - resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==} + content-disposition@2.0.1: + resolution: {integrity: sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==} engines: {node: '>=18'} - content-type@1.0.5: - resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} - engines: {node: '>= 0.6'} + content-type@2.1.0: + resolution: {integrity: sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==} + engines: {node: '>=18'} convert-source-map@1.9.0: resolution: {integrity: sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==} @@ -5616,12 +5898,12 @@ packages: resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} engines: {node: '>=12'} - drizzle-kit@0.31.10: - resolution: {integrity: sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==} + drizzle-kit@0.31.11: + resolution: {integrity: sha512-YCYqxTLIB2OCqf6w9/Vef13baBVbwQIPcbT4Y56AfO6B9ajZhDhD8Jkveg/hJvT/iuMloKD/IYYkyMMNhr7Kqg==} hasBin: true - drizzle-orm@0.45.2: - resolution: {integrity: sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==} + drizzle-orm@0.45.4: + resolution: {integrity: sha512-NiM1YZpJ2UxyeE67DpDDZ+Tsw4YLX3trSlkNT7Tm9eKoUNb1LvaAaQzXgYQsWs+AQ9TUmmwMmBOCXuqqxUWKKQ==} peerDependencies: '@aws-sdk/client-rds-data': '>=3' '@cloudflare/workers-types': '>=4' @@ -5629,6 +5911,7 @@ packages: '@libsql/client': '>=0.10.0' '@libsql/client-wasm': '>=0.10.0' '@neondatabase/serverless': '>=0.10.0' + '@netlify/db': '>=0.4.0' '@op-engineering/op-sqlite': '>=2' '@opentelemetry/api': ^1.4.1 '@planetscale/database': '>=1.13' @@ -5665,6 +5948,8 @@ packages: optional: true '@neondatabase/serverless': optional: true + '@netlify/db': + optional: true '@op-engineering/op-sqlite': optional: true '@opentelemetry/api': @@ -5761,6 +6046,9 @@ packages: electron-to-chromium@1.5.403: resolution: {integrity: sha512-MQsYmdaLzvaCX5j+ZZBr5Fm6uCCnPQcRtlvmvRlWqrXy+BH2O4ffXIAScF+JQznQWB9brWp4lSD9Z4yNmaf2BA==} + electron-to-chromium@1.5.452: + resolution: {integrity: sha512-cVvk1YBSj6571VmV6hdJTqj94Y79xSu8cj6Y9t6ae4Y4431z8/7EJFvu3rVp90xhfuv9BpPKUmaLfTo4+FOGBg==} + electron-updater@6.8.9: resolution: {integrity: sha512-ZhVxM9iGONUpZGI1FxdMRgJjUFXi7AYGVa5PwKlO1tV1/4zDxQmfKpXOHVztKrd6L9rLcFjERvi1Mf2vxyTkig==} @@ -5786,8 +6074,8 @@ packages: emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} - empathic@2.0.1: - resolution: {integrity: sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q==} + empathic@2.1.0: + resolution: {integrity: sha512-AnfC1ATldl49/cvZdLPDjBfrRNwbDO05aibiOtzQu3qtlbJtomNLhF30HEtn/7iBz50dlMECqATo3fG0LrdEgw==} engines: {node: '>=14'} encodeurl@2.0.0: @@ -5817,6 +6105,10 @@ packages: resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} engines: {node: '>=20.19.0'} + entities@8.1.0: + resolution: {integrity: sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==} + engines: {node: '>=20.19.0'} + env-paths@2.2.1: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} @@ -6091,14 +6383,6 @@ packages: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} - eventsource-parser@3.1.1: - resolution: {integrity: sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==} - engines: {node: '>=18.0.0'} - - eventsource@3.0.7: - resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} - engines: {node: '>=18.0.0'} - execa@5.1.1: resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} engines: {node: '>=10'} @@ -6115,17 +6399,15 @@ packages: resolution: {integrity: sha512-m8YrYgvKe9+9gEnWEuKz+qCGfHqkrff7PPfyDnOFkjsfYRKqiYyOxDFMFieCGAuhtk/VNm63tvNgKZVzVy+Hvg==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + expect@30.5.2: + resolution: {integrity: sha512-0LNuMvs8/5mRYhnCR4k+lGV7fqPMs6Bnksda4S3zsCUmdxBpAn4zU4NNzXdq1pMKe/H4W5t/zVIDSJ/H3e0vDA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + exponential-backoff@3.1.3: resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==} - express-rate-limit@8.3.0: - resolution: {integrity: sha512-KJzBawY6fB9FiZGdE/0aftepZ91YlaGIrV8vgblRM3J8X+dHx/aiowJWwkx6LIGyuqGiANsjSwwrbb8mifOJ4Q==} - engines: {node: '>= 16'} - peerDependencies: - express: '>= 4.11' - - express@5.2.1: - resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + express@5.3.0: + resolution: {integrity: sha512-qCqy1xSoaugxOD4IVHb1ZWdkvCF2W3aE2okKouWj8VwJ+ztskSiXoBnCSJqfKT+cVPJhuJqe9YLbY425+Zh00g==} engines: {node: '>= 18'} extend@3.0.2: @@ -6162,8 +6444,8 @@ packages: fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} - fb-watchman@2.0.2: - resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} + fb-watchman@2.0.4: + resolution: {integrity: sha512-tGQtaoYisPDriJBN0mT5yZSyNETnJGA1gTYxaVRAE5KUO3LI9kPUc9WIu8JXcxaIci+faIKlzXqXyPgrmubtBQ==} fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} @@ -6220,6 +6502,10 @@ packages: resolution: {integrity: sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ==} engines: {node: '>=8'} + flru@1.0.2: + resolution: {integrity: sha512-kWyh8ADvHBFz6ua5xYOPnUroZTT/bwWfrCeL0Wj1dzG4/YOmOcfJ99W8dOVyyynJN35rZ9aCOtHChqQovV7yog==} + engines: {node: '>=6'} + follow-redirects@1.16.0: resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} engines: {node: '>=4.0'} @@ -6303,11 +6589,6 @@ packages: fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} - fsevents@2.3.2: - resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -6335,8 +6616,8 @@ packages: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} - get-east-asian-width@1.6.0: - resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} + get-east-asian-width@1.7.0: + resolution: {integrity: sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==} engines: {node: '>=18'} get-intrinsic@1.3.0: @@ -6363,8 +6644,8 @@ packages: resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} engines: {node: '>= 0.4'} - get-tsconfig@4.13.0: - resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==} + get-tsconfig@4.14.3: + resolution: {integrity: sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==} get-tsconfig@5.0.0-beta.4: resolution: {integrity: sha512-7nF7C9fIPFEMHgEMEfgIlO9wDdZ8CyHw27rWciFZfHvHDReIiPhsYuzPRXsfvBCqFy1l8RRyyWV7QLM+ZhUJsQ==} @@ -6442,8 +6723,8 @@ packages: h3@1.15.10: resolution: {integrity: sha512-YzJeWSkDZxAhvmp8dexjRK5hxziRO7I9m0N53WhvYL5NiWfkUkzssVzY9jvGu0HBoLFW6+duYmNSn6MaZBCCtg==} - handlebars@4.7.9: - resolution: {integrity: sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==} + handlebars@4.7.10: + resolution: {integrity: sha512-P5VJMVM7qgBn6vjXMw8WG9uVI+ncf2pi72j4de4yz5ZULLj2RGqLYaKOYGsgyrViQ0tePOVlN1tDCCXXtFqXKg==} engines: {node: '>=0.4.7'} hasBin: true @@ -6521,12 +6802,8 @@ packages: resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} hasBin: true - hls.js@1.7.1: - resolution: {integrity: sha512-DlzIkeBAS9IIQ432k3BUf3HlwbsR0+trB1i2lDdN2gUkNkrehFurh0/48M5c1/EjlDkdGng1gwZIpwyPxvdZ/g==} - - hono@4.13.5: - resolution: {integrity: sha512-O6+/eCYRkzzzy0rPWwKLiGBR1nFuUPZynnwjxN1MBA62NNqbT0wQEzQyK2gSO5yDIDB336sXQleAhOHrzlYyKw==} - engines: {node: '>=16.9.0'} + hls.js@1.7.3: + resolution: {integrity: sha512-MsPlx6yVW4Qv4C7mEVou4/gk/5cN2dVxOTnrNjPSmyH2f0Ln+/UkG10Ax1PE6OvrmTVPKgQYozTtBaPOuCzq7A==} hosted-git-info@10.1.1: resolution: {integrity: sha512-DeOnSPAvOndYKfw075gt8yZzQ7S2hNztw34zBTfhIzLhmBTswIBg5/y+pqu/VD5cYWm5goAFTusDmUEmKZ0PEQ==} @@ -6600,10 +6877,6 @@ packages: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} - iconv-lite@0.7.1: - resolution: {integrity: sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw==} - engines: {node: '>=0.10.0'} - iconv-lite@0.7.3: resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} engines: {node: '>=0.10.0'} @@ -6619,6 +6892,10 @@ packages: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} + ignore@7.0.12: + resolution: {integrity: sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg==} + engines: {node: '>= 4'} + ignore@7.0.5: resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} engines: {node: '>= 4'} @@ -6668,10 +6945,6 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} - ip-address@10.5.0: - resolution: {integrity: sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==} - engines: {node: '>= 12'} - ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} @@ -6947,8 +7220,12 @@ packages: resolution: {integrity: sha512-NgliezXQ6yznqR4W5Gqw++0cZSbBZlF0NknNIAE5VmSJKWOtmWJmWnBq3TSkUOVBTPrT7FGGhVdA8DLWnxo2Sw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-cli@30.5.1: - resolution: {integrity: sha512-uwNYepWgaNBCplm42fCIUZTf/tIEHIy5AYvx7eL1BrwIgyjPt+2poouR23UO2yopIP+kV8oZFHfv+l4pg/8prQ==} + jest-circus@30.5.2: + resolution: {integrity: sha512-vffFgjs5JSJ9q4ds1vNW3klKYPJfYfIY61LT/zaZgvTeASJOWZUt8LBfHgIcV4rwxlPBLl/ePBGccHEclS4PlQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-cli@30.5.2: + resolution: {integrity: sha512-YYYxUUVjFgPvgEleKNKMaYGIFgR3l3832Cl0kRL/oCiDZ03v7wImUtquHl2OOBCCNgprjZgBN5bczS/JklPBDg==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: @@ -6972,6 +7249,21 @@ packages: ts-node: optional: true + jest-config@30.5.2: + resolution: {integrity: sha512-U6221OZekabePvGcGPf4raIBKMvYQWFHvhobDJRojOYaFMj2HaGytAdknjDhYc6JTz2fdHW9+6k0wWUDaNeOFQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + peerDependencies: + '@types/node': '*' + esbuild-register: '>=3.4.0' + ts-node: '>=9.0.0' + peerDependenciesMeta: + '@types/node': + optional: true + esbuild-register: + optional: true + ts-node: + optional: true + jest-diff@30.2.0: resolution: {integrity: sha512-dQHFo3Pt4/NLlG5z4PxZ/3yZTZ1C7s9hveiOj+GCN+uT109NC2QgsoVZsVOAvbJ3RgKkvyLGXZV9+piDpWbm6A==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -6980,6 +7272,10 @@ packages: resolution: {integrity: sha512-e3cNNMpv8Kh20MjjphTXs+3Vz7DQyLM1nft7KJhnh46atFhjVJRa+0Hq0beywuwsACtMQUBihQkFl8zxb7gt1Q==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-diff@30.5.2: + resolution: {integrity: sha512-rBtHnI6BWTiWj3lM7fOLVfChx7R9B0u9VV3PRHawKs79x6ZjW1QoSrKenaigNGWlvhtoi2BqvDbABDie+Os3xQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-docblock@30.5.0: resolution: {integrity: sha512-NwDqcxtoZi33RhuW+zJS/RVA3rmheQ8BnwpYZuc/Eruaz6seQb7+aoCeDZu/3X7W2XmD8DbSo9Pn72DbKsBFYw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -6988,8 +7284,12 @@ packages: resolution: {integrity: sha512-S1af0TU4v1EZ/AUlkFs/sxf/5KGsbAT9kRgdyoMX/x72y7C8ZEgETE5o1TPnbKRnrbprc5FR/moYLfdRmqEjsQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-environment-jsdom@30.5.1: - resolution: {integrity: sha512-8lzKbC/SRbQE24wr1OOJV+aYtDAuVNKBryN6YcFiCcaZZ3I7grcZY7w91BwNvGET0ubKDmomEHZFHMaF+6pAlA==} + jest-each@30.5.2: + resolution: {integrity: sha512-GRrW+7fmmgmkvyfEBqc1QFdWnF1Ex5yG6Y6AWL4TTxnkWlKIT98dsKh3P3UuWm6v4XsQwVE6DEzXIPMzWJ3ZbA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-environment-jsdom@30.5.2: + resolution: {integrity: sha512-6NOZjEP+WNi4WXjbxb/1ROnfaMxn6Dhqu6uSC22o0H28z+uppejs/jZ9KCVoLepqqoCjcNZujtzQqC9jSV15jg==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} peerDependencies: canvas: ^3.0.0 @@ -7001,6 +7301,10 @@ packages: resolution: {integrity: sha512-LrPj3sPMjsQoOB3jrb8p/sa+XkSFNKo60TTsyc+EB2kxQJHgbwElpXnx1yX25fdFn5958FIObRtcLSHyV8VIAw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-environment-node@30.5.2: + resolution: {integrity: sha512-Ciz4KgTGIbojxSKpuFImgWJj5EyqfE8xMq9NxWmOXnQywHHS03H9Mv+vaCB29J5pr3JBrY27WbRV18rBdjh8Dg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-haste-map@30.5.1: resolution: {integrity: sha512-VIFgt67jW480YDxKfEv9IYQKrFpYt7bOCMn3VsnjK7AK9qo7p6acpnA1DHwsVOULE3dTaYew/6JaTD/d0VDHoQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -7017,6 +7321,10 @@ packages: resolution: {integrity: sha512-aroZVqwOz/wC2y6pC+obgFWKV9viaQWQTTSB6W5H55+egtUczIfXR/rxExTv92xD/ADYwpqaYfVWx1aqwKg7FA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-matcher-utils@30.5.2: + resolution: {integrity: sha512-lFkB365PTIHOhPzwrb9T6gvlDnPpOZ7/c3ewOKKB7bzztqjVrtlyRL2MkyfJXpyY2u2SKw001JKrTx14fBUFcQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-message-util@30.2.0: resolution: {integrity: sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -7033,8 +7341,12 @@ packages: resolution: {integrity: sha512-9fVjc3leUpGID2/by/LU4Dvdcp7PFh9LlxS3QRWK3ABm+KtvEVsG/AEGeLY3gKOZsjkBxyfwGltoAVlW7dygHg==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-preset-angular@17.0.0: - resolution: {integrity: sha512-2yAHkA1c5rSICGJVtLYYqPC5RDsvo4+i4CwWFHVXwv41cHNX7gCWeh074IuZ5mFw7Vwsr+i25EowCnGtKkxNWw==} + jest-mock@30.5.2: + resolution: {integrity: sha512-KTHb37Fhj8xY8qPFvTFaFJHcdsumQp+ExsUlgoX232Agmc706gagBs53+CYvulV7MVQo9AgXOUxsqzzVBoJlrA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-preset-angular@17.0.2: + resolution: {integrity: sha512-P1de5TJfHRKkjqyyP8V5LrHScCi/QI72KrfdD4ehTcYA05TahyS3krEVxLlFlSPQZGZnch2iv6nIcnmtKDRVhQ==} engines: {node: ^20.11.1 || >=22.0.0} peerDependencies: '@angular/compiler-cli': '>=20.0.0 <23.0.0' @@ -7052,8 +7364,8 @@ packages: resolution: {integrity: sha512-Mg0WK7A6xRHLSA1udJ8y9f3lM0uUhFTBnLKzwPmqB9AylvpleJ6BLemR8K9dK27DY+cesDryoA7yLZCAHsPG1A==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-resolve-dependencies@30.5.1: - resolution: {integrity: sha512-JKpXGONDcTaunrNVn8KCl6qAnwl06jIkvv70lhq0Ze47lsPx9sH5HoeEUiXX6iFGnliHN/qpIbQ0l38wrVmXaw==} + jest-resolve-dependencies@30.5.2: + resolution: {integrity: sha512-GMI0DP5enX9Z2qW2zFy1bOkrCfWAOPRJQ7qHt0pdfrxBPsgWxglMPrRSK2TX/wRWYxcPRSLFg3Z88qPOzHx7FQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} jest-resolve@30.5.1: @@ -7064,14 +7376,26 @@ packages: resolution: {integrity: sha512-FPlQE4+mwnFxXmpPrSi836KV2ZzvK1g6/nPCT8o5BcoDUUNJQeHo1/Qdkoe/QeoL4M7OeJpbnGUhYKhC1VMdaQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-runner@30.5.2: + resolution: {integrity: sha512-/B6px7hiiNhVSRwuv9AGn0nawYvwsHeQZ3ItTd5Gp2diFd0EOKaWVeWqcbw3L88vx1GfpCKjQkxWItZCTl11Rw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-runtime@30.5.1: resolution: {integrity: sha512-UB88+NRkK2Tw/OqV7dofYcyiUGrVZtD41k/N0xQOs9fG//57XHK7JLWG52HD1UYpUAhjK4xm6DBvFX3yWudsMA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-runtime@30.5.2: + resolution: {integrity: sha512-0paUAHBlheai10A3XSy9Xh+YBG0IROy+/qzcDPzQ/nbbXrznQzeWy5qniICWK6d3W3scaRQCVAbXdcK0ibBq4w==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-snapshot@30.5.1: resolution: {integrity: sha512-cNWFdSb5xuDGl8hKkAZJ3YtI/PzHpAPFV+HUXWIOG8rMhpDTLVbvAc2d2wRicoYw2wGsJGLaurJT6BLC97bLXQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-snapshot@30.5.2: + resolution: {integrity: sha512-jKIdes25AnAH73dOCi2qE6J6vHJ6L2vyBS11M89kT50DVJcLTf2AOjvcc+2vVaN8lUbY5v2R83aqXo3Ro9fqYA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-util@30.2.0: resolution: {integrity: sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -7088,6 +7412,10 @@ packages: resolution: {integrity: sha512-+FHJ7C+S7b3ySfhA1aFmoa6TztsnwZVv84ycPRn0tVN93np2EU4b8C/KadqA3l6igdjgLBTnUotab9ER0HLG8A==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-watcher@30.5.2: + resolution: {integrity: sha512-rqRgg4R11GA9m1UsEZwQeixHsgZReCn6TOHAHtIH33yXrIvoX0OGPA20Yzzjkz7X40fZvcRSxEpQppYPcWzmcQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-worker@27.5.1: resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} engines: {node: '>= 10.13.0'} @@ -7096,8 +7424,8 @@ packages: resolution: {integrity: sha512-Cbxh5v7AoLuFRmFJSM4/aHdQ68rjXvUWr716EE0Dh3I7T+T/3FgFKhOERGXHcU2Meftq9+9zxPM3TSNyI9D+HA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest@30.5.1: - resolution: {integrity: sha512-3qrR8+ZXFnn7y0H2yjWQNkGGBLBY4zTRoTMAq9zJcgwLLtlyonfsCLviIXK9xuE2KgIyM+M36AFcoK2DgFR36w==} + jest@30.5.2: + resolution: {integrity: sha512-3gnpdBIza8ijCl3iMV9ChE3hSt1+46865qqod863gQtJr1DixOkAU5DoGbi3u+XlRcw++BhZQMu/y4xBdHBvSQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: @@ -7114,9 +7442,6 @@ packages: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true - jose@6.2.9: - resolution: {integrity: sha512-XrchZOFZUl/T3vTwRe8XK+cJrGtMF4th1ARnDfwbBXFKThGhlsxEE4Zu03AD/bjJSt/9jT/mxrOCkJWOg77aPA==} - js-tokens@10.0.0: resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} @@ -7157,9 +7482,6 @@ packages: json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} - json-schema-typed@8.0.2: - resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} - json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -7311,6 +7633,10 @@ packages: resolution: {integrity: sha512-8K88S0aSrcSXdJfiZtEy5BQMnR+TyjrCGLcgAvQs6ta0NEnIm0RJ72/Pv67Jvg07cfBhDbuN74V81lSSVYEFEw==} engines: {node: '>=22.13.0'} + listr2@11.1.0: + resolution: {integrity: sha512-xsuczF6mWkEo/E80QHaEokQzKmTy7m80KcLRnkUt9wrcePres2w7dbQi5zOsWtDUg8Dac4gPFyu4EJ/IvKwTOg==} + engines: {node: '>=22.13.0'} + lmdb@3.5.6: resolution: {integrity: sha512-j3uE8ReKNyUWDjhfEFSJqE/1DLtfTR5Z8yFzVHvBjAk37wNg7HdScjcv8ttPHRvrdgPQMPWxFFI0SsdBzI5lBw==} hasBin: true @@ -7375,6 +7701,10 @@ packages: resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} engines: {node: 20 || >=22} + lru-cache@11.5.3: + resolution: {integrity: sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==} + engines: {node: 20 || >=22} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -7397,6 +7727,9 @@ packages: magic-string@1.2.2: resolution: {integrity: sha512-veT/+7iXrXzT39XnEN4lOxtNl72dMgJ8Lp+5Bd6YcMSWpb0n0MjBM8Uuooi6jgJr8dhUW2swQgBmoZVMni5SVg==} + magic-string@1.4.1: + resolution: {integrity: sha512-8lyCu36ErXR0J9uaGKlKQoiLZKmtI63YGLE8G2o9jyRPdr4X47LusSOwgOJOzcVtp81fTAAjxR7BwKz682Jhow==} + magicast@0.5.2: resolution: {integrity: sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==} @@ -7418,8 +7751,8 @@ packages: markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} - marked@18.0.11: - resolution: {integrity: sha512-HnslJfsZkRPBDJRHvVtAaWlZHEpSu7u8LgQuJCELjRKuWR+hpq4A7sLq3p8HaI9ypVoXDXxV34CsQJEe1+J5Aw==} + marked@18.1.0: + resolution: {integrity: sha512-PamYXWWWg2nboG3oX5Ffzpy3EFZROqZJK9hSlMmUnekp7TxPs5DcT95vwd46m73/exfKCRVMtgXoPMI4FEJL6w==} engines: {node: '>= 20'} hasBin: true @@ -7491,8 +7824,8 @@ packages: mdn-data@2.27.1: resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} - media-typer@1.1.0: - resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + media-typer@1.1.1: + resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==} engines: {node: '>= 0.8'} memfs@3.5.3: @@ -7759,9 +8092,9 @@ packages: engines: {node: '>= 4.4.x'} hasBin: true - negotiator@1.0.0: - resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} - engines: {node: '>= 0.6'} + negotiator@1.1.0: + resolution: {integrity: sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==} + engines: {node: '>=18'} neo-async@2.6.2: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} @@ -7770,8 +8103,8 @@ packages: resolution: {integrity: sha512-WmmLty1YWwJl9yZi77v2dVIV6X2kuYV8YYBI/G3LWGKdGHmHUvL1z7FW0iDvEvGAwNEoc5x1tOOOyDnf5jJw/w==} engines: {node: '>= 10'} - ng-mocks@14.17.6: - resolution: {integrity: sha512-8SK+6fwLN5HxNLfNkVc3UCP998NtfzeQWGPFyh5PL5pJA7Ohf7OKEPzLM5S4qRZKHeFXK0Omc9S3+daKBb+VxQ==} + ng-mocks@14.18.1: + resolution: {integrity: sha512-XEArPJ06C3G0XmLIS9YMy1Xke+Conn+pAV1IC3hA3AEbFag74kxXN3F/IaMWJC1N1E1UoTPH1SYFBNloBAHAOw==} peerDependencies: '@angular/common': 5.0.0-alpha - 5 || 6.0.0-alpha - 6 || 7.0.0-alpha - 7 || 8.0.0-alpha - 8 || 9.0.0-alpha - 9 || 10.0.0-alpha - 10 || 11.0.0-alpha - 11 || 12.0.0-alpha - 12 || 13.0.0-alpha - 13 || 14.0.0-alpha - 14 || 15.0.0-alpha - 15 || 16.0.0-alpha - 16 || 17.0.0-alpha - 17 || 18.0.0-alpha - 18 || 19.0.0-alpha - 19 || 20.0.0-alpha - 20 || 21.0.0-alpha - 21 || 22.0.0-alpha - 22 '@angular/core': 5.0.0-alpha - 5 || 6.0.0-alpha - 6 || 7.0.0-alpha - 7 || 8.0.0-alpha - 8 || 9.0.0-alpha - 9 || 10.0.0-alpha - 10 || 11.0.0-alpha - 11 || 12.0.0-alpha - 12 || 13.0.0-alpha - 13 || 14.0.0-alpha - 14 || 15.0.0-alpha - 15 || 16.0.0-alpha - 16 || 17.0.0-alpha - 17 || 18.0.0-alpha - 18 || 19.0.0-alpha - 19 || 20.0.0-alpha - 20 || 21.0.0-alpha - 21 || 22.0.0-alpha - 22 @@ -7835,6 +8168,10 @@ packages: resolution: {integrity: sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==} engines: {node: '>=18'} + node-releases@2.0.58: + resolution: {integrity: sha512-BdiKAgbfRlvfMq/XzO/AF9NPmaA8a+YpLAyAFTVFBj3gARrAIfWvNq7Q7NE4se3KgFu0KkrvQMxTS0XVnsGzHg==} + engines: {node: '>=18'} + nopt@9.0.0: resolution: {integrity: sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==} engines: {node: ^20.17.0 || >=22.9.0} @@ -7917,6 +8254,14 @@ packages: resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} engines: {node: '>=12.20.0'} + obug@2.2.1: + resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==} + engines: {node: '>=12.20.0'} + + obug@3.0.0: + resolution: {integrity: sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==} + engines: {node: '>=12.20.0'} + ofetch@1.5.1: resolution: {integrity: sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==} @@ -7978,6 +8323,10 @@ packages: resolution: {integrity: sha512-kKR+jPiRJYJDexVoziIg/FVGvr1fT1FZSSJOk6tVoMKKSlsf1Cso+cgGCJkOEDWOP174vRntCPFKg+AS7InWvw==} engines: {node: ^20.19.0 || >=22.12.0} + oxc-parser@0.150.0: + resolution: {integrity: sha512-zwajKw1GUa57IOdafIS7/yOObGF2NihwIfZfs5yJTRprMSkiwhWOc/k+n8bwnUPbHr2/XsjfBO8KyTPaW3VpWg==} + engines: {node: ^20.19.0 || >=22.12.0} + oxc-resolver@11.17.1: resolution: {integrity: sha512-pyRXK9kH81zKlirHufkFhOFBZRks8iAMLwPH8gU7lvKFiuzUH9L8MxDEllazwOb8fjXMcWjY1PMDfMJ2/yh5cw==} @@ -8083,8 +8432,8 @@ packages: resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} engines: {node: 18 || 20 || >=22} - path-to-regexp@8.3.0: - resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} path-type@4.0.0: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} @@ -8136,10 +8485,6 @@ packages: resolution: {integrity: sha512-DszUCKeVN/5G5QKo6jAVHL8fmKnkJvQ0ACiVgY7YGCq3TUB2oznAOayvZPIAdEThvhczkXR+qm3IHsNXpFCYfA==} engines: {node: '>=20.x'} - pkce-challenge@5.0.1: - resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} - engines: {node: '>=16.20.0'} - pkcs7@1.0.4: resolution: {integrity: sha512-afRERtHn54AlwaF2/+LFszyAANTCggGilmcmILUzEjvs3XgFZT+xE6+QWQcAGmu4xajy+Xtj7acLOPdx5/eXWQ==} hasBin: true @@ -8152,13 +8497,13 @@ packages: resolution: {integrity: sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw==} engines: {node: '>=16.0.0'} - playwright-core@1.62.1: - resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==} + playwright-core@1.64.0: + resolution: {integrity: sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==} engines: {node: '>=20'} hasBin: true - playwright@1.62.1: - resolution: {integrity: sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==} + playwright@1.64.0: + resolution: {integrity: sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==} engines: {node: '>=20'} hasBin: true @@ -8247,8 +8592,8 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} - prettier@3.9.6: - resolution: {integrity: sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==} + prettier@3.9.9: + resolution: {integrity: sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==} engines: {node: '>=14'} hasBin: true @@ -8297,8 +8642,8 @@ packages: property-information@7.1.0: resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} - proxy-addr@2.0.7: - resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + proxy-addr@2.0.8: + resolution: {integrity: sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==} engines: {node: '>= 0.10'} proxy-agent-negotiate@1.1.0: @@ -8343,6 +8688,10 @@ packages: resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} engines: {node: '>=0.6'} + qs@6.16.0: + resolution: {integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==} + engines: {node: '>=0.6'} + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -8353,8 +8702,8 @@ packages: radix3@1.1.2: resolution: {integrity: sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==} - range-parser@1.2.1: - resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + range-parser@1.3.0: + resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==} engines: {node: '>= 0.6'} raw-body@3.0.2: @@ -8894,10 +9243,6 @@ packages: resolution: {integrity: sha512-Mb/GvXPHBAXdgGIcnfU5L3ldpn1XcxrGkPHwqgRx17/I2XRfqlFKk2vGkHWINn1kdXvzJZeuO3is6I9KLPFm0g==} engines: {node: '>=20'} - side-channel-list@1.0.0: - resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} - engines: {node: '>= 0.4'} - side-channel-list@1.0.1: resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} engines: {node: '>= 0.4'} @@ -8910,10 +9255,6 @@ packages: resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} engines: {node: '>= 0.4'} - side-channel@1.1.0: - resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} - engines: {node: '>= 0.4'} - side-channel@1.1.1: resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} engines: {node: '>= 0.4'} @@ -8945,8 +9286,8 @@ packages: resolution: {integrity: sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==} engines: {node: '>=14.16'} - slice-ansi@9.0.0: - resolution: {integrity: sha512-SO/3iYL5S3W57LLEniscOGPZgOqZUPCx6d3dB+52B80yJ0XstzsC/eV8gnA4tM3MHDrKz+OCFSLNjswdSC+/bA==} + slice-ansi@9.0.1: + resolution: {integrity: sha512-aBY19bn/XA+hKOKX0qL7Z0UfdBsbQvc9hn993U8ALUjzxCvDcuZqLoRXjGJrUARWAlwMnsRVD9sw2AFNOYvalA==} engines: {node: '>=22'} smol-toml@1.7.1: @@ -8975,6 +9316,10 @@ packages: resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} engines: {node: '>= 12'} + source-map@0.8.0: + resolution: {integrity: sha512-d8EqvL+k/SOXCreS/SUzg2ciyHqBBLcN/yuRjFsbvVhHTE2pgei7oAhmPM7kWFbkX6OSMQfUq4KbkF3au9lhYQ==} + engines: {node: '>= 12'} + space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} @@ -9020,8 +9365,8 @@ packages: resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} engines: {node: '>=18'} - string-width@8.2.2: - resolution: {integrity: sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==} + string-width@8.3.0: + resolution: {integrity: sha512-ZbmZM0JCihQN91dWnxoipT2KOEyHqEyfRXUyjuRhW8b/xnqPDoq4gWEVApTVa9db2wN8mmoikgFBbjh71+cGeQ==} engines: {node: '>=20'} string.prototype.trim@1.2.10: @@ -9250,12 +9595,12 @@ packages: ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} - ts-jest@29.4.12: - resolution: {integrity: sha512-Ov6ClY53Fflh6BGAnY2DlTq1hYDrTycz2PVTXBWFW2CU+9zrEqAp9fWdGXl42EXO5RLSFAcAZ2JFKbP+zBTFfw==} + ts-jest@29.4.14: + resolution: {integrity: sha512-+943G6wCX5E3VpzTQhBGBFntCmdT+QAkTvUlp03edTONFBvqskuXPhpyX16xdUotO4P5HQKyppldSWjocoltLQ==} engines: {node: ^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0} hasBin: true peerDependencies: - '@babel/core': '>=7.0.0-beta.0 <8' + '@babel/core': '>=7.0.0-beta.0 <9' '@jest/transform': ^29.0.0 || ^30.0.0 '@jest/types': ^29.0.0 || ^30.0.0 babel-jest: ^29.0.0 || ^30.0.0 @@ -9316,8 +9661,8 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - tsx@4.23.13: - resolution: {integrity: sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==} + tsx@4.23.15: + resolution: {integrity: sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==} engines: {node: '>=18.0.0'} hasBin: true @@ -9345,9 +9690,9 @@ packages: resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} engines: {node: '>=16'} - type-is@2.0.1: - resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} - engines: {node: '>= 0.6'} + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} typed-array-buffer@1.0.3: resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} @@ -9365,8 +9710,8 @@ packages: resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} engines: {node: '>= 0.4'} - typescript-eslint@8.69.0: - resolution: {integrity: sha512-B3MltX0VqjUBNEe3b3sSuiRbfa6XrfHFtBiPamjT5AsW/dfq+y+bc0wyuS9DxAS1LyzCxRp2+rxzpLUvqM2BvA==} + typescript-eslint@8.71.1: + resolution: {integrity: sha512-oiNVPC3/NbV1FpaHU07l8O6ynqG9v4PAiW2WvTrAmZdR0f2HI/gE668ElMLCK7Xbsu5k2fK5C4fXNiq05j7/nQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -9401,6 +9746,9 @@ packages: undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + undici@6.28.0: resolution: {integrity: sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==} engines: {node: '>=18.17'} @@ -9559,6 +9907,12 @@ packages: peerDependencies: browserslist: 4.28.8 + update-browserslist-db@1.3.4: + resolution: {integrity: sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==} + hasBin: true + peerDependencies: + browserslist: 4.28.8 + uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -9592,8 +9946,8 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} - verkit@0.3.2: - resolution: {integrity: sha512-zj/ob3UsvJGN0whEAKFp53REA5X66hvffVqoCtVQAakJKnKlH+/PcOfMoFwIG/o4rElqLv/ycAFlx8ZlXUorCg==} + verkit@0.5.0: + resolution: {integrity: sha512-u4J1hr6Y+C/hit34GwL3eIcUf3wngT8TlYUjK7tk0jxydKLAq3sYvifjdTDA6U3KCKRcQCQDGf/srjxfZF3l7g==} engines: {node: '>=18.12.0'} vfile-location@5.0.3: @@ -9608,8 +9962,8 @@ packages: video.js@7.21.7: resolution: {integrity: sha512-T2s3WFAht7Zjr2OSJamND9x9Dn2O+Z5WuHGdh8jI5SYh5mkMdVTQ7vSRmA5PYpjXJ2ycch6jpMjkJEIEU2xxqw==} - video.js@8.24.0: - resolution: {integrity: sha512-ZUuxTlDRkEWhpp2mvGCSFzI0Tc3zcC6IUZbIhBcZ3QW0Ms+IRp/tzYkhqexcMYwrcukRFiWWJmRANsLKvv8Vsg==} + video.js@8.24.1: + resolution: {integrity: sha512-Qh2WxVyiw6BEvpS95KCoZM8wt10r2iDvembOjHPftVcxY4QR53WVTDQlJEuI1J46Yo9Zw+6rRDIe62C+J+edfQ==} videojs-contrib-quality-levels@4.1.0: resolution: {integrity: sha512-TfrXJJg1Bv4t6TOCMEVMwF/CoS8iENYsWNKip8zfhB5kTcegiFYezEA0eHAJPU64ZC8NQbxQgOwAsYU8VXbOWA==} @@ -9841,6 +10195,10 @@ packages: resolution: {integrity: sha512-M0N4xzyzosiIok3svYlEo1sdLZts/8FPgYH/GPC3wvlmPoRvnoManGMrE54waYj3tISA8w6lsdesfVv67qSr8Q==} engines: {node: '>=20'} + wrap-ansi@10.0.2: + resolution: {integrity: sha512-6OLRNZqRntVGBm26ghZ5eZhCuWWyOkRCSn8XsOIuq5stpCE/tjLbdozd93naILZig0lmD8ANfRwnhn0DLeXGlg==} + engines: {node: '>=20'} + wrap-ansi@6.2.0: resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} engines: {node: '>=8'} @@ -9920,6 +10278,11 @@ packages: engines: {node: '>= 14.6'} hasBin: true + yaml@2.9.1: + resolution: {integrity: sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==} + engines: {node: '>= 14.6'} + hasBin: true + yargs-parser@18.1.3: resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} engines: {node: '>=6'} @@ -9948,6 +10311,10 @@ packages: resolution: {integrity: sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg==} engines: {node: ^20.19.0 || ^22.12.0 || >=23} + yargs@18.2.0: + resolution: {integrity: sha512-9OpKOLeaoNFecEp7P6iYbzze/5CqWoH7N3SMs/6y1XF4nMvFMspEGZzJ6uFi9MmQwXhyzqYoSEKO3tvA3Z/o2w==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + yn@3.1.1: resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} engines: {node: '>=6'} @@ -9964,16 +10331,8 @@ packages: resolution: {integrity: sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg==} engines: {node: '>=18'} - zod-to-json-schema@3.25.2: - resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} - peerDependencies: - zod: ^3.25.28 || ^4 - - zod@4.4.3: - resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} - - zod@4.5.4: - resolution: {integrity: sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==} + zod@4.6.5: + resolution: {integrity: sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==} zone.js@0.16.3: resolution: {integrity: sha512-ihXL9+vhYyEhXz4TDNpHeAOZN9FVrbog0Il64OIEI28UP/n5AaI6gsccRuOHBfx+206agzyoK527bYIO0Foy6A==} @@ -9997,6 +10356,13 @@ snapshots: transitivePeerDependencies: - chokidar + '@angular-devkit/architect@0.2202.2(chokidar@5.0.0)': + dependencies: + '@angular-devkit/core': 22.2.2(chokidar@5.0.0) + rxjs: 7.8.2 + transitivePeerDependencies: + - chokidar + '@angular-devkit/core@22.1.8(chokidar@5.0.0)': dependencies: ajv: 8.20.0 @@ -10008,11 +10374,22 @@ snapshots: optionalDependencies: chokidar: 5.0.0 - '@angular-devkit/schematics@22.1.8(chokidar@5.0.0)': + '@angular-devkit/core@22.2.2(chokidar@5.0.0)': dependencies: - '@angular-devkit/core': 22.1.8(chokidar@5.0.0) + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) jsonc-parser: 3.3.1 - magic-string: 1.0.0 + picomatch: 4.0.7 + rxjs: 7.8.2 + source-map: 0.8.0 + optionalDependencies: + chokidar: 5.0.0 + + '@angular-devkit/schematics@22.2.2(chokidar@5.0.0)': + dependencies: + '@angular-devkit/core': 22.2.2(chokidar@5.0.0) + jsonc-parser: 3.3.1 + magic-string: 1.4.1 ora: 9.4.1 rxjs: 7.8.2 transitivePeerDependencies: @@ -10021,7 +10398,7 @@ snapshots: '@angular-eslint/builder@22.5.0(chokidar@5.0.0)(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3)': dependencies: '@angular-devkit/architect': 0.2201.8(chokidar@5.0.0) - '@angular-devkit/core': 22.1.8(chokidar@5.0.0) + '@angular-devkit/core': 22.2.2(chokidar@5.0.0) eslint: 9.39.2(jiti@1.21.7) typescript: 6.0.3 transitivePeerDependencies: @@ -10029,33 +10406,62 @@ snapshots: '@angular-eslint/bundled-angular-compiler@22.5.0': {} - '@angular-eslint/eslint-plugin-template@22.5.0(@angular-eslint/template-parser@22.5.0(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3))(@typescript-eslint/types@8.69.0)(@typescript-eslint/utils@8.69.0(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3))(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3)': + '@angular-eslint/eslint-plugin-template@22.5.0(@angular-eslint/template-parser@22.5.0(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3))(@typescript-eslint/types@8.69.0)(@typescript-eslint/utils@8.71.1(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3))(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3)': dependencies: '@angular-eslint/bundled-angular-compiler': 22.5.0 '@angular-eslint/template-parser': 22.5.0(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3) - '@angular-eslint/utils': 22.5.0(@typescript-eslint/utils@8.69.0(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3))(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3) + '@angular-eslint/utils': 22.5.0(@typescript-eslint/utils@8.71.1(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3))(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3) '@typescript-eslint/types': 8.69.0 - '@typescript-eslint/utils': 8.69.0(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3) + '@typescript-eslint/utils': 8.71.1(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3) aria-query: 5.3.2 axobject-query: 4.1.0 eslint: 9.39.2(jiti@1.21.7) typescript: 6.0.3 - '@angular-eslint/eslint-plugin@22.5.0(@typescript-eslint/utils@8.69.0(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3))(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3)': + '@angular-eslint/eslint-plugin-template@22.5.0(@angular-eslint/template-parser@22.5.0(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3))(@typescript-eslint/types@8.71.1)(@typescript-eslint/utils@8.71.1(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3))(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3)': dependencies: '@angular-eslint/bundled-angular-compiler': 22.5.0 - '@angular-eslint/utils': 22.5.0(@typescript-eslint/utils@8.69.0(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3))(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3) - '@typescript-eslint/utils': 8.69.0(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3) + '@angular-eslint/template-parser': 22.5.0(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3) + '@angular-eslint/utils': 22.5.0(@typescript-eslint/utils@8.71.1(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3))(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3) + '@typescript-eslint/types': 8.71.1 + '@typescript-eslint/utils': 8.71.1(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3) + aria-query: 5.3.2 + axobject-query: 4.1.0 + eslint: 9.39.2(jiti@1.21.7) + typescript: 6.0.3 + + '@angular-eslint/eslint-plugin@22.5.0(@typescript-eslint/utils@8.71.1(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3))(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3)': + dependencies: + '@angular-eslint/bundled-angular-compiler': 22.5.0 + '@angular-eslint/utils': 22.5.0(@typescript-eslint/utils@8.71.1(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3))(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3) + '@typescript-eslint/utils': 8.71.1(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3) eslint: 9.39.2(jiti@1.21.7) ts-api-utils: 2.5.0(typescript@6.0.3) typescript: 6.0.3 - '@angular-eslint/schematics@22.5.0(@angular-eslint/template-parser@22.5.0(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3))(@typescript-eslint/types@8.69.0)(@typescript-eslint/utils@8.69.0(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3))(chokidar@5.0.0)(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3)': + '@angular-eslint/schematics@22.5.0(@angular-eslint/template-parser@22.5.0(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3))(@typescript-eslint/types@8.69.0)(@typescript-eslint/utils@8.71.1(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3))(chokidar@5.0.0)(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3)': dependencies: - '@angular-devkit/core': 22.1.8(chokidar@5.0.0) - '@angular-devkit/schematics': 22.1.8(chokidar@5.0.0) - '@angular-eslint/eslint-plugin': 22.5.0(@typescript-eslint/utils@8.69.0(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3))(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3) - '@angular-eslint/eslint-plugin-template': 22.5.0(@angular-eslint/template-parser@22.5.0(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3))(@typescript-eslint/types@8.69.0)(@typescript-eslint/utils@8.69.0(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3))(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3) + '@angular-devkit/core': 22.2.2(chokidar@5.0.0) + '@angular-devkit/schematics': 22.2.2(chokidar@5.0.0) + '@angular-eslint/eslint-plugin': 22.5.0(@typescript-eslint/utils@8.71.1(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3))(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3) + '@angular-eslint/eslint-plugin-template': 22.5.0(@angular-eslint/template-parser@22.5.0(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3))(@typescript-eslint/types@8.69.0)(@typescript-eslint/utils@8.71.1(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3))(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3) + ignore: 7.0.8 + semver: 7.8.5 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - '@angular-eslint/template-parser' + - '@typescript-eslint/types' + - '@typescript-eslint/utils' + - chokidar + - eslint + - typescript + + '@angular-eslint/schematics@22.5.0(@angular-eslint/template-parser@22.5.0(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3))(@typescript-eslint/types@8.71.1)(@typescript-eslint/utils@8.71.1(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3))(chokidar@5.0.0)(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3)': + dependencies: + '@angular-devkit/core': 22.2.2(chokidar@5.0.0) + '@angular-devkit/schematics': 22.2.2(chokidar@5.0.0) + '@angular-eslint/eslint-plugin': 22.5.0(@typescript-eslint/utils@8.71.1(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3))(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3) + '@angular-eslint/eslint-plugin-template': 22.5.0(@angular-eslint/template-parser@22.5.0(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3))(@typescript-eslint/types@8.71.1)(@typescript-eslint/utils@8.71.1(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3))(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3) ignore: 7.0.8 semver: 7.8.5 strip-json-comments: 3.1.1 @@ -10074,29 +10480,29 @@ snapshots: eslint-scope: 9.1.2 typescript: 6.0.3 - '@angular-eslint/utils@22.5.0(@typescript-eslint/utils@8.69.0(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3))(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3)': + '@angular-eslint/utils@22.5.0(@typescript-eslint/utils@8.71.1(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3))(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3)': dependencies: '@angular-eslint/bundled-angular-compiler': 22.5.0 - '@typescript-eslint/utils': 8.69.0(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3) + '@typescript-eslint/utils': 8.71.1(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3) eslint: 9.39.2(jiti@1.21.7) typescript: 6.0.3 - '@angular/animations@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))': + '@angular/animations@22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))': dependencies: - '@angular/core': 22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3) + '@angular/core': 22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3) tslib: 2.8.1 - '@angular/build@22.1.8(@angular/compiler-cli@22.1.6(@angular/compiler@22.1.6)(typescript@6.0.3))(@angular/compiler@22.1.6)(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(@angular/platform-browser@22.1.6(@angular/animations@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/common@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/service-worker@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@types/node@20.19.9)(chokidar@5.0.0)(istanbul-lib-instrument@6.0.3)(jiti@1.21.7)(less@4.5.1)(postcss@8.5.26)(rollup@4.59.0)(sass-embedded@1.100.0)(tailwindcss@3.4.19(tsx@4.23.13)(yaml@2.9.0))(terser@5.49.2)(tslib@2.8.1)(tsx@4.23.13)(typescript@6.0.3)(yaml@2.9.0)': + '@angular/build@22.1.8(@angular/compiler-cli@22.2.2(@angular/compiler@22.2.2)(typescript@6.0.3))(@angular/compiler@22.2.2)(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(@angular/platform-browser@22.2.2(@angular/animations@22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/common@22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/service-worker@22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@types/node@20.19.9)(chokidar@5.0.0)(istanbul-lib-instrument@6.0.3)(jiti@1.21.7)(less@4.5.1)(postcss@8.5.26)(rollup@4.59.0)(sass-embedded@1.100.0)(tailwindcss@3.4.19(tsx@4.23.15)(yaml@2.9.1))(terser@5.49.2)(tslib@2.8.1)(tsx@4.23.15)(typescript@6.0.3)(yaml@2.9.1)': dependencies: '@ampproject/remapping': 2.3.0 '@angular-devkit/architect': 0.2201.8(chokidar@5.0.0) - '@angular/compiler': 22.1.6 - '@angular/compiler-cli': 22.1.6(@angular/compiler@22.1.6)(typescript@6.0.3) + '@angular/compiler': 22.2.2 + '@angular/compiler-cli': 22.2.2(@angular/compiler@22.2.2)(typescript@6.0.3) '@babel/core': 8.0.1 '@babel/helper-annotate-as-pure': 8.0.0 '@babel/helper-split-export-declaration': 7.24.7 '@inquirer/confirm': 6.1.1(@types/node@20.19.9) - '@vitejs/plugin-basic-ssl': 2.3.0(vite@8.1.5(patch_hash=469ec4025880fc7b86d3d8b3b4e2028572d6acf65bdfb93973db18fdb3c00ad9)(@types/node@20.19.9)(esbuild@0.28.2)(jiti@1.21.7)(less@4.5.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.49.2)(tsx@4.23.13)(yaml@2.9.0)) + '@vitejs/plugin-basic-ssl': 2.3.0(vite@8.1.5(patch_hash=469ec4025880fc7b86d3d8b3b4e2028572d6acf65bdfb93973db18fdb3c00ad9)(@types/node@20.19.9)(esbuild@0.28.2)(jiti@1.21.7)(less@4.5.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.1)) beasties: 0.4.3 browserslist: 4.28.8 esbuild: 0.28.2 @@ -10116,18 +10522,18 @@ snapshots: tinyglobby: 0.2.17 tslib: 2.8.1 typescript: 6.0.3 - vite: 8.1.5(patch_hash=469ec4025880fc7b86d3d8b3b4e2028572d6acf65bdfb93973db18fdb3c00ad9)(@types/node@20.19.9)(esbuild@0.28.2)(jiti@1.21.7)(less@4.5.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.49.2)(tsx@4.23.13)(yaml@2.9.0) + vite: 8.1.5(patch_hash=469ec4025880fc7b86d3d8b3b4e2028572d6acf65bdfb93973db18fdb3c00ad9)(@types/node@20.19.9)(esbuild@0.28.2)(jiti@1.21.7)(less@4.5.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.1) watchpack: 2.5.2 optionalDependencies: - '@angular/core': 22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3) - '@angular/platform-browser': 22.1.6(@angular/animations@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/common@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3)) - '@angular/service-worker': 22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2) + '@angular/core': 22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3) + '@angular/platform-browser': 22.2.2(@angular/animations@22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/common@22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3)) + '@angular/service-worker': 22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2) istanbul-lib-instrument: 6.0.3 less: 4.5.1 lmdb: 3.5.6 postcss: 8.5.26 rollup: 4.59.0 - tailwindcss: 3.4.19(tsx@4.23.13)(yaml@2.9.0) + tailwindcss: 3.4.19(tsx@4.23.15)(yaml@2.9.1) transitivePeerDependencies: - '@types/node' - '@vitejs/devtools' @@ -10142,119 +10548,117 @@ snapshots: - tsx - yaml - '@angular/cdk@22.1.6(d84494623d562fa967d8dbca5e8215e7)': + '@angular/cdk@22.2.2(a2e0e17c73c6aceb9301ae9aaf890120)': dependencies: - '@angular/common': 22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2) - '@angular/core': 22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3) - '@angular/forms': 22.1.6(@angular/common@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(@angular/platform-browser@22.1.6(@angular/animations@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/common@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3)))(rxjs@7.8.2) - '@angular/platform-browser': 22.1.6(@angular/animations@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/common@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3)) + '@angular/common': 22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2) + '@angular/core': 22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3) + '@angular/forms': 22.2.2(@angular/common@22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(@angular/platform-browser@22.2.2(@angular/animations@22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/common@22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3)))(rxjs@7.8.2) + '@angular/platform-browser': 22.2.2(@angular/animations@22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/common@22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3)) parse5: 8.0.1 rxjs: 7.8.2 tslib: 2.8.1 - '@angular/cli@22.1.8(@types/node@20.19.9)(chokidar@5.0.0)': + '@angular/cli@22.2.2(@types/node@20.19.9)(chokidar@5.0.0)': dependencies: - '@angular-devkit/architect': 0.2201.8(chokidar@5.0.0) - '@angular-devkit/core': 22.1.8(chokidar@5.0.0) - '@angular-devkit/schematics': 22.1.8(chokidar@5.0.0) - '@inquirer/prompts': 8.5.2(@types/node@20.19.9) - '@listr2/prompt-adapter-inquirer': 4.2.5(@inquirer/prompts@8.5.2(@types/node@20.19.9))(@types/node@20.19.9)(listr2@11.0.0) - '@modelcontextprotocol/sdk': 1.30.0(zod@4.4.3) - '@schematics/angular': 22.1.8(chokidar@5.0.0) + '@angular-devkit/architect': 0.2202.2(chokidar@5.0.0) + '@angular-devkit/core': 22.2.2(chokidar@5.0.0) + '@angular-devkit/schematics': 22.2.2(chokidar@5.0.0) + '@inquirer/prompts': 8.7.2(@types/node@20.19.9) + '@listr2/prompt-adapter-inquirer': 4.2.7(@inquirer/prompts@8.7.2(@types/node@20.19.9))(@types/node@20.19.9)(listr2@11.1.0) + '@modelcontextprotocol/server': 2.3.1 + '@schematics/angular': 22.2.2(chokidar@5.0.0) jsonc-parser: 3.3.1 - listr2: 11.0.0 + listr2: 11.1.0 npm-package-arg: 14.0.0 parse5-html-rewriting-stream: 8.0.1 semver: 7.8.5 yargs: 18.1.0 - zod: 4.4.3 + zod: 4.6.5 transitivePeerDependencies: - - '@cfworker/json-schema' - '@types/node' - chokidar - - supports-color - '@angular/common@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2)': + '@angular/common@22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2)': dependencies: - '@angular/core': 22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3) + '@angular/core': 22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3) rxjs: 7.8.2 tslib: 2.8.1 - '@angular/compiler-cli@22.1.6(@angular/compiler@22.1.6)(typescript@6.0.3)': + '@angular/compiler-cli@22.2.2(@angular/compiler@22.2.2)(typescript@6.0.3)': dependencies: - '@angular/compiler': 22.1.6 + '@angular/compiler': 22.2.2 '@babel/core': 8.0.1 - '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/sourcemap-codec': 1.6.0 chokidar: 5.0.0 convert-source-map: 1.9.0 reflect-metadata: 0.2.2 semver: 7.8.5 tslib: 2.8.1 - yargs: 18.1.0 + yargs: 18.2.0 optionalDependencies: typescript: 6.0.3 - '@angular/compiler@22.1.6': + '@angular/compiler@22.2.2': dependencies: tslib: 2.8.1 - '@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3)': + '@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3)': dependencies: rxjs: 7.8.2 tslib: 2.8.1 optionalDependencies: - '@angular/compiler': 22.1.6 + '@angular/compiler': 22.2.2 zone.js: 0.16.3 - '@angular/forms@22.1.6(@angular/common@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(@angular/platform-browser@22.1.6(@angular/animations@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/common@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3)))(rxjs@7.8.2)': + '@angular/forms@22.2.2(@angular/common@22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(@angular/platform-browser@22.2.2(@angular/animations@22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/common@22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3)))(rxjs@7.8.2)': dependencies: - '@angular/common': 22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2) - '@angular/core': 22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3) - '@angular/platform-browser': 22.1.6(@angular/animations@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/common@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3)) + '@angular/common': 22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2) + '@angular/core': 22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3) + '@angular/platform-browser': 22.2.2(@angular/animations@22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/common@22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3)) '@standard-schema/spec': 1.1.0 rxjs: 7.8.2 tslib: 2.8.1 - zod: 4.5.4 + zod: 4.6.5 - '@angular/language-service@22.1.6': {} + '@angular/language-service@22.2.2': {} - '@angular/material@22.1.6(42f78374544ce4ecfab2a610740b5d20)': + '@angular/material@22.2.2(641510506f6736c740497081311accfd)': dependencies: - '@angular/cdk': 22.1.6(d84494623d562fa967d8dbca5e8215e7) - '@angular/common': 22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2) - '@angular/core': 22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3) - '@angular/forms': 22.1.6(@angular/common@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(@angular/platform-browser@22.1.6(@angular/animations@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/common@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3)))(rxjs@7.8.2) - '@angular/platform-browser': 22.1.6(@angular/animations@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/common@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3)) + '@angular/cdk': 22.2.2(a2e0e17c73c6aceb9301ae9aaf890120) + '@angular/common': 22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2) + '@angular/core': 22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3) + '@angular/forms': 22.2.2(@angular/common@22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(@angular/platform-browser@22.2.2(@angular/animations@22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/common@22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3)))(rxjs@7.8.2) + '@angular/platform-browser': 22.2.2(@angular/animations@22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/common@22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3)) rxjs: 7.8.2 tslib: 2.8.1 - '@angular/platform-browser-dynamic@22.1.6(@angular/common@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/compiler@22.1.6)(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(@angular/platform-browser@22.1.6(@angular/animations@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/common@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3)))': + '@angular/platform-browser-dynamic@22.2.2(@angular/common@22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/compiler@22.2.2)(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(@angular/platform-browser@22.2.2(@angular/animations@22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/common@22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3)))': dependencies: - '@angular/common': 22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2) - '@angular/compiler': 22.1.6 - '@angular/core': 22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3) - '@angular/platform-browser': 22.1.6(@angular/animations@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/common@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3)) + '@angular/common': 22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2) + '@angular/compiler': 22.2.2 + '@angular/core': 22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3) + '@angular/platform-browser': 22.2.2(@angular/animations@22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/common@22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3)) tslib: 2.8.1 - '@angular/platform-browser@22.1.6(@angular/animations@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/common@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))': + '@angular/platform-browser@22.2.2(@angular/animations@22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/common@22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))': dependencies: - '@angular/common': 22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2) - '@angular/core': 22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3) + '@angular/common': 22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2) + '@angular/core': 22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3) tslib: 2.8.1 optionalDependencies: - '@angular/animations': 22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3)) + '@angular/animations': 22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3)) - '@angular/router@22.1.6(@angular/common@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(@angular/platform-browser@22.1.6(@angular/animations@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/common@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3)))(rxjs@7.8.2)': + '@angular/router@22.2.2(@angular/common@22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(@angular/platform-browser@22.2.2(@angular/animations@22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/common@22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3)))(rxjs@7.8.2)': dependencies: - '@angular/common': 22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2) - '@angular/core': 22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3) - '@angular/platform-browser': 22.1.6(@angular/animations@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/common@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3)) + '@angular/common': 22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2) + '@angular/core': 22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3) + '@angular/platform-browser': 22.2.2(@angular/animations@22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/common@22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3)) rxjs: 7.8.2 tslib: 2.8.1 - '@angular/service-worker@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2)': + '@angular/service-worker@22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2)': dependencies: - '@angular/core': 22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3) + '@angular/core': 22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3) rxjs: 7.8.2 tslib: 2.8.1 @@ -10383,13 +10787,13 @@ snapshots: github-slugger: 2.0.0 satteri: 0.10.5 - '@astrojs/mdx@7.0.7(@astrojs/markdown-satteri@0.3.7)(astro@7.2.10(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.49.2)(tsx@4.23.13)(yaml@2.9.0))': + '@astrojs/mdx@7.0.7(@astrojs/markdown-satteri@0.3.7)(astro@7.2.10(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.1))': dependencies: '@astrojs/internal-helpers': 0.10.4 '@astrojs/markdown-remark': 7.2.4 '@mdx-js/mdx': 3.1.1 acorn: 8.18.0 - astro: 7.2.10(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.49.2)(tsx@4.23.13)(yaml@2.9.0) + astro: 7.2.10(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.1) es-module-lexer: 2.3.1 estree-util-visit: 2.0.0 hast-util-to-html: 9.0.5 @@ -10412,7 +10816,7 @@ snapshots: '@astrojs/sitemap@3.7.4': dependencies: sitemap: 9.0.1 - zod: 4.5.4 + zod: 4.6.5 '@astrojs/telemetry@3.3.3': dependencies: @@ -10433,9 +10837,9 @@ snapshots: js-tokens: 4.0.0 picocolors: 1.1.1 - '@babel/code-frame@8.0.0': + '@babel/code-frame@8.0.6': dependencies: - '@babel/helper-validator-identifier': 8.0.4 + '@babel/helper-validator-identifier': 8.0.6 js-tokens: 10.0.0 '@babel/compat-data@7.29.7': {} @@ -10464,21 +10868,21 @@ snapshots: '@babel/core@8.0.1': dependencies: - '@babel/code-frame': 8.0.0 - '@babel/generator': 8.0.5 - '@babel/helper-compilation-targets': 8.0.5 - '@babel/helpers': 8.0.5 - '@babel/parser': 8.0.5 + '@babel/code-frame': 8.0.6 + '@babel/generator': 8.0.6 + '@babel/helper-compilation-targets': 8.0.7 + '@babel/helpers': 8.0.7 + '@babel/parser': 8.0.7 '@babel/template': 8.0.0 - '@babel/traverse': 8.0.5 - '@babel/types': 8.0.5 + '@babel/traverse': 8.0.7 + '@babel/types': 8.0.6 '@types/gensync': 1.0.5 convert-source-map: 2.0.0 - empathic: 2.0.1 + empathic: 2.1.0 gensync: 1.0.0-beta.2 import-meta-resolve: 4.2.0 json5: 2.2.3 - obug: 2.1.4 + obug: 2.2.1 semver: 7.8.5 '@babel/generator@7.29.8': @@ -10489,10 +10893,10 @@ snapshots: '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 - '@babel/generator@8.0.5': + '@babel/generator@8.0.6': dependencies: - '@babel/parser': 8.0.5 - '@babel/types': 8.0.5 + '@babel/parser': 8.0.7 + '@babel/types': 8.0.6 '@jridgewell/gen-mapping': 0.4.0-beta.0 '@jridgewell/trace-mapping': 0.3.31 '@types/jsesc': 2.5.1 @@ -10514,13 +10918,13 @@ snapshots: lru-cache: 5.1.1 semver: 6.3.1 - '@babel/helper-compilation-targets@8.0.5': + '@babel/helper-compilation-targets@8.0.7': dependencies: '@babel/compat-data': 8.0.5 '@babel/helper-validator-option': 8.0.0 browserslist: 4.28.8 - lru-cache: 11.5.2 - verkit: 0.3.2 + flru: 1.0.2 + verkit: 0.5.0 '@babel/helper-create-class-features-plugin@7.29.7(@babel/core@7.29.7)': dependencies: @@ -10530,7 +10934,7 @@ snapshots: '@babel/helper-optimise-call-expression': 7.29.7 '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7) '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 - '@babel/traverse': 7.29.8 + '@babel/traverse': 7.29.10 semver: 6.3.1 transitivePeerDependencies: - supports-color @@ -10555,18 +10959,18 @@ snapshots: '@babel/helper-globals@7.29.7': {} - '@babel/helper-globals@8.0.0': {} + '@babel/helper-globals@8.0.6': {} '@babel/helper-member-expression-to-functions@7.29.7': dependencies: - '@babel/traverse': 7.29.8 + '@babel/traverse': 7.29.10 '@babel/types': 7.29.8 transitivePeerDependencies: - supports-color '@babel/helper-module-imports@7.29.7': dependencies: - '@babel/traverse': 7.29.8 + '@babel/traverse': 7.29.10 '@babel/types': 7.29.8 transitivePeerDependencies: - supports-color @@ -10591,7 +10995,7 @@ snapshots: '@babel/core': 7.29.7 '@babel/helper-annotate-as-pure': 7.29.7 '@babel/helper-wrap-function': 7.29.7 - '@babel/traverse': 7.29.8 + '@babel/traverse': 7.29.10 transitivePeerDependencies: - supports-color @@ -10600,13 +11004,13 @@ snapshots: '@babel/core': 7.29.7 '@babel/helper-member-expression-to-functions': 7.29.7 '@babel/helper-optimise-call-expression': 7.29.7 - '@babel/traverse': 7.29.8 + '@babel/traverse': 7.29.10 transitivePeerDependencies: - supports-color '@babel/helper-skip-transparent-expression-wrappers@7.29.7': dependencies: - '@babel/traverse': 7.29.8 + '@babel/traverse': 7.29.10 '@babel/types': 7.29.8 transitivePeerDependencies: - supports-color @@ -10619,12 +11023,16 @@ snapshots: '@babel/helper-string-parser@8.0.0': {} + '@babel/helper-string-parser@8.0.6': {} + '@babel/helper-validator-identifier@7.28.5': {} '@babel/helper-validator-identifier@7.29.7': {} '@babel/helper-validator-identifier@8.0.4': {} + '@babel/helper-validator-identifier@8.0.6': {} + '@babel/helper-validator-option@7.29.7': {} '@babel/helper-validator-option@8.0.0': {} @@ -10632,7 +11040,7 @@ snapshots: '@babel/helper-wrap-function@7.29.7': dependencies: '@babel/template': 7.29.7 - '@babel/traverse': 7.29.8 + '@babel/traverse': 7.29.10 '@babel/types': 7.29.8 transitivePeerDependencies: - supports-color @@ -10642,24 +11050,28 @@ snapshots: '@babel/template': 7.29.7 '@babel/types': 7.29.8 - '@babel/helpers@8.0.5': + '@babel/helpers@8.0.7': dependencies: '@babel/template': 8.0.0 - '@babel/types': 8.0.5 + '@babel/types': 8.0.6 '@babel/parser@7.29.8': dependencies: '@babel/types': 7.29.8 - '@babel/parser@8.0.5': + '@babel/parser@7.29.9': dependencies: - '@babel/types': 8.0.5 + '@babel/types': 7.29.8 + + '@babel/parser@8.0.7': + dependencies: + '@babel/types': 8.0.6 '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.29.7(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 - '@babel/traverse': 7.29.8 + '@babel/traverse': 7.29.10 transitivePeerDependencies: - supports-color @@ -10694,7 +11106,7 @@ snapshots: dependencies: '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 - '@babel/traverse': 7.29.8 + '@babel/traverse': 7.29.10 transitivePeerDependencies: - supports-color @@ -10822,7 +11234,7 @@ snapshots: '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 '@babel/helper-remap-async-to-generator': 7.29.7(@babel/core@7.29.7) - '@babel/traverse': 7.29.8 + '@babel/traverse': 7.29.10 transitivePeerDependencies: - supports-color @@ -10869,7 +11281,7 @@ snapshots: '@babel/helper-globals': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7) - '@babel/traverse': 7.29.8 + '@babel/traverse': 7.29.10 transitivePeerDependencies: - supports-color @@ -10883,7 +11295,7 @@ snapshots: dependencies: '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 - '@babel/traverse': 7.29.8 + '@babel/traverse': 7.29.10 transitivePeerDependencies: - supports-color @@ -10940,7 +11352,7 @@ snapshots: '@babel/core': 7.29.7 '@babel/helper-compilation-targets': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 - '@babel/traverse': 7.29.8 + '@babel/traverse': 7.29.10 transitivePeerDependencies: - supports-color @@ -10986,7 +11398,7 @@ snapshots: '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) '@babel/helper-plugin-utils': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 - '@babel/traverse': 7.29.8 + '@babel/traverse': 7.29.10 transitivePeerDependencies: - supports-color @@ -11026,7 +11438,7 @@ snapshots: '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-transform-destructuring': 7.29.7(@babel/core@7.29.7) '@babel/plugin-transform-parameters': 7.29.7(@babel/core@7.29.7) - '@babel/traverse': 7.29.8 + '@babel/traverse': 7.29.10 transitivePeerDependencies: - supports-color @@ -11263,6 +11675,8 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/runtime@7.29.10': {} + '@babel/runtime@7.29.7': {} '@babel/template@7.29.7': @@ -11273,9 +11687,21 @@ snapshots: '@babel/template@8.0.0': dependencies: - '@babel/code-frame': 8.0.0 - '@babel/parser': 8.0.5 - '@babel/types': 8.0.5 + '@babel/code-frame': 8.0.6 + '@babel/parser': 8.0.7 + '@babel/types': 8.0.6 + + '@babel/traverse@7.29.10': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.9 + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + debug: 4.4.3(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color '@babel/traverse@7.29.8': dependencies: @@ -11289,15 +11715,15 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/traverse@8.0.5': + '@babel/traverse@8.0.7': dependencies: - '@babel/code-frame': 8.0.0 - '@babel/generator': 8.0.5 - '@babel/helper-globals': 8.0.0 - '@babel/parser': 8.0.5 + '@babel/code-frame': 8.0.6 + '@babel/generator': 8.0.6 + '@babel/helper-globals': 8.0.6 + '@babel/parser': 8.0.7 '@babel/template': 8.0.0 - '@babel/types': 8.0.5 - obug: 2.1.4 + '@babel/types': 8.0.6 + obug: 3.0.0 '@babel/types@7.29.8': dependencies: @@ -11309,6 +11735,11 @@ snapshots: '@babel/helper-string-parser': 8.0.0 '@babel/helper-validator-identifier': 8.0.4 + '@babel/types@8.0.6': + dependencies: + '@babel/helper-string-parser': 8.0.6 + '@babel/helper-validator-identifier': 8.0.6 + '@bcoe/v8-coverage@0.2.3': {} '@bruits/satteri-darwin-arm64@0.10.5': @@ -11558,7 +11989,7 @@ snapshots: '@esbuild-kit/esm-loader@2.6.5': dependencies: '@esbuild-kit/core-utils': 3.3.2 - get-tsconfig: 4.13.0 + get-tsconfig: 4.14.3 '@esbuild/aix-ppc64@0.25.12': optional: true @@ -11782,10 +12213,6 @@ snapshots: '@harperfast/extended-iterable@1.0.3': optional: true - '@hono/node-server@1.19.17(hono@4.13.5)': - dependencies: - hono: 4.13.5 - '@humanfs/core@0.19.2': dependencies: '@humanfs/types': 0.15.0 @@ -11910,12 +12337,14 @@ snapshots: '@inquirer/ansi@2.0.8': {} - '@inquirer/checkbox@5.2.5(@types/node@20.19.9)': + '@inquirer/ansi@2.0.9': {} + + '@inquirer/checkbox@5.2.6(@types/node@20.19.9)': dependencies: - '@inquirer/ansi': 2.0.8 - '@inquirer/core': 12.0.3(@types/node@20.19.9) + '@inquirer/ansi': 2.0.9 + '@inquirer/core': 12.0.4(@types/node@20.19.9) '@inquirer/figures': 2.0.9 - '@inquirer/type': 4.1.1(@types/node@20.19.9) + '@inquirer/type': 4.2.0(@types/node@20.19.9) optionalDependencies: '@types/node': 20.19.9 @@ -11926,10 +12355,10 @@ snapshots: optionalDependencies: '@types/node': 20.19.9 - '@inquirer/confirm@6.3.2(@types/node@20.19.9)': + '@inquirer/confirm@6.3.3(@types/node@20.19.9)': dependencies: - '@inquirer/core': 12.0.3(@types/node@20.19.9) - '@inquirer/type': 4.1.1(@types/node@20.19.9) + '@inquirer/core': 12.0.4(@types/node@20.19.9) + '@inquirer/type': 4.2.0(@types/node@20.19.9) optionalDependencies: '@types/node': 20.19.9 @@ -11937,7 +12366,7 @@ snapshots: dependencies: '@inquirer/ansi': 2.0.8 '@inquirer/figures': 2.0.9 - '@inquirer/type': 4.1.1(@types/node@20.19.9) + '@inquirer/type': 4.2.0(@types/node@20.19.9) cli-width: 4.1.0 fast-wrap-ansi: 0.2.2 mute-stream: 3.0.0 @@ -11945,11 +12374,11 @@ snapshots: optionalDependencies: '@types/node': 20.19.9 - '@inquirer/core@12.0.3(@types/node@20.19.9)': + '@inquirer/core@12.0.4(@types/node@20.19.9)': dependencies: - '@inquirer/ansi': 2.0.8 + '@inquirer/ansi': 2.0.9 '@inquirer/figures': 2.0.9 - '@inquirer/type': 4.1.1(@types/node@20.19.9) + '@inquirer/type': 4.2.0(@types/node@20.19.9) cli-width: 4.1.0 fast-wrap-ansi: 0.2.2 mute-stream: 3.0.0 @@ -11957,22 +12386,22 @@ snapshots: optionalDependencies: '@types/node': 20.19.9 - '@inquirer/editor@5.3.3(@types/node@20.19.9)': + '@inquirer/editor@5.3.4(@types/node@20.19.9)': dependencies: - '@inquirer/core': 12.0.3(@types/node@20.19.9) - '@inquirer/external-editor': 3.0.5(@types/node@20.19.9) - '@inquirer/type': 4.1.1(@types/node@20.19.9) + '@inquirer/core': 12.0.4(@types/node@20.19.9) + '@inquirer/external-editor': 3.0.6(@types/node@20.19.9) + '@inquirer/type': 4.2.0(@types/node@20.19.9) optionalDependencies: '@types/node': 20.19.9 - '@inquirer/expand@5.1.5(@types/node@20.19.9)': + '@inquirer/expand@5.1.6(@types/node@20.19.9)': dependencies: - '@inquirer/core': 12.0.3(@types/node@20.19.9) - '@inquirer/type': 4.1.1(@types/node@20.19.9) + '@inquirer/core': 12.0.4(@types/node@20.19.9) + '@inquirer/type': 4.2.0(@types/node@20.19.9) optionalDependencies: '@types/node': 20.19.9 - '@inquirer/external-editor@3.0.5(@types/node@20.19.9)': + '@inquirer/external-editor@3.0.6(@types/node@20.19.9)': dependencies: chardet: 2.2.0 iconv-lite: 0.7.3 @@ -11981,64 +12410,64 @@ snapshots: '@inquirer/figures@2.0.9': {} - '@inquirer/input@5.1.6(@types/node@20.19.9)': + '@inquirer/input@5.1.7(@types/node@20.19.9)': dependencies: - '@inquirer/core': 12.0.3(@types/node@20.19.9) - '@inquirer/type': 4.1.1(@types/node@20.19.9) + '@inquirer/core': 12.0.4(@types/node@20.19.9) + '@inquirer/type': 4.2.0(@types/node@20.19.9) optionalDependencies: '@types/node': 20.19.9 - '@inquirer/number@4.2.3(@types/node@20.19.9)': + '@inquirer/number@4.2.4(@types/node@20.19.9)': dependencies: - '@inquirer/core': 12.0.3(@types/node@20.19.9) - '@inquirer/type': 4.1.1(@types/node@20.19.9) + '@inquirer/core': 12.0.4(@types/node@20.19.9) + '@inquirer/type': 4.2.0(@types/node@20.19.9) optionalDependencies: '@types/node': 20.19.9 - '@inquirer/password@5.2.2(@types/node@20.19.9)': + '@inquirer/password@5.2.3(@types/node@20.19.9)': dependencies: - '@inquirer/ansi': 2.0.8 - '@inquirer/core': 12.0.3(@types/node@20.19.9) - '@inquirer/type': 4.1.1(@types/node@20.19.9) + '@inquirer/ansi': 2.0.9 + '@inquirer/core': 12.0.4(@types/node@20.19.9) + '@inquirer/type': 4.2.0(@types/node@20.19.9) optionalDependencies: '@types/node': 20.19.9 - '@inquirer/prompts@8.5.2(@types/node@20.19.9)': + '@inquirer/prompts@8.7.2(@types/node@20.19.9)': dependencies: - '@inquirer/checkbox': 5.2.5(@types/node@20.19.9) - '@inquirer/confirm': 6.3.2(@types/node@20.19.9) - '@inquirer/editor': 5.3.3(@types/node@20.19.9) - '@inquirer/expand': 5.1.5(@types/node@20.19.9) - '@inquirer/input': 5.1.6(@types/node@20.19.9) - '@inquirer/number': 4.2.3(@types/node@20.19.9) - '@inquirer/password': 5.2.2(@types/node@20.19.9) - '@inquirer/rawlist': 5.3.5(@types/node@20.19.9) - '@inquirer/search': 4.3.3(@types/node@20.19.9) - '@inquirer/select': 5.2.5(@types/node@20.19.9) + '@inquirer/checkbox': 5.2.6(@types/node@20.19.9) + '@inquirer/confirm': 6.3.3(@types/node@20.19.9) + '@inquirer/editor': 5.3.4(@types/node@20.19.9) + '@inquirer/expand': 5.1.6(@types/node@20.19.9) + '@inquirer/input': 5.1.7(@types/node@20.19.9) + '@inquirer/number': 4.2.4(@types/node@20.19.9) + '@inquirer/password': 5.2.3(@types/node@20.19.9) + '@inquirer/rawlist': 5.3.6(@types/node@20.19.9) + '@inquirer/search': 4.3.4(@types/node@20.19.9) + '@inquirer/select': 5.2.6(@types/node@20.19.9) optionalDependencies: '@types/node': 20.19.9 - '@inquirer/rawlist@5.3.5(@types/node@20.19.9)': + '@inquirer/rawlist@5.3.6(@types/node@20.19.9)': dependencies: - '@inquirer/core': 12.0.3(@types/node@20.19.9) - '@inquirer/type': 4.1.1(@types/node@20.19.9) + '@inquirer/core': 12.0.4(@types/node@20.19.9) + '@inquirer/type': 4.2.0(@types/node@20.19.9) optionalDependencies: '@types/node': 20.19.9 - '@inquirer/search@4.3.3(@types/node@20.19.9)': + '@inquirer/search@4.3.4(@types/node@20.19.9)': dependencies: - '@inquirer/core': 12.0.3(@types/node@20.19.9) + '@inquirer/core': 12.0.4(@types/node@20.19.9) '@inquirer/figures': 2.0.9 - '@inquirer/type': 4.1.1(@types/node@20.19.9) + '@inquirer/type': 4.2.0(@types/node@20.19.9) optionalDependencies: '@types/node': 20.19.9 - '@inquirer/select@5.2.5(@types/node@20.19.9)': + '@inquirer/select@5.2.6(@types/node@20.19.9)': dependencies: - '@inquirer/ansi': 2.0.8 - '@inquirer/core': 12.0.3(@types/node@20.19.9) + '@inquirer/ansi': 2.0.9 + '@inquirer/core': 12.0.4(@types/node@20.19.9) '@inquirer/figures': 2.0.9 - '@inquirer/type': 4.1.1(@types/node@20.19.9) + '@inquirer/type': 4.2.0(@types/node@20.19.9) optionalDependencies: '@types/node': 20.19.9 @@ -12046,6 +12475,10 @@ snapshots: optionalDependencies: '@types/node': 20.19.9 + '@inquirer/type@4.2.0(@types/node@20.19.9)': + optionalDependencies: + '@types/node': 20.19.9 + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -12078,13 +12511,22 @@ snapshots: jest-util: 30.5.1 slash: 3.0.0 - '@jest/core@30.5.1(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.2))(ts-node@10.9.2(@swc/core@1.16.1(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@6.0.3))': + '@jest/console@30.5.2': dependencies: - '@jest/console': 30.5.1 + '@jest/types': 30.5.1 + '@types/node': 20.19.9 + chalk: 4.1.2 + jest-message-util: 30.5.1 + jest-util: 30.5.1 + slash: 3.0.0 + + '@jest/core@30.5.2(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.2))(ts-node@10.9.2(@swc/core@1.16.13(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@6.0.3))': + dependencies: + '@jest/console': 30.5.2 '@jest/pattern': 30.5.0 - '@jest/reporters': 30.5.1 - '@jest/test-result': 30.5.1 - '@jest/transform': 30.5.1 + '@jest/reporters': 30.5.2 + '@jest/test-result': 30.5.2 + '@jest/transform': 30.5.2 '@jest/types': 30.5.1 '@types/node': 20.19.9 ansi-escapes: 4.3.2 @@ -12094,18 +12536,18 @@ snapshots: fast-json-stable-stringify: 2.1.0 graceful-fs: 4.2.11 jest-changed-files: 30.5.1 - jest-config: 30.5.1(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.2))(ts-node@10.9.2(@swc/core@1.16.1(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@6.0.3)) + jest-config: 30.5.2(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.2))(ts-node@10.9.2(@swc/core@1.16.13(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@6.0.3)) jest-haste-map: 30.5.1 jest-message-util: 30.5.1 jest-regex-util: 30.5.0 jest-resolve: 30.5.1 - jest-resolve-dependencies: 30.5.1 - jest-runner: 30.5.1 - jest-runtime: 30.5.1 - jest-snapshot: 30.5.1 + jest-resolve-dependencies: 30.5.2 + jest-runner: 30.5.2 + jest-runtime: 30.5.2 + jest-snapshot: 30.5.2 jest-util: 30.5.1 jest-validate: 30.5.1 - jest-watcher: 30.5.1 + jest-watcher: 30.5.2 pretty-format: 30.5.1 slash: 3.0.0 transitivePeerDependencies: @@ -12118,14 +12560,14 @@ snapshots: '@jest/diff-sequences@30.5.0': {} - '@jest/environment-jsdom-abstract@30.5.1(@types/jsdom@21.1.7)(jsdom@26.1.0)': + '@jest/environment-jsdom-abstract@30.5.2(@types/jsdom@21.1.7)(jsdom@26.1.0)': dependencies: - '@jest/environment': 30.5.1 - '@jest/fake-timers': 30.5.1 + '@jest/environment': 30.5.2 + '@jest/fake-timers': 30.5.2 '@jest/types': 30.5.1 '@types/jsdom': 21.1.7 '@types/node': 20.19.9 - jest-mock: 30.5.1 + jest-mock: 30.5.2 jest-util: 30.5.1 jsdom: 26.1.0 @@ -12136,6 +12578,13 @@ snapshots: '@types/node': 20.19.9 jest-mock: 30.5.1 + '@jest/environment@30.5.2': + dependencies: + '@jest/fake-timers': 30.5.2 + '@jest/types': 30.5.1 + '@types/node': 20.19.9 + jest-mock: 30.5.2 + '@jest/expect-utils@30.2.0': dependencies: '@jest/get-type': 30.1.0 @@ -12144,6 +12593,10 @@ snapshots: dependencies: '@jest/get-type': 30.5.0 + '@jest/expect-utils@30.5.2': + dependencies: + '@jest/get-type': 30.5.0 + '@jest/expect@30.5.1': dependencies: expect: 30.5.1 @@ -12151,6 +12604,13 @@ snapshots: transitivePeerDependencies: - supports-color + '@jest/expect@30.5.2': + dependencies: + expect: 30.5.2 + jest-snapshot: 30.5.2 + transitivePeerDependencies: + - supports-color + '@jest/fake-timers@30.5.1': dependencies: '@jest/types': 30.5.1 @@ -12160,6 +12620,15 @@ snapshots: jest-mock: 30.5.1 jest-util: 30.5.1 + '@jest/fake-timers@30.5.2': + dependencies: + '@jest/types': 30.5.1 + '@sinonjs/fake-timers': 15.4.0 + '@types/node': 20.19.9 + jest-message-util: 30.5.1 + jest-mock: 30.5.2 + jest-util: 30.5.1 + '@jest/get-type@30.1.0': {} '@jest/get-type@30.5.0': {} @@ -12173,6 +12642,15 @@ snapshots: transitivePeerDependencies: - supports-color + '@jest/globals@30.5.2': + dependencies: + '@jest/environment': 30.5.2 + '@jest/expect': 30.5.2 + '@jest/types': 30.5.1 + jest-mock: 30.5.2 + transitivePeerDependencies: + - supports-color + '@jest/pattern@30.0.1': dependencies: '@types/node': 20.19.9 @@ -12211,6 +12689,34 @@ snapshots: transitivePeerDependencies: - supports-color + '@jest/reporters@30.5.2': + dependencies: + '@bcoe/v8-coverage': 0.2.3 + '@jest/console': 30.5.2 + '@jest/test-result': 30.5.2 + '@jest/transform': 30.5.2 + '@jest/types': 30.5.1 + '@jridgewell/trace-mapping': 0.3.31 + '@types/node': 20.19.9 + chalk: 4.1.2 + collect-v8-coverage: 1.0.3 + exit-x: 0.2.2 + glob: 13.0.6 + graceful-fs: 4.2.11 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-instrument: 6.0.3 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 5.0.6 + istanbul-reports: 3.2.0 + jest-message-util: 30.5.1 + jest-util: 30.5.1 + jest-worker: 30.5.1 + slash: 3.0.0 + string-length: 4.0.2 + v8-to-istanbul: 9.3.0 + transitivePeerDependencies: + - supports-color + '@jest/schemas@30.0.5': dependencies: '@sinclair/typebox': 0.34.46 @@ -12233,6 +12739,13 @@ snapshots: convert-source-map: 2.0.0 graceful-fs: 4.2.11 + '@jest/source-map@30.5.2': + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + callsites: 3.1.0 + convert-source-map: 2.0.0 + graceful-fs: 4.2.11 + '@jest/test-result@30.5.1': dependencies: '@jest/console': 30.5.1 @@ -12240,6 +12753,13 @@ snapshots: '@types/istanbul-lib-coverage': 2.0.6 collect-v8-coverage: 1.0.3 + '@jest/test-result@30.5.2': + dependencies: + '@jest/console': 30.5.2 + '@jest/types': 30.5.1 + '@types/istanbul-lib-coverage': 2.0.6 + collect-v8-coverage: 1.0.3 + '@jest/test-sequencer@30.5.1': dependencies: '@jest/test-result': 30.5.1 @@ -12247,6 +12767,13 @@ snapshots: jest-haste-map: 30.5.1 slash: 3.0.0 + '@jest/test-sequencer@30.5.2': + dependencies: + '@jest/test-result': 30.5.2 + graceful-fs: 4.2.11 + jest-haste-map: 30.5.1 + slash: 3.0.0 + '@jest/transform@30.5.1': dependencies: '@babel/core': 7.29.7 @@ -12266,6 +12793,25 @@ snapshots: transitivePeerDependencies: - supports-color + '@jest/transform@30.5.2': + dependencies: + '@babel/core': 7.29.7 + '@jest/types': 30.5.1 + '@jridgewell/trace-mapping': 0.3.31 + babel-plugin-istanbul: 8.0.0 + chalk: 4.1.2 + convert-source-map: 2.0.0 + fast-json-stable-stringify: 2.1.0 + graceful-fs: 4.2.11 + jest-haste-map: 30.5.1 + jest-regex-util: 30.5.0 + jest-util: 30.5.1 + pirates: 4.0.7 + slash: 3.0.0 + write-file-atomic: 5.0.1 + transitivePeerDependencies: + - supports-color + '@jest/types@30.2.0': dependencies: '@jest/pattern': 30.0.1 @@ -12288,7 +12834,7 @@ snapshots: '@jridgewell/gen-mapping@0.3.13': dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/sourcemap-codec': 1.6.0 '@jridgewell/trace-mapping': 0.3.31 '@jridgewell/gen-mapping@0.4.0-beta.0': @@ -12308,25 +12854,23 @@ snapshots: '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 - '@jridgewell/sourcemap-codec@1.5.5': {} - '@jridgewell/sourcemap-codec@1.6.0': {} '@jridgewell/trace-mapping@0.3.31': dependencies: '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/sourcemap-codec': 1.6.0 '@jridgewell/trace-mapping@0.3.9': dependencies: '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/sourcemap-codec': 1.6.0 - '@listr2/prompt-adapter-inquirer@4.2.5(@inquirer/prompts@8.5.2(@types/node@20.19.9))(@types/node@20.19.9)(listr2@11.0.0)': + '@listr2/prompt-adapter-inquirer@4.2.7(@inquirer/prompts@8.7.2(@types/node@20.19.9))(@types/node@20.19.9)(listr2@11.1.0)': dependencies: - '@inquirer/prompts': 8.5.2(@types/node@20.19.9) - '@inquirer/type': 4.1.1(@types/node@20.19.9) - listr2: 11.0.0 + '@inquirer/prompts': 8.7.2(@types/node@20.19.9) + '@inquirer/type': 4.2.0(@types/node@20.19.9) + listr2: 11.1.0 transitivePeerDependencies: - '@types/node' @@ -12394,27 +12938,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@modelcontextprotocol/sdk@1.30.0(zod@4.4.3)': + '@modelcontextprotocol/core@2.3.1': dependencies: - '@hono/node-server': 1.19.17(hono@4.13.5) - ajv: 8.20.0 - ajv-formats: 3.0.1(ajv@8.20.0) - content-type: 1.0.5 - cors: 2.8.6 - cross-spawn: 7.0.6 - eventsource: 3.0.7 - eventsource-parser: 3.1.1 - express: 5.2.1 - express-rate-limit: 8.3.0(express@5.2.1) - hono: 4.13.5 - jose: 6.2.9 - json-schema-typed: 8.0.2 - pkce-challenge: 5.0.1 - raw-body: 3.0.2 - zod: 4.4.3 - zod-to-json-schema: 3.25.2(zod@4.4.3) - transitivePeerDependencies: - - supports-color + zod: 4.6.5 + + '@modelcontextprotocol/server@2.3.1': + dependencies: + '@modelcontextprotocol/core': 2.3.1 + zod: 4.6.5 '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': optional: true @@ -12554,73 +13085,73 @@ snapshots: '@tybys/wasm-util': 0.10.3 optional: true - '@ngrx/effects@22.0.1(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(@ngrx/store@22.0.1(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(rxjs@7.8.2)': + '@ngrx/effects@22.0.1(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(@ngrx/store@22.0.1(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(rxjs@7.8.2)': dependencies: - '@angular/core': 22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3) - '@ngrx/store': 22.0.1(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2) + '@angular/core': 22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3) + '@ngrx/store': 22.0.1(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2) rxjs: 7.8.2 tslib: 2.8.1 - '@ngrx/entity@22.0.1(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(@ngrx/store@22.0.1(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(rxjs@7.8.2)': + '@ngrx/entity@22.0.1(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(@ngrx/store@22.0.1(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(rxjs@7.8.2)': dependencies: - '@angular/core': 22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3) - '@ngrx/store': 22.0.1(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2) + '@angular/core': 22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3) + '@ngrx/store': 22.0.1(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2) rxjs: 7.8.2 tslib: 2.8.1 - '@ngrx/eslint-plugin@22.0.1(@typescript-eslint/utils@8.69.0(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3))(eslint@9.39.2(jiti@1.21.7))(typescript-eslint@8.69.0(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3))(typescript@6.0.3)': + '@ngrx/eslint-plugin@22.0.1(@typescript-eslint/utils@8.71.1(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3))(eslint@9.39.2(jiti@1.21.7))(typescript-eslint@8.71.1(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3))(typescript@6.0.3)': dependencies: - '@typescript-eslint/utils': 8.69.0(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3) + '@typescript-eslint/utils': 8.71.1(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3) eslint: 9.39.2(jiti@1.21.7) typescript: 6.0.3 - typescript-eslint: 8.69.0(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3) + typescript-eslint: 8.71.1(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3) - '@ngrx/router-store@22.0.1(@angular/common@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(@angular/router@22.1.6(@angular/common@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(@angular/platform-browser@22.1.6(@angular/animations@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/common@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3)))(rxjs@7.8.2))(@ngrx/store@22.0.1(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(rxjs@7.8.2)': + '@ngrx/router-store@22.0.1(@angular/common@22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(@angular/router@22.2.2(@angular/common@22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(@angular/platform-browser@22.2.2(@angular/animations@22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/common@22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3)))(rxjs@7.8.2))(@ngrx/store@22.0.1(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(rxjs@7.8.2)': dependencies: - '@angular/common': 22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2) - '@angular/core': 22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3) - '@angular/router': 22.1.6(@angular/common@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(@angular/platform-browser@22.1.6(@angular/animations@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/common@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3)))(rxjs@7.8.2) - '@ngrx/store': 22.0.1(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2) + '@angular/common': 22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2) + '@angular/core': 22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3) + '@angular/router': 22.2.2(@angular/common@22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(@angular/platform-browser@22.2.2(@angular/animations@22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/common@22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3)))(rxjs@7.8.2) + '@ngrx/store': 22.0.1(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2) rxjs: 7.8.2 tslib: 2.8.1 - '@ngrx/signals@22.0.1(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2)': + '@ngrx/signals@22.0.1(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2)': dependencies: - '@angular/core': 22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3) + '@angular/core': 22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3) tslib: 2.8.1 optionalDependencies: rxjs: 7.8.2 - '@ngrx/store-devtools@22.0.1(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(@ngrx/store@22.0.1(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(rxjs@7.8.2)': + '@ngrx/store-devtools@22.0.1(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(@ngrx/store@22.0.1(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(rxjs@7.8.2)': dependencies: - '@angular/core': 22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3) - '@ngrx/store': 22.0.1(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2) + '@angular/core': 22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3) + '@ngrx/store': 22.0.1(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2) rxjs: 7.8.2 tslib: 2.8.1 - '@ngrx/store@22.0.1(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2)': + '@ngrx/store@22.0.1(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2)': dependencies: - '@angular/core': 22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3) + '@angular/core': 22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3) rxjs: 7.8.2 tslib: 2.8.1 - '@ngx-pwa/local-storage@22.0.0(@angular/common@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2)': + '@ngx-pwa/local-storage@22.0.0(@angular/common@22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2)': dependencies: - '@angular/common': 22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2) - '@angular/core': 22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3) + '@angular/common': 22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2) + '@angular/core': 22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3) rxjs: 7.8.2 tslib: 2.8.1 - '@ngx-translate/core@16.0.4(@angular/common@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))': + '@ngx-translate/core@16.0.4(@angular/common@22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))': dependencies: - '@angular/common': 22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2) - '@angular/core': 22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3) + '@angular/common': 22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2) + '@angular/core': 22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3) tslib: 2.8.1 - '@ngx-translate/http-loader@16.0.1(@angular/common@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))': + '@ngx-translate/http-loader@16.0.1(@angular/common@22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))': dependencies: - '@angular/common': 22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2) - '@angular/core': 22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3) + '@angular/common': 22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2) + '@angular/core': 22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3) tslib: 2.8.1 '@noble/hashes@1.4.0': {} @@ -12700,16 +13231,16 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 - '@nx/angular@23.2.1(e86c4a4fb085aa7a209327ce2b2f688a)': + '@nx/angular@23.2.1(eefa38d48d90431e98f5616ec43eae75)': dependencies: - '@angular-devkit/core': 22.1.8(chokidar@5.0.0) - '@angular-devkit/schematics': 22.1.8(chokidar@5.0.0) - '@nx/devkit': 23.2.1(nx@23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.1(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.1(@swc/helpers@0.5.23))) - '@nx/eslint': 23.2.1(@babel/traverse@7.29.8)(@nx/jest@23.2.1(72e9877c63da59ac93e1f6ba5cf055e8))(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.1(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.1(@swc/helpers@0.5.23))(@zkochan/js-yaml@0.0.7)(eslint@9.39.2(jiti@1.21.7))(nx@23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.1(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.1(@swc/helpers@0.5.23))) - '@nx/js': 23.2.1(@babel/traverse@7.29.8)(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.1(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.1(@swc/helpers@0.5.23))(nx@23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.1(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.1(@swc/helpers@0.5.23))) - '@nx/web': 23.2.1(f806492ec758094179990070b023c41c) + '@angular-devkit/core': 22.2.2(chokidar@5.0.0) + '@angular-devkit/schematics': 22.2.2(chokidar@5.0.0) + '@nx/devkit': 23.2.1(nx@23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.13(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.13(@swc/helpers@0.5.23))) + '@nx/eslint': 23.2.1(@babel/traverse@7.29.10)(@nx/jest@23.2.1(45be1c4077e405ec0f48ec85376a2be8))(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.13(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.13(@swc/helpers@0.5.23))(@zkochan/js-yaml@0.0.7)(eslint@9.39.2(jiti@1.21.7))(nx@23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.13(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.13(@swc/helpers@0.5.23))) + '@nx/js': 23.2.1(@babel/traverse@7.29.10)(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.13(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.13(@swc/helpers@0.5.23))(nx@23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.13(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.13(@swc/helpers@0.5.23))) + '@nx/web': 23.2.1(33e69643271612c9d9b31a956c0e020d) '@phenomnomnominal/tsquery': 6.2.0(typescript@6.0.3) - '@schematics/angular': 22.1.8(chokidar@5.0.0) + '@schematics/angular': 22.2.2(chokidar@5.0.0) '@typescript-eslint/type-utils': 8.69.0(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3) jsonc-parser: 3.3.1 magic-string: 0.30.21 @@ -12719,8 +13250,8 @@ snapshots: semver: 7.8.5 tslib: 2.8.1 optionalDependencies: - '@angular/build': 22.1.8(@angular/compiler-cli@22.1.6(@angular/compiler@22.1.6)(typescript@6.0.3))(@angular/compiler@22.1.6)(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(@angular/platform-browser@22.1.6(@angular/animations@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/common@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/service-worker@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@types/node@20.19.9)(chokidar@5.0.0)(istanbul-lib-instrument@6.0.3)(jiti@1.21.7)(less@4.5.1)(postcss@8.5.26)(rollup@4.59.0)(sass-embedded@1.100.0)(tailwindcss@3.4.19(tsx@4.23.13)(yaml@2.9.0))(terser@5.49.2)(tslib@2.8.1)(tsx@4.23.13)(typescript@6.0.3)(yaml@2.9.0) - '@nx/playwright': 23.2.1(@babel/traverse@7.29.8)(@nx/jest@23.2.1(72e9877c63da59ac93e1f6ba5cf055e8))(@playwright/test@1.62.1)(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.1(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.1(@swc/helpers@0.5.23))(@zkochan/js-yaml@0.0.7)(eslint@9.39.2(jiti@1.21.7))(nx@23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.1(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.1(@swc/helpers@0.5.23))) + '@angular/build': 22.1.8(@angular/compiler-cli@22.2.2(@angular/compiler@22.2.2)(typescript@6.0.3))(@angular/compiler@22.2.2)(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(@angular/platform-browser@22.2.2(@angular/animations@22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/common@22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/service-worker@22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@types/node@20.19.9)(chokidar@5.0.0)(istanbul-lib-instrument@6.0.3)(jiti@1.21.7)(less@4.5.1)(postcss@8.5.26)(rollup@4.59.0)(sass-embedded@1.100.0)(tailwindcss@3.4.19(tsx@4.23.15)(yaml@2.9.1))(terser@5.49.2)(tslib@2.8.1)(tsx@4.23.15)(typescript@6.0.3)(yaml@2.9.1) + '@nx/playwright': 23.2.1(@babel/traverse@7.29.10)(@nx/jest@23.2.1(45be1c4077e405ec0f48ec85376a2be8))(@playwright/test@1.64.0)(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.13(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.13(@swc/helpers@0.5.23))(@zkochan/js-yaml@0.0.7)(eslint@9.39.2(jiti@1.21.7))(nx@23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.13(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.13(@swc/helpers@0.5.23))) webpack-merge: 5.10.0 transitivePeerDependencies: - '@babel/traverse' @@ -12737,19 +13268,19 @@ snapshots: - typescript - verdaccio - '@nx/devkit@23.2.1(nx@23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.1(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.1(@swc/helpers@0.5.23)))': + '@nx/devkit@23.2.1(nx@23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.13(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.13(@swc/helpers@0.5.23)))': dependencies: ejs: 5.0.1 minimatch: 10.2.5 - nx: 23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.1(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.1(@swc/helpers@0.5.23)) + nx: 23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.13(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.13(@swc/helpers@0.5.23)) semver: 7.8.5 tslib: 2.8.1 yargs-parser: 21.1.1 - '@nx/esbuild@23.2.1(@babel/traverse@7.29.8)(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.1(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.1(@swc/helpers@0.5.23))(esbuild@0.28.2)(nx@23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.1(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.1(@swc/helpers@0.5.23)))': + '@nx/esbuild@23.2.1(@babel/traverse@7.29.10)(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.13(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.13(@swc/helpers@0.5.23))(esbuild@0.28.2)(nx@23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.13(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.13(@swc/helpers@0.5.23)))': dependencies: - '@nx/devkit': 23.2.1(nx@23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.1(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.1(@swc/helpers@0.5.23))) - '@nx/js': 23.2.1(@babel/traverse@7.29.8)(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.1(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.1(@swc/helpers@0.5.23))(nx@23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.1(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.1(@swc/helpers@0.5.23))) + '@nx/devkit': 23.2.1(nx@23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.13(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.13(@swc/helpers@0.5.23))) + '@nx/js': 23.2.1(@babel/traverse@7.29.10)(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.13(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.13(@swc/helpers@0.5.23))(nx@23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.13(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.13(@swc/helpers@0.5.23))) picocolors: 1.1.1 tinyglobby: 0.2.17 tsconfig-paths: 4.2.0 @@ -12765,13 +13296,13 @@ snapshots: - supports-color - verdaccio - '@nx/eslint-plugin@23.2.1(@babel/traverse@7.29.8)(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.1(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.1(@swc/helpers@0.5.23))(@typescript-eslint/parser@8.69.0(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3))(eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@1.21.7)))(eslint@9.39.2(jiti@1.21.7))(nx@23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.1(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.1(@swc/helpers@0.5.23)))(typescript@6.0.3)': + '@nx/eslint-plugin@23.2.1(@babel/traverse@7.29.10)(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.13(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.13(@swc/helpers@0.5.23))(@typescript-eslint/parser@8.71.1(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3))(eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@1.21.7)))(eslint@9.39.2(jiti@1.21.7))(nx@23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.13(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.13(@swc/helpers@0.5.23)))(typescript@6.0.3)': dependencies: - '@nx/devkit': 23.2.1(nx@23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.1(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.1(@swc/helpers@0.5.23))) - '@nx/js': 23.2.1(@babel/traverse@7.29.8)(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.1(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.1(@swc/helpers@0.5.23))(nx@23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.1(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.1(@swc/helpers@0.5.23))) + '@nx/devkit': 23.2.1(nx@23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.13(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.13(@swc/helpers@0.5.23))) + '@nx/js': 23.2.1(@babel/traverse@7.29.10)(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.13(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.13(@swc/helpers@0.5.23))(nx@23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.13(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.13(@swc/helpers@0.5.23))) '@phenomnomnominal/tsquery': 6.2.0(typescript@6.0.3) '@typescript-eslint/type-utils': 8.69.0(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3) - '@typescript-eslint/utils': 8.69.0(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3) + '@typescript-eslint/utils': 8.71.1(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3) confusing-browser-globals: 1.0.11 globals: 17.9.0 jsonc-eslint-parser: 2.4.2 @@ -12779,7 +13310,7 @@ snapshots: semver: 7.8.5 tslib: 2.8.1 optionalDependencies: - '@typescript-eslint/parser': 8.69.0(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3) + '@typescript-eslint/parser': 8.71.1(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3) eslint-config-prettier: 10.1.8(eslint@9.39.2(jiti@1.21.7)) transitivePeerDependencies: - '@babel/traverse' @@ -12792,16 +13323,16 @@ snapshots: - typescript - verdaccio - '@nx/eslint@23.2.1(@babel/traverse@7.29.8)(@nx/jest@23.2.1(72e9877c63da59ac93e1f6ba5cf055e8))(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.1(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.1(@swc/helpers@0.5.23))(@zkochan/js-yaml@0.0.7)(eslint@9.39.2(jiti@1.21.7))(nx@23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.1(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.1(@swc/helpers@0.5.23)))': + '@nx/eslint@23.2.1(@babel/traverse@7.29.10)(@nx/jest@23.2.1(45be1c4077e405ec0f48ec85376a2be8))(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.13(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.13(@swc/helpers@0.5.23))(@zkochan/js-yaml@0.0.7)(eslint@9.39.2(jiti@1.21.7))(nx@23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.13(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.13(@swc/helpers@0.5.23)))': dependencies: - '@nx/devkit': 23.2.1(nx@23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.1(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.1(@swc/helpers@0.5.23))) - '@nx/js': 23.2.1(@babel/traverse@7.29.8)(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.1(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.1(@swc/helpers@0.5.23))(nx@23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.1(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.1(@swc/helpers@0.5.23))) + '@nx/devkit': 23.2.1(nx@23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.13(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.13(@swc/helpers@0.5.23))) + '@nx/js': 23.2.1(@babel/traverse@7.29.10)(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.13(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.13(@swc/helpers@0.5.23))(nx@23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.13(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.13(@swc/helpers@0.5.23))) eslint: 9.39.2(jiti@1.21.7) semver: 7.8.5 tslib: 2.8.1 typescript: 6.0.3 optionalDependencies: - '@nx/jest': 23.2.1(72e9877c63da59ac93e1f6ba5cf055e8) + '@nx/jest': 23.2.1(45be1c4077e405ec0f48ec85376a2be8) '@zkochan/js-yaml': 0.0.7 transitivePeerDependencies: - '@babel/traverse' @@ -12812,15 +13343,15 @@ snapshots: - supports-color - verdaccio - '@nx/jest@23.2.1(72e9877c63da59ac93e1f6ba5cf055e8)': + '@nx/jest@23.2.1(45be1c4077e405ec0f48ec85376a2be8)': dependencies: '@jest/reporters': 30.5.1 '@jest/test-result': 30.5.1 - '@nx/devkit': 23.2.1(nx@23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.1(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.1(@swc/helpers@0.5.23))) - '@nx/js': 23.2.1(@babel/traverse@7.29.8)(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.1(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.1(@swc/helpers@0.5.23))(nx@23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.1(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.1(@swc/helpers@0.5.23))) + '@nx/devkit': 23.2.1(nx@23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.13(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.13(@swc/helpers@0.5.23))) + '@nx/js': 23.2.1(@babel/traverse@7.29.10)(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.13(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.13(@swc/helpers@0.5.23))(nx@23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.13(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.13(@swc/helpers@0.5.23))) '@phenomnomnominal/tsquery': 6.2.0(typescript@6.0.3) identity-obj-proxy: 3.0.0 - jest-config: 30.5.1(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.2))(ts-node@10.9.2(@swc/core@1.16.1(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@6.0.3)) + jest-config: 30.5.1(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.2))(ts-node@10.9.2(@swc/core@1.16.13(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@6.0.3)) jest-resolve: 30.5.1 jest-util: 30.5.1 jsonc-parser: 3.3.1 @@ -12831,8 +13362,8 @@ snapshots: tslib: 2.8.1 yargs-parser: 21.1.1 optionalDependencies: - jest: 30.5.1(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.2))(ts-node@10.9.2(@swc/core@1.16.1(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@6.0.3)) - ts-jest: 29.4.12(@babel/core@7.29.7)(@jest/transform@30.5.1)(@jest/types@30.5.1)(babel-jest@30.5.1(@babel/core@7.29.7))(esbuild@0.28.2)(jest-util@30.5.1)(jest@30.5.1(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.2))(ts-node@10.9.2(@swc/core@1.16.1(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@6.0.3)))(typescript@6.0.3) + jest: 30.5.2(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.2))(ts-node@10.9.2(@swc/core@1.16.13(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@6.0.3)) + ts-jest: 29.4.14(@babel/core@7.29.7)(@jest/transform@30.5.2)(@jest/types@30.5.1)(babel-jest@30.5.2(@babel/core@7.29.7))(esbuild@0.28.2)(jest-util@30.5.1)(jest@30.5.2(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.2))(ts-node@10.9.2(@swc/core@1.16.13(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@6.0.3)))(typescript@6.0.3) transitivePeerDependencies: - '@babel/traverse' - '@swc-node/register' @@ -12848,7 +13379,7 @@ snapshots: - typescript - verdaccio - '@nx/js@23.2.1(@babel/traverse@7.29.8)(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.1(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.1(@swc/helpers@0.5.23))(nx@23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.1(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.1(@swc/helpers@0.5.23)))': + '@nx/js@23.2.1(@babel/traverse@7.29.10)(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.13(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.13(@swc/helpers@0.5.23))(nx@23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.13(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.13(@swc/helpers@0.5.23)))': dependencies: '@babel/core': 7.29.7 '@babel/plugin-proposal-decorators': 7.29.7(@babel/core@7.29.7) @@ -12860,12 +13391,12 @@ snapshots: '@babel/preset-env': 7.29.7(@babel/core@7.29.7) '@babel/preset-typescript': 7.29.7(@babel/core@7.29.7) '@babel/runtime': 7.29.7 - '@nx/devkit': 23.2.1(nx@23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.1(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.1(@swc/helpers@0.5.23))) - '@nx/workspace': 23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.1(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.1(@swc/helpers@0.5.23)) + '@nx/devkit': 23.2.1(nx@23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.13(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.13(@swc/helpers@0.5.23))) + '@nx/workspace': 23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.13(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.13(@swc/helpers@0.5.23)) '@zkochan/js-yaml': 0.0.7 babel-plugin-const-enum: 1.2.0(@babel/core@7.29.7) babel-plugin-macros: 3.1.0 - babel-plugin-transform-typescript-metadata: 0.3.2(@babel/core@7.29.7)(@babel/traverse@7.29.8) + babel-plugin-transform-typescript-metadata: 0.3.2(@babel/core@7.29.7)(@babel/traverse@7.29.10) chalk: 4.1.2 columnify: 1.6.0 detect-port: 2.1.0 @@ -12916,17 +13447,17 @@ snapshots: '@nx/nx-win32-x64-msvc@23.2.1': optional: true - '@nx/playwright@23.2.1(@babel/traverse@7.29.8)(@nx/jest@23.2.1(72e9877c63da59ac93e1f6ba5cf055e8))(@playwright/test@1.62.1)(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.1(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.1(@swc/helpers@0.5.23))(@zkochan/js-yaml@0.0.7)(eslint@9.39.2(jiti@1.21.7))(nx@23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.1(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.1(@swc/helpers@0.5.23)))': + '@nx/playwright@23.2.1(@babel/traverse@7.29.10)(@nx/jest@23.2.1(45be1c4077e405ec0f48ec85376a2be8))(@playwright/test@1.64.0)(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.13(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.13(@swc/helpers@0.5.23))(@zkochan/js-yaml@0.0.7)(eslint@9.39.2(jiti@1.21.7))(nx@23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.13(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.13(@swc/helpers@0.5.23)))': dependencies: - '@nx/devkit': 23.2.1(nx@23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.1(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.1(@swc/helpers@0.5.23))) - '@nx/eslint': 23.2.1(@babel/traverse@7.29.8)(@nx/jest@23.2.1(72e9877c63da59ac93e1f6ba5cf055e8))(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.1(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.1(@swc/helpers@0.5.23))(@zkochan/js-yaml@0.0.7)(eslint@9.39.2(jiti@1.21.7))(nx@23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.1(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.1(@swc/helpers@0.5.23))) - '@nx/js': 23.2.1(@babel/traverse@7.29.8)(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.1(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.1(@swc/helpers@0.5.23))(nx@23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.1(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.1(@swc/helpers@0.5.23))) + '@nx/devkit': 23.2.1(nx@23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.13(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.13(@swc/helpers@0.5.23))) + '@nx/eslint': 23.2.1(@babel/traverse@7.29.10)(@nx/jest@23.2.1(45be1c4077e405ec0f48ec85376a2be8))(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.13(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.13(@swc/helpers@0.5.23))(@zkochan/js-yaml@0.0.7)(eslint@9.39.2(jiti@1.21.7))(nx@23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.13(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.13(@swc/helpers@0.5.23))) + '@nx/js': 23.2.1(@babel/traverse@7.29.10)(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.13(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.13(@swc/helpers@0.5.23))(nx@23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.13(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.13(@swc/helpers@0.5.23))) https-proxy-agent: 7.0.6 minimatch: 10.2.5 semver: 7.8.5 tslib: 2.8.1 optionalDependencies: - '@playwright/test': 1.62.1 + '@playwright/test': 1.64.0 transitivePeerDependencies: - '@babel/traverse' - '@nx/jest' @@ -12939,18 +13470,18 @@ snapshots: - supports-color - verdaccio - '@nx/web@23.2.1(f806492ec758094179990070b023c41c)': + '@nx/web@23.2.1(33e69643271612c9d9b31a956c0e020d)': dependencies: - '@nx/devkit': 23.2.1(nx@23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.1(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.1(@swc/helpers@0.5.23))) - '@nx/js': 23.2.1(@babel/traverse@7.29.8)(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.1(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.1(@swc/helpers@0.5.23))(nx@23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.1(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.1(@swc/helpers@0.5.23))) + '@nx/devkit': 23.2.1(nx@23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.13(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.13(@swc/helpers@0.5.23))) + '@nx/js': 23.2.1(@babel/traverse@7.29.10)(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.13(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.13(@swc/helpers@0.5.23))(nx@23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.13(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.13(@swc/helpers@0.5.23))) detect-port: 2.1.0 http-server: 14.1.1 picocolors: 1.1.1 tslib: 2.8.1 optionalDependencies: - '@nx/eslint': 23.2.1(@babel/traverse@7.29.8)(@nx/jest@23.2.1(72e9877c63da59ac93e1f6ba5cf055e8))(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.1(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.1(@swc/helpers@0.5.23))(@zkochan/js-yaml@0.0.7)(eslint@9.39.2(jiti@1.21.7))(nx@23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.1(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.1(@swc/helpers@0.5.23))) - '@nx/jest': 23.2.1(72e9877c63da59ac93e1f6ba5cf055e8) - '@nx/playwright': 23.2.1(@babel/traverse@7.29.8)(@nx/jest@23.2.1(72e9877c63da59ac93e1f6ba5cf055e8))(@playwright/test@1.62.1)(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.1(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.1(@swc/helpers@0.5.23))(@zkochan/js-yaml@0.0.7)(eslint@9.39.2(jiti@1.21.7))(nx@23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.1(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.1(@swc/helpers@0.5.23))) + '@nx/eslint': 23.2.1(@babel/traverse@7.29.10)(@nx/jest@23.2.1(45be1c4077e405ec0f48ec85376a2be8))(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.13(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.13(@swc/helpers@0.5.23))(@zkochan/js-yaml@0.0.7)(eslint@9.39.2(jiti@1.21.7))(nx@23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.13(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.13(@swc/helpers@0.5.23))) + '@nx/jest': 23.2.1(45be1c4077e405ec0f48ec85376a2be8) + '@nx/playwright': 23.2.1(@babel/traverse@7.29.10)(@nx/jest@23.2.1(45be1c4077e405ec0f48ec85376a2be8))(@playwright/test@1.64.0)(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.13(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.13(@swc/helpers@0.5.23))(@zkochan/js-yaml@0.0.7)(eslint@9.39.2(jiti@1.21.7))(nx@23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.13(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.13(@swc/helpers@0.5.23))) transitivePeerDependencies: - '@babel/traverse' - '@swc-node/register' @@ -12961,12 +13492,12 @@ snapshots: - supports-color - verdaccio - '@nx/workspace@23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.1(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.1(@swc/helpers@0.5.23))': + '@nx/workspace@23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.13(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.13(@swc/helpers@0.5.23))': dependencies: - '@nx/devkit': 23.2.1(nx@23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.1(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.1(@swc/helpers@0.5.23))) + '@nx/devkit': 23.2.1(nx@23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.13(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.13(@swc/helpers@0.5.23))) '@zkochan/js-yaml': 0.0.7 chalk: 4.1.2 - nx: 23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.1(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.1(@swc/helpers@0.5.23)) + nx: 23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.13(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.13(@swc/helpers@0.5.23)) picomatch: 4.0.4 semver: 7.8.5 tslib: 2.8.1 @@ -12980,51 +13511,99 @@ snapshots: '@oxc-parser/binding-android-arm-eabi@0.142.0': optional: true + '@oxc-parser/binding-android-arm-eabi@0.150.0': + optional: true + '@oxc-parser/binding-android-arm64@0.142.0': optional: true + '@oxc-parser/binding-android-arm64@0.150.0': + optional: true + '@oxc-parser/binding-darwin-arm64@0.142.0': optional: true + '@oxc-parser/binding-darwin-arm64@0.150.0': + optional: true + '@oxc-parser/binding-darwin-x64@0.142.0': optional: true + '@oxc-parser/binding-darwin-x64@0.150.0': + optional: true + '@oxc-parser/binding-freebsd-x64@0.142.0': optional: true + '@oxc-parser/binding-freebsd-x64@0.150.0': + optional: true + '@oxc-parser/binding-linux-arm-gnueabihf@0.142.0': optional: true + '@oxc-parser/binding-linux-arm-gnueabihf@0.150.0': + optional: true + '@oxc-parser/binding-linux-arm-musleabihf@0.142.0': optional: true + '@oxc-parser/binding-linux-arm-musleabihf@0.150.0': + optional: true + '@oxc-parser/binding-linux-arm64-gnu@0.142.0': optional: true + '@oxc-parser/binding-linux-arm64-gnu@0.150.0': + optional: true + '@oxc-parser/binding-linux-arm64-musl@0.142.0': optional: true + '@oxc-parser/binding-linux-arm64-musl@0.150.0': + optional: true + '@oxc-parser/binding-linux-ppc64-gnu@0.142.0': optional: true + '@oxc-parser/binding-linux-ppc64-gnu@0.150.0': + optional: true + '@oxc-parser/binding-linux-riscv64-gnu@0.142.0': optional: true + '@oxc-parser/binding-linux-riscv64-gnu@0.150.0': + optional: true + '@oxc-parser/binding-linux-riscv64-musl@0.142.0': optional: true + '@oxc-parser/binding-linux-riscv64-musl@0.150.0': + optional: true + '@oxc-parser/binding-linux-s390x-gnu@0.142.0': optional: true + '@oxc-parser/binding-linux-s390x-gnu@0.150.0': + optional: true + '@oxc-parser/binding-linux-x64-gnu@0.142.0': optional: true + '@oxc-parser/binding-linux-x64-gnu@0.150.0': + optional: true + '@oxc-parser/binding-linux-x64-musl@0.142.0': optional: true + '@oxc-parser/binding-linux-x64-musl@0.150.0': + optional: true + '@oxc-parser/binding-openharmony-arm64@0.142.0': optional: true + '@oxc-parser/binding-openharmony-arm64@0.150.0': + optional: true + '@oxc-parser/binding-wasm32-wasi@0.142.0': dependencies: '@emnapi/core': 1.11.2 @@ -13035,12 +13614,21 @@ snapshots: '@oxc-parser/binding-win32-arm64-msvc@0.142.0': optional: true + '@oxc-parser/binding-win32-arm64-msvc@0.150.0': + optional: true + '@oxc-parser/binding-win32-ia32-msvc@0.142.0': optional: true + '@oxc-parser/binding-win32-ia32-msvc@0.150.0': + optional: true + '@oxc-parser/binding-win32-x64-msvc@0.142.0': optional: true + '@oxc-parser/binding-win32-x64-msvc@0.150.0': + optional: true + '@oxc-project/types@0.139.0': {} '@oxc-project/types@0.140.0': {} @@ -13049,6 +13637,8 @@ snapshots: '@oxc-project/types@0.146.0': {} + '@oxc-project/types@0.150.0': {} + '@oxc-resolver/binding-android-arm-eabi@11.17.1': optional: true @@ -13155,7 +13745,7 @@ snapshots: detect-libc: 2.1.2 is-glob: 4.0.3 node-addon-api: 7.1.1 - picomatch: 4.0.5 + picomatch: 4.0.7 optionalDependencies: '@parcel/watcher-android-arm64': 2.6.0 '@parcel/watcher-darwin-arm64': 2.6.0 @@ -13203,9 +13793,9 @@ snapshots: '@pkgr/core@0.3.6': {} - '@playwright/test@1.62.1': + '@playwright/test@1.64.0': dependencies: - playwright: 1.62.1 + playwright: 1.64.0 '@rolldown/binding-android-arm-eabi@1.2.5': optional: true @@ -13429,11 +14019,12 @@ snapshots: '@rtsao/scc@1.1.0': {} - '@schematics/angular@22.1.8(chokidar@5.0.0)': + '@schematics/angular@22.2.2(chokidar@5.0.0)': dependencies: - '@angular-devkit/core': 22.1.8(chokidar@5.0.0) - '@angular-devkit/schematics': 22.1.8(chokidar@5.0.0) + '@angular-devkit/core': 22.2.2(chokidar@5.0.0) + '@angular-devkit/schematics': 22.2.2(chokidar@5.0.0) jsonc-parser: 3.3.1 + oxc-parser: 0.150.0 typescript: 6.0.3 transitivePeerDependencies: - chokidar @@ -13496,17 +14087,17 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@swc-node/core@1.15.0(@swc/core@1.16.1(@swc/helpers@0.5.23))(@swc/types@0.1.28)': + '@swc-node/core@1.15.0(@swc/core@1.16.13(@swc/helpers@0.5.23))(@swc/types@0.1.28)': dependencies: - '@swc/core': 1.16.1(@swc/helpers@0.5.23) + '@swc/core': 1.16.13(@swc/helpers@0.5.23) '@swc/types': 0.1.28 - '@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.1(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3)': + '@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.13(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3)': dependencies: '@node-rs/xxhash': 1.7.6 - '@swc-node/core': 1.15.0(@swc/core@1.16.1(@swc/helpers@0.5.23))(@swc/types@0.1.28) + '@swc-node/core': 1.15.0(@swc/core@1.16.13(@swc/helpers@0.5.23))(@swc/types@0.1.28) '@swc-node/sourcemap-support': 0.6.1 - '@swc/core': 1.16.1(@swc/helpers@0.5.23) + '@swc/core': 1.16.13(@swc/helpers@0.5.23) colorette: 2.0.20 debug: 4.4.3(supports-color@7.2.0) fast-json-stable-stringify: 2.1.0 @@ -13525,59 +14116,59 @@ snapshots: source-map-support: 0.5.21 tslib: 2.8.1 - '@swc/core-darwin-arm64@1.16.1': + '@swc/core-darwin-arm64@1.16.13': optional: true - '@swc/core-darwin-x64@1.16.1': + '@swc/core-darwin-x64@1.16.13': optional: true - '@swc/core-linux-arm-gnueabihf@1.16.1': + '@swc/core-linux-arm-gnueabihf@1.16.13': optional: true - '@swc/core-linux-arm64-gnu@1.16.1': + '@swc/core-linux-arm64-gnu@1.16.13': optional: true - '@swc/core-linux-arm64-musl@1.16.1': + '@swc/core-linux-arm64-musl@1.16.13': optional: true - '@swc/core-linux-ppc64-gnu@1.16.1': + '@swc/core-linux-ppc64-gnu@1.16.13': optional: true - '@swc/core-linux-s390x-gnu@1.16.1': + '@swc/core-linux-s390x-gnu@1.16.13': optional: true - '@swc/core-linux-x64-gnu@1.16.1': + '@swc/core-linux-x64-gnu@1.16.13': optional: true - '@swc/core-linux-x64-musl@1.16.1': + '@swc/core-linux-x64-musl@1.16.13': optional: true - '@swc/core-win32-arm64-msvc@1.16.1': + '@swc/core-win32-arm64-msvc@1.16.13': optional: true - '@swc/core-win32-ia32-msvc@1.16.1': + '@swc/core-win32-ia32-msvc@1.16.13': optional: true - '@swc/core-win32-x64-msvc@1.16.1': + '@swc/core-win32-x64-msvc@1.16.13': optional: true - '@swc/core@1.16.1(@swc/helpers@0.5.23)': + '@swc/core@1.16.13(@swc/helpers@0.5.23)': dependencies: '@swc/counter': 0.1.3 '@swc/types': 0.1.28 optionalDependencies: - '@swc/core-darwin-arm64': 1.16.1 - '@swc/core-darwin-x64': 1.16.1 - '@swc/core-linux-arm-gnueabihf': 1.16.1 - '@swc/core-linux-arm64-gnu': 1.16.1 - '@swc/core-linux-arm64-musl': 1.16.1 - '@swc/core-linux-ppc64-gnu': 1.16.1 - '@swc/core-linux-s390x-gnu': 1.16.1 - '@swc/core-linux-x64-gnu': 1.16.1 - '@swc/core-linux-x64-musl': 1.16.1 - '@swc/core-win32-arm64-msvc': 1.16.1 - '@swc/core-win32-ia32-msvc': 1.16.1 - '@swc/core-win32-x64-msvc': 1.16.1 + '@swc/core-darwin-arm64': 1.16.13 + '@swc/core-darwin-x64': 1.16.13 + '@swc/core-linux-arm-gnueabihf': 1.16.13 + '@swc/core-linux-arm64-gnu': 1.16.13 + '@swc/core-linux-arm64-musl': 1.16.13 + '@swc/core-linux-ppc64-gnu': 1.16.13 + '@swc/core-linux-s390x-gnu': 1.16.13 + '@swc/core-linux-x64-gnu': 1.16.13 + '@swc/core-linux-x64-musl': 1.16.13 + '@swc/core-win32-arm64-msvc': 1.16.13 + '@swc/core-win32-ia32-msvc': 1.16.13 + '@swc/core-win32-x64-msvc': 1.16.13 '@swc/helpers': 0.5.23 '@swc/counter@0.1.3': {} @@ -13594,10 +14185,10 @@ snapshots: dependencies: defer-to-connect: 2.0.1 - '@tailwindcss/typography@0.5.20(tailwindcss@3.4.19(tsx@4.23.13)(yaml@2.9.0))': + '@tailwindcss/typography@0.5.20(tailwindcss@3.4.19(tsx@4.23.15)(yaml@2.9.1))': dependencies: postcss-selector-parser: 6.0.10 - tailwindcss: 3.4.19(tsx@4.23.13)(yaml@2.9.0) + tailwindcss: 3.4.19(tsx@4.23.15)(yaml@2.9.1) '@tsconfig/node10@1.0.12': {} @@ -13618,7 +14209,7 @@ snapshots: '@types/babel__core@7.20.5': dependencies: - '@babel/parser': 7.29.8 + '@babel/parser': 7.29.9 '@babel/types': 7.29.8 '@types/babel__generator': 7.27.0 '@types/babel__template': 7.4.4 @@ -13630,7 +14221,7 @@ snapshots: '@types/babel__template@7.4.4': dependencies: - '@babel/parser': 7.29.8 + '@babel/parser': 7.29.9 '@babel/types': 7.29.8 '@types/babel__traverse@7.28.0': @@ -13773,6 +14364,10 @@ snapshots: dependencies: undici-types: 7.18.2 + '@types/node@24.19.1': + dependencies: + undici-types: 7.24.6 + '@types/parse-json@4.0.2': {} '@types/proxy-from-env@1.0.4': @@ -13789,7 +14384,7 @@ snapshots: '@types/sax@1.2.7': dependencies: - '@types/node': 24.13.3 + '@types/node': 20.19.9 '@types/send@1.2.1': dependencies: @@ -13816,28 +14411,28 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 - '@typescript-eslint/eslint-plugin@8.69.0(@typescript-eslint/parser@8.69.0(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3))(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3)': + '@typescript-eslint/eslint-plugin@8.71.1(@typescript-eslint/parser@8.71.1(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3))(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.69.0(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3) - '@typescript-eslint/scope-manager': 8.69.0 - '@typescript-eslint/type-utils': 8.69.0(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3) - '@typescript-eslint/utils': 8.69.0(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3) - '@typescript-eslint/visitor-keys': 8.69.0 + '@typescript-eslint/parser': 8.71.1(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.71.1 + '@typescript-eslint/type-utils': 8.71.1(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3) + '@typescript-eslint/utils': 8.71.1(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.71.1 eslint: 9.39.2(jiti@1.21.7) - ignore: 7.0.8 + ignore: 7.0.12 natural-compare: 1.4.0 ts-api-utils: 2.5.0(typescript@6.0.3) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.69.0(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3)': + '@typescript-eslint/parser@8.71.1(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3)': dependencies: - '@typescript-eslint/scope-manager': 8.69.0 - '@typescript-eslint/types': 8.69.0 - '@typescript-eslint/typescript-estree': 8.69.0(typescript@6.0.3) - '@typescript-eslint/visitor-keys': 8.69.0 + '@typescript-eslint/scope-manager': 8.71.1 + '@typescript-eslint/types': 8.71.1 + '@typescript-eslint/typescript-estree': 8.71.1(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.71.1 debug: 4.4.3(supports-color@7.2.0) eslint: 9.39.2(jiti@1.21.7) typescript: 6.0.3 @@ -13846,8 +14441,17 @@ snapshots: '@typescript-eslint/project-service@8.69.0(typescript@6.0.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.69.0(typescript@6.0.3) - '@typescript-eslint/types': 8.69.0 + '@typescript-eslint/tsconfig-utils': 8.71.1(typescript@6.0.3) + '@typescript-eslint/types': 8.71.1 + debug: 4.4.3(supports-color@7.2.0) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.71.1(typescript@6.0.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.71.1(typescript@6.0.3) + '@typescript-eslint/types': 8.71.1 debug: 4.4.3(supports-color@7.2.0) typescript: 6.0.3 transitivePeerDependencies: @@ -13858,10 +14462,19 @@ snapshots: '@typescript-eslint/types': 8.69.0 '@typescript-eslint/visitor-keys': 8.69.0 + '@typescript-eslint/scope-manager@8.71.1': + dependencies: + '@typescript-eslint/types': 8.71.1 + '@typescript-eslint/visitor-keys': 8.71.1 + '@typescript-eslint/tsconfig-utils@8.69.0(typescript@6.0.3)': dependencies: typescript: 6.0.3 + '@typescript-eslint/tsconfig-utils@8.71.1(typescript@6.0.3)': + dependencies: + typescript: 6.0.3 + '@typescript-eslint/type-utils@8.69.0(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3)': dependencies: '@typescript-eslint/types': 8.69.0 @@ -13874,8 +14487,22 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/type-utils@8.71.1(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3)': + dependencies: + '@typescript-eslint/types': 8.71.1 + '@typescript-eslint/typescript-estree': 8.71.1(typescript@6.0.3) + '@typescript-eslint/utils': 8.71.1(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3) + debug: 4.4.3(supports-color@7.2.0) + eslint: 9.39.2(jiti@1.21.7) + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/types@8.69.0': {} + '@typescript-eslint/types@8.71.1': {} + '@typescript-eslint/typescript-estree@8.69.0(typescript@6.0.3)': dependencies: '@typescript-eslint/project-service': 8.69.0(typescript@6.0.3) @@ -13891,6 +14518,21 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/typescript-estree@8.71.1(typescript@6.0.3)': + dependencies: + '@typescript-eslint/project-service': 8.71.1(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.71.1(typescript@6.0.3) + '@typescript-eslint/types': 8.71.1 + '@typescript-eslint/visitor-keys': 8.71.1 + debug: 4.4.3(supports-color@7.2.0) + minimatch: 10.2.6 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/utils@8.69.0(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3)': dependencies: '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.2(jiti@1.21.7)) @@ -13902,11 +14544,27 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/utils@8.71.1(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3)': + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.2(jiti@1.21.7)) + '@typescript-eslint/scope-manager': 8.71.1 + '@typescript-eslint/types': 8.71.1 + '@typescript-eslint/typescript-estree': 8.71.1(typescript@6.0.3) + eslint: 9.39.2(jiti@1.21.7) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/visitor-keys@8.69.0': dependencies: '@typescript-eslint/types': 8.69.0 eslint-visitor-keys: 5.0.1 + '@typescript-eslint/visitor-keys@8.71.1': + dependencies: + '@typescript-eslint/types': 8.71.1 + eslint-visitor-keys: 5.0.1 + '@ungap/structured-clone@1.3.3': {} '@ungap/structured-clone@1.4.0': {} @@ -13983,7 +14641,7 @@ snapshots: '@videojs/http-streaming@2.16.3(video.js@7.21.7)': dependencies: - '@babel/runtime': 7.29.7 + '@babel/runtime': 7.29.10 '@videojs/vhs-utils': 3.0.5 aes-decrypter: 3.1.3 global: 4.4.0 @@ -13992,43 +14650,43 @@ snapshots: mux.js: 6.0.1 video.js: 7.21.7 - '@videojs/http-streaming@3.17.5(video.js@8.24.0)': + '@videojs/http-streaming@3.17.5(video.js@8.24.1)': dependencies: - '@babel/runtime': 7.29.7 + '@babel/runtime': 7.29.10 '@videojs/vhs-utils': 4.1.2 aes-decrypter: 4.0.2 global: 4.4.0 m3u8-parser: 7.2.0 mpd-parser: 1.4.0 mux.js: 7.1.0 - video.js: 8.24.0 + video.js: 8.24.1 '@videojs/vhs-utils@3.0.5': dependencies: - '@babel/runtime': 7.29.7 + '@babel/runtime': 7.29.10 global: 4.4.0 url-toolkit: 2.2.5 '@videojs/vhs-utils@4.1.2': dependencies: - '@babel/runtime': 7.29.7 + '@babel/runtime': 7.29.10 global: 4.4.0 '@videojs/xhr@2.6.0': dependencies: - '@babel/runtime': 7.29.7 + '@babel/runtime': 7.29.10 global: 4.4.0 is-function: 1.0.2 '@videojs/xhr@2.7.0': dependencies: - '@babel/runtime': 7.29.7 + '@babel/runtime': 7.29.10 global: 4.4.0 is-function: 1.0.2 - '@vitejs/plugin-basic-ssl@2.3.0(vite@8.1.5(patch_hash=469ec4025880fc7b86d3d8b3b4e2028572d6acf65bdfb93973db18fdb3c00ad9)(@types/node@20.19.9)(esbuild@0.28.2)(jiti@1.21.7)(less@4.5.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.49.2)(tsx@4.23.13)(yaml@2.9.0))': + '@vitejs/plugin-basic-ssl@2.3.0(vite@8.1.5(patch_hash=469ec4025880fc7b86d3d8b3b4e2028572d6acf65bdfb93973db18fdb3c00ad9)(@types/node@20.19.9)(esbuild@0.28.2)(jiti@1.21.7)(less@4.5.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.1))': dependencies: - vite: 8.1.5(patch_hash=469ec4025880fc7b86d3d8b3b4e2028572d6acf65bdfb93973db18fdb3c00ad9)(@types/node@20.19.9)(esbuild@0.28.2)(jiti@1.21.7)(less@4.5.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.49.2)(tsx@4.23.13)(yaml@2.9.0) + vite: 8.1.5(patch_hash=469ec4025880fc7b86d3d8b3b4e2028572d6acf65bdfb93973db18fdb3c00ad9)(@types/node@20.19.9)(esbuild@0.28.2)(jiti@1.21.7)(less@4.5.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.1) '@webassemblyjs/ast@1.14.1': dependencies: @@ -14128,7 +14786,7 @@ snapshots: accepts@2.0.0: dependencies: mime-types: 3.0.2 - negotiator: 1.0.0 + negotiator: 1.1.0 acorn-import-phases@1.0.4(acorn@8.15.0): dependencies: @@ -14157,14 +14815,14 @@ snapshots: aes-decrypter@3.1.3: dependencies: - '@babel/runtime': 7.29.7 + '@babel/runtime': 7.29.10 '@videojs/vhs-utils': 3.0.5 global: 4.4.0 pkcs7: 1.0.4 aes-decrypter@4.0.2: dependencies: - '@babel/runtime': 7.29.7 + '@babel/runtime': 7.29.10 '@videojs/vhs-utils': 4.1.2 global: 4.4.0 pkcs7: 1.0.4 @@ -14221,28 +14879,28 @@ snapshots: dependencies: process-ancestry: 0.1.0 - angular-eslint@22.5.0(chokidar@5.0.0)(eslint@9.39.2(jiti@1.21.7))(typescript-eslint@8.69.0(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3))(typescript@6.0.3): + angular-eslint@22.5.0(chokidar@5.0.0)(eslint@9.39.2(jiti@1.21.7))(typescript-eslint@8.71.1(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3))(typescript@6.0.3): dependencies: - '@angular-devkit/core': 22.1.8(chokidar@5.0.0) - '@angular-devkit/schematics': 22.1.8(chokidar@5.0.0) + '@angular-devkit/core': 22.2.2(chokidar@5.0.0) + '@angular-devkit/schematics': 22.2.2(chokidar@5.0.0) '@angular-eslint/builder': 22.5.0(chokidar@5.0.0)(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3) - '@angular-eslint/eslint-plugin': 22.5.0(@typescript-eslint/utils@8.69.0(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3))(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3) - '@angular-eslint/eslint-plugin-template': 22.5.0(@angular-eslint/template-parser@22.5.0(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3))(@typescript-eslint/types@8.69.0)(@typescript-eslint/utils@8.69.0(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3))(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3) - '@angular-eslint/schematics': 22.5.0(@angular-eslint/template-parser@22.5.0(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3))(@typescript-eslint/types@8.69.0)(@typescript-eslint/utils@8.69.0(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3))(chokidar@5.0.0)(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3) + '@angular-eslint/eslint-plugin': 22.5.0(@typescript-eslint/utils@8.71.1(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3))(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3) + '@angular-eslint/eslint-plugin-template': 22.5.0(@angular-eslint/template-parser@22.5.0(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3))(@typescript-eslint/types@8.69.0)(@typescript-eslint/utils@8.71.1(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3))(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3) + '@angular-eslint/schematics': 22.5.0(@angular-eslint/template-parser@22.5.0(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3))(@typescript-eslint/types@8.69.0)(@typescript-eslint/utils@8.71.1(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3))(chokidar@5.0.0)(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3) '@angular-eslint/template-parser': 22.5.0(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3) '@typescript-eslint/types': 8.69.0 - '@typescript-eslint/utils': 8.69.0(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3) + '@typescript-eslint/utils': 8.71.1(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3) eslint: 9.39.2(jiti@1.21.7) typescript: 6.0.3 - typescript-eslint: 8.69.0(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3) + typescript-eslint: 8.71.1(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3) transitivePeerDependencies: - chokidar - supports-color - angularx-qrcode@22.0.1(@angular/common@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3)): + angularx-qrcode@22.0.1(@angular/common@22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3)): dependencies: - '@angular/common': 22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2) - '@angular/core': 22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3) + '@angular/common': 22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2) + '@angular/core': 22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3) qrcode: 1.5.4 tslib: 2.8.1 @@ -14397,7 +15055,7 @@ snapshots: astring@1.9.0: {} - astro@7.2.10(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.49.2)(tsx@4.23.13)(yaml@2.9.0): + astro@7.2.10(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@20.19.9)(jiti@1.21.7)(less@4.5.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.1): dependencies: '@astrojs/compiler-rs': 0.4.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3) '@astrojs/internal-helpers': 0.11.0 @@ -14447,11 +15105,11 @@ snapshots: ultrahtml: 1.6.0 unifont: 0.7.5 unstorage: 1.17.5 - vite: 8.2.2(@types/node@20.19.9)(esbuild@0.28.2)(jiti@1.21.7)(less@4.5.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.49.2)(tsx@4.23.13)(yaml@2.9.0) - vitefu: 1.1.3(vite@8.2.2(@types/node@20.19.9)(esbuild@0.28.2)(jiti@1.21.7)(less@4.5.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.49.2)(tsx@4.23.13)(yaml@2.9.0)) + vite: 8.2.2(@types/node@20.19.9)(esbuild@0.28.2)(jiti@1.21.7)(less@4.5.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.1) + vitefu: 1.1.3(vite@8.2.2(@types/node@20.19.9)(esbuild@0.28.2)(jiti@1.21.7)(less@4.5.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.1)) xxhash-wasm: 1.1.0 yargs-parser: 22.0.0 - zod: 4.5.4 + zod: 4.6.5 optionalDependencies: sharp: 0.35.5(@types/node@20.19.9) transitivePeerDependencies: @@ -14498,10 +15156,10 @@ snapshots: at-least-node@1.0.0: {} - autoprefixer@10.5.4(postcss@8.5.26): + autoprefixer@10.6.1(postcss@8.5.26): dependencies: - browserslist: 4.28.8 - caniuse-lite: 1.0.30001809 + browserslist: 4.29.3 + caniuse-lite: 1.0.30001815 fraction.js: 5.3.4 picocolors: 1.1.1 postcss: 8.5.26 @@ -14513,7 +15171,7 @@ snapshots: aws4@1.13.2: {} - axe-core@4.13.0: {} + axe-core@4.14.0: {} axios@1.18.1(debug@4.4.3(supports-color@7.2.0))(supports-color@7.2.0): dependencies: @@ -14550,6 +15208,19 @@ snapshots: transitivePeerDependencies: - supports-color + babel-jest@30.5.2(@babel/core@7.29.7): + dependencies: + '@babel/core': 7.29.7 + '@jest/transform': 30.5.2 + '@types/babel__core': 7.20.5 + babel-plugin-istanbul: 8.0.0 + babel-preset-jest: 30.5.0(@babel/core@7.29.7) + chalk: 4.1.2 + graceful-fs: 4.2.11 + slash: 3.0.0 + transitivePeerDependencies: + - supports-color + babel-plugin-const-enum@1.2.0(@babel/core@7.29.7): dependencies: '@babel/core': 7.29.7 @@ -14575,7 +15246,7 @@ snapshots: babel-plugin-macros@3.1.0: dependencies: - '@babel/runtime': 7.29.7 + '@babel/runtime': 7.29.10 cosmiconfig: 7.1.0 resolve: 1.22.12 @@ -14611,12 +15282,12 @@ snapshots: transitivePeerDependencies: - supports-color - babel-plugin-transform-typescript-metadata@0.3.2(@babel/core@7.29.7)(@babel/traverse@7.29.8): + babel-plugin-transform-typescript-metadata@0.3.2(@babel/core@7.29.7)(@babel/traverse@7.29.10): dependencies: '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 optionalDependencies: - '@babel/traverse': 7.29.8 + '@babel/traverse': 7.29.10 babel-preset-current-node-syntax@1.2.0(@babel/core@7.29.7): dependencies: @@ -14655,6 +15326,8 @@ snapshots: baseline-browser-mapping@2.11.13: {} + baseline-browser-mapping@2.11.28: {} + basic-auth@2.0.1: dependencies: safe-buffer: 5.1.2 @@ -14685,17 +15358,17 @@ snapshots: bluebird@3.7.2: {} - body-parser@2.2.1: + body-parser@2.3.0: dependencies: bytes: 3.1.2 - content-type: 1.0.5 + content-type: 2.1.0 debug: 4.4.3(supports-color@7.2.0) http-errors: 2.0.1 - iconv-lite: 0.7.1 + iconv-lite: 0.7.3 on-finished: 2.4.1 - qs: 6.15.3 + qs: 6.16.0 raw-body: 3.0.2 - type-is: 2.0.1 + type-is: 2.1.0 transitivePeerDependencies: - supports-color @@ -14709,10 +15382,14 @@ snapshots: balanced-match: 1.0.2 concat-map: 0.0.1 - brace-expansion@2.1.4: + brace-expansion@2.1.7: dependencies: balanced-match: 1.0.2 + brace-expansion@5.0.12: + dependencies: + balanced-match: 4.0.4 + brace-expansion@5.0.9: dependencies: balanced-match: 4.0.4 @@ -14724,16 +15401,24 @@ snapshots: browserslist@4.28.8: dependencies: baseline-browser-mapping: 2.11.13 - caniuse-lite: 1.0.30001809 + caniuse-lite: 1.0.30001815 electron-to-chromium: 1.5.403 node-releases: 2.0.53 update-browserslist-db: 1.3.0(browserslist@4.28.8) + browserslist@4.29.3: + dependencies: + baseline-browser-mapping: 2.11.28 + caniuse-lite: 1.0.30001815 + electron-to-chromium: 1.5.452 + node-releases: 2.0.58 + update-browserslist-db: 1.3.4(browserslist@4.29.3) + bs-logger@0.2.6: dependencies: fast-json-stable-stringify: 2.1.0 - bser@2.1.1: + bser@2.1.2: dependencies: node-int64: 0.4.0 @@ -14815,7 +15500,7 @@ snapshots: camelcase@6.3.0: {} - caniuse-lite@1.0.30001809: {} + caniuse-lite@1.0.30001815: {} ccount@2.0.1: {} @@ -14868,7 +15553,7 @@ snapshots: ci-info@4.4.0: {} - cjs-module-lexer@2.2.1: {} + cjs-module-lexer@2.3.0: {} cli-cursor@3.1.0: dependencies: @@ -14884,8 +15569,8 @@ snapshots: cli-truncate@6.1.1: dependencies: - slice-ansi: 9.0.0 - string-width: 8.2.2 + slice-ansi: 9.0.1 + string-width: 8.3.0 cli-width@4.1.0: {} @@ -14968,9 +15653,9 @@ snapshots: confusing-browser-globals@1.0.11: {} - content-disposition@1.0.1: {} + content-disposition@2.0.1: {} - content-type@1.0.5: {} + content-type@2.1.0: {} convert-source-map@1.9.0: {} @@ -14989,7 +15674,7 @@ snapshots: is-what: 3.14.1 optional: true - copy-webpack-plugin@12.0.2(webpack@5.104.1(@swc/core@1.16.1(@swc/helpers@0.5.23))(esbuild@0.28.2)): + copy-webpack-plugin@12.0.2(webpack@5.104.1(@swc/core@1.16.13(@swc/helpers@0.5.23))(esbuild@0.28.2)): dependencies: fast-glob: 3.3.3 glob-parent: 6.0.2 @@ -14997,11 +15682,11 @@ snapshots: normalize-path: 3.0.0 schema-utils: 4.3.3 serialize-javascript: 7.1.0 - webpack: 5.104.1(@swc/core@1.16.1(@swc/helpers@0.5.23))(esbuild@0.28.2) + webpack: 5.104.1(@swc/core@1.16.13(@swc/helpers@0.5.23))(esbuild@0.28.2) core-js-compat@3.50.0: dependencies: - browserslist: 4.28.8 + browserslist: 4.29.3 core-util-is@1.0.3: {} @@ -15256,14 +15941,14 @@ snapshots: dotenv@16.6.1: {} - drizzle-kit@0.31.10: + drizzle-kit@0.31.11: dependencies: '@drizzle-team/brocli': 0.10.2 '@esbuild-kit/esm-loader': 2.6.5 esbuild: 0.25.12 - tsx: 4.23.13 + tsx: 4.23.15 - drizzle-orm@0.45.2(@types/better-sqlite3@9.6.0)(better-sqlite3@13.0.3): + drizzle-orm@0.45.4(@types/better-sqlite3@9.6.0)(better-sqlite3@13.0.3): optionalDependencies: '@types/better-sqlite3': 9.6.0 better-sqlite3: 13.0.3 @@ -15340,6 +16025,8 @@ snapshots: electron-to-chromium@1.5.403: {} + electron-to-chromium@1.5.452: {} + electron-updater@6.8.9: dependencies: builder-util-runtime: 9.7.0 @@ -15381,7 +16068,7 @@ snapshots: emoji-regex@9.2.2: {} - empathic@2.0.1: {} + empathic@2.1.0: {} encodeurl@2.0.0: {} @@ -15402,6 +16089,8 @@ snapshots: entities@8.0.0: {} + entities@8.1.0: {} + env-paths@2.2.1: {} env-paths@3.0.0: {} @@ -15628,17 +16317,17 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.69.0(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.2(jiti@1.21.7)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.71.1(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.2(jiti@1.21.7)): dependencies: debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 8.69.0(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3) + '@typescript-eslint/parser': 8.71.1(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3) eslint: 9.39.2(jiti@1.21.7) eslint-import-resolver-node: 0.3.9 transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.69.0(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3))(eslint@9.39.2(jiti@1.21.7)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.71.1(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3))(eslint@9.39.2(jiti@1.21.7)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -15649,7 +16338,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.2(jiti@1.21.7) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.69.0(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.2(jiti@1.21.7)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.71.1(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.2(jiti@1.21.7)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -15661,7 +16350,7 @@ snapshots: string.prototype.trimend: 1.0.9 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 8.69.0(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3) + '@typescript-eslint/parser': 8.71.1(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack @@ -15805,12 +16494,6 @@ snapshots: events@3.3.0: {} - eventsource-parser@3.1.1: {} - - eventsource@3.0.7: - dependencies: - eventsource-parser: 3.1.1 - execa@5.1.1: dependencies: cross-spawn: 7.0.6 @@ -15843,19 +16526,23 @@ snapshots: jest-mock: 30.5.1 jest-util: 30.5.1 + expect@30.5.2: + dependencies: + '@jest/expect-utils': 30.5.2 + '@jest/get-type': 30.5.0 + jest-matcher-utils: 30.5.2 + jest-message-util: 30.5.1 + jest-mock: 30.5.2 + jest-util: 30.5.1 + exponential-backoff@3.1.3: {} - express-rate-limit@8.3.0(express@5.2.1): - dependencies: - express: 5.2.1 - ip-address: 10.5.0 - - express@5.2.1: + express@5.3.0: dependencies: accepts: 2.0.0 - body-parser: 2.2.1 - content-disposition: 1.0.1 - content-type: 1.0.5 + body-parser: 2.3.0 + content-disposition: 2.0.1 + content-type: 2.1.0 cookie: 0.7.2 cookie-signature: 1.2.2 debug: 4.4.3(supports-color@7.2.0) @@ -15871,14 +16558,14 @@ snapshots: on-finished: 2.4.1 once: 1.4.0 parseurl: 1.3.3 - proxy-addr: 2.0.7 - qs: 6.15.3 - range-parser: 1.2.1 + proxy-addr: 2.0.8 + qs: 6.16.0 + range-parser: 1.3.0 router: 2.2.0 send: 1.2.1 serve-static: 2.2.1 statuses: 2.0.2 - type-is: 2.0.1 + type-is: 2.1.0 vary: 1.1.2 transitivePeerDependencies: - supports-color @@ -15919,9 +16606,9 @@ snapshots: dependencies: reusify: 1.1.0 - fb-watchman@2.0.2: + fb-watchman@2.0.4: dependencies: - bser: 2.1.1 + bser: 2.1.2 fdir@6.5.0(picomatch@4.0.7): optionalDependencies: @@ -15977,6 +16664,8 @@ snapshots: flattie@1.1.1: {} + flru@1.0.2: {} + follow-redirects@1.16.0(debug@4.4.3(supports-color@7.2.0)): optionalDependencies: debug: 4.4.3(supports-color@7.2.0) @@ -15998,7 +16687,7 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 - fork-ts-checker-webpack-plugin@9.1.0(typescript@6.0.3)(webpack@5.104.1(@swc/core@1.16.1(@swc/helpers@0.5.23))(esbuild@0.28.2)): + fork-ts-checker-webpack-plugin@9.1.0(typescript@6.0.3)(webpack@5.104.1(@swc/core@1.16.13(@swc/helpers@0.5.23))(esbuild@0.28.2)): dependencies: '@babel/code-frame': 7.27.1 chalk: 4.1.2 @@ -16013,7 +16702,7 @@ snapshots: semver: 7.8.5 tapable: 2.3.0 typescript: 6.0.3 - webpack: 5.104.1(@swc/core@1.16.1(@swc/helpers@0.5.23))(esbuild@0.28.2) + webpack: 5.104.1(@swc/core@1.16.13(@swc/helpers@0.5.23))(esbuild@0.28.2) form-data@4.0.6: dependencies: @@ -16079,9 +16768,6 @@ snapshots: fs.realpath@1.0.0: {} - fsevents@2.3.2: - optional: true - fsevents@2.3.3: optional: true @@ -16104,7 +16790,7 @@ snapshots: get-caller-file@2.0.5: {} - get-east-asian-width@1.6.0: {} + get-east-asian-width@1.7.0: {} get-intrinsic@1.3.0: dependencies: @@ -16138,7 +16824,7 @@ snapshots: es-errors: 1.3.0 get-intrinsic: 1.3.0 - get-tsconfig@4.13.0: + get-tsconfig@4.14.3: dependencies: resolve-pkg-maps: 1.0.0 @@ -16216,7 +16902,7 @@ snapshots: dependencies: '@sindresorhus/merge-streams': 2.3.0 fast-glob: 3.3.3 - ignore: 7.0.8 + ignore: 7.0.12 path-type: 6.0.0 slash: 5.1.0 unicorn-magic: 0.3.0 @@ -16251,7 +16937,7 @@ snapshots: ufo: 1.6.3 uncrypto: 0.1.3 - handlebars@4.7.9: + handlebars@4.7.10: dependencies: minimist: 1.2.8 neo-async: 2.6.2 @@ -16418,13 +17104,11 @@ snapshots: he@1.2.0: {} - hls.js@1.7.1: {} - - hono@4.13.5: {} + hls.js@1.7.3: {} hosted-git-info@10.1.1: dependencies: - lru-cache: 11.5.2 + lru-cache: 11.5.3 hosted-git-info@4.1.0: dependencies: @@ -16529,10 +17213,6 @@ snapshots: dependencies: safer-buffer: 2.1.2 - iconv-lite@0.7.1: - dependencies: - safer-buffer: 2.1.2 - iconv-lite@0.7.3: dependencies: safer-buffer: 2.1.2 @@ -16545,6 +17225,8 @@ snapshots: ignore@5.3.2: {} + ignore@7.0.12: {} + ignore@7.0.5: {} ignore@7.0.8: {} @@ -16583,9 +17265,7 @@ snapshots: dependencies: es-errors: 1.3.0 hasown: 2.0.2 - side-channel: 1.1.0 - - ip-address@10.5.0: {} + side-channel: 1.1.1 ipaddr.js@1.9.1: {} @@ -16666,7 +17346,7 @@ snapshots: is-fullwidth-code-point@5.1.0: dependencies: - get-east-asian-width: 1.6.0 + get-east-asian-width: 1.7.0 is-function@1.0.2: {} @@ -16792,7 +17472,7 @@ snapshots: istanbul-lib-instrument@6.0.3: dependencies: '@babel/core': 7.29.7 - '@babel/parser': 7.29.8 + '@babel/parser': 7.29.9 '@istanbuljs/schema': 0.1.6 istanbul-lib-coverage: 3.2.2 semver: 7.8.5 @@ -16862,15 +17542,41 @@ snapshots: - babel-plugin-macros - supports-color - jest-cli@30.5.1(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.2))(ts-node@10.9.2(@swc/core@1.16.1(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@6.0.3)): + jest-circus@30.5.2(babel-plugin-macros@3.1.0): dependencies: - '@jest/core': 30.5.1(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.2))(ts-node@10.9.2(@swc/core@1.16.1(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@6.0.3)) - '@jest/test-result': 30.5.1 + '@jest/environment': 30.5.2 + '@jest/expect': 30.5.2 + '@jest/test-result': 30.5.2 + '@jest/types': 30.5.1 + '@types/node': 20.19.9 + chalk: 4.1.2 + co: 4.6.0 + dedent: 1.7.2(babel-plugin-macros@3.1.0) + is-generator-fn: 2.1.0 + jest-each: 30.5.2 + jest-matcher-utils: 30.5.2 + jest-message-util: 30.5.1 + jest-runtime: 30.5.2 + jest-snapshot: 30.5.2 + jest-util: 30.5.1 + p-limit: 3.1.0 + pretty-format: 30.5.1 + pure-rand: 7.0.1 + slash: 3.0.0 + stack-utils: 2.0.6 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + + jest-cli@30.5.2(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.2))(ts-node@10.9.2(@swc/core@1.16.13(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@6.0.3)): + dependencies: + '@jest/core': 30.5.2(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.2))(ts-node@10.9.2(@swc/core@1.16.13(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@6.0.3)) + '@jest/test-result': 30.5.2 '@jest/types': 30.5.1 chalk: 4.1.2 exit-x: 0.2.2 import-local: 3.2.0 - jest-config: 30.5.1(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.2))(ts-node@10.9.2(@swc/core@1.16.1(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@6.0.3)) + jest-config: 30.5.2(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.2))(ts-node@10.9.2(@swc/core@1.16.13(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@6.0.3)) jest-util: 30.5.1 jest-validate: 30.5.1 yargs: 17.7.3 @@ -16881,7 +17587,7 @@ snapshots: - supports-color - ts-node - jest-config@30.5.1(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.2))(ts-node@10.9.2(@swc/core@1.16.1(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@6.0.3)): + jest-config@30.5.1(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.2))(ts-node@10.9.2(@swc/core@1.16.13(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@6.0.3)): dependencies: '@babel/core': 7.29.7 '@jest/get-type': 30.5.0 @@ -16909,7 +17615,40 @@ snapshots: optionalDependencies: '@types/node': 20.19.9 esbuild-register: 3.6.0(esbuild@0.28.2) - ts-node: 10.9.2(@swc/core@1.16.1(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@6.0.3) + ts-node: 10.9.2(@swc/core@1.16.13(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@6.0.3) + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + + jest-config@30.5.2(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.2))(ts-node@10.9.2(@swc/core@1.16.13(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@6.0.3)): + dependencies: + '@babel/core': 7.29.7 + '@jest/get-type': 30.5.0 + '@jest/pattern': 30.5.0 + '@jest/test-sequencer': 30.5.2 + '@jest/types': 30.5.1 + babel-jest: 30.5.2(@babel/core@7.29.7) + chalk: 4.1.2 + ci-info: 4.4.0 + deepmerge: 4.3.1 + glob: 13.0.6 + graceful-fs: 4.2.11 + jest-circus: 30.5.2(babel-plugin-macros@3.1.0) + jest-docblock: 30.5.0 + jest-environment-node: 30.5.2 + jest-regex-util: 30.5.0 + jest-resolve: 30.5.1 + jest-runner: 30.5.2 + jest-util: 30.5.1 + jest-validate: 30.5.1 + parse-json: 5.2.0 + pretty-format: 30.5.1 + slash: 3.0.0 + strip-json-comments: 3.1.1 + optionalDependencies: + '@types/node': 20.19.9 + esbuild-register: 3.6.0(esbuild@0.28.2) + ts-node: 10.9.2(@swc/core@1.16.13(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@6.0.3) transitivePeerDependencies: - babel-plugin-macros - supports-color @@ -16928,6 +17667,13 @@ snapshots: chalk: 4.1.2 pretty-format: 30.5.1 + jest-diff@30.5.2: + dependencies: + '@jest/diff-sequences': 30.5.0 + '@jest/get-type': 30.5.0 + chalk: 4.1.2 + pretty-format: 30.5.1 + jest-docblock@30.5.0: dependencies: detect-newline: 3.1.0 @@ -16940,10 +17686,19 @@ snapshots: jest-util: 30.5.1 pretty-format: 30.5.1 - jest-environment-jsdom@30.5.1: + jest-each@30.5.2: dependencies: - '@jest/environment': 30.5.1 - '@jest/environment-jsdom-abstract': 30.5.1(@types/jsdom@21.1.7)(jsdom@26.1.0) + '@jest/get-type': 30.5.0 + '@jest/types': 30.5.1 + chalk: 4.1.2 + jest-regex-util: 30.5.0 + jest-util: 30.5.1 + pretty-format: 30.5.1 + + jest-environment-jsdom@30.5.2: + dependencies: + '@jest/environment': 30.5.2 + '@jest/environment-jsdom-abstract': 30.5.2(@types/jsdom@21.1.7)(jsdom@26.1.0) '@types/jsdom': 21.1.7 jsdom: 26.1.0 transitivePeerDependencies: @@ -16961,13 +17716,23 @@ snapshots: jest-util: 30.5.1 jest-validate: 30.5.1 + jest-environment-node@30.5.2: + dependencies: + '@jest/environment': 30.5.2 + '@jest/fake-timers': 30.5.2 + '@jest/types': 30.5.1 + '@types/node': 20.19.9 + jest-mock: 30.5.2 + jest-util: 30.5.1 + jest-validate: 30.5.1 + jest-haste-map@30.5.1: dependencies: '@jest/types': 30.5.1 '@parcel/watcher': 2.6.0 '@types/node': 20.19.9 anymatch: 3.1.3 - fb-watchman: 2.0.2 + fb-watchman: 2.0.4 fdir: 6.5.0(picomatch@4.0.7) graceful-fs: 4.2.11 jest-regex-util: 30.5.0 @@ -16994,6 +17759,13 @@ snapshots: jest-diff: 30.5.1 pretty-format: 30.5.1 + jest-matcher-utils@30.5.2: + dependencies: + '@jest/get-type': 30.5.0 + chalk: 4.1.2 + jest-diff: 30.5.2 + pretty-format: 30.5.1 + jest-message-util@30.2.0: dependencies: '@babel/code-frame': 7.29.7 @@ -17032,19 +17804,26 @@ snapshots: '@types/node': 20.19.9 jest-util: 30.5.1 - jest-preset-angular@17.0.0(@angular/compiler-cli@22.1.6(@angular/compiler@22.1.6)(typescript@6.0.3))(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(@angular/platform-browser@22.1.6(@angular/animations@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/common@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3)))(@babel/core@7.29.7)(@jest/transform@30.5.1)(@jest/types@30.5.1)(@types/jsdom@21.1.7)(babel-jest@30.5.1(@babel/core@7.29.7))(jest@30.5.1(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.2))(ts-node@10.9.2(@swc/core@1.16.1(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@6.0.3)))(jsdom@26.1.0)(typescript@6.0.3): + jest-mock@30.5.2: dependencies: - '@angular/compiler-cli': 22.1.6(@angular/compiler@22.1.6)(typescript@6.0.3) - '@angular/core': 22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3) - '@angular/platform-browser': 22.1.6(@angular/animations@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/common@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3)) - '@jest/environment-jsdom-abstract': 30.5.1(@types/jsdom@21.1.7)(jsdom@26.1.0) + '@jest/expect-utils': 30.5.2 + '@jest/types': 30.5.1 + '@types/node': 20.19.9 + jest-util: 30.5.1 + + jest-preset-angular@17.0.2(@angular/compiler-cli@22.2.2(@angular/compiler@22.2.2)(typescript@6.0.3))(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(@angular/platform-browser@22.2.2(@angular/animations@22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/common@22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3)))(@babel/core@7.29.7)(@jest/transform@30.5.2)(@jest/types@30.5.1)(@types/jsdom@21.1.7)(babel-jest@30.5.2(@babel/core@7.29.7))(jest@30.5.2(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.2))(ts-node@10.9.2(@swc/core@1.16.13(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@6.0.3)))(jsdom@26.1.0)(typescript@6.0.3): + dependencies: + '@angular/compiler-cli': 22.2.2(@angular/compiler@22.2.2)(typescript@6.0.3) + '@angular/core': 22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3) + '@angular/platform-browser': 22.2.2(@angular/animations@22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/common@22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3)) + '@jest/environment-jsdom-abstract': 30.5.2(@types/jsdom@21.1.7)(jsdom@26.1.0) bs-logger: 0.2.6 esbuild-wasm: 0.28.2 - jest: 30.5.1(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.2))(ts-node@10.9.2(@swc/core@1.16.1(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@6.0.3)) + jest: 30.5.2(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.2))(ts-node@10.9.2(@swc/core@1.16.13(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@6.0.3)) jest-util: 30.5.1 jsdom: 26.1.0 pretty-format: 30.5.1 - ts-jest: 29.4.12(@babel/core@7.29.7)(@jest/transform@30.5.1)(@jest/types@30.5.1)(babel-jest@30.5.1(@babel/core@7.29.7))(esbuild@0.28.2)(jest-util@30.5.1)(jest@30.5.1(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.2))(ts-node@10.9.2(@swc/core@1.16.1(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@6.0.3)))(typescript@6.0.3) + ts-jest: 29.4.14(@babel/core@7.29.7)(@jest/transform@30.5.2)(@jest/types@30.5.1)(babel-jest@30.5.2(@babel/core@7.29.7))(esbuild@0.28.2)(jest-util@30.5.1)(jest@30.5.2(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.2))(ts-node@10.9.2(@swc/core@1.16.13(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@6.0.3)))(typescript@6.0.3) typescript: 6.0.3 optionalDependencies: esbuild: 0.28.2 @@ -17060,10 +17839,10 @@ snapshots: jest-regex-util@30.5.0: {} - jest-resolve-dependencies@30.5.1: + jest-resolve-dependencies@30.5.2: dependencies: jest-regex-util: 30.5.0 - jest-snapshot: 30.5.1 + jest-snapshot: 30.5.2 transitivePeerDependencies: - supports-color @@ -17104,6 +17883,33 @@ snapshots: transitivePeerDependencies: - supports-color + jest-runner@30.5.2: + dependencies: + '@jest/console': 30.5.2 + '@jest/environment': 30.5.2 + '@jest/source-map': 30.5.2 + '@jest/test-result': 30.5.2 + '@jest/transform': 30.5.2 + '@jest/types': 30.5.1 + '@types/node': 20.19.9 + chalk: 4.1.2 + emittery: 0.13.1 + exit-x: 0.2.2 + graceful-fs: 4.2.11 + jest-docblock: 30.5.0 + jest-environment-node: 30.5.2 + jest-haste-map: 30.5.1 + jest-leak-detector: 30.5.1 + jest-message-util: 30.5.1 + jest-resolve: 30.5.1 + jest-runtime: 30.5.2 + jest-util: 30.5.1 + jest-watcher: 30.5.2 + jest-worker: 30.5.1 + p-limit: 3.1.0 + transitivePeerDependencies: + - supports-color + jest-runtime@30.5.1: dependencies: '@jest/environment': 30.5.1 @@ -17115,7 +17921,7 @@ snapshots: '@jest/types': 30.5.1 '@types/node': 20.19.9 chalk: 4.1.2 - cjs-module-lexer: 2.2.1 + cjs-module-lexer: 2.3.0 collect-v8-coverage: 1.0.3 es-module-lexer: 2.3.2 glob: 13.0.6 @@ -17132,6 +17938,34 @@ snapshots: transitivePeerDependencies: - supports-color + jest-runtime@30.5.2: + dependencies: + '@jest/environment': 30.5.2 + '@jest/fake-timers': 30.5.2 + '@jest/globals': 30.5.2 + '@jest/source-map': 30.5.2 + '@jest/test-result': 30.5.2 + '@jest/transform': 30.5.2 + '@jest/types': 30.5.1 + '@types/node': 20.19.9 + chalk: 4.1.2 + cjs-module-lexer: 2.3.0 + collect-v8-coverage: 1.0.3 + es-module-lexer: 2.3.2 + glob: 13.0.6 + graceful-fs: 4.2.11 + jest-haste-map: 30.5.1 + jest-message-util: 30.5.1 + jest-mock: 30.5.2 + jest-regex-util: 30.5.0 + jest-resolve: 30.5.1 + jest-snapshot: 30.5.2 + jest-util: 30.5.1 + slash: 3.0.0 + strip-bom: 4.0.0 + transitivePeerDependencies: + - supports-color + jest-snapshot@30.5.1: dependencies: '@babel/core': 7.29.7 @@ -17158,6 +17992,32 @@ snapshots: transitivePeerDependencies: - supports-color + jest-snapshot@30.5.2: + dependencies: + '@babel/core': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7) + '@babel/types': 7.29.8 + '@jest/expect-utils': 30.5.2 + '@jest/get-type': 30.5.0 + '@jest/snapshot-utils': 30.5.1 + '@jest/transform': 30.5.2 + '@jest/types': 30.5.1 + babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.7) + chalk: 4.1.2 + expect: 30.5.2 + graceful-fs: 4.2.11 + jest-diff: 30.5.2 + jest-matcher-utils: 30.5.2 + jest-message-util: 30.5.1 + jest-util: 30.5.1 + pretty-format: 30.5.1 + semver: 7.8.5 + synckit: 0.11.13 + transitivePeerDependencies: + - supports-color + jest-util@30.2.0: dependencies: '@jest/types': 30.2.0 @@ -17196,6 +18056,17 @@ snapshots: jest-util: 30.5.1 string-length: 4.0.2 + jest-watcher@30.5.2: + dependencies: + '@jest/test-result': 30.5.2 + '@jest/types': 30.5.1 + '@types/node': 20.19.9 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + emittery: 0.13.1 + jest-util: 30.5.1 + string-length: 4.0.2 + jest-worker@27.5.1: dependencies: '@types/node': 20.19.9 @@ -17210,12 +18081,12 @@ snapshots: merge-stream: 2.0.0 supports-color: 8.1.1 - jest@30.5.1(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.2))(ts-node@10.9.2(@swc/core@1.16.1(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@6.0.3)): + jest@30.5.2(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.2))(ts-node@10.9.2(@swc/core@1.16.13(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@6.0.3)): dependencies: - '@jest/core': 30.5.1(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.2))(ts-node@10.9.2(@swc/core@1.16.1(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@6.0.3)) + '@jest/core': 30.5.2(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.2))(ts-node@10.9.2(@swc/core@1.16.13(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@6.0.3)) '@jest/types': 30.5.1 import-local: 3.2.0 - jest-cli: 30.5.1(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.2))(ts-node@10.9.2(@swc/core@1.16.1(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@6.0.3)) + jest-cli: 30.5.2(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.2))(ts-node@10.9.2(@swc/core@1.16.13(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@6.0.3)) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -17227,8 +18098,6 @@ snapshots: jiti@2.7.0: {} - jose@6.2.9: {} - js-tokens@10.0.0: {} js-tokens@4.0.0: {} @@ -17279,8 +18148,6 @@ snapshots: json-schema-traverse@1.0.0: {} - json-schema-typed@8.0.2: {} - json-stable-stringify-without-jsonify@1.0.1: {} json-stringify-safe@5.0.1: @@ -17343,11 +18210,11 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 - license-webpack-plugin@4.0.2(webpack@5.104.1(@swc/core@1.16.1(@swc/helpers@0.5.23))(esbuild@0.28.2)): + license-webpack-plugin@4.0.2(webpack@5.104.1(@swc/core@1.16.13(@swc/helpers@0.5.23))(esbuild@0.28.2)): dependencies: webpack-sources: 3.3.3 optionalDependencies: - webpack: 5.104.1(@swc/core@1.16.1(@swc/helpers@0.5.23))(esbuild@0.28.2) + webpack: 5.104.1(@swc/core@1.16.13(@swc/helpers@0.5.23))(esbuild@0.28.2) lightningcss-android-arm64@1.33.0: optional: true @@ -17410,6 +18277,12 @@ snapshots: log-update: 8.0.0 wrap-ansi: 10.0.1 + listr2@11.1.0: + dependencies: + cli-truncate: 6.1.1 + log-update: 8.0.0 + wrap-ansi: 10.0.2 + lmdb@3.5.6: dependencies: '@harperfast/extended-iterable': 1.0.3 @@ -17466,10 +18339,10 @@ snapshots: dependencies: ansi-escapes: 7.3.0 cli-cursor: 5.0.0 - slice-ansi: 9.0.0 - string-width: 8.2.2 + slice-ansi: 9.0.1 + string-width: 8.3.0 strip-ansi: 7.2.0 - wrap-ansi: 10.0.1 + wrap-ansi: 10.0.2 longest-streak@3.1.0: {} @@ -17479,6 +18352,8 @@ snapshots: lru-cache@11.5.2: {} + lru-cache@11.5.3: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -17489,28 +18364,32 @@ snapshots: m3u8-parser@4.8.0: dependencies: - '@babel/runtime': 7.29.7 + '@babel/runtime': 7.29.10 '@videojs/vhs-utils': 3.0.5 global: 4.4.0 m3u8-parser@7.2.0: dependencies: - '@babel/runtime': 7.29.7 + '@babel/runtime': 7.29.10 '@videojs/vhs-utils': 4.1.2 global: 4.4.0 magic-string@0.30.21: dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/sourcemap-codec': 1.6.0 magic-string@1.0.0: dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/sourcemap-codec': 1.6.0 magic-string@1.2.2: dependencies: '@jridgewell/sourcemap-codec': 1.6.0 + magic-string@1.4.1: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + magicast@0.5.2: dependencies: '@babel/parser': 7.29.8 @@ -17533,7 +18412,7 @@ snapshots: markdown-table@3.0.4: {} - marked@18.0.11: {} + marked@18.1.0: {} matcher@3.0.0: dependencies: @@ -17717,7 +18596,7 @@ snapshots: mdn-data@2.27.1: {} - media-typer@1.1.0: {} + media-typer@1.1.1: {} memfs@3.5.3: dependencies: @@ -18032,7 +18911,7 @@ snapshots: minimatch@10.2.6: dependencies: - brace-expansion: 5.0.9 + brace-expansion: 5.0.12 minimatch@3.1.5: dependencies: @@ -18040,11 +18919,11 @@ snapshots: minimatch@5.1.9: dependencies: - brace-expansion: 2.1.4 + brace-expansion: 2.1.7 minimatch@9.0.9: dependencies: - brace-expansion: 2.1.4 + brace-expansion: 2.1.7 minimist@1.2.8: {} @@ -18060,14 +18939,14 @@ snapshots: mpd-parser@0.22.1: dependencies: - '@babel/runtime': 7.29.7 + '@babel/runtime': 7.29.10 '@videojs/vhs-utils': 3.0.5 '@xmldom/xmldom': 0.8.15 global: 4.4.0 mpd-parser@1.4.0: dependencies: - '@babel/runtime': 7.29.7 + '@babel/runtime': 7.29.10 '@videojs/vhs-utils': 4.1.2 '@xmldom/xmldom': 0.8.15 global: 4.4.0 @@ -18102,12 +18981,12 @@ snapshots: mux.js@6.0.1: dependencies: - '@babel/runtime': 7.29.7 + '@babel/runtime': 7.29.10 global: 4.4.0 mux.js@7.1.0: dependencies: - '@babel/runtime': 7.29.7 + '@babel/runtime': 7.29.10 global: 4.4.0 mz@2.7.0: @@ -18128,29 +19007,31 @@ snapshots: sax: 1.6.1 optional: true - negotiator@1.0.0: {} + negotiator@1.1.0: + dependencies: + content-type: 2.1.0 neo-async@2.6.2: {} neotraverse@1.0.1: {} - ng-mocks@14.17.6(c5ca909b0ec8f50b848b5bca41287c00): + ng-mocks@14.18.1(706c0063418d3ed3bcf96ef54b4c3dc3): dependencies: - '@angular/common': 22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2) - '@angular/core': 22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3) - '@angular/forms': 22.1.6(@angular/common@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(@angular/platform-browser@22.1.6(@angular/animations@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/common@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3)))(rxjs@7.8.2) - '@angular/platform-browser': 22.1.6(@angular/animations@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/common@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3)) + '@angular/common': 22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2) + '@angular/core': 22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3) + '@angular/forms': 22.2.2(@angular/common@22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(@angular/platform-browser@22.2.2(@angular/animations@22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/common@22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3)))(rxjs@7.8.2) + '@angular/platform-browser': 22.2.2(@angular/animations@22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3)))(@angular/common@22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3)) - ngx-indexed-db@22.0.0(@angular/common@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3)): + ngx-indexed-db@22.0.0(@angular/common@22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3)): dependencies: - '@angular/common': 22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2) - '@angular/core': 22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3) + '@angular/common': 22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2) + '@angular/core': 22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3) tslib: 2.8.1 - ngx-skeleton-loader@11.3.0(@angular/common@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3)): + ngx-skeleton-loader@11.3.0(@angular/common@22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3)): dependencies: - '@angular/common': 22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2) - '@angular/core': 22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3) + '@angular/common': 22.2.2(@angular/core@22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2) + '@angular/core': 22.2.2(@angular/compiler@22.2.2)(rxjs@7.8.2)(zone.js@0.16.3) tslib: 2.8.1 nlcst-to-string@4.0.0: @@ -18200,6 +19081,8 @@ snapshots: node-releases@2.0.53: {} + node-releases@2.0.58: {} + nopt@9.0.0: dependencies: abbrev: 4.0.0 @@ -18225,22 +19108,22 @@ snapshots: nwsapi@2.2.27: {} - nx-electron@22.0.0(patch_hash=4d5ac9c5b10268dcc40998d7a2b166d004105ae7875fa182130d6810871a1e84)(@nx/devkit@23.2.1(nx@23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.1(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.1(@swc/helpers@0.5.23))))(@nx/workspace@23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.1(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.1(@swc/helpers@0.5.23)))(@swc/core@1.16.1(@swc/helpers@0.5.23))(electron-builder-squirrel-windows@26.15.7)(electron@43.3.0)(esbuild@0.28.2)(rxjs@7.8.2)(typescript@6.0.3): + nx-electron@22.0.0(patch_hash=4d5ac9c5b10268dcc40998d7a2b166d004105ae7875fa182130d6810871a1e84)(@nx/devkit@23.2.1(nx@23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.13(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.13(@swc/helpers@0.5.23))))(@nx/workspace@23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.13(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.13(@swc/helpers@0.5.23)))(@swc/core@1.16.13(@swc/helpers@0.5.23))(electron-builder-squirrel-windows@26.15.7)(electron@43.3.0)(esbuild@0.28.2)(rxjs@7.8.2)(typescript@6.0.3): dependencies: - '@nx/devkit': 23.2.1(nx@23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.1(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.1(@swc/helpers@0.5.23))) - '@nx/workspace': 23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.1(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.1(@swc/helpers@0.5.23)) - copy-webpack-plugin: 12.0.2(webpack@5.104.1(@swc/core@1.16.1(@swc/helpers@0.5.23))(esbuild@0.28.2)) + '@nx/devkit': 23.2.1(nx@23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.13(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.13(@swc/helpers@0.5.23))) + '@nx/workspace': 23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.13(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.13(@swc/helpers@0.5.23)) + copy-webpack-plugin: 12.0.2(webpack@5.104.1(@swc/core@1.16.13(@swc/helpers@0.5.23))(esbuild@0.28.2)) electron: 43.3.0 electron-builder: 26.15.7(electron-builder-squirrel-windows@26.15.7) - fork-ts-checker-webpack-plugin: 9.1.0(typescript@6.0.3)(webpack@5.104.1(@swc/core@1.16.1(@swc/helpers@0.5.23))(esbuild@0.28.2)) - license-webpack-plugin: 4.0.2(webpack@5.104.1(@swc/core@1.16.1(@swc/helpers@0.5.23))(esbuild@0.28.2)) + fork-ts-checker-webpack-plugin: 9.1.0(typescript@6.0.3)(webpack@5.104.1(@swc/core@1.16.13(@swc/helpers@0.5.23))(esbuild@0.28.2)) + license-webpack-plugin: 4.0.2(webpack@5.104.1(@swc/core@1.16.13(@swc/helpers@0.5.23))(esbuild@0.28.2)) rxjs-for-await: 1.0.0(rxjs@7.8.2) strip-json-comments: 3.1.1 - terser-webpack-plugin: 5.3.16(@swc/core@1.16.1(@swc/helpers@0.5.23))(esbuild@0.28.2)(webpack@5.104.1(@swc/core@1.16.1(@swc/helpers@0.5.23))(esbuild@0.28.2)) + terser-webpack-plugin: 5.3.16(@swc/core@1.16.13(@swc/helpers@0.5.23))(esbuild@0.28.2)(webpack@5.104.1(@swc/core@1.16.13(@swc/helpers@0.5.23))(esbuild@0.28.2)) tree-kill: 1.2.2 - ts-loader: 9.6.2(typescript@6.0.3)(webpack@5.104.1(@swc/core@1.16.1(@swc/helpers@0.5.23))(esbuild@0.28.2)) + ts-loader: 9.6.2(typescript@6.0.3)(webpack@5.104.1(@swc/core@1.16.13(@swc/helpers@0.5.23))(esbuild@0.28.2)) tsconfig-paths-webpack-plugin: 4.2.0 - webpack: 5.104.1(@swc/core@1.16.1(@swc/helpers@0.5.23))(esbuild@0.28.2) + webpack: 5.104.1(@swc/core@1.16.13(@swc/helpers@0.5.23))(esbuild@0.28.2) webpack-merge: 5.10.0 webpack-node-externals: 3.0.0 transitivePeerDependencies: @@ -18254,7 +19137,7 @@ snapshots: - uglify-js - webpack-cli - nx@23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.1(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.1(@swc/helpers@0.5.23)): + nx@23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.13(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.13(@swc/helpers@0.5.23)): dependencies: '@clack/core': 1.4.3 '@clack/prompts': 1.7.0 @@ -18391,8 +19274,8 @@ snapshots: '@nx/nx-linux-x64-musl': 23.2.1 '@nx/nx-win32-arm64-msvc': 23.2.1 '@nx/nx-win32-x64-msvc': 23.2.1 - '@swc-node/register': 1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.1(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3) - '@swc/core': 1.16.1(@swc/helpers@0.5.23) + '@swc-node/register': 1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.13(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3) + '@swc/core': 1.16.13(@swc/helpers@0.5.23) object-assign@4.1.1: {} @@ -18433,6 +19316,10 @@ snapshots: obug@2.1.4: {} + obug@2.2.1: {} + + obug@3.0.0: {} + ofetch@1.5.1: dependencies: destr: 2.0.5 @@ -18508,7 +19395,7 @@ snapshots: is-unicode-supported: 2.1.0 log-symbols: 7.0.1 stdin-discarder: 0.3.2 - string-width: 8.2.2 + string-width: 8.3.0 ordered-binary@1.6.1: optional: true @@ -18544,6 +19431,30 @@ snapshots: '@oxc-parser/binding-win32-ia32-msvc': 0.142.0 '@oxc-parser/binding-win32-x64-msvc': 0.142.0 + oxc-parser@0.150.0: + dependencies: + '@oxc-project/types': 0.150.0 + optionalDependencies: + '@oxc-parser/binding-android-arm-eabi': 0.150.0 + '@oxc-parser/binding-android-arm64': 0.150.0 + '@oxc-parser/binding-darwin-arm64': 0.150.0 + '@oxc-parser/binding-darwin-x64': 0.150.0 + '@oxc-parser/binding-freebsd-x64': 0.150.0 + '@oxc-parser/binding-linux-arm-gnueabihf': 0.150.0 + '@oxc-parser/binding-linux-arm-musleabihf': 0.150.0 + '@oxc-parser/binding-linux-arm64-gnu': 0.150.0 + '@oxc-parser/binding-linux-arm64-musl': 0.150.0 + '@oxc-parser/binding-linux-ppc64-gnu': 0.150.0 + '@oxc-parser/binding-linux-riscv64-gnu': 0.150.0 + '@oxc-parser/binding-linux-riscv64-musl': 0.150.0 + '@oxc-parser/binding-linux-s390x-gnu': 0.150.0 + '@oxc-parser/binding-linux-x64-gnu': 0.150.0 + '@oxc-parser/binding-linux-x64-musl': 0.150.0 + '@oxc-parser/binding-openharmony-arm64': 0.150.0 + '@oxc-parser/binding-win32-arm64-msvc': 0.150.0 + '@oxc-parser/binding-win32-ia32-msvc': 0.150.0 + '@oxc-parser/binding-win32-x64-msvc': 0.150.0 + oxc-resolver@11.17.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3): optionalDependencies: '@oxc-resolver/binding-android-arm-eabi': 11.17.1 @@ -18642,7 +19553,7 @@ snapshots: parse5-html-rewriting-stream@8.0.1: dependencies: - entities: 8.0.0 + entities: 8.1.0 parse5: 8.0.1 parse5-sax-parser: 8.0.0 @@ -18675,10 +19586,10 @@ snapshots: path-scurry@2.0.2: dependencies: - lru-cache: 11.5.2 + lru-cache: 11.5.3 minipass: 7.1.3 - path-to-regexp@8.3.0: {} + path-to-regexp@8.4.2: {} path-type@4.0.0: {} @@ -18709,11 +19620,9 @@ snapshots: optionalDependencies: '@napi-rs/nice': 1.1.1 - pkce-challenge@5.0.1: {} - pkcs7@1.0.4: dependencies: - '@babel/runtime': 7.29.7 + '@babel/runtime': 7.29.10 pkg-dir@4.2.0: dependencies: @@ -18728,13 +19637,11 @@ snapshots: pvutils: 1.2.0 tslib: 2.8.1 - playwright-core@1.62.1: {} + playwright-core@1.64.0: {} - playwright@1.62.1: + playwright@1.64.0: dependencies: - playwright-core: 1.62.1 - optionalDependencies: - fsevents: 2.3.2 + playwright-core: 1.64.0 plist@3.1.0: dependencies: @@ -18765,14 +19672,14 @@ snapshots: camelcase-css: 2.0.1 postcss: 8.5.26 - postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.26)(tsx@4.23.13)(yaml@2.9.0): + postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.26)(tsx@4.23.15)(yaml@2.9.1): dependencies: lilconfig: 3.1.3 optionalDependencies: jiti: 1.21.7 postcss: 8.5.26 - tsx: 4.23.13 - yaml: 2.9.0 + tsx: 4.23.15 + yaml: 2.9.1 postcss-media-query-parser@0.2.3: {} @@ -18810,7 +19717,7 @@ snapshots: prelude-ls@1.2.1: {} - prettier@3.9.6: {} + prettier@3.9.9: {} pretty-format@30.2.0: dependencies: @@ -18852,7 +19759,7 @@ snapshots: property-information@7.1.0: {} - proxy-addr@2.0.7: + proxy-addr@2.0.8: dependencies: forwarded: 0.2.0 ipaddr.js: 1.9.1 @@ -18890,13 +19797,18 @@ snapshots: es-define-property: 1.0.1 side-channel: 1.1.1 + qs@6.16.0: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + queue-microtask@1.2.3: {} quick-lru@5.1.1: {} radix3@1.1.2: {} - range-parser@1.2.1: {} + range-parser@1.3.0: {} raw-body@3.0.2: dependencies: @@ -19290,7 +20202,7 @@ snapshots: depd: 2.0.0 is-promise: 4.0.0 parseurl: 1.3.3 - path-to-regexp: 8.3.0 + path-to-regexp: 8.4.2 transitivePeerDependencies: - supports-color @@ -19520,7 +20432,7 @@ snapshots: mime-types: 3.0.2 ms: 2.1.3 on-finished: 2.4.1 - range-parser: 1.2.1 + range-parser: 1.3.0 statuses: 2.0.2 transitivePeerDependencies: - supports-color @@ -19633,11 +20545,6 @@ snapshots: '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.5 - side-channel-list@1.0.0: - dependencies: - es-errors: 1.3.0 - object-inspect: 1.13.4 - side-channel-list@1.0.1: dependencies: es-errors: 1.3.0 @@ -19658,14 +20565,6 @@ snapshots: object-inspect: 1.13.4 side-channel-map: 1.0.1 - side-channel@1.1.0: - dependencies: - es-errors: 1.3.0 - object-inspect: 1.13.4 - side-channel-list: 1.0.0 - side-channel-map: 1.0.1 - side-channel-weakmap: 1.0.2 - side-channel@1.1.1: dependencies: es-errors: 1.3.0 @@ -19686,7 +20585,7 @@ snapshots: sitemap@9.0.1: dependencies: - '@types/node': 24.13.3 + '@types/node': 24.19.1 '@types/sax': 1.2.7 arg: 5.0.2 sax: 1.6.1 @@ -19695,7 +20594,7 @@ snapshots: slash@5.1.0: {} - slice-ansi@9.0.0: + slice-ansi@9.0.1: dependencies: ansi-styles: 6.2.3 is-fullwidth-code-point: 5.1.0 @@ -19721,6 +20620,8 @@ snapshots: source-map@0.7.6: {} + source-map@0.8.0: {} + space-separated-tokens@2.0.2: {} sprintf-js@1.0.3: {} @@ -19763,12 +20664,12 @@ snapshots: string-width@7.2.0: dependencies: emoji-regex: 10.6.0 - get-east-asian-width: 1.6.0 + get-east-asian-width: 1.7.0 strip-ansi: 7.2.0 - string-width@8.2.2: + string-width@8.3.0: dependencies: - get-east-asian-width: 1.6.0 + get-east-asian-width: 1.7.0 strip-ansi: 7.2.0 string.prototype.trim@1.2.10: @@ -19881,7 +20782,7 @@ snapshots: dependencies: '@pkgr/core': 0.3.6 - tailwindcss@3.4.19(tsx@4.23.13)(yaml@2.9.0): + tailwindcss@3.4.19(tsx@4.23.15)(yaml@2.9.1): dependencies: '@alloc/quick-lru': 5.2.0 arg: 5.0.2 @@ -19900,7 +20801,7 @@ snapshots: postcss: 8.5.26 postcss-import: 15.1.0(postcss@8.5.26) postcss-js: 4.1.0(postcss@8.5.26) - postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.26)(tsx@4.23.13)(yaml@2.9.0) + postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.26)(tsx@4.23.15)(yaml@2.9.1) postcss-nested: 6.2.0(postcss@8.5.26) postcss-selector-parser: 6.1.4 resolve: 1.22.11 @@ -19937,16 +20838,16 @@ snapshots: mkdirp: 0.5.6 rimraf: 2.6.3 - terser-webpack-plugin@5.3.16(@swc/core@1.16.1(@swc/helpers@0.5.23))(esbuild@0.28.2)(webpack@5.104.1(@swc/core@1.16.1(@swc/helpers@0.5.23))(esbuild@0.28.2)): + terser-webpack-plugin@5.3.16(@swc/core@1.16.13(@swc/helpers@0.5.23))(esbuild@0.28.2)(webpack@5.104.1(@swc/core@1.16.13(@swc/helpers@0.5.23))(esbuild@0.28.2)): dependencies: '@jridgewell/trace-mapping': 0.3.31 jest-worker: 27.5.1 schema-utils: 4.3.3 serialize-javascript: 7.1.0 terser: 5.44.1 - webpack: 5.104.1(@swc/core@1.16.1(@swc/helpers@0.5.23))(esbuild@0.28.2) + webpack: 5.104.1(@swc/core@1.16.13(@swc/helpers@0.5.23))(esbuild@0.28.2) optionalDependencies: - '@swc/core': 1.16.1(@swc/helpers@0.5.23) + '@swc/core': 1.16.13(@swc/helpers@0.5.23) esbuild: 0.28.2 terser@5.44.1: @@ -20037,12 +20938,12 @@ snapshots: ts-interface-checker@0.1.13: {} - ts-jest@29.4.12(@babel/core@7.29.7)(@jest/transform@30.5.1)(@jest/types@30.5.1)(babel-jest@30.5.1(@babel/core@7.29.7))(esbuild@0.28.2)(jest-util@30.5.1)(jest@30.5.1(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.2))(ts-node@10.9.2(@swc/core@1.16.1(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@6.0.3)))(typescript@6.0.3): + ts-jest@29.4.14(@babel/core@7.29.7)(@jest/transform@30.5.2)(@jest/types@30.5.1)(babel-jest@30.5.2(@babel/core@7.29.7))(esbuild@0.28.2)(jest-util@30.5.1)(jest@30.5.2(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.2))(ts-node@10.9.2(@swc/core@1.16.13(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@6.0.3)))(typescript@6.0.3): dependencies: bs-logger: 0.2.6 fast-json-stable-stringify: 2.1.0 - handlebars: 4.7.9 - jest: 30.5.1(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.2))(ts-node@10.9.2(@swc/core@1.16.1(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@6.0.3)) + handlebars: 4.7.10 + jest: 30.5.2(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.2))(ts-node@10.9.2(@swc/core@1.16.13(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@6.0.3)) json5: 2.2.3 lodash.memoize: 4.1.2 make-error: 1.3.6 @@ -20052,21 +20953,21 @@ snapshots: yargs-parser: 21.1.1 optionalDependencies: '@babel/core': 7.29.7 - '@jest/transform': 30.5.1 + '@jest/transform': 30.5.2 '@jest/types': 30.5.1 - babel-jest: 30.5.1(@babel/core@7.29.7) + babel-jest: 30.5.2(@babel/core@7.29.7) esbuild: 0.28.2 jest-util: 30.5.1 - ts-loader@9.6.2(typescript@6.0.3)(webpack@5.104.1(@swc/core@1.16.1(@swc/helpers@0.5.23))(esbuild@0.28.2)): + ts-loader@9.6.2(typescript@6.0.3)(webpack@5.104.1(@swc/core@1.16.13(@swc/helpers@0.5.23))(esbuild@0.28.2)): dependencies: chalk: 4.1.2 picomatch: 4.0.7 source-map: 0.7.6 typescript: 6.0.3 - webpack: 5.104.1(@swc/core@1.16.1(@swc/helpers@0.5.23))(esbuild@0.28.2) + webpack: 5.104.1(@swc/core@1.16.13(@swc/helpers@0.5.23))(esbuild@0.28.2) - ts-node@10.9.2(@swc/core@1.16.1(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@6.0.3): + ts-node@10.9.2(@swc/core@1.16.13(@swc/helpers@0.5.23))(@types/node@20.19.9)(typescript@6.0.3): dependencies: '@cspotcode/source-map-support': 0.8.1 '@tsconfig/node10': 1.0.12 @@ -20084,7 +20985,7 @@ snapshots: v8-compile-cache-lib: 3.0.1 yn: 3.1.1 optionalDependencies: - '@swc/core': 1.16.1(@swc/helpers@0.5.23) + '@swc/core': 1.16.13(@swc/helpers@0.5.23) tsconfig-paths-webpack-plugin@4.2.0: dependencies: @@ -20108,7 +21009,7 @@ snapshots: tslib@2.8.1: {} - tsx@4.23.13: + tsx@4.23.15: dependencies: esbuild: 0.28.2 optionalDependencies: @@ -20129,10 +21030,10 @@ snapshots: type-fest@4.41.0: {} - type-is@2.0.1: + type-is@2.1.0: dependencies: - content-type: 1.0.5 - media-typer: 1.1.0 + content-type: 2.1.0 + media-typer: 1.1.1 mime-types: 3.0.2 typed-array-buffer@1.0.3: @@ -20168,12 +21069,12 @@ snapshots: possible-typed-array-names: 1.1.0 reflect.getprototypeof: 1.0.10 - typescript-eslint@8.69.0(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3): + typescript-eslint@8.71.1(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.69.0(@typescript-eslint/parser@8.69.0(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3))(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3) - '@typescript-eslint/parser': 8.69.0(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3) - '@typescript-eslint/typescript-estree': 8.69.0(typescript@6.0.3) - '@typescript-eslint/utils': 8.69.0(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3) + '@typescript-eslint/eslint-plugin': 8.71.1(@typescript-eslint/parser@8.71.1(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3))(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3) + '@typescript-eslint/parser': 8.71.1(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3) + '@typescript-eslint/typescript-estree': 8.71.1(typescript@6.0.3) + '@typescript-eslint/utils': 8.71.1(eslint@9.39.2(jiti@1.21.7))(typescript@6.0.3) eslint: 9.39.2(jiti@1.21.7) typescript: 6.0.3 transitivePeerDependencies: @@ -20201,6 +21102,8 @@ snapshots: undici-types@7.18.2: {} + undici-types@7.24.6: {} + undici@6.28.0: {} undici@7.29.0: @@ -20345,6 +21248,12 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 + update-browserslist-db@1.3.4(browserslist@4.29.3): + dependencies: + browserslist: 4.29.3 + escalade: 3.2.0 + picocolors: 1.1.1 + uri-js@4.4.1: dependencies: punycode: 2.3.1 @@ -20372,7 +21281,7 @@ snapshots: vary@1.1.2: {} - verkit@0.3.2: {} + verkit@0.5.0: {} vfile-location@5.0.3: dependencies: @@ -20391,7 +21300,7 @@ snapshots: video.js@7.21.7: dependencies: - '@babel/runtime': 7.29.7 + '@babel/runtime': 7.29.10 '@videojs/http-streaming': 2.16.3(video.js@7.21.7) '@videojs/vhs-utils': 3.0.5 '@videojs/xhr': 2.6.0 @@ -20405,10 +21314,10 @@ snapshots: videojs-font: 3.2.0 videojs-vtt.js: 0.15.5 - video.js@8.24.0: + video.js@8.24.1: dependencies: - '@babel/runtime': 7.29.7 - '@videojs/http-streaming': 3.17.5(video.js@8.24.0) + '@babel/runtime': 7.29.10 + '@videojs/http-streaming': 3.17.5(video.js@8.24.1) '@videojs/vhs-utils': 4.1.2 '@videojs/xhr': 2.7.0 aes-decrypter: 4.0.2 @@ -20416,14 +21325,14 @@ snapshots: m3u8-parser: 7.2.0 mpd-parser: 1.4.0 mux.js: 7.1.0 - videojs-contrib-quality-levels: 4.1.0(video.js@8.24.0) + videojs-contrib-quality-levels: 4.1.0(video.js@8.24.1) videojs-font: 4.2.0 videojs-vtt.js: 0.15.5 - videojs-contrib-quality-levels@4.1.0(video.js@8.24.0): + videojs-contrib-quality-levels@4.1.0(video.js@8.24.1): dependencies: global: 4.4.0 - video.js: 8.24.0 + video.js: 8.24.1 videojs-font@3.2.0: {} @@ -20432,14 +21341,14 @@ snapshots: videojs-quality-selector-hls@1.1.1: dependencies: global: 4.4.0 - video.js: 8.24.0 - videojs-contrib-quality-levels: 4.1.0(video.js@8.24.0) + video.js: 8.24.1 + videojs-contrib-quality-levels: 4.1.0(video.js@8.24.1) videojs-vtt.js@0.15.5: dependencies: global: 4.4.0 - vite@8.1.5(patch_hash=469ec4025880fc7b86d3d8b3b4e2028572d6acf65bdfb93973db18fdb3c00ad9)(@types/node@20.19.9)(esbuild@0.28.2)(jiti@1.21.7)(less@4.5.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.49.2)(tsx@4.23.13)(yaml@2.9.0): + vite@8.1.5(patch_hash=469ec4025880fc7b86d3d8b3b4e2028572d6acf65bdfb93973db18fdb3c00ad9)(@types/node@20.19.9)(esbuild@0.28.2)(jiti@1.21.7)(less@4.5.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.1): dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 @@ -20455,10 +21364,10 @@ snapshots: sass: 1.101.0 sass-embedded: 1.100.0 terser: 5.49.2 - tsx: 4.23.13 - yaml: 2.9.0 + tsx: 4.23.15 + yaml: 2.9.1 - vite@8.2.2(@types/node@20.19.9)(esbuild@0.28.2)(jiti@1.21.7)(less@4.5.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.49.2)(tsx@4.23.13)(yaml@2.9.0): + vite@8.2.2(@types/node@20.19.9)(esbuild@0.28.2)(jiti@1.21.7)(less@4.5.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.1): dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 @@ -20474,12 +21383,12 @@ snapshots: sass: 1.101.0 sass-embedded: 1.100.0 terser: 5.49.2 - tsx: 4.23.13 - yaml: 2.9.0 + tsx: 4.23.15 + yaml: 2.9.1 - vitefu@1.1.3(vite@8.2.2(@types/node@20.19.9)(esbuild@0.28.2)(jiti@1.21.7)(less@4.5.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.49.2)(tsx@4.23.13)(yaml@2.9.0)): + vitefu@1.1.3(vite@8.2.2(@types/node@20.19.9)(esbuild@0.28.2)(jiti@1.21.7)(less@4.5.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.1)): optionalDependencies: - vite: 8.2.2(@types/node@20.19.9)(esbuild@0.28.2)(jiti@1.21.7)(less@4.5.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.49.2)(tsx@4.23.13)(yaml@2.9.0) + vite: 8.2.2(@types/node@20.19.9)(esbuild@0.28.2)(jiti@1.21.7)(less@4.5.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.49.2)(tsx@4.23.15)(yaml@2.9.1) w3c-xmlserializer@5.0.0: dependencies: @@ -20518,7 +21427,7 @@ snapshots: webpack-sources@3.3.3: {} - webpack@5.104.1(@swc/core@1.16.1(@swc/helpers@0.5.23))(esbuild@0.28.2): + webpack@5.104.1(@swc/core@1.16.13(@swc/helpers@0.5.23))(esbuild@0.28.2): dependencies: '@types/eslint-scope': 3.7.7 '@types/estree': 1.0.8 @@ -20542,7 +21451,7 @@ snapshots: neo-async: 2.6.2 schema-utils: 4.3.3 tapable: 2.3.0 - terser-webpack-plugin: 5.3.16(@swc/core@1.16.1(@swc/helpers@0.5.23))(esbuild@0.28.2)(webpack@5.104.1(@swc/core@1.16.1(@swc/helpers@0.5.23))(esbuild@0.28.2)) + terser-webpack-plugin: 5.3.16(@swc/core@1.16.13(@swc/helpers@0.5.23))(esbuild@0.28.2)(webpack@5.104.1(@swc/core@1.16.13(@swc/helpers@0.5.23))(esbuild@0.28.2)) watchpack: 2.5.2 webpack-sources: 3.3.3 transitivePeerDependencies: @@ -20633,7 +21542,12 @@ snapshots: wrap-ansi@10.0.1: dependencies: ansi-styles: 6.2.3 - string-width: 8.2.2 + string-width: 8.3.0 + + wrap-ansi@10.0.2: + dependencies: + ansi-styles: 6.2.3 + string-width: 8.3.0 wrap-ansi@6.2.0: dependencies: @@ -20694,6 +21608,8 @@ snapshots: yaml@2.9.0: {} + yaml@2.9.1: {} + yargs-parser@18.1.3: dependencies: camelcase: 5.3.1 @@ -20742,7 +21658,16 @@ snapshots: cliui: 9.0.1 escalade: 3.2.0 get-caller-file: 2.0.5 - string-width: 8.2.2 + string-width: 8.3.0 + y18n: 5.0.8 + yargs-parser: 22.0.0 + + yargs@18.2.0: + dependencies: + cliui: 9.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + string-width: 8.3.0 y18n: 5.0.8 yargs-parser: 22.0.0 @@ -20754,13 +21679,7 @@ snapshots: yoctocolors@2.2.0: {} - zod-to-json-schema@3.25.2(zod@4.4.3): - dependencies: - zod: 4.4.3 - - zod@4.4.3: {} - - zod@4.5.4: {} + zod@4.6.5: {} zone.js@0.16.3: {} From 90a31d9be936031c1134a0c0bd38589af2dfcab0 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Wed, 14 Oct 2026 08:06:23 +0200 Subject: [PATCH 2/5] feat(sources): redesign the Sources page and the playlist switcher (#1932) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(sources): add the shared source row and credential-free formatting One app-source-row (regular for the Sources page, compact for the playlist switcher) with an app-source-tile, built on pure helpers: - resolvePlaylistSourceKind: one provider rule for icons, filters and counts (the list filter and the panel counts used to disagree). - sourceDisplayName / sourceDisplayUrl: a name or URL never shows a password, token or MAC. URL imports that stored "get.php?username=…&password=…" as the title now show the host. - stripUrlCredentials (shared/logging): drops what the log redactor would mask, plus short user/pass keys. - formatSourceDate / sourceMetaParts / sourceStatusTag: one meta line order and Intl dates and durations in the UI language. - SourceStatusService + SourceHealthHistoryService: one status per source (desktop health check, browser Xtream portal status) and the last conclusive result kept across restarts, so "not responding for 3 days" survives a relaunch. No URL or credential is stored. Also fixes playlistNameHasCredentials answering differently on repeated calls (global regex lastIndex), and adds the UI-kit button, menu and segmented-control mixins (libs/ui/styles/_app-controls.scss); menus opt in with class="app-menu", styled by the components that use them. Co-Authored-By: Claude Opus 5.5 * feat(sources): rebuild the Sources page on the shared source row - Side list (UI-kit side list): title with the playlist count and an Add button, a Type group, and on desktop a Status group (Any / Responding / Not responding). The two filters combine and persist; each group counts within the other's choice. The chosen row uses one selected-row token (soft tint, outlined in light theme). - Page: a 28px title with the visible count instead of the bordered header bar; Clean up and the sort menu are secondary buttons. Clean up shows how many sources do not respond. Sort adds Last updated, Most channels and (desktop) Not responding first, and keeps Name Z-A and Custom order. - List: app-source-row with hover actions (Refresh, Edit, ⋯); a failing row keeps Retry in view. "No matches" offers Reset filters. Dragging in custom order no longer reuses positions of rows a filter hides. - Delete with Undo: no confirmation; the row leaves at once and the delete runs when the toast closes (its close button commits at once). Deleting the active source switches to the next row. A large Xtream delete shows its row as "Removing…" with progress and Cancel, as before. - The list's refresh flows moved into SourceListRefreshService; E2E and performance drivers follow the new markup and delete flow. Co-Authored-By: Claude Opus 5.5 * feat(switcher): restyle the toolbar trigger and open it with Cmd/Ctrl+P - The trigger is a 40px secondary button: the active source's tile with its status dot, its name and "type · channels" (or the source count), and a chevron that turns while open. The name comes from sourceDisplayName, so a URL-named playlist no longer shows its URL or credentials in the toolbar. - Cmd+P / Ctrl+P opens the switcher wherever it is shown (Cmd+K stays the command palette) and is listed in the keyboard shortcuts dialog. - The trigger's inline refresh button is gone; refresh stays in the playlist's ⋯ menu and the command palette. Co-Authored-By: Claude Opus 5.5 * feat(switcher): rebuild the playlist switcher as an overlay popover The switcher's mat-menu becomes a CDK connected overlay (app-playlist-switcher-panel), 440px wide, with a dimmed, blurred backdrop; only its list scrolls and row menus open above it. - Header with the source count, Add playlist and All sources. - Search by name or host, autofocused; the type filter is one segmented control (All / M3U / Xtream / Stalker with counts) instead of three checkboxes, remembered between openings (the old checkbox setting is migrated). The search is no longer kept between openings. - "Recent" (the three most recently opened, kept across restarts) above "All" in the Sources page order, as compact source rows: the current one tinted with a check. Each row's ⋯ menu holds Refresh, Playlist info, Account info, Check again and Remove (with Undo); the footer's info, account and add actions moved there and to the header. - Keyboard: a listbox driven by aria-activedescendant from the search field. Arrow keys move and wrap, Enter opens, Shift+F10 opens the row's menu, Escape closes and returns focus to the trigger. Hints in the footer. - Deleting from a row menu moves focus to the toast's Undo, since the row (and its focus) left the page. Co-Authored-By: Claude Opus 5.5 * refactor(sources): remove the old source row, filter state and dead styles - Delete app-playlist-item and SourceHealthIndicatorComponent, replaced by app-source-row and its tile dot. - Delete the ngrx type-filter slice (FilterActions, selectedFilters, selectActiveTypeFilters); the Sources filters live in SourcesViewStateService. - Drop the empty state's unused "no results" variant and its styles. - The switcher and Sources list components are no longer in the max-lines baseline. - Docs: UI guidelines (source row, Sources page and switcher, status dot, selected-row tokens, UI-kit mixins), the source-health contract and the zoneless inventory. Release notes for the redesign and the credentials fix. Co-Authored-By: Claude Opus 5.5 * fix(catalog): keep the refine menu whole in short windows The refine menu held 12 rows at 48px plus two section titles (657px). Angular Material 22 sets no max-height on a menu panel, so below ~800px the overlay was pushed up over its own Refine button, and at 600px the last thresholds were clipped behind an invisible scrollbar. The minimum-rating thresholds now open in a submenu from one row that names the active threshold. The menu is about 390px and opens below its button at 600px. The submenu trigger sits in a role="none" wrapper: Material offsets a submenu by the first direct mat-menu-item's offsetTop, and with the sort rows inside a role="group" that item was the trigger itself, which opened the submenu 335px off screen. The Electron E2E opens both menus by keyboard in a 600px-tall window and checks that each lies whole on screen without scrolling. Co-Authored-By: Claude Opus 5.5 * style(workspace): drop the side panel's divider line The context panel beside Sources, categories, Settings and collections drew a 1px outline-variant border against the content surface, whose background, shadow and rounded top-left corner already mark the edge; the line clashed with that corner. The border stays 1px but transparent, so the panel width, the persisted sidebar widths and the resize handle's position do not move. The handle keeps its hover and drag highlight. Co-Authored-By: Claude Opus 5.5 * fix(switcher): keep the search field and type filter whole with many sources The switcher popover is a height-capped flex column. With a long list (hundreds of sources) the browser shrank every part in proportion to its size, not only the list: the type filter, which clips its overflow, collapsed to 2px and showed no labels, and the search field dropped from 36px to its 21px text line. The header, search, type filter, empty state and footer are now flex: none, so only the list shrinks and scrolls. The search field now matches the header and side-panel search fields: the shared --app-search-* tokens, a 10px radius, an 18px icon, the same text size and focus halo. The Electron E2E opens the switcher in a 320px-tall window and checks the search height, the type toggles and the scrolling list; it fails without the fix (search 21px). Co-Authored-By: Claude Opus 5.5 * fix(sources): checks behind filters, keyboard Undo in the switcher, check causes Local review round 1 (Codex + Greptile) on the redesign: - A saved Responding / Not responding filter hid every source without a result, and the hidden rows were what started the checks, so the list stayed empty. keepSourcesChecked (portal shared data-access) now keeps every library source checked while the Sources page lives, on desktop where a check is one shared job that rows join. - Removing from the switcher left its focus trap open around the search, outside of which the Undo toast sits: Tab could not reach Undo. The popover now closes first; Undo takes the focus and returns it to the trigger (SourceRemovalOptions.returnFocusTo). Every switcher action closes through one path, so a closing row menu no longer pulls the focus back into the popover. - A running desktop Xtream delete brought its source back into the switcher as an ordinary option that could be opened or removed again; the switcher now leaves it out until the delete ends (the Sources page shows its progress and Cancel). - Undo re-activated the removed source over a source the user had chosen since, when nothing had replaced it (it was the only row a filter showed). Undo now restores only while no other source is active. - The switcher highlight stays on its option, or in range, when the list changes under the same search, so Enter still opens something. - The status dot named every failure "Could not connect". SourceStatus now carries the check's cause (access denied, account disabled, timeout…, kept with the health history and validated on read), and M3U dots again say the check covers the playlist URL, not its channels. Tests: new specs for keepSourcesChecked, the saved-filter check, the Undo guard and focus return, the switcher close/highlight/deleting cases and the dot's cause; the filter and Undo specs fail without their fixes. The switcher keyboard E2E now deletes and undoes from the keyboard. Co-Authored-By: Claude Opus 5.5 * fix(sources): own Undo timer, per-connection health history, race-free perf waits Local review round 2 (Codex + Greptile): - The delete committed whenever the shared snack bar was dismissed, so another message (a refresh finishing elsewhere) cut the four-second Undo window short and deleted the source early. The window is now the service's own timer; only the toast's close button (commitNow) or a second delete commits sooner. - The health history was keyed by playlist ID only, so an edited address or account inherited the old connection's result, failing run included. Entries now keep a 32-bit FNV-1a fingerprint of the source health key (no URL or credential); a result for another connection starts over and a stale entry no longer shows. - The M3U refresh-cancellation probe still queried the removed row's .cancel-btn, .busy-state__message and .action-spinner, so its cancel never fired; it now uses the source row's selectors (smoke run passes, cancelEffect=true). - The Xtream delete benchmark waited to catch the "Removing…" row coming back before it left, which a fast delete could finish before the wait began. The driver now arms a delete-playlist operation window before the commit click and ends on its completed event and the absent row. Co-Authored-By: Claude Opus 5.5 * fix(sources): sort by the shown name; start the benchmark's Undo window in its trigger Local review round 3 (Greptile; Codex was clean): - Name order compared the stored title, while the rows show a URL-named or credential-bearing source by its host: a source stored as get.php?username=… sorted by its hidden account details. sortPlaylists takes the list's name function, and Sources and the switcher pass sourceSortName, the name they display. - The Xtream delete benchmark clicked Remove while preparing, which starts the four-second Undo window; capture setup before the trigger could outlast it, so the delete committed on its own before the measurement and the close button was gone. Preparation now stops at the open menu, and the trigger clicks Remove and then the toast's close button, with the trigger time taken right before the commit click as before. Co-Authored-By: Claude Opus 5.5 * fix(sources): never hand over to a leaving source; browser checks per connection Local review round 4 (Codex + Greptile): - Removing the active source handed over to its neighbour even when that neighbour's own delete was still running ("Removing…"), activating a source about to disappear. replacementAfterRemoval (playlist shared util) picks the nearest row that is not leaving, on the Sources page and in the switcher; SourceRemovalService also refuses a leaving replacement. - In the browser build a "Check again" for an account the user then edited could finish last and overwrite the new account's status under the same playlist ID. Portal statuses are now keyed by playlist and connection fingerprint, so the old check lands under the old key. - The switcher scrolled the highlighted option into view through a selector built from the playlist ID, which a restored ID with "." or ":" broke (or made throw). It now finds the row by its exact DOM ID. Co-Authored-By: Claude Opus 5.5 * fix(sources): keep ⌘P out of open dialogs; redact refresh errors Local review round 5 (Greptile; Codex was clean): - Ctrl/Cmd+P opened the playlist switcher over an open dialog (an editor, a confirmation), taking the focus out of it and handing it back to the toolbar behind the dialog on close. The shortcut now leaves the key alone while MatDialog has open dialogs. - SourceListRefreshService logged a failed refresh's raw error, which can carry the playlist URL and its credentials. It now logs through the redacting portal logger (createLogger). Co-Authored-By: Claude Opus 5.5 * fix(sources): show a new search's first match; keep inconclusive check causes Local review round 6 (Codex; Greptile was clean at 5/5): - Typing a search or picking a type in the switcher highlighted the first match but kept the list scrolled where the previous result left it, so the highlighted row could sit above the view. The list returns to the top whenever the search or type changes. - A source with no kept history whose check was inconclusive (connection checks paused by the connectivity guard, a cancelled check) lost the live cause and time, and its dot said only "Not verified". The status now keeps them; a running check still reads as checking. Co-Authored-By: Claude Opus 5.5 * fix(switcher): fall back to All when the remembered type has no sources left Local review round 7 (Greptile 5/5 with one P2; Codex was clean): when the last source of the remembered switcher type was removed while two other types remained, the list kept filtering by the missing type. Its segment was gone, no segment was selected, and the list said "Nothing found". The list now filters by All until a source of that type returns; the remembered choice is kept. Co-Authored-By: Claude Opus 5.5 * fix(sources): clean credentials nested in kept URL parts Codex bot (PR review, P1): stripUrlCredentials copied every non-sensitive parameter value unchanged, so a URL nested in one (a proxy's ?url=http%3A%2F%2Fjoe%3Ahunter2%40origin.test%2F…) kept its userinfo and password and reached the Sources row through sourceDisplayUrl. Kept query and fragment values and path segments are now cleaned recursively, up to four levels: a nested URL loses its credentials the same way, a nested parameter list loses its sensitive keys, and a part that still names a secret (joe:hunter2@host, token=…) is dropped. Deeper nesting is dropped rather than shown. Co-Authored-By: Claude Opus 5.5 * fix(sources): keep fragment and nested URL secrets out of source names Greptile bot (PR review, P1 security): an unnamed import of https://host.test/list.m3u#api_key=secret123 stored list.m3u#api_key=… as its title. isUrlDerivedName only knew names ending in a query, and sourceSecrets only read the top-level query, so the row and the toolbar showed the secret while the displayed URL hid it. A name that ends the URL with a query or a fragment is now URL-derived, and shows the host. The secrets a typed name is checked against now include sensitive fragment parameters and, as stripUrlCredentials cleans them, the credentials of URLs and parameter lists nested in parameters (nested account names included). Co-Authored-By: Claude Opus 5.5 * fix(sources): keep deletes busy until settled and on screen; refresh E2E via the switcher PR #1932 round 1 (CI + local Codex/Greptile on the bot fixes): - CI: category-management.e2e refreshed the active playlist through the old switcher trigger's inline refresh button, which the redesign replaced with the switcher row menu (⋯ → Refresh playlist). The helper now goes through that menu; it failed on all three OS shards. - A desktop Xtream delete's completed event cleared its progress while PlaylistsService still cleaned up (parental lock, stored state), so the already-deleted source was actionable again on Sources and back in the switcher until the store dropped it. A terminal event now only ends Cancel; the row stays busy until the delete settles and the removal service clears it. - A running delete (from the switcher, say) disappeared from Sources when the page's saved filters excluded it, hiding its only progress and Cancel. Running deletes stay listed whatever the filters. Co-Authored-By: Claude Opus 5.5 * fix(sources): keep counting a failure's duration while the list stays open Local Greptile (5/5, one P2) on the PR round: the row's status tag formatted "Not responding · 3 days" against the time the row mounted (or the language last changed), so a new failure never gained its duration and an existing one froze while Sources stayed open. A shared root SourceClock now ticks once a minute and only the status tag reads it, so dates and the meta line do not recompute every minute. Co-Authored-By: Claude Opus 5.5 * fix(sources): keep the page header in view; mark the active source's row Local Greptile (two P2s) on the PR round, both regressions against master: - The redesigned Sources page scrolled as one, so the title, Clean up and Sort left the view in a long library (master kept a sticky header, and the UI guidelines say headers stay and lists scroll). The header now stays and only the list below it scrolls, which also keeps a row reached by keyboard from scrolling under the header. - Master marked the active playlist on the Sources page; the new row only tinted the current row in the compact (switcher) size. The active source's row is now tinted with the same --app-list-selected-* token on the page too; the switcher keeps its check. The design handoff called the header non-sticky and the current tint switcher-only; both followed master and the guidelines instead. Co-Authored-By: Claude Opus 5.5 * fix(sources): name the Sources sort group; refit the switcher on resize Local review on the PR round: - Greptile: the Sources sort menu's radio group had no accessible name, unlike every other menu group (UI guidelines). It is named by its section, "Sort by". - Codex: the switcher popover capped its height once, when it opened, so shrinking the window while it was open left the footer and the lower rows out of reach. A window resize refits the open popover. Co-Authored-By: Claude Opus 5.5 * wip: linear URL-cleaning regexes * fix(sources): linear URL-cleaning patterns; keep the stripper off the startup path PR #1932 CI: - CodeQL (polynomial regular expression on uncontrolled data): the embedded-URL and keyed-pair patterns in stripUrlCredentials could retry from every position of a long run of letters. Matches now start only where a name can start (lookbehinds) and every name part is bounded, as is the bare-userinfo pattern; a spec times 50,000-character runs. The preceding "wip: linear URL-cleaning regexes" commit holds that change. - Initial bytes ratchet (+135 B over after master's #1928 lowered the baseline): stripUrlCredentials sat behind the shared logging barrel, which startup code imports, so esbuild placed it in an initial chunk although only the lazy Sources page and switcher use it. It now lives beside its only consumer in playlist/shared/util source-display, and the logging barrel exports the two redaction helpers it needs. Measured locally on the merge with master: 1,537,497 bytes, within 1,538,700. Co-Authored-By: Claude Opus 5.5 * fix(sources): case-insensitive Xtream path markers; no removal during a refresh - Strip and redact the account segments after `/LIVE/`, `/Movie/` or an encoded marker such as `/%6Cive/`, as after `/live/`. The display-name check also treats an Xtream path password as a URL secret. - A source cannot be removed while a switcher refresh clears and refills it: the removal service ignores the request, the switcher disables Remove, and the Sources page shows the refresh as busy. Co-Authored-By: Claude Opus 5.5 * fix(sources): search URLs nested in path segments for name secrets A proxy URL can carry a whole source URL, or a token pair, in one path segment. The display stripper already cleans such segments; the name check now collects their secrets too, so a name holding them collapses to the host. Co-Authored-By: Claude Opus 5.5 * fix(sources): decode nested URLs before classifying; narrow Xtream paths - A URL nested in a parameter or path segment is percent-decoded one round at a time until it reads as a URL or parameter list, so one encoded twice is cleaned too; a value still encoded after the last round is dropped. The name check unwraps nested values the same way. - The two segments after `/live/` and its siblings count as an account only when a stream follows them, so a playlist path such as `/live/news/list.m3u` keeps its file name in the URL and the title. - Adding a playlist from the phone drawer closes the drawer first, so two focus-trapped modals never stack. Co-Authored-By: Claude Opus 5.5 * fix(sources): clean URL-shaped fragments; one registry for busy sources - A fragment is cleaned whatever its shape: `#https://joe:pw@…` loses its userinfo, an unclassifiable secret is dropped, and the name check reads a URL in the fragment as well as a parameter list. - Every refresh holds its source in the root SourceActivityService, which deletes and the startup auto-refresh already use: Sources-row refreshes, the switcher's M3U refresh and the shared Xtream flow. The switcher no longer offers Refresh or Remove for a source the page is refreshing, the removal service ignores a held source, a second refresh from either entry point is skipped, and the page disables Remove while it is held. - The long-input timing spec allows 3 s: the inputs take about 0.25 s, a backtracking pattern takes minutes, and a loaded runner took 1.6 s. Co-Authored-By: Claude Opus 5.5 * fix(sources): keep a browser refresh busy until the backend answers The browser build's PLAYLIST_UPDATE returned at once while its request ran on, so the row stayed refreshable and removable and the shared registry saw the source as idle. The event now settles when the refresh does (the PWA service resolves after its success or error report; the Electron fallback awaits its handler), and both the Sources row and the switcher keep the source busy and held until then. Co-Authored-By: Claude Opus 5.5 * fix(sources): name check hides values under short account keys The name check used the log redactor's key list, which leaves out the short keys `pass`, `pas`, `user` and `usr` that the URL stripper drops. Both now share `isDisplaySensitiveKey`, so a name holding `?pass=…` collapses to the host; `user`/`usr` are account names like `username` and stay shown at the top level, as by design. Co-Authored-By: Claude Opus 5.5 * fix(sources): read keys, markers and secrets through every encoding layer Each check decoded its input once, so one more encoding layer slipped a secret past it: `?%2570assword=…` kept its value, and `/%256Cive/…` kept its account segments, both on screen and in logs. `decodeFully` (shared logging) undoes up to four percent-encoding layers and is now the one form every name check reads: the redactor's key normaliser and Xtream marker check, the display key predicate and path check, and the name check's secret values. Logs and the Sources lists gain the fix together. Co-Authored-By: Claude Opus 5.5 * fix(sources): read Xtream paths decoded; keep short decoded forms out - An Xtream account could hide behind encoded separators (`live%2Fjoe%2Fhunter2%2F1.ts` is one raw segment). `findXtreamAccount` reads the path as decoded parts, maps them back to raw segments, and is the one scanner for the log redactor, the on-screen URL and the name check. Account segments go whole, joined ones included. - The name check required a minimum length only of the raw secret, so a decoded form as short as one letter (`%2542` is `B`) hid ordinary names. Each form must now be long enough on its own. - The decoding helpers move to `url-credential-parts.ts`, which keeps the redactor under the line limit. Co-Authored-By: Claude Opus 5.5 * fix(sources): read an Xtream path both whole-segment and split Reading the path only as decoded parts misplaced the account when the user name itself held an encoded slash (`/live/joe%2Fx/hunter2/1.ts`): the password segment stayed on screen and in logs. `findXtreamAccount` now scans both readings, each raw segment decoded whole and the decoded path split on every separator, and drops what either finds. Co-Authored-By: Claude Opus 5.5 * fix(sources): drop JSON configs from shown URLs; read their secrets A nested value such as `?config={"password":"…"}` was neither a URL nor a parameter list, and the quoted key slipped past the free-text check. - A nested value that parses as a JSON object or array is dropped from the shown URL (JSON can spell a key so no pattern reads it, such as `w`); brackets that are not JSON, like `[1080p]`, stay. - The free-text check accepts a quoted key (`"token": …`). - The name check parses JSON values and collects what sits under sensitive keys, and follows URLs in its other strings. Co-Authored-By: Claude Opus 5.5 * fix(sources): hold Xtream sources through re-import; match secrets in any case - An Xtream refresh released its hold when navigation ended, while the re-import ran on from the route session, so Remove came back during the import's writes. The route session now takes the hold synchronously on NavigationEnd and keeps it until its sync, and the import it awaits, settle; the store also holds the source for every content initialization, retries included. - Secret matching was case-sensitive against encodeURIComponent's upper-case escapes, so `%3a` or a lower-case MAC slipped through. Text and secrets are now compared lower-cased, the text also fully decoded. - The Xtream `· username` label is left out when it is, or holds, the password. Co-Authored-By: Claude Opus 5.5 * fix(sources): read a bracketed key as parameters unless it is JSON The name check returned early for any value starting with `{` or `[`, so `[password]=…` (not JSON) skipped the parameter check and its password stayed in the name. It now returns early only when the value parses as a JSON object or array. Co-Authored-By: Claude Opus 5.5 * fix(sources): no opening a source from the switcher mid-refresh While an Xtream refresh was still deleting the catalog, ⌘P could reopen the switcher and selecting that source navigated at once, starting its import before the user data to restore was parked. The refresh flow now exposes `isRunning` (its set of runs is a signal), and the switcher marks a refreshing source aria-disabled and ignores its selection by pointer and Enter alike. Imports alone do not block opening a source. Co-Authored-By: Claude Opus 5.5 * fix(sources): hand over only to an idle source; hide names that are a short secret - Removing the active source could hand over to a neighbour a refresh was still clearing, opening it and starting its import early. `SourceRemovalService.canTakeOver` (not leaving, not deleting, not held in SourceActivityService) now picks the replacement in both lists and gates it in the service. - Secrets under three characters were dropped from the check entirely, so a name that was just a two-letter password showed it. Short secrets now count as the whole text (trimmed, any case); longer ones still match inside a text. Co-Authored-By: Claude Opus 5.5 * fix(sources): offer Refresh only when it can run; drag handle never opens - PlaylistRefreshActionService runs one refresh at a time, but canRefresh checked only the source, so the switcher, the command palette and the dashboard offered a Refresh that refresh() then ignored. canRefresh now applies the same global guard. - In Custom order a click on a row's drag handle bubbled to the row and opened the source; the handle now stops it. Co-Authored-By: Claude Opus 5.5 * fix(sources): no idle Refresh on held rows; Recent counts the filtered list - The Sources page offered Refresh on a source the startup auto-refresh, an import or a delete held, and refresh() then refused it. The row now offers Refresh only while it can run, keeping it (with its spinner) while that source refreshes. - The switcher showed Recent whenever the library had six sources, so a type filter down to one or two listed them twice and the keyboard visited each twice. Recent now depends on the filtered count. Co-Authored-By: Claude Opus 5.5 * fix(sources): compare passwords in their own case; only a MAC caselessly Matching every secret lower-cased hid any name holding the password word in another case: the Electron E2E source "Stress Xtream Portal" (password `stress`) showed its host, and the deletion test could not find its row. A password is case-sensitive, so it is compared as written; the text is still read fully decoded, so `%3a` escapes count as `:`. A MAC is the same address in either letter case and is compared lower-cased. Co-Authored-By: Claude Opus 5.5 * fix(sources): match a MAC from the URL in any letter case Only the source's own `macAddress` was compared caselessly, so an M3U URL carrying `?mac=…` (nested ones too) left the MAC visible in a name written in another case. Any MAC-shaped secret, and every form of the source's own MAC, is now compared lower-cased; passwords keep their case. Co-Authored-By: Claude Opus 5.5 * perf(sources): keep source-health helpers off the startup path Master's #1931 lowered the initial-bytes baseline, and this branch then sat 535 bytes over its slack. Most of its growth was runtime helpers in `@iptvnator/shared/interfaces` (reachability, health history, reason and fingerprint helpers) that only the lazily loaded source services call: the bundler put them in a chunk the app preloads. They now live in `@iptvnator/portal/shared/data-access` beside those services (types stay in interfaces); initialBytes 1,350,438 → 1,349,048, 855 under the limit. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .changes/catalog-refine-menu-short-windows.md | 6 + .changes/sources-hide-url-credentials.md | 6 + .../sources-redesigned-list-and-switcher.md | 7 + .changes/workspace-side-panel-no-divider.md | 6 + .../src/catalog-sort-menus.e2e.ts | 147 ++- .../src/category-management.e2e.ts | 9 +- .../src/electron-test-fixtures.ts | 73 +- .../src/file-import.e2e.ts | 2 +- .../src/keyboard-focus-ring.e2e.ts | 9 +- .../journey-renderer-probe.spec.ts | 4 +- .../src/performance/journey-renderer-probe.ts | 4 +- .../m3u-refresh-cancellation.benchmark.ts | 2 +- .../m3u-refresh-renderer-capture.ts | 15 +- .../src/performance/xtream-renderer-probe.ts | 4 +- .../xtream-scenario-driver-wiring.spec.ts | 13 +- .../xtream-scenario-driver.spec.ts | 61 +- .../src/performance/xtream-scenario-driver.ts | 34 +- .../xtream-scenario-operation-observer.ts | 63 +- .../src/performance/xtream-scenario-ui.ts | 72 +- .../xtream-ui-action-probe.spec.ts | 2 +- .../src/performance/xtream-ui-action-probe.ts | 4 +- .../src/playlist-switcher.e2e.ts | 258 ++++- .../src/sources-keyboard.e2e.ts | 80 +- apps/electron-backend-e2e/src/sources.e2e.ts | 43 +- .../src/xtream-responsiveness.e2e.ts | 25 +- apps/web-e2e/src/icon-button-names.e2e.ts | 8 +- .../src/player-settings-panel.fixture.ts | 2 +- apps/web-e2e/src/self-hosted.e2e.ts | 2 +- apps/web-e2e/src/sources-pwa.helpers.ts | 7 +- apps/web-e2e/src/stalker.e2e.ts | 11 +- apps/web-e2e/src/xtream.e2e.ts | 11 +- apps/web/src/app/services/electron.service.ts | 3 +- apps/web/src/app/services/pwa.service.spec.ts | 49 + apps/web/src/app/services/pwa.service.ts | 62 +- apps/web/src/assets/i18n/ar.json | 41 +- apps/web/src/assets/i18n/ary.json | 41 +- apps/web/src/assets/i18n/by.json | 41 +- apps/web/src/assets/i18n/de.json | 41 +- apps/web/src/assets/i18n/el.json | 41 +- apps/web/src/assets/i18n/en.json | 41 +- apps/web/src/assets/i18n/es.json | 41 +- apps/web/src/assets/i18n/fr.json | 41 +- apps/web/src/assets/i18n/hu.json | 41 +- apps/web/src/assets/i18n/it.json | 41 +- apps/web/src/assets/i18n/ja.json | 41 +- apps/web/src/assets/i18n/ko.json | 41 +- apps/web/src/assets/i18n/nl.json | 41 +- apps/web/src/assets/i18n/pl.json | 41 +- apps/web/src/assets/i18n/pt.json | 41 +- apps/web/src/assets/i18n/ru.json | 41 +- apps/web/src/assets/i18n/tr.json | 41 +- apps/web/src/assets/i18n/zh.json | 41 +- apps/web/src/assets/i18n/zhtw.json | 41 +- apps/web/src/m3-theme.scss | 30 + docs/architecture/iptvnator-ui-guidelines.md | 137 ++- docs/architecture/m3u-playlist-module.md | 9 + docs/architecture/zoneless-migration.md | 8 +- libs/m3u-state/src/lib/actions.ts | 7 - libs/m3u-state/src/lib/playlists.state.ts | 2 - .../src/lib/reducers/filter.reducers.ts | 18 - libs/m3u-state/src/lib/reducers/index.ts | 4 +- libs/m3u-state/src/lib/selectors.ts | 5 - libs/playlist/shared/ui/src/index.ts | 4 + .../playlist-refresh-action.service.spec.ts | 32 +- .../lib/playlist-refresh-action.service.ts | 43 +- .../playlist-switcher-list.spec.ts | 108 ++ .../playlist-switcher-list.ts | 116 +++ .../playlist-switcher-open.service.ts | 31 + .../playlist-switcher-panel.component.html | 170 ++++ .../playlist-switcher-panel.component.scss | 202 ++++ .../playlist-switcher-panel.component.spec.ts | 407 ++++++++ .../playlist-switcher-panel.component.ts | 407 ++++++++ .../playlist-switcher.component.html | 338 +----- .../playlist-switcher.component.scss | 678 ++----------- .../playlist-switcher.component.spec.ts | 503 ++------- .../playlist-switcher.component.ts | 629 +++--------- .../empty-state/empty-state.component.html | 17 - .../empty-state/empty-state.component.scss | 34 - .../empty-state/empty-state.component.ts | 2 +- .../empty-state/empty-state.responsive.scss | 21 - .../playlist-item.component.html | 290 ------ .../playlist-item.component.scss | 290 ------ .../playlist-item.component.spec.ts | 408 -------- .../playlist-item/playlist-item.component.ts | 173 ---- .../recent-playlists.component.html | 94 +- .../recent-playlists.component.scss | 60 +- .../recent-playlists.component.spec.ts | 959 ++++++------------ .../recent-playlists.component.ts | 817 ++++----------- .../source-list-refresh.service.spec.ts | 639 ++++++++++++ .../source-list-refresh.service.ts | 326 ++++++ .../source-health-indicator.component.spec.ts | 65 -- .../source-health-indicator.component.ts | 112 -- .../source-delete-progress.service.ts | 115 +++ .../source-removal-toast.component.scss | 29 + .../source-removal-toast.component.spec.ts | 116 +++ .../source-removal-toast.component.ts | 111 ++ .../source-removal.service.spec.ts | 402 ++++++++ .../source-removal/source-removal.service.ts | 295 ++++++ .../ui/src/lib/source-row/source-clock.ts | 22 + .../lib/source-row/source-row.component.html | 261 +++++ .../lib/source-row/source-row.component.scss | 366 +++++++ .../source-row/source-row.component.spec.ts | 229 +++++ .../lib/source-row/source-row.component.ts | 222 ++++ .../lib/source-row/source-tile.component.scss | 74 ++ .../lib/source-row/source-tile.component.ts | 111 ++ .../lib/xtream-refresh-flow.service.spec.ts | 37 + .../ui/src/lib/xtream-refresh-flow.service.ts | 40 +- libs/playlist/shared/util/src/index.ts | 5 + .../util/src/lib/playlist-context.facade.ts | 3 + .../playlist-recent-history.service.spec.ts | 35 + .../lib/playlist-recent-history.service.ts | 51 + .../lib/source-display/source-display.spec.ts | 411 ++++++++ .../src/lib/source-display/source-display.ts | 330 ++++++ .../lib/source-display/source-format.spec.ts | 206 ++++ .../src/lib/source-display/source-format.ts | 221 ++++ .../lib/source-display/source-list.spec.ts | 155 +++ .../src/lib/source-display/source-list.ts | 151 +++ .../sources-view-state.service.ts | 63 ++ .../strip-url-credentials.spec.ts | 258 +++++ .../source-display/strip-url-credentials.ts | 220 ++++ .../category-content-view.component.html | 102 +- .../category-content-view.component.scss | 15 + .../category-content-view.component.spec.ts | 28 +- libs/portal/shared/data-access/src/index.ts | 4 + .../lib/source-health-history.service.spec.ts | 116 +++ .../src/lib/source-health-history.service.ts | 131 +++ .../src/lib/source-health-identity.ts | 44 + .../src/lib/source-health.service.ts | 9 + .../src/lib/source-reachability.spec.ts | 135 +++ .../src/lib/source-reachability.ts | 116 +++ .../src/lib/source-status-checks.spec.ts | 98 ++ .../src/lib/source-status-checks.ts | 58 ++ .../src/lib/source-status.service.spec.ts | 289 ++++++ .../src/lib/source-status.service.ts | 214 ++++ .../src/lib/keyboard-shortcut-definitions.ts | 17 +- .../with-content.feature.import-phase.spec.ts | 14 + .../stores/features/with-content.feature.ts | 8 + ...am-workspace-route-session.service.spec.ts | 25 + .../xtream-workspace-route-session.service.ts | 21 + libs/services/src/lib/sort.service.spec.ts | 109 ++ libs/services/src/lib/sort.service.ts | 45 +- libs/shared/interfaces/src/index.ts | 1 + .../lib/playlist-display-label.util.spec.ts | 17 +- .../src/lib/playlist-display-label.util.ts | 6 +- .../interfaces/src/lib/source-reachability.ts | 51 + .../src/lib/source-type-icons.util.spec.ts | 11 + .../src/lib/source-type-icons.util.ts | 17 + libs/shared/logging/src/index.ts | 4 + .../src/lib/redact-sensitive-data.spec.ts | 64 +- .../logging/src/lib/redact-sensitive-data.ts | 24 +- .../logging/src/lib/url-credential-parts.ts | 97 ++ libs/ui/styles/_app-controls.scss | 302 ++++++ libs/ui/styles/_index.scss | 1 + libs/ui/styles/_side-list.scss | 102 ++ ...space-shell-context-sidebar.component.scss | 11 +- .../workspace-shell-header.component.html | 8 - .../workspace-shell-header.component.spec.ts | 8 - .../workspace-shell-header.component.ts | 19 - .../workspace-shell-header.service.ts | 1 - .../services/workspace-shell.facade.spec.ts | 39 + .../services/workspace-shell.facade.ts | 21 +- .../workspace-shell.component.html | 7 - .../workspace-shell.component.spec.ts | 8 - ...space-sources-filters-panel.component.html | 104 +- ...space-sources-filters-panel.component.scss | 120 +-- ...ce-sources-filters-panel.component.spec.ts | 160 +++ ...rkspace-sources-filters-panel.component.ts | 166 +-- .../workspace-sources-sort-options.ts | 53 + .../workspace-sources.component.html | 129 ++- .../workspace-sources.component.scss | 136 ++- .../workspace-sources.component.spec.ts | 144 ++- .../workspace-sources.component.ts | 346 +++---- tools/eslint/max-lines-baseline.mjs | 2 - 173 files changed, 12821 insertions(+), 5573 deletions(-) create mode 100644 .changes/catalog-refine-menu-short-windows.md create mode 100644 .changes/sources-hide-url-credentials.md create mode 100644 .changes/sources-redesigned-list-and-switcher.md create mode 100644 .changes/workspace-side-panel-no-divider.md delete mode 100644 libs/m3u-state/src/lib/reducers/filter.reducers.ts create mode 100644 libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher-list.spec.ts create mode 100644 libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher-list.ts create mode 100644 libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher-open.service.ts create mode 100644 libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher-panel.component.html create mode 100644 libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher-panel.component.scss create mode 100644 libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher-panel.component.spec.ts create mode 100644 libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher-panel.component.ts delete mode 100644 libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.html delete mode 100644 libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.scss delete mode 100644 libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.spec.ts delete mode 100644 libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.ts create mode 100644 libs/playlist/shared/ui/src/lib/recent-playlists/source-list-refresh.service.spec.ts create mode 100644 libs/playlist/shared/ui/src/lib/recent-playlists/source-list-refresh.service.ts delete mode 100644 libs/playlist/shared/ui/src/lib/source-health/source-health-indicator.component.spec.ts delete mode 100644 libs/playlist/shared/ui/src/lib/source-health/source-health-indicator.component.ts create mode 100644 libs/playlist/shared/ui/src/lib/source-removal/source-delete-progress.service.ts create mode 100644 libs/playlist/shared/ui/src/lib/source-removal/source-removal-toast.component.scss create mode 100644 libs/playlist/shared/ui/src/lib/source-removal/source-removal-toast.component.spec.ts create mode 100644 libs/playlist/shared/ui/src/lib/source-removal/source-removal-toast.component.ts create mode 100644 libs/playlist/shared/ui/src/lib/source-removal/source-removal.service.spec.ts create mode 100644 libs/playlist/shared/ui/src/lib/source-removal/source-removal.service.ts create mode 100644 libs/playlist/shared/ui/src/lib/source-row/source-clock.ts create mode 100644 libs/playlist/shared/ui/src/lib/source-row/source-row.component.html create mode 100644 libs/playlist/shared/ui/src/lib/source-row/source-row.component.scss create mode 100644 libs/playlist/shared/ui/src/lib/source-row/source-row.component.spec.ts create mode 100644 libs/playlist/shared/ui/src/lib/source-row/source-row.component.ts create mode 100644 libs/playlist/shared/ui/src/lib/source-row/source-tile.component.scss create mode 100644 libs/playlist/shared/ui/src/lib/source-row/source-tile.component.ts create mode 100644 libs/playlist/shared/util/src/lib/playlist-recent-history.service.spec.ts create mode 100644 libs/playlist/shared/util/src/lib/playlist-recent-history.service.ts create mode 100644 libs/playlist/shared/util/src/lib/source-display/source-display.spec.ts create mode 100644 libs/playlist/shared/util/src/lib/source-display/source-display.ts create mode 100644 libs/playlist/shared/util/src/lib/source-display/source-format.spec.ts create mode 100644 libs/playlist/shared/util/src/lib/source-display/source-format.ts create mode 100644 libs/playlist/shared/util/src/lib/source-display/source-list.spec.ts create mode 100644 libs/playlist/shared/util/src/lib/source-display/source-list.ts create mode 100644 libs/playlist/shared/util/src/lib/source-display/sources-view-state.service.ts create mode 100644 libs/playlist/shared/util/src/lib/source-display/strip-url-credentials.spec.ts create mode 100644 libs/playlist/shared/util/src/lib/source-display/strip-url-credentials.ts create mode 100644 libs/portal/shared/data-access/src/lib/source-health-history.service.spec.ts create mode 100644 libs/portal/shared/data-access/src/lib/source-health-history.service.ts create mode 100644 libs/portal/shared/data-access/src/lib/source-health-identity.ts create mode 100644 libs/portal/shared/data-access/src/lib/source-reachability.spec.ts create mode 100644 libs/portal/shared/data-access/src/lib/source-reachability.ts create mode 100644 libs/portal/shared/data-access/src/lib/source-status-checks.spec.ts create mode 100644 libs/portal/shared/data-access/src/lib/source-status-checks.ts create mode 100644 libs/portal/shared/data-access/src/lib/source-status.service.spec.ts create mode 100644 libs/portal/shared/data-access/src/lib/source-status.service.ts create mode 100644 libs/services/src/lib/sort.service.spec.ts create mode 100644 libs/shared/interfaces/src/lib/source-reachability.ts create mode 100644 libs/shared/logging/src/lib/url-credential-parts.ts create mode 100644 libs/ui/styles/_app-controls.scss create mode 100644 libs/ui/styles/_side-list.scss create mode 100644 libs/workspace/shell/feature/src/lib/workspace-sources-filters-panel/workspace-sources-filters-panel.component.spec.ts create mode 100644 libs/workspace/shell/feature/src/lib/workspace-sources/workspace-sources-sort-options.ts diff --git a/.changes/catalog-refine-menu-short-windows.md b/.changes/catalog-refine-menu-short-windows.md new file mode 100644 index 000000000..e1d5a8dcf --- /dev/null +++ b/.changes/catalog-refine-menu-short-windows.md @@ -0,0 +1,6 @@ +--- +type: fix +area: catalog +--- + +The Refine menu on Movies and Series now fits in a short window: the minimum-rating choices open in their own submenu, and the menu shows the active one. diff --git a/.changes/sources-hide-url-credentials.md b/.changes/sources-hide-url-credentials.md new file mode 100644 index 000000000..812a3c310 --- /dev/null +++ b/.changes/sources-hide-url-credentials.md @@ -0,0 +1,6 @@ +--- +type: fix +area: sources +--- + +Playlists added from a URL no longer show the login or password from that URL in the Sources list or the toolbar: they show the server's host instead. diff --git a/.changes/sources-redesigned-list-and-switcher.md b/.changes/sources-redesigned-list-and-switcher.md new file mode 100644 index 000000000..f79982aca --- /dev/null +++ b/.changes/sources-redesigned-list-and-switcher.md @@ -0,0 +1,7 @@ +--- +type: feature +area: sources +highlight: Redesigned Sources and playlist switcher +--- + +Sources and the playlist switcher share a new, compact row with a status dot. Filter Sources by type and by whether sources respond, delete with Undo, and open the switcher with ⌘P / Ctrl+P to search, filter by type and switch from the keyboard. diff --git a/.changes/workspace-side-panel-no-divider.md b/.changes/workspace-side-panel-no-divider.md new file mode 100644 index 000000000..dd711bff4 --- /dev/null +++ b/.changes/workspace-side-panel-no-divider.md @@ -0,0 +1,6 @@ +--- +type: fix +area: workspace +--- + +The side panel next to Sources, categories and Settings no longer draws a thin divider line against the content area, whose colour, shadow and rounded corner already mark the edge. Dragging the panel's edge still resizes it. diff --git a/apps/electron-backend-e2e/src/catalog-sort-menus.e2e.ts b/apps/electron-backend-e2e/src/catalog-sort-menus.e2e.ts index ff90069c5..80f0ac019 100644 --- a/apps/electron-backend-e2e/src/catalog-sort-menus.e2e.ts +++ b/apps/electron-backend-e2e/src/catalog-sort-menus.e2e.ts @@ -28,6 +28,9 @@ import { applyTheme, expectTextContrast } from './theme-contrast'; // Single-choice menus (catalog refine, live channels, categories, sources) // are radio groups: every row reserves the same leading check slot, so the // labels share one left edge, and `aria-checked` marks the active choice. +// +// The refine menu keeps its rating thresholds in a submenu, so it opens +// whole below its button in a short window. // --------------------------------------------------------------------------- const locales = ['en', 'de', 'ru', 'hu'] as const; @@ -83,14 +86,62 @@ function openMenuPanel(page: Page): Locator { return page.locator('.cdk-overlay-pane .mat-mdc-menu-panel').last(); } +/** The refine menu's own panel, also while its rating submenu is open. */ +function refineMenuPanel(page: Page): Locator { + return page.locator( + '.cdk-overlay-pane .mat-mdc-menu-panel.refine-menu:not(.refine-rating-menu)' + ); +} + +function ratingSubmenuPanel(page: Page): Locator { + return page.locator('.cdk-overlay-pane .refine-rating-menu'); +} + async function openRefineMenu(page: Page): Promise { await page.locator('app-category-content-view .refine-action').click(); - const menu = openMenuPanel(page); + const menu = refineMenuPanel(page); await expect(menu).toBeVisible(); await settleAnimations(menu); return menu; } +/** Opens the minimum-rating thresholds from the open refine menu. */ +async function openRatingSubmenu(page: Page): Promise { + await page.locator('.refine-rating-trigger').click(); + const submenu = ratingSubmenuPanel(page); + await expect(submenu).toBeVisible(); + await settleAnimations(submenu); + return submenu; +} + +/** The panel lies inside the window and shows every row without scrolling. */ +async function expectWholeOnScreen( + panel: Locator, + label: string +): Promise<{ top: number; bottom: number }> { + const geometry = await panel.evaluate((element) => { + const rect = element.getBoundingClientRect(); + return { + top: rect.top, + bottom: rect.bottom, + windowHeight: window.innerHeight, + scrolls: element.scrollHeight > element.clientHeight + 1, + }; + }); + expect( + geometry.top, + `${label}: ${JSON.stringify(geometry)}` + ).toBeGreaterThanOrEqual(0); + expect( + geometry.bottom, + `${label}: ${JSON.stringify(geometry)}` + ).toBeLessThanOrEqual(geometry.windowHeight); + expect(geometry.scrolls, `${label}: ${JSON.stringify(geometry)}`).toBe( + false + ); + return geometry; +} + async function settleAnimations(menu: Locator): Promise { await menu.evaluate((element) => Promise.all( @@ -210,12 +261,8 @@ test.describe('Electron catalog sort menus', () => { // A rating threshold shows the second chip next to the sort one. let menu = await openRefineMenu(page); - await menu - .getByRole('group') - .nth(1) - .getByRole('menuitemradio') - .nth(5) - .click(); + const thresholds = await openRatingSubmenu(page); + await thresholds.getByRole('menuitemradio').nth(5).click(); await expect(menu).toBeHidden(); for (const locale of locales) { @@ -252,16 +299,33 @@ test.describe('Electron catalog sort menus', () => { menu = await openRefineMenu(page); const groups = menu.getByRole('group'); - await expect(groups).toHaveCount(2); + await expect(groups).toHaveCount(1); await expectRadioGroup(groups.nth(0), 5, `${locale} sort`); - await expectRadioGroup(groups.nth(1), 5, `${locale} rating`); - const lefts = (await radioRows(menu)).map( - (row) => row.labelLeft - ); + // The thresholds row starts its label on the sort labels' + // edge and names the active threshold. + const ratingRow = menu.locator('.refine-rating-trigger'); + await expect(ratingRow).toContainText('5.0+'); + const lefts = [ + ...(await radioRows(menu)).map((row) => row.labelLeft), + Math.round( + await ratingRow + .locator('.mat-mdc-menu-item-text') + .evaluate( + (text) => text.getBoundingClientRect().left + ) + ), + ]; expect( [...new Set(lefts)], - `${locale}: both groups` + `${locale}: sort and rating rows` ).toHaveLength(1); + const ratings = await openRatingSubmenu(page); + await expectRadioGroup( + ratings.getByRole('group'), + 5, + `${locale} rating` + ); + await closeMenu(page, ratings); await closeMenu(page, menu); } } finally { @@ -269,6 +333,63 @@ test.describe('Electron catalog sort menus', () => { } }); + test('opens the refine menu whole below its button in a 600px-tall window, with the rating thresholds in a keyboard submenu', async ({ + dataDir, + request, + }) => { + await resetMockServers(request, ['xtream']); + const app = await launchElectronApp(dataDir); + const page = app.mainWindow; + + try { + await addXtreamPortal(page); + await waitForXtreamWorkspaceReady(page); + await page.setViewportSize({ width: 1280, height: 600 }); + await openWorkspaceSection(page, 'Movies'); + await expect( + page.locator('app-category-content-view mat-card').first() + ).toBeVisible({ timeout: 20_000 }); + + const trigger = page.locator( + 'app-category-content-view .refine-action' + ); + await trigger.focus(); + await page.keyboard.press('Enter'); + const menu = refineMenuPanel(page); + await expect(menu).toBeVisible(); + await settleAnimations(menu); + const triggerBottom = await trigger.evaluate( + (element) => element.getBoundingClientRect().bottom + ); + const menuBox = await expectWholeOnScreen(menu, 'refine menu'); + // Below the button, not pushed up over it. + expect(menuBox.top).toBeGreaterThanOrEqual(triggerBottom); + + // The last row opens the thresholds with the arrow key. + await page.keyboard.press('ArrowUp'); + await expect(menu.locator('.refine-rating-trigger')).toBeFocused(); + await page.keyboard.press('ArrowRight'); + const thresholds = ratingSubmenuPanel(page); + await expect(thresholds).toBeVisible(); + await settleAnimations(thresholds); + await expect(thresholds.getByRole('menuitemradio')).toHaveCount(6); + await expect( + thresholds.getByRole('menuitemradio').first() + ).toBeFocused(); + await expectWholeOnScreen(thresholds, 'rating submenu'); + + // Escape steps back out: the submenu, then the menu. + await page.keyboard.press('Escape'); + await expect(thresholds).toBeHidden(); + await expect(menu.locator('.refine-rating-trigger')).toBeFocused(); + await page.keyboard.press('Escape'); + await expect(menu).toBeHidden(); + await expect(trigger).toBeFocused(); + } finally { + await closeElectronApp(app); + } + }); + test('shows aligned radio rows with a legible check in the live, category and source sort menus, in light and dark themes', async ({ dataDir, request, diff --git a/apps/electron-backend-e2e/src/category-management.e2e.ts b/apps/electron-backend-e2e/src/category-management.e2e.ts index d2086dca9..1a490371b 100644 --- a/apps/electron-backend-e2e/src/category-management.e2e.ts +++ b/apps/electron-backend-e2e/src/category-management.e2e.ts @@ -545,8 +545,15 @@ async function expectCategoryCentered(row: Locator): Promise { } async function refreshFromWorkspaceHeader(page: Page): Promise { + // Refresh for the active source lives in its switcher row menu. + await page.getByTestId('playlist-switcher-trigger').click(); await page - .getByRole('button', { name: 'Refresh playlist', exact: true }) + .locator( + 'app-playlist-switcher-panel [role="option"][aria-current="true"] .source-row__more' + ) + .click(); + await page + .getByRole('menuitem', { name: 'Refresh playlist', exact: true }) .click(); const dialog = page.locator('mat-dialog-container'); diff --git a/apps/electron-backend-e2e/src/electron-test-fixtures.ts b/apps/electron-backend-e2e/src/electron-test-fixtures.ts index bc8f0e6d1..bef0b4f6e 100644 --- a/apps/electron-backend-e2e/src/electron-test-fixtures.ts +++ b/apps/electron-backend-e2e/src/electron-test-fixtures.ts @@ -1073,7 +1073,10 @@ export async function importM3uPlaylistFromUrl( playlistUrl ); if (userAgent !== undefined) { - await setInputValue(dialog.getByRole('textbox', { name: 'User agent', exact: true }), userAgent); + await setInputValue( + dialog.getByRole('textbox', { name: 'User agent', exact: true }), + userAgent + ); } await dialog.getByRole('button', { name: /Add playlist/i }).click(); await dialog.waitFor({ state: 'detached' }); @@ -1187,7 +1190,10 @@ export async function createMutableTextServer( return; } - if (options.requiredUserAgent && req.headers['user-agent'] !== options.requiredUserAgent) { + if ( + options.requiredUserAgent && + req.headers['user-agent'] !== options.requiredUserAgent + ) { res.writeHead(403); res.end('User-Agent required'); return; @@ -1469,20 +1475,23 @@ export async function clickCategoryByNameExact( } export function sourceRowByTitle(page: Page, title: string): Locator { - return page.locator('app-playlist-item').filter({ + return page.locator('app-workspace-sources app-source-row').filter({ hasText: flexibleTextPattern(title), }); } export async function getVisibleSourceTitles(page: Page): Promise { - return page.locator('app-playlist-item').evaluateAll((elements) => - elements - .map((element) => { - const titleElement = element.querySelector('.playlist-title'); - return titleElement?.textContent?.trim() ?? ''; - }) - .filter((title) => title.length > 0) - ); + return page + .locator('app-workspace-sources app-source-row') + .evaluateAll((elements) => + elements + .map((element) => { + const titleElement = + element.querySelector('.source-row__name'); + return titleElement?.textContent?.trim() ?? ''; + }) + .filter((title) => title.length > 0) + ); } export async function selectSourceTypeFilter( @@ -1497,8 +1506,10 @@ export async function selectSourceSort( sortLabel: | 'Date added (Newest first)' | 'Date added (Oldest first)' + | 'Last updated' | 'Name (A-Z)' | 'Name (Z-A)' + | 'Most channels' | 'Custom order' ): Promise { const sortTrigger = page.locator('app-workspace-sources .sort-trigger'); @@ -1523,10 +1534,10 @@ export async function dragSourceBefore( sourceTitle: string, targetTitle: string ): Promise { - const source = sourceRowByTitle(page, sourceTitle).locator('.drag-icon'); - const target = sourceRowByTitle(page, targetTitle).locator( - '.playlist-item' + const source = sourceRowByTitle(page, sourceTitle).locator( + '.source-row__handle' ); + const target = sourceRowByTitle(page, targetTitle).locator('.source-row'); await expect(source.first()).toBeVisible(); await expect(target.first()).toBeVisible(); @@ -1560,15 +1571,18 @@ export async function openSourceEditor( const row = sourceRowByTitle(page, title).first(); await expect(row).toBeVisible(); - await row.locator('.edit-btn').click(); + await row.hover(); + await row.locator('.source-row__edit').click(); const dialog = page.locator('mat-dialog-container').last(); await expect(dialog).toBeVisible(); // Material applies its initial focus after the opening transition. await dialog.evaluate(async (element) => { - await Promise.all(element.getAnimations({ subtree: true }).map((animation) => - animation.finished.catch(() => undefined) - )); + await Promise.all( + element + .getAnimations({ subtree: true }) + .map((animation) => animation.finished.catch(() => undefined)) + ); }); return dialog; } @@ -1678,12 +1692,24 @@ export async function expectSourceDialogValues( } } +/** + * Deletes a source from its ⋯ menu. The row leaves at once; the delete + * itself runs when the Undo toast closes, so this waits for the toast to go + * before the caller checks persisted state. + */ export async function deleteSource(page: Page, title: string): Promise { const row = sourceRowByTitle(page, title).first(); await expect(row).toBeVisible(); - await row.locator('.delete-btn').click(); - await confirmDialog(page, 'Remove playlist'); + await row.hover(); + await row.locator('.source-row__more').click(); + await page + .locator('.cdk-overlay-pane [mat-menu-item]') + .filter({ hasText: 'Remove playlist' }) + .click(); + const toast = page.locator('mat-snack-bar-container.app-toast'); + await expect(toast).toContainText('Removed'); + await expect(toast).toBeHidden({ timeout: 15000 }); } export async function refreshSource( @@ -1697,7 +1723,8 @@ export async function refreshSource( const row = sourceRowByTitle(page, title).first(); await expect(row).toBeVisible(); - await row.locator('.refresh-btn').click(); + await row.hover(); + await row.locator('.source-row__refresh').click(); if (confirm) { await confirmDialog(page, 'Refresh playlist'); @@ -1711,10 +1738,10 @@ export async function waitForSourceRowIdle( const row = sourceRowByTitle(page, title).first(); await expect(row).toBeVisible(); - await expect(row.locator('.busy-state__message')).toHaveCount(0, { + await expect(row.locator('.source-row__busy')).toHaveCount(0, { timeout: 60000, }); - await expect(row.locator('.action-spinner')).toHaveCount(0, { + await expect(row.locator('mat-spinner')).toHaveCount(0, { timeout: 60000, }); } diff --git a/apps/electron-backend-e2e/src/file-import.e2e.ts b/apps/electron-backend-e2e/src/file-import.e2e.ts index 22f264e56..1aab6dc95 100644 --- a/apps/electron-backend-e2e/src/file-import.e2e.ts +++ b/apps/electron-backend-e2e/src/file-import.e2e.ts @@ -70,7 +70,7 @@ test.describe('Electron Native Playlist Import', () => { }); await openSources(app.mainWindow); await expect( - app.mainWindow.locator('app-playlist-item') + app.mainWindow.locator('app-workspace-sources app-source-row') ).toHaveCount(2, { timeout: 30000 }); } finally { await closeElectronApp(app); diff --git a/apps/electron-backend-e2e/src/keyboard-focus-ring.e2e.ts b/apps/electron-backend-e2e/src/keyboard-focus-ring.e2e.ts index 80ff98a98..59de70ae8 100644 --- a/apps/electron-backend-e2e/src/keyboard-focus-ring.e2e.ts +++ b/apps/electron-backend-e2e/src/keyboard-focus-ring.e2e.ts @@ -435,6 +435,7 @@ async function openMoviesWithRatingFilter(page: Page): Promise { timeout: 20_000, }); await view.locator('.refine-action').click(); + await page.locator('.refine-rating-trigger').click(); await page .getByRole('menuitemradio', { name: /^\s*5\.0 and higher/ }) .click(); @@ -550,19 +551,21 @@ test.describe('Keyboard focus ring', () => { // Sources: the type filters, then the list's row actions. await openSources(page); await expect( - page.locator('app-playlist-item').first() + page.locator('app-workspace-sources app-source-row').first() ).toBeVisible(); + // Add, the Type and Status rows, Clean up, Sort, then the first + // row and its actions; Check again lives in the row's menu. await walk( 'sources list', page.locator('app-workspace-shell'), page .locator('app-workspace-sources-filters-panel button') .first(), - 12 + 16 ); expect( report['sources list (dark)'].map((stop) => stop.label) - ).toContain('Check again'); + ).toContain('Playlist actions'); // The live EPG list: its rows are Tab stops (`role="button"`) // with their own inset ring, then the row's info button. diff --git a/apps/electron-backend-e2e/src/performance/journey-renderer-probe.spec.ts b/apps/electron-backend-e2e/src/performance/journey-renderer-probe.spec.ts index 37f86d4d5..1770decf5 100644 --- a/apps/electron-backend-e2e/src/performance/journey-renderer-probe.spec.ts +++ b/apps/electron-backend-e2e/src/performance/journey-renderer-probe.spec.ts @@ -590,7 +590,7 @@ test('launch options target the workspace source cards and the shared sentinel', assert.equal(options.splashId, 'initial-splash'); assert.equal(options.routeFragment, '/workspace'); assert.match(options.cardSelector, /dashboard-recent-sources-rail-card/); - assert.match(options.cardSelector, /app-playlist-item/); + assert.match(options.cardSelector, /app-source-row/); assert.deepEqual(options.settle, { capMs: JOURNEY_SETTLE_CAP_MS, quietMs: JOURNEY_SETTLE_QUIET_MS, @@ -921,7 +921,7 @@ test('open-source options start at the portal card and end on the source route', options.startClick?.selector ?? '', /dashboard-recent-sources-rail-card/ ); - assert.match(options.startClick?.selector ?? '', /app-playlist-item/); + assert.match(options.startClick?.selector ?? '', /app-source-row/); assert.equal(options.routeFragment, '/workspace/xtreams/'); assert.match(options.cardSelector, /app-grid-list mat-card/); assert.match(options.cardSelector, /channel-item/); diff --git a/apps/electron-backend-e2e/src/performance/journey-renderer-probe.ts b/apps/electron-backend-e2e/src/performance/journey-renderer-probe.ts index 7ee9bac31..f6d764981 100644 --- a/apps/electron-backend-e2e/src/performance/journey-renderer-probe.ts +++ b/apps/electron-backend-e2e/src/performance/journey-renderer-probe.ts @@ -57,7 +57,7 @@ export const JOURNEY_PLAYBACK_START_SELECTOR = export const JOURNEY_PLAYBACK_VIDEO_SELECTOR = 'app-web-player-view video'; /** The Xtream portal card on the dashboard or its row on /workspace/sources. */ export const JOURNEY_OPEN_SOURCE_START_SELECTOR = - '[data-test-id="dashboard-recent-sources-rail-card"], app-playlist-item'; + '[data-test-id="dashboard-recent-sources-rail-card"], app-source-row'; /** * Bridge method used for the sentinels. The preload emits the trace event * before it forwards the call, and `SOURCE_HEALTH_CANCEL` only looks the id @@ -862,7 +862,7 @@ export function createLaunchJourneyProbeOptions( ): JourneyRendererProbeOptions { return { cardSelector: - '[data-test-id="dashboard-recent-sources-rail-card"], app-playlist-item', + '[data-test-id="dashboard-recent-sources-rail-card"], app-source-row', cdTickCounterKey: JOURNEY_CD_TICK_COUNTER_KEY, ...(idleWindowMs === null ? {} diff --git a/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation.benchmark.ts b/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation.benchmark.ts index bcb95d21d..ba5f745fc 100644 --- a/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation.benchmark.ts +++ b/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation.benchmark.ts @@ -262,7 +262,7 @@ async function runIteration( await renderer.markOperationStart(); const row = sourceRowByTitle(app.mainWindow, SOURCE_TITLE).first(); - await row.locator('.refresh-btn').click(); + await row.locator('.source-row__refresh').click(); await renderer.waitForCancelClick(); await Promise.all([ renderer.waitForTerminal(), diff --git a/apps/electron-backend-e2e/src/performance/m3u-refresh-renderer-capture.ts b/apps/electron-backend-e2e/src/performance/m3u-refresh-renderer-capture.ts index e61c1e6c9..22fab9ebd 100644 --- a/apps/electron-backend-e2e/src/performance/m3u-refresh-renderer-capture.ts +++ b/apps/electron-backend-e2e/src/performance/m3u-refresh-renderer-capture.ts @@ -301,7 +301,7 @@ async function installRendererProbe( const epoch = (): number => performance.timeOrigin + performance.now(); const sourceRow = (): Element | undefined => - Array.from(document.querySelectorAll('app-playlist-item')).find( + Array.from(document.querySelectorAll('app-source-row')).find( (element) => element.textContent?.includes(title) ); const state = { @@ -330,7 +330,7 @@ async function installRendererProbe( const row = sourceRow(); const message = row - ?.querySelector('.busy-state__message') + ?.querySelector('.source-row__busy-message') ?.textContent?.trim() .toLowerCase() ?? ''; for (const phase of ['fetch', 'pars', 'sav']) { @@ -345,9 +345,10 @@ async function installRendererProbe( state.cancelClickEpochMs !== null && state.uiSettledEpochMs === null && row !== undefined && - row.querySelector('.busy-state__message') === null && - row.querySelector('.action-spinner') === null && - row.querySelector('.cancel-btn') === null + row.querySelector('.source-row__busy-message') === null && + row.querySelector('.source-row__refresh mat-spinner') === + null && + row.querySelector('.source-row__cancel') === null ) { state.uiSettledEpochMs = epoch(); state.uiPaintFrameRequestId = requestAnimationFrame(() => { @@ -421,7 +422,9 @@ async function installRendererProbe( event.phase === 'parsing' && state.cancelClickEpochMs === null ) { - const button = sourceRow()?.querySelector('.cancel-btn'); + const button = sourceRow()?.querySelector( + '.source-row__cancel' + ); state.cancelButtonFound = button instanceof HTMLButtonElement; if (button instanceof HTMLButtonElement) { diff --git a/apps/electron-backend-e2e/src/performance/xtream-renderer-probe.ts b/apps/electron-backend-e2e/src/performance/xtream-renderer-probe.ts index ea06941c6..795021039 100644 --- a/apps/electron-backend-e2e/src/performance/xtream-renderer-probe.ts +++ b/apps/electron-backend-e2e/src/performance/xtream-renderer-probe.ts @@ -152,11 +152,11 @@ export async function installXtreamRendererProbe( ); const sourceRowAbsent = (): boolean => !Array.from( - document.querySelectorAll('app-playlist-item') + document.querySelectorAll('app-source-row') ).some( (row) => row - .querySelector('.playlist-title') + .querySelector('.source-row__name') ?.textContent?.trim() === state.sourceTitle ); const terminalDomGate = (): boolean => { diff --git a/apps/electron-backend-e2e/src/performance/xtream-scenario-driver-wiring.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-scenario-driver-wiring.spec.ts index 31782aebc..97e293dce 100644 --- a/apps/electron-backend-e2e/src/performance/xtream-scenario-driver-wiring.spec.ts +++ b/apps/electron-backend-e2e/src/performance/xtream-scenario-driver-wiring.spec.ts @@ -32,14 +32,14 @@ describe('Xtream scenario driver source wiring', () => { "'#serverUrl'", "'#username'", "'#password'", - "'app-playlist-item'", - "'.refresh-btn'", - "'.delete-btn'", + "'app-workspace-sources app-source-row'", + "'.source-row__refresh'", + "'.source-row__more'", "'Refresh playlist'", "'Remove playlist'", - "'.busy-state__message'", - "'.action-spinner'", - "'.cancel-btn'", + "'.source-row__busy'", + "'mat-spinner'", + "'.source-row__cancel'", "'.workspace-loading-overlay'", ]) { assert.ok(ui.includes(selector), `missing ${selector}`); @@ -68,6 +68,7 @@ describe('Xtream scenario driver source wiring', () => { ); assert.ok(driver.includes('installXtreamCancellationObserver')); assert.ok(driver.includes('installXtreamOperationWindow')); + assert.ok(driver.includes("'delete-playlist'")); assert.ok(driver.includes('runBackgroundProbe')); assert.doesNotMatch(combined, /waitForTimeout\s*\(/); assert.doesNotMatch(combined, /setTimeout\s*\(/); diff --git a/apps/electron-backend-e2e/src/performance/xtream-scenario-driver.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-scenario-driver.spec.ts index 17e187503..2f7b68f72 100644 --- a/apps/electron-backend-e2e/src/performance/xtream-scenario-driver.spec.ts +++ b/apps/electron-backend-e2e/src/performance/xtream-scenario-driver.spec.ts @@ -157,15 +157,18 @@ describe('Xtream Playwright scenario driver', () => { }); it('does not require a misleading portal-status badge before refresh or delete', async () => { - for (const [scenarioId, selector, confirmLabel] of [ + for (const [scenarioId, selector, confirmClick] of [ [ XTREAM_SCENARIO_ID.REFRESH_LARGE, - '.refresh-btn', - 'Refresh playlist', + '.source-row__refresh', + 'name=Refresh playlist;exact=true', + ], + [ + XTREAM_SCENARIO_ID.DELETE_LARGE, + '.source-row__more', + '.source-removal-toast__close', ], - [XTREAM_SCENARIO_ID.DELETE_LARGE, '.delete-btn', 'Remove playlist'], ] as const) { - const confirmClick = `name=${confirmLabel};exact=true`; const fake = new FakePage(); const prepared = await prepareXtreamScenario( fake.asPage(), @@ -188,6 +191,54 @@ describe('Xtream Playwright scenario driver', () => { } }); + it('arms the playlist delete before its commit click and ends on its completion', async () => { + const fake = new FakePage(); + const completed = { + active: false, + operation: 'delete-playlist', + operationId: 'delete-playlist-1', + playlistId: 'playlist-1', + startedEpochMs: 100, + terminalEpochMs: 150, + terminalStatus: 'completed', + }; + fake.operationSnapshot = completed; + const prepared = await prepareXtreamScenario( + fake.asPage(), + scenarioOptions(XTREAM_SCENARIO_ID.DELETE_LARGE) + ); + // Preparation stops at the open menu: Remove would start the Undo + // window, which commits on its own before a slow capture setup ends. + assert.equal(fake.clicksMatching('filter(Remove playlist)'), 0); + await prepared.trigger(); + assert.equal(fake.clicksMatching('filter(Remove playlist)'), 1); + const armIndex = fake.log.indexOf('evaluate:install-operation'); + const clickIndex = fake.log.findIndex( + (entry) => + entry.startsWith('click:') && + entry.includes('.source-removal-toast__close') + ); + assert.ok(armIndex >= 0, 'the delete window is armed'); + assert.ok(armIndex < clickIndex, 'before the commit click'); + + await prepared.waitForTerminal(); + assert.ok( + fake.log.indexOf('waitForFunction:operation-terminal') > clickIndex + ); + + const failed = new FakePage(); + failed.operationSnapshot = { ...completed, terminalStatus: 'error' }; + const failing = await prepareXtreamScenario( + failed.asPage(), + scenarioOptions(XTREAM_SCENARIO_ID.DELETE_LARGE) + ); + await failing.trigger(); + await assert.rejects( + () => failing.waitForTerminal(), + /xtream-delete-did-not-complete/ + ); + }); + it('arms cancellation after preparation and immediately before the final Add trigger', async () => { const fake = new FakePage(); const prepared = await prepareXtreamScenario( diff --git a/apps/electron-backend-e2e/src/performance/xtream-scenario-driver.ts b/apps/electron-backend-e2e/src/performance/xtream-scenario-driver.ts index eb1d8f2ac..9acba2d41 100644 --- a/apps/electron-backend-e2e/src/performance/xtream-scenario-driver.ts +++ b/apps/electron-backend-e2e/src/performance/xtream-scenario-driver.ts @@ -19,7 +19,8 @@ import { prepareXtreamSourceActionTrigger, waitForXtreamCancellationTerminal, waitForXtreamCatalogTerminal, - waitForXtreamDeleteTerminal, + removeXtreamSource, + waitForXtreamSourceRowGone, } from './xtream-scenario-ui'; import { assertXtreamMockOrigin } from './xtream-control-client'; import { assertXtreamBackgroundRefreshActive } from './xtream-renderer-probe'; @@ -106,6 +107,7 @@ export async function prepareXtreamScenario( return createPreparedScenario({ backgroundOperation, cancellationObserver: null, + deleteOperation: null, options, page, trigger, @@ -119,6 +121,8 @@ export async function prepareXtreamScenario( interface PreparedState { readonly backgroundOperation: XtreamOperationWindow | null; cancellationObserver: XtreamCancellationObserver | null; + /** The playlist delete, armed before its commit click. */ + deleteOperation: XtreamOperationWindow<'delete-playlist'> | null; readonly options: PrepareXtreamScenarioOptions; readonly page: Page; readonly trigger: Locator; @@ -136,6 +140,7 @@ function createPreparedScenario(state: PreparedState): PreparedXtreamScenario { await Promise.allSettled([ state.cancellationObserver?.dispose(), state.backgroundOperation?.dispose(), + state.deleteOperation?.dispose(), ]); }; const prepared: PreparedXtreamScenario = { @@ -189,8 +194,21 @@ function createPreparedScenario(state: PreparedState): PreparedXtreamScenario { state.cancellationObserver = await installXtreamCancellationObserver(state.page); } + if (state.options.scenarioId === XTREAM_SCENARIO_ID.DELETE_LARGE) { + // A fast delete can bring its row back and drop it again + // before a UI wait starts; the operation's own events cannot + // be missed once armed. + state.deleteOperation = await installXtreamOperationWindow( + state.page, + 'delete-playlist' + ); + } + let commit = state.trigger; + if (state.options.scenarioId === XTREAM_SCENARIO_ID.DELETE_LARGE) { + commit = await removeXtreamSource(state.page, state.trigger); + } const triggerEpochMs = Date.now(); - await state.trigger.click(); + await commit.click(); return { scenarioId: state.options.scenarioId, triggerEpochMs, @@ -224,12 +242,20 @@ async function waitForScenarioTerminal( cancellation = await state.cancellationObserver.waitForClick(); await waitForXtreamCancellationTerminal(state.page); break; - case XTREAM_SCENARIO_ID.DELETE_LARGE: - await waitForXtreamDeleteTerminal( + case XTREAM_SCENARIO_ID.DELETE_LARGE: { + if (!state.deleteOperation) { + throw new Error('xtream-delete-operation-unavailable'); + } + const deleted = await state.deleteOperation.waitForTerminal(); + if (deleted.terminalStatus !== 'completed') { + throw new Error('xtream-delete-did-not-complete'); + } + await waitForXtreamSourceRowGone( state.page, state.options.sourceTitle ); break; + } case XTREAM_SCENARIO_ID.BACKGROUND_UI: if (readBackgroundProbe() === null) { throw new Error('xtream-background-probe-incomplete'); diff --git a/apps/electron-backend-e2e/src/performance/xtream-scenario-operation-observer.ts b/apps/electron-backend-e2e/src/performance/xtream-scenario-operation-observer.ts index e582ab632..4ddc900e4 100644 --- a/apps/electron-backend-e2e/src/performance/xtream-scenario-operation-observer.ts +++ b/apps/electron-backend-e2e/src/performance/xtream-scenario-operation-observer.ts @@ -2,11 +2,16 @@ import type { Page } from '@playwright/test'; const OPERATION_STATE_KEY = '__iptvnatorXtreamScenarioOperationWindow'; const DELETE_OPERATION = 'delete-xtream-content'; +/** A content delete before a refresh, or a whole playlist's delete. */ +export type XtreamObservedOperation = + typeof DELETE_OPERATION | 'delete-playlist'; const OPERATION_TIMEOUT_MS = 120_000; -export interface XtreamOperationWindowSnapshot { +export interface XtreamOperationWindowSnapshot< + Operation extends XtreamObservedOperation = typeof DELETE_OPERATION, +> { readonly active: boolean; - readonly operation: typeof DELETE_OPERATION; + readonly operation: Operation; readonly operationId: string; readonly playlistId: string; readonly startedEpochMs: number; @@ -14,16 +19,21 @@ export interface XtreamOperationWindowSnapshot { readonly terminalStatus: 'cancelled' | 'completed' | 'error' | null; } -export interface XtreamOperationWindow { - assertActive(): Promise; +export interface XtreamOperationWindow< + Operation extends XtreamObservedOperation = typeof DELETE_OPERATION, +> { + assertActive(): Promise>; dispose(): Promise; - waitForActive(): Promise; - waitForTerminal(): Promise; + waitForActive(): Promise>; + waitForTerminal(): Promise>; } -export async function installXtreamOperationWindow( - page: Page -): Promise { +export async function installXtreamOperationWindow< + Operation extends XtreamObservedOperation = typeof DELETE_OPERATION, +>( + page: Page, + operation: Operation = DELETE_OPERATION as Operation +): Promise> { await page.evaluate( ({ command, operation, stateKey }) => { interface State { @@ -104,20 +114,20 @@ export async function installXtreamOperationWindow( }, { command: 'install-operation', - operation: DELETE_OPERATION, + operation, stateKey: OPERATION_STATE_KEY, } ); return { - assertActive: () => assertOperationActive(page), + assertActive: () => assertOperationActive(page, operation), dispose: () => disposeOperationState(page), async waitForActive() { await waitForOperationState(page, 'operation-active'); - return assertOperationActive(page); + return assertOperationActive(page, operation); }, async waitForTerminal() { await waitForOperationState(page, 'operation-terminal'); - return readOperationSnapshot(page); + return readOperationSnapshot(page, operation); }, }; } @@ -144,19 +154,21 @@ async function waitForOperationState( ); } -async function assertOperationActive( - page: Page -): Promise { - const snapshot = await readOperationSnapshot(page); +async function assertOperationActive( + page: Page, + operation: Operation +): Promise> { + const snapshot = await readOperationSnapshot(page, operation); if (!snapshot.active || snapshot.terminalEpochMs !== null) { throw new Error('xtream-background-action-outside-delete-window'); } return snapshot; } -async function readOperationSnapshot( - page: Page -): Promise { +async function readOperationSnapshot( + page: Page, + operation: Operation +): Promise> { const value = await page.evaluate( ({ stateKey }) => { const target = globalThis as unknown as Record; @@ -183,7 +195,7 @@ async function readOperationSnapshot( }, { command: 'snapshot-operation', stateKey: OPERATION_STATE_KEY } ); - if (!isOperationSnapshot(value)) { + if (!isOperationSnapshot(value, operation)) { throw new Error('xtream-operation-window-invalid'); } return value; @@ -202,16 +214,17 @@ async function disposeOperationState(page: Page): Promise { ); } -function isOperationSnapshot( - value: unknown -): value is XtreamOperationWindowSnapshot { +function isOperationSnapshot( + value: unknown, + operation: Operation +): value is XtreamOperationWindowSnapshot { if (!isRecord(value)) return false; const active = value['active']; const startedEpochMs = value['startedEpochMs']; const terminalEpochMs = value['terminalEpochMs']; const terminalStatus = value['terminalStatus']; return ( - value['operation'] === DELETE_OPERATION && + value['operation'] === operation && typeof active === 'boolean' && finitePositive(startedEpochMs) && nonEmptyString(value['operationId']) && diff --git a/apps/electron-backend-e2e/src/performance/xtream-scenario-ui.ts b/apps/electron-backend-e2e/src/performance/xtream-scenario-ui.ts index e2c7b83e0..460d8bbcf 100644 --- a/apps/electron-backend-e2e/src/performance/xtream-scenario-ui.ts +++ b/apps/electron-backend-e2e/src/performance/xtream-scenario-ui.ts @@ -68,28 +68,54 @@ export async function prepareXtreamSourceActionTrigger( const row = xtreamSourceRow(page, sourceTitle); await row.waitFor({ state: 'visible' }); for (const selector of [ - '.busy-state__message', - '.action-spinner', - '.cancel-btn', + '.source-row__busy', + 'mat-spinner', + '.source-row__cancel', ]) { await row.locator(selector).waitFor({ state: 'detached' }); } - const actionSelector = - action === 'refresh' ? '.refresh-btn' : '.delete-btn'; - const actionButton = row.locator(actionSelector); - await assertTriggerReady(actionButton, `xtream-source-${action}`); - await actionButton.click(); + if (action === 'refresh') { + const refresh = row.locator('.source-row__refresh'); + await assertTriggerReady(refresh, 'xtream-source-refresh'); + await refresh.click(); + const dialog = page.locator('mat-dialog-container').last(); + await dialog.waitFor({ state: 'visible' }); + const confirmation = dialog + .getByRole('button', { name: 'Refresh playlist', exact: true }) + .last(); + await assertTriggerReady(confirmation, 'xtream-source-refresh-confirm'); + return confirmation; + } - const dialog = page.locator('mat-dialog-container').last(); - await dialog.waitFor({ state: 'visible' }); - const confirmation = dialog - .getByRole('button', { - name: action === 'refresh' ? 'Refresh playlist' : 'Remove playlist', - exact: true, - }) - .last(); - await assertTriggerReady(confirmation, `xtream-source-${action}-confirm`); - return confirmation; + // Remove starts the Undo window, which commits on its own after four + // seconds: preparation stops at the open menu, and the trigger removes + // and commits at once (removeXtreamSource). + const more = row.locator('.source-row__more'); + await assertTriggerReady(more, 'xtream-source-delete-menu'); + await more.click(); + const remove = page + .locator('.cdk-overlay-pane [mat-menu-item]') + .filter({ hasText: 'Remove playlist' }) + .first(); + await assertTriggerReady(remove, 'xtream-source-delete'); + return remove; +} + +/** + * Clicks Remove and returns the Undo toast's close button, which commits the + * delete without waiting out the Undo window: the measured delete starts + * with that click. + */ +export async function removeXtreamSource( + page: Page, + remove: Locator +): Promise { + await remove.click(); + const commit = page.locator( + 'mat-snack-bar-container.app-toast .source-removal-toast__close' + ); + await assertTriggerReady(commit, 'xtream-source-delete-confirm'); + return commit; } export async function waitForXtreamCatalogTerminal(page: Page): Promise { @@ -125,7 +151,11 @@ export async function waitForXtreamCancellationTerminal( ); } -export async function waitForXtreamDeleteTerminal( +/** + * After the database reports the delete complete: the row, back as + * "Removing…" while it ran or never shown again for a fast delete, leaves. + */ +export async function waitForXtreamSourceRowGone( page: Page, sourceTitle: string ): Promise { @@ -157,9 +187,9 @@ async function selectXtreamMethod(dialog: Locator): Promise { function xtreamSourceRow(page: Page, sourceTitle: string): Locator { const exactTitle = new RegExp(`^\\s*${escapeRegex(sourceTitle)}\\s*$`, 'u'); return page - .locator('app-playlist-item') + .locator('app-workspace-sources app-source-row') .filter({ - has: page.locator('.playlist-title', { + has: page.locator('.source-row__name', { hasText: exactTitle, }), }) diff --git a/apps/electron-backend-e2e/src/performance/xtream-ui-action-probe.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-ui-action-probe.spec.ts index aefb1725c..f152c54bb 100644 --- a/apps/electron-backend-e2e/src/performance/xtream-ui-action-probe.spec.ts +++ b/apps/electron-backend-e2e/src/performance/xtream-ui-action-probe.spec.ts @@ -178,7 +178,7 @@ class SearchCompletionFakePage { } this.searchGateObserved = /searchParams\.get\((['"])q\1\)/u.test(source) && - source.includes('app-playlist-item') && + source.includes('app-source-row') && source.includes('no-results-state'); if (this.searchGateObserved) { this.searchResultsApplied = true; diff --git a/apps/electron-backend-e2e/src/performance/xtream-ui-action-probe.ts b/apps/electron-backend-e2e/src/performance/xtream-ui-action-probe.ts index 579ece981..f7198880e 100644 --- a/apps/electron-backend-e2e/src/performance/xtream-ui-action-probe.ts +++ b/apps/electron-backend-e2e/src/performance/xtream-ui-action-probe.ts @@ -154,14 +154,14 @@ export async function runXtreamBackgroundUiActions( ); const resultRows = Array.from( document.querySelectorAll( - 'app-workspace-sources app-playlist-item' + 'app-workspace-sources app-source-row' ) ); const resultPainted = resultRows.length > 0 ? resultRows.every((row) => ( - row.querySelector('.playlist-title') + row.querySelector('.source-row__name') ?.textContent ?? '' ) .trim() diff --git a/apps/electron-backend-e2e/src/playlist-switcher.e2e.ts b/apps/electron-backend-e2e/src/playlist-switcher.e2e.ts index 024972a30..4ace9dbce 100644 --- a/apps/electron-backend-e2e/src/playlist-switcher.e2e.ts +++ b/apps/electron-backend-e2e/src/playlist-switcher.e2e.ts @@ -245,44 +245,41 @@ test.describe('Electron Playlist Switcher', () => { await waitForM3uCatalog(app.mainWindow); await openPlaylistFavorites(app.mainWindow); - await expect(channelItemByTitle(app.mainWindow, localAChannelName)).toHaveCount( - 1 - ); - await expect(channelItemByTitle(app.mainWindow, localBChannelName)).toHaveCount( - 0 - ); + await expect( + channelItemByTitle(app.mainWindow, localAChannelName) + ).toHaveCount(1); + await expect( + channelItemByTitle(app.mainWindow, localBChannelName) + ).toHaveCount(0); await switchPlaylistFromHeader(app.mainWindow, localBDisplayName); await expectPathname( app.mainWindow, /\/workspace\/global-favorites$/ ); - await expect(channelItemByTitle(app.mainWindow, localBChannelName)).toHaveCount( - 1 - ); - await expect(channelItemByTitle(app.mainWindow, localAChannelName)).toHaveCount( - 0 - ); + await expect( + channelItemByTitle(app.mainWindow, localBChannelName) + ).toHaveCount(1); + await expect( + channelItemByTitle(app.mainWindow, localAChannelName) + ).toHaveCount(0); await openPlaylistRecent(app.mainWindow); - await expect(channelItemByTitle(app.mainWindow, localBChannelName)).toHaveCount( - 1 - ); - await expect(channelItemByTitle(app.mainWindow, localAChannelName)).toHaveCount( - 0 - ); + await expect( + channelItemByTitle(app.mainWindow, localBChannelName) + ).toHaveCount(1); + await expect( + channelItemByTitle(app.mainWindow, localAChannelName) + ).toHaveCount(0); await switchPlaylistFromHeader(app.mainWindow, localADisplayName); - await expectPathname( - app.mainWindow, - /\/workspace\/global-recent$/ - ); - await expect(channelItemByTitle(app.mainWindow, localAChannelName)).toHaveCount( - 1 - ); - await expect(channelItemByTitle(app.mainWindow, localBChannelName)).toHaveCount( - 0 - ); + await expectPathname(app.mainWindow, /\/workspace\/global-recent$/); + await expect( + channelItemByTitle(app.mainWindow, localAChannelName) + ).toHaveCount(1); + await expect( + channelItemByTitle(app.mainWindow, localBChannelName) + ).toHaveCount(0); } finally { await closeElectronApp(app); } @@ -319,7 +316,9 @@ test.describe('Electron Playlist Switcher', () => { username: xtreamStressUsername, }); - const overlay = app.mainWindow.locator('.workspace-loading-overlay'); + const overlay = app.mainWindow.locator( + '.workspace-loading-overlay' + ); await expect(overlay).toBeVisible({ timeout: 15000 }); await waitForDbOperationEvent(app.mainWindow, { operation: 'save-content', @@ -428,17 +427,172 @@ test.describe('Electron Playlist Switcher', () => { await switchPlaylistFromHeader(app.mainWindow, offlineXtreamName); await waitForXtreamCatalog(app.mainWindow); await expectXtreamUnavailableWarning(app.mainWindow); - await expect(app.mainWindow.locator('.xtream-content-gate')).toHaveCount( - 0 - ); + await expect( + app.mainWindow.locator('.xtream-content-gate') + ).toHaveCount(0); app = await restartElectronApp(app, dataDir); await waitForXtreamCatalog(app.mainWindow); await expectXtreamUnavailableWarning(app.mainWindow); - await expect(app.mainWindow.locator('.xtream-content-gate')).toHaveCount( - 0 + await expect( + app.mainWindow.locator('.xtream-content-gate') + ).toHaveCount(0); + } finally { + await closeElectronApp(app); + } + }); + + test('runs from the keyboard: Cmd/Ctrl+P, filter, arrows, Enter and Escape', async ({ + dataDir, + request, + }) => { + await resetMockServers(request, ['xtream']); + const localAPath = writeTemporaryM3uFile(dataDir, localAFileName, [ + { + groupTitle: 'News', + name: 'Switcher Local A', + url: 'https://streams.example.test/switcher-local-a.m3u8', + }, + ]); + const localBPath = writeTemporaryM3uFile(dataDir, localBFileName, [ + { + groupTitle: 'Sports', + name: 'Switcher Local B', + url: 'https://streams.example.test/switcher-local-b.m3u8', + }, + ]); + const app = await launchElectronApp(dataDir); + const page = app.mainWindow; + + try { + await importM3uPlaylistFromNativeDialog(app, localAPath); + await importM3uPlaylistFromNativeDialog(app, localBPath); + await addXtreamPortal(page, { name: xtreamAName }); + await waitForXtreamCatalog(page); + + const trigger = page.getByTestId('playlist-switcher-trigger'); + const panel = page.locator('app-playlist-switcher-panel'); + const search = panel.locator('input[role="combobox"]'); + await expect(playlistSwitcherTitle(page)).toContainText( + xtreamAName ); + + await page.keyboard.press('ControlOrMeta+p'); + await expect(search).toBeFocused(); + // No list shows a credential: the Xtream row names the account + // on purpose, never its password. + await expect(panel).not.toContainText(defaultXtreamPassword); + await expect(panel).not.toContainText('password='); + + await page.keyboard.type('switcher-local'); + await expect(switcherOptions(page)).toHaveCount(2); + // A search highlights its first match; ↓ moves on, ↑ wraps. + const options = switcherOptions(page); + const firstId = (await options.nth(0).getAttribute('id')) ?? ''; + const secondId = (await options.nth(1).getAttribute('id')) ?? ''; + await expect(search).toHaveAttribute( + 'aria-activedescendant', + firstId + ); + await page.keyboard.press('ArrowDown'); + await expect(search).toHaveAttribute( + 'aria-activedescendant', + secondId + ); + await page.keyboard.press('ArrowDown'); + await expect(search).toHaveAttribute( + 'aria-activedescendant', + firstId + ); + await page.keyboard.press('ArrowUp'); + await expect(search).toHaveAttribute( + 'aria-activedescendant', + secondId + ); + const targetName = + ( + await options + .nth(1) + .locator('.source-row__name') + .textContent() + )?.trim() ?? ''; + await page.keyboard.press('Enter'); + + await expect(panel).toHaveCount(0); + await waitForM3uCatalog(page); + await expect(playlistSwitcherTitle(page)).toContainText(targetName); + + // Escape closes and puts focus back on the trigger. + await page.keyboard.press('ControlOrMeta+p'); + await expect(search).toBeFocused(); + await page.keyboard.press('Escape'); + await expect(panel).toHaveCount(0); + await expect(trigger).toBeFocused(); + + // In a short window only the list shrinks and scrolls; the + // search field and the type filter keep their height. + const viewport = page.viewportSize(); + await page.setViewportSize({ + width: viewport?.width ?? 1280, + height: 320, + }); + await page.keyboard.press('ControlOrMeta+p'); + await expect(search).toBeFocused(); + const chrome = await panel.evaluate((host) => { + const height = (element: Element | null) => + element?.getBoundingClientRect().height ?? 0; + const list = host.querySelector('.switcher__list'); + return { + search: height(host.querySelector('.switcher__search')), + toggles: Array.from( + host.querySelectorAll('.mat-button-toggle'), + height + ), + listScrolls: + !!list && list.scrollHeight > list.clientHeight, + }; + }); + expect(chrome.search, JSON.stringify(chrome)).toBeCloseTo(36, 0); + expect(chrome.toggles.length, JSON.stringify(chrome)).toBe(3); + for (const toggle of chrome.toggles) { + expect(toggle, JSON.stringify(chrome)).toBeGreaterThanOrEqual( + 30 + ); + } + expect(chrome.listScrolls, JSON.stringify(chrome)).toBe(true); + await page.keyboard.press('Escape'); + await expect(panel).toHaveCount(0); + if (viewport) await page.setViewportSize(viewport); + + // A delete from the keyboard: the popover closes, since its + // focus trap would keep Tab from Undo; Undo takes the focus and + // gives it back to the trigger. + await page.keyboard.press('ControlOrMeta+p'); + await expect(search).toBeFocused(); + await page.keyboard.press('ArrowDown'); + await page.keyboard.press('Shift+F10'); + const rowMenu = page.locator('.mat-mdc-menu-panel'); + await expect(rowMenu).toBeVisible(); + await page.keyboard.press('End'); + await expect( + rowMenu.getByRole('menuitem', { name: /Remove/ }) + ).toBeFocused(); + await page.keyboard.press('Enter'); + await expect(panel).toHaveCount(0); + const undo = page.locator('.source-removal-toast__undo'); + await expect(undo).toBeFocused(); + await page.keyboard.press('Enter'); + await expect(undo).toHaveCount(0); + await expect(trigger).toBeFocused(); + + await openSources(page); + await expect( + page.locator('app-workspace-sources app-source-row') + ).toHaveCount(3); + await expect( + page.locator('app-workspace-sources') + ).not.toContainText(defaultXtreamPassword); } finally { await closeElectronApp(app); } @@ -446,12 +600,12 @@ test.describe('Electron Playlist Switcher', () => { }); async function openPlaylistSwitcher(page: Page): Promise { - await page - .locator('app-playlist-switcher .playlist-switcher-trigger') - .click(); - await expect( - page.locator('.cdk-overlay-pane .playlist-item').first() - ).toBeVisible(); + await page.getByTestId('playlist-switcher-trigger').click(); + await expect(switcherOptions(page).first()).toBeVisible(); +} + +function switcherOptions(page: Page) { + return page.locator('app-playlist-switcher-panel [role="option"]'); } async function switchPlaylistFromHeader( @@ -459,16 +613,11 @@ async function switchPlaylistFromHeader( title: string ): Promise { await openPlaylistSwitcher(page); - const item = page - .locator('.cdk-overlay-pane .playlist-item') - .filter({ hasText: title }) - .first(); + const item = switcherOptions(page).filter({ hasText: title }).first(); await expect(item).toBeVisible(); await item.click(); - await expect(page.locator('.cdk-overlay-pane .playlist-item')).toHaveCount( - 0 - ); + await expect(page.locator('app-playlist-switcher-panel')).toHaveCount(0); } async function expectPathname(page: Page, pattern: RegExp): Promise { @@ -503,16 +652,21 @@ async function selectSettingsOption( } async function expectXtreamUnavailableWarning(page: Page): Promise { - await expect( - page.getByTestId('xtream-offline-warning') - ).toBeVisible({ timeout: 20000 }); + await expect(page.getByTestId('xtream-offline-warning')).toBeVisible({ + timeout: 20000, + }); } -async function toggleFavoriteForChannel(page: Page, title: string): Promise { +async function toggleFavoriteForChannel( + page: Page, + title: string +): Promise { const item = channelItemByTitle(page, title).first(); await expect(item).toBeVisible({ timeout: 20000 }); await item.hover(); await item.locator('.favorite-button').first().click(); - await expect(item.locator('.favorite-button mat-icon').first()).toHaveText(/star/); + await expect(item.locator('.favorite-button mat-icon').first()).toHaveText( + /star/ + ); } diff --git a/apps/electron-backend-e2e/src/sources-keyboard.e2e.ts b/apps/electron-backend-e2e/src/sources-keyboard.e2e.ts index 83dc3802d..231278419 100644 --- a/apps/electron-backend-e2e/src/sources-keyboard.e2e.ts +++ b/apps/electron-backend-e2e/src/sources-keyboard.e2e.ts @@ -31,7 +31,7 @@ type RowRing = { function readRowRing(page: Page): Promise { return page.evaluate(() => { const focused = document.activeElement as HTMLElement; - const row = focused.closest('.playlist-item') as HTMLElement; + const row = focused.closest('.source-row') as HTMLElement; const probe = document.createElement('span'); probe.style.color = 'var(--app-focus-ring)'; row.append(probe); @@ -93,16 +93,19 @@ test.describe('Electron Sources keyboard', () => { timeout: 30_000, }); await openSources(page); - await expect(page.locator('app-playlist-item')).toHaveCount(2, { + await expect( + page.locator('app-workspace-sources app-source-row') + ).toHaveCount(2, { timeout: 30_000, }); // Bravo opened last, so it is the active source; open the other. const row = sourceRowByTitle(page, 'keyboard-alpha').first(); - await expect(row.locator('.playlist-item')).not.toHaveClass( - /\bselected\b/ + await expect(row.locator('.source-row__body')).not.toHaveAttribute( + 'aria-current', + 'true' ); const title = ( - await row.locator('.playlist-title').textContent() + await row.locator('.source-row__name').textContent() )?.trim(); expect(title).toBeTruthy(); @@ -153,7 +156,7 @@ test.describe('Electron Sources keyboard', () => { await expect(open).not.toBeFocused(); expect( await row - .locator('.playlist-item') + .locator('.source-row') .evaluate((el) => getComputedStyle(el).outlineStyle) ).toBe('none'); await page.keyboard.press('Shift+Tab'); @@ -172,4 +175,69 @@ test.describe('Electron Sources keyboard', () => { await closeElectronApp(app); } }); + + test('@m3u @electron deletes from the row menu and undoes from the toast, keyboard only', async ({ + dataDir, + }) => { + const alpha = writeTemporaryM3uFile(dataDir, 'undo-alpha.m3u', [ + { + groupTitle: 'News', + name: 'Undo Alpha News', + url: 'https://streams.example.test/undo-alpha.m3u8', + }, + ]); + const bravo = writeTemporaryM3uFile(dataDir, 'undo-bravo.m3u', [ + { + groupTitle: 'Sports', + name: 'Undo Bravo Sports', + url: 'https://streams.example.test/undo-bravo.m3u8', + }, + ]); + const app = await launchElectronApp(dataDir, { + appArgs: [alpha, bravo], + }); + const page = app.mainWindow; + const rows = page.locator('app-workspace-sources app-source-row'); + + try { + await page.waitForURL(/\/workspace\/playlists\/.+/, { + timeout: 30_000, + }); + await openSources(page); + await expect(rows).toHaveCount(2, { timeout: 30_000 }); + const before = await rows + .locator('.source-row__name') + .allTextContents(); + + // Focus the first row's ⋯ and open its menu from the keyboard. + const row = rows.first(); + await row.locator('.source-row__more').focus(); + await page.keyboard.press('Enter'); + const remove = page.getByRole('menuitem', { + name: 'Remove playlist', + }); + await expect(remove).toBeVisible(); + await page.keyboard.press('End'); + await expect(remove).toBeFocused(); + await page.keyboard.press('Enter'); + + // The row leaves at once; Undo takes the focus the row had. + await expect(rows).toHaveCount(1); + const undo = page.locator( + 'mat-snack-bar-container.app-toast .source-removal-toast__undo' + ); + await expect(undo).toBeFocused(); + await page.keyboard.press('Enter'); + + await expect(rows).toHaveCount(2); + expect( + await rows.locator('.source-row__name').allTextContents() + ).toEqual(before); + await expect( + page.locator('mat-snack-bar-container.app-toast') + ).toHaveCount(0); + } finally { + await closeElectronApp(app); + } + }); }); diff --git a/apps/electron-backend-e2e/src/sources.e2e.ts b/apps/electron-backend-e2e/src/sources.e2e.ts index 7c21e103d..072585c71 100644 --- a/apps/electron-backend-e2e/src/sources.e2e.ts +++ b/apps/electron-backend-e2e/src/sources.e2e.ts @@ -717,14 +717,14 @@ https://streams.example.test/url-omega.m3u8 await expect( sourceRowByTitle(app.mainWindow, 'Delete Me Stalker').locator( - 'app-source-health-indicator [data-state]' + '.source-tile__dot[data-state]' ) - ).toHaveAttribute('data-state', 'active', { timeout: 20000 }); + ).toHaveAttribute('data-state', 'ok', { timeout: 20000 }); await expect( sourceRowByTitle(app.mainWindow, 'Delete Me Xtream').locator( - 'app-source-health-indicator [data-state]' + '.source-tile__dot[data-state]' ) - ).toHaveAttribute('data-state', 'active', { timeout: 20000 }); + ).toHaveAttribute('data-state', 'ok', { timeout: 20000 }); await deleteSource(app.mainWindow, deletableLocalSourceDisplayName); await expect( @@ -785,11 +785,20 @@ https://streams.example.test/original-url.m3u8 try { await dropM3uPlaylistOntoWorkspace(app.mainWindow, localFilePath); await waitForM3uCatalog(app.mainWindow); + // Refresh for the active source lives in its switcher row menu. + await app.mainWindow.getByTestId('playlist-switcher-trigger').click(); + const currentRow = app.mainWindow.locator( + 'app-playlist-switcher-panel [role="option"][aria-current="true"]' + ); + await currentRow.locator('.source-row__more').click(); await expect( - app.mainWindow.locator( - 'app-playlist-switcher .trigger-refresh-button' - ) + app.mainWindow.getByRole('menuitem', { name: 'Refresh playlist' }) ).toBeVisible(); + await app.mainWindow.keyboard.press('Escape'); + await app.mainWindow.keyboard.press('Escape'); + await expect( + app.mainWindow.locator('app-playlist-switcher-panel') + ).toHaveCount(0); await goToDashboard(app.mainWindow); const sourceCard = app.mainWindow @@ -822,8 +831,8 @@ https://streams.example.test/original-url.m3u8 await expect( sourceRowByTitle(app.mainWindow, refreshLocalSourceDisplayName) .first() - .locator('.refresh-btn') - ).toBeVisible(); + .locator('.source-row__refresh') + ).toBeAttached(); writeTemporaryM3uFile(dataDir, 'refresh-local-source.m3u', [ { @@ -865,8 +874,8 @@ https://streams.example.test/refreshed-url.m3u8 await expect( sourceRowByTitle(app.mainWindow, 'refresh-url-source.m3u') .first() - .locator('.meta') - ).toContainText('Updated:'); + .locator('.source-row__meta') + ).toContainText('updated'); await waitForSourceRowIdle( app.mainWindow, 'refresh-url-source.m3u' @@ -876,9 +885,11 @@ https://streams.example.test/refreshed-url.m3u8 'refresh-url-source.m3u' ).first(); await expect( - refreshedUrlRow.locator('.playlist-item') - ).not.toHaveClass(/is-busy/); - await expect(refreshedUrlRow.locator('.refresh-btn')).toBeEnabled(); + refreshedUrlRow.locator('.source-row') + ).not.toHaveClass(/source-row--busy/); + await expect( + refreshedUrlRow.locator('.source-row__refresh') + ).toBeEnabled(); await refreshedUrlRow .getByText('refresh-url-source.m3u', { exact: true }) .click(); @@ -904,8 +915,8 @@ https://streams.example.test/refreshed-url.m3u8 await expect( sourceRowByTitle(app.mainWindow, 'Refresh Xtream Source') .first() - .locator('.meta') - ).toContainText('Updated:'); + .locator('.source-row__meta') + ).toContainText('updated'); } finally { await closeElectronApp(app); await urlServer.close(); diff --git a/apps/electron-backend-e2e/src/xtream-responsiveness.e2e.ts b/apps/electron-backend-e2e/src/xtream-responsiveness.e2e.ts index 0b97a93f3..b13738ea2 100644 --- a/apps/electron-backend-e2e/src/xtream-responsiveness.e2e.ts +++ b/apps/electron-backend-e2e/src/xtream-responsiveness.e2e.ts @@ -25,7 +25,7 @@ const dbWorkerBatchDelayMs = '20'; const dbWorkerRowsPerTransaction = '100'; function playlistRowByName(name: string) { - return `app-playlist-item:has-text("${name}")`; + return `app-workspace-sources app-source-row:has-text("${name}")`; } async function openSources(page: Parameters[0]) { @@ -287,14 +287,13 @@ test.describe('Electron Xtream Responsiveness', () => { ); await expect(playlistRow).toBeVisible({ timeout: 20000 }); - await playlistRow.locator('.delete-btn').click(); - - const confirmDialog = app.mainWindow.locator( - 'mat-dialog-container' - ); - await expect(confirmDialog).toBeVisible(); - await confirmDialog - .getByRole('button', { name: 'Remove playlist', exact: true }) + // Delete from the row menu; the delete starts when the Undo + // toast closes, and the row comes back to show its progress. + await playlistRow.hover(); + await playlistRow.locator('.source-row__more').click(); + await app.mainWindow + .locator('.cdk-overlay-pane [mat-menu-item]') + .filter({ hasText: 'Remove playlist' }) .click(); await waitForDbOperationEvent(app.mainWindow, { @@ -303,10 +302,10 @@ test.describe('Electron Xtream Responsiveness', () => { timeoutMs: 20000, }); - await expect( - playlistRow.locator('.busy-state__message') - ).toBeVisible({ timeout: 20000 }); - const cancel = playlistRow.locator('.cancel-btn'); + await expect(playlistRow.locator('.source-row__busy')).toBeVisible({ + timeout: 20000, + }); + const cancel = playlistRow.locator('.source-row__cancel'); await expect(cancel).toBeVisible(); // The row's action buttons inherit its color; the cancel action // must still read as destructive on the selected row. diff --git a/apps/web-e2e/src/icon-button-names.e2e.ts b/apps/web-e2e/src/icon-button-names.e2e.ts index 18e7b997d..13ad647b7 100644 --- a/apps/web-e2e/src/icon-button-names.e2e.ts +++ b/apps/web-e2e/src/icon-button-names.e2e.ts @@ -111,16 +111,12 @@ test('@a11y icon-only buttons are named in Sources and the playlist info dialog' }) => { await addM3uUrlPlaylist(page, M3U_TITLE); - // Sources list: refresh, details and remove on each row. + // Sources list: refresh, edit and the actions menu on each row. await openSources(page); const row = sourceRowByTitle(page, M3U_TITLE); await expect(row).toBeVisible({ timeout: 15_000 }); await row.hover(); - for (const name of [ - 'Refresh playlist', - 'Show playlist details', - 'Remove playlist', - ]) { + for (const name of ['Refresh playlist', 'Edit…', 'Playlist actions']) { await expect(row.getByRole('button', { name })).toBeAttached(); } await expectNamedButtons(page, 3); diff --git a/apps/web-e2e/src/player-settings-panel.fixture.ts b/apps/web-e2e/src/player-settings-panel.fixture.ts index f83995bba..898268107 100644 --- a/apps/web-e2e/src/player-settings-panel.fixture.ts +++ b/apps/web-e2e/src/player-settings-panel.fixture.ts @@ -80,7 +80,7 @@ export async function importPlaylist(page: Page): Promise { .getByRole('button', { name: 'Add playlist', exact: true }) .click(), ]); - await expect(page.getByText('1 channels')).toBeVisible(); + await expect(page.getByText('1 channel', { exact: false })).toBeVisible(); return page.url(); } diff --git a/apps/web-e2e/src/self-hosted.e2e.ts b/apps/web-e2e/src/self-hosted.e2e.ts index afa09de53..be4981db7 100644 --- a/apps/web-e2e/src/self-hosted.e2e.ts +++ b/apps/web-e2e/src/self-hosted.e2e.ts @@ -259,7 +259,7 @@ test('@self-hosted M3U User-Agent reaches the provider on import and refresh', a channel = 'Refreshed UA Channel'; const row = sourceRowByTitle(page, 'Protected M3U'); await row.hover(); - await row.locator('.refresh-btn').click(); + await row.locator('.source-row__refresh').click(); await expect( page.locator('.mat-mdc-snack-bar-label').last() ).toContainText('updated'); diff --git a/apps/web-e2e/src/sources-pwa.helpers.ts b/apps/web-e2e/src/sources-pwa.helpers.ts index ea393d357..848feef97 100644 --- a/apps/web-e2e/src/sources-pwa.helpers.ts +++ b/apps/web-e2e/src/sources-pwa.helpers.ts @@ -190,7 +190,10 @@ export async function openSources(page: Page): Promise { } export function sourceRowByTitle(page: Page, title: string): Locator { - return page.locator('app-playlist-item').filter({ hasText: title }).first(); + return page + .locator('app-workspace-sources app-source-row') + .filter({ hasText: title }) + .first(); } export async function openSourceEditor( @@ -200,7 +203,7 @@ export async function openSourceEditor( const row = sourceRowByTitle(page, title); await expect(row).toBeVisible({ timeout: 15_000 }); await row.hover(); - await row.locator('.edit-btn').click(); + await row.locator('.source-row__edit').click(); const dialog = page.locator('mat-dialog-container').last(); await expect(dialog).toBeVisible(); return dialog; diff --git a/apps/web-e2e/src/stalker.e2e.ts b/apps/web-e2e/src/stalker.e2e.ts index 1c744ef63..525fe7935 100644 --- a/apps/web-e2e/src/stalker.e2e.ts +++ b/apps/web-e2e/src/stalker.e2e.ts @@ -182,12 +182,15 @@ test('@stalker account info dialog shows subscription facts from the portal', as }) => { await addStalkerPortal(page); - // Open the header playlist switcher and its "Account info" entry for - // the active stalker portal. - await page.locator('.playlist-switcher-trigger').click(); + // Open the header playlist switcher and the "Account info" entry in the + // active stalker portal's row menu. + await page.locator('[data-test-id="playlist-switcher-trigger"]').click(); await page - .locator('.context-action-item', { hasText: 'Account info' }) + .locator( + 'app-playlist-switcher-panel [role="option"][aria-current="true"] .source-row__more' + ) .click(); + await page.getByRole('menuitem', { name: 'Account info' }).click(); const dialog = page.locator('mat-dialog-container'); await expect(dialog).toBeVisible(); diff --git a/apps/web-e2e/src/xtream.e2e.ts b/apps/web-e2e/src/xtream.e2e.ts index 193eba2d8..60bc3a554 100644 --- a/apps/web-e2e/src/xtream.e2e.ts +++ b/apps/web-e2e/src/xtream.e2e.ts @@ -61,18 +61,15 @@ async function openPlaylistDetailsDialog(page: Page, title: string) { await playlistSettingsButton.click(); } else { await page - .locator('app-playlist-switcher .playlist-switcher-trigger') + .locator('[data-test-id="playlist-switcher-trigger"]') .click(); - const switcherMenu = page.getByRole('menu').filter({ hasText: title }); - const sourceRow = switcherMenu - .locator('.playlist-item') + const sourceRow = page + .locator('app-playlist-switcher-panel [role="option"]') .filter({ hasText: title }) .first(); await expect(sourceRow).toBeVisible(); - await sourceRow - .getByRole('button', { name: 'Playlist actions' }) - .click(); + await sourceRow.locator('.source-row__more').click(); await page.getByRole('menuitem', { name: 'Playlist info' }).click(); } diff --git a/apps/web/src/app/services/electron.service.ts b/apps/web/src/app/services/electron.service.ts index c3652150c..46ec142e2 100644 --- a/apps/web/src/app/services/electron.service.ts +++ b/apps/web/src/app/services/electron.service.ts @@ -164,7 +164,8 @@ export class ElectronService extends DataService { } if (type === PLAYLIST_UPDATE) { - this.updateM3uPlaylistFromFile( + // Settles when the refresh does, so callers keep the source busy. + await this.updateM3uPlaylistFromFile( payload as { id: string; filePath?: string; diff --git a/apps/web/src/app/services/pwa.service.spec.ts b/apps/web/src/app/services/pwa.service.spec.ts index e3bc14800..a132d5038 100644 --- a/apps/web/src/app/services/pwa.service.spec.ts +++ b/apps/web/src/app/services/pwa.service.spec.ts @@ -201,6 +201,55 @@ describe('PwaService', () => { } }); + it('settles a playlist refresh only once the backend has answered', async () => { + let settled = false; + const pending = Promise.resolve( + service.sendIpcEvent(PLAYLIST_UPDATE, { + id: 'playlist-1', + url: 'https://provider.example/list.m3u', + }) + ).then(() => { + settled = true; + }); + + http.expectOne((req) => req.url.endsWith('/provider-targets')).flush({ + targetId: 'target-1', + }); + await new Promise((resolve) => setTimeout(resolve)); + // The caller keeps the source busy while the parse is in flight. + expect(settled).toBe(false); + + http.expectOne((req) => req.url.endsWith('/parse')).flush({ + _id: 'playlist-1', + }); + await pending; + expect(settled).toBe(true); + expect(TestBed.inject(Store).dispatch).toHaveBeenCalled(); + }); + + it('settles a failed playlist refresh after reporting it', async () => { + const pending = service.sendIpcEvent(PLAYLIST_UPDATE, { + id: 'playlist-1', + url: 'https://provider.example/list.m3u', + }); + + http.expectOne((req) => req.url.endsWith('/provider-targets')).flush({ + targetId: 'target-1', + }); + await new Promise((resolve) => setTimeout(resolve)); + http.expectOne((req) => req.url.endsWith('/parse')).flush( + { message: 'failed', status: 500 }, + { status: 500, statusText: 'Internal Server Error' } + ); + + await expect(pending).resolves.toBeUndefined(); + expect(TestBed.inject(MatSnackBar).open).toHaveBeenCalledWith( + expect.any(String), + 'CLOSE', + { duration: 5000 } + ); + }); + it('appends the proxy network code to the playlist-refresh failure toast', async () => { const unhandled: unknown[] = []; const previousHandler = rxjsConfig.onUnhandledError; diff --git a/apps/web/src/app/services/pwa.service.ts b/apps/web/src/app/services/pwa.service.ts index 1d7152983..0b5369ddb 100644 --- a/apps/web/src/app/services/pwa.service.ts +++ b/apps/web/src/app/services/pwa.service.ts @@ -133,8 +133,10 @@ export class PwaService extends DataService { } if (type === PLAYLIST_UPDATE) { - this.refreshPlaylist(payload as Partial); - return undefined as T; + // Settles when the refresh does, so callers keep the source busy. + return this.refreshPlaylist( + payload as Partial + ) as T; } if (type === XTREAM_REQUEST) { @@ -215,16 +217,39 @@ export class PwaService extends DataService { } } - refreshPlaylist(payload?: Partial) { + /** Resolves once the backend has answered, whether it succeeded or not. */ + refreshPlaylist( + payload?: Partial + ): Promise { if (!payload?.url || !payload?.id) { - return; + return Promise.resolve(); } const playlistId = payload.id; + const { url, userAgent } = payload; - this.getPlaylistFromUrl(payload.url, payload.userAgent) - .pipe( - catchError((error) => { + return new Promise((resolve) => { + this.getPlaylistFromUrl(url, userAgent).subscribe({ + next: (playlist: Playlist) => { + this.store.dispatch( + PlaylistActions.updatePlaylist({ + playlist, + playlistId, + refreshEpg: true, + }) + ); + + this.snackBar.open( + this.translateService.instant( + 'HOME.PLAYLISTS.PLAYLIST_UPDATE_SUCCESS' + ), + undefined, + { duration: 2000 } + ); + }, + error: (error: unknown) => { + // Redacted: the error can carry the playlist URL. + this.logger.error('Playlist refresh error:', error); this.snackBar.open( this.appendProviderErrorCode( this.getPlaylistRefreshErrorMessage(error), @@ -235,26 +260,11 @@ export class PwaService extends DataService { duration: 5000, } ); - return throwError(() => error); - }) - ) - .subscribe((playlist: Playlist) => { - this.store.dispatch( - PlaylistActions.updatePlaylist({ - playlist, - playlistId, - refreshEpg: true, - }) - ); - - this.snackBar.open( - this.translateService.instant( - 'HOME.PLAYLISTS.PLAYLIST_UPDATE_SUCCESS' - ), - undefined, - { duration: 2000 } - ); + resolve(); + }, + complete: () => resolve(), }); + }); } private getPlaylistRefreshErrorMessage(error: unknown): string { diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index fc058d5aa..d9695f59b 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -260,7 +260,10 @@ "NAME_DESC": "الاسم (ي-أ)", "NEWEST": "تاريخ الإضافة (الأحدث أولاً)", "OLDEST": "تاريخ الإضافة (الأقدم أولاً)", - "CUSTOM_ORDER": "ترتيب مخصص (سحب وإفلات)" + "CUSTOM_ORDER": "ترتيب مخصص (سحب وإفلات)", + "UPDATED": "آخر تحديث", + "CHANNELS": "الأكثر قنوات", + "FAILING_FIRST": "غير المستجيبة أولاً" }, "PLAYLIST_TYPES": { "M3U": "M3U (محلي، رابط، نص)", @@ -1906,7 +1909,29 @@ "STALKER_PLAYLISTS": "قوائم Stalker", "ALL_PLAYLISTS": "جميع قوائم التشغيل", "PLAYLIST_COUNT_ONE": "قائمة تشغيل واحدة", - "PLAYLIST_COUNT_OTHER": "{{count}} قائمة تشغيل" + "PLAYLIST_COUNT_OTHER": "{{count}} قائمة تشغيل", + "ROW": { + "TYPE_M3U_FILE": "M3U · ملف", + "ADDED": "أُضيف {{date}}", + "UPDATED": "حُدِّث {{date}}", + "UNREACHABLE": "لا يستجيب", + "UNREACHABLE_FOR": "لا يستجيب · {{duration}}", + "EXPIRED": "منتهي", + "EDIT": "تعديل…", + "COPY_URL": "نسخ الرابط", + "OPEN": "فتح", + "CHANNELS_ONE": "قناة واحدة" + }, + "STATUS": "الحالة", + "STATUS_ANY": "أي حالة", + "STATUS_OK": "تستجيب", + "STATUS_BAD": "لا تستجيب", + "NO_MATCHES": "لا توجد قوائم تطابق عوامل التصفية هذه", + "RESET_FILTERS": "إعادة تعيين عوامل التصفية", + "REMOVED": "تمت إزالة «{{name}}»", + "REMOVED_SWITCHED": "تمت إزالة «{{name}}»، والانتقال إلى «{{next}}»", + "REMOVE_FAILED": "تعذّرت إزالة «{{name}}»", + "ADD_SOURCE": "إضافة مصدر" }, "CONTEXT": { "CATEGORIES": "الفئات", @@ -1975,7 +2000,8 @@ "COMMAND_PALETTE_NAVIGATION": "نقل تحديد لوحة الأوامر", "COMMAND_PALETTE_RUN": "تشغيل عنصر لوحة الأوامر المحدد", "CLOSE_DIALOGS": "إغلاق مربع الحوار أو إخفاء التراكب", - "DOWNLOADS_OPEN_ITEM": "فتح عنصر التنزيل المحدد في المكتبة" + "DOWNLOADS_OPEN_ITEM": "فتح عنصر التنزيل المحدد في المكتبة", + "OPEN_PLAYLIST_SWITCHER": "تبديل قائمة التشغيل" } }, "GLOBAL_FAVORITES": { @@ -1998,6 +2024,15 @@ "NO_PLAYLISTS_BODY": "أضف قائمة تشغيل وسيظهر سجل المشاهدة هنا.", "NO_ITEMS_TITLE": "لا توجد مشاهدات بعد", "NO_ITEMS_BODY": "ابدأ بمشاهدة أي محتوى من مكتبتك وسيظهر هنا." + }, + "SWITCHER": { + "SEARCH": "ابحث عن قائمة تشغيل…", + "RECENT": "الأخيرة", + "NO_MATCHES": "لم يُعثر على شيء", + "ALL_SOURCES": "كل المصادر", + "HINT_SELECT": "تحديد", + "HINT_OPEN": "فتح", + "HINT_CLOSE": "إغلاق" } }, "LOADING": "جارٍ التحميل", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index ee152ff15..d0faa8e50 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -260,7 +260,10 @@ "NAME_DESC": "الاسم (ي-أ)", "NEWEST": "تاريخ الإضافة (الأحدث أولاً)", "OLDEST": "تاريخ الإضافة (الأقدم أولاً)", - "CUSTOM_ORDER": "ترتيب مخصص (جرّ وحط)" + "CUSTOM_ORDER": "ترتيب مخصص (جرّ وحط)", + "UPDATED": "آخر تحديث", + "CHANNELS": "اللي فيها كتر القنوات", + "FAILING_FIRST": "اللي ما كيجاوبوش اللولين" }, "PLAYLIST_TYPES": { "M3U": "M3U (محلي، رابط، نص)", @@ -1906,7 +1909,29 @@ "STALKER_PLAYLISTS": "قوائم تشغيل Stalker", "ALL_PLAYLISTS": "جميع قوائم التشغيل", "PLAYLIST_COUNT_ONE": "قائمة تشغيل وحدة", - "PLAYLIST_COUNT_OTHER": "{{count}} ديال قوائم التشغيل" + "PLAYLIST_COUNT_OTHER": "{{count}} ديال قوائم التشغيل", + "ROW": { + "TYPE_M3U_FILE": "M3U · فيشي", + "ADDED": "تزاد {{date}}", + "UPDATED": "تحدّث {{date}}", + "UNREACHABLE": "ما كيجاوبش", + "UNREACHABLE_FOR": "ما كيجاوبش · {{duration}}", + "EXPIRED": "سالات", + "EDIT": "بدّل…", + "COPY_URL": "نسخ الرابط", + "OPEN": "حلّ", + "CHANNELS_ONE": "قناة وحدة" + }, + "STATUS": "الحالة", + "STATUS_ANY": "أي حالة", + "STATUS_OK": "كيجاوبو", + "STATUS_BAD": "ما كيجاوبوش", + "NO_MATCHES": "حتى ليستة ما كتوافق هاد الفلاتر", + "RESET_FILTERS": "رجّع الفلاتر", + "REMOVED": "تحيّدات «{{name}}»", + "REMOVED_SWITCHED": "تحيّدات «{{name}}»، دوّزنا لـ «{{next}}»", + "REMOVE_FAILED": "ما قدرناش نحيّدو «{{name}}»", + "ADD_SOURCE": "زيد مصدر" }, "CONTEXT": { "CATEGORIES": "الفئات", @@ -1975,7 +2000,8 @@ "COMMAND_PALETTE_NAVIGATION": "حرك الاختيار فلوحة الأوامر", "COMMAND_PALETTE_RUN": "شغل العنصر المختار فلوحة الأوامر", "CLOSE_DIALOGS": "سد النافذة ولا خبي الطبقة", - "DOWNLOADS_OPEN_ITEM": "حل عنصر التحميل المحدد فالمكتبة" + "DOWNLOADS_OPEN_ITEM": "حل عنصر التحميل المحدد فالمكتبة", + "OPEN_PLAYLIST_SWITCHER": "بدّل الليستة" } }, "GLOBAL_FAVORITES": { @@ -1998,6 +2024,15 @@ "NO_PLAYLISTS_BODY": "زيد قائمة تشغيل وسجل التفرج ديالك غادي يبان هنا.", "NO_ITEMS_TITLE": "ما تشاف حتى شي حاجة دابا", "NO_ITEMS_BODY": "بدا تفرج فشي حاجة من المكتبة ديالك وغادي تبان هنا." + }, + "SWITCHER": { + "SEARCH": "قلّب على ليستة…", + "RECENT": "الجداد", + "NO_MATCHES": "ما لقينا والو", + "ALL_SOURCES": "كاع المصادر", + "HINT_SELECT": "ختار", + "HINT_OPEN": "حلّ", + "HINT_CLOSE": "سدّ" } }, "LOADING": "كيتشارجا", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index ca47cc90a..3be626e68 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -260,7 +260,10 @@ "NAME_DESC": "Назва (Я-А)", "NEWEST": "Дата дадання (спачатку новыя)", "OLDEST": "Дата дадання (спачатку старыя)", - "CUSTOM_ORDER": "Уласны парадак (перацягванне)" + "CUSTOM_ORDER": "Уласны парадак (перацягванне)", + "UPDATED": "Апошняе абнаўленне", + "CHANNELS": "Колькасць каналаў", + "FAILING_FIRST": "Спачатку тыя, што не адказваюць" }, "PLAYLIST_TYPES": { "M3U": "M3U (лакальны, URL, тэкст)", @@ -1906,7 +1909,29 @@ "STALKER_PLAYLISTS": "Stalker плэйлісты", "ALL_PLAYLISTS": "Усе плэйлісты", "PLAYLIST_COUNT_ONE": "1 плэйліст", - "PLAYLIST_COUNT_OTHER": "{{count}} плэйлістаў" + "PLAYLIST_COUNT_OTHER": "{{count}} плэйлістаў", + "ROW": { + "TYPE_M3U_FILE": "M3U · файл", + "ADDED": "дададзены {{date}}", + "UPDATED": "абноўлены {{date}}", + "UNREACHABLE": "Не адказвае", + "UNREACHABLE_FOR": "Не адказвае · {{duration}}", + "EXPIRED": "Скончыўся", + "EDIT": "Змяніць…", + "COPY_URL": "Скапіяваць URL", + "OPEN": "Адкрыць", + "CHANNELS_ONE": "1 канал" + }, + "STATUS": "Статус", + "STATUS_ANY": "Любы", + "STATUS_OK": "Адказваюць", + "STATUS_BAD": "Не адказваюць", + "NO_MATCHES": "Няма плэйлістоў пад гэтыя фільтры", + "RESET_FILTERS": "Скінуць фільтры", + "REMOVED": "Выдалены «{{name}}»", + "REMOVED_SWITCHED": "Выдалены «{{name}}», адкрыты «{{next}}»", + "REMOVE_FAILED": "Не ўдалося выдаліць «{{name}}»", + "ADD_SOURCE": "Дадаць крыніцу" }, "CONTEXT": { "CATEGORIES": "Катэгорыі", @@ -1975,7 +2000,8 @@ "COMMAND_PALETTE_NAVIGATION": "Перамясціць выбар у палітры каманд", "COMMAND_PALETTE_RUN": "Выканаць выбраны элемент палітры каманд", "CLOSE_DIALOGS": "Закрыць дыялог або схаваць накладку", - "DOWNLOADS_OPEN_ITEM": "Адкрыць сфакусаваную спампоўку ў бібліятэцы" + "DOWNLOADS_OPEN_ITEM": "Адкрыць сфакусаваную спампоўку ў бібліятэцы", + "OPEN_PLAYLIST_SWITCHER": "Пераключыць плэйліст" } }, "GLOBAL_FAVORITES": { @@ -1998,6 +2024,15 @@ "NO_PLAYLISTS_BODY": "Дадайце плэйліст, і гісторыя прагляду з’явіцца тут.", "NO_ITEMS_TITLE": "Праглядаў пакуль няма", "NO_ITEMS_BODY": "Пачніце глядзець што-небудзь з бібліятэкі, і гэта з’явіцца тут." + }, + "SWITCHER": { + "SEARCH": "Знайсці плэйліст…", + "RECENT": "Нядаўнія", + "NO_MATCHES": "Нічога не знойдзена", + "ALL_SOURCES": "Усе крыніцы", + "HINT_SELECT": "выбар", + "HINT_OPEN": "адкрыць", + "HINT_CLOSE": "закрыць" } }, "LOADING": "Загрузка", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index f713e2ae4..feb6ee0e0 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -260,7 +260,10 @@ "NAME_DESC": "Name (Z-A)", "NEWEST": "Hinzugefügt (Neueste zuerst)", "OLDEST": "Hinzugefügt (Älteste zuerst)", - "CUSTOM_ORDER": "Benutzerdefinierte Reihenfolge (Drag & Drop)" + "CUSTOM_ORDER": "Benutzerdefinierte Reihenfolge (Drag & Drop)", + "UPDATED": "Zuletzt aktualisiert", + "CHANNELS": "Meiste Sender", + "FAILING_FIRST": "Nicht erreichbare zuerst" }, "PLAYLIST_TYPES": { "M3U": "M3U (lokal, URL, Text)", @@ -1906,7 +1909,29 @@ "STALKER_PLAYLISTS": "Stalker-Playlists", "ALL_PLAYLISTS": "Alle Playlists", "PLAYLIST_COUNT_ONE": "1 Playlist", - "PLAYLIST_COUNT_OTHER": "{{count}} Playlists" + "PLAYLIST_COUNT_OTHER": "{{count}} Playlists", + "ROW": { + "TYPE_M3U_FILE": "M3U · Datei", + "ADDED": "hinzugefügt am {{date}}", + "UPDATED": "aktualisiert am {{date}}", + "UNREACHABLE": "Antwortet nicht", + "UNREACHABLE_FOR": "Antwortet nicht · {{duration}}", + "EXPIRED": "Abgelaufen", + "EDIT": "Bearbeiten…", + "COPY_URL": "URL kopieren", + "OPEN": "Öffnen", + "CHANNELS_ONE": "1 Sender" + }, + "STATUS": "Status (Erreichbarkeit)", + "STATUS_ANY": "Alle", + "STATUS_OK": "Erreichbar", + "STATUS_BAD": "Nicht erreichbar", + "NO_MATCHES": "Keine Playlists passen zu diesen Filtern", + "RESET_FILTERS": "Filter zurücksetzen", + "REMOVED": "„{{name}}“ entfernt", + "REMOVED_SWITCHED": "„{{name}}“ entfernt, zu „{{next}}“ gewechselt", + "REMOVE_FAILED": "„{{name}}“ konnte nicht entfernt werden", + "ADD_SOURCE": "Quelle hinzufügen" }, "CONTEXT": { "CATEGORIES": "Kategorien", @@ -1975,7 +2000,8 @@ "COMMAND_PALETTE_NAVIGATION": "Auswahl in der Befehlspalette verschieben", "COMMAND_PALETTE_RUN": "Ausgewählten Eintrag der Befehlspalette ausführen", "CLOSE_DIALOGS": "Dialog schließen oder Overlay ausblenden", - "DOWNLOADS_OPEN_ITEM": "Fokussierten Download-Eintrag in der Bibliothek öffnen" + "DOWNLOADS_OPEN_ITEM": "Fokussierten Download-Eintrag in der Bibliothek öffnen", + "OPEN_PLAYLIST_SWITCHER": "Playlist wechseln" } }, "GLOBAL_FAVORITES": { @@ -1998,6 +2024,15 @@ "NO_PLAYLISTS_BODY": "Füge eine Wiedergabeliste hinzu, und dein Wiedergabeverlauf erscheint hier.", "NO_ITEMS_TITLE": "Noch nichts angesehen", "NO_ITEMS_BODY": "Beginne, etwas aus deiner Bibliothek anzusehen, dann erscheint es hier." + }, + "SWITCHER": { + "SEARCH": "Playlist suchen…", + "RECENT": "Zuletzt geöffnet", + "NO_MATCHES": "Nichts gefunden", + "ALL_SOURCES": "Alle Quellen", + "HINT_SELECT": "auswählen", + "HINT_OPEN": "öffnen", + "HINT_CLOSE": "schließen" } }, "LOADING": "Laden", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 8e08feb76..f427ba100 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -260,7 +260,10 @@ "NAME_DESC": "Όνομα (Z-A)", "NEWEST": "Ημερομηνία προσθήκης (Πρώτα τα νεότερα)", "OLDEST": "Ημερομηνία προσθήκης (Πρώτα το παλαιότερο)", - "CUSTOM_ORDER": "Προσαρμοσμένη σειρά (drag & drop)" + "CUSTOM_ORDER": "Προσαρμοσμένη σειρά (drag & drop)", + "UPDATED": "Τελευταία ενημέρωση", + "CHANNELS": "Περισσότερα κανάλια", + "FAILING_FIRST": "Πρώτα όσα δεν αποκρίνονται" }, "PLAYLIST_TYPES": { "M3U": "M3U (τοπικό, διεύθυνση url, κείμενο)", @@ -1906,7 +1909,29 @@ "STALKER_PLAYLISTS": "Λίστες αναπαραγωγής Stalker", "ALL_PLAYLISTS": "Όλες οι λίστες αναπαραγωγής", "PLAYLIST_COUNT_ONE": "1 λίστα αναπαραγωγής", - "PLAYLIST_COUNT_OTHER": "{{count}} λίστες αναπαραγωγής" + "PLAYLIST_COUNT_OTHER": "{{count}} λίστες αναπαραγωγής", + "ROW": { + "TYPE_M3U_FILE": "M3U · αρχείο", + "ADDED": "προστέθηκε {{date}}", + "UPDATED": "ενημερώθηκε {{date}}", + "UNREACHABLE": "Δεν αποκρίνεται", + "UNREACHABLE_FOR": "Δεν αποκρίνεται · {{duration}}", + "EXPIRED": "Έληξε", + "EDIT": "Επεξεργασία…", + "COPY_URL": "Αντιγραφή URL", + "OPEN": "Άνοιγμα", + "CHANNELS_ONE": "1 κανάλι" + }, + "STATUS": "Κατάσταση", + "STATUS_ANY": "Οποιαδήποτε", + "STATUS_OK": "Αποκρίνονται", + "STATUS_BAD": "Δεν αποκρίνονται", + "NO_MATCHES": "Καμία λίστα δεν ταιριάζει με αυτά τα φίλτρα", + "RESET_FILTERS": "Επαναφορά φίλτρων", + "REMOVED": "Καταργήθηκε το «{{name}}»", + "REMOVED_SWITCHED": "Καταργήθηκε το «{{name}}», μετάβαση στο «{{next}}»", + "REMOVE_FAILED": "Δεν ήταν δυνατή η κατάργηση του «{{name}}»", + "ADD_SOURCE": "Προσθήκη πηγής" }, "CONTEXT": { "CATEGORIES": "Κατηγορίες", @@ -1975,7 +2000,8 @@ "COMMAND_PALETTE_NAVIGATION": "Μετακίνηση επιλογής παλέτας εντολών", "COMMAND_PALETTE_RUN": "Εκτέλεση επιλεγμένου στοιχείου παλέτας εντολών", "CLOSE_DIALOGS": "Κλείσιμο διαλόγου ή απόκρυψη overlay", - "DOWNLOADS_OPEN_ITEM": "Άνοιγμα εστιασμένου στοιχείου λήψης στη βιβλιοθήκη" + "DOWNLOADS_OPEN_ITEM": "Άνοιγμα εστιασμένου στοιχείου λήψης στη βιβλιοθήκη", + "OPEN_PLAYLIST_SWITCHER": "Εναλλαγή λίστας" } }, "GLOBAL_FAVORITES": { @@ -1998,6 +2024,15 @@ "NO_PLAYLISTS_BODY": "Προσθέστε μια λίστα αναπαραγωγής και το ιστορικό προβολής σας θα εμφανιστεί εδώ.", "NO_ITEMS_TITLE": "Δεν έχει παρακολουθηθεί τίποτα ακόμη", "NO_ITEMS_BODY": "Ξεκινήστε να παρακολουθείτε οτιδήποτε από τη βιβλιοθήκη σας και θα εμφανιστεί εδώ." + }, + "SWITCHER": { + "SEARCH": "Εύρεση λίστας…", + "RECENT": "Πρόσφατα", + "NO_MATCHES": "Δεν βρέθηκε τίποτα", + "ALL_SOURCES": "Όλες οι πηγές", + "HINT_SELECT": "επιλογή", + "HINT_OPEN": "άνοιγμα", + "HINT_CLOSE": "κλείσιμο" } }, "LOADING": "Φόρτωση", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index deab0dfe1..cf9e5bd7c 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -260,7 +260,10 @@ "NAME_DESC": "Name (Z-A)", "NEWEST": "Date added (Newest first)", "OLDEST": "Date added (Oldest first)", - "CUSTOM_ORDER": "Custom order (Drag & Drop)" + "CUSTOM_ORDER": "Custom order (Drag & Drop)", + "UPDATED": "Last updated", + "CHANNELS": "Most channels", + "FAILING_FIRST": "Not responding first" }, "PLAYLIST_TYPES": { "M3U": "M3U (local, url, text)", @@ -1906,7 +1909,29 @@ "STALKER_PLAYLISTS": "Stalker Playlists", "ALL_PLAYLISTS": "All Playlists", "PLAYLIST_COUNT_ONE": "1 playlist", - "PLAYLIST_COUNT_OTHER": "{{count}} playlists" + "PLAYLIST_COUNT_OTHER": "{{count}} playlists", + "ROW": { + "TYPE_M3U_FILE": "M3U · file", + "ADDED": "added {{date}}", + "UPDATED": "updated {{date}}", + "UNREACHABLE": "Not responding", + "UNREACHABLE_FOR": "Not responding · {{duration}}", + "EXPIRED": "Expired", + "EDIT": "Edit…", + "COPY_URL": "Copy URL", + "OPEN": "Open", + "CHANNELS_ONE": "1 channel" + }, + "STATUS": "Status", + "STATUS_ANY": "Any", + "STATUS_OK": "Responding", + "STATUS_BAD": "Not responding", + "NO_MATCHES": "No playlists match these filters", + "RESET_FILTERS": "Reset filters", + "REMOVED": "Removed “{{name}}”", + "REMOVED_SWITCHED": "Removed “{{name}}”, switched to “{{next}}”", + "REMOVE_FAILED": "Could not remove “{{name}}”", + "ADD_SOURCE": "Add a source" }, "CONTEXT": { "CATEGORIES": "Categories", @@ -1975,7 +2000,8 @@ "COMMAND_PALETTE_NAVIGATION": "Move command palette selection", "COMMAND_PALETTE_RUN": "Run selected command palette item", "CLOSE_DIALOGS": "Close dialog or dismiss overlay", - "DOWNLOADS_OPEN_ITEM": "Open focused download item in the library" + "DOWNLOADS_OPEN_ITEM": "Open focused download item in the library", + "OPEN_PLAYLIST_SWITCHER": "Switch playlist" } }, "GLOBAL_FAVORITES": { @@ -1998,6 +2024,15 @@ "NO_PLAYLISTS_BODY": "Add a playlist and your watching history will appear here.", "NO_ITEMS_TITLE": "Nothing watched yet", "NO_ITEMS_BODY": "Start watching anything from your library and it will show up here." + }, + "SWITCHER": { + "SEARCH": "Find a playlist…", + "RECENT": "Recent", + "NO_MATCHES": "Nothing found", + "ALL_SOURCES": "All sources", + "HINT_SELECT": "select", + "HINT_OPEN": "open", + "HINT_CLOSE": "close" } }, "LOADING": "Loading", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 0290e5f4d..17d845b3c 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -260,7 +260,10 @@ "NAME_DESC": "Nombre (Z-A)", "NEWEST": "Fecha de adición (más recientes primero)", "OLDEST": "Fecha de adición (más antiguas primero)", - "CUSTOM_ORDER": "Orden personalizado (arrastrar y soltar)" + "CUSTOM_ORDER": "Orden personalizado (arrastrar y soltar)", + "UPDATED": "Última actualización", + "CHANNELS": "Más canales", + "FAILING_FIRST": "Primero las que no responden" }, "PLAYLIST_TYPES": { "M3U": "M3U (local, url, texto)", @@ -1906,7 +1909,29 @@ "STALKER_PLAYLISTS": "Listas Stalker", "ALL_PLAYLISTS": "Todas las listas", "PLAYLIST_COUNT_ONE": "1 lista de reproducción", - "PLAYLIST_COUNT_OTHER": "{{count}} listas de reproducción" + "PLAYLIST_COUNT_OTHER": "{{count}} listas de reproducción", + "ROW": { + "TYPE_M3U_FILE": "M3U · archivo", + "ADDED": "añadido el {{date}}", + "UPDATED": "actualizado el {{date}}", + "UNREACHABLE": "No responde", + "UNREACHABLE_FOR": "No responde · {{duration}}", + "EXPIRED": "Caducada", + "EDIT": "Editar…", + "COPY_URL": "Copiar URL", + "OPEN": "Abrir", + "CHANNELS_ONE": "1 canal" + }, + "STATUS": "Estado", + "STATUS_ANY": "Cualquiera", + "STATUS_OK": "Responden", + "STATUS_BAD": "No responden", + "NO_MATCHES": "Ninguna lista coincide con estos filtros", + "RESET_FILTERS": "Restablecer filtros", + "REMOVED": "Se eliminó «{{name}}»", + "REMOVED_SWITCHED": "Se eliminó «{{name}}» y se cambió a «{{next}}»", + "REMOVE_FAILED": "No se pudo eliminar «{{name}}»", + "ADD_SOURCE": "Añadir una fuente" }, "CONTEXT": { "CATEGORIES": "Categorías", @@ -1975,7 +2000,8 @@ "COMMAND_PALETTE_NAVIGATION": "Mover la selección de la paleta de comandos", "COMMAND_PALETTE_RUN": "Ejecutar el elemento seleccionado de la paleta de comandos", "CLOSE_DIALOGS": "Cerrar diálogo o descartar superposición", - "DOWNLOADS_OPEN_ITEM": "Abrir en la biblioteca el elemento de descarga enfocado" + "DOWNLOADS_OPEN_ITEM": "Abrir en la biblioteca el elemento de descarga enfocado", + "OPEN_PLAYLIST_SWITCHER": "Cambiar de lista" } }, "GLOBAL_FAVORITES": { @@ -1998,6 +2024,15 @@ "NO_PLAYLISTS_BODY": "Añade una lista de reproducción y tu historial de visualización aparecerá aquí.", "NO_ITEMS_TITLE": "Aún no has visto nada", "NO_ITEMS_BODY": "Empieza a ver cualquier contenido de tu biblioteca y aparecerá aquí." + }, + "SWITCHER": { + "SEARCH": "Buscar una lista…", + "RECENT": "Recientes", + "NO_MATCHES": "No se encontró nada", + "ALL_SOURCES": "Todas las fuentes", + "HINT_SELECT": "elegir", + "HINT_OPEN": "abrir", + "HINT_CLOSE": "cerrar" } }, "LOADING": "Cargando", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 4d97b71b8..e17c937e8 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -260,7 +260,10 @@ "NAME_DESC": "Nom (Z-A)", "NEWEST": "Date d'ajout (Plus récent)", "OLDEST": "Date d'ajout (Plus ancien)", - "CUSTOM_ORDER": "Ordre personnalisé (glisser-déposer)" + "CUSTOM_ORDER": "Ordre personnalisé (glisser-déposer)", + "UPDATED": "Dernière mise à jour", + "CHANNELS": "Plus de chaînes", + "FAILING_FIRST": "Celles qui ne répondent pas d'abord" }, "PLAYLIST_TYPES": { "M3U": "M3U (local, URL, texte)", @@ -1906,7 +1909,29 @@ "STALKER_PLAYLISTS": "Listes de lecture Stalker", "ALL_PLAYLISTS": "Toutes les listes de lecture", "PLAYLIST_COUNT_ONE": "1 liste de lecture", - "PLAYLIST_COUNT_OTHER": "{{count}} listes de lecture" + "PLAYLIST_COUNT_OTHER": "{{count}} listes de lecture", + "ROW": { + "TYPE_M3U_FILE": "M3U · fichier", + "ADDED": "ajouté le {{date}}", + "UPDATED": "mis à jour le {{date}}", + "UNREACHABLE": "Ne répond pas", + "UNREACHABLE_FOR": "Ne répond pas · {{duration}}", + "EXPIRED": "Expiré", + "EDIT": "Modifier…", + "COPY_URL": "Copier l’URL", + "OPEN": "Ouvrir", + "CHANNELS_ONE": "1 chaîne" + }, + "STATUS": "État", + "STATUS_ANY": "Tous", + "STATUS_OK": "Répondent", + "STATUS_BAD": "Ne répondent pas", + "NO_MATCHES": "Aucune playlist ne correspond à ces filtres", + "RESET_FILTERS": "Réinitialiser les filtres", + "REMOVED": "« {{name}} » supprimée", + "REMOVED_SWITCHED": "« {{name}} » supprimée, passage à « {{next}} »", + "REMOVE_FAILED": "Impossible de supprimer « {{name}} »", + "ADD_SOURCE": "Ajouter une source" }, "CONTEXT": { "CATEGORIES": "Catégories", @@ -1975,7 +2000,8 @@ "COMMAND_PALETTE_NAVIGATION": "Déplacer la sélection dans la palette de commandes", "COMMAND_PALETTE_RUN": "Exécuter l’élément sélectionné dans la palette de commandes", "CLOSE_DIALOGS": "Fermer une boîte de dialogue ou masquer une superposition", - "DOWNLOADS_OPEN_ITEM": "Ouvrir l’élément de téléchargement ciblé dans la bibliothèque" + "DOWNLOADS_OPEN_ITEM": "Ouvrir l’élément de téléchargement ciblé dans la bibliothèque", + "OPEN_PLAYLIST_SWITCHER": "Changer de playlist" } }, "GLOBAL_FAVORITES": { @@ -1998,6 +2024,15 @@ "NO_PLAYLISTS_BODY": "Ajoutez une playlist et votre historique de visionnage apparaîtra ici.", "NO_ITEMS_TITLE": "Rien regardé pour le moment", "NO_ITEMS_BODY": "Commencez à regarder un contenu de votre bibliothèque et il apparaîtra ici." + }, + "SWITCHER": { + "SEARCH": "Trouver une playlist…", + "RECENT": "Récentes", + "NO_MATCHES": "Aucun résultat", + "ALL_SOURCES": "Toutes les sources", + "HINT_SELECT": "choisir", + "HINT_OPEN": "ouvrir", + "HINT_CLOSE": "fermer" } }, "LOADING": "Chargement", diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index 36dcb1a08..ccb9b0f19 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -260,7 +260,10 @@ "NAME_DESC": "Név szerint (Z–A)", "NEWEST": "Hozzáadás dátuma (legújabb elöl)", "OLDEST": "Hozzáadás dátuma (legrégebbi elöl)", - "CUSTOM_ORDER": "Egyéni sorrend (húzás és ejtés)" + "CUSTOM_ORDER": "Egyéni sorrend (húzás és ejtés)", + "UPDATED": "Legutóbbi frissítés", + "CHANNELS": "Legtöbb csatorna", + "FAILING_FIRST": "Nem válaszolók elöl" }, "PLAYLIST_TYPES": { "M3U": "M3U (helyi fájl, URL vagy szöveg)", @@ -1906,7 +1909,29 @@ "STALKER_PLAYLISTS": "Stalker-lejátszási listák", "ALL_PLAYLISTS": "Összes lejátszási lista", "PLAYLIST_COUNT_ONE": "1 lejátszási lista", - "PLAYLIST_COUNT_OTHER": "{{count}} lejátszási lista" + "PLAYLIST_COUNT_OTHER": "{{count}} lejátszási lista", + "ROW": { + "TYPE_M3U_FILE": "M3U · fájl", + "ADDED": "hozzáadva: {{date}}", + "UPDATED": "frissítve: {{date}}", + "UNREACHABLE": "Nem válaszol", + "UNREACHABLE_FOR": "Nem válaszol · {{duration}}", + "EXPIRED": "Lejárt", + "EDIT": "Szerkesztés…", + "COPY_URL": "URL másolása", + "OPEN": "Megnyitás", + "CHANNELS_ONE": "1 csatorna" + }, + "STATUS": "Állapot", + "STATUS_ANY": "Bármelyik", + "STATUS_OK": "Válaszolnak", + "STATUS_BAD": "Nem válaszolnak", + "NO_MATCHES": "Egyik lejátszási lista sem felel meg ezeknek a szűrőknek", + "RESET_FILTERS": "Szűrők törlése", + "REMOVED": "„{{name}}” eltávolítva", + "REMOVED_SWITCHED": "„{{name}}” eltávolítva, váltás erre: „{{next}}”", + "REMOVE_FAILED": "„{{name}}” nem távolítható el", + "ADD_SOURCE": "Forrás hozzáadása" }, "CONTEXT": { "CATEGORIES": "Kategóriák", @@ -1975,7 +2000,8 @@ "COMMAND_PALETTE_NAVIGATION": "Kijelölés mozgatása a parancspalettán", "COMMAND_PALETTE_RUN": "A parancspalettán kijelölt művelet végrehajtása", "CLOSE_DIALOGS": "Párbeszédpanel bezárása vagy fedőréteg eltüntetése", - "DOWNLOADS_OPEN_ITEM": "A fókuszban lévő letöltés megnyitása a médiatárban" + "DOWNLOADS_OPEN_ITEM": "A fókuszban lévő letöltés megnyitása a médiatárban", + "OPEN_PLAYLIST_SWITCHER": "Lejátszási lista váltása" } }, "GLOBAL_FAVORITES": { @@ -1998,6 +2024,15 @@ "NO_PLAYLISTS_BODY": "Adjon hozzá egy lejátszási listát, és megtekintési előzményei itt fognak megjelenni.", "NO_ITEMS_TITLE": "Még nincs megtekintett tartalom", "NO_ITEMS_BODY": "Indítson el bármit a médiatárából, és itt meg fog jelenni." + }, + "SWITCHER": { + "SEARCH": "Lejátszási lista keresése…", + "RECENT": "Legutóbbiak", + "NO_MATCHES": "Nincs találat", + "ALL_SOURCES": "Összes forrás", + "HINT_SELECT": "kijelölés", + "HINT_OPEN": "megnyitás", + "HINT_CLOSE": "bezárás" } }, "LOADING": "Betöltés", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 4fd1cd3c4..e28779378 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -260,7 +260,10 @@ "NAME_DESC": "Nome (Z-A)", "NEWEST": "Data aggiunta (prima più recenti)", "OLDEST": "Data aggiunta (prima più vecchi)", - "CUSTOM_ORDER": "Ordine personalizzato (trascina e rilascia)" + "CUSTOM_ORDER": "Ordine personalizzato (trascina e rilascia)", + "UPDATED": "Ultimo aggiornamento", + "CHANNELS": "Più canali", + "FAILING_FIRST": "Prima quelle che non rispondono" }, "PLAYLIST_TYPES": { "M3U": "M3U (locale, URL, testo)", @@ -1906,7 +1909,29 @@ "STALKER_PLAYLISTS": "Playlist Stalker", "ALL_PLAYLISTS": "Tutte le playlist", "PLAYLIST_COUNT_ONE": "1 playlist", - "PLAYLIST_COUNT_OTHER": "{{count}} playlist" + "PLAYLIST_COUNT_OTHER": "{{count}} playlist", + "ROW": { + "TYPE_M3U_FILE": "M3U · file locale", + "ADDED": "aggiunto il {{date}}", + "UPDATED": "aggiornato il {{date}}", + "UNREACHABLE": "Non risponde", + "UNREACHABLE_FOR": "Non risponde · {{duration}}", + "EXPIRED": "Scaduto", + "EDIT": "Modifica…", + "COPY_URL": "Copia URL", + "OPEN": "Apri", + "CHANNELS_ONE": "1 canale" + }, + "STATUS": "Stato", + "STATUS_ANY": "Qualsiasi", + "STATUS_OK": "Rispondono", + "STATUS_BAD": "Non rispondono", + "NO_MATCHES": "Nessuna playlist corrisponde a questi filtri", + "RESET_FILTERS": "Reimposta filtri", + "REMOVED": "«{{name}}» rimossa", + "REMOVED_SWITCHED": "«{{name}}» rimossa, passaggio a «{{next}}»", + "REMOVE_FAILED": "Impossibile rimuovere «{{name}}»", + "ADD_SOURCE": "Aggiungi una fonte" }, "CONTEXT": { "CATEGORIES": "Categorie", @@ -1975,7 +2000,8 @@ "COMMAND_PALETTE_NAVIGATION": "Sposta selezione della palette comandi", "COMMAND_PALETTE_RUN": "Esegui elemento selezionato della palette comandi", "CLOSE_DIALOGS": "Chiudi dialogo o nascondi overlay", - "DOWNLOADS_OPEN_ITEM": "Apri nella libreria l’elemento download selezionato" + "DOWNLOADS_OPEN_ITEM": "Apri nella libreria l’elemento download selezionato", + "OPEN_PLAYLIST_SWITCHER": "Cambia playlist" } }, "GLOBAL_FAVORITES": { @@ -1998,6 +2024,15 @@ "NO_PLAYLISTS_BODY": "Aggiungi una playlist e la tua cronologia di visione apparirà qui.", "NO_ITEMS_TITLE": "Ancora nessun contenuto guardato", "NO_ITEMS_BODY": "Inizia a guardare qualsiasi contenuto della tua libreria e apparirà qui." + }, + "SWITCHER": { + "SEARCH": "Trova una playlist…", + "RECENT": "Recenti", + "NO_MATCHES": "Nessun risultato", + "ALL_SOURCES": "Tutte le fonti", + "HINT_SELECT": "scegli", + "HINT_OPEN": "apri", + "HINT_CLOSE": "chiudi" } }, "LOADING": "Caricamento", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index ad455169b..83d6d2f6b 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -260,7 +260,10 @@ "NAME_DESC": "名前(Z-A)", "NEWEST": "追加日(新しい順)", "OLDEST": "追加日(古い順)", - "CUSTOM_ORDER": "カスタム順(ドラッグ&ドロップ)" + "CUSTOM_ORDER": "カスタム順(ドラッグ&ドロップ)", + "UPDATED": "最終更新", + "CHANNELS": "チャンネル数", + "FAILING_FIRST": "応答なしを先頭に" }, "PLAYLIST_TYPES": { "M3U": "M3U(ローカル、URL、テキスト)", @@ -1906,7 +1909,29 @@ "STALKER_PLAYLISTS": "Stalkerプレイリスト", "ALL_PLAYLISTS": "すべてのプレイリスト", "PLAYLIST_COUNT_ONE": "1 件のプレイリスト", - "PLAYLIST_COUNT_OTHER": "{{count}} 件のプレイリスト" + "PLAYLIST_COUNT_OTHER": "{{count}} 件のプレイリスト", + "ROW": { + "TYPE_M3U_FILE": "M3U · ファイル", + "ADDED": "{{date}}に追加", + "UPDATED": "{{date}}に更新", + "UNREACHABLE": "応答なし", + "UNREACHABLE_FOR": "応答なし · {{duration}}", + "EXPIRED": "期限切れ", + "EDIT": "編集…", + "COPY_URL": "URLをコピー", + "OPEN": "開く", + "CHANNELS_ONE": "1 チャンネル" + }, + "STATUS": "状態", + "STATUS_ANY": "すべて", + "STATUS_OK": "応答あり", + "STATUS_BAD": "応答なし", + "NO_MATCHES": "この条件に一致するプレイリストはありません", + "RESET_FILTERS": "フィルターをリセット", + "REMOVED": "「{{name}}」を削除しました", + "REMOVED_SWITCHED": "「{{name}}」を削除し、「{{next}}」に切り替えました", + "REMOVE_FAILED": "「{{name}}」を削除できませんでした", + "ADD_SOURCE": "ソースを追加" }, "CONTEXT": { "CATEGORIES": "カテゴリー", @@ -1975,7 +2000,8 @@ "COMMAND_PALETTE_NAVIGATION": "コマンドパレットの選択を移動", "COMMAND_PALETTE_RUN": "選択中のコマンドパレット項目を実行", "CLOSE_DIALOGS": "ダイアログを閉じる、またはオーバーレイを解除", - "DOWNLOADS_OPEN_ITEM": "フォーカス中のダウンロード項目をライブラリで開く" + "DOWNLOADS_OPEN_ITEM": "フォーカス中のダウンロード項目をライブラリで開く", + "OPEN_PLAYLIST_SWITCHER": "プレイリストを切り替え" } }, "GLOBAL_FAVORITES": { @@ -1998,6 +2024,15 @@ "NO_PLAYLISTS_BODY": "プレイリストを追加すると、視聴履歴がここに表示されます。", "NO_ITEMS_TITLE": "視聴履歴はまだありません", "NO_ITEMS_BODY": "ライブラリ内のコンテンツを視聴し始めると、ここに表示されます。" + }, + "SWITCHER": { + "SEARCH": "プレイリストを検索…", + "RECENT": "最近", + "NO_MATCHES": "見つかりませんでした", + "ALL_SOURCES": "すべてのソース", + "HINT_SELECT": "選択", + "HINT_OPEN": "開く", + "HINT_CLOSE": "閉じる" } }, "LOADING": "読み込み中", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index c5e7e8a8c..15f7b33c4 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -260,7 +260,10 @@ "NAME_DESC": "이름 (Z-A)", "NEWEST": "추가 날짜 (최신순)", "OLDEST": "추가 날짜 (오래된순)", - "CUSTOM_ORDER": "사용자 지정 순서(드래그 앤 드롭)" + "CUSTOM_ORDER": "사용자 지정 순서(드래그 앤 드롭)", + "UPDATED": "최근 업데이트", + "CHANNELS": "채널 많은 순", + "FAILING_FIRST": "응답 없는 항목 먼저" }, "PLAYLIST_TYPES": { "M3U": "M3U (로컬, URL, 텍스트)", @@ -1906,7 +1909,29 @@ "STALKER_PLAYLISTS": "Stalker 재생목록", "ALL_PLAYLISTS": "모든 재생목록", "PLAYLIST_COUNT_ONE": "1개 재생목록", - "PLAYLIST_COUNT_OTHER": "{{count}}개 재생목록" + "PLAYLIST_COUNT_OTHER": "{{count}}개 재생목록", + "ROW": { + "TYPE_M3U_FILE": "M3U · 파일", + "ADDED": "{{date}} 추가됨", + "UPDATED": "{{date}} 업데이트됨", + "UNREACHABLE": "응답 없음", + "UNREACHABLE_FOR": "응답 없음 · {{duration}}", + "EXPIRED": "만료됨", + "EDIT": "편집…", + "COPY_URL": "URL 복사", + "OPEN": "열기", + "CHANNELS_ONE": "채널 1개" + }, + "STATUS": "상태", + "STATUS_ANY": "전체", + "STATUS_OK": "응답함", + "STATUS_BAD": "응답 없음", + "NO_MATCHES": "이 필터와 일치하는 재생목록이 없습니다", + "RESET_FILTERS": "필터 초기화", + "REMOVED": "'{{name}}' 삭제됨", + "REMOVED_SWITCHED": "'{{name}}' 삭제됨, '{{next}}'(으)로 전환됨", + "REMOVE_FAILED": "'{{name}}'을(를) 삭제할 수 없습니다", + "ADD_SOURCE": "소스 추가" }, "CONTEXT": { "CATEGORIES": "카테고리", @@ -1975,7 +2000,8 @@ "COMMAND_PALETTE_NAVIGATION": "명령 팔레트 선택 이동", "COMMAND_PALETTE_RUN": "선택한 명령 팔레트 항목 실행", "CLOSE_DIALOGS": "대화상자 닫기 또는 오버레이 해제", - "DOWNLOADS_OPEN_ITEM": "포커스된 다운로드 항목을 라이브러리에서 열기" + "DOWNLOADS_OPEN_ITEM": "포커스된 다운로드 항목을 라이브러리에서 열기", + "OPEN_PLAYLIST_SWITCHER": "재생목록 전환" } }, "GLOBAL_FAVORITES": { @@ -1998,6 +2024,15 @@ "NO_PLAYLISTS_BODY": "플레이리스트를 추가하면 시청 기록이 여기에 표시됩니다.", "NO_ITEMS_TITLE": "아직 시청 기록이 없습니다", "NO_ITEMS_BODY": "라이브러리의 콘텐츠를 시청하기 시작하면 여기에 표시됩니다." + }, + "SWITCHER": { + "SEARCH": "재생목록 찾기…", + "RECENT": "최근", + "NO_MATCHES": "찾은 항목 없음", + "ALL_SOURCES": "모든 소스", + "HINT_SELECT": "선택", + "HINT_OPEN": "열기", + "HINT_CLOSE": "닫기" } }, "LOADING": "로드 중", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 949f32e6e..14d4a7968 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -260,7 +260,10 @@ "NAME_DESC": "Naam (Z-A)", "NEWEST": "Datum toegevoegd (Nieuwste eerst)", "OLDEST": "Datum toegevoegd (Oudste eerst)", - "CUSTOM_ORDER": "Aangepaste volgorde (slepen en neerzetten)" + "CUSTOM_ORDER": "Aangepaste volgorde (slepen en neerzetten)", + "UPDATED": "Laatst bijgewerkt", + "CHANNELS": "Meeste zenders", + "FAILING_FIRST": "Niet-reagerende eerst" }, "PLAYLIST_TYPES": { "M3U": "M3U (lokaal, URL, tekst)", @@ -1906,7 +1909,29 @@ "STALKER_PLAYLISTS": "Stalker-afspeellijsten", "ALL_PLAYLISTS": "Alle afspeellijsten", "PLAYLIST_COUNT_ONE": "1 afspeellijst", - "PLAYLIST_COUNT_OTHER": "{{count}} afspeellijsten" + "PLAYLIST_COUNT_OTHER": "{{count}} afspeellijsten", + "ROW": { + "TYPE_M3U_FILE": "M3U · bestand", + "ADDED": "toegevoegd {{date}}", + "UPDATED": "bijgewerkt {{date}}", + "UNREACHABLE": "Reageert niet", + "UNREACHABLE_FOR": "Reageert niet · {{duration}}", + "EXPIRED": "Verlopen", + "EDIT": "Bewerken…", + "COPY_URL": "URL kopiëren", + "OPEN": "Openen", + "CHANNELS_ONE": "1 zender" + }, + "STATUS": "Bereikbaarheid", + "STATUS_ANY": "Alle", + "STATUS_OK": "Reageren", + "STATUS_BAD": "Reageren niet", + "NO_MATCHES": "Geen afspeellijsten voldoen aan deze filters", + "RESET_FILTERS": "Filters wissen", + "REMOVED": "‘{{name}}’ verwijderd", + "REMOVED_SWITCHED": "‘{{name}}’ verwijderd, overgeschakeld naar ‘{{next}}’", + "REMOVE_FAILED": "‘{{name}}’ kon niet worden verwijderd", + "ADD_SOURCE": "Bron toevoegen" }, "CONTEXT": { "CATEGORIES": "Categorieën", @@ -1975,7 +2000,8 @@ "COMMAND_PALETTE_NAVIGATION": "Selectie in opdrachtpalet verplaatsen", "COMMAND_PALETTE_RUN": "Geselecteerd item in opdrachtpalet uitvoeren", "CLOSE_DIALOGS": "Dialoogvenster sluiten of overlay verbergen", - "DOWNLOADS_OPEN_ITEM": "Gefocust downloaditem in de bibliotheek openen" + "DOWNLOADS_OPEN_ITEM": "Gefocust downloaditem in de bibliotheek openen", + "OPEN_PLAYLIST_SWITCHER": "Afspeellijst wisselen" } }, "GLOBAL_FAVORITES": { @@ -1998,6 +2024,15 @@ "NO_PLAYLISTS_BODY": "Voeg een afspeellijst toe en uw kijkgeschiedenis verschijnt hier.", "NO_ITEMS_TITLE": "Nog niets bekeken", "NO_ITEMS_BODY": "Begin iets uit uw bibliotheek te kijken en het verschijnt hier." + }, + "SWITCHER": { + "SEARCH": "Afspeellijst zoeken…", + "RECENT": "Onlangs geopend", + "NO_MATCHES": "Niets gevonden", + "ALL_SOURCES": "Alle bronnen", + "HINT_SELECT": "kiezen", + "HINT_OPEN": "openen", + "HINT_CLOSE": "sluiten" } }, "LOADING": "Laden", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 5fdcc07c2..b3d3f4098 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -260,7 +260,10 @@ "NAME_DESC": "Nazwa (Z-A)", "NEWEST": "Data dodania (najnowsze pierwsze)", "OLDEST": "Data dodania (najstarsze pierwsze)", - "CUSTOM_ORDER": "Własna kolejność (przeciągnij i upuść)" + "CUSTOM_ORDER": "Własna kolejność (przeciągnij i upuść)", + "UPDATED": "Ostatnia aktualizacja", + "CHANNELS": "Najwięcej kanałów", + "FAILING_FIRST": "Najpierw nieodpowiadające" }, "PLAYLIST_TYPES": { "M3U": "M3U (lokalny, URL, tekst)", @@ -1906,7 +1909,29 @@ "STALKER_PLAYLISTS": "Listy odtwarzania Stalker", "ALL_PLAYLISTS": "Wszystkie listy odtwarzania", "PLAYLIST_COUNT_ONE": "1 lista odtwarzania", - "PLAYLIST_COUNT_OTHER": "{{count}} list odtwarzania" + "PLAYLIST_COUNT_OTHER": "{{count}} list odtwarzania", + "ROW": { + "TYPE_M3U_FILE": "M3U · plik", + "ADDED": "dodano {{date}}", + "UPDATED": "zaktualizowano {{date}}", + "UNREACHABLE": "Nie odpowiada", + "UNREACHABLE_FOR": "Nie odpowiada · {{duration}}", + "EXPIRED": "Wygasło", + "EDIT": "Edytuj…", + "COPY_URL": "Kopiuj URL", + "OPEN": "Otwórz", + "CHANNELS_ONE": "1 kanał" + }, + "STATUS": "Stan", + "STATUS_ANY": "Dowolny", + "STATUS_OK": "Odpowiadają", + "STATUS_BAD": "Nie odpowiadają", + "NO_MATCHES": "Żadna playlista nie pasuje do tych filtrów", + "RESET_FILTERS": "Wyczyść filtry", + "REMOVED": "Usunięto „{{name}}”", + "REMOVED_SWITCHED": "Usunięto „{{name}}”, przełączono na „{{next}}”", + "REMOVE_FAILED": "Nie udało się usunąć „{{name}}”", + "ADD_SOURCE": "Dodaj źródło" }, "CONTEXT": { "CATEGORIES": "Kategorie", @@ -1975,7 +2000,8 @@ "COMMAND_PALETTE_NAVIGATION": "Przenieś zaznaczenie w palecie poleceń", "COMMAND_PALETTE_RUN": "Uruchom zaznaczoną pozycję palety poleceń", "CLOSE_DIALOGS": "Zamknij okno dialogowe lub ukryj nakładkę", - "DOWNLOADS_OPEN_ITEM": "Otwórz aktywne pobranie w bibliotece" + "DOWNLOADS_OPEN_ITEM": "Otwórz aktywne pobranie w bibliotece", + "OPEN_PLAYLIST_SWITCHER": "Przełącz playlistę" } }, "GLOBAL_FAVORITES": { @@ -1998,6 +2024,15 @@ "NO_PLAYLISTS_BODY": "Dodaj listę odtwarzania, a historia oglądania pojawi się tutaj.", "NO_ITEMS_TITLE": "Jeszcze nic nie oglądano", "NO_ITEMS_BODY": "Zacznij oglądać cokolwiek ze swojej biblioteki, a pojawi się tutaj." + }, + "SWITCHER": { + "SEARCH": "Znajdź playlistę…", + "RECENT": "Ostatnie", + "NO_MATCHES": "Nic nie znaleziono", + "ALL_SOURCES": "Wszystkie źródła", + "HINT_SELECT": "wybór", + "HINT_OPEN": "otwórz", + "HINT_CLOSE": "zamknij" } }, "LOADING": "Ładowanie", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index df202d04e..b58921338 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -260,7 +260,10 @@ "NAME_DESC": "Nome (Z-A)", "NEWEST": "Data de adição (Mais recente primeiro)", "OLDEST": "Data de adição (Mais antigo primeiro)", - "CUSTOM_ORDER": "Ordem personalizada (arrastar e soltar)" + "CUSTOM_ORDER": "Ordem personalizada (arrastar e soltar)", + "UPDATED": "Última atualização", + "CHANNELS": "Mais canais", + "FAILING_FIRST": "Primeiro as que não respondem" }, "PLAYLIST_TYPES": { "M3U": "M3U (local, url, texto)", @@ -1906,7 +1909,29 @@ "STALKER_PLAYLISTS": "Playlists Stalker", "ALL_PLAYLISTS": "Todas as playlists", "PLAYLIST_COUNT_ONE": "1 playlist", - "PLAYLIST_COUNT_OTHER": "{{count}} playlists" + "PLAYLIST_COUNT_OTHER": "{{count}} playlists", + "ROW": { + "TYPE_M3U_FILE": "M3U · arquivo", + "ADDED": "adicionado em {{date}}", + "UPDATED": "atualizado em {{date}}", + "UNREACHABLE": "Não responde", + "UNREACHABLE_FOR": "Não responde · {{duration}}", + "EXPIRED": "Expirada", + "EDIT": "Editar…", + "COPY_URL": "Copiar URL", + "OPEN": "Abrir", + "CHANNELS_ONE": "1 canal" + }, + "STATUS": "Estado", + "STATUS_ANY": "Qualquer", + "STATUS_OK": "Respondem", + "STATUS_BAD": "Não respondem", + "NO_MATCHES": "Nenhuma lista corresponde a estes filtros", + "RESET_FILTERS": "Repor filtros", + "REMOVED": "«{{name}}» removida", + "REMOVED_SWITCHED": "«{{name}}» removida, mudou para «{{next}}»", + "REMOVE_FAILED": "Não foi possível remover «{{name}}»", + "ADD_SOURCE": "Adicionar uma fonte" }, "CONTEXT": { "CATEGORIES": "Categorias", @@ -1975,7 +2000,8 @@ "COMMAND_PALETTE_NAVIGATION": "Mover seleção da paleta de comandos", "COMMAND_PALETTE_RUN": "Executar item selecionado da paleta de comandos", "CLOSE_DIALOGS": "Fechar diálogo ou dispensar sobreposição", - "DOWNLOADS_OPEN_ITEM": "Abrir o download em foco na biblioteca" + "DOWNLOADS_OPEN_ITEM": "Abrir o download em foco na biblioteca", + "OPEN_PLAYLIST_SWITCHER": "Mudar de lista" } }, "GLOBAL_FAVORITES": { @@ -1998,6 +2024,15 @@ "NO_PLAYLISTS_BODY": "Adicione uma playlist e seu histórico de visualização aparecerá aqui.", "NO_ITEMS_TITLE": "Nada assistido ainda", "NO_ITEMS_BODY": "Comece a assistir qualquer item da sua biblioteca e ele aparecerá aqui." + }, + "SWITCHER": { + "SEARCH": "Procurar uma lista…", + "RECENT": "Recentes", + "NO_MATCHES": "Nada encontrado", + "ALL_SOURCES": "Todas as fontes", + "HINT_SELECT": "escolher", + "HINT_OPEN": "abrir", + "HINT_CLOSE": "fechar" } }, "LOADING": "A carregar", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 95d13a063..2f395e18c 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -260,7 +260,10 @@ "NAME_DESC": "Имени (Я-А)", "NEWEST": "Дате добавления (сначала новые)", "OLDEST": "Дате добавления (сначала старые)", - "CUSTOM_ORDER": "Пользовательский порядок (перетаскивание)" + "CUSTOM_ORDER": "Пользовательский порядок (перетаскивание)", + "UPDATED": "Последнему обновлению", + "CHANNELS": "Числу каналов", + "FAILING_FIRST": "Сначала неактивные" }, "PLAYLIST_TYPES": { "M3U": "M3U (локально, URL, текст)", @@ -1906,7 +1909,29 @@ "STALKER_PLAYLISTS": "Stalker плейлисты", "ALL_PLAYLISTS": "Все плейлисты", "PLAYLIST_COUNT_ONE": "1 плейлист", - "PLAYLIST_COUNT_OTHER": "{{count}} плейлистов" + "PLAYLIST_COUNT_OTHER": "{{count}} плейлистов", + "ROW": { + "TYPE_M3U_FILE": "M3U · файл", + "ADDED": "добавлен {{date}}", + "UPDATED": "обновлён {{date}}", + "UNREACHABLE": "Не отвечает", + "UNREACHABLE_FOR": "Не отвечает · {{duration}}", + "EXPIRED": "Истёк", + "EDIT": "Изменить…", + "COPY_URL": "Копировать URL", + "OPEN": "Открыть", + "CHANNELS_ONE": "1 канал" + }, + "STATUS": "Статус", + "STATUS_ANY": "Любой", + "STATUS_OK": "Отвечают", + "STATUS_BAD": "Не отвечают", + "NO_MATCHES": "Нет плейлистов с такими фильтрами", + "RESET_FILTERS": "Сбросить фильтры", + "REMOVED": "Удалён «{{name}}»", + "REMOVED_SWITCHED": "Удалён «{{name}}», открыт «{{next}}»", + "REMOVE_FAILED": "Не удалось удалить «{{name}}»", + "ADD_SOURCE": "Добавить источник" }, "CONTEXT": { "CATEGORIES": "Категории", @@ -1975,7 +2000,8 @@ "COMMAND_PALETTE_NAVIGATION": "Переместить выбор в палитре команд", "COMMAND_PALETTE_RUN": "Выполнить выбранный пункт палитры команд", "CLOSE_DIALOGS": "Закрыть диалог или скрыть оверлей", - "DOWNLOADS_OPEN_ITEM": "Открыть выбранную загрузку в библиотеке" + "DOWNLOADS_OPEN_ITEM": "Открыть выбранную загрузку в библиотеке", + "OPEN_PLAYLIST_SWITCHER": "Переключить плейлист" } }, "GLOBAL_FAVORITES": { @@ -1998,6 +2024,15 @@ "NO_PLAYLISTS_BODY": "Добавьте плейлист, и история просмотра появится здесь.", "NO_ITEMS_TITLE": "Пока ничего не просмотрено", "NO_ITEMS_BODY": "Начните смотреть любой контент из библиотеки, и он появится здесь." + }, + "SWITCHER": { + "SEARCH": "Найти плейлист…", + "RECENT": "Недавние", + "NO_MATCHES": "Ничего не найдено", + "ALL_SOURCES": "Все источники", + "HINT_SELECT": "выбор", + "HINT_OPEN": "открыть", + "HINT_CLOSE": "закрыть" } }, "LOADING": "Загрузка", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 400dacc08..b2446e752 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -260,7 +260,10 @@ "NAME_DESC": "İsim (Z-A)", "NEWEST": "Eklenme tarihi (En yeni önce)", "OLDEST": "Eklenme tarihi (En eski önce)", - "CUSTOM_ORDER": "Özel sıra (sürükle ve bırak)" + "CUSTOM_ORDER": "Özel sıra (sürükle ve bırak)", + "UPDATED": "Son güncelleme", + "CHANNELS": "En çok kanal", + "FAILING_FIRST": "Önce yanıt vermeyenler" }, "PLAYLIST_TYPES": { "M3U": "M3U (yerel, url, metin)", @@ -1906,7 +1909,29 @@ "STALKER_PLAYLISTS": "Stalker Oynatma Listeleri", "ALL_PLAYLISTS": "Tüm Oynatma Listeleri", "PLAYLIST_COUNT_ONE": "1 oynatma listesi", - "PLAYLIST_COUNT_OTHER": "{{count}} oynatma listesi" + "PLAYLIST_COUNT_OTHER": "{{count}} oynatma listesi", + "ROW": { + "TYPE_M3U_FILE": "M3U · dosya", + "ADDED": "{{date}} eklendi", + "UPDATED": "{{date}} güncellendi", + "UNREACHABLE": "Yanıt vermiyor", + "UNREACHABLE_FOR": "Yanıt vermiyor · {{duration}}", + "EXPIRED": "Süresi doldu", + "EDIT": "Düzenle…", + "COPY_URL": "URL'yi kopyala", + "OPEN": "Aç", + "CHANNELS_ONE": "1 kanal" + }, + "STATUS": "Durum", + "STATUS_ANY": "Tümü", + "STATUS_OK": "Yanıt veriyor", + "STATUS_BAD": "Yanıt vermiyor", + "NO_MATCHES": "Bu filtrelere uyan oynatma listesi yok", + "RESET_FILTERS": "Filtreleri sıfırla", + "REMOVED": "“{{name}}” kaldırıldı", + "REMOVED_SWITCHED": "“{{name}}” kaldırıldı, “{{next}}” açıldı", + "REMOVE_FAILED": "“{{name}}” kaldırılamadı", + "ADD_SOURCE": "Kaynak ekle" }, "CONTEXT": { "CATEGORIES": "Kategoriler", @@ -1975,7 +2000,8 @@ "COMMAND_PALETTE_NAVIGATION": "Komut paleti seçimini taşı", "COMMAND_PALETTE_RUN": "Seçili komut paleti öğesini çalıştır", "CLOSE_DIALOGS": "İletişim kutusunu kapat veya katmanı gizle", - "DOWNLOADS_OPEN_ITEM": "Odaktaki indirme öğesini kitaplıkta aç" + "DOWNLOADS_OPEN_ITEM": "Odaktaki indirme öğesini kitaplıkta aç", + "OPEN_PLAYLIST_SWITCHER": "Oynatma listesini değiştir" } }, "GLOBAL_FAVORITES": { @@ -1998,6 +2024,15 @@ "NO_PLAYLISTS_BODY": "Bir oynatma listesi ekleyin; izleme geçmişiniz burada görünecek.", "NO_ITEMS_TITLE": "Henüz hiçbir şey izlenmedi", "NO_ITEMS_BODY": "Kitaplığınızdaki herhangi bir şeyi izlemeye başlayın, burada görünecek." + }, + "SWITCHER": { + "SEARCH": "Oynatma listesi bul…", + "RECENT": "Son açılanlar", + "NO_MATCHES": "Hiçbir şey bulunamadı", + "ALL_SOURCES": "Tüm kaynaklar", + "HINT_SELECT": "seç", + "HINT_OPEN": "aç", + "HINT_CLOSE": "kapat" } }, "LOADING": "Yükleniyor", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 33034eeb9..ec13f426f 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -260,7 +260,10 @@ "NAME_DESC": "名称(Z-A)", "NEWEST": "添加日期(最新优先)", "OLDEST": "添加日期(最旧优先)", - "CUSTOM_ORDER": "自定义顺序(拖放)" + "CUSTOM_ORDER": "自定义顺序(拖放)", + "UPDATED": "最近更新", + "CHANNELS": "频道数量", + "FAILING_FIRST": "无响应的优先" }, "PLAYLIST_TYPES": { "M3U": "M3U(本地、URL、文本)", @@ -1906,7 +1909,29 @@ "STALKER_PLAYLISTS": "Stalker 播放列表", "ALL_PLAYLISTS": "所有播放列表", "PLAYLIST_COUNT_ONE": "1 个播放列表", - "PLAYLIST_COUNT_OTHER": "{{count}} 个播放列表" + "PLAYLIST_COUNT_OTHER": "{{count}} 个播放列表", + "ROW": { + "TYPE_M3U_FILE": "M3U · 文件", + "ADDED": "{{date}} 添加", + "UPDATED": "{{date}} 更新", + "UNREACHABLE": "无响应", + "UNREACHABLE_FOR": "无响应 · {{duration}}", + "EXPIRED": "已过期", + "EDIT": "编辑…", + "COPY_URL": "复制 URL", + "OPEN": "打开", + "CHANNELS_ONE": "1 个频道" + }, + "STATUS": "状态", + "STATUS_ANY": "全部", + "STATUS_OK": "有响应", + "STATUS_BAD": "无响应", + "NO_MATCHES": "没有符合这些筛选条件的播放列表", + "RESET_FILTERS": "重置筛选", + "REMOVED": "已删除“{{name}}”", + "REMOVED_SWITCHED": "已删除“{{name}}”,已切换到“{{next}}”", + "REMOVE_FAILED": "无法删除“{{name}}”", + "ADD_SOURCE": "添加来源" }, "CONTEXT": { "CATEGORIES": "分类", @@ -1975,7 +2000,8 @@ "COMMAND_PALETTE_NAVIGATION": "移动命令面板选中项", "COMMAND_PALETTE_RUN": "运行选中的命令面板项目", "CLOSE_DIALOGS": "关闭对话框或取消覆盖层", - "DOWNLOADS_OPEN_ITEM": "在媒体库中打开聚焦的下载项" + "DOWNLOADS_OPEN_ITEM": "在媒体库中打开聚焦的下载项", + "OPEN_PLAYLIST_SWITCHER": "切换播放列表" } }, "GLOBAL_FAVORITES": { @@ -1998,6 +2024,15 @@ "NO_PLAYLISTS_BODY": "添加播放列表后,您的观看历史会显示在这里。", "NO_ITEMS_TITLE": "还没有观看记录", "NO_ITEMS_BODY": "开始观看媒体库中的任意内容后,它会显示在这里。" + }, + "SWITCHER": { + "SEARCH": "查找播放列表…", + "RECENT": "最近", + "NO_MATCHES": "未找到", + "ALL_SOURCES": "所有来源", + "HINT_SELECT": "选择", + "HINT_OPEN": "打开", + "HINT_CLOSE": "关闭" } }, "LOADING": "加载中", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 81eddab71..8a2a4468e 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -260,7 +260,10 @@ "NAME_DESC": "名稱(Z-A)", "NEWEST": "新增日期(最新優先)", "OLDEST": "新增日期(最舊優先)", - "CUSTOM_ORDER": "自訂順序(拖放)" + "CUSTOM_ORDER": "自訂順序(拖放)", + "UPDATED": "最近更新", + "CHANNELS": "頻道數量", + "FAILING_FIRST": "無回應的優先" }, "PLAYLIST_TYPES": { "M3U": "M3U(本機、網址、文字)", @@ -1906,7 +1909,29 @@ "STALKER_PLAYLISTS": "Stalker 播放清單", "ALL_PLAYLISTS": "所有播放清單", "PLAYLIST_COUNT_ONE": "1 個播放清單", - "PLAYLIST_COUNT_OTHER": "{{count}} 個播放清單" + "PLAYLIST_COUNT_OTHER": "{{count}} 個播放清單", + "ROW": { + "TYPE_M3U_FILE": "M3U · 檔案", + "ADDED": "{{date}} 新增", + "UPDATED": "{{date}} 更新", + "UNREACHABLE": "無回應", + "UNREACHABLE_FOR": "無回應 · {{duration}}", + "EXPIRED": "已過期", + "EDIT": "編輯…", + "COPY_URL": "複製 URL", + "OPEN": "開啟", + "CHANNELS_ONE": "1 個頻道" + }, + "STATUS": "狀態", + "STATUS_ANY": "全部", + "STATUS_OK": "有回應", + "STATUS_BAD": "無回應", + "NO_MATCHES": "沒有符合這些篩選條件的播放清單", + "RESET_FILTERS": "重設篩選", + "REMOVED": "已移除「{{name}}」", + "REMOVED_SWITCHED": "已移除「{{name}}」,已切換到「{{next}}」", + "REMOVE_FAILED": "無法移除「{{name}}」", + "ADD_SOURCE": "新增來源" }, "CONTEXT": { "CATEGORIES": "類別", @@ -1975,7 +2000,8 @@ "COMMAND_PALETTE_NAVIGATION": "移動命令面板選取項", "COMMAND_PALETTE_RUN": "執行選取的命令面板項目", "CLOSE_DIALOGS": "關閉對話框或取消覆蓋層", - "DOWNLOADS_OPEN_ITEM": "在媒體庫中開啟聚焦的下載項目" + "DOWNLOADS_OPEN_ITEM": "在媒體庫中開啟聚焦的下載項目", + "OPEN_PLAYLIST_SWITCHER": "切換播放清單" } }, "GLOBAL_FAVORITES": { @@ -1998,6 +2024,15 @@ "NO_PLAYLISTS_BODY": "新增播放清單後,您的觀看歷史會顯示在這裡。", "NO_ITEMS_TITLE": "還沒有觀看記錄", "NO_ITEMS_BODY": "開始觀看媒體庫中的任意內容後,它會顯示在這裡。" + }, + "SWITCHER": { + "SEARCH": "尋找播放清單…", + "RECENT": "最近", + "NO_MATCHES": "找不到", + "ALL_SOURCES": "所有來源", + "HINT_SELECT": "選擇", + "HINT_OPEN": "開啟", + "HINT_CLOSE": "關閉" } }, "LOADING": "載入中", diff --git a/apps/web/src/m3-theme.scss b/apps/web/src/m3-theme.scss index c12face66..ff8683705 100644 --- a/apps/web/src/m3-theme.scss +++ b/apps/web/src/m3-theme.scss @@ -219,6 +219,23 @@ html { // 4.5:1 over hero artwork under the light scrim (#a16207 measured 3.4:1). --app-rating-color: #7a4a00; + // Source lists (Sources page, playlist switcher) and their popovers. + --app-line-strong: rgba(0, 0, 0, 0.12); + --app-font-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, monospace; + // A failing source's tag and destructive menu items. + --app-danger-text: #b3261e; + --app-danger-surface: color-mix(in srgb, #d12c2c 12%, transparent); + --app-neutral-tag-surface: rgba(0, 0, 0, 0.06); + // One selected-row look for side lists and the switcher's current row: + // the Settings navigation's soft tint, plus an outline in light theme + // (selection reads as outline + tint here, not a solid slab). Light + // text stays the heading colour: the blue on its own tint is 3.3:1. + --app-list-selected-bg: var(--app-selection-surface); + --app-list-selected-fg: var(--app-heading-color); + --app-list-selected-outline: var(--app-selection-border); + --app-popover-shadow: 0 24px 70px rgba(15, 20, 28, 0.18); + --app-popover-backdrop: rgba(20, 24, 32, 0.28); + .dark-theme { @include component-colors($dark-theme); // Only the color roles differ per theme: the elevation shadows are @@ -324,6 +341,19 @@ html { --app-cta-hover-bg: #ffffff; --app-cta-meta-fg: #5a6274; --app-rating-color: #ffcf5c; + + // Source lists and popovers. The selected-row tokens are + // re-declared so they resolve against this context's selection + // colour, not the light values computed on `html`. + --app-line-strong: rgba(255, 255, 255, 0.1); + --app-danger-text: #ff8d9b; + --app-danger-surface: rgba(255, 91, 110, 0.14); + --app-neutral-tag-surface: rgba(255, 255, 255, 0.08); + --app-list-selected-bg: var(--app-selection-surface); + --app-list-selected-fg: var(--app-selection-color); + --app-list-selected-outline: transparent; + --app-popover-shadow: 0 24px 70px rgba(0, 0, 0, 0.6); + --app-popover-backdrop: rgba(4, 6, 10, 0.5); } } diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index 12ef8a693..13972e2f6 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -38,6 +38,12 @@ Use it when changing existing views or introducing new list-based UI in the work `libs/ui/epg/src/lib/epg-list-view/epg-list-view.component.scss` - Shared list selection style: `libs/ui/styles/_nav-list.scss` +- Source row (Sources page and playlist switcher): + `libs/playlist/shared/ui/src/lib/source-row/source-row.component.html` +- UI-kit buttons, menus and segmented control: + `libs/ui/styles/_app-controls.scss` +- Side lists (title, group labels, single-choice rows): + `libs/ui/styles/_side-list.scss` - Theme tokens: `apps/web/src/m3-theme.scss` - Settings surfaces: @@ -67,6 +73,15 @@ in `apps/web/src/m3-theme.scss`): - `--app-on-surface` — primary text - `--app-eyebrow-color` — secondary/muted text - `--app-success-color` — done/watched status (episode watched toggle) +- `--app-list-selected-bg` / `--app-list-selected-fg` / + `--app-list-selected-outline` — the chosen row of a side list and the + active source's row on Sources and in the switcher (see Selection Pattern) +- `--app-line-strong` — popover and menu borders, a step above + `--app-widget-header-border` +- `--app-danger-text` / `--app-danger-surface` — a failing source's tag and + destructive menu items; `--app-neutral-tag-surface` for a neutral tag +- `--app-font-mono` — URLs and keyboard hints (JetBrains Mono, 400/500) +- `--app-popover-shadow` / `--app-popover-backdrop` — the switcher popover Angular Material mixins and Material-component overrides may use the tokens owned by that component. Outside a Material-owned component, prefer the @@ -216,6 +231,13 @@ Use this pattern for: Do not add extra badges, left rails, or second selection systems unless there is a strong reason. +Side lists styled with `_side-list.scss` (the Sources filters) and the +active source's row (Sources page and playlist switcher) use the Settings +navigation's variant +instead, through `--app-list-selected-*`: a soft tint with no lift or glow, +outlined in light theme. Its text is the heading colour in light theme (the +selection blue on its own tint is 3.3:1) and the selection colour in dark. + ## Single-Choice Menus And Status Chips A `mat-menu` that picks one value, such as a sort order or a rating @@ -241,6 +263,15 @@ threshold, uses `appMenuItemRadio` and `appMenuItemRadioCheck` from - A menu with several groups, such as the catalog refine menu, wraps each group in `role="group"`. The group is named by its section title, and `role="separator"` sits between groups. +- Keep a menu short enough to open whole below its trigger in a 600px-tall + window. Material 22 sets no max-height on a menu panel: a taller menu is + pushed up over its own trigger, then clipped. Move a secondary group into + a submenu whose row shows the active value, as the refine menu does with + its rating thresholds. +- Material offsets a submenu by the `offsetTop` of the parent menu's first + direct `mat-menu-item`. When the earlier rows sit inside a `role="group"`, + that first direct item is the submenu trigger itself, and the submenu + opens off screen. Wrap such a trigger in `
`. A chip that shows the active state next to its menu trigger, such as the catalog sort chip, shows its icon and a short value at every width, for @@ -1360,27 +1391,97 @@ A visual change is not done until: ### Source row keyboard access -A Sources row (`app-playlist-item`) opens on a click anywhere on it, and it -also holds a drag handle, a health indicator and actions. Like -`app-content-card`, it opens from the keyboard through a dedicated element -that is a sibling of those controls: the title and meta block -`.playlist-content` (`role="button"`, `tabindex="0"`, named by the source -title, `aria-current` on the active source, `aria-disabled` while busy). -Enter and Space open the source (Space prevents the page scroll), and Tab -reaches each row before its actions. Never put `role="button"` or key -handlers on the whole row: the actions would sit inside a button, and their -Enter and Space would bubble into it. The row draws the ring for that -element, inset (`$offset: -2px`), because the list's scroller would cut a -ring drawn outside the full-width row. `sources-keyboard.e2e.ts` (Electron) -tabs to a row, checks its ring and opens the source with Enter. +A Sources row (`app-source-row`, regular size) opens on a click anywhere on +it, and it also holds a drag handle and its actions. Like `app-content-card`, +it opens from the keyboard through a dedicated element that is a sibling of +those controls: the title and meta block `.source-row__body` +(`role="button"`, `tabindex="0"`, named by the source's display name, +`aria-current` on the active source, `aria-disabled` while busy). Enter and +Space open the source (Space prevents the page scroll), and Tab reaches each +row before its actions. Never put `role="button"` or key handlers on the whole +row: the actions would sit inside a button, and their Enter and Space would +bubble into it. The row draws the ring for that element, inset +(`$offset: -2px`), because the list's scroller would cut a ring drawn outside +the full-width row. `R` on a focused row refreshes it, as its menu item shows. +`sources-keyboard.e2e.ts` (Electron) tabs to a row, checks its ring, opens +the source with Enter, and deletes and undoes a source keyboard-only. + +### Sources page and playlist switcher + +One `app-source-row` serves both lists: `size="regular"` on the Sources page +(56px, 36px tile, hover actions Refresh, Edit and ⋯; a failing row keeps +Retry in view; touch screens always show the actions) and `size="compact"` +in the switcher (52px, 32px tile, ⋯ only). In both the active source's row +is tinted with the selected-row token; the switcher adds a check. The page +header (title, Clean up, Sort) stays; only the list below it scrolls. The meta line is always type · channels · added · updated · URL, with +empty parts left out; only M3U playlists store a channel count. The helpers +in `@iptvnator/playlist/shared/util` (`source-display`, `source-format`, +`source-list`) own that text, the filters and the counts. + +- **No credentials on screen.** Names come from `sourceDisplayName`: a URL, a + URL-derived name such as `get.php?username=…&password=…` or any name holding + the source's password, MAC or URL secrets becomes the host. URLs come from + `sourceDisplayUrl`, built on `stripUrlCredentials` (beside it in + `source-display`, out of the eager logging barrel): no + userinfo, sensitive query or fragment parameters, or Xtream path + credentials; Xtream shows `scheme://host:port · username`. Copy URL copies + the full address on request. +- **Delete with Undo.** A single delete has no confirmation: the row leaves + every list at once (`SourceRemovalService.pendingIds`) and the delete runs + when the four-second Undo window ends. The window is the service's own + timer, not the toast's: another message replacing the toast (a refresh + finishing elsewhere) does not shorten it. The toast's close button commits + at once, and quitting within the four seconds keeps the source. Deleting the active source + switches to the next row; Undo switches back only if no other source was + chosen since. A desktop Xtream delete brings its row back as "Removing…" + with progress and Cancel on the Sources page; the switcher leaves the + source out until the delete ends. When the delete took the row and with + it the focus, the toast's Undo takes the focus. A delete from the + switcher closes the popover first, since its focus trap would keep the + keyboard from Undo: Undo takes the focus and returns it to the trigger + (`SourceRemovalOptions.returnFocusTo`). A source cannot be removed while + a refresh clears and refills it: every refresh (a Sources row, the + switcher, the Xtream flow, the startup auto-refresh), every Xtream import + and every delete holds the source in the root `SourceActivityService`, + which both lists, the removal service and a second refresh consult. The + Xtream route session takes its hold on the refresh's `NavigationEnd`, so + the catalog is never idle between the delete and the re-import. The Sources page shows + a switcher refresh as busy. Bulk cleanup keeps its confirmation dialog. +- **Side list.** Type and, on desktop, Status (Any / Responding / Not + responding) combine and persist (`SourcesViewStateService`); each group + counts within the other's choice. The page checks every source, not only + the rows a filter shows (`keepSourcesChecked`): a saved status filter + would otherwise hide the unchecked sources it is waiting for. +- **Switcher.** The trigger is a 40px secondary button; ⌘P / Ctrl+P opens it + (⌘K stays the command palette). The popover is a CDK connected overlay, so + row menus open above it, not inside its scrolling list. Focus stays in the + search field, which drives a `role="listbox"` through + `aria-activedescendant`: ↑/↓ move and wrap, Enter opens, Shift+F10 opens + the highlighted row's menu, Escape closes and returns focus to the + trigger. The compact row's ⋯ is pointer-only (`aria-hidden`, not + focusable), since an option may not contain a control. "Recent" lists the + three most recently opened sources once there are six or more. The search + field is the shared 36px search field (`--app-search-*`). The popover is a + height-capped column in which only the list shrinks and scrolls: every + other part is `flex: none`. Otherwise a long list (hundreds of sources) + squeezes them in proportion to its size, and the type filter collapses. ### Network source indicators (Electron) -The switcher and source rows use `SourceHealthIndicatorComponent`: green means -available, orange expired, red disabled/access refused, and neutral means -checking or unverified. Accessible tooltip text distinguishes refusal from a -confirmed disabled account and includes the check time. M3U indicators describe -the source URL only. Check again is a separate action, not a playlist refresh. +The status dot on a source tile reads `SourceStatusService`: green answers, +red does not, orange is an expired account, grey is not checked yet; local +files have no dot. On desktop the live `SourceHealthService` result wins; the +last conclusive result per source is kept across restarts +(`SourceHealthHistoryService`, no URL or credential stored, only a 32-bit +fingerprint of the connection), so a source that failed yesterday shows as +failing before it is checked again, with "Not responding · 3 days". An +edited address or account is a new connection: its kept result no longer +counts, and its history starts over. The dot's tooltip and accessible name give the +check's cause (`SourceStatus.reason`: access denied, account disabled, +timeout…, kept with the history) and check time; M3U checks say they +cover the playlist URL, not its channels. Retry and Check +again rerun the check; they are not a playlist refresh. The browser build +keeps the Xtream portal status only. ### Inactive-source cleanup dialog diff --git a/docs/architecture/m3u-playlist-module.md b/docs/architecture/m3u-playlist-module.md index fa0e4d6de..e0f38ecb6 100644 --- a/docs/architecture/m3u-playlist-module.md +++ b/docs/architecture/m3u-playlist-module.md @@ -1856,6 +1856,15 @@ shared data access. Electron checks Xtream account info, Stalker account/profile facts through the existing session, and the first 64 KiB of M3U URL responses. An M3U success describes the playlist URL, not every channel. Local files and text imports have no network indicator. PWA retains its existing Xtream path. +Lists read one status per source through `SourceStatusService`, which falls +back to the last conclusive result kept by `SourceHealthHistoryService` +(localStorage, by playlist ID: state, check time, the start of a failing +run, the check's cause and a 32-bit fingerprint of the checked connection; +no URL or credential). A result kept for an edited address or account is +ignored, and the next check starts the history over. Deleting a source or +resetting the library clears its entry. The Sources page keeps every +library source checked while it is open (`keepSourcesChecked`), so a status +filter never hides the sources it is waiting for. See the [UI guidelines](iptvnator-ui-guidelines.md#network-source-indicators-electron). Checks are demand-driven, never a startup readiness barrier: four at a time, two per origin, shared between surfaces. Known results remain visible during diff --git a/docs/architecture/zoneless-migration.md b/docs/architecture/zoneless-migration.md index a5eddb7c6..5fede9146 100644 --- a/docs/architecture/zoneless-migration.md +++ b/docs/architecture/zoneless-migration.md @@ -61,7 +61,7 @@ must be ticked here. ## Eager components 65 production files, 66 components (`epg-progress-panel.component.ts` holds -two). Tick an entry by deleting `changeDetection: ChangeDetectionStrategy.Eager` +two); two have since been deleted (the old Sources row and its health dot). Tick an entry by deleting `changeDetection: ChangeDetectionStrategy.Eager` (or setting OnPush) once its template state is signals, signal inputs or explicitly marked. The guard spec compares the unticked entries with the files whose component metadata still sets @@ -127,7 +127,7 @@ picker), so each marks itself on the form's `events` through - [x] `libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.ts` (idle audit root) - [x] `libs/workspace/shell/feature/src/lib/workspace-sources/workspace-sources.component.ts` -### libs/playlist (14) +### libs/playlist (12) - [x] `libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.ts` - [x] `libs/playlist/import/feature/src/lib/auto-import/auto-import.component.ts` @@ -140,9 +140,7 @@ picker), so each marks itself on the form's `events` through - [x] `libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts` - [x] `libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.component.ts` - [x] `libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts` -- [x] `libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.ts` - [x] `libs/playlist/shared/ui/src/lib/source-health/source-cleanup-dialog.component.ts` -- [x] `libs/playlist/shared/ui/src/lib/source-health/source-health-indicator.component.ts` `libs/playlist/m3u/feature-player` (2) goes with the playback PR. @@ -173,7 +171,7 @@ the field a signal (or a `computed`), or writes it through one. | --- | --- | --- | --- | | [x] | `libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts` `onChannelNumberInput`/`clearChannelNumberInput` | 2 s `window.setTimeout` hides the channel-number overlay through plain `showChannelNumberOverlay`/`channelNumberInput` | playback | | [x] | same file, `applySettings` and the settings `effect()` | IndexedDB `storage.get(...).subscribe` and an effect assign plain `playerSettings`, which picks the player in the template | playback | -| [x] | `libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.ts` `checkPortalStatus` | plain `portalStatus` assigned after `await` in `ngOnInit` (PWA only: skipped when source health is supported) | playlist | +| [x] | `libs/portal/shared/data-access/src/lib/source-status.service.ts` `checkPortal` (replaced the old Sources row's `checkPortalStatus`) | the PWA Xtream portal status lands after `await`; it is written to a signal | playlist | | [x] | `libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts` (EPG clear and EPG file pick handlers) | plain `playlist` reassigned after `await` | playlist | | [x] | `libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts` (device-id derivation) | `form.patchValue` after `await`; no fix needed: while derivation is on `hasManualDeviceIds` is false whatever the IDs are, the controls write their own DOM values, and typing goes through value-accessor listeners that mark the view | playlist | | [x] | `libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts` (favorites load) | `favorites` Map filled in a `subscribe` without `markForCheck`; the component is OnPush already, so this is a latent bug today | portal | diff --git a/libs/m3u-state/src/lib/actions.ts b/libs/m3u-state/src/lib/actions.ts index 53de17f8c..6f8ab9a94 100644 --- a/libs/m3u-state/src/lib/actions.ts +++ b/libs/m3u-state/src/lib/actions.ts @@ -93,10 +93,3 @@ export const FavoritesActions = createActionGroup({ 'Hydrate Favorites': props<{ channelIds: string[] }>(), }, }); - -export const FilterActions = createActionGroup({ - source: 'Filters', - events: { - 'Set Selected Filters': props<{ selectedFilters: string[] }>(), - }, -}); diff --git a/libs/m3u-state/src/lib/playlists.state.ts b/libs/m3u-state/src/lib/playlists.state.ts index 4a46a04ad..671f608ec 100644 --- a/libs/m3u-state/src/lib/playlists.state.ts +++ b/libs/m3u-state/src/lib/playlists.state.ts @@ -10,7 +10,6 @@ export interface PlaylistMetaState extends EntityState { selectedId: string; allPlaylistsLoaded: boolean; loadFailed: boolean; - selectedFilters: string[]; } export const initialPlaylistMetaState: PlaylistMetaState = @@ -18,7 +17,6 @@ export const initialPlaylistMetaState: PlaylistMetaState = selectedId: '', allPlaylistsLoaded: false, loadFailed: false, - selectedFilters: ['m3u', 'xtream', 'stalker'], }); export const { diff --git a/libs/m3u-state/src/lib/reducers/filter.reducers.ts b/libs/m3u-state/src/lib/reducers/filter.reducers.ts deleted file mode 100644 index a4533992c..000000000 --- a/libs/m3u-state/src/lib/reducers/filter.reducers.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { on } from '@ngrx/store'; -import { FilterActions } from '../actions'; -import { PlaylistState } from '../state'; - -export const filterReducers = [ - on( - FilterActions.setSelectedFilters, - (state, { selectedFilters }): PlaylistState => { - return { - ...state, - playlists: { - ...state.playlists, - selectedFilters, - }, - }; - } - ), -]; diff --git a/libs/m3u-state/src/lib/reducers/index.ts b/libs/m3u-state/src/lib/reducers/index.ts index cac5d90ac..d83d2a87c 100644 --- a/libs/m3u-state/src/lib/reducers/index.ts +++ b/libs/m3u-state/src/lib/reducers/index.ts @@ -3,7 +3,6 @@ import { initialState, PlaylistState } from '../state'; import { channelReducers } from './channel.reducers'; import { epgReducers } from './epg.reducers'; import { favoritesReducers } from './favorites.reducers'; -import { filterReducers } from './filter.reducers'; import { playlistReducers } from './playlist.reducers'; export const playlistReducer = createReducer( @@ -11,8 +10,7 @@ export const playlistReducer = createReducer( ...epgReducers, ...channelReducers, ...playlistReducers, - ...favoritesReducers, - ...filterReducers + ...favoritesReducers ); // Selectors diff --git a/libs/m3u-state/src/lib/selectors.ts b/libs/m3u-state/src/lib/selectors.ts index be8bdaec9..ba4e3c514 100644 --- a/libs/m3u-state/src/lib/selectors.ts +++ b/libs/m3u-state/src/lib/selectors.ts @@ -63,11 +63,6 @@ export const selectAllPlaylistsMeta = createSelector( fromPlaylistMetaState.getAllPlaylistsMeta ); -export const selectActiveTypeFilters = createSelector( - selectPlaylistsMetaState, - (state) => state.selectedFilters -); - export const selectPlaylistEntity = (id: string) => createSelector(selectPlaylistEntities, (entities) => entities[id]); diff --git a/libs/playlist/shared/ui/src/index.ts b/libs/playlist/shared/ui/src/index.ts index 16a7edff9..8b0e703a5 100644 --- a/libs/playlist/shared/ui/src/index.ts +++ b/libs/playlist/shared/ui/src/index.ts @@ -1,5 +1,6 @@ export * from './lib/add-playlist-menu/playlist-type'; export * from './lib/playlist-switcher/playlist-switcher.component'; +export * from './lib/playlist-switcher/playlist-switcher-open.service'; export * from './lib/recent-playlists/playlist-info/playlist-info.component'; export * from './lib/recent-playlists/playlist-info/stalker-playlist-connection-editor.token'; export * from './lib/recent-playlists/recent-playlists.component'; @@ -8,3 +9,6 @@ export * from './lib/playlist-refresh-action.service'; export * from './lib/xtream-refresh-flow.service'; export * from './lib/source-health/source-cleanup-dialog.component'; +export * from './lib/source-row/source-row.component'; +export * from './lib/source-row/source-tile.component'; +export * from './lib/source-removal/source-removal.service'; diff --git a/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.spec.ts b/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.spec.ts index 81f84ec5f..1a53c3e28 100644 --- a/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.spec.ts +++ b/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.spec.ts @@ -312,8 +312,15 @@ describe('PlaylistRefreshActionService', () => { ).toBe(true); }); - it('uses the browser URL refresh path when the refresh bridge is unavailable', () => { + it('uses the browser URL refresh path when the refresh bridge is unavailable', async () => { runtime.supportsPlaylistRefresh = false; + let answer!: () => void; + dataService.sendIpcEvent.mockReturnValueOnce( + new Promise((resolve) => { + answer = resolve; + }) + ); + const activity = TestBed.inject(SourceActivityService); const playlist = createPlaylistMeta({ _id: 'playlist-url', title: 'URL playlist', @@ -333,6 +340,15 @@ describe('PlaylistRefreshActionService', () => { userAgent: 'IPTVnator-Test/1.0', }); expect(playlistRefreshService.refreshPlaylist).not.toHaveBeenCalled(); + // Busy, and held for removal and other refreshes, until it answers. + expect(service.isSourceBusy('playlist-url')).toBe(true); + expect(activity.isBusy('playlist-url')).toBe(true); + + answer(); + await Promise.resolve(); + await Promise.resolve(); + expect(service.isSourceBusy('playlist-url')).toBe(false); + expect(activity.isBusy('playlist-url')).toBe(false); }); it('tracks a header M3U refresh by source until the operation settles', async () => { @@ -349,13 +365,27 @@ describe('PlaylistRefreshActionService', () => { resolve = r; }) ); + const activity = TestBed.inject(SourceActivityService); service.refresh(playlist); expect(service.isSourceBusy('busy')).toBe(true); expect(service.isSourceBusy('other')).toBe(false); + // One refresh at a time: no other source is offered meanwhile. + const other = createPlaylistMeta({ + _id: 'other', + serverUrl: undefined, + username: undefined, + password: undefined, + url: 'https://source.test/other', + }); + expect(service.canRefresh(other)).toBe(false); + // The Sources page and the removal guard see it too. + expect(activity.isBusy('busy')).toBe(true); resolve({ _id: 'busy', playlist: { items: [] } } as Playlist); await Promise.resolve(); await Promise.resolve(); expect(service.isSourceBusy('busy')).toBe(false); + expect(activity.isBusy('busy')).toBe(false); + expect(service.canRefresh(other)).toBe(true); }); it('passes trusted TLS hosts to URL-backed M3U refreshes', async () => { diff --git a/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.ts b/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.ts index 26e298bb9..3e47735fa 100644 --- a/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.ts +++ b/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.ts @@ -64,7 +64,13 @@ export class PlaylistRefreshActionService { readonly refreshPreparation = this.refreshPreparationState.asReadonly(); canRefresh(playlist: PlaylistMeta | null): boolean { - if (!playlist || this.activity.isBusy(playlist._id)) { + // The same guards as refresh(): one refresh runs at a time, so an + // offer made meanwhile would be a silent no-op. + if ( + !playlist || + this.isRefreshing() || + this.activity.isBusy(playlist._id) + ) { return false; } @@ -94,14 +100,32 @@ export class PlaylistRefreshActionService { ) { void this.refreshM3u(playlist); } else if (playlist.url) { - this.dataService.sendIpcEvent(PLAYLIST_UPDATE, { - id: playlist._id, - title: playlist.title, - url: playlist.url, - ...(playlist.userAgent - ? { userAgent: playlist.userAgent } - : {}), + void this.refreshViaBackend(playlist, playlist.url); + } + } + + /** + * The browser build refreshes through the backend, which reports its own + * success or failure; the source stays busy until it has answered. + */ + private async refreshViaBackend( + item: PlaylistMeta, + url: string + ): Promise { + this.m3uRefreshId.set(item._id); + this.isRefreshing.set(true); + const release = this.activity.begin([item._id]); + try { + await this.dataService.sendIpcEvent(PLAYLIST_UPDATE, { + id: item._id, + title: item.title, + url, + ...(item.userAgent ? { userAgent: item.userAgent } : {}), }); + } finally { + this.m3uRefreshId.set(null); + this.isRefreshing.set(false); + release(); } } @@ -148,6 +172,8 @@ export class PlaylistRefreshActionService { this.m3uRefreshId.set(item._id); this.isRefreshing.set(true); + // Seen by the Sources page and the removal guard as well. + const release = this.activity.begin([item._id]); if (isActiveM3uRoute) { this.store.dispatch( ChannelActions.setChannelsLoading({ loading: true }) @@ -214,6 +240,7 @@ export class PlaylistRefreshActionService { } this.m3uRefreshId.set(null); this.isRefreshing.set(false); + release(); } } diff --git a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher-list.spec.ts b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher-list.spec.ts new file mode 100644 index 000000000..4994a7fdb --- /dev/null +++ b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher-list.spec.ts @@ -0,0 +1,108 @@ +import { PlaylistMeta } from '@iptvnator/shared/interfaces'; +import { + buildSwitcherGroups, + moveHighlight, + readSwitcherType, + SWITCHER_TYPE_STORAGE_KEY, + writeSwitcherType, +} from './playlist-switcher-list'; + +const source = (id: string, fields: Partial = {}) => + ({ + _id: id, + title: id, + count: 0, + importDate: '', + autoRefresh: false, + ...fields, + }) as PlaylistMeta; + +const many = [ + source('a', { url: 'http://a.example/list.m3u' }), + source('b', { + serverUrl: 'http://b.example', + username: 'u', + password: 'p', + }), + source('c', { macAddress: 'm', portalUrl: 'http://c.example/c/' }), + source('d'), + source('e'), + source('f'), +]; + +describe('buildSwitcherGroups', () => { + it('lists recent sources first, newest first, and all of them below', () => { + const groups = buildSwitcherGroups(many, ['c', 'gone', 'a'], 'all', ''); + expect(groups.recent.map((o) => o.playlist._id)).toEqual(['c', 'a']); + expect(groups.all).toHaveLength(6); + expect(groups.recent[0].id).not.toBe( + groups.all.find((o) => o.playlist._id === 'c')?.id + ); + }); + + it('hides Recent while searching and for a short list', () => { + expect(buildSwitcherGroups(many, ['a'], 'all', 'b').recent).toEqual([]); + expect( + buildSwitcherGroups(many.slice(0, 3), ['a'], 'all', '').recent + ).toEqual([]); + // A long library filtered down to one type is a short list too. + expect(buildSwitcherGroups(many, ['b'], 'xtream', '').recent).toEqual( + [] + ); + }); + + it('filters by type and by name or host', () => { + expect( + buildSwitcherGroups(many, [], 'xtream', '').all.map( + (o) => o.playlist._id + ) + ).toEqual(['b']); + expect( + buildSwitcherGroups(many, [], 'all', 'c.example').all.map( + (o) => o.playlist._id + ) + ).toEqual(['c']); + }); +}); + +describe('moveHighlight', () => { + it('wraps at both ends and starts from either end', () => { + expect(moveHighlight(-1, 1, 3)).toBe(0); + expect(moveHighlight(-1, -1, 3)).toBe(2); + expect(moveHighlight(2, 1, 3)).toBe(0); + expect(moveHighlight(0, -1, 3)).toBe(2); + expect(moveHighlight(1, 1, 0)).toBe(-1); + }); +}); + +describe('switcher type memory', () => { + beforeEach(() => localStorage.clear()); + + it('remembers the chosen type', () => { + writeSwitcherType('stalker'); + expect(readSwitcherType()).toBe('stalker'); + }); + + it('migrates the old checkboxes to one type or All', () => { + localStorage.setItem( + 'playlist-switcher:type-filters', + JSON.stringify({ m3u: false, stalker: false, xtream: true }) + ); + localStorage.setItem('playlist-switcher:search-query', 'old query'); + expect(readSwitcherType()).toBe('xtream'); + expect(localStorage.getItem(SWITCHER_TYPE_STORAGE_KEY)).toBe('xtream'); + expect( + localStorage.getItem('playlist-switcher:type-filters') + ).toBeNull(); + expect( + localStorage.getItem('playlist-switcher:search-query') + ).toBeNull(); + + localStorage.clear(); + localStorage.setItem( + 'playlist-switcher:type-filters', + JSON.stringify({ m3u: true, stalker: true, xtream: false }) + ); + expect(readSwitcherType()).toBe('all'); + }); +}); diff --git a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher-list.ts b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher-list.ts new file mode 100644 index 000000000..000b6fbd1 --- /dev/null +++ b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher-list.ts @@ -0,0 +1,116 @@ +import { + matchesSourceQuery, + matchesSourceType, + SourcesTypeFilter, +} from '@iptvnator/playlist/shared/util'; +import { PlaylistMeta } from '@iptvnator/shared/interfaces'; + +export const SWITCHER_TYPE_STORAGE_KEY = 'playlist-switcher:type:v2'; +/** The three checkboxes the segmented type filter replaced. */ +const LEGACY_TYPE_STORAGE_KEY = 'playlist-switcher:type-filters'; +/** The search is no longer kept between openings. */ +const LEGACY_QUERY_STORAGE_KEY = 'playlist-switcher:search-query'; +const TYPES: readonly SourcesTypeFilter[] = ['all', 'm3u', 'xtream', 'stalker']; +/** Below this many sources the "Recent" group would repeat the whole list. */ +export const SWITCHER_RECENT_MIN_SOURCES = 6; + +/** + * The remembered type. The old checkbox set maps to the one type it left + * on, or to "All" when it left several (the only states it could save). + */ +export function readSwitcherType(): SourcesTypeFilter { + try { + const stored = localStorage.getItem(SWITCHER_TYPE_STORAGE_KEY); + if (TYPES.includes(stored as SourcesTypeFilter)) { + return stored as SourcesTypeFilter; + } + const legacy = JSON.parse( + localStorage.getItem(LEGACY_TYPE_STORAGE_KEY) ?? 'null' + ) as Record | null; + localStorage.removeItem(LEGACY_QUERY_STORAGE_KEY); + if (legacy && typeof legacy === 'object') { + const on = (['m3u', 'xtream', 'stalker'] as const).filter( + (type) => legacy[type] !== false + ); + localStorage.removeItem(LEGACY_TYPE_STORAGE_KEY); + const type = on.length === 1 ? on[0] : 'all'; + writeSwitcherType(type); + return type; + } + } catch { + // Fall through to the default. + } + return 'all'; +} + +export function writeSwitcherType(type: SourcesTypeFilter): void { + try { + localStorage.setItem(SWITCHER_TYPE_STORAGE_KEY, type); + } catch { + // The choice still applies while the app runs. + } +} + +export interface SwitcherOption { + /** Unique per group: a recent source is also listed under "All". */ + id: string; + group: 'recent' | 'all'; + playlist: PlaylistMeta; +} + +export interface SwitcherGroups { + recent: SwitcherOption[]; + all: SwitcherOption[]; +} + +/** + * The switcher's two groups: "Recent" (newest first, only without a search + * and when enough sources are shown to be worth it) and "All" in the given + * order, both narrowed by type and search. + */ +export function buildSwitcherGroups( + ordered: readonly PlaylistMeta[], + recentIds: readonly string[], + type: SourcesTypeFilter, + query: string +): SwitcherGroups { + const visible = ordered.filter( + (playlist) => + matchesSourceType(playlist, type) && + matchesSourceQuery(playlist, query) + ); + const option = + (group: SwitcherOption['group']) => + (playlist: PlaylistMeta): SwitcherOption => ({ + id: `playlist-switcher-${group}-${playlist._id}`, + group, + playlist, + }); + // Counted after the type filter: a short filtered list would show the + // same sources twice, and the keyboard would visit each twice. + const showRecent = + !query.trim() && visible.length >= SWITCHER_RECENT_MIN_SOURCES; + const byId = new Map(visible.map((playlist) => [playlist._id, playlist])); + const recent = showRecent + ? recentIds + .map((id) => byId.get(id)) + .filter((playlist): playlist is PlaylistMeta => !!playlist) + .map(option('recent')) + : []; + return { recent, all: visible.map(option('all')) }; +} + +/** The next highlight for ↑/↓, wrapping at both ends. */ +export function moveHighlight( + current: number, + delta: 1 | -1, + count: number +): number { + if (count === 0) { + return -1; + } + if (current < 0) { + return delta > 0 ? 0 : count - 1; + } + return (current + delta + count) % count; +} diff --git a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher-open.service.ts b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher-open.service.ts new file mode 100644 index 000000000..7b067c6f6 --- /dev/null +++ b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher-open.service.ts @@ -0,0 +1,31 @@ +import { Injectable } from '@angular/core'; +import { Subject } from 'rxjs'; + +/** + * Opens the toolbar playlist switcher from outside it: the ⌘P / Ctrl+P + * shortcut. The switcher subscribes while it is on screen (it is hidden on + * Settings), so a request with no switcher does nothing. + */ +@Injectable({ providedIn: 'root' }) +export class PlaylistSwitcherOpenService { + private readonly requests = new Subject(); + readonly openRequests = this.requests.asObservable(); + private listeners = 0; + + /** Whether a switcher is on screen to take the request. */ + get available(): boolean { + return this.listeners > 0; + } + + request(): void { + this.requests.next(); + } + + /** Called by the switcher; the returned function unregisters it. */ + register(): () => void { + this.listeners += 1; + return () => { + this.listeners -= 1; + }; + } +} diff --git a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher-panel.component.html b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher-panel.component.html new file mode 100644 index 000000000..758f1caed --- /dev/null +++ b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher-panel.component.html @@ -0,0 +1,170 @@ + + + + + diff --git a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher-panel.component.scss b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher-panel.component.scss new file mode 100644 index 000000000..5cb9e1482 --- /dev/null +++ b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher-panel.component.scss @@ -0,0 +1,202 @@ +@use '../../../../../../ui/styles/app-controls' as controls; + +// 440px popover on the widget surface; only the list scrolls. +:host { + display: block; + width: min(440px, calc(100vw - 16px)); +} + +.switcher { + display: flex; + flex-direction: column; + max-height: var(--playlist-switcher-max-height, calc(100vh - 88px)); + overflow: hidden; + border: 1px solid var(--app-line-strong); + border-radius: 14px; + background: var(--app-widget-bg); + box-shadow: var(--app-popover-shadow); + color: var(--app-heading-color); +} + +// Only the list shrinks and scrolls. A column flex container shrinks its +// items in proportion to their size, so with a long list (hundreds of +// sources) the fixed parts lost nearly all their height too: the type +// filter, which clips its overflow, collapsed to 2px and the search field +// to its text line. +.switcher__header, +.switcher__search, +.switcher__types, +.switcher__empty, +.switcher__footer { + flex: none; +} + +.switcher__header { + display: flex; + align-items: center; + gap: 8px; + padding: 14px 14px 10px; +} + +.switcher__title { + margin: 0; + font-size: 15px; + font-weight: 600; + letter-spacing: -0.2px; +} + +.switcher__count { + font-size: 12px; + font-variant-numeric: tabular-nums; + color: var(--app-muted-color); +} + +.switcher__spacer { + flex: 1; +} + +.switcher__action { + @include controls.ghost-icon-button(32px, 18px); +} + +// The same search field as the header and the side panels: 36px, the +// shared search tokens, focus on the field (token border and halo) rather +// than as an outline on the input. +.switcher__search { + display: flex; + align-items: center; + gap: 8px; + height: 36px; + margin: 0 14px 10px; + padding: 0 10px; + box-sizing: border-box; + border: 1px solid var(--app-search-border, var(--app-line-strong)); + border-radius: 10px; + background: var(--app-search-bg, var(--app-content-bg)); + color: var(--app-muted-color); + transition: + border-color 0.15s ease, + box-shadow 0.15s ease; + + &:focus-within { + border-color: var(--app-focus-ring); + box-shadow: 0 0 0 3px + color-mix(in srgb, var(--app-focus-ring) 12%, transparent); + } + + .mat-icon { + flex: none; + width: 18px; + height: 18px; + font-size: 18px; + } + + input { + flex: 1; + min-width: 0; + border: 0; + outline: none; + background: none; + color: var(--app-heading-color); + font: inherit; + font-size: 0.84rem; + + &::placeholder { + color: var(--app-muted-color); + } + } + + kbd { + font-family: var(--app-font-mono); + font-size: 11px; + font-weight: 500; + } +} + +.switcher__types { + @include controls.segmented-control(30px); + align-self: flex-start; + margin: 0 14px 8px; +} + +.switcher__type-count { + margin-inline-start: 4px; + font-size: 11px; + color: var(--app-muted-color); +} + +.switcher__list { + // Positioned, so options' offsetTop is relative to the list it scrolls. + position: relative; + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + padding: 4px 8px 8px; + border-top: 1px solid var(--app-widget-header-border); + scrollbar-width: thin; + + app-source-row { + --source-row-base: var(--app-widget-bg); + } +} + +.switcher__group { + padding: 10px 8px 4px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.8px; + text-transform: uppercase; + color: var(--app-muted-color); +} + +.switcher__empty { + margin: 0; + padding: 20px 16px; + font-size: 13px; + color: var(--app-body-color); +} + +.switcher__footer { + display: flex; + align-items: center; + gap: 4px; + padding: 8px; + border-top: 1px solid var(--app-widget-header-border); + background: color-mix(in srgb, var(--app-shell-bg) 40%, transparent); +} + +.switcher__hints { + display: flex; + align-items: center; + gap: 6px; + padding: 0 6px; + font-family: var(--app-font-mono); + font-size: 11px; + font-weight: 400; + color: var(--app-muted-color); + + kbd { + margin-inline-start: 4px; + padding: 0 4px; + border: 1px solid var(--app-line-strong); + border-radius: 3px; + font: inherit; + font-weight: 500; + color: var(--app-body-color); + } + + kbd:first-child { + margin-inline-start: 0; + } +} + +.switcher__all { + @include controls.ghost-button(sm); +} + +// Phones: the hints need a keyboard; the link stays. +@media (max-width: 480px) { + .switcher__hints { + display: none; + } +} diff --git a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher-panel.component.spec.ts b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher-panel.component.spec.ts new file mode 100644 index 000000000..17f33aade --- /dev/null +++ b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher-panel.component.spec.ts @@ -0,0 +1,407 @@ +import { signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MatDialog } from '@angular/material/dialog'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { Router } from '@angular/router'; +import { TranslateModule } from '@ngx-translate/core'; +import { BehaviorSubject } from 'rxjs'; +import { SourceStatusService } from '@iptvnator/portal/shared/data-access'; +import { + PlaylistContextFacade, + PlaylistRecentHistoryService, +} from '@iptvnator/playlist/shared/util'; +import { + SortBy, + SortOptions, + SortOrder, + SortService, + SourceActivityService, +} from '@iptvnator/services'; +import { PlaylistMeta } from '@iptvnator/shared/interfaces'; +import { PlaylistRefreshActionService } from '../playlist-refresh-action.service'; +import { SourceRemovalService } from '../source-removal/source-removal.service'; +import { XtreamRefreshFlowService } from '../xtream-refresh-flow.service'; +import { PlaylistSwitcherPanelComponent } from './playlist-switcher-panel.component'; + +const source = (id: string, fields: Partial = {}) => + ({ + _id: id, + title: id, + count: 0, + importDate: '2026-01-01T00:00:00.000Z', + autoRefresh: false, + ...fields, + }) as PlaylistMeta; + +const ALPHA = source('alpha', { url: 'https://alpha.example/list.m3u' }); +const BRAVO = source('bravo', { + serverUrl: 'http://bravo.example', + username: 'u', + password: 'secret-pass', +}); +const CHARLIE = source('charlie', { + macAddress: '00:1A:79:00:00:01', + portalUrl: 'http://charlie.example/c/', +}); + +describe('PlaylistSwitcherPanelComponent', () => { + let fixture: ComponentFixture; + let component: PlaylistSwitcherPanelComponent; + let playlists: ReturnType>; + let pending: ReturnType>>; + let deleting: ReturnType>>; + let selectPlaylist: jest.Mock; + let remove: jest.Mock; + let navigate: jest.Mock; + let xtreamRefreshing: ReturnType>>; + + const input = () => + (fixture.nativeElement as HTMLElement).querySelector( + '.switcher__search input' + ) as HTMLInputElement; + const press = (key: string, init: KeyboardEventInit = {}) => { + input().dispatchEvent( + new KeyboardEvent('keydown', { key, bubbles: true, ...init }) + ); + fixture.detectChanges(); + }; + const type = (value: string) => { + input().value = value; + input().dispatchEvent(new Event('input')); + fixture.detectChanges(); + }; + const optionIds = () => + Array.from( + (fixture.nativeElement as HTMLElement).querySelectorAll( + '[role="option"]' + ) + ).map((option) => option.id); + + beforeEach(() => { + localStorage.clear(); + playlists = signal([ALPHA, BRAVO, CHARLIE]); + pending = signal>(new Set()); + deleting = signal>(new Set()); + selectPlaylist = jest.fn(); + remove = jest.fn(); + navigate = jest.fn(); + xtreamRefreshing = signal>(new Set()); + TestBed.configureTestingModule({ + imports: [ + PlaylistSwitcherPanelComponent, + NoopAnimationsModule, + TranslateModule.forRoot(), + ], + providers: [ + { + provide: PlaylistContextFacade, + useValue: { + playlists, + resolvedPlaylistId: signal('bravo'), + selectPlaylist, + }, + }, + { + provide: PlaylistRecentHistoryService, + useValue: { ids: signal(['charlie']) }, + }, + { + provide: SortService, + useValue: { + getSortOptions: () => + new BehaviorSubject({ + by: SortBy.NAME, + order: SortOrder.ASC, + }), + sortPlaylists: new SortService().sortPlaylists, + }, + }, + { + provide: SourceStatusService, + useValue: { + statusOf: () => null, + tracks: () => false, + ensureChecked: jest.fn(), + recheck: jest.fn(), + }, + }, + { + provide: SourceRemovalService, + useValue: { + pendingIds: pending, + remove, + progress: { + isDeleting: (id: string) => deleting().has(id), + }, + canTakeOver: (id: string) => + !deleting().has(id) && + !TestBed.inject(SourceActivityService).isBusy(id), + }, + }, + { + provide: PlaylistRefreshActionService, + useValue: { + canRefresh: () => true, + refresh: jest.fn(), + isSourceBusy: () => false, + }, + }, + { + provide: XtreamRefreshFlowService, + useValue: { + isRunning: (id: string) => xtreamRefreshing().has(id), + }, + }, + { provide: MatDialog, useValue: { open: jest.fn() } }, + { provide: Router, useValue: { navigate } }, + ], + }); + fixture = TestBed.createComponent(PlaylistSwitcherPanelComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('lists every source in the Sources order as listbox options', () => { + expect(optionIds()).toEqual([ + 'playlist-switcher-all-alpha', + 'playlist-switcher-all-bravo', + 'playlist-switcher-all-charlie', + ]); + const current = (fixture.nativeElement as HTMLElement).querySelector( + '#playlist-switcher-all-bravo' + ); + expect(current?.getAttribute('aria-current')).toBe('true'); + expect( + (fixture.nativeElement as HTMLElement).textContent + ).not.toContain('secret-pass'); + }); + + it('shows Recent above All once the list is long enough', () => { + playlists.set([ + ALPHA, + BRAVO, + CHARLIE, + source('d'), + source('e'), + source('f'), + ]); + fixture.detectChanges(); + expect(optionIds()[0]).toBe('playlist-switcher-recent-charlie'); + expect(optionIds()).toHaveLength(7); + }); + + it('moves the highlight with the arrow keys, wrapping, and opens it on Enter', () => { + press('ArrowDown'); + expect(input().getAttribute('aria-activedescendant')).toBe( + 'playlist-switcher-all-alpha' + ); + press('ArrowUp'); + expect(input().getAttribute('aria-activedescendant')).toBe( + 'playlist-switcher-all-charlie' + ); + press('ArrowDown'); + expect(input().getAttribute('aria-activedescendant')).toBe( + 'playlist-switcher-all-alpha' + ); + + const closed = jest.fn(); + component.closeRequested.subscribe(closed); + press('Enter'); + expect(selectPlaylist).toHaveBeenCalledWith(ALPHA); + expect(closed).toHaveBeenCalled(); + }); + + it('scrolls a new search back to its first match at the top', () => { + const list = (fixture.nativeElement as HTMLElement).querySelector( + '.switcher__list' + ) as HTMLElement; + list.scrollTop = 120; + type('a'); + expect(list.scrollTop).toBe(0); + + list.scrollTop = 120; + component.setType('xtream'); + expect(list.scrollTop).toBe(0); + }); + + it('moves through options whose IDs a selector would misread', () => { + const restored = { ...ALPHA, _id: 'odd:id.one', title: 'Aardvark' }; + playlists.set([restored, BRAVO]); + fixture.detectChanges(); + expect(() => press('ArrowDown')).not.toThrow(); + expect(input().getAttribute('aria-activedescendant')).toBe( + 'playlist-switcher-all-odd:id.one' + ); + }); + + it('filters by name or host as you type and highlights the first match', () => { + type('charlie.example'); + expect(optionIds()).toEqual(['playlist-switcher-all-charlie']); + expect(input().getAttribute('aria-activedescendant')).toBe( + 'playlist-switcher-all-charlie' + ); + + type('nothing like it'); + expect(optionIds()).toEqual([]); + expect( + (fixture.nativeElement as HTMLElement).querySelector( + '.switcher__empty' + )?.textContent + ).toContain('WORKSPACE.SWITCHER.NO_MATCHES'); + }); + + it('falls back to All when the remembered type has no sources left', () => { + component.setType('xtream'); + fixture.detectChanges(); + expect(optionIds()).toEqual(['playlist-switcher-all-bravo']); + + // The last Xtream source is removed; two other types remain. + playlists.set([ALPHA, CHARLIE]); + fixture.detectChanges(); + expect(component.activeType()).toBe('all'); + expect(optionIds()).toEqual([ + 'playlist-switcher-all-alpha', + 'playlist-switcher-all-charlie', + ]); + // The choice is kept for when an Xtream source returns. + expect(localStorage.getItem('playlist-switcher:type:v2')).toBe( + 'xtream' + ); + }); + + it('filters by type with one segment and remembers it', () => { + component.setType('xtream'); + fixture.detectChanges(); + expect(optionIds()).toEqual(['playlist-switcher-all-bravo']); + expect(localStorage.getItem('playlist-switcher:type:v2')).toBe( + 'xtream' + ); + }); + + it('closes on Escape', () => { + const closed = jest.fn(); + component.closeRequested.subscribe(closed); + press('Escape'); + expect(closed).toHaveBeenCalled(); + }); + + it('closes before a delete, so Undo can take the focus and give it back to the trigger', () => { + const trigger = document.createElement('button'); + document.body.appendChild(trigger); + // The host closes the popover and focuses its trigger. + component.closeRequested.subscribe(() => trigger.focus()); + + component.remove(BRAVO); + expect(remove).toHaveBeenCalledWith(BRAVO, CHARLIE, { + returnFocusTo: trigger, + }); + trigger.remove(); + }); + + it('offers no Remove while a refresh clears and refills the source', async () => { + // Held by a refresh from any entry point, the Sources page included. + const release = TestBed.inject(SourceActivityService).begin(['bravo']); + fixture.detectChanges(); + const menuRemove = async (id: string) => { + (fixture.nativeElement as HTMLElement) + .querySelector( + `#playlist-switcher-all-${id} .source-row__more` + ) + ?.click(); + fixture.detectChanges(); + await fixture.whenStable(); + const items = Array.from( + document.querySelectorAll( + '.cdk-overlay-container [mat-menu-item]' + ) + ); + const item = items[items.length - 1]; + document.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Escape' }) + ); + return item; + }; + expect((await menuRemove('bravo')).disabled).toBe(true); + expect((await menuRemove('alpha')).disabled).toBe(false); + // Still a source you can switch to. + (fixture.nativeElement as HTMLElement) + .querySelector( + '#playlist-switcher-all-bravo .source-row' + ) + ?.click(); + expect(selectPlaylist).toHaveBeenCalled(); + release(); + }); + + it('does not open a source while its refresh clears the catalog', () => { + xtreamRefreshing.set(new Set(['alpha'])); + fixture.detectChanges(); + const element = fixture.nativeElement as HTMLElement; + const alpha = element.querySelector('#playlist-switcher-all-alpha'); + expect(alpha?.getAttribute('aria-disabled')).toBe('true'); + + alpha?.querySelector('.source-row')?.click(); + // Enter on the highlighted option goes through the same method. + component.select(ALPHA); + expect(selectPlaylist).not.toHaveBeenCalled(); + + xtreamRefreshing.set(new Set()); + fixture.detectChanges(); + component.select(ALPHA); + expect(selectPlaylist).toHaveBeenCalledWith(ALPHA); + }); + + it('hands over past a source a refresh holds when removing the current one', () => { + // Bravo is current; Charlie would follow it but is refreshing. + const release = TestBed.inject(SourceActivityService).begin([ + 'charlie', + ]); + component.remove(BRAVO); + expect(remove).toHaveBeenCalledWith(BRAVO, ALPHA, expect.anything()); + release(); + }); + + it('leaves out a source whose delete is pending or running', () => { + pending.set(new Set(['bravo'])); + fixture.detectChanges(); + expect(optionIds()).not.toContain('playlist-switcher-all-bravo'); + + pending.set(new Set()); + deleting.set(new Set(['alpha'])); + fixture.detectChanges(); + expect(optionIds()).toEqual([ + 'playlist-switcher-all-bravo', + 'playlist-switcher-all-charlie', + ]); + }); + + it('keeps the highlight on its option, or in range, when the list changes', () => { + press('ArrowUp'); + expect(input().getAttribute('aria-activedescendant')).toBe( + 'playlist-switcher-all-charlie' + ); + // An earlier option leaves: the highlight follows charlie. + playlists.set([BRAVO, CHARLIE]); + fixture.detectChanges(); + expect(input().getAttribute('aria-activedescendant')).toBe( + 'playlist-switcher-all-charlie' + ); + // The highlighted last option leaves: Enter opens the new last one. + playlists.set([BRAVO]); + fixture.detectChanges(); + expect(input().getAttribute('aria-activedescendant')).toBe( + 'playlist-switcher-all-bravo' + ); + press('Enter'); + expect(selectPlaylist).toHaveBeenCalledWith(BRAVO); + }); + + it('goes to the Sources page from the footer', () => { + ( + (fixture.nativeElement as HTMLElement).querySelector( + '.switcher__all' + ) as HTMLButtonElement + ).click(); + expect(navigate).toHaveBeenCalledWith(['/workspace', 'sources']); + }); +}); diff --git a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher-panel.component.ts b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher-panel.component.ts new file mode 100644 index 000000000..9f31f197c --- /dev/null +++ b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher-panel.component.ts @@ -0,0 +1,407 @@ +import { A11yModule } from '@angular/cdk/a11y'; +import { DOCUMENT, NgTemplateOutlet } from '@angular/common'; +import { + afterNextRender, + ChangeDetectionStrategy, + Component, + computed, + ElementRef, + inject, + linkedSignal, + output, + signal, + viewChild, + viewChildren, +} from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { MatButton, MatIconButton } from '@angular/material/button'; +import { MatButtonToggleModule } from '@angular/material/button-toggle'; +import { MatDialog } from '@angular/material/dialog'; +import { MatIcon } from '@angular/material/icon'; +import { MatTooltip } from '@angular/material/tooltip'; +import { Router } from '@angular/router'; +import { TranslatePipe } from '@ngx-translate/core'; +import { + SourceStatusService, + sourceStatusBucket, +} from '@iptvnator/portal/shared/data-access'; +import { + countSourcesByType, + orderFailingFirst, + PlaylistContextFacade, + PlaylistRecentHistoryService, + replacementAfterRemoval, + SourceBucketOf, + sourceSortName, + SourcesTypeFilter, +} from '@iptvnator/playlist/shared/util'; +import { + SortBy, + SortService, + SourceActivityService, +} from '@iptvnator/services'; +import { PlaylistMeta, PlaylistSourceKind } from '@iptvnator/shared/interfaces'; +import { PlaylistRefreshActionService } from '../playlist-refresh-action.service'; +import { PlaylistInfoComponent } from '../recent-playlists/playlist-info/playlist-info.component'; +import { SourceRemovalService } from '../source-removal/source-removal.service'; +import { SourceRowComponent } from '../source-row/source-row.component'; +import { XtreamRefreshFlowService } from '../xtream-refresh-flow.service'; +import { + buildSwitcherGroups, + moveHighlight, + readSwitcherType, + SwitcherOption, + writeSwitcherType, +} from './playlist-switcher-list'; + +const KINDS: readonly PlaylistSourceKind[] = ['m3u', 'xtream', 'stalker']; + +/** + * The switcher popover: search, a type segment, "Recent" and "All" groups + * of compact source rows, and a footer. Focus stays in the search field; + * ↑/↓ move the highlight (`aria-activedescendant`), Enter opens it, + * Shift+F10 opens its menu and Escape closes the popover. + */ +@Component({ + selector: 'app-playlist-switcher-panel', + imports: [ + A11yModule, + MatButton, + MatButtonToggleModule, + MatIcon, + MatIconButton, + MatTooltip, + NgTemplateOutlet, + SourceRowComponent, + TranslatePipe, + ], + templateUrl: './playlist-switcher-panel.component.html', + styleUrl: './playlist-switcher-panel.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class PlaylistSwitcherPanelComponent { + private readonly activity = inject(SourceActivityService); + private readonly context = inject(PlaylistContextFacade); + private readonly document = inject(DOCUMENT); + private readonly dialog = inject(MatDialog); + private readonly recentHistory = inject(PlaylistRecentHistoryService); + private readonly refreshAction = inject(PlaylistRefreshActionService); + private readonly removal = inject(SourceRemovalService); + private readonly router = inject(Router); + private readonly sortService = inject(SortService); + private readonly statusService = inject(SourceStatusService); + private readonly xtreamRefreshFlow = inject(XtreamRefreshFlowService); + private readonly list = viewChild.required>('list'); + private readonly searchInput = + viewChild.required>('searchInput'); + private readonly rows = viewChildren(SourceRowComponent); + private readonly rowElements = viewChildren(SourceRowComponent, { + read: ElementRef, + }); + + readonly closeRequested = output(); + private closing = false; + readonly playlistSelected = output(); + readonly addPlaylistRequested = output(); + readonly accountInfoRequested = output(); + + readonly listId = 'playlist-switcher-listbox'; + readonly kinds = KINDS; + readonly kindLabels = { m3u: 'M3U', xtream: 'Xtream', stalker: 'Stalker' }; + readonly currentId = this.context.resolvedPlaylistId; + readonly query = signal(''); + readonly type = signal(readSwitcherType()); + + private readonly sortOptions = toSignal(this.sortService.getSortOptions(), { + requireSync: true, + }); + private readonly bucketOf: SourceBucketOf = (playlist) => { + const status = this.statusService.statusOf(playlist); + return status ? sourceStatusBucket(status.reachability) : null; + }; + + /** + * Every source except one whose delete is pending or running: a running + * delete shows its progress and Cancel on the Sources page, and its + * source cannot be opened or removed again from here. + */ + readonly playlists = computed(() => { + const pending = this.removal.pendingIds(); + return this.context + .playlists() + .filter( + (playlist) => + !pending.has(playlist._id) && + !this.removal.progress.isDeleting(playlist._id) + ); + }); + readonly typeCounts = computed(() => + countSourcesByType(this.playlists(), 'all', this.bucketOf) + ); + readonly showTypeFilter = computed( + () => KINDS.filter((kind) => this.typeCounts()[kind] > 0).length > 1 + ); + /** + * The type the list filters by: the remembered one while it has sources + * and its segment shows, else All. Removing the last source of the + * remembered type would otherwise leave an empty list with no segment + * selected. The remembered choice itself is kept for when it returns. + */ + readonly activeType = computed(() => { + const type = this.type(); + return this.showTypeFilter() && + (type === 'all' || this.typeCounts()[type] > 0) + ? type + : 'all'; + }); + /** The Sources page order, so both lists agree. */ + private readonly ordered = computed(() => { + const options = this.sortOptions(); + const sorted = this.sortService.sortPlaylists( + this.playlists(), + options, + sourceSortName + ); + return options.failingFirst && options.by !== SortBy.CUSTOM + ? orderFailingFirst(sorted, this.bucketOf) + : sorted; + }); + readonly groups = computed(() => + buildSwitcherGroups( + this.ordered(), + this.recentHistory.ids(), + this.activeType(), + this.query() + ) + ); + readonly options = computed(() => [ + ...this.groups().recent, + ...this.groups().all, + ]); + /** + * A search highlights its first match, so Enter opens it. When the list + * changes under the same search (a source added or removed), the + * highlight stays on its option, or on the same place, clamped. + */ + readonly highlight = linkedSignal< + { + query: string; + type: SourcesTypeFilter; + ids: readonly string[]; + }, + number + >({ + source: () => ({ + query: this.query(), + type: this.activeType(), + ids: this.options().map((option) => option.id), + }), + computation: (source, previous) => { + const sameSearch = + previous && + previous.source.query === source.query && + previous.source.type === source.type; + if (!sameSearch) { + return source.query.trim() ? 0 : -1; + } + const id = previous.source.ids[previous.value]; + const kept = id === undefined ? -1 : source.ids.indexOf(id); + return kept !== -1 + ? kept + : Math.min(previous.value, source.ids.length - 1); + }, + }); + readonly activeOptionId = computed( + () => this.options()[this.highlight()]?.id ?? null + ); + + constructor() { + // Typing goes straight to the search. Not the focus trap's + // auto-capture: on close it would move focus back to wherever it was + // before ⌘P, and closing returns it to the trigger instead. + afterNextRender(() => this.searchInput().nativeElement.focus()); + } + + setQuery(value: string): void { + this.query.set(value); + this.scrollToTop(); + } + + setType(type: SourcesTypeFilter): void { + this.type.set(type); + writeSwitcherType(type); + this.scrollToTop(); + } + + /** + * A new search or type highlights the first match, at the top of the + * list: a list scrolled down for the previous one would hide it. + */ + private scrollToTop(): void { + this.list().nativeElement.scrollTop = 0; + } + + onSearchKeydown(event: KeyboardEvent): void { + const count = this.options().length; + switch (event.key) { + case 'ArrowDown': + case 'ArrowUp': + event.preventDefault(); + this.highlight.set( + moveHighlight( + this.highlight(), + event.key === 'ArrowDown' ? 1 : -1, + count + ) + ); + this.scrollHighlightIntoView(); + break; + case 'Enter': { + const option = this.options()[this.highlight()]; + if (option) { + event.preventDefault(); + this.select(option.playlist); + } + break; + } + case 'Escape': + event.preventDefault(); + event.stopPropagation(); + this.close(); + break; + case 'ContextMenu': + case 'F10': + if (event.key === 'ContextMenu' || event.shiftKey) { + event.preventDefault(); + this.openHighlightedMenu(); + } + break; + } + } + + select(playlist: PlaylistMeta): void { + // Pointer and Enter alike: opening it would race the refresh. + if (this.isRefreshing(playlist)) { + return; + } + this.context.selectPlaylist(playlist); + this.playlistSelected.emit(playlist._id); + this.close(); + } + + canRefresh(playlist: PlaylistMeta): boolean { + return this.refreshAction.canRefresh(playlist); + } + + /** + * A refresh is clearing and refilling the source: it cannot be opened + * until the refresh has parked the user data its import restores. + */ + isRefreshing(playlist: PlaylistMeta): boolean { + return ( + this.refreshAction.isSourceBusy(playlist._id) || + this.xtreamRefreshFlow.isRunning(playlist._id) + ); + } + + /** A source cannot be removed while a refresh clears and refills it. */ + isBusy(playlist: PlaylistMeta): boolean { + return this.activity.isBusy(playlist._id); + } + + refresh(playlist: PlaylistMeta): void { + this.close(); + this.refreshAction.refresh(playlist); + } + + openInfo(playlist: PlaylistMeta): void { + this.close(); + this.dialog.open(PlaylistInfoComponent, { data: playlist }); + } + + openAccountInfo(playlist: PlaylistMeta): void { + this.close(); + this.accountInfoRequested.emit(playlist); + } + + /** + * Deletes with Undo; the active source hands over to its neighbour. The + * popover closes first: its focus trap would keep the keyboard away + * from Undo, which then takes the focus and returns it to the trigger. + */ + remove(playlist: PlaylistMeta): void { + const next = replacementAfterRemoval( + this.groups().all.map((option) => option.playlist), + playlist._id, + (row) => !this.removal.canTakeOver(row._id) + ); + this.close(); + // Closing put the focus back on the trigger. + const returnFocusTo = this.document.activeElement; + this.removal.remove(playlist, next, { + returnFocusTo: + returnFocusTo instanceof HTMLElement && + returnFocusTo !== this.document.body + ? returnFocusTo + : null, + }); + } + + /** A row menu gives the focus back to the search, unless closing. */ + onMenuClosed(): void { + if (!this.closing) { + this.searchInput().nativeElement.focus(); + } + } + + /** + * Every action that leaves the popover closes it through here, so a row + * menu closing after its action does not pull the focus back into it. + */ + private close(): void { + this.closing = true; + this.closeRequested.emit(); + } + + addPlaylist(): void { + this.close(); + this.addPlaylistRequested.emit(); + } + + openAllSources(): void { + this.close(); + void this.router.navigate(['/workspace', 'sources']); + } + + /** Shift+F10 / the menu key: the highlighted row's ⋯ menu. */ + private openHighlightedMenu(): void { + const id = this.activeOptionId(); + const index = this.rowElements().findIndex( + (element) => (element.nativeElement as HTMLElement).id === id + ); + this.rows()[index]?.openMenu(); + } + + /** Keeps the highlighted option in the list's view, not the page's. */ + private scrollHighlightIntoView(): void { + const id = this.activeOptionId(); + const list = this.list().nativeElement; + // By exact ID, not a selector: a restored playlist ID may hold "." + // or ":", which a selector would misread. + const option = id + ? this.rowElements() + .map((row) => row.nativeElement as HTMLElement) + .find((element) => element.id === id) + : undefined; + if (!option) { + return; + } + const top = option.offsetTop; + const bottom = top + option.offsetHeight; + if (top < list.scrollTop) { + list.scrollTop = top - 8; + } else if (bottom > list.scrollTop + list.clientHeight) { + list.scrollTop = bottom - list.clientHeight + 8; + } + } +} diff --git a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.html b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.html index d8ef6dbde..3a5f84a3b 100644 --- a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.html +++ b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.html @@ -1,293 +1,55 @@ -
-
- playlist_play -
-
-
{{ displayTitle() }}
-
{{ subtitle() }}
-
- @if (canRefreshActivePlaylist()) { - + @if (activePlaylist(); as active) { + + } @else { + } - - expand_more - -
+ + {{ displayTitle() }} + {{ triggerSubtitle() }} + + expand_more + - - - - @if (showSearchField()) { -
- search - - @if (searchQuery()) { - - } -
- } - - @if (showTypeFilters()) { -
- @if (hasType('m3u')) { - - } - @if (hasType('stalker')) { - - } - @if (hasType('xtream')) { - - } -
- } - - - - @if (showAddPlaylist() || showAccountInfo() || showPlaylistInfo()) { -
- @if (showPlaylistInfo()) { - - } - @if (showAccountInfo()) { - - } - @if (showAddPlaylist()) { - - } -
- } -
- - - - - @if (hasAccountInfo(playlist)) { - - } - @if (runtime.supportsSourceHealth && sourceHealthType(playlist)) { - - } - @if (canRefreshPlaylist(playlist)) { - - } - - - - + + diff --git a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.scss b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.scss index 0e17b7f06..69b5b3eae 100644 --- a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.scss +++ b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.scss @@ -5,648 +5,134 @@ width: 100%; } +// The trigger is a secondary button (UI kit): 40px, 10px radius, a 10% +// fill, the source's tile, two lines and a chevron that turns when open. .playlist-switcher-trigger { display: flex; align-items: center; - gap: 8px; + gap: 10px; width: 100%; + height: 40px; + padding: 0 10px 0 6px; box-sizing: border-box; - padding: 4px 8px 4px 4px; + border: 0; border-radius: 10px; + background: color-mix(in srgb, var(--app-on-surface) 10%, transparent); + color: var(--app-heading-color); + font: inherit; + text-align: start; cursor: pointer; - transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); - background: color-mix(in srgb, var(--app-on-surface) 5%, transparent); - border: 1px solid - color-mix(in srgb, var(--mat-sys-outline-variant) 36%, transparent); app-region: no-drag; + transition: background 0.12s ease; &:hover { - background: color-mix( - in srgb, - var(--app-on-surface) 12%, - transparent - ); - border-color: color-mix( - in srgb, - var(--mat-sys-outline-variant) 64%, - transparent - ); + background: color-mix(in srgb, var(--app-on-surface) 16%, transparent); } + // Open: one step up, with a 2px inset ring in the selection colour. &.is-open { - background: color-mix( - in srgb, - var(--app-on-surface) 12%, - transparent - ); - border-color: color-mix( - in srgb, - var(--mat-sys-outline-variant) 72%, - transparent - ); + background: color-mix(in srgb, var(--app-on-surface) 14%, transparent); + box-shadow: inset 0 0 0 2px var(--app-selection-color); } - &:active { - transform: scale(0.98); + &:focus-visible { + @include focus-ring.focus-ring-declarations; + } + + app-source-tile { + --source-tile-bg: color-mix( + in srgb, + var(--app-on-surface) 10%, + var(--app-header-bg) + ); + --source-tile-ring: var(--app-header-bg); } } -.trigger-icon-container { - width: 30px; - height: 30px; - border-radius: 8px; - display: inline-flex; - align-items: center; - justify-content: center; - background: color-mix( - in srgb, - var(--app-on-surface) 8%, - transparent - ); - flex-shrink: 0; -} +.trigger-placeholder-tile { + flex: none; + display: grid; + place-items: center; + width: 28px; + height: 28px; + border-radius: 7px; + background: color-mix(in srgb, var(--app-on-surface) 10%, transparent); + color: var(--app-body-color); -.trigger-playlist-icon { - font-size: 18px; - width: 18px; - height: 18px; - color: var(--mat-sys-on-surface-variant); + .mat-icon { + width: 14px; + height: 14px; + font-size: 14px; + line-height: 14px; + } } .playlist-info { + display: flex; flex: 1; + flex-direction: column; min-width: 0; - .name { - font-size: 0.95em; - font-weight: 600; + .name, + .channels-count { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - line-height: 1.25; - letter-spacing: 0.01em; + } + + .name { + font-size: 13px; + font-weight: 600; + line-height: 1.3; } .channels-count { - font-size: 0.74em; + font-size: 11px; line-height: 1.3; - letter-spacing: 0.02em; - color: var(--mat-sys-on-surface-variant); - // A wrapping subtitle pushes the trigger past the 56px header row. - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } -} - -// On a phone the header row has no width to spare, and the shell allows this -// trigger to shrink to 88px there. The decorative type icon goes so that the -// trigger's fixed chrome (refresh, chevron, gaps, padding) actually fits that -// floor with the name ellipsizing; the subtitle goes because the name is the -// only part worth the room. -@media (max-width: 640px) { - .trigger-icon-container { - display: none; - } - - .playlist-info .channels-count { - display: none; - } -} - -.trigger-refresh-button { - margin-left: auto; - width: 32px; - height: 32px; - min-width: 32px; - min-height: 32px; - padding: 0; - line-height: 32px; - flex-shrink: 0; - display: inline-flex; - align-items: center; - justify-content: center; - color: var(--mat-sys-on-surface-variant); - transition: - color 0.15s ease, - background 0.15s ease; - - &:hover:not(:disabled) { - color: var(--mat-sys-on-surface); - background: var(--mat-sys-surface-container); - } - - ::ng-deep .mat-mdc-button-touch-target { - width: 32px; - height: 32px; - } - - ::ng-deep .mat-mdc-button-persistent-ripple, - ::ng-deep .mat-mdc-focus-indicator { - border-radius: 50%; - } -} - -.trigger-refresh-icon { - font-size: 18px; - width: 18px; - height: 18px; - line-height: 18px; - margin: 0; - padding: 0; - display: inline-flex; - align-items: center; - justify-content: center; - - &.is-spinning { - animation: switcher-refresh-spin 1s linear infinite; - } -} - -@keyframes switcher-refresh-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); + color: var(--app-body-color); } } .dropdown-icon { - font-size: 20px; - width: 20px; - height: 20px; - color: var(--mat-sys-on-surface-variant); - transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1); - margin-left: 4px; + flex: none; + width: 12px; + height: 12px; + font-size: 12px; + line-height: 12px; + color: var(--app-body-color); + transition: transform 0.15s ease; &.rotated { transform: rotate(180deg); } } -::ng-deep .playlist-switcher-menu.mat-mdc-menu-panel { - --mat-menu-container-color: var(--mat-sys-surface-container-high); - width: min( - var(--playlist-switcher-overlay-width, 400px), - calc(100vw - 24px) - ) !important; - min-width: min( - var(--playlist-switcher-overlay-width, 400px), - calc(100vw - 24px) - ) !important; - max-width: min( - var(--playlist-switcher-overlay-width, 400px), - calc(100vw - 24px) - ) !important; - margin-top: -1px !important; - border-radius: 0 0 12px 12px !important; - border: 1px solid var(--mat-sys-outline) !important; - border-top: 0 !important; - overflow: hidden !important; - background: var(--mat-menu-container-color) !important; - - .mat-mdc-menu-content { - padding: 0 !important; - background: var(--mat-menu-container-color) !important; - } -} - -.menu-header { - display: flex; - align-items: center; - gap: 10px; - padding: 8px 12px 6px; - font-size: 0.85em; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.5px; - background: var(--mat-menu-container-color); - - .menu-header-title { - flex: 1; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - opacity: 0.7; - } - - .menu-header-icon { - opacity: 0.7; - } - - mat-icon { - font-size: 18px; - width: 18px; - height: 18px; - } - - .search-toggle-btn { - width: 30px; - height: 30px; - line-height: 30px; - color: var(--mat-sys-on-surface-variant); - opacity: 0.8; - padding: 0; - - &:hover, - &.is-active { - opacity: 1; - color: var(--mat-sys-on-surface); - } - - mat-icon { - font-size: 18px; - width: 18px; - height: 18px; - } - } -} - -.search-container { - display: flex; - align-items: center; - gap: 8px; - padding: 8px 12px; - margin: 0 8px 4px; - background: color-mix( - in srgb, - var(--app-on-surface) 6%, - transparent - ); - border-radius: 8px; - border: 1px solid transparent; - transition: all 0.2s ease; -} - -.search-container:focus-within { - border-color: var(--app-focus-ring); - background: var(--mat-sys-surface-container-highest); -} - -.search-container .search-icon { - font-size: 18px; - width: 18px; - height: 18px; - color: var(--mat-sys-on-surface-variant); -} - -.search-container .search-input { - flex: 1; - border: none; - background: transparent; - outline: none; - font-size: 0.86em; - font-weight: 500; - line-height: 1.4; - color: inherit; - min-width: 0; -} - -.search-container .search-input::placeholder { - color: var(--mat-sys-on-surface-variant); -} - -.search-container .clear-btn { - width: 24px; - height: 24px; - line-height: 24px; - padding: 0; -} - -.search-container .clear-btn mat-icon { - font-size: 16px; - width: 16px; - height: 16px; -} - -.type-filters { - display: flex; - flex-wrap: wrap; - gap: 6px; - padding: 0 8px 8px; - margin-top: 6px; -} - -.type-filter-chip { - height: 24px; - padding: 0 10px; - border-radius: 999px; - border: 1px solid - color-mix(in srgb, var(--mat-sys-outline-variant) 72%, transparent); - background: color-mix( - in srgb, - var(--mat-sys-surface-container-high) 78%, - transparent - ); - color: var(--mat-sys-on-surface-variant); - font-size: 0.72rem; - font-weight: 600; - letter-spacing: 0.02em; - line-height: 1; - cursor: pointer; - transition: all 0.16s ease; - - &:hover { - background: var(--mat-sys-surface-container); - color: var(--mat-sys-on-surface); - } - - &.selected { - border-color: var(--app-selection-border, var(--app-selection-color)); - background: var( - --app-selection-surface-strong, - var(--app-selection-surface) - ); - color: var(--app-selection-color, var(--mat-sys-primary)); - /* box-shadow: - inset 0 0 0 1px - color-mix(in srgb, var(--app-selection-color) 18%, transparent), - 0 0 0 1px - color-mix(in srgb, var(--app-selection-color) 12%, transparent), - 0 0 10px var(--app-selection-glow, transparent); */ - } - - &:focus-visible { - @include focus-ring.focus-ring-declarations($offset: 1px); - } -} - -.menu-content { - max-height: 360px; - overflow-y: auto; - padding: 4px 0; - background: color-mix(in srgb, #000 22%, transparent); - border-top: 1px solid - color-mix(in srgb, var(--app-on-surface) 14%, transparent); - border-bottom: 1px solid - color-mix(in srgb, var(--app-on-surface) 14%, transparent); -} - -.playlist-item { - display: flex; - flex-direction: row; - align-items: center; - gap: 10px; - padding: 4px 12px; - min-height: 44px; - transition: background-color 0.15s ease; - position: relative; - cursor: pointer; - - &:not(:last-child) { - border-bottom: 1px solid - color-mix(in srgb, var(--app-on-surface) 8%, transparent); - } -} - -.playlist-item.selected { - background: var(--app-selection-surface, rgba(139, 92, 246, 0.1)); - - &::before { - content: ''; - position: absolute; - left: 0; - top: 6px; - bottom: 6px; - width: 3px; - border-radius: 0 3px 3px 0; - background: var(--app-selection-color, #a78bfa); - } - - .playlist-title, - .playlist-meta { - color: var(--app-selection-color, #a78bfa); - } -} - -.item-action-trigger { - opacity: 0; - pointer-events: none; - transition: opacity 0.15s ease; - width: 32px; - height: 32px; - line-height: 32px; - flex-shrink: 0; - color: var(--mat-sys-on-surface-variant);padding: 0; - - mat-icon { - font-size: 18px; - width: 18px; - height: 18px; - } -} - -.playlist-item:hover .item-action-trigger, -.playlist-item.selected .item-action-trigger, -.item-action-trigger:focus-visible, -.item-action-trigger[aria-expanded='true'] { - opacity: 1; - pointer-events: auto; -} - -::ng-deep .playlist-item-action-menu { - .danger, - .danger mat-icon { - color: var(--mat-sys-error, #f44336); - } -} - -.playlist-item:hover:not(.selected) { - background: color-mix( - in srgb, - var(--app-on-surface) 10%, - transparent - ); -} - -.playlist-icon-container { - position: relative; - display: flex; - align-items: center; - justify-content: center; - width: 36px; - height: 36px; - border-radius: 8px; - background: color-mix( - in srgb, - var(--app-on-surface) 6%, - transparent - ); - flex-shrink: 0; -} - -.playlist-type-icon { - font-size: 20px; - width: 20px; - height: 20px; - color: var(--mat-sys-on-surface-variant); -} - -.status-dot { - position: absolute; - width: 10px; - height: 10px; - border-radius: 50%; - right: -4px; - bottom: -4px; - border: 2px solid var(--mat-menu-container-color, #2a2a2e); -} - -.status-active { - background-color: #4caf50; - box-shadow: 0 0 6px rgba(76, 175, 80, 0.4); -} - -.status-inactive { - background-color: #f44336; - box-shadow: 0 0 6px rgba(244, 67, 54, 0.4); -} - -.status-expired { - background-color: #ff9800; - box-shadow: 0 0 6px rgba(255, 152, 0, 0.4); -} - -.status-unavailable { - background-color: #666666; -} - -.status-checking { - background-color: #9e9e9e; - animation: switcher-status-checking 1.4s ease-in-out infinite; -} - -@keyframes switcher-status-checking { - 0%, - 100% { - opacity: 0.4; - box-shadow: 0 0 0 0 rgba(158, 158, 158, 0); - } - 50% { - opacity: 1; - box-shadow: 0 0 4px 1px rgba(158, 158, 158, 0.6); - } -} - @media (prefers-reduced-motion: reduce) { - .status-checking { - animation: none; - opacity: 0.7; + .playlist-switcher-trigger, + .dropdown-icon { + transition: none; } } -.auto-refresh-indicator { - position: absolute; - right: -2px; - bottom: -2px; - font-size: 12px !important; - width: 14px !important; - height: 14px !important; - color: var(--mat-sys-primary); - background-color: var(--mat-menu-container-color, var(--mat-sys-surface)); - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; -} - -.playlist-details { - flex: 1; - min-width: 0; - display: flex; - flex-direction: column; - gap: 3px; -} - -.playlist-title { - font-size: 0.92em; - font-weight: 600; - line-height: 1.2; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.playlist-meta { - font-size: 0.73em; - line-height: 1.25; - letter-spacing: 0.01em; - color: var(--mat-sys-on-surface-variant); - display: flex; - align-items: center; - gap: 4px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - - .separator { - color: var(--mat-sys-on-surface-variant); - } -} - -.empty-state { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - padding: 32px 16px; - gap: 12px; - color: var(--mat-sys-on-surface-variant); - - mat-icon { - font-size: 36px; - width: 36px; - height: 36px; - } - - span { - font-size: 0.85em; - } -} - -.context-actions-section { - padding: 6px 8px 8px; - background: var(--mat-menu-container-color); - - .context-action-item { - display: flex; - align-items: center; - gap: 10px; - width: 100%; - padding: 8px 10px; - border: none; - border-radius: 8px; - background: transparent; - color: var(--mat-sys-on-surface-variant); - font: inherit; - font-size: 0.86em; - font-weight: 500; - cursor: pointer; - transition: - background 0.15s ease, - color 0.15s ease; - - &:hover { - background: var(--mat-sys-surface-container); - color: var(--mat-sys-on-surface); - } - - mat-icon { - font-size: 18px; - width: 18px; - height: 18px; - } - } -} - -@media only screen and (max-width: 480px) { - .playlist-info .name { - font-size: 0.9em; - } - - .playlist-icon-container { +// On a phone the header row has no width to spare and the trigger may +// shrink to 88px: the tile and the subtitle go, the name ellipsizes. +@media (max-width: 640px) { + .playlist-switcher-trigger app-source-tile, + .trigger-placeholder-tile, + .playlist-info .channels-count { display: none; } } + +// The popover's backdrop dims and slightly blurs the window behind it. The +// overlay container is outside this component, so these are global. +::ng-deep .playlist-switcher-backdrop { + background: var(--app-popover-backdrop); + backdrop-filter: blur(2px); +} + +@media (prefers-reduced-transparency: reduce) { + ::ng-deep .playlist-switcher-backdrop { + backdrop-filter: none; + } +} diff --git a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.spec.ts b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.spec.ts index dcf69c4f4..f2217483c 100644 --- a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.spec.ts +++ b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.spec.ts @@ -1,111 +1,53 @@ -import { signal } from '@angular/core'; import { - ComponentFixture, - TestBed, - fakeAsync, - flushMicrotasks, - tick, -} from '@angular/core/testing'; -import { MatDialog } from '@angular/material/dialog'; -import { MatSnackBar } from '@angular/material/snack-bar'; -import { Store } from '@ngrx/store'; + ChangeDetectionStrategy, + Component, + output, + signal, +} from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; -import { TranslateModule, TranslateService } from '@ngx-translate/core'; -import { PlaylistActions } from '@iptvnator/m3u-state'; +import { TranslateModule } from '@ngx-translate/core'; +import { SourceStatusService } from '@iptvnator/portal/shared/data-access'; import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util'; -import { DialogService } from '@iptvnator/ui/components'; -import { - PlaylistDeleteActionService, - PortalStatusService, -} from '@iptvnator/services'; import { PlaylistMeta } from '@iptvnator/shared/interfaces'; -import { PlaylistRefreshActionService } from '../playlist-refresh-action.service'; +import { PlaylistSwitcherOpenService } from './playlist-switcher-open.service'; +import { PlaylistSwitcherPanelComponent } from './playlist-switcher-panel.component'; import { PlaylistSwitcherComponent } from './playlist-switcher.component'; -const SEARCH_QUERY_STORAGE_KEY = 'playlist-switcher:search-query'; -const PLAYLIST_TYPE_FILTER_STORAGE_KEY = 'playlist-switcher:type-filters'; - -function createPlaylist( - overrides: Partial & { _id: string } -): PlaylistMeta { - return { - title: overrides.title ?? overrides.filename ?? overrides._id, - filename: overrides.filename, - count: overrides.count ?? 0, - importDate: - overrides.importDate ?? - new Date('2026-04-05T10:00:00.000Z').toISOString(), - autoRefresh: overrides.autoRefresh ?? false, - ...overrides, - } as PlaylistMeta; +@Component({ + selector: 'app-playlist-switcher-panel', + template: '', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +class StubPanelComponent { + readonly closeRequested = output(); + readonly playlistSelected = output(); + readonly addPlaylistRequested = output(); + readonly accountInfoRequested = output(); } +const URL_NAMED = { + _id: 'url-named', + title: 'get.php?username=joe&password=hunter2&type=m3u_plus', + url: 'http://biggie.test:8080/get.php?username=joe&password=hunter2&type=m3u_plus', + count: 3220, + importDate: '', + autoRefresh: false, +} as PlaylistMeta; + describe('PlaylistSwitcherComponent', () => { - const testWindow = window as unknown as { electron?: unknown }; - const originalElectron = testWindow.electron; let fixture: ComponentFixture; - let component: PlaylistSwitcherComponent; - let playlistsSignal: ReturnType>; - let resolvedPlaylistIdSignal: ReturnType>; - let activePlaylistSignal: ReturnType>; - let playlistContext: { - playlists: typeof playlistsSignal; - allPlaylistsLoaded: ReturnType>; - resolvedPlaylistId: typeof resolvedPlaylistIdSignal; - activePlaylist: typeof activePlaylistSignal; - selectPlaylist: jest.Mock; - }; - let portalStatusService: { - checkPortalStatus: jest.Mock; - getCachedStatus: jest.Mock; - getStatusClass: jest.Mock; - }; - let refreshActionService: { - canRefresh: jest.Mock; - refresh: jest.Mock; - }; - let dialog: { - open: jest.Mock; - }; - let dialogService: { - openConfirmDialog: jest.Mock; - }; - let playlistDeleteAction: { - deletePlaylist: jest.Mock; - }; - let snackBar: { - open: jest.Mock; - }; - let store: { - dispatch: jest.Mock; - }; + let active: ReturnType>; - const m3uPlaylist = createPlaylist({ - _id: 'm3u-1', - count: 24, - importDate: '2026-04-05T09:00:00.000Z', - title: 'Morning M3U', - url: 'http://example.test/m3u.m3u', - }); - const stalkerPlaylist = createPlaylist({ - _id: 'stalker-1', - importDate: '2026-04-05T11:00:00.000Z', - macAddress: '00:1A:79:00:00:01', - portalUrl: 'http://127.0.0.1:3210/portal.php', - title: 'Living Room Stalker', - }); - const xtreamPlaylist = createPlaylist({ - _id: 'xtream-1', - importDate: '2026-04-05T12:00:00.000Z', - password: 'pass1', - serverUrl: 'http://127.0.0.1:3211', - title: 'Cinema Xtream', - updateDate: new Date('2026-04-05T12:30:00.000Z').getTime(), - username: 'user1', - }); + const trigger = () => + (fixture.nativeElement as HTMLElement).querySelector( + '[data-test-id="playlist-switcher-trigger"]' + ) as HTMLButtonElement; + const panel = () => document.querySelector('app-playlist-switcher-panel'); - async function createComponent(): Promise { - await TestBed.configureTestingModule({ + beforeEach(() => { + active = signal(URL_NAMED); + TestBed.configureTestingModule({ imports: [ PlaylistSwitcherComponent, NoopAnimationsModule, @@ -114,337 +56,98 @@ describe('PlaylistSwitcherComponent', () => { providers: [ { provide: PlaylistContextFacade, - useValue: playlistContext, + useValue: { + resolvedPlaylistId: signal('url-named'), + activePlaylist: active, + }, }, { - provide: PortalStatusService, - useValue: portalStatusService, - }, - { - provide: PlaylistRefreshActionService, - useValue: refreshActionService, - }, - { - provide: PlaylistDeleteActionService, - useValue: playlistDeleteAction, - }, - { - provide: MatDialog, - useValue: dialog, - }, - { - provide: DialogService, - useValue: dialogService, - }, - { - provide: MatSnackBar, - useValue: snackBar, - }, - { - provide: Store, - useValue: store, + provide: SourceStatusService, + useValue: { statusOf: () => ({ reachability: 'ok' }) }, }, ], - }).compileComponents(); - + }); + TestBed.overrideComponent(PlaylistSwitcherComponent, { + remove: { imports: [PlaylistSwitcherPanelComponent] }, + add: { imports: [StubPanelComponent] }, + }); fixture = TestBed.createComponent(PlaylistSwitcherComponent); - component = fixture.componentInstance; fixture.componentRef.setInput('currentTitle', ''); - fixture.componentRef.setInput('subtitle', '3 playlists'); + fixture.componentRef.setInput('subtitle', '3 sources'); fixture.detectChanges(); - } - - beforeEach(() => { - localStorage.clear(); - - playlistsSignal = signal([ - m3uPlaylist, - stalkerPlaylist, - xtreamPlaylist, - ]); - resolvedPlaylistIdSignal = signal(xtreamPlaylist._id); - activePlaylistSignal = signal(xtreamPlaylist); - - playlistContext = { - playlists: playlistsSignal, - allPlaylistsLoaded: signal(true), - resolvedPlaylistId: resolvedPlaylistIdSignal, - activePlaylist: activePlaylistSignal, - selectPlaylist: jest.fn((playlist: PlaylistMeta) => { - resolvedPlaylistIdSignal.set(playlist._id); - activePlaylistSignal.set(playlist); - }), - }; - portalStatusService = { - checkPortalStatus: jest - .fn() - .mockResolvedValueOnce('active') - .mockResolvedValueOnce('inactive'), - getCachedStatus: jest.fn().mockReturnValue(null), - getStatusClass: jest.fn((status: string) => `status-${status}`), - }; - refreshActionService = { - canRefresh: jest.fn((playlist: PlaylistMeta) => - Boolean(playlist.serverUrl || playlist.url || playlist.filePath) - ), - refresh: jest.fn(), - }; - dialog = { - open: jest.fn(), - }; - dialogService = { - openConfirmDialog: jest.fn(), - }; - playlistDeleteAction = { - deletePlaylist: jest.fn().mockResolvedValue(true), - }; - snackBar = { - open: jest.fn(), - }; - store = { - dispatch: jest.fn(), - }; }); - afterEach(() => { - localStorage.clear(); - Object.defineProperty(window, 'electron', { - configurable: true, - writable: true, - value: originalElectron, - }); - jest.restoreAllMocks(); - }); + afterEach(() => fixture.destroy()); - it('labels each playlist row with translated text', async () => { - await createComponent(); - const translate = TestBed.inject(TranslateService); - translate.setTranslation('en', { - HOME: { PLAYLISTS: { CHANNELS_COUNT: '{{count}} channels' } }, - WORKSPACE: { - SHELL: { - STALKER_PORTAL: 'Stalker Portal', - XTREAM_CODE: 'Xtream Code', - }, - }, - }); - translate.use('en'); - - expect(component.getPlaylistMetaLabel(m3uPlaylist)).toBe('24 channels'); - expect(component.getPlaylistMetaLabel(stalkerPlaylist)).toBe( - 'Stalker Portal' + it('names the active source by its host, never by a credential URL', () => { + expect(trigger().querySelector('.name')?.textContent?.trim()).toBe( + 'biggie.test:8080' ); - expect(component.getPlaylistMetaLabel(xtreamPlaylist)).toBe( - 'Xtream Code' - ); - }); - - it('reads persisted search and type filters on startup and filters playlists accordingly', async () => { - localStorage.setItem(SEARCH_QUERY_STORAGE_KEY, 'room'); - localStorage.setItem( - PLAYLIST_TYPE_FILTER_STORAGE_KEY, - JSON.stringify({ - m3u: false, - stalker: true, - xtream: true, - }) - ); - - await createComponent(); - - expect(component.searchQuery()).toBe('room'); - expect(component.isTypeFilterSelected('m3u')).toBe(false); - expect(component.filteredPlaylists()).toEqual([stalkerPlaylist]); - }); - - it('sorts playlists by import date descending and prevents clearing the last enabled type filter', async () => { - await createComponent(); - expect( - component.filteredPlaylists().map((playlist) => playlist._id) - ).toEqual([xtreamPlaylist._id, stalkerPlaylist._id, m3uPlaylist._id]); - - component.togglePlaylistTypeFilter('m3u'); - component.togglePlaylistTypeFilter('stalker'); - expect(component.playlistTypeFilters()).toEqual({ - m3u: false, - stalker: false, - xtream: true, - }); - - component.togglePlaylistTypeFilter('xtream'); - expect(component.playlistTypeFilters()).toEqual({ - m3u: false, - stalker: false, - xtream: true, - }); + trigger().querySelector('.channels-count')?.textContent + ).toContain('M3U · HOME.PLAYLISTS.CHANNELS_COUNT'); + expect(trigger().textContent).not.toMatch(/hunter2|password=/); + expect(trigger().querySelector('.source-tile__dot')).not.toBeNull(); + expect(trigger().getAttribute('aria-keyshortcuts')).toBe( + 'Meta+P Control+P' + ); }); - it('selects a playlist, closes the menu, and emits the selected id', async () => { - await createComponent(); - - const closeMenuSpy = jest.spyOn(component.menuTrigger(), 'closeMenu'); - const selectedIds: string[] = []; - component.playlistSelected.subscribe((playlistId) => { - selectedIds.push(playlistId); - }); - - component.selectPlaylist(stalkerPlaylist); + it('shows the source count when no source is active', () => { + active.set(null); fixture.detectChanges(); - - expect(closeMenuSpy).toHaveBeenCalledTimes(1); - expect(playlistContext.selectPlaylist).toHaveBeenCalledWith( - stalkerPlaylist + expect(trigger().querySelector('.channels-count')?.textContent).toBe( + '3 sources' ); - expect(selectedIds).toEqual([stalkerPlaylist._id]); - expect(component.isSelected(stalkerPlaylist)).toBe(true); }); - it('emits playlist and account info requests through the context actions', async () => { - await createComponent(); - - const closeMenuSpy = jest.spyOn(component.menuTrigger(), 'closeMenu'); - const playlistInfoSpy = jest.fn(); - const accountInfoSpy = jest.fn(); - component.playlistInfoRequested.subscribe(playlistInfoSpy); - component.accountInfoRequested.subscribe(accountInfoSpy); - - component.requestPlaylistInfo(); - component.requestAccountInfo(); - - expect(closeMenuSpy).toHaveBeenCalledTimes(2); - expect(playlistInfoSpy).toHaveBeenCalledTimes(1); - expect(accountInfoSpy).toHaveBeenCalledTimes(1); - }); - - it('renders playlist info, account info, and add playlist context actions in the menu', async () => { - await createComponent(); - fixture.componentRef.setInput('showPlaylistInfo', true); - fixture.componentRef.setInput('showAccountInfo', true); - fixture.componentRef.setInput('showAddPlaylist', true); + it('opens the popover in an overlay and closes it on Escape, back on the trigger', () => { + trigger().click(); fixture.detectChanges(); + expect(panel()).not.toBeNull(); + expect(trigger().getAttribute('aria-expanded')).toBe('true'); + expect(trigger().classList).toContain('is-open'); - const playlistInfoSpy = jest.fn(); - component.playlistInfoRequested.subscribe(playlistInfoSpy); - - component.menuTrigger().openMenu(); + document.body.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }) + ); fixture.detectChanges(); - - const actionButtons = Array.from( - document.querySelectorAll( - '.context-actions-section .context-action-item' - ) - ); - const labels = actionButtons.map((button) => - button.textContent?.trim() - ); - - expect(labels).toEqual([ - expect.stringContaining('PLAYLIST_INFO'), - expect.stringContaining('ACCOUNT_INFO'), - expect.stringContaining('ADD_PLAYLIST'), - ]); - - actionButtons[0].click(); - expect(playlistInfoSpy).toHaveBeenCalledTimes(1); + expect(panel()).toBeNull(); + expect(document.activeElement).toBe(trigger()); }); - it('offers per-playlist account info for portal playlists only', async () => { - await createComponent(); + it('refits the open popover when the window shrinks', () => { + const height = Object.getOwnPropertyDescriptor(window, 'innerHeight'); + const setHeight = (value: number) => + Object.defineProperty(window, 'innerHeight', { + configurable: true, + value, + }); + try { + setHeight(900); + trigger().click(); + fixture.detectChanges(); + const opened = fixture.componentInstance.maxHeight(); - const requestedPlaylists: PlaylistMeta[] = []; - component.accountInfoForPlaylistRequested.subscribe((playlist) => - requestedPlaylists.push(playlist) - ); - - expect(component.hasAccountInfo(m3uPlaylist)).toBe(false); - expect(component.hasAccountInfo(stalkerPlaylist)).toBe(true); - expect(component.hasAccountInfo(xtreamPlaylist)).toBe(true); - - component.requestAccountInfoFor(stalkerPlaylist); - expect(requestedPlaylists).toEqual([stalkerPlaylist]); + setHeight(600); + window.dispatchEvent(new Event('resize')); + expect(fixture.componentInstance.maxHeight()).not.toBe(opened); + expect( + parseInt(fixture.componentInstance.maxHeight() ?? '', 10) + ).toBeLessThanOrEqual(600); + } finally { + if (height) Object.defineProperty(window, 'innerHeight', height); + } }); - it('opens the menu, syncs overlay width, and checks portal statuses for Xtream playlists', fakeAsync(async () => { - await createComponent(); - - const triggerElement = component.triggerElement().nativeElement; - jest.spyOn(triggerElement, 'getBoundingClientRect').mockReturnValue({ - bottom: 0, - height: 0, - left: 0, - right: 320, - toJSON: () => ({}), - top: 0, - width: 320, - x: 0, - y: 0, - }); - - component.onMenuOpened(); - flushMicrotasks(); - tick(); - - expect(component.isMenuOpen()).toBe(true); - expect( - document.documentElement.style.getPropertyValue( - '--playlist-switcher-overlay-width' - ) - ).toBe('400px'); - expect(portalStatusService.checkPortalStatus).toHaveBeenCalledTimes(1); - expect(portalStatusService.checkPortalStatus).toHaveBeenCalledWith( - xtreamPlaylist.serverUrl, - xtreamPlaylist.username, - xtreamPlaylist.password - ); - expect(component.portalStatuses().get(xtreamPlaylist._id)).toBe( - 'active' - ); - expect(component.getStatusClass(xtreamPlaylist._id)).toBe( - 'status-active' - ); - - component.onMenuClosed(); - expect(component.isMenuOpen()).toBe(false); - expect( - document.documentElement.style.getPropertyValue( - '--playlist-switcher-overlay-width' - ) - ).toBe(''); - })); - - it('falls back to the active playlist title when no explicit current title is provided', async () => { - await createComponent(); - - expect(component.displayTitle()).toBe('Cinema Xtream'); - - const translate = TestBed.inject(TranslateService); - translate.setTranslation('en', { - HOME: { PLAYLISTS: { SELECT_PLAYLIST: 'Pick a source' } }, - }); - translate.use('en'); - resolvedPlaylistIdSignal.set(null); - activePlaylistSignal.set(null); + it('opens on a ⌘P / Ctrl+P request while it is on screen', () => { + const switcherOpen = TestBed.inject(PlaylistSwitcherOpenService); + expect(switcherOpen.available).toBe(true); + switcherOpen.request(); fixture.detectChanges(); + expect(panel()).not.toBeNull(); - expect(component.displayTitle()).toBe('Pick a source'); - }); - - it('delegates playlist deletion and removes the source after confirmation', async () => { - await createComponent(); - - component.removePlaylistFor(xtreamPlaylist); - const confirm = dialogService.openConfirmDialog.mock.calls[0][0] - .onConfirm as () => Promise; - await confirm(); - - expect(playlistDeleteAction.deletePlaylist).toHaveBeenCalledWith( - xtreamPlaylist - ); - expect(store.dispatch).toHaveBeenCalledWith( - PlaylistActions.playlistRemovalCommitted({ - playlistId: xtreamPlaylist._id, - }) - ); + fixture.destroy(); + expect(switcherOpen.available).toBe(false); }); }); diff --git a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.ts b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.ts index 7b0f32b5a..ddec7bd97 100644 --- a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.ts +++ b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.ts @@ -1,12 +1,8 @@ -import { Injector } from '@angular/core'; import { - RuntimeCapabilitiesService, - injectTranslationTick, -} from '@iptvnator/services'; -import { SourceHealthService } from '@iptvnator/portal/shared/data-access'; -import { foldSearchText, sourceHealthType } from '@iptvnator/shared/interfaces'; -import { SourceHealthIndicatorComponent } from '../source-health/source-health-indicator.component'; -import { DatePipe, DOCUMENT } from '@angular/common'; + CdkConnectedOverlay, + CdkOverlayOrigin, + ConnectedPosition, +} from '@angular/cdk/overlay'; import { ChangeDetectionStrategy, Component, @@ -19,562 +15,173 @@ import { signal, viewChild, } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatIconButton } from '@angular/material/button'; -import { MatRippleModule } from '@angular/material/core'; -import { MatDialog } from '@angular/material/dialog'; -import { MatDivider } from '@angular/material/divider'; import { MatIcon } from '@angular/material/icon'; -import { MatInputModule } from '@angular/material/input'; -import { MatMenu, MatMenuModule, MatMenuTrigger } from '@angular/material/menu'; -import { MatSnackBar } from '@angular/material/snack-bar'; -import { MatTooltip } from '@angular/material/tooltip'; -import { Store } from '@ngrx/store'; +import { TranslateService } from '@ngx-translate/core'; import { normalizeDateLocale } from '@iptvnator/pipes'; -import { TranslatePipe, TranslateService } from '@ngx-translate/core'; -import { DialogService } from '@iptvnator/ui/components'; -import { PlaylistActions } from '@iptvnator/m3u-state'; -import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util'; +import { SourceStatusService } from '@iptvnator/portal/shared/data-access'; import { - PlaylistDeleteActionService, - PortalStatus, - PortalStatusService, -} from '@iptvnator/services'; -import { - getPlaylistSourceIcon, - isPortalAccountPlaylist, - PlaylistMeta, -} from '@iptvnator/shared/interfaces'; -import { PlaylistRefreshActionService } from '../playlist-refresh-action.service'; -import { PlaylistInfoComponent } from '../recent-playlists/playlist-info/playlist-info.component'; + PlaylistContextFacade, + sourceDisplayName, + sourceMetaParts, +} from '@iptvnator/playlist/shared/util'; +import { injectTranslationTick } from '@iptvnator/services'; +import { PlaylistMeta } from '@iptvnator/shared/interfaces'; +import { SourceTileComponent } from '../source-row/source-tile.component'; +import { PlaylistSwitcherOpenService } from './playlist-switcher-open.service'; +import { PlaylistSwitcherPanelComponent } from './playlist-switcher-panel.component'; -type PlaylistFilterType = 'm3u' | 'stalker' | 'xtream'; - -const SEARCH_QUERY_STORAGE_KEY = 'playlist-switcher:search-query'; -const PLAYLIST_TYPE_FILTER_STORAGE_KEY = 'playlist-switcher:type-filters'; -const DEFAULT_PLAYLIST_TYPE_FILTERS: Record = { - m3u: true, - stalker: true, - xtream: true, -}; +/** Below the trigger's left edge, 8px down; above it when there is no room. */ +const POSITIONS: ConnectedPosition[] = [ + { + originX: 'start', + originY: 'bottom', + overlayX: 'start', + overlayY: 'top', + offsetY: 8, + }, + { + originX: 'start', + originY: 'top', + overlayX: 'start', + overlayY: 'bottom', + offsetY: -8, + }, +]; +/** The popover keeps this far from the window's bottom edge. */ +const VIEWPORT_MARGIN = 12; +/** + * The toolbar playlist switcher: a trigger showing the active source and a + * popover (`app-playlist-switcher-panel`) in a CDK overlay, so row menus + * open above it rather than inside its scrolling list. Opens on click and + * on ⌘P / Ctrl+P; Escape closes it and returns focus to the trigger. + */ @Component({ selector: 'app-playlist-switcher', + imports: [ + CdkConnectedOverlay, + CdkOverlayOrigin, + MatIcon, + PlaylistSwitcherPanelComponent, + SourceTileComponent, + ], templateUrl: './playlist-switcher.component.html', styleUrls: ['./playlist-switcher.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, - imports: [ - SourceHealthIndicatorComponent, - DatePipe, - FormsModule, - MatDivider, - MatIcon, - MatIconButton, - MatInputModule, - MatMenuModule, - MatRippleModule, - MatTooltip, - TranslatePipe, - ], + host: { '(window:resize)': 'onWindowResize()' }, }) export class PlaylistSwitcherComponent { - private readonly document = inject(DOCUMENT); - private readonly destroyRef = inject(DestroyRef); private readonly playlistContext = inject(PlaylistContextFacade); - private readonly portalStatusService = inject(PortalStatusService); + private readonly statusService = inject(SourceStatusService); private readonly translate = inject(TranslateService); - private readonly refreshAction = inject(PlaylistRefreshActionService); - private readonly dialog = inject(MatDialog); - private readonly dialogService = inject(DialogService); - private readonly playlistDeleteAction = inject(PlaylistDeleteActionService); - private readonly snackBar = inject(MatSnackBar); - private readonly store = inject(Store); - private focusSearchTimeoutId: ReturnType | null = null; private readonly languageTick = injectTranslationTick(); - - readonly runtime = inject(RuntimeCapabilitiesService); - private readonly healthInjector = inject(Injector); - readonly sourceHealthType = sourceHealthType; - recheckSource(playlist: PlaylistMeta): void { - void this.healthInjector.get(SourceHealthService).recheck(playlist); - } + private readonly trigger = + viewChild.required>('trigger'); readonly currentTitle = input.required(); + /** Shown under the name while no source is active ("3 sources"). */ readonly subtitle = input(''); - readonly showPlaylistInfo = input(false); - readonly showAccountInfo = input(false); - readonly showAddPlaylist = input(false); - readonly canRefreshActivePlaylist = input(false); - readonly isRefreshingActivePlaylist = input(false); readonly playlistSelected = output(); - readonly playlistInfoRequested = output(); - readonly accountInfoRequested = output(); readonly accountInfoForPlaylistRequested = output(); readonly addPlaylistRequested = output(); - readonly refreshPlaylistRequested = output(); - onRefreshClick(event: Event): void { - event.stopPropagation(); - event.preventDefault(); - this.refreshPlaylistRequested.emit(); - } - - readonly menuTrigger = viewChild.required('menuTrigger'); - readonly playlistMenu = viewChild.required('playlistMenu'); - readonly triggerElement = - viewChild.required>('triggerElement'); - readonly searchInput = - viewChild>('searchInput'); - - readonly isMenuOpen = signal(false); - readonly searchQuery = signal(this.readPersistedSearchQuery()); - readonly playlistTypeFilters = signal(this.readPersistedTypeFilters()); - readonly searchExpanded = signal(false); - private static readonly SEARCH_AUTO_VISIBLE_THRESHOLD = 5; - - readonly playlists = this.playlistContext.playlists; - readonly allPlaylistsLoaded = this.playlistContext.allPlaylistsLoaded; + readonly positions = POSITIONS; + readonly isOpen = signal(false); + readonly maxHeight = signal(null); readonly activePlaylistId = this.playlistContext.resolvedPlaylistId; readonly activePlaylist = this.playlistContext.activePlaylist; - readonly filteredPlaylists = computed(() => { - const query = foldSearchText(this.searchQuery()).trim(); - const filters = this.playlistTypeFilters(); - const allPlaylists = this.playlists(); - const filteredByType = allPlaylists.filter((playlist) => { - const playlistType = this.getPlaylistFilterType(playlist); - return filters[playlistType]; - }); - const filtered = query - ? filteredByType.filter( - (playlist) => - foldSearchText(playlist.title ?? '').includes(query) || - foldSearchText(playlist.filename ?? '').includes(query) - ) - : filteredByType; - return [...filtered].sort( - (a, b) => - new Date(b.importDate ?? 0).getTime() - - new Date(a.importDate ?? 0).getTime() - ); - }); + /** The active source's name; never its URL or a credential. */ readonly displayTitle = computed(() => { this.languageTick(); if (!this.activePlaylistId()) { return this.translate.instant('HOME.PLAYLISTS.SELECT_PLAYLIST'); } - + const active = this.activePlaylist(); return ( - this.currentTitle() || - this.activePlaylist()?.title || - this.activePlaylist()?.filename || - this.activePlaylist()?.url || - this.activePlaylist()?.portalUrl || + (active ? sourceDisplayName(active) : this.currentTitle()) || this.translate.instant('HOME.PLAYLISTS.UNTITLED_PLAYLIST') ); }); - - readonly availablePlaylistTypes = computed(() => { - const types = new Set(); - for (const playlist of this.playlists()) { - types.add(this.getPlaylistFilterType(playlist)); - } - return types; - }); - readonly showTypeFilters = computed( - () => this.availablePlaylistTypes().size > 1 - ); - readonly hasSearchToggle = computed( - () => - this.playlists().length <= - PlaylistSwitcherComponent.SEARCH_AUTO_VISIBLE_THRESHOLD - ); - readonly showSearchField = computed( - () => !this.hasSearchToggle() || this.searchExpanded() - ); - - readonly portalStatuses = signal>(new Map()); - - /** - * Tracks the in-flight check round so we can cancel pending writes when - * the menu closes (avoiding zombie writes from a slow portal landing - * after the user has moved on). Status caching itself lives in - * PortalStatusService so it's shared across components. - */ - private portalStatusAbortController: AbortController | null = null; - readonly currentLocale = computed(() => { + /** "Stalker", "M3U · 3,220 channels", or the header's source count. */ + readonly triggerSubtitle = computed(() => { this.languageTick(); - return normalizeDateLocale( - this.translate.currentLang || this.translate.defaultLang - ); + const active = this.activePlaylist(); + if (!active) { + return this.subtitle(); + } + return sourceMetaParts( + active, + { + translate: (key, params) => this.translate.instant(key, params), + locale: normalizeDateLocale( + this.translate.currentLang || this.translate.defaultLang + ), + now: Date.now(), + }, + true + ) + .filter((part) => part.kind === 'type' || part.kind === 'channels') + .map((part) => part.text) + .join(' · '); + }); + readonly activeStatus = computed(() => { + const active = this.activePlaylist(); + return active ? this.statusService.statusOf(active) : null; }); constructor() { - this.destroyRef.onDestroy(() => { - this.clearMenuOverlayWidth(); - this.clearSearchFocusTimeout(); - this.cancelPortalStatusChecks(); + const openService = inject(PlaylistSwitcherOpenService); + const unregister = openService.register(); + const requests = openService.openRequests.subscribe(() => this.open()); + inject(DestroyRef).onDestroy(() => { + unregister(); + requests.unsubscribe(); }); } - onMenuOpened(): void { - this.isMenuOpen.set(true); - this.syncMenuOverlayWidthToTrigger(); - if (this.showSearchField()) { - this.focusSearchField(); - } - if (!this.runtime.supportsSourceHealth) - void this.checkPortalStatuses(this.playlists()); - } - - onMenuClosed(): void { - this.isMenuOpen.set(false); - this.clearMenuOverlayWidth(); - this.clearSearchFocusTimeout(); - this.cancelPortalStatusChecks(); - if (this.hasSearchToggle()) { - this.searchExpanded.set(false); + toggle(): void { + if (this.isOpen()) { + this.close(); + } else { + this.open(); } } - toggleSearchField(event?: Event): void { - event?.stopPropagation(); - const next = !this.searchExpanded(); - this.searchExpanded.set(next); - if (next) { - this.focusSearchField(); + open(): void { + this.fitToViewport(); + this.isOpen.set(true); + } + + /** A window resized while the popover is open refits it. */ + onWindowResize(): void { + if (this.isOpen()) { + this.fitToViewport(); } } - setSearchQuery(value: string): void { - this.searchQuery.set(value); - this.writePersistedSearchQuery(value); + /** Caps the popover to the room below the trigger; only its list scrolls. */ + private fitToViewport(): void { + const bottom = + this.trigger().nativeElement.getBoundingClientRect().bottom; + this.maxHeight.set( + `${Math.max(240, window.innerHeight - bottom - 8 - VIEWPORT_MARGIN)}px` + ); } - togglePlaylistTypeFilter(type: PlaylistFilterType, event?: Event): void { - event?.stopPropagation(); - const current = this.playlistTypeFilters(); - const next = { - ...current, - [type]: !current[type], - }; - - if (!next.m3u && !next.stalker && !next.xtream) { + /** Closes the popover; focus goes back to the trigger. */ + close(): void { + if (!this.isOpen()) { return; } - - this.playlistTypeFilters.set(next); - this.writePersistedTypeFilters(next); + this.isOpen.set(false); + this.trigger().nativeElement.focus(); } - isTypeFilterSelected(type: PlaylistFilterType): boolean { - return this.playlistTypeFilters()[type]; - } - - selectPlaylist(playlist: PlaylistMeta): void { - this.menuTrigger().closeMenu(); - this.playlistContext.selectPlaylist(playlist); - this.playlistSelected.emit(playlist._id); - } - - requestPlaylistInfo(): void { - this.menuTrigger().closeMenu(); - this.playlistInfoRequested.emit(); - } - - requestAccountInfo(): void { - this.menuTrigger().closeMenu(); - this.accountInfoRequested.emit(); - } - - requestAddPlaylist(): void { - this.menuTrigger().closeMenu(); - this.addPlaylistRequested.emit(); - } - - hasType(type: PlaylistFilterType): boolean { - return this.availablePlaylistTypes().has(type); - } - - canRefreshPlaylist(playlist: PlaylistMeta): boolean { - return this.refreshAction.canRefresh(playlist); - } - - openPlaylistInfoFor(playlist: PlaylistMeta, event?: Event): void { - event?.stopPropagation(); - this.menuTrigger().closeMenu(); - this.dialog.open(PlaylistInfoComponent, { data: playlist }); - } - - hasAccountInfo(playlist: PlaylistMeta): boolean { - return isPortalAccountPlaylist(playlist); - } - - requestAccountInfoFor(playlist: PlaylistMeta, event?: Event): void { - event?.stopPropagation(); - this.menuTrigger().closeMenu(); - this.accountInfoForPlaylistRequested.emit(playlist); - } - - refreshPlaylistFor(playlist: PlaylistMeta, event?: Event): void { - event?.stopPropagation(); - this.menuTrigger().closeMenu(); - this.refreshAction.refresh(playlist); - } - - removePlaylistFor(playlist: PlaylistMeta, event?: Event): void { - event?.stopPropagation(); - this.menuTrigger().closeMenu(); - this.dialogService.openConfirmDialog({ - title: this.translate.instant('HOME.PLAYLISTS.REMOVE_DIALOG.TITLE'), - message: this.translate.instant( - 'HOME.PLAYLISTS.REMOVE_DIALOG.MESSAGE' - ), - confirmLabel: this.translate.instant('HOME.PLAYLISTS.REMOVE'), - tone: 'destructive', - onConfirm: () => this.removePlaylistConfirmed(playlist), - }); - } - - private async removePlaylistConfirmed( - playlist: PlaylistMeta - ): Promise { - const deleted = - await this.playlistDeleteAction.deletePlaylist(playlist); - - if (!deleted) { - return; - } - - this.store.dispatch( - PlaylistActions.playlistRemovalCommitted({ - playlistId: playlist._id, - }) - ); - this.snackBar.open( - this.translate.instant('HOME.PLAYLISTS.REMOVE_DIALOG.SUCCESS'), - undefined, - { duration: 2000 } - ); - } - - getPlaylistIcon(playlist: PlaylistMeta): string { - return getPlaylistSourceIcon(playlist); - } - - getPlaylistMetaLabel(playlist: PlaylistMeta): string { - if (playlist.macAddress) { - return this.translate.instant('WORKSPACE.SHELL.STALKER_PORTAL'); - } - if (playlist.serverUrl) { - return this.translate.instant('WORKSPACE.SHELL.XTREAM_CODE'); - } - return this.translate.instant('HOME.PLAYLISTS.CHANNELS_COUNT', { - count: playlist.count ?? 0, - }); - } - - getStatusClass(playlistId: string): string { - const status = this.portalStatuses().get(playlistId); - // No entry yet means we haven't checked (or are mid-flight without a - // 'checking' write). Render no status class instead of misleading the - // user with the red 'unavailable' dot. - if (!status) { - return ''; - } - return this.portalStatusService.getStatusClass(status); - } - - isSelected(playlist: PlaylistMeta): boolean { - return this.activePlaylistId() === playlist._id; - } - - private getPlaylistFilterType(playlist: PlaylistMeta): PlaylistFilterType { - if (playlist.macAddress) { - return 'stalker'; - } - if (playlist.serverUrl) { - return 'xtream'; - } - return 'm3u'; - } - - private async checkPortalStatuses(playlists: PlaylistMeta[]) { - // Cancel any prior in-flight round so its results can't overwrite the - // new round (e.g. user closes + reopens the menu rapidly). - this.cancelPortalStatusChecks(); - const controller = new AbortController(); - this.portalStatusAbortController = controller; - - const xtreamPlaylists = playlists.filter( - ( - playlist - ): playlist is PlaylistMeta & { - serverUrl: string; - username: string; - password: string; - } => - Boolean( - playlist.serverUrl && playlist.username && playlist.password - ) - ); - if (xtreamPlaylists.length === 0) { - return; - } - - // Hydrate from the shared service cache + mark uncached portals as - // 'checking' in a single signal write so the UI flips from blank → - // cached/checking dots in one render, not one per playlist. - const next = new Map(this.portalStatuses()); - const toFetch: (PlaylistMeta & { - serverUrl: string; - username: string; - password: string; - })[] = []; - for (const playlist of xtreamPlaylists) { - const cached = this.portalStatusService.getCachedStatus( - playlist.serverUrl, - playlist.username, - playlist.password - ); - if (cached !== null) { - next.set(playlist._id, cached); - } else { - next.set(playlist._id, 'checking'); - toFetch.push(playlist); - } - } - this.portalStatuses.set(next); - - if (toFetch.length === 0) { - return; - } - - // Stream results: each portal's dot updates as soon as ITS request - // resolves, independent of the slowest one. Replaces the prior - // Promise.all that blocked all dots on the tail latency. - await Promise.all( - toFetch.map(async (playlist) => { - let status: PortalStatus; - try { - status = await this.portalStatusService.checkPortalStatus( - playlist.serverUrl, - playlist.username, - playlist.password - ); - } catch { - status = 'unavailable'; - } - - if (controller.signal.aborted) { - return; - } - - this.portalStatuses.update((current) => { - const updated = new Map(current); - updated.set(playlist._id, status); - return updated; - }); - }) - ); - } - - private cancelPortalStatusChecks(): void { - if (this.portalStatusAbortController) { - this.portalStatusAbortController.abort(); - this.portalStatusAbortController = null; - } - } - - private syncMenuOverlayWidthToTrigger(): void { - const triggerWidth = Math.round( - this.triggerElement().nativeElement.getBoundingClientRect().width - ); - const overlayWidth = Math.max(triggerWidth, 400); - this.document.documentElement.style.setProperty( - '--playlist-switcher-overlay-width', - `${overlayWidth}px` - ); - } - - private clearMenuOverlayWidth(): void { - this.document.documentElement.style.removeProperty( - '--playlist-switcher-overlay-width' - ); - } - - private focusSearchField(): void { - this.clearSearchFocusTimeout(); - this.focusSearchTimeoutId = setTimeout(() => { - const input = this.searchInput()?.nativeElement; - if (!input) { - return; - } - input.focus(); - input.select(); - this.focusSearchTimeoutId = null; - }, 0); - } - - private clearSearchFocusTimeout(): void { - if (this.focusSearchTimeoutId !== null) { - clearTimeout(this.focusSearchTimeoutId); - this.focusSearchTimeoutId = null; - } - } - - private readPersistedSearchQuery(): string { - try { - return localStorage.getItem(SEARCH_QUERY_STORAGE_KEY) ?? ''; - } catch { - return ''; - } - } - - private writePersistedSearchQuery(value: string): void { - try { - localStorage.setItem(SEARCH_QUERY_STORAGE_KEY, value); - } catch { - // Ignore storage write failures. - } - } - - private readPersistedTypeFilters(): Record { - try { - const raw = localStorage.getItem(PLAYLIST_TYPE_FILTER_STORAGE_KEY); - if (!raw) { - return { ...DEFAULT_PLAYLIST_TYPE_FILTERS }; - } - - const parsed = JSON.parse(raw) as Partial< - Record - >; - const next = { - m3u: parsed.m3u !== false, - stalker: parsed.stalker !== false, - xtream: parsed.xtream !== false, - }; - - if (!next.m3u && !next.stalker && !next.xtream) { - return { ...DEFAULT_PLAYLIST_TYPE_FILTERS }; - } - - return next; - } catch { - return { ...DEFAULT_PLAYLIST_TYPE_FILTERS }; - } - } - - private writePersistedTypeFilters( - filters: Record - ): void { - try { - localStorage.setItem( - PLAYLIST_TYPE_FILTER_STORAGE_KEY, - JSON.stringify(filters) - ); - } catch { - // Ignore storage write failures. + onOverlayKeydown(event: KeyboardEvent): void { + if (event.key === 'Escape') { + event.preventDefault(); + this.close(); } } } diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.component.html b/libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.component.html index 97227f172..c56b235f1 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.component.html +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.component.html @@ -82,23 +82,6 @@
} - @case ('no-results') { -
-
- - - - - - -
- -
-

{{ 'HOME.PLAYLISTS.NO_RESULTS_TITLE' | translate }}

-

{{ 'HOME.PLAYLISTS.NO_RESULTS_DESCRIPTION' | translate }}

-
-
- } @case ('no-data') {
diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.component.scss b/libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.component.scss index 0fb6176c8..7f51a4cea 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.component.scss +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.component.scss @@ -66,40 +66,6 @@ } } -.no-results-state { - padding: 40px 24px; - - .illustration-container { - margin-bottom: 24px; - opacity: 0.5; - - &.small { - animation: gentleFloat 1s ease-out; - } - } - - .illustration { - width: 120px; - height: 120px; - color: var(--app-body-color); - - .search-circle, - .search-handle { - stroke: currentColor; - opacity: 0.4; - } - - .empty-box, - .empty-line { - stroke: currentColor; - } - } - - .description { - margin-bottom: 0; - font-size: 0.875rem; - } -} .no-data-state { padding: 56px 24px; diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.component.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.component.ts index ca7e2177a..d798445f8 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.component.ts +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.component.ts @@ -11,7 +11,7 @@ import type { PlaylistType } from '../../add-playlist-menu/playlist-type'; import { SOURCE_TYPE_ICONS } from '@iptvnator/shared/interfaces'; export type EmptyStateType = - 'welcome-dashboard' | 'welcome-sources' | 'no-results' | 'no-data'; + 'welcome-dashboard' | 'welcome-sources' | 'no-data'; interface FeatureCard { icon: string; diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.responsive.scss b/libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.responsive.scss index ba080ca32..633dfbc52 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.responsive.scss +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.responsive.scss @@ -1,12 +1,4 @@ @media (max-width: 480px) { - .no-results-state { - padding: 30px 20px; - - .illustration { - width: 100px; - height: 100px; - } - } .no-data-state { padding: 40px 20px; @@ -19,10 +11,6 @@ } @media (max-height: 700px) { - .no-results-state { - padding: 24px 20px; - .illustration-container { margin-bottom: 16px; } - } .no-data-state { padding: 32px 24px; @@ -30,12 +18,3 @@ } } -@media (max-height: 550px) { - .no-results-state { - padding: 16px 20px; - .illustration-container { margin-bottom: 12px; } - .illustration { width: 80px; height: 80px; } - .title { font-size: 1.125rem; } - .description { font-size: 0.8125rem; } - } -} diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.html b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.html deleted file mode 100644 index ff2d1ef16..000000000 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.html +++ /dev/null @@ -1,290 +0,0 @@ -
- @if (isDraggable()) { - drag_indicator - } -
- @if (runtime.supportsSourceHealth) { - - } - -
- @switch (sourceIconKey) { - @case ('stalker') { - {{ sourceIcons.stalker }} - } - @case ('xtream') { - {{ sourceIcons.xtream }} - } - @case ('m3u-url') { - {{ sourceIcons['m3u-url'] }} - } - @default { - {{ sourceIcons['m3u-local'] }} - } - } - @if (showsPortalStatusDot) { - @let statusLabel = - 'HOME.PLAYLISTS.PORTAL_STATUS' - | translate - : { status: (portalStatusLabelKey() | translate) }; - - } @else if (showsAutoRefresh) { - autorenew - } -
-
- -
-
- {{ item.title || item.filename }} -
-
- @if (!item.serverUrl && !item.portalUrl) { - {{ 'HOME.PLAYLISTS.CHANNELS' | translate }}: {{ item.count }} - @if (item.importDate.length > 3) { - | {{ 'HOME.PLAYLISTS.ADDED' | translate }}: - {{ - item.importDate - | date: undefined : undefined : currentLocale() - }} - } - @if (item.updateDate) { - | {{ 'HOME.PLAYLISTS.UPDATED' | translate }}: - {{ - item.updateDate - | date - : 'MMMM d, yyyy, HH:mm' - : undefined - : currentLocale() - }} - } - @if (item.updateState === 2) { - warning - } - } @else { - @if (item.macAddress) { - Stalker - } @else { - Xtream - } - @if (item.importDate.length > 3) { - | {{ 'HOME.PLAYLISTS.ADDED' | translate }}: - {{ - item.importDate - | date: undefined : undefined : currentLocale() - }} - } - @if (item.updateDate) { - | {{ 'HOME.PLAYLISTS.UPDATED' | translate }}: - {{ - item.updateDate - | date - : 'MMMM d, yyyy, HH:mm' - : undefined - : currentLocale() - }} - } - } -
- @if (isBusy() && busyMessage()) { -
- {{ busyMessage() }} - @if (busyProgress() !== null) { - {{ busyProgress() }}% - } -
- } - @if (isBusy() && busyMessage() && busyProgress() !== null) { -
- -
- } - @if (isBusy() && busyMessage() && busyProgress() === null) { -
- -
- } -
- - @if (showActions()) { -
- @if (runtime.supportsSourceHealth && sourceHealthType(item)) { - - } - @if (canCancelBusyAction()) { - - } - @if (item.url || (item.filePath && supportsPlaylistRefresh)) { - - } - @if (item.serverUrl && supportsXtreamSqliteDataSource) { - - } - - - - -
- } -
diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.scss b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.scss deleted file mode 100644 index 3129a13fa..000000000 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.scss +++ /dev/null @@ -1,290 +0,0 @@ -@use '../../../../../../../ui/styles/focus-ring'; - -:host { - display: block; - width: 100%; -} - -.playlist-item { - width: 100%; - min-height: 68px; - display: flex; - align-items: center; - gap: 12px; - padding: 8px 10px 8px 12px; - border: 1px solid transparent; - border-radius: 8px; - background: transparent; - color: inherit; - cursor: pointer; - box-sizing: border-box; - transition: - background 0.15s ease, - border-color 0.15s ease, - box-shadow 0.18s ease, - transform 0.18s ease, - color 0.15s ease; - - &:hover { - background: color-mix(in srgb, currentColor 8%, transparent); - } - - &.selected { - background: linear-gradient( - 135deg, - var(--app-selection-surface-strong), - var(--app-selection-surface) - ); - border-color: var(--app-selection-border); - color: var(--app-selection-color); - box-shadow: - inset 0 0 0 1px var(--app-selection-border), - 0 12px 20px -18px var(--app-selection-glow); - transform: translateY(-1px); - - .meta, - .upload-type-icon, - .drag-icon, - .action-buttons button, - .action-buttons mat-icon { - color: inherit; - } - - .status-dot { - border-color: color-mix( - in srgb, - var(--app-selection-surface-strong) 85%, - transparent - ); - } - } - - &.is-busy { - cursor: progress; - } -} - -.playlist-icon { - flex-shrink: 0; - display: flex; - align-items: center; -} - -.playlist-content { - min-width: 0; - flex: 1; - display: flex; - flex-direction: column; - justify-content: center; - gap: 2px; - // The row draws this element's focus ring. - outline: none; -} - -// The keyboard ring belongs to the whole row, the unit that opens. It sits -// inside the row: the list's scroller would cut one drawn outside it. -.playlist-item:has(> .playlist-content:focus-visible) { - @include focus-ring.focus-ring-declarations($offset: -2px); -} - -.playlist-title { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-size: 0.96rem; - font-weight: 600; - letter-spacing: -0.01em; - line-height: 1.2; -} - -.meta { - font-size: 0.82rem; - color: var(--mat-sys-on-surface-variant); - opacity: 0.84; - line-height: 1.2; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.busy-state { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - font-size: 0.7rem; - color: var(--app-selection-border, #8aa4c8); - margin-top: 2px; -} - -.busy-state__message { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.busy-state__value { - opacity: 0.8; -} - -.busy-state__bar { - margin-top: 4px; -} - -.cdk-drag-preview { - box-sizing: border-box; - border-radius: 8px; - box-shadow: - 0 8px 24px rgba(0, 0, 0, 0.25), - 0 4px 12px rgba(0, 0, 0, 0.15), - 0 2px 6px rgba(0, 0, 0, 0.1); - opacity: 0.9; -} - -.cdk-drag-placeholder { - opacity: 0; -} - -.cdk-drag-animating { - transition: transform 300ms cubic-bezier(0.4, 0, 0.2, 1); -} - -.drag-icon { - flex-shrink: 0; - cursor: move; - opacity: 0.46; - transition: opacity 0.15s ease; - - &:hover { - opacity: 1; - } -} - -.action-buttons { - flex-shrink: 0; - display: flex; - align-items: center; - gap: 2px; - margin-inline-start: auto; - - button { - width: 34px; - height: 34px; - border-radius: 10px; - color: inherit; - opacity: 0.76; - transition: - opacity 0.15s ease, - background-color 0.15s ease, - transform 0.15s ease; - - &:hover { - opacity: 1; - background: color-mix(in srgb, currentColor 10%, transparent); - transform: translateY(-1px); - } - - mat-icon { - width: 20px; - height: 20px; - font-size: 20px; - } - - &:disabled { - transform: none; - opacity: 0.38; - } - } -} - -// The action buttons inherit the row color, including the selected row's, -// which masks the .app-destructive-button token on the cancel action. -.playlist-item .action-buttons .cancel-btn, -.playlist-item.selected .action-buttons .cancel-btn { - color: var(--mat-sys-error); -} - -.action-spinner { - display: block; - margin: 0 auto; -} - -.icon-container { - position: relative; - display: inline-flex; - - .upload-type-icon { - opacity: 0.75; - transition: opacity 0.15s ease; - } - - .playlist-item:hover & .upload-type-icon { - opacity: 0.95; - } -} - -.status-dot { - position: absolute; - width: 10px; - height: 10px; - border-radius: 50%; - right: 0; - bottom: 0; - border: 2px solid var(--mat-sys-surface); - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); - transition: all 0.15s ease; -} - -.status-active { - background-color: #4caf50; - box-shadow: 0 0 8px rgba(76, 175, 80, 0.4); -} - -.status-inactive { - background-color: #f44336; - box-shadow: 0 0 8px rgba(244, 67, 54, 0.4); -} - -.status-expired { - background-color: #ff9800; - box-shadow: 0 0 8px rgba(255, 152, 0, 0.4); -} - -.status-unavailable { - background-color: var(--mat-sys-on-surface-variant); - opacity: 0.5; -} - -.auto-refresh-indicator { - position: absolute; - right: -2px; - bottom: -2px; - font-size: 14px !important; - width: 14px !important; - height: 14px !important; - color: #4caf50; - background-color: var(--mat-sys-surface); - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); - transition: all 0.15s ease; - - .playlist-item:hover & { - color: #66bb6a; - transform: rotate(90deg); - } -} - -@media only screen and (max-width: 480px) { - .upload-type-icon, - .icon-container, - .action-buttons .refresh-btn, - .action-buttons .edit-btn, - .action-buttons .delete-btn { - display: none; - } -} diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.spec.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.spec.ts deleted file mode 100644 index 2ba2c712b..000000000 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.spec.ts +++ /dev/null @@ -1,408 +0,0 @@ -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; -import { MatIconModule } from '@angular/material/icon'; -import { MatListModule } from '@angular/material/list'; -import { MatTooltipModule } from '@angular/material/tooltip'; -import { TranslateModule, TranslateService } from '@ngx-translate/core'; -import { MockModule } from 'ng-mocks'; -import { - PortalStatusService, - RuntimeCapabilitiesService, -} from '@iptvnator/services'; -import { PlaylistItemComponent } from './playlist-item.component'; - -describe('PlaylistItemComponent', () => { - let component: PlaylistItemComponent; - let fixture: ComponentFixture; - let runtime: { - supportsPlaylistRefresh: boolean; - supportsXtreamSqliteDataSource: boolean; - }; - - beforeEach(waitForAsync(() => { - runtime = { - supportsPlaylistRefresh: true, - supportsXtreamSqliteDataSource: true, - }; - - TestBed.configureTestingModule({ - imports: [ - PlaylistItemComponent, - MockModule(MatIconModule), - MockModule(MatListModule), - MockModule(MatTooltipModule), - TranslateModule.forRoot(), - ], - providers: [ - { - provide: PortalStatusService, - useValue: { - checkPortalStatus: jest - .fn() - .mockResolvedValue('active'), - getStatusClass: jest.fn(() => 'status-active'), - getStatusIcon: jest.fn(() => 'check_circle'), - }, - }, - { - provide: RuntimeCapabilitiesService, - useValue: runtime, - }, - ], - }).compileComponents(); - })); - - beforeEach(() => { - fixture = TestBed.createComponent(PlaylistItemComponent); - component = fixture.componentInstance; - component.item = { - title: 'Playlist', - _id: '1', - count: 10, - importDate: Date.now().toString(), - autoRefresh: false, - }; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('shows busy UI and suppresses playlist clicks while deleting', () => { - const emitSpy = jest.spyOn(component.playlistClicked, 'emit'); - - fixture.componentRef.setInput('isDeleting', true); - fixture.detectChanges(); - - const nativeElement = fixture.nativeElement as HTMLElement; - const deleteButton = nativeElement.querySelector( - '.delete-btn' - ) as HTMLButtonElement; - const editButton = nativeElement.querySelector( - '.edit-btn' - ) as HTMLButtonElement; - - expect(component.isBusy()).toBe(true); - expect(deleteButton.disabled).toBe(true); - expect(editButton.disabled).toBe(true); - expect(nativeElement.querySelector('.action-spinner')).not.toBeNull(); - - component.onPlaylistClick(); - expect(emitSpy).not.toHaveBeenCalled(); - }); - - describe('keyboard activation', () => { - const row = () => fixture.nativeElement as HTMLElement; - const activation = () => - row().querySelector('[role="button"]') as HTMLElement; - const keydown = (target: HTMLElement, key: string) => { - const event = new KeyboardEvent('keydown', { - key, - bubbles: true, - cancelable: true, - }); - target.dispatchEvent(event); - return event; - }; - - it('is a Tab stop named after the source that Enter and Space open', () => { - const emitSpy = jest.spyOn(component.playlistClicked, 'emit'); - const surface = activation(); - - expect(surface).not.toBeNull(); - expect(surface.getAttribute('tabindex')).toBe('0'); - expect(surface.getAttribute('aria-label')).toBe('Playlist'); - - const enter = keydown(surface, 'Enter'); - const space = keydown(surface, ' '); - - expect(emitSpy).toHaveBeenNthCalledWith(1, '1'); - expect(emitSpy).toHaveBeenNthCalledWith(2, '1'); - expect(enter.defaultPrevented).toBe(true); - // Space must not scroll the page. - expect(space.defaultPrevented).toBe(true); - }); - - it('keeps the drag handle and actions outside the activation element so their keys never open the source', () => { - fixture.componentRef.setInput('isDraggable', true); - fixture.detectChanges(); - const emitSpy = jest.spyOn(component.playlistClicked, 'emit'); - const editSpy = jest.spyOn(component.editPlaylistClicked, 'emit'); - const controls = Array.from( - row().querySelectorAll( - '.action-buttons button, .drag-icon' - ) - ); - - expect(activation()).not.toBeNull(); - expect(controls.length).toBeGreaterThan(1); - // No interactive control nested inside a role="button". - for (const control of controls) { - expect(control.closest('[role="button"]')).toBeNull(); - } - - const editButton = row().querySelector( - '.edit-btn' - ) as HTMLButtonElement; - keydown(editButton, 'Enter'); - const space = keydown(editButton, ' '); - editButton.click(); - - // The button's own activation edits; the row must neither open - // the source nor swallow the Space the button relies on. - expect(emitSpy).not.toHaveBeenCalled(); - expect(space.defaultPrevented).toBe(false); - expect(editSpy).toHaveBeenCalledTimes(1); - }); - - it('opens once for a pointer click anywhere on the row', () => { - const emitSpy = jest.spyOn(component.playlistClicked, 'emit'); - - activation().click(); - (row().querySelector('.playlist-item') as HTMLElement).click(); - (row().querySelector('.playlist-icon') as HTMLElement).click(); - - expect(emitSpy).toHaveBeenCalledTimes(3); - }); - - it('announces the selected source and ignores keys while busy', () => { - const emitSpy = jest.spyOn(component.playlistClicked, 'emit'); - expect(activation().hasAttribute('aria-current')).toBe(false); - expect(activation().hasAttribute('aria-disabled')).toBe(false); - - fixture.componentRef.setInput('isSelected', true); - fixture.componentRef.setInput('isRefreshing', true); - fixture.detectChanges(); - - expect(activation().getAttribute('aria-current')).toBe('true'); - expect(activation().getAttribute('aria-disabled')).toBe('true'); - // Still a Tab stop, so focus is not lost when a refresh starts. - expect(activation().getAttribute('tabindex')).toBe('0'); - - const space = keydown(activation(), ' '); - keydown(activation(), 'Enter'); - - expect(emitSpy).not.toHaveBeenCalled(); - expect(space.defaultPrevented).toBe(true); - }); - - it('falls back to the file name for the accessible name', () => { - fixture.destroy(); - fixture = TestBed.createComponent(PlaylistItemComponent); - fixture.componentInstance.item = { - title: '', - filename: 'channels.m3u', - _id: 'file-source', - count: 10, - importDate: Date.now().toString(), - autoRefresh: false, - }; - fixture.detectChanges(); - - expect(activation().getAttribute('aria-label')).toBe( - 'channels.m3u' - ); - }); - }); - - it('renders a refresh action for file-backed M3U playlists', () => { - fixture.destroy(); - runtime.supportsPlaylistRefresh = true; - fixture = TestBed.createComponent(PlaylistItemComponent); - component = fixture.componentInstance; - component.item = { - title: 'Local Source', - _id: 'local-source', - count: 10, - importDate: Date.now().toString(), - autoRefresh: false, - filePath: '/tmp/local-source.m3u', - }; - fixture.detectChanges(); - - const nativeElement = fixture.nativeElement as HTMLElement; - - expect(nativeElement.querySelector('.refresh-btn')).not.toBeNull(); - }); - - it('hides file-backed M3U refresh without the refresh bridge', () => { - fixture.destroy(); - runtime.supportsPlaylistRefresh = false; - fixture = TestBed.createComponent(PlaylistItemComponent); - component = fixture.componentInstance; - component.item = { - title: 'Local Source', - _id: 'local-source', - count: 10, - importDate: Date.now().toString(), - autoRefresh: false, - filePath: '/tmp/local-source.m3u', - }; - fixture.detectChanges(); - - expect( - (fixture.nativeElement as HTMLElement).querySelector('.refresh-btn') - ).toBeNull(); - }); - - it('keeps URL-backed M3U refresh visible without the refresh bridge', () => { - fixture.destroy(); - runtime.supportsPlaylistRefresh = false; - fixture = TestBed.createComponent(PlaylistItemComponent); - component = fixture.componentInstance; - component.item = { - title: 'Remote Source', - _id: 'remote-source', - count: 10, - importDate: Date.now().toString(), - autoRefresh: false, - url: 'https://example.com/playlist.m3u', - }; - fixture.detectChanges(); - - expect( - (fixture.nativeElement as HTMLElement).querySelector('.refresh-btn') - ).not.toBeNull(); - }); - - it('renders the Xtream refresh action only when the SQLite data source is available', () => { - fixture.destroy(); - runtime.supportsXtreamSqliteDataSource = true; - fixture = TestBed.createComponent(PlaylistItemComponent); - component = fixture.componentInstance; - component.item = { - title: 'Xtream Source', - _id: 'xtream-source', - count: 10, - importDate: Date.now().toString(), - autoRefresh: false, - serverUrl: 'https://example.com', - username: 'demo', - password: 'secret', - }; - fixture.detectChanges(); - - expect( - (fixture.nativeElement as HTMLElement).querySelector('.refresh-btn') - ).not.toBeNull(); - - fixture.destroy(); - runtime.supportsXtreamSqliteDataSource = false; - fixture = TestBed.createComponent(PlaylistItemComponent); - component = fixture.componentInstance; - component.item = { - title: 'Xtream Source', - _id: 'xtream-source', - count: 10, - importDate: Date.now().toString(), - autoRefresh: false, - serverUrl: 'https://example.com', - username: 'demo', - password: 'secret', - }; - fixture.detectChanges(); - - expect( - (fixture.nativeElement as HTMLElement).querySelector('.refresh-btn') - ).toBeNull(); - }); - - // OnPush: the status arrives after an await outside any template event, - // so it must reach the view without a zone-triggered tick. - it('renders the portal status that resolves after the first render', async () => { - fixture.destroy(); - fixture = TestBed.createComponent(PlaylistItemComponent); - component = fixture.componentInstance; - component.item = { - title: 'Xtream Source', - _id: 'xtream-source', - count: 10, - importDate: Date.now().toString(), - autoRefresh: false, - serverUrl: 'https://example.com', - username: 'demo', - password: 'secret', - }; - const translate = TestBed.inject(TranslateService); - // Not English text, so the label must come from the keys. - translate.setTranslation('en', { - HOME: { - PLAYLISTS: { - PORTAL_STATUS: 'Portalstatus: {{status}}', - PORTAL_STATUS_ACTIVE: 'aktiv', - }, - }, - }); - translate.use('en'); - // The fixture renders on its own: a forced detectChanges() after the - // await would hide a status that does not schedule a render. - fixture.autoDetectChanges(); - await fixture.whenStable(); - await new Promise((resolve) => setTimeout(resolve)); - - const statusDot = (fixture.nativeElement as HTMLElement).querySelector( - '.status-dot' - ); - expect(statusDot?.getAttribute('aria-label')).toBe( - 'Portalstatus: aktiv' - ); - }); - - it('renders cancel and progress UI for long-running playlist actions', () => { - fixture.componentRef.setInput('isDeleting', true); - fixture.componentRef.setInput( - 'busyMessage', - 'Removing cached content...' - ); - fixture.componentRef.setInput('busyProgress', 42); - fixture.componentRef.setInput('canCancelBusyAction', true); - fixture.detectChanges(); - - const nativeElement = fixture.nativeElement as HTMLElement; - - expect( - nativeElement.querySelector('.busy-state__message')?.textContent - ).toContain('Removing cached content...'); - expect( - nativeElement.querySelector('.busy-state__value')?.textContent - ).toContain('42%'); - expect(nativeElement.querySelector('.cancel-btn')).not.toBeNull(); - }); - - it.each([ - [ - { macAddress: '00:1A:79:00:00:01', url: 'http://portal.test' }, - 'cast', - true, - ], - [{ macAddress: '00:1A:79:00:00:01' }, 'cast', false], - [{ serverUrl: 'http://xtream.test' }, 'cloud', false], - [{ url: 'http://list.test/playlist.m3u' }, 'link', true], - [{}, 'description', true], - ])( - 'shows one provider icon for %o (%s) and keeps the auto-refresh badge: %s', - (source, icon, autoRefreshBadge) => { - fixture.destroy(); - fixture = TestBed.createComponent(PlaylistItemComponent); - fixture.componentInstance.item = { - title: 'Source', - _id: 'source', - count: 10, - importDate: Date.now().toString(), - autoRefresh: true, - ...source, - }; - fixture.detectChanges(); - - const row = fixture.nativeElement as HTMLElement; - const icons = Array.from( - row.querySelectorAll('.upload-type-icon'), - (element) => element.textContent?.trim() - ); - expect(icons).toEqual([icon]); - expect(row.querySelector('.auto-refresh-indicator') !== null).toBe( - autoRefreshBadge - ); - } - ); -}); diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.ts deleted file mode 100644 index db537f5d8..000000000 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { Injector } from '@angular/core'; -import { SourceHealthService } from '@iptvnator/portal/shared/data-access'; -import { - PlaylistSourceIconKey, - resolvePlaylistSourceIconKey, - SOURCE_TYPE_ICONS, - sourceHealthType, -} from '@iptvnator/shared/interfaces'; -import { SourceHealthIndicatorComponent } from '../../source-health/source-health-indicator.component'; -import { DragDropModule } from '@angular/cdk/drag-drop'; -import { DatePipe } from '@angular/common'; -import { - Component, - Input, - OnInit, - computed, - inject, - input, - output, - signal, - ChangeDetectionStrategy, -} from '@angular/core'; -import { MatIconButton } from '@angular/material/button'; -import { MatIcon } from '@angular/material/icon'; -import { MatProgressBarModule } from '@angular/material/progress-bar'; -import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; -import { MatTooltip } from '@angular/material/tooltip'; -import { normalizeDateLocale } from '@iptvnator/pipes'; -import { TranslatePipe, TranslateService } from '@ngx-translate/core'; -import { - PortalStatus, - PortalStatusService, - RuntimeCapabilitiesService, - injectTranslationTick, -} from '@iptvnator/services'; -import type { PlaylistMeta } from '@iptvnator/shared/interfaces'; - -/** Translation keys for the status word in the portal status tooltip. */ -const PORTAL_STATUS_LABEL_KEYS: Record = { - active: 'HOME.PLAYLISTS.PORTAL_STATUS_ACTIVE', - inactive: 'HOME.PLAYLISTS.PORTAL_STATUS_INACTIVE', - expired: 'HOME.PLAYLISTS.PORTAL_STATUS_EXPIRED', - unavailable: 'HOME.PLAYLISTS.PORTAL_STATUS_UNAVAILABLE', - checking: 'HOME.PLAYLISTS.PORTAL_STATUS_CHECKING', -}; - -@Component({ - selector: 'app-playlist-item', - templateUrl: './playlist-item.component.html', - styleUrls: ['./playlist-item.component.scss'], - changeDetection: ChangeDetectionStrategy.OnPush, - imports: [ - SourceHealthIndicatorComponent, - DatePipe, - DragDropModule, - MatIconButton, - MatIcon, - MatProgressBarModule, - MatProgressSpinnerModule, - MatTooltip, - TranslatePipe, - ], -}) -export class PlaylistItemComponent implements OnInit { - @Input() item!: PlaylistMeta; - readonly showActions = input(true); - readonly isDraggable = input(false); - readonly isSelected = input(false); - readonly isRefreshing = input(false); - readonly isDeleting = input(false); - readonly busyMessage = input(''); - readonly busyProgress = input(null); - readonly canCancelBusyAction = input(false); - readonly isBusy = computed(() => this.isRefreshing() || this.isDeleting()); - - readonly editPlaylistClicked = output(); - readonly playlistClicked = output(); - readonly refreshClicked = output(); - readonly removeClicked = output(); - readonly cancelBusyActionClicked = output(); - - readonly portalStatus = signal('unavailable'); - readonly portalStatusLabelKey = computed( - () => PORTAL_STATUS_LABEL_KEYS[this.portalStatus()] - ); - private readonly portalStatusService = inject(PortalStatusService); - readonly runtime = inject(RuntimeCapabilitiesService); - readonly sourceIcons = SOURCE_TYPE_ICONS; - - get sourceIconKey(): PlaylistSourceIconKey { - return resolvePlaylistSourceIconKey(this.item); - } - - /** Without source health, Xtream rows badge the portal status instead. */ - get showsPortalStatusDot(): boolean { - return ( - this.sourceIconKey === 'xtream' && - !this.runtime.supportsSourceHealth - ); - } - - /** - * Auto-refresh re-fetches a URL or a local file, so any row with a URL - * keeps the badge, whichever provider icon it shows. - */ - get showsAutoRefresh(): boolean { - return ( - !!this.item.autoRefresh && - (!!this.item.url || this.sourceIconKey === 'm3u-local') - ); - } - private readonly translate = inject(TranslateService); - private readonly languageTick = injectTranslationTick(); - - readonly supportsPlaylistRefresh = this.runtime.supportsPlaylistRefresh; - readonly supportsXtreamSqliteDataSource = - this.runtime.supportsXtreamSqliteDataSource; - readonly currentLocale = computed(() => { - this.languageTick(); - return normalizeDateLocale( - this.translate.currentLang || this.translate.defaultLang - ); - }); - - private readonly healthInjector = inject(Injector); - readonly sourceHealthType = sourceHealthType; - recheckSource(): void { - void this.healthInjector.get(SourceHealthService).recheck(this.item); - } - - async ngOnInit() { - if (!this.runtime.supportsSourceHealth) await this.checkPortalStatus(); - } - - private async checkPortalStatus() { - if (this.item.serverUrl && this.item.username && this.item.password) { - this.portalStatus.set( - await this.portalStatusService.checkPortalStatus( - this.item.serverUrl, - this.item.username, - this.item.password - ) - ); - } - } - - getStatusClass(): string { - return this.portalStatusService.getStatusClass(this.portalStatus()); - } - - getStatusIcon(): string { - return this.portalStatusService.getStatusIcon(this.portalStatus()); - } - - onPlaylistClick(): void { - if (this.isBusy()) { - return; - } - - this.playlistClicked.emit(this.item._id); - } - - /** - * Enter/Space open the source like a click on the row; Space also - * prevents the page scroll. The drag handle and the actions are siblings - * of the activation element, never descendants, so their keys cannot - * reach this handler. - */ - onActivationKey(event: Event): void { - event.preventDefault(); - this.onPlaylistClick(); - } -} diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.html b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.html index b795a9e09..150f6afd0 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.html +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.html @@ -1,45 +1,53 @@ -@if (playlistsData(); as data) { - @if (data.playlists.length === 0) { - @if (data.totalCount === 0) { - - - } @else { - - - } - } @else { -
- @for ( - item of data.playlists; - track item._id; - let last = $last - ) { - - } -
+@if (playlists().length === 0) { + @if (allPlaylistsLoaded()) { + } +} @else if (visiblePlaylists().length === 0) { +
+

+ {{ 'WORKSPACE.SOURCES.NO_MATCHES' | translate }} +

+ @if (hasFilters()) { + + } +
+} @else { +
+ @for (item of visiblePlaylists(); track item._id) { + + } +
} diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.scss b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.scss index ccb5152f8..118a1ff05 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.scss +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.scss @@ -1,7 +1,8 @@ +@use '../../../../../../ui/styles/app-controls' as controls; + :host { display: flex; flex-direction: column; - align-items: center; width: 100%; min-width: 0; min-height: 0; @@ -15,35 +16,40 @@ min-height: 0; } -.playlists-list { - padding: 0 6px; +.sources-list { + display: flex; + flex-direction: column; width: 100%; - overflow-x: hidden; - box-sizing: border-box; } -#skeleton-container { - padding-top: 10px; - width: 100%; - - .skeleton-item { - display: flex; - padding: 0 20px; - height: 72px; - - .text { - flex: 1; - align-self: center; - padding-left: 6px; - flex-direction: column; - display: flex; - } - } +// A 1px line between rows, hidden around the hovered row so its fill +// reads as one shape. +app-source-row + app-source-row { + border-top: 1px solid var(--app-widget-header-border); } -@media only screen and (max-width: 480px) { - .favorites-icon { - padding-left: 0 !important; - display: none; - } +app-source-row:hover, +app-source-row:hover + app-source-row, +app-source-row:focus-within, +app-source-row:focus-within + app-source-row { + border-top-color: transparent; +} + +.sources-empty { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + padding: 64px 16px; + text-align: center; +} + +.sources-empty__message { + margin: 0; + font-size: 13px; + color: var(--app-body-color); +} + +.sources-empty__reset { + @include controls.ghost-button(sm); } diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.spec.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.spec.ts index 9db1e2121..f08aba0a6 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.spec.ts +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.spec.ts @@ -1,742 +1,365 @@ import { signal } from '@angular/core'; -import { TestBed } from '@angular/core/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; import { MatDialog } from '@angular/material/dialog'; -import { MatSnackBar } from '@angular/material/snack-bar'; import { Router } from '@angular/router'; import { MockStore, provideMockStore } from '@ngrx/store/testing'; -import { TranslateService } from '@ngx-translate/core'; -import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util'; +import { TranslateModule } from '@ngx-translate/core'; +import { BehaviorSubject } from 'rxjs'; import { PlaylistActions, - selectActiveTypeFilters, selectAllPlaylistsMeta, selectPlaylistsLoadingFlag, } from '@iptvnator/m3u-state'; -import { of } from 'rxjs'; -import { DialogService } from '@iptvnator/ui/components'; +import { SourceStatusService } from '@iptvnator/portal/shared/data-access'; +import { + PlaylistContextFacade, + SourcesViewStateService, +} from '@iptvnator/playlist/shared/util'; import { - DatabaseService, - DataService, - DbOperationEvent, - PlaybackPositionService, - PlaylistDeleteActionService, - PlaylistRefreshService, RuntimeCapabilitiesService, SortBy, + SortOptions, SortOrder, SortService, + SourceActivityService, } from '@iptvnator/services'; -import { - CONNECTIVITY_GUARD_RESET, - PLAYLIST_UPDATE, - PlaylistMeta, -} from '@iptvnator/shared/interfaces'; -import { - RENDERER_PERFORMANCE_PHASE_HOOK_KEY, - type RendererPerformancePhaseEvent, -} from '@iptvnator/shared/logging'; +import { PlaylistMeta, SourceStatus } from '@iptvnator/shared/interfaces'; +import { PlaylistRefreshActionService } from '../playlist-refresh-action.service'; +import { SourceRemovalService } from '../source-removal/source-removal.service'; +import { SourceRowComponent } from '../source-row/source-row.component'; import { RecentPlaylistsComponent } from './recent-playlists.component'; +import { SourceListRefreshService } from './source-list-refresh.service'; -const performanceHookSymbol = Symbol.for(RENDERER_PERFORMANCE_PHASE_HOOK_KEY); +const source = ( + id: string, + fields: Partial = {} +): PlaylistMeta => ({ + _id: id, + title: id, + count: 0, + importDate: '2026-01-01T00:00:00.000Z', + autoRefresh: false, + ...fields, +}); -function setPerformanceHook( - hook: ((event: RendererPerformancePhaseEvent) => void) | null -): void { - const target = globalThis as unknown as Record; - if (hook === null) { - delete target[performanceHookSymbol]; - } else { - target[performanceHookSymbol] = hook; - } -} +const M3U = source('m3u', { url: 'https://cdn.example/list.m3u', position: 0 }); +const XTREAM = source('xtream', { + serverUrl: 'http://x.example', + username: 'u', + password: 'p', + position: 1, +}); +const FILE = source('file', { position: 2 }); -function createDeferred() { - let resolve!: (value: T) => void; - let reject!: (reason?: unknown) => void; - const promise = new Promise((res, rej) => { - resolve = res; - reject = rej; - }); - - return { promise, resolve, reject }; -} - -function createAbortError(): Error { - const error = new Error('Cancelled'); - error.name = 'AbortError'; - return error; -} - -function createPlaylistMeta( - overrides: Partial = {} -): PlaylistMeta { - return { - _id: 'playlist-1', - title: 'Xtream Playlist', - count: 0, - importDate: new Date('2026-03-28T00:00:00.000Z').toISOString(), - autoRefresh: false, - serverUrl: 'http://localhost:8080', - username: 'demo', - password: 'secret', - ...overrides, - } as PlaylistMeta; -} - -describe('RecentPlaylistsComponent busy state', () => { +describe('RecentPlaylistsComponent', () => { + let fixture: ComponentFixture; let component: RecentPlaylistsComponent; - let databaseService: { - cancelOperation: jest.Mock; - createOperationId: jest.Mock; - deletePlaylist: jest.Mock; - deleteXtreamPlaylistContent: jest.Mock; - updateXtreamPlaylistDetails: jest.Mock; - }; - let dialogService: { - openConfirmDialog: jest.Mock; - }; - let dataService: { - sendIpcEvent: jest.Mock; - }; - let playbackPositionService: { - getAllPlaybackPositions: jest.Mock; - }; - let playlistDeleteAction: { - deletePlaylist: jest.Mock; - }; - let playlistRefreshService: { - cancelRefresh: jest.Mock; - refreshPlaylist: jest.Mock; - }; - let runtime: { - isElectron: boolean; - supportsPlaylistRefresh: boolean; - supportsXtreamSqliteDataSource: boolean; - }; - let router: { - navigate: jest.Mock; - }; - let snackBar: { - open: jest.Mock; - }; let store: MockStore; + let sort: BehaviorSubject; + let statuses: Map; + let pending: ReturnType>>; + let removal: { + pendingIds: typeof pending; + remove: jest.Mock; + isPending: jest.Mock; + canTakeOver: jest.Mock; + progress: object; + }; + let viewState: SourcesViewStateService; + let switcherRefreshing: ReturnType>; - beforeEach(async () => { - databaseService = { - cancelOperation: jest.fn().mockResolvedValue(true), - createOperationId: jest.fn((prefix: string) => `${prefix}-op`), - deletePlaylist: jest.fn(), - deleteXtreamPlaylistContent: jest.fn(), - updateXtreamPlaylistDetails: jest.fn().mockResolvedValue(undefined), + beforeEach(() => { + localStorage.clear(); + sort = new BehaviorSubject({ + by: SortBy.CUSTOM, + order: SortOrder.ASC, + }); + statuses = new Map([ + ['m3u', { reachability: 'ok' }], + ['xtream', { reachability: 'bad' }], + ]); + pending = signal>(new Set()); + switcherRefreshing = signal(null); + removal = { + pendingIds: pending, + remove: jest.fn(), + isPending: jest.fn((id: string) => pending().has(id)), + canTakeOver: jest.fn( + (id: string) => + !( + removal.progress as { + isDeleting: (id: string) => boolean; + } + ).isDeleting(id) && + !TestBed.inject(SourceActivityService).isBusy(id) + ), + progress: { + isDeleting: () => false, + message: () => '', + progress: () => null, + canCancel: () => false, + }, }; - dialogService = { - openConfirmDialog: jest.fn(), - }; - dataService = { - sendIpcEvent: jest.fn(), - }; - playbackPositionService = { - getAllPlaybackPositions: jest.fn().mockResolvedValue([]), - }; - playlistDeleteAction = { - deletePlaylist: jest.fn().mockResolvedValue(true), - }; - playlistRefreshService = { - cancelRefresh: jest.fn().mockResolvedValue(undefined), - refreshPlaylist: jest.fn().mockResolvedValue({ - id: 'playlist-1', - items: [], - }), - }; - runtime = { - isElectron: true, - supportsPlaylistRefresh: true, - supportsXtreamSqliteDataSource: true, - }; - router = { - navigate: jest.fn(), - }; - snackBar = { - open: jest.fn(), - }; - await TestBed.configureTestingModule({ - imports: [RecentPlaylistsComponent], + TestBed.configureTestingModule({ + imports: [RecentPlaylistsComponent, TranslateModule.forRoot()], providers: [ provideMockStore({ selectors: [ { selector: selectPlaylistsLoadingFlag, value: true }, { selector: selectAllPlaylistsMeta, - value: [], - }, - { - selector: selectActiveTypeFilters, - value: ['xtream', 'm3u', 'stalker'], + value: [M3U, XTREAM, FILE], }, ], }), { - provide: DatabaseService, - useValue: databaseService, + provide: SortService, + useValue: { + getSortOptions: () => sort.asObservable(), + sortPlaylists: new SortService().sortPlaylists, + }, }, { - provide: DialogService, - useValue: dialogService, + provide: SourceStatusService, + useValue: { + supportsStatusFilter: true, + statusOf: (p: PlaylistMeta) => + statuses.get(p._id) ?? null, + tracks: (p: PlaylistMeta) => statuses.has(p._id), + ensureChecked: jest.fn(), + recheck: jest.fn(), + }, }, + { provide: SourceRemovalService, useValue: removal }, { - provide: DataService, - useValue: dataService, - }, - { - provide: PlaybackPositionService, - useValue: playbackPositionService, - }, - { - provide: PlaylistDeleteActionService, - useValue: playlistDeleteAction, - }, - { - provide: PlaylistRefreshService, - useValue: playlistRefreshService, + provide: PlaylistContextFacade, + useValue: { resolvedPlaylistId: signal('xtream') }, }, { provide: RuntimeCapabilitiesService, - useValue: runtime, - }, - { - provide: PlaylistContextFacade, useValue: { - resolvedPlaylistId: signal(null), + isElectron: true, + supportsPlaylistRefresh: true, + supportsXtreamSqliteDataSource: true, }, }, { - provide: MatDialog, + provide: PlaylistRefreshActionService, useValue: { - open: jest.fn(), - }, - }, - { - provide: MatSnackBar, - useValue: snackBar, - }, - { - provide: Router, - useValue: router, - }, - { - provide: SortService, - useValue: { - getSortOptions: jest.fn(() => - of({ - by: SortBy.DATE_ADDED, - order: SortOrder.DESC, - }) - ), - sortPlaylists: jest.fn( - (playlists: PlaylistMeta[]) => playlists - ), - }, - }, - { - provide: TranslateService, - useValue: { - instant: jest.fn((key: string) => key), + isSourceBusy: (id: string) => + switcherRefreshing() === id, }, }, + { provide: MatDialog, useValue: { open: jest.fn() } }, + { provide: Router, useValue: { navigate: jest.fn() } }, ], - }).compileComponents(); - - component = TestBed.createComponent( - RecentPlaylistsComponent - ).componentInstance; + }); + TestBed.overrideComponent(RecentPlaylistsComponent, { + set: { + providers: [ + { + provide: SourceListRefreshService, + useValue: { + isRefreshing: () => false, + canRefresh: () => true, + message: () => '', + progress: () => null, + canCancel: () => false, + }, + }, + ], + }, + }); + fixture = TestBed.createComponent(RecentPlaylistsComponent); + component = fixture.componentInstance; store = TestBed.inject(MockStore); + viewState = TestBed.inject(SourcesViewStateService); jest.spyOn(store, 'dispatch'); + fixture.detectChanges(); }); - afterEach(() => { - setPerformanceHook(null); - jest.restoreAllMocks(); - localStorage.clear(); + const ids = () => component.visiblePlaylists().map((p) => p._id); + + it('lists every source as a row in the chosen order', () => { + expect(ids()).toEqual(['m3u', 'xtream', 'file']); + expect( + (fixture.nativeElement as HTMLElement).querySelectorAll( + 'app-source-row' + ).length + ).toBe(3); }); - it('tracks delete progress and clears the busy row after completion', async () => { - const events: RendererPerformancePhaseEvent[] = []; - setPerformanceHook((event) => events.push(event)); - const item = createPlaylistMeta({ _id: 'playlist-delete-1' }); - const deletion = createDeferred(); + it('checks the sources a saved status filter hides', () => { + fixture.destroy(); + viewState.setStatus('ok'); + statuses.set('xtream', { reachability: 'unknown' }); + const ensureChecked = TestBed.inject(SourceStatusService) + .ensureChecked as jest.Mock; + ensureChecked.mockClear(); - playlistDeleteAction.deletePlaylist.mockImplementation( - ( - _playlist: PlaylistMeta, - options?: { - onEvent?: (event: DbOperationEvent) => void; - } - ) => { - options?.onEvent?.({ - operation: 'delete-playlist', - operationId: 'playlist-delete-op', - status: 'progress', - phase: 'deleting-content', - current: 25, - total: 100, - }); - return deletion.promise; - } + const restarted = TestBed.createComponent(RecentPlaylistsComponent); + restarted.detectChanges(); + TestBed.tick(); + + expect( + restarted.componentInstance.visiblePlaylists().map((p) => p._id) + ).toEqual(['m3u']); + // No row is on screen for it, yet its check runs, so it can join + // the list once it answers. + expect(ensureChecked.mock.calls.map(([p]) => p._id)).toContain( + 'xtream' ); + restarted.destroy(); + }); - const removalPromise = component.removePlaylist(item); + it('sorts by the name it shows: the host for a URL-named source', () => { + store.overrideSelector(selectAllPlaylistsMeta, [ + M3U, + XTREAM, + FILE, + source('url', { + title: 'get.php?username=a&password=b', + url: 'http://zzz.example/get.php?username=a&password=b', + }), + ]); + store.refreshState(); + sort.next({ by: SortBy.NAME, order: SortOrder.ASC }); + fixture.detectChanges(); + expect(ids()).toEqual(['file', 'm3u', 'xtream', 'url']); + }); - expect(component.isDeletePending(item._id)).toBe(true); - expect(component.getBusyMessage(item)).toBe( - 'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_CONTENT' - ); - expect(component.getBusyProgress(item._id)).toBe(25); - expect(component.canCancelBusyOperation(item)).toBe(true); - expect(playlistDeleteAction.deletePlaylist).toHaveBeenCalledWith(item, { - onEvent: expect.any(Function), + it('hands over past a source whose delete is still running', () => { + ( + removal.progress as { isDeleting: (id: string) => boolean } + ).isDeleting = (id) => id === 'xtream'; + component.remove(M3U); + expect(removal.remove).toHaveBeenCalledWith(M3U, FILE); + }); + + it('hands over past a source a refresh holds', () => { + const release = TestBed.inject(SourceActivityService).begin(['xtream']); + component.remove(M3U); + expect(removal.remove).toHaveBeenCalledWith(M3U, FILE); + release(); + }); + + it('keeps a running delete on screen whatever the filters', () => { + ( + removal.progress as { isDeleting: (id: string) => boolean } + ).isDeleting = (id) => id === 'xtream'; + viewState.setType('m3u'); + fixture.detectChanges(); + expect(ids()).toEqual(['m3u', 'xtream', 'file']); + }); + + it('applies the type and status filters together', () => { + viewState.setType('m3u'); + expect(ids()).toEqual(['m3u', 'file']); + viewState.setStatus('ok'); + expect(ids()).toEqual(['m3u']); + viewState.setType('all'); + viewState.setStatus('bad'); + expect(ids()).toEqual(['xtream']); + }); + + it('offers to reset filters when nothing matches', () => { + viewState.setType('stalker'); + fixture.detectChanges(); + const element = fixture.nativeElement as HTMLElement; + expect(element.textContent).toContain('WORKSPACE.SOURCES.NO_MATCHES'); + element + .querySelector('.sources-empty__reset') + ?.click(); + expect(viewState.filter()).toEqual({ type: 'all', status: 'all' }); + }); + + it('puts failing sources first on request', () => { + sort.next({ + by: SortBy.NAME, + order: SortOrder.ASC, + failingFirst: true, }); + expect(ids()).toEqual(['xtream', 'file', 'm3u']); + }); - await component.cancelBusyOperation(item); - expect(databaseService.cancelOperation).toHaveBeenCalledWith( - 'playlist-delete-op' - ); - - deletion.resolve(true); - await removalPromise; - - expect(component.isDeletePending(item._id)).toBe(false); - expect(component.getBusyProgress(item._id)).toBeNull(); - expect(store.dispatch).toHaveBeenCalledWith( - PlaylistActions.playlistRemovalCommitted({ playlistId: item._id }) - ); - expect(snackBar.open).toHaveBeenCalledWith( - 'HOME.PLAYLISTS.REMOVE_DIALOG.SUCCESS', - undefined, - { duration: 2000 } + it('keeps a source busy while the switcher refreshes it', () => { + switcherRefreshing.set('xtream'); + fixture.detectChanges(); + const rows = Array.from( + (fixture.nativeElement as HTMLElement).querySelectorAll( + 'app-source-row' + ) ); expect( - events.map(({ boundary, metadata, outcome, phase }) => ({ - boundary, - items: metadata?.items, - outcome, - phase, - })) - ).toEqual([ - { - boundary: 'start', - items: 1, - outcome: undefined, - phase: 'store.xtream-delete-row', - }, - { - boundary: 'end', - items: 1, - outcome: 'success', - phase: 'store.xtream-delete-row', - }, - ]); - }); - - it('delegates playlist deletion and updates local UI state after success', async () => { - const events: RendererPerformancePhaseEvent[] = []; - setPerformanceHook((event) => events.push(event)); - const item = createPlaylistMeta({ - _id: 'pwa-playlist-1', - serverUrl: undefined, - username: undefined, - password: undefined, - url: 'https://example.com/playlist.m3u', - }); - playlistDeleteAction.deletePlaylist.mockResolvedValue(true); - - await component.removePlaylist(item); - - expect(playlistDeleteAction.deletePlaylist).toHaveBeenCalledWith(item, { - onEvent: expect.any(Function), - }); - expect(databaseService.deletePlaylist).not.toHaveBeenCalled(); - expect(store.dispatch).toHaveBeenCalledWith( - PlaylistActions.playlistRemovalCommitted({ playlistId: item._id }) - ); - expect(snackBar.open).toHaveBeenCalledWith( - 'HOME.PLAYLISTS.REMOVE_DIALOG.SUCCESS', - undefined, - { duration: 2000 } - ); - expect(events).toEqual([]); - }); - - it('records dispatch failure and still clears delete-row busy state', async () => { - const events: RendererPerformancePhaseEvent[] = []; - const item = createPlaylistMeta({ _id: 'dispatch-failure' }); - const dispatchError = new Error('dispatch failed'); - setPerformanceHook((event) => events.push(event)); - (store.dispatch as jest.Mock).mockImplementation(() => { - throw dispatchError; - }); - - await expect(component.removePlaylist(item)).rejects.toBe( - dispatchError - ); - - expect(component.isDeletePending(item._id)).toBe(false); - expect(component.getBusyProgress(item._id)).toBeNull(); - expect(snackBar.open).not.toHaveBeenCalled(); + rows.map((row) => + row + .querySelector('.source-row') + ?.classList.contains('source-row--busy') + ) + ).toEqual([false, true, false]); + // Its menu, and Remove with it, waits for the refresh to settle. expect( - events.map(({ boundary, outcome, phase }) => ({ - boundary, - outcome, - phase, - })) - ).toEqual([ - { - boundary: 'start', - outcome: undefined, - phase: 'store.xtream-delete-row', - }, - { - boundary: 'end', - outcome: 'error', - phase: 'store.xtream-delete-row', - }, - ]); + rows[1].querySelector('.source-row__more') + ?.disabled + ).toBe(true); }); - it('tracks Xtream refresh progress and clears the busy row after abort', async () => { - const item = createPlaylistMeta({ _id: 'playlist-refresh-1' }); - const refresh = createDeferred<{ - success: boolean; - favorites: Array<{ xtreamId: number; contentType: string }>; - recentlyViewed: Array<{ - xtreamId: number; - contentType: string; - viewedAt: string; - }>; - hiddenCategories: Array<{ - xtreamId: number; - categoryType: string; - }>; - }>(); - let confirmPromise: Promise | undefined; - // The shared flow awaits the connectivity-guard reset before it starts - // deleting, so the delete is several ticks away and counting them is - // fragile. The mock fires onEvent synchronously and the component - // applies it synchronously, so resolving this deferred inside the mock - // is a deterministic "the busy row has been updated" signal. - const workerEventDelivered = createDeferred(); - - dialogService.openConfirmDialog.mockImplementation( - ({ onConfirm }: { onConfirm?: () => Promise }) => { - confirmPromise = onConfirm?.(); - } - ); - - databaseService.deleteXtreamPlaylistContent.mockImplementation( - ( - _playlistId: string, - options?: { - onEvent?: (event: DbOperationEvent) => void; - operationId?: string; - } - ) => { - options?.onEvent?.({ - operation: 'delete-xtream-content', - operationId: 'xtream-refresh-op', - status: 'progress', - phase: 'collecting-user-data', - current: 1, - total: 4, - }); - workerEventDelivered.resolve(); - - return refresh.promise; - } - ); - - component.refreshXtreamPlaylist(item); - await workerEventDelivered.promise; - - expect(component.isRefreshPending(item._id)).toBe(true); - expect(component.getBusyMessage(item)).toBe( - 'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.COLLECTING_DATA' - ); - expect(component.getBusyProgress(item._id)).toBe(25); - expect(component.canCancelBusyOperation(item)).toBe(true); - - await component.cancelBusyOperation(item); - expect(databaseService.cancelOperation).toHaveBeenCalledWith( - 'xtream-refresh-op' - ); - - refresh.reject(createAbortError()); - await confirmPromise; - - expect(component.isRefreshPending(item._id)).toBe(false); - expect(component.getBusyProgress(item._id)).toBeNull(); - expect(router.navigate).not.toHaveBeenCalled(); + it('offers no Refresh while something else holds a source', () => { + const canRefresh = () => + fixture.debugElement + .queryAll(By.directive(SourceRowComponent)) + .map((row) => + (row.componentInstance as SourceRowComponent).canRefresh() + ); + expect(canRefresh()).toEqual([true, true, true]); + // The startup auto-refresh or an import: refresh() would refuse. + const release = TestBed.inject(SourceActivityService).begin(['m3u']); + fixture.detectChanges(); + expect(canRefresh()).toEqual([false, true, true]); + release(); + fixture.detectChanges(); + expect(canRefresh()).toEqual([true, true, true]); }); - it('dispatches playlist meta with updateDate after Xtream refresh setup succeeds', async () => { - const item = createPlaylistMeta({ _id: 'playlist-refresh-success-1' }); - let confirmPromise: Promise | undefined; - const executionOrder: string[] = []; - const performanceEvents: RendererPerformancePhaseEvent[] = []; - setPerformanceHook((event) => { - performanceEvents.push(event); - executionOrder.push(event.boundary); - }); - const dateNowSpy = jest - .spyOn(Date, 'now') - .mockReturnValue(1712145600000); - const originalSetItem = Storage.prototype.setItem; - const setItemSpy = jest - .spyOn(Storage.prototype, 'setItem') - .mockImplementation(function ( - this: Storage, - key: string, - value: string - ) { - executionOrder.push('setItem'); - return originalSetItem.call(this, key, value); - }); + it('offers no Remove while something else holds a source', () => { + const removable = () => + fixture.debugElement + .queryAll(By.directive(SourceRowComponent)) + .map((row) => + (row.componentInstance as SourceRowComponent).removable() + ); + expect(removable()).toEqual([true, true, true]); + // The startup auto-refresh, or a refresh from the switcher. + const release = TestBed.inject(SourceActivityService).begin(['m3u']); + fixture.detectChanges(); + expect(removable()).toEqual([false, true, true]); + release(); + }); - dialogService.openConfirmDialog.mockImplementation( - ({ onConfirm }: { onConfirm?: () => Promise }) => { - confirmPromise = onConfirm?.(); - } - ); - (store.dispatch as jest.Mock).mockImplementation((action: unknown) => { - executionOrder.push('dispatch'); - return action; - }); - router.navigate.mockImplementation((commands: unknown[]) => { - executionOrder.push('navigate'); - return Promise.resolve(Boolean(commands)); - }); - databaseService.deleteXtreamPlaylistContent.mockResolvedValue({ - success: true, - favorites: [ - { xtreamId: 101, contentType: 'live' }, - { xtreamId: 202, contentType: 'movie' }, - ], - recentlyViewed: [ - { - xtreamId: 303, - contentType: 'series', - viewedAt: '2026-04-03T11:15:00.000Z', - }, - ], - hiddenCategories: [{ xtreamId: 404, categoryType: 'live' }], - }); + it('hides sources whose delete is pending', () => { + pending.set(new Set(['m3u'])); + expect(ids()).toEqual(['xtream', 'file']); + }); - component.refreshXtreamPlaylist(item); - await confirmPromise; + it('hands the active source over to the next row on delete', () => { + component.remove(XTREAM); + expect(removal.remove).toHaveBeenCalledWith(XTREAM, FILE); + component.remove(FILE); + expect(removal.remove).toHaveBeenLastCalledWith(FILE, XTREAM); + }); - expect(dialogService.openConfirmDialog).toHaveBeenCalledWith( - expect.objectContaining({ - width: '400px', + it('reorders the shown rows without moving the hidden ones', () => { + viewState.setType('m3u'); + component.drop({ previousIndex: 1, currentIndex: 0 } as never); + expect(store.dispatch).toHaveBeenCalledWith( + PlaylistActions.updatePlaylistPositions({ + positionUpdates: [ + { id: 'file', changes: { position: 0 } }, + { id: 'xtream', changes: { position: 1 } }, + { id: 'm3u', changes: { position: 2 } }, + ], }) ); - expect( - databaseService.updateXtreamPlaylistDetails - ).toHaveBeenCalledWith({ - id: item._id, - updateDate: 1712145600000, - }); - expect(store.dispatch).toHaveBeenCalledWith( - PlaylistActions.updatePlaylistMeta({ - playlist: { ...item, updateDate: 1712145600000 }, - }) - ); - expect(setItemSpy).toHaveBeenCalledWith( - `xtream-restore-${item._id}`, - expect.any(String) - ); - const persistedRestoreState = JSON.parse( - setItemSpy.mock.calls.find( - ([key]) => key === `xtream-restore-${item._id}` - )?.[1] ?? 'null' - ); - expect(persistedRestoreState).toEqual({ - hiddenCategories: [{ xtreamId: 404, categoryType: 'live' }], - favorites: [ - { xtreamId: 101, contentType: 'live' }, - { xtreamId: 202, contentType: 'movie' }, - ], - recentlyViewed: [ - { - xtreamId: 303, - contentType: 'series', - viewedAt: '2026-04-03T11:15:00.000Z', - }, - ], - playbackPositions: [], - }); - expect(router.navigate).toHaveBeenCalledWith([ - '/workspace', - 'xtreams', - item._id, - ]); - expect(executionOrder).toEqual([ - 'setItem', - 'start', - 'dispatch', - 'end', - 'navigate', - ]); - expect(performanceEvents).toHaveLength(2); - expect(performanceEvents[0]?.phaseId).toBe( - performanceEvents[1]?.phaseId - ); - expect( - performanceEvents.map(({ boundary, metadata, outcome, phase }) => ({ - boundary, - metadata, - outcome, - phase, - })) - ).toEqual([ - { - boundary: 'start', - metadata: { items: 1 }, - outcome: undefined, - phase: 'store.xtream-refresh-meta', - }, - { - boundary: 'end', - metadata: { items: 1 }, - outcome: 'success', - phase: 'store.xtream-refresh-meta', - }, - ]); - - setItemSpy.mockRestore(); - dateNowSpy.mockRestore(); - }); - - it('uses the legacy IPC refresh flow for non-Xtream playlists', () => { - runtime.supportsPlaylistRefresh = false; - const item = createPlaylistMeta({ - _id: 'playlist-m3u-1', - serverUrl: undefined, - username: undefined, - password: undefined, - filePath: undefined, - url: 'https://example.com/test.m3u', - userAgent: 'IPTVnator-Test/1.0', - }); - - component.refreshPlaylist(item); - - expect(dataService.sendIpcEvent).toHaveBeenCalledWith(PLAYLIST_UPDATE, { - id: item._id, - title: item.title, - url: item.url, - userAgent: item.userAgent, - }); - }); - - it('does not use legacy IPC refresh for file-backed playlists without the refresh bridge', () => { - runtime.supportsPlaylistRefresh = false; - const item = createPlaylistMeta({ - _id: 'playlist-m3u-1', - serverUrl: undefined, - username: undefined, - password: undefined, - filePath: '/tmp/test.m3u', - }); - - component.refreshPlaylist(item); - - expect(dataService.sendIpcEvent).not.toHaveBeenCalled(); - expect(playlistRefreshService.refreshPlaylist).not.toHaveBeenCalled(); - }); - - it('re-evaluates refresh bridge availability when refreshing local M3U playlists', async () => { - runtime.supportsPlaylistRefresh = false; - const lateComponent = TestBed.createComponent( - RecentPlaylistsComponent - ).componentInstance; - runtime.supportsPlaylistRefresh = true; - const item = createPlaylistMeta({ - _id: 'playlist-m3u-2', - serverUrl: undefined, - username: undefined, - password: undefined, - filePath: '/tmp/test.m3u', - userAgent: 'IPTVnator-Test/1.0', - }); - - lateComponent.refreshPlaylist(item); - - expect(playlistRefreshService.refreshPlaylist).toHaveBeenCalledWith( - { - operationId: 'playlist-refresh-op', - playlistId: item._id, - title: item.title, - url: item.url, - userAgent: item.userAgent, - filePath: item.filePath, - }, - { - onEvent: expect.any(Function), - } - ); - expect(dataService.sendIpcEvent).not.toHaveBeenCalled(); - - await Promise.resolve(); - expect(store.dispatch).toHaveBeenCalledWith( - PlaylistActions.updatePlaylist({ - playlist: expect.objectContaining({ - _id: item._id, - }), - playlistId: item._id, - refreshEpg: true, - operationId: 'playlist-refresh-op', - }) - ); - }); - - it('clears the connectivity guard before deleting the cached Xtream catalog', async () => { - // Second, independent implementation of the destructive refresh (the - // Workspace sources page). Same consequence as the shared action: the - // catalog is already gone by the time an open guard fast-fails the - // re-import bootstrap. - const item = { - _id: 'xtream-guard', - title: 'Guarded Xtream', - serverUrl: 'http://panel.example:8080', - } as PlaylistMeta; - const order: string[] = []; - let confirmPromise: Promise | undefined; - - dataService.sendIpcEvent.mockImplementation((event: string) => { - order.push(`ipc:${event}`); - return Promise.resolve({ success: true }); - }); - databaseService.deleteXtreamPlaylistContent.mockImplementation(() => { - order.push('deleteXtreamPlaylistContent'); - return Promise.resolve({ - hiddenCategories: [], - favorites: [], - recentlyViewed: [], - sourcePins: [], - }); - }); - dialogService.openConfirmDialog.mockImplementation( - ({ onConfirm }: { onConfirm?: () => Promise }) => { - confirmPromise = onConfirm?.(); - } - ); - - component.refreshXtreamPlaylist(item); - await confirmPromise; - - expect(order[0]).toBe(`ipc:${CONNECTIVITY_GUARD_RESET}`); - expect(order).toContain('deleteXtreamPlaylistContent'); - expect(dataService.sendIpcEvent).toHaveBeenCalledWith( - CONNECTIVITY_GUARD_RESET, - { url: item.serverUrl } - ); }); }); diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts index 674a3b366..6511a2b05 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts @@ -6,97 +6,86 @@ import { import { ChangeDetectionStrategy, Component, - effect, + computed, inject, input, output, - signal, } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; +import { MatButton } from '@angular/material/button'; import { MatDialog } from '@angular/material/dialog'; -import { MatInputModule } from '@angular/material/input'; -import { MatSnackBar } from '@angular/material/snack-bar'; +import { MatIcon } from '@angular/material/icon'; import { Router } from '@angular/router'; import { Store } from '@ngrx/store'; -import { TranslateService } from '@ngx-translate/core'; -import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util'; +import { TranslatePipe } from '@ngx-translate/core'; import { PlaylistActions, - selectActiveTypeFilters, selectAllPlaylistsMeta, selectPlaylistsLoadingFlag, } from '@iptvnator/m3u-state'; -import { BehaviorSubject, combineLatest, map } from 'rxjs'; -import { DialogService } from '@iptvnator/ui/components'; import { - DatabaseService, - DataService, - DbOperationEvent, - isDbAbortError, - PlaylistDeleteActionService, - PlaylistRefreshService, + keepSourcesChecked, + SourceStatusService, + sourceStatusBucket, +} from '@iptvnator/portal/shared/data-access'; +import { + filterSources, + orderFailingFirst, + PlaylistContextFacade, + replacementAfterRemoval, + SourceBucketOf, + sourceSortName, + SourcesViewStateService, +} from '@iptvnator/playlist/shared/util'; +import { RuntimeCapabilitiesService, SortBy, SortService, + SourceActivityService, } from '@iptvnator/services'; -import { - foldSearchText, - PLAYLIST_UPDATE, - PlaylistMeta, - PlaylistRefreshEvent, -} from '@iptvnator/shared/interfaces'; -import { - measureRendererPerformancePhase, - RENDERER_PERFORMANCE_PHASE, -} from '@iptvnator/shared/logging'; - -import { - XtreamRefreshFlowService, - type XtreamRefreshProgressReporter, -} from '../xtream-refresh-flow.service'; -import { EmptyStateComponent } from './empty-state/empty-state.component'; +import { PlaylistMeta } from '@iptvnator/shared/interfaces'; import type { PlaylistType } from '../add-playlist-menu/playlist-type'; +import { PlaylistRefreshActionService } from '../playlist-refresh-action.service'; +import { SourceRemovalService } from '../source-removal/source-removal.service'; +import { SourceRowComponent } from '../source-row/source-row.component'; +import { EmptyStateComponent } from './empty-state/empty-state.component'; import { PlaylistInfoComponent } from './playlist-info/playlist-info.component'; -import { PlaylistItemComponent } from './playlist-item/playlist-item.component'; - -type PlaylistBusyOperation = { - current?: number; - operation: string; - operationId?: string; - phase?: string; - status: DbOperationEvent['status']; - total?: number; -}; +import { SourceListRefreshService } from './source-list-refresh.service'; +/** + * The Sources page list: every source that passes the side list's type and + * status filters and the header search, in the chosen sort order, as + * `app-source-row`s. Sources being deleted (Undo pending) are left out. + */ @Component({ selector: 'app-recent-playlists', templateUrl: './recent-playlists.component.html', styleUrls: ['./recent-playlists.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, + providers: [SourceListRefreshService], imports: [ DragDropModule, EmptyStateComponent, - MatInputModule, - PlaylistItemComponent, + MatButton, + MatIcon, + SourceRowComponent, + TranslatePipe, ], }) export class RecentPlaylistsComponent { - private readonly databaseService = inject(DatabaseService); private readonly dialog = inject(MatDialog); - private readonly dialogService = inject(DialogService); - private readonly dataService = inject(DataService); - private readonly playlistRefreshService = inject(PlaylistRefreshService); private readonly router = inject(Router); - private readonly snackBar = inject(MatSnackBar); private readonly sortService = inject(SortService); private readonly store = inject(Store); private readonly runtime = inject(RuntimeCapabilitiesService); - private readonly translate = inject(TranslateService); private readonly playlistContext = inject(PlaylistContextFacade); - private readonly playlistDeleteAction = inject(PlaylistDeleteActionService); - private readonly xtreamRefreshFlow = inject(XtreamRefreshFlowService); + private readonly statusService = inject(SourceStatusService); + private readonly viewState = inject(SourcesViewStateService); + readonly removal = inject(SourceRemovalService); + readonly refresh = inject(SourceListRefreshService); + private readonly refreshAction = inject(PlaylistRefreshActionService); + private readonly activity = inject(SourceActivityService); - readonly sidebarMode = input(false); readonly searchQueryInput = input(''); readonly playlistClicked = output(); readonly addPlaylistClicked = output(); @@ -105,115 +94,96 @@ export class RecentPlaylistsComponent { return this.runtime.isElectron; } - get supportsPlaylistRefresh(): boolean { - return this.runtime.supportsPlaylistRefresh; - } - - get supportsXtreamSqliteDataSource(): boolean { - return this.runtime.supportsXtreamSqliteDataSource; - } - readonly allPlaylistsLoaded = this.store.selectSignal( selectPlaylistsLoadingFlag ); + private readonly allPlaylists = this.store.selectSignal( + selectAllPlaylistsMeta + ); readonly activePlaylistId = this.playlistContext.resolvedPlaylistId; - - private readonly currentSortOptions = toSignal( - this.sortService.getSortOptions(), - { requireSync: true } + private readonly sortOptions = toSignal(this.sortService.getSortOptions(), { + requireSync: true, + }); + readonly isCustomSort = computed( + () => this.sortOptions().by === SortBy.CUSTOM ); - - readonly isCustomSortActive = () => - this.currentSortOptions().by === SortBy.CUSTOM; - - readonly searchQuery = new BehaviorSubject(''); - readonly pendingDeletionIds = signal>(new Set()); - readonly pendingRefreshIds = signal>(new Set()); - readonly busyOperations = signal>( - new Map() - ); - - constructor() { - // Update searchQuery when input changes - effect(() => { - this.searchQuery.next(this.searchQueryInput()); - }); - } - - readonly playlistsData$ = combineLatest([ - this.store.select(selectAllPlaylistsMeta), - this.searchQuery, - this.store.select(selectActiveTypeFilters), - this.sortService.getSortOptions(), - ]).pipe( - map(([playlists, searchQuery, filters, sortOptions]) => { - const filteredPlaylists = playlists - .filter((item) => { - const isStalkerFilter = - item.macAddress && filters.includes('stalker'); - const isXtreamFilter = - item.username && - item.password && - item.serverUrl && - filters.includes('xtream'); - const isM3uFilter = - !item.username && - !item.password && - !item.serverUrl && - !item.macAddress && - filters.includes('m3u'); - - return ( - (isStalkerFilter && filters.includes('stalker')) || - (isXtreamFilter && filters.includes('xtream')) || - (isM3uFilter && filters.includes('m3u')) - ); - }) - .filter((item) => - foldSearchText(item.title || '').includes( - foldSearchText(searchQuery) - ) - ); - - // Apply sorting using the SortService - const sortedPlaylists = this.sortService.sortPlaylists( - filteredPlaylists, - sortOptions - ); - - return { - playlists: sortedPlaylists, - totalCount: playlists.length, - }; - }) - ); - - readonly playlistsData = toSignal(this.playlistsData$, { - initialValue: { - playlists: [] as PlaylistMeta[], - totalCount: 0, - }, + readonly hasFilters = computed(() => { + const filter = this.viewState.filter(); + return filter.type !== 'all' || filter.status !== 'all'; }); - /** - * Opens the details dialog with the information about the provided playlist - * @param data selected playlist - */ - openInfoDialog(data: PlaylistMeta): void { - this.dialog.open(PlaylistInfoComponent, { - data, - }); + /** Every source except those whose delete is pending. */ + readonly playlists = computed(() => { + const pending = this.removal.pendingIds(); + return this.allPlaylists().filter( + (playlist) => !pending.has(playlist._id) + ); + }); + + readonly visiblePlaylists = computed(() => { + const bucketOf: SourceBucketOf = (playlist) => { + const status = this.statusService.statusOf(playlist); + return status ? sourceStatusBucket(status.reachability) : null; + }; + const options = this.sortOptions(); + const filter = this.viewState.filter(); + const matching = filterSources( + this.playlists(), + this.statusService.supportsStatusFilter + ? filter + : { ...filter, status: 'all' }, + this.searchQueryInput(), + bucketOf + ); + // A running delete keeps its row, whatever the filters: its + // progress and Cancel live there (one started from the switcher + // has nowhere else to show). + const filtered = this.playlists().filter( + (playlist) => + matching.includes(playlist) || + this.removal.progress.isDeleting(playlist._id) + ); + const sorted = this.sortService.sortPlaylists( + filtered, + options, + sourceSortName + ); + return options.failingFirst && options.by !== SortBy.CUSTOM + ? orderFailingFirst(sorted, bucketOf) + : sorted; + }); + + constructor() { + // A status filter hides the sources it has no result for, so the + // checks follow the library rather than the rows on screen. + keepSourcesChecked(() => this.playlists()); + } + + resetFilters(): void { + this.viewState.reset(); } /** - * Drop event handler - applies the new sort order to the playlists array - * @param event drop event + * Applies a drag in custom order to the whole library: the shown rows + * trade places among the slots they occupy, and sources a filter hides + * keep theirs, so every position stays unique. */ - drop(event: CdkDragDrop, playlists: PlaylistMeta[]): void { - moveItemInArray(playlists, event.previousIndex, event.currentIndex); + drop(event: CdkDragDrop): void { + const visible = [...this.visiblePlaylists()]; + moveItemInArray(visible, event.previousIndex, event.currentIndex); + const visibleIds = new Set(visible.map((item) => item._id)); + const ordered = this.sortService.sortPlaylists( + this.allPlaylists(), + this.sortOptions(), + sourceSortName + ); + let shown = 0; + const merged = ordered.map((item) => + visibleIds.has(item._id) ? visible[shown++] : item + ); this.store.dispatch( PlaylistActions.updatePlaylistPositions({ - positionUpdates: playlists.map((item, index) => ({ + positionUpdates: merged.map((item, index) => ({ id: item._id, changes: { position: index }, })), @@ -221,475 +191,100 @@ export class RecentPlaylistsComponent { ); } - onAddPlaylist(type?: PlaylistType) { + open(playlist: PlaylistMeta): void { + if (playlist.serverUrl) { + void this.router.navigate(['/workspace', 'xtreams', playlist._id]); + } else if (playlist.macAddress) { + void this.router.navigate(['/workspace', 'stalker', playlist._id]); + } else { + void this.router.navigate([ + '/workspace', + 'playlists', + playlist._id, + ]); + this.playlistClicked.emit(playlist._id); + } + } + + edit(playlist: PlaylistMeta): void { + this.dialog.open(PlaylistInfoComponent, { data: playlist }); + } + + /** Deletes with Undo; the active source hands over to its neighbour. */ + remove(playlist: PlaylistMeta): void { + if (this.refresh.isRefreshing(playlist._id)) { + return; + } + const next = replacementAfterRemoval( + this.visiblePlaylists(), + playlist._id, + (row) => !this.removal.canTakeOver(row._id) + ); + this.removal.remove(playlist, next); + } + + /** Refreshing from this page, or from the playlist switcher. */ + isRefreshing(item: PlaylistMeta): boolean { + return ( + this.refresh.isRefreshing(item._id) || + this.refreshAction.isSourceBusy(item._id) + ); + } + + /** + * Refresh is offered while it can run, and kept (with its spinner) + * while this source refreshes. Held by anything else (the startup + * auto-refresh, an import, a delete), refresh() would refuse it. + */ + canRefresh(item: PlaylistMeta): boolean { + return ( + this.refresh.canRefresh(item) && + (this.isRefreshing(item) || !this.activity.isBusy(item._id)) + ); + } + + /** Nothing else (a refresh, the startup auto-refresh) holds it. */ + isRemovable(item: PlaylistMeta): boolean { + return !this.activity.isBusy(item._id); + } + + busyMessage(item: PlaylistMeta): string { + return ( + this.removal.progress.message(item._id) || + this.refresh.message(item) + ); + } + + busyProgress(item: PlaylistMeta): number | null { + return this.removal.progress.isDeleting(item._id) + ? this.removal.progress.progress(item._id) + : this.refresh.progress(item._id); + } + + canCancelBusy(item: PlaylistMeta): boolean { + return ( + this.removal.progress.canCancel(item._id) || + this.refresh.canCancel(item) + ); + } + + cancelBusy(item: PlaylistMeta): void { + if (this.removal.progress.isDeleting(item._id)) { + void this.removal.progress.cancel(item._id); + } else { + void this.refresh.cancel(item); + } + } + + onAddPlaylist(type?: PlaylistType): void { this.addPlaylistClicked.emit(type); } - getPlaylist(playlistMeta: PlaylistMeta): void { - if (playlistMeta.serverUrl) { - this.router.navigate(['/workspace', 'xtreams', playlistMeta._id]); - } else if (playlistMeta.macAddress) { - this.router.navigate(['/workspace', 'stalker', playlistMeta._id]); - } else { - this.router.navigate(['/workspace', 'playlists', playlistMeta._id]); - this.playlistClicked.emit(playlistMeta._id); - } - } - - /** - * Triggers on remove click - * @param playlistId playlist id to remove - */ - removeClicked(item: PlaylistMeta): void { - if (this.isDeletePending(item._id) || this.isRefreshPending(item._id)) { - return; - } - - this.dialogService.openConfirmDialog({ - title: this.translate.instant('HOME.PLAYLISTS.REMOVE_DIALOG.TITLE'), - message: this.translate.instant( - 'HOME.PLAYLISTS.REMOVE_DIALOG.MESSAGE' - ), - confirmLabel: this.translate.instant('HOME.PLAYLISTS.REMOVE'), - tone: 'destructive', - onConfirm: () => { - this.removePlaylist(item); - }, - }); - } - - /** - * Removes the provided playlist from the database - * @param playlistId playlist id to remove - */ - async removePlaylist(item: PlaylistMeta) { - if (this.isDeletePending(item._id) || this.isRefreshPending(item._id)) { - return; - } - - this.setPendingDeletion(item._id, true); - try { - const deleted = await this.playlistDeleteAction.deletePlaylist( - item, - { - onEvent: (event) => - this.updateBusyOperation(item._id, event), - } - ); - if (deleted) { - if (item.serverUrl) { - measureRendererPerformancePhase( - RENDERER_PERFORMANCE_PHASE.XTREAM_DELETE_ROW, - () => - this.store.dispatch( - PlaylistActions.playlistRemovalCommitted({ - playlistId: item._id, - }) - ), - () => ({ items: 1 }) - ); - } else { - this.store.dispatch( - PlaylistActions.playlistRemovalCommitted({ - playlistId: item._id, - }) - ); - } - this.snackBar.open( - this.translate.instant( - 'HOME.PLAYLISTS.REMOVE_DIALOG.SUCCESS' - ), - undefined, - { - duration: 2000, - } - ); - } - } finally { - this.clearBusyOperation(item._id); - this.setPendingDeletion(item._id, false); - } - } - - /** - * Sends an IPC event with the playlist details to the main process to trigger the refresh operation - * @param item playlist to update - */ - refreshPlaylist(item: PlaylistMeta) { - if (this.isDeletePending(item._id) || this.isRefreshPending(item._id)) { - return; - } - - if (item.serverUrl && this.supportsXtreamSqliteDataSource) { - // For Xtream playlists, delete and re-import - this.refreshXtreamPlaylist(item); - } else if ( - this.supportsPlaylistRefresh && - (item.url || item.filePath) - ) { - void this.refreshM3uPlaylist(item); - } else if (item.url) { - // Browser/PWA URL refresh uses the PWA data service path. - this.dataService.sendIpcEvent(PLAYLIST_UPDATE, { - id: item._id, - title: item.title, - url: item.url, - ...(item.userAgent ? { userAgent: item.userAgent } : {}), - }); - } - } - - /** - * Refresh Xtream playlist by deleting all data and re-importing from remote - * @param item Xtream playlist to refresh - */ - refreshXtreamPlaylist(item: PlaylistMeta): void { - if (this.isDeletePending(item._id) || this.isRefreshPending(item._id)) { - return; - } - - this.xtreamRefreshFlow.confirmAndRefresh( - item, - this.xtreamRefreshReporter - ); - } - - /** - * Reports the shared destructive refresh into this page's per-row busy - * indicators, which are keyed by playlist and shared with deletion — hence - * the same `updateBusyOperation`/`clearBusyOperation` pair used there. - */ - private readonly xtreamRefreshReporter: XtreamRefreshProgressReporter = { - isBusy: (playlistId) => - this.isDeletePending(playlistId) || - this.isRefreshPending(playlistId), - begin: ({ playlistId }) => this.setPendingRefresh(playlistId, true), - report: ({ playlistId }, event) => - this.updateBusyOperation(playlistId, event), - end: ({ playlistId }) => { - this.clearBusyOperation(playlistId); - this.setPendingRefresh(playlistId, false); - }, - }; - - private async refreshM3uPlaylist(item: PlaylistMeta): Promise { - if (this.isDeletePending(item._id) || this.isRefreshPending(item._id)) { - return; - } - - this.setPendingRefresh(item._id, true); - const operationId = - this.databaseService.createOperationId('playlist-refresh'); - - try { - const refreshedPlaylist = - await this.playlistRefreshService.refreshPlaylist( - { - operationId, - playlistId: item._id, - title: item.title, - url: item.url, - ...(item.userAgent - ? { userAgent: item.userAgent } - : {}), - filePath: item.filePath, - }, - { - onEvent: (event) => - this.updateBusyOperation( - item._id, - this.toPlaylistRefreshBusyEvent(event) - ), - } - ); - - this.updateBusyOperation(item._id, { - operationId, - operation: 'playlist-refresh', - playlistId: item._id, - phase: 'saving', - status: 'progress', - }); - - this.store.dispatch( - PlaylistActions.updatePlaylist({ - playlist: { - ...refreshedPlaylist, - _id: item._id, - }, - playlistId: item._id, - refreshEpg: true, - operationId, - }) - ); - - this.clearBusyOperation(item._id); - this.snackBar.open( - this.translate.instant( - 'HOME.PLAYLISTS.PLAYLIST_UPDATE_SUCCESS' - ), - undefined, - { duration: 2000 } - ); - } catch (error) { - if (!isDbAbortError(error)) { - console.error('Error refreshing playlist:', error); - this.snackBar.open( - this.getPlaylistRefreshErrorMessage(error, item), - this.translate.instant('CLOSE'), - { duration: 5000 } - ); - } - } finally { - this.clearBusyOperation(item._id); - this.setPendingRefresh(item._id, false); - } - } - isDeletePending(playlistId: string): boolean { - return this.pendingDeletionIds().has(playlistId); + return this.removal.isPending(playlistId); } isRefreshPending(playlistId: string): boolean { - return this.pendingRefreshIds().has(playlistId); - } - - getBusyMessage(item: PlaylistMeta): string { - const operation = this.busyOperations().get(item._id); - if (!operation) { - return ''; - } - - switch (operation.operation) { - case 'delete-playlist': - return this.translateDeletePhase(operation.phase); - case 'delete-xtream-content': - return this.translateRefreshPhase(operation.phase); - case 'playlist-refresh': - return this.translatePlaylistRefreshPhase(operation.phase); - default: - return ''; - } - } - - getBusyProgress(playlistId: string): number | null { - const operation = this.busyOperations().get(playlistId); - if ( - !operation || - operation.current == null || - operation.total == null || - operation.total <= 0 - ) { - return null; - } - - return Math.min( - 100, - Math.round((operation.current / operation.total) * 100) - ); - } - - canCancelBusyOperation(item: PlaylistMeta): boolean { - const operation = this.busyOperations().get(item._id); - if (!operation?.operationId) { - return false; - } - - if (operation.operation === 'playlist-refresh') { - return true; - } - - return Boolean(item.serverUrl); - } - - async cancelBusyOperation(item: PlaylistMeta): Promise { - const operation = this.busyOperations().get(item._id); - if (!operation?.operationId) { - return; - } - - if (operation.operation === 'playlist-refresh') { - await this.playlistRefreshService.cancelRefresh( - operation.operationId - ); - return; - } - - await this.databaseService.cancelOperation(operation.operationId); - } - - private setPendingDeletion(playlistId: string, isPending: boolean): void { - this.pendingDeletionIds.update((current) => { - const next = new Set(current); - if (isPending) { - next.add(playlistId); - } else { - next.delete(playlistId); - } - return next; - }); - } - - private setPendingRefresh(playlistId: string, isPending: boolean): void { - this.pendingRefreshIds.update((current) => { - const next = new Set(current); - if (isPending) { - next.add(playlistId); - } else { - next.delete(playlistId); - } - return next; - }); - } - - private updateBusyOperation( - playlistId: string, - event: DbOperationEvent - ): void { - this.busyOperations.update((current) => { - const next = new Map(current); - - if ( - event.status === 'completed' || - event.status === 'cancelled' || - event.status === 'error' - ) { - next.delete(playlistId); - return next; - } - - next.set(playlistId, { - operation: event.operation, - operationId: event.operationId, - phase: event.phase, - current: event.current, - total: event.total, - status: event.status, - }); - return next; - }); - } - - private clearBusyOperation(playlistId: string): void { - this.busyOperations.update((current) => { - const next = new Map(current); - next.delete(playlistId); - return next; - }); - } - - private toPlaylistRefreshBusyEvent( - event: PlaylistRefreshEvent - ): DbOperationEvent { - return { - operationId: event.operationId, - operation: 'playlist-refresh', - playlistId: event.playlistId, - phase: event.phase, - status: event.status, - error: event.error, - }; - } - - private translateDeletePhase(phase?: string): string { - switch (phase) { - case 'deleting-favorites': - return this.translate.instant( - 'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_FAVORITES' - ); - case 'deleting-recently-viewed': - return this.translate.instant( - 'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_RECENT' - ); - case 'deleting-playback-positions': - return this.translate.instant( - 'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_PROGRESS' - ); - case 'deleting-downloads': - return this.translate.instant( - 'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_DOWNLOADS' - ); - case 'deleting-content': - return this.translate.instant( - 'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_CONTENT' - ); - case 'deleting-categories': - return this.translate.instant( - 'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_CATEGORIES' - ); - case 'deleting-playlist': - return this.translate.instant( - 'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_PLAYLIST' - ); - default: - return this.translate.instant( - 'HOME.PLAYLISTS.REMOVE_DIALOG.IN_PROGRESS' - ); - } - } - - private translateRefreshPhase(phase?: string): string { - switch (phase) { - case 'collecting-user-data': - return this.translate.instant( - 'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.COLLECTING_DATA' - ); - case 'deleting-content': - return this.translate.instant( - 'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.DELETING_CONTENT' - ); - case 'deleting-categories': - return this.translate.instant( - 'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.DELETING_CATEGORIES' - ); - default: - return this.translate.instant( - 'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.IN_PROGRESS' - ); - } - } - - private translatePlaylistRefreshPhase(phase?: string): string { - switch (phase) { - case 'fetching': - return this.translate.instant( - 'HOME.PLAYLISTS.REFRESH_FETCHING' - ); - case 'reading-file': - return this.translate.instant( - 'HOME.PLAYLISTS.REFRESH_READING_FILE' - ); - case 'parsing': - return this.translate.instant('HOME.PLAYLISTS.REFRESH_PARSING'); - case 'saving': - return this.translate.instant('HOME.PLAYLISTS.REFRESH_SAVING'); - default: - return this.translate.instant('HOME.PLAYLISTS.REFRESH'); - } - } - - private getPlaylistRefreshErrorMessage( - error: unknown, - item: PlaylistMeta - ): string { - if (item.filePath) { - const message = String( - (error as { message?: string })?.message ?? error - ); - - if (/(ENOENT|no such file or directory|not found)/i.test(message)) { - return this.translate.instant( - 'HOME.PLAYLISTS.PLAYLIST_UPDATE_FILE_NOT_FOUND' - ); - } - - if (/(EACCES|EPERM|permission denied)/i.test(message)) { - return this.translate.instant( - 'HOME.PLAYLISTS.PLAYLIST_UPDATE_FILE_ACCESS_ERROR' - ); - } - } - - return this.translate.instant('HOME.PLAYLISTS.PLAYLIST_UPDATE_ERROR'); + return this.refresh.isRefreshing(playlistId); } } diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/source-list-refresh.service.spec.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/source-list-refresh.service.spec.ts new file mode 100644 index 000000000..90a28940e --- /dev/null +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/source-list-refresh.service.spec.ts @@ -0,0 +1,639 @@ +import { signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { MatDialog } from '@angular/material/dialog'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { Router } from '@angular/router'; +import { MockStore, provideMockStore } from '@ngrx/store/testing'; +import { TranslateService } from '@ngx-translate/core'; +import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util'; +import { + PlaylistActions, + selectAllPlaylistsMeta, + selectPlaylistsLoadingFlag, +} from '@iptvnator/m3u-state'; +import { of } from 'rxjs'; +import { DialogService } from '@iptvnator/ui/components'; +import { + DatabaseService, + DataService, + DbOperationEvent, + PlaybackPositionService, + PlaylistDeleteActionService, + PlaylistRefreshService, + RuntimeCapabilitiesService, + SortBy, + SortOrder, + SortService, + SourceActivityService, +} from '@iptvnator/services'; +import { + CONNECTIVITY_GUARD_RESET, + PLAYLIST_UPDATE, + PlaylistMeta, +} from '@iptvnator/shared/interfaces'; +import { + RENDERER_PERFORMANCE_PHASE_HOOK_KEY, + type RendererPerformancePhaseEvent, +} from '@iptvnator/shared/logging'; +import { SourceListRefreshService } from './source-list-refresh.service'; + +const performanceHookSymbol = Symbol.for(RENDERER_PERFORMANCE_PHASE_HOOK_KEY); + +function setPerformanceHook( + hook: ((event: RendererPerformancePhaseEvent) => void) | null +): void { + const target = globalThis as unknown as Record; + if (hook === null) { + delete target[performanceHookSymbol]; + } else { + target[performanceHookSymbol] = hook; + } +} + +function createDeferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + + return { promise, resolve, reject }; +} + +function createAbortError(): Error { + const error = new Error('Cancelled'); + error.name = 'AbortError'; + return error; +} + +function createPlaylistMeta( + overrides: Partial = {} +): PlaylistMeta { + return { + _id: 'playlist-1', + title: 'Xtream Playlist', + count: 0, + importDate: new Date('2026-03-28T00:00:00.000Z').toISOString(), + autoRefresh: false, + serverUrl: 'http://localhost:8080', + username: 'demo', + password: 'secret', + ...overrides, + } as PlaylistMeta; +} + +describe('SourceListRefreshService', () => { + let component: SourceListRefreshService; + let databaseService: { + cancelOperation: jest.Mock; + createOperationId: jest.Mock; + deletePlaylist: jest.Mock; + deleteXtreamPlaylistContent: jest.Mock; + updateXtreamPlaylistDetails: jest.Mock; + }; + let dialogService: { + openConfirmDialog: jest.Mock; + }; + let dataService: { + sendIpcEvent: jest.Mock; + }; + let playbackPositionService: { + getAllPlaybackPositions: jest.Mock; + }; + let playlistDeleteAction: { + deletePlaylist: jest.Mock; + }; + let playlistRefreshService: { + cancelRefresh: jest.Mock; + refreshPlaylist: jest.Mock; + }; + let runtime: { + isElectron: boolean; + supportsPlaylistRefresh: boolean; + supportsXtreamSqliteDataSource: boolean; + }; + let router: { + navigate: jest.Mock; + }; + let snackBar: { + open: jest.Mock; + }; + let store: MockStore; + + beforeEach(async () => { + databaseService = { + cancelOperation: jest.fn().mockResolvedValue(true), + createOperationId: jest.fn((prefix: string) => `${prefix}-op`), + deletePlaylist: jest.fn(), + deleteXtreamPlaylistContent: jest.fn(), + updateXtreamPlaylistDetails: jest.fn().mockResolvedValue(undefined), + }; + dialogService = { + openConfirmDialog: jest.fn(), + }; + dataService = { + sendIpcEvent: jest.fn(), + }; + playbackPositionService = { + getAllPlaybackPositions: jest.fn().mockResolvedValue([]), + }; + playlistDeleteAction = { + deletePlaylist: jest.fn().mockResolvedValue(true), + }; + playlistRefreshService = { + cancelRefresh: jest.fn().mockResolvedValue(undefined), + refreshPlaylist: jest.fn().mockResolvedValue({ + id: 'playlist-1', + items: [], + }), + }; + runtime = { + isElectron: true, + supportsPlaylistRefresh: true, + supportsXtreamSqliteDataSource: true, + }; + router = { + navigate: jest.fn(), + }; + snackBar = { + open: jest.fn(), + }; + await TestBed.configureTestingModule({ + providers: [ + SourceListRefreshService, + provideMockStore({ + selectors: [ + { selector: selectPlaylistsLoadingFlag, value: true }, + { + selector: selectAllPlaylistsMeta, + value: [], + }, + ], + }), + { + provide: DatabaseService, + useValue: databaseService, + }, + { + provide: DialogService, + useValue: dialogService, + }, + { + provide: DataService, + useValue: dataService, + }, + { + provide: PlaybackPositionService, + useValue: playbackPositionService, + }, + { + provide: PlaylistDeleteActionService, + useValue: playlistDeleteAction, + }, + { + provide: PlaylistRefreshService, + useValue: playlistRefreshService, + }, + { + provide: RuntimeCapabilitiesService, + useValue: runtime, + }, + { + provide: PlaylistContextFacade, + useValue: { + resolvedPlaylistId: signal(null), + }, + }, + { + provide: MatDialog, + useValue: { + open: jest.fn(), + }, + }, + { + provide: MatSnackBar, + useValue: snackBar, + }, + { + provide: Router, + useValue: router, + }, + { + provide: SortService, + useValue: { + getSortOptions: jest.fn(() => + of({ + by: SortBy.DATE_ADDED, + order: SortOrder.DESC, + }) + ), + sortPlaylists: jest.fn( + (playlists: PlaylistMeta[]) => playlists + ), + }, + }, + { + provide: TranslateService, + useValue: { + instant: jest.fn((key: string) => key), + }, + }, + ], + }).compileComponents(); + + component = TestBed.inject(SourceListRefreshService); + store = TestBed.inject(MockStore); + jest.spyOn(store, 'dispatch'); + }); + + afterEach(() => { + setPerformanceHook(null); + jest.restoreAllMocks(); + localStorage.clear(); + }); + + it('tracks Xtream refresh progress and clears the busy row after abort', async () => { + const item = createPlaylistMeta({ _id: 'playlist-refresh-1' }); + const refresh = createDeferred<{ + success: boolean; + favorites: Array<{ xtreamId: number; contentType: string }>; + recentlyViewed: Array<{ + xtreamId: number; + contentType: string; + viewedAt: string; + }>; + hiddenCategories: Array<{ + xtreamId: number; + categoryType: string; + }>; + }>(); + let confirmPromise: Promise | undefined; + // The shared flow awaits the connectivity-guard reset before it starts + // deleting, so the delete is several ticks away and counting them is + // fragile. The mock fires onEvent synchronously and the component + // applies it synchronously, so resolving this deferred inside the mock + // is a deterministic "the busy row has been updated" signal. + const workerEventDelivered = createDeferred(); + + dialogService.openConfirmDialog.mockImplementation( + ({ onConfirm }: { onConfirm?: () => Promise }) => { + confirmPromise = onConfirm?.(); + } + ); + + databaseService.deleteXtreamPlaylistContent.mockImplementation( + ( + _playlistId: string, + options?: { + onEvent?: (event: DbOperationEvent) => void; + operationId?: string; + } + ) => { + options?.onEvent?.({ + operation: 'delete-xtream-content', + operationId: 'xtream-refresh-op', + status: 'progress', + phase: 'collecting-user-data', + current: 1, + total: 4, + }); + workerEventDelivered.resolve(); + + return refresh.promise; + } + ); + + component.refresh(item); + await workerEventDelivered.promise; + + expect(component.isRefreshing(item._id)).toBe(true); + expect(component.message(item)).toBe( + 'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.COLLECTING_DATA' + ); + expect(component.progress(item._id)).toBe(25); + expect(component.canCancel(item)).toBe(true); + + await component.cancel(item); + expect(databaseService.cancelOperation).toHaveBeenCalledWith( + 'xtream-refresh-op' + ); + + refresh.reject(createAbortError()); + await confirmPromise; + + expect(component.isRefreshing(item._id)).toBe(false); + expect(component.progress(item._id)).toBeNull(); + expect(router.navigate).not.toHaveBeenCalled(); + }); + + it('dispatches playlist meta with updateDate after Xtream refresh setup succeeds', async () => { + const item = createPlaylistMeta({ _id: 'playlist-refresh-success-1' }); + let confirmPromise: Promise | undefined; + const executionOrder: string[] = []; + const performanceEvents: RendererPerformancePhaseEvent[] = []; + setPerformanceHook((event) => { + performanceEvents.push(event); + executionOrder.push(event.boundary); + }); + const dateNowSpy = jest + .spyOn(Date, 'now') + .mockReturnValue(1712145600000); + const originalSetItem = Storage.prototype.setItem; + const setItemSpy = jest + .spyOn(Storage.prototype, 'setItem') + .mockImplementation(function ( + this: Storage, + key: string, + value: string + ) { + executionOrder.push('setItem'); + return originalSetItem.call(this, key, value); + }); + + dialogService.openConfirmDialog.mockImplementation( + ({ onConfirm }: { onConfirm?: () => Promise }) => { + confirmPromise = onConfirm?.(); + } + ); + (store.dispatch as jest.Mock).mockImplementation((action: unknown) => { + executionOrder.push('dispatch'); + return action; + }); + router.navigate.mockImplementation((commands: unknown[]) => { + executionOrder.push('navigate'); + return Promise.resolve(Boolean(commands)); + }); + databaseService.deleteXtreamPlaylistContent.mockResolvedValue({ + success: true, + favorites: [ + { xtreamId: 101, contentType: 'live' }, + { xtreamId: 202, contentType: 'movie' }, + ], + recentlyViewed: [ + { + xtreamId: 303, + contentType: 'series', + viewedAt: '2026-04-03T11:15:00.000Z', + }, + ], + hiddenCategories: [{ xtreamId: 404, categoryType: 'live' }], + }); + + component.refresh(item); + await confirmPromise; + + expect(dialogService.openConfirmDialog).toHaveBeenCalledWith( + expect.objectContaining({ + width: '400px', + }) + ); + expect( + databaseService.updateXtreamPlaylistDetails + ).toHaveBeenCalledWith({ + id: item._id, + updateDate: 1712145600000, + }); + expect(store.dispatch).toHaveBeenCalledWith( + PlaylistActions.updatePlaylistMeta({ + playlist: { ...item, updateDate: 1712145600000 }, + }) + ); + expect(setItemSpy).toHaveBeenCalledWith( + `xtream-restore-${item._id}`, + expect.any(String) + ); + const persistedRestoreState = JSON.parse( + setItemSpy.mock.calls.find( + ([key]) => key === `xtream-restore-${item._id}` + )?.[1] ?? 'null' + ); + expect(persistedRestoreState).toEqual({ + hiddenCategories: [{ xtreamId: 404, categoryType: 'live' }], + favorites: [ + { xtreamId: 101, contentType: 'live' }, + { xtreamId: 202, contentType: 'movie' }, + ], + recentlyViewed: [ + { + xtreamId: 303, + contentType: 'series', + viewedAt: '2026-04-03T11:15:00.000Z', + }, + ], + playbackPositions: [], + }); + expect(router.navigate).toHaveBeenCalledWith([ + '/workspace', + 'xtreams', + item._id, + ]); + expect(executionOrder).toEqual([ + 'setItem', + 'start', + 'dispatch', + 'end', + 'navigate', + ]); + expect(performanceEvents).toHaveLength(2); + expect(performanceEvents[0]?.phaseId).toBe( + performanceEvents[1]?.phaseId + ); + expect( + performanceEvents.map(({ boundary, metadata, outcome, phase }) => ({ + boundary, + metadata, + outcome, + phase, + })) + ).toEqual([ + { + boundary: 'start', + metadata: { items: 1 }, + outcome: undefined, + phase: 'store.xtream-refresh-meta', + }, + { + boundary: 'end', + metadata: { items: 1 }, + outcome: 'success', + phase: 'store.xtream-refresh-meta', + }, + ]); + + setItemSpy.mockRestore(); + dateNowSpy.mockRestore(); + }); + + it('uses the legacy IPC refresh flow for non-Xtream playlists', async () => { + runtime.supportsPlaylistRefresh = false; + let answer!: () => void; + dataService.sendIpcEvent.mockReturnValueOnce( + new Promise((resolve) => { + answer = resolve; + }) + ); + const activity = TestBed.inject(SourceActivityService); + const item = createPlaylistMeta({ + _id: 'playlist-m3u-1', + serverUrl: undefined, + username: undefined, + password: undefined, + filePath: undefined, + url: 'https://example.com/test.m3u', + userAgent: 'IPTVnator-Test/1.0', + }); + + component.refresh(item); + + expect(dataService.sendIpcEvent).toHaveBeenCalledWith(PLAYLIST_UPDATE, { + id: item._id, + title: item.title, + url: item.url, + userAgent: item.userAgent, + }); + // Busy, and held for removal and other refreshes, until it answers. + expect(component.isRefreshing(item._id)).toBe(true); + expect(activity.isBusy(item._id)).toBe(true); + component.refresh(item); + expect(dataService.sendIpcEvent).toHaveBeenCalledTimes(1); + + answer(); + await Promise.resolve(); + await Promise.resolve(); + expect(component.isRefreshing(item._id)).toBe(false); + expect(activity.isBusy(item._id)).toBe(false); + }); + + it('does not use legacy IPC refresh for file-backed playlists without the refresh bridge', () => { + runtime.supportsPlaylistRefresh = false; + const item = createPlaylistMeta({ + _id: 'playlist-m3u-1', + serverUrl: undefined, + username: undefined, + password: undefined, + filePath: '/tmp/test.m3u', + }); + + component.refresh(item); + + expect(dataService.sendIpcEvent).not.toHaveBeenCalled(); + expect(playlistRefreshService.refreshPlaylist).not.toHaveBeenCalled(); + }); + + it('holds a row refresh in the shared registry, and leaves a held source alone', async () => { + const activity = TestBed.inject(SourceActivityService); + const item = createPlaylistMeta({ + _id: 'playlist-m3u', + serverUrl: undefined, + username: undefined, + password: undefined, + url: 'https://source.test/list.m3u', + }); + let resolve!: (value: unknown) => void; + playlistRefreshService.refreshPlaylist.mockReturnValueOnce( + new Promise((r) => { + resolve = r; + }) + ); + component.refresh(item); + // The switcher and the removal guard see the row's refresh. + expect(activity.isBusy(item._id)).toBe(true); + resolve({ id: item._id, items: [] }); + await Promise.resolve(); + await Promise.resolve(); + expect(activity.isBusy(item._id)).toBe(false); + + // A refresh or delete started elsewhere holds it: no second run. + const release = activity.begin([item._id]); + component.refresh(item); + expect(playlistRefreshService.refreshPlaylist).toHaveBeenCalledTimes(1); + release(); + }); + + it('re-evaluates refresh bridge availability when refreshing local M3U playlists', async () => { + runtime.supportsPlaylistRefresh = false; + const lateComponent = TestBed.inject(SourceListRefreshService); + runtime.supportsPlaylistRefresh = true; + const item = createPlaylistMeta({ + _id: 'playlist-m3u-2', + serverUrl: undefined, + username: undefined, + password: undefined, + filePath: '/tmp/test.m3u', + userAgent: 'IPTVnator-Test/1.0', + }); + + lateComponent.refresh(item); + + expect(playlistRefreshService.refreshPlaylist).toHaveBeenCalledWith( + { + operationId: 'playlist-refresh-op', + playlistId: item._id, + title: item.title, + url: item.url, + userAgent: item.userAgent, + filePath: item.filePath, + }, + { + onEvent: expect.any(Function), + } + ); + expect(dataService.sendIpcEvent).not.toHaveBeenCalled(); + + await Promise.resolve(); + expect(store.dispatch).toHaveBeenCalledWith( + PlaylistActions.updatePlaylist({ + playlist: expect.objectContaining({ + _id: item._id, + }), + playlistId: item._id, + refreshEpg: true, + operationId: 'playlist-refresh-op', + }) + ); + }); + + it('clears the connectivity guard before deleting the cached Xtream catalog', async () => { + // Second, independent implementation of the destructive refresh (the + // Workspace sources page). Same consequence as the shared action: the + // catalog is already gone by the time an open guard fast-fails the + // re-import bootstrap. + const item = { + _id: 'xtream-guard', + title: 'Guarded Xtream', + serverUrl: 'http://panel.example:8080', + } as PlaylistMeta; + const order: string[] = []; + let confirmPromise: Promise | undefined; + + dataService.sendIpcEvent.mockImplementation((event: string) => { + order.push(`ipc:${event}`); + return Promise.resolve({ success: true }); + }); + databaseService.deleteXtreamPlaylistContent.mockImplementation(() => { + order.push('deleteXtreamPlaylistContent'); + return Promise.resolve({ + hiddenCategories: [], + favorites: [], + recentlyViewed: [], + sourcePins: [], + }); + }); + dialogService.openConfirmDialog.mockImplementation( + ({ onConfirm }: { onConfirm?: () => Promise }) => { + confirmPromise = onConfirm?.(); + } + ); + + component.refresh(item); + await confirmPromise; + + expect(order[0]).toBe(`ipc:${CONNECTIVITY_GUARD_RESET}`); + expect(order).toContain('deleteXtreamPlaylistContent'); + expect(dataService.sendIpcEvent).toHaveBeenCalledWith( + CONNECTIVITY_GUARD_RESET, + { url: item.serverUrl } + ); + }); +}); diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/source-list-refresh.service.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/source-list-refresh.service.ts new file mode 100644 index 000000000..fe50cd3c1 --- /dev/null +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/source-list-refresh.service.ts @@ -0,0 +1,326 @@ +import { Injectable, inject, signal } from '@angular/core'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { Store } from '@ngrx/store'; +import { TranslateService } from '@ngx-translate/core'; +import { PlaylistActions } from '@iptvnator/m3u-state'; +import { createLogger } from '@iptvnator/portal/shared/util'; +import { + DatabaseService, + DataService, + DbOperationEvent, + isDbAbortError, + PlaylistRefreshService, + RuntimeCapabilitiesService, + SourceActivityService, +} from '@iptvnator/services'; +import { + PLAYLIST_UPDATE, + PlaylistMeta, + PlaylistRefreshEvent, +} from '@iptvnator/shared/interfaces'; +import { + XtreamRefreshFlowService, + type XtreamRefreshProgressReporter, +} from '../xtream-refresh-flow.service'; + +interface BusyOperation { + current?: number; + operation: string; + operationId?: string; + phase?: string; + status: DbOperationEvent['status']; + total?: number; +} + +const REFRESH_PHASE_KEYS: Record = { + fetching: 'HOME.PLAYLISTS.REFRESH_FETCHING', + 'reading-file': 'HOME.PLAYLISTS.REFRESH_READING_FILE', + parsing: 'HOME.PLAYLISTS.REFRESH_PARSING', + saving: 'HOME.PLAYLISTS.REFRESH_SAVING', +}; +const XTREAM_PHASE_KEYS: Record = { + 'collecting-user-data': + 'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.COLLECTING_DATA', + 'deleting-content': 'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.DELETING_CONTENT', + 'deleting-categories': + 'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.DELETING_CATEGORIES', +}; + +/** + * The Sources page's per-row refresh: which rows refresh, their phase and + * progress, and cancelling. M3U refreshes in place, Xtream re-imports + * through the shared confirmation flow, and the browser build sends the + * URL refresh event. Provided by the list component. + */ +@Injectable() +export class SourceListRefreshService { + private readonly databaseService = inject(DatabaseService); + private readonly dataService = inject(DataService); + private readonly playlistRefreshService = inject(PlaylistRefreshService); + private readonly runtime = inject(RuntimeCapabilitiesService); + // Root registry: the switcher, the removal guard and cleanup consult it, + // so a row refresh must not be visible only to this page. + private readonly activity = inject(SourceActivityService); + private readonly releases = new Map void>(); + private readonly snackBar = inject(MatSnackBar); + private readonly store = inject(Store); + private readonly translate = inject(TranslateService); + private readonly xtreamRefreshFlow = inject(XtreamRefreshFlowService); + private readonly logger = createLogger('SourceListRefresh'); + + private readonly refreshingIds = signal>(new Set()); + private readonly operations = signal>( + new Map() + ); + + isRefreshing(id: string): boolean { + return this.refreshingIds().has(id); + } + + canRefresh(item: PlaylistMeta): boolean { + if (item.serverUrl) { + return this.runtime.supportsXtreamSqliteDataSource; + } + return ( + !!item.url || + (!!item.filePath && this.runtime.supportsPlaylistRefresh) + ); + } + + refresh(item: PlaylistMeta): void { + if (this.isRefreshing(item._id) || this.activity.isBusy(item._id)) { + return; + } + if (item.serverUrl && this.runtime.supportsXtreamSqliteDataSource) { + this.xtreamRefreshFlow.confirmAndRefresh(item, this.xtreamReporter); + } else if ( + this.runtime.supportsPlaylistRefresh && + (item.url || item.filePath) + ) { + void this.refreshM3u(item); + } else if (item.url) { + void this.refreshViaBackend(item, item.url); + } + } + + message(item: PlaylistMeta): string { + const operation = this.operations().get(item._id); + if (!operation) { + return ''; + } + if (operation.operation === 'playlist-refresh') { + return this.translate.instant( + REFRESH_PHASE_KEYS[operation.phase ?? ''] ?? + 'HOME.PLAYLISTS.REFRESH' + ); + } + if (operation.operation === 'delete-xtream-content') { + return this.translate.instant( + XTREAM_PHASE_KEYS[operation.phase ?? ''] ?? + 'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.IN_PROGRESS' + ); + } + return ''; + } + + progress(id: string): number | null { + const operation = this.operations().get(id); + if ( + !operation || + operation.current == null || + operation.total == null || + operation.total <= 0 + ) { + return null; + } + return Math.min( + 100, + Math.round((operation.current / operation.total) * 100) + ); + } + + canCancel(item: PlaylistMeta): boolean { + const operation = this.operations().get(item._id); + if (!operation?.operationId) { + return false; + } + return ( + operation.operation === 'playlist-refresh' || + Boolean(item.serverUrl) + ); + } + + async cancel(item: PlaylistMeta): Promise { + const operation = this.operations().get(item._id); + if (!operation?.operationId) { + return; + } + if (operation.operation === 'playlist-refresh') { + await this.playlistRefreshService.cancelRefresh( + operation.operationId + ); + return; + } + await this.databaseService.cancelOperation(operation.operationId); + } + + /** The shared Xtream re-import reports into this list's row state. */ + private readonly xtreamReporter: XtreamRefreshProgressReporter = { + isBusy: (playlistId) => + this.isRefreshing(playlistId) || this.activity.isBusy(playlistId), + begin: ({ playlistId }) => this.setRefreshing(playlistId, true), + report: ({ playlistId }, event) => this.track(playlistId, event), + end: ({ playlistId }) => { + this.clear(playlistId); + this.setRefreshing(playlistId, false); + }, + }; + + private async refreshM3u(item: PlaylistMeta): Promise { + this.setRefreshing(item._id, true); + const operationId = + this.databaseService.createOperationId('playlist-refresh'); + try { + const refreshed = await this.playlistRefreshService.refreshPlaylist( + { + operationId, + playlistId: item._id, + title: item.title, + url: item.url, + ...(item.userAgent ? { userAgent: item.userAgent } : {}), + filePath: item.filePath, + }, + { + onEvent: (event) => + this.track(item._id, this.toBusyEvent(event)), + } + ); + this.store.dispatch( + PlaylistActions.updatePlaylist({ + playlist: { ...refreshed, _id: item._id }, + playlistId: item._id, + refreshEpg: true, + operationId, + }) + ); + this.snackBar.open( + this.translate.instant( + 'HOME.PLAYLISTS.PLAYLIST_UPDATE_SUCCESS' + ), + undefined, + { duration: 2000 } + ); + } catch (error) { + if (!isDbAbortError(error)) { + // Redacted: the error can carry the playlist URL. + this.logger.error('Error refreshing playlist:', error); + this.snackBar.open( + this.refreshErrorMessage(error, item), + this.translate.instant('CLOSE'), + { duration: 5000 } + ); + } + } finally { + this.clear(item._id); + this.setRefreshing(item._id, false); + } + } + + /** + * The browser build refreshes through the backend; the row stays busy, + * and held in the shared registry, until the backend answers. + */ + private async refreshViaBackend( + item: PlaylistMeta, + url: string + ): Promise { + this.setRefreshing(item._id, true); + try { + await this.dataService.sendIpcEvent(PLAYLIST_UPDATE, { + id: item._id, + title: item.title, + url, + ...(item.userAgent ? { userAgent: item.userAgent } : {}), + }); + } catch (error) { + this.logger.error('Error refreshing playlist:', error); + } finally { + this.setRefreshing(item._id, false); + } + } + + private refreshErrorMessage(error: unknown, item: PlaylistMeta): string { + if (item.filePath) { + const message = String( + (error as { message?: string })?.message ?? error + ); + if (/(ENOENT|no such file or directory|not found)/i.test(message)) { + return this.translate.instant( + 'HOME.PLAYLISTS.PLAYLIST_UPDATE_FILE_NOT_FOUND' + ); + } + if (/(EACCES|EPERM|permission denied)/i.test(message)) { + return this.translate.instant( + 'HOME.PLAYLISTS.PLAYLIST_UPDATE_FILE_ACCESS_ERROR' + ); + } + } + return this.translate.instant('HOME.PLAYLISTS.PLAYLIST_UPDATE_ERROR'); + } + + private toBusyEvent(event: PlaylistRefreshEvent): DbOperationEvent { + return { + operationId: event.operationId, + operation: 'playlist-refresh', + playlistId: event.playlistId, + phase: event.phase, + status: event.status, + error: event.error, + }; + } + + private track(id: string, event: DbOperationEvent): void { + this.operations.update((current) => { + const next = new Map(current); + if (['completed', 'cancelled', 'error'].includes(event.status)) { + next.delete(id); + } else { + next.set(id, { + operation: event.operation, + operationId: event.operationId, + phase: event.phase, + current: event.current, + total: event.total, + status: event.status, + }); + } + return next; + }); + } + + private clear(id: string): void { + this.operations.update((current) => { + const next = new Map(current); + next.delete(id); + return next; + }); + } + + private setRefreshing(id: string, refreshing: boolean): void { + if (refreshing && !this.releases.has(id)) { + this.releases.set(id, this.activity.begin([id])); + } else if (!refreshing) { + this.releases.get(id)?.(); + this.releases.delete(id); + } + this.refreshingIds.update((current) => { + const next = new Set(current); + if (refreshing) { + next.add(id); + } else { + next.delete(id); + } + return next; + }); + } +} diff --git a/libs/playlist/shared/ui/src/lib/source-health/source-health-indicator.component.spec.ts b/libs/playlist/shared/ui/src/lib/source-health/source-health-indicator.component.spec.ts deleted file mode 100644 index 30ea72630..000000000 --- a/libs/playlist/shared/ui/src/lib/source-health/source-health-indicator.component.spec.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { signal } from '@angular/core'; -import { TestBed } from '@angular/core/testing'; -import { NoopAnimationsModule } from '@angular/platform-browser/animations'; -import { TranslateModule, TranslateService } from '@ngx-translate/core'; -import { RuntimeCapabilitiesService } from '@iptvnator/services'; -import { SourceHealthService } from '@iptvnator/portal/shared/data-access'; -import { SourceHealthSnapshot } from '@iptvnator/shared/interfaces'; -import { SourceHealthIndicatorComponent } from './source-health-indicator.component'; - -describe('SourceHealthIndicatorComponent', () => { - it.each(['en', 'zhtw'])( - 'updates the dot and localized time without restarting its request (%s)', - (language) => { - const snapshot = signal( - undefined - ); - const check = jest.fn().mockImplementation(() => { - snapshot(); // The real coordinator reads its signal-backed cache. - return Promise.resolve(); - }); - TestBed.configureTestingModule({ - imports: [ - SourceHealthIndicatorComponent, - TranslateModule.forRoot(), - NoopAnimationsModule, - ], - providers: [ - { - provide: RuntimeCapabilitiesService, - useValue: { supportsSourceHealth: true }, - }, - { - provide: SourceHealthService, - useValue: { get: snapshot, check }, - }, - ], - }); - TestBed.inject(TranslateService).use(language); - const fixture = TestBed.createComponent( - SourceHealthIndicatorComponent - ); - fixture.componentRef.setInput('playlist', { - _id: 'a', - url: 'https://source.test/list', - }); - fixture.detectChanges(); - const requestSignal = check.mock.calls[0][1].signal as AbortSignal; - snapshot.set({ - state: 'active', - reason: 'available', - confirmedInactive: false, - checkedAt: Date.now(), - }); - fixture.detectChanges(); - expect(fixture.componentInstance.description()).toContain(' · '); - expect(check).toHaveBeenCalledTimes(1); - expect(requestSignal.aborted).toBe(false); - expect( - fixture.nativeElement.querySelector('.health-dot').dataset.state - ).toBe('active'); - fixture.destroy(); - expect(requestSignal.aborted).toBe(true); - } - ); -}); diff --git a/libs/playlist/shared/ui/src/lib/source-health/source-health-indicator.component.ts b/libs/playlist/shared/ui/src/lib/source-health/source-health-indicator.component.ts deleted file mode 100644 index 09baa69b0..000000000 --- a/libs/playlist/shared/ui/src/lib/source-health/source-health-indicator.component.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { - Component, - Injector, - computed, - effect, - inject, - input, - untracked, - ChangeDetectionStrategy, -} from '@angular/core'; -import { MatTooltip } from '@angular/material/tooltip'; -import { TranslateService } from '@ngx-translate/core'; -import { normalizeDateLocale } from '@iptvnator/pipes'; -import { - RuntimeCapabilitiesService, - injectTranslationTick, -} from '@iptvnator/services'; -import { SourceHealthService } from '@iptvnator/portal/shared/data-access'; -import { PlaylistMeta, sourceHealthType } from '@iptvnator/shared/interfaces'; - -@Component({ - selector: 'app-source-health-indicator', - imports: [MatTooltip], - template: `@if (supported()) { - - }`, - changeDetection: ChangeDetectionStrategy.OnPush, - styles: [ - ` - :host { - display: inline-flex; - vertical-align: middle; - } - .health-dot { - display: block; - width: 9px; - height: 9px; - border-radius: 50%; - background: var(--mat-sys-on-surface-variant); - border: 2px solid var(--mat-sys-surface); - } - [data-state='active'] { - background: #4caf50; - } - [data-state='expired'] { - background: #ff9800; - } - [data-state='inactive'] { - background: #f44336; - } - [data-state='checking'] { - opacity: 0.45; - } - `, - ], -}) -export class SourceHealthIndicatorComponent { - readonly playlist = input.required(); - readonly enabled = input(true); - private readonly runtime = inject(RuntimeCapabilitiesService); - private readonly injector = inject(Injector); - private readonly translate = inject(TranslateService); - private readonly language = injectTranslationTick(); - readonly supported = computed( - () => - this.runtime.supportsSourceHealth && - !!sourceHealthType(this.playlist()) - ); - readonly snapshot = computed(() => - this.supported() - ? this.injector.get(SourceHealthService).get(this.playlist()) - : undefined - ); - readonly description = computed(() => { - this.language(); - const snapshot = this.snapshot(); - const reason = - snapshot?.state === 'checking' - ? 'checking' - : (snapshot?.reason ?? 'unknown'); - const label = this.translate.instant(`SOURCE_HEALTH.REASON.${reason}`); - const time = snapshot?.checkedAt - ? new Date(snapshot.checkedAt).toLocaleTimeString( - normalizeDateLocale(this.translate.currentLang || 'en') - ) - : ''; - const scope = - sourceHealthType(this.playlist()) === 'm3u' - ? this.translate.instant('SOURCE_HEALTH.M3U_SCOPE') - : ''; - return [label, scope, time].filter(Boolean).join(' · '); - }); - constructor() { - effect((onCleanup) => { - const p = this.playlist(); - if (!this.enabled() || !this.supported()) return; - const controller = new AbortController(); - untracked(() => { - void this.injector - .get(SourceHealthService) - .check(p, { signal: controller.signal }); - }); - onCleanup(() => controller.abort()); - }); - } -} diff --git a/libs/playlist/shared/ui/src/lib/source-removal/source-delete-progress.service.ts b/libs/playlist/shared/ui/src/lib/source-removal/source-delete-progress.service.ts new file mode 100644 index 000000000..b18e7c180 --- /dev/null +++ b/libs/playlist/shared/ui/src/lib/source-removal/source-delete-progress.service.ts @@ -0,0 +1,115 @@ +import { Injectable, inject, signal } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; +import { DatabaseService, DbOperationEvent } from '@iptvnator/services'; + +interface DeleteOperation { + operationId?: string; + phase?: string; + current?: number; + total?: number; +} + +const PHASE_KEYS: Record = { + 'deleting-favorites': 'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_FAVORITES', + 'deleting-recently-viewed': 'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_RECENT', + 'deleting-playback-positions': + 'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_PROGRESS', + 'deleting-downloads': 'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_DOWNLOADS', + 'deleting-content': 'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_CONTENT', + 'deleting-categories': 'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_CATEGORIES', + 'deleting-playlist': 'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_PLAYLIST', +}; + +/** + * Progress of a committed delete that reports it: an Xtream catalog in the + * desktop database can take a while, so its row comes back as "Removing…" + * with progress and a Cancel action that keeps the source. + */ +@Injectable({ providedIn: 'root' }) +export class SourceDeleteProgressService { + private readonly database = inject(DatabaseService); + private readonly translate = inject(TranslateService); + private readonly operations = signal>( + new Map() + ); + private readonly cancelled = new Set(); + + isDeleting(id: string): boolean { + return this.operations().has(id); + } + + message(id: string): string { + const operation = this.operations().get(id); + if (!operation) { + return ''; + } + return this.translate.instant( + PHASE_KEYS[operation.phase ?? ''] ?? + 'HOME.PLAYLISTS.REMOVE_DIALOG.IN_PROGRESS' + ); + } + + progress(id: string): number | null { + const operation = this.operations().get(id); + if (!operation?.total || operation.current == null) { + return null; + } + return Math.min( + 100, + Math.round((operation.current / operation.total) * 100) + ); + } + + canCancel(id: string): boolean { + return !!this.operations().get(id)?.operationId; + } + + async cancel(id: string): Promise { + const operationId = this.operations().get(id)?.operationId; + if (operationId) { + this.cancelled.add(id); + await this.database.cancelOperation(operationId); + } + } + + /** True once, after a cancelled delete: the caller skips its error. */ + takeCancelled(id: string): boolean { + return this.cancelled.delete(id); + } + + /** + * Follows a running delete. A terminal event ends what Cancel can stop, + * but the row stays busy: the delete still cleans up (parental lock, + * stored state) before its source leaves the store, so the caller clears + * it once the whole delete has settled. + */ + track(id: string, event: DbOperationEvent): void { + const terminal = ['completed', 'cancelled', 'error'].includes( + event.status + ); + this.operations.update((current) => + new Map(current).set( + id, + terminal + ? { ...current.get(id), operationId: undefined } + : { + operationId: event.operationId, + phase: event.phase, + current: event.current, + total: event.total, + } + ) + ); + } + + clear(id: string): void { + if (!this.operations().has(id)) { + return; + } + this.operations.update((current) => { + const next = new Map(current); + next.delete(id); + return next; + }); + } +} diff --git a/libs/playlist/shared/ui/src/lib/source-removal/source-removal-toast.component.scss b/libs/playlist/shared/ui/src/lib/source-removal/source-removal-toast.component.scss new file mode 100644 index 000000000..7626aaa3a --- /dev/null +++ b/libs/playlist/shared/ui/src/lib/source-removal/source-removal-toast.component.scss @@ -0,0 +1,29 @@ +@use '@angular/material' as mat; + +.source-removal-toast__undo { + font-weight: 600; +} + +.source-removal-toast__close { + color: var(--app-body-color); +} + +// The toast frame (`panelClass: 'app-toast'`): bottom centre, one step +// above the page. The container renders in the overlay, so the rule is +// global; it ships with this component instead of the initial stylesheet. +::ng-deep .mat-mdc-snack-bar-container.app-toast { + @include mat.snack-bar-overrides( + ( + container-color: var(--app-widget-bg), + container-shape: 10px, + supporting-text-color: var(--app-heading-color), + supporting-text-size: 13px, + button-color: var(--app-selection-color), + ) + ); + + .mat-mdc-snackbar-surface { + border: 1px solid var(--app-line-strong); + box-shadow: 0 12px 30px rgba(0, 0, 0, 0.35); + } +} diff --git a/libs/playlist/shared/ui/src/lib/source-removal/source-removal-toast.component.spec.ts b/libs/playlist/shared/ui/src/lib/source-removal/source-removal-toast.component.spec.ts new file mode 100644 index 000000000..0ee342918 --- /dev/null +++ b/libs/playlist/shared/ui/src/lib/source-removal/source-removal-toast.component.spec.ts @@ -0,0 +1,116 @@ +import { TestBed } from '@angular/core/testing'; +import { + MAT_SNACK_BAR_DATA, + MatSnackBarRef, +} from '@angular/material/snack-bar'; +import { Subject } from 'rxjs'; +import { SourceRemovalToastComponent } from './source-removal-toast.component'; + +describe('SourceRemovalToastComponent', () => { + let opened: Subject; + let dismissed: Subject; + let ref: { + afterOpened: () => Subject; + afterDismissed: () => Subject; + dismiss: jest.Mock; + dismissWithAction: jest.Mock; + }; + let data: Record; + + const render = () => { + const fixture = TestBed.createComponent(SourceRemovalToastComponent); + fixture.detectChanges(); + document.body.appendChild(fixture.nativeElement); + return fixture.nativeElement as HTMLElement; + }; + + beforeEach(() => { + opened = new Subject(); + dismissed = new Subject(); + ref = { + afterOpened: () => opened, + afterDismissed: () => dismissed, + dismiss: jest.fn(), + dismissWithAction: jest.fn(), + }; + data = { + message: 'Removed “Alpha”', + undoLabel: 'Undo', + closeLabel: 'Close', + }; + TestBed.configureTestingModule({ + imports: [SourceRemovalToastComponent], + providers: [ + { provide: MAT_SNACK_BAR_DATA, useFactory: () => data }, + { provide: MatSnackBarRef, useValue: ref }, + ], + }); + }); + + it('undoes, or commits at once from the close button', () => { + const element = render(); + expect(element.textContent).toContain('Removed “Alpha”'); + element + .querySelector('.source-removal-toast__undo') + ?.click(); + expect(ref.dismissWithAction).toHaveBeenCalled(); + element + .querySelector('.source-removal-toast__close') + ?.click(); + expect(ref.dismiss).toHaveBeenCalled(); + expect( + element + .querySelector('.source-removal-toast__close') + ?.getAttribute('aria-label') + ).toBe('Close'); + }); + + it('commits through the service from the close button when it can', () => { + const commitNow = jest.fn(); + data['commitNow'] = commitNow; + render() + .querySelector('.source-removal-toast__close') + ?.click(); + expect(commitNow).toHaveBeenCalled(); + expect(ref.dismiss).not.toHaveBeenCalled(); + }); + + it('takes the focus when the delete left it nowhere', () => { + const element = render(); + (document.activeElement as HTMLElement | null)?.blur(); + opened.next(); + expect(document.activeElement).toBe( + element.querySelector('.source-removal-toast__undo') + ); + }); + + it('takes the focus from a closing surface and gives it back after', () => { + const trigger = document.createElement('button'); + document.body.appendChild(trigger); + trigger.focus(); + data['returnFocusTo'] = trigger; + const element = render(); + const undo = element.querySelector( + '.source-removal-toast__undo' + ); + + opened.next(); + expect(document.activeElement).toBe(undo); + + // The toast leaves the page and its focus with it. + element.remove(); + dismissed.next(); + expect(document.activeElement).toBe(trigger); + trigger.remove(); + }); + + it('leaves the focus where the user put it', () => { + render(); + const elsewhere = document.createElement('button'); + document.body.appendChild(elsewhere); + elsewhere.focus(); + opened.next(); + expect(document.activeElement).toBe(elsewhere); + elsewhere.remove(); + }); +}); diff --git a/libs/playlist/shared/ui/src/lib/source-removal/source-removal-toast.component.ts b/libs/playlist/shared/ui/src/lib/source-removal/source-removal-toast.component.ts new file mode 100644 index 000000000..a888c28a8 --- /dev/null +++ b/libs/playlist/shared/ui/src/lib/source-removal/source-removal-toast.component.ts @@ -0,0 +1,111 @@ +import { DOCUMENT } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + ElementRef, + inject, + viewChild, +} from '@angular/core'; +import { MatButton, MatIconButton } from '@angular/material/button'; +import { MatIcon } from '@angular/material/icon'; +import { + MAT_SNACK_BAR_DATA, + MatSnackBarAction, + MatSnackBarActions, + MatSnackBarLabel, + MatSnackBarRef, +} from '@angular/material/snack-bar'; + +export interface SourceRemovalToastData { + message: string; + undoLabel: string; + closeLabel: string; + /** Undo takes the focus, which returns here when the toast closes. */ + returnFocusTo?: HTMLElement | null; + /** The close button: commit the delete now rather than at the end. */ + commitNow?: () => void; +} + +/** + * "Removed …" with Undo, and a close button that commits the delete at + * once instead of waiting out the toast. A delete from a row's menu takes + * the row, and with it the focus, off the page: Undo then takes the focus, + * so the keyboard can reach it. A caller that closes on delete (the + * switcher) passes `returnFocusTo`: Undo takes the focus regardless, and + * the focus goes back there when the toast closes. + */ +@Component({ + selector: 'app-source-removal-toast', + imports: [ + MatButton, + MatIcon, + MatIconButton, + MatSnackBarAction, + MatSnackBarActions, + MatSnackBarLabel, + ], + template: ` + {{ data.message }} + + + + + `, + styleUrl: './source-removal-toast.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SourceRemovalToastComponent { + readonly data = inject(MAT_SNACK_BAR_DATA); + readonly ref = + inject>(MatSnackBarRef); + private readonly undo = viewChild.required('undo', { read: ElementRef }); + + constructor() { + const document = inject(DOCUMENT); + // After the opening animation: by then a closing menu has tried, and + // failed, to give focus back to the removed row's ⋯ button. + const returnFocusTo = this.data.returnFocusTo ?? null; + this.ref.afterOpened().subscribe(() => { + const active = document.activeElement; + if (returnFocusTo || !active || active === document.body) { + (this.undo().nativeElement as HTMLElement).focus(); + } + }); + // The toast is gone by now; focus that left with it lands on body. + this.ref.afterDismissed().subscribe(() => { + const active = document.activeElement; + if ( + returnFocusTo?.isConnected && + (!active || active === document.body) + ) { + returnFocusTo.focus(); + } + }); + } + + /** Commits the delete at once; the toast closes with it. */ + close(): void { + if (this.data.commitNow) { + this.data.commitNow(); + } else { + this.ref.dismiss(); + } + } +} diff --git a/libs/playlist/shared/ui/src/lib/source-removal/source-removal.service.spec.ts b/libs/playlist/shared/ui/src/lib/source-removal/source-removal.service.spec.ts new file mode 100644 index 000000000..0ce7323d6 --- /dev/null +++ b/libs/playlist/shared/ui/src/lib/source-removal/source-removal.service.spec.ts @@ -0,0 +1,402 @@ +import { signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { Router } from '@angular/router'; +import { Store } from '@ngrx/store'; +import { TranslateModule } from '@ngx-translate/core'; +import { Subject } from 'rxjs'; +import { PlaylistActions } from '@iptvnator/m3u-state'; +import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util'; +import { + DatabaseService, + PlaylistDeleteActionService, + RuntimeCapabilitiesService, + SourceActivityService, +} from '@iptvnator/services'; +import { PlaylistMeta } from '@iptvnator/shared/interfaces'; +import { + RENDERER_PERFORMANCE_PHASE_HOOK_KEY, + type RendererPerformancePhaseEvent, +} from '@iptvnator/shared/logging'; +import { + SOURCE_REMOVAL_UNDO_MS, + SourceRemovalService, +} from './source-removal.service'; + +const playlist = (id: string): PlaylistMeta => ({ + _id: id, + title: id, + count: 0, + importDate: '', + autoRefresh: false, +}); + +describe('SourceRemovalService', () => { + let toasts: Array<{ + message: string; + data: { commitNow?: () => void }; + action: Subject; + dismissed: Subject; + dismiss: jest.Mock; + }>; + let deletePlaylist: jest.Mock; + let cancelOperation: jest.Mock; + let dispatch: jest.Mock; + let resolvedPlaylistId: ReturnType>; + let routePlaylistId: ReturnType>; + let context: { + resolvedPlaylistId: typeof resolvedPlaylistId; + routePlaylistId: typeof routePlaylistId; + selectPlaylist: jest.Mock; + activatePlaylist: jest.Mock; + }; + let router: { url: string; navigate: jest.Mock; navigateByUrl: jest.Mock }; + let service: SourceRemovalService; + + const toast = (message: string, data: { commitNow?: () => void } = {}) => { + const dismissed = new Subject(); + const entry: (typeof toasts)[number] = { + message, + data, + action: new Subject(), + dismissed, + dismiss: jest.fn(() => dismissed.next()), + }; + toasts.push(entry); + return { + onAction: () => entry.action, + afterDismissed: () => entry.dismissed, + dismiss: entry.dismiss, + }; + }; + + /** The Undo window ends: the service's own timer commits. */ + const expireUndo = () => jest.advanceTimersByTime(SOURCE_REMOVAL_UNDO_MS); + + const flush = async () => { + await Promise.resolve(); + await Promise.resolve(); + }; + + beforeEach(() => { + jest.useFakeTimers(); + toasts = []; + deletePlaylist = jest.fn().mockResolvedValue(true); + cancelOperation = jest.fn().mockResolvedValue(true); + dispatch = jest.fn(); + resolvedPlaylistId = signal(null); + routePlaylistId = signal(null); + context = { + resolvedPlaylistId, + routePlaylistId, + selectPlaylist: jest.fn((p: PlaylistMeta) => + resolvedPlaylistId.set(p._id) + ), + activatePlaylist: jest.fn(), + }; + router = { + url: '/workspace/sources', + navigate: jest.fn().mockResolvedValue(true), + navigateByUrl: jest.fn().mockResolvedValue(true), + }; + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot()], + providers: [ + SourceRemovalService, + { + provide: MatSnackBar, + useValue: { + open: jest.fn((message: string) => toast(message)), + openFromComponent: jest.fn( + ( + _component: unknown, + config: { + data: { + message: string; + commitNow?: () => void; + }; + } + ) => toast(config.data.message, config.data) + ), + }, + }, + { + provide: PlaylistDeleteActionService, + useValue: { deletePlaylist }, + }, + { provide: PlaylistContextFacade, useValue: context }, + { provide: Router, useValue: router }, + { provide: Store, useValue: { dispatch } }, + { provide: DatabaseService, useValue: { cancelOperation } }, + { + provide: RuntimeCapabilitiesService, + useValue: { supportsXtreamSqliteDataSource: true }, + }, + ], + }); + service = TestBed.inject(SourceRemovalService); + }); + afterEach(() => jest.useRealTimers()); + + it('hides the source and deletes it only when the Undo window ends', async () => { + const a = playlist('a'); + service.remove(a); + expect(service.isPending('a')).toBe(true); + expect(deletePlaylist).not.toHaveBeenCalled(); + expect(toasts[0].message).toBe('WORKSPACE.SOURCES.REMOVED'); + + expireUndo(); + await flush(); + expect(deletePlaylist).toHaveBeenCalledWith(a, { + onEvent: expect.any(Function), + }); + expect(dispatch).toHaveBeenCalledWith( + PlaylistActions.playlistRemovalCommitted({ playlistId: 'a' }) + ); + expect(service.isPending('a')).toBe(false); + }); + + it('does not remove a source while a refresh clears and refills it', async () => { + // Any entry point's refresh, or the startup auto-refresh. + const release = TestBed.inject(SourceActivityService).begin(['a']); + service.remove(playlist('a')); + expireUndo(); + await flush(); + expect(service.isPending('a')).toBe(false); + expect(toasts).toHaveLength(0); + expect(deletePlaylist).not.toHaveBeenCalled(); + release(); + }); + + it('restores the source on Undo without deleting anything', async () => { + service.remove(playlist('a')); + toasts[0].action.next(); + toasts[0].dismissed.next(); + expireUndo(); + await flush(); + expect(service.isPending('a')).toBe(false); + expect(deletePlaylist).not.toHaveBeenCalled(); + }); + + it('keeps the full Undo window when another message replaces the toast', async () => { + service.remove(playlist('a')); + // A refresh finishing elsewhere opens its own snack bar. + toasts[0].dismissed.next(); + jest.advanceTimersByTime(SOURCE_REMOVAL_UNDO_MS - 1); + await flush(); + expect(deletePlaylist).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(1); + await flush(); + expect(deletePlaylist).toHaveBeenCalledTimes(1); + }); + + it('commits at once from the close button, and only once', async () => { + service.remove(playlist('a')); + toasts[0].data.commitNow?.(); + await flush(); + expect(deletePlaylist).toHaveBeenCalledTimes(1); + expect(toasts[0].dismiss).toHaveBeenCalled(); + + expireUndo(); + await flush(); + expect(deletePlaylist).toHaveBeenCalledTimes(1); + }); + + it('commits an earlier removal when a second one starts', async () => { + service.remove(playlist('a')); + service.remove(playlist('b')); + await flush(); + expect(toasts[0].dismiss).toHaveBeenCalled(); + expect(deletePlaylist).toHaveBeenCalledTimes(1); + expect(service.isPending('b')).toBe(true); + }); + + it('switches away from the active source and back on Undo', () => { + resolvedPlaylistId.set('a'); + service.remove(playlist('a'), playlist('b')); + expect(context.selectPlaylist).toHaveBeenCalledWith(playlist('b')); + expect(toasts[0].message).toBe('WORKSPACE.SOURCES.REMOVED_SWITCHED'); + + toasts[0].action.next(); + expect(context.activatePlaylist).toHaveBeenCalledWith('a'); + }); + + it('does not hand over to a source that is being deleted', () => { + resolvedPlaylistId.set('a'); + service.progress.track('b', { + operation: 'delete-playlist', + status: 'started', + }); + service.remove(playlist('a'), playlist('b')); + expect(context.selectPlaylist).not.toHaveBeenCalled(); + expect(toasts[0].message).toBe('WORKSPACE.SOURCES.REMOVED'); + }); + + it('does not hand over to a source a refresh or import holds', () => { + resolvedPlaylistId.set('a'); + const release = TestBed.inject(SourceActivityService).begin(['b']); + service.remove(playlist('a'), playlist('b')); + expect(context.selectPlaylist).not.toHaveBeenCalled(); + expect(toasts[0].message).toBe('WORKSPACE.SOURCES.REMOVED'); + release(); + }); + + it('leaves a switch the user made after the delete alone on Undo', () => { + resolvedPlaylistId.set('a'); + service.remove(playlist('a'), playlist('b')); + resolvedPlaylistId.set('c'); + toasts[0].action.next(); + expect(context.activatePlaylist).not.toHaveBeenCalled(); + }); + + it('leaves a later switch alone on Undo when nothing replaced the source', () => { + // The source was the only row a filter showed: no replacement. + resolvedPlaylistId.set('a'); + routePlaylistId.set('a'); + router.url = '/workspace/xtreams/a/vod'; + service.remove(playlist('a')); + resolvedPlaylistId.set('c'); + + toasts[0].action.next(); + expect(router.navigateByUrl).not.toHaveBeenCalled(); + expect(context.activatePlaylist).not.toHaveBeenCalled(); + }); + + it('passes the focus target of a closing surface to the toast', () => { + const trigger = document.createElement('button'); + service.remove(playlist('a'), null, { returnFocusTo: trigger }); + const openFromComponent = TestBed.inject(MatSnackBar) + .openFromComponent as jest.Mock; + expect(openFromComponent.mock.calls[0][1].data.returnFocusTo).toBe( + trigger + ); + }); + + it('leaves the deleted source’s own page and returns on Undo', () => { + resolvedPlaylistId.set('a'); + routePlaylistId.set('a'); + router.url = '/workspace/xtreams/a/vod'; + service.remove(playlist('a')); + expect(router.navigate).toHaveBeenCalledWith(['/workspace', 'sources']); + + toasts[0].action.next(); + expect(router.navigateByUrl).toHaveBeenCalledWith( + '/workspace/xtreams/a/vod' + ); + }); + + it('shows a desktop Xtream row as removing as soon as the delete starts', async () => { + deletePlaylist.mockReturnValue(new Promise(() => undefined)); + service.remove({ ...playlist('x'), serverUrl: 'http://x.test' }); + expect(service.isPending('x')).toBe(true); + expireUndo(); + await flush(); + expect(service.isPending('x')).toBe(false); + expect(service.progress.isDeleting('x')).toBe(true); + expect(service.progress.message('x')).toBe( + 'HOME.PLAYLISTS.REMOVE_DIALOG.IN_PROGRESS' + ); + }); + + it('times an Xtream row leaving the store for the performance journeys', async () => { + const events: RendererPerformancePhaseEvent[] = []; + const hook = Symbol.for(RENDERER_PERFORMANCE_PHASE_HOOK_KEY); + const target = globalThis as unknown as Record; + target[hook] = (event: RendererPerformancePhaseEvent) => + events.push(event); + try { + service.remove({ ...playlist('x'), serverUrl: 'http://x.test' }); + expireUndo(); + await flush(); + } finally { + delete target[hook]; + } + expect(events.map(({ boundary, phase }) => [boundary, phase])).toEqual([ + ['start', 'store.xtream-delete-row'], + ['end', 'store.xtream-delete-row'], + ]); + }); + + it('shows a long delete on its row and keeps the source on Cancel', async () => { + let finish!: (deleted: boolean) => void; + deletePlaylist.mockImplementation( + ( + _playlist: PlaylistMeta, + options: { onEvent: (event: object) => void } + ) => { + options.onEvent({ + operation: 'delete-playlist', + operationId: 'op-1', + status: 'progress', + phase: 'deleting-content', + current: 1, + total: 4, + }); + return new Promise((resolve) => (finish = resolve)); + } + ); + service.remove(playlist('x')); + expireUndo(); + await flush(); + + // The row is back, in its busy state. + expect(service.isPending('x')).toBe(false); + expect(service.progress.isDeleting('x')).toBe(true); + expect(service.progress.message('x')).toBe( + 'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_CONTENT' + ); + expect(service.progress.progress('x')).toBe(25); + + await service.progress.cancel('x'); + expect(cancelOperation).toHaveBeenCalledWith('op-1'); + finish(false); + await flush(); + expect(service.progress.isDeleting('x')).toBe(false); + expect(dispatch).not.toHaveBeenCalled(); + // A cancel is not a failure. + expect(toasts).toHaveLength(1); + }); + + it('keeps a deleted row busy until the whole delete has settled', async () => { + let finish!: (deleted: boolean) => void; + deletePlaylist.mockImplementation( + ( + _playlist: PlaylistMeta, + options: { onEvent: (event: object) => void } + ) => { + options.onEvent({ + operation: 'delete-playlist', + operationId: 'op-2', + status: 'completed', + }); + return new Promise((resolve) => (finish = resolve)); + } + ); + service.remove({ ...playlist('x'), serverUrl: 'http://x.test' }); + expireUndo(); + await flush(); + + // The database is done, its cleanup is not: no Cancel, still busy. + expect(service.progress.isDeleting('x')).toBe(true); + expect(service.progress.canCancel('x')).toBe(false); + expect(dispatch).not.toHaveBeenCalled(); + + finish(true); + await flush(); + expect(dispatch).toHaveBeenCalledWith( + PlaylistActions.playlistRemovalCommitted({ playlistId: 'x' }) + ); + expect(service.progress.isDeleting('x')).toBe(false); + }); + + it('brings the source back and says so when the delete fails', async () => { + deletePlaylist.mockResolvedValue(false); + service.remove(playlist('a')); + expireUndo(); + await flush(); + expect(dispatch).not.toHaveBeenCalled(); + expect(service.isPending('a')).toBe(false); + expect(toasts[1].message).toBe('WORKSPACE.SOURCES.REMOVE_FAILED'); + }); +}); diff --git a/libs/playlist/shared/ui/src/lib/source-removal/source-removal.service.ts b/libs/playlist/shared/ui/src/lib/source-removal/source-removal.service.ts new file mode 100644 index 000000000..9e267df9a --- /dev/null +++ b/libs/playlist/shared/ui/src/lib/source-removal/source-removal.service.ts @@ -0,0 +1,295 @@ +import { Injectable, inject, signal } from '@angular/core'; +import { MatSnackBar, MatSnackBarRef } from '@angular/material/snack-bar'; +import { Router } from '@angular/router'; +import { Store } from '@ngrx/store'; +import { TranslateService } from '@ngx-translate/core'; +import { PlaylistActions } from '@iptvnator/m3u-state'; +import { + PlaylistContextFacade, + sourceDisplayName, +} from '@iptvnator/playlist/shared/util'; +import { + PlaylistDeleteActionService, + RuntimeCapabilitiesService, + SourceActivityService, +} from '@iptvnator/services'; +import { PlaylistMeta } from '@iptvnator/shared/interfaces'; +import { SourceDeleteProgressService } from './source-delete-progress.service'; +import { SourceRemovalToastComponent } from './source-removal-toast.component'; +import { + measureRendererPerformancePhase, + RENDERER_PERFORMANCE_PHASE, +} from '@iptvnator/shared/logging'; + +/** How long Undo stays available before the delete is committed. */ +export const SOURCE_REMOVAL_UNDO_MS = 4000; + +export interface SourceRemovalOptions { + /** + * For a surface that closes on delete, such as the switcher popover, + * whose focus trap the toast sits outside: Undo takes the focus at once, + * and it comes back here when the toast closes. + */ + returnFocusTo?: HTMLElement | null; +} + +interface PendingRemoval { + playlist: PlaylistMeta; + /** Its Undo toast, set once opened. */ + snackBar?: MatSnackBarRef; + /** The playlist was the active one; `next` replaced it, if any. */ + wasActive: boolean; + wasOnItsRoute: boolean; + previousUrl: string; + next: PlaylistMeta | null; + undone: boolean; + /** Undone or committed: nothing more happens to it. */ + settled: boolean; + /** Commits when the Undo window ends, whatever happened to the toast. */ + timer: ReturnType | undefined; +} + +/** + * Single-source delete with Undo, for the Sources page and the switcher. + * + * Deleting a source drops its favourites, history and playback progress, so + * nothing is deleted while Undo is offered: the source only leaves the + * lists (`pendingIds`), and the real delete runs when the toast closes + * without Undo. Quitting the app inside those four seconds keeps the + * source. The window is the service's own timer, not the toast's: another + * message can replace the toast early without shortening it. One removal + * is pending at a time; a second delete commits the + * first. A delete that reports progress (a large Xtream catalog) brings its + * row back as "Removing…" with Cancel, which keeps the source. Bulk cleanup + * keeps its confirmation dialog instead. + */ +@Injectable({ providedIn: 'root' }) +export class SourceRemovalService { + private readonly deleteAction = inject(PlaylistDeleteActionService); + private readonly playlistContext = inject(PlaylistContextFacade); + private readonly activity = inject(SourceActivityService); + private readonly router = inject(Router); + private readonly runtime = inject(RuntimeCapabilitiesService); + private readonly snackBar = inject(MatSnackBar); + private readonly store = inject(Store); + private readonly translate = inject(TranslateService); + /** Progress of a long delete; its row comes back to show it. */ + readonly progress = inject(SourceDeleteProgressService); + + private readonly pendingState = signal>(new Set()); + /** Sources hidden from every list while Undo or the delete runs. */ + readonly pendingIds = this.pendingState.asReadonly(); + private pending: PendingRemoval | null = null; + + isPending(id: string): boolean { + return this.pendingState().has(id); + } + + /** + * Whether a source can become active in place of a removed one: not + * leaving itself, and not held by a refresh, import or delete, since + * opening it would start an import against a catalog being rewritten. + */ + canTakeOver(id: string): boolean { + return ( + !this.isPending(id) && + !this.progress.isDeleting(id) && + !this.activity.isBusy(id) + ); + } + + /** + * @param next The source to switch to when `playlist` is the active one: + * the caller's next row, so the switch follows the list on screen. + */ + remove( + playlist: PlaylistMeta, + next: PlaylistMeta | null = null, + options: SourceRemovalOptions = {} + ): void { + // A refresh from any entry point (or the startup auto-refresh) is + // destructive too: it clears the catalog, then re-imports it. + if ( + this.isPending(playlist._id) || + this.activity.isBusy(playlist._id) + ) { + return; + } + // A second delete commits the first at once. + if (this.pending) { + this.settle(this.pending); + } + + const wasActive = + this.playlistContext.resolvedPlaylistId() === playlist._id; + const wasOnItsRoute = + this.playlistContext.routePlaylistId() === playlist._id; + const previousUrl = this.router.url; + const replacement = + wasActive && next && this.canTakeOver(next._id) ? next : null; + this.setPending(playlist._id, true); + + if (replacement) { + this.playlistContext.selectPlaylist(replacement); + } else if (wasOnItsRoute) { + void this.router.navigate(['/workspace', 'sources']); + } + + const name = this.nameOf(playlist); + const message = replacement + ? this.translate.instant('WORKSPACE.SOURCES.REMOVED_SWITCHED', { + name, + next: this.nameOf(replacement), + }) + : this.translate.instant('WORKSPACE.SOURCES.REMOVED', { name }); + const removal: PendingRemoval = { + playlist, + wasActive, + wasOnItsRoute, + previousUrl, + next: replacement, + undone: false, + settled: false, + timer: undefined, + }; + const snackBar = this.snackBar.openFromComponent( + SourceRemovalToastComponent, + { + data: { + message, + undoLabel: this.translate.instant( + 'PORTALS.MULTI_SOURCE.UNDO' + ), + closeLabel: this.translate.instant('CLOSE'), + returnFocusTo: options.returnFocusTo ?? null, + commitNow: () => this.settle(removal), + }, + duration: SOURCE_REMOVAL_UNDO_MS, + panelClass: 'app-toast', + } + ); + removal.snackBar = snackBar; + removal.timer = setTimeout( + () => this.settle(removal), + SOURCE_REMOVAL_UNDO_MS + ); + this.pending = removal; + snackBar.onAction().subscribe(() => this.undo(removal)); + } + + /** Ends the Undo window: commits the delete unless it was undone. */ + private settle(removal: PendingRemoval): void { + if (removal.settled) { + return; + } + removal.settled = true; + clearTimeout(removal.timer); + if (this.pending === removal) { + this.pending = null; + } + removal.snackBar?.dismiss(); + if (!removal.undone) { + void this.commit(removal); + } + } + + private undo(removal: PendingRemoval): void { + if (removal.settled) { + return; + } + removal.undone = true; + this.settle(removal); + this.setPending(removal.playlist._id, false); + if (!removal.wasActive) { + return; + } + // Only take the user back if they are still where the delete left + // them: on its replacement, or with no other source chosen. A switch + // they made since then wins, also when nothing replaced the source + // (it was the only row a filter showed). + const current = this.playlistContext.resolvedPlaylistId(); + const untouched = + current === null || + current === removal.playlist._id || + current === removal.next?._id; + if (!untouched) { + return; + } + if (removal.wasOnItsRoute) { + void this.router.navigateByUrl(removal.previousUrl); + } else { + this.playlistContext.activatePlaylist(removal.playlist._id); + } + } + + private async commit(removal: PendingRemoval): Promise { + const { playlist } = removal; + // A desktop Xtream delete works through the catalog database and + // reports progress: its row returns at once as "Removing…". + if (playlist.serverUrl && this.runtime.supportsXtreamSqliteDataSource) { + this.progress.track(playlist._id, { + operation: 'delete-playlist', + status: 'started', + }); + this.setPending(playlist._id, false); + } + try { + const deleted = await this.deleteAction.deletePlaylist(playlist, { + onEvent: (event) => { + this.progress.track(playlist._id, event); + if (this.progress.isDeleting(playlist._id)) { + this.setPending(playlist._id, false); + } + }, + }); + if (deleted) { + const commit = () => + this.store.dispatch( + PlaylistActions.playlistRemovalCommitted({ + playlistId: playlist._id, + }) + ); + // The performance journeys time an Xtream row leaving the store. + if (playlist.serverUrl) { + measureRendererPerformancePhase( + RENDERER_PERFORMANCE_PHASE.XTREAM_DELETE_ROW, + commit, + () => ({ items: 1 }) + ); + } else { + commit(); + } + } else if (!this.progress.takeCancelled(playlist._id)) { + this.snackBar.open( + this.translate.instant('WORKSPACE.SOURCES.REMOVE_FAILED', { + name: this.nameOf(playlist), + }), + this.translate.instant('CLOSE'), + { duration: 5000, panelClass: 'app-toast' } + ); + } + } finally { + this.progress.clear(playlist._id); + this.setPending(playlist._id, false); + } + } + + private nameOf(playlist: PlaylistMeta): string { + return ( + sourceDisplayName(playlist) ?? + this.translate.instant('HOME.PLAYLISTS.UNTITLED_PLAYLIST') + ); + } + + private setPending(id: string, pending: boolean): void { + this.pendingState.update((current) => { + const next = new Set(current); + if (pending) { + next.add(id); + } else { + next.delete(id); + } + return next; + }); + } +} diff --git a/libs/playlist/shared/ui/src/lib/source-row/source-clock.ts b/libs/playlist/shared/ui/src/lib/source-row/source-clock.ts new file mode 100644 index 000000000..8838d7c3d --- /dev/null +++ b/libs/playlist/shared/ui/src/lib/source-row/source-clock.ts @@ -0,0 +1,22 @@ +import { DestroyRef, Injectable, inject, signal } from '@angular/core'; + +/** Often enough for durations shown in whole hours and days. */ +export const SOURCE_CLOCK_TICK_MS = 60_000; + +/** + * One shared minute clock for source rows, so "Not responding · 3 days" + * moves on while a list stays open, without a timer per row. + */ +@Injectable({ providedIn: 'root' }) +export class SourceClock { + private readonly state = signal(Date.now()); + readonly now = this.state.asReadonly(); + + constructor() { + const timer = setInterval( + () => this.state.set(Date.now()), + SOURCE_CLOCK_TICK_MS + ); + inject(DestroyRef).onDestroy(() => clearInterval(timer)); + } +} diff --git a/libs/playlist/shared/ui/src/lib/source-row/source-row.component.html b/libs/playlist/shared/ui/src/lib/source-row/source-row.component.html new file mode 100644 index 000000000..9a64a43fa --- /dev/null +++ b/libs/playlist/shared/ui/src/lib/source-row/source-row.component.html @@ -0,0 +1,261 @@ +
+ @if (draggable()) { + + + } + +
+
+ {{ name() }} + @if (tag(); as tag) { + {{ tag.label }} + } +
+
+ @for (part of meta(); track part.kind; let first = $first) { + @if (!first) { + + } + {{ part.text }} + } +
+ @if (busy() && busyMessage()) { +
+ {{ + busyMessage() + }} + @if (busyProgress() !== null) { + {{ busyProgress() }}% + } +
+ + } +
+ +
+ @if (compact()) { + @if (current()) { + + } + + + } @else { + @if (canCancelBusy()) { + + } + @if (failing() && canRecheck()) { + + } @else if (canRefresh()) { + + } + + + } +
+
+ + + + @if (canRefresh()) { + + } + @if (compact()) { + + @if (hasAccountInfo()) { + + } + } @else { + + @if (copyUrl()) { + + } + + } + @if (canRecheck()) { + + } + + + + diff --git a/libs/playlist/shared/ui/src/lib/source-row/source-row.component.scss b/libs/playlist/shared/ui/src/lib/source-row/source-row.component.scss new file mode 100644 index 000000000..59f1ea752 --- /dev/null +++ b/libs/playlist/shared/ui/src/lib/source-row/source-row.component.scss @@ -0,0 +1,366 @@ +@use '@angular/material' as mat; +@use '../../../../../../ui/styles/app-controls' as controls; +@use '../../../../../../ui/styles/focus-ring'; + +// `--source-row-base` is the surface the list sits on (the page or the +// popover). Row fills are solid mixes of it, so the status dot's ring, which +// takes the row's fill, always matches what is behind the dot. +:host { + display: block; + --source-row-base: var(--app-content-bg); +} + +.source-row { + --source-row-fill: var(--source-row-base); + --source-row-ring: var(--source-row-fill); + + position: relative; + display: grid; + grid-template-columns: 36px minmax(0, 1fr) auto; + align-items: center; + gap: 14px; + min-height: 56px; + padding: 8px 12px; + box-sizing: border-box; + border-radius: 10px; + background: var(--source-row-fill); + cursor: pointer; + + &:has(> .source-row__handle) { + grid-template-columns: 18px 36px minmax(0, 1fr) auto; + gap: 10px 14px; + padding-inline-start: 4px; + } + + &:hover, + &:focus-within, + &.source-row--menu-open { + --source-row-fill: color-mix( + in srgb, + var(--app-on-surface) 5%, + var(--source-row-base) + ); + } +} + +.source-row--busy { + cursor: default; +} + +// ── Compact (playlist switcher) ────────────────────────────────────────── +.source-row--compact { + grid-template-columns: 32px minmax(0, 1fr) auto; + gap: 12px; + min-height: 52px; + padding: 0 8px; + border-radius: 9px; + + &.source-row--highlighted { + --source-row-fill: color-mix( + in srgb, + var(--app-on-surface) 7%, + var(--source-row-base) + ); + // The listbox keeps focus in its search field; the highlighted + // option is the focus indicator. + @include focus-ring.focus-ring-declarations($offset: -2px); + } +} + +// The active source, on the Sources page and in the switcher: the same +// selected-row look as the side lists (a soft tint, and an outline in light +// theme). The dot's ring approximates the tint. +.source-row--current { + --source-row-ring: color-mix( + in srgb, + var(--app-selection-color) 16%, + var(--source-row-base) + ); + background: var(--app-list-selected-bg); + box-shadow: inset 0 0 0 1px var(--app-list-selected-outline); +} + +// ── Tile column ────────────────────────────────────────────────────────── +.source-row__handle { + width: 18px; + height: 18px; + font-size: 18px; + color: var(--app-muted-color); + cursor: grab; +} + +// Set on the tile element itself: its host declares defaults, which an +// inherited value would not override. +app-source-tile { + --source-tile-ring: var(--source-row-ring); +} + +.source-row--failing app-source-tile { + --source-tile-color: var(--app-muted-color); +} + +// ── Text column ────────────────────────────────────────────────────────── +.source-row__body { + display: flex; + flex-direction: column; + gap: 3px; + min-width: 0; + // The row draws this element's focus ring. + outline: none; + + .source-row--compact & { + gap: 2px; + } +} + +// The keyboard ring belongs to the whole row, the unit that opens; inset, +// since the list's scroller would cut one drawn outside the row. +.source-row:has(> .source-row__body:focus-visible) { + @include focus-ring.focus-ring-declarations($offset: -2px); +} + +.source-row__title { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.source-row__name { + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + font-size: 14px; + font-weight: 500; + line-height: 1.3; + color: var(--app-heading-color); + + .source-row--compact & { + font-size: 13.5px; + } + + .source-row--failing & { + color: var(--app-body-color); + } +} + +.source-row__tag { + flex: none; + padding: 2px 7px; + border-radius: 999px; + background: var(--app-danger-surface); + color: var(--app-danger-text); + font-size: 11px; + font-weight: 600; + line-height: 1.3; + white-space: nowrap; +} + +.source-row__tag--neutral { + background: var(--app-neutral-tag-surface); + color: var(--app-body-color); +} + +.source-row__meta { + display: flex; + align-items: baseline; + gap: 6px; + min-width: 0; + overflow: hidden; + white-space: nowrap; + font-size: 12.5px; + line-height: 1.35; + color: var(--app-body-color); + + .source-row--compact & { + font-size: 12px; + } +} + +.source-row__sep { + color: var(--app-muted-color); +} + +.source-row__part { + flex: none; +} + +.source-row__part--number { + font-variant-numeric: tabular-nums; +} + +.source-row__part--url { + flex: 0 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + font-family: var(--app-font-mono); + font-size: 12px; + font-weight: 400; + color: var(--app-muted-color); +} + +.source-row__busy { + display: flex; + justify-content: space-between; + gap: 12px; + font-size: 12px; + color: var(--app-body-color); +} + +.source-row__busy-message { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.source-row__busy-value { + font-variant-numeric: tabular-nums; +} + +.source-row__busy-bar { + margin-top: 2px; +} + +// ── Actions ────────────────────────────────────────────────────────────── +.source-row__actions { + display: flex; + align-items: center; + gap: 2px; +} + +.source-row__action { + @include controls.ghost-icon-button(32px, 14px); +} + +.source-row__retry { + @include controls.ghost-button(sm); +} + +// Cancelling a running refresh or delete reads as destructive. +.source-row__cancel { + @include mat.icon-button-overrides( + ( + icon-color: var(--mat-sys-error), + ) + ); + + &:not([disabled]):hover { + @include mat.icon-button-overrides( + ( + icon-color: var(--mat-sys-error), + ) + ); + } +} + +// Row actions appear on hover, keyboard focus and while the menu is open. +// A failing row keeps Retry in view; touch screens, which cannot hover, +// always show them. +.source-row__action, +.source-row__more--compact { + opacity: 0; + transition: opacity 120ms ease; +} + +.source-row:hover, +.source-row:focus-within, +.source-row--menu-open, +.source-row--current, +.source-row--highlighted { + .source-row__action, + .source-row__more--compact { + opacity: 1; + } +} + +.source-row__action--visible { + opacity: 1; +} + +@media (hover: none) { + .source-row__action, + .source-row__more--compact { + opacity: 1; + } +} + +@media (prefers-reduced-motion: reduce) { + .source-row__action, + .source-row__more--compact { + transition: none; + } +} + +.source-row__check { + width: 18px; + height: 18px; + font-size: 18px; + color: var(--app-selection-color); +} + +.source-row__more--compact { + display: grid; + place-items: center; + width: 30px; + height: 30px; + border-radius: 8px; + color: var(--app-body-color); + + &:hover { + background: color-mix(in srgb, var(--app-on-surface) 8%, transparent); + color: var(--app-heading-color); + } + + .mat-icon { + width: 16px; + height: 16px; + font-size: 16px; + } +} + +// ── Drag and drop (custom order) ───────────────────────────────────────── +.cdk-drag-preview { + box-shadow: 0 12px 30px rgba(0, 0, 0, 0.3); +} + +.cdk-drag-placeholder { + opacity: 0; +} + +.cdk-drag-animating { + transition: transform 250ms cubic-bezier(0.4, 0, 0.2, 1); +} + +@media (prefers-reduced-motion: reduce) { + .cdk-drag-animating { + transition: none; + } +} + +// Phones keep the ⋯ menu, which holds every action, and drop the rest. +@media (max-width: 480px) { + .source-row { + gap: 10px; + padding-inline: 8px; + } + + .source-row__action:not(.source-row__more):not( + .source-row__action--visible + ) { + display: none; + } + + .source-row__part--url, + .source-row__sep:has(+ .source-row__part--url) { + display: none; + } +} + +// UI-kit menu panel. The panel renders in the overlay container, so the +// rule is global; it ships with the components that use `class="app-menu"` +// rather than in the app's initial stylesheet. +::ng-deep .mat-mdc-menu-panel.app-menu { + @include controls.menu-panel; +} diff --git a/libs/playlist/shared/ui/src/lib/source-row/source-row.component.spec.ts b/libs/playlist/shared/ui/src/lib/source-row/source-row.component.spec.ts new file mode 100644 index 000000000..489e9d27e --- /dev/null +++ b/libs/playlist/shared/ui/src/lib/source-row/source-row.component.spec.ts @@ -0,0 +1,229 @@ +import { signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { TranslateModule } from '@ngx-translate/core'; +import { SourceStatusService } from '@iptvnator/portal/shared/data-access'; +import { PlaylistMeta, SourceStatus } from '@iptvnator/shared/interfaces'; +import { SourceClock } from './source-clock'; +import { SourceRowComponent } from './source-row.component'; + +const M3U: PlaylistMeta = { + _id: 'm3u', + title: 'get.php?username=joe&password=hunter2&type=m3u_plus', + url: 'http://biggie.to:8080/get.php?username=joe&password=hunter2&type=m3u_plus', + count: 1803, + importDate: new Date(2026, 8, 18).toISOString(), + autoRefresh: false, +}; +const LOCAL: PlaylistMeta = { + _id: 'local', + title: 'playlist (13)', + count: 3220, + importDate: new Date(2026, 8, 30).toISOString(), + autoRefresh: false, +}; + +describe('SourceRowComponent', () => { + let fixture: ComponentFixture; + let statuses: Map; + let statusService: { + statusOf: jest.Mock; + tracks: jest.Mock; + ensureChecked: jest.Mock; + recheck: jest.Mock; + }; + + const render = ( + playlist: PlaylistMeta, + inputs: Record = {} + ): HTMLElement => { + fixture = TestBed.createComponent(SourceRowComponent); + fixture.componentRef.setInput('playlist', playlist); + for (const [name, value] of Object.entries(inputs)) { + fixture.componentRef.setInput(name, value); + } + fixture.detectChanges(); + return fixture.nativeElement as HTMLElement; + }; + + beforeEach(() => { + statuses = new Map(); + statusService = { + statusOf: jest.fn((p: PlaylistMeta) => + p.url || p.serverUrl ? (statuses.get(p._id) ?? null) : null + ), + tracks: jest.fn((p: PlaylistMeta) => !!(p.url || p.serverUrl)), + ensureChecked: jest.fn(), + recheck: jest.fn(), + }; + TestBed.configureTestingModule({ + imports: [ + SourceRowComponent, + NoopAnimationsModule, + TranslateModule.forRoot(), + ], + providers: [ + { provide: SourceStatusService, useValue: statusService }, + ], + }); + }); + + it('shows the host and a credential-free URL, never the password', () => { + const element = render(M3U); + expect(element.querySelector('.source-row__name')?.textContent).toBe( + 'biggie.to:8080' + ); + expect( + element.querySelector('.source-row__part--url')?.textContent + ).toBe('http://biggie.to:8080/get.php?type=m3u_plus'); + expect(element.textContent).not.toMatch(/hunter2|password=/); + expect( + element + .querySelector('.source-row__body') + ?.getAttribute('aria-label') + ).toBe('biggie.to:8080'); + }); + + it('draws no status dot for a local file', () => { + const element = render(LOCAL); + expect(element.querySelector('.source-tile__dot')).toBeNull(); + expect(element.querySelector('.source-row__part--url')).toBeNull(); + }); + + it('tags a failing source and offers Retry, which rechecks it', () => { + statuses.set('m3u', { reachability: 'bad' }); + const element = render(M3U, { canRefresh: true }); + expect(element.querySelector('.source-row__tag')?.textContent).toBe( + 'WORKSPACE.SOURCES.ROW.UNREACHABLE' + ); + const retry = + element.querySelector('.source-row__retry'); + expect(retry).not.toBeNull(); + retry?.click(); + expect(statusService.recheck).toHaveBeenCalledWith(M3U); + expect( + element + .querySelector('.source-tile__dot') + ?.getAttribute('data-state') + ).toBe('bad'); + }); + + it("names the check's cause on the dot, and an M3U check's scope", () => { + const dotLabel = (element: HTMLElement) => + element + .querySelector('.source-tile__dot') + ?.getAttribute('aria-label'); + statuses.set('m3u', { reachability: 'bad', reason: 'auth' }); + expect(dotLabel(render(M3U))).toBe( + 'SOURCE_HEALTH.REASON.auth · SOURCE_HEALTH.M3U_SCOPE' + ); + + // Without a kept cause the dot falls back to its colour's wording. + statuses.set('xtream', { reachability: 'bad' }); + expect( + dotLabel( + render({ + ...M3U, + _id: 'xtream', + url: undefined, + serverUrl: 'http://portal.test', + username: 'joe', + password: 'hunter2', + }) + ) + ).toBe('SOURCE_HEALTH.REASON.network'); + }); + + it("keeps counting a failure's duration while the list stays open", () => { + const start = Date.UTC(2026, 9, 13, 12); + const now = signal(start); + TestBed.overrideProvider(SourceClock, { useValue: { now } }); + statuses.set('m3u', { + reachability: 'bad', + failingSince: start - 10 * 60_000, + }); + const element = render(M3U); + const tag = () => + element.querySelector('.source-row__tag')?.textContent?.trim(); + expect(tag()).toBe('WORKSPACE.SOURCES.ROW.UNREACHABLE'); + + now.set(start + 2 * 60 * 60_000); + fixture.detectChanges(); + expect(tag()).toBe('WORKSPACE.SOURCES.ROW.UNREACHABLE_FOR'); + }); + + it('opens on click and on Enter, but not while busy', () => { + const element = render(M3U); + const opened = jest.fn(); + fixture.componentInstance.activated.subscribe(opened); + + element.querySelector('.source-row')?.click(); + element + .querySelector('.source-row__body') + ?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); + expect(opened).toHaveBeenCalledTimes(2); + + fixture.componentRef.setInput('deleting', true); + fixture.detectChanges(); + element.querySelector('.source-row')?.click(); + expect(opened).toHaveBeenCalledTimes(2); + }); + + it('does not open the source from a click on the drag handle', () => { + const element = render(M3U, { draggable: true }); + const opened = jest.fn(); + fixture.componentInstance.activated.subscribe(opened); + element.querySelector('.source-row__handle')?.click(); + expect(opened).not.toHaveBeenCalled(); + }); + + it('refreshes the focused row with R', () => { + const element = render(M3U, { canRefresh: true }); + const refresh = jest.fn(); + fixture.componentInstance.refreshRequested.subscribe(refresh); + const row = element.querySelector('.source-row'); + + row?.dispatchEvent( + new KeyboardEvent('keydown', { key: 'r', bubbles: true }) + ); + row?.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'r', + metaKey: true, + bubbles: true, + }) + ); + expect(refresh).toHaveBeenCalledTimes(1); + }); + + it('is a plain option in the switcher, with a pointer-only menu', () => { + const element = render(M3U, { size: 'compact', current: true }); + expect( + element.querySelector('.source-row__body')?.getAttribute('role') + ).toBeNull(); + expect(element.querySelector('button')).toBeNull(); + expect( + element + .querySelector('.source-row__more--compact') + ?.getAttribute('aria-hidden') + ).toBe('true'); + expect(element.querySelector('.source-row__check')).not.toBeNull(); + expect(element.querySelector('.source-row__part--url')).toBeNull(); + }); + + it('checks the source while shown and cancels when removed', () => { + render(M3U); + expect(statusService.ensureChecked).toHaveBeenCalledWith( + M3U, + expect.any(AbortSignal) + ); + const signal = statusService.ensureChecked.mock.calls[0][1]; + fixture.destroy(); + expect((signal as AbortSignal).aborted).toBe(true); + }); + + it('does not check the source when told not to', () => { + render(M3U, { checkHealth: false }); + expect(statusService.ensureChecked).not.toHaveBeenCalled(); + }); +}); diff --git a/libs/playlist/shared/ui/src/lib/source-row/source-row.component.ts b/libs/playlist/shared/ui/src/lib/source-row/source-row.component.ts new file mode 100644 index 000000000..331a43346 --- /dev/null +++ b/libs/playlist/shared/ui/src/lib/source-row/source-row.component.ts @@ -0,0 +1,222 @@ +import { Clipboard } from '@angular/cdk/clipboard'; +import { DragDropModule } from '@angular/cdk/drag-drop'; +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + inject, + input, + output, + signal, + untracked, + viewChild, +} from '@angular/core'; +import { MatButton, MatIconButton } from '@angular/material/button'; +import { MatDivider } from '@angular/material/divider'; +import { MatIcon } from '@angular/material/icon'; +import { MatMenuModule, MatMenuTrigger } from '@angular/material/menu'; +import { MatProgressBar } from '@angular/material/progress-bar'; +import { MatProgressSpinner } from '@angular/material/progress-spinner'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { MatTooltip } from '@angular/material/tooltip'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; +import { normalizeDateLocale } from '@iptvnator/pipes'; +import { SourceStatusService } from '@iptvnator/portal/shared/data-access'; +import { injectTranslationTick } from '@iptvnator/services'; +import { + isPortalAccountPlaylist, + PlaylistMeta, +} from '@iptvnator/shared/interfaces'; +import { + sourceCopyUrl, + sourceDisplayName, + SourceFormatContext, + sourceMetaParts, + sourceStatusTag, +} from '@iptvnator/playlist/shared/util'; +import { SourceClock } from './source-clock'; +import { SourceTileComponent } from './source-tile.component'; + +/** + * One source in a list: the Sources page (`regular`, 56px, hover actions) + * or the playlist switcher (`compact`, 52px, a ⋯ menu only). + * + * The regular row opens from the keyboard through its title block, a + * `role="button"` sibling of the drag handle and the actions, so no control + * sits inside a button. In the switcher the host is a listbox option driven + * by `aria-activedescendant`; its ⋯ is pointer-only and the list opens the + * menu from the keyboard (`openMenu()`). + */ +@Component({ + selector: 'app-source-row', + imports: [ + DragDropModule, + MatButton, + MatDivider, + MatIcon, + MatIconButton, + MatMenuModule, + MatProgressBar, + MatProgressSpinner, + MatTooltip, + SourceTileComponent, + TranslatePipe, + ], + templateUrl: './source-row.component.html', + styleUrl: './source-row.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SourceRowComponent { + private readonly statusService = inject(SourceStatusService); + private readonly clock = inject(SourceClock); + private readonly translate = inject(TranslateService); + private readonly languageTick = injectTranslationTick(); + private readonly clipboard = inject(Clipboard); + private readonly snackBar = inject(MatSnackBar); + private readonly menuTrigger = viewChild(MatMenuTrigger); + + readonly playlist = input.required(); + readonly size = input<'regular' | 'compact'>('regular'); + /** The active playlist (switcher): filled row, check mark, ⋯ shown. */ + readonly current = input(false); + /** The keyboard highlight of the switcher's listbox. */ + readonly highlighted = input(false); + /** Runs a passive reachability check while the row is shown. */ + readonly checkHealth = input(true); + readonly canRefresh = input(false); + readonly draggable = input(false); + readonly refreshing = input(false); + readonly deleting = input(false); + readonly busyMessage = input(''); + readonly busyProgress = input(null); + readonly canCancelBusy = input(false); + /** False while something else, such as a refresh, holds the source. */ + readonly removable = input(true); + + readonly activated = output(); + readonly refreshRequested = output(); + readonly editRequested = output(); + readonly removeRequested = output(); + readonly cancelBusyRequested = output(); + readonly playlistInfoRequested = output(); + readonly accountInfoRequested = output(); + readonly menuClosed = output(); + + readonly menuOpen = signal(false); + readonly compact = computed(() => this.size() === 'compact'); + readonly busy = computed(() => this.refreshing() || this.deleting()); + readonly status = computed(() => + this.statusService.statusOf(this.playlist()) + ); + readonly failing = computed(() => { + const reachability = this.status()?.reachability; + return reachability === 'bad' || reachability === 'expired'; + }); + readonly canRecheck = computed(() => + this.statusService.tracks(this.playlist()) + ); + readonly copyUrl = computed(() => sourceCopyUrl(this.playlist())); + readonly hasAccountInfo = computed(() => + isPortalAccountPlaylist(this.playlist()) + ); + + private readonly formatContext = computed(() => { + this.languageTick(); + return { + translate: (key, params) => this.translate.instant(key, params), + locale: normalizeDateLocale( + this.translate.currentLang || this.translate.defaultLang + ), + now: Date.now(), + }; + }); + readonly name = computed(() => { + this.languageTick(); + return ( + sourceDisplayName(this.playlist()) ?? + this.translate.instant('HOME.PLAYLISTS.UNTITLED_PLAYLIST') + ); + }); + readonly meta = computed(() => + sourceMetaParts(this.playlist(), this.formatContext(), this.compact()) + ); + /** Reads the shared clock, so a failure's duration keeps counting. */ + readonly tag = computed(() => + sourceStatusTag( + this.status(), + { ...this.formatContext(), now: this.clock.now() }, + this.compact() + ) + ); + + constructor() { + effect((onCleanup) => { + const playlist = this.playlist(); + if (!this.checkHealth()) { + return; + } + const controller = new AbortController(); + untracked(() => + this.statusService.ensureChecked(playlist, controller.signal) + ); + onCleanup(() => controller.abort()); + }); + } + + onRowClick(): void { + if (!this.busy()) { + this.activated.emit(); + } + } + + /** Enter/Space on the title block; Space must not scroll the page. */ + onActivationKey(event: Event): void { + event.preventDefault(); + this.onRowClick(); + } + + /** `R` refreshes the focused row, as its menu item says. */ + onRowKeydown(event: KeyboardEvent): void { + const plain = + !event.ctrlKey && + !event.metaKey && + !event.altKey && + !event.shiftKey; + if ( + plain && + event.key.toLowerCase() === 'r' && + !this.compact() && + this.canRefresh() && + !this.busy() + ) { + event.preventDefault(); + this.refreshRequested.emit(); + } + } + + recheck(): void { + this.statusService.recheck(this.playlist()); + } + + copyUrlToClipboard(): void { + const url = this.copyUrl(); + if (url && this.clipboard.copy(url)) { + this.snackBar.open( + this.translate.instant('HOME.PLAYLISTS.INFO_DIALOG.URL_COPIED'), + undefined, + { duration: 2000 } + ); + } + } + + /** Opens the ⋯ menu from the keyboard (switcher: Shift+F10). */ + openMenu(): void { + this.menuTrigger()?.openMenu(); + } + + onMenuClosed(): void { + this.menuOpen.set(false); + this.menuClosed.emit(); + } +} diff --git a/libs/playlist/shared/ui/src/lib/source-row/source-tile.component.scss b/libs/playlist/shared/ui/src/lib/source-row/source-tile.component.scss new file mode 100644 index 000000000..7c8cc22ae --- /dev/null +++ b/libs/playlist/shared/ui/src/lib/source-row/source-tile.component.scss @@ -0,0 +1,74 @@ +// The provider tile of a source row and the switcher trigger. The dot's +// 2px ring takes `--source-tile-ring`, which the row sets to its own +// background, so the dot reads as cut out of the tile in every state. +:host { + // Defaults a row or trigger overrides on the `app-source-tile` element. + --source-tile-bg: var(--app-widget-bg); + --source-tile-color: var(--app-body-color); + --source-tile-ring: var(--app-content-bg); + --source-tile-size: 36px; + --source-tile-radius: 9px; + --source-tile-icon: 17px; + --source-tile-dot: 11px; + --source-tile-dot-offset: -3px; + + position: relative; + flex: none; + display: grid; + place-items: center; + width: var(--source-tile-size); + height: var(--source-tile-size); + border-radius: var(--source-tile-radius); + background: var(--source-tile-bg); + color: var(--source-tile-color); +} + +:host(.source-tile--md) { + --source-tile-size: 32px; + --source-tile-radius: 8px; + --source-tile-icon: 15px; + --source-tile-dot: 9px; +} + +:host(.source-tile--sm) { + --source-tile-size: 28px; + --source-tile-radius: 7px; + --source-tile-icon: 14px; + --source-tile-dot: 9px; + --source-tile-dot-offset: -2px; +} + +.source-tile__icon { + width: var(--source-tile-icon); + height: var(--source-tile-icon); + font-size: var(--source-tile-icon); + line-height: var(--source-tile-icon); +} + +.source-tile__dot { + position: absolute; + right: var(--source-tile-dot-offset); + bottom: var(--source-tile-dot-offset); + box-sizing: border-box; + width: var(--source-tile-dot); + height: var(--source-tile-dot); + border-radius: 50%; + border: 2px solid var(--source-tile-ring); + background: var(--app-muted-color); + + &[data-state='ok'] { + background: var(--app-success-color); + } + + &[data-state='bad'] { + background: var(--app-live-color); + } + + &[data-state='expired'] { + background: var(--app-expiry-warn-text); + } +} + +.source-tile__dot--pending { + opacity: 0.45; +} diff --git a/libs/playlist/shared/ui/src/lib/source-row/source-tile.component.ts b/libs/playlist/shared/ui/src/lib/source-row/source-tile.component.ts new file mode 100644 index 000000000..ff41dd2d5 --- /dev/null +++ b/libs/playlist/shared/ui/src/lib/source-row/source-tile.component.ts @@ -0,0 +1,111 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + inject, + input, +} from '@angular/core'; +import { MatIcon } from '@angular/material/icon'; +import { MatTooltip } from '@angular/material/tooltip'; +import { TranslateService } from '@ngx-translate/core'; +import { normalizeDateLocale } from '@iptvnator/pipes'; +import { injectTranslationTick } from '@iptvnator/services'; +import { + getPlaylistSourceIcon, + PlaylistMeta, + resolvePlaylistSourceKind, + SourceReachability, + SourceStatus, +} from '@iptvnator/shared/interfaces'; + +/** + * The health reason shown for each reachability when the check left no + * cause, as in a browser build's "checking" status. + */ +const REACHABILITY_REASON: Record = { + ok: 'available', + bad: 'network', + expired: 'expired', + checking: 'checking', + unknown: 'unknown', +}; + +/** + * The provider icon on a rounded tile, with the status dot at its corner: + * green answers, red does not, orange is an expired account, grey is not + * checked yet. Local files have no dot. + */ +@Component({ + selector: 'app-source-tile', + imports: [MatIcon, MatTooltip], + template: ` + + @if (status(); as status) { + + } + `, + styleUrl: './source-tile.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + class: 'source-tile', + '[class.source-tile--sm]': "size() === 'sm'", + '[class.source-tile--md]': "size() === 'md'", + '[class.source-tile--lg]': "size() === 'lg'", + }, +}) +export class SourceTileComponent { + private readonly translate = inject(TranslateService); + private readonly languageTick = injectTranslationTick(); + + readonly playlist = + input.required< + Pick + >(); + readonly status = input(null); + /** 28px (trigger), 32px (switcher) or 36px (Sources page). */ + readonly size = input<'sm' | 'md' | 'lg'>('lg'); + + readonly icon = computed(() => getPlaylistSourceIcon(this.playlist())); + readonly dotState = computed(() => { + const reachability = this.status()?.reachability; + return reachability === 'checking' ? 'unknown' : reachability; + }); + readonly description = computed(() => { + this.languageTick(); + const status = this.status(); + if (!status) { + return ''; + } + const reason = status.pending + ? 'checking' + : (status.reason ?? REACHABILITY_REASON[status.reachability]); + const label = this.translate.instant(`SOURCE_HEALTH.REASON.${reason}`); + // An M3U check reaches the playlist URL, not its channels. + const scope = + resolvePlaylistSourceKind(this.playlist()) === 'm3u' + ? this.translate.instant('SOURCE_HEALTH.M3U_SCOPE') + : ''; + const time = status.checkedAt + ? new Date(status.checkedAt).toLocaleString( + normalizeDateLocale(this.translate.currentLang || 'en'), + { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + } + ) + : ''; + return [label, scope, time].filter(Boolean).join(' · '); + }); +} diff --git a/libs/playlist/shared/ui/src/lib/xtream-refresh-flow.service.spec.ts b/libs/playlist/shared/ui/src/lib/xtream-refresh-flow.service.spec.ts index 13c75c41c..727ab15e0 100644 --- a/libs/playlist/shared/ui/src/lib/xtream-refresh-flow.service.spec.ts +++ b/libs/playlist/shared/ui/src/lib/xtream-refresh-flow.service.spec.ts @@ -9,6 +9,7 @@ import { DatabaseService, DbOperationEvent, PlaybackPositionService, + SourceActivityService, } from '@iptvnator/services'; import { CONNECTIVITY_GUARD_RESET, @@ -168,6 +169,42 @@ describe('XtreamRefreshFlowService', () => { expect(dataService.sendIpcEvent).not.toHaveBeenCalled(); }); + it('holds the source in the shared registry for the run, and skips a held one', async () => { + const activity = TestBed.inject(SourceActivityService); + const item = createPlaylistMeta(); + let heldDuringDelete = false; + let runningDuringDelete = false; + databaseService.deleteXtreamPlaylistContent.mockImplementationOnce( + () => { + heldDuringDelete = activity.isBusy(item._id); + runningDuringDelete = service.isRunning(item._id); + return Promise.resolve({ + success: true, + favorites: [], + recentlyViewed: [], + hiddenCategories: [], + }); + } + ); + service.confirmAndRefresh(item, createReporter().reporter); + await confirmPromise; + expect(heldDuringDelete).toBe(true); + expect(runningDuringDelete).toBe(true); + expect(activity.isBusy(item._id)).toBe(false); + expect(service.isRunning(item._id)).toBe(false); + + // A delete or another refresh already holds it: nothing runs. + const release = activity.begin([item._id]); + const second = createReporter(); + service.confirmAndRefresh(item, second.reporter); + await confirmPromise; + expect(second.calls).toEqual([]); + expect( + databaseService.deleteXtreamPlaylistContent + ).toHaveBeenCalledTimes(1); + release(); + }); + it('serializes destructive runs for one playlist across entry points', async () => { const item = createPlaylistMeta(); const confirms: Array | undefined> = []; diff --git a/libs/playlist/shared/ui/src/lib/xtream-refresh-flow.service.ts b/libs/playlist/shared/ui/src/lib/xtream-refresh-flow.service.ts index c872a6263..a3d2788b0 100644 --- a/libs/playlist/shared/ui/src/lib/xtream-refresh-flow.service.ts +++ b/libs/playlist/shared/ui/src/lib/xtream-refresh-flow.service.ts @@ -1,4 +1,4 @@ -import { inject, Injectable } from '@angular/core'; +import { inject, Injectable, signal } from '@angular/core'; import { Router } from '@angular/router'; import { Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; @@ -11,6 +11,7 @@ import { isDbAbortError, PlaybackPositionService, resetHostConnectivityGuard, + SourceActivityService, XtreamPendingRestoreService, } from '@iptvnator/services'; import { PlaylistActions } from '@iptvnator/m3u-state'; @@ -79,6 +80,7 @@ export class XtreamRefreshFlowService { private readonly pendingRestoreService = inject( XtreamPendingRestoreService ); + private readonly activity = inject(SourceActivityService); /** * Playlists with a destructive run in flight, across every entry point. @@ -91,7 +93,30 @@ export class XtreamRefreshFlowService { * losing favorites, history, hidden categories and playback positions on * re-import. This service is the only place that sees both. */ - private readonly activeRefreshPlaylistIds = new Set(); + private readonly activeRefreshPlaylistIds = signal>( + new Set() + ); + + /** + * Whether a destructive run is in flight for the playlist. Opening the + * playlist meanwhile would start its import against a half-deleted + * catalog, before the user data to restore has been parked. + */ + isRunning(playlistId: string): boolean { + return this.activeRefreshPlaylistIds().has(playlistId); + } + + private setRunning(playlistId: string, running: boolean): void { + this.activeRefreshPlaylistIds.update((current) => { + const next = new Set(current); + if (running) { + next.add(playlistId); + } else { + next.delete(playlistId); + } + return next; + }); + } /** * Asks for confirmation, then deletes and re-imports the playlist. Callers @@ -119,8 +144,11 @@ export class XtreamRefreshFlowService { item: PlaylistMeta, reporter: XtreamRefreshProgressReporter ): Promise { + // The shared registry also holds deletes, the startup auto-refresh + // and M3U refreshes from either entry point. if ( - this.activeRefreshPlaylistIds.has(item._id) || + this.isRunning(item._id) || + this.activity.isBusy(item._id) || reporter.isBusy(item._id) ) { return; @@ -130,7 +158,8 @@ export class XtreamRefreshFlowService { this.databaseService.createOperationId('xtream-refresh'); const run: XtreamRefreshRun = { playlistId: item._id, operationId }; reporter.begin(run); - this.activeRefreshPlaylistIds.add(item._id); + this.setRunning(item._id, true); + const release = this.activity.begin([item._id]); try { // Before anything destructive: this deletes the cached catalog and @@ -204,7 +233,8 @@ export class XtreamRefreshFlowService { ); } } finally { - this.activeRefreshPlaylistIds.delete(item._id); + this.setRunning(item._id, false); + release(); reporter.end(run); } } diff --git a/libs/playlist/shared/util/src/index.ts b/libs/playlist/shared/util/src/index.ts index 7fc8eefe5..d2c2ca19d 100644 --- a/libs/playlist/shared/util/src/index.ts +++ b/libs/playlist/shared/util/src/index.ts @@ -1,2 +1,7 @@ export * from './lib/playlist-context.facade'; +export * from './lib/playlist-recent-history.service'; export * from './lib/playlist-file-import.service'; +export * from './lib/source-display/source-display'; +export * from './lib/source-display/source-format'; +export * from './lib/source-display/source-list'; +export * from './lib/source-display/sources-view-state.service'; diff --git a/libs/playlist/shared/util/src/lib/playlist-context.facade.ts b/libs/playlist/shared/util/src/lib/playlist-context.facade.ts index 696fdd871..e811651d7 100644 --- a/libs/playlist/shared/util/src/lib/playlist-context.facade.ts +++ b/libs/playlist/shared/util/src/lib/playlist-context.facade.ts @@ -22,6 +22,7 @@ import { PortalRailSection, } from '@iptvnator/portal/shared/util'; import { RuntimeCapabilitiesService } from '@iptvnator/services'; +import { PlaylistRecentHistoryService } from './playlist-recent-history.service'; export interface PlaylistRouteContext { inWorkspace: boolean; @@ -73,6 +74,7 @@ export class PlaylistContextFacade { private readonly runtime = inject(RuntimeCapabilitiesService); private readonly router = inject(Router); private readonly store = inject(Store); + private readonly recentHistory = inject(PlaylistRecentHistoryService); readonly playlists = this.store.selectSignal(selectAllPlaylistsMeta); readonly allPlaylistsLoaded = this.store.selectSignal( @@ -539,6 +541,7 @@ export class PlaylistContextFacade { } private writePersistedActivePlaylistId(playlistId: string): void { + this.recentHistory.record(playlistId); try { localStorage.setItem(LAST_ACTIVE_PLAYLIST_STORAGE_KEY, playlistId); } catch { diff --git a/libs/playlist/shared/util/src/lib/playlist-recent-history.service.spec.ts b/libs/playlist/shared/util/src/lib/playlist-recent-history.service.spec.ts new file mode 100644 index 000000000..a94f347f2 --- /dev/null +++ b/libs/playlist/shared/util/src/lib/playlist-recent-history.service.spec.ts @@ -0,0 +1,35 @@ +import { TestBed } from '@angular/core/testing'; +import { + PLAYLIST_RECENT_HISTORY_STORAGE_KEY, + PlaylistRecentHistoryService, +} from './playlist-recent-history.service'; + +describe('PlaylistRecentHistoryService', () => { + const create = () => { + TestBed.configureTestingModule({ + providers: [PlaylistRecentHistoryService], + }); + return TestBed.inject(PlaylistRecentHistoryService); + }; + + beforeEach(() => localStorage.clear()); + afterEach(() => TestBed.resetTestingModule()); + + it('keeps the three most recently opened, newest first', () => { + const history = create(); + for (const id of ['a', 'b', 'c', 'b', 'd']) { + history.record(id); + } + expect(history.ids()).toEqual(['d', 'b', 'c']); + }); + + it('survives a restart and ignores unreadable storage', () => { + create().record('a'); + TestBed.resetTestingModule(); + expect(create().ids()).toEqual(['a']); + + TestBed.resetTestingModule(); + localStorage.setItem(PLAYLIST_RECENT_HISTORY_STORAGE_KEY, '{oops'); + expect(create().ids()).toEqual([]); + }); +}); diff --git a/libs/playlist/shared/util/src/lib/playlist-recent-history.service.ts b/libs/playlist/shared/util/src/lib/playlist-recent-history.service.ts new file mode 100644 index 000000000..a3583a506 --- /dev/null +++ b/libs/playlist/shared/util/src/lib/playlist-recent-history.service.ts @@ -0,0 +1,51 @@ +import { Injectable, signal } from '@angular/core'; + +export const PLAYLIST_RECENT_HISTORY_STORAGE_KEY = + 'playlist-switcher:recent:v1'; +/** The switcher's "Recent" group shows this many. */ +export const PLAYLIST_RECENT_HISTORY_SIZE = 3; + +function readIds(): string[] { + try { + const parsed: unknown = JSON.parse( + localStorage.getItem(PLAYLIST_RECENT_HISTORY_STORAGE_KEY) ?? '[]' + ); + return Array.isArray(parsed) + ? parsed + .filter((id): id is string => typeof id === 'string' && !!id) + .slice(0, PLAYLIST_RECENT_HISTORY_SIZE) + : []; + } catch { + return []; + } +} + +/** + * The playlists opened most recently, newest first, kept across restarts. + * IDs of deleted playlists stay until pushed out; readers skip the ones that + * no longer exist. + */ +@Injectable({ providedIn: 'root' }) +export class PlaylistRecentHistoryService { + private readonly state = signal(readIds()); + readonly ids = this.state.asReadonly(); + + record(playlistId: string): void { + if (!playlistId || this.state()[0] === playlistId) { + return; + } + const next = [ + playlistId, + ...this.state().filter((id) => id !== playlistId), + ].slice(0, PLAYLIST_RECENT_HISTORY_SIZE); + this.state.set(next); + try { + localStorage.setItem( + PLAYLIST_RECENT_HISTORY_STORAGE_KEY, + JSON.stringify(next) + ); + } catch { + // The list still works for this session. + } + } +} diff --git a/libs/playlist/shared/util/src/lib/source-display/source-display.spec.ts b/libs/playlist/shared/util/src/lib/source-display/source-display.spec.ts new file mode 100644 index 000000000..21c27c62c --- /dev/null +++ b/libs/playlist/shared/util/src/lib/source-display/source-display.spec.ts @@ -0,0 +1,411 @@ +import { + sourceCopyUrl, + sourceDisplayName, + sourceSortName, + sourceDisplayUrl, + sourceHost, +} from './source-display'; + +const M3U_URL = + 'http://biggie.to:8080/get.php?username=joe&password=hunter2&type=m3u_plus'; + +describe('sourceDisplayName', () => { + it('keeps a name the user chose', () => { + expect(sourceDisplayName({ title: 'Free TV', url: M3U_URL })).toBe( + 'Free TV' + ); + }); + + it('shows the host when the source has no name', () => { + expect(sourceDisplayName({ title: '', url: M3U_URL })).toBe( + 'biggie.to:8080' + ); + expect( + sourceDisplayName({ + title: '', + serverUrl: 'http://xtream.tv:80', + username: 'joe', + password: 'hunter2', + }) + ).toBe('xtream.tv'); + }); + + it('shows the host for the name an URL import derives', () => { + // The import stores the URL's last segment, query and all. + expect( + sourceDisplayName({ + title: 'get.php?username=joe&password=hunter2&type=m3u_plus', + url: M3U_URL, + }) + ).toBe('biggie.to:8080'); + }); + + it('shows the host for a pasted URL', () => { + expect( + sourceDisplayName({ + title: 'https://cdn.tv/lists/clean.m3u', + url: 'https://cdn.tv/lists/clean.m3u', + }) + ).toBe('cdn.tv'); + }); + + it('drops a name that carries a credential', () => { + expect( + sourceDisplayName({ + title: 'My portal pass== hunter2', + serverUrl: 'http://xtream.tv', + }) + ).toBe('xtream.tv'); + expect( + sourceDisplayName({ + title: 'backup hunter2', + serverUrl: 'http://xtream.tv', + password: 'hunter2', + }) + ).toBe('xtream.tv'); + expect( + sourceDisplayName({ + title: 'Box 00:1A:79:00:00:01', + macAddress: '00:1A:79:00:00:01', + portalUrl: 'http://portal.tv/c/', + }) + ).toBe('portal.tv'); + }); + + it('keeps an account name in the name: it is shown by design', () => { + expect(sourceDisplayName({ title: 'joe', url: M3U_URL })).toBe('joe'); + expect( + sourceDisplayName({ + title: 'joe', + url: 'https://host.test/list?usr=joe&pass=hunter2', + }) + ).toBe('joe'); + }); + + it('reads keys, markers and values through every encoding layer', () => { + for (const url of [ + 'https://proxy.test/list?%2570assword=hunter2', + 'https://origin.test/%256Cive/joe/hunter2/1.ts', + ]) { + expect(sourceDisplayName({ title: 'Mine hunter2', url })).toBe( + new URL(url).host + ); + } + // A password encoded twice still matches the name it appears in. + const url = `https://host.test/list?password=${encodeURIComponent( + encodeURIComponent('pw#9876') + )}`; + expect(sourceDisplayName({ title: 'Mine pw#9876', url })).toBe( + 'host.test' + ); + }); + + it('drops a name holding an account hidden behind encoded separators', () => { + for (const url of [ + 'https://origin.test/live%2Fjoe%2Fhunter2%2F1.ts', + 'https://origin.test/live/joe%2Fx/hunter2/1.ts', + ]) { + expect(sourceDisplayName({ title: 'Mine hunter2', url })).toBe( + 'origin.test' + ); + } + }); + + it('drops a name holding a secret of a JSON config in the URL', () => { + for (const json of [ + '{"password":"hunter2"}', + '{"pass\\u0077ord":"hunter2"}', + '{"proxy":{"upstream":"http://joe:hunter2@origin.test/a"}}', + ]) { + const url = `https://proxy.test/list?config=${encodeURIComponent(json)}`; + expect(sourceDisplayName({ title: 'Mine hunter2', url })).toBe( + 'proxy.test' + ); + } + expect( + sourceDisplayName({ + title: 'Mine', + url: 'https://proxy.test/list?config=%7B%22hd%22%3A1%7D', + }) + ).toBe('Mine'); + }); + + it('reads a bracketed key that only starts like JSON as parameters', () => { + const url = `https://proxy.test/list?config=${encodeURIComponent( + '[password]=hunter2' + )}`; + expect(sourceDisplayName({ title: 'Mine hunter2', url })).toBe( + 'proxy.test' + ); + }); + + it('drops a name that is nothing but a short password', () => { + const xtream = { + serverUrl: 'http://xtream.tv:8080', + username: 'joe', + password: 'pw', + }; + expect(sourceDisplayName({ ...xtream, title: 'pw' })).toBe( + 'xtream.tv:8080' + ); + expect(sourceDisplayName({ ...xtream, title: ' pw ' })).toBe( + 'xtream.tv:8080' + ); + // A password has its own case: `PW` is not `pw`. + expect(sourceDisplayName({ ...xtream, title: 'PW' })).toBe('PW'); + // Inside a longer name a short password is just letters. + expect(sourceDisplayName({ ...xtream, title: 'Pwnova' })).toBe( + 'Pwnova' + ); + expect(sourceDisplayUrl({ ...xtream, username: 'pw' })).toBe( + 'http://xtream.tv:8080' + ); + }); + + it('finds a MAC from the URL whatever its letter case', () => { + const mac = '00:1A:79:00:00:01'; + for (const url of [ + `https://host.test/list.m3u?mac=${mac}`, + `https://proxy.test/list?url=${encodeURIComponent( + `http://origin.test/c/?mac=${mac}` + )}`, + ]) { + expect( + sourceDisplayName({ title: 'Box 00:1a:79:00:00:01', url }) + ).toBe(new URL(url).host); + } + }); + + it('keeps a name holding the password word in another case', () => { + expect( + sourceDisplayName({ + title: 'Stress Portal', + serverUrl: 'http://xtream.tv', + username: 'stress', + password: 'stress', + }) + ).toBe('Stress Portal'); + }); + + it('keeps a name that only shares a letter with a decoded password', () => { + // `%2542` is the password as written; decoded it is just `B`. + const url = 'https://host.test/list?password=%2542'; + expect(sourceDisplayName({ title: 'Bay News', url })).toBe('Bay News'); + expect(sourceDisplayName({ title: 'Mine %42', url })).toBe('host.test'); + }); + + it('drops a name holding a password under a short account key', () => { + for (const key of ['pass', 'pas', 'PASS']) { + const url = `https://host.test/list?user=joe&${key}=hunter2`; + expect(sourceDisplayName({ title: 'hunter2', url })).toBe( + 'host.test' + ); + expect(sourceDisplayName({ title: 'Mine hunter2', url })).toBe( + 'host.test' + ); + } + }); + + it('shows the host for a name an import took from a URL fragment', () => { + const url = 'https://host.test/list.m3u#api_key=secret123'; + expect( + sourceDisplayName({ title: 'list.m3u#api_key=secret123', url }) + ).toBe('host.test'); + // A typed name holding the fragment's secret collapses too. + expect(sourceDisplayName({ title: 'My secret123', url })).toBe( + 'host.test' + ); + }); + + it('drops a name holding a secret of a URL nested in the source URL', () => { + const url = `https://proxy.test/list?url=${encodeURIComponent( + 'http://joe:hunter2@origin.test/get.php?password=pw4567&type=m3u' + )}`; + for (const title of ['Mine hunter2', 'Mine pw4567', 'Mine joe']) { + expect(sourceDisplayName({ title, url })).toBe('proxy.test'); + } + expect(sourceDisplayName({ title: 'Mine', url })).toBe('Mine'); + }); + + it('drops a name holding the password of an Xtream stream path', () => { + const url = 'http://host.tv/LIVE/joe/hunter2/1.ts'; + expect(sourceDisplayName({ title: 'Mine hunter2', url })).toBe( + 'host.tv' + ); + expect( + sourceDisplayName({ + title: 'My hunter2', + url: 'https://host.test/live/joe/hunter2/list.m3u', + }) + ).toBe('host.test'); + }); + + it('drops a name holding a secret of a URL in the fragment', () => { + const url = 'https://proxy.test/list#https://joe:hunter2@origin.test/a'; + for (const title of ['Mine hunter2', 'Mine joe']) { + expect(sourceDisplayName({ title, url })).toBe('proxy.test'); + } + expect(sourceDisplayName({ title: 'Mine', url })).toBe('Mine'); + }); + + it('keeps a playlist file name from a path that names no account', () => { + const url = 'https://cdn.example/live/news/list.m3u'; + expect(sourceDisplayName({ title: 'list.m3u', url })).toBe('list.m3u'); + }); + + it('drops a name holding a secret of a nested URL encoded twice', () => { + const url = `https://proxy.test/list?url=${encodeURIComponent( + encodeURIComponent('http://joe:hunter2@origin.test/a.m3u') + )}`; + for (const title of ['Mine hunter2', 'Mine joe']) { + expect(sourceDisplayName({ title, url })).toBe('proxy.test'); + } + }); + + it('drops a name holding a secret of a URL nested in a path segment', () => { + const url = `https://proxy.test/fetch/${encodeURIComponent( + 'http://origin.test/live/joe/hunter2/1.ts' + )}/token=tk98765`; + for (const title of ['Mine hunter2', 'Mine joe', 'Mine tk98765']) { + expect(sourceDisplayName({ title, url })).toBe('proxy.test'); + } + expect(sourceDisplayName({ title: 'Mine', url })).toBe('Mine'); + }); + + it('returns null for a local file without a name', () => { + expect(sourceDisplayName({ title: '' })).toBeNull(); + expect(sourceDisplayName({ title: '', filename: 'list.m3u' })).toBe( + 'list.m3u' + ); + }); +}); + +describe('sourceSortName', () => { + it('sorts a source by the name it shows', () => { + expect(sourceSortName({ title: 'get.php?x=1', url: M3U_URL })).toBe( + sourceDisplayName({ title: 'get.php?x=1', url: M3U_URL }) + ); + expect(sourceSortName({ title: '' })).toBe(''); + }); +}); + +describe('sourceDisplayUrl', () => { + it('shows an M3U URL without credentials', () => { + expect(sourceDisplayUrl({ url: M3U_URL })).toBe( + 'http://biggie.to:8080/get.php?type=m3u_plus' + ); + }); + + it('shortens a long path to its last segment', () => { + expect( + sourceDisplayUrl({ + url: 'https://raw.githubusercontent.com/user/repo/main/clean.m3u', + }) + ).toBe('https://raw.githubusercontent.com/…/clean.m3u'); + expect( + sourceDisplayUrl({ + url: 'https://iptv-org.github.io/iptv/index.m3u', + }) + ).toBe('https://iptv-org.github.io/iptv/index.m3u'); + }); + + it('shows host, port and account for Xtream, never the password', () => { + expect( + sourceDisplayUrl({ + serverUrl: 'http://xtream.tv:8080/', + username: 'joe', + password: 'hunter2', + }) + ).toBe('http://xtream.tv:8080 · joe'); + }); + + it('leaves out an account name that is, or holds, the password', () => { + for (const username of ['hunter2', 'joe-hunter2']) { + expect( + sourceDisplayUrl({ + serverUrl: 'http://xtream.tv:8080/', + username, + password: 'hunter2', + }) + ).toBe('http://xtream.tv:8080'); + } + }); + + it('shows the portal URL for Stalker, never the MAC', () => { + expect( + sourceDisplayUrl({ + macAddress: '00:1A:79:00:00:01', + portalUrl: + 'http://portal.tv/stalker_portal/c/?mac=00:1A:79:00:00:01', + }) + ).toBe('http://portal.tv/stalker_portal/c/'); + }); + + it('falls back to the origin when a secret sits in the path', () => { + expect( + sourceDisplayUrl({ + macAddress: '00:1A:79:00:00:01', + portalUrl: 'http://portal.tv/00:1A:79:00:00:01/c/', + }) + ).toBe('http://portal.tv'); + }); + + it('finds a secret whatever the case of its escapes or letters', () => { + for (const portalUrl of [ + 'http://portal.tv/00%3a1A%3a79%3a00%3a00%3a01/c/', + 'http://portal.tv/00:1a:79:00:00:01/c/', + ]) { + expect( + sourceDisplayUrl({ macAddress: '00:1A:79:00:00:01', portalUrl }) + ).toBe('http://portal.tv'); + } + expect( + sourceDisplayName({ + title: 'Box 00:1a:79:00:00:01', + macAddress: '00:1A:79:00:00:01', + portalUrl: 'http://portal.tv/c/', + }) + ).toBe('portal.tv'); + }); + + it('has no URL for a local file or an unparsable address', () => { + expect(sourceDisplayUrl({ title: 'local' })).toBeNull(); + expect(sourceDisplayUrl({ url: 'not a url' })).toBeNull(); + expect( + sourceDisplayUrl({ serverUrl: 'nope', username: 'joe' }) + ).toBeNull(); + }); + + it('never shows a password, token or MAC', () => { + const sources = [ + { url: M3U_URL }, + { url: 'http://joe:hunter2@cdn.tv/a/b/c/list.m3u?token=hunter2' }, + { serverUrl: 'http://x.tv', username: 'joe', password: 'hunter2' }, + { + macAddress: 'hunter2', + portalUrl: 'http://p.tv/c/?mac=hunter2&sn=hunter2', + }, + ]; + for (const source of sources) { + expect(sourceDisplayUrl(source) ?? '').not.toMatch(/hunter2/); + expect( + sourceDisplayName({ ...source, title: '' }) ?? '' + ).not.toMatch(/hunter2/); + } + }); +}); + +describe('sourceHost and sourceCopyUrl', () => { + it('reads the endpoint by provider precedence', () => { + const stalker = { + macAddress: '00:1A:79:00:00:01', + portalUrl: 'http://portal.tv/c/', + url: 'http://other.tv/list.m3u', + }; + expect(sourceHost(stalker)).toBe('portal.tv'); + expect(sourceCopyUrl(stalker)).toBe('http://portal.tv/c/'); + expect(sourceCopyUrl({ url: M3U_URL })).toBe(M3U_URL); + expect(sourceCopyUrl({})).toBeNull(); + expect(sourceHost({})).toBeNull(); + }); +}); diff --git a/libs/playlist/shared/util/src/lib/source-display/source-display.ts b/libs/playlist/shared/util/src/lib/source-display/source-display.ts new file mode 100644 index 000000000..6b400d081 --- /dev/null +++ b/libs/playlist/shared/util/src/lib/source-display/source-display.ts @@ -0,0 +1,330 @@ +import { + PlaylistMeta, + playlistDisplayLabel, + playlistNameHasCredentials, + resolvePlaylistSourceKind, +} from '@iptvnator/shared/interfaces'; +import { decodeFully, findXtreamAccount } from '@iptvnator/shared/logging'; +import { + isDisplaySensitiveKey, + stripUrlCredentials, + unwrapNestedValue, +} from './strip-url-credentials'; + +export type SourceDisplayFields = Partial< + Pick< + PlaylistMeta, + | 'title' + | 'filename' + | 'url' + | 'serverUrl' + | 'portalUrl' + | 'macAddress' + | 'username' + | 'password' + > +>; + +/** The account name stays on screen by design; every other secret goes. */ +const SHOWN_ACCOUNT_KEYS = new Set(['username', 'login', 'user', 'usr']); +/** + * Shorter secrets match ordinary words inside a text, so they count only + * as the whole text: a name that is just a two-letter password is hidden. + */ +const MIN_SECRET_LENGTH = 3; +const ABSOLUTE_URL = /^[a-z][a-z0-9+.-]*:\/\//i; +/** Six hex pairs, as a STB MAC is written, wherever the secret came from. */ +const MAC_ADDRESS = /^[0-9a-f]{2}(?:[:-][0-9a-f]{2}){5}$/i; + +function parseUrl(value: string | undefined): URL | null { + if (!value) { + return null; + } + try { + return new URL(value.trim()); + } catch { + return null; + } +} + +function safeDecode(value: string): string { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + +/** The portal, server or playlist URL a source is loaded from. */ +export function sourceEndpoint( + playlist: Pick< + SourceDisplayFields, + 'macAddress' | 'serverUrl' | 'portalUrl' | 'url' + > +): string | undefined { + if (playlist.macAddress) { + return playlist.portalUrl || playlist.url; + } + return playlist.serverUrl || playlist.url; +} + +/** Host and non-default port of the endpoint; null for a local file. */ +export function sourceHost( + playlist: Pick< + SourceDisplayFields, + 'macAddress' | 'serverUrl' | 'portalUrl' | 'url' + > +): string | null { + return parseUrl(sourceEndpoint(playlist))?.host || null; +} + +function parseJson(value: string): unknown { + try { + return JSON.parse(value); + } catch { + return null; + } +} + +/** How deep URLs nested in the source URL are searched for secrets. */ +const MAX_SECRET_NESTING = 4; + +/** + * The secrets a URL carries: its userinfo password, the password of an + * Xtream stream path, every sensitive query or fragment parameter (the + * source's own account name excepted), and the same again in any URL or + * parameter list nested in a parameter or path segment, whose account names + * count as secrets too. + */ +function collectUrlSecrets( + url: URL, + secrets: (string | undefined)[], + depth: number +): void { + if (depth >= MAX_SECRET_NESTING) { + return; + } + secrets.push(safeDecode(url.password)); + if (depth > 0) { + secrets.push(safeDecode(url.username)); + } + const visitNested = (raw: string): void => { + const value = unwrapNestedValue(raw); + if (value === null) { + return; + } + // Only real JSON: `[password]=…` starts like it but is a parameter + // list, read below. + const json = /^\s*[{[]/.test(value) ? parseJson(value) : null; + if (json !== null && typeof json === 'object') { + visitJson(json, 0); + return; + } + const nested = ABSOLUTE_URL.test(value.trim()) ? parseUrl(value) : null; + if (nested) { + collectUrlSecrets(nested, secrets, depth + 1); + } else if (value.includes('=')) { + visit(new URLSearchParams(value)); + } + }; + // A JSON config in a parameter: values under sensitive keys are + // secrets, and any other string may be a URL or a parameter list. + const visitJson = (value: unknown, level: number): void => { + if (level >= MAX_SECRET_NESTING) { + return; + } + if (typeof value === 'string') { + visitNested(value); + } else if (Array.isArray(value)) { + value.forEach((item) => visitJson(item, level + 1)); + } else if (value && typeof value === 'object') { + for (const [key, inner] of Object.entries(value)) { + const scalar = + typeof inner === 'string' || typeof inner === 'number'; + if (scalar && isDisplaySensitiveKey(key)) { + secrets.push(String(inner)); + } else { + visitJson(inner, level + 1); + } + } + } + }; + const visit = (params: URLSearchParams): void => + params.forEach((value, key) => { + const normalized = decodeFully(key) + .toLowerCase() + .replace(/[^a-z0-9]/g, ''); + if (isDisplaySensitiveKey(key)) { + if (depth > 0 || !SHOWN_ACCOUNT_KEYS.has(normalized)) { + secrets.push(value); + } + return; + } + visitNested(value); + }); + // An Xtream stream path carries the account: + // `/live///`. + const segments = url.pathname.split('/'); + const { accounts } = findXtreamAccount(segments, { streamFollows: true }); + for (const [user, password] of accounts) { + secrets.push(password); + if (depth > 0) { + secrets.push(user); + } + } + // A proxy may carry a whole URL in one segment. + segments.map(safeDecode).forEach(visitNested); + visit(url.searchParams); + // A fragment may hold a parameter list or a whole URL. + visitNested(url.hash.slice(1)); +} + +/** + * Values that must never be on screen for this source: the password, the + * MAC address, and every secret its URL carries (`collectUrlSecrets`), in + * both decoded and encoded form. + */ +function sourceSecrets(playlist: SourceDisplayFields): string[] { + const secrets = [playlist.password, playlist.macAddress]; + const url = parseUrl(sourceEndpoint(playlist)); + if (url) { + collectUrlSecrets(url, secrets, 0); + } + const forms: string[] = []; + for (const secret of secrets) { + if (secret) { + // A value encoded more than once reads as itself in a name. + forms.push(secret, encodeURIComponent(secret), decodeFully(secret)); + } + } + return forms; +} + +/** + * Whether one of the texts shows the secret: as the whole text, or inside + * it when the secret is long enough. Each form on its own: `%2542` decodes + * to `B`, which may only hide a text that is nothing but `B`. + */ +function shows(texts: readonly string[], secret: string): boolean { + return texts.some( + (text) => + text === secret || + (secret.length >= MIN_SECRET_LENGTH && text.includes(secret)) + ); +} + +/** + * Whether the text shows any of the source's secrets, read as written and + * fully decoded (so `%3a` escapes count as `:`). A password is compared in + * its own case: `Stress` in a name is not the password `stress`. A MAC is + * the same address in either letter case, so it is compared lower-cased: + * the source's own, and any MAC-shaped secret its URL carries (`?mac=…`, + * nested ones included). + */ +export function containsSourceSecret( + text: string, + playlist: SourceDisplayFields +): boolean { + const texts = [text, decodeFully(text)].map((form) => form.trim()); + const lowerTexts = texts.map((form) => form.toLowerCase()); + const ownMac = playlist.macAddress?.toLowerCase(); + return sourceSecrets(playlist).some((secret) => { + const plain = decodeFully(secret).toLowerCase(); + const isMac = plain === ownMac || MAC_ADDRESS.test(plain); + return isMac + ? shows(lowerTexts, secret.toLowerCase()) + : shows(texts, secret); + }); +} + +/** + * A name an import made from the URL rather than one the user typed: the + * URL itself, or the end of a URL with a query or fragment, such as + * `get.php?username=…&password=…` or `list.m3u#api_key=…`. + */ +function isUrlDerivedName(name: string, url: string | undefined): boolean { + if (ABSOLUTE_URL.test(name)) { + return true; + } + return !!url && /[?#]/.test(name) && url.trim().endsWith(name); +} + +/** + * The name a source list shows. A name the user chose stays; an empty name, + * a URL or a URL-derived name becomes the host, and any name that carries a + * credential collapses to the host as well. Null when there is nothing safe + * to show; the caller renders its "untitled" label. + */ +export function sourceDisplayName( + playlist: SourceDisplayFields +): string | null { + const raw = (playlist.title || playlist.filename || '').trim(); + const host = sourceHost(playlist); + if (!raw) { + return host; + } + if ( + isUrlDerivedName(raw, playlist.url) || + playlistNameHasCredentials(raw) + ) { + return host ?? (playlistDisplayLabel(raw) || null); + } + return containsSourceSecret(raw, playlist) ? host : raw; +} + +/** The name a list shows and sorts a source by; empty when it has none. */ +export function sourceSortName(playlist: SourceDisplayFields): string { + return sourceDisplayName(playlist) ?? ''; +} + +/** `origin/…/last` once a path has more than two segments. */ +function compactPath(pathname: string): string { + const segments = pathname.split('/').filter(Boolean); + return segments.length > 2 + ? `/…/${segments[segments.length - 1]}` + : pathname; +} + +/** + * The location a source list shows beside the name, without credentials: + * `scheme://host:port · username` for Xtream, the portal URL for Stalker, + * the playlist URL with a shortened path for M3U, and null for a local file. + */ +export function sourceDisplayUrl(playlist: SourceDisplayFields): string | null { + const kind = resolvePlaylistSourceKind(playlist); + if (kind === 'xtream') { + const server = parseUrl(playlist.serverUrl); + if (!server) { + return null; + } + const base = `${server.protocol}//${server.host}`; + // An account name that is, or holds, the password stays off screen. + const user = playlist.username; + return user && !containsSourceSecret(user, playlist) + ? `${base} · ${user}` + : base; + } + + const stripped = parseUrl( + stripUrlCredentials(sourceEndpoint(playlist) ?? '') ?? undefined + ); + if (!stripped) { + return null; + } + const origin = `${stripped.protocol}//${stripped.host}`; + const shown = `${origin}${compactPath(stripped.pathname)}${stripped.search}${stripped.hash}`; + return containsSourceSecret(shown, playlist) ? origin : shown; +} + +/** + * The full address "Copy URL" puts on the clipboard; credentials included, + * since that is what the user asked for. Null for a local file. + */ +export function sourceCopyUrl( + playlist: Pick< + SourceDisplayFields, + 'macAddress' | 'serverUrl' | 'portalUrl' | 'url' + > +): string | null { + return sourceEndpoint(playlist)?.trim() || null; +} diff --git a/libs/playlist/shared/util/src/lib/source-display/source-format.spec.ts b/libs/playlist/shared/util/src/lib/source-display/source-format.spec.ts new file mode 100644 index 000000000..5fd62b262 --- /dev/null +++ b/libs/playlist/shared/util/src/lib/source-display/source-format.spec.ts @@ -0,0 +1,206 @@ +import { + formatSourceDate, + formatSourceDuration, + SourceFormatContext, + sourceMetaParts, + sourceStatusTag, + sourceTypeLabel, +} from './source-format'; + +const NOW = new Date(2026, 9, 12, 12, 0).getTime(); +const DAY = 86_400_000; + +/** Echoes the key and its parameters, so specs read what was asked for. */ +const translate = (key: string, params?: Record) => + params ? `${key} ${JSON.stringify(params)}` : key; + +const context = (locale = 'en'): SourceFormatContext => ({ + translate, + locale, + now: NOW, +}); + +describe('formatSourceDate', () => { + it('shows day and month in the current year', () => { + const date = new Date(2026, 8, 6).toISOString(); + expect(formatSourceDate(date, 'en', NOW)).toBe('Sep 6'); + expect(formatSourceDate(date, 'ru', NOW)).toMatch(/^6 сент/); + }); + + it('adds the year for another year', () => { + const date = new Date(2025, 8, 6).toISOString(); + expect(formatSourceDate(date, 'en', NOW)).toBe('Sep 6, 2025'); + }); + + it('adds the time on request', () => { + const date = new Date(2026, 8, 6, 21, 34).getTime(); + expect(formatSourceDate(date, 'en-GB', NOW, true)).toBe( + '6 Sept, 21:34' + ); + }); + + it('reads epoch milliseconds stored as a string', () => { + const value = String(new Date(2026, 0, 2).getTime()); + expect(formatSourceDate(value, 'en', NOW)).toBe('Jan 2'); + }); + + it('returns null for an empty or invalid value', () => { + expect(formatSourceDate(undefined, 'en', NOW)).toBeNull(); + expect(formatSourceDate('', 'en', NOW)).toBeNull(); + expect(formatSourceDate('not a date', 'en', NOW)).toBeNull(); + }); + + it('falls back to English for an unknown locale', () => { + const date = new Date(2026, 8, 6).toISOString(); + expect(formatSourceDate(date, 'not_a-locale!', NOW)).toBe('Sep 6'); + }); +}); + +describe('formatSourceDuration', () => { + it('counts whole days, then hours, in the UI language', () => { + expect(formatSourceDuration(NOW - 3 * DAY - 5, 'en', NOW)).toBe( + '3 days' + ); + expect(formatSourceDuration(NOW - 3 * DAY, 'ru', NOW)).toBe('3 дня'); + expect(formatSourceDuration(NOW - 5 * 3_600_000, 'en', NOW)).toBe( + '5 hours' + ); + }); + + it('says nothing under an hour', () => { + expect(formatSourceDuration(NOW - 60_000, 'en', NOW)).toBeNull(); + }); +}); + +describe('sourceMetaParts', () => { + const m3u = { + title: 'Free TV', + url: 'https://cdn.tv/a/b/list.m3u?token=secret1', + count: 16805, + importDate: new Date(2026, 8, 30).toISOString(), + updateDate: new Date(2026, 9, 8, 3, 0).getTime(), + }; + + it('lists type, channels, added, updated and URL in that order', () => { + const parts = sourceMetaParts(m3u, context()); + expect(parts.map((part) => part.kind)).toEqual([ + 'type', + 'channels', + 'added', + 'updated', + 'url', + ]); + expect(parts[0].text).toBe('M3U'); + expect(parts[1].text).toBe( + 'HOME.PLAYLISTS.CHANNELS_COUNT {"count":"16,805"}' + ); + expect(parts[4].text).toBe('https://cdn.tv/…/list.m3u'); + }); + + it('counts a single channel in the singular', () => { + expect(sourceMetaParts({ ...m3u, count: 1 }, context())[1].text).toBe( + 'WORKSPACE.SOURCES.ROW.CHANNELS_ONE {"count":"1"}' + ); + }); + + it('leaves out empty parts and zero channels', () => { + const parts = sourceMetaParts( + { title: 'x', count: 0, importDate: '' }, + context() + ); + expect(parts).toEqual([ + { kind: 'type', text: 'WORKSPACE.SOURCES.ROW.TYPE_M3U_FILE' }, + ]); + }); + + it('shows no channel count for Xtream and Stalker', () => { + const parts = sourceMetaParts( + { + title: 'x', + serverUrl: 'http://x.tv', + username: 'joe', + password: 'p4ss', + count: 0, + importDate: '', + }, + context() + ); + expect(parts.map((part) => part.kind)).toEqual(['type', 'url']); + expect(parts[0].text).toBe('Xtream'); + }); + + it('keeps type, channels and updated in the compact row', () => { + expect( + sourceMetaParts(m3u, context(), true).map((part) => part.kind) + ).toEqual(['type', 'channels', 'updated']); + }); +}); + +describe('sourceTypeLabel', () => { + it('names every provider', () => { + expect(sourceTypeLabel({ url: 'http://x' }, translate)).toBe('M3U'); + expect(sourceTypeLabel({}, translate)).toBe( + 'WORKSPACE.SOURCES.ROW.TYPE_M3U_FILE' + ); + expect(sourceTypeLabel({ serverUrl: 'http://x' }, translate)).toBe( + 'Xtream' + ); + expect(sourceTypeLabel({ macAddress: 'm' }, translate)).toBe('Stalker'); + }); +}); + +describe('sourceStatusTag', () => { + it('says how long a source has not responded', () => { + expect( + sourceStatusTag( + { reachability: 'bad', failingSince: NOW - 3 * DAY }, + context() + ) + ).toEqual({ + tone: 'danger', + label: 'WORKSPACE.SOURCES.ROW.UNREACHABLE_FOR {"duration":"3 days"}', + }); + }); + + it('drops the duration in the compact row and under an hour', () => { + const status = { + reachability: 'bad', + failingSince: NOW - 3 * DAY, + } as const; + expect(sourceStatusTag(status, context(), true)?.label).toBe( + 'WORKSPACE.SOURCES.ROW.UNREACHABLE' + ); + expect( + sourceStatusTag( + { reachability: 'bad', failingSince: NOW - 1000 }, + context() + )?.label + ).toBe('WORKSPACE.SOURCES.ROW.UNREACHABLE'); + }); + + it('tags expired accounts and unchecked sources', () => { + expect(sourceStatusTag({ reachability: 'expired' }, context())).toEqual( + { + tone: 'danger', + label: 'WORKSPACE.SOURCES.ROW.EXPIRED', + } + ); + expect(sourceStatusTag({ reachability: 'unknown' }, context())).toEqual( + { + tone: 'neutral', + label: 'SOURCE_HEALTH.REASON.unknown', + } + ); + expect( + sourceStatusTag({ reachability: 'unknown' }, context(), true) + ).toBeNull(); + }); + + it('has no tag for a healthy, checking or local source', () => { + expect(sourceStatusTag({ reachability: 'ok' }, context())).toBeNull(); + expect( + sourceStatusTag({ reachability: 'checking' }, context()) + ).toBeNull(); + expect(sourceStatusTag(null, context())).toBeNull(); + }); +}); diff --git a/libs/playlist/shared/util/src/lib/source-display/source-format.ts b/libs/playlist/shared/util/src/lib/source-display/source-format.ts new file mode 100644 index 000000000..f6e5c7a4c --- /dev/null +++ b/libs/playlist/shared/util/src/lib/source-display/source-format.ts @@ -0,0 +1,221 @@ +import { + PlaylistMeta, + resolvePlaylistSourceKind, + SourceStatus, +} from '@iptvnator/shared/interfaces'; +import { SourceDisplayFields, sourceDisplayUrl } from './source-display'; + +/** `TranslateService.instant` or a test double. */ +export type SourceTranslate = ( + key: string, + params?: Record +) => string; + +export interface SourceFormatContext { + translate: SourceTranslate; + /** A BCP 47 locale for `Intl`, e.g. from `normalizeDateLocale`. */ + locale: string; + now: number; +} + +const DAY_MS = 86_400_000; +const HOUR_MS = 3_600_000; + +/** Provider names are brands, the same in every language. */ +const KIND_LABELS = { + m3u: 'M3U', + xtream: 'Xtream', + stalker: 'Stalker', +} as const; + +function toDate(value: string | number | undefined | null): Date | null { + if (value === undefined || value === null || value === '') { + return null; + } + const date = + typeof value === 'number' || /^\d+$/.test(value) + ? new Date(Number(value)) + : new Date(value); + return Number.isNaN(date.getTime()) ? null : date; +} + +function dateFormat( + locale: string, + options: Intl.DateTimeFormatOptions +): Intl.DateTimeFormat { + try { + return new Intl.DateTimeFormat(locale, options); + } catch { + return new Intl.DateTimeFormat('en', options); + } +} + +/** + * A short date in the UI language: day and month, the year only when it is + * not the current one, and the time when asked for. + */ +export function formatSourceDate( + value: string | number | undefined | null, + locale: string, + now: number, + withTime = false +): string | null { + const date = toDate(value); + if (!date) { + return null; + } + const sameYear = date.getFullYear() === new Date(now).getFullYear(); + return dateFormat(locale, { + day: 'numeric', + month: 'short', + ...(sameYear ? {} : { year: 'numeric' }), + ...(withTime ? { hour: '2-digit', minute: '2-digit' } : {}), + }).format(date); +} + +/** "3 days" / "5 hours" in the UI language; null under an hour. */ +export function formatSourceDuration( + since: number, + locale: string, + now: number +): string | null { + const elapsed = now - since; + const [unit, amount] = + elapsed >= DAY_MS + ? (['day', Math.floor(elapsed / DAY_MS)] as const) + : (['hour', Math.floor(elapsed / HOUR_MS)] as const); + if (amount < 1) { + return null; + } + // The app's TypeScript lib (es2018) predates unit formatting; every + // supported runtime has it. + const options = { + style: 'unit', + unit, + unitDisplay: 'long', + } as unknown as Intl.NumberFormatOptions; + try { + return new Intl.NumberFormat(locale, options).format(amount); + } catch { + return new Intl.NumberFormat('en', options).format(amount); + } +} + +export function sourceTypeLabel( + playlist: Pick, + translate: SourceTranslate +): string { + const kind = resolvePlaylistSourceKind(playlist); + if (kind === 'm3u' && !playlist.url) { + return translate('WORKSPACE.SOURCES.ROW.TYPE_M3U_FILE'); + } + return KIND_LABELS[kind]; +} + +export type SourceMetaPartKind = + 'type' | 'channels' | 'added' | 'updated' | 'url'; + +export interface SourceMetaPart { + kind: SourceMetaPartKind; + text: string; +} + +export type SourceMetaFields = SourceDisplayFields & + Partial>; + +/** + * The meta line, always in this order: type · channels · added · updated · + * URL, with empty parts left out. Only M3U playlists know their channel + * count. The compact switcher row keeps type, channels and updated. + */ +export function sourceMetaParts( + playlist: SourceMetaFields, + context: SourceFormatContext, + compact = false +): SourceMetaPart[] { + const { translate, locale, now } = context; + const parts: SourceMetaPart[] = [ + { kind: 'type', text: sourceTypeLabel(playlist, translate) }, + ]; + const isM3u = resolvePlaylistSourceKind(playlist) === 'm3u'; + if (isM3u && (playlist.count ?? 0) > 0) { + const count = new Intl.NumberFormat(locale).format(playlist.count ?? 0); + parts.push({ + kind: 'channels', + text: translate( + playlist.count === 1 + ? 'WORKSPACE.SOURCES.ROW.CHANNELS_ONE' + : 'HOME.PLAYLISTS.CHANNELS_COUNT', + { count } + ), + }); + } + const added = compact + ? null + : formatSourceDate(playlist.importDate, locale, now); + if (added) { + parts.push({ + kind: 'added', + text: translate('WORKSPACE.SOURCES.ROW.ADDED', { date: added }), + }); + } + const updated = formatSourceDate(playlist.updateDate, locale, now, true); + if (updated) { + parts.push({ + kind: 'updated', + text: translate('WORKSPACE.SOURCES.ROW.UPDATED', { date: updated }), + }); + } + const url = compact ? null : sourceDisplayUrl(playlist); + if (url) { + parts.push({ kind: 'url', text: url }); + } + return parts; +} + +export interface SourceStatusTag { + label: string; + tone: 'danger' | 'neutral'; +} + +/** + * The tag after the name. A healthy source has none: green is the dot's + * job. The compact row drops the duration and the "not checked" tag. + */ +export function sourceStatusTag( + status: SourceStatus | null, + context: SourceFormatContext, + compact = false +): SourceStatusTag | null { + const { translate, locale, now } = context; + switch (status?.reachability) { + case 'bad': { + const duration = + !compact && status.failingSince !== undefined + ? formatSourceDuration(status.failingSince, locale, now) + : null; + return { + tone: 'danger', + label: duration + ? translate('WORKSPACE.SOURCES.ROW.UNREACHABLE_FOR', { + duration, + }) + : translate('WORKSPACE.SOURCES.ROW.UNREACHABLE'), + }; + } + case 'expired': + return { + tone: 'danger', + label: translate('WORKSPACE.SOURCES.ROW.EXPIRED'), + }; + case 'unknown': + return compact + ? null + : { + tone: 'neutral', + label: translate('SOURCE_HEALTH.REASON.unknown'), + }; + default: + return null; + } +} diff --git a/libs/playlist/shared/util/src/lib/source-display/source-list.spec.ts b/libs/playlist/shared/util/src/lib/source-display/source-list.spec.ts new file mode 100644 index 000000000..861c41792 --- /dev/null +++ b/libs/playlist/shared/util/src/lib/source-display/source-list.spec.ts @@ -0,0 +1,155 @@ +import { TestBed } from '@angular/core/testing'; +import { PlaylistMeta } from '@iptvnator/shared/interfaces'; +import { + countSourcesByStatus, + countSourcesByType, + filterSources, + matchesSourceQuery, + orderFailingFirst, + replacementAfterRemoval, + SourceBucketOf, +} from './source-list'; +import { + SOURCES_VIEW_STORAGE_KEY, + SourcesViewStateService, +} from './sources-view-state.service'; + +const base = { count: 0, importDate: '', autoRefresh: false }; +const m3u: PlaylistMeta = { + ...base, + _id: 'm3u', + title: 'Free TV', + url: 'https://cdn.example/list.m3u', +}; +const file: PlaylistMeta = { ...base, _id: 'file', title: 'local list' }; +const xtream: PlaylistMeta = { + ...base, + _id: 'xtream', + title: '', + serverUrl: 'http://biggie.to:8080', + username: 'joe', + password: 'hunter2', +}; +const stalker: PlaylistMeta = { + ...base, + _id: 'stalker', + title: 'Portal', + macAddress: '00:1A:79:00:00:01', + portalUrl: 'http://portal.example/c/', +}; +const all = [m3u, file, xtream, stalker]; +const bucketOf: SourceBucketOf = (playlist) => + (({ m3u: 'ok', xtream: 'bad', stalker: 'ok' })[playlist._id] as + 'ok' | 'bad' | undefined) ?? null; + +describe('source list filters', () => { + it('combines type, status and query', () => { + const ids = ( + type: 'all' | 'm3u', + status: 'all' | 'ok' | 'bad', + q = '' + ) => + filterSources(all, { type, status }, q, bucketOf).map((p) => p._id); + expect(ids('all', 'all')).toEqual(['m3u', 'file', 'xtream', 'stalker']); + expect(ids('m3u', 'all')).toEqual(['m3u', 'file']); + expect(ids('all', 'bad')).toEqual(['xtream']); + // A local file has no status, so it is in neither status group. + expect(ids('m3u', 'ok')).toEqual(['m3u']); + expect(ids('all', 'all', 'portal')).toEqual(['stalker']); + }); + + it('finds a source by its host, not by its password', () => { + expect(matchesSourceQuery(xtream, 'biggie')).toBe(true); + expect(matchesSourceQuery(xtream, 'hunter2')).toBe(false); + expect(matchesSourceQuery(m3u, 'FREE')).toBe(true); + }); + + it('counts each group within the other group’s choice', () => { + expect(countSourcesByType(all, 'all', bucketOf)).toEqual({ + all: 4, + m3u: 2, + xtream: 1, + stalker: 1, + }); + expect(countSourcesByType(all, 'ok', bucketOf)).toEqual({ + all: 2, + m3u: 1, + xtream: 0, + stalker: 1, + }); + expect(countSourcesByStatus(all, 'all', bucketOf)).toEqual({ + all: 4, + ok: 2, + bad: 1, + }); + expect(countSourcesByStatus(all, 'm3u', bucketOf)).toEqual({ + all: 2, + ok: 1, + bad: 0, + }); + }); + + it('puts failing sources first and keeps the order otherwise', () => { + expect(orderFailingFirst(all, bucketOf).map((p) => p._id)).toEqual([ + 'xtream', + 'm3u', + 'file', + 'stalker', + ]); + }); +}); + +describe('replacementAfterRemoval', () => { + const rows = ['a', 'b', 'c', 'd'].map((id) => ({ _id: id })); + const none = () => false; + + it('hands over to the next row, or the previous one at the end', () => { + expect(replacementAfterRemoval(rows, 'b', none)?._id).toBe('c'); + expect(replacementAfterRemoval(rows, 'd', none)?._id).toBe('c'); + expect(replacementAfterRemoval([{ _id: 'a' }], 'a', none)).toBeNull(); + }); + + it('skips rows that are leaving too', () => { + const deleting = (row: { _id: string }) => + row._id === 'c' || row._id === 'd'; + expect(replacementAfterRemoval(rows, 'b', deleting)?._id).toBe('a'); + expect( + replacementAfterRemoval(rows, 'a', (row) => row._id !== 'a') + ).toBeNull(); + }); +}); + +describe('SourcesViewStateService', () => { + const create = () => { + TestBed.configureTestingModule({ + providers: [SourcesViewStateService], + }); + return TestBed.inject(SourcesViewStateService); + }; + + beforeEach(() => localStorage.clear()); + afterEach(() => TestBed.resetTestingModule()); + + it('keeps the filters across a restart', () => { + const state = create(); + state.setType('xtream'); + state.setStatus('bad'); + TestBed.resetTestingModule(); + expect(create().filter()).toEqual({ type: 'xtream', status: 'bad' }); + }); + + it('resets both filters', () => { + const state = create(); + state.setType('m3u'); + state.reset(); + expect(state.filter()).toEqual({ type: 'all', status: 'all' }); + }); + + it('ignores unknown stored values', () => { + localStorage.setItem( + SOURCES_VIEW_STORAGE_KEY, + JSON.stringify({ type: 'radio', status: 'maybe' }) + ); + expect(create().filter()).toEqual({ type: 'all', status: 'all' }); + }); +}); diff --git a/libs/playlist/shared/util/src/lib/source-display/source-list.ts b/libs/playlist/shared/util/src/lib/source-display/source-list.ts new file mode 100644 index 000000000..d77ecbcc5 --- /dev/null +++ b/libs/playlist/shared/util/src/lib/source-display/source-list.ts @@ -0,0 +1,151 @@ +import { + foldSearchText, + PlaylistMeta, + PlaylistSourceKind, + resolvePlaylistSourceKind, + SourceStatusBucket, +} from '@iptvnator/shared/interfaces'; +import { sourceDisplayName, sourceHost } from './source-display'; + +export type SourcesTypeFilter = 'all' | PlaylistSourceKind; +export type SourcesStatusFilter = 'all' | SourceStatusBucket; + +export interface SourcesFilter { + type: SourcesTypeFilter; + status: SourcesStatusFilter; +} + +/** A source's status bucket; null for local files and unchecked sources. */ +export type SourceBucketOf = ( + playlist: PlaylistMeta +) => SourceStatusBucket | null; + +export type SourceTypeCounts = Record; +export type SourceStatusCounts = Record; + +export function matchesSourceType( + playlist: PlaylistMeta, + type: SourcesTypeFilter +): boolean { + return type === 'all' || resolvePlaylistSourceKind(playlist) === type; +} + +export function matchesSourceStatus( + playlist: PlaylistMeta, + status: SourcesStatusFilter, + bucketOf: SourceBucketOf +): boolean { + return status === 'all' || bucketOf(playlist) === status; +} + +/** + * Search by what the row shows: the name (stored and displayed) and the + * host, folded for case and accents. An empty query matches everything. + */ +export function matchesSourceQuery( + playlist: PlaylistMeta, + query: string +): boolean { + const folded = foldSearchText(query).trim(); + if (!folded) { + return true; + } + const haystack = [ + playlist.title, + playlist.filename, + sourceDisplayName(playlist), + sourceHost(playlist), + ]; + return haystack.some( + (value) => !!value && foldSearchText(value).includes(folded) + ); +} + +export function filterSources( + playlists: readonly PlaylistMeta[], + filter: SourcesFilter, + query: string, + bucketOf: SourceBucketOf +): PlaylistMeta[] { + return playlists.filter( + (playlist) => + matchesSourceType(playlist, filter.type) && + matchesSourceStatus(playlist, filter.status, bucketOf) && + matchesSourceQuery(playlist, query) + ); +} + +/** Type counts within the current status choice, as the side list shows. */ +export function countSourcesByType( + playlists: readonly PlaylistMeta[], + status: SourcesStatusFilter, + bucketOf: SourceBucketOf +): SourceTypeCounts { + const counts: SourceTypeCounts = { all: 0, m3u: 0, xtream: 0, stalker: 0 }; + for (const playlist of playlists) { + if (matchesSourceStatus(playlist, status, bucketOf)) { + counts.all += 1; + counts[resolvePlaylistSourceKind(playlist)] += 1; + } + } + return counts; +} + +/** Status counts within the current type choice. */ +export function countSourcesByStatus( + playlists: readonly PlaylistMeta[], + type: SourcesTypeFilter, + bucketOf: SourceBucketOf +): SourceStatusCounts { + const counts: SourceStatusCounts = { all: 0, ok: 0, bad: 0 }; + for (const playlist of playlists) { + if (matchesSourceType(playlist, type)) { + counts.all += 1; + const bucket = bucketOf(playlist); + if (bucket) { + counts[bucket] += 1; + } + } + } + return counts; +} + +/** Moves sources that do not respond to the top, keeping the order. */ +export function orderFailingFirst( + playlists: readonly PlaylistMeta[], + bucketOf: SourceBucketOf +): PlaylistMeta[] { + const failing: PlaylistMeta[] = []; + const rest: PlaylistMeta[] = []; + for (const playlist of playlists) { + (bucketOf(playlist) === 'bad' ? failing : rest).push(playlist); + } + return [...failing, ...rest]; +} + +/** + * The source that takes over when `removedId` leaves a list: the nearest + * row after it, else before it, skipping rows that are leaving too (a + * delete still running). Null when no other source can take over. + */ +export function replacementAfterRemoval( + rows: readonly T[], + removedId: string, + leaving: (row: T) => boolean +): T | null { + const index = rows.findIndex((row) => row._id === removedId); + if (index === -1) { + return null; + } + for (let next = index + 1; next < rows.length; next += 1) { + if (!leaving(rows[next])) { + return rows[next]; + } + } + for (let previous = index - 1; previous >= 0; previous -= 1) { + if (!leaving(rows[previous])) { + return rows[previous]; + } + } + return null; +} diff --git a/libs/playlist/shared/util/src/lib/source-display/sources-view-state.service.ts b/libs/playlist/shared/util/src/lib/source-display/sources-view-state.service.ts new file mode 100644 index 000000000..151062e5e --- /dev/null +++ b/libs/playlist/shared/util/src/lib/source-display/sources-view-state.service.ts @@ -0,0 +1,63 @@ +import { Injectable, signal } from '@angular/core'; +import { + SourcesFilter, + SourcesStatusFilter, + SourcesTypeFilter, +} from './source-list'; + +export const SOURCES_VIEW_STORAGE_KEY = 'sources:view:v1'; + +const TYPES: readonly SourcesTypeFilter[] = ['all', 'm3u', 'xtream', 'stalker']; +const STATUSES: readonly SourcesStatusFilter[] = ['all', 'ok', 'bad']; +const DEFAULT_FILTER: SourcesFilter = { type: 'all', status: 'all' }; + +function readFilter(): SourcesFilter { + try { + const raw = localStorage.getItem(SOURCES_VIEW_STORAGE_KEY); + const parsed = raw ? (JSON.parse(raw) as Partial) : {}; + return { + type: TYPES.includes(parsed.type as SourcesTypeFilter) + ? (parsed.type as SourcesTypeFilter) + : 'all', + status: STATUSES.includes(parsed.status as SourcesStatusFilter) + ? (parsed.status as SourcesStatusFilter) + : 'all', + }; + } catch { + return { ...DEFAULT_FILTER }; + } +} + +/** + * The Sources page's type and status filters, kept across restarts. The two + * combine (AND); each side-list group counts within the other's choice. + */ +@Injectable({ providedIn: 'root' }) +export class SourcesViewStateService { + private readonly state = signal(readFilter()); + readonly filter = this.state.asReadonly(); + + setType(type: SourcesTypeFilter): void { + this.write({ ...this.state(), type }); + } + + setStatus(status: SourcesStatusFilter): void { + this.write({ ...this.state(), status }); + } + + reset(): void { + this.write({ ...DEFAULT_FILTER }); + } + + private write(filter: SourcesFilter): void { + this.state.set(filter); + try { + localStorage.setItem( + SOURCES_VIEW_STORAGE_KEY, + JSON.stringify(filter) + ); + } catch { + // Filters still apply for this session. + } + } +} diff --git a/libs/playlist/shared/util/src/lib/source-display/strip-url-credentials.spec.ts b/libs/playlist/shared/util/src/lib/source-display/strip-url-credentials.spec.ts new file mode 100644 index 000000000..a7a335579 --- /dev/null +++ b/libs/playlist/shared/util/src/lib/source-display/strip-url-credentials.spec.ts @@ -0,0 +1,258 @@ +import { stripUrlCredentials } from './strip-url-credentials'; + +describe('stripUrlCredentials', () => { + it.each([ + [ + 'http://host.tv:8080/get.php?username=joe&password=hunter2&type=m3u_plus', + 'http://host.tv:8080/get.php?type=m3u_plus', + ], + ['http://joe:hunter2@host.tv/list.m3u', 'http://host.tv/list.m3u'], + [ + 'http://host.tv/live/joe/hunter2/123.ts', + 'http://host.tv/live/123.ts', + ], + [ + 'https://cdn.tv/playlist.m3u8?token=abc123&quality=hd', + 'https://cdn.tv/playlist.m3u8?quality=hd', + ], + ['http://portal.tv/c/?mac=00:1A:79:00:00:01', 'http://portal.tv/c/'], + [ + 'http://host.tv/list.m3u#user=joe&pass=hunter2', + 'http://host.tv/list.m3u', + ], + ['http://[::1]:3211/get.php?password=p', 'http://[::1]:3211/get.php'], + [ + 'https://iptv-org.github.io/iptv/index.m3u', + 'https://iptv-org.github.io/iptv/index.m3u', + ], + ])('shows %s as %s', (input, expected) => { + expect(stripUrlCredentials(input)).toBe(expected); + }); + + it('never returns a password from a credential-bearing URL', () => { + const urls = [ + 'http://joe:hunter2@host.tv/get.php?username=joe&password=hunter2', + 'http://host.tv/movie/joe/hunter2/9.mkv?auth=hunter2', + 'http://host.tv/x#password=hunter2', + ]; + for (const url of urls) { + expect(stripUrlCredentials(url)).not.toMatch(/hunter2/); + } + }); + + it('cleans a credential-bearing URL nested in a kept parameter', () => { + const proxied = `https://proxy.test/list?url=${encodeURIComponent( + 'http://joe:hunter2@origin.test/get.php?username=joe&password=hunter2&type=m3u' + )}&quality=hd`; + const shown = stripUrlCredentials(proxied); + expect(shown).not.toMatch(/hunter2|joe/); + expect(new URL(shown ?? '').searchParams.get('url')).toBe( + 'http://origin.test/get.php?type=m3u' + ); + expect(new URL(shown ?? '').searchParams.get('quality')).toBe('hd'); + }); + + it('cleans nested parameter lists, fragments and path segments', () => { + expect( + stripUrlCredentials( + `https://proxy.test/list?next=${encodeURIComponent( + 'token=abc&page=2' + )}` + ) + ).toBe('https://proxy.test/list?next=page%3D2'); + expect( + stripUrlCredentials( + `https://proxy.test/list#src=${encodeURIComponent( + 'http://joe:hunter2@origin.test/a.m3u' + )}` + ) + ).not.toMatch(/hunter2/); + const inPath = `https://proxy.test/fetch/${encodeURIComponent( + 'http://joe:hunter2@origin.test/a.m3u' + )}`; + expect(stripUrlCredentials(inPath)).not.toMatch(/hunter2|joe/); + }); + + it('knows Xtream account segments in any case, also nested', () => { + expect( + stripUrlCredentials('http://host.tv/LIVE/joe/hunter2/1.ts') + ).toBe('http://host.tv/LIVE/1.ts'); + const nested = `https://proxy.test/list?url=${encodeURIComponent( + 'https://origin.test/LIVE/joe/hunter2/1.ts' + )}`; + expect(stripUrlCredentials(nested)).not.toMatch(/hunter2|joe/); + }); + + it('reads parameter names and path markers through every encoding layer', () => { + expect( + stripUrlCredentials( + 'https://proxy.test/list?%2570assword=hunter2&hd=1' + ) + ).toBe('https://proxy.test/list?hd=1'); + expect( + stripUrlCredentials('https://proxy.test/list?%2570ass=hunter2&hd=1') + ).toBe('https://proxy.test/list?hd=1'); + const layered = 'https://origin.test/%256Cive/joe/hunter2/1.ts'; + expect(stripUrlCredentials(layered)).not.toMatch(/hunter2|joe/); + expect( + stripUrlCredentials( + `https://proxy.test/list?url=${encodeURIComponent(layered)}` + ) + ).not.toMatch(/hunter2|joe/); + }); + + it('drops an Xtream account hidden behind encoded separators', () => { + for (const url of [ + 'https://origin.test/live%2Fjoe%2Fhunter2%2F1.ts', + 'https://origin.test/live%2Fjoe/hunter2/1.ts', + 'https://origin.test/live%252Fjoe%252Fhunter2%252F1.ts', + // A slash encoded inside the user name, not between segments. + 'https://origin.test/live/joe%2Fx/hunter2/1.ts', + 'https://origin.test/live/joe/hun%2Fter2/1.ts', + ]) { + expect(stripUrlCredentials(url)).not.toMatch(/hunter2|hun|joe/); + } + // Separators alone are no account: the path stays as written. + expect( + stripUrlCredentials('https://cdn.test/files%2Flive%2Flist.m3u') + ).toBe('https://cdn.test/files%2Flive%2Flist.m3u'); + }); + + it('drops a JSON value and a quoted key that name a secret', () => { + for (const json of [ + '{"password":"hunter2"}', + '{"pass\\u0077ord":"hunter2"}', + '[{"auth":{"token":"hunter2"}}]', + ]) { + expect( + stripUrlCredentials( + `https://proxy.test/list?config=${encodeURIComponent(json)}&hd=1` + ) + ).toBe('https://proxy.test/list?hd=1'); + } + expect( + stripUrlCredentials( + `https://proxy.test/list?note=${encodeURIComponent( + "see 'token': 'hunter2'" + )}&hd=1` + ) + ).toBe('https://proxy.test/list?hd=1'); + // Brackets that are not JSON stay: `[1080p]` is a folder name. + expect(stripUrlCredentials('https://cdn.test/[1080p]/list.m3u')).toBe( + 'https://cdn.test/[1080p]/list.m3u' + ); + }); + + it('cleans a fragment that holds a URL rather than parameters', () => { + expect( + stripUrlCredentials( + 'https://proxy.test/list#https://joe:hunter2@origin.test/a.m3u' + ) + ).toBe('https://proxy.test/list#https://origin.test/a.m3u'); + expect( + stripUrlCredentials( + `https://proxy.test/list#${encodeURIComponent( + 'https://joe:hunter2@origin.test/a.m3u' + )}` + ) + ).not.toMatch(/hunter2|joe/); + expect( + stripUrlCredentials('https://proxy.test/list#joe:hunter2@origin') + ).toBe('https://proxy.test/list'); + // A plain anchor stays as written. + expect(stripUrlCredentials('https://host.test/list.m3u#part-2')).toBe( + 'https://host.test/list.m3u#part-2' + ); + }); + + it('keeps a playlist path that only starts like an Xtream stream', () => { + // No stream follows `/news/list.m3u`: it names no account. + expect(stripUrlCredentials('https://cdn.test/live/news/list.m3u')).toBe( + 'https://cdn.test/live/news/list.m3u' + ); + expect( + stripUrlCredentials('https://cdn.test/live/joe/hunter2/list.m3u') + ).toBe('https://cdn.test/live/list.m3u'); + }); + + it('cleans a nested URL encoded more than once', () => { + const origin = 'https://joe:hunter2@origin.test/a.m3u?token=tk98765'; + for (const rounds of [1, 2, 3]) { + let value = origin; + for (let round = 0; round < rounds; round += 1) { + value = encodeURIComponent(value); + } + const shown = stripUrlCredentials( + `https://proxy.test/list?url=${value}&hd=1` + ); + expect(shown).not.toMatch(/hunter2|joe|tk98765/); + expect(new URL(shown ?? '').searchParams.get('url')).toBe( + 'https://origin.test/a.m3u' + ); + } + // Still encoded after every round it decodes: not shown at all. + let deep = origin; + for (let round = 0; round < 8; round += 1) { + deep = encodeURIComponent(deep); + } + expect( + stripUrlCredentials(`https://proxy.test/list?url=${deep}&hd=1`) + ).toBe('https://proxy.test/list?hd=1'); + }); + + it('keeps a nested URL’s own encoded parameters as written', () => { + const nested = `https://origin.test/a.m3u?q=${encodeURIComponent( + 'a&b=c' + )}`; + const shown = stripUrlCredentials( + `https://proxy.test/list?url=${encodeURIComponent(nested)}` + ); + expect(new URL(shown ?? '').searchParams.get('url')).toBe(nested); + // Plain text with a stray percent sign is not an escape. + expect( + stripUrlCredentials('https://proxy.test/list?note=100%25&hd=1') + ).toBe('https://proxy.test/list?note=100%25&hd=1'); + }); + + it('drops a kept part that still names a secret', () => { + expect( + stripUrlCredentials( + 'https://proxy.test/list?via=joe:hunter2@origin.test&hd=1' + ) + ).toBe('https://proxy.test/list?hd=1'); + expect( + stripUrlCredentials( + `https://proxy.test/list?note=${encodeURIComponent( + 'token: abc' + )}&hd=1` + ) + ).toBe('https://proxy.test/list?hd=1'); + }); + + it('drops URLs nested deeper than it cleans', () => { + let url = 'http://joe:hunter2@origin.test/a.m3u'; + for (let depth = 0; depth < 6; depth += 1) { + url = `https://hop${depth}.test/?u=${encodeURIComponent(url)}`; + } + expect(stripUrlCredentials(url)).not.toMatch(/hunter2/); + }); + + it('stays fast on long runs a backtracking pattern would retry', () => { + const run = 'a'.repeat(50_000); + const started = performance.now(); + for (const value of [run, `${run}.`.repeat(2), 'a.'.repeat(25_000)]) { + stripUrlCredentials( + `https://host.test/list?note=${encodeURIComponent(value)}` + ); + } + // About 0.25 s in total when idle; a backtracking pattern takes + // minutes. The margin absorbs a loaded CI runner. + expect(performance.now() - started).toBeLessThan(3_000); + }); + + it('returns null for text that is not an absolute URL', () => { + expect(stripUrlCredentials('get.php?username=joe')).toBeNull(); + expect(stripUrlCredentials('')).toBeNull(); + expect(stripUrlCredentials('not a url')).toBeNull(); + }); +}); diff --git a/libs/playlist/shared/util/src/lib/source-display/strip-url-credentials.ts b/libs/playlist/shared/util/src/lib/source-display/strip-url-credentials.ts new file mode 100644 index 000000000..b994ed940 --- /dev/null +++ b/libs/playlist/shared/util/src/lib/source-display/strip-url-credentials.ts @@ -0,0 +1,220 @@ +import { + decodeFully, + findXtreamAccount, + isSensitiveKey, + looksLikeSearchParams, +} from '@iptvnator/shared/logging'; + +/** + * Short account keys some playlist URLs use. A log line keeps them for + * diagnosis; a list on screen has no reason to. + */ +const SHORT_ACCOUNT_KEYS = new Set(['user', 'usr', 'pass', 'pas']); + +/** How deep URLs nested in URLs are cleaned; anything deeper is dropped. */ +const MAX_NESTING = 4; +/** Percent-decoding rounds a nested value gets before it is classified. */ +const MAX_DECODE_ROUNDS = 4; +// Linear on any input: a match starts only where a name can start (the +// lookbehinds), and every name part is bounded, so a long run of letters +// cannot make a scan retry from each position. +const ABSOLUTE_URL = /^[a-z][a-z0-9+.-]{0,31}:\/\//iu; +const EMBEDDED_URL = /(?]+/giu; +/** `joe:hunter2@origin.test`: an authority's userinfo without a scheme. */ +const BARE_USERINFO = /(?:^|[\s/])[^\s/:@]{1,256}:[^\s/@]{0,256}@[^\s/@]/u; +// A key may be quoted, as in JSON or code: `"token": …`. +const KEYED_PAIR = + /(?= MAX_NESTING || decoded === null) { + return null; + } + const trimmed = decoded.trim(); + if (ABSOLUTE_URL.test(trimmed)) { + return stripUrl(trimmed, depth + 1); + } + if (looksLikeSearchParams(trimmed)) { + return withoutSensitiveParams(new URLSearchParams(trimmed), depth + 1); + } + // JSON can spell a key so no pattern reads it (`"pass\u0077ord"`): a + // list has no need to show a config object, so it goes whole. + if ( + isJsonLiteral(trimmed) || + BARE_USERINFO.test(decoded) || + hasSensitivePair(decoded) + ) { + return null; + } + const cleaned = decoded.replace( + EMBEDDED_URL, + (candidate) => stripUrl(candidate, depth + 1) ?? '' + ); + // Nothing to clean: keep the value as it was written. + return cleaned === decoded ? value : cleaned; +} + +function withoutSensitiveParams( + params: URLSearchParams, + depth: number +): string { + const kept = new URLSearchParams(); + params.forEach((value, key) => { + if (isDisplaySensitiveKey(key)) { + return; + } + const clean = sanitizeNested(value, depth); + if (clean !== null) { + kept.append(key, clean); + } + }); + return kept.toString(); +} + +function decodeSegment(segment: string): string { + try { + return decodeURIComponent(segment); + } catch { + return segment; + } +} + +function stripUrl(value: string, depth: number): string | null { + let url: URL; + try { + url = new URL(value.trim()); + } catch { + return null; + } + + url.username = ''; + url.password = ''; + + // The account segments of an Xtream stream path go whole, however the + // marker is spelled and even when encoded separators join them into one. + const segments = url.pathname.split('/'); + const account = findXtreamAccount(segments, { streamFollows: true }); + const kept: string[] = []; + segments.forEach((segment, index) => { + if (account.segments.has(index)) { + return; + } + const decoded = decodeSegment(segment); + const clean = sanitizeNested(decoded, depth); + if (clean !== null) { + kept.push(clean === decoded ? segment : encodeURIComponent(clean)); + } + }); + url.pathname = kept.join('/'); + + const search = withoutSensitiveParams(url.searchParams, depth); + url.search = search ? `?${search}` : ''; + + // Any fragment, not only a parameter list: `#https://joe:pw@…` is a URL. + const fragment = url.hash.slice(1); + if (fragment) { + const clean = sanitizeNested(fragment, depth); + if (!clean) { + url.hash = ''; + } else if (clean !== fragment) { + url.hash = clean; + } + } + + return url.toString(); +} + +/** + * A URL safe to show on screen. It lives beside its only consumer + * (`source-display`): behind the shared logging barrel, which startup code + * imports, it rode into the initial bundle. + * Where the log redactor replaces secrets with + * a marker, this drops them: the userinfo, every query or fragment parameter + * the redactor treats as sensitive (`username`, `password`, `token`, `mac`, + * …), and the two account segments after `/live/`, `/movie/`, `/series/` or + * `/timeshift/` when a stream follows them. A URL or parameter list nested + * in a kept parameter or path segment (a proxy's `?url=…`, even encoded + * twice) is cleaned the same way, and a part that still names a secret (`joe:hunter2@…`, `token=…`) is + * dropped. A list can then say where a source points without any part of + * its credentials. + * + * Returns null for text that is not an absolute URL, so a caller never + * renders the unparsed original by mistake. + */ +export function stripUrlCredentials(value: string): string | null { + return stripUrl(value, 0); +} diff --git a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.html b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.html index d876116b4..5f2511981 100644 --- a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.html +++ b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.html @@ -131,58 +131,74 @@ role="separator" >
} -
- + +
+
+ } + + +
+ + @for ( + threshold of ratingThresholds; + track threshold + ) { + - @for ( - threshold of ratingThresholds; - track threshold - ) { - - } -
- } + } +
}
diff --git a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.scss b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.scss index c4b3c5926..f5ae129da 100644 --- a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.scss +++ b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.scss @@ -160,6 +160,21 @@ background: color-mix(in srgb, var(--app-on-surface) 8%, transparent); } +// The label span fills the row, so the active threshold sits at its end, +// before the submenu arrow. +.refine-rating-trigger ::ng-deep .mat-mdc-menu-item-text { + display: flex; + flex: 1; + align-items: center; + gap: 16px; +} + +.refine-rating-value { + margin-inline-start: auto; + color: var(--app-body-color, var(--mat-sys-on-surface-variant)); + font-variant-numeric: tabular-nums; +} + @container category-content (max-width: 920px) { .category-content-header { grid-template-columns: minmax(0, 1fr) auto auto; diff --git a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.spec.ts b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.spec.ts index 0f41f4b56..6a506fd80 100644 --- a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.spec.ts +++ b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.spec.ts @@ -113,6 +113,16 @@ describe('CategoryContentViewComponent', () => { getItemProgress: jest.fn().mockReturnValue({}), }; + /** Opens the rating thresholds from the open refine menu. */ + const openRatingSubmenu = () => { + ( + document.querySelector( + '.refine-rating-trigger' + ) as HTMLButtonElement + ).click(); + fixture.detectChanges(); + }; + beforeEach(async () => { routeReady.set(true); playlist.set(null); @@ -320,10 +330,15 @@ describe('CategoryContentViewComponent', () => { refineButton?.click(); fixture.detectChanges(); - const overlayText = document.body.textContent ?? ''; + let overlayText = document.body.textContent ?? ''; expect(overlayText).toContain('WORKSPACE.REFINE_SORT_SECTION'); expect(overlayText).toContain('WORKSPACE.SORT_DATE_DESC'); expect(overlayText).toContain('WORKSPACE.REFINE_RATING_SECTION'); + // The thresholds wait in a submenu, so the menu fits a short window. + expect(overlayText).not.toContain('WORKSPACE.FILTER_RATING_ANY'); + + openRatingSubmenu(); + overlayText = document.body.textContent ?? ''; expect(overlayText).toContain('WORKSPACE.FILTER_RATING_ANY'); }); @@ -446,6 +461,13 @@ describe('CategoryContentViewComponent', () => { ) as HTMLButtonElement ).click(); fixture.detectChanges(); + // The row that opens the thresholds names the active one. + expect( + document + .querySelector('.refine-rating-trigger .refine-rating-value') + ?.textContent?.trim() + ).toBe('8.0+'); + openRatingSubmenu(); const groups = Array.from( document.querySelectorAll( @@ -481,7 +503,9 @@ describe('CategoryContentViewComponent', () => { 'false WORKSPACE.FILTER_RATING_MIN', ]); expect( - document.querySelectorAll('.refine-menu [mat-menu-item]') + document.querySelectorAll( + '.refine-menu [mat-menu-item]:not(.refine-rating-trigger)' + ) ).toHaveLength( document.querySelectorAll('.refine-menu [role="menuitemradio"]') .length diff --git a/libs/portal/shared/data-access/src/index.ts b/libs/portal/shared/data-access/src/index.ts index eb5318d98..bf28db4c4 100644 --- a/libs/portal/shared/data-access/src/index.ts +++ b/libs/portal/shared/data-access/src/index.ts @@ -4,6 +4,10 @@ export * from './lib/multi-source'; export * from './lib/live-channel-playback-queue'; export * from './lib/source-health.service'; +export * from './lib/source-health-history.service'; +export * from './lib/source-status.service'; +export * from './lib/source-status-checks'; +export { sourceStatusBucket } from './lib/source-reachability'; export * from './lib/source-cleanup.service'; diff --git a/libs/portal/shared/data-access/src/lib/source-health-history.service.spec.ts b/libs/portal/shared/data-access/src/lib/source-health-history.service.spec.ts new file mode 100644 index 000000000..d33367074 --- /dev/null +++ b/libs/portal/shared/data-access/src/lib/source-health-history.service.spec.ts @@ -0,0 +1,116 @@ +import { TestBed } from '@angular/core/testing'; +import { SourceHealthEvidenceService } from '@iptvnator/services'; +import { + SOURCE_HEALTH_HISTORY_STORAGE_KEY, + SourceHealthHistoryService, +} from './source-health-history.service'; + +const failed = (checkedAt: number) => ({ + state: 'unavailable' as const, + reason: 'network' as const, + confirmedInactive: false, + checkedAt, +}); + +describe('SourceHealthHistoryService', () => { + const create = () => { + TestBed.configureTestingModule({ + providers: [ + SourceHealthHistoryService, + SourceHealthEvidenceService, + ], + }); + return TestBed.inject(SourceHealthHistoryService); + }; + + beforeEach(() => localStorage.clear()); + afterEach(() => TestBed.resetTestingModule()); + + it('keeps the start of a failing run across a restart', () => { + create().record(['a'], failed(100)); + TestBed.resetTestingModule(); + + const restarted = create(); + restarted.record(['a'], failed(900)); + expect(restarted.entries().get('a')).toEqual({ + state: 'bad', + checkedAt: 900, + failingSince: 100, + reason: 'network', + }); + }); + + it('stores no URL or credential', () => { + create().record(['a'], failed(100)); + expect(localStorage.getItem(SOURCE_HEALTH_HISTORY_STORAGE_KEY)).toBe( + '{"a":{"state":"bad","checkedAt":100,"failingSince":100,"reason":"network"}}' + ); + }); + + it('keeps the fingerprint of the checked connection, and starts over for another', () => { + const history = create(); + history.record(['a'], failed(100), 'aaaa1111'); + history.record(['a'], failed(900), 'bbbb2222'); + expect(history.entries().get('a')).toEqual({ + state: 'bad', + checkedAt: 900, + failingSince: 900, + reason: 'network', + connection: 'bbbb2222', + }); + }); + + it('drops a stored cause this build cannot describe', () => { + localStorage.setItem( + SOURCE_HEALTH_HISTORY_STORAGE_KEY, + '{"a":{"state":"bad","checkedAt":1,"reason":"