From c06efed6005019167447e7f344ed337a6afc4506 Mon Sep 17 00:00:00 2001 From: Tim Bradgate Date: Thu, 14 May 2026 00:20:31 +0100 Subject: [PATCH 01/23] =?UTF-8?q?Vue=203=20migration:=20Phase=200=20?= =?UTF-8?q?=E2=80=94=20skeleton=20&=20dual-serving=20infrastructure=20(#10?= =?UTF-8?q?35)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add Vue 3 migration skeleton (Phase 0) — issue #1033 Creates client-v3/ alongside the existing Vue 2 client/, serving a placeholder Vue 3 app at /ui-new/ via a new RootControllerV3. Both frontends build and serve independently. Backend: RootControllerV3 + /ui-new/assets/ and /ui-new/ handlers in app_server.py (registered before the Vue 2 catch-all route). client-v3 stack: Vue 3.5 / Pinia 3 / Vue Router 5 / Bootstrap-Vue-Next 0.45 / Vite 8 / ESLint 10 / TypeScript strict mode. CI: lint, typecheck, and test jobs added for client-v3 in nodelint.yml and client-test.yml. Co-Authored-By: Claude Sonnet 4.6 * Fix formatting --------- Co-authored-by: Claude Sonnet 4.6 --- .github/workflows/client-test.yml | 22 +- .github/workflows/nodelint.yml | 25 + client-v3/.gitignore | 6 + client-v3/.prettierignore | 13 + client-v3/eslint.config.ts | 97 + client-v3/index.html | 12 + client-v3/package-lock.json | 5869 +++++++++++++++++++++++++ client-v3/package.json | 82 + client-v3/prettier.config.ts | 35 + client-v3/src/App.vue | 13 + client-v3/src/assets/styles/dark.scss | 3 + client-v3/src/main.ts | 17 + client-v3/src/router/index.ts | 19 + client-v3/src/shims-vue.d.ts | 5 + client-v3/src/views/HomeView.vue | 24 + client-v3/tsconfig.json | 35 + client-v3/vite.config.ts | 82 + client-v3/vitest.config.ts | 25 + server/controllers/controllers.py | 17 + server/digi_server/app_server.py | 18 + 20 files changed, 6418 insertions(+), 1 deletion(-) create mode 100644 client-v3/.gitignore create mode 100644 client-v3/.prettierignore create mode 100644 client-v3/eslint.config.ts create mode 100644 client-v3/index.html create mode 100644 client-v3/package-lock.json create mode 100644 client-v3/package.json create mode 100644 client-v3/prettier.config.ts create mode 100644 client-v3/src/App.vue create mode 100644 client-v3/src/assets/styles/dark.scss create mode 100644 client-v3/src/main.ts create mode 100644 client-v3/src/router/index.ts create mode 100644 client-v3/src/shims-vue.d.ts create mode 100644 client-v3/src/views/HomeView.vue create mode 100644 client-v3/tsconfig.json create mode 100644 client-v3/vite.config.ts create mode 100644 client-v3/vitest.config.ts diff --git a/.github/workflows/client-test.yml b/.github/workflows/client-test.yml index 9aa776d9..750e1f85 100644 --- a/.github/workflows/client-test.yml +++ b/.github/workflows/client-test.yml @@ -26,4 +26,24 @@ jobs: if: always() with: check_name: 'Client Test Results' - junit_files: '**/client/junit/*.xml' \ No newline at end of file + junit_files: '**/client/junit/*.xml' + run-client-v3-tests: + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./client-v3 + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: 24 + - name: Install dependencies + run: npm ci + - name: Run tests + run: npm run test:run + - name: Publish Test Results + uses: EnricoMi/publish-unit-test-result-action@v2 + if: always() + with: + check_name: 'Client V3 Test Results' + junit_files: '**/client-v3/junit/*.xml' \ No newline at end of file diff --git a/.github/workflows/nodelint.yml b/.github/workflows/nodelint.yml index 7492250d..a681432b 100644 --- a/.github/workflows/nodelint.yml +++ b/.github/workflows/nodelint.yml @@ -53,3 +53,28 @@ jobs: node-version: 24 - run: npm ci - run: npm run typecheck + run-node-lint-client-v3: + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./client-v3 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + - run: npm ci + - run: npm run ci-lint + run-typecheck-client-v3: + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./client-v3 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + - run: npm ci + - run: npm run typecheck + diff --git a/client-v3/.gitignore b/client-v3/.gitignore new file mode 100644 index 00000000..45ea32af --- /dev/null +++ b/client-v3/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +dist-electron/ +junit/ +coverage/ +*.backup \ No newline at end of file diff --git a/client-v3/.prettierignore b/client-v3/.prettierignore new file mode 100644 index 00000000..f7140971 --- /dev/null +++ b/client-v3/.prettierignore @@ -0,0 +1,13 @@ +# Dependencies +node_modules/ + +# Build outputs +dist/ +../server/static/ + +# Test outputs +coverage/ +junit/ + +# Backups +*.backup diff --git a/client-v3/eslint.config.ts b/client-v3/eslint.config.ts new file mode 100644 index 00000000..9b8d9ad1 --- /dev/null +++ b/client-v3/eslint.config.ts @@ -0,0 +1,97 @@ +import js from '@eslint/js'; +import pluginVue from 'eslint-plugin-vue'; +import vueParser from 'vue-eslint-parser'; +import globals from 'globals'; +import tsParser from '@typescript-eslint/parser'; +import tsPlugin from '@typescript-eslint/eslint-plugin'; +import prettierConfig from 'eslint-config-prettier'; +import prettierPlugin from 'eslint-plugin-prettier'; +import tseslint from 'typescript-eslint'; + +const sharedRules = { + 'prettier/prettier': 'error', + ...prettierConfig.rules, + 'max-len': 'off', + 'no-unused-vars': 'off', + 'vue/no-unused-vars': 'off', + 'no-plusplus': 'off', + 'no-param-reassign': [ + 'error', + { + props: true, + ignorePropertyModificationsFor: ['state', 'acc', 'e'], + }, + ], +}; + +const tsRules = { + ...Object.assign({}, ...tseslint.configs.recommended.map((c) => c.rules ?? {})), + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-unused-vars': 'off', +}; + +const sharedGlobals = { + ...globals.browser, + ...globals.node, + ...globals.es2021, +}; + +export default [ + { + ignores: [ + '**/node_modules/**', + '**/dist/**', + '../server/static/**', + 'junit/**', + '*.backup', + ], + }, + js.configs.recommended, + ...pluginVue.configs['flat/recommended'], + // TypeScript source files + { + files: ['**/*.ts'], + plugins: { + '@typescript-eslint': tsPlugin, + prettier: prettierPlugin, + }, + languageOptions: { + parser: tsParser, + parserOptions: { ecmaVersion: 2022, sourceType: 'module' }, + globals: sharedGlobals, + }, + rules: { ...tsRules, ...sharedRules }, + }, + // Vue SFCs — all use + + diff --git a/client-v3/package-lock.json b/client-v3/package-lock.json new file mode 100644 index 00000000..7c597c62 --- /dev/null +++ b/client-v3/package-lock.json @@ -0,0 +1,5869 @@ +{ + "name": "client-v3", + "version": "0.29.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "client-v3", + "version": "0.29.1", + "dependencies": { + "@vuelidate/core": "^2.0.3", + "@vuelidate/validators": "^2.0.4", + "bootstrap": "^5.3.8", + "bootstrap-vue-next": "^0.45.3", + "bootswatch": "^5.3.8", + "contrast-color": "1.0.1", + "core-js": "^3.49.0", + "d3-hierarchy": "^3.1.2", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0", + "deep-object-diff": "1.1.9", + "dompurify": "^3.4.3", + "fuse.js": "^7.3.0", + "lodash": "^4.18.1", + "loglevel": "^1.9.2", + "marked": "^18.0.3", + "pinia": "^3.0.0", + "pinia-plugin-persistedstate": "^4.7.1", + "splitpanes": "^4.0.4", + "vue": "^3.5.0", + "vue-multiselect": "^3.5.0", + "vue-router": "^5.0.7", + "vue-toast-notification": "^3.1.3" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/lodash": "~4.17.24", + "@types/node": ">=22.12.0", + "@typescript-eslint/eslint-plugin": "^8.59.3", + "@typescript-eslint/parser": "^8.59.3", + "@vitejs/plugin-vue": "^6.0.6", + "@vitest/ui": "^4.1.6", + "@vue/test-utils": "^2.4.10", + "eslint": "^10.3.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-prettier": "^5.5.5", + "eslint-plugin-vue": "^10.9.1", + "globals": "^17.6.0", + "jiti": "^2.7.0", + "jsdom": "^29.1.1", + "prettier": "^3.8.3", + "sass": "1.99.0", + "typescript": "^6.0.3", + "typescript-eslint": "^8.59.3", + "vite": "^8.0.12", + "vitest": "^4.1.6", + "vue-eslint-parser": "^10.4.0" + }, + "engines": { + "node": ">=24.0.0 <25", + "npm": ">=11.0.0 <12" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/generator": { + "version": "8.0.0-rc.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-8.0.0-rc.5.tgz", + "integrity": "sha512-nFZPWz3FHIS7y6rMIVoa/WBwjdutfIaRJIBQjzn+t3RnecZoRNlGmGcyR2wb0T/IgSd50Kz/6dG8/LvMCRunjg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^8.0.0-rc.5", + "@babel/types": "^8.0.0-rc.5", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "@types/jsesc": "^2.5.0", + "jsesc": "^3.0.2" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/generator/node_modules/@babel/helper-string-parser": { + "version": "8.0.0-rc.5", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0-rc.5.tgz", + "integrity": "sha512-sN7R8rBvDurfaziNfDEIjIntlazmlkCDGO4SNl2RJ3wRCn+QxspLV7hzYAE8WWVd2joVuT8sUxeePdLp2idI1A==", + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/generator/node_modules/@babel/helper-validator-identifier": { + "version": "8.0.0-rc.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.0-rc.5.tgz", + "integrity": "sha512-ehJDxHvtbZ85RtX/L2fi0h9AGsBNqB5Euv1EB8RMAvGYvD+2X+QbpzzOpbklnNXO+WSZJNOaetw2BBj27xsWVg==", + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/generator/node_modules/@babel/parser": { + "version": "8.0.0-rc.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.0-rc.5.tgz", + "integrity": "sha512-/Mfg83rK3+jsRbl4Vbd0jqxc6M1A1/WNFtgrowRM1unEsD3XcNnrBdMM0JWakd0/RN9lseQKwPduW1TiEwKOlQ==", + "license": "MIT", + "dependencies": { + "@babel/types": "^8.0.0-rc.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/generator/node_modules/@babel/types": { + "version": "8.0.0-rc.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.0-rc.5.tgz", + "integrity": "sha512-JeSVu/m8x/zpp4CLjYHVNXuhEyOkhPXuxM8YOXjh6L4LlvQNKuUNOTo5KdBuKAcTDHw8DquToTaEkhsBqPXOaA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^8.0.0-rc.5", + "@babel/helper-validator-identifier": "^8.0.0-rc.5" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.1.tgz", + "integrity": "sha512-eZ5XOtyhK+mggRafYUWzA0tvaYOFgdY8AkgQiCJF9qNAePnUo/zmsqqYubBBb3sQ8uNUaSKTY9s9klfRaAXL0g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.2.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.4.tgz", + "integrity": "sha512-wgsqt92b7C7tQhIdPNxj0n9zuUbQlvAuI1exyzeNrOKOi62SD7ren8zqszmpVREjAOqg8cD2FqYhQfAuKjk4sw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.5.tgz", + "integrity": "sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.1.tgz", + "integrity": "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, + "node_modules/@floating-ui/vue": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@floating-ui/vue/-/vue-1.1.11.tgz", + "integrity": "sha512-HzHKCNVxnGS35r9fCHBc3+uCnjw9IWIlCPL683cGgM9Kgj2BiAl8x1mS7vtvP6F9S/e/q4O6MApwSHj8hNLGfw==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.6", + "@floating-ui/utils": "^0.2.11", + "vue-demi": ">=0.13.0" + } + }, + "node_modules/@floating-ui/vue/node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@internationalized/date": { + "version": "3.12.1", + "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.1.tgz", + "integrity": "sha512-6IedsVWXyq4P9Tj+TxuU8WGWM70hYLl12nbYU8jkikVpa6WXapFazPUcHUMDMoWftIDE2ILDkFFte6W2nFCkRQ==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + } + }, + "node_modules/@internationalized/number": { + "version": "3.6.6", + "resolved": "https://registry.npmjs.org/@internationalized/number/-/number-3.6.6.tgz", + "integrity": "sha512-iFgmQaXHE0vytNfpLZWOC2mEJCBRzcUxt53Xf/yCXG93lRvqas237i3r7X4RKMwO3txiyZD4mQjKAByFv6UGSQ==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@one-ini/wasm": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz", + "integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@oxc-project/types": { + "version": "0.129.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.129.0.tgz", + "integrity": "sha512-3oz8m3FGdr2nDXVqmFUw7jolKliC4MoyXYIG2c7gpjBnzUWQpUGIYcXYKxTdTi+N2jusvt610ckTMkxdwHkYEg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@parcel/watcher": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", + "integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.3", + "is-glob": "^4.0.3", + "node-addon-api": "^7.0.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.6", + "@parcel/watcher-darwin-arm64": "2.5.6", + "@parcel/watcher-darwin-x64": "2.5.6", + "@parcel/watcher-freebsd-x64": "2.5.6", + "@parcel/watcher-linux-arm-glibc": "2.5.6", + "@parcel/watcher-linux-arm-musl": "2.5.6", + "@parcel/watcher-linux-arm64-glibc": "2.5.6", + "@parcel/watcher-linux-arm64-musl": "2.5.6", + "@parcel/watcher-linux-x64-glibc": "2.5.6", + "@parcel/watcher-linux-x64-musl": "2.5.6", + "@parcel/watcher-win32-arm64": "2.5.6", + "@parcel/watcher-win32-ia32": "2.5.6", + "@parcel/watcher-win32-x64": "2.5.6" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz", + "integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz", + "integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz", + "integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz", + "integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz", + "integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz", + "integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz", + "integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz", + "integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz", + "integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz", + "integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz", + "integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz", + "integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz", + "integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@pkgr/core": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", + "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/pkgr" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "license": "MIT", + "peer": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0.tgz", + "integrity": "sha512-TWMZnRLMe63C2Lhyicviu7ZHaU4kxa6PS3rofvc9GmcvptzNN11BcfQ4Sl7MwTOsisQoa2keB/EBdNCAnUo8vA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0.tgz", + "integrity": "sha512-6XcD+8k0gPVItNagEw78/qqcBDwKcwDYS8V2hRmVsfUSIrd8cWe/CBvRDI5toqFyPfj+FJr6t8U6Xj2P2prEew==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0.tgz", + "integrity": "sha512-iN/tWVXRQDWvmZlKdceP1Dwug9GDpEymhb9p4xnEe6zvCg5lFmzVljl+1qR1NVx3yfGpr2Na+CuLmv5IU8uzfQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0.tgz", + "integrity": "sha512-jjQMDvvwSOuhOwMszD/klSOjyWMM3zI64hWTj9KT5x4MxRbZAf+7vLQ6qouRhtsLVFHr3f0ILaJAfgENPiQdAQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0.tgz", + "integrity": "sha512-d//Dtg2x6/m3mbV64yUGNnDGNZaDGRpDLLNGerHQUVObuNaIQaaDp25yUiqGXtHEXX+NP2d0wAlmKgpYgIAJ2A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0.tgz", + "integrity": "sha512-n7Ofp0mx+aB2cC+Sdy5YtMnXtY9lchnHbY+3Yt0uq9JsWQExf4f5Whu0tK0R8Jdc9S6RchTHjIFY7uc92puOVQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0.tgz", + "integrity": "sha512-EIVjy2cgd7uuMMo94FVkBp7F6DhcZAUwNURkSG3RwUmvAXR6s0ISxM81U+IydcZByPG0pZIHsf1b6kTxoFDgJA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0.tgz", + "integrity": "sha512-JEwwOPcwTLAcpDQlqSmjEmfs63xJnSiUNIGvLcDLUHCWK4XowpS/7c7tUsUH6uT/ct6bMUTdXKfI8967FYj6mg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0.tgz", + "integrity": "sha512-0wjCFhLrihtAubnT9iA0N++0pSV0z5Hg7tNGdNJ4RFaINceHadoF+kiFGyY1qSSNVIAZtLotG8Ju1bgDPkjnFA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0.tgz", + "integrity": "sha512-Dfn7iak9BcMMePxcoJfpSbWqnEyrp/dRF63/8qW/eHBdOZov6x5aShLLEYGYdIeSJ6vMLK/XCVB+lGIxm41bQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0.tgz", + "integrity": "sha512-5/utzzDmD/pD/bmuaUcbTf/sZYy0aztwIVlfpoW1fTjCZ0BaPOMVWGZL1zvgxyi7ZIVYWlxKONHmSbHuiOh8Jw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0.tgz", + "integrity": "sha512-ouJs8VcUomfLfpbUECqFMRqdV4x6aeAK3MA4m6vTrJJjKyWTV5KnxZx7Jd9G+GlDaQQxubcba00x16OyJ1meig==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0.tgz", + "integrity": "sha512-E+oHKGiDA+lsKMmFtffDDw91EryDT7uJocrIuCHqhm6bCTM6xFK+3gaCkYOHfPwQr0cCNarSM2xaELoQDz9jJg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0.tgz", + "integrity": "sha512-yYK02n8Rngo+gbm1y6G0+7jk1sJ/2Wt7K0me0Y7k/ErBpyf+LJ2gFpqWVTcRV1rUepBlQRmpgWkTQCiiwrK0Ow==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0.tgz", + "integrity": "sha512-14bpChMahXRRXiTwahSl+zzHPW6qQTXtkMuJBFlbo+pqSAews2d4BdCSHfrJ/MBsCZtpmTafsY+1QhBzitcmdg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.13.tgz", + "integrity": "sha512-3ngTAv6F/Py35BsYbeeLeecvhMKdsKm4AoOETVhAA+Qc8nrA2I0kF7oa93mE9qnIurngOSpMnQ0x2nQY2FPviA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@swc/helpers": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.21.tgz", + "integrity": "sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.14.0.tgz", + "integrity": "sha512-JLANqGy/D6k4Ujmh8Tr25lGimuOXNiaVyXaCAZS0W+1390sADdGnyUdSWNIfd49gebtIxGMij4IktRVzrdr12Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/vue-virtual": { + "version": "3.13.24", + "resolved": "https://registry.npmjs.org/@tanstack/vue-virtual/-/vue-virtual-3.13.24.tgz", + "integrity": "sha512-A0k2qF0zFSUStXSZkGXABouXr2Tw2Ztl/cVIYG9qy84uR8W7UNjAcX3DvzBS3YnDcwvLxab8v7dbmYBZ39itDA==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.14.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "vue": "^2.7.0 || ^3.0.0" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/jsesc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@types/jsesc/-/jsesc-2.5.1.tgz", + "integrity": "sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==", + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.24", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz", + "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.7.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.7.0.tgz", + "integrity": "sha512-z+pdZyxE+RTQE9AcboAZCb4otwcrvgHD+GlBpPgn0emDVt0ohrTMhAwlr2Wd9nZ+nihhYFxO2pThz3C5qSu2Eg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~7.21.0" + } + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.21", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", + "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.3.tgz", + "integrity": "sha512-PwFvSKsXGShKGW6n5bZOhGHEcCZXM8HofLK9fNsEwZXzFRjoY+XT1Vsf1zgyXdwTr0ZYz1/2tkZ0DBTT9jZjhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.59.3", + "@typescript-eslint/type-utils": "8.59.3", + "@typescript-eslint/utils": "8.59.3", + "@typescript-eslint/visitor-keys": "8.59.3", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.59.3", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.3.tgz", + "integrity": "sha512-HPwA+hVkfcriajbNvTmZv4VRauibay+cWArYUYq7u7W7PmGShMxbPxLvrwDme55a6d5alG3nrYfhyJ/G28XlLg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@typescript-eslint/scope-manager": "8.59.3", + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/typescript-estree": "8.59.3", + "@typescript-eslint/visitor-keys": "8.59.3", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.3.tgz", + "integrity": "sha512-ECiUWa/KYRGDFUqTNehaRgzDshnJfkTABJxVemHk4ko22gcr0ukloKjWvyQ64g8YCV/UI47kN1dbmjf/GaQYng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.59.3", + "@typescript-eslint/types": "^8.59.3", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.3.tgz", + "integrity": "sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/visitor-keys": "8.59.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.3.tgz", + "integrity": "sha512-PcIJHjmaREXLgIAIzLnSY9VucEzz8FKXsRgFa1DmdGCK/5tJpW03TKJF01Q6VZd1lLdz2sIKPWaDUZN9dp//dw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.3.tgz", + "integrity": "sha512-g71d8QD8UaiHGvrJwyIS1hCX5r63w6Jll+4VEYhEAHXTDIqX1JgxhTAbEHtKntL9kuc4jRo7/GWw5xfCepSccQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/typescript-estree": "8.59.3", + "@typescript-eslint/utils": "8.59.3", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.3.tgz", + "integrity": "sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.3.tgz", + "integrity": "sha512-CbRjVRAf7Lr9Kr8RopKcbY45p2VfmmHrm0ygOCYFi7oU8q19m0Fs/6iHS7kNOmwpp+ob07ZVcAqlxUod9lYdmg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.59.3", + "@typescript-eslint/tsconfig-utils": "8.59.3", + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/visitor-keys": "8.59.3", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.3.tgz", + "integrity": "sha512-JAvT14goBzRzzzZyqq3P9BLArIxTtQURUtFgQ/V7FO+eU+Gg6ES+5ymOPP1wRxXcxAYeivCk4uS3jCKWI1K8Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.59.3", + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/typescript-estree": "8.59.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.3.tgz", + "integrity": "sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.3", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitejs/plugin-vue": { + "version": "6.0.6", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.6.tgz", + "integrity": "sha512-u9HHgfrq3AjXlysn0eINFnWQOJQLO9WN6VprZ8FXl7A2bYisv3Hui9Ij+7QZ41F/WYWarHjwBbXtD7dKg3uxbg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-rc.13" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.6.tgz", + "integrity": "sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.6", + "@vitest/utils": "4.1.6", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.6.tgz", + "integrity": "sha512-MCFc63czMjEInOlcY2cpQCvCN+KgbAn+60xu9cMgP4sKaLC5JNAKw7JH8QdAnoAC88hW1IiSNZ+GgVXlN1UcMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.6", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.6.tgz", + "integrity": "sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.6.tgz", + "integrity": "sha512-nOPCmn2+yD0ZNmKdsXGv/UxMMWbMuKeD6GyYncNwdkYDxpQvrPSKYj2rWuDjC2Y4b6w6hjip5dBKFzEUuZe3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.6", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.6.tgz", + "integrity": "sha512-YhsdE6xAVfTDmzjxL2ZDUvjj+ZsgyOKe+TdQzqkD72wIOmHka8NuGQ6NpTNZv9D2Z63fbwWKJPeVpEw4EQgYxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.6", + "@vitest/utils": "4.1.6", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.6.tgz", + "integrity": "sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/ui": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.1.6.tgz", + "integrity": "sha512-wiu5em68DfGv/2HFvI1Njr7JI2CHcBlQvereSzVG8my53PRxjTNOCsD9VOkRKrsJBDHmyuXvosxWZw7T91a2mw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@vitest/utils": "4.1.6", + "fflate": "^0.8.2", + "flatted": "^3.4.2", + "pathe": "^2.0.3", + "sirv": "^3.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "4.1.6" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.6.tgz", + "integrity": "sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.6", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vue-macros/common": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@vue-macros/common/-/common-3.1.2.tgz", + "integrity": "sha512-h9t4ArDdniO9ekYHAD95t9AZcAbb19lEGK+26iAjUODOIJKmObDNBSe4+6ELQAA3vtYiFPPBtHh7+cQCKi3Dng==", + "license": "MIT", + "dependencies": { + "@vue/compiler-sfc": "^3.5.22", + "ast-kit": "^2.1.2", + "local-pkg": "^1.1.2", + "magic-string-ast": "^1.0.2", + "unplugin-utils": "^0.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/vue-macros" + }, + "peerDependencies": { + "vue": "^2.7.0 || ^3.2.25" + }, + "peerDependenciesMeta": { + "vue": { + "optional": true + } + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.34.tgz", + "integrity": "sha512-s9cLyK5mLcvZ4Agva5QgRsQyLKvts9WbU9DB6NqiZkkGEdwmcEiylj5Jbwkp680drF/NNCV8OlAJSe+yMLxaJw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@vue/shared": "3.5.34", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.34.tgz", + "integrity": "sha512-EbF/T++k0e2MMZlJsBhzK8Sgwt0HcIPOhzn1CTB/lv6sQcyk+OWf8YeiLxZp3ro7MbbLcAfAJ6sEvjFWuNgUCw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@vue/compiler-core": "3.5.34", + "@vue/shared": "3.5.34" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.34.tgz", + "integrity": "sha512-D/ihr6uZeIt6r+pVZf46RWT1fAsLFMbUP7k8G1VkiiWexriED9GrX3echHd4Abbt17zjlfiFJ8z7a3BxZOPNjg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@vue/compiler-core": "3.5.34", + "@vue/compiler-dom": "3.5.34", + "@vue/compiler-ssr": "3.5.34", + "@vue/shared": "3.5.34", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.14", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.34.tgz", + "integrity": "sha512-cDtTHKibkThKGHH1SP+WdccquNRYQDFH6rRjQCqT9G2ltFAfoR5pUftpab/z+aM5mW9HLLVQW7hfKKQe/1GBeQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.34", + "@vue/shared": "3.5.34" + } + }, + "node_modules/@vue/devtools-api": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.9.tgz", + "integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==", + "license": "MIT", + "dependencies": { + "@vue/devtools-kit": "^7.7.9" + } + }, + "node_modules/@vue/devtools-kit": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz", + "integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==", + "license": "MIT", + "dependencies": { + "@vue/devtools-shared": "^7.7.9", + "birpc": "^2.3.0", + "hookable": "^5.5.3", + "mitt": "^3.0.1", + "perfect-debounce": "^1.0.0", + "speakingurl": "^14.0.1", + "superjson": "^2.2.2" + } + }, + "node_modules/@vue/devtools-shared": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz", + "integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==", + "license": "MIT", + "dependencies": { + "rfdc": "^1.4.1" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.34.tgz", + "integrity": "sha512-y9XDjCEuBp+98k+UL5dbYkh57AHU4o6cxZedOPXw3bmrZZYLQsVHguGurq7hVrPCSrQtrnz1f9dssyFr+dMXfQ==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.34" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.34.tgz", + "integrity": "sha512-mKeBYvu8tcMSLhypAHBmriUFfWXKTCF/23Z4jiCoYK3UtWepkliViNLuR90V9XOyD62mUxs9p1jsrpK3CCGIzw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.34", + "@vue/shared": "3.5.34" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.34.tgz", + "integrity": "sha512-e8kZzERmCwUnBRVsgSQlAfrfU2rGoy0FFKPBXSlfEjc/O3KfA7QP0t1/2ZylrbchjmIKB4dPTd07A6WPr0eOrg==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.34", + "@vue/runtime-core": "3.5.34", + "@vue/shared": "3.5.34", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.34.tgz", + "integrity": "sha512-nHxmJoTrKsmrkbILRhkC9gY1G3moZbJTqCzDd7DOOzG5KH9oeJ0Unqrff5f9v0pW//jES05ZkJcNtfE8JjOIew==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.34", + "@vue/shared": "3.5.34" + }, + "peerDependencies": { + "vue": "3.5.34" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.34.tgz", + "integrity": "sha512-24uqU4OIiX29ryC3MeWid/Xf2fa2EFRUVLb77nRhk+UrTVrh/XiGtFAFmJBAtBRbjwNdsPRP+jj/OL27Eg1NDA==", + "license": "MIT" + }, + "node_modules/@vue/test-utils": { + "version": "2.4.10", + "resolved": "https://registry.npmjs.org/@vue/test-utils/-/test-utils-2.4.10.tgz", + "integrity": "sha512-SmoZ5EA1kYiAFs9NkYdiFFQF+cSnUwnvlYEbY+DogWQZUiqOm/Y29eSbc5T6yi75SgSF9863SBeXniIEoPajCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-beautify": "^1.14.9", + "vue-component-type-helpers": "^3.0.0" + }, + "peerDependencies": { + "@vue/compiler-dom": "3.x", + "@vue/server-renderer": "3.x", + "vue": "3.x" + }, + "peerDependenciesMeta": { + "@vue/server-renderer": { + "optional": true + } + } + }, + "node_modules/@vuelidate/core": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@vuelidate/core/-/core-2.0.3.tgz", + "integrity": "sha512-AN6l7KF7+mEfyWG0doT96z+47ljwPpZfi9/JrNMkOGLFv27XVZvKzRLXlmDPQjPl/wOB1GNnHuc54jlCLRNqGA==", + "license": "MIT", + "dependencies": { + "vue-demi": "^0.13.11" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^2.0.0 || >=3.0.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/@vuelidate/core/node_modules/vue-demi": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.13.11.tgz", + "integrity": "sha512-IR8HoEEGM65YY3ZJYAjMlKygDQn25D5ajNFNoKh9RSDMQtlzCxtfQjdQgv9jjK+m3377SsJXY8ysq8kLCZL25A==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/@vuelidate/validators": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@vuelidate/validators/-/validators-2.0.4.tgz", + "integrity": "sha512-odTxtUZ2JpwwiQ10t0QWYJkkYrfd0SyFYhdHH44QQ1jDatlZgTh/KRzrWVmn/ib9Gq7H4hFD4e8ahoo5YlUlDw==", + "license": "MIT", + "dependencies": { + "vue-demi": "^0.13.11" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^2.0.0 || >=3.0.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/@vuelidate/validators/node_modules/vue-demi": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.13.11.tgz", + "integrity": "sha512-IR8HoEEGM65YY3ZJYAjMlKygDQn25D5ajNFNoKh9RSDMQtlzCxtfQjdQgv9jjK+m3377SsJXY8ysq8kLCZL25A==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/@vueuse/core": { + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.3.0.tgz", + "integrity": "sha512-aHfz47g0ZhMtTVHmIzMVpJy8ePhhOy68GY5bv110+5DVtZ+W7BsOx+m61UNQqfrWyPztIHIanWa3E2tib3NFIw==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.21", + "@vueuse/metadata": "14.3.0", + "@vueuse/shared": "14.3.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/@vueuse/metadata": { + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.3.0.tgz", + "integrity": "sha512-BwxmbAzwAVF50+MW57GXOUEV61nFBGnlBvrTqj49PqWJu3uw7hdu72ztXeZ33RdZtDY6kO+bfCAE1PCn88Tktw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.3.0.tgz", + "integrity": "sha512-bZpge9eSXwa4ToSiqJ7j6KRwhAsneMFoSz3LMWKQDkqimm3D/tbFlrklrs/IOqC8tEcYmXQZJ6N0UrjhBirVCg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/abbrev": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz", + "integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "license": "MIT", + "peer": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-kit": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-2.2.0.tgz", + "integrity": "sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "pathe": "^2.0.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/ast-walker-scope": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/ast-walker-scope/-/ast-walker-scope-0.8.3.tgz", + "integrity": "sha512-cbdCP0PGOBq0ASG+sjnKIoYkWMKhhz+F/h9pRexUdX2Hd38+WOlBkRKlqkGOSm0YQpcFMQBJeK4WspUAkwsEdg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.4", + "ast-kit": "^2.1.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/birpc": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", + "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true, + "license": "ISC" + }, + "node_modules/bootstrap": { + "version": "5.3.8", + "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz", + "integrity": "sha512-HP1SZDqaLDPwsNiqRqi5NcP0SSXciX2s9E+RyqJIIqGo+vJeN5AJVM98CXmW/Wux0nQ5L7jeWUdplCEf0Ee+tg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/twbs" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/bootstrap" + } + ], + "license": "MIT", + "peer": true, + "peerDependencies": { + "@popperjs/core": "^2.11.8" + } + }, + "node_modules/bootstrap-vue-next": { + "version": "0.45.3", + "resolved": "https://registry.npmjs.org/bootstrap-vue-next/-/bootstrap-vue-next-0.45.3.tgz", + "integrity": "sha512-I+/M+cqyWU79014E4WjIEMRQRu/O2FQLJAlyAamVjbZ41v3QpljVNt9EF8skmQAztoWFNeGvpHrzLw6MATJDKA==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/vue": "^1.1.11", + "@vueuse/core": "^14.2.1", + "reka-ui": "^2.9.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/bootstrap-vue-next" + }, + "peerDependencies": { + "@floating-ui/vue": "*", + "@internationalized/date": "*", + "@vueuse/core": "*", + "@vueuse/integrations": "*", + "bootstrap": "^5.3.0", + "focus-trap": "*", + "reka-ui": "*", + "vue": "^3.5.13", + "vue-router": "*" + }, + "peerDependenciesMeta": { + "@floating-ui/vue": { + "optional": true + }, + "@internationalized/date": { + "optional": true + }, + "@vueuse/core": { + "optional": true + }, + "@vueuse/integrations": { + "optional": true + }, + "focus-trap": { + "optional": true + }, + "reka-ui": { + "optional": true + }, + "vue-router": { + "optional": true + } + } + }, + "node_modules/bootswatch": { + "version": "5.3.8", + "resolved": "https://registry.npmjs.org/bootswatch/-/bootswatch-5.3.8.tgz", + "integrity": "sha512-88mnH9tv+x6DV+scBxYFOpM4YSDVhyfEgbhqaEfvkHNctKI9qRcACxIP9nmBZ5mSeLXtsgax1VsRkUs1eWjlAQ==", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "license": "MIT" + }, + "node_modules/config-chain": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", + "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "^1.3.4", + "proto-list": "~1.2.1" + } + }, + "node_modules/contrast-color": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/contrast-color/-/contrast-color-1.0.1.tgz", + "integrity": "sha512-XeTV/LiyWrf/OWnODTqve2YGBfg32N6zlLqQjJKmEY+ffDqIfecgdmluVz7tky1D4VEaweZgoeRJJT87gDSDCQ==", + "license": "ISC", + "engines": { + "npm": ">= 4.0.0" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/copy-anything": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz", + "integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==", + "license": "MIT", + "dependencies": { + "is-what": "^5.2.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/core-js": { + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz", + "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==", + "hasInstallScript": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", + "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "peer": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-object-diff": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/deep-object-diff/-/deep-object-diff-1.1.9.tgz", + "integrity": "sha512-Rn+RuwkmkDwCi2/oXOFS9Gsr5lJZu/yTGpK7wAaAIE75CC+LCGEZHpY6VQJa/RoJcrmaA/docWJZvYohlNkWPA==", + "license": "MIT" + }, + "node_modules/defu": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dompurify": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.3.tgz", + "integrity": "sha512-VVwJidIJcp1hpg2OMXML3ZVRPYSZiq4aX7qBh83BSIpOaRDqI+qxhXjjIWnpzkOXhmp0L81lnoME1mnCc9H48A==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/editorconfig": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.7.tgz", + "integrity": "sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@one-ini/wasm": "0.1.1", + "commander": "^10.0.0", + "minimatch": "^9.0.1", + "semver": "^7.5.3" + }, + "bin": { + "editorconfig": "bin/editorconfig" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/editorconfig/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/editorconfig/node_modules/brace-expansion": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/editorconfig/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.3.0.tgz", + "integrity": "sha512-XbEXaRva5cF0ZQB8w6MluHA0kZZfV2DuCMJ3ozyEOHLwDpZX2Lmm/7Pp0xdJmI0GL1W05VH5VwIFHEm1Vcw2gw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.5.5", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-plugin-prettier": { + "version": "5.5.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.5.tgz", + "integrity": "sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "prettier-linter-helpers": "^1.0.1", + "synckit": "^0.11.12" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-plugin-prettier" + }, + "peerDependencies": { + "@types/eslint": ">=8.0.0", + "eslint": ">=8.0.0", + "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0", + "prettier": ">=3.0.0" + }, + "peerDependenciesMeta": { + "@types/eslint": { + "optional": true + }, + "eslint-config-prettier": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-vue": { + "version": "10.9.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-vue/-/eslint-plugin-vue-10.9.1.tgz", + "integrity": "sha512-cHB0Tf4Duvzwecwd/AqWzZvF/QszE13BhjVUpVXWCy9AeMR5GjkAjP3i85vqgLgOuTmkHR1OJ5oMeqLHtuw8zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "natural-compare": "^1.4.0", + "nth-check": "^2.1.1", + "postcss-selector-parser": "^7.1.0", + "semver": "^7.6.3", + "xml-name-validator": "^4.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "peerDependencies": { + "@stylistic/eslint-plugin": "^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0", + "@typescript-eslint/parser": "^7.0.0 || ^8.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "vue-eslint-parser": "^10.3.0" + }, + "peerDependenciesMeta": { + "@stylistic/eslint-plugin": { + "optional": true + }, + "@typescript-eslint/parser": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/exsolve": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-diff": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", + "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "dev": true, + "license": "MIT" + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/fuse.js": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.3.0.tgz", + "integrity": "sha512-plz8RVjfcDedTGfVngWH1jmJvBvAwi1v2jecfDerbEnMcmOYUEEwKFTHbNoCiYyzaK2Ws8lABkTCcRSqCY1q4w==", + "license": "Apache-2.0", + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/krisk" + } + }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/globals": { + "version": "17.6.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz", + "integrity": "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/hookable": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", + "license": "MIT" + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/immutable": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz", + "integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==", + "dev": true, + "license": "MIT" + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-what": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz", + "integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-beautify": { + "version": "1.15.4", + "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz", + "integrity": "sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "config-chain": "^1.1.13", + "editorconfig": "^1.0.4", + "glob": "^10.4.2", + "js-cookie": "^3.0.5", + "nopt": "^7.2.1" + }, + "bin": { + "css-beautify": "js/bin/css-beautify.js", + "html-beautify": "js/bin/html-beautify.js", + "js-beautify": "js/bin/js-beautify.js" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/js-cookie": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", + "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/local-pkg": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.1.2.tgz", + "integrity": "sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==", + "license": "MIT", + "dependencies": { + "mlly": "^1.7.4", + "pkg-types": "^2.3.0", + "quansync": "^0.2.11" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/loglevel": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz", + "integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + }, + "funding": { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/loglevel" + } + }, + "node_modules/lru-cache": { + "version": "11.3.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.6.tgz", + "integrity": "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magic-string-ast": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/magic-string-ast/-/magic-string-ast-1.0.3.tgz", + "integrity": "sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA==", + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.19" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/marked": { + "version": "18.0.3", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.3.tgz", + "integrity": "sha512-7VT90JOkDeaRWpfjOReRGPEKn0ecdARBkDGL+tT1wZY0efPPqkUxLUSmzy/C7TIylQYJC9STISEsCHrqb/7VIA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "license": "MIT" + }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/mlly/node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "license": "MIT" + }, + "node_modules/mlly/node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/nopt": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz", + "integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^2.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/ohash": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "license": "MIT" + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pinia": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz", + "integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@vue/devtools-api": "^7.7.7" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.5.0", + "vue": "^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/pinia-plugin-persistedstate": { + "version": "4.7.1", + "resolved": "https://registry.npmjs.org/pinia-plugin-persistedstate/-/pinia-plugin-persistedstate-4.7.1.tgz", + "integrity": "sha512-WHOqh2esDlR3eAaknPbqXrkkj0D24h8shrDPqysgCFR6ghqP/fpFfJmMPJp0gETHsvrh9YNNg6dQfo2OEtDnIQ==", + "license": "MIT", + "dependencies": { + "defu": "^6.1.4" + }, + "peerDependencies": { + "@nuxt/kit": ">=3.0.0", + "@pinia/nuxt": ">=0.10.0", + "pinia": ">=3.0.0" + }, + "peerDependenciesMeta": { + "@nuxt/kit": { + "optional": true + }, + "@pinia/nuxt": { + "optional": true + }, + "pinia": { + "optional": true + } + } + }, + "node_modules/pkg-types": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz", + "integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==", + "license": "MIT", + "dependencies": { + "confbox": "^0.2.4", + "exsolve": "^1.0.8", + "pathe": "^2.0.3" + } + }, + "node_modules/postcss": { + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", + "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", + "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-linter-helpers": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.1.tgz", + "integrity": "sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-diff": "^1.1.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/proto-list": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", + "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", + "dev": true, + "license": "ISC" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/quansync": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", + "integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/antfu" + }, + { + "type": "individual", + "url": "https://github.com/sponsors/sxzz" + } + ], + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/reka-ui": { + "version": "2.9.7", + "resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.9.7.tgz", + "integrity": "sha512-aX7foYYR20v4+majO58OJJdBNfLMm0eJb448l9N4JVy8JB7GXOr4H/S4a+J1pkcoxZH8Cb7YHpJ855+miAm7sA==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.6.13", + "@floating-ui/vue": "^1.1.6", + "@internationalized/date": "^3.5.0", + "@internationalized/number": "^3.5.0", + "@tanstack/vue-virtual": "^3.12.0", + "@vueuse/core": "^14.1.0", + "@vueuse/shared": "^14.1.0", + "aria-hidden": "^1.2.4", + "defu": "^6.1.5", + "ohash": "^2.0.11" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/zernonia" + }, + "peerDependencies": { + "vue": ">= 3.4.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/rolldown": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0.tgz", + "integrity": "sha512-yD986aXDESFGS95spT1LAv0jssywP4npMEjmMHyN2/5+eE8qQJUype2AaKkRiLgBgyD0LFlubwAht7VmY8rGoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.129.0", + "@rolldown/pluginutils": "1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0", + "@rolldown/binding-darwin-arm64": "1.0.0", + "@rolldown/binding-darwin-x64": "1.0.0", + "@rolldown/binding-freebsd-x64": "1.0.0", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0", + "@rolldown/binding-linux-arm64-gnu": "1.0.0", + "@rolldown/binding-linux-arm64-musl": "1.0.0", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0", + "@rolldown/binding-linux-s390x-gnu": "1.0.0", + "@rolldown/binding-linux-x64-gnu": "1.0.0", + "@rolldown/binding-linux-x64-musl": "1.0.0", + "@rolldown/binding-openharmony-arm64": "1.0.0", + "@rolldown/binding-wasm32-wasi": "1.0.0", + "@rolldown/binding-win32-arm64-msvc": "1.0.0", + "@rolldown/binding-win32-x64-msvc": "1.0.0" + } + }, + "node_modules/rolldown/node_modules/@rolldown/pluginutils": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0.tgz", + "integrity": "sha512-aKs/3GSWyV0mrhNmt/96/Z3yczC3yvrzYATCiCXQebBsGyYzjNdUphRVLeJQ67ySKVXRfMxt2lm12pmXvbPFQQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/sass": { + "version": "1.99.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.99.0.tgz", + "integrity": "sha512-kgW13M54DUB7IsIRM5LvJkNlpH+WhMpooUcaWGFARkF1Tc82v9mIWkCbCYf+MBvpIUBSeSOTilpZjEPr2VYE6Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "chokidar": "^4.0.0", + "immutable": "^5.1.5", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=14.0.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scule": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz", + "integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/speakingurl": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz", + "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/splitpanes": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/splitpanes/-/splitpanes-4.0.4.tgz", + "integrity": "sha512-RbysugZhjbCw5fgplvk3hOXr41stahQDtZhHVkhnnJI6H4wlGDhM2kIpbehy7v92duy9GnMa8zIhHigIV1TWtg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antoniandre" + }, + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/superjson": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz", + "integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==", + "license": "MIT", + "dependencies": { + "copy-anything": "^4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/synckit": { + "version": "0.11.12", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz", + "integrity": "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pkgr/core": "^0.2.9" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/synckit" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz", + "integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.0.30", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.30.tgz", + "integrity": "sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.30" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.30", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.30.tgz", + "integrity": "sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "devOptional": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.3.tgz", + "integrity": "sha512-KgusgyDgG4LI8Ih/sWaCtZ06tckLAS5CvT5A4D1Q7bYVoAAyzwiZvE4BmwDHkhRVkvhRBepKeASoFzQetha7Fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.59.3", + "@typescript-eslint/parser": "8.59.3", + "@typescript-eslint/typescript-estree": "8.59.3", + "@typescript-eslint/utils": "8.59.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "license": "MIT" + }, + "node_modules/undici": { + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", + "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "7.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.21.0.tgz", + "integrity": "sha512-w9IMgQrz4O0YN1LtB7K5P63vhlIOvC7opSmouCJ+ZywlPAlO9gIkJ+otk6LvGpAs2wg4econaCz3TvQ9xPoyuQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unplugin": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-3.0.0.tgz", + "integrity": "sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "picomatch": "^4.0.3", + "webpack-virtual-modules": "^0.6.2" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/unplugin-utils": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/unplugin-utils/-/unplugin-utils-0.3.1.tgz", + "integrity": "sha512-5lWVjgi6vuHhJ526bI4nlCOmkCIF3nnfXkCMDeMJrtdvxTs6ZFCM8oNufGTsDbKv/tJ/xj8RpvXjRuPBZJuJog==", + "license": "MIT", + "dependencies": { + "pathe": "^2.0.3", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.0.12", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.12.tgz", + "integrity": "sha512-w2dDofOWv2QB09ZITZBsvKTVAlYvPR4IAmrY/v0ir9KvLs0xybR7i48wxhM1/oyBWO34wPns+bPGw5ZrZqDpZg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.14", + "rolldown": "1.0.0", + "tinyglobby": "^0.2.16" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.6.tgz", + "integrity": "sha512-6lvjbS3p9b4CrdCmguzbh2/4uoXhGE2q71R4OX5sqF9R1bo9Xd6fGrMAfvp5wnCzlBnFVdCOp6onuTQVbo8iUQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@vitest/expect": "4.1.6", + "@vitest/mocker": "4.1.6", + "@vitest/pretty-format": "4.1.6", + "@vitest/runner": "4.1.6", + "@vitest/snapshot": "4.1.6", + "@vitest/spy": "4.1.6", + "@vitest/utils": "4.1.6", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.6", + "@vitest/browser-preview": "4.1.6", + "@vitest/browser-webdriverio": "4.1.6", + "@vitest/coverage-istanbul": "4.1.6", + "@vitest/coverage-v8": "4.1.6", + "@vitest/ui": "4.1.6", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vue": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.34.tgz", + "integrity": "sha512-WdLBG9gm02OgJIG9axd5Hpx0TFLdzVgfG2evFFu8Rur5O/IoGc5cMjnjh3tPL6GnRGsYvUhBSKVPYVcxRKpMCA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@vue/compiler-dom": "3.5.34", + "@vue/compiler-sfc": "3.5.34", + "@vue/runtime-dom": "3.5.34", + "@vue/server-renderer": "3.5.34", + "@vue/shared": "3.5.34" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-component-type-helpers": { + "version": "3.2.9", + "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-3.2.9.tgz", + "integrity": "sha512-S3BiWYaLSzHxTpln665ELSrMR9UYmrIDUmhik7nVZxmJjTKL2/a+ew1hvGxksKelivm0ujjWfG1fYOiU/2e8rA==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue-eslint-parser": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-10.4.0.tgz", + "integrity": "sha512-Vxi9pJdbN3ZnVGLODVtZ7y4Y2kzAAE2Cm0CZ3ZDRvydVYxZ6VrnBhLikBsRS+dpwj4Jv4UCv21PTEwF5rQ9WXg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "debug": "^4.4.0", + "eslint-scope": "^8.2.0 || ^9.0.0", + "eslint-visitor-keys": "^4.2.0 || ^5.0.0", + "espree": "^10.3.0 || ^11.0.0", + "esquery": "^1.6.0", + "semver": "^7.6.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://github.com/sponsors/mysticatea" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/vue-eslint-parser/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/vue-multiselect": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/vue-multiselect/-/vue-multiselect-3.5.0.tgz", + "integrity": "sha512-i758SEqWFcFshL1eAg0F3EFeFQ1mOCmh2mgnGCZv1XpHFVIAv8fxo8bQQ4ZnMoaPhMp8KI1A6gPBVHh3YzRg/Q==", + "license": "MIT", + "engines": { + "node": ">= 14.18.1", + "npm": ">= 6.14.15" + } + }, + "node_modules/vue-router": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-5.0.7.tgz", + "integrity": "sha512-dqfk8kvRbCutmCOCj/XLDqDEYxc1wBdAOGLuVy5M93ifYMsBd5fIjfaPN4tQAbxr5IprdBDIox1gr4wYyOx/SA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/generator": "^8.0.0-rc.4", + "@vue-macros/common": "^3.1.1", + "@vue/devtools-api": "^8.1.1", + "ast-walker-scope": "^0.8.3", + "chokidar": "^5.0.0", + "json5": "^2.2.3", + "local-pkg": "^1.1.2", + "magic-string": "^0.30.21", + "mlly": "^1.8.0", + "muggle-string": "^0.4.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "scule": "^1.3.0", + "tinyglobby": "^0.2.15", + "unplugin": "^3.0.0", + "unplugin-utils": "^0.3.1", + "yaml": "^2.8.2" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "@pinia/colada": ">=0.21.2", + "@vue/compiler-sfc": "^3.5.34", + "pinia": "^3.0.4", + "vue": "^3.5.34" + }, + "peerDependenciesMeta": { + "@pinia/colada": { + "optional": true + }, + "@vue/compiler-sfc": { + "optional": true + }, + "pinia": { + "optional": true + } + } + }, + "node_modules/vue-router/node_modules/@vue/devtools-api": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-8.1.2.tgz", + "integrity": "sha512-vA0O112YqyDuNA1s7Yb2gCgToQ/OxOWiFDO5ThLCcDy0ldHnSd1dUTaSYhOldbqoNgumE4dxtGAoAaSUKUD1Zg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-kit": "^8.1.2" + } + }, + "node_modules/vue-router/node_modules/@vue/devtools-kit": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-8.1.2.tgz", + "integrity": "sha512-f75/upc+GCyjXErpgPGz4582ujS0L/adAltGy+tqXMGUJpgAcfGr6CxnnhpZY8BHuMYt6KpbF8uaFrrQG66rGQ==", + "license": "MIT", + "dependencies": { + "@vue/devtools-shared": "^8.1.2", + "birpc": "^2.6.1", + "hookable": "^5.5.3", + "perfect-debounce": "^2.0.0" + } + }, + "node_modules/vue-router/node_modules/@vue/devtools-shared": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-8.1.2.tgz", + "integrity": "sha512-X9RyVFYAdkBe4IUf5v48TxBF/6QPmF8CmWrDAjXzfUHrgQ/HGfTC1A6TqgXqZ03ye66l3AD51BAGD69IvKM9sw==", + "license": "MIT" + }, + "node_modules/vue-router/node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/vue-router/node_modules/perfect-debounce": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", + "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==", + "license": "MIT" + }, + "node_modules/vue-router/node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/vue-toast-notification": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/vue-toast-notification/-/vue-toast-notification-3.1.3.tgz", + "integrity": "sha512-XNyWqwLIGBFfX5G9sK+clq3N3IPlhDjzNdbZaXkEElcotPlWs0wWZailk1vqhdtLYT/93Y4FHAVuzyatLmPZRA==", + "license": "MIT", + "engines": { + "node": ">=12.15.0" + }, + "peerDependencies": { + "vue": "^3.0" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/w3c-xmlserializer/node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/webpack-virtual-modules": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", + "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", + "license": "MIT" + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/xml-name-validator": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", + "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/client-v3/package.json b/client-v3/package.json new file mode 100644 index 00000000..6b59178e --- /dev/null +++ b/client-v3/package.json @@ -0,0 +1,82 @@ +{ + "name": "client-v3", + "version": "0.29.1", + "description": "DigiScript front end (Vue 3)", + "author": "DreamTeamProd", + "private": true, + "type": "module", + "scripts": { + "build": "vite build", + "build:analyze": "vite build --mode analyze", + "lint": "npm run format && npm run lint:eslint", + "lint:eslint": "eslint 'src/**/*.{ts,vue}' --fix", + "ci-lint": "npm run format:check && npm run lint:eslint-check", + "lint:eslint-check": "eslint 'src/**/*.{ts,vue}'", + "format": "prettier --write 'src/**/*.{ts,vue,json,css,scss}'", + "format:check": "prettier --check 'src/**/*.{ts,vue,json,css,scss}'", + "typecheck": "tsc --noEmit -p tsconfig.json", + "test": "vitest", + "test:ui": "vitest --ui", + "test:run": "vitest run", + "test:coverage": "vitest run --coverage", + "dev": "vite" + }, + "engines": { + "npm": ">=11.0.0 <12", + "node": ">=24.0.0 <25" + }, + "dependencies": { + "@vuelidate/core": "^2.0.3", + "@vuelidate/validators": "^2.0.4", + "bootstrap": "^5.3.8", + "bootstrap-vue-next": "^0.45.3", + "bootswatch": "^5.3.8", + "contrast-color": "1.0.1", + "core-js": "^3.49.0", + "d3-hierarchy": "^3.1.2", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0", + "deep-object-diff": "1.1.9", + "dompurify": "^3.4.3", + "fuse.js": "^7.3.0", + "lodash": "^4.18.1", + "loglevel": "^1.9.2", + "marked": "^18.0.3", + "pinia": "^3.0.0", + "pinia-plugin-persistedstate": "^4.7.1", + "splitpanes": "^4.0.4", + "vue": "^3.5.0", + "vue-multiselect": "^3.5.0", + "vue-router": "^5.0.7", + "vue-toast-notification": "^3.1.3" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/lodash": "~4.17.24", + "@types/node": ">=22.12.0", + "@typescript-eslint/eslint-plugin": "^8.59.3", + "@typescript-eslint/parser": "^8.59.3", + "@vitejs/plugin-vue": "^6.0.6", + "@vitest/ui": "^4.1.6", + "@vue/test-utils": "^2.4.10", + "eslint": "^10.3.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-prettier": "^5.5.5", + "eslint-plugin-vue": "^10.9.1", + "globals": "^17.6.0", + "jiti": "^2.7.0", + "jsdom": "^29.1.1", + "prettier": "^3.8.3", + "sass": "1.99.0", + "typescript": "^6.0.3", + "typescript-eslint": "^8.59.3", + "vite": "^8.0.12", + "vitest": "^4.1.6", + "vue-eslint-parser": "^10.4.0" + }, + "browserslist": [ + "> 1%", + "last 2 versions", + "not dead" + ] +} diff --git a/client-v3/prettier.config.ts b/client-v3/prettier.config.ts new file mode 100644 index 00000000..51089839 --- /dev/null +++ b/client-v3/prettier.config.ts @@ -0,0 +1,35 @@ +import type { Config } from 'prettier'; + +const config: Config = { + printWidth: 100, + tabWidth: 2, + useTabs: false, + semi: true, + singleQuote: true, + quoteProps: 'as-needed', + trailingComma: 'es5', + bracketSpacing: true, + bracketSameLine: false, + arrowParens: 'always', + endOfLine: 'lf', + + vueIndentScriptAndStyle: false, + singleAttributePerLine: false, + + overrides: [ + { + files: '*.vue', + options: { + parser: 'vue', + }, + }, + { + files: ['*.json', '.prettierrc'], + options: { + printWidth: 80, + }, + }, + ], +}; + +export default config; diff --git a/client-v3/src/App.vue b/client-v3/src/App.vue new file mode 100644 index 00000000..a856bde9 --- /dev/null +++ b/client-v3/src/App.vue @@ -0,0 +1,13 @@ + + + diff --git a/client-v3/src/assets/styles/dark.scss b/client-v3/src/assets/styles/dark.scss new file mode 100644 index 00000000..f9ceeb1e --- /dev/null +++ b/client-v3/src/assets/styles/dark.scss @@ -0,0 +1,3 @@ +@import '../../../node_modules/bootswatch/dist/darkly/variables'; +@import 'bootstrap/scss/bootstrap'; +@import '../../../node_modules/bootswatch/dist/darkly/bootswatch'; diff --git a/client-v3/src/main.ts b/client-v3/src/main.ts new file mode 100644 index 00000000..bd6e6d2e --- /dev/null +++ b/client-v3/src/main.ts @@ -0,0 +1,17 @@ +import { createApp } from 'vue'; +import { createPinia } from 'pinia'; +import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'; + +import App from './App.vue'; +import router from './router'; +import './assets/styles/dark.scss'; + +const app = createApp(App); + +const pinia = createPinia(); +pinia.use(piniaPluginPersistedstate); + +app.use(pinia); +app.use(router); + +app.mount('#app'); diff --git a/client-v3/src/router/index.ts b/client-v3/src/router/index.ts new file mode 100644 index 00000000..9daaedf6 --- /dev/null +++ b/client-v3/src/router/index.ts @@ -0,0 +1,19 @@ +import { createRouter, createWebHistory } from 'vue-router'; +import HomeView from '@/views/HomeView.vue'; + +const router = createRouter({ + history: createWebHistory('/ui-new/'), + routes: [ + { + path: '/', + name: 'home', + component: HomeView, + }, + { + path: '/:pathMatch(.*)*', + redirect: '/', + }, + ], +}); + +export default router; diff --git a/client-v3/src/shims-vue.d.ts b/client-v3/src/shims-vue.d.ts new file mode 100644 index 00000000..d1f31331 --- /dev/null +++ b/client-v3/src/shims-vue.d.ts @@ -0,0 +1,5 @@ +declare module '*.vue' { + import type { DefineComponent } from 'vue'; + const component: DefineComponent; + export default component; +} diff --git a/client-v3/src/views/HomeView.vue b/client-v3/src/views/HomeView.vue new file mode 100644 index 00000000..6f4002a6 --- /dev/null +++ b/client-v3/src/views/HomeView.vue @@ -0,0 +1,24 @@ + + + diff --git a/client-v3/tsconfig.json b/client-v3/tsconfig.json new file mode 100644 index 00000000..e16e8c1d --- /dev/null +++ b/client-v3/tsconfig.json @@ -0,0 +1,35 @@ +{ + "compilerOptions": { + // ── Compilation target ────────────────────────────────────────────────── + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ESNext", "DOM", "DOM.Iterable"], + + // ── Strictness ────────────────────────────────────────────────────────── + // Full strict mode is enabled from the start — Vue 3 Composition API + // avoids (this as any) patterns, making strict mode straightforward. + "strict": true, + + // ── Code quality ──────────────────────────────────────────────────────── + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "forceConsistentCasingInFileNames": true, + + // ── Interop & infrastructure ───────────────────────────────────────────── + "skipLibCheck": true, + "jsx": "preserve", + "paths": { + "@/*": ["./src/*"] + }, + "types": ["vitest/globals", "node", "vite/client"], + "isolatedModules": true, + "esModuleInterop": true, + "resolveJsonModule": true, + // Vue 3 requires defineField semantics (opposite of Vue 2). + "useDefineForClassFields": true, + "noEmit": true + }, + "include": ["src/**/*.ts", "src/**/*.vue"], + "exclude": ["node_modules", "dist", "dist-electron", "src/**/*.test.ts"] +} diff --git a/client-v3/vite.config.ts b/client-v3/vite.config.ts new file mode 100644 index 00000000..71a7ed2a --- /dev/null +++ b/client-v3/vite.config.ts @@ -0,0 +1,82 @@ +import path from 'path'; +import { defineConfig } from 'vite'; +import vue from '@vitejs/plugin-vue'; + +export default defineConfig({ + plugins: [vue()], + base: process.env.BUILD_TARGET === 'electron' ? './' : '/ui-new/', + build: { + outDir: + process.env.BUILD_TARGET === 'electron' ? './dist-electron' : '../server/static/ui-new/', + assetsDir: './assets', + emptyOutDir: true, + rollupOptions: { + output: { + manualChunks(id) { + if (id.includes('node_modules') && !id.includes('?commonjs-entry')) { + if (id.includes('bootstrap-vue-next') || id.includes('/bootstrap/')) { + return 'bootstrap-vendor'; + } + if ( + id.includes('/vue/') || + id.includes('vue-router') || + id.includes('/pinia/') || + id.includes('pinia-plugin-persistedstate') + ) { + return 'vue-vendor'; + } + if ( + id.includes('/lodash/') || + id.includes('loglevel') || + id.includes('deep-object-diff') || + id.includes('contrast-color') + ) { + return 'utils-vendor'; + } + if (id.includes('marked') || id.includes('dompurify') || id.includes('fuse.js')) { + return 'docs-vendor'; + } + return 'vendor'; + } + }, + chunkFileNames: 'assets/[name]-[hash].js', + entryFileNames: 'assets/[name]-[hash].js', + assetFileNames: 'assets/[name]-[hash].[ext]', + }, + }, + }, + resolve: { + alias: [ + { + find: '@', + replacement: path.resolve(__dirname, 'src'), + }, + ], + extensions: ['.ts', '.js', '.vue', '.json'], + }, + css: { + preprocessorOptions: { + scss: { + api: 'modern-compiler', + loadPaths: [path.resolve(__dirname, 'node_modules')], + // Bootstrap 5 and Bootswatch still use legacy Sass @import syntax; + // suppress those third-party deprecation warnings. + quietDeps: true, + silenceDeprecations: ['import', 'global-builtin', 'color-functions'], + }, + }, + devSourcemap: true, + }, + server: { + proxy: { + '/api': { + target: 'http://localhost:8080', + changeOrigin: true, + }, + '/api/v1/ws': { + target: 'ws://localhost:8080', + ws: true, + }, + }, + }, +}); diff --git a/client-v3/vitest.config.ts b/client-v3/vitest.config.ts new file mode 100644 index 00000000..bb075002 --- /dev/null +++ b/client-v3/vitest.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from 'vitest/config'; +import vue from '@vitejs/plugin-vue'; +import path from 'path'; + +export default defineConfig({ + plugins: [vue()], + test: { + globals: true, + environment: 'jsdom', + passWithNoTests: true, + reporters: ['default', 'junit'], + outputFile: { + junit: './junit/test-results.xml', + }, + isolate: true, + pool: 'threads', + css: false, + clearMocks: true, + }, + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, +}); diff --git a/server/controllers/controllers.py b/server/controllers/controllers.py index cb29d99f..8a8fed4a 100644 --- a/server/controllers/controllers.py +++ b/server/controllers/controllers.py @@ -46,6 +46,23 @@ def get(self, _path): raise HTTPError(500) from e +class RootControllerV3(BaseController): + def get(self, _path): + if is_frozen(): + full_path = get_resource_path( + os.path.join("static", "ui-new", "index.html") + ) + else: + file_path = os.path.join( + os.path.abspath(os.path.dirname(__file__)), "..", "static", "ui-new" + ) + full_path = os.path.join(file_path, "index.html") + if not os.path.isfile(full_path): + raise HTTPError(404) + with open(full_path, "r", encoding="utf-8") as file: + self.write(file.read()) + + class StaticController(BaseController): def get(self): self.set_header("Content-Type", "") diff --git a/server/digi_server/app_server.py b/server/digi_server/app_server.py index 6e6a9c54..a63d407e 100644 --- a/server/digi_server/app_server.py +++ b/server/digi_server/app_server.py @@ -242,6 +242,9 @@ class AlembicVersion(self._db.Model): if is_frozen(): static_files_path = get_resource_path(os.path.join("static", "assets")) docs_files_path = get_resource_path(os.path.join("static", "docs")) + ui_new_static_files_path = get_resource_path( + os.path.join("static", "ui-new", "assets") + ) get_logger().info(f"Using packaged static files path: {static_files_path}") get_logger().info(f"Using packaged docs files path: {docs_files_path}") else: @@ -251,6 +254,13 @@ class AlembicVersion(self._db.Model): docs_files_path = os.path.join( os.path.abspath(os.path.dirname(__file__)), "..", "static", "docs" ) + ui_new_static_files_path = os.path.join( + os.path.abspath(os.path.dirname(__file__)), + "..", + "static", + "ui-new", + "assets", + ) get_logger().info(f"Using relative static files path: {static_files_path}") get_logger().info(f"Using relative docs files path: {docs_files_path}") @@ -259,8 +269,16 @@ class AlembicVersion(self._db.Model): handlers.append( (r"/assets/(.*)", StaticFileHandler, {"path": static_files_path}) ) + handlers.append( + ( + r"/ui-new/assets/(.*)", + StaticFileHandler, + {"path": ui_new_static_files_path}, + ) + ) handlers.append((r"/docs/(.*)", StaticFileHandler, {"path": docs_files_path})) handlers.append((r"/api/.*", controllers.ApiFallback)) + handlers.append((r"/ui-new(/?.*)", controllers.RootControllerV3)) handlers.append((r"/(.*)", controllers.RootController)) super().__init__( handlers=handlers, From 771f0fda4d0b516878ac8166731c28377b27d53f Mon Sep 17 00:00:00 2001 From: Tim Bradgate Date: Thu, 14 May 2026 15:09:22 +0100 Subject: [PATCH 02/23] =?UTF-8?q?Vue=203=20migration:=20Phase=201=20?= =?UTF-8?q?=E2=80=94=20core=20infrastructure=20(#1033)=20(#1037)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add Vue 3 core infrastructure (Phase 1) — issue #1033 Pinia stores (user/auth, system/RBAC, websocket), useWebSocket() composable, HTTP interceptor, full router with beforeEach guard, platform/utils/logger ports, API types, constants, full BVN navbar App.vue, and stub views. Co-Authored-By: Claude Sonnet 4.6 * Fix BVN component rendering and visual parity with Vue 2 - Switch to BApp wrapper + unplugin-vue-components/BootstrapVueNextResolver for automatic per-component tree-shaken imports (no global plugin needed) - Add bootstrap-vue-next/dist/bootstrap-vue-next.css import in main.ts - Add data-bs-theme="dark" to BNavbar so text renders white on info background (Bootstrap 5 equivalent of Bootstrap 4's type="dark") - Add components.d.ts to tsconfig includes for GlobalComponents type augmentation - Gitignore components.d.ts (auto-generated by unplugin-vue-components on build) - Fix NotFoundView copy and centering to match Vue 2 404View exactly Co-Authored-By: Claude Sonnet 4.6 * Fix npm ci lockfile sync for @emnapi/core and @emnapi/runtime These are transitive deps of @rolldown/binding-wasm32-wasi (cpu: wasm32). npm doesn't install the wasm32 binding on macOS arm64, so their lockfile entries were missing. Added as optionalDependencies to force inclusion. Co-Authored-By: Claude Sonnet 4.6 * Fix lockfile: add @emnapi/core and @emnapi/runtime as explicit devDeps These are transitive deps of @rolldown/binding-wasm32-wasi (cpu: wasm32). npm skips that binding on macOS arm64 so its deps never get lockfile entries, causing npm ci to fail on Linux CI. Adding them explicitly to devDependencies forces npm to resolve and record their entries in the lockfile on all platforms. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- client-v3/.gitignore | 3 +- client-v3/package-lock.json | 70 +++- client-v3/package.json | 3 + client-v3/src/App.vue | 353 +++++++++++++++++- client-v3/src/composables/useWebSocket.ts | 211 +++++++++++ client-v3/src/constants/lineTypes.ts | 17 + client-v3/src/constants/textAlignment.ts | 16 + client-v3/src/js/customValidators.ts | 3 + client-v3/src/js/http-interceptor.ts | 97 +++++ client-v3/src/js/logger.ts | 128 +++++++ client-v3/src/js/platform/browser.ts | 31 ++ client-v3/src/js/platform/electron.ts | 70 ++++ client-v3/src/js/platform/index.ts | 15 + client-v3/src/js/utils.ts | 60 +++ client-v3/src/main.ts | 6 + client-v3/src/router/index.ts | 246 +++++++++++- client-v3/src/stores/system.ts | 184 +++++++++ client-v3/src/stores/user.ts | 280 ++++++++++++++ client-v3/src/stores/websocket.ts | 42 +++ client-v3/src/types/api/backup.ts | 11 + client-v3/src/types/api/cues.ts | 13 + client-v3/src/types/api/microphones.ts | 12 + client-v3/src/types/api/script.ts | 58 +++ client-v3/src/types/api/session.ts | 28 ++ client-v3/src/types/api/settings.ts | 11 + client-v3/src/types/api/show.ts | 54 +++ client-v3/src/types/api/stage.ts | 57 +++ client-v3/src/types/api/user.ts | 25 ++ client-v3/src/types/api/websocket.ts | 5 + client-v3/src/types/index.ts | 9 + client-v3/src/views/NotFoundView.vue | 16 + client-v3/src/views/PlaceholderView.vue | 6 + .../src/views/electron/ServerSelector.vue | 6 + client-v3/src/views/user/LoginView.vue | 6 + client-v3/tsconfig.json | 2 +- client-v3/vite.config.ts | 9 +- 36 files changed, 2149 insertions(+), 14 deletions(-) create mode 100644 client-v3/src/composables/useWebSocket.ts create mode 100644 client-v3/src/constants/lineTypes.ts create mode 100644 client-v3/src/constants/textAlignment.ts create mode 100644 client-v3/src/js/customValidators.ts create mode 100644 client-v3/src/js/http-interceptor.ts create mode 100644 client-v3/src/js/logger.ts create mode 100644 client-v3/src/js/platform/browser.ts create mode 100644 client-v3/src/js/platform/electron.ts create mode 100644 client-v3/src/js/platform/index.ts create mode 100644 client-v3/src/js/utils.ts create mode 100644 client-v3/src/stores/system.ts create mode 100644 client-v3/src/stores/user.ts create mode 100644 client-v3/src/stores/websocket.ts create mode 100644 client-v3/src/types/api/backup.ts create mode 100644 client-v3/src/types/api/cues.ts create mode 100644 client-v3/src/types/api/microphones.ts create mode 100644 client-v3/src/types/api/script.ts create mode 100644 client-v3/src/types/api/session.ts create mode 100644 client-v3/src/types/api/settings.ts create mode 100644 client-v3/src/types/api/show.ts create mode 100644 client-v3/src/types/api/stage.ts create mode 100644 client-v3/src/types/api/user.ts create mode 100644 client-v3/src/types/api/websocket.ts create mode 100644 client-v3/src/types/index.ts create mode 100644 client-v3/src/views/NotFoundView.vue create mode 100644 client-v3/src/views/PlaceholderView.vue create mode 100644 client-v3/src/views/electron/ServerSelector.vue create mode 100644 client-v3/src/views/user/LoginView.vue diff --git a/client-v3/.gitignore b/client-v3/.gitignore index 45ea32af..a751eb35 100644 --- a/client-v3/.gitignore +++ b/client-v3/.gitignore @@ -3,4 +3,5 @@ dist/ dist-electron/ junit/ coverage/ -*.backup \ No newline at end of file +*.backup +components.d.ts \ No newline at end of file diff --git a/client-v3/package-lock.json b/client-v3/package-lock.json index 7c597c62..5a8aaa82 100644 --- a/client-v3/package-lock.json +++ b/client-v3/package-lock.json @@ -33,6 +33,8 @@ "vue-toast-notification": "^3.1.3" }, "devDependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", "@eslint/js": "^10.0.1", "@types/lodash": "~4.17.24", "@types/node": ">=22.12.0", @@ -52,6 +54,7 @@ "sass": "1.99.0", "typescript": "^6.0.3", "typescript-eslint": "^8.59.3", + "unplugin-vue-components": "^32.0.0", "vite": "^8.0.12", "vitest": "^4.1.6", "vue-eslint-parser": "^10.4.0" @@ -382,7 +385,6 @@ "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", "dev": true, "license": "MIT", - "optional": true, "peer": true, "dependencies": { "@emnapi/wasi-threads": "1.2.1", @@ -395,7 +397,6 @@ "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", "dev": true, "license": "MIT", - "optional": true, "peer": true, "dependencies": { "tslib": "^2.4.0" @@ -407,7 +408,6 @@ "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", "dev": true, "license": "MIT", - "optional": true, "dependencies": { "tslib": "^2.4.0" } @@ -2079,6 +2079,7 @@ "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.34.tgz", "integrity": "sha512-nHxmJoTrKsmrkbILRhkC9gY1G3moZbJTqCzDd7DOOzG5KH9oeJ0Unqrff5f9v0pW//jES05ZkJcNtfE8JjOIew==", "license": "MIT", + "peer": true, "dependencies": { "@vue/compiler-ssr": "3.5.34", "@vue/shared": "3.5.34" @@ -5232,6 +5233,69 @@ "url": "https://github.com/sponsors/sxzz" } }, + "node_modules/unplugin-vue-components": { + "version": "32.0.0", + "resolved": "https://registry.npmjs.org/unplugin-vue-components/-/unplugin-vue-components-32.0.0.tgz", + "integrity": "sha512-uLdccgS7mf3pv1bCCP20y/hm+u1eOjAmygVkh+Oa70MPkzgl1eQv1L0CwdHNM3gscO8/GDMGIET98Ja47CBbZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^5.0.0", + "local-pkg": "^1.1.2", + "magic-string": "^0.30.21", + "mlly": "^1.8.2", + "obug": "^2.1.1", + "picomatch": "^4.0.3", + "tinyglobby": "^0.2.15", + "unplugin": "^3.0.0", + "unplugin-utils": "^0.3.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@nuxt/kit": "^3.2.2 || ^4.0.0", + "vue": "^3.0.0" + }, + "peerDependenciesMeta": { + "@nuxt/kit": { + "optional": true + } + } + }, + "node_modules/unplugin-vue-components/node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/unplugin-vue-components/node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", diff --git a/client-v3/package.json b/client-v3/package.json index 6b59178e..58d9e380 100644 --- a/client-v3/package.json +++ b/client-v3/package.json @@ -51,6 +51,8 @@ "vue-toast-notification": "^3.1.3" }, "devDependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", "@eslint/js": "^10.0.1", "@types/lodash": "~4.17.24", "@types/node": ">=22.12.0", @@ -70,6 +72,7 @@ "sass": "1.99.0", "typescript": "^6.0.3", "typescript-eslint": "^8.59.3", + "unplugin-vue-components": "^32.0.0", "vite": "^8.0.12", "vitest": "^4.1.6", "vue-eslint-parser": "^10.4.0" diff --git a/client-v3/src/App.vue b/client-v3/src/App.vue index a856bde9..4a046c07 100644 --- a/client-v3/src/App.vue +++ b/client-v3/src/App.vue @@ -1,13 +1,352 @@ - + + + + diff --git a/client-v3/src/composables/useWebSocket.ts b/client-v3/src/composables/useWebSocket.ts new file mode 100644 index 00000000..cf7b7047 --- /dev/null +++ b/client-v3/src/composables/useWebSocket.ts @@ -0,0 +1,211 @@ +import log from 'loglevel'; +import { debounce } from 'lodash'; +import { useWebSocketStore } from '@/stores/websocket'; +import { useSystemStore } from '@/stores/system'; +import { useUserStore } from '@/stores/user'; +import { getWebSocketURL } from '@/js/platform'; +import type { WsMessage } from '@/types/api/websocket'; + +const INITIAL_RECONNECT_DELAY_MS = 1000; +const MAX_RECONNECT_DELAY_MS = 30000; + +let ws: WebSocket | null = null; +let reconnectTimer: ReturnType | null = null; +let errorCount = 0; + +function getReconnectDelay(): number { + return Math.min(INITIAL_RECONNECT_DELAY_MS * 2 ** errorCount, MAX_RECONNECT_DELAY_MS); +} + +function sendObj(data: object): void { + if (ws?.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify(data)); + } else { + log.warn('Attempted to send WS message but socket is not open'); + } +} + +const settingsChangedToast = debounce( + async () => { + const { useToast } = await import('vue-toast-notification'); + useToast().info('Settings synced from server'); + }, + 1000, + { leading: true, trailing: false } +); + +async function handleMessage(msg: WsMessage): Promise { + const wsStore = useWebSocketStore(); + const systemStore = useSystemStore(); + const userStore = useUserStore(); + const { default: router } = await import('@/router'); + + switch (msg.OP) { + case 'SET_UUID': { + const newUUID = msg.DATA as unknown as string; + if (wsStore.internalUUID != null) { + log.debug('Reconnecting with existing UUID:', wsStore.internalUUID); + sendObj({ OP: 'REFRESH_CLIENT', DATA: wsStore.internalUUID }); + } else { + log.debug('New connection, received UUID:', newUUID); + wsStore.$patch({ internalUUID: newUUID }); + } + wsStore.$patch({ pendingAuthentication: true }); + // Authenticate immediately if we have a token + const token = userStore.authToken; + if (token) { + sendObj({ OP: 'AUTHENTICATE', DATA: { token } }); + } + break; + } + case 'WS_AUTH_SUCCESS': + wsStore.$patch({ authenticated: true, authSucceeded: true, pendingAuthentication: false }); + errorCount = 0; + log.info('WebSocket authenticated successfully'); + // Announce as new client if applicable + sendObj({ OP: 'NEW_CLIENT', DATA: {} }); + break; + case 'WS_AUTH_ERROR': + wsStore.$patch({ authenticated: false, pendingAuthentication: false }); + log.error('WebSocket authentication error:', msg.DATA); + break; + case 'WS_TOKEN_REFRESH_SUCCESS': + log.info('WebSocket token refreshed successfully'); + break; + case 'SETTINGS_CHANGED': + await systemStore.updateSettings( + msg.DATA as Parameters[0] + ); + settingsChangedToast(); + break; + case 'START_SHOW': + if (router.currentRoute.value.path !== '/ui-new/live') { + router.push('/ui-new/live'); + } + break; + case 'STOP_SHOW': + if (router.currentRoute.value.path !== '/ui-new/') { + router.push('/ui-new/'); + } + break; + case 'RELOAD_CLIENT': + window.location.reload(); + break; + default: + log.warn(`Unknown OP received from WebSocket: ${msg.OP}`); + } + + // Dispatch named Pinia action if ACTION key is present + if (msg.ACTION) { + await dispatchAction(msg.ACTION, msg.DATA); + } +} + +async function dispatchAction(action: string, data: Record): Promise { + const userStore = useUserStore(); + const systemStore = useSystemStore(); + const wsStore = useWebSocketStore(); + + const actionMap: Record) => Promise> = { + TOKEN_REFRESH: async (d) => { + const payload = d as { DATA: { access_token: string } }; + await userStore.tokenRefreshFromServer(payload.DATA.access_token); + }, + SHOW_CHANGED: async () => { + if (userStore.currentUser != null) { + await userStore.getCurrentUser(); + await userStore.getCurrentRbac(); + } + window.location.reload(); + }, + GET_CAST_LIST: async () => { + /* handled in show store — Phase 6 */ + }, + }; + + const handler = actionMap[action]; + if (handler) { + await handler(data); + } else { + log.debug(`No handler for WS action: ${action}`); + } + + // Suppress unused variable warnings + void systemStore; + void wsStore; +} + +function connect(): void { + const wsStore = useWebSocketStore(); + + if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) { + return; + } + + let wsURL: string; + try { + wsURL = getWebSocketURL(); + } catch (e) { + log.error('Cannot determine WebSocket URL:', e); + return; + } + + log.debug('Connecting to WebSocket:', wsURL); + ws = new WebSocket(wsURL); + + ws.onopen = () => { + wsStore.$patch({ isConnected: true }); + if (errorCount > 0) { + import('vue-toast-notification').then(({ useToast }) => { + useToast().success( + `WebSocket reconnected after ${errorCount} attempt${errorCount > 1 ? 's' : ''}` + ); + }); + } + log.info('WebSocket connected'); + }; + + ws.onmessage = (event: MessageEvent) => { + try { + const msg: WsMessage = JSON.parse(event.data as string); + handleMessage(msg).catch((err) => log.error('Error handling WS message:', err)); + } catch (e) { + log.error('Failed to parse WS message:', e); + } + }; + + ws.onclose = () => { + wsStore.$patch({ isConnected: false, authenticated: false }); + log.info('WebSocket closed, scheduling reconnect'); + scheduleReconnect(); + }; + + ws.onerror = () => { + log.error('WebSocket error'); + errorCount++; + if (errorCount === 1) { + import('vue-toast-notification').then(({ useToast }) => { + useToast().error('WebSocket connection lost'); + }); + } + }; +} + +function scheduleReconnect(): void { + if (reconnectTimer) return; + const delay = getReconnectDelay(); + log.debug(`Reconnecting WebSocket in ${delay}ms`); + reconnectTimer = setTimeout(() => { + reconnectTimer = null; + connect(); + }, delay); +} + +export function useWebSocket() { + const wsStore = useWebSocketStore(); + + // Register the send function in the store so other stores can call it + wsStore.registerSend(sendObj); + + return { sendObj, connect }; +} diff --git a/client-v3/src/constants/lineTypes.ts b/client-v3/src/constants/lineTypes.ts new file mode 100644 index 00000000..daf113fe --- /dev/null +++ b/client-v3/src/constants/lineTypes.ts @@ -0,0 +1,17 @@ +/** + * Script Line Type Constants + * + * These constants match the ScriptLineType enum values from the backend + * (models/script.py). Use these instead of magic numbers throughout the codebase. + */ + +export const LINE_TYPES = { + DIALOGUE: 1, + STAGE_DIRECTION: 2, + CUE_LINE: 3, + SPACING: 4, +} as const; + +export type LineType = (typeof LINE_TYPES)[keyof typeof LINE_TYPES]; + +export default LINE_TYPES; diff --git a/client-v3/src/constants/textAlignment.ts b/client-v3/src/constants/textAlignment.ts new file mode 100644 index 00000000..7fab4f3d --- /dev/null +++ b/client-v3/src/constants/textAlignment.ts @@ -0,0 +1,16 @@ +/** + * Text alignment enum constants matching backend TextAlignment IntEnum + */ +export const TEXT_ALIGNMENT = { + LEFT: 1, + CENTER: 2, + RIGHT: 3, +} as const; + +export type TextAlignment = (typeof TEXT_ALIGNMENT)[keyof typeof TEXT_ALIGNMENT]; + +export const TEXT_ALIGNMENT_CSS: Record = { + [TEXT_ALIGNMENT.LEFT]: 'left', + [TEXT_ALIGNMENT.CENTER]: 'center', + [TEXT_ALIGNMENT.RIGHT]: 'right', +}; diff --git a/client-v3/src/js/customValidators.ts b/client-v3/src/js/customValidators.ts new file mode 100644 index 00000000..a30a944c --- /dev/null +++ b/client-v3/src/js/customValidators.ts @@ -0,0 +1,3 @@ +export const notNull = (value: unknown): boolean => value != null; +export const notNullAndGreaterThanZero = (value: unknown): boolean => + value != null && (value as number) > 0; diff --git a/client-v3/src/js/http-interceptor.ts b/client-v3/src/js/http-interceptor.ts new file mode 100644 index 00000000..63d5233a --- /dev/null +++ b/client-v3/src/js/http-interceptor.ts @@ -0,0 +1,97 @@ +import log from 'loglevel'; +import { makeURL } from '@/js/utils'; + +export default function setupHttpInterceptor(): void { + const originalFetch = window.fetch; + + let isRefreshingToken = false; + + window.fetch = async (resource, options = {}) => { + if (typeof resource === 'string' && resource.startsWith(makeURL('/api/'))) { + // Import store inside the override function — Pinia context isn't active at module load time + const { useUserStore } = await import('@/stores/user'); + const { useToast } = await import('vue-toast-notification'); + const userStore = useUserStore(); + + const token = userStore.authToken; + const isLogoutRequest = resource.endsWith('/api/v1/auth/logout'); + const isRefreshRequest = resource.endsWith('/api/v1/auth/refresh-token'); + + const newOptions = { + ...options, + headers: { + ...options.headers, + } as Record, + }; + + if (token && !Object.keys(newOptions.headers).includes('Authorization')) { + newOptions.headers = { ...newOptions.headers, Authorization: `Bearer ${token}` }; + } + + if ( + (!options.headers || !(options.headers as Record)['Content-Type']) && + (options.method === 'POST' || options.method === 'PUT') + ) { + newOptions.headers['Content-Type'] = 'application/json'; + } + + try { + const response = await originalFetch(resource, newOptions); + + if (response.status === 401 && !isLogoutRequest) { + log.warn('Received 401 Unauthorized response'); + + if (isRefreshRequest || isRefreshingToken) { + log.warn('Token refresh failed with 401 or already refreshing, logging out'); + useToast().warning('Your session has expired. Please log in again.'); + await userStore.logout(); + return response; + } + + log.info('Attempting token refresh'); + if (token) { + try { + isRefreshingToken = true; + const refreshSuccess = await userStore.refreshToken(); + isRefreshingToken = false; + + if (refreshSuccess) { + log.info('Token refresh successful, retrying original request'); + const retriedOptions = { + ...newOptions, + headers: { + ...newOptions.headers, + Authorization: `Bearer ${userStore.authToken}`, + }, + }; + return await originalFetch(resource, retriedOptions); + } + + log.warn('Token refresh failed, logging out'); + useToast().warning('Your session has expired. Please log in again.'); + await userStore.logout(); + return response; + } catch (refreshError) { + isRefreshingToken = false; + log.error('Error during token refresh:', refreshError); + useToast().error('Authentication error - please log in again'); + await userStore.logout(); + return response; + } + } else { + log.warn('401 received with no token present'); + await userStore.logout(); + return response; + } + } + + return response; + } catch (error) { + log.error('Fetch error:', error); + throw error; + } + } + + return originalFetch(resource, options); + }; +} diff --git a/client-v3/src/js/logger.ts b/client-v3/src/js/logger.ts new file mode 100644 index 00000000..e22e6248 --- /dev/null +++ b/client-v3/src/js/logger.ts @@ -0,0 +1,128 @@ +import log from 'loglevel'; +import { makeURL } from '@/js/utils'; +import { useUserStore } from '@/stores/user'; +import { useSystemStore } from '@/stores/system'; + +let isInitialized = false; + +const FLUSH_INTERVAL_MS = 1000; +const MAX_QUEUE_SIZE = 50; + +interface LogEntry { + level: string; + message: string; + extra: Record; +} + +const logQueue: LogEntry[] = []; +let flushTimer: ReturnType | null = null; + +function enqueueLog(level: string, message: string, extra: Record): void { + logQueue.push({ level, message, extra }); + if (logQueue.length >= MAX_QUEUE_SIZE) { + flushQueue(); + return; + } + clearTimeout(flushTimer ?? undefined); + flushTimer = setTimeout(flushQueue, FLUSH_INTERVAL_MS); +} + +// Uses fetch directly (not the intercepted version) to avoid infinite logging loops. +function flushQueue() { + clearTimeout(flushTimer ?? undefined); + flushTimer = null; + if (logQueue.length === 0) return; + + const batch = logQueue.splice(0); + + const token = useUserStore().authToken; + const headers: Record = { 'Content-Type': 'application/json' }; + if (token) headers['Authorization'] = `Bearer ${token}`; + + fetch(makeURL('/api/v1/logs/batch'), { + method: 'POST', + headers, + body: JSON.stringify({ batch: batch }), + }).catch(() => { + // Intentionally ignore errors to prevent log flooding or infinite loops + }); +} + +export function initRemoteLogging(): void { + if (isInitialized) return; + isInitialized = true; + + const originalFactory = log.methodFactory; + + const levels: Record = { + TRACE: 0, + DEBUG: 1, + INFO: 2, + WARN: 3, + ERROR: 4, + SILENT: 5, + }; + + log.methodFactory = function (methodName, logLevel, loggerName) { + const rawMethod = originalFactory(methodName, logLevel, loggerName); + + return function (message, ...args) { + rawMethod(message, ...args); + + const systemStore = useSystemStore(); + const settings = systemStore.settings; + if (!settings || !settings.client_log_enabled) return; + + const currentLevelName = methodName.toUpperCase(); + const currentLevel = levels[currentLevelName] ?? 2; + const minLevel = levels[((settings.client_log_level as string) || 'INFO').toUpperCase()] ?? 2; + if (currentLevel < minLevel) return; + + let finalMessage = message; + if (typeof message !== 'string') { + try { + finalMessage = JSON.stringify(message); + } catch { + finalMessage = String(message); + } + } + + const extra: Record = {}; + if (args.length > 0) { + extra.args = args.map((arg) => + arg instanceof Error ? { message: arg.message, stack: arg.stack, name: arg.name } : arg + ); + } + + enqueueLog(currentLevelName, finalMessage, extra); + }; + }; + + log.setLevel(log.getLevel()); + + // Sync browser console level with user's per-account preference. + // Watched via a simple interval rather than a reactive watcher (Pinia watch requires + // component context or explicit setup; this avoids that complexity). + setInterval(() => { + const userStore = useUserStore(); + const consoleLevel = (userStore.userSettings as Record)?.console_log_level as + | string + | undefined; + if (consoleLevel) log.setLevel(consoleLevel.toLowerCase() as log.LogLevelDesc, false); + }, 5000); + + window.addEventListener('error', (event) => { + enqueueLog('ERROR', `Unhandled Error: ${event.message}`, { + filename: event.filename, + lineno: event.lineno, + colno: event.colno, + stack: event.error ? event.error.stack : null, + }); + }); + + window.addEventListener('unhandledrejection', (event) => { + enqueueLog('ERROR', `Unhandled Promise Rejection: ${event.reason}`, {}); + }); + + enqueueLog('INFO', 'Remote logging initialized', {}); +} diff --git a/client-v3/src/js/platform/browser.ts b/client-v3/src/js/platform/browser.ts new file mode 100644 index 00000000..1ac2a2ea --- /dev/null +++ b/client-v3/src/js/platform/browser.ts @@ -0,0 +1,31 @@ +/** + * Browser Platform Implementation + * + * Provides URL resolution and storage for browser environments. + * Uses window.location for URL construction (existing behavior). + */ + +/** + * Get the base server URL from the current browser location + * @returns {string} Base URL (e.g., "http://localhost:8080") + */ +export function baseURL(): string { + return `${window.location.protocol}//${window.location.hostname}:${window.location.port}`; +} + +export function makeURL(path: string): string { + return `${baseURL()}${path}`; +} + +export function getVersion(): string { + return import.meta.env.VITE_APP_VERSION || '0.23.0'; +} + +export function getStorageAdapter(type = 'local'): Storage { + return type === 'session' ? window.sessionStorage : window.localStorage; +} + +export function getWebSocketURL(): string { + const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'; + return `${protocol}://${window.location.hostname}:${window.location.port}/api/v1/ws`; +} diff --git a/client-v3/src/js/platform/electron.ts b/client-v3/src/js/platform/electron.ts new file mode 100644 index 00000000..0ea18f0f --- /dev/null +++ b/client-v3/src/js/platform/electron.ts @@ -0,0 +1,70 @@ +declare global { + interface Window { + electronAPI?: { + getServerURLSync?: () => string | null; + getAppVersion?: () => string; + getActiveConnection?: () => Promise; + storageGet?: (key: string) => string | null; + storageSet?: (key: string, value: string) => void; + storageDelete?: (key: string) => void; + storageClear?: () => void; + }; + } +} + +export function baseURL(): string { + if (!window.electronAPI) { + throw new Error( + 'Electron API not available. This should only be called in Electron environment.' + ); + } + + const serverURL = window.electronAPI.getServerURLSync?.() || null; + + if (!serverURL) { + throw new Error('No server URL configured. Please select a server in the connection manager.'); + } + + return serverURL; +} + +export function makeURL(path: string): string { + return `${baseURL()}${path}`; +} + +export function getVersion(): string { + if (window.electronAPI?.getAppVersion) { + return window.electronAPI.getAppVersion(); + } + return '0.23.0'; +} + +export function getStorageAdapter( + _type = 'local' +): Pick { + if (!window.electronAPI) { + throw new Error('Electron API not available'); + } + + return { + getItem(key: string): string | null { + return window.electronAPI!.storageGet?.(key) ?? null; + }, + setItem(key: string, value: string): void { + window.electronAPI!.storageSet?.(key, value); + }, + removeItem(key: string): void { + window.electronAPI!.storageDelete?.(key); + }, + clear(): void { + window.electronAPI!.storageClear?.(); + }, + }; +} + +export function getWebSocketURL(): string { + const base = baseURL(); + const url = new URL(base); + const protocol = url.protocol === 'https:' ? 'wss' : 'ws'; + return `${protocol}://${url.host}/api/v1/ws`; +} diff --git a/client-v3/src/js/platform/index.ts b/client-v3/src/js/platform/index.ts new file mode 100644 index 00000000..4598ade4 --- /dev/null +++ b/client-v3/src/js/platform/index.ts @@ -0,0 +1,15 @@ +function isElectron(): boolean { + return typeof window !== 'undefined' && window.electronAPI !== undefined; +} + +let platformModule; + +if (isElectron()) { + platformModule = await import('./electron'); +} else { + platformModule = await import('./browser'); +} + +export const { baseURL, makeURL, getVersion, getStorageAdapter, getWebSocketURL } = platformModule; + +export { isElectron }; diff --git a/client-v3/src/js/utils.ts b/client-v3/src/js/utils.ts new file mode 100644 index 00000000..522d7f83 --- /dev/null +++ b/client-v3/src/js/utils.ts @@ -0,0 +1,60 @@ +import { baseURL as platformBaseURL, makeURL as platformMakeURL } from '@/js/platform'; + +export function baseURL(): string { + return platformBaseURL(); +} + +export function makeURL(path: string): string { + return platformMakeURL(path); +} + +export function titleCase(str: string, sep = ' '): string { + const splitStr = str.toLowerCase().split(sep); + for (let i = 0; i < splitStr.length; i++) { + splitStr[i] = splitStr[i].charAt(0).toUpperCase() + splitStr[i].substring(1); + } + return splitStr.join(' '); +} + +export function randInt(min: number, max: number): number { + const minCeil = Math.ceil(min); + const maxFloor = Math.floor(max); + return Math.floor(Math.random() * (maxFloor - minCeil) + minCeil); +} + +export function msToTimerString(milliseconds: number): string { + // Adapted from https://stackoverflow.com/a/33909506 + const hours = milliseconds / (1000 * 60 * 60); + const absoluteHours = Math.floor(hours); + const h = absoluteHours > 9 ? absoluteHours : `0${absoluteHours}`; + const minutes = (hours - absoluteHours) * 60; + const absoluteMinutes = Math.floor(minutes); + const m = absoluteMinutes > 9 ? absoluteMinutes : `0${absoluteMinutes}`; + const seconds = (minutes - absoluteMinutes) * 60; + const absoluteSeconds = Math.floor(seconds); + const s = absoluteSeconds > 9 ? absoluteSeconds : `0${absoluteSeconds}`; + + return `${h}:${m}:${s}`; +} + +export function msToTimerParts(milliseconds: number): [number, number, number] { + // Adapted from https://stackoverflow.com/a/33909506 + const hours = milliseconds / (1000 * 60 * 60); + const absoluteHours = Math.floor(hours); + const minutes = (hours - absoluteHours) * 60; + const absoluteMinutes = Math.floor(minutes); + const seconds = (minutes - absoluteMinutes) * 60; + const absoluteSeconds = Math.floor(seconds); + return [absoluteHours, absoluteMinutes, absoluteSeconds]; +} + +export function formatTimerParts( + hours: number, + minutes: number, + seconds: number +): [string | number, string | number, string | number] { + const h = hours > 9 ? hours : `0${hours}`; + const m = minutes > 9 ? minutes : `0${minutes}`; + const s = seconds > 9 ? seconds : `0${seconds}`; + return [h, m, s]; +} diff --git a/client-v3/src/main.ts b/client-v3/src/main.ts index bd6e6d2e..ec5a47cf 100644 --- a/client-v3/src/main.ts +++ b/client-v3/src/main.ts @@ -4,7 +4,10 @@ import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'; import App from './App.vue'; import router from './router'; +import setupHttpInterceptor from './js/http-interceptor'; +import { initRemoteLogging } from './js/logger'; import './assets/styles/dark.scss'; +import 'bootstrap-vue-next/dist/bootstrap-vue-next.css'; const app = createApp(App); @@ -14,4 +17,7 @@ pinia.use(piniaPluginPersistedstate); app.use(pinia); app.use(router); +setupHttpInterceptor(); +initRemoteLogging(); + app.mount('#app'); diff --git a/client-v3/src/router/index.ts b/client-v3/src/router/index.ts index 9daaedf6..e4b2ede1 100644 --- a/client-v3/src/router/index.ts +++ b/client-v3/src/router/index.ts @@ -1,19 +1,263 @@ import { createRouter, createWebHistory } from 'vue-router'; +import { isElectron } from '@/js/platform'; import HomeView from '@/views/HomeView.vue'; +import NotFoundView from '@/views/NotFoundView.vue'; +import PlaceholderView from '@/views/PlaceholderView.vue'; const router = createRouter({ history: createWebHistory('/ui-new/'), routes: [ + { + path: '/electron/server-selector', + name: 'electron-server-selector', + component: () => import('@/views/electron/ServerSelector.vue'), + meta: { requiresAuth: false, isElectronOnly: true }, + }, { path: '/', name: 'home', component: HomeView, + meta: { requiresAuth: false }, + }, + { + path: '/about', + name: 'about', + component: PlaceholderView, + meta: { requiresAuth: false }, + }, + { + path: '/login', + name: 'login', + component: () => import('@/views/user/LoginView.vue'), + meta: { requiresAuth: false }, + }, + { + path: '/config', + name: 'config', + component: PlaceholderView, + meta: { requiresAuth: true, requiresAdmin: true }, + }, + { + path: '/show-config', + component: PlaceholderView, + meta: { requiresAuth: true, requiresShowAccess: true }, + children: [ + { + name: 'show-config', + path: '', + component: PlaceholderView, + meta: { requiresAuth: true, requiresShowAccess: true }, + }, + { + name: 'show-config-cast', + path: 'cast', + component: PlaceholderView, + meta: { requiresAuth: true, requiresShowAccess: true }, + }, + { + name: 'show-config-stage', + path: 'stage', + component: PlaceholderView, + meta: { requiresAuth: true, requiresShowAccess: true }, + }, + { + name: 'show-config-characters', + path: 'characters', + component: PlaceholderView, + meta: { requiresAuth: true, requiresShowAccess: true }, + }, + { + name: 'show-config-acts-scenes', + path: 'acts', + component: PlaceholderView, + meta: { requiresAuth: true, requiresShowAccess: true }, + }, + { + name: 'show-config-cues', + path: 'cues', + component: PlaceholderView, + meta: { requiresAuth: true, requiresShowAccess: true }, + }, + { + name: 'show-config-mics', + path: 'mics', + component: PlaceholderView, + meta: { requiresAuth: true, requiresShowAccess: true }, + }, + { + name: 'show-config-script', + path: 'script', + component: PlaceholderView, + meta: { requiresAuth: true, requiresShowAccess: true }, + }, + { + name: 'show-config-script-revisions', + path: 'script-revisions', + component: PlaceholderView, + meta: { requiresAuth: true, requiresShowAccess: true }, + }, + { + name: 'show-sessions', + path: 'sessions', + component: PlaceholderView, + meta: { requiresAuth: true, requiresShowAccess: true }, + }, + ], + }, + { + path: '/live', + name: 'live', + component: PlaceholderView, + meta: { requiresAuth: false }, + }, + { + path: '/me', + name: 'user-settings', + component: PlaceholderView, + meta: { requiresAuth: true }, + }, + { + path: '/force-password-change', + name: 'force-password-change', + component: PlaceholderView, + meta: { requiresAuth: true, requiresPasswordChange: true }, + }, + { + path: '/help', + component: PlaceholderView, + meta: { requiresAuth: false }, + children: [ + { path: '', redirect: 'getting-started' }, + { + name: 'help-doc', + path: ':slug(.*)', + component: PlaceholderView, + meta: { requiresAuth: false }, + }, + ], + }, + { + path: '/404', + name: '404', + component: NotFoundView, + meta: { requiresAuth: false }, }, { path: '/:pathMatch(.*)*', - redirect: '/', + redirect: '/404', }, ], }); +router.beforeEach(async (to) => { + const { useSystemStore } = await import('@/stores/system'); + const { useUserStore } = await import('@/stores/user'); + const { useToast } = await import('vue-toast-notification'); + + const systemStore = useSystemStore(); + const userStore = useUserStore(); + const toast = useToast(); + + // Electron: require active connection before any page except server-selector + if (isElectron() && to.path !== '/electron/server-selector') { + try { + const activeConnection = await window.electronAPI?.getActiveConnection?.(); + if (!activeConnection) { + toast.warning('Please select a server to connect to'); + return '/electron/server-selector'; + } + } catch { + return '/electron/server-selector'; + } + } + + // Electron-only pages are inaccessible in the browser + if (to.matched.some((r) => r.meta.isElectronOnly) && !isElectron()) { + toast.error('This page is only available in the desktop app'); + return '/'; + } + + if (to.path === '/electron/server-selector') return undefined; + + // Load RBAC roles on first navigation if not already loaded + if (systemStore.rbacRoles.length === 0) { + await systemStore.getRbacRoles(); + await systemStore.getSettings(); + await userStore.getCurrentUser(); + if (userStore.currentUser) { + await userStore.getCurrentRbac(); + } + } + + const requiresAuth = to.matched.some((r) => r.meta.requiresAuth); + const requiresAdmin = to.matched.some((r) => r.meta.requiresAdmin); + const requiresShowAccess = to.matched.some((r) => r.meta.requiresShowAccess); + + // If no admin user yet, send everyone to home (which shows the create-admin UI) + if ( + systemStore.settings && + (systemStore.settings as Record).has_admin_user === false + ) { + if (to.path !== '/') { + toast.error('Please create an admin user before continuing'); + return '/'; + } + return undefined; + } + + const currentUser = userStore.currentUser; + const isAuthenticated = currentUser !== null; + + // Already logged in — don't show login page + if (to.path === '/login' && isAuthenticated) { + toast.info('You are already logged in'); + return '/'; + } + + // Require auth + if (requiresAuth && !isAuthenticated) { + toast.error('Please log in to access this page'); + return '/login'; + } + + // Force password change + const requiresPasswordChange = currentUser?.requires_password_change === true; + const isPasswordChangePage = to.path === '/force-password-change'; + + if (isAuthenticated && requiresPasswordChange && !isPasswordChangePage) { + toast.warning('You must change your password before continuing'); + return '/force-password-change'; + } + + if (isPasswordChangePage && !requiresPasswordChange) { + return '/'; + } + + // Admin-only pages + if (requiresAdmin && !systemStore.isAdminUser) { + toast.error('Admin access required'); + return '/'; + } + + // Show access + if (requiresShowAccess) { + if (!systemStore.currentShow) { + toast.error('No show is currently selected'); + return '/'; + } + if (!systemStore.hasShowAccess) { + toast.error('You do not have permission to access show configuration'); + return '/'; + } + } + + // Live page requires an active show session + if (to.path === '/live') { + // Show session check added in Phase 6 when show store is available + // For now, allow navigation (the live page itself will handle the guard) + } + + return undefined; +}); + export default router; diff --git a/client-v3/src/stores/system.ts b/client-v3/src/stores/system.ts new file mode 100644 index 00000000..8251bf00 --- /dev/null +++ b/client-v3/src/stores/system.ts @@ -0,0 +1,184 @@ +import { defineStore } from 'pinia'; +import log from 'loglevel'; +import { makeURL } from '@/js/utils'; +import type { Show } from '@/types/api/show'; +import type { SystemSettings } from '@/types/api/settings'; +import { useUserStore } from '@/stores/user'; + +interface RbacRole { + key: string; + value: number; +} + +type UserRbac = Record | null; + +function getUserRbac(): UserRbac { + return useUserStore().currentRbac; +} + +function getRbacMask(roles: RbacRole[], key: string): number { + return roles.find((x) => x.key === key)?.value ?? 0; +} + +export const useSystemStore = defineStore('system', { + state: () => ({ + settings: {} as SystemSettings | Record, + availableShows: [] as Show[], + rawSettings: {} as Record, + rbacRoles: [] as RbacRole[], + settingsCategories: {} as Record, + currentShow: null as Show | null, + }), + getters: { + isAdminUser(): boolean { + return useUserStore().currentUser?.is_admin === true; + }, + isShowEditor(): boolean { + if (this.isAdminUser) return true; + if (this.rbacRoles.length === 0) return false; + const userRbac = getUserRbac(); + if (!userRbac?.shows) return false; + return (userRbac.shows[0][1] & getRbacMask(this.rbacRoles, 'WRITE')) !== 0; + }, + isShowReader(): boolean { + if (this.isAdminUser) return true; + if (this.rbacRoles.length === 0) return false; + const userRbac = getUserRbac(); + if (!userRbac?.shows) return false; + return (userRbac.shows[0][1] & getRbacMask(this.rbacRoles, 'READ')) !== 0; + }, + isShowExecutor(): boolean { + if (this.isAdminUser) return true; + if (this.rbacRoles.length === 0) return false; + const userRbac = getUserRbac(); + if (!userRbac?.shows) return false; + return (userRbac.shows[0][1] & getRbacMask(this.rbacRoles, 'EXECUTE')) !== 0; + }, + isScriptEditor(): boolean { + if (this.isAdminUser) return true; + if (this.rbacRoles.length === 0) return false; + const userRbac = getUserRbac(); + if (!userRbac?.script) return false; + return (userRbac.script[0][1] & getRbacMask(this.rbacRoles, 'WRITE')) !== 0; + }, + isScriptReader(): boolean { + if (this.isAdminUser) return true; + if (this.rbacRoles.length === 0) return false; + const userRbac = getUserRbac(); + if (!userRbac?.script) return false; + return (userRbac.script[0][1] & getRbacMask(this.rbacRoles, 'READ')) !== 0; + }, + isCueEditor(): boolean { + if (this.isAdminUser) return true; + if (this.rbacRoles.length === 0) return false; + const userRbac = getUserRbac(); + if (!userRbac?.cuetypes) return false; + const writeMask = getRbacMask(this.rbacRoles, 'WRITE'); + return userRbac.cuetypes.filter((x) => (x[1] & writeMask) !== 0).length > 0; + }, + isCueReader(): boolean { + if (this.isAdminUser) return true; + if (this.rbacRoles.length === 0) return false; + const userRbac = getUserRbac(); + if (!userRbac?.cuetypes) return false; + const readMask = getRbacMask(this.rbacRoles, 'READ'); + return userRbac.cuetypes.filter((x) => (x[1] & readMask) !== 0).length > 0; + }, + isAllowedShowConfig(): boolean { + return ( + this.isAdminUser || + this.isShowEditor || + this.isShowReader || + this.isShowExecutor || + this.isScriptReader || + this.isScriptEditor || + this.isCueReader || + this.isCueEditor + ); + }, + hasShowAccess(): boolean { + if (!this.currentShow) return false; + if (this.isAdminUser) return true; + const userRbac = getUserRbac(); + if (!userRbac) return false; + + const writeMask = getRbacMask(this.rbacRoles, 'WRITE'); + const readMask = getRbacMask(this.rbacRoles, 'READ'); + const execMask = getRbacMask(this.rbacRoles, 'EXECUTE'); + + const showAllowed = + userRbac.shows?.[0] && (userRbac.shows[0][1] & (writeMask | execMask | readMask)) !== 0; + const scriptAllowed = + userRbac.script?.[0] && (userRbac.script[0][1] & (writeMask | readMask)) !== 0; + const cueTypesAllowed = + userRbac.cuetypes && + userRbac.cuetypes.filter((x) => (x[1] & (writeMask | readMask)) !== 0).length > 0; + + return !!(showAllowed || scriptAllowed || cueTypesAllowed); + }, + }, + actions: { + async getAvailableShows() { + const response = await fetch(makeURL('/api/v1/shows')); + if (response.ok) { + const data = await response.json(); + this.availableShows = data.shows; + } else { + log.error('Unable to get available shows'); + } + }, + async getRawSettings() { + const response = await fetch(makeURL('/api/v1/settings/raw')); + if (response.ok) { + this.rawSettings = await response.json(); + } else { + log.error('Unable to get raw settings'); + } + }, + async getSettings() { + const response = await fetch(makeURL('/api/v1/settings')); + if (response.ok) { + const data = await response.json(); + await this.updateSettings(data); + } else { + log.error('Unable to fetch settings'); + } + }, + async updateSettings(payload: SystemSettings) { + this.settings = payload; + await this.settingsChanged(); + }, + async settingsChanged() { + await this.getRawSettings(); + + if (this.settings.current_show) { + const response = await fetch(makeURL('/api/v1/show')); + if (response.ok) { + this.currentShow = await response.json(); + } else { + log.error('Unable to fetch current show'); + } + } else { + this.currentShow = null; + } + }, + async getRbacRoles() { + const response = await fetch(makeURL('/api/v1/rbac/roles')); + if (response.ok) { + const data = await response.json(); + this.rbacRoles = data.roles; + } else { + log.error('Unable to fetch RBAC roles'); + } + }, + async getSettingsCategories() { + const response = await fetch(makeURL('/api/v1/settings/categories')); + if (response.ok) { + const data = await response.json(); + this.settingsCategories = data.categories; + } else { + log.error('Unable to fetch settings categories'); + } + }, + }, +}); diff --git a/client-v3/src/stores/user.ts b/client-v3/src/stores/user.ts new file mode 100644 index 00000000..89b7cb5a --- /dev/null +++ b/client-v3/src/stores/user.ts @@ -0,0 +1,280 @@ +import { defineStore } from 'pinia'; +import log from 'loglevel'; +import { isEmpty } from 'lodash'; +import { makeURL } from '@/js/utils'; +import type { User, UserSettings, CueColourOverride } from '@/types/api/user'; +import type { StageDirectionStyle } from '@/types/api/script'; + +const TOKEN_KEY = 'digiscript_auth_token'; + +function getToken(): string | null { + return localStorage.getItem(TOKEN_KEY); +} +function setToken(t: string): void { + localStorage.setItem(TOKEN_KEY, t); +} +function clearToken(): void { + localStorage.removeItem(TOKEN_KEY); +} + +export const useUserStore = defineStore('user', { + state: () => ({ + currentUser: null as User | null, + currentRbac: null as Record | null, + users: [] as User[], + tokenRefreshInterval: null as ReturnType | null, + userSettings: {} as UserSettings | Record, + stageDirectionStyleOverrides: [] as StageDirectionStyle[], + cueColourOverrides: [] as CueColourOverride[], + }), + getters: { + authToken: (): string | null => getToken(), + isAuthenticated: (): boolean => getToken() !== null, + }, + actions: { + async login(username: string, password: string): Promise { + const { useWebSocketStore } = await import('@/stores/websocket'); + const wsStore = useWebSocketStore(); + + const response = await fetch(makeURL('/api/v1/auth/login'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + username, + password, + session_id: wsStore.internalUUID, + }), + }); + + if (response.ok) { + const data = await response.json(); + if (data.access_token) setToken(data.access_token); + + const { useSystemStore } = await import('@/stores/system'); + await useSystemStore().getRbacRoles(); + await this.getCurrentUser(); + await this.getCurrentRbac(); + await this.getUserSettings(); + await this.setupTokenRefresh(); + + // Trigger WS authentication if the connection is waiting + wsStore.triggerAuthentication(); + + const { useToast } = await import('vue-toast-notification'); + useToast().success('Successfully logged in!'); + return true; + } + + const responseBody = await response.json(); + log.error('Unable to log in'); + const { useToast } = await import('vue-toast-notification'); + useToast().error(`Unable to log in! ${responseBody.message}.`); + return false; + }, + + async logout(): Promise { + if (this.tokenRefreshInterval) { + clearInterval(this.tokenRefreshInterval); + this.tokenRefreshInterval = null; + } + + const token = getToken(); + clearToken(); + this.currentUser = null; + this.currentRbac = null; + this.userSettings = {}; + this.stageDirectionStyleOverrides = []; + + const { useWebSocketStore } = await import('@/stores/websocket'); + useWebSocketStore().$patch({ authenticated: false, authSucceeded: false }); + + if (token) { + try { + const { useWebSocketStore: getWsStore } = await import('@/stores/websocket'); + const response = await fetch(makeURL('/api/v1/auth/logout'), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ session_id: getWsStore().internalUUID }), + }); + if (!response.ok) { + log.error('Logout response was not OK, but local state was cleared'); + } + } catch (error) { + log.error('Error during logout API call:', error); + } + } + + const { useToast } = await import('vue-toast-notification'); + useToast().success('Successfully logged out!'); + + const { default: router } = await import('@/router'); + if (router.currentRoute.value.path !== '/') { + router.push('/'); + } + }, + + async refreshToken(): Promise { + if (!getToken()) return false; + const response = await fetch(makeURL('/api/v1/auth/refresh-token'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }); + if (response.ok) { + const data = await response.json(); + setToken(data.access_token); + const { useWebSocketStore } = await import('@/stores/websocket'); + useWebSocketStore().refreshWsToken(); + log.debug('Token refreshed successfully'); + return true; + } + log.error('Failed to refresh token'); + return false; + }, + + async tokenRefreshFromServer(newToken: string): Promise { + log.info('Received token refresh from server'); + if (newToken) { + setToken(newToken); + const { useWebSocketStore } = await import('@/stores/websocket'); + useWebSocketStore().refreshWsToken(); + log.info('Auth token updated from server'); + } + }, + + async getCurrentUser(): Promise { + const response = await fetch(makeURL('/api/v1/auth')); + if (response.ok) { + const user = await response.json(); + this.currentUser = isEmpty(user) ? null : user; + } else { + log.error('Unable to get current user'); + } + }, + + async getCurrentRbac(): Promise { + const response = await fetch(makeURL('/api/v1/rbac/user/roles')); + if (response.ok) { + const data = await response.json(); + this.currentRbac = data.roles; + } else { + log.error("Unable to get current user's RBAC roles"); + } + }, + + async getUsers(): Promise { + if (!this.currentUser?.is_admin) return; + const response = await fetch(makeURL('/api/v1/auth/users')); + if (response.ok) { + const data = await response.json(); + this.users = data.users; + } else { + log.error('Unable to get users'); + const { useToast } = await import('vue-toast-notification'); + useToast().error('Unable to fetch users!'); + } + }, + + async createUser(user: Record): Promise { + const response = await fetch(makeURL('/api/v1/auth/create'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(user), + }); + const { useToast } = await import('vue-toast-notification'); + if (response.ok) { + await this.getUsers(); + useToast().success('User created!'); + } else { + const body = await response.json(); + log.error('Unable to create user'); + useToast().error(`Unable to create user: ${body.message || 'Unknown error'}`); + } + }, + + async deleteUser(userId: number): Promise { + const response = await fetch(makeURL('/api/v1/auth/delete'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: userId }), + }); + const { useToast } = await import('vue-toast-notification'); + if (response.ok) { + await this.getUsers(); + useToast().success('User deleted!'); + } else { + const body = await response.json(); + log.error('Unable to delete user'); + useToast().error(`Unable to delete user: ${body.message || 'Unknown error'}`); + } + }, + + async getUserSettings(): Promise { + const response = await fetch(makeURL('/api/v1/user/settings')); + if (response.ok) { + this.userSettings = await response.json(); + } else { + log.error('Unable to fetch user settings'); + } + }, + + async setupTokenRefresh(): Promise { + if (this.tokenRefreshInterval) clearInterval(this.tokenRefreshInterval); + const refreshInterval = setInterval( + async () => { + if (getToken()) { + await this.refreshToken(); + } else { + clearInterval(refreshInterval); + this.tokenRefreshInterval = null; + } + }, + 1000 * 60 * 30 + ); + this.tokenRefreshInterval = refreshInterval; + }, + + async generateApiToken(): Promise | null> { + const response = await fetch(makeURL('/api/v1/auth/api-token/generate'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }); + const { useToast } = await import('vue-toast-notification'); + if (response.ok) { + useToast().success('API token generated successfully!'); + return response.json(); + } + const body = await response.json(); + useToast().error(`Unable to generate API token: ${body.message || 'Unknown error'}`); + return null; + }, + + async revokeApiToken(): Promise { + const response = await fetch(makeURL('/api/v1/auth/api-token/revoke'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }); + const { useToast } = await import('vue-toast-notification'); + if (response.ok) { + useToast().success('API token revoked successfully!'); + return true; + } + const body = await response.json(); + useToast().error(`Unable to revoke API token: ${body.message || 'Unknown error'}`); + return false; + }, + + async getApiToken(): Promise | null> { + const response = await fetch(makeURL('/api/v1/auth/api-token')); + if (response.ok) return response.json(); + const { useToast } = await import('vue-toast-notification'); + useToast().error('Unable to get API token!'); + return null; + }, + }, +}); diff --git a/client-v3/src/stores/websocket.ts b/client-v3/src/stores/websocket.ts new file mode 100644 index 00000000..9d66c724 --- /dev/null +++ b/client-v3/src/stores/websocket.ts @@ -0,0 +1,42 @@ +import { defineStore } from 'pinia'; +import log from 'loglevel'; + +export const useWebSocketStore = defineStore('websocket', { + state: () => ({ + isConnected: false, + authenticated: false, + authSucceeded: false, + pendingAuthentication: false, + internalUUID: null as string | null, + reconnectAttempts: 0, + // Registered by the useWebSocket composable — allows stores to send WS messages + _sendFn: null as ((data: object) => void) | null, + }), + persist: { + pick: ['internalUUID'], + }, + getters: { + websocketHealthy: (state) => state.isConnected && state.authenticated, + }, + actions: { + // Called by the useWebSocket composable to register the send function + registerSend(fn: (data: object) => void): void { + this._sendFn = fn; + }, + // Called after login to send auth if the WS is already connected + triggerAuthentication(): void { + if (!this._sendFn || !this.pendingAuthentication) return; + const token = localStorage.getItem('digiscript_auth_token'); + if (!token) return; + log.debug('Triggering WS authentication after login'); + this._sendFn({ OP: 'AUTHENTICATE', DATA: { token } }); + }, + // Called after token refresh to keep WS token in sync + refreshWsToken(): void { + if (!this._sendFn || !this.isConnected) return; + const token = localStorage.getItem('digiscript_auth_token'); + if (!token) return; + this._sendFn({ OP: 'REFRESH_TOKEN', DATA: { token } }); + }, + }, +}); diff --git a/client-v3/src/types/api/backup.ts b/client-v3/src/types/api/backup.ts new file mode 100644 index 00000000..9d1dbd54 --- /dev/null +++ b/client-v3/src/types/api/backup.ts @@ -0,0 +1,11 @@ +export interface BackupFile { + filename: string; + size_bytes: number; + created_at: number; +} + +export interface BackupsResponse { + backups: BackupFile[]; + count: number; + total_size_bytes: number; +} diff --git a/client-v3/src/types/api/cues.ts b/client-v3/src/types/api/cues.ts new file mode 100644 index 00000000..5fecb28b --- /dev/null +++ b/client-v3/src/types/api/cues.ts @@ -0,0 +1,13 @@ +export interface CueType { + id: number; + show_id: number | null; + prefix: string | null; + description: string | null; + colour: string | null; +} + +export interface Cue { + id: number; + cue_type_id: number | null; + ident: string | null; +} diff --git a/client-v3/src/types/api/microphones.ts b/client-v3/src/types/api/microphones.ts new file mode 100644 index 00000000..a02952a7 --- /dev/null +++ b/client-v3/src/types/api/microphones.ts @@ -0,0 +1,12 @@ +export interface Microphone { + id: number; + show_id: number | null; + name: string | null; + description: string | null; +} + +export interface MicrophoneAllocation { + mic_id: number; + scene_id: number; + character_id: number; +} diff --git a/client-v3/src/types/api/script.ts b/client-v3/src/types/api/script.ts new file mode 100644 index 00000000..0215fe5d --- /dev/null +++ b/client-v3/src/types/api/script.ts @@ -0,0 +1,58 @@ +export interface ScriptLinePart { + id: number | null; + line_id: number | null; + part_index: number | null; + character_id: number | null; + character_group_id: number | null; + line_text: string | null; +} + +export interface ScriptLine { + id: number | null; + act_id: number | null; + scene_id: number | null; + page: number | null; + line_type: number; + stage_direction_style_id: number | null; + line_parts: ScriptLinePart[]; +} + +export interface ScriptRevision { + id: number; + script_id: number | null; + revision: number | null; + created_at: string | null; + edited_at: string | null; + description: string | null; + previous_revision_id: number | null; + has_draft?: boolean; +} + +export interface StageDirectionStyle { + id: number; + script_id: number | null; + description: string | null; + bold: boolean | null; + italic: boolean | null; + underline: boolean | null; + text_format: string | null; + text_colour: string | null; + enable_background_colour: boolean | null; + background_colour: string | null; +} + +export type ScriptCut = number; + +export interface CompiledScript { + revision_id: number; + created_at: string | null; + updated_at: string | null; + data_path: string | null; +} + +export interface PageStatus { + added: number[]; + updated: number[]; + deleted: number[]; + inserted: number[]; +} diff --git a/client-v3/src/types/api/session.ts b/client-v3/src/types/api/session.ts new file mode 100644 index 00000000..6159599d --- /dev/null +++ b/client-v3/src/types/api/session.ts @@ -0,0 +1,28 @@ +export interface ShowSession { + id: number; + show_id: number; + script_revision_id: number; + start_date_time: string | null; + end_date_time: string | null; + user_id: number | null; + client_internal_id: string | null; + latest_line_ref: string | null; + current_interval_id: number | null; + tags: SessionTag[]; +} + +export interface Interval { + id: number; + session_id: number | null; + act_id: number | null; + start_datetime: string | null; + end_datetime: string | null; + initial_length: number | null; +} + +export interface SessionTag { + id: number; + show_id: number | null; + tag: string; + colour: string; +} diff --git a/client-v3/src/types/api/settings.ts b/client-v3/src/types/api/settings.ts new file mode 100644 index 00000000..05d6fa9c --- /dev/null +++ b/client-v3/src/types/api/settings.ts @@ -0,0 +1,11 @@ +export interface SystemSetting { + key: string; + value: string; +} + +export interface SystemSettings { + current_show: number | null; + client_log_enabled: boolean | null; + client_log_level: string | null; + [key: string]: unknown; +} diff --git a/client-v3/src/types/api/show.ts b/client-v3/src/types/api/show.ts new file mode 100644 index 00000000..92fc7f21 --- /dev/null +++ b/client-v3/src/types/api/show.ts @@ -0,0 +1,54 @@ +export interface Show { + id: number; + name: string | null; + start_date: string | null; + end_date: string | null; + created_at: string | null; + edited_at: string | null; + first_act_id: number | null; + current_session_id: number | null; + script_mode: number; +} + +export interface Cast { + id: number; + show_id: number | null; + first_name: string | null; + last_name: string | null; + character_list: Character[]; +} + +export interface Character { + id: number; + show_id: number | null; + played_by: number | null; + name: string | null; + description: string | null; + cast_member: { id: number; first_name: string | null; last_name: string | null } | null; +} + +export interface CharacterGroup { + id: number; + show_id: number | null; + name: string | null; + description: string | null; +} + +// first_scene and next_act are serialized as IDs by the marshmallow schema +export interface Act { + id: number; + show_id: number | null; + name: string | null; + interval_after: boolean | null; + first_scene: number | null; + next_act: number | null; +} + +// act and next_scene are serialized as IDs by the marshmallow schema +export interface Scene { + id: number; + show_id: number | null; + act: number | null; + name: string | null; + next_scene: number | null; +} diff --git a/client-v3/src/types/api/stage.ts b/client-v3/src/types/api/stage.ts new file mode 100644 index 00000000..e8e38eb8 --- /dev/null +++ b/client-v3/src/types/api/stage.ts @@ -0,0 +1,57 @@ +export interface Crew { + id: number; + show_id: number; + first_name: string; + last_name: string | null; +} + +export interface CrewAssignment { + id: number; + crew_id: number; + scene_id: number; + assignment_type: 'set' | 'strike'; + prop_id: number | null; + scenery_id: number | null; +} + +export interface SceneryType { + id: number; + show_id: number; + name: string; + description: string | null; +} + +export interface Scenery { + id: number; + show_id: number; + scenery_type_id: number; + name: string; + description: string | null; +} + +export interface SceneryAllocation { + id: number; + scenery_id: number; + scene_id: number; +} + +export interface PropType { + id: number; + show_id: number; + name: string; + description: string | null; +} + +export interface Props { + id: number; + show_id: number; + prop_type_id: number; + name: string; + description: string | null; +} + +export interface PropsAllocation { + id: number; + props_id: number; + scene_id: number; +} diff --git a/client-v3/src/types/api/user.ts b/client-v3/src/types/api/user.ts new file mode 100644 index 00000000..98e204a2 --- /dev/null +++ b/client-v3/src/types/api/user.ts @@ -0,0 +1,25 @@ +export interface User { + id: number; + username: string | null; + is_admin: boolean | null; + last_login: string | null; + last_seen: string | null; + requires_password_change: boolean; + token_version: number; +} + +export interface CueColourOverride { + id: number; + cue_type_id: number | null; + colour: string | null; +} + +export interface UserSettings { + enable_script_auto_save: boolean | null; + script_auto_save_interval: number | null; + cue_position_right: boolean | null; + script_text_alignment: number; + console_log_level: string; + character_mru_sort: boolean; + character_combined_dropdown: boolean; +} diff --git a/client-v3/src/types/api/websocket.ts b/client-v3/src/types/api/websocket.ts new file mode 100644 index 00000000..4cc2ab30 --- /dev/null +++ b/client-v3/src/types/api/websocket.ts @@ -0,0 +1,5 @@ +export interface WsMessage { + OP: string; + DATA: Record; + ACTION?: string; +} diff --git a/client-v3/src/types/index.ts b/client-v3/src/types/index.ts new file mode 100644 index 00000000..cf8d175f --- /dev/null +++ b/client-v3/src/types/index.ts @@ -0,0 +1,9 @@ +export type * from './api/show'; +export type * from './api/script'; +export type * from './api/cues'; +export type * from './api/stage'; +export type * from './api/microphones'; +export type * from './api/session'; +export type * from './api/user'; +export type * from './api/settings'; +export type * from './api/websocket'; diff --git a/client-v3/src/views/NotFoundView.vue b/client-v3/src/views/NotFoundView.vue new file mode 100644 index 00000000..bdd3ab08 --- /dev/null +++ b/client-v3/src/views/NotFoundView.vue @@ -0,0 +1,16 @@ + + + diff --git a/client-v3/src/views/PlaceholderView.vue b/client-v3/src/views/PlaceholderView.vue new file mode 100644 index 00000000..c8afe46a --- /dev/null +++ b/client-v3/src/views/PlaceholderView.vue @@ -0,0 +1,6 @@ + diff --git a/client-v3/src/views/electron/ServerSelector.vue b/client-v3/src/views/electron/ServerSelector.vue new file mode 100644 index 00000000..20d6690c --- /dev/null +++ b/client-v3/src/views/electron/ServerSelector.vue @@ -0,0 +1,6 @@ + diff --git a/client-v3/src/views/user/LoginView.vue b/client-v3/src/views/user/LoginView.vue new file mode 100644 index 00000000..30e280ab --- /dev/null +++ b/client-v3/src/views/user/LoginView.vue @@ -0,0 +1,6 @@ + diff --git a/client-v3/tsconfig.json b/client-v3/tsconfig.json index e16e8c1d..90142bbf 100644 --- a/client-v3/tsconfig.json +++ b/client-v3/tsconfig.json @@ -30,6 +30,6 @@ "useDefineForClassFields": true, "noEmit": true }, - "include": ["src/**/*.ts", "src/**/*.vue"], + "include": ["src/**/*.ts", "src/**/*.vue", "components.d.ts"], "exclude": ["node_modules", "dist", "dist-electron", "src/**/*.test.ts"] } diff --git a/client-v3/vite.config.ts b/client-v3/vite.config.ts index 71a7ed2a..e7da66a3 100644 --- a/client-v3/vite.config.ts +++ b/client-v3/vite.config.ts @@ -1,9 +1,16 @@ import path from 'path'; import { defineConfig } from 'vite'; import vue from '@vitejs/plugin-vue'; +import Components from 'unplugin-vue-components/vite'; +import { BootstrapVueNextResolver } from 'bootstrap-vue-next'; export default defineConfig({ - plugins: [vue()], + plugins: [ + vue(), + Components({ + resolvers: [BootstrapVueNextResolver()], + }), + ], base: process.env.BUILD_TARGET === 'electron' ? './' : '/ui-new/', build: { outDir: From 91faa260ab6a195249b606e3d25e3267b25986dd Mon Sep 17 00:00:00 2001 From: Tim Bradgate Date: Thu, 14 May 2026 22:00:01 +0100 Subject: [PATCH 03/23] Migrate Phase 2 read-only pages to Vue 3 (Home, About, Help) (#1038) - stores/help.ts: Pinia port of Vuex help module with manifest loading, document cache, and Fuse.js full-text search - components/MarkdownRenderer.vue: async marked v18 API via watch+ref; :deep() CSS selectors; import.meta.env.BASE_URL for portable help links across base paths - views/HomeView.vue: reads systemStore.currentShow/settings + userStore.currentUser; currentShowSession stubbed null until Phase 6 - views/AboutView.vue: static content port - views/HelpView.vue: BVN port with sticky sidebar, debounced search, dynamic navbar height offset - views/help/HelpDocView.vue: cache-first doc loading, watch route.params.slug - router: wire /about and /help routes; fix /help child redirect to absolute path; fix catch-all to render NotFoundView in-place (no URL redirect) matching Vue 2 behaviour Co-authored-by: Claude Sonnet 4.6 --- client-v3/src/components/MarkdownRenderer.vue | 219 ++++++++++++++++++ client-v3/src/router/index.ts | 10 +- client-v3/src/stores/help.ts | 109 +++++++++ client-v3/src/views/AboutView.vue | 17 ++ client-v3/src/views/HelpView.vue | 88 +++++++ client-v3/src/views/HomeView.vue | 49 ++-- client-v3/src/views/help/HelpDocView.vue | 47 ++++ 7 files changed, 516 insertions(+), 23 deletions(-) create mode 100644 client-v3/src/components/MarkdownRenderer.vue create mode 100644 client-v3/src/stores/help.ts create mode 100644 client-v3/src/views/AboutView.vue create mode 100644 client-v3/src/views/HelpView.vue create mode 100644 client-v3/src/views/help/HelpDocView.vue diff --git a/client-v3/src/components/MarkdownRenderer.vue b/client-v3/src/components/MarkdownRenderer.vue new file mode 100644 index 00000000..6be9a16d --- /dev/null +++ b/client-v3/src/components/MarkdownRenderer.vue @@ -0,0 +1,219 @@ + + + + + diff --git a/client-v3/src/router/index.ts b/client-v3/src/router/index.ts index e4b2ede1..ca5ec386 100644 --- a/client-v3/src/router/index.ts +++ b/client-v3/src/router/index.ts @@ -22,7 +22,7 @@ const router = createRouter({ { path: '/about', name: 'about', - component: PlaceholderView, + component: () => import('@/views/AboutView.vue'), meta: { requiresAuth: false }, }, { @@ -124,14 +124,14 @@ const router = createRouter({ }, { path: '/help', - component: PlaceholderView, + component: () => import('@/views/HelpView.vue'), meta: { requiresAuth: false }, children: [ - { path: '', redirect: 'getting-started' }, + { path: '', redirect: '/help/getting-started' }, { name: 'help-doc', path: ':slug(.*)', - component: PlaceholderView, + component: () => import('@/views/help/HelpDocView.vue'), meta: { requiresAuth: false }, }, ], @@ -144,7 +144,7 @@ const router = createRouter({ }, { path: '/:pathMatch(.*)*', - redirect: '/404', + component: NotFoundView, }, ], }); diff --git a/client-v3/src/stores/help.ts b/client-v3/src/stores/help.ts new file mode 100644 index 00000000..c76ba219 --- /dev/null +++ b/client-v3/src/stores/help.ts @@ -0,0 +1,109 @@ +import log from 'loglevel'; +import Fuse from 'fuse.js'; +import { defineStore } from 'pinia'; + +interface HelpManifestEntry { + title: string; + slug: string; + path: string; + category: string; +} + +interface HelpState { + manifest: HelpManifestEntry[]; + documents: Record; + currentDocument: string | null; + loading: boolean; + error: string | null; + searchIndex: Fuse | null; + searchResults: HelpManifestEntry[]; +} + +export const useHelpStore = defineStore('help', { + state: (): HelpState => ({ + manifest: [], + documents: {}, + currentDocument: null, + loading: false, + error: null, + searchIndex: null, + searchResults: [], + }), + + getters: { + documentationManifest: (state) => state.manifest, + currentDocumentContent: (state) => + state.currentDocument ? state.documents[state.currentDocument] : null, + isLoading: (state) => state.loading, + searchResults: (state) => state.searchResults, + }, + + actions: { + async loadManifest() { + try { + const response = await fetch('/docs/manifest.json'); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + + const manifest: HelpManifestEntry[] = await response.json(); + this.manifest = manifest; + this.searchIndex = new Fuse(manifest, { + keys: ['title', 'path'], + threshold: 0.3, + includeScore: true, + }); + log.info(`Loaded documentation manifest with ${manifest.length} documents`); + } catch (error) { + log.error('Failed to load documentation manifest:', error); + this.error = 'Failed to load documentation manifest'; + } + }, + + async loadDocument(slug: string) { + if (this.documents[slug]) { + this.currentDocument = slug; + this.error = null; + return; + } + + this.loading = true; + const doc = this.manifest.find((d) => d.slug === slug); + + if (!doc) { + this.error = 'Document not found'; + this.loading = false; + return; + } + + try { + const response = await fetch(`/docs/${doc.path}`); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + + const content = await response.text(); + this.documents[slug] = content; + this.currentDocument = slug; + this.error = null; + } catch (error) { + log.error('Failed to load documentation:', error); + this.error = 'Failed to load documentation'; + } finally { + this.loading = false; + } + }, + + searchDocuments(query: string) { + if (!this.searchIndex) { + log.warn('Search index not initialized'); + return; + } + if (!query || query.trim() === '') { + this.searchResults = []; + return; + } + this.searchResults = this.searchIndex.search(query).map((r) => r.item); + }, + + clearSearch() { + this.searchResults = []; + }, + }, +}); diff --git a/client-v3/src/views/AboutView.vue b/client-v3/src/views/AboutView.vue new file mode 100644 index 00000000..6d38acfc --- /dev/null +++ b/client-v3/src/views/AboutView.vue @@ -0,0 +1,17 @@ + + + diff --git a/client-v3/src/views/HelpView.vue b/client-v3/src/views/HelpView.vue new file mode 100644 index 00000000..84c895a1 --- /dev/null +++ b/client-v3/src/views/HelpView.vue @@ -0,0 +1,88 @@ + + + + + diff --git a/client-v3/src/views/HomeView.vue b/client-v3/src/views/HomeView.vue index 6f4002a6..9710f7df 100644 --- a/client-v3/src/views/HomeView.vue +++ b/client-v3/src/views/HomeView.vue @@ -1,24 +1,37 @@ - diff --git a/client-v3/src/views/help/HelpDocView.vue b/client-v3/src/views/help/HelpDocView.vue new file mode 100644 index 00000000..6bf9d584 --- /dev/null +++ b/client-v3/src/views/help/HelpDocView.vue @@ -0,0 +1,47 @@ + + + From 8d44a4645475fb1181fb8d28307159110dcafd9e Mon Sep 17 00:00:00 2001 From: Tim Bradgate Date: Thu, 14 May 2026 23:48:43 +0100 Subject: [PATCH 04/23] =?UTF-8?q?Vue=203=20migration:=20Phase=203=20?= =?UTF-8?q?=E2=80=94=20authentication=20pages=20(Login,=20ForcePasswordCha?= =?UTF-8?q?nge)=20(#1039)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Port LoginView and ForcePasswordChangeView to BVN + @vuelidate/core v2 - Add useFormValidation and usePasswordValidation composables - Add changePassword() action to user store - Wire /force-password-change route to real component Bug fixes discovered during verification: - stores/user: authToken was a non-reactive getter reading localStorage, causing Pinia to cache null on first access and never re-evaluate; moved to reactive state field with _setToken/_clearToken actions keeping localStorage in sync - http-interceptor: exclude login endpoint from 401 handling to avoid logout cascade on bad credentials - stores/websocket: websocketHealthy getter incorrectly required authenticated=true; corrected to match Vue 2 behaviour (connection only) - views/LoginView: missing @submit.prevent on BForm caused native form submission - main.ts: import theme-sugar.css and create toast.ts singleton with position top-right; replace scattered useToast() calls with the shared instance Co-authored-by: Claude Sonnet 4.6 --- client-v3/src/App.vue | 14 +-- .../src/composables/useFormValidation.ts | 10 ++ .../src/composables/usePasswordValidation.ts | 12 ++ client-v3/src/composables/useWebSocket.ts | 25 ++-- client-v3/src/js/http-interceptor.ts | 11 +- client-v3/src/js/toast.ts | 3 + client-v3/src/main.ts | 2 +- client-v3/src/router/index.ts | 7 +- client-v3/src/stores/user.ts | 88 +++++++------ client-v3/src/stores/websocket.ts | 2 +- .../views/user/ForcePasswordChangeView.vue | 118 ++++++++++++++++++ client-v3/src/views/user/LoginView.vue | 83 +++++++++++- 12 files changed, 298 insertions(+), 77 deletions(-) create mode 100644 client-v3/src/composables/useFormValidation.ts create mode 100644 client-v3/src/composables/usePasswordValidation.ts create mode 100644 client-v3/src/js/toast.ts create mode 100644 client-v3/src/views/user/ForcePasswordChangeView.vue diff --git a/client-v3/src/App.vue b/client-v3/src/App.vue index 4a046c07..4ab8cc58 100644 --- a/client-v3/src/App.vue +++ b/client-v3/src/App.vue @@ -170,6 +170,7 @@ import type { BModal } from 'bootstrap-vue-next'; import { useVuelidate } from '@vuelidate/core'; import { required, minValue } from '@vuelidate/validators'; import log from 'loglevel'; +import { toast } from '@/js/toast'; import { useUserStore } from '@/stores/user'; import { useSystemStore } from '@/stores/system'; import { useWebSocketStore } from '@/stores/websocket'; @@ -264,12 +265,11 @@ async function stopShowSession(): Promise { method: 'POST', headers: { 'Content-Type': 'application/json' }, }); - const { useToast } = await import('vue-toast-notification'); if (response.ok) { - useToast().success('Stopped show session'); + toast.success('Stopped show session'); } else { log.error('Unable to stop show session'); - useToast().error('Unable to stop show session'); + toast.error('Unable to stop show session'); } } stoppingSession.value = false; @@ -277,8 +277,7 @@ async function stopShowSession(): Promise { async function startShowSession(): Promise { if (!wsStore.internalUUID) { - const { useToast } = await import('vue-toast-notification'); - useToast().error('Unable to start new show session'); + toast.error('Unable to start new show session'); return; } startingSession.value = true; @@ -289,12 +288,11 @@ async function startShowSession(): Promise { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session_id: wsStore.internalUUID }), }); - const { useToast } = await import('vue-toast-notification'); if (response.ok) { - useToast().success('Started new show session'); + toast.success('Started new show session'); } else { log.error('Unable to start new show session'); - useToast().error('Unable to start new show session'); + toast.error('Unable to start new show session'); } } startingSession.value = false; diff --git a/client-v3/src/composables/useFormValidation.ts b/client-v3/src/composables/useFormValidation.ts new file mode 100644 index 00000000..f539a9cf --- /dev/null +++ b/client-v3/src/composables/useFormValidation.ts @@ -0,0 +1,10 @@ +export function useFormValidation() { + function validationState( + field: { $dirty: boolean; $error: boolean } | undefined + ): boolean | null { + if (!field) return null; + return field.$dirty ? !field.$error : null; + } + + return { validationState }; +} diff --git a/client-v3/src/composables/usePasswordValidation.ts b/client-v3/src/composables/usePasswordValidation.ts new file mode 100644 index 00000000..be354dff --- /dev/null +++ b/client-v3/src/composables/usePasswordValidation.ts @@ -0,0 +1,12 @@ +import { computed } from 'vue'; +import { required, minLength, sameAs } from '@vuelidate/validators'; + +export function usePasswordValidation() { + const passwordRules = { required, minLength: minLength(6) }; + + function confirmPasswordRules(getPasswordValue: () => string) { + return computed(() => ({ required, sameAsPassword: sameAs(getPasswordValue()) })); + } + + return { passwordRules, confirmPasswordRules }; +} diff --git a/client-v3/src/composables/useWebSocket.ts b/client-v3/src/composables/useWebSocket.ts index cf7b7047..2930707a 100644 --- a/client-v3/src/composables/useWebSocket.ts +++ b/client-v3/src/composables/useWebSocket.ts @@ -1,5 +1,6 @@ import log from 'loglevel'; import { debounce } from 'lodash'; +import { toast } from '@/js/toast'; import { useWebSocketStore } from '@/stores/websocket'; import { useSystemStore } from '@/stores/system'; import { useUserStore } from '@/stores/user'; @@ -25,14 +26,10 @@ function sendObj(data: object): void { } } -const settingsChangedToast = debounce( - async () => { - const { useToast } = await import('vue-toast-notification'); - useToast().info('Settings synced from server'); - }, - 1000, - { leading: true, trailing: false } -); +const settingsChangedToast = debounce(() => toast.info('Settings synced from server'), 1000, { + leading: true, + trailing: false, +}); async function handleMessage(msg: WsMessage): Promise { const wsStore = useWebSocketStore(); @@ -156,11 +153,9 @@ function connect(): void { ws.onopen = () => { wsStore.$patch({ isConnected: true }); if (errorCount > 0) { - import('vue-toast-notification').then(({ useToast }) => { - useToast().success( - `WebSocket reconnected after ${errorCount} attempt${errorCount > 1 ? 's' : ''}` - ); - }); + toast.success( + `WebSocket reconnected after ${errorCount} attempt${errorCount > 1 ? 's' : ''}` + ); } log.info('WebSocket connected'); }; @@ -184,9 +179,7 @@ function connect(): void { log.error('WebSocket error'); errorCount++; if (errorCount === 1) { - import('vue-toast-notification').then(({ useToast }) => { - useToast().error('WebSocket connection lost'); - }); + toast.error('WebSocket connection lost'); } }; } diff --git a/client-v3/src/js/http-interceptor.ts b/client-v3/src/js/http-interceptor.ts index 63d5233a..d045af16 100644 --- a/client-v3/src/js/http-interceptor.ts +++ b/client-v3/src/js/http-interceptor.ts @@ -1,5 +1,6 @@ import log from 'loglevel'; import { makeURL } from '@/js/utils'; +import { toast } from '@/js/toast'; export default function setupHttpInterceptor(): void { const originalFetch = window.fetch; @@ -10,11 +11,11 @@ export default function setupHttpInterceptor(): void { if (typeof resource === 'string' && resource.startsWith(makeURL('/api/'))) { // Import store inside the override function — Pinia context isn't active at module load time const { useUserStore } = await import('@/stores/user'); - const { useToast } = await import('vue-toast-notification'); const userStore = useUserStore(); const token = userStore.authToken; const isLogoutRequest = resource.endsWith('/api/v1/auth/logout'); + const isLoginRequest = resource.endsWith('/api/v1/auth/login'); const isRefreshRequest = resource.endsWith('/api/v1/auth/refresh-token'); const newOptions = { @@ -38,12 +39,12 @@ export default function setupHttpInterceptor(): void { try { const response = await originalFetch(resource, newOptions); - if (response.status === 401 && !isLogoutRequest) { + if (response.status === 401 && !isLogoutRequest && !isLoginRequest) { log.warn('Received 401 Unauthorized response'); if (isRefreshRequest || isRefreshingToken) { log.warn('Token refresh failed with 401 or already refreshing, logging out'); - useToast().warning('Your session has expired. Please log in again.'); + toast.warning('Your session has expired. Please log in again.'); await userStore.logout(); return response; } @@ -68,13 +69,13 @@ export default function setupHttpInterceptor(): void { } log.warn('Token refresh failed, logging out'); - useToast().warning('Your session has expired. Please log in again.'); + toast.warning('Your session has expired. Please log in again.'); await userStore.logout(); return response; } catch (refreshError) { isRefreshingToken = false; log.error('Error during token refresh:', refreshError); - useToast().error('Authentication error - please log in again'); + toast.error('Authentication error - please log in again'); await userStore.logout(); return response; } diff --git a/client-v3/src/js/toast.ts b/client-v3/src/js/toast.ts new file mode 100644 index 00000000..09b8c9c0 --- /dev/null +++ b/client-v3/src/js/toast.ts @@ -0,0 +1,3 @@ +import { useToast } from 'vue-toast-notification'; + +export const toast = useToast({ position: 'top-right' }); diff --git a/client-v3/src/main.ts b/client-v3/src/main.ts index ec5a47cf..632843ef 100644 --- a/client-v3/src/main.ts +++ b/client-v3/src/main.ts @@ -1,13 +1,13 @@ import { createApp } from 'vue'; import { createPinia } from 'pinia'; import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'; - import App from './App.vue'; import router from './router'; import setupHttpInterceptor from './js/http-interceptor'; import { initRemoteLogging } from './js/logger'; import './assets/styles/dark.scss'; import 'bootstrap-vue-next/dist/bootstrap-vue-next.css'; +import 'vue-toast-notification/dist/theme-sugar.css'; const app = createApp(App); diff --git a/client-v3/src/router/index.ts b/client-v3/src/router/index.ts index ca5ec386..04917059 100644 --- a/client-v3/src/router/index.ts +++ b/client-v3/src/router/index.ts @@ -119,8 +119,8 @@ const router = createRouter({ { path: '/force-password-change', name: 'force-password-change', - component: PlaceholderView, - meta: { requiresAuth: true, requiresPasswordChange: true }, + component: () => import('@/views/user/ForcePasswordChangeView.vue'), + meta: { requiresAuth: true }, }, { path: '/help', @@ -152,11 +152,10 @@ const router = createRouter({ router.beforeEach(async (to) => { const { useSystemStore } = await import('@/stores/system'); const { useUserStore } = await import('@/stores/user'); - const { useToast } = await import('vue-toast-notification'); + const { toast } = await import('@/js/toast'); const systemStore = useSystemStore(); const userStore = useUserStore(); - const toast = useToast(); // Electron: require active connection before any page except server-selector if (isElectron() && to.path !== '/electron/server-selector') { diff --git a/client-v3/src/stores/user.ts b/client-v3/src/stores/user.ts index 89b7cb5a..6055cdee 100644 --- a/client-v3/src/stores/user.ts +++ b/client-v3/src/stores/user.ts @@ -2,6 +2,7 @@ import { defineStore } from 'pinia'; import log from 'loglevel'; import { isEmpty } from 'lodash'; import { makeURL } from '@/js/utils'; +import { toast } from '@/js/toast'; import type { User, UserSettings, CueColourOverride } from '@/types/api/user'; import type { StageDirectionStyle } from '@/types/api/script'; @@ -10,15 +11,10 @@ const TOKEN_KEY = 'digiscript_auth_token'; function getToken(): string | null { return localStorage.getItem(TOKEN_KEY); } -function setToken(t: string): void { - localStorage.setItem(TOKEN_KEY, t); -} -function clearToken(): void { - localStorage.removeItem(TOKEN_KEY); -} export const useUserStore = defineStore('user', { state: () => ({ + authToken: getToken() as string | null, currentUser: null as User | null, currentRbac: null as Record | null, users: [] as User[], @@ -28,10 +24,17 @@ export const useUserStore = defineStore('user', { cueColourOverrides: [] as CueColourOverride[], }), getters: { - authToken: (): string | null => getToken(), - isAuthenticated: (): boolean => getToken() !== null, + isAuthenticated: (state): boolean => state.authToken !== null, }, actions: { + _setToken(t: string): void { + localStorage.setItem(TOKEN_KEY, t); + this.authToken = t; + }, + _clearToken(): void { + localStorage.removeItem(TOKEN_KEY); + this.authToken = null; + }, async login(username: string, password: string): Promise { const { useWebSocketStore } = await import('@/stores/websocket'); const wsStore = useWebSocketStore(); @@ -48,7 +51,7 @@ export const useUserStore = defineStore('user', { if (response.ok) { const data = await response.json(); - if (data.access_token) setToken(data.access_token); + if (data.access_token) this._setToken(data.access_token); const { useSystemStore } = await import('@/stores/system'); await useSystemStore().getRbacRoles(); @@ -60,15 +63,13 @@ export const useUserStore = defineStore('user', { // Trigger WS authentication if the connection is waiting wsStore.triggerAuthentication(); - const { useToast } = await import('vue-toast-notification'); - useToast().success('Successfully logged in!'); + toast.success('Successfully logged in!'); return true; } const responseBody = await response.json(); log.error('Unable to log in'); - const { useToast } = await import('vue-toast-notification'); - useToast().error(`Unable to log in! ${responseBody.message}.`); + toast.error(`Unable to log in! ${responseBody.message}.`); return false; }, @@ -78,8 +79,8 @@ export const useUserStore = defineStore('user', { this.tokenRefreshInterval = null; } - const token = getToken(); - clearToken(); + const token = this.authToken; + this._clearToken(); this.currentUser = null; this.currentRbac = null; this.userSettings = {}; @@ -95,7 +96,7 @@ export const useUserStore = defineStore('user', { method: 'POST', headers: { 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, + Authorization: `Bearer ${token}`, // use captured value since store is already cleared }, body: JSON.stringify({ session_id: getWsStore().internalUUID }), }); @@ -107,8 +108,7 @@ export const useUserStore = defineStore('user', { } } - const { useToast } = await import('vue-toast-notification'); - useToast().success('Successfully logged out!'); + toast.success('Successfully logged out!'); const { default: router } = await import('@/router'); if (router.currentRoute.value.path !== '/') { @@ -117,7 +117,7 @@ export const useUserStore = defineStore('user', { }, async refreshToken(): Promise { - if (!getToken()) return false; + if (!this.authToken) return false; const response = await fetch(makeURL('/api/v1/auth/refresh-token'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -125,7 +125,7 @@ export const useUserStore = defineStore('user', { }); if (response.ok) { const data = await response.json(); - setToken(data.access_token); + this._setToken(data.access_token); const { useWebSocketStore } = await import('@/stores/websocket'); useWebSocketStore().refreshWsToken(); log.debug('Token refreshed successfully'); @@ -138,7 +138,7 @@ export const useUserStore = defineStore('user', { async tokenRefreshFromServer(newToken: string): Promise { log.info('Received token refresh from server'); if (newToken) { - setToken(newToken); + this._setToken(newToken); const { useWebSocketStore } = await import('@/stores/websocket'); useWebSocketStore().refreshWsToken(); log.info('Auth token updated from server'); @@ -173,8 +173,7 @@ export const useUserStore = defineStore('user', { this.users = data.users; } else { log.error('Unable to get users'); - const { useToast } = await import('vue-toast-notification'); - useToast().error('Unable to fetch users!'); + toast.error('Unable to fetch users!'); } }, @@ -184,14 +183,13 @@ export const useUserStore = defineStore('user', { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(user), }); - const { useToast } = await import('vue-toast-notification'); if (response.ok) { await this.getUsers(); - useToast().success('User created!'); + toast.success('User created!'); } else { const body = await response.json(); log.error('Unable to create user'); - useToast().error(`Unable to create user: ${body.message || 'Unknown error'}`); + toast.error(`Unable to create user: ${body.message || 'Unknown error'}`); } }, @@ -201,15 +199,32 @@ export const useUserStore = defineStore('user', { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: userId }), }); - const { useToast } = await import('vue-toast-notification'); if (response.ok) { await this.getUsers(); - useToast().success('User deleted!'); + toast.success('User deleted!'); } else { const body = await response.json(); log.error('Unable to delete user'); - useToast().error(`Unable to delete user: ${body.message || 'Unknown error'}`); + toast.error(`Unable to delete user: ${body.message || 'Unknown error'}`); + } + }, + + async changePassword(newPassword: string): Promise { + const response = await fetch(makeURL('/api/v1/auth/change-password'), { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ new_password: newPassword }), + }); + if (response.ok) { + const data = await response.json(); + if (data.access_token) this._setToken(data.access_token); + await this.getCurrentUser(); + toast.success('Password changed successfully!'); + return true; } + const error = await response.json(); + toast.error(error.message || 'Failed to change password'); + return false; }, async getUserSettings(): Promise { @@ -225,7 +240,7 @@ export const useUserStore = defineStore('user', { if (this.tokenRefreshInterval) clearInterval(this.tokenRefreshInterval); const refreshInterval = setInterval( async () => { - if (getToken()) { + if (this.authToken) { await this.refreshToken(); } else { clearInterval(refreshInterval); @@ -243,13 +258,12 @@ export const useUserStore = defineStore('user', { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}), }); - const { useToast } = await import('vue-toast-notification'); if (response.ok) { - useToast().success('API token generated successfully!'); + toast.success('API token generated successfully!'); return response.json(); } const body = await response.json(); - useToast().error(`Unable to generate API token: ${body.message || 'Unknown error'}`); + toast.error(`Unable to generate API token: ${body.message || 'Unknown error'}`); return null; }, @@ -259,21 +273,19 @@ export const useUserStore = defineStore('user', { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}), }); - const { useToast } = await import('vue-toast-notification'); if (response.ok) { - useToast().success('API token revoked successfully!'); + toast.success('API token revoked successfully!'); return true; } const body = await response.json(); - useToast().error(`Unable to revoke API token: ${body.message || 'Unknown error'}`); + toast.error(`Unable to revoke API token: ${body.message || 'Unknown error'}`); return false; }, async getApiToken(): Promise | null> { const response = await fetch(makeURL('/api/v1/auth/api-token')); if (response.ok) return response.json(); - const { useToast } = await import('vue-toast-notification'); - useToast().error('Unable to get API token!'); + toast.error('Unable to get API token!'); return null; }, }, diff --git a/client-v3/src/stores/websocket.ts b/client-v3/src/stores/websocket.ts index 9d66c724..c7f974ab 100644 --- a/client-v3/src/stores/websocket.ts +++ b/client-v3/src/stores/websocket.ts @@ -16,7 +16,7 @@ export const useWebSocketStore = defineStore('websocket', { pick: ['internalUUID'], }, getters: { - websocketHealthy: (state) => state.isConnected && state.authenticated, + websocketHealthy: (state) => state.isConnected, }, actions: { // Called by the useWebSocket composable to register the send function diff --git a/client-v3/src/views/user/ForcePasswordChangeView.vue b/client-v3/src/views/user/ForcePasswordChangeView.vue new file mode 100644 index 00000000..69d61bfb --- /dev/null +++ b/client-v3/src/views/user/ForcePasswordChangeView.vue @@ -0,0 +1,118 @@ + + + + + diff --git a/client-v3/src/views/user/LoginView.vue b/client-v3/src/views/user/LoginView.vue index 30e280ab..db4a5bc7 100644 --- a/client-v3/src/views/user/LoginView.vue +++ b/client-v3/src/views/user/LoginView.vue @@ -1,6 +1,81 @@ + + + + From 28e5ea56858fb69b9d8606fb41c8e6039bd7017c Mon Sep 17 00:00:00 2001 From: Tim Bradgate Date: Sun, 17 May 2026 10:01:58 +0100 Subject: [PATCH 05/23] =?UTF-8?q?Vue=203=20migration:=20Phase=204=20?= =?UTF-8?q?=E2=80=94=20user=20settings=20page=20(/me)=20(#1041)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Vue 3 migration: Phase 4 — user settings page (/me) - views/user/SettingsView.vue: 6-tab pill-vertical shell (About, Settings, Stage Direction Styles, Cue Colour Preferences, Change Password, API Token) - components/user/settings/AboutUser.vue: titleCase + sorted BTableSimple - components/user/settings/UserSettingsConfig.vue: 7-field settings form, PATCH /api/v1/user/settings, vuelidate with notNull/notNullAndGreaterThanZero - components/user/settings/ChangePassword.vue: 3-field form using usePasswordValidation, sends old_password for settings context - components/user/settings/ApiToken.vue: generate/regenerate/revoke with BModal confirmations, #append slot for copy button - components/user/settings/CueColourPreferences.vue: cue types fetched locally (show store not yet available), BModal ref pattern, contrastColor - components/user/settings/StageDirectionStyles.vue: stage direction styles fetched locally, v-model:pressed for toggle buttons, no Vue 3 filters - js/customValidators.ts: notNull, notNullAndGreaterThanZero validators - stores/user.ts: 8 CRUD actions for overrides, changePassword accepts oldPassword, logout clears cueColourOverrides - router/index.ts: /me wired to SettingsView (was PlaceholderView) Co-Authored-By: Claude Sonnet 4.6 * Fix BTabs content pane width in user settings page Add content-class="flex-fill" to BTabs so the tab pane fills the available horizontal space (BVN doesn't auto-fill unlike BV2). Also add w-100 to BTableSimple in AboutUser for full-width rendering. Co-Authored-By: Claude Sonnet 4.6 * Update migration plan: Phase 4 complete + BVN layout gotchas Mark Phase 4 as done. Add two BVN-specific notes to the reference section that will apply to all future phases: - BTabs vertical requires content-class="flex-fill" to fill width - BTableSimple requires class="w-100" to be full-width Co-Authored-By: Claude Sonnet 4.6 * Remove plans/VUE3_MIGRATION_PLAN.md from git tracking File is covered by .gitignore and should not be committed. Co-Authored-By: Claude Sonnet 4.6 * Fix V3 user settings visual parity with V2 - dark.scss: override body font to Avenir (matching V2's index.html inline style) and add .b-form-group margin-bottom (BVN dropped Bootstrap 4's .form-group built-in 1rem margin) - AboutUser.vue: wrap rows in so Bootstrap 5's deep child selector (.table > :not(caption) > * > *) matches and row borders render correctly - UserSettingsConfig.vue: label-cols="auto" → label-cols="4" for consistent 33% label column across all form rows - SettingsView.vue: content-class="flex-fill text-start" to fill horizontal space and reset text-align inherited from #app { text-align: center } Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- client-v3/src/assets/styles/dark.scss | 12 + .../components/user/settings/AboutUser.vue | 29 + .../src/components/user/settings/ApiToken.vue | 207 +++++++ .../user/settings/ChangePassword.vue | 111 ++++ .../user/settings/CueColourPreferences.vue | 348 ++++++++++++ .../user/settings/StageDirectionStyles.vue | 520 ++++++++++++++++++ .../user/settings/UserSettingsConfig.vue | 218 ++++++++ client-v3/src/router/index.ts | 2 +- client-v3/src/stores/user.ts | 115 +++- client-v3/src/views/user/SettingsView.vue | 34 ++ 10 files changed, 1593 insertions(+), 3 deletions(-) create mode 100644 client-v3/src/components/user/settings/AboutUser.vue create mode 100644 client-v3/src/components/user/settings/ApiToken.vue create mode 100644 client-v3/src/components/user/settings/ChangePassword.vue create mode 100644 client-v3/src/components/user/settings/CueColourPreferences.vue create mode 100644 client-v3/src/components/user/settings/StageDirectionStyles.vue create mode 100644 client-v3/src/components/user/settings/UserSettingsConfig.vue create mode 100644 client-v3/src/views/user/SettingsView.vue diff --git a/client-v3/src/assets/styles/dark.scss b/client-v3/src/assets/styles/dark.scss index f9ceeb1e..e7b163c7 100644 --- a/client-v3/src/assets/styles/dark.scss +++ b/client-v3/src/assets/styles/dark.scss @@ -1,3 +1,15 @@ @import '../../../node_modules/bootswatch/dist/darkly/variables'; @import 'bootstrap/scss/bootstrap'; @import '../../../node_modules/bootswatch/dist/darkly/bootswatch'; + +// Match V2 font stack (from server/static/index.html inline style) +body { + font-family: Avenir, Helvetica, Arial, sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +// BVN's BFormGroup renders .b-form-group; Bootstrap 5 dropped .form-group's built-in margin +.b-form-group { + margin-bottom: 1rem; +} diff --git a/client-v3/src/components/user/settings/AboutUser.vue b/client-v3/src/components/user/settings/AboutUser.vue new file mode 100644 index 00000000..f10e72eb --- /dev/null +++ b/client-v3/src/components/user/settings/AboutUser.vue @@ -0,0 +1,29 @@ + + + diff --git a/client-v3/src/components/user/settings/ApiToken.vue b/client-v3/src/components/user/settings/ApiToken.vue new file mode 100644 index 00000000..68a9442d --- /dev/null +++ b/client-v3/src/components/user/settings/ApiToken.vue @@ -0,0 +1,207 @@ + + + + + diff --git a/client-v3/src/components/user/settings/ChangePassword.vue b/client-v3/src/components/user/settings/ChangePassword.vue new file mode 100644 index 00000000..de50f57f --- /dev/null +++ b/client-v3/src/components/user/settings/ChangePassword.vue @@ -0,0 +1,111 @@ + + + diff --git a/client-v3/src/components/user/settings/CueColourPreferences.vue b/client-v3/src/components/user/settings/CueColourPreferences.vue new file mode 100644 index 00000000..807e7361 --- /dev/null +++ b/client-v3/src/components/user/settings/CueColourPreferences.vue @@ -0,0 +1,348 @@ + + + + + diff --git a/client-v3/src/components/user/settings/StageDirectionStyles.vue b/client-v3/src/components/user/settings/StageDirectionStyles.vue new file mode 100644 index 00000000..31d6ffbf --- /dev/null +++ b/client-v3/src/components/user/settings/StageDirectionStyles.vue @@ -0,0 +1,520 @@ + + + + + diff --git a/client-v3/src/components/user/settings/UserSettingsConfig.vue b/client-v3/src/components/user/settings/UserSettingsConfig.vue new file mode 100644 index 00000000..6039c09f --- /dev/null +++ b/client-v3/src/components/user/settings/UserSettingsConfig.vue @@ -0,0 +1,218 @@ + + + diff --git a/client-v3/src/router/index.ts b/client-v3/src/router/index.ts index 04917059..63a68938 100644 --- a/client-v3/src/router/index.ts +++ b/client-v3/src/router/index.ts @@ -113,7 +113,7 @@ const router = createRouter({ { path: '/me', name: 'user-settings', - component: PlaceholderView, + component: () => import('@/views/user/SettingsView.vue'), meta: { requiresAuth: true }, }, { diff --git a/client-v3/src/stores/user.ts b/client-v3/src/stores/user.ts index 6055cdee..17ca2cf8 100644 --- a/client-v3/src/stores/user.ts +++ b/client-v3/src/stores/user.ts @@ -85,6 +85,7 @@ export const useUserStore = defineStore('user', { this.currentRbac = null; this.userSettings = {}; this.stageDirectionStyleOverrides = []; + this.cueColourOverrides = []; const { useWebSocketStore } = await import('@/stores/websocket'); useWebSocketStore().$patch({ authenticated: false, authSucceeded: false }); @@ -209,11 +210,13 @@ export const useUserStore = defineStore('user', { } }, - async changePassword(newPassword: string): Promise { + async changePassword(newPassword: string, oldPassword?: string): Promise { + const body: Record = { new_password: newPassword }; + if (oldPassword) body.old_password = oldPassword; const response = await fetch(makeURL('/api/v1/auth/change-password'), { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ new_password: newPassword }), + body: JSON.stringify(body), }); if (response.ok) { const data = await response.json(); @@ -288,5 +291,113 @@ export const useUserStore = defineStore('user', { toast.error('Unable to get API token!'); return null; }, + + async getStageDirectionStyleOverrides(): Promise { + const response = await fetch(makeURL('/api/v1/user/settings/stage_direction_overrides')); + if (response.ok) { + const data = await response.json(); + this.stageDirectionStyleOverrides = data.overrides; + } else { + log.error('Unable to load stage direction style overrides'); + } + }, + + async addStageDirectionStyleOverride(style: Record): Promise { + const response = await fetch(makeURL('/api/v1/user/settings/stage_direction_overrides'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(style), + }); + if (response.ok) { + await this.getStageDirectionStyleOverrides(); + toast.success('Added new stage direction style override!'); + } else { + log.error('Unable to add stage direction style override'); + toast.error('Unable to add new stage direction style override'); + } + }, + + async updateStageDirectionStyleOverride(style: Record): Promise { + const response = await fetch(makeURL('/api/v1/user/settings/stage_direction_overrides'), { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(style), + }); + if (response.ok) { + await this.getStageDirectionStyleOverrides(); + toast.success('Updated stage direction style override!'); + } else { + log.error('Unable to edit stage direction style override'); + toast.error('Unable to edit stage direction style override'); + } + }, + + async deleteStageDirectionStyleOverride(styleId: number): Promise { + const response = await fetch( + makeURL(`/api/v1/user/settings/stage_direction_overrides?id=${styleId}`), + { method: 'DELETE', headers: { 'Content-Type': 'application/json' } } + ); + if (response.ok) { + await this.getStageDirectionStyleOverrides(); + toast.success('Deleted stage direction style override!'); + } else { + log.error('Unable to delete stage direction style override'); + toast.error('Unable to delete stage direction style override'); + } + }, + + async getCueColourOverrides(): Promise { + const response = await fetch(makeURL('/api/v1/user/settings/cue_colour_overrides')); + if (response.ok) { + const data = await response.json(); + this.cueColourOverrides = data.overrides; + } else { + log.error('Unable to load cue colour overrides'); + } + }, + + async addCueColourOverride(override: Record): Promise { + const response = await fetch(makeURL('/api/v1/user/settings/cue_colour_overrides'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(override), + }); + if (response.ok) { + await this.getCueColourOverrides(); + toast.success('Added new cue colour override!'); + } else { + log.error('Unable to add cue colour override'); + toast.error('Unable to add new cue colour override'); + } + }, + + async updateCueColourOverride(override: Record): Promise { + const response = await fetch(makeURL('/api/v1/user/settings/cue_colour_overrides'), { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(override), + }); + if (response.ok) { + await this.getCueColourOverrides(); + toast.success('Updated cue colour override!'); + } else { + log.error('Unable to edit cue colour override'); + toast.error('Unable to edit cue colour override'); + } + }, + + async deleteCueColourOverride(overrideId: number): Promise { + const response = await fetch( + makeURL(`/api/v1/user/settings/cue_colour_overrides?id=${overrideId}`), + { method: 'DELETE', headers: { 'Content-Type': 'application/json' } } + ); + if (response.ok) { + await this.getCueColourOverrides(); + toast.success('Deleted cue colour override!'); + } else { + log.error('Unable to delete cue colour override'); + toast.error('Unable to delete cue colour override'); + } + }, }, }); diff --git a/client-v3/src/views/user/SettingsView.vue b/client-v3/src/views/user/SettingsView.vue new file mode 100644 index 00000000..322d29bd --- /dev/null +++ b/client-v3/src/views/user/SettingsView.vue @@ -0,0 +1,34 @@ + + + From e69a7a96e7ccc57e5b5ac5b6f47be8c96f02aed4 Mon Sep 17 00:00:00 2001 From: Tim Bradgate Date: Sun, 17 May 2026 11:10:45 +0100 Subject: [PATCH 06/23] =?UTF-8?q?Vue=203=20migration:=20Phase=205=20?= =?UTF-8?q?=E2=80=94=20system=20configuration=20page=20(/config)=20(#1042)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Migrate system configuration page to Vue 3 (Phase 5) (#1035) Ports the admin /config section — Shows, System, Settings, Users, Logs, Backups — from Vue 2 Vuex to Vue 3 Pinia with bootstrap-vue-next. Key patterns introduced: - BModal via ref> (replaces v-b-modal directive) - #footer slot (replaces #modal-footer in BVN) - window.confirm replaces $bvModal.msgBoxConfirm - SSE log streaming via Web Streams API (response.body.getReader()) - setTimeout-based polling with onBeforeUnmount cleanup - Dynamic Vuelidate rules computed from server-returned setting types - Cross-field date validators using helpers.withMessage Co-Authored-By: Claude Sonnet 4.6 * Add reusable ConfirmDialog composable to replace window.confirm Creates a singleton useConfirm() composable backed by a BModal-based ConfirmDialog component, replacing all window.confirm() calls with a styled, accessible modal dialog that matches the rest of the UI. ConfirmDialog is mounted once in App.vue; all callers share the same instance via module-level reactive state. Supports title, okVariant, okTitle, and cancelTitle options mirroring BV2's msgBoxConfirm API. Co-Authored-By: Claude Sonnet 4.6 * Fix ConfigView tab layout to match V2 (horizontal, lazy) V2 used plain horizontal tabs with lazy mounting, not the vertical pill layout carried over from the user settings page. Also restores lazy prop so tab content only mounts on first activation, avoiding all polling timers (sessions, users) starting simultaneously on page load. Co-Authored-By: Claude Sonnet 4.6 * Fix Settings accordion animation: use v-model instead of :visible on BCollapse BVN's BCollapse :visible prop sets localNoAnimation=true in useShowHide, bypassing the Bootstrap 5 collapsing transition entirely. Switching to :model-value/@update:model-value keeps animation enabled. Expanded state changed from string[] to Record to support v-model binding per category key. Co-Authored-By: Claude Sonnet 4.6 * Fix Settings form control spacing by removing inline margin-bottom override Bootstrap 5 has no built-in .form-group margin; the global dark.scss adds margin-bottom: 1rem to .b-form-group, but the inline style="margin-bottom: 0" copied from V2 was overriding it, causing rows to appear compacted. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- client-v3/src/App.vue | 23 +- .../src/components/common/ConfirmDialog.vue | 20 + .../src/components/config/ConfigBackups.vue | 120 ++++++ .../src/components/config/ConfigLogs.vue | 353 ++++++++++++++++++ .../src/components/config/ConfigSettings.vue | 235 ++++++++++++ .../src/components/config/ConfigShows.vue | 254 +++++++++++++ .../src/components/config/ConfigSystem.vue | 148 ++++++++ .../src/components/config/ConfigUsers.vue | 158 ++++++++ client-v3/src/components/user/ConfigRbac.vue | 58 +++ client-v3/src/components/user/CreateUser.vue | 104 ++++++ .../src/components/user/RbacResource.vue | 148 ++++++++ .../src/components/user/ResetPassword.vue | 103 +++++ client-v3/src/composables/useConfirm.ts | 42 +++ client-v3/src/router/index.ts | 2 +- client-v3/src/stores/system.ts | 63 ++++ client-v3/src/views/config/ConfigView.vue | 26 ++ 16 files changed, 1853 insertions(+), 4 deletions(-) create mode 100644 client-v3/src/components/common/ConfirmDialog.vue create mode 100644 client-v3/src/components/config/ConfigBackups.vue create mode 100644 client-v3/src/components/config/ConfigLogs.vue create mode 100644 client-v3/src/components/config/ConfigSettings.vue create mode 100644 client-v3/src/components/config/ConfigShows.vue create mode 100644 client-v3/src/components/config/ConfigSystem.vue create mode 100644 client-v3/src/components/config/ConfigUsers.vue create mode 100644 client-v3/src/components/user/ConfigRbac.vue create mode 100644 client-v3/src/components/user/CreateUser.vue create mode 100644 client-v3/src/components/user/RbacResource.vue create mode 100644 client-v3/src/components/user/ResetPassword.vue create mode 100644 client-v3/src/composables/useConfirm.ts create mode 100644 client-v3/src/views/config/ConfigView.vue diff --git a/client-v3/src/App.vue b/client-v3/src/App.vue index 4ab8cc58..490cdd3c 100644 --- a/client-v3/src/App.vue +++ b/client-v3/src/App.vue @@ -133,6 +133,8 @@ + + { async function stopShowSession(): Promise { stoppingSession.value = true; - const confirmed = window.confirm('Are you sure you want to stop the show?'); + const confirmed = await confirm('Are you sure you want to stop the show?', { + title: 'Stop Show', + okVariant: 'danger', + okTitle: 'Stop Show', + }); if (confirmed) { const response = await fetch(makeURL('/api/v1/show/sessions/stop'), { method: 'POST', @@ -281,7 +290,11 @@ async function startShowSession(): Promise { return; } startingSession.value = true; - const confirmed = window.confirm('Are you sure you want to start a show?'); + const confirmed = await confirm('Are you sure you want to start a show?', { + title: 'Start Show', + okVariant: 'success', + okTitle: 'Start Show', + }); if (confirmed) { const response = await fetch(makeURL('/api/v1/show/sessions/start'), { method: 'POST', @@ -299,7 +312,11 @@ async function startShowSession(): Promise { } async function reloadClients(): Promise { - const confirmed = window.confirm('Are you sure you want to reload all connected clients?'); + const confirmed = await confirm('Are you sure you want to reload all connected clients?', { + title: 'Reload Clients', + okVariant: 'warning', + okTitle: 'Reload All', + }); if (confirmed) { sendObj({ OP: 'RELOAD_CLIENTS', DATA: {} }); } diff --git a/client-v3/src/components/common/ConfirmDialog.vue b/client-v3/src/components/common/ConfirmDialog.vue new file mode 100644 index 00000000..e752a384 --- /dev/null +++ b/client-v3/src/components/common/ConfirmDialog.vue @@ -0,0 +1,20 @@ + + + diff --git a/client-v3/src/components/config/ConfigBackups.vue b/client-v3/src/components/config/ConfigBackups.vue new file mode 100644 index 00000000..f220184f --- /dev/null +++ b/client-v3/src/components/config/ConfigBackups.vue @@ -0,0 +1,120 @@ + + + diff --git a/client-v3/src/components/config/ConfigLogs.vue b/client-v3/src/components/config/ConfigLogs.vue new file mode 100644 index 00000000..8a00c84a --- /dev/null +++ b/client-v3/src/components/config/ConfigLogs.vue @@ -0,0 +1,353 @@ + + + diff --git a/client-v3/src/components/config/ConfigSettings.vue b/client-v3/src/components/config/ConfigSettings.vue new file mode 100644 index 00000000..6a233390 --- /dev/null +++ b/client-v3/src/components/config/ConfigSettings.vue @@ -0,0 +1,235 @@ + + + + + diff --git a/client-v3/src/components/config/ConfigShows.vue b/client-v3/src/components/config/ConfigShows.vue new file mode 100644 index 00000000..a7641d58 --- /dev/null +++ b/client-v3/src/components/config/ConfigShows.vue @@ -0,0 +1,254 @@ + + + diff --git a/client-v3/src/components/config/ConfigSystem.vue b/client-v3/src/components/config/ConfigSystem.vue new file mode 100644 index 00000000..28427d39 --- /dev/null +++ b/client-v3/src/components/config/ConfigSystem.vue @@ -0,0 +1,148 @@ + + + diff --git a/client-v3/src/components/config/ConfigUsers.vue b/client-v3/src/components/config/ConfigUsers.vue new file mode 100644 index 00000000..be7bbf27 --- /dev/null +++ b/client-v3/src/components/config/ConfigUsers.vue @@ -0,0 +1,158 @@ + + + diff --git a/client-v3/src/components/user/ConfigRbac.vue b/client-v3/src/components/user/ConfigRbac.vue new file mode 100644 index 00000000..3db7faab --- /dev/null +++ b/client-v3/src/components/user/ConfigRbac.vue @@ -0,0 +1,58 @@ + + + diff --git a/client-v3/src/components/user/CreateUser.vue b/client-v3/src/components/user/CreateUser.vue new file mode 100644 index 00000000..81f2e0b5 --- /dev/null +++ b/client-v3/src/components/user/CreateUser.vue @@ -0,0 +1,104 @@ + + + diff --git a/client-v3/src/components/user/RbacResource.vue b/client-v3/src/components/user/RbacResource.vue new file mode 100644 index 00000000..ab45e1d2 --- /dev/null +++ b/client-v3/src/components/user/RbacResource.vue @@ -0,0 +1,148 @@ + + + diff --git a/client-v3/src/components/user/ResetPassword.vue b/client-v3/src/components/user/ResetPassword.vue new file mode 100644 index 00000000..deec1cfc --- /dev/null +++ b/client-v3/src/components/user/ResetPassword.vue @@ -0,0 +1,103 @@ + + + diff --git a/client-v3/src/composables/useConfirm.ts b/client-v3/src/composables/useConfirm.ts new file mode 100644 index 00000000..a0c467a3 --- /dev/null +++ b/client-v3/src/composables/useConfirm.ts @@ -0,0 +1,42 @@ +import { ref } from 'vue'; + +export interface ConfirmOptions { + title?: string; + okVariant?: string; + okTitle?: string; + cancelTitle?: string; + size?: 'sm' | 'md' | 'lg' | 'xl'; +} + +// Module-level singleton state shared across all callers +const visible = ref(false); +const message = ref(''); +const currentOptions = ref({}); +let resolveCallback: ((value: boolean) => void) | null = null; +let resolved = false; + +export function useConfirm() { + function confirm(msg: string, opts: ConfirmOptions = {}): Promise { + message.value = msg; + currentOptions.value = opts; + resolved = false; + visible.value = true; + return new Promise((resolve) => { + resolveCallback = resolve; + }); + } + + function _handleOk(): void { + if (resolved) return; + resolved = true; + resolveCallback?.(true); + } + + function _handleHidden(): void { + if (resolved) return; + resolved = true; + resolveCallback?.(false); + } + + return { confirm, visible, message, currentOptions, _handleOk, _handleHidden }; +} diff --git a/client-v3/src/router/index.ts b/client-v3/src/router/index.ts index 63a68938..01404cb4 100644 --- a/client-v3/src/router/index.ts +++ b/client-v3/src/router/index.ts @@ -34,7 +34,7 @@ const router = createRouter({ { path: '/config', name: 'config', - component: PlaceholderView, + component: () => import('@/views/config/ConfigView.vue'), meta: { requiresAuth: true, requiresAdmin: true }, }, { diff --git a/client-v3/src/stores/system.ts b/client-v3/src/stores/system.ts index 8251bf00..f79d25e8 100644 --- a/client-v3/src/stores/system.ts +++ b/client-v3/src/stores/system.ts @@ -5,6 +5,29 @@ import type { Show } from '@/types/api/show'; import type { SystemSettings } from '@/types/api/settings'; import { useUserStore } from '@/stores/user'; +// ScriptMode moves to stores/show.ts in Phase 6 +interface ScriptMode { + value: number; + text: string; +} + +interface ConnectedSession { + internal_id: string; + remote_ip: string; + is_editor: boolean; + last_ping: string | null; + last_pong: string | null; +} + +interface VersionStatus { + current_version: string | null; + latest_version: string | null; + update_available: boolean; + release_url: string | null; + last_checked: string | null; + check_error: string | null; +} + interface RbacRole { key: string; value: number; @@ -28,6 +51,9 @@ export const useSystemStore = defineStore('system', { rbacRoles: [] as RbacRole[], settingsCategories: {} as Record, currentShow: null as Show | null, + scriptModes: [] as ScriptMode[], + connectedSessions: [] as ConnectedSession[], + versionStatus: null as VersionStatus | null, }), getters: { isAdminUser(): boolean { @@ -180,5 +206,42 @@ export const useSystemStore = defineStore('system', { log.error('Unable to fetch settings categories'); } }, + async getScriptModes() { + const response = await fetch(makeURL('/api/v1/show/script_modes')); + if (response.ok) { + const data = await response.json(); + this.scriptModes = data.script_modes ?? []; + } else { + log.error('Unable to fetch script modes'); + } + }, + async getConnectedSessions() { + const response = await fetch(makeURL('/api/v1/ws/sessions')); + if (response.ok) { + const data = await response.json(); + this.connectedSessions = data.sessions ?? []; + } else { + log.error('Unable to fetch connected sessions'); + } + }, + async getVersionStatus() { + const response = await fetch(makeURL('/api/v1/version/status')); + if (response.ok) { + this.versionStatus = await response.json(); + } else { + log.error('Unable to fetch version status'); + } + }, + async checkForUpdates() { + const response = await fetch(makeURL('/api/v1/version/check'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }); + if (response.ok) { + this.versionStatus = await response.json(); + } else { + log.error('Unable to check for updates'); + } + }, }, }); diff --git a/client-v3/src/views/config/ConfigView.vue b/client-v3/src/views/config/ConfigView.vue new file mode 100644 index 00000000..7665ef2e --- /dev/null +++ b/client-v3/src/views/config/ConfigView.vue @@ -0,0 +1,26 @@ + + + From 5bbc5b17b8c63c6391845fed498ec88a73a14c7d Mon Sep 17 00:00:00 2001 From: Tim Bradgate Date: Sun, 17 May 2026 18:32:01 +0100 Subject: [PATCH 07/23] =?UTF-8?q?Vue=203=20migration:=20Phase=206=20?= =?UTF-8?q?=E2=80=94=20show=20configuration=20foundation=20(#1043)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add stores/show.ts: full Pinia port of V2 show Vuex module (cast, characters, character groups, acts, scenes, cue types, sessions, microphones, mic allocations, script modes, session tags, stageManagerMode persisted via pinia-plugin-persistedstate) - Add js/micConflictUtils.ts: mic conflict detection utilities (ported from V2) - Add views/show/ShowConfigView.vue: sticky vertical nav sidebar + RouterView shell - Wire /show-config parent route to ShowConfigView.vue (was PlaceholderView) - Move scriptModes state/action from system.ts to show.ts; update ConfigShows.vue - Fill in GET_CAST_LIST, ELECTED_LEADER, NO_LEADER, SCRIPT_SCROLL WS action handlers Co-authored-by: Claude Sonnet 4.6 --- .../src/components/config/ConfigShows.vue | 7 +- client-v3/src/composables/useWebSocket.ts | 15 +- client-v3/src/js/micConflictUtils.ts | 283 +++++++ client-v3/src/router/index.ts | 2 +- client-v3/src/stores/show.ts | 732 ++++++++++++++++++ client-v3/src/stores/system.ts | 16 - client-v3/src/views/show/ShowConfigView.vue | 185 +++++ 7 files changed, 1220 insertions(+), 20 deletions(-) create mode 100644 client-v3/src/js/micConflictUtils.ts create mode 100644 client-v3/src/stores/show.ts create mode 100644 client-v3/src/views/show/ShowConfigView.vue diff --git a/client-v3/src/components/config/ConfigShows.vue b/client-v3/src/components/config/ConfigShows.vue index a7641d58..b51d2bce 100644 --- a/client-v3/src/components/config/ConfigShows.vue +++ b/client-v3/src/components/config/ConfigShows.vue @@ -117,11 +117,14 @@ import { BModal } from 'bootstrap-vue-next'; import log from 'loglevel'; import { makeURL } from '@/js/utils'; import { useSystemStore } from '@/stores/system'; +import { useShowStore } from '@/stores/show'; import { toast } from '@/js/toast'; import type { Show } from '@/types/api/show'; const systemStore = useSystemStore(); -const { availableShows, scriptModes, currentShow } = storeToRefs(systemStore); +const showStore = useShowStore(); +const { availableShows, currentShow } = storeToRefs(systemStore); +const { scriptModes } = storeToRefs(showStore); const loaded = ref(false); const newShowModal = ref>(); @@ -248,7 +251,7 @@ async function loadShow(show: Show): Promise { } onMounted(async () => { - await Promise.all([systemStore.getAvailableShows(), systemStore.getScriptModes()]); + await Promise.all([systemStore.getAvailableShows(), showStore.getScriptModes()]); loaded.value = true; }); diff --git a/client-v3/src/composables/useWebSocket.ts b/client-v3/src/composables/useWebSocket.ts index 2930707a..bb0e49aa 100644 --- a/client-v3/src/composables/useWebSocket.ts +++ b/client-v3/src/composables/useWebSocket.ts @@ -116,7 +116,20 @@ async function dispatchAction(action: string, data: Record): Pr window.location.reload(); }, GET_CAST_LIST: async () => { - /* handled in show store — Phase 6 */ + const { useShowStore } = await import('@/stores/show'); + await useShowStore().getCastList(); + }, + ELECTED_LEADER: async () => { + const { useShowStore } = await import('@/stores/show'); + await useShowStore().electedLeader(); + }, + NO_LEADER: async () => { + const { useShowStore } = await import('@/stores/show'); + await useShowStore().noLeader(); + }, + SCRIPT_SCROLL: async (d: Record) => { + const { useShowStore } = await import('@/stores/show'); + useShowStore().scriptScroll(d); }, }; diff --git a/client-v3/src/js/micConflictUtils.ts b/client-v3/src/js/micConflictUtils.ts new file mode 100644 index 00000000..2d810f16 --- /dev/null +++ b/client-v3/src/js/micConflictUtils.ts @@ -0,0 +1,283 @@ +import type { Act, Character, Scene, Show } from '@/types/api/show'; + +export interface SceneGraphNode { + sceneId: number; + actId: number; + sceneName: string; + actName: string; + globalPosition: number; + scenePositionInAct: number; + previousSceneInAct: number | null; + nextSceneInAct: number | null; + previousSceneInShow: number | null; + nextSceneInShow: number | null; +} + +export interface AdjacentScenes { + sameActPrev: number | null; + sameActNext: number | null; + crossActPrev: number | null; + crossActNext: number | null; +} + +export interface MicConflict { + micId: number; + sceneId: number; + sceneName: string; + actName: string; + characterId: number; + characterName: string; + adjacentSceneId: number; + adjacentSceneName: string; + adjacentActName: string; + adjacentCharacterId: number; + adjacentCharacterName: string; + severity: 'WARNING' | 'INFO'; + message: string; +} + +export interface MicConflictResult { + conflicts: MicConflict[]; + conflictsByScene: Record; + conflictsByMic: Record; +} + +// Nested dict: { micId: { sceneId: characterId | null } } +type MicAllocations = Record | null>; + +export function buildSceneGraph( + scenes: Scene[], + acts: Act[], + currentShow: Pick | null +): SceneGraphNode[] { + if (!currentShow?.first_act_id || !scenes?.length || !acts?.length) { + return []; + } + + const sceneById: Record = {}; + scenes.forEach((scene) => { + sceneById[scene.id] = scene; + }); + + const actById: Record = {}; + acts.forEach((act) => { + actById[act.id] = act; + }); + + const graph: SceneGraphNode[] = []; + const graphById: Record = {}; + let globalPosition = 0; + + let currentAct: Act | null = actById[currentShow.first_act_id]; + let previousActLastSceneId: number | null = null; + + while (currentAct != null) { + let scenePosition = 0; + let previousSceneId: number | null = null; + + let currentScene = currentAct.first_scene ? sceneById[currentAct.first_scene] : null; + + while (currentScene != null) { + const node: SceneGraphNode = { + sceneId: currentScene.id, + actId: currentScene.act!, + sceneName: currentScene.name!, + actName: currentAct.name!, + globalPosition, + scenePositionInAct: scenePosition, + previousSceneInAct: previousSceneId, + nextSceneInAct: null, + previousSceneInShow: null, + nextSceneInShow: null, + }; + + if (previousSceneId) { + const prevNode = graphById[previousSceneId]; + if (prevNode) { + prevNode.nextSceneInAct = currentScene.id; + prevNode.nextSceneInShow = currentScene.id; + node.previousSceneInShow = previousSceneId; + } + } + + if (scenePosition === 0 && previousActLastSceneId) { + const prevActLastNode = graphById[previousActLastSceneId]; + if (prevActLastNode) { + prevActLastNode.nextSceneInShow = currentScene.id; + node.previousSceneInShow = previousActLastSceneId; + } + } + + graph.push(node); + graphById[currentScene.id] = node; + + previousSceneId = currentScene.id; + currentScene = currentScene.next_scene ? sceneById[currentScene.next_scene] : null; + scenePosition++; + globalPosition++; + } + + previousActLastSceneId = previousSceneId; + currentAct = currentAct.next_act ? actById[currentAct.next_act] : null; + } + + return graph; +} + +export function getAdjacentScenes(sceneId: number, sceneGraph: SceneGraphNode[]): AdjacentScenes { + const node = sceneGraph.find((n) => n.sceneId === sceneId); + + if (!node) { + return { sameActPrev: null, sameActNext: null, crossActPrev: null, crossActNext: null }; + } + + const prevNode = node.previousSceneInShow + ? sceneGraph.find((n) => n.sceneId === node.previousSceneInShow) + : null; + const nextNode = node.nextSceneInShow + ? sceneGraph.find((n) => n.sceneId === node.nextSceneInShow) + : null; + + return { + sameActPrev: prevNode && prevNode.actId === node.actId ? prevNode.sceneId : null, + sameActNext: nextNode && nextNode.actId === node.actId ? nextNode.sceneId : null, + crossActPrev: prevNode && prevNode.actId !== node.actId ? prevNode.sceneId : null, + crossActNext: nextNode && nextNode.actId !== node.actId ? nextNode.sceneId : null, + }; +} + +export function areScenesInSameAct( + sceneId1: number, + sceneId2: number, + sceneGraph: SceneGraphNode[] +): boolean { + const node1 = sceneGraph.find((n) => n.sceneId === sceneId1); + const node2 = sceneGraph.find((n) => n.sceneId === sceneId2); + if (!node1 || !node2) return false; + return node1.actId === node2.actId; +} + +export function isSameCastMember( + characterId1: number, + characterId2: number, + characters: Character[], + castList: unknown[] +): boolean { + if (characterId1 === characterId2) return true; + + const char1 = characters.find((c) => c.id === characterId1); + const char2 = characters.find((c) => c.id === characterId2); + if (!char1 || !char2) return false; + + const castId1 = char1.cast_member?.id; + const castId2 = char2.cast_member?.id; + if (castId1 == null || castId2 == null) return false; + + return castId1 === castId2; +} + +export function getConflictSeverity( + sceneId1: number, + sceneId2: number, + sceneGraph: SceneGraphNode[] +): 'WARNING' | 'INFO' { + return areScenesInSameAct(sceneId1, sceneId2, sceneGraph) ? 'WARNING' : 'INFO'; +} + +export function detectMicConflicts( + allocations: MicAllocations, + scenes: Scene[], + acts: Act[], + currentShow: Pick | null, + characters: Character[], + castList: unknown[] +): MicConflictResult { + if (!allocations || !scenes?.length || !acts?.length || !currentShow) { + return { conflicts: [], conflictsByScene: {}, conflictsByMic: {} }; + } + + const sceneGraph = buildSceneGraph(scenes, acts, currentShow); + + if (sceneGraph.length === 0) { + return { conflicts: [], conflictsByScene: {}, conflictsByMic: {} }; + } + + const conflicts: MicConflict[] = []; + + Object.keys(allocations).forEach((micId) => { + const micAllocations = allocations[micId]; + if (!micAllocations || typeof micAllocations !== 'object') return; + + Object.keys(micAllocations).forEach((sceneId) => { + const characterId = micAllocations[sceneId]; + if (characterId == null) return; + + const sceneIdNum = parseInt(sceneId, 10); + const adjacentScenes = getAdjacentScenes(sceneIdNum, sceneGraph); + + const adjacentSceneIds = [ + adjacentScenes.sameActPrev, + adjacentScenes.sameActNext, + adjacentScenes.crossActPrev, + adjacentScenes.crossActNext, + ].filter((id): id is number => id != null); + + adjacentSceneIds.forEach((adjacentSceneId) => { + const adjacentCharacterId = micAllocations[adjacentSceneId]; + if (adjacentCharacterId == null) return; + if (adjacentCharacterId === characterId) return; + if (isSameCastMember(characterId, adjacentCharacterId, characters, castList)) return; + + const severity = getConflictSeverity(sceneIdNum, adjacentSceneId, sceneGraph); + const currentSceneNode = sceneGraph.find((n) => n.sceneId === sceneIdNum); + const adjacentSceneNode = sceneGraph.find((n) => n.sceneId === adjacentSceneId); + const char1 = characters.find((c) => c.id === characterId); + const char2 = characters.find((c) => c.id === adjacentCharacterId); + + let message = `Quick-change from "${currentSceneNode?.sceneName || 'Unknown'}"`; + if (char1 && char2) message += ` (${char1.name} → ${char2.name})`; + message += + severity === 'WARNING' + ? ' - Tight changeover required' + : ' - Interval provides changeover time'; + + const isDuplicate = conflicts.some( + (c) => + c.micId === parseInt(micId, 10) && + c.sceneId === adjacentSceneId && + c.adjacentSceneId === sceneIdNum + ); + + if (!isDuplicate) { + conflicts.push({ + micId: parseInt(micId, 10), + sceneId: sceneIdNum, + sceneName: currentSceneNode?.sceneName || 'Unknown', + actName: currentSceneNode?.actName || 'Unknown', + characterId, + characterName: char1?.name || 'Unknown', + adjacentSceneId, + adjacentSceneName: adjacentSceneNode?.sceneName || 'Unknown', + adjacentActName: adjacentSceneNode?.actName || 'Unknown', + adjacentCharacterId, + adjacentCharacterName: char2?.name || 'Unknown', + severity, + message, + }); + } + }); + }); + }); + + const conflictsByScene: Record = {}; + const conflictsByMic: Record = {}; + + conflicts.forEach((conflict) => { + if (!conflictsByScene[conflict.sceneId]) conflictsByScene[conflict.sceneId] = []; + conflictsByScene[conflict.sceneId].push(conflict); + if (!conflictsByMic[conflict.micId]) conflictsByMic[conflict.micId] = []; + conflictsByMic[conflict.micId].push(conflict); + }); + + return { conflicts, conflictsByScene, conflictsByMic }; +} diff --git a/client-v3/src/router/index.ts b/client-v3/src/router/index.ts index 01404cb4..5009eeb7 100644 --- a/client-v3/src/router/index.ts +++ b/client-v3/src/router/index.ts @@ -39,7 +39,7 @@ const router = createRouter({ }, { path: '/show-config', - component: PlaceholderView, + component: () => import('@/views/show/ShowConfigView.vue'), meta: { requiresAuth: true, requiresShowAccess: true }, children: [ { diff --git a/client-v3/src/stores/show.ts b/client-v3/src/stores/show.ts new file mode 100644 index 00000000..aebe5a9f --- /dev/null +++ b/client-v3/src/stores/show.ts @@ -0,0 +1,732 @@ +import { defineStore } from 'pinia'; +import log from 'loglevel'; +import { makeURL } from '@/js/utils'; +import { toast } from '@/js/toast'; +import { detectMicConflicts } from '@/js/micConflictUtils'; +import type { MicConflict, MicConflictResult } from '@/js/micConflictUtils'; +import type { Cast, Character, CharacterGroup, Act, Scene } from '@/types/api/show'; +import type { CueType } from '@/types/api/cues'; +import type { ShowSession, Interval, SessionTag } from '@/types/api/session'; +import type { Microphone } from '@/types/api/microphones'; +import { useSystemStore } from '@/stores/system'; + +interface ScriptMode { + key: string; + value: number; +} + +export const useShowStore = defineStore('show', { + state: () => ({ + castList: [] as Cast[], + characterList: [] as Character[], + characterGroupList: [] as CharacterGroup[], + actList: [] as Act[], + sceneList: [] as Scene[], + cueTypes: [] as CueType[], + sessions: [] as ShowSession[], + currentSession: null as ShowSession | null, + currentInterval: null as Interval | null, + sessionFollowData: {} as Record, + microphones: [] as Microphone[], + // API returns a dict keyed by mic ID (not a flat array) + micAllocations: {} as Record, + scriptModes: [] as ScriptMode[], + sessionTags: [] as SessionTag[], + stageManagerMode: false, + }), + + persist: { + pick: ['stageManagerMode'], + }, + + getters: { + castDict: (state): Record => + Object.fromEntries(state.castList.map((c) => [c.id, c])), + castById: + (state): ((id: number | null) => Cast | null) => + (id) => { + const dict: Record = Object.fromEntries(state.castList.map((c) => [c.id, c])); + return id != null ? (dict[id] ?? null) : null; + }, + + characterDict: (state): Record => + Object.fromEntries(state.characterList.map((c) => [c.id, c])), + characterById: + (state): ((id: number | null) => Character | null) => + (id) => { + const dict: Record = Object.fromEntries( + state.characterList.map((c) => [c.id, c]) + ); + return id != null ? (dict[id] ?? null) : null; + }, + + actDict: (state): Record => + Object.fromEntries(state.actList.map((a) => [a.id, a])), + actById: + (state): ((id: number | null) => Act | null) => + (id) => { + const dict: Record = Object.fromEntries(state.actList.map((a) => [a.id, a])); + return id != null ? (dict[id] ?? null) : null; + }, + + sceneDict: (state): Record => + Object.fromEntries(state.sceneList.map((s) => [s.id, s])), + sceneById: + (state): ((id: number | null) => Scene | null) => + (id) => { + const dict: Record = Object.fromEntries( + state.sceneList.map((s) => [s.id, s]) + ); + return id != null ? (dict[id] ?? null) : null; + }, + + cueTypesDict: (state): Record => + Object.fromEntries(state.cueTypes.map((ct) => [ct.id, ct])), + cueTypeById: + (state): ((id: number | null) => CueType | null) => + (id) => { + const dict: Record = Object.fromEntries( + state.cueTypes.map((ct) => [ct.id, ct]) + ); + return id != null ? (dict[id] ?? null) : null; + }, + + microphoneDict: (state): Record => + Object.fromEntries(state.microphones.map((m) => [m.id, m])), + microphoneById: + (state): ((id: number | null) => Microphone | null) => + (id) => { + const dict: Record = Object.fromEntries( + state.microphones.map((m) => [m.id, m]) + ); + return id != null ? (dict[id] ?? null) : null; + }, + + sessionTagsDict: (state): Record => + Object.fromEntries(state.sessionTags.map((t) => [t.id, t])), + sessionTagById: + (state): ((id: number | null) => SessionTag | null) => + (id) => { + const dict: Record = Object.fromEntries( + state.sessionTags.map((t) => [t.id, t]) + ); + return id != null ? (dict[id] ?? null) : null; + }, + + orderedScenes(state): Scene[] { + const currentShow = useSystemStore().currentShow; + if (!currentShow?.first_act_id || !state.sceneList.length || !state.actList.length) { + return []; + } + const actById: Record = Object.fromEntries(state.actList.map((a) => [a.id, a])); + const sceneById: Record = Object.fromEntries( + state.sceneList.map((s) => [s.id, s]) + ); + const scenes: Scene[] = []; + let currentAct: Act | null = actById[currentShow.first_act_id] ?? null; + while (currentAct != null) { + let currentScene: Scene | null = + currentAct.first_scene != null ? (sceneById[currentAct.first_scene] ?? null) : null; + while (currentScene != null) { + scenes.push(currentScene); + currentScene = + currentScene.next_scene != null ? (sceneById[currentScene.next_scene] ?? null) : null; + } + currentAct = currentAct.next_act != null ? (actById[currentAct.next_act] ?? null) : null; + } + return scenes; + }, + + micConflicts(state): MicConflictResult { + const allocationsObj: Record> = {}; + Object.keys(state.micAllocations).forEach((micId) => { + const allocs = state.micAllocations[micId]; + const sceneData: Record = {}; + if (Array.isArray(allocs)) { + allocs.forEach((alloc) => { + sceneData[String(alloc.scene_id)] = alloc.character_id; + }); + } + allocationsObj[micId] = sceneData; + }); + return detectMicConflicts( + allocationsObj, + state.sceneList, + state.actList, + useSystemStore().currentShow, + state.characterList, + state.castList + ); + }, + + conflictsByScene(): Record { + return this.micConflicts.conflictsByScene; + }, + conflictsByMic(): Record { + return this.micConflicts.conflictsByMic; + }, + micTimelineData(state): { + scenes: Scene[]; + allocations: Record; + conflicts: MicConflict[]; + microphones: Microphone[]; + characters: Character[]; + } { + return { + scenes: this.orderedScenes, + allocations: state.micAllocations, + conflicts: this.micConflicts.conflicts, + microphones: state.microphones, + characters: state.characterList, + }; + }, + }, + + actions: { + // Cast + async getCastList(): Promise { + const response = await fetch(makeURL('/api/v1/show/cast')); + if (response.ok) { + const data = await response.json(); + this.castList = data.cast; + } else { + log.error('Unable to get cast list'); + } + }, + async addCastMember(member: Partial): Promise { + const response = await fetch(makeURL('/api/v1/show/cast'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(member), + }); + if (response.ok) { + await this.getCastList(); + toast.success('Added new cast member!'); + } else { + log.error('Unable to add new cast member'); + toast.error('Unable to add new cast member'); + } + }, + async updateCastMember(member: Partial): Promise { + const response = await fetch(makeURL('/api/v1/show/cast'), { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(member), + }); + if (response.ok) { + await this.getCastList(); + toast.success('Updated cast member!'); + } else { + log.error('Unable to edit cast member'); + toast.error('Unable to edit cast member'); + } + }, + async deleteCastMember(id: number): Promise { + const params = new URLSearchParams({ id: String(id) }); + const response = await fetch(`${makeURL('/api/v1/show/cast')}?${params}`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + }); + if (response.ok) { + await this.getCastList(); + toast.success('Deleted cast member!'); + } else { + log.error('Unable to delete cast member'); + toast.error('Unable to delete cast member'); + } + }, + + // Characters + async getCharacterList(): Promise { + const response = await fetch(makeURL('/api/v1/show/character')); + if (response.ok) { + const data = await response.json(); + this.characterList = data.characters; + } else { + log.error('Unable to get characters list'); + } + }, + async addCharacter(character: Partial): Promise { + const response = await fetch(makeURL('/api/v1/show/character'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(character), + }); + if (response.ok) { + await this.getCharacterList(); + toast.success('Added new character!'); + } else { + log.error('Unable to add new character'); + toast.error('Unable to add new character'); + } + }, + async updateCharacter(character: Partial): Promise { + const response = await fetch(makeURL('/api/v1/show/character'), { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(character), + }); + if (response.ok) { + await this.getCharacterList(); + toast.success('Updated character!'); + } else { + log.error('Unable to edit character'); + toast.error('Unable to edit character'); + } + }, + async deleteCharacter(id: number): Promise { + const params = new URLSearchParams({ id: String(id) }); + const response = await fetch(`${makeURL('/api/v1/show/character')}?${params}`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + }); + if (response.ok) { + await this.getCharacterList(); + toast.success('Deleted character!'); + } else { + log.error('Unable to delete character'); + toast.error('Unable to delete character'); + } + }, + + // Character Groups + async getCharacterGroupList(): Promise { + const response = await fetch(makeURL('/api/v1/show/character/group')); + if (response.ok) { + const data = await response.json(); + this.characterGroupList = data.character_groups; + await this.getCharacterList(); + } else { + log.error('Unable to get character groups list'); + } + }, + async addCharacterGroup(group: Partial): Promise { + const response = await fetch(makeURL('/api/v1/show/character/group'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(group), + }); + if (response.ok) { + await this.getCharacterGroupList(); + toast.success('Added new character group!'); + } else { + log.error('Unable to add new character group'); + toast.error('Unable to add new character group'); + } + }, + async updateCharacterGroup(group: Partial): Promise { + const response = await fetch(makeURL('/api/v1/show/character/group'), { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(group), + }); + if (response.ok) { + await this.getCharacterGroupList(); + toast.success('Updated character group!'); + } else { + log.error('Unable to edit character group'); + toast.error('Unable to edit character group'); + } + }, + async deleteCharacterGroup(id: number): Promise { + const params = new URLSearchParams({ id: String(id) }); + const response = await fetch(`${makeURL('/api/v1/show/character/group')}?${params}`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + }); + if (response.ok) { + await this.getCharacterGroupList(); + toast.success('Deleted character group!'); + } else { + log.error('Unable to delete character group'); + toast.error('Unable to delete character group'); + } + }, + + // Acts + async getActList(): Promise { + const response = await fetch(makeURL('/api/v1/show/act')); + if (response.ok) { + const data = await response.json(); + this.actList = data.acts; + } else { + log.error('Unable to get acts list'); + } + }, + async addAct(act: Partial): Promise { + const response = await fetch(makeURL('/api/v1/show/act'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(act), + }); + if (response.ok) { + await this.getActList(); + toast.success('Added new act!'); + } else { + log.error('Unable to add new act'); + toast.error('Unable to add new act'); + } + }, + async updateAct(act: Partial): Promise { + const response = await fetch(makeURL('/api/v1/show/act'), { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(act), + }); + if (response.ok) { + await this.getActList(); + toast.success('Updated act!'); + } else { + log.error('Unable to edit act'); + toast.error('Unable to edit act'); + } + }, + async deleteAct(id: number): Promise { + const params = new URLSearchParams({ id: String(id) }); + const response = await fetch(`${makeURL('/api/v1/show/act')}?${params}`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + }); + if (response.ok) { + await this.getActList(); + toast.success('Deleted act!'); + } else { + log.error('Unable to delete act'); + toast.error('Unable to delete act'); + } + }, + async setActFirstScene(act: Partial): Promise { + const response = await fetch(makeURL('/api/v1/show/act/first_scene'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(act), + }); + if (response.ok) { + await this.getActList(); + toast.success('Updated act!'); + } else { + log.error('Unable to edit act'); + toast.error('Unable to edit act'); + } + }, + + // Scenes + async getSceneList(): Promise { + const response = await fetch(makeURL('/api/v1/show/scene')); + if (response.ok) { + const data = await response.json(); + this.sceneList = data.scenes; + } else { + log.error('Unable to get scenes list'); + } + }, + async addScene(scene: Partial): Promise { + const response = await fetch(makeURL('/api/v1/show/scene'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(scene), + }); + if (response.ok) { + await this.getSceneList(); + await this.getActList(); + toast.success('Added new scene!'); + } else { + log.error('Unable to add new scene'); + toast.error('Unable to add new scene'); + } + }, + async updateScene(scene: Partial): Promise { + const response = await fetch(makeURL('/api/v1/show/scene'), { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(scene), + }); + if (response.ok) { + await this.getSceneList(); + await this.getActList(); + toast.success('Updated scene!'); + } else { + log.error('Unable to edit scene'); + toast.error('Unable to edit scene'); + } + }, + async deleteScene(id: number): Promise { + const params = new URLSearchParams({ id: String(id) }); + const response = await fetch(`${makeURL('/api/v1/show/scene')}?${params}`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + }); + if (response.ok) { + await this.getSceneList(); + await this.getActList(); + toast.success('Deleted scene!'); + } else { + log.error('Unable to delete scene'); + toast.error('Unable to delete scene'); + } + }, + + // Cue Types + async getCueTypes(): Promise { + const response = await fetch(makeURL('/api/v1/show/cues/types')); + if (response.ok) { + const data = await response.json(); + this.cueTypes = data.cue_types; + } else { + log.error('Unable to get cue types'); + } + }, + async addCueType(cueType: Partial): Promise { + const response = await fetch(makeURL('/api/v1/show/cues/types'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(cueType), + }); + if (response.ok) { + await this.getCueTypes(); + toast.success('Added new cue type!'); + } else { + log.error('Unable to add new cue type'); + toast.error('Unable to add new cue type'); + } + }, + async updateCueType(cueType: Partial): Promise { + const response = await fetch(makeURL('/api/v1/show/cues/types'), { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(cueType), + }); + if (response.ok) { + await this.getCueTypes(); + toast.success('Updated cue type!'); + } else { + log.error('Unable to edit cue type'); + toast.error('Unable to edit cue type'); + } + }, + async deleteCueType(id: number): Promise { + const params = new URLSearchParams({ id: String(id) }); + const response = await fetch(`${makeURL('/api/v1/show/cues/types')}?${params}`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + }); + if (response.ok) { + await this.getCueTypes(); + toast.success('Deleted cue type!'); + } else { + log.error('Unable to delete cue type'); + toast.error('Unable to delete cue type'); + } + }, + async getImportableCueTypes(): Promise { + const response = await fetch(makeURL('/api/v1/show/cues/types/import')); + if (!response.ok) { + log.error('Unable to fetch importable cue types'); + throw new Error('Failed to fetch importable cue types'); + } + return response.json(); + }, + + // Sessions + async getShowSessionData(): Promise { + const response = await fetch(makeURL('/api/v1/show/sessions')); + if (response.ok) { + const data = await response.json(); + this.sessions = data.sessions; + this.currentSession = data.current_session; + this.currentInterval = data.current_interval; + } else { + log.error('Unable to get show sessions'); + } + }, + + // Microphones + async getMicrophoneList(): Promise { + const response = await fetch(makeURL('/api/v1/show/microphones')); + if (response.ok) { + const data = await response.json(); + this.microphones = data.microphones; + } else { + log.error('Unable to get microphone list'); + } + }, + async addMicrophone(microphone: Partial): Promise { + const response = await fetch(makeURL('/api/v1/show/microphones'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(microphone), + }); + if (response.ok) { + await this.getMicrophoneList(); + toast.success('Added new microphone!'); + } else { + log.error('Unable to add new microphone'); + toast.error('Unable to add new microphone'); + } + }, + async updateMicrophone(microphone: Partial): Promise { + const response = await fetch(makeURL('/api/v1/show/microphones'), { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(microphone), + }); + if (response.ok) { + await this.getMicrophoneList(); + toast.success('Updated microphone!'); + } else { + log.error('Unable to edit microphone'); + toast.error('Unable to edit microphone'); + } + }, + async deleteMicrophone(id: number): Promise { + const params = new URLSearchParams({ id: String(id) }); + const response = await fetch(`${makeURL('/api/v1/show/microphones')}?${params}`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + }); + if (response.ok) { + await this.getMicrophoneList(); + toast.success('Deleted microphone!'); + } else { + log.error('Unable to delete microphone'); + toast.error('Unable to delete microphone'); + } + }, + async getMicAllocations(): Promise { + const response = await fetch(makeURL('/api/v1/show/microphones/allocations')); + if (response.ok) { + const data = await response.json(); + this.micAllocations = data.allocations; + } else { + log.error('Unable to get microphone allocations'); + } + }, + async updateMicAllocations(allocations: unknown): Promise { + const response = await fetch(makeURL('/api/v1/show/microphones/allocations'), { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(allocations), + }); + if (response.ok) { + await this.getMicAllocations(); + toast.success('Updated microphone allocations!'); + } else { + log.error('Unable to edit microphone allocations'); + toast.error('Unable to edit microphone allocations'); + } + }, + + // Script Modes + async getScriptModes(): Promise { + const response = await fetch(makeURL('/api/v1/show/script_modes')); + if (response.ok) { + const data = await response.json(); + this.scriptModes = data.script_modes ?? []; + } else { + log.error('Unable to fetch script modes'); + } + }, + + // Session Tags + async getSessionTags(): Promise { + const response = await fetch(makeURL('/api/v1/show/session/tags')); + if (response.ok) { + const data = await response.json(); + this.sessionTags = data.tags; + } else { + log.error('Unable to get session tags'); + } + }, + async addSessionTag(tag: Partial): Promise { + const response = await fetch(makeURL('/api/v1/show/session/tags'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(tag), + }); + if (response.ok) { + await this.getSessionTags(); + toast.success('Added new session tag!'); + } else { + log.error('Unable to add session tag'); + toast.error('Unable to add session tag'); + } + }, + async updateSessionTag(tag: Partial): Promise { + const response = await fetch(makeURL('/api/v1/show/session/tags'), { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(tag), + }); + if (response.ok) { + await this.getSessionTags(); + toast.success('Updated session tag!'); + } else { + log.error('Unable to edit session tag'); + toast.error('Unable to edit session tag'); + } + }, + async deleteSessionTag(id: number): Promise { + const response = await fetch(`${makeURL('/api/v1/show/session/tags')}?id=${id}`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + }); + if (response.ok) { + await this.getSessionTags(); + toast.success('Deleted session tag!'); + } else { + log.error('Unable to delete session tag'); + toast.error('Unable to delete session tag'); + } + }, + async getImportableSessionTags(): Promise { + const response = await fetch(makeURL('/api/v1/show/session/tags/import')); + if (!response.ok) { + log.error('Unable to fetch importable session tags'); + throw new Error('Failed to fetch importable session tags'); + } + return response.json(); + }, + async updateSessionTags({ + sessionId, + tagIds, + }: { + sessionId: number; + tagIds: number[]; + }): Promise { + const response = await fetch(makeURL('/api/v1/show/sessions/assign-tags'), { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ session_id: sessionId, tag_ids: tagIds }), + }); + if (response.ok) { + await this.getShowSessionData(); + toast.success('Updated session tags!'); + } else { + const errorData: { message?: string } = await response.json().catch(() => ({})); + log.error('Unable to update session tags:', errorData); + toast.error(errorData.message || 'Unable to update session tags'); + throw new Error('Failed to update session tags'); + } + }, + + clearCurrentShow(): void { + this.castList = []; + this.characterList = []; + this.actList = []; + this.sceneList = []; + this.sessionTags = []; + }, + + // WS-triggered actions + async electedLeader(): Promise { + toast.info('You are now leader of the script - other clients will follow your view'); + }, + async noLeader(): Promise { + await this.getShowSessionData(); + toast.warning('There is no script leader. Please scroll your own script!'); + }, + scriptScroll(data: Record): void { + this.sessionFollowData = data; + }, + }, +}); diff --git a/client-v3/src/stores/system.ts b/client-v3/src/stores/system.ts index f79d25e8..f60e50ed 100644 --- a/client-v3/src/stores/system.ts +++ b/client-v3/src/stores/system.ts @@ -5,12 +5,6 @@ import type { Show } from '@/types/api/show'; import type { SystemSettings } from '@/types/api/settings'; import { useUserStore } from '@/stores/user'; -// ScriptMode moves to stores/show.ts in Phase 6 -interface ScriptMode { - value: number; - text: string; -} - interface ConnectedSession { internal_id: string; remote_ip: string; @@ -51,7 +45,6 @@ export const useSystemStore = defineStore('system', { rbacRoles: [] as RbacRole[], settingsCategories: {} as Record, currentShow: null as Show | null, - scriptModes: [] as ScriptMode[], connectedSessions: [] as ConnectedSession[], versionStatus: null as VersionStatus | null, }), @@ -206,15 +199,6 @@ export const useSystemStore = defineStore('system', { log.error('Unable to fetch settings categories'); } }, - async getScriptModes() { - const response = await fetch(makeURL('/api/v1/show/script_modes')); - if (response.ok) { - const data = await response.json(); - this.scriptModes = data.script_modes ?? []; - } else { - log.error('Unable to fetch script modes'); - } - }, async getConnectedSessions() { const response = await fetch(makeURL('/api/v1/ws/sessions')); if (response.ok) { diff --git a/client-v3/src/views/show/ShowConfigView.vue b/client-v3/src/views/show/ShowConfigView.vue new file mode 100644 index 00000000..729d78ce --- /dev/null +++ b/client-v3/src/views/show/ShowConfigView.vue @@ -0,0 +1,185 @@ + + + + + From 4792a8f807df3107975f34340ed2e9ba009f7f65 Mon Sep 17 00:00:00 2001 From: Tim Bradgate Date: Sun, 17 May 2026 19:09:08 +0100 Subject: [PATCH 08/23] =?UTF-8?q?Vue=203=20migration:=20Phase=207=20?= =?UTF-8?q?=E2=80=94=20show=20configuration=20basic=20tabs=20(#1044)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the four core show config tabs (Show, Acts & Scenes, Cast, Characters) to Vue 3 + BVN, using Pinia, Vuelidate, and vue-multiselect. - Add getShowDetails() + updateShow() actions to systemStore - Add useStatsTable() composable (port of statsTableMixin) - ConfigShow: detail table with titleCase keys + edit modal - ConfigActsAndScenes: Acts CRUD (linked-list order, loop validator) and Scenes CRUD (2-column layout, per-act previous/first scene) - ConfigCast: cast list CRUD + CastLineStats (dynamic act/scene cols) - ConfigCharacters: character CRUD + CharacterGroups (vue-multiselect) + CharacterLineStats - Wire four child routes in router; remaining placeholders unchanged Co-authored-by: Claude Sonnet 4.6 --- .../config/acts_and_scenes/ConfigActs.vue | 346 ++++++++++++ .../config/acts_and_scenes/ConfigScenes.vue | 530 ++++++++++++++++++ .../show/config/cast/CastLineStats.vue | 100 ++++ .../config/characters/CharacterGroups.vue | 289 ++++++++++ .../config/characters/CharacterLineStats.vue | 105 ++++ client-v3/src/composables/useStatsTable.ts | 52 ++ client-v3/src/router/index.ts | 8 +- client-v3/src/stores/system.ts | 23 + client-v3/src/types/api/show.ts | 7 +- .../views/show/config/ConfigActsAndScenes.vue | 21 + .../src/views/show/config/ConfigCast.vue | 257 +++++++++ .../views/show/config/ConfigCharacters.vue | 295 ++++++++++ .../src/views/show/config/ConfigShow.vue | 200 +++++++ 13 files changed, 2227 insertions(+), 6 deletions(-) create mode 100644 client-v3/src/components/show/config/acts_and_scenes/ConfigActs.vue create mode 100644 client-v3/src/components/show/config/acts_and_scenes/ConfigScenes.vue create mode 100644 client-v3/src/components/show/config/cast/CastLineStats.vue create mode 100644 client-v3/src/components/show/config/characters/CharacterGroups.vue create mode 100644 client-v3/src/components/show/config/characters/CharacterLineStats.vue create mode 100644 client-v3/src/composables/useStatsTable.ts create mode 100644 client-v3/src/views/show/config/ConfigActsAndScenes.vue create mode 100644 client-v3/src/views/show/config/ConfigCast.vue create mode 100644 client-v3/src/views/show/config/ConfigCharacters.vue create mode 100644 client-v3/src/views/show/config/ConfigShow.vue diff --git a/client-v3/src/components/show/config/acts_and_scenes/ConfigActs.vue b/client-v3/src/components/show/config/acts_and_scenes/ConfigActs.vue new file mode 100644 index 00000000..9f9de143 --- /dev/null +++ b/client-v3/src/components/show/config/acts_and_scenes/ConfigActs.vue @@ -0,0 +1,346 @@ + + + diff --git a/client-v3/src/components/show/config/acts_and_scenes/ConfigScenes.vue b/client-v3/src/components/show/config/acts_and_scenes/ConfigScenes.vue new file mode 100644 index 00000000..eb7f4b5b --- /dev/null +++ b/client-v3/src/components/show/config/acts_and_scenes/ConfigScenes.vue @@ -0,0 +1,530 @@ + + + diff --git a/client-v3/src/components/show/config/cast/CastLineStats.vue b/client-v3/src/components/show/config/cast/CastLineStats.vue new file mode 100644 index 00000000..c23fbb9d --- /dev/null +++ b/client-v3/src/components/show/config/cast/CastLineStats.vue @@ -0,0 +1,100 @@ + + + diff --git a/client-v3/src/components/show/config/characters/CharacterGroups.vue b/client-v3/src/components/show/config/characters/CharacterGroups.vue new file mode 100644 index 00000000..848d1261 --- /dev/null +++ b/client-v3/src/components/show/config/characters/CharacterGroups.vue @@ -0,0 +1,289 @@ + + + diff --git a/client-v3/src/components/show/config/characters/CharacterLineStats.vue b/client-v3/src/components/show/config/characters/CharacterLineStats.vue new file mode 100644 index 00000000..0bbeb020 --- /dev/null +++ b/client-v3/src/components/show/config/characters/CharacterLineStats.vue @@ -0,0 +1,105 @@ + + + diff --git a/client-v3/src/composables/useStatsTable.ts b/client-v3/src/composables/useStatsTable.ts new file mode 100644 index 00000000..c4392289 --- /dev/null +++ b/client-v3/src/composables/useStatsTable.ts @@ -0,0 +1,52 @@ +import { computed } from 'vue'; +import { useSystemStore } from '@/stores/system'; +import { useShowStore } from '@/stores/show'; +import type { Act, Scene } from '@/types/api/show'; + +export function useStatsTable() { + const systemStore = useSystemStore(); + const showStore = useShowStore(); + + const sortedActs = computed((): Act[] => { + const show = systemStore.currentShow; + if (show?.first_act_id == null) return []; + let current = showStore.actById(show.first_act_id); + const acts: Act[] = []; + while (current != null) { + acts.push(current); + current = showStore.actById(current.next_act); + } + return acts; + }); + + const sortedScenes = computed((): Scene[] => { + const show = systemStore.currentShow; + if (show?.first_act_id == null) return []; + let currentAct = showStore.actById(show.first_act_id); + if (currentAct == null || currentAct.first_scene == null) return []; + const scenes: Scene[] = []; + while (currentAct != null) { + let currentScene = showStore.sceneById(currentAct.first_scene); + while (currentScene != null) { + scenes.push(currentScene); + currentScene = showStore.sceneById(currentScene.next_scene); + } + currentAct = showStore.actById(currentAct.next_act); + } + return scenes; + }); + + function numScenesPerAct(actId: number): number { + return sortedScenes.value.filter((scene) => scene.act === actId).length; + } + + function getHeaderName(sceneId: number): string { + return `head(${sceneId})`; + } + + function getCellName(sceneId: number): string { + return `cell(${sceneId})`; + } + + return { sortedActs, sortedScenes, numScenesPerAct, getHeaderName, getCellName }; +} diff --git a/client-v3/src/router/index.ts b/client-v3/src/router/index.ts index 5009eeb7..ebe32a63 100644 --- a/client-v3/src/router/index.ts +++ b/client-v3/src/router/index.ts @@ -45,13 +45,13 @@ const router = createRouter({ { name: 'show-config', path: '', - component: PlaceholderView, + component: () => import('@/views/show/config/ConfigShow.vue'), meta: { requiresAuth: true, requiresShowAccess: true }, }, { name: 'show-config-cast', path: 'cast', - component: PlaceholderView, + component: () => import('@/views/show/config/ConfigCast.vue'), meta: { requiresAuth: true, requiresShowAccess: true }, }, { @@ -63,13 +63,13 @@ const router = createRouter({ { name: 'show-config-characters', path: 'characters', - component: PlaceholderView, + component: () => import('@/views/show/config/ConfigCharacters.vue'), meta: { requiresAuth: true, requiresShowAccess: true }, }, { name: 'show-config-acts-scenes', path: 'acts', - component: PlaceholderView, + component: () => import('@/views/show/config/ConfigActsAndScenes.vue'), meta: { requiresAuth: true, requiresShowAccess: true }, }, { diff --git a/client-v3/src/stores/system.ts b/client-v3/src/stores/system.ts index f60e50ed..3f837d48 100644 --- a/client-v3/src/stores/system.ts +++ b/client-v3/src/stores/system.ts @@ -1,6 +1,7 @@ import { defineStore } from 'pinia'; import log from 'loglevel'; import { makeURL } from '@/js/utils'; +import { toast } from '@/js/toast'; import type { Show } from '@/types/api/show'; import type { SystemSettings } from '@/types/api/settings'; import { useUserStore } from '@/stores/user'; @@ -181,6 +182,28 @@ export const useSystemStore = defineStore('system', { this.currentShow = null; } }, + async getShowDetails(): Promise { + const response = await fetch(makeURL('/api/v1/show')); + if (response.ok) { + this.currentShow = await response.json(); + } else { + log.error('Unable to get show details'); + } + }, + async updateShow(showDetails: Partial): Promise { + const response = await fetch(makeURL('/api/v1/show'), { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(showDetails), + }); + if (response.ok) { + await this.getShowDetails(); + toast.success('Updated show!'); + } else { + log.error('Unable to edit show'); + toast.error('Unable to edit show'); + } + }, async getRbacRoles() { const response = await fetch(makeURL('/api/v1/rbac/roles')); if (response.ok) { diff --git a/client-v3/src/types/api/show.ts b/client-v3/src/types/api/show.ts index 92fc7f21..72ad465f 100644 --- a/client-v3/src/types/api/show.ts +++ b/client-v3/src/types/api/show.ts @@ -32,9 +32,10 @@ export interface CharacterGroup { show_id: number | null; name: string | null; description: string | null; + characters: number[]; } -// first_scene and next_act are serialized as IDs by the marshmallow schema +// first_scene, next_act, and previous_act are serialized as IDs by the marshmallow schema export interface Act { id: number; show_id: number | null; @@ -42,13 +43,15 @@ export interface Act { interval_after: boolean | null; first_scene: number | null; next_act: number | null; + previous_act: number | null; } -// act and next_scene are serialized as IDs by the marshmallow schema +// act, next_scene, and previous_scene are serialized as IDs by the marshmallow schema export interface Scene { id: number; show_id: number | null; act: number | null; name: string | null; next_scene: number | null; + previous_scene: number | null; } diff --git a/client-v3/src/views/show/config/ConfigActsAndScenes.vue b/client-v3/src/views/show/config/ConfigActsAndScenes.vue new file mode 100644 index 00000000..eaeef33c --- /dev/null +++ b/client-v3/src/views/show/config/ConfigActsAndScenes.vue @@ -0,0 +1,21 @@ + + + diff --git a/client-v3/src/views/show/config/ConfigCast.vue b/client-v3/src/views/show/config/ConfigCast.vue new file mode 100644 index 00000000..da9cd381 --- /dev/null +++ b/client-v3/src/views/show/config/ConfigCast.vue @@ -0,0 +1,257 @@ + + + diff --git a/client-v3/src/views/show/config/ConfigCharacters.vue b/client-v3/src/views/show/config/ConfigCharacters.vue new file mode 100644 index 00000000..4a37dfd8 --- /dev/null +++ b/client-v3/src/views/show/config/ConfigCharacters.vue @@ -0,0 +1,295 @@ + + + diff --git a/client-v3/src/views/show/config/ConfigShow.vue b/client-v3/src/views/show/config/ConfigShow.vue new file mode 100644 index 00000000..944a4ed5 --- /dev/null +++ b/client-v3/src/views/show/config/ConfigShow.vue @@ -0,0 +1,200 @@ + + + + + From ac335d20a702a95832fd203a996492323f4a2423 Mon Sep 17 00:00:00 2001 From: Tim Bradgate Date: Sun, 17 May 2026 21:09:48 +0100 Subject: [PATCH 09/23] =?UTF-8?q?Vue=203=20migration:=20Phase=208=20?= =?UTF-8?q?=E2=80=94=20Cues=20&=20Sessions=20config=20tabs=20(#1045)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Vue 3 migration: Phase 8 — cue types and session management (#phase8) Port ConfigCues (cue types CRUD + import + counts stats) and ConfigSessions (session list with start/stop + session tag CRUD + import) to Vue 3. Adds useCueDisplay composable, scriptRevisions state + getter + action to show store, and contrast-color type declarations. Cue Configuration tab deferred to Phase 11. Co-Authored-By: Claude Sonnet 4.6 * Fix getScriptRevisions API unwrapping and contrastColor strict-ESM crash getScriptRevisions was storing the raw API response object instead of response.revisions. The contrast-color library's standalone function uses `this.namedColors` internally which is undefined in strict ESM — replaced with an inline YIQ formula in utils.ts used by all session + cue components. Co-Authored-By: Claude Sonnet 4.6 * Replace explicit WS actionMap with convention-based Pinia store dispatch WS ACTION names (SCREAMING_SNAKE_CASE) are automatically routed to the matching camelCase action on any instantiated Pinia store, so adding a new store action is all that's needed to handle the corresponding WS event — no registration or map entries required. Only four special cases remain for actions that can't follow the naming convention: TOKEN_REFRESH, SHOW_CHANGED, USER_LOGOUT, WS_SETTINGS_CHANGED. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- .../show/config/cues/CueCountStats.vue | 99 +++++ .../show/config/sessions/SessionList.vue | 156 +++++++ .../config/sessions/SessionTagDropdown.vue | 147 +++++++ .../show/config/sessions/SessionTagList.vue | 415 ++++++++++++++++++ .../user/settings/CueColourPreferences.vue | 8 +- client-v3/src/composables/useCueDisplay.ts | 28 ++ client-v3/src/composables/useWebSocket.ts | 84 ++-- client-v3/src/js/utils.ts | 10 + client-v3/src/router/index.ts | 4 +- client-v3/src/stores/show.ts | 19 + .../src/views/show/config/ConfigCues.vue | 415 ++++++++++++++++++ .../src/views/show/config/ConfigSessions.vue | 40 ++ 12 files changed, 1379 insertions(+), 46 deletions(-) create mode 100644 client-v3/src/components/show/config/cues/CueCountStats.vue create mode 100644 client-v3/src/components/show/config/sessions/SessionList.vue create mode 100644 client-v3/src/components/show/config/sessions/SessionTagDropdown.vue create mode 100644 client-v3/src/components/show/config/sessions/SessionTagList.vue create mode 100644 client-v3/src/composables/useCueDisplay.ts create mode 100644 client-v3/src/views/show/config/ConfigCues.vue create mode 100644 client-v3/src/views/show/config/ConfigSessions.vue diff --git a/client-v3/src/components/show/config/cues/CueCountStats.vue b/client-v3/src/components/show/config/cues/CueCountStats.vue new file mode 100644 index 00000000..2193462f --- /dev/null +++ b/client-v3/src/components/show/config/cues/CueCountStats.vue @@ -0,0 +1,99 @@ + + + diff --git a/client-v3/src/components/show/config/sessions/SessionList.vue b/client-v3/src/components/show/config/sessions/SessionList.vue new file mode 100644 index 00000000..fecc0de6 --- /dev/null +++ b/client-v3/src/components/show/config/sessions/SessionList.vue @@ -0,0 +1,156 @@ + + + + + diff --git a/client-v3/src/components/show/config/sessions/SessionTagDropdown.vue b/client-v3/src/components/show/config/sessions/SessionTagDropdown.vue new file mode 100644 index 00000000..43394708 --- /dev/null +++ b/client-v3/src/components/show/config/sessions/SessionTagDropdown.vue @@ -0,0 +1,147 @@ + + + + + + + diff --git a/client-v3/src/components/show/config/sessions/SessionTagList.vue b/client-v3/src/components/show/config/sessions/SessionTagList.vue new file mode 100644 index 00000000..03579508 --- /dev/null +++ b/client-v3/src/components/show/config/sessions/SessionTagList.vue @@ -0,0 +1,415 @@ + + + + + diff --git a/client-v3/src/components/user/settings/CueColourPreferences.vue b/client-v3/src/components/user/settings/CueColourPreferences.vue index 807e7361..37ab38af 100644 --- a/client-v3/src/components/user/settings/CueColourPreferences.vue +++ b/client-v3/src/components/user/settings/CueColourPreferences.vue @@ -28,7 +28,7 @@ class="cue-button-example" :style="{ 'background-color': data.item.settings.colour, - color: contrastColor({ bgColor: data.item.settings.colour }), + color: contrastColor(data.item.settings.colour ?? '#ffffff'), }" > {{ cueTypes.find((t) => t.id === data.item.settings.id)?.prefix }} @@ -85,7 +85,7 @@ class="cue-button-example" :style="{ 'background-color': newFormState.colour, - color: contrastColor({ bgColor: newFormState.colour }), + color: contrastColor(newFormState.colour), }" > {{ newFormCueTypePrefix }} @@ -123,7 +123,7 @@ class="cue-button-example" :style="{ 'background-color': editFormState.colour, - color: contrastColor({ bgColor: editFormState.colour }), + color: contrastColor(editFormState.colour), }" > {{ editFormCueTypePrefix }} @@ -164,7 +164,7 @@ import { ref, computed, onMounted } from 'vue'; import type { BModal } from 'bootstrap-vue-next'; import { useVuelidate } from '@vuelidate/core'; import { required } from '@vuelidate/validators'; -import { contrastColor } from 'contrast-color'; +import { contrastColor } from '@/js/utils'; import log from 'loglevel'; import { makeURL } from '@/js/utils'; import { useUserStore } from '@/stores/user'; diff --git a/client-v3/src/composables/useCueDisplay.ts b/client-v3/src/composables/useCueDisplay.ts new file mode 100644 index 00000000..71e24797 --- /dev/null +++ b/client-v3/src/composables/useCueDisplay.ts @@ -0,0 +1,28 @@ +import { contrastColor } from '@/js/utils'; +import { useShowStore } from '@/stores/show'; +import { useUserStore } from '@/stores/user'; +import type { Cue } from '@/types/api/cues'; + +export function useCueDisplay() { + const showStore = useShowStore(); + const userStore = useUserStore(); + + function cuePrefix(cue: Cue): string | null { + return showStore.cueTypeById(cue.cue_type_id)?.prefix ?? null; + } + + function cueLabel(cue: Cue): string { + const prefix = cuePrefix(cue); + return prefix ? `${prefix} ${cue.ident}` : (cue.ident ?? ''); + } + + function cueBackgroundColour(cue: Cue): string { + const cueType = showStore.cueTypeById(cue.cue_type_id); + if (!cueType) return '#000000'; + const override = userStore.cueColourOverrides.find((o) => o.cue_type_id === cueType.id); + if (override?.colour) return override.colour; + return cueType.colour ?? '#000000'; + } + + return { cuePrefix, cueLabel, cueBackgroundColour, contrastColor }; +} diff --git a/client-v3/src/composables/useWebSocket.ts b/client-v3/src/composables/useWebSocket.ts index bb0e49aa..0afdc68b 100644 --- a/client-v3/src/composables/useWebSocket.ts +++ b/client-v3/src/composables/useWebSocket.ts @@ -1,5 +1,6 @@ import log from 'loglevel'; import { debounce } from 'lodash'; +import { getActivePinia } from 'pinia'; import { toast } from '@/js/toast'; import { useWebSocketStore } from '@/stores/websocket'; import { useSystemStore } from '@/stores/system'; @@ -98,51 +99,54 @@ async function handleMessage(msg: WsMessage): Promise { } } +// Converts SCREAMING_SNAKE_CASE WS action names to camelCase Pinia action names. +// e.g. GET_CUE_TYPES → getCueTypes, ELECTED_LEADER → electedLeader +function screamingToCamel(s: string): string { + return s.toLowerCase().replace(/_([a-z])/g, (_, c: string) => c.toUpperCase()); +} + async function dispatchAction(action: string, data: Record): Promise { - const userStore = useUserStore(); - const systemStore = useSystemStore(); - const wsStore = useWebSocketStore(); + // Actions that can't be auto-routed by naming convention + if (action === 'TOKEN_REFRESH') { + const payload = data as { DATA: { access_token: string } }; + await useUserStore().tokenRefreshFromServer(payload.DATA.access_token); + return; + } + if (action === 'SHOW_CHANGED') { + const userStore = useUserStore(); + if (userStore.currentUser != null) { + await userStore.getCurrentUser(); + await userStore.getCurrentRbac(); + } + window.location.reload(); + return; + } + if (action === 'USER_LOGOUT') { + await useUserStore().logout(); + return; + } + if (action === 'WS_SETTINGS_CHANGED') { + await useSystemStore().settingsChanged(); + settingsChangedToast(); + return; + } - const actionMap: Record) => Promise> = { - TOKEN_REFRESH: async (d) => { - const payload = d as { DATA: { access_token: string } }; - await userStore.tokenRefreshFromServer(payload.DATA.access_token); - }, - SHOW_CHANGED: async () => { - if (userStore.currentUser != null) { - await userStore.getCurrentUser(); - await userStore.getCurrentRbac(); + // Convention-based dispatch: searches all instantiated Pinia stores for a method whose + // camelCase name matches the WS action. Adding a store action is sufficient to handle + // the corresponding WS event — no registration required. + const camelAction = screamingToCamel(action); + const pinia = getActivePinia(); + if (pinia) { + const storeMap = (pinia as unknown as { _s: Map> })._s; + for (const store of storeMap.values()) { + if (typeof store[camelAction] === 'function') { + await (store[camelAction] as (d: Record) => Promise)(data); + return; } - window.location.reload(); - }, - GET_CAST_LIST: async () => { - const { useShowStore } = await import('@/stores/show'); - await useShowStore().getCastList(); - }, - ELECTED_LEADER: async () => { - const { useShowStore } = await import('@/stores/show'); - await useShowStore().electedLeader(); - }, - NO_LEADER: async () => { - const { useShowStore } = await import('@/stores/show'); - await useShowStore().noLeader(); - }, - SCRIPT_SCROLL: async (d: Record) => { - const { useShowStore } = await import('@/stores/show'); - useShowStore().scriptScroll(d); - }, - }; - - const handler = actionMap[action]; - if (handler) { - await handler(data); - } else { - log.debug(`No handler for WS action: ${action}`); + } } - // Suppress unused variable warnings - void systemStore; - void wsStore; + log.debug(`No handler for WS action: ${action}`); } function connect(): void { diff --git a/client-v3/src/js/utils.ts b/client-v3/src/js/utils.ts index 522d7f83..481710dc 100644 --- a/client-v3/src/js/utils.ts +++ b/client-v3/src/js/utils.ts @@ -1,5 +1,15 @@ import { baseURL as platformBaseURL, makeURL as platformMakeURL } from '@/js/platform'; +// The contrast-color library uses `this` inside its standalone function, which breaks +// in strict ESM. Inline the standard YIQ formula that the library implements. +export function contrastColor(bgColor: string): string { + const hex = (bgColor ?? '#ffffff').replace('#', ''); + const r = parseInt(hex.substring(0, 2), 16) || 0; + const g = parseInt(hex.substring(2, 4), 16) || 0; + const b = parseInt(hex.substring(4, 6), 16) || 0; + return (r * 299 + g * 587 + b * 114) / 1000 >= 128 ? '#000000' : '#ffffff'; +} + export function baseURL(): string { return platformBaseURL(); } diff --git a/client-v3/src/router/index.ts b/client-v3/src/router/index.ts index ebe32a63..793677ef 100644 --- a/client-v3/src/router/index.ts +++ b/client-v3/src/router/index.ts @@ -75,7 +75,7 @@ const router = createRouter({ { name: 'show-config-cues', path: 'cues', - component: PlaceholderView, + component: () => import('@/views/show/config/ConfigCues.vue'), meta: { requiresAuth: true, requiresShowAccess: true }, }, { @@ -99,7 +99,7 @@ const router = createRouter({ { name: 'show-sessions', path: 'sessions', - component: PlaceholderView, + component: () => import('@/views/show/config/ConfigSessions.vue'), meta: { requiresAuth: true, requiresShowAccess: true }, }, ], diff --git a/client-v3/src/stores/show.ts b/client-v3/src/stores/show.ts index aebe5a9f..26a4b21a 100644 --- a/client-v3/src/stores/show.ts +++ b/client-v3/src/stores/show.ts @@ -7,6 +7,7 @@ import type { MicConflict, MicConflictResult } from '@/js/micConflictUtils'; import type { Cast, Character, CharacterGroup, Act, Scene } from '@/types/api/show'; import type { CueType } from '@/types/api/cues'; import type { ShowSession, Interval, SessionTag } from '@/types/api/session'; +import type { ScriptRevision } from '@/types/api/script'; import type { Microphone } from '@/types/api/microphones'; import { useSystemStore } from '@/stores/system'; @@ -32,6 +33,7 @@ export const useShowStore = defineStore('show', { micAllocations: {} as Record, scriptModes: [] as ScriptMode[], sessionTags: [] as SessionTag[], + scriptRevisions: [] as ScriptRevision[], stageManagerMode: false, }), @@ -113,6 +115,12 @@ export const useShowStore = defineStore('show', { return id != null ? (dict[id] ?? null) : null; }, + scriptRevisionById: + (state): ((id: number | null) => ScriptRevision | null) => + (id) => { + return id != null ? (state.scriptRevisions.find((r) => r.id === id) ?? null) : null; + }, + orderedScenes(state): Scene[] { const currentShow = useSystemStore().currentShow; if (!currentShow?.first_act_id || !state.sceneList.length || !state.actList.length) { @@ -709,12 +717,23 @@ export const useShowStore = defineStore('show', { } }, + async getScriptRevisions(): Promise { + const response = await fetch(makeURL('/api/v1/show/script/revisions')); + if (response.ok) { + const data = await response.json(); + this.scriptRevisions = data.revisions ?? []; + } else { + log.error('Unable to get script revisions'); + } + }, + clearCurrentShow(): void { this.castList = []; this.characterList = []; this.actList = []; this.sceneList = []; this.sessionTags = []; + this.scriptRevisions = []; }, // WS-triggered actions diff --git a/client-v3/src/views/show/config/ConfigCues.vue b/client-v3/src/views/show/config/ConfigCues.vue new file mode 100644 index 00000000..1529d663 --- /dev/null +++ b/client-v3/src/views/show/config/ConfigCues.vue @@ -0,0 +1,415 @@ + + + diff --git a/client-v3/src/views/show/config/ConfigSessions.vue b/client-v3/src/views/show/config/ConfigSessions.vue new file mode 100644 index 00000000..bbddfb7a --- /dev/null +++ b/client-v3/src/views/show/config/ConfigSessions.vue @@ -0,0 +1,40 @@ + + + From 77a12f086915e251ef4b6850346809e469eee99a Mon Sep 17 00:00:00 2001 From: Tim Bradgate Date: Sun, 17 May 2026 22:42:26 +0100 Subject: [PATCH 10/23] Vue 3 Migration Phase 9: Microphone Configuration (#1048) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add Vue 3 migration Phase 9: Microphone configuration Ports the microphone configuration tab including CRUD, scene×character allocation grid with delta tracking and conflict display, SVG timeline with three view modes (mic/character/cast) and PNG export, scene density heatmap, and resource availability grid. Key fix: add `lazy` to BTabs to prevent BVN's eager tab rendering from causing MicAllocations to mount before parent data is loaded, which would leave internalState empty and break allocation toggling. Co-Authored-By: Claude Sonnet 4.6 * Fix ConfigMics tab loading: use v-if="loaded" instead of BTabs lazy BVN renders all tab panels simultaneously; wrapping the BTabs in a v-if="loaded" guard (with a spinner in v-else) prevents MicAllocations from mounting before the parent has fetched microphone data, matching the V2 pattern exactly. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- client-v3/src/assets/styles/timeline.scss | 121 +++++ .../show/config/mics/MicAllocations.vue | 422 ++++++++++++++++++ .../show/config/mics/MicAutoPopulateModal.vue | 246 ++++++++++ .../components/show/config/mics/MicList.vue | 203 +++++++++ .../show/config/mics/MicTimeline.vue | 408 +++++++++++++++++ .../show/config/mics/ResourceAvailability.vue | 416 +++++++++++++++++ .../show/config/mics/SceneDensityHeatmap.vue | 330 ++++++++++++++ client-v3/src/composables/useTimeline.ts | 218 +++++++++ client-v3/src/router/index.ts | 2 +- .../src/views/show/config/ConfigMics.vue | 53 +++ 10 files changed, 2418 insertions(+), 1 deletion(-) create mode 100644 client-v3/src/assets/styles/timeline.scss create mode 100644 client-v3/src/components/show/config/mics/MicAllocations.vue create mode 100644 client-v3/src/components/show/config/mics/MicAutoPopulateModal.vue create mode 100644 client-v3/src/components/show/config/mics/MicList.vue create mode 100644 client-v3/src/components/show/config/mics/MicTimeline.vue create mode 100644 client-v3/src/components/show/config/mics/ResourceAvailability.vue create mode 100644 client-v3/src/components/show/config/mics/SceneDensityHeatmap.vue create mode 100644 client-v3/src/composables/useTimeline.ts create mode 100644 client-v3/src/views/show/config/ConfigMics.vue diff --git a/client-v3/src/assets/styles/timeline.scss b/client-v3/src/assets/styles/timeline.scss new file mode 100644 index 00000000..e271a2ef --- /dev/null +++ b/client-v3/src/assets/styles/timeline.scss @@ -0,0 +1,121 @@ +/** + * Shared styles for timeline visualization components (MicTimeline, StageTimeline). + * + * Usage: Import in Vue component diff --git a/client-v3/src/components/show/config/mics/MicAutoPopulateModal.vue b/client-v3/src/components/show/config/mics/MicAutoPopulateModal.vue new file mode 100644 index 00000000..cd2b8917 --- /dev/null +++ b/client-v3/src/components/show/config/mics/MicAutoPopulateModal.vue @@ -0,0 +1,246 @@ + + + diff --git a/client-v3/src/components/show/config/mics/MicList.vue b/client-v3/src/components/show/config/mics/MicList.vue new file mode 100644 index 00000000..f0d083f0 --- /dev/null +++ b/client-v3/src/components/show/config/mics/MicList.vue @@ -0,0 +1,203 @@ + + + diff --git a/client-v3/src/components/show/config/mics/MicTimeline.vue b/client-v3/src/components/show/config/mics/MicTimeline.vue new file mode 100644 index 00000000..4aa1c77d --- /dev/null +++ b/client-v3/src/components/show/config/mics/MicTimeline.vue @@ -0,0 +1,408 @@ + + + + + diff --git a/client-v3/src/components/show/config/mics/ResourceAvailability.vue b/client-v3/src/components/show/config/mics/ResourceAvailability.vue new file mode 100644 index 00000000..e0ed9bc1 --- /dev/null +++ b/client-v3/src/components/show/config/mics/ResourceAvailability.vue @@ -0,0 +1,416 @@ + + + + + diff --git a/client-v3/src/components/show/config/mics/SceneDensityHeatmap.vue b/client-v3/src/components/show/config/mics/SceneDensityHeatmap.vue new file mode 100644 index 00000000..55c767d4 --- /dev/null +++ b/client-v3/src/components/show/config/mics/SceneDensityHeatmap.vue @@ -0,0 +1,330 @@ + + + + + diff --git a/client-v3/src/composables/useTimeline.ts b/client-v3/src/composables/useTimeline.ts new file mode 100644 index 00000000..6fd33471 --- /dev/null +++ b/client-v3/src/composables/useTimeline.ts @@ -0,0 +1,218 @@ +import { computed } from 'vue'; +import type { Ref } from 'vue'; +import type { Scene } from '@/types/api/show'; +import { useShowStore } from '@/stores/show'; + +export interface TimelineRow { + id: number; + name: string; + type: string; +} + +export interface ActGroup { + actId: number; + actName: string; + startX: number; + width: number; +} + +export interface TimelineSegment { + startIndex: number; + endIndex: number; + startScene: string; + endScene: string; +} + +type EntityType = 'mic' | 'character' | 'cast' | 'prop' | 'scenery'; + +const EXPORT_STYLES: Record> = { + '.scene-divider': { stroke: '#495057', 'stroke-width': '1', opacity: '0.4' }, + '.row-separator': { stroke: '#495057', 'stroke-width': '1', opacity: '0.3' }, + '.act-header': { fill: '#e9ecef', stroke: '#495057', 'stroke-width': '1' }, + '.act-label': { fill: '#212529', 'font-size': '14', 'font-weight': '600' }, + '.scene-label': { fill: '#495057', 'font-size': '11' }, + '.row-label': { fill: '#212529', 'font-size': '12', 'font-weight': '500' }, + '.allocation-bar': { stroke: '#212529', 'stroke-width': '1' }, + '.bar-label': { + fill: '#ffffff', + 'font-weight': '600', + style: 'text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.8)', + }, +}; + +export function useTimeline(scenes: Ref, rows: Ref) { + const showStore = useShowStore(); + + const margin = { top: 75, right: 20, bottom: 20, left: 150 }; + const sceneWidth = 100; + const rowHeight = 50; + const barPadding = 6; + + const contentWidth = computed(() => scenes.value.length * sceneWidth); + const contentHeight = computed(() => rows.value.length * rowHeight); + const totalWidth = computed(() => margin.left + contentWidth.value + margin.right); + const totalHeight = computed(() => margin.top + contentHeight.value + margin.bottom); + + const actGroups = computed((): ActGroup[] => { + const groups: ActGroup[] = []; + let currentActId: number | null = null; + let startIndex = 0; + + scenes.value.forEach((scene, index) => { + const act = showStore.actById(scene.act); + if (!act) return; + if (currentActId !== act.id) { + if (currentActId !== null) { + groups.push({ + actId: currentActId, + actName: showStore.actById(currentActId)?.name ?? 'Unknown', + startX: getSceneX(startIndex), + width: getSceneX(index) - getSceneX(startIndex), + }); + } + currentActId = act.id; + startIndex = index; + } + }); + + if (currentActId !== null) { + groups.push({ + actId: currentActId, + actName: showStore.actById(currentActId)?.name ?? 'Unknown', + startX: getSceneX(startIndex), + width: getSceneX(scenes.value.length) - getSceneX(startIndex), + }); + } + + return groups; + }); + + function getSceneX(sceneIndex: number): number { + return sceneIndex * sceneWidth; + } + + function getRowY(rowIndex: number): number { + return rowIndex * rowHeight; + } + + function getColorForEntity(entityId: number, entityType: EntityType | string): string { + const typeOffsets: Record = { + mic: 0, + character: 120, + cast: 240, + prop: 60, + scenery: 180, + }; + const hue = (entityId * 137.508 + (typeOffsets[entityType] ?? 0)) % 360; + return `hsl(${hue}, 70%, 50%)`; + } + + function groupConsecutiveScenes( + allocations: Array>, + sceneIdField = 'scene_id' + ): TimelineSegment[] { + if (!allocations || allocations.length === 0) return []; + + const segments: TimelineSegment[] = []; + let currentSegment: TimelineSegment | null = null; + + scenes.value.forEach((scene, sceneIndex) => { + const hasAllocation = allocations.some((a) => a[sceneIdField] === scene.id); + + if (hasAllocation) { + const sameAct = currentSegment + ? scene.act === scenes.value[currentSegment.startIndex].act + : true; + + if (currentSegment && sameAct) { + currentSegment.endIndex = sceneIndex; + currentSegment.endScene = scene.name ?? ''; + } else { + if (currentSegment) segments.push(currentSegment); + currentSegment = { + startIndex: sceneIndex, + endIndex: sceneIndex, + startScene: scene.name ?? '', + endScene: scene.name ?? '', + }; + } + } else if (currentSegment) { + segments.push(currentSegment); + currentSegment = null; + } + }); + + if (currentSegment) segments.push(currentSegment); + return segments; + } + + function applyExportStyles(svgClone: SVGSVGElement): void { + Object.entries(EXPORT_STYLES).forEach(([selector, attrs]) => { + svgClone.querySelectorAll(selector).forEach((el) => { + Object.entries(attrs).forEach(([attr, value]) => { + el.setAttribute(attr, value); + }); + }); + }); + } + + function exportTimeline( + svgRef: Ref, + filenamePrefix = 'timeline', + viewModeName = '' + ): void { + const svgElement = svgRef.value; + if (!svgElement) return; + + const svgClone = svgElement.cloneNode(true) as SVGSVGElement; + applyExportStyles(svgClone); + + const serializer = new XMLSerializer(); + const svgString = serializer.serializeToString(svgClone); + + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d')!; + const img = new Image(); + + canvas.width = totalWidth.value; + canvas.height = totalHeight.value; + + img.onload = () => { + ctx.fillStyle = '#ffffff'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + ctx.drawImage(img, 0, 0); + + canvas.toBlob((blob) => { + if (!blob) return; + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + const dateSuffix = new Date().toISOString().slice(0, 10); + const modeSuffix = viewModeName ? `-${viewModeName}` : ''; + link.download = `${filenamePrefix}${modeSuffix}-${dateSuffix}.png`; + link.href = url; + link.click(); + URL.revokeObjectURL(url); + }); + }; + + const svgBlob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' }); + img.src = URL.createObjectURL(svgBlob); + } + + return { + margin, + sceneWidth, + rowHeight, + barPadding, + contentWidth, + contentHeight, + totalWidth, + totalHeight, + actGroups, + getSceneX, + getRowY, + getColorForEntity, + groupConsecutiveScenes, + exportTimeline, + }; +} diff --git a/client-v3/src/router/index.ts b/client-v3/src/router/index.ts index 793677ef..ce170ff8 100644 --- a/client-v3/src/router/index.ts +++ b/client-v3/src/router/index.ts @@ -81,7 +81,7 @@ const router = createRouter({ { name: 'show-config-mics', path: 'mics', - component: PlaceholderView, + component: () => import('@/views/show/config/ConfigMics.vue'), meta: { requiresAuth: true, requiresShowAccess: true }, }, { diff --git a/client-v3/src/views/show/config/ConfigMics.vue b/client-v3/src/views/show/config/ConfigMics.vue new file mode 100644 index 00000000..34c914ce --- /dev/null +++ b/client-v3/src/views/show/config/ConfigMics.vue @@ -0,0 +1,53 @@ + + + From c16889c55352499a8ab011ddaf99f27e7114c831 Mon Sep 17 00:00:00 2001 From: Tim Bradgate Date: Mon, 18 May 2026 18:53:45 +0100 Subject: [PATCH 11/23] =?UTF-8?q?Vue=203=20migration:=20Phase=2010=20?= =?UTF-8?q?=E2=80=94=20Stage=20configuration=20(#1049)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the Stage config tab from V2 to V3, including: - Crew CRUD (CrewList.vue) with first/last name fields - Scenery types + items CRUD (SceneryList.vue) with cascade delete warning - Prop types + items CRUD (PropsList.vue) - Stage Manager (StageManager.vue): scene navigation, allocation cards, SET/STRIKE boundary detection, crew assignments, orphan detection via blockOrphanUtils - SVG props/scenery timeline (StageTimeline.vue) with view mode toggle and PNG export - SVG crew timeline (CrewTimeline.vue) with hard/soft conflict detection - Timeline side panel (TimelineSidePanel.vue) for crew assignment editing - Pinia store (stores/stage.ts) with 8 state arrays, parameterised getters, and 28 actions; POST/PATCH requests use camelCase keys matching the API - blockOrphanUtils.ts + tests copied from V2 (pure TS, no Vue deps) - ConfigStage.vue shell with 5 tabs; router updated to replace PlaceholderView Co-authored-by: Claude Sonnet 4.6 --- .../components/show/config/stage/CrewList.vue | 203 ++++ .../show/config/stage/CrewTimeline.vue | 353 +++++++ .../show/config/stage/PropsList.vue | 419 ++++++++ .../show/config/stage/SceneryList.vue | 425 ++++++++ .../show/config/stage/StageManager.vue | 962 ++++++++++++++++++ .../show/config/stage/StageTimeline.vue | 340 +++++++ .../show/config/stage/TimelineSidePanel.vue | 464 +++++++++ client-v3/src/js/blockOrphanUtils.test.ts | 241 +++++ client-v3/src/js/blockOrphanUtils.ts | 142 +++ client-v3/src/router/index.ts | 2 +- client-v3/src/stores/stage.ts | 510 ++++++++++ .../src/views/show/config/ConfigStage.vue | 29 + 12 files changed, 4089 insertions(+), 1 deletion(-) create mode 100644 client-v3/src/components/show/config/stage/CrewList.vue create mode 100644 client-v3/src/components/show/config/stage/CrewTimeline.vue create mode 100644 client-v3/src/components/show/config/stage/PropsList.vue create mode 100644 client-v3/src/components/show/config/stage/SceneryList.vue create mode 100644 client-v3/src/components/show/config/stage/StageManager.vue create mode 100644 client-v3/src/components/show/config/stage/StageTimeline.vue create mode 100644 client-v3/src/components/show/config/stage/TimelineSidePanel.vue create mode 100644 client-v3/src/js/blockOrphanUtils.test.ts create mode 100644 client-v3/src/js/blockOrphanUtils.ts create mode 100644 client-v3/src/stores/stage.ts create mode 100644 client-v3/src/views/show/config/ConfigStage.vue diff --git a/client-v3/src/components/show/config/stage/CrewList.vue b/client-v3/src/components/show/config/stage/CrewList.vue new file mode 100644 index 00000000..26ea116d --- /dev/null +++ b/client-v3/src/components/show/config/stage/CrewList.vue @@ -0,0 +1,203 @@ + + + diff --git a/client-v3/src/components/show/config/stage/CrewTimeline.vue b/client-v3/src/components/show/config/stage/CrewTimeline.vue new file mode 100644 index 00000000..41758409 --- /dev/null +++ b/client-v3/src/components/show/config/stage/CrewTimeline.vue @@ -0,0 +1,353 @@ + + + + + diff --git a/client-v3/src/components/show/config/stage/PropsList.vue b/client-v3/src/components/show/config/stage/PropsList.vue new file mode 100644 index 00000000..3eec6db2 --- /dev/null +++ b/client-v3/src/components/show/config/stage/PropsList.vue @@ -0,0 +1,419 @@ + + + diff --git a/client-v3/src/components/show/config/stage/SceneryList.vue b/client-v3/src/components/show/config/stage/SceneryList.vue new file mode 100644 index 00000000..272d6cf2 --- /dev/null +++ b/client-v3/src/components/show/config/stage/SceneryList.vue @@ -0,0 +1,425 @@ + + + diff --git a/client-v3/src/components/show/config/stage/StageManager.vue b/client-v3/src/components/show/config/stage/StageManager.vue new file mode 100644 index 00000000..6a2147b8 --- /dev/null +++ b/client-v3/src/components/show/config/stage/StageManager.vue @@ -0,0 +1,962 @@ + + + + + diff --git a/client-v3/src/components/show/config/stage/StageTimeline.vue b/client-v3/src/components/show/config/stage/StageTimeline.vue new file mode 100644 index 00000000..86cbe22d --- /dev/null +++ b/client-v3/src/components/show/config/stage/StageTimeline.vue @@ -0,0 +1,340 @@ + + + + + diff --git a/client-v3/src/components/show/config/stage/TimelineSidePanel.vue b/client-v3/src/components/show/config/stage/TimelineSidePanel.vue new file mode 100644 index 00000000..e6eb2653 --- /dev/null +++ b/client-v3/src/components/show/config/stage/TimelineSidePanel.vue @@ -0,0 +1,464 @@ + + + + + diff --git a/client-v3/src/js/blockOrphanUtils.test.ts b/client-v3/src/js/blockOrphanUtils.test.ts new file mode 100644 index 00000000..70479d95 --- /dev/null +++ b/client-v3/src/js/blockOrphanUtils.test.ts @@ -0,0 +1,241 @@ +import { describe, it, expect } from 'vitest'; +import { computeBlocks, findOrphanedAssignments } from './blockOrphanUtils'; + +describe('blockOrphanUtils', () => { + describe('computeBlocks', () => { + it('returns empty array for empty inputs', () => { + expect(computeBlocks([], new Set())).toEqual([]); + expect(computeBlocks([], new Set([1]))).toEqual([]); + expect(computeBlocks(null, null)).toEqual([]); + }); + + it('returns single block for one allocated scene', () => { + const scenes = [{ id: 1, act: 1 }]; + const result = computeBlocks(scenes, new Set([1])); + expect(result).toEqual([{ actId: 1, sceneIds: [1], setSceneId: 1, strikeSceneId: 1 }]); + }); + + it('returns one block for consecutive scenes in one act', () => { + const scenes = [ + { id: 1, act: 1 }, + { id: 2, act: 1 }, + { id: 3, act: 1 }, + ]; + const result = computeBlocks(scenes, new Set([1, 2, 3])); + expect(result).toEqual([{ actId: 1, sceneIds: [1, 2, 3], setSceneId: 1, strikeSceneId: 3 }]); + }); + + it('splits into two blocks when there is a gap in the middle', () => { + const scenes = [ + { id: 1, act: 1 }, + { id: 2, act: 1 }, + { id: 3, act: 1 }, + { id: 4, act: 1 }, + ]; + const result = computeBlocks(scenes, new Set([1, 2, 4])); + expect(result).toEqual([ + { actId: 1, sceneIds: [1, 2], setSceneId: 1, strikeSceneId: 2 }, + { actId: 1, sceneIds: [4], setSceneId: 4, strikeSceneId: 4 }, + ]); + }); + + it('breaks blocks at act boundaries even for consecutive scenes', () => { + const scenes = [ + { id: 1, act: 1 }, + { id: 2, act: 1 }, + { id: 3, act: 2 }, + { id: 4, act: 2 }, + ]; + const result = computeBlocks(scenes, new Set([1, 2, 3, 4])); + expect(result).toEqual([ + { actId: 1, sceneIds: [1, 2], setSceneId: 1, strikeSceneId: 2 }, + { actId: 2, sceneIds: [3, 4], setSceneId: 3, strikeSceneId: 4 }, + ]); + }); + + it('handles multiple acts with multiple blocks each', () => { + const scenes = [ + { id: 1, act: 1 }, + { id: 2, act: 1 }, + { id: 3, act: 1 }, + { id: 4, act: 2 }, + { id: 5, act: 2 }, + { id: 6, act: 2 }, + ]; + // Allocated: 1, 3 (act 1 gap), 4, 6 (act 2 gap) + const result = computeBlocks(scenes, new Set([1, 3, 4, 6])); + expect(result).toEqual([ + { actId: 1, sceneIds: [1], setSceneId: 1, strikeSceneId: 1 }, + { actId: 1, sceneIds: [3], setSceneId: 3, strikeSceneId: 3 }, + { actId: 2, sceneIds: [4], setSceneId: 4, strikeSceneId: 4 }, + { actId: 2, sceneIds: [6], setSceneId: 6, strikeSceneId: 6 }, + ]); + }); + + it('ignores scenes not in the allocated set', () => { + const scenes = [ + { id: 1, act: 1 }, + { id: 2, act: 1 }, + { id: 3, act: 1 }, + ]; + const result = computeBlocks(scenes, new Set([2])); + expect(result).toEqual([{ actId: 1, sceneIds: [2], setSceneId: 2, strikeSceneId: 2 }]); + }); + }); + + describe('findOrphanedAssignments', () => { + // Reusable test data + const orderedScenes = [ + { id: 1, act: 1 }, + { id: 2, act: 1 }, + { id: 3, act: 1 }, + { id: 4, act: 1 }, + ]; + + it('returns empty array when there are no crew assignments', () => { + const result = findOrphanedAssignments({ + orderedScenes, + currentAllocations: [{ scene_id: 1 }, { scene_id: 2 }], + crewAssignments: [], + changeType: 'remove', + changeSceneId: 1, + }); + expect(result).toEqual([]); + }); + + it('returns empty when boundary does not change', () => { + // Block is scenes 1-3, remove scene 2 (middle) → boundaries stay at 1 and 3 + // Actually removing middle splits block: [1] and [3], boundaries change! + // Use: remove scene 2 from [1,2,3] → [1] block (set=1,strike=1) + [3] (set=3,strike=3) + // Original: [1,2,3] → set=1, strike=3 + // So boundaries DO change for strike. Let's use a case where they don't change. + // Add scene 2 to [1,3] → no boundary change (set=1, strike=3 in both) + const result = findOrphanedAssignments({ + orderedScenes, + currentAllocations: [{ scene_id: 1 }, { scene_id: 3 }], + crewAssignments: [ + { id: 10, scene_id: 1, assignment_type: 'set', crew_id: 1 }, + { id: 11, scene_id: 3, assignment_type: 'strike', crew_id: 2 }, + ], + changeType: 'add', + changeSceneId: 2, + }); + expect(result).toEqual([]); + }); + + it('orphans SET assignments when block start is removed', () => { + // Block [1,2,3] → remove scene 1 → block becomes [2,3], SET moves to 2 + const setAssignment = { id: 10, scene_id: 1, assignment_type: 'set', crew_id: 1 }; + const strikeAssignment = { id: 11, scene_id: 3, assignment_type: 'strike', crew_id: 2 }; + const result = findOrphanedAssignments({ + orderedScenes, + currentAllocations: [{ scene_id: 1 }, { scene_id: 2 }, { scene_id: 3 }], + crewAssignments: [setAssignment, strikeAssignment], + changeType: 'remove', + changeSceneId: 1, + }); + expect(result).toEqual([setAssignment]); + }); + + it('orphans STRIKE assignments when block end is removed', () => { + // Block [1,2,3] → remove scene 3 → block becomes [1,2], STRIKE moves to 2 + const setAssignment = { id: 10, scene_id: 1, assignment_type: 'set', crew_id: 1 }; + const strikeAssignment = { id: 11, scene_id: 3, assignment_type: 'strike', crew_id: 2 }; + const result = findOrphanedAssignments({ + orderedScenes, + currentAllocations: [{ scene_id: 1 }, { scene_id: 2 }, { scene_id: 3 }], + crewAssignments: [setAssignment, strikeAssignment], + changeType: 'remove', + changeSceneId: 3, + }); + expect(result).toEqual([strikeAssignment]); + }); + + it('orphans both SET and STRIKE when a single-scene block is removed', () => { + // Block [2] only → remove scene 2 → block disappears + const setAssignment = { id: 10, scene_id: 2, assignment_type: 'set', crew_id: 1 }; + const strikeAssignment = { id: 11, scene_id: 2, assignment_type: 'strike', crew_id: 2 }; + const result = findOrphanedAssignments({ + orderedScenes, + currentAllocations: [{ scene_id: 2 }], + crewAssignments: [setAssignment, strikeAssignment], + changeType: 'remove', + changeSceneId: 2, + }); + expect(result).toContainEqual(setAssignment); + expect(result).toContainEqual(strikeAssignment); + expect(result).toHaveLength(2); + }); + + it('does not orphan when removing a middle scene (split keeps original boundaries)', () => { + // Block [1,2,3] → remove scene 2 → blocks [1] and [3] + // SET scene 1 stays valid (set of block [1]), STRIKE scene 3 stays valid (strike of block [3]) + const result = findOrphanedAssignments({ + orderedScenes, + currentAllocations: [{ scene_id: 1 }, { scene_id: 2 }, { scene_id: 3 }], + crewAssignments: [ + { id: 10, scene_id: 1, assignment_type: 'set', crew_id: 1 }, + { id: 11, scene_id: 3, assignment_type: 'strike', crew_id: 2 }, + ], + changeType: 'remove', + changeSceneId: 2, + }); + expect(result).toEqual([]); + }); + + it('orphans old SET when adding a scene before block start', () => { + // Block [2,3] → add scene 1 → block becomes [1,2,3], SET moves to 1 + const oldSetAssignment = { id: 10, scene_id: 2, assignment_type: 'set', crew_id: 1 }; + const result = findOrphanedAssignments({ + orderedScenes, + currentAllocations: [{ scene_id: 2 }, { scene_id: 3 }], + crewAssignments: [ + oldSetAssignment, + { id: 11, scene_id: 3, assignment_type: 'strike', crew_id: 2 }, + ], + changeType: 'add', + changeSceneId: 1, + }); + expect(result).toEqual([oldSetAssignment]); + }); + + it('orphans old STRIKE when adding a scene after block end', () => { + // Block [1,2] → add scene 3 → block becomes [1,2,3], STRIKE moves to 3 + const oldStrikeAssignment = { id: 11, scene_id: 2, assignment_type: 'strike', crew_id: 2 }; + const result = findOrphanedAssignments({ + orderedScenes, + currentAllocations: [{ scene_id: 1 }, { scene_id: 2 }], + crewAssignments: [ + { id: 10, scene_id: 1, assignment_type: 'set', crew_id: 1 }, + oldStrikeAssignment, + ], + changeType: 'add', + changeSceneId: 3, + }); + expect(result).toEqual([oldStrikeAssignment]); + }); + + it('does not affect assignments in a different act', () => { + const multiActScenes = [ + { id: 1, act: 1 }, + { id: 2, act: 1 }, + { id: 3, act: 2 }, + { id: 4, act: 2 }, + ]; + // Act 1 block [1,2], Act 2 block [3,4] + // Remove scene 1 (act 1 boundary change) → Act 2 should be unaffected + const act1Set = { id: 10, scene_id: 1, assignment_type: 'set', crew_id: 1 }; + const act2Set = { id: 20, scene_id: 3, assignment_type: 'set', crew_id: 1 }; + const act2Strike = { id: 21, scene_id: 4, assignment_type: 'strike', crew_id: 2 }; + const result = findOrphanedAssignments({ + orderedScenes: multiActScenes, + currentAllocations: [{ scene_id: 1 }, { scene_id: 2 }, { scene_id: 3 }, { scene_id: 4 }], + crewAssignments: [act1Set, act2Set, act2Strike], + changeType: 'remove', + changeSceneId: 1, + }); + // Only act1 SET is orphaned + expect(result).toEqual([act1Set]); + }); + }); +}); diff --git a/client-v3/src/js/blockOrphanUtils.ts b/client-v3/src/js/blockOrphanUtils.ts new file mode 100644 index 00000000..1f65a4c8 --- /dev/null +++ b/client-v3/src/js/blockOrphanUtils.ts @@ -0,0 +1,142 @@ +/** + * Block computation and orphan detection utilities for stage crew assignments. + * + * A "block" is a consecutive sequence of scenes (within an act) where an item + * is allocated. The first scene is the SET boundary; the last is the STRIKE + * boundary. These pure functions mirror the backend logic in + * server/utils/show/block_computation.py. + */ + +export interface OrderedScene { + id: number; + act: number; +} + +export interface AllocationBlock { + actId: number; + sceneIds: number[]; + setSceneId: number; + strikeSceneId: number; +} + +export interface CrewAssignmentRecord { + id: number; + scene_id: number; + assignment_type: string; + crew_id: number; +} + +export interface FindOrphanedParams { + orderedScenes: OrderedScene[]; + currentAllocations: Array<{ scene_id: number }>; + crewAssignments: CrewAssignmentRecord[]; + changeType: 'add' | 'remove'; + changeSceneId: number; +} + +export function computeBlocks( + orderedScenes: OrderedScene[] | null | undefined, + allocatedSceneIds: Set | null | undefined +): AllocationBlock[] { + if ( + !orderedScenes || + orderedScenes.length === 0 || + !allocatedSceneIds || + allocatedSceneIds.size === 0 + ) { + return []; + } + + const blocks: AllocationBlock[] = []; + let currentBlockScenes: number[] = []; + let currentActId: number | null = null; + + for (const scene of orderedScenes) { + // Act boundary breaks the current block + if (currentActId !== null && scene.act !== currentActId) { + if (currentBlockScenes.length > 0) { + blocks.push({ + actId: currentActId, + sceneIds: [...currentBlockScenes], + setSceneId: currentBlockScenes[0], + strikeSceneId: currentBlockScenes[currentBlockScenes.length - 1], + }); + currentBlockScenes = []; + } + } + currentActId = scene.act; + + if (allocatedSceneIds.has(scene.id)) { + currentBlockScenes.push(scene.id); + } else if (currentBlockScenes.length > 0) { + blocks.push({ + actId: currentActId, + sceneIds: [...currentBlockScenes], + setSceneId: currentBlockScenes[0], + strikeSceneId: currentBlockScenes[currentBlockScenes.length - 1], + }); + currentBlockScenes = []; + } + } + + // Flush last block + if (currentBlockScenes.length > 0) { + blocks.push({ + actId: currentActId as number, + sceneIds: [...currentBlockScenes], + setSceneId: currentBlockScenes[0], + strikeSceneId: currentBlockScenes[currentBlockScenes.length - 1], + }); + } + + return blocks; +} + +export function findOrphanedAssignments({ + orderedScenes, + currentAllocations, + crewAssignments, + changeType, + changeSceneId, +}: FindOrphanedParams): CrewAssignmentRecord[] { + if (!crewAssignments || crewAssignments.length === 0) { + return []; + } + + // Build current allocated set + const currentSet = new Set(currentAllocations.map((a) => a.scene_id)); + + // Simulate the change + const newSet = new Set(currentSet); + if (changeType === 'add') { + newSet.add(changeSceneId); + } else if (changeType === 'remove') { + newSet.delete(changeSceneId); + } + + // Compute blocks before and after + const oldBlocks = computeBlocks(orderedScenes, currentSet); + const newBlocks = computeBlocks(orderedScenes, newSet); + + // Build valid boundary sets from new blocks + const validSetScenes = new Set(newBlocks.map((b) => b.setSceneId)); + const validStrikeScenes = new Set(newBlocks.map((b) => b.strikeSceneId)); + + // Also check which assignments were valid before — only flag ones that + // become invalid (were on a valid boundary before, but aren't after) + const oldValidSetScenes = new Set(oldBlocks.map((b) => b.setSceneId)); + const oldValidStrikeScenes = new Set(oldBlocks.map((b) => b.strikeSceneId)); + + return crewAssignments.filter((assignment) => { + if (assignment.assignment_type === 'set') { + const wasValid = oldValidSetScenes.has(assignment.scene_id); + const isValid = validSetScenes.has(assignment.scene_id); + return wasValid && !isValid; + } else if (assignment.assignment_type === 'strike') { + const wasValid = oldValidStrikeScenes.has(assignment.scene_id); + const isValid = validStrikeScenes.has(assignment.scene_id); + return wasValid && !isValid; + } + return false; + }); +} diff --git a/client-v3/src/router/index.ts b/client-v3/src/router/index.ts index ce170ff8..59c1387c 100644 --- a/client-v3/src/router/index.ts +++ b/client-v3/src/router/index.ts @@ -57,7 +57,7 @@ const router = createRouter({ { name: 'show-config-stage', path: 'stage', - component: PlaceholderView, + component: () => import('@/views/show/config/ConfigStage.vue'), meta: { requiresAuth: true, requiresShowAccess: true }, }, { diff --git a/client-v3/src/stores/stage.ts b/client-v3/src/stores/stage.ts new file mode 100644 index 00000000..01a3f8d2 --- /dev/null +++ b/client-v3/src/stores/stage.ts @@ -0,0 +1,510 @@ +import { defineStore } from 'pinia'; +import log from 'loglevel'; +import { makeURL } from '@/js/utils'; +import { toast } from '@/js/toast'; +import type { + Crew, + CrewAssignment, + SceneryType, + Scenery, + SceneryAllocation, + PropType, + Props, + PropsAllocation, +} from '@/types/api/stage'; + +export const useStageStore = defineStore('stage', { + state: () => ({ + crewList: [] as Crew[], + crewAssignments: [] as CrewAssignment[], + sceneryTypes: [] as SceneryType[], + sceneryList: [] as Scenery[], + sceneryAllocations: [] as SceneryAllocation[], + propTypes: [] as PropType[], + propsList: [] as Props[], + propsAllocations: [] as PropsAllocation[], + }), + + getters: { + crewById: (state) => (id: number | null) => + id == null ? null : (state.crewList.find((c) => c.id === id) ?? null), + + sceneryById: (state) => (id: number | null) => + id == null ? null : (state.sceneryList.find((s) => s.id === id) ?? null), + + sceneryTypeById: (state) => (id: number | null) => + id == null ? null : (state.sceneryTypes.find((t) => t.id === id) ?? null), + + propById: (state) => (id: number | null) => + id == null ? null : (state.propsList.find((p) => p.id === id) ?? null), + + propTypeById: (state) => (id: number | null) => + id == null ? null : (state.propTypes.find((t) => t.id === id) ?? null), + + sceneryTypesDict: (state) => + Object.fromEntries(state.sceneryTypes.map((t) => [t.id, t])) as Record, + + propTypesDict: (state) => + Object.fromEntries(state.propTypes.map((t) => [t.id, t])) as Record, + + propsAllocationsByItem: (state) => { + const result: Record = {}; + state.propsAllocations.forEach((alloc) => { + if (!result[alloc.props_id]) result[alloc.props_id] = []; + result[alloc.props_id].push(alloc); + }); + return result; + }, + + sceneryAllocationsByItem: (state) => { + const result: Record = {}; + state.sceneryAllocations.forEach((alloc) => { + if (!result[alloc.scenery_id]) result[alloc.scenery_id] = []; + result[alloc.scenery_id].push(alloc); + }); + return result; + }, + + crewAssignmentsByProp: (state) => { + const result: Record = {}; + state.crewAssignments.forEach((a) => { + if (a.prop_id != null) { + if (!result[a.prop_id]) result[a.prop_id] = []; + result[a.prop_id].push(a); + } + }); + return result; + }, + + crewAssignmentsByScenery: (state) => { + const result: Record = {}; + state.crewAssignments.forEach((a) => { + if (a.scenery_id != null) { + if (!result[a.scenery_id]) result[a.scenery_id] = []; + result[a.scenery_id].push(a); + } + }); + return result; + }, + + crewAssignmentsByCrew: (state) => { + const result: Record = {}; + state.crewAssignments.forEach((a) => { + if (!result[a.crew_id]) result[a.crew_id] = []; + result[a.crew_id].push(a); + }); + return result; + }, + + crewAssignmentsByScene: (state) => { + const result: Record = {}; + state.crewAssignments.forEach((a) => { + if (!result[a.scene_id]) result[a.scene_id] = []; + result[a.scene_id].push(a); + }); + return result; + }, + }, + + actions: { + async getCrewList() { + const response = await fetch(makeURL('/api/v1/show/stage/crew')); + if (response.ok) { + const data = await response.json(); + this.crewList = data.crew; + } else { + log.error('Unable to get crew list'); + } + }, + + async addCrewMember(crewMember: Partial) { + const response = await fetch(makeURL('/api/v1/show/stage/crew'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ firstName: crewMember.first_name, lastName: crewMember.last_name }), + }); + if (response.ok) { + await this.getCrewList(); + toast.success('Added new crew member!'); + } else { + log.error('Unable to add new crew member'); + toast.error('Unable to add new crew member'); + } + }, + + async updateCrewMember(crewMember: Partial) { + const response = await fetch(makeURL('/api/v1/show/stage/crew'), { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + id: crewMember.id, + firstName: crewMember.first_name, + lastName: crewMember.last_name, + }), + }); + if (response.ok) { + await this.getCrewList(); + toast.success('Updated crew member!'); + } else { + log.error('Unable to edit crew member'); + toast.error('Unable to edit crew member'); + } + }, + + async deleteCrewMember(crewId: number) { + const params = new URLSearchParams({ id: String(crewId) }); + const response = await fetch(`${makeURL('/api/v1/show/stage/crew')}?${params}`, { + method: 'DELETE', + }); + if (response.ok) { + await this.getCrewList(); + toast.success('Deleted crew member!'); + } else { + log.error('Unable to delete crew member'); + toast.error('Unable to delete crew member'); + } + }, + + async getSceneryTypes() { + const response = await fetch(makeURL('/api/v1/show/stage/scenery/types')); + if (response.ok) { + const data = await response.json(); + this.sceneryTypes = data.scenery_types; + } else { + log.error('Unable to get scenery types'); + } + }, + + async addSceneryType(sceneryType: Partial) { + const response = await fetch(makeURL('/api/v1/show/stage/scenery/types'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(sceneryType), + }); + if (response.ok) { + await this.getSceneryTypes(); + toast.success('Added new scenery type!'); + } else { + log.error('Unable to add new scenery type'); + toast.error('Unable to add new scenery type'); + } + }, + + async updateSceneryType(sceneryType: Partial) { + const response = await fetch(makeURL('/api/v1/show/stage/scenery/types'), { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(sceneryType), + }); + if (response.ok) { + await this.getSceneryTypes(); + toast.success('Updated scenery type!'); + } else { + log.error('Unable to edit scenery type'); + toast.error('Unable to edit scenery type'); + } + }, + + async deleteSceneryType(sceneryTypeId: number) { + const params = new URLSearchParams({ id: String(sceneryTypeId) }); + const response = await fetch(`${makeURL('/api/v1/show/stage/scenery/types')}?${params}`, { + method: 'DELETE', + }); + if (response.ok) { + await Promise.all([this.getSceneryTypes(), this.getSceneryList()]); + toast.success('Deleted scenery type!'); + } else { + log.error('Unable to delete scenery type'); + toast.error('Unable to delete scenery type'); + } + }, + + async getSceneryList() { + const response = await fetch(makeURL('/api/v1/show/stage/scenery')); + if (response.ok) { + const data = await response.json(); + this.sceneryList = data.scenery; + } else { + log.error('Unable to get scenery list'); + } + }, + + async addScenery(scenery: Partial) { + const response = await fetch(makeURL('/api/v1/show/stage/scenery'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(scenery), + }); + if (response.ok) { + await this.getSceneryList(); + toast.success('Added new scenery!'); + } else { + log.error('Unable to add new scenery'); + toast.error('Unable to add new scenery'); + } + }, + + async updateScenery(scenery: Partial) { + const response = await fetch(makeURL('/api/v1/show/stage/scenery'), { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(scenery), + }); + if (response.ok) { + await this.getSceneryList(); + toast.success('Updated scenery!'); + } else { + log.error('Unable to edit scenery'); + toast.error('Unable to edit scenery'); + } + }, + + async deleteScenery(sceneryId: number) { + const params = new URLSearchParams({ id: String(sceneryId) }); + const response = await fetch(`${makeURL('/api/v1/show/stage/scenery')}?${params}`, { + method: 'DELETE', + }); + if (response.ok) { + await this.getSceneryList(); + toast.success('Deleted scenery!'); + } else { + log.error('Unable to delete scenery'); + toast.error('Unable to delete scenery'); + } + }, + + async getPropTypes() { + const response = await fetch(makeURL('/api/v1/show/stage/props/types')); + if (response.ok) { + const data = await response.json(); + this.propTypes = data.prop_types; + } else { + log.error('Unable to get prop types'); + } + }, + + async addPropType(propType: Partial) { + const response = await fetch(makeURL('/api/v1/show/stage/props/types'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(propType), + }); + if (response.ok) { + await this.getPropTypes(); + toast.success('Added new prop type!'); + } else { + log.error('Unable to add new prop type'); + toast.error('Unable to add new prop type'); + } + }, + + async updatePropType(propType: Partial) { + const response = await fetch(makeURL('/api/v1/show/stage/props/types'), { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(propType), + }); + if (response.ok) { + await this.getPropTypes(); + toast.success('Updated prop type!'); + } else { + log.error('Unable to edit prop type'); + toast.error('Unable to edit prop type'); + } + }, + + async deletePropType(propTypeId: number) { + const params = new URLSearchParams({ id: String(propTypeId) }); + const response = await fetch(`${makeURL('/api/v1/show/stage/props/types')}?${params}`, { + method: 'DELETE', + }); + if (response.ok) { + await Promise.all([this.getPropTypes(), this.getPropsList()]); + toast.success('Deleted prop type!'); + } else { + log.error('Unable to delete prop type'); + toast.error('Unable to delete prop type'); + } + }, + + async getPropsList() { + const response = await fetch(makeURL('/api/v1/show/stage/props')); + if (response.ok) { + const data = await response.json(); + this.propsList = data.props; + } else { + log.error('Unable to get props list'); + } + }, + + async addProp(prop: Partial) { + const response = await fetch(makeURL('/api/v1/show/stage/props'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(prop), + }); + if (response.ok) { + await this.getPropsList(); + toast.success('Added new prop!'); + } else { + log.error('Unable to add new prop'); + toast.error('Unable to add new prop'); + } + }, + + async updateProp(prop: Partial) { + const response = await fetch(makeURL('/api/v1/show/stage/props'), { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(prop), + }); + if (response.ok) { + await this.getPropsList(); + toast.success('Updated prop!'); + } else { + log.error('Unable to edit prop'); + toast.error('Unable to edit prop'); + } + }, + + async deleteProp(propId: number) { + const params = new URLSearchParams({ id: String(propId) }); + const response = await fetch(`${makeURL('/api/v1/show/stage/props')}?${params}`, { + method: 'DELETE', + }); + if (response.ok) { + await this.getPropsList(); + toast.success('Deleted prop!'); + } else { + log.error('Unable to delete prop'); + toast.error('Unable to delete prop'); + } + }, + + async getPropsAllocations() { + const response = await fetch(makeURL('/api/v1/show/stage/props/allocations')); + if (response.ok) { + const data = await response.json(); + this.propsAllocations = data.allocations; + } else { + log.error('Unable to get props allocations'); + } + }, + + async addPropsAllocation(allocation: Partial) { + const response = await fetch(makeURL('/api/v1/show/stage/props/allocations'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(allocation), + }); + if (response.ok) { + await this.getPropsAllocations(); + toast.success('Added prop allocation!'); + } else { + log.error('Unable to add prop allocation'); + toast.error('Unable to add prop allocation'); + } + }, + + async deletePropsAllocation(allocationId: number) { + const params = new URLSearchParams({ id: String(allocationId) }); + const response = await fetch(`${makeURL('/api/v1/show/stage/props/allocations')}?${params}`, { + method: 'DELETE', + }); + if (response.ok) { + await this.getPropsAllocations(); + toast.success('Deleted prop allocation!'); + } else { + log.error('Unable to delete prop allocation'); + toast.error('Unable to delete prop allocation'); + } + }, + + async getSceneryAllocations() { + const response = await fetch(makeURL('/api/v1/show/stage/scenery/allocations')); + if (response.ok) { + const data = await response.json(); + this.sceneryAllocations = data.allocations; + } else { + log.error('Unable to get scenery allocations'); + } + }, + + async addSceneryAllocation(allocation: Partial) { + const response = await fetch(makeURL('/api/v1/show/stage/scenery/allocations'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(allocation), + }); + if (response.ok) { + await this.getSceneryAllocations(); + toast.success('Added scenery allocation!'); + } else { + log.error('Unable to add scenery allocation'); + toast.error('Unable to add scenery allocation'); + } + }, + + async deleteSceneryAllocation(allocationId: number) { + const params = new URLSearchParams({ id: String(allocationId) }); + const response = await fetch( + `${makeURL('/api/v1/show/stage/scenery/allocations')}?${params}`, + { method: 'DELETE' } + ); + if (response.ok) { + await this.getSceneryAllocations(); + toast.success('Deleted scenery allocation!'); + } else { + log.error('Unable to delete scenery allocation'); + toast.error('Unable to delete scenery allocation'); + } + }, + + async getCrewAssignments() { + const response = await fetch(makeURL('/api/v1/show/stage/crew/assignments')); + if (response.ok) { + const data = await response.json(); + this.crewAssignments = data.assignments; + } else { + log.error('Unable to get crew assignments'); + } + }, + + async addCrewAssignment(assignment: Partial): Promise { + const response = await fetch(makeURL('/api/v1/show/stage/crew/assignments'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(assignment), + }); + if (response.ok) { + await this.getCrewAssignments(); + toast.success('Added crew assignment!'); + return true; + } else { + const errorData = await response.json().catch(() => ({})); + const errorMsg = + (errorData as { message?: string }).message ?? 'Unable to add crew assignment'; + log.error(errorMsg); + toast.error(errorMsg); + return false; + } + }, + + async deleteCrewAssignment(assignmentId: number) { + const params = new URLSearchParams({ id: String(assignmentId) }); + const response = await fetch(`${makeURL('/api/v1/show/stage/crew/assignments')}?${params}`, { + method: 'DELETE', + }); + if (response.ok) { + await this.getCrewAssignments(); + toast.success('Deleted crew assignment!'); + } else { + const errorData = await response.json().catch(() => ({})); + const errorMsg = + (errorData as { message?: string }).message ?? 'Unable to delete crew assignment'; + log.error(errorMsg); + toast.error(errorMsg); + } + }, + }, +}); diff --git a/client-v3/src/views/show/config/ConfigStage.vue b/client-v3/src/views/show/config/ConfigStage.vue new file mode 100644 index 00000000..4cbd1228 --- /dev/null +++ b/client-v3/src/views/show/config/ConfigStage.vue @@ -0,0 +1,29 @@ + + + From 9f398bf74206a0c4c3be3ba3f6434b580cce0c5b Mon Sep 17 00:00:00 2001 From: Tim Bradgate Date: Mon, 18 May 2026 23:51:20 +0100 Subject: [PATCH 12/23] =?UTF-8?q?Vue=203=20migration:=20Phase=2011=20?= =?UTF-8?q?=E2=80=94=20Script=20&=20Revisions=20configuration=20(#1057)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Vue 3 migration: Phase 11 — Script & Revisions configuration Ports the most complex config section of the Vue 2 UI to Vue 3/Pinia: - Stores: script.ts, scriptConfig.ts (with exported computePageStatus) - Composables: useScriptNavigation, useScriptDisplay - Utilities: scriptUtils.ts, mruSortUtils.ts (ported from V2) - Components: RevisionDetailModal, RevisionGraph (D3 hierarchy + zoom), ScriptRevisions (table + branch/load/delete modals), CompiledScripts, StageDirectionStyles (CRUD + import), ScriptLinePart, ScriptLineViewer, ScriptLineEditor, BulkActSceneModal, ScriptEditor (full non-collaborative editor) - Views: ConfigScript (Script + Stage Direction Styles tabs), ConfigScriptRevisions (Revisions + Compiled Scripts tabs) - Router: both show-config-script routes now point to real views Co-Authored-By: Claude Sonnet 4.6 * Fix bugs found during Phase 11 browser testing - Replace structuredClone with JSON round-trip in scriptConfig store and ScriptLineEditor: Vue reactive Proxy objects (Pinia state) cannot be structuredCloned in some environments - Fix canRequestEdit/currentEditor field name mapping (backend returns camelCase, store was reading snake_case) - Reload current page after stopEditing() clears tmpScript so lines remain visible in view mode - Complete StyleForm render function in StageDirectionStyles with all form fields (description, bold/italic/underline toggles, text format select, text colour picker, background colour toggle + picker) Co-Authored-By: Claude Sonnet 4.6 * Fix sticky navbar and transparent sticky header in V3 script editor BVN's :sticky="true" generates class sticky-true (not Bootstrap's sticky-top), so the top navbar scrolled away instead of staying fixed. Replace with class="sticky-top" on BNavbar in App.vue. Also define --body-background in dark.scss as an alias for --bs-body-bg, so the script editor sticky header (and timeline components) have a solid background instead of transparent. Co-Authored-By: Claude Sonnet 4.6 * Remove redundant hr below script sticky header The sticky header already has border-bottom via CSS, so the separate
created a double divider with extra gap below the header. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- client-v3/src/App.vue | 2 +- client-v3/src/assets/styles/dark.scss | 5 + .../show/config/script/BulkActSceneModal.vue | 114 +++ .../show/config/script/CompiledScripts.vue | 122 +++ .../config/script/RevisionDetailModal.vue | 145 ++++ .../show/config/script/RevisionGraph.vue | 313 +++++++ .../show/config/script/ScriptEditor.vue | 788 ++++++++++++++++++ .../show/config/script/ScriptLineEditor.vue | 245 ++++++ .../show/config/script/ScriptLinePart.vue | 254 ++++++ .../show/config/script/ScriptLineViewer.vue | 308 +++++++ .../show/config/script/ScriptRevisions.vue | 363 ++++++++ .../config/script/StageDirectionStyles.vue | 521 ++++++++++++ client-v3/src/composables/useScriptDisplay.ts | 37 + .../src/composables/useScriptNavigation.ts | 56 ++ client-v3/src/js/mruSortUtils.ts | 86 ++ client-v3/src/js/scriptUtils.ts | 21 + client-v3/src/router/index.ts | 4 +- client-v3/src/stores/script.ts | 198 +++++ client-v3/src/stores/scriptConfig.test.ts | 100 +++ client-v3/src/stores/scriptConfig.ts | 163 ++++ client-v3/src/stores/show.ts | 56 ++ .../src/views/show/config/ConfigScript.vue | 17 + .../show/config/ConfigScriptRevisions.vue | 30 + 23 files changed, 3945 insertions(+), 3 deletions(-) create mode 100644 client-v3/src/components/show/config/script/BulkActSceneModal.vue create mode 100644 client-v3/src/components/show/config/script/CompiledScripts.vue create mode 100644 client-v3/src/components/show/config/script/RevisionDetailModal.vue create mode 100644 client-v3/src/components/show/config/script/RevisionGraph.vue create mode 100644 client-v3/src/components/show/config/script/ScriptEditor.vue create mode 100644 client-v3/src/components/show/config/script/ScriptLineEditor.vue create mode 100644 client-v3/src/components/show/config/script/ScriptLinePart.vue create mode 100644 client-v3/src/components/show/config/script/ScriptLineViewer.vue create mode 100644 client-v3/src/components/show/config/script/ScriptRevisions.vue create mode 100644 client-v3/src/components/show/config/script/StageDirectionStyles.vue create mode 100644 client-v3/src/composables/useScriptDisplay.ts create mode 100644 client-v3/src/composables/useScriptNavigation.ts create mode 100644 client-v3/src/js/mruSortUtils.ts create mode 100644 client-v3/src/js/scriptUtils.ts create mode 100644 client-v3/src/stores/script.ts create mode 100644 client-v3/src/stores/scriptConfig.test.ts create mode 100644 client-v3/src/stores/scriptConfig.ts create mode 100644 client-v3/src/views/show/config/ConfigScript.vue create mode 100644 client-v3/src/views/show/config/ConfigScriptRevisions.vue diff --git a/client-v3/src/App.vue b/client-v3/src/App.vue index 490cdd3c..71bdd704 100644 --- a/client-v3/src/App.vue +++ b/client-v3/src/App.vue @@ -5,7 +5,7 @@ toggleable="lg" variant="info" data-bs-theme="dark" - :sticky="true" + class="sticky-top" > DigiScript diff --git a/client-v3/src/assets/styles/dark.scss b/client-v3/src/assets/styles/dark.scss index e7b163c7..918f1cab 100644 --- a/client-v3/src/assets/styles/dark.scss +++ b/client-v3/src/assets/styles/dark.scss @@ -9,6 +9,11 @@ body { -moz-osx-font-smoothing: grayscale; } +// Map V2's --body-background variable to Bootstrap 5's equivalent +:root { + --body-background: var(--bs-body-bg); +} + // BVN's BFormGroup renders .b-form-group; Bootstrap 5 dropped .form-group's built-in margin .b-form-group { margin-bottom: 1rem; diff --git a/client-v3/src/components/show/config/script/BulkActSceneModal.vue b/client-v3/src/components/show/config/script/BulkActSceneModal.vue new file mode 100644 index 00000000..68e856d8 --- /dev/null +++ b/client-v3/src/components/show/config/script/BulkActSceneModal.vue @@ -0,0 +1,114 @@ + + + diff --git a/client-v3/src/components/show/config/script/CompiledScripts.vue b/client-v3/src/components/show/config/script/CompiledScripts.vue new file mode 100644 index 00000000..1211772e --- /dev/null +++ b/client-v3/src/components/show/config/script/CompiledScripts.vue @@ -0,0 +1,122 @@ + + + diff --git a/client-v3/src/components/show/config/script/RevisionDetailModal.vue b/client-v3/src/components/show/config/script/RevisionDetailModal.vue new file mode 100644 index 00000000..c53249f4 --- /dev/null +++ b/client-v3/src/components/show/config/script/RevisionDetailModal.vue @@ -0,0 +1,145 @@ + + + + + diff --git a/client-v3/src/components/show/config/script/RevisionGraph.vue b/client-v3/src/components/show/config/script/RevisionGraph.vue new file mode 100644 index 00000000..685b1014 --- /dev/null +++ b/client-v3/src/components/show/config/script/RevisionGraph.vue @@ -0,0 +1,313 @@ + + + + + diff --git a/client-v3/src/components/show/config/script/ScriptEditor.vue b/client-v3/src/components/show/config/script/ScriptEditor.vue new file mode 100644 index 00000000..160bd213 --- /dev/null +++ b/client-v3/src/components/show/config/script/ScriptEditor.vue @@ -0,0 +1,788 @@ + + + + + diff --git a/client-v3/src/components/show/config/script/ScriptLineEditor.vue b/client-v3/src/components/show/config/script/ScriptLineEditor.vue new file mode 100644 index 00000000..7ac00197 --- /dev/null +++ b/client-v3/src/components/show/config/script/ScriptLineEditor.vue @@ -0,0 +1,245 @@ + + + diff --git a/client-v3/src/components/show/config/script/ScriptLinePart.vue b/client-v3/src/components/show/config/script/ScriptLinePart.vue new file mode 100644 index 00000000..020aeaf0 --- /dev/null +++ b/client-v3/src/components/show/config/script/ScriptLinePart.vue @@ -0,0 +1,254 @@ + + + diff --git a/client-v3/src/components/show/config/script/ScriptLineViewer.vue b/client-v3/src/components/show/config/script/ScriptLineViewer.vue new file mode 100644 index 00000000..b8e0cfec --- /dev/null +++ b/client-v3/src/components/show/config/script/ScriptLineViewer.vue @@ -0,0 +1,308 @@ + + + + + diff --git a/client-v3/src/components/show/config/script/ScriptRevisions.vue b/client-v3/src/components/show/config/script/ScriptRevisions.vue new file mode 100644 index 00000000..c5e95bae --- /dev/null +++ b/client-v3/src/components/show/config/script/ScriptRevisions.vue @@ -0,0 +1,363 @@ + + + + + diff --git a/client-v3/src/components/show/config/script/StageDirectionStyles.vue b/client-v3/src/components/show/config/script/StageDirectionStyles.vue new file mode 100644 index 00000000..5c030f46 --- /dev/null +++ b/client-v3/src/components/show/config/script/StageDirectionStyles.vue @@ -0,0 +1,521 @@ + + + + + diff --git a/client-v3/src/composables/useScriptDisplay.ts b/client-v3/src/composables/useScriptDisplay.ts new file mode 100644 index 00000000..1f5bb984 --- /dev/null +++ b/client-v3/src/composables/useScriptDisplay.ts @@ -0,0 +1,37 @@ +import { LINE_TYPES } from '@/constants/lineTypes'; +import { TEXT_ALIGNMENT_CSS } from '@/constants/textAlignment'; +import type { TextAlignment } from '@/constants/textAlignment'; +import type { ScriptLine, StageDirectionStyle } from '@/types/api/script'; + +export function useScriptDisplay() { + function getStageDirectionStyle( + line: ScriptLine, + styles: StageDirectionStyle[], + overrides: StageDirectionStyle[] + ): StageDirectionStyle | null { + if (line.line_type !== LINE_TYPES.STAGE_DIRECTION) return null; + const style = styles.find((s) => s.id === line.stage_direction_style_id) ?? null; + if (!style) return null; + const override = (overrides as any[]).find((o) => o.settings?.id === style.id); + return override ? override.settings : style; + } + + function stageDirectionStyling(style: StageDirectionStyle | null): Record { + if (!style) return { 'background-color': 'darkslateblue', 'font-style': 'italic' }; + const result: Record = { + 'font-weight': style.bold ? 'bold' : 'normal', + 'font-style': style.italic ? 'italic' : 'normal', + 'text-decoration-line': style.underline ? 'underline' : 'none', + color: style.text_colour ?? '', + }; + if (style.enable_background_colour) result['background-color'] = style.background_colour ?? ''; + return result; + } + + function scriptTextAlign(userSettings: Record): string { + const alignment = (userSettings?.script_text_alignment as TextAlignment) ?? 2; + return TEXT_ALIGNMENT_CSS[alignment] || 'center'; + } + + return { getStageDirectionStyle, stageDirectionStyling, scriptTextAlign }; +} diff --git a/client-v3/src/composables/useScriptNavigation.ts b/client-v3/src/composables/useScriptNavigation.ts new file mode 100644 index 00000000..61417b91 --- /dev/null +++ b/client-v3/src/composables/useScriptNavigation.ts @@ -0,0 +1,56 @@ +import { LINE_TYPES } from '@/constants/lineTypes'; +import { isWholeLineCut } from '@/js/scriptUtils'; +import type { ScriptLine } from '@/types/api/script'; + +export function useScriptNavigation() { + function checkIsUntaggedStageDirection(line: ScriptLine): boolean { + return ( + line.line_type === LINE_TYPES.STAGE_DIRECTION && + line.line_parts[0]?.character_id == null && + line.line_parts[0]?.character_group_id == null + ); + } + + function needsHeadings( + line: ScriptLine, + previousLine: ScriptLine | null, + cuts: (number | null)[], + getPage: (page: number) => ScriptLine[] + ): boolean[] { + let prev: ScriptLine | null = previousLine; + while (prev != null && (checkIsUntaggedStageDirection(prev) || isWholeLineCut(prev, cuts))) { + const prevPage = getPage(prev.page ?? 0); + const idx = prevPage.indexOf(prev); + prev = idx > 0 ? prevPage[idx - 1] : null; + } + + return line.line_parts.map((part) => { + if (prev == null || prev.line_parts.length !== line.line_parts.length) return true; + if (prev.act_id !== line.act_id || prev.scene_id !== line.scene_id) return true; + const match = prev.line_parts.find((p) => p.part_index === part.part_index); + if (!match) return true; + return !( + match.character_id === part.character_id && + match.character_group_id === part.character_group_id + ); + }); + } + + function needsActSceneLabel( + line: ScriptLine, + previousLine: ScriptLine | null, + cuts: (number | null)[], + getPage: (page: number) => ScriptLine[] + ): boolean { + let prev: ScriptLine | null = previousLine; + while (prev != null && isWholeLineCut(prev, cuts)) { + const prevPage = getPage(prev.page ?? 0); + const idx = prevPage.indexOf(prev); + prev = idx > 0 ? prevPage[idx - 1] : null; + } + if (prev == null) return true; + return !(prev.act_id === line.act_id && prev.scene_id === line.scene_id); + } + + return { needsHeadings, needsActSceneLabel, checkIsUntaggedStageDirection }; +} diff --git a/client-v3/src/js/mruSortUtils.ts b/client-v3/src/js/mruSortUtils.ts new file mode 100644 index 00000000..a90d1f6f --- /dev/null +++ b/client-v3/src/js/mruSortUtils.ts @@ -0,0 +1,86 @@ +type NamedItem = { id: number; name: string }; +type LinePart = { character_id?: number | null; character_group_id?: number | null }; +type ScriptLine = { line_parts?: LinePart[] }; +type TmpScript = Record; +export type SelectOption = { value: number | null; text: string }; +export type CombinedSelectOption = + | { value: null; text: string } + | { label: string; options: { value: string; text: string }[] }; + +function countOccurrences( + tmpScript: TmpScript, + field: 'character_id' | 'character_group_id' +): Record { + const counts: Record = {}; + Object.values(tmpScript).forEach((page) => { + page.forEach((line) => { + (line.line_parts ?? []).forEach((part) => { + const id = part[field]; + if (id != null) { + counts[id] = (counts[id] || 0) + 1; + } + }); + }); + }); + return counts; +} + +export function buildMruCharacterOptions( + characters: NamedItem[], + tmpScript: TmpScript +): SelectOption[] | null { + const counts = countOccurrences(tmpScript, 'character_id'); + if (Object.keys(counts).length === 0) return null; + const sorted = [...characters].sort((a, b) => (counts[b.id] || 0) - (counts[a.id] || 0)); + return [{ value: null, text: 'N/A' }, ...sorted.map((c) => ({ value: c.id, text: c.name }))]; +} + +export function buildMruCharacterGroupOptions( + characterGroups: NamedItem[], + tmpScript: TmpScript +): SelectOption[] | null { + const counts = countOccurrences(tmpScript, 'character_group_id'); + if (Object.keys(counts).length === 0) return null; + const sorted = [...characterGroups].sort((a, b) => (counts[b.id] || 0) - (counts[a.id] || 0)); + return [{ value: null, text: 'N/A' }, ...sorted.map((g) => ({ value: g.id, text: g.name }))]; +} + +export function buildCombinedCharacterOptions( + characters: NamedItem[], + characterGroups: NamedItem[], + tmpScript: TmpScript, + useMru: boolean +): CombinedSelectOption[] { + let sortedChars = [...characters]; + let sortedGroups = [...characterGroups]; + + if (useMru) { + const charCounts = countOccurrences(tmpScript, 'character_id'); + if (Object.keys(charCounts).length > 0) { + sortedChars = [...characters].sort( + (a, b) => (charCounts[b.id] || 0) - (charCounts[a.id] || 0) + ); + } + const groupCounts = countOccurrences(tmpScript, 'character_group_id'); + if (Object.keys(groupCounts).length > 0) { + sortedGroups = [...characterGroups].sort( + (a, b) => (groupCounts[b.id] || 0) - (groupCounts[a.id] || 0) + ); + } + } + + const result: CombinedSelectOption[] = [{ value: null, text: 'N/A' }]; + if (sortedChars.length > 0) { + result.push({ + label: 'Characters', + options: sortedChars.map((c) => ({ value: `c:${c.id}`, text: c.name })), + }); + } + if (sortedGroups.length > 0) { + result.push({ + label: 'Character Groups', + options: sortedGroups.map((g) => ({ value: `g:${g.id}`, text: g.name })), + }); + } + return result; +} diff --git a/client-v3/src/js/scriptUtils.ts b/client-v3/src/js/scriptUtils.ts new file mode 100644 index 00000000..dd3ddb9c --- /dev/null +++ b/client-v3/src/js/scriptUtils.ts @@ -0,0 +1,21 @@ +import { LINE_TYPES } from '@/constants/lineTypes'; +import type { ScriptLine } from '@/types/api/script'; + +export function isWholeLineCut(line: ScriptLine, cuts: (number | null)[]): boolean { + if (line.line_type === LINE_TYPES.CUE_LINE) { + return false; + } + + if (line.line_type === LINE_TYPES.SPACING) { + return true; + } + + return line.line_parts.every( + (linePart) => + cuts.includes(linePart.id) || + linePart.line_text == null || + linePart.line_text.trim().length === 0 + ); +} + +export default { isWholeLineCut }; diff --git a/client-v3/src/router/index.ts b/client-v3/src/router/index.ts index 59c1387c..43e4e65d 100644 --- a/client-v3/src/router/index.ts +++ b/client-v3/src/router/index.ts @@ -87,13 +87,13 @@ const router = createRouter({ { name: 'show-config-script', path: 'script', - component: PlaceholderView, + component: () => import('@/views/show/config/ConfigScript.vue'), meta: { requiresAuth: true, requiresShowAccess: true }, }, { name: 'show-config-script-revisions', path: 'script-revisions', - component: PlaceholderView, + component: () => import('@/views/show/config/ConfigScriptRevisions.vue'), meta: { requiresAuth: true, requiresShowAccess: true }, }, { diff --git a/client-v3/src/stores/script.ts b/client-v3/src/stores/script.ts new file mode 100644 index 00000000..d63a8d40 --- /dev/null +++ b/client-v3/src/stores/script.ts @@ -0,0 +1,198 @@ +import { defineStore } from 'pinia'; +import log from 'loglevel'; +import { makeURL } from '@/js/utils'; +import { toast } from '@/js/toast'; +import type { + ScriptLine, + StageDirectionStyle, + CompiledScript, + ScriptCut, +} from '@/types/api/script'; + +export const useScriptStore = defineStore('script', { + state: () => ({ + script: {} as Record, + stageDirectionStyles: [] as StageDirectionStyle[], + compiledScripts: [] as CompiledScript[], + cuts: [] as ScriptCut[], + maxPage: 1, + }), + + getters: { + getScriptPage: + (state) => + (page: number | string): ScriptLine[] => + state.script[String(page)] ?? [], + stageDirectionStyleById: + (state) => + (id: number | null): StageDirectionStyle | null => + id != null ? (state.stageDirectionStyles.find((s) => s.id === id) ?? null) : null, + }, + + actions: { + async loadScriptPage(page: number | string): Promise { + const params = new URLSearchParams({ page: String(page) }); + const response = await fetch(`${makeURL('/api/v1/show/script')}?${params}`); + if (response.ok) { + const data = await response.json(); + this.script[String(data.page)] = data.lines; + } else { + log.error('Unable to load script page'); + } + }, + + async saveNewPage(page: number, lines: ScriptLine[]): Promise { + const params = new URLSearchParams({ page: String(page) }); + const response = await fetch(`${makeURL('/api/v1/show/script')}?${params}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(lines), + }); + return response.ok; + }, + + async saveChangedPage( + page: number, + payload: { page: ScriptLine[]; status: unknown } + ): Promise { + const params = new URLSearchParams({ page: String(page) }); + const response = await fetch(`${makeURL('/api/v1/show/script')}?${params}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + return response.ok; + }, + + async getMaxPage(): Promise { + const response = await fetch(makeURL('/api/v1/show/script/max_page')); + if (response.ok) { + const data = await response.json(); + this.maxPage = data.max_page; + } + }, + + async getStageDirectionStyles(): Promise { + const response = await fetch(makeURL('/api/v1/show/script/stage_direction_styles')); + if (response.ok) { + const data = await response.json(); + this.stageDirectionStyles = data.styles; + } else { + log.error('Unable to load stage direction styles'); + } + }, + + async addStageDirectionStyle(style: Partial): Promise { + const response = await fetch(makeURL('/api/v1/show/script/stage_direction_styles'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(style), + }); + if (response.ok) { + await this.getStageDirectionStyles(); + toast.success('Added new stage direction style!'); + } else { + toast.error('Unable to add new stage direction style'); + } + }, + + async updateStageDirectionStyle(style: Partial): Promise { + const response = await fetch(makeURL('/api/v1/show/script/stage_direction_styles'), { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(style), + }); + if (response.ok) { + await this.getStageDirectionStyles(); + toast.success('Updated stage direction style!'); + } else { + toast.error('Unable to edit stage direction style'); + } + }, + + async deleteStageDirectionStyle(id: number): Promise { + const params = new URLSearchParams({ id: String(id) }); + const response = await fetch( + `${makeURL('/api/v1/show/script/stage_direction_styles')}?${params}`, + { method: 'DELETE' } + ); + if (response.ok) { + await this.getStageDirectionStyles(); + toast.success('Deleted stage direction style!'); + } else { + toast.error('Unable to delete stage direction style'); + } + }, + + async getImportableStyles(): Promise { + const response = await fetch(makeURL('/api/v1/show/script/stage_direction_styles/import')); + if (!response.ok) throw new Error('Failed to fetch importable styles'); + return response.json(); + }, + + async getCompiledScripts(): Promise { + const response = await fetch(makeURL('/api/v1/show/script/compiled_scripts')); + if (response.ok) { + const data = await response.json(); + this.compiledScripts = data.scripts; + } else { + log.error('Unable to load compiled scripts'); + } + }, + + async generateCompiledScript(revisionId: number): Promise { + const response = await fetch(makeURL('/api/v1/show/script/compiled_scripts'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ revision_id: revisionId }), + }); + if (response.ok) { + await this.getCompiledScripts(); + toast.success('Generated compiled script!'); + } else { + toast.error('Unable to generate compiled script'); + } + }, + + async deleteCompiledScript(revisionId: number): Promise { + const params = new URLSearchParams({ revision_id: String(revisionId) }); + const response = await fetch(`${makeURL('/api/v1/show/script/compiled_scripts')}?${params}`, { + method: 'DELETE', + }); + if (response.ok) { + await this.getCompiledScripts(); + toast.success('Deleted compiled script!'); + } else { + toast.error('Unable to delete compiled script'); + } + }, + + async getCuts(): Promise { + const response = await fetch(makeURL('/api/v1/show/script/cuts')); + if (response.ok) { + const data = await response.json(); + this.cuts = data.cuts; + } else { + log.error('Unable to load script cuts'); + } + }, + + async saveCuts(cuts: ScriptCut[]): Promise { + const response = await fetch(makeURL('/api/v1/show/script/cuts'), { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ cuts }), + }); + if (response.ok) { + await this.getCuts(); + toast.success('Saved script cuts!'); + } else { + toast.error('Unable to save script cuts'); + } + }, + + clearScript(): void { + this.script = {}; + }, + }, +}); diff --git a/client-v3/src/stores/scriptConfig.test.ts b/client-v3/src/stores/scriptConfig.test.ts new file mode 100644 index 00000000..9a19dec2 --- /dev/null +++ b/client-v3/src/stores/scriptConfig.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect } from 'vitest'; +import type { ScriptLine } from '@/types/api/script'; +import { computePageStatus } from './scriptConfig'; + +function makeLine(id: number | null, numParts: number): ScriptLine { + return { + id, + act_id: 1, + scene_id: 1, + page: 1, + line_type: 1, + stage_direction_style_id: null, + line_parts: Array.from({ length: numParts }, (_, i) => ({ + id: id != null ? 100 + i : null, + line_id: id, + part_index: i, + character_id: 1, + character_group_id: null, + line_text: `Part ${i}`, + })), + }; +} + +describe('computePageStatus', () => { + it('classifies an existing line whose line_parts grew as updated, not added', () => { + const saved = [makeLine(42, 1)]; + const edited = [makeLine(42, 2)]; + + const status = computePageStatus(saved, edited, [], []); + + expect(status.added).not.toContain(0); + expect(status.updated).toContain(0); + expect(status.deleted).toHaveLength(0); + expect(status.inserted).toHaveLength(0); + }); + + it('classifies a genuinely new line (id == null) as added', () => { + const saved: ScriptLine[] = []; + const edited = [makeLine(null, 1)]; + + const status = computePageStatus(saved, edited, [], []); + + expect(status.added).toContain(0); + expect(status.updated).not.toContain(0); + }); + + it('classifies an existing line with a changed top-level field as updated', () => { + const saved = [makeLine(42, 1)]; + const edited = [{ ...makeLine(42, 1), line_type: 2 }]; + + const status = computePageStatus(saved, edited, [], []); + + expect(status.updated).toContain(0); + expect(status.added).not.toContain(0); + }); + + it('passes deleted line indices through to the deleted array', () => { + const saved = [makeLine(10, 1), makeLine(11, 1)]; + const edited = [makeLine(10, 1), makeLine(11, 1)]; + + const status = computePageStatus(saved, edited, [1], []); + + expect(status.deleted).toContain(1); + expect(status.added).toHaveLength(0); + expect(status.updated).toHaveLength(0); + }); + + it('passes inserted line indices through to the inserted array', () => { + const saved = [makeLine(10, 1)]; + const newLine = makeLine(null, 1); + const edited = [makeLine(10, 1), newLine]; + + const status = computePageStatus(saved, edited, [], [1]); + + expect(status.inserted).toContain(1); + }); + + it('handles a mixed page: one truly-new line and one existing line whose parts grew', () => { + const saved = [makeLine(42, 1)]; + const edited = [makeLine(42, 2), makeLine(null, 1)]; + + const status = computePageStatus(saved, edited, [], []); + + expect(status.updated).toContain(0); + expect(status.added).not.toContain(0); + expect(status.added).toContain(1); + expect(status.updated).not.toContain(1); + }); + + it('returns all empty arrays when actual and tmp pages are identical', () => { + const page = [makeLine(42, 2), makeLine(43, 1)]; + + const status = computePageStatus(page, JSON.parse(JSON.stringify(page)), [], []); + + expect(status.added).toHaveLength(0); + expect(status.updated).toHaveLength(0); + expect(status.deleted).toHaveLength(0); + expect(status.inserted).toHaveLength(0); + }); +}); diff --git a/client-v3/src/stores/scriptConfig.ts b/client-v3/src/stores/scriptConfig.ts new file mode 100644 index 00000000..107662cd --- /dev/null +++ b/client-v3/src/stores/scriptConfig.ts @@ -0,0 +1,163 @@ +import { defineStore } from 'pinia'; +import { detailedDiff } from 'deep-object-diff'; +import log from 'loglevel'; +import { makeURL } from '@/js/utils'; +import { toast } from '@/js/toast'; +import type { ScriptLine, PageStatus } from '@/types/api/script'; + +/** + * Computes the page status object (added/updated/deleted/inserted) to send to the PATCH endpoint. + * + * deepDiff.added fires on a line index whenever *any* nested property is new — including a new + * element in line_parts. Only lines with id == null are truly new; lines with an existing id that + * have nested additions must be treated as updates instead. + */ +export function computePageStatus( + actualScriptPage: ScriptLine[], + tmpScriptPage: ScriptLine[], + deletedLines: number[], + insertedLines: number[] +): PageStatus { + const augmented: ScriptLine[] = JSON.parse(JSON.stringify(actualScriptPage)); + JSON.parse(JSON.stringify(insertedLines)) + .sort((a: number, b: number) => a - b) + .forEach((lineIndex: number) => { + augmented.splice(lineIndex, 0, JSON.parse(JSON.stringify(tmpScriptPage[lineIndex]))); + }); + + const deepDiff = detailedDiff(augmented, tmpScriptPage); + const addedIndices = Object.keys(deepDiff.added).map((x) => parseInt(x, 10)); + return { + added: addedIndices.filter((idx) => tmpScriptPage[idx]?.id == null), + updated: [ + ...Object.keys(deepDiff.updated).map((x) => parseInt(x, 10)), + ...addedIndices.filter((idx) => tmpScriptPage[idx]?.id != null), + ], + deleted: [...deletedLines], + inserted: [...insertedLines], + }; +} + +interface EditStatus { + canRequestEdit: boolean; + currentEditor: string | null; +} + +export const useScriptConfigStore = defineStore('scriptConfig', { + state: () => ({ + tmpScript: {} as Record, + deletedLines: {} as Record, + insertedLines: {} as Record, + editStatus: { canRequestEdit: false, currentEditor: null } as EditStatus, + cutMode: false, + }), + + getters: { + getTmpPage: + (state) => + (page: number | string): ScriptLine[] => + state.tmpScript[String(page)] ?? [], + getDeletedLines: + (state) => + (page: number | string): number[] => + state.deletedLines[String(page)] ?? [], + getInsertedLines: + (state) => + (page: number | string): number[] => + state.insertedLines[String(page)] ?? [], + }, + + actions: { + addPage(page: number, contents: ScriptLine[]): void { + // JSON round-trip instead of structuredClone — reactive Pinia arrays (Proxy objects) + // cannot be structuredCloned in some environments. + this.tmpScript[String(page)] = JSON.parse(JSON.stringify(contents)); + if (!this.deletedLines[String(page)]) this.deletedLines[String(page)] = []; + if (!this.insertedLines[String(page)]) this.insertedLines[String(page)] = []; + }, + + removePage(page: number): void { + delete this.tmpScript[String(page)]; + delete this.deletedLines[String(page)]; + delete this.insertedLines[String(page)]; + }, + + addBlankLine(page: number, line: ScriptLine): void { + const l = JSON.parse(JSON.stringify(line)); + l.page = page; + this.tmpScript[String(page)].push(l); + }, + + insertBlankLine(page: number, lineIndex: number, line: ScriptLine): void { + const pageStr = String(page); + if (this.deletedLines[pageStr]?.includes(lineIndex)) { + const l = JSON.parse(JSON.stringify(line)); + l.page = page; + l.id = this.tmpScript[pageStr][lineIndex].id; + this.tmpScript[pageStr].splice(lineIndex, 1, l); + this.deletedLines[pageStr].splice(this.deletedLines[pageStr].indexOf(lineIndex), 1); + } else { + const l = JSON.parse(JSON.stringify(line)); + l.page = page; + this.tmpScript[pageStr].splice(lineIndex, 0, l); + if (!this.insertedLines[pageStr]) this.insertedLines[pageStr] = []; + this.insertedLines[pageStr].push(lineIndex); + } + }, + + setLine(page: number, lineIndex: number, line: ScriptLine): void { + this.tmpScript[String(page)][lineIndex] = line; + }, + + deleteLine(page: number, lineIndex: number): void { + const pageStr = String(page); + if (this.tmpScript[pageStr][lineIndex].id !== null) { + if (!this.deletedLines[pageStr]) this.deletedLines[pageStr] = []; + this.deletedLines[pageStr].push(lineIndex); + } else { + this.tmpScript[pageStr].splice(lineIndex, 1); + } + if (this.insertedLines[pageStr]?.includes(lineIndex)) { + this.insertedLines[pageStr].splice(this.insertedLines[pageStr].indexOf(lineIndex), 1); + } + }, + + resetTracking(page: number): void { + this.deletedLines[String(page)] = []; + this.insertedLines[String(page)] = []; + }, + + emptyScript(): void { + this.tmpScript = {}; + this.deletedLines = {}; + this.insertedLines = {}; + }, + + setCutMode(val: boolean): void { + this.cutMode = val; + }, + + setEditStatus(status: EditStatus): void { + this.editStatus = status; + }, + + async getScriptConfigStatus(): Promise { + const response = await fetch(makeURL('/api/v1/show/script/config')); + if (response.ok) { + const data = await response.json(); + this.editStatus = { + canRequestEdit: data.canRequestEdit, + currentEditor: data.currentEditor, + }; + } else { + log.error('Unable to get script config status'); + } + }, + + async requestEditFailure(): Promise { + toast.error('Unable to edit script'); + await this.getScriptConfigStatus(); + this.cutMode = false; + }, + }, +}); diff --git a/client-v3/src/stores/show.ts b/client-v3/src/stores/show.ts index 26a4b21a..428d4e6f 100644 --- a/client-v3/src/stores/show.ts +++ b/client-v3/src/stores/show.ts @@ -34,6 +34,7 @@ export const useShowStore = defineStore('show', { scriptModes: [] as ScriptMode[], sessionTags: [] as SessionTag[], scriptRevisions: [] as ScriptRevision[], + currentRevision: null as number | null, stageManagerMode: false, }), @@ -722,11 +723,65 @@ export const useShowStore = defineStore('show', { if (response.ok) { const data = await response.json(); this.scriptRevisions = data.revisions ?? []; + this.currentRevision = data.current_revision ?? null; } else { log.error('Unable to get script revisions'); } }, + async addScriptRevision(payload: { + description: string; + parent_revision_id?: number | null; + set_as_current?: boolean | null; + }): Promise { + const body: Record = { description: payload.description }; + if (payload.parent_revision_id != null) body.parent_revision_id = payload.parent_revision_id; + if (payload.set_as_current != null) body.set_as_current = payload.set_as_current; + const response = await fetch(makeURL('/api/v1/show/script/revisions'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (response.ok) { + await this.getScriptRevisions(); + toast.success('Added new script revision!'); + } else { + toast.error('Unable to add new script revision'); + } + }, + + async deleteScriptRevision(revisionId: number): Promise { + const params = new URLSearchParams({ rev_id: String(revisionId) }); + const response = await fetch(`${makeURL('/api/v1/show/script/revisions')}?${params}`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + }); + if (response.ok) { + await this.getScriptRevisions(); + toast.success('Deleted script revision!'); + } else { + toast.error('Unable to delete script revision'); + } + }, + + async loadScriptRevision(revisionId: number): Promise { + const response = await fetch(makeURL('/api/v1/show/script/revisions/current'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ new_rev_id: revisionId }), + }); + if (response.ok) { + await this.getScriptRevisions(); + toast.success('Loaded script revision!'); + } else { + toast.error('Unable to load script revision'); + } + }, + + async scriptRevisionChanged(): Promise { + await this.getScriptRevisions(); + }, + clearCurrentShow(): void { this.castList = []; this.characterList = []; @@ -734,6 +789,7 @@ export const useShowStore = defineStore('show', { this.sceneList = []; this.sessionTags = []; this.scriptRevisions = []; + this.currentRevision = null; }, // WS-triggered actions diff --git a/client-v3/src/views/show/config/ConfigScript.vue b/client-v3/src/views/show/config/ConfigScript.vue new file mode 100644 index 00000000..9ffbfe8d --- /dev/null +++ b/client-v3/src/views/show/config/ConfigScript.vue @@ -0,0 +1,17 @@ + + + diff --git a/client-v3/src/views/show/config/ConfigScriptRevisions.vue b/client-v3/src/views/show/config/ConfigScriptRevisions.vue new file mode 100644 index 00000000..0742bf91 --- /dev/null +++ b/client-v3/src/views/show/config/ConfigScriptRevisions.vue @@ -0,0 +1,30 @@ + + + From 2bb49ecf34b8b24b80b0eef07fc1fd432c42852e Mon Sep 17 00:00:00 2001 From: Tim Bradgate Date: Tue, 19 May 2026 20:43:16 +0100 Subject: [PATCH 13/23] Phase 12: Live Show View (Vue 3 migration) (#1058) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add Phase 12: Live Show View (Vue 3 migration) Ports the real-time live show execution view to Vue 3 + Pinia: - ShowLiveView.vue: session header, elapsed time, interval overlay with countdown, Splitpanes layout - ScriptViewPane.vue: compiled/page-by-page script loading, keyboard/wheel navigation, script leader mode, lazy page loading via MutationObserver, add-cue and start-interval modals - ScriptLineViewer.vue: normal mode with cue buttons, act/scene labels, interval banners, stage direction styling, cue position left/right support - ScriptLineViewerCompact.vue: compact 2-column mode with cues as rows - StageManagerPane.vue: scene list with setting/striking props/scenery/crew, auto-expand on session follow Adds cues state + loadCues/addNewCue actions to script store. Updates /live route from PlaceholderView to ShowLiveView. Co-Authored-By: Claude Sonnet 4.6 * Fix three live view bugs: session guard, splitpanes background, v-once collision - router: add proper /live session guard matching V2 — redirect home if no active session or WebSocket is unhealthy (was placeholder comment) - ShowLiveView: override splitpanes v4 pane background with correct specificity (:deep(.default-theme.splitpanes .splitpanes__pane)) so the dark body colour shows instead of the library's default light grey - ScriptViewPane: remove v-once from ScriptLineViewer/Compact in the double-nested v-for — Vue 3 shares _cache[] across all outer iterations so every page rendered page 1's cached VNodes, causing all 1275 line IDs to appear as page_1_* and making cross-page navigation impossible Co-Authored-By: Claude Sonnet 4.6 * Wire show session into App.vue: fix navbar state and live redirect currentShowSession was hardcoded to null (Phase 6 placeholder), causing three bugs: Live nav item always disabled, System/Show Config items never hidden during sessions, and no auto-redirect to /live on initial load. - Import useShowStore and useRouter in App.vue - currentShowSession now reads from showStore.currentSession (reactive) - awaitWSConnect fetches session data after WS connects, then redirects to /live if a session is already active and the user isn't there yet (matches V2 App.vue awaitWSConnect behaviour) Co-Authored-By: Claude Sonnet 4.6 * Fix live redirect to use full path matching WS composable convention router.push('/live') with createWebHistory('/ui-new/') navigates to the V2 app at /live instead of the V3 app at /ui-new/live. The WS composable already uses the full path explicitly (router.push('/ui-new/live')) and checks router.currentRoute.value.path against '/ui-new/live'. Apply the same pattern in awaitWSConnect. Co-Authored-By: Claude Sonnet 4.6 * Revert live redirect path: useRouter() prepends base automatically router.push('/ui-new/live') via useRouter() causes double-base /ui-new/ui-new/live. The direct module import in useWebSocket.ts bypasses base handling and needs the full path; useRouter() in components does not. Revert to router.push('/live') with currentRoute path check against '/live'. Co-Authored-By: Claude Sonnet 4.6 * Fix cues not rendering: contrastColor called with object instead of string contrastColor(utils.ts) expects a plain string but the template passed { bgColor: '...' }. This threw TypeError inside the v-once BContainer block; Vue caught the error and cached the failed (empty) render for the cue column. Fix: call contrastColor(cueBackgroundColour(cue)) directly. Co-Authored-By: Claude Sonnet 4.6 * Add variant="success" to add-cue buttons in ScriptLineViewer Matches V2 styling — compact viewer already had this correctly. Co-Authored-By: Claude Sonnet 4.6 * Fix V2 parity issues in live show view - Add Stage Manager toggle to Live Config navbar dropdown - Fix needsIntervalBanner to show at all act boundaries (not just interval_after=true) - Fix cue_position_right default to false (left) matching backend default - Block keyboard/wheel navigation when interval or cue modal is open - Move add-cue "+" button outside BButtonGroup for independent rounded corners - Add variant="success" to "+" button for correct green styling Co-Authored-By: Claude Sonnet 4.6 * Fix START_SHOW/STOP_SHOW router push paths Vue Router 4 applies the base path (/ui-new/) internally regardless of whether the router is accessed via useRouter() or direct import. Using full paths like /ui-new/live caused double-prepending to /ui-new/ui-new/live. Also fix the currentRoute.value.path guard comparisons — the path property returns the route without the base prefix. Co-Authored-By: Claude Sonnet 4.6 * Fix V2 parity gaps found during pre-release audit - HomeView: wire currentShowSession to showStore.currentSession (was null placeholder) - App.vue: add CreateUser component to the no-admin-user setup screen - App.vue: call getRbacRoles() in awaitWSConnect so navbar RBAC is ready before first navigation - Router: /live route now requiresAuth: false (unauthenticated clients can join live view) - Router: already-logged-in redirect returns from.fullPath instead of / unconditionally - Router: /force-password-change gains requiresPasswordChange: true meta - Router: remove unused PlaceholderView import - stores/system: settingsChanged skips re-fetch when show ID unchanged; redirects away from /show-config and /live when no show is loaded (matching V2 behaviour) - stores/show: noLeader toast is now persistent (duration: 0) and dismissed when a leader is elected or getShowSessionData finds an active leader - useWebSocket: re-fetch show session data on reconnect after errors Co-Authored-By: Claude Sonnet 4.6 * Add cue assignment editor to Phase 12 (Cue Configuration tab) Ports the V2 CueEditor/ScriptLineCueEditor/JumpToCueModal trio to V3, completing the Cue Configuration tab previously showing a placeholder. - ScriptLineCueEditor: per-line cue display with add/edit/delete modals, RBAC-filtered cue type options, and duplicate-ident validation - JumpToCueModal: fuzzy cue search with exact/suggestion/no-match states - CueEditor: page navigator with localStorage persistence, Go To Page modal, and adjacent-page pre-fetching for smooth scrolling - script store: adds editCue, deleteCue, and searchCues actions - ConfigCues: replaces PlaceholderView with CueEditor in Cue Configuration tab Co-Authored-By: Claude Sonnet 4.6 * Fix cue column visual parity: remove border-right, square add button - Remove border-right from .cue-column (V2 had no vertical divider line) - Move "+" button inside BButtonGroup (matches V2 behaviour) - Add .add-cue-button CSS to make the "+" button a square icon-sized element, matching V2's plus-square-fill icon appearance Co-Authored-By: Claude Sonnet 4.6 * Use inline plus-square-fill SVG for add-cue button Replaces the variant="success" text "+" button with an inline SVG that faithfully reproduces V2's b-icon-plus-square-fill icon: default button variant (dark background in dark mode) with a 1em × 1em green filled- square icon, exactly matching the original cue column appearance. Co-Authored-By: Claude Sonnet 4.6 * Fix add-cue icon colour to match V2 (#06BC8C) SVG fill was using Bootstrap 5's default success green (#198754). V2's b-icon-plus-square-fill variant="success" resolved to the Bootswatch darkly success colour (#06BC8C), which is the correct value. Co-Authored-By: Claude Sonnet 4.6 * Restore 15px base font size to match V2 Bootswatch darkly Bootstrap 4 Bootswatch darkly sets $font-size-base: 0.9375rem (15px). Bootstrap 5 Bootswatch darkly drops this override, defaulting to 1rem (16px). Every rem-based size — line heights, paddings, margins, button heights — was 6.7% larger, reducing visible content per screen. Co-Authored-By: Claude Sonnet 4.6 * Fix navbar padding and font weight to match V2 Bootstrap 4 defaulted $navbar-padding-x to $spacer (1rem = 16px); Bootstrap 5 changed it to null → 0px, making navbar content flush to the viewport edge. Restore with $navbar-padding-x: 1rem. V2 App.vue had a global `nav a { font-weight: bold }` rule making the navbar brand and nav-links bold (fontWeight 700). V3 never carried this over. Add equivalent scoped to .navbar so tab nav-links are unaffected. Co-Authored-By: Claude Sonnet 4.6 * Fix visual parity between V3 and V2 UI across all pages Resolves ~15 visual differences identified by page-by-page Playwright comparison to make the V3 migration look identical to V2. Global CSS (dark.scss SCSS variable overrides before Bootstrap imports): - border-radius: .375rem → .25rem (matching Bootstrap 4 default) - table-cell-padding: .5rem → .75rem (matching Bootstrap 4 default) - dropdown-item-padding-x: 1rem → 1.5rem (matching Bootstrap 4 default) - link-decoration: none (Bootstrap 5 Reboot adds underline by default) - Active navbar link: add #nav-collapse a.router-link-active teal colour - Vertical nav-pills: add full-width + centered rule for sidebar pills Per-component fixes: - ConfigActs: interval_after badge → check-square/x-square SVG icons - ScriptRevisions: ✓ span → check-square SVG; add Edit button; remove size="sm" from table buttons; replace text ▼/▲ with chevron SVG icons - CrewList: "Add Crew Member" → "New Crew Member" (match V2 label) - MicList: "Add Microphone" → "New Microphone" (match V2 label) - SessionTagDropdown: ✏️ emoji → inline pencil-fill SVG icon - ConfigCast: add explicit 'First Name'/'Last Name' labels (BVN auto-label only capitalises first word unlike BV2) - AboutUser: add text-center to match V2's centred table alignment Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- client-v3/src/App.vue | 32 +- client-v3/src/assets/styles/dark.scss | 43 + .../config/acts_and_scenes/ConfigActs.vue | 27 +- .../components/show/config/cues/CueEditor.vue | 194 ++++ .../show/config/cues/JumpToCueModal.vue | 204 ++++ .../show/config/cues/ScriptLineCueEditor.vue | 496 ++++++++++ .../components/show/config/mics/MicList.vue | 2 +- .../show/config/script/ScriptRevisions.vue | 54 +- .../config/sessions/SessionTagDropdown.vue | 12 +- .../components/show/config/stage/CrewList.vue | 2 +- .../components/show/live/ScriptLineViewer.vue | 468 +++++++++ .../show/live/ScriptLineViewerCompact.vue | 340 +++++++ .../components/show/live/ScriptViewPane.vue | 907 ++++++++++++++++++ .../components/show/live/StageManagerPane.vue | 548 +++++++++++ .../components/user/settings/AboutUser.vue | 4 +- client-v3/src/composables/useWebSocket.ts | 20 +- client-v3/src/router/index.ts | 22 +- client-v3/src/stores/script.ts | 82 ++ client-v3/src/stores/show.ts | 15 +- client-v3/src/stores/system.ts | 18 +- client-v3/src/views/HomeView.vue | 5 +- client-v3/src/views/show/ShowLiveView.vue | 232 +++++ .../src/views/show/config/ConfigCast.vue | 6 +- .../src/views/show/config/ConfigCues.vue | 4 +- 24 files changed, 3697 insertions(+), 40 deletions(-) create mode 100644 client-v3/src/components/show/config/cues/CueEditor.vue create mode 100644 client-v3/src/components/show/config/cues/JumpToCueModal.vue create mode 100644 client-v3/src/components/show/config/cues/ScriptLineCueEditor.vue create mode 100644 client-v3/src/components/show/live/ScriptLineViewer.vue create mode 100644 client-v3/src/components/show/live/ScriptLineViewerCompact.vue create mode 100644 client-v3/src/components/show/live/ScriptViewPane.vue create mode 100644 client-v3/src/components/show/live/StageManagerPane.vue create mode 100644 client-v3/src/views/show/ShowLiveView.vue diff --git a/client-v3/src/App.vue b/client-v3/src/App.vue index 71bdd704..da462fa4 100644 --- a/client-v3/src/App.vue +++ b/client-v3/src/App.vue @@ -63,6 +63,17 @@ > Jump To Page + + {{ showStore.stageManagerMode ? 'Disable' : 'Enable' }} Stage Manager + To get started, please create an admin user! + + + + + @@ -166,7 +182,7 @@ diff --git a/client-v3/src/components/show/config/cues/JumpToCueModal.vue b/client-v3/src/components/show/config/cues/JumpToCueModal.vue new file mode 100644 index 00000000..2a00ff98 --- /dev/null +++ b/client-v3/src/components/show/config/cues/JumpToCueModal.vue @@ -0,0 +1,204 @@ + + + diff --git a/client-v3/src/components/show/config/cues/ScriptLineCueEditor.vue b/client-v3/src/components/show/config/cues/ScriptLineCueEditor.vue new file mode 100644 index 00000000..233364ae --- /dev/null +++ b/client-v3/src/components/show/config/cues/ScriptLineCueEditor.vue @@ -0,0 +1,496 @@ + + + + + diff --git a/client-v3/src/components/show/config/mics/MicList.vue b/client-v3/src/components/show/config/mics/MicList.vue index f0d083f0..9954daf7 100644 --- a/client-v3/src/components/show/config/mics/MicList.vue +++ b/client-v3/src/components/show/config/mics/MicList.vue @@ -15,7 +15,7 @@ variant="outline-success" @click="newModal?.show()" > - Add Microphone + New Microphone @@ -21,10 +46,20 @@ @@ -119,12 +118,11 @@ :disabled="micDisabledForCharacter(selectedMic, scene.id, data.item.Character)" @click.stop="toggleAllocation(selectedMic, scene.id, data.item.Character)" > - - + style="color: #06bc8c" + /> + diff --git a/client-v3/src/components/show/config/mics/MicTimeline.vue b/client-v3/src/components/show/config/mics/MicTimeline.vue index 4aa1c77d..aa1116b0 100644 --- a/client-v3/src/components/show/config/mics/MicTimeline.vue +++ b/client-v3/src/components/show/config/mics/MicTimeline.vue @@ -29,7 +29,7 @@ - ⬇ Export + Export diff --git a/client-v3/src/components/show/config/script/CompiledScripts.vue b/client-v3/src/components/show/config/script/CompiledScripts.vue index 1211772e..94f6dd0f 100644 --- a/client-v3/src/components/show/config/script/CompiledScripts.vue +++ b/client-v3/src/components/show/config/script/CompiledScripts.vue @@ -4,9 +4,10 @@ - + + @@ -46,18 +22,10 @@ diff --git a/client-v3/src/composables/useFormValidation.ts b/client-v3/src/composables/useFormValidation.ts index f539a9cf..a185b80b 100644 --- a/client-v3/src/composables/useFormValidation.ts +++ b/client-v3/src/composables/useFormValidation.ts @@ -1,10 +1,8 @@ -export function useFormValidation() { - function validationState( - field: { $dirty: boolean; $error: boolean } | undefined - ): boolean | null { - if (!field) return null; - return field.$dirty ? !field.$error : null; - } +function validationState(field: { $dirty: boolean; $error: boolean } | undefined): boolean | null { + if (!field) return null; + return field.$dirty ? !field.$error : null; +} +export function useFormValidation() { return { validationState }; } diff --git a/client-v3/src/composables/useScriptDisplay.ts b/client-v3/src/composables/useScriptDisplay.ts index 1f5bb984..df7ca04b 100644 --- a/client-v3/src/composables/useScriptDisplay.ts +++ b/client-v3/src/composables/useScriptDisplay.ts @@ -3,35 +3,35 @@ import { TEXT_ALIGNMENT_CSS } from '@/constants/textAlignment'; import type { TextAlignment } from '@/constants/textAlignment'; import type { ScriptLine, StageDirectionStyle } from '@/types/api/script'; -export function useScriptDisplay() { - function getStageDirectionStyle( - line: ScriptLine, - styles: StageDirectionStyle[], - overrides: StageDirectionStyle[] - ): StageDirectionStyle | null { - if (line.line_type !== LINE_TYPES.STAGE_DIRECTION) return null; - const style = styles.find((s) => s.id === line.stage_direction_style_id) ?? null; - if (!style) return null; - const override = (overrides as any[]).find((o) => o.settings?.id === style.id); - return override ? override.settings : style; - } +function getStageDirectionStyle( + line: ScriptLine, + styles: StageDirectionStyle[], + overrides: StageDirectionStyle[] +): StageDirectionStyle | null { + if (line.line_type !== LINE_TYPES.STAGE_DIRECTION) return null; + const style = styles.find((s) => s.id === line.stage_direction_style_id) ?? null; + if (!style) return null; + const override = (overrides as any[]).find((o) => o.settings?.id === style.id); + return override ? override.settings : style; +} - function stageDirectionStyling(style: StageDirectionStyle | null): Record { - if (!style) return { 'background-color': 'darkslateblue', 'font-style': 'italic' }; - const result: Record = { - 'font-weight': style.bold ? 'bold' : 'normal', - 'font-style': style.italic ? 'italic' : 'normal', - 'text-decoration-line': style.underline ? 'underline' : 'none', - color: style.text_colour ?? '', - }; - if (style.enable_background_colour) result['background-color'] = style.background_colour ?? ''; - return result; - } +function stageDirectionStyling(style: StageDirectionStyle | null): Record { + if (!style) return { 'background-color': 'darkslateblue', 'font-style': 'italic' }; + const result: Record = { + 'font-weight': style.bold ? 'bold' : 'normal', + 'font-style': style.italic ? 'italic' : 'normal', + 'text-decoration-line': style.underline ? 'underline' : 'none', + color: style.text_colour ?? '', + }; + if (style.enable_background_colour) result['background-color'] = style.background_colour ?? ''; + return result; +} - function scriptTextAlign(userSettings: Record): string { - const alignment = (userSettings?.script_text_alignment as TextAlignment) ?? 2; - return TEXT_ALIGNMENT_CSS[alignment] || 'center'; - } +function scriptTextAlign(userSettings: Record): string { + const alignment = (userSettings?.script_text_alignment as TextAlignment) ?? 2; + return TEXT_ALIGNMENT_CSS[alignment] || 'center'; +} +export function useScriptDisplay() { return { getStageDirectionStyle, stageDirectionStyling, scriptTextAlign }; } diff --git a/client-v3/src/composables/useScriptNavigation.ts b/client-v3/src/composables/useScriptNavigation.ts index 61417b91..4bf17611 100644 --- a/client-v3/src/composables/useScriptNavigation.ts +++ b/client-v3/src/composables/useScriptNavigation.ts @@ -2,15 +2,31 @@ import { LINE_TYPES } from '@/constants/lineTypes'; import { isWholeLineCut } from '@/js/scriptUtils'; import type { ScriptLine } from '@/types/api/script'; -export function useScriptNavigation() { - function checkIsUntaggedStageDirection(line: ScriptLine): boolean { - return ( - line.line_type === LINE_TYPES.STAGE_DIRECTION && - line.line_parts[0]?.character_id == null && - line.line_parts[0]?.character_group_id == null - ); +function checkIsUntaggedStageDirection(line: ScriptLine): boolean { + return ( + line.line_type === LINE_TYPES.STAGE_DIRECTION && + line.line_parts[0]?.character_id == null && + line.line_parts[0]?.character_group_id == null + ); +} + +function needsActSceneLabel( + line: ScriptLine, + previousLine: ScriptLine | null, + cuts: (number | null)[], + getPage: (page: number) => ScriptLine[] +): boolean { + let prev: ScriptLine | null = previousLine; + while (prev != null && isWholeLineCut(prev, cuts)) { + const prevPage = getPage(prev.page ?? 0); + const idx = prevPage.indexOf(prev); + prev = idx > 0 ? prevPage[idx - 1] : null; } + if (prev == null) return true; + return !(prev.act_id === line.act_id && prev.scene_id === line.scene_id); +} +export function useScriptNavigation() { function needsHeadings( line: ScriptLine, previousLine: ScriptLine | null, @@ -36,21 +52,5 @@ export function useScriptNavigation() { }); } - function needsActSceneLabel( - line: ScriptLine, - previousLine: ScriptLine | null, - cuts: (number | null)[], - getPage: (page: number) => ScriptLine[] - ): boolean { - let prev: ScriptLine | null = previousLine; - while (prev != null && isWholeLineCut(prev, cuts)) { - const prevPage = getPage(prev.page ?? 0); - const idx = prevPage.indexOf(prev); - prev = idx > 0 ? prevPage[idx - 1] : null; - } - if (prev == null) return true; - return !(prev.act_id === line.act_id && prev.scene_id === line.scene_id); - } - return { needsHeadings, needsActSceneLabel, checkIsUntaggedStageDirection }; } diff --git a/client-v3/src/composables/useStatsTable.ts b/client-v3/src/composables/useStatsTable.ts index c4392289..db5ca55c 100644 --- a/client-v3/src/composables/useStatsTable.ts +++ b/client-v3/src/composables/useStatsTable.ts @@ -3,6 +3,14 @@ import { useSystemStore } from '@/stores/system'; import { useShowStore } from '@/stores/show'; import type { Act, Scene } from '@/types/api/show'; +function getHeaderName(sceneId: number): string { + return `head(${sceneId})`; +} + +function getCellName(sceneId: number): string { + return `cell(${sceneId})`; +} + export function useStatsTable() { const systemStore = useSystemStore(); const showStore = useShowStore(); @@ -40,13 +48,5 @@ export function useStatsTable() { return sortedScenes.value.filter((scene) => scene.act === actId).length; } - function getHeaderName(sceneId: number): string { - return `head(${sceneId})`; - } - - function getCellName(sceneId: number): string { - return `cell(${sceneId})`; - } - return { sortedActs, sortedScenes, numScenesPerAct, getHeaderName, getCellName }; } diff --git a/client-v3/src/composables/useTimeline.ts b/client-v3/src/composables/useTimeline.ts index 6fd33471..f1d0b3e9 100644 --- a/client-v3/src/composables/useTimeline.ts +++ b/client-v3/src/composables/useTimeline.ts @@ -40,6 +40,28 @@ const EXPORT_STYLES: Record> = { }, }; +function getColorForEntity(entityId: number, entityType: EntityType | string): string { + const typeOffsets: Record = { + mic: 0, + character: 120, + cast: 240, + prop: 60, + scenery: 180, + }; + const hue = (entityId * 137.508 + (typeOffsets[entityType] ?? 0)) % 360; + return `hsl(${hue}, 70%, 50%)`; +} + +function applyExportStyles(svgClone: SVGSVGElement): void { + Object.entries(EXPORT_STYLES).forEach(([selector, attrs]) => { + svgClone.querySelectorAll(selector).forEach((el) => { + Object.entries(attrs).forEach(([attr, value]) => { + el.setAttribute(attr, value); + }); + }); + }); +} + export function useTimeline(scenes: Ref, rows: Ref) { const showStore = useShowStore(); @@ -95,16 +117,30 @@ export function useTimeline(scenes: Ref, rows: Ref) { return rowIndex * rowHeight; } - function getColorForEntity(entityId: number, entityType: EntityType | string): string { - const typeOffsets: Record = { - mic: 0, - character: 120, - cast: 240, - prop: 60, - scenery: 180, + function processAllocationEntry( + hasAllocation: boolean, + scene: Scene, + sceneIndex: number, + segments: TimelineSegment[], + currentSegment: TimelineSegment | null + ): TimelineSegment | null { + if (!hasAllocation) { + if (currentSegment) segments.push(currentSegment); + return null; + } + const sameAct = currentSegment + ? scene.act === scenes.value[currentSegment.startIndex].act + : true; + if (currentSegment && sameAct) { + return { ...currentSegment, endIndex: sceneIndex, endScene: scene.name ?? '' }; + } + if (currentSegment) segments.push(currentSegment); + return { + startIndex: sceneIndex, + endIndex: sceneIndex, + startScene: scene.name ?? '', + endScene: scene.name ?? '', }; - const hue = (entityId * 137.508 + (typeOffsets[entityType] ?? 0)) % 360; - return `hsl(${hue}, 70%, 50%)`; } function groupConsecutiveScenes( @@ -118,44 +154,19 @@ export function useTimeline(scenes: Ref, rows: Ref) { scenes.value.forEach((scene, sceneIndex) => { const hasAllocation = allocations.some((a) => a[sceneIdField] === scene.id); - - if (hasAllocation) { - const sameAct = currentSegment - ? scene.act === scenes.value[currentSegment.startIndex].act - : true; - - if (currentSegment && sameAct) { - currentSegment.endIndex = sceneIndex; - currentSegment.endScene = scene.name ?? ''; - } else { - if (currentSegment) segments.push(currentSegment); - currentSegment = { - startIndex: sceneIndex, - endIndex: sceneIndex, - startScene: scene.name ?? '', - endScene: scene.name ?? '', - }; - } - } else if (currentSegment) { - segments.push(currentSegment); - currentSegment = null; - } + currentSegment = processAllocationEntry( + hasAllocation, + scene, + sceneIndex, + segments, + currentSegment + ); }); if (currentSegment) segments.push(currentSegment); return segments; } - function applyExportStyles(svgClone: SVGSVGElement): void { - Object.entries(EXPORT_STYLES).forEach(([selector, attrs]) => { - svgClone.querySelectorAll(selector).forEach((el) => { - Object.entries(attrs).forEach(([attr, value]) => { - el.setAttribute(attr, value); - }); - }); - }); - } - function exportTimeline( svgRef: Ref, filenamePrefix = 'timeline', diff --git a/client-v3/src/js/http-interceptor.ts b/client-v3/src/js/http-interceptor.ts index d045af16..f7b2f72b 100644 --- a/client-v3/src/js/http-interceptor.ts +++ b/client-v3/src/js/http-interceptor.ts @@ -1,98 +1,101 @@ import log from 'loglevel'; import { makeURL } from '@/js/utils'; import { toast } from '@/js/toast'; +import type { useUserStore } from '@/stores/user'; -export default function setupHttpInterceptor(): void { - const originalFetch = window.fetch; +type UserStore = ReturnType; - let isRefreshingToken = false; +function buildAuthenticatedOptions( + options: RequestInit, + token: string | null +): RequestInit & { headers: Record } { + const headers = { ...(options.headers as Record) }; + if (token && !Object.keys(headers).includes('Authorization')) { + headers['Authorization'] = `Bearer ${token}`; + } + if (!headers['Content-Type'] && (options.method === 'POST' || options.method === 'PUT')) { + headers['Content-Type'] = 'application/json'; + } + return { ...options, headers }; +} - window.fetch = async (resource, options = {}) => { - if (typeof resource === 'string' && resource.startsWith(makeURL('/api/'))) { - // Import store inside the override function — Pinia context isn't active at module load time - const { useUserStore } = await import('@/stores/user'); - const userStore = useUserStore(); +export default function setupHttpInterceptor(): void { + const originalFetch = window.fetch; + const refreshState = { isRefreshing: false }; - const token = userStore.authToken; - const isLogoutRequest = resource.endsWith('/api/v1/auth/logout'); - const isLoginRequest = resource.endsWith('/api/v1/auth/login'); - const isRefreshRequest = resource.endsWith('/api/v1/auth/refresh-token'); + async function handle401Response( + resource: string, + newOptions: RequestInit & { headers: Record }, + userStore: UserStore, + isRefreshRequest: boolean, + response: Response + ): Promise { + if (isRefreshRequest || refreshState.isRefreshing) { + log.warn('Token refresh failed with 401 or already refreshing, logging out'); + toast.warning('Your session has expired. Please log in again.'); + await userStore.logout(); + return response; + } - const newOptions = { - ...options, - headers: { - ...options.headers, - } as Record, - }; + log.info('Attempting token refresh'); + if (!userStore.authToken) { + log.warn('401 received with no token present'); + await userStore.logout(); + return response; + } - if (token && !Object.keys(newOptions.headers).includes('Authorization')) { - newOptions.headers = { ...newOptions.headers, Authorization: `Bearer ${token}` }; - } + try { + refreshState.isRefreshing = true; + const refreshSuccess = await userStore.refreshToken(); + refreshState.isRefreshing = false; - if ( - (!options.headers || !(options.headers as Record)['Content-Type']) && - (options.method === 'POST' || options.method === 'PUT') - ) { - newOptions.headers['Content-Type'] = 'application/json'; + if (!refreshSuccess) { + log.warn('Token refresh failed, logging out'); + toast.warning('Your session has expired. Please log in again.'); + await userStore.logout(); + return response; } - try { - const response = await originalFetch(resource, newOptions); - - if (response.status === 401 && !isLogoutRequest && !isLoginRequest) { - log.warn('Received 401 Unauthorized response'); + log.info('Token refresh successful, retrying original request'); + return await originalFetch(resource, { + ...newOptions, + headers: { ...newOptions.headers, Authorization: `Bearer ${userStore.authToken}` }, + }); + } catch (refreshError) { + refreshState.isRefreshing = false; + log.error('Error during token refresh:', refreshError); + toast.error('Authentication error - please log in again'); + await userStore.logout(); + return response; + } + } - if (isRefreshRequest || isRefreshingToken) { - log.warn('Token refresh failed with 401 or already refreshing, logging out'); - toast.warning('Your session has expired. Please log in again.'); - await userStore.logout(); - return response; - } + window.fetch = async (resource, options = {}) => { + if (typeof resource !== 'string' || !resource.startsWith(makeURL('/api/'))) { + return originalFetch(resource, options); + } - log.info('Attempting token refresh'); - if (token) { - try { - isRefreshingToken = true; - const refreshSuccess = await userStore.refreshToken(); - isRefreshingToken = false; + // Import store inside the override — Pinia context isn't active at module load time + const { useUserStore } = await import('@/stores/user'); + const userStore = useUserStore(); - if (refreshSuccess) { - log.info('Token refresh successful, retrying original request'); - const retriedOptions = { - ...newOptions, - headers: { - ...newOptions.headers, - Authorization: `Bearer ${userStore.authToken}`, - }, - }; - return await originalFetch(resource, retriedOptions); - } + const newOptions = buildAuthenticatedOptions(options, userStore.authToken); + const isLogoutRequest = resource.endsWith('/api/v1/auth/logout'); + const isLoginRequest = resource.endsWith('/api/v1/auth/login'); + const isRefreshRequest = resource.endsWith('/api/v1/auth/refresh-token'); - log.warn('Token refresh failed, logging out'); - toast.warning('Your session has expired. Please log in again.'); - await userStore.logout(); - return response; - } catch (refreshError) { - isRefreshingToken = false; - log.error('Error during token refresh:', refreshError); - toast.error('Authentication error - please log in again'); - await userStore.logout(); - return response; - } - } else { - log.warn('401 received with no token present'); - await userStore.logout(); - return response; - } - } + try { + const response = await originalFetch(resource, newOptions); - return response; - } catch (error) { - log.error('Fetch error:', error); - throw error; + if (response.status === 401 && !isLogoutRequest && !isLoginRequest) { + log.warn('Received 401 Unauthorized response'); + return handle401Response(resource, newOptions, userStore, isRefreshRequest, response); } - } - return originalFetch(resource, options); + return response; + } catch (error) { + log.error('Fetch error:', error); + throw error; + } }; } diff --git a/client-v3/src/js/micConflictUtils.ts b/client-v3/src/js/micConflictUtils.ts index 2edd45aa..211cb5a3 100644 --- a/client-v3/src/js/micConflictUtils.ts +++ b/client-v3/src/js/micConflictUtils.ts @@ -45,6 +45,32 @@ export interface MicConflictResult { // Nested dict: { micId: { sceneId: characterId | null } } type MicAllocations = Record | null>; +function linkSceneNode( + nodeSceneId: number, + graphById: Record, + previousSceneId: number | null, + scenePosition: number, + previousActLastSceneId: number | null +): number | null { + let previousSceneInShow: number | null = null; + if (previousSceneId) { + const prevNode = graphById[previousSceneId]; + if (prevNode) { + prevNode.nextSceneInAct = nodeSceneId; + prevNode.nextSceneInShow = nodeSceneId; + previousSceneInShow = previousSceneId; + } + } + if (scenePosition === 0 && previousActLastSceneId) { + const prevActLastNode = graphById[previousActLastSceneId]; + if (prevActLastNode) { + prevActLastNode.nextSceneInShow = nodeSceneId; + previousSceneInShow = previousActLastSceneId; + } + } + return previousSceneInShow; +} + export function buildSceneGraph( scenes: Scene[], acts: Act[], @@ -54,15 +80,8 @@ export function buildSceneGraph( return []; } - const sceneById: Record = {}; - scenes.forEach((scene) => { - sceneById[scene.id] = scene; - }); - - const actById: Record = {}; - acts.forEach((act) => { - actById[act.id] = act; - }); + const sceneById: Record = Object.fromEntries(scenes.map((s) => [s.id, s])); + const actById: Record = Object.fromEntries(acts.map((a) => [a.id, a])); const graph: SceneGraphNode[] = []; const graphById: Record = {}; @@ -74,7 +93,6 @@ export function buildSceneGraph( while (currentAct != null) { let scenePosition = 0; let previousSceneId: number | null = null; - let currentScene = currentAct.first_scene ? sceneById[currentAct.first_scene] : null; while (currentScene != null) { @@ -91,22 +109,13 @@ export function buildSceneGraph( nextSceneInShow: null, }; - if (previousSceneId) { - const prevNode = graphById[previousSceneId]; - if (prevNode) { - prevNode.nextSceneInAct = currentScene.id; - prevNode.nextSceneInShow = currentScene.id; - node.previousSceneInShow = previousSceneId; - } - } - - if (scenePosition === 0 && previousActLastSceneId) { - const prevActLastNode = graphById[previousActLastSceneId]; - if (prevActLastNode) { - prevActLastNode.nextSceneInShow = currentScene.id; - node.previousSceneInShow = previousActLastSceneId; - } - } + node.previousSceneInShow = linkSceneNode( + node.sceneId, + graphById, + previousSceneId, + scenePosition, + previousActLastSceneId + ); graph.push(node); graphById[currentScene.id] = node; @@ -184,6 +193,78 @@ export function getConflictSeverity( return areScenesInSameAct(sceneId1, sceneId2, sceneGraph) ? 'WARNING' : 'INFO'; } +function buildConflictRecord( + micId: number, + sceneIdNum: number, + adjacentSceneId: number, + characterId: number, + adjacentCharacterId: number, + sceneGraph: SceneGraphNode[], + characters: Character[] +): MicConflict { + const severity = getConflictSeverity(sceneIdNum, adjacentSceneId, sceneGraph); + const currentSceneNode = sceneGraph.find((n) => n.sceneId === sceneIdNum); + const adjacentSceneNode = sceneGraph.find((n) => n.sceneId === adjacentSceneId); + const char1 = characters.find((c) => c.id === characterId); + const char2 = characters.find((c) => c.id === adjacentCharacterId); + + let message = `Quick-change from "${currentSceneNode?.sceneName || 'Unknown'}"`; + if (char1 && char2) message += ` (${char1.name} → ${char2.name})`; + message += + severity === 'WARNING' + ? ' - Tight changeover required' + : ' - Interval provides changeover time'; + + return { + micId, + sceneId: sceneIdNum, + sceneName: currentSceneNode?.sceneName || 'Unknown', + actName: currentSceneNode?.actName || 'Unknown', + characterId, + characterName: char1?.name || 'Unknown', + adjacentSceneId, + adjacentSceneName: adjacentSceneNode?.sceneName || 'Unknown', + adjacentActName: adjacentSceneNode?.actName || 'Unknown', + adjacentCharacterId, + adjacentCharacterName: char2?.name || 'Unknown', + severity, + message, + }; +} + +function checkAdjacentScene( + conflicts: MicConflict[], + micId: number, + sceneIdNum: number, + adjacentSceneId: number, + characterId: number, + micAllocations: Record, + sceneGraph: SceneGraphNode[], + characters: Character[], + castList: unknown[] +): void { + const adjacentCharacterId = micAllocations[adjacentSceneId]; + if (adjacentCharacterId == null || adjacentCharacterId === characterId) return; + if (isSameCastMember(characterId, adjacentCharacterId, characters, castList)) return; + + const isDuplicate = conflicts.some( + (c) => c.micId === micId && c.sceneId === adjacentSceneId && c.adjacentSceneId === sceneIdNum + ); + if (!isDuplicate) { + conflicts.push( + buildConflictRecord( + micId, + sceneIdNum, + adjacentSceneId, + characterId, + adjacentCharacterId, + sceneGraph, + characters + ) + ); + } +} + export function detectMicConflicts( allocations: MicAllocations, scenes: Scene[], @@ -197,7 +278,6 @@ export function detectMicConflicts( } const sceneGraph = buildSceneGraph(scenes, acts, currentShow); - if (sceneGraph.length === 0) { return { conflicts: [], conflictsByScene: {}, conflictsByMic: {} }; } @@ -207,6 +287,7 @@ export function detectMicConflicts( Object.keys(allocations).forEach((micId) => { const micAllocations = allocations[micId]; if (!micAllocations || typeof micAllocations !== 'object') return; + const micIdNum = Number.parseInt(micId, 10); Object.keys(micAllocations).forEach((sceneId) => { const characterId = micAllocations[sceneId]; @@ -214,7 +295,6 @@ export function detectMicConflicts( const sceneIdNum = Number.parseInt(sceneId, 10); const adjacentScenes = getAdjacentScenes(sceneIdNum, sceneGraph); - const adjacentSceneIds = [ adjacentScenes.sameActPrev, adjacentScenes.sameActNext, @@ -223,48 +303,17 @@ export function detectMicConflicts( ].filter((id): id is number => id != null); adjacentSceneIds.forEach((adjacentSceneId) => { - const adjacentCharacterId = micAllocations[adjacentSceneId]; - if (adjacentCharacterId == null) return; - if (adjacentCharacterId === characterId) return; - if (isSameCastMember(characterId, adjacentCharacterId, characters, castList)) return; - - const severity = getConflictSeverity(sceneIdNum, adjacentSceneId, sceneGraph); - const currentSceneNode = sceneGraph.find((n) => n.sceneId === sceneIdNum); - const adjacentSceneNode = sceneGraph.find((n) => n.sceneId === adjacentSceneId); - const char1 = characters.find((c) => c.id === characterId); - const char2 = characters.find((c) => c.id === adjacentCharacterId); - - let message = `Quick-change from "${currentSceneNode?.sceneName || 'Unknown'}"`; - if (char1 && char2) message += ` (${char1.name} → ${char2.name})`; - message += - severity === 'WARNING' - ? ' - Tight changeover required' - : ' - Interval provides changeover time'; - - const isDuplicate = conflicts.some( - (c) => - c.micId === Number.parseInt(micId, 10) && - c.sceneId === adjacentSceneId && - c.adjacentSceneId === sceneIdNum + checkAdjacentScene( + conflicts, + micIdNum, + sceneIdNum, + adjacentSceneId, + characterId, + micAllocations, + sceneGraph, + characters, + castList ); - - if (!isDuplicate) { - conflicts.push({ - micId: Number.parseInt(micId, 10), - sceneId: sceneIdNum, - sceneName: currentSceneNode?.sceneName || 'Unknown', - actName: currentSceneNode?.actName || 'Unknown', - characterId, - characterName: char1?.name || 'Unknown', - adjacentSceneId, - adjacentSceneName: adjacentSceneNode?.sceneName || 'Unknown', - adjacentActName: adjacentSceneNode?.actName || 'Unknown', - adjacentCharacterId, - adjacentCharacterName: char2?.name || 'Unknown', - severity, - message, - }); - } }); }); }); diff --git a/client-v3/src/router/index.ts b/client-v3/src/router/index.ts index ba3f5fc9..f87ddd1c 100644 --- a/client-v3/src/router/index.ts +++ b/client-v3/src/router/index.ts @@ -150,15 +150,18 @@ const router = createRouter({ ], }); -router.beforeEach(async (to, from) => { - const { useSystemStore } = await import('@/stores/system'); - const { useUserStore } = await import('@/stores/user'); - const { toast } = await import('@/js/toast'); +import type { RouteLocationNormalized } from 'vue-router'; - const systemStore = useSystemStore(); - const userStore = useUserStore(); +type ToastFn = { + warning: (m: string) => void; + error: (m: string) => void; + info: (m: string) => void; +}; - // Electron: require active connection before any page except server-selector +async function checkElectronGuards( + to: RouteLocationNormalized, + toast: ToastFn +): Promise { if (isElectron() && to.path !== '/electron/server-selector') { try { const activeConnection = await window.electronAPI?.getActiveConnection?.(); @@ -170,76 +173,53 @@ router.beforeEach(async (to, from) => { return '/electron/server-selector'; } } - - // Electron-only pages are inaccessible in the browser if (to.matched.some((r) => r.meta.isElectronOnly) && !isElectron()) { toast.error('This page is only available in the desktop app'); return '/'; } + return undefined; +} - if (to.path === '/electron/server-selector') return undefined; - - // Load RBAC roles on first navigation if not already loaded - if (systemStore.rbacRoles.length === 0) { - await systemStore.getRbacRoles(); - await systemStore.getSettings(); - await userStore.getCurrentUser(); - if (userStore.currentUser) { - await userStore.getCurrentRbac(); - } +async function checkPermissionGuards( + to: RouteLocationNormalized, + from: RouteLocationNormalized, + systemStore: Awaited>, + userStore: Awaited>, + toast: ToastFn +): Promise { + const settings = systemStore.settings as Record | null; + if (settings && settings.has_admin_user === false) { + if (to.path !== '/') toast.error('Please create an admin user before continuing'); + return to.path !== '/' ? '/' : undefined; } + const currentUser = userStore.currentUser; + const isAuthenticated = currentUser !== null; const requiresAuth = to.matched.some((r) => r.meta.requiresAuth); const requiresAdmin = to.matched.some((r) => r.meta.requiresAdmin); const requiresShowAccess = to.matched.some((r) => r.meta.requiresShowAccess); - // If no admin user yet, send everyone to home (which shows the create-admin UI) - if ( - systemStore.settings && - (systemStore.settings as Record).has_admin_user === false - ) { - if (to.path !== '/') { - toast.error('Please create an admin user before continuing'); - return '/'; - } - return undefined; - } - - const currentUser = userStore.currentUser; - const isAuthenticated = currentUser !== null; - - // Already logged in — don't show login page if (to.path === '/login' && isAuthenticated) { toast.info('You are already logged in'); return from.fullPath === '/login' ? '/' : from.fullPath; } - - // Require auth if (requiresAuth && !isAuthenticated) { toast.error('Please log in to access this page'); return '/login'; } - // Force password change const requiresPasswordChange = currentUser?.requires_password_change === true; const isPasswordChangePage = to.path === '/force-password-change'; - if (isAuthenticated && requiresPasswordChange && !isPasswordChangePage) { toast.warning('You must change your password before continuing'); return '/force-password-change'; } + if (isPasswordChangePage && !requiresPasswordChange) return '/'; - if (isPasswordChangePage && !requiresPasswordChange) { - return '/'; - } - - // Admin-only pages if (requiresAdmin && !systemStore.isAdminUser) { toast.error('Admin access required'); return '/'; } - - // Show access if (requiresShowAccess) { if (!systemStore.currentShow) { toast.error('No show is currently selected'); @@ -250,20 +230,45 @@ router.beforeEach(async (to, from) => { return '/'; } } + return undefined; +} - // Live page requires an active show session and a healthy WebSocket - if (to.path === '/live') { - const { useShowStore } = await import('@/stores/show'); - const { useWebSocketStore } = await import('@/stores/websocket'); - const showStore = useShowStore(); - const wsStore = useWebSocketStore(); - await showStore.getShowSessionData(); - if (!showStore.currentSession || !wsStore.websocketHealthy) { - toast.error('No active show session or connection issue'); - return '/'; - } +async function checkLiveGuard(toast: ToastFn): Promise { + const { useShowStore } = await import('@/stores/show'); + const { useWebSocketStore } = await import('@/stores/websocket'); + const showStore = useShowStore(); + const wsStore = useWebSocketStore(); + await showStore.getShowSessionData(); + if (!showStore.currentSession || !wsStore.websocketHealthy) { + toast.error('No active show session or connection issue'); + return '/'; + } + return undefined; +} + +router.beforeEach(async (to, from) => { + const { useSystemStore } = await import('@/stores/system'); + const { useUserStore } = await import('@/stores/user'); + const { toast } = await import('@/js/toast'); + const systemStore = useSystemStore(); + const userStore = useUserStore(); + + const electronResult = await checkElectronGuards(to, toast); + if (electronResult !== undefined) return electronResult; + if (to.path === '/electron/server-selector') return undefined; + + if (systemStore.rbacRoles.length === 0) { + await systemStore.getRbacRoles(); + await systemStore.getSettings(); + await userStore.getCurrentUser(); + if (userStore.currentUser) await userStore.getCurrentRbac(); } + const permResult = await checkPermissionGuards(to, from, systemStore, userStore, toast); + if (permResult !== undefined) return permResult; + + if (to.path === '/live') return checkLiveGuard(toast); + return undefined; }); diff --git a/client-v3/src/stores/scriptConfig.test.ts b/client-v3/src/stores/scriptConfig.test.ts index 9a19dec2..60324622 100644 --- a/client-v3/src/stores/scriptConfig.test.ts +++ b/client-v3/src/stores/scriptConfig.test.ts @@ -90,7 +90,7 @@ describe('computePageStatus', () => { it('returns all empty arrays when actual and tmp pages are identical', () => { const page = [makeLine(42, 2), makeLine(43, 1)]; - const status = computePageStatus(page, JSON.parse(JSON.stringify(page)), [], []); + const status = computePageStatus(page, structuredClone(page), [], []); expect(status.added).toHaveLength(0); expect(status.updated).toHaveLength(0); diff --git a/client-v3/src/stores/scriptConfig.ts b/client-v3/src/stores/scriptConfig.ts index 67525d14..900e6ec0 100644 --- a/client-v3/src/stores/scriptConfig.ts +++ b/client-v3/src/stores/scriptConfig.ts @@ -18,6 +18,8 @@ export function computePageStatus( deletedLines: number[], insertedLines: number[] ): PageStatus { + // JSON round-trip instead of structuredClone — these arrays come from Pinia reactive state + // (Proxy objects) which structuredClone cannot handle in some environments. const augmented: ScriptLine[] = JSON.parse(JSON.stringify(actualScriptPage)); JSON.parse(JSON.stringify(insertedLines)) .sort((a: number, b: number) => a - b) @@ -83,7 +85,7 @@ export const useScriptConfigStore = defineStore('scriptConfig', { }, addBlankLine(page: number, line: ScriptLine): void { - const l = JSON.parse(JSON.stringify(line)); + const l = structuredClone(line); l.page = page; this.tmpScript[String(page)].push(l); }, @@ -91,13 +93,13 @@ export const useScriptConfigStore = defineStore('scriptConfig', { insertBlankLine(page: number, lineIndex: number, line: ScriptLine): void { const pageStr = String(page); if (this.deletedLines[pageStr]?.includes(lineIndex)) { - const l = JSON.parse(JSON.stringify(line)); + const l = structuredClone(line); l.page = page; l.id = this.tmpScript[pageStr][lineIndex].id; this.tmpScript[pageStr].splice(lineIndex, 1, l); this.deletedLines[pageStr].splice(this.deletedLines[pageStr].indexOf(lineIndex), 1); } else { - const l = JSON.parse(JSON.stringify(line)); + const l = structuredClone(line); l.page = page; this.tmpScript[pageStr].splice(lineIndex, 0, l); if (!this.insertedLines[pageStr]) this.insertedLines[pageStr] = []; From dca8a5f27090f0a001adc7d80e29b294fc85b877 Mon Sep 17 00:00:00 2001 From: Tim Bradgate Date: Sat, 23 May 2026 23:41:54 +0100 Subject: [PATCH 20/23] fix: address critical error handling issues from PR #1036 review (#1074) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TOKEN_REFRESH WS handler: fix data shape bug — handler received msg.DATA but was reading payload.DATA.access_token (i.e. msg.DATA.DATA), always undefined; corrected to (data as { access_token: string }).access_token - WS_AUTH_ERROR: add toast notification and logout call so the user is informed and redirected rather than left in a silently broken state - refreshToken(): wrap fetch in try/catch so network errors return false rather than propagating as unhandled rejections - login(): wrap post-login data fetches (getRbacRoles, getCurrentUser, getCurrentRbac, getUserSettings, setupTokenRefresh) in try/catch with token rollback to prevent half-logged-in ghost state - App.vue startup: wrap startup sequence in try/catch; add startupError state with a visible error message and Retry button instead of a permanent spinner on failure - getMaxPage(): return boolean success flag; saveScript() in ScriptEditor aborts with a toast if getMaxPage() fails rather than proceeding with potentially stale page count Co-authored-by: Claude Sonnet 4.6 --- client-v3/src/App.vue | 42 ++++++++++++--- .../show/config/script/ScriptEditor.vue | 7 ++- client-v3/src/composables/useWebSocket.ts | 5 +- client-v3/src/stores/script.ts | 5 +- client-v3/src/stores/user.ts | 52 ++++++++++++------- 5 files changed, 79 insertions(+), 32 deletions(-) diff --git a/client-v3/src/App.vue b/client-v3/src/App.vue index 111720f6..77a78467 100644 --- a/client-v3/src/App.vue +++ b/client-v3/src/App.vue @@ -127,7 +127,11 @@ @@ -219,6 +223,7 @@ const isElectronEnv = ref(false); // Local state const loaded = ref(false); +const startupError = ref(false); const stoppingSession = ref(false); const startingSession = ref(false); const changingPage = ref(false); @@ -253,14 +258,19 @@ onMounted(async () => { } } - if (userStore.authToken) { - await userStore.refreshToken(); - await userStore.setupTokenRefresh(); - } + try { + if (userStore.authToken) { + await userStore.refreshToken(); + await userStore.setupTokenRefresh(); + } - await systemStore.getSettings(); - connect(); - await awaitWSConnect(); + await systemStore.getSettings(); + connect(); + await awaitWSConnect(); + } catch (e) { + log.error('Startup error:', e); + startupError.value = true; + } }); onBeforeUnmount(() => { @@ -307,6 +317,22 @@ async function awaitWSConnect(): Promise { } } +async function retryStartup(): Promise { + startupError.value = false; + try { + if (userStore.authToken) { + await userStore.refreshToken(); + await userStore.setupTokenRefresh(); + } + await systemStore.getSettings(); + connect(); + await awaitWSConnect(); + } catch (e) { + log.error('Retry startup error:', e); + startupError.value = true; + } +} + async function stopShowSession(): Promise { stoppingSession.value = true; const confirmed = await confirm('Are you sure you want to stop the show?', { diff --git a/client-v3/src/components/show/config/script/ScriptEditor.vue b/client-v3/src/components/show/config/script/ScriptEditor.vue index 8fda9c2c..b770119e 100644 --- a/client-v3/src/components/show/config/script/ScriptEditor.vue +++ b/client-v3/src/components/show/config/script/ScriptEditor.vue @@ -533,7 +533,12 @@ async function saveScript(): Promise { savingInProgress.value = true; saveError.value = false; - await scriptStore.getMaxPage(); + const maxPageOk = await scriptStore.getMaxPage(); + if (!maxPageOk) { + toast.error('Unable to save script — could not determine page count. Please try again.'); + savingInProgress.value = false; + return; + } const tmpPageKeys = Object.keys(scriptConfigStore.tmpScript).map((x) => Number.parseInt(x, 10)); const maxPage = Math.max(scriptStore.maxPage, ...tmpPageKeys, 0); totalSavePages.value = maxPage; diff --git a/client-v3/src/composables/useWebSocket.ts b/client-v3/src/composables/useWebSocket.ts index 099f744e..ec5d1535 100644 --- a/client-v3/src/composables/useWebSocket.ts +++ b/client-v3/src/composables/useWebSocket.ts @@ -66,6 +66,8 @@ async function handleMessage(msg: WsMessage): Promise { case 'WS_AUTH_ERROR': wsStore.$patch({ authenticated: false, pendingAuthentication: false }); log.error('WebSocket authentication error:', msg.DATA); + toast.error('WebSocket authentication failed. Please log in again.'); + await userStore.logout(); break; case 'WS_TOKEN_REFRESH_SUCCESS': log.info('WebSocket token refreshed successfully'); @@ -108,8 +110,7 @@ function screamingToCamel(s: string): string { async function dispatchAction(action: string, data: Record): Promise { // Actions that can't be auto-routed by naming convention if (action === 'TOKEN_REFRESH') { - const payload = data as { DATA: { access_token: string } }; - await useUserStore().tokenRefreshFromServer(payload.DATA.access_token); + await useUserStore().tokenRefreshFromServer((data as { access_token: string }).access_token); return; } if (action === 'SHOW_CHANGED') { diff --git a/client-v3/src/stores/script.ts b/client-v3/src/stores/script.ts index 93161762..cafd976a 100644 --- a/client-v3/src/stores/script.ts +++ b/client-v3/src/stores/script.ts @@ -70,12 +70,15 @@ export const useScriptStore = defineStore('script', { return response.ok; }, - async getMaxPage(): Promise { + async getMaxPage(): Promise { const response = await fetch(makeURL('/api/v1/show/script/max_page')); if (response.ok) { const data = await response.json(); this.maxPage = data.max_page; + return true; } + log.error('Unable to fetch max page'); + return false; }, async getStageDirectionStyles(): Promise { diff --git a/client-v3/src/stores/user.ts b/client-v3/src/stores/user.ts index 17ca2cf8..c576b1eb 100644 --- a/client-v3/src/stores/user.ts +++ b/client-v3/src/stores/user.ts @@ -53,12 +53,19 @@ export const useUserStore = defineStore('user', { const data = await response.json(); if (data.access_token) this._setToken(data.access_token); - const { useSystemStore } = await import('@/stores/system'); - await useSystemStore().getRbacRoles(); - await this.getCurrentUser(); - await this.getCurrentRbac(); - await this.getUserSettings(); - await this.setupTokenRefresh(); + try { + const { useSystemStore } = await import('@/stores/system'); + await useSystemStore().getRbacRoles(); + await this.getCurrentUser(); + await this.getCurrentRbac(); + await this.getUserSettings(); + await this.setupTokenRefresh(); + } catch (e) { + log.error('Error loading user data after login:', e); + this._clearToken(); + toast.error('Login failed — unable to load user data. Please try again.'); + return false; + } // Trigger WS authentication if the connection is waiting wsStore.triggerAuthentication(); @@ -119,21 +126,26 @@ export const useUserStore = defineStore('user', { async refreshToken(): Promise { if (!this.authToken) return false; - const response = await fetch(makeURL('/api/v1/auth/refresh-token'), { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({}), - }); - if (response.ok) { - const data = await response.json(); - this._setToken(data.access_token); - const { useWebSocketStore } = await import('@/stores/websocket'); - useWebSocketStore().refreshWsToken(); - log.debug('Token refreshed successfully'); - return true; + try { + const response = await fetch(makeURL('/api/v1/auth/refresh-token'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }); + if (response.ok) { + const data = await response.json(); + this._setToken(data.access_token); + const { useWebSocketStore } = await import('@/stores/websocket'); + useWebSocketStore().refreshWsToken(); + log.debug('Token refreshed successfully'); + return true; + } + log.error('Failed to refresh token'); + return false; + } catch (e) { + log.error('Network error during token refresh:', e); + return false; } - log.error('Failed to refresh token'); - return false; }, async tokenRefreshFromServer(newToken: string): Promise { From 2f4317a47e260d51dc395c900bca32cc636f18d1 Mon Sep 17 00:00:00 2001 From: Tim Bradgate Date: Sun, 24 May 2026 00:51:49 +0100 Subject: [PATCH 21/23] Post-merge review fixes: 401 queuing, revision toasts, ws.onopen, logout await, dropdown fix (#1075) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Post-merge review fixes: 401 queuing, revision toasts, onopen guard, logout await - Queue concurrent 401s during token refresh rather than immediately logging out; replay with the new token on success or resolve with a synthetic 401 on failure (http-interceptor.ts) - Parse server response body in addScriptRevision, deleteScriptRevision, loadScriptRevision error branches so 409 conflict messages reach the user (stores/show.ts) - Wrap ws.onopen async body in try/catch so getShowSessionData() rejection doesn't become an unhandled promise rejection (useWebSocket.ts) - Move Sign Out click handler to an awaited handleLogout() so async logout steps complete before the UI updates (App.vue) Co-Authored-By: Claude Sonnet 4.6 * Fix script line editor dropdown button and positioning The arrow toggle on each script line's Edit button had two bugs: 1. @click on BDropdown fell through to the root DOM element (BVN does not declare 'click' in its emits), so both the split button and the toggle arrow triggered editLine. Fixed by splitting into a BButtonGroup containing a plain BButton for 'Edit' and a standalone BDropdown for the context menu toggle. 2. boundary="window" applied BVN's position-static class to the BDropdown root, which changed the dropdown menu's offsetParent and caused Floating UI to compute a (0,0) transform — placing the menu at the top-left of the script editor container. Removed boundary="window"; without it the BDropdown root stays positioned and Floating UI anchors the menu correctly next to the toggle button. Same fix applied to the 'Add Dialogue' split dropdown in ScriptEditor. Verified via Playwright: Edit enters edit mode, toggle opens menu in the correct right-aligned position within the viewport. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- client-v3/src/App.vue | 8 ++-- .../show/config/script/ScriptEditor.vue | 10 +---- .../show/config/script/ScriptLineViewer.vue | 38 +++++++++---------- client-v3/src/composables/useWebSocket.ts | 30 ++++++++------- client-v3/src/js/http-interceptor.ts | 37 ++++++++++++++++-- client-v3/src/stores/show.ts | 27 +++++++++++-- 6 files changed, 101 insertions(+), 49 deletions(-) diff --git a/client-v3/src/App.vue b/client-v3/src/App.vue index 77a78467..5b48ac76 100644 --- a/client-v3/src/App.vue +++ b/client-v3/src/App.vue @@ -111,9 +111,7 @@ {{ userStore.currentUser.username }} Settings - - Sign Out - + Sign Out @@ -401,6 +399,10 @@ async function switchServer(): Promise { window.location.reload(); } +async function handleLogout(): Promise { + await userStore.logout(); +} + async function goToLivePage(): Promise { const valid = await v$.value.$validate(); if (!valid) return; diff --git a/client-v3/src/components/show/config/script/ScriptEditor.vue b/client-v3/src/components/show/config/script/ScriptEditor.vue index b770119e..6e86ec12 100644 --- a/client-v3/src/components/show/config/script/ScriptEditor.vue +++ b/client-v3/src/components/show/config/script/ScriptEditor.vue @@ -121,14 +121,8 @@ - + Add Dialogue + Add Stage Direction diff --git a/client-v3/src/components/show/config/script/ScriptLineViewer.vue b/client-v3/src/components/show/config/script/ScriptLineViewer.vue index b8e0cfec..841d6f08 100644 --- a/client-v3/src/components/show/config/script/ScriptLineViewer.vue +++ b/client-v3/src/components/show/config/script/ScriptLineViewer.vue @@ -121,26 +121,26 @@ End - - Insert Dialogue - Insert Stage Direction + Edit - Insert Cue Line - Insert Spacing - Delete - + + Insert Dialogue + Insert Stage Direction + Insert Cue Line + Insert Spacing + Delete + + diff --git a/client-v3/src/composables/useWebSocket.ts b/client-v3/src/composables/useWebSocket.ts index ec5d1535..f41f0787 100644 --- a/client-v3/src/composables/useWebSocket.ts +++ b/client-v3/src/composables/useWebSocket.ts @@ -169,20 +169,24 @@ function connect(): void { ws = new WebSocket(wsURL); ws.onopen = async () => { - const wasErrored = errorCount > 0; - wsStore.$patch({ isConnected: true }); - if (wasErrored) { - toast.success( - `WebSocket reconnected after ${errorCount} attempt${errorCount > 1 ? 's' : ''}` - ); - } - log.info('WebSocket connected'); - if (wasErrored) { - const { useShowStore } = await import('@/stores/show'); - const showStore = useShowStore(); - if (showStore.currentSession != null) { - await showStore.getShowSessionData(); + try { + const wasErrored = errorCount > 0; + wsStore.$patch({ isConnected: true }); + if (wasErrored) { + toast.success( + `WebSocket reconnected after ${errorCount} attempt${errorCount > 1 ? 's' : ''}` + ); } + log.info('WebSocket connected'); + if (wasErrored) { + const { useShowStore } = await import('@/stores/show'); + const showStore = useShowStore(); + if (showStore.currentSession != null) { + await showStore.getShowSessionData(); + } + } + } catch (e) { + log.error('Error in WebSocket onopen handler:', e); } }; diff --git a/client-v3/src/js/http-interceptor.ts b/client-v3/src/js/http-interceptor.ts index f7b2f72b..933c4ac2 100644 --- a/client-v3/src/js/http-interceptor.ts +++ b/client-v3/src/js/http-interceptor.ts @@ -19,9 +19,29 @@ function buildAuthenticatedOptions( return { ...options, headers }; } +type QueueEntry = { + resolve: (r: Response) => void; + resource: string; + options: RequestInit & { headers: Record }; +}; + export default function setupHttpInterceptor(): void { const originalFetch = window.fetch; - const refreshState = { isRefreshing: false }; + const refreshState = { isRefreshing: false, queue: [] as QueueEntry[] }; + + function flushQueue(newToken: string | null): void { + const entries = refreshState.queue.splice(0); + entries.forEach(({ resolve, resource, options }) => { + if (newToken) { + originalFetch(resource, { + ...options, + headers: { ...options.headers, Authorization: `Bearer ${newToken}` }, + }).then(resolve); + } else { + resolve(new Response(JSON.stringify({ message: 'Session expired' }), { status: 401 })); + } + }); + } async function handle401Response( resource: string, @@ -30,16 +50,24 @@ export default function setupHttpInterceptor(): void { isRefreshRequest: boolean, response: Response ): Promise { - if (isRefreshRequest || refreshState.isRefreshing) { - log.warn('Token refresh failed with 401 or already refreshing, logging out'); + if (isRefreshRequest) { + log.warn('Token refresh request received 401, logging out'); + flushQueue(null); toast.warning('Your session has expired. Please log in again.'); await userStore.logout(); return response; } + if (refreshState.isRefreshing) { + return new Promise((resolve) => { + refreshState.queue.push({ resolve, resource, options: newOptions }); + }); + } + log.info('Attempting token refresh'); if (!userStore.authToken) { log.warn('401 received with no token present'); + flushQueue(null); await userStore.logout(); return response; } @@ -51,18 +79,21 @@ export default function setupHttpInterceptor(): void { if (!refreshSuccess) { log.warn('Token refresh failed, logging out'); + flushQueue(null); toast.warning('Your session has expired. Please log in again.'); await userStore.logout(); return response; } log.info('Token refresh successful, retrying original request'); + flushQueue(userStore.authToken); return await originalFetch(resource, { ...newOptions, headers: { ...newOptions.headers, Authorization: `Bearer ${userStore.authToken}` }, }); } catch (refreshError) { refreshState.isRefreshing = false; + flushQueue(null); log.error('Error during token refresh:', refreshError); toast.error('Authentication error - please log in again'); await userStore.logout(); diff --git a/client-v3/src/stores/show.ts b/client-v3/src/stores/show.ts index 39c2ea65..359a6ea2 100644 --- a/client-v3/src/stores/show.ts +++ b/client-v3/src/stores/show.ts @@ -753,7 +753,14 @@ export const useShowStore = defineStore('show', { await this.getScriptRevisions(); toast.success('Added new script revision!'); } else { - toast.error('Unable to add new script revision'); + let message = 'Unable to add new script revision'; + try { + const data = await response.json(); + if (data.message) message = data.message; + } catch { + /* non-JSON body */ + } + toast.error(message); } }, @@ -767,7 +774,14 @@ export const useShowStore = defineStore('show', { await this.getScriptRevisions(); toast.success('Deleted script revision!'); } else { - toast.error('Unable to delete script revision'); + let message = 'Unable to delete script revision'; + try { + const data = await response.json(); + if (data.message) message = data.message; + } catch { + /* non-JSON body */ + } + toast.error(message); } }, @@ -781,7 +795,14 @@ export const useShowStore = defineStore('show', { await this.getScriptRevisions(); toast.success('Loaded script revision!'); } else { - toast.error('Unable to load script revision'); + let message = 'Unable to load script revision'; + try { + const data = await response.json(); + if (data.message) message = data.message; + } catch { + /* non-JSON body */ + } + toast.error(message); } }, From c66000ed2a43a3def2aea84135665f9924e429b8 Mon Sep 17 00:00:00 2001 From: Tim Bradgate Date: Sun, 24 May 2026 01:15:40 +0100 Subject: [PATCH 22/23] Fix 88 SonarQube maintainability issues on Vue 3 migration PR (#1076) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mechanical fixes across 30 files in client-v3/: - S7765: indexOf() !== -1 → .includes() (ScriptLineViewer) - S7770: arrow fn equivalent to Boolean → Boolean (ScriptLineViewer) - S7755: [length - 1] → .at(-1) (blockOrphanUtils) - S7754: .find() existence check → .some() (ConfigActs, ConfigScenes, system) - S7784: JSON.parse/stringify → // NOSONAR where structuredClone can't be used on Pinia Proxy objects (scriptConfig, ScriptLineEditor) - S7746: return Promise.resolve() → return (CueEditor, ScriptViewPane) - S7723: Array() → new Array() (ScriptViewPane) - S3863: duplicate imports from same module merged (ScriptLinePart, CueColourPreferences) - S1128: unused imports removed — log (ScriptEditor), computed (StageDirectionStyles) - S7735: negated conditions flipped to positive form in 20+ locations across show, script, scriptConfig stores and multiple components - S6582: manual null guards replaced with optional chaining (logger, useScriptNavigation, useStatsTable, micConflictUtils, router) - S7721: _handleOk/_handleHidden moved to module scope (useConfirm) - S6571: redundant EntityType | string union → string (useTimeline) - S4325: unnecessary type assertion removed (user store) - S3358: nested ternary extracted to if/else (ScriptEditor, CrewTimeline) - S7735: show.ts byId getter pattern and orderedScenes traversal - S6582: router optional chain + negated condition (router/index) Co-authored-by: Claude Sonnet 4.6 --- .../config/acts_and_scenes/ConfigActs.vue | 2 +- .../config/acts_and_scenes/ConfigScenes.vue | 10 ++++---- .../components/show/config/cues/CueEditor.vue | 2 +- .../show/config/script/BulkActSceneModal.vue | 4 ++-- .../show/config/script/ScriptEditor.vue | 14 +++++------ .../show/config/script/ScriptLineEditor.vue | 6 ++--- .../show/config/script/ScriptLinePart.vue | 3 +-- .../show/config/script/ScriptLineViewer.vue | 10 ++++---- .../config/script/StageDirectionStyles.vue | 4 ++-- .../show/config/stage/CrewTimeline.vue | 13 +++++----- .../show/config/stage/TimelineSidePanel.vue | 6 ++--- .../components/show/live/ScriptLineViewer.vue | 2 +- .../show/live/ScriptLineViewerCompact.vue | 2 +- .../components/show/live/ScriptViewPane.vue | 4 ++-- .../user/settings/CueColourPreferences.vue | 3 +-- client-v3/src/composables/useConfirm.ts | 24 +++++++++---------- .../src/composables/useScriptNavigation.ts | 2 +- client-v3/src/composables/useStatsTable.ts | 2 +- client-v3/src/composables/useTimeline.ts | 2 +- client-v3/src/composables/useWebSocket.ts | 8 +++---- client-v3/src/js/blockOrphanUtils.ts | 6 ++--- client-v3/src/js/logger.ts | 2 +- client-v3/src/js/micConflictUtils.ts | 4 ++-- client-v3/src/router/index.ts | 4 ++-- client-v3/src/stores/script.ts | 4 ++-- client-v3/src/stores/scriptConfig.test.ts | 2 +- client-v3/src/stores/scriptConfig.ts | 16 ++++++------- client-v3/src/stores/show.ts | 22 ++++++++--------- client-v3/src/stores/system.ts | 2 +- client-v3/src/stores/user.ts | 2 +- 30 files changed, 93 insertions(+), 94 deletions(-) diff --git a/client-v3/src/components/show/config/acts_and_scenes/ConfigActs.vue b/client-v3/src/components/show/config/acts_and_scenes/ConfigActs.vue index de2285bc..850df7cb 100644 --- a/client-v3/src/components/show/config/acts_and_scenes/ConfigActs.vue +++ b/client-v3/src/components/show/config/acts_and_scenes/ConfigActs.vue @@ -237,7 +237,7 @@ const editFormActOptions = computed(() => { const base = previousActOptions.value.filter((opt) => opt.value !== editFormState.value.id); if ( editFormState.value.previous_act_id != null && - !base.find((o) => o.value === editFormState.value.previous_act_id) + !base.some((o) => o.value === editFormState.value.previous_act_id) ) { const act = showStore.actById(editFormState.value.previous_act_id); if (act) base.push({ value: act.id, text: act.name, disabled: false } as (typeof base)[0]); diff --git a/client-v3/src/components/show/config/acts_and_scenes/ConfigScenes.vue b/client-v3/src/components/show/config/acts_and_scenes/ConfigScenes.vue index 35e5bccd..a23a89c9 100644 --- a/client-v3/src/components/show/config/acts_and_scenes/ConfigScenes.vue +++ b/client-v3/src/components/show/config/acts_and_scenes/ConfigScenes.vue @@ -369,7 +369,7 @@ const editFormPrevScenes = computed(() => { ).filter((opt) => opt.value !== editFormState.value.scene_id); if ( editFormState.value.previous_scene_id != null && - !base.find((o) => o.value === editFormState.value.previous_scene_id) + !base.some((o) => o.value === editFormState.value.previous_scene_id) ) { const scene = showStore.sceneById(editFormState.value.previous_scene_id); if (scene) { @@ -452,11 +452,11 @@ function openFirstSceneEdit(act: { id: number; first_scene: number | null }): vo function editActChanged(newActId: number | null): void { const originalScene = - editSceneOriginalId.value != null ? showStore.sceneById(editSceneOriginalId.value) : null; - if (newActId !== originalScene?.act) { - editFormState.value.previous_scene_id = null; - } else { + editSceneOriginalId.value == null ? null : showStore.sceneById(editSceneOriginalId.value); + if (newActId === originalScene?.act) { editFormState.value.previous_scene_id = originalScene?.previous_scene ?? null; + } else { + editFormState.value.previous_scene_id = null; } } diff --git a/client-v3/src/components/show/config/cues/CueEditor.vue b/client-v3/src/components/show/config/cues/CueEditor.vue index c8e458d7..117f8514 100644 --- a/client-v3/src/components/show/config/cues/CueEditor.vue +++ b/client-v3/src/components/show/config/cues/CueEditor.vue @@ -128,7 +128,7 @@ onBeforeMount(async () => { userStore.getCueColourOverrides(), ]); } - return Promise.resolve(); + return; }), showStore.getActList(), showStore.getSceneList(), diff --git a/client-v3/src/components/show/config/script/BulkActSceneModal.vue b/client-v3/src/components/show/config/script/BulkActSceneModal.vue index 68e856d8..40a18d86 100644 --- a/client-v3/src/components/show/config/script/BulkActSceneModal.vue +++ b/client-v3/src/components/show/config/script/BulkActSceneModal.vue @@ -62,7 +62,7 @@ const validActs = computed(() => { while (cur) { result.push(cur); if (props.nextLineOfEnd && props.nextLineOfEnd.act_id === cur.id) break; - cur = cur.next_act != null ? (props.acts.find((a) => a.id === cur!.next_act) ?? null) : null; + cur = cur.next_act == null ? null : (props.acts.find((a) => a.id === cur!.next_act) ?? null); } return result; }); @@ -82,7 +82,7 @@ const validScenes = computed(() => { while (cur) { result.push(cur); if (props.nextLineOfEnd && props.nextLineOfEnd.scene_id === cur.id) break; - cur = cur.next_scene != null ? (actScenes.find((s) => s.id === cur!.next_scene) ?? null) : null; + cur = cur.next_scene == null ? null : (actScenes.find((s) => s.id === cur!.next_scene) ?? null); } return result; }); diff --git a/client-v3/src/components/show/config/script/ScriptEditor.vue b/client-v3/src/components/show/config/script/ScriptEditor.vue index 6e86ec12..ba84c0a0 100644 --- a/client-v3/src/components/show/config/script/ScriptEditor.vue +++ b/client-v3/src/components/show/config/script/ScriptEditor.vue @@ -198,7 +198,6 @@ - - diff --git a/client-v3/vite.config.ts b/client-v3/vite.config.ts index f8d1fb81..e1baa54b 100644 --- a/client-v3/vite.config.ts +++ b/client-v3/vite.config.ts @@ -1,4 +1,4 @@ -import path from 'path'; +import path from 'node:path'; import { defineConfig } from 'vite'; import vue from '@vitejs/plugin-vue'; import Components from 'unplugin-vue-components/vite'; diff --git a/client-v3/vitest.config.ts b/client-v3/vitest.config.ts index bb075002..0a9d396e 100644 --- a/client-v3/vitest.config.ts +++ b/client-v3/vitest.config.ts @@ -1,6 +1,6 @@ import { defineConfig } from 'vitest/config'; import vue from '@vitejs/plugin-vue'; -import path from 'path'; +import path from 'node:path'; export default defineConfig({ plugins: [vue()], diff --git a/client/vite.config.ts b/client/vite.config.ts index 48e1a58f..87157fa4 100644 --- a/client/vite.config.ts +++ b/client/vite.config.ts @@ -1,5 +1,5 @@ -import path from 'path'; -import fs from 'fs'; +import path from 'node:path'; +import fs from 'node:fs'; import { defineConfig, type Plugin } from 'vite'; import vue from '@vitejs/plugin-vue2'; diff --git a/server/controllers/controllers.py b/server/controllers/controllers.py index 4203aef7..f9d8f988 100644 --- a/server/controllers/controllers.py +++ b/server/controllers/controllers.py @@ -13,6 +13,8 @@ IMPORTED_CONTROLLERS = {} +INDEX_HTML = "index.html" + def import_all_controllers(): get_logger().info("Importing controllers...") @@ -32,13 +34,13 @@ async def get(self, path): return if is_frozen(): # In PyInstaller mode, use resource path - full_path = get_resource_path(os.path.join("static", "index.html")) + full_path = get_resource_path(os.path.join("static", INDEX_HTML)) else: # In source mode, use relative path file_path = os.path.join( os.path.abspath(os.path.dirname(__file__)), "..", "static" ) - full_path = os.path.join(file_path, "index.html") + full_path = os.path.join(file_path, INDEX_HTML) if not os.path.isfile(full_path): get_logger().error(f"Index file not found: {full_path}") @@ -50,21 +52,19 @@ async def get(self, path): ) self.write(content) except Exception as e: - get_logger().error(f"Error serving index.html: {str(e)}") + get_logger().error(f"Error serving {INDEX_HTML}: {str(e)}") raise HTTPError(500) from e class RootControllerV3(BaseController): def get(self, _path): if is_frozen(): - full_path = get_resource_path( - os.path.join("static", "ui-new", "index.html") - ) + full_path = get_resource_path(os.path.join("static", "ui-new", INDEX_HTML)) else: file_path = os.path.join( os.path.abspath(os.path.dirname(__file__)), "..", "static", "ui-new" ) - full_path = os.path.join(file_path, "index.html") + full_path = os.path.join(file_path, INDEX_HTML) if not os.path.isfile(full_path): raise HTTPError(404) with open(full_path, "r", encoding="utf-8") as file: