diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..860c5a7 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,7 @@ +{ + "semi": true, + "singleQuote": true, + "trailingComma": "es5", + "printWidth": 100, + "tabWidth": 2 +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 403a128..4e6d29e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,14 +10,17 @@ "dependencies": { "@tauri-apps/api": "^2", "@tauri-apps/plugin-opener": "^2", - "react": "^19.1.0", - "react-dom": "^19.1.0" + "react": "^19.2.8", + "react-dom": "^19.2.8" }, "devDependencies": { "@tauri-apps/cli": "^2", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@vitejs/plugin-react": "^4.6.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-prettier": "^5.5.6", + "prettier": "^3.9.6", "typescript": "~5.8.3", "vite": "^7.0.4" } @@ -746,6 +749,192 @@ "node": ">=18" } }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "peer": true, + "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/eslint-utils/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", + "peer": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "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", + "peer": true, + "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", + "peer": true, + "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.6.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", + "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "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", + "peer": true, + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "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", + "peer": true, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "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", + "peer": true, + "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", + "peer": true, + "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", + "peer": true, + "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", + "peer": true, + "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", + "peer": true, + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -796,6 +985,19 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@pkgr/core": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.3.6.tgz", + "integrity": "sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/pkgr" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -1488,6 +1690,14 @@ "@babel/types": "^7.28.2" } }, + "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", + "peer": true + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -1495,6 +1705,14 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/react": { "version": "19.2.17", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", @@ -1536,6 +1754,60 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peer": true, + "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", + "peer": true, + "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/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", + "peer": true, + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.10.44", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.44.tgz", @@ -1549,6 +1821,20 @@ "node": ">=6.0.0" } }, + "node_modules/brace-expansion": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/browserslist": { "version": "4.28.6", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.6.tgz", @@ -1611,6 +1897,22 @@ "dev": true, "license": "MIT" }, + "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", + "peer": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1636,6 +1938,14 @@ } } }, + "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", + "peer": true + }, "node_modules/electron-to-chromium": { "version": "1.5.394", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.394.tgz", @@ -1695,6 +2005,261 @@ "node": ">=6" } }, + "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", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.7.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.7.0.tgz", + "integrity": "sha512-GVTD7s1vdIl6UYvAfriOPeY1Df8LIZjfofLvHwde+erDHGGuHyuM6xoxRxmHiebhYuD2p1vN4wWh0XzPARSGDQ==", + "dev": true, + "license": "MIT", + "peer": true, + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.6.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", + "@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", + "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.6", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.6.tgz", + "integrity": "sha512-ifetmTcxWfz+4qRW3pH/ujdTq2jQIj59AxJMIN26K5avYgU8dxycUETQonWiW+wPrYXA0j3Try0l1CnwVQtDqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prettier-linter-helpers": "^1.0.1", + "synckit": "^0.11.13" + }, + "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-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", + "peer": true, + "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": "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", + "peer": true, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "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", + "peer": true, + "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/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", + "peer": true, + "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", + "peer": true, + "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", + "peer": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "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", + "peer": true + }, + "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", + "peer": true + }, + "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", + "peer": true + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1713,6 +2278,61 @@ } } }, + "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", + "peer": true, + "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", + "peer": true, + "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", + "peer": true, + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.3.tgz", + "integrity": "sha512-/zipXxyO6rGvuNGDiULY9MvEGSkb2gaG4GGH4ygMi0ZZzyMHdUZBmntJmx5x1G2VuPytCwGN4xsJP6cw+sK+vQ==", + "dev": true, + "license": "ISC", + "peer": true + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1738,6 +2358,75 @@ "node": ">=6.9.0" } }, + "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", + "peer": true, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "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", + "peer": true, + "engines": { + "node": ">= 4" + } + }, + "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", + "peer": true, + "engines": { + "node": ">=0.8.19" + } + }, + "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", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "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", + "peer": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "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", + "peer": true + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -1758,6 +2447,30 @@ "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", + "peer": true + }, + "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", + "peer": true + }, + "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", + "peer": true + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -1771,6 +2484,49 @@ "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", + "peer": true, + "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", + "peer": true, + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "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", + "peer": true, + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -1781,6 +2537,23 @@ "yallist": "^3.0.2" } }, + "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", + "peer": true, + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1807,6 +2580,14 @@ "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", + "peer": true + }, "node_modules/node-releases": { "version": "2.0.51", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", @@ -1817,6 +2598,81 @@ "node": ">=18" } }, + "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", + "peer": true, + "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", + "peer": true, + "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", + "peer": true, + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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", + "peer": true, + "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", + "peer": true, + "engines": { + "node": ">=8" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1866,25 +2722,76 @@ "node": "^10 || ^12 || >=14" } }, + "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", + "peer": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "dev": true, + "license": "MIT", + "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/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", + "peer": true, + "engines": { + "node": ">=6" + } + }, "node_modules/react": { - "version": "19.2.7", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", - "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "19.2.7", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", - "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", "license": "MIT", "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.2.7" + "react": "^19.2.8" } }, "node_modules/react-refresh": { @@ -1958,6 +2865,31 @@ "semver": "bin/semver.js" } }, + "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", + "peer": true, + "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", + "peer": true, + "engines": { + "node": ">=8" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1968,6 +2900,22 @@ "node": ">=0.10.0" } }, + "node_modules/synckit": { + "version": "0.11.13", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.13.tgz", + "integrity": "sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pkgr/core": "^0.3.6" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/synckit" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -1985,6 +2933,20 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "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", + "peer": true, + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/typescript": { "version": "5.8.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", @@ -2030,6 +2992,17 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "peer": true, + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/vite": { "version": "7.3.6", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", @@ -2105,12 +3078,54 @@ } } }, + "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", + "peer": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "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", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true, "license": "ISC" + }, + "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", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/package.json b/package.json index 98f3c9f..fc3f9ca 100644 --- a/package.json +++ b/package.json @@ -7,20 +7,25 @@ "dev": "vite", "build": "tsc && vite build", "preview": "vite preview", - "tauri": "tauri" + "tauri": "tauri", + "format": "prettier --write \"src/**/*.{ts,tsx,js,jsx}\"", + "format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx}\"" }, "dependencies": { - "react": "^19.1.0", - "react-dom": "^19.1.0", "@tauri-apps/api": "^2", - "@tauri-apps/plugin-opener": "^2" + "@tauri-apps/plugin-opener": "^2", + "react": "^19.2.8", + "react-dom": "^19.2.8" }, "devDependencies": { + "@tauri-apps/cli": "^2", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@vitejs/plugin-react": "^4.6.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-prettier": "^5.5.6", + "prettier": "^3.9.6", "typescript": "~5.8.3", - "vite": "^7.0.4", - "@tauri-apps/cli": "^2" + "vite": "^7.0.4" } } diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 3ce86d2..5487be9 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -796,6 +796,8 @@ dependencies = [ name = "dsp_thomann" version = "0.1.0" dependencies = [ + "serde", + "serde_json", "strum", "strum_macros", "thiserror 2.0.19", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index db9e6dc..fbd5504 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -22,4 +22,4 @@ tauri = { version = "2", features = [] } tauri-plugin-opener = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" -dsp_thomann = { path = "../../dsp_thomann" } +dsp_thomann = { path = "../../dsp_thomann", features = ["serialization"] } diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 75a965e..d9cf37f 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1,14 +1,58 @@ use tauri::State; -use crate::state::AppState; +use crate::state::{AppState, DSPDevice}; use dsp_thomann::dsp408::DSP408; use std::{net::IpAddr, str::FromStr}; +use std::sync::{Arc, Mutex}; -#[tauri::command] -pub fn device_count( - state: State -) -> usize { - let devices = state.devices.lock().unwrap(); - devices.len() +impl AppState { + pub fn with_device_mut( + &self, + id: u64, + f: F, + ) -> Result + where + F: FnOnce(&mut DSPDevice) -> Result, + { + let device = { + let devices = self + .devices + .lock() + .map_err(|e| e.to_string())?; + + devices + .get(&id) + .cloned() + .ok_or_else(|| "DSP not found".to_string())? + }; + + let mut device = device + .lock() + .map_err(|e| e.to_string())?; + + f(&mut device) + } + + pub fn remove_device( + &self, + id: u64, + ) -> Result<(), String> { + + let device = { + let mut devices = self.devices + .lock() + .map_err(|e| e.to_string())?; + + devices + .remove(&id) + .ok_or_else(|| format!("DSP {} not found", id))? + }; + + // Drop happens here. + // DSPDevice owns DSP408. + drop(device); + + Ok(()) + } } #[tauri::command] @@ -17,19 +61,114 @@ pub fn create_dsp408( ip: String, port: u16, device_id: u8, -) -> Result { +) -> Result { + + let ip = IpAddr::from_str(&ip) + .map_err(|e| e.to_string())?; + + let dsp = DSP408::new(ip, port, device_id) + .map_err(|e| e.to_string())?; + + let mut next_id = state + .next_id + .lock() + .map_err(|e| e.to_string())?; + + let id = *next_id; + *next_id += 1; + + let device = DSPDevice { + dsp, + }; let mut devices = state .devices .lock() .map_err(|e| e.to_string())?; - let ip = IpAddr::from_str(&ip).map_err(|e| e.to_string())?; + devices.insert( + id, + Arc::new(Mutex::new(device)), + ); - let dsp = DSP408::new(ip, port, device_id) - .map_err(|e| e.to_string())?; + Ok(id) +} - devices.push(dsp); +#[tauri::command] +pub async fn remove_dsp408( + state: State<'_, AppState>, + id: u64, +) -> Result<(), String> { - Ok(devices.len()) -} \ No newline at end of file + state.with_device_mut(id, |device| { + + if device.dsp.connected() { + device.dsp + .disconnect() + .map_err(|e| e.to_string())?; + } + + Ok(()) + })?; + + state.remove_device(id)?; + + Ok(()) +} + +#[tauri::command] +pub async fn connect_dsp408( + state: State<'_, AppState>, + id: u64, +) -> Result<(), String> { + + state.with_device_mut(id, |device| { + device.dsp + .connect() + .map_err(|e| e.to_string()) + }) +} + +#[tauri::command] +pub async fn disconnect_dsp408( + state: State<'_, AppState>, + id: u64, +) -> Result<(), String> { + + state.with_device_mut(id, |device| { + device.dsp + .disconnect() + .map_err(|e| e.to_string()) + }) +} + +#[tauri::command] +pub async fn get_dsp_state( + state: State<'_, AppState>, + id: u64, +) -> Result { + + state.with_device_mut(id, |device| { + let dsp_state = device.dsp + .state() + .map_err(|e| e.to_string())?; + + Ok(dsp_state.clone()) + }) +} + +#[tauri::command] +pub async fn set_channel_gain( + state: State<'_, AppState>, + id: u64, + channel: dsp_thomann::dsp408::types::Channel, + db: f32, +) -> Result { + state.with_device_mut(id, |device| { + let r = device.dsp + .set_channel_gain(channel, db) + .map_err(|e| e.to_string())?; + + Ok(r) + }) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index cf15db7..44a4fb8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3,21 +3,18 @@ mod commands; use state::AppState; -// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ -#[tauri::command] -fn greet(name: &str) -> String { - format!("Hello, {}! You've been greeted from Rust!", name) -} - #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_opener::init()) .manage(AppState::new()) .invoke_handler(tauri::generate_handler![ - greet, - commands::device_count, commands::create_dsp408, + commands::connect_dsp408, + commands::remove_dsp408, + commands::disconnect_dsp408, + commands::get_dsp_state, + commands::set_channel_gain, ] ) .run(tauri::generate_context!()) diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index bda9d59..ab1e5d5 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -1,14 +1,21 @@ -use std::sync::Mutex; +use std::sync::{Mutex, Arc}; +use std::collections::HashMap; use dsp_thomann::dsp408::DSP408; +pub struct DSPDevice { + pub dsp: DSP408, +} + pub struct AppState { - pub devices: Mutex>, + pub devices: Mutex>>>, + pub next_id: Mutex, } impl AppState { pub fn new() -> Self { Self { - devices: Mutex::new(Vec::new()), + devices: Mutex::new(HashMap::new()), + next_id: Mutex::new(1), } } } \ No newline at end of file diff --git a/src/App.css b/src/App.css deleted file mode 100644 index 0557da0..0000000 --- a/src/App.css +++ /dev/null @@ -1,479 +0,0 @@ -@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap"); - -:root { - --bg-void: #0d0f12; - --bg-panel: #16191d; - --bg-raised: #1e2227; - --border-hairline: #2a2f36; - --text-primary: #e8eaed; - --text-muted: #8b92a0; - --accent-brand: #5b8def; - --accent-live: #3ecf8e; - --accent-down: #ff5c5c; - --accent-connecting: #f5a623; - - --font-ui: "Inter", -apple-system, "Segoe UI", sans-serif; - --font-mono: "JetBrains Mono", "SF Mono", Menlo, monospace; -} - -* { - box-sizing: border-box; -} - -html, -body, -#root { - height: 100%; -} - -body { - margin: 0; - background: var(--bg-void); - color: var(--text-primary); - font-family: var(--font-ui); -} - -.mono { - font-family: var(--font-mono); -} - -.app { - display: flex; - flex-direction: column; - height: 100vh; -} - -/* ---------- Top bar ---------- */ - -.topbar { - display: flex; - align-items: center; - gap: 20px; - padding: 0 16px; - height: 48px; - background: var(--bg-void); - border-bottom: 1px solid var(--border-hairline); - -webkit-app-region: drag; -} - -.logo-slot { - display: flex; - align-items: center; - justify-content: center; - height: 100%; - aspect-ratio: 1 / 1; - width: auto; - flex-shrink: 0; - overflow: hidden; - -webkit-app-region: no-drag; -} - -.logo-slot > * { - width: 100%; - height: 100%; -} - -.tabstrip { - display: flex; - align-items: flex-end; - gap: 2px; - height: 48px; - overflow-x: auto; - overflow-y: hidden; - -webkit-app-region: no-drag; - scrollbar-width: thin; - scrollbar-color: var(--border-hairline) transparent; -} - -.tabstrip::-webkit-scrollbar { - height: 6px; -} - -.tabstrip::-webkit-scrollbar-track { - background: transparent; -} - -.tabstrip::-webkit-scrollbar-thumb { - background: var(--border-hairline); - border-radius: 6px; -} - -.tabstrip::-webkit-scrollbar-thumb:hover { - background: var(--text-muted); -} - -.tab { - display: flex; - align-items: center; - gap: 8px; - height: 36px; - margin-top: 8px; - padding: 0 12px; - border: 1px solid transparent; - border-bottom: none; - border-radius: 8px 8px 0 0; - background: transparent; - color: var(--text-muted); - font-family: var(--font-ui); - font-size: 13px; - cursor: pointer; - transition: background 0.12s ease, color 0.12s ease; - width: 160px; - flex: 0 0 auto; -} - -.tab:hover { - background: var(--bg-raised); - color: var(--text-primary); -} - -.tab--active { - background: var(--bg-panel); - border-color: var(--border-hairline); - color: var(--text-primary); -} - -.tab--dimmed { - opacity: 0.55; -} - -.tab-name { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.tab-close { - margin-left: 2px; - font-size: 15px; - line-height: 1; - color: var(--text-muted); - border-radius: 4px; - padding: 2px 4px; -} - -.tab-close:hover { - background: rgba(255, 255, 255, 0.08); - color: var(--text-primary); -} - -/* Quiet icon button — used once at least one tab exists */ -.tab-add { - display: flex; - align-items: center; - justify-content: center; - width: 26px; - height: 26px; - align-self: flex-end; - margin-bottom: 5px; /* (tab height 36 - button height 26) / 2 */ - margin-left: 6px; - border: none; - border-radius: 50%; - background: transparent; - color: var(--text-muted); - cursor: pointer; - flex-shrink: 0; - transition: background 0.12s ease, color 0.12s ease; -} - -.tab-add:hover { - background: var(--bg-raised); - color: var(--text-primary); -} - -.tab-add:active { - background: var(--border-hairline); -} - -/* Clearer CTA — used when the bar has nothing else to anchor to */ -.tab-add-empty { - display: flex; - align-items: center; - gap: 6px; - height: 30px; - align-self: flex-end; - margin-bottom: 3px; /* (tab height 36 - CTA height 30) / 2 */ - padding: 0 12px 0 10px; - border: 1px dashed var(--border-hairline); - border-radius: 999px; - background: transparent; - color: var(--text-muted); - font-family: var(--font-ui); - font-size: 12px; - font-weight: 500; - cursor: pointer; - flex-shrink: 0; - transition: background 0.12s ease, color 0.12s ease, border-color 0.12s ease; -} - -.tab-add-empty:hover { - background: var(--bg-raised); - border-color: var(--accent-brand); - border-style: solid; - color: var(--text-primary); -} - -/* ---------- LED status dot ---------- */ - -.led { - width: 8px; - height: 8px; - border-radius: 50%; - flex-shrink: 0; - background: var(--accent-down); -} - -.led--connected { - background: var(--accent-live); - box-shadow: 0 0 6px 1px var(--accent-live); -} - -.led--connecting { - background: var(--accent-connecting); - box-shadow: 0 0 6px 1px var(--accent-connecting); - animation: pulse 1s ease-in-out infinite; -} - -.led--disconnected { - background: var(--accent-down); - box-shadow: 0 0 4px 0px var(--accent-down); -} - -.led--lg { - width: 12px; - height: 12px; -} - -@keyframes pulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.4; } -} - -/* ---------- Stage / main area ---------- */ - -.stage { - flex: 1; - display: flex; - align-items: center; - justify-content: center; - padding: 40px 24px; - background: var(--bg-panel); - overflow-y: auto; -} - -.card { - width: 100%; - max-width: 480px; - background: var(--bg-raised); - border: 1px solid var(--border-hairline); - border-radius: 12px; - padding: 28px; -} - -.empty { - display: flex; - flex-direction: column; - align-items: center; - gap: 14px; - color: var(--text-muted); - text-align: center; -} - -.empty-mark { - font-size: 28px; - color: var(--border-hairline); -} - -.empty p { - margin: 0; - font-size: 14px; -} - -/* ---------- DSP panel ---------- */ - -.panel-header { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 16px; - margin-bottom: 24px; -} - -.panel-title { - margin: 0; - font-size: 18px; - font-weight: 600; -} - -.panel-type { - margin-top: 4px; - font-size: 12px; - color: var(--text-muted); - font-family: var(--font-mono); - letter-spacing: 0.03em; -} - -.status-block { - display: flex; - align-items: center; - gap: 8px; - flex-shrink: 0; -} - -.status-label { - font-size: 12px; - font-weight: 500; -} - -.status-label--connected { color: var(--accent-live); } -.status-label--connecting { color: var(--accent-connecting); } -.status-label--disconnected { color: var(--text-muted); } - -.specs { - display: flex; - flex-direction: column; - gap: 12px; - margin: 0 0 24px; - padding: 16px; - background: var(--bg-panel); - border: 1px solid var(--border-hairline); - border-radius: 8px; -} - -.spec { - display: flex; - align-items: center; - justify-content: space-between; - font-size: 13px; -} - -.spec dt { - color: var(--text-muted); -} - -.spec dd { - margin: 0; - color: var(--text-primary); -} - -.panel-actions { - display: flex; - justify-content: flex-end; -} - -/* ---------- Form ---------- */ - -.form-header { - margin-bottom: 20px; -} - -.form-hint { - margin: 6px 0 0; - font-size: 13px; - color: var(--text-muted); -} - -.field { - display: flex; - flex-direction: column; - gap: 6px; - margin-bottom: 16px; - flex: 1; -} - -.field--grow { - flex: 2; -} - -.field--narrow { - flex: 1; -} - -.field-row { - display: flex; - gap: 12px; -} - -.field label { - font-size: 12px; - color: var(--text-muted); -} - -.field input, -.field select { - height: 36px; - padding: 0 10px; - background: var(--bg-panel); - border: 1px solid var(--border-hairline); - border-radius: 6px; - color: var(--text-primary); - font-family: var(--font-ui); - font-size: 13px; - outline: none; - transition: border-color 0.12s ease; -} - -.field input.mono { - font-family: var(--font-mono); -} - -.field input:focus, -.field select:focus { - border-color: var(--accent-brand); -} - -.form-actions { - display: flex; - justify-content: flex-end; - gap: 10px; - margin-top: 8px; -} - -/* ---------- Buttons ---------- */ - -.btn { - height: 36px; - padding: 0 16px; - border-radius: 6px; - font-family: var(--font-ui); - font-size: 13px; - font-weight: 500; - cursor: pointer; - border: 1px solid transparent; - transition: background 0.12s ease, border-color 0.12s ease, opacity 0.12s ease; -} - -.btn:disabled { - opacity: 0.45; - cursor: not-allowed; -} - -.btn-primary { - background: var(--accent-brand); - color: #0d0f12; -} - -.btn-primary:hover:not(:disabled) { - background: #6f9bf2; -} - -.btn-outline { - background: transparent; - border-color: var(--accent-down); - color: var(--accent-down); -} - -.btn-outline:hover { - background: rgba(255, 92, 92, 0.1); -} - -.btn-ghost { - background: transparent; - border-color: var(--border-hairline); - color: var(--text-muted); -} - -.btn-ghost:hover { - color: var(--text-primary); - border-color: var(--text-muted); -} \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx deleted file mode 100644 index aed5343..0000000 --- a/src/App.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import { invoke } from "@tauri-apps/api/core"; - -import { useState } from "react"; -import "./App.css"; -import { DSP, DSPStatus } from "./types"; -import TopBar from "./components/TopBar"; -import DSPPanel from "./components/Dsppanel"; -import AddDSPForm from "./components/Adddspform"; - -function App() { - const [dsps, setDsps] = useState([]); - const [selected, setSelected] = useState(null); - const [showAdd, setShowAdd] = useState(false); - - async function addDSP(dsp: Omit) { - try { - const count = await invoke("create_dsp408", { - ip: dsp.ip, - port: dsp.port, - deviceId: Number(dsp.deviceId), - }); - - console.log("DSP count:", count); - - const newDsp: DSP = { - ...dsp, - status: "disconnected", - }; - - setDsps(prev => [...prev, newDsp]); - setSelected(newDsp.id); - setShowAdd(false); - } catch (err) { - console.error("Failed to create DSP:", err); - // TODO: Show an error dialog/toast - } - } - - function removeDSP(id: string) { - setDsps(prev => { - const next = prev.filter(d => d.id !== id); - if (selected === id) { - setSelected(next.length ? next[0].id : null); - } - return next; - }); - } - - function setStatus(id: string, status: DSPStatus) { - setDsps(prev => prev.map(d => (d.id === id ? { ...d, status } : d))); - } - - function connect(dsp: DSP) { - setStatus(dsp.id, "connecting"); - window.setTimeout(() => { - setStatus(dsp.id, "connected"); - }, 900); - } - - function disconnect(dsp: DSP) { - setStatus(dsp.id, "disconnected"); - } - - const activeDsp = dsps.find(d => d.id === selected) ?? null; - - return ( -
- { - setSelected(id); - setShowAdd(false); - }} - onRemove={removeDSP} - onAddClick={() => setShowAdd(true)} - /> - -
- {showAdd ? ( - setShowAdd(false)} onAdd={addDSP} /> - ) : activeDsp ? ( - connect(activeDsp)} - onDisconnect={() => disconnect(activeDsp)} - /> - ) : ( -
-
-

No output selected.

- -
- )} -
-
- ); -} - -export default App; \ No newline at end of file diff --git a/src/components/AnimatedLogo.tsx b/src/assets/icons/AnimatedLogo.tsx similarity index 83% rename from src/components/AnimatedLogo.tsx rename to src/assets/icons/AnimatedLogo.tsx index a629e5c..6e2490e 100644 --- a/src/components/AnimatedLogo.tsx +++ b/src/assets/icons/AnimatedLogo.tsx @@ -1,59 +1,59 @@ -import { useEffect, useRef, useState } from 'react' +import { useEffect, useRef, useState } from 'react'; // degrees per second -const DONUT_BASE_SPEED = 360 / 8 -const DONUT_HOVER_SPEED = 360 / 2 -const INNER_BASE_SPEED = 360 / 12 -const INNER_HOVER_SPEED = 360 / 3 +const DONUT_BASE_SPEED = 360 / 8; +const DONUT_HOVER_SPEED = 360 / 2; +const INNER_BASE_SPEED = 360 / 12; +const INNER_HOVER_SPEED = 360 / 3; const AnimatedLogo = ({ size = 45, className = '' }) => { - const [isHovered, setIsHovered] = useState(false) - const isHoveredRef = useRef(false) - const donutGroupRef = useRef(null) - const innerGroupRef = useRef(null) - const angleRef = useRef({ donut: 0, inner: 0 }) - const lastTimeRef = useRef(null) - const rafIdRef = useRef(null) + const [isHovered, setIsHovered] = useState(false); + const isHoveredRef = useRef(false); + const donutGroupRef = useRef(null); + const innerGroupRef = useRef(null); + const angleRef = useRef({ donut: 0, inner: 0 }); + const lastTimeRef = useRef(null); + const rafIdRef = useRef(null); useEffect(() => { - isHoveredRef.current = isHovered - }, [isHovered]) + isHoveredRef.current = isHovered; + }, [isHovered]); useEffect(() => { const tick = (time: number) => { - if (lastTimeRef.current == null) lastTimeRef.current = time - const dt = (time - lastTimeRef.current) / 1000 - lastTimeRef.current = time + if (lastTimeRef.current == null) lastTimeRef.current = time; + const dt = (time - lastTimeRef.current) / 1000; + lastTimeRef.current = time; - const donutSpeed = isHoveredRef.current ? DONUT_HOVER_SPEED : DONUT_BASE_SPEED - const innerSpeed = isHoveredRef.current ? INNER_HOVER_SPEED : INNER_BASE_SPEED + const donutSpeed = isHoveredRef.current ? DONUT_HOVER_SPEED : DONUT_BASE_SPEED; + const innerSpeed = isHoveredRef.current ? INNER_HOVER_SPEED : INNER_BASE_SPEED; // Accumulate angle continuously -- speed can change instantly // without ever resetting the current rotation. - angleRef.current.donut = (angleRef.current.donut + donutSpeed * dt) % 360 - angleRef.current.inner = (angleRef.current.inner + innerSpeed * dt) % 360 + angleRef.current.donut = (angleRef.current.donut + donutSpeed * dt) % 360; + angleRef.current.inner = (angleRef.current.inner + innerSpeed * dt) % 360; if (donutGroupRef.current) { donutGroupRef.current.setAttribute( 'transform', `rotate(${angleRef.current.donut} 100 100)` - ) + ); } if (innerGroupRef.current) { innerGroupRef.current.setAttribute( 'transform', `rotate(${angleRef.current.inner} 100 100)` - ) + ); } - rafIdRef.current = requestAnimationFrame(tick) - } + rafIdRef.current = requestAnimationFrame(tick); + }; - rafIdRef.current = requestAnimationFrame(tick) + rafIdRef.current = requestAnimationFrame(tick); return () => { - if (rafIdRef.current) cancelAnimationFrame(rafIdRef.current) - } - }, []) + if (rafIdRef.current) cancelAnimationFrame(rafIdRef.current); + }; + }, []); return (
{ - + @@ -203,7 +197,7 @@ const AnimatedLogo = ({ size = 45, className = '' }) => {
- ) -} + ); +}; -export default AnimatedLogo \ No newline at end of file +export default AnimatedLogo; diff --git a/src/assets/icons/AudioIcons.tsx b/src/assets/icons/AudioIcons.tsx new file mode 100644 index 0000000..19624b3 --- /dev/null +++ b/src/assets/icons/AudioIcons.tsx @@ -0,0 +1,33 @@ +export function InfoIcon(props: React.SVGProps) { + return ( + + + + ); +} + +export function SlidersIcon(props: React.SVGProps) { + return ( + + + + + + + + + + ); +} diff --git a/src/assets/icons/ChevronLeftIcon.tsx b/src/assets/icons/ChevronLeftIcon.tsx new file mode 100644 index 0000000..fe1ffcd --- /dev/null +++ b/src/assets/icons/ChevronLeftIcon.tsx @@ -0,0 +1,13 @@ +export function ChevronLeftIcon(props: React.SVGProps) { + return ( + + + + ); +} diff --git a/src/assets/icons/CloseIcon.tsx b/src/assets/icons/CloseIcon.tsx new file mode 100644 index 0000000..f5a6fc0 --- /dev/null +++ b/src/assets/icons/CloseIcon.tsx @@ -0,0 +1,7 @@ +export function CloseIcon(props: React.SVGProps) { + return ( + + + + ); +} diff --git a/src/assets/icons/CrownIcon.tsx b/src/assets/icons/CrownIcon.tsx new file mode 100644 index 0000000..f84b5af --- /dev/null +++ b/src/assets/icons/CrownIcon.tsx @@ -0,0 +1,14 @@ +export function CrownIcon(props: React.SVGProps) { + return ( + + + + + ); +} diff --git a/src/assets/icons/PlusIcon.tsx b/src/assets/icons/PlusIcon.tsx new file mode 100644 index 0000000..5c44ad6 --- /dev/null +++ b/src/assets/icons/PlusIcon.tsx @@ -0,0 +1,12 @@ +export function PlusIcon(props: React.SVGProps) { + return ( + + + + ); +} diff --git a/src/components/DSPTabs.tsx b/src/components/DSPTabs.tsx deleted file mode 100644 index 5116d8c..0000000 --- a/src/components/DSPTabs.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { DSP } from "../types"; - -type Props = { - dsps: DSP[]; - selected: string | null; - showAdd: boolean; - onSelect(id: string): void; - onRemove(id: string): void; - onAdd(): void; -}; - -export default function DSPTabs({ - dsps, - onSelect, - onRemove, - onAdd, -}: Props) { - -return ( -
- - {dsps.map(dsp => ( - - ))} - - - -
-); -} \ No newline at end of file diff --git a/src/components/Notifications.tsx b/src/components/Notifications.tsx new file mode 100644 index 0000000..ce62478 --- /dev/null +++ b/src/components/Notifications.tsx @@ -0,0 +1,31 @@ +import type { Notification } from '../types/types'; + +type Props = { + notifications: Notification[]; + onRemove: (id: number) => void; +}; + +export default function Notifications({ notifications, onRemove }: Props) { + return ( +
+ {notifications.map((n) => ( +
+ + {n.message} + + + +
+ ))} +
+ ); +} diff --git a/src/components/Topbar.tsx b/src/components/Topbar.tsx deleted file mode 100644 index 4b48359..0000000 --- a/src/components/Topbar.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { DSP } from "../types"; -import Tab from "./Tab"; -import AnimatedLogo from "./AnimatedLogo"; // adjust path to wherever yours lives - -function TopBar({ - dsps, - selected, - showAdd, - onSelect, - onRemove, - onAddClick, -}: { - dsps: DSP[]; - selected: string | null; - showAdd: boolean; - onSelect: (id: string) => void; - onRemove: (id: string) => void; - onAddClick: () => void; -}) { - const hasTabs = dsps.length > 0; - - return ( -
-
- -
- -
- {dsps.map(dsp => ( - onSelect(dsp.id)} - onRemove={() => onRemove(dsp.id)} - /> - ))} - - {hasTabs ? ( - - ) : ( - - )} -
-
- ); -} - -function PlusIcon() { - return ( - - - - ); -} - -export default TopBar; \ No newline at end of file diff --git a/src/components/Adddspform.tsx b/src/components/app/AddDspPage.tsx similarity index 59% rename from src/components/Adddspform.tsx rename to src/components/app/AddDspPage.tsx index 760c55f..09e4459 100644 --- a/src/components/Adddspform.tsx +++ b/src/components/app/AddDspPage.tsx @@ -1,26 +1,29 @@ -import { useState } from "react"; -import { DSP, DSP_TYPES } from "../types"; +import { useState } from 'react'; +import { DSP, DSP_TYPES } from '../../types/types'; function AddDSPForm({ onCancel, onAdd, }: { onCancel: () => void; - onAdd: (d: Omit) => void; + onAdd: (d: Omit) => void; }) { const [form, setForm] = useState({ - name: "", + name: '', type: DSP_TYPES[0].name, - ip: "192.168.1.100", + ip: '192.168.1.100', port: DSP_TYPES[0].defaultPort, - deviceId: "", + deviceId: '1', }); - const canSubmit = form.name.trim().length > 0 && form.ip.trim().length > 0 && form.deviceId; + const canSubmit = + form.name.trim().length > 0 && form.ip.trim().length > 0 && form.deviceId.length > 0; function submit() { if (!canSubmit) return; - onAdd({ id: crypto.randomUUID(), ...form }); + onAdd({ + ...form, + }); } return ( @@ -36,7 +39,7 @@ function AddDSPForm({ id="f-name" placeholder="Main Room DSP" value={form.name} - onChange={e => setForm({ ...form, name: e.target.value })} + onChange={(e) => setForm({ ...form, name: e.target.value })} /> @@ -46,41 +49,37 @@ function AddDSPForm({
- - - setForm({ ...form, deviceId: e.target.value }) - } - /> + + setForm({ ...form, deviceId: e.target.value })} + />
@@ -91,7 +90,7 @@ function AddDSPForm({ id="f-ip" className="mono" value={form.ip} - onChange={e => setForm({ ...form, ip: e.target.value })} + onChange={(e) => setForm({ ...form, ip: e.target.value })} /> @@ -102,7 +101,7 @@ function AddDSPForm({ className="mono" type="number" value={form.port} - onChange={e => setForm({ ...form, port: Number(e.target.value) })} + onChange={(e) => setForm({ ...form, port: Number(e.target.value) })} /> @@ -119,4 +118,4 @@ function AddDSPForm({ ); } -export default AddDSPForm; \ No newline at end of file +export default AddDSPForm; diff --git a/src/components/app/App.tsx b/src/components/app/App.tsx new file mode 100644 index 0000000..ff6d2ac --- /dev/null +++ b/src/components/app/App.tsx @@ -0,0 +1,174 @@ +import { invoke } from '@tauri-apps/api/core'; + +import { useState } from 'react'; +import '../../styles/App.css'; +import { DSP, DSPStatus, AppPage } from '../../types/types'; +import TopBar from '../topbar/Topbar'; +import DSPPage from '../dsp408/DspPage'; +import AddDSPForm from './AddDspPage'; +import { useNotifications } from '../../hooks/useNotifications'; +import Notifications from '../Notifications'; +import { DSPState } from '../../types/dsp408State'; +import CreditsPage from './CreditsPage'; +import HomePage from './HomePage'; + +function App() { + const [dsps, setDsps] = useState([]); + const [selected, setSelected] = useState(null); + const [page, setPage] = useState('home'); + const { notifications, notify, removeNotification } = useNotifications(); + + async function addDSP(dsp: Omit) { + try { + const id = await invoke('create_dsp408', { + ip: dsp.ip, + port: dsp.port, + deviceId: Number(dsp.deviceId), + }); + + const newDsp: DSP = { + id, + ...dsp, + status: 'disconnected', + }; + + setDsps((prev) => [...prev, newDsp]); + setSelected(id); + setPage('view-dsp'); + + notify('Added successfully', 'success', dsp.name); + } catch (err) { + notify(`Connection failed: ${String(err)}`, 'error', dsp.name); + } + } + + async function removeDSP(dsp: DSP) { + try { + let id = dsp.id; + await invoke('remove_dsp408', { + id, + }); + + setDsps((prev) => { + const next = prev.filter((d) => d.id !== id); + + if (selected === id) { + setSelected(next.length ? next[0].id : null); + } + + return next; + }); + + notify('Removed successfully', 'success', dsp?.name); + } catch (err) { + notify(`Remove failed: ${String(err)}`, 'error', dsp?.name); + } + } + + async function connectDSP(dsp: DSP) { + setStatus(dsp.id, 'connecting'); + + try { + await invoke('connect_dsp408', { + id: dsp.id, + }); + + console.log('Connected'); + + setStatus(dsp.id, 'connected'); + + const state = await getDSPState(dsp); + + console.log(state); + + notify('Connected', 'success', dsp.name); + } catch (err) { + notify(`Connection failed: ${String(err)}`, 'error', dsp.name); + + setStatus(dsp.id, 'disconnected'); + } + } + + async function disconnectDSP(dsp: DSP) { + try { + await invoke('disconnect_dsp408', { + id: dsp.id, + }); + + setStatus(dsp.id, 'disconnected'); + + notify('Disconnected', 'success', dsp.name); + } catch (err) { + notify(`Disconnect failed: ${String(err)}`, 'error', dsp.name); + } + } + + async function getDSPState(dsp: DSP) { + try { + const state = await invoke('get_dsp_state', { + id: dsp.id, + }); + + return state; + } catch (err) { + notify(`Failed to get state: ${String(err)}`, 'error', dsp.name); + + return null; + } + } + + function setStatus(id: number, status: DSPStatus) { + setDsps((prev) => prev.map((d) => (d.id === id ? { ...d, status } : d))); + } + + const activeDsp = dsps.find((d) => d.id === selected) ?? null; + const connectedDspCount = dsps.filter((dsp) => dsp.status === 'connected').length; + + return ( +
+ + + { + setSelected(id); + setPage('view-dsp'); + }} + + onRemove={removeDSP} + + onAddClick={() => { + setPage('add-dsp'); + }} + /> + +
+ {page === 'credits' ? ( + + ) : page === 'add-dsp' ? ( + setPage('home')} onAdd={addDSP} /> + ) : activeDsp ? ( + connectDSP(activeDsp)} + onDisconnect={() => disconnectDSP(activeDsp)} + notify={notify} + /> + ) : ( + setPage('add-dsp')} + dspCount={dsps.length} + connectedCount={connectedDspCount} + /> + )} +
+
+ ); +} + +export default App; diff --git a/src/components/app/CreditsPage.tsx b/src/components/app/CreditsPage.tsx new file mode 100644 index 0000000..2ee3ec8 --- /dev/null +++ b/src/components/app/CreditsPage.tsx @@ -0,0 +1,35 @@ +function CreditsPage() { + return ( +
+
+
+

Credits

+
Thank you to everyone supporting the project
+
+
+ +
+
+
Developer
+
Your Name
+
+ +
+
Design
+
Open Source Community
+
+ +
+
Libraries
+
React · Tauri · TypeScript
+
+
+ +
+ +
+
+ ); +} + +export default CreditsPage; diff --git a/src/components/app/HomePage.tsx b/src/components/app/HomePage.tsx new file mode 100644 index 0000000..eced272 --- /dev/null +++ b/src/components/app/HomePage.tsx @@ -0,0 +1,37 @@ +function HomePage({ + dspCount, + connectedCount, + onAddDSP, +}: { + dspCount: number; + connectedCount: number; + onAddDSP: () => void; +}) { + return ( +
+
+
+
Connected
+
+ {connectedCount} DSP{connectedCount !== 1 ? 's' : ''} +
+
+ +
+
Devices
+
+ {dspCount} DSP{dspCount !== 1 ? 's' : ''} configured +
+
+
+ +
+ +
+
+ ); +} + +export default HomePage; diff --git a/src/components/Dsppanel.tsx b/src/components/dsp408/ConnectionPanel.tsx similarity index 64% rename from src/components/Dsppanel.tsx rename to src/components/dsp408/ConnectionPanel.tsx index 44a238b..da6fb89 100644 --- a/src/components/Dsppanel.tsx +++ b/src/components/dsp408/ConnectionPanel.tsx @@ -1,6 +1,6 @@ -import { DSP } from "../types"; +import { DSP } from '../../types/types'; -function DSPPanel({ +function ConnectionPanel({ dsp, onConnect, onDisconnect, @@ -10,11 +10,11 @@ function DSPPanel({ onDisconnect: () => void; }) { const statusLabel = - dsp.status === "connected" - ? "Connected" - : dsp.status === "connecting" - ? "Connecting…" - : "Disconnected"; + dsp.status === 'connected' + ? 'Connected' + : dsp.status === 'connecting' + ? 'Connecting…' + : 'Disconnected'; return (
@@ -25,10 +25,8 @@ function DSPPanel({
- - - {statusLabel} - + + {statusLabel}
@@ -37,18 +35,20 @@ function DSPPanel({
IP address
{dsp.ip}
+
Port
{dsp.port}
+
Device ID
-
{dsp.deviceId || "—"}
+
{dsp.deviceId || '—'}
- {dsp.status === "connected" ? ( + {dsp.status === 'connected' ? ( @@ -56,9 +56,9 @@ function DSPPanel({ )}
@@ -66,4 +66,4 @@ function DSPPanel({ ); } -export default DSPPanel; \ No newline at end of file +export default ConnectionPanel; diff --git a/src/components/dsp408/DspPage.tsx b/src/components/dsp408/DspPage.tsx new file mode 100644 index 0000000..3e5c86d --- /dev/null +++ b/src/components/dsp408/DspPage.tsx @@ -0,0 +1,62 @@ +import { useState } from 'react'; +import { DSP } from '../../types/types'; +import Sidebar from './Sidebar/Sidebar'; +import { InfoIcon, SlidersIcon } from '../../assets/icons/AudioIcons'; +import ConnectionPanel from './ConnectionPanel'; +import GainPanel from './GainPanel'; +import { NotificationType } from '../../types/types'; + +function DSPPage({ + dsp, + onConnect, + onDisconnect, + notify, +}: { + dsp: DSP; + onConnect: () => void; + onDisconnect: () => void; + notify: (message: string, type?: NotificationType, dspName?: string) => void; +}) { + const [activeSidebar, setActiveSidebar] = useState('overview'); + + const sidebarItems = [ + { + id: 'overview', + label: 'Overview', + icon: , + }, + { + id: 'gain', + label: 'Gain', + icon: , + }, + ]; + + const renderTab = () => { + switch (activeSidebar) { + case 'overview': + return ; + + case 'gain': + return ; + + default: + return null; + } + }; + + return ( +
+ + +
{renderTab()}
+
+ ); +} + +export default DSPPage; diff --git a/src/components/dsp408/GainPanel.tsx b/src/components/dsp408/GainPanel.tsx new file mode 100644 index 0000000..7597761 --- /dev/null +++ b/src/components/dsp408/GainPanel.tsx @@ -0,0 +1,56 @@ +import VerticalStack from './VerticalStack/VerticalStack'; +import VerticalSlider from './Slider.tsx/VerticalSlider'; +import { invoke } from '@tauri-apps/api/core'; +import { InputChannel, Channel } from '../../types/dsp408State'; +import { DSP } from '../../types/types'; +import { NotificationType } from '../../types/types'; + +function GainPanel({ + dsp, + notify, +}: { + dsp: DSP; + notify: (message: string, type?: NotificationType, dspName?: string) => void; +}) { + const setGain = async (value: number) => { + const channel: Channel = { + Input: InputChannel.InA, + }; + + try { + const success = await invoke('set_channel_gain', { + id: dsp.id, + channel, + db: value, + }); + + if (success) { + notify(`Input A gain set to ${value} dB`, 'success', dsp.name); + } else { + notify('Failed to set input gain', 'error', dsp.name); + } + } catch (err) { + notify(`Gain update failed: ${String(err)}`, 'error', dsp.name); + } + }; + + return ( + + + + + + + + ); +} + +export default GainPanel; diff --git a/src/components/dsp408/Sidebar/Sidebar.css b/src/components/dsp408/Sidebar/Sidebar.css new file mode 100644 index 0000000..604d894 --- /dev/null +++ b/src/components/dsp408/Sidebar/Sidebar.css @@ -0,0 +1,268 @@ +/* ---------- Label ---------- */ + +.sidebar-label { + opacity: 0; + max-width: 0; + + overflow: hidden; + white-space: nowrap; + + transition: + opacity var(--dur-fast) var(--ease-standard), + max-width var(--dur-base) var(--ease-standard); +} + +.sidebar-rail--expanded .sidebar-label { + opacity: 1; + max-width: 200px; +} + + +/* ---------- Icon ---------- */ + +.sidebar-icon { + width: var(--sidebar-icon-size, 20px); + height: var(--sidebar-icon-size, 20px); + + flex-shrink: 0; + + display: flex; + align-items: center; + justify-content: center; + + color: currentColor; +} + +.sidebar-icon svg { + width: 100%; + height: 100%; + + display: block; + + stroke: currentColor; + fill: none; +} + + +/* ---------- Item ---------- */ + +.sidebar-item { + width: var(--sidebar-item-size); + height: var(--sidebar-item-size); + + margin: 0 auto; + + display: flex; + align-items: center; + justify-content: center; + + border: var(--border-width) solid transparent; + border-radius: var(--radius-lg); + + background: transparent; + color: var(--text-muted); + + cursor: pointer; + + transition: + width var(--dur-base) var(--ease-standard), + background var(--dur-base) var(--ease-standard), + color var(--dur-base) var(--ease-standard), + border-color var(--dur-base) var(--ease-standard); +} + + +.sidebar-rail--expanded .sidebar-item { + width: calc(100% - var(--space-4)); + gap: var(--sidebar-item-gap); + + justify-content: flex-start; + + padding-left: var(--sidebar-item-padding); +} + + +/* ---------- Item States ---------- */ + +.sidebar-item:hover { + background: var(--bg-raised); + color: var(--text-primary); +} + + +.sidebar-item--active { + border-color: var(--accent-brand); + color: var(--text-primary); +} + + +/* ---------- Sidebar ---------- */ + +.sidebar-rail { + position: fixed; + + top: var(--header-height); + left: 0; + bottom: 0; + + width: var(--sidebar-width-collapsed); + + display: flex; + flex-direction: column; + + background: var(--bg-void); + + border-right: var(--border-width) solid var(--border-hairline); + + z-index: 30; + + overflow: visible; + + transition: + width var(--dur-base) var(--ease-standard); +} + + +.sidebar-rail--expanded { + width: var(--sidebar-width-expanded); +} + + +/* ---------- App name ---------- */ + +.sidebar-app-name { + position: absolute; + + top: var(--space-3); + left: var(--space-4); + + height: var(--sidebar-toggle-size); + + display: flex; + align-items: center; + + font-size: var(--font-size-sm, 13px); + font-weight: 600; + color: var(--text-primary); + + white-space: nowrap; + overflow: hidden; + + opacity: 0; + pointer-events: none; + + transition: opacity var(--dur-fast) var(--ease-standard); + + z-index: 31; +} + +.sidebar-rail--expanded .sidebar-app-name { + opacity: 1; + pointer-events: auto; + transition-delay: var(--dur-fast); +} + + +/* ---------- Scroll ---------- */ + +.sidebar-scroll { + flex: 1; + + display: flex; + flex-direction: column; + align-items: center; + + gap: var(--space-1); + + padding: calc(var(--sidebar-toggle-size) + var(--space-3) * 2) 0 var(--space-3); + + overflow-y: auto; +} + + +.sidebar-rail--expanded .sidebar-scroll { + align-items: stretch; +} + + +/* ---------- Toggle ---------- */ + +.sidebar-toggle { + position: absolute; + + top: var(--space-3); + left: calc((var(--sidebar-width-collapsed) - var(--sidebar-toggle-size)) / 2); + + width: var(--sidebar-toggle-size); + height: var(--sidebar-toggle-size); + + display: flex; + align-items: center; + justify-content: center; + + border: none; + border-radius: var(--radius-pill); + + background: var(--bg-void); + color: var(--text-muted); + + cursor: pointer; + padding: 0; + + box-shadow: none; + + transition: + left var(--dur-base) var(--ease-standard), + background var(--dur-base) var(--ease-standard), + color var(--dur-base) var(--ease-standard), + transform var(--dur-fast) var(--ease-standard); + + z-index: 32; +} + + +.sidebar-rail--expanded .sidebar-toggle { + left: calc(var(--sidebar-width-expanded) - var(--sidebar-toggle-size) - var(--space-3)); +} + + +.sidebar-toggle:hover { + background: var(--bg-raised); + + color: var(--text-primary); + + border-color: var(--accent-brand); +} + + +.sidebar-toggle:active { + transform: scale(.92); +} + +.sidebar-rail--expanded .sidebar-toggle:active { + transform: scale(.92); +} + + +.sidebar-toggle-icon { + width: 14px; + height: 14px; + + transition: + transform var(--dur-base) var(--ease-standard); +} + + +.sidebar-rail--expanded .sidebar-toggle-icon { + transform: rotate(180deg); +} + + +/* ---------- Content ---------- */ + +.stage--with-sidebar { + margin-left: var(--sidebar-width-collapsed); + + transition: + margin-left var(--dur-base) var(--ease-standard); +} \ No newline at end of file diff --git a/src/components/dsp408/Sidebar/Sidebar.tsx b/src/components/dsp408/Sidebar/Sidebar.tsx new file mode 100644 index 0000000..138553b --- /dev/null +++ b/src/components/dsp408/Sidebar/Sidebar.tsx @@ -0,0 +1,54 @@ +import { useState } from 'react'; +import './Sidebar.css'; +import { ChevronLeftIcon } from '../../../assets/icons/ChevronLeftIcon'; + +export type SidebarItem = { + id: string; + label: string; + icon: React.ReactNode; +}; + +export default function Sidebar({ + items, + activeId, + onSelect, + appName, +}: { + items: SidebarItem[]; + activeId: string | null; + onSelect: (id: string) => void; + appName: string; +}) { + const [expanded, setExpanded] = useState(false); + + return ( + + ); +} diff --git a/src/components/dsp408/Slider.tsx/VerticalSlider.css b/src/components/dsp408/Slider.tsx/VerticalSlider.css new file mode 100644 index 0000000..ee6f9a0 --- /dev/null +++ b/src/components/dsp408/Slider.tsx/VerticalSlider.css @@ -0,0 +1,164 @@ +.gauge-container { + display: flex; + flex-direction: column; + align-items: center; + + width: 4.375rem; + + padding-top: var(--space-3); + margin: var(--space-3); + + box-sizing: border-box; + + container-type: inline-size; +} + +.gauge-track { + position: relative; + + display: flex; + align-items: center; + justify-content: center; + + width: 100%; + height: 13.125rem; +} + +.slider { + position: relative; + + width: 34.3%; + height: 100%; + + cursor: pointer; + touch-action: none; +} + +.track { + position: absolute; + + left: 50%; + top: 0; + + transform: translateX(-50%); + + width: 2.86cqw; + height: 100%; + + background: var(--border-hairline); + border-radius: var(--radius-sm); +} + +.thumb { + position: absolute; + + left: 50%; + + width: 75%; + aspect-ratio: 1; + + transform: translate(-50%, 50%); + + border-radius: 50%; + + background: var(--bg-panel); + + border: var(--border-width-active) solid var(--accent-brand); + + box-sizing: border-box; + + box-shadow: var(--shadow-sm); + + transition: + transform var(--transition-fast) var(--ease-standard), + box-shadow var(--transition-fast) var(--ease-standard); +} + +.ticks { + position: absolute; + + top: 0; + height: 100%; + + display: flex; + flex-direction: column; + justify-content: space-between; + + pointer-events: none; +} + +.ticks span { + width: 14.3cqw; + height: 2.86cqw; + + background: var(--border-hairline); + + border-radius: var(--radius-sm); +} + +.left { + left: 11.4%; +} + +.right { + right: 11.4%; +} + +.value { + margin-top: var(--space-2); + + width: 100%; + + box-sizing: border-box; + + padding: var(--space-2); + + border-radius: var(--radius-md); + + color: var(--accent-brand); + + font-size: var(--value-font-size, 16px); + + font-family: var(--font-ui); + + box-shadow: var(--shadow-sm); + + display: flex; + + justify-content: center; + align-items: center; + + gap: 0.25em; + + overflow: hidden; + white-space: nowrap; +} + + +.value input, +.value-unit { + font-size: inherit; + line-height: 1; + + font-family: inherit; + color: inherit; +} + + +.value input { + height: 1.4em; + + box-sizing: border-box; + + text-align: center; + + flex: 0 1 auto; + + background: transparent; + + border: 1px solid var(--border-hairline); + + border-radius: var(--radius-sm); + + appearance: none; +} \ No newline at end of file diff --git a/src/components/dsp408/Slider.tsx/VerticalSlider.tsx b/src/components/dsp408/Slider.tsx/VerticalSlider.tsx new file mode 100644 index 0000000..381db24 --- /dev/null +++ b/src/components/dsp408/Slider.tsx/VerticalSlider.tsx @@ -0,0 +1,264 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; + +import './VerticalSlider.css'; + +type VerticalSliderProps = { + min: number; + max: number; + step: number; + + switchStepValue?: number; + switchStep?: number; + + value?: number; + onChange?: (value: number) => void; + + unit?: string; + + className?: string; + style?: React.CSSProperties; +}; + +function getDecimals(n: number) { + const s = n.toString(); + const i = s.indexOf('.'); + return i === -1 ? 0 : s.length - i - 1; +} + +// Measures whatever box the consumer actually rendered — no more +// assuming the gauge is always 70px wide. +function useElementSize() { + const ref = useRef(null); + const [size, setSize] = useState({ width: 0, height: 0 }); + + useEffect(() => { + const el = ref.current; + if (!el) return; + + const observer = new ResizeObserver((entries) => { + const { width, height } = entries[0].contentRect; + setSize({ width, height }); + }); + + observer.observe(el); + + const rect = el.getBoundingClientRect(); + setSize({ width: rect.width, height: rect.height }); + + return () => observer.disconnect(); + }, []); + + return [ref, size] as const; +} + +export default function VerticalSlider({ + min, + max, + step, + switchStepValue, + switchStep, + value: controlledValue, + onChange, + unit = '', + className, + style, +}: VerticalSliderProps) { + const [internalValue, setInternalValue] = useState(min); + const value = controlledValue ?? internalValue; + + const [editingText, setEditingText] = useState(null); + + const inputRef = useRef(null); + const [containerRef, containerSize] = useElementSize(); + const sliderRef = useRef(null); + const isDragging = useRef(false); + const lastValue = useRef(value); + const keyboardValue = useRef(null); + + const currentStep = + switchStepValue !== undefined && switchStep !== undefined && value >= switchStepValue + ? switchStep + : step; + + const decimals = useMemo( + () => Math.max(getDecimals(step), switchStep !== undefined ? getDecimals(switchStep) : 0), + [step, switchStep] + ); + + const charWidth = useMemo( + () => Math.max(min.toFixed(decimals).length, max.toFixed(decimals).length) + 1, + [min, max, decimals] + ); + + // Same formula as before, just driven by a real measurement + // instead of a hardcoded container width. + const fontSize = useMemo(() => { + if (!containerSize.width) return 18; + + const chars = charWidth + unit.length + 1; + + const available = containerSize.width * 0.9; + + return Math.max(12, Math.min(22, available / chars)); + }, [containerSize.width, charWidth, unit.length]); + + function normalizeValue(input: number) { + const clamped = Math.min(max, Math.max(min, input)); + const snapped = Math.round(clamped / currentStep) * currentStep; + return Number(snapped.toFixed(6)); + } + + function updateValue(v: number) { + const next = normalizeValue(v); + + if (controlledValue === undefined) { + setInternalValue(next); + } + + lastValue.current = next; + } + + function handleKeyDown(e: React.KeyboardEvent) { + if ( + e.key !== 'ArrowUp' && + e.key !== 'ArrowDown' && + e.key !== 'ArrowLeft' && + e.key !== 'ArrowRight' + ) { + return; + } + + e.preventDefault(); + + let next = value; + + if (e.key === 'ArrowUp' || e.key === 'ArrowRight') { + next += currentStep; + } + + if (e.key === 'ArrowDown' || e.key === 'ArrowLeft') { + next -= currentStep; + } + + updateValue(next); + + keyboardValue.current = normalizeValue(next); + } + + function handleKeyUp(_e: React.KeyboardEvent) { + if (keyboardValue.current !== null) { + onChange?.(keyboardValue.current); + keyboardValue.current = null; + } + } + + function valueFromPointer(clientY: number) { + if (!sliderRef.current) return; + + const rect = sliderRef.current.getBoundingClientRect(); + const trackHeight = rect.height; // measured, not a constant + + let offset = clientY - rect.top; + offset = Math.max(0, Math.min(trackHeight, offset)); + + const percent = 1 - offset / trackHeight; + const raw = min + percent * (max - min); + + updateValue(raw); + } + + function startDrag(e: React.PointerEvent) { + isDragging.current = true; + + valueFromPointer(e.clientY); + + const move = (ev: PointerEvent) => { + valueFromPointer(ev.clientY); + }; + + const up = () => { + isDragging.current = false; + + onChange?.(lastValue.current); + + window.removeEventListener('pointermove', move); + window.removeEventListener('pointerup', up); + }; + + window.addEventListener('pointermove', move); + window.addEventListener('pointerup', up); + } + + function commitEditing() { + if (editingText !== null && editingText !== '' && editingText !== '-') { + const parsed = Number(editingText); + if (!Number.isNaN(parsed)) updateValue(parsed); + } + setEditingText(null); + } + + const percent = (value - min) / (max - min); + + return ( +
+
+
+ {Array.from({ length: 15 }).map((_, i) => ( + + ))} +
+ +
+
+
+
+ +
+ {Array.from({ length: 15 }).map((_, i) => ( + + ))} +
+
+ +
+ setEditingText(value.toFixed(decimals))} + onChange={(e) => setEditingText(e.target.value)} + onBlur={commitEditing} + onKeyDown={(e) => { + if (e.key === 'Enter') { + inputRef.current?.blur(); + } + + if (e.key === 'Escape') { + setEditingText(null); + inputRef.current?.blur(); + } + }} + /> + {unit} +
+
+ ); +} diff --git a/src/components/dsp408/VerticalStack/VerticalStack.css b/src/components/dsp408/VerticalStack/VerticalStack.css new file mode 100644 index 0000000..56f70bb --- /dev/null +++ b/src/components/dsp408/VerticalStack/VerticalStack.css @@ -0,0 +1,91 @@ +.vertical-stack { + + width: 100%; + height: 100%; + + display: flex; + flex-direction: column; + + border: 1px solid var(--border-hairline); + + border-radius: var(--radius-md); + + box-sizing: border-box; + + overflow: hidden; +} + + +/* header */ + +.vertical-stack__title { + + flex: 0 0 auto; + + padding: var(--space-2); + + text-align: center; + + font-family: var(--font-ui); + + font-weight: 600; + + color: var(--text-primary); + + border-bottom: 1px solid var(--border-hairline); + + line-height: 1.2; +} + + + +/* area containing components */ + +.vertical-stack__content { + + flex: 1; + + min-height: 0; + + display: flex; + + flex-direction: column; + +} + + + +/* each component slot */ + +.vertical-stack__item { + + flex: 1; + + min-height: 0; + + display: flex; + + justify-content: center; + + align-items: center; + + overflow: hidden; +} + + + +/* + The child keeps its natural dimensions. + Only horizontal scaling happens. +*/ + +.vertical-stack__scale { + + display: flex; + + justify-content: center; + + align-items: center; + + transform-origin: center; +} \ No newline at end of file diff --git a/src/components/dsp408/VerticalStack/VerticalStack.tsx b/src/components/dsp408/VerticalStack/VerticalStack.tsx new file mode 100644 index 0000000..35c5330 --- /dev/null +++ b/src/components/dsp408/VerticalStack/VerticalStack.tsx @@ -0,0 +1,85 @@ +import React, { useLayoutEffect, useRef, useState } from 'react'; + +import './VerticalStack.css'; + +type VerticalStackProps = { + title: string; + titleFontSize?: number; + children: React.ReactNode; +}; + +export default function VerticalStack({ title, titleFontSize = 16, children }: VerticalStackProps) { + const containerRef = useRef(null); + const itemRefs = useRef([]); + + const [scales, setScales] = useState([]); + + useLayoutEffect(() => { + function updateScale() { + if (!containerRef.current) { + return; + } + + const availableWidth = containerRef.current.clientWidth; + + const newScales = itemRefs.current.map((item) => { + const child = item.firstElementChild as HTMLElement; + + if (!child) { + return 1; + } + + const width = child.scrollWidth; + + return Math.min(1, availableWidth / width); + }); + + setScales(newScales); + } + + updateScale(); + + const observer = new ResizeObserver(updateScale); + + if (containerRef.current) { + observer.observe(containerRef.current); + } + + return () => observer.disconnect(); + }, [children]); + + return ( +
+
+ {title} +
+ +
+ {React.Children.map(children, (child, index) => ( +
{ + if (el) { + itemRefs.current[index] = el; + } + }} + > +
+ {child} +
+
+ ))} +
+
+ ); +} diff --git a/src/components/Tab.tsx b/src/components/topbar/Tab.tsx similarity index 57% rename from src/components/Tab.tsx rename to src/components/topbar/Tab.tsx index 809e48a..a8bed6f 100644 --- a/src/components/Tab.tsx +++ b/src/components/topbar/Tab.tsx @@ -1,4 +1,4 @@ -import { DSP } from "../types"; +import { DSP } from '../../types/types'; function Tab({ dsp, @@ -15,28 +15,26 @@ function Tab({ }) { return ( ); } -export default Tab; \ No newline at end of file +export default Tab; diff --git a/src/components/topbar/Topbar.tsx b/src/components/topbar/Topbar.tsx new file mode 100644 index 0000000..6acf9aa --- /dev/null +++ b/src/components/topbar/Topbar.tsx @@ -0,0 +1,95 @@ +import { useEffect, useRef } from 'react'; +import { DSP, AppPage } from '../../types/types'; +import Tab from './Tab'; +import AnimatedLogo from '../../assets/icons/AnimatedLogo'; +import { PlusIcon } from '../../assets/icons/PlusIcon'; +import { CrownIcon } from '../../assets/icons/CrownIcon'; + +function TopBar({ + dsps, + selected, + page, + setPage, + setSelect, + onSelect, + onRemove, + onAddClick, +}: { + dsps: DSP[]; + selected: number | null; + page: AppPage; + setPage: (page: AppPage) => void; + setSelect: (select: number | null) => void; + onSelect: (id: number) => void; + onRemove: (dsp: DSP) => void; + onAddClick: () => void; +}) { + const hasTabs = dsps.length > 0; + const tabstripRef = useRef(null); + const prevCount = useRef(dsps.length); + + useEffect(() => { + if (dsps.length > prevCount.current && tabstripRef.current) { + tabstripRef.current.scrollTo({ + left: tabstripRef.current.scrollWidth, + behavior: 'smooth', + }); + } + prevCount.current = dsps.length; + }, [dsps.length]); + + return ( +
+
{ + setSelect(null); + setPage('home'); + }} + aria-label="Go to home" + > + +
+ +
+ {dsps.map((dsp) => ( + onSelect(dsp.id)} + onRemove={() => onRemove(dsp)} + /> + ))} +
+ +
+ {hasTabs ? ( + + ) : ( + + )} +
+ + +
+ ); +} + +export default TopBar; diff --git a/src/hooks/useNotifications.tsx b/src/hooks/useNotifications.tsx new file mode 100644 index 0000000..57b768b --- /dev/null +++ b/src/hooks/useNotifications.tsx @@ -0,0 +1,42 @@ +import { useState, useCallback } from 'react'; +import { NotificationType, Notification } from '../types/types'; + +export function useNotifications() { + const [notifications, setNotifications] = useState([]); + + const notify = useCallback( + (message: string, type: NotificationType = 'error', dspName?: string) => { + const id = Date.now(); + + const fullMessage = dspName ? `${dspName}: ${message}` : message; + + setNotifications((prev) => [ + ...prev, + { + id, + message: fullMessage, + type, + }, + ]); + + setTimeout(() => { + setNotifications((prev) => prev.filter((n) => n.id !== id)); + }, 5000); + }, + [] + ); + + const removeNotification = useCallback((id: number) => { + setNotifications((prev) => prev.map((n) => (n.id === id ? { ...n, removing: true } : n))); + + setTimeout(() => { + setNotifications((prev) => prev.filter((n) => n.id !== id)); + }, 250); + }, []); + + return { + notifications, + notify, + removeNotification, + }; +} diff --git a/src/main.tsx b/src/main.tsx index 2be325e..4e9b466 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,9 +1,9 @@ -import React from "react"; -import ReactDOM from "react-dom/client"; -import App from "./App"; +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './components/app/App'; -ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( +ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( - , + ); diff --git a/src/styles/App.css b/src/styles/App.css new file mode 100644 index 0000000..575a05e --- /dev/null +++ b/src/styles/App.css @@ -0,0 +1,725 @@ +@import "./variables.css"; + +* { + box-sizing: border-box; + user-select: none; +} + +input, +select, +textarea, +[contenteditable="true"] { + user-select: text; +} + +html, +body, +#root { + height: 100%; +} + +body { + margin: 0; + background: var(--bg-void); + color: var(--text-primary); + font-family: var(--font-ui); +} + +.mono { + font-family: var(--font-mono); +} + +.app { + display: flex; + flex-direction: column; + height: 100vh; +} + +@media (prefers-reduced-motion: reduce) { + * { + animation-duration: 0.001ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.001ms !important; + } +} + +button:focus-visible, +*:focus-visible { + outline: none; + box-shadow: inset 0 0 0 1px var(--accent-brand); +} + +/* ---------- Top bar ---------- */ + +.topbar { + display: flex; + align-items: center; + gap: var(--space-5); + padding: 0 var(--space-4); + height: 48px; + background: var(--bg-void); + border-bottom: 1px solid var(--border-hairline); + -webkit-app-region: drag; +} + +.logo-slot { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + aspect-ratio: 1 / 1; + width: auto; + flex-shrink: 0; + overflow: hidden; + -webkit-app-region: no-drag; +} + +.logo-slot > * { + width: 100%; + height: 100%; +} + +.tabstrip { + display: flex; + align-items: center; + gap: 2px; + height: 48px; + flex: 1 1 auto; + min-width: 0; + overflow-x: auto; + overflow-y: hidden; + -webkit-app-region: no-drag; + scrollbar-width: thin; + scrollbar-color: var(--border-hairline) transparent; + scroll-behavior: smooth; + scroll-padding-inline: var(--space-3); +} + +.crown-action { + color: #facc15; + background: transparent; + border: none; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; +} + +.tabstrip::-webkit-scrollbar { + height: 5px; +} + +.tabstrip::-webkit-scrollbar-track { + background: transparent; +} + +.tabstrip::-webkit-scrollbar-thumb { + background: var(--border-hairline); + border-radius: var(--radius-pill); +} + +.tabstrip::-webkit-scrollbar-thumb:hover { + background: var(--accent-brand); +} + +.tab { + display: flex; + align-items: center; + gap: var(--space-2); + height: 34px; + padding: 0 var(--space-3); + border: 1px solid var(--border-hairline); + border-radius: var(--radius-md); + background: transparent; + color: var(--text-muted); + font-family: var(--font-ui); + font-size: 13px; + cursor: pointer; + transition: background var(--dur-base) var(--ease-standard), + border-color var(--dur-base) var(--ease-standard), + color var(--dur-base) var(--ease-standard); + width: 160px; + flex: 0 0 auto; +} + +.tab:hover { + background: var(--bg-raised); + border-color: var(--text-muted); + color: var(--text-primary); +} + +.tab--active { + background: var(--bg-panel); + border-color: var(--accent-brand); + color: var(--text-primary); +} + +.tab--active:hover { + background: var(--bg-panel); + border-color: var(--accent-brand); +} + +.tab--dimmed { + opacity: 0.55; +} + +.tab-name { + overflow: hidden; + white-space: nowrap; + min-width: 0; + flex: 1 1 auto; + line-height: 1; + display: flex; + align-items: center; + height: 100%; + mask-image: linear-gradient(to right, black calc(100% - 18px), transparent 100%); + -webkit-mask-image: linear-gradient(to right, black calc(100% - 18px), transparent 100%); +} + +.tab-close { + display: flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + margin-left: auto; + width: 18px; + height: 18px; + padding: 0; + font-size: 15px; + line-height: 1; + color: var(--text-muted); + border-radius: 4px; + transition: background var(--dur-fast) var(--ease-standard), + color var(--dur-fast) var(--ease-standard); +} + +.tab-close:hover { + background: rgba(255, 255, 255, 0.08); + color: var(--text-primary); +} + +/* Quiet icon button — used once at least one tab exists */ +.tab-add { + display: flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + margin-left: 6px; + border: none; + border-radius: 50%; + background: transparent; + color: var(--text-muted); + cursor: pointer; + flex-shrink: 0; + transition: background var(--dur-base) var(--ease-standard), + color var(--dur-base) var(--ease-standard); +} + +/* Lives outside .tabstrip so it never scrolls out of view */ +.tabstrip-actions { + display: flex; + align-items: center; + flex-shrink: 0; + -webkit-app-region: no-drag; +} + +.tab-add:hover { + background: var(--bg-raised); + color: var(--text-primary); +} + +.tab-add:active { + background: var(--border-hairline); +} + +/* Clearer CTA — used when the bar has nothing else to anchor to */ +.tab-add-empty { + display: flex; + align-items: center; + justify-content: center; + line-height: 1; + gap: 6px; + height: 30px; + padding: 0 var(--space-3) 0 10px; + border: 1px dashed var(--border-hairline); + border-radius: var(--radius-pill); + background: transparent; + color: var(--text-muted); + font-family: var(--font-ui); + font-size: 12px; + font-weight: 500; + cursor: pointer; + flex-shrink: 0; + transition: background var(--dur-base) var(--ease-standard), + border-color var(--dur-base) var(--ease-standard), + color var(--dur-base) var(--ease-standard); +} + +.tab-add-empty svg, +.tab-add svg { + display: block; + flex-shrink: 0; +} + +.tab-add-empty:hover { + background: var(--bg-raised); + border-color: var(--accent-brand); + border-style: solid; + color: var(--text-primary); +} + +/* ---------- LED status dot ---------- */ + +.led { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; + background: var(--accent-down); + transition: background var(--dur-base) var(--ease-standard); +} + +.led--connected { + background: var(--accent-live); + box-shadow: 0 0 6px 1px var(--accent-live); +} + +.led--connecting { + background: var(--accent-connecting); + box-shadow: 0 0 6px 1px var(--accent-connecting); + animation: pulse 1s var(--ease-standard) infinite; +} + +.led--disconnected { + background: var(--accent-down); + box-shadow: 0 0 4px 0px var(--accent-down); +} + +.led--lg { + width: 12px; + height: 12px; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} + +/* ---------- Stage / main area ---------- */ + +.stage { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + padding: 40px var(--space-6); + background: var(--bg-panel); + overflow-y: auto; +} + +.card { + width: 100%; + max-width: 480px; + background: var(--bg-raised); + border: 1px solid var(--border-hairline); + border-radius: var(--radius-lg); + padding: 28px; +} + +.empty { + display: flex; + flex-direction: column; + align-items: center; + gap: 14px; + color: var(--text-muted); + text-align: center; +} + +.empty-mark { + font-size: 28px; + color: var(--border-hairline); +} + +.empty p { + margin: 0; + font-size: 14px; +} + +/* ---------- DSP panel ---------- */ + +.panel-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--space-4); + margin-bottom: var(--space-6); +} + +.panel-title { + margin: 0; + font-size: 18px; + font-weight: 600; +} + +.panel-type { + margin-top: var(--space-1); + font-size: 12px; + color: var(--text-muted); + font-family: var(--font-mono); + letter-spacing: 0.03em; +} + +.status-block { + display: flex; + align-items: center; + gap: var(--space-2); + flex-shrink: 0; +} + +.status-label { + font-size: 12px; + font-weight: 500; + transition: color var(--dur-base) var(--ease-standard); +} + +.status-label--connected { color: var(--accent-live); } +.status-label--connecting { color: var(--accent-connecting); } +.status-label--disconnected { color: var(--text-muted); } + +.specs { + display: flex; + flex-direction: column; + gap: var(--space-3); + margin: 0 0 var(--space-6); + padding: var(--space-4); + background: var(--bg-panel); + border: 1px solid var(--border-hairline); + border-radius: var(--radius-md); +} + +.spec { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 13px; +} + +.spec dt { + color: var(--text-muted); +} + +.spec dd { + margin: 0; + color: var(--text-primary); +} + +.panel-actions { + display: flex; + justify-content: flex-end; +} + +/* ---------- Form ---------- */ + +.form-header { + margin-bottom: var(--space-5); +} + +.form-hint { + margin: 6px 0 0; + font-size: 13px; + color: var(--text-muted); +} + +.field { + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: var(--space-4); + flex: 1; +} + +.field--grow { + flex: 2; +} + +.field--narrow { + flex: 1; +} + +.field-row { + display: flex; + gap: var(--space-3); +} + +.field label { + font-size: 12px; + color: var(--text-muted); +} + +.field input, +.field select { + height: 36px; + padding: 0 10px; + background: var(--bg-panel); + border: 1px solid var(--border-hairline); + border-radius: var(--radius-sm); + color: var(--text-primary); + font-family: var(--font-ui); + font-size: 13px; + outline: none; + transition: border-color var(--dur-base) var(--ease-standard); +} + +.field input.mono { + font-family: var(--font-mono); +} + +.field input:focus, +.field select:focus { + border-color: var(--accent-brand); +} + +.form-actions { + display: flex; + justify-content: flex-end; + gap: 10px; + margin-top: var(--space-2); +} + +/* ---------- Buttons ---------- */ + +.btn { + height: 36px; + padding: 0 var(--space-4); + border-radius: var(--radius-sm); + font-family: var(--font-ui); + font-size: 13px; + font-weight: 500; + cursor: pointer; + border: 1px solid transparent; + transition: background var(--dur-base) var(--ease-standard), + border-color var(--dur-base) var(--ease-standard), + opacity var(--dur-base) var(--ease-standard); +} + +.btn:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.btn-primary { + background: var(--accent-brand); + color: #0d0f12; +} + +.btn-primary:hover:not(:disabled) { + background: #6f9bf2; +} + +.btn-outline { + background: transparent; + border-color: var(--accent-down); + color: var(--accent-down); +} + +.btn-outline:hover { + background: rgba(255, 92, 92, 0.1); +} + +.btn-ghost { + background: transparent; + border-color: var(--border-hairline); + color: var(--text-muted); +} + +.btn-ghost:hover { + color: var(--text-primary); + border-color: var(--text-muted); +} + +/* ---------- Notifications ---------- */ + +.notifications { + position: fixed; + right: var(--space-5); + bottom: var(--space-5); + z-index: 9999; + + display: flex; + flex-direction: column; + gap: var(--space-3); + + width: 320px; + pointer-events: none; +} + +.notification { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); + + padding: 10px var(--space-3); + border-radius: var(--radius-md); + + font-size: 14px; + + box-shadow: var(--shadow-md); + + animation: notification-in var(--dur-slow) var(--ease-out) forwards; + + pointer-events: auto; +} + +.notification-out { + animation: notification-out var(--dur-slow) var(--ease-in) forwards; +} + +.notification-close { + display: flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + width: 20px; + height: 20px; + padding: 0; + border: none; + background: transparent; + border-radius: 4px; + font-size: 15px; + line-height: 1; + color: inherit; + cursor: pointer; + transition: background var(--dur-fast) var(--ease-standard); +} + +.notification-close:hover { + background: rgba(255, 255, 255, 0.15); +} + +.notification.error { + background: #b42318; + color: white; +} + +.notification.success { + background: #15803d; + color: white; +} + +.notification.warning { + background: #ca8a04; + color: white; +} + +.notification-message { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +@keyframes notification-in { + from { + transform: translateX(50px); + opacity: 0; + } + + to { + transform: translateX(0); + opacity: 1; + } +} + +@keyframes notification-out { + from { + transform: translateX(0); + opacity: 1; + } + + to { + transform: translateX(50px); + opacity: 0; + } +} + +/* ---------- DSP detail: side rail + content ---------- */ + +.dsp-detail { + display: flex; + align-items: flex-start; + gap: var(--space-5); + width: 100%; + max-width: 720px; +} + +.dsp-subnav { + display: flex; + flex-direction: column; + gap: 2px; + flex: 0 0 168px; + padding: var(--space-2); + background: var(--bg-panel); + border: 1px solid var(--border-hairline); + border-radius: var(--radius-md); +} + +.dsp-subnav-item { + display: flex; + align-items: center; + height: 34px; + padding: 0 var(--space-3); + border: none; + border-left: 2px solid transparent; + border-radius: var(--radius-sm); + background: transparent; + color: var(--text-muted); + font-family: var(--font-ui); + font-size: 13px; + text-align: left; + cursor: pointer; + transition: background var(--dur-base) var(--ease-standard), + color var(--dur-base) var(--ease-standard), + border-color var(--dur-base) var(--ease-standard); +} + +.dsp-subnav-item:hover { + background: var(--bg-raised); + color: var(--text-primary); +} + +.dsp-subnav-item--active { + background: var(--bg-raised); + border-left-color: var(--accent-brand); + color: var(--text-primary); + font-weight: 500; +} + +.dsp-subnav-content { + flex: 1 1 auto; + min-width: 0; +} + +/* Let the panel card fill the available width next to the rail, + instead of capping out at 480px like it does standalone in .stage */ +.dsp-subnav-content .card { + max-width: none; + width: 100%; +} + +/* ---------- Add to your :root ---------- */ +:root { + --header-height: 48px; /* matches .topbar's existing height */ + --sidebar-width: 48px; /* matches the old icon rail's w-12 */ +} + +/* ---------- Layout wrapper below the topbar ---------- */ +/* .app is already: display:flex; flex-direction:column; height:100vh; + this row just needs to fill the remaining space */ +.main-layout { + flex: 1; + display: flex; + min-height: 0; /* lets .stage's own overflow-y:auto do the scrolling */ +} + diff --git a/src/styles/variables.css b/src/styles/variables.css new file mode 100644 index 0000000..d0be270 --- /dev/null +++ b/src/styles/variables.css @@ -0,0 +1,64 @@ +@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap"); + +:root { + --header-height: 48px; /* matches .topbar's existing height */ + --sidebar-width-collapsed: 56px; + --sidebar-width-expanded: 180px; + + /* ---------- Color ---------- */ + --bg-void: #0d0f12; + --bg-panel: #16191d; + --bg-raised: #1e2227; + --border-hairline: #2a2f36; + --text-primary: #e8eaed; + --text-muted: #8b92a0; + --accent-brand: #5b8def; + --accent-live: #3ecf8e; + --accent-down: #ff5c5c; + --accent-connecting: #f5a623; + + /* ---------- Type ---------- */ + --font-ui: "Inter", -apple-system, "Segoe UI", sans-serif; + --font-mono: "JetBrains Mono", "SF Mono", Menlo, monospace; + + /* ---------- Motion ---------- */ + --dur-fast: 100ms; + --dur-base: 180ms; + --dur-slow: 300ms; + --ease-standard: cubic-bezier(0.4, 0, 0.2, 1); + --ease-out: cubic-bezier(0, 0, 0.2, 1); + --ease-in: cubic-bezier(0.4, 0, 1, 1); + + /* ---------- Spacing scale ---------- */ + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 20px; + --space-6: 24px; + + /* ---------- Radius ---------- */ + --radius-sm: 6px; + --radius-md: 8px; + --radius-lg: 12px; + --radius-pill: 999px; + + /* ---------- Shadow ---------- */ + --shadow-sm: 0 2px 6px rgba(0, 0, 0, 0.2); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.25); + + /* ---------- Border ---------- */ + --border-width: 1px; + --border-width-active: 2px; + + /* ---------- Animation ---------- */ + --transition-fast: 120ms; + --transition-base: 180ms; + + /* ---------- Component sizes ---------- */ + --sidebar-item-size: 32px; + --sidebar-item-gap: 10px; + --sidebar-item-padding: 8px; + + --sidebar-toggle-size: 24px; +} \ No newline at end of file diff --git a/src/types.ts b/src/types.ts deleted file mode 100644 index 0b910e3..0000000 --- a/src/types.ts +++ /dev/null @@ -1,22 +0,0 @@ -export type DSPStatus = "connected" | "connecting" | "disconnected"; - -export type DSP = { - id: string; - name: string; - type: string; - ip: string; - port: number; - deviceId: string; - status: DSPStatus; -}; - -export const DSP_TYPES = [ - { - name: "DSP408", - defaultPort: 9761, - }, - { - name: "DSP204", - defaultPort: 94, - }, -]; \ No newline at end of file diff --git a/src/types/dsp408State.ts b/src/types/dsp408State.ts new file mode 100644 index 0000000..b5cea39 --- /dev/null +++ b/src/types/dsp408State.ts @@ -0,0 +1,193 @@ +export enum DelayUnit { + Millisecond = 'Millisecond', + Meter = 'Meter', + Feet = 'Feet', +} + +export enum InputChannel { + InA = 'InA', + InB = 'InB', + InC = 'InC', + InD = 'InD', +} + +export enum OutputChannel { + Out1 = 'Out1', + Out2 = 'Out2', + Out3 = 'Out3', + Out4 = 'Out4', + Out5 = 'Out5', + Out6 = 'Out6', + Out7 = 'Out7', + Out8 = 'Out8', +} + +export type Channel = { Input: InputChannel } | { Output: OutputChannel }; + +export enum Ratio { + Ratio1_1 = 'Ratio1_1', + Ratio1_1_1 = 'Ratio1_1_1', + Ratio1_1_3 = 'Ratio1_1_3', + Ratio1_1_5 = 'Ratio1_1_5', + Ratio1_1_7 = 'Ratio1_1_7', + Ratio1_2 = 'Ratio1_2', + Ratio1_2_5 = 'Ratio1_2_5', + Ratio1_3 = 'Ratio1_3', + Ratio1_3_5 = 'Ratio1_3_5', + Ratio1_4 = 'Ratio1_4', + Ratio1_5 = 'Ratio1_5', + Ratio1_6 = 'Ratio1_6', + Ratio1_8 = 'Ratio1_8', + Ratio1_10 = 'Ratio1_10', + Ratio1_20 = 'Ratio1_20', + Limit = 'Limit', +} + +export enum CrossoverFilter { + Bypass = 'Bypass', + Bw6 = 'Bw6', + Bw12 = 'Bw12', + Bw18 = 'Bw18', + Bw24 = 'Bw24', + Bw30 = 'Bw30', + Bw36 = 'Bw36', + Bw42 = 'Bw42', + Bw48 = 'Bw48', + Lk12 = 'Lk12', + Lk24 = 'Lk24', + Lk36 = 'Lk36', + Lk48 = 'Lk48', +} + +export enum PEQFilter { + Peak = 'Peak', + LowShelf = 'LowShelf', + HighShelf = 'HighShelf', + Lp6Db = 'Lp6Db', + Lp12Db = 'Lp12Db', + Hp6Db = 'Hp6Db', + Hp12Db = 'Hp12Db', + AllPass1 = 'AllPass1', + AllPass2 = 'AllPass2', +} + +// ---------------- STATE ---------------- + +export type Gate = { + attack: number; + release: number; + hold: number; + threshold: number; +}; + +export type Compressor = { + threshold: number; + ratio: Ratio; + attack: number; + release: number; + knee: number; +}; + +export type Limiter = { + threshold: number; + attack: number; + release: number; +}; + +export type GraphicEQ = { + gains: number[]; + bypass: boolean; +}; + +export type PEQ = { + gain: number; + frequency: number; + q: number; + filter_type: PEQFilter; + bypass: boolean; +}; + +export type Crossover = { + frequency: number; + slope: CrossoverFilter; +}; + +export type CrossoverFilters = { + high_pass: Crossover; + low_pass: Crossover; +}; + +export type MatrixRoutes = { + connected: InputChannel[]; + gains: number[]; +}; + +export type PEQChain = { + bands: PEQ[]; + bypass: boolean; +}; + +export type PresetBank = { + current_index: number; + names: string[]; + modified: boolean[]; +}; + +export type DeviceFlags = { + is_locked: boolean; +}; + +export type InputSource = { + type: string; + frequency: string; +}; + +export type InputChannelState = { + name: string; + gain: number; + phase_inverted: boolean; + delay: number; + + gate: Gate; + geq: GraphicEQ; + peq_chain: PEQChain; + crossover: CrossoverFilters; + + linked_channels: Channel[]; + + mute: boolean; +}; + +export type OutputChannelState = { + name: string; + + matrix_routes: MatrixRoutes; + crossover: CrossoverFilters; + peq_chain: PEQChain; + + compressor: Compressor; + limiter: Limiter; + + gain: number; + phase_inverted: boolean; + delay: number; + + linked_channels: Channel[]; + + mute: boolean; +}; + +export type DSPConfigState = { + input_states: Record; + output_states: Record; + + input_source: InputSource; + delay_unit: DelayUnit; +}; + +export type DSPState = { + name: string; + flags: DeviceFlags; + presets: PresetBank; + current_config: DSPConfigState; +}; diff --git a/src/types/types.ts b/src/types/types.ts new file mode 100644 index 0000000..9610c30 --- /dev/null +++ b/src/types/types.ts @@ -0,0 +1,33 @@ +export type DSPStatus = 'connected' | 'connecting' | 'disconnected'; + +export type DSP = { + id: number; + name: string; + type: string; + ip: string; + port: number; + deviceId: string; + status: DSPStatus; +}; + +export const DSP_TYPES = [ + { + name: 'DSP408', + defaultPort: 9761, + }, + { + name: 'DSP204', + defaultPort: 94, + }, +]; + +export type NotificationType = 'success' | 'error' | 'warning'; + +export type Notification = { + id: number; + message: string; + type: NotificationType; + removing?: boolean; +}; + +export type AppPage = 'home' | 'add-dsp' | 'view-dsp' | 'credits';