diff --git a/.github/workflows/electron.yaml b/.github/workflows/electron.yaml index de8b4be03..e17d50f44 100644 --- a/.github/workflows/electron.yaml +++ b/.github/workflows/electron.yaml @@ -49,11 +49,15 @@ jobs: - name: Build Vite App run: npm run vitebuildcli - - name: Upload Vite Build Artifact + - name: Zip dist-react and dist-electron + run: | + zip -r vite-builds.zip dist-react dist-electron + + - name: Upload Vite Build Artifacts Zip uses: actions/upload-artifact@main with: - name: vite-dist - path: ./dist + name: vite-builds-zip + path: vite-builds.zip build: if: github.event_name == 'push' && github.ref == 'refs/heads/main' @@ -80,11 +84,16 @@ jobs: - name: Checkout code uses: actions/checkout@main - - name: Download Vite Build Artifact + - name: Download Vite Build Artifacts Zip uses: actions/download-artifact@main with: - name: vite-dist - path: ./dist + name: vite-builds-zip + path: . + + - name: Unzip dist-react and dist-electron + run: | + unzip -o vite-builds.zip + # dist-react and dist-electron are now in the workspace root - name: Get version from package.json id: get_version diff --git a/.github/workflows/static.yml b/.github/workflows/static.yml index 322fe5882..19e0bae94 100644 --- a/.github/workflows/static.yml +++ b/.github/workflows/static.yml @@ -52,14 +52,14 @@ jobs: - name: Install dependencies run: npm ci - name: Build - run: npm run build && cp ./dist/index.html ./dist/404.html + run: npm run build && cp ./dist-react/index.html ./dist-react/404.html - name: Setup Pages uses: actions/configure-pages@main - name: Upload artifact uses: actions/upload-pages-artifact@main with: # Upload dist folder - path: "./dist" + path: "./dist-react" - name: Deploy to GitHub Pages id: deployment uses: actions/deploy-pages@main diff --git a/.gitignore b/.gitignore index 81d35bc02..68742db15 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,9 @@ node_modules dist dist-ssr *.local +production +dist-electron +dist-react # Editor directories and files .vscode/* diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f0be183e5..5786ed72b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,27 +6,37 @@ Thank you for your interest in contributing! Please take a moment to review this ## 📬 Submitting issues, feature requests, or translations -- To report bugs or request features, please [open an issue](https://github.com/learnercraft/ispeakerreact/issues/new/choose) and choose the appropriate category. +- To report bugs or request features, [open an issue](https://github.com/learnercraft/ispeakerreact/issues/new/choose) and choose the appropriate category. -- For translation contributions, please [refer to this issue](https://github.com/learnercraft/ispeakerreact/issues/18) for more information. +- For translation contributions, [refer to this issue](https://github.com/learnercraft/ispeakerreact/issues/18) for more information. -## 🔀 Submitting pull requests +## ▶️ Run the code locally + +To run the code locally, you need the latest **LTS (Long Term Support)** version of [Node.js](https://nodejs.org/en/) installed. + +Then, clone the repository using either [Git](https://git-scm.com/downloads) (if you are familiar with the command line) or [GitHub Desktop](https://desktop.github.com/). + +Install dependencies with `npm install`, then start the development server using one of the following commands: + +- Web: `npm run dev` +- Electron: `npm run start` -### 📌 Project note +To test the production build: -- This project **does not use TypeScript** or any kind of static type checking yet. - - In the meantime, **use** `PropTypes` to validate component props for basic type safety. +- Web: `npm run build`, then `npm run preview` +- Electron: `npm run make` — the executable will appear in the `./out` folder + +## 🔀 Submitting pull requests + +As of [pull request #65](https://github.com/learnercraft/ispeakerreact/pull/65), this project uses TypeScript for improved type safety and developer experience. ### ✅ What you should do - **Use a code editor** (e.g., Visual Studio Code) to write and format code efficiently. -- **Format your code** before committing and pushing. You must use **Prettier with our configuration** to ensure consistent formatting. - -- **Test your code thoroughly** before pushing. Resolve any ESLint errors if possible. - - An exception is allowed for variables defined in `vite.config.js` and available only at build time. If ESLint complains about these (e.g., the `__APP_VERSION__` variable), you can safely ignore the warning. +- **Format your code** before committing using Prettier with the project's configuration. -- **Use clear, concise variable names** written in `camelCase`. Names should be self-explanatory and reflect their purpose. +- **Test your code thoroughly** before pushing. Resolve any TypeScript and ESLint errors if possible. - **Use a clear, concise pull request title**. We recommend following [semantic commit message conventions](https://gist.github.com/joshbuchea/6f47e86d2510bce28f8e7f42ae84c716). Examples: - `fix: handle audio timeout error on older devices` @@ -38,10 +48,25 @@ Thank you for your interest in contributing! Please take a moment to review this - Before submitting a **large pull request** or major change, open an issue first and select the appropriate category. After a review by our team, you can start your work. +### 💻 Coding style + +- Follow the latest ECMAScript standards. This project is built with modern JavaScript in mind, so polyfills are typically unnecessary. + - Be mindful of browser compatibility. Check [Can I use...](https://caniuse.com/) before using experimental APIs. + +- Do not use deprecated APIs. Refer to [MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/API) for up-to-date information. + +- **Use clear, concise variable names** written in `camelCase`. For React components, use `PascalCase`. Names should be self-explanatory and reflect their purpose. + +- Prefer **arrow functions** over `function` declarations to avoid hoisting issues and for consistency. + +- Use `async` and `await` instead of `.then()` where possible for readability. + +- In React components, wrap expensive computations in `useMemo` and functions in `useCallback` hooks when appropriate. + ### 🧪 Review process - All PRs are reviewed before merging. Please be responsive to feedback. - When resolving comments, **make a new commit with**: + When addressing review comments, commit with a message like: `address feedback by @` ### 🤔 What you should NOT do @@ -49,9 +74,9 @@ Thank you for your interest in contributing! Please take a moment to review this - Submit pull requests that only include **cosmetic changes** like whitespace tweaks or code reformatting without any functional impact. These changes clutter diffs and make code reviews harder. [See this comment by the Rails team](https://github.com/rails/rails/pull/13771#issuecomment-32746700). -- Submit a pull request with **one or several giant commit(s)**. This makes it difficult to review. +- Submit pull requests that consist of a single large commit or several oversized commits. Break your changes into logical, reviewable commits. -- Use unclear, vague, or default commit messages like `Update file`, `fix`, or `misc changes`. +- Use vague or default commit messages like `update file`, `fix`, or `misc changes`. - Modify configuration files (e.g., `.prettierrc`, `eslint.config.js`, etc.), or any files in the `.github` folder without prior discussion. @@ -60,12 +85,15 @@ Thank you for your interest in contributing! Please take a moment to review this - Add code or commits that: - Are **obscure** or **unclear** in intent - Are **malicious** or **unsafe** - - **Executes scripts from external sources** associated with malicious, unsafe, or illegal behavior + - **Executes scripts from external sources** associated with **malicious, unsafe, or illegal behavior** - Attempts to introduce **backdoors** or hidden functionality - If we find any code that violates these rules, you will be blocked from further contributions and reported to GitHub for Terms of Use violations. + Violations will result in being blocked from contributing. In severe cases, you may also be reported to GitHub for Terms of Use violations. + +- Commit **hardcoded secrets, tokens, or sensitive user information**. + While our project includes a secret-scanning tool, it is still your responsibility to ensure that no sensitive data is committed to the repository. -- Use expletives or offensive language. This project is intended for everyone, and we strive to maintain a respectful environment for all contributors and users. +- Use **expletives** or **offensive language**. This project is intended for everyone, and we strive to maintain a respectful environment for all contributors and users. Any inappropriate comments will be removed and treated as a final warning. --- diff --git a/data/splash.html b/data/splash.html index d7593c953..a74db73d5 100644 --- a/data/splash.html +++ b/data/splash.html @@ -17,20 +17,13 @@
- Splash Image + Splash Image
+ diff --git a/netlify.toml b/netlify.toml index 1319c2bc2..46c368028 100644 --- a/netlify.toml +++ b/netlify.toml @@ -1,5 +1,5 @@ [build] - publish = "dist" + publish = "dist-react" command = "npm run build" [context.deploy-preview] diff --git a/nodemon.json b/nodemon.json new file mode 100644 index 000000000..254e56f9a --- /dev/null +++ b/nodemon.json @@ -0,0 +1,5 @@ +{ + "watch": ["src"], + "ignore": ["dist", "dist-electron", "dist-react"], + "ext": "cts,mts,ts,tsx,cjs,mjs,js,jsx,json,html,css,sass" +} diff --git a/package-lock.json b/package-lock.json index 497a83634..161c796c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,14 +14,15 @@ "@fix-webm-duration/fix": "^1.0.1", "cors": "^2.8.5", "electron-conf": "^1.3.0", - "electron-log": "^5.4.0", + "electron-log": "^5.4.1", "electron-squirrel-startup": "^1.0.1", "express": "^5.1.0", "express-rate-limit": "^7.5.0", "fkill": "^9.0.0", "js7z-tools": "^2.4.1", "masonry-layout": "^4.2.2", - "mime": "^4.0.7" + "mime": "^4.0.7", + "zod": "^3.25.67" }, "devDependencies": { "@dnd-kit/core": "^6.3.1", @@ -34,46 +35,59 @@ "@electron-forge/maker-zip": "^7.8.1", "@electron-forge/plugin-auto-unpack-natives": "^7.8.1", "@electron-forge/plugin-fuses": "^7.8.1", + "@electron-forge/shared-types": "^7.8.1", "@electron/fuses": "^1.8.0", - "@eslint/js": "^9.27.0", - "@tailwindcss/postcss": "^4.1.7", + "@eslint/js": "^9.29.0", + "@tailwindcss/postcss": "^4.1.10", "@tailwindcss/typography": "^0.5.16", - "@tailwindcss/vite": "^4.1.7", - "@types/react": "^19.1.5", - "@types/react-dom": "^19.1.5", + "@tailwindcss/vite": "^4.1.10", + "@types/cors": "^2.8.19", + "@types/electron": "^1.6.12", + "@types/electron-squirrel-startup": "^1.0.2", + "@types/express": "^5.0.3", + "@types/he": "^1.2.3", + "@types/lodash": "^4.17.17", + "@types/node": "^24.0.3", + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", "@vidstack/react": "^1.12.13", - "@vitejs/plugin-react": "^4.5.0", - "@vitejs/plugin-react-swc": "^3.10.0", + "@vitejs/plugin-react": "^4.5.2", + "@vitejs/plugin-react-swc": "^3.10.2", "@wavesurfer/react": "^1.0.11", "autoprefixer": "^10.4.21", "concurrently": "^9.1.2", "cross-env": "^7.0.3", - "daisyui": "^5.0.37", + "daisyui": "^5.0.43", "dexie": "^4.0.11", - "electron": "^36.3.1", - "eslint": "^9.27.0", + "electron": "^36.4.0", + "eslint": "^9.29.0", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^6.0.0-rc.1", "eslint-plugin-react-refresh": "^0.4.20", - "globals": "^16.1.0", + "globals": "^16.2.0", "he": "^1.2.0", - "i18next": "^25.2.0", - "i18next-browser-languagedetector": "^8.1.0", + "i18next": "^25.2.1", + "i18next-browser-languagedetector": "^8.2.0", "i18next-http-backend": "^3.0.2", "lodash": "^4.17.21", - "postcss": "^8.5.3", + "nodemon": "^3.1.10", + "postcss": "^8.5.6", "prettier": "^3.5.3", - "prettier-plugin-tailwindcss": "^0.6.11", + "prettier-plugin-tailwindcss": "^0.6.12", "react": "^19.1.0", "react-dom": "^19.1.0", "react-flip-toolkit": "^7.2.4", - "react-i18next": "^15.5.2", + "react-i18next": "^15.5.3", "react-icons": "^5.5.0", "react-loading-skeleton": "^3.5.0", - "react-router-dom": "^7.6.0", - "rollup-plugin-visualizer": "^6.0.0", - "sonner": "^2.0.3", - "tailwindcss": "^4.1.7", + "react-router-dom": "^7.6.2", + "rollup-plugin-visualizer": "^6.0.3", + "sonner": "^2.0.5", + "tailwindcss": "^4.1.10", + "terser": "^5.42.0", + "ts-node": "^10.9.2", + "typescript": "~5.8.3", + "typescript-eslint": "^8.34.1", "vidstack": "^1.12.12", "vite": "^6.3.5", "vite-plugin-pwa": "^1.0.0", @@ -112,24 +126,24 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.26.2", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", - "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.25.9", + "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", - "picocolors": "^1.0.0" + "picocolors": "^1.1.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/compat-data": { - "version": "7.26.8", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.26.8.tgz", - "integrity": "sha512-oH5UPLMWR3L2wEFLnFJ1TZXqHufiTKAiLfqw5zkhS4dKXLJ10yVztfil/twG8EDTA4F/tvVNw9nOl4ZMslB8rQ==", + "version": "7.27.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.27.5.tgz", + "integrity": "sha512-KiRAp/VoJaWkkte84TvUd9qjdbZAdiqyvMxrGl1N6vzFogKmaLgoM3L1kgtLicp2HP5fBJS8JrZKLVIZGVJAVg==", "dev": true, "license": "MIT", "engines": { @@ -137,22 +151,22 @@ } }, "node_modules/@babel/core": { - "version": "7.26.10", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.10.tgz", - "integrity": "sha512-vMqyb7XCDMPvJFFOaT9kxtiRh42GwlZEg1/uIgtZshS5a/8OaduUfCi7kynKgc3Tw/6Uo2D+db9qBttghhmxwQ==", + "version": "7.27.4", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.27.4.tgz", + "integrity": "sha512-bXYxrXFubeYdvB0NhD/NBB3Qi6aZeV20GOWVI47t2dkecCEoneR4NPVcb7abpXDEvejgrUfFtG6vG/zxAKmg+g==", "dev": true, "license": "MIT", "dependencies": { "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.26.2", - "@babel/generator": "^7.26.10", - "@babel/helper-compilation-targets": "^7.26.5", - "@babel/helper-module-transforms": "^7.26.0", - "@babel/helpers": "^7.26.10", - "@babel/parser": "^7.26.10", - "@babel/template": "^7.26.9", - "@babel/traverse": "^7.26.10", - "@babel/types": "^7.26.10", + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.27.3", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.27.3", + "@babel/helpers": "^7.27.4", + "@babel/parser": "^7.27.4", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.27.4", + "@babel/types": "^7.27.3", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", @@ -178,14 +192,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.0.tgz", - "integrity": "sha512-VybsKvpiN1gU1sdMZIp7FcqphVVKEwcuj02x73uvcHE0PTihx1nlBcowYWhDwjpoAXRv43+gDzyggGnn1XZhVw==", + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.3.tgz", + "integrity": "sha512-xnlJYj5zepml8NXtjkG0WquFUv8RskFqyFcVgTBp5k+NaA/8uw/K+OSVf8AMGw5e9HKP2ETd5xpK5MLZQD6b4Q==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.27.0", - "@babel/types": "^7.27.0", + "@babel/parser": "^7.27.3", + "@babel/types": "^7.27.3", "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25", "jsesc": "^3.0.2" @@ -195,27 +209,27 @@ } }, "node_modules/@babel/helper-annotate-as-pure": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.25.9.tgz", - "integrity": "sha512-gv7320KBUFJz1RnylIg5WWYPRXKZ884AGkYpgpWW02TH66Dl+HaC1t1CKd0z3R4b6hdYEcmrNZHUmfCP+1u3/g==", + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", + "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.25.9" + "@babel/types": "^7.27.3" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.0.tgz", - "integrity": "sha512-LVk7fbXml0H2xH34dFzKQ7TDZ2G4/rVTOrq9V+icbbadjbVxxeFeDsNHv2SrZeWoA+6ZiTyWYWtScEIW07EAcA==", + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.26.8", - "@babel/helper-validator-option": "^7.25.9", + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -235,18 +249,18 @@ } }, "node_modules/@babel/helper-create-class-features-plugin": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.27.0.tgz", - "integrity": "sha512-vSGCvMecvFCd/BdpGlhpXYNhhC4ccxyvQWpbGL4CWbvfEoLFWUZuSuf7s9Aw70flgQF+6vptvgK2IfOnKlRmBg==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.27.1.tgz", + "integrity": "sha512-QwGAmuvM17btKU5VqXfb+Giw4JcN0hjuufz3DYnpeVDvZLAObloM77bhMXiqry3Iio+Ai4phVRDwl6WU10+r5A==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.25.9", - "@babel/helper-member-expression-to-functions": "^7.25.9", - "@babel/helper-optimise-call-expression": "^7.25.9", - "@babel/helper-replace-supers": "^7.26.5", - "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9", - "@babel/traverse": "^7.27.0", + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-member-expression-to-functions": "^7.27.1", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/helper-replace-supers": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/traverse": "^7.27.1", "semver": "^6.3.1" }, "engines": { @@ -312,43 +326,43 @@ } }, "node_modules/@babel/helper-member-expression-to-functions": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.25.9.tgz", - "integrity": "sha512-wbfdZ9w5vk0C0oyHqAJbc62+vet5prjj01jjJ8sKn3j9h3MQQlflEdXYvuqRWjHnM12coDEqiC1IRCi0U/EKwQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.27.1.tgz", + "integrity": "sha512-E5chM8eWjTp/aNoVpcbfM7mLxu9XGLWYise2eBKGQomAk/Mb4XoxyqXTZbuTohbsl8EKqdlMhnDI2CCLfcs9wA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.25.9", - "@babel/types": "^7.25.9" + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-imports": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.25.9.tgz", - "integrity": "sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.25.9", - "@babel/types": "^7.25.9" + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.26.0.tgz", - "integrity": "sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw==", + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.27.3.tgz", + "integrity": "sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.25.9", - "@babel/helper-validator-identifier": "^7.25.9", - "@babel/traverse": "^7.25.9" + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.27.3" }, "engines": { "node": ">=6.9.0" @@ -358,22 +372,22 @@ } }, "node_modules/@babel/helper-optimise-call-expression": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.25.9.tgz", - "integrity": "sha512-FIpuNaz5ow8VyrYcnXQTDRGvV6tTjkNtCK/RYNDXGSLlUD6cBuQTSw43CShGxjvfBTfcUA/r6UhUCbtYqkhcuQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz", + "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.25.9" + "@babel/types": "^7.27.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.26.5", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.26.5.tgz", - "integrity": "sha512-RS+jZcRdZdRFzMyr+wcsaqOmld1/EqTghfaBGQQd/WnRdzdlvSZ//kF7U8VQTxf1ynZ4cjUcYgjVGx13ewNPMg==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", "dev": true, "license": "MIT", "engines": { @@ -399,15 +413,15 @@ } }, "node_modules/@babel/helper-replace-supers": { - "version": "7.26.5", - "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.26.5.tgz", - "integrity": "sha512-bJ6iIVdYX1YooY2X7w1q6VITt+LnUILtNk7zT78ykuwStx8BauCzxvFqFaHjOpW1bVnSUM1PN1f0p5P21wHxvg==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.27.1.tgz", + "integrity": "sha512-7EHz6qDZc8RYS5ElPoShMheWvEgERonFCs7IAonWLLUTXW59DP14bCZt89/GKyreYn8g3S83m21FelHKbeDCKA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-member-expression-to-functions": "^7.25.9", - "@babel/helper-optimise-call-expression": "^7.25.9", - "@babel/traverse": "^7.26.5" + "@babel/helper-member-expression-to-functions": "^7.27.1", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/traverse": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -417,23 +431,23 @@ } }, "node_modules/@babel/helper-skip-transparent-expression-wrappers": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.25.9.tgz", - "integrity": "sha512-K4Du3BFa3gvyhzgPcntrkDgZzQaq6uozzcpGbOO1OEJaI+EJdqWIMTLgFgQf6lrfiDFo5FU+BxKepI9RmZqahA==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz", + "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.25.9", - "@babel/types": "^7.25.9" + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-string-parser": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", - "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", "dev": true, "license": "MIT", "engines": { @@ -441,9 +455,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", - "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", "dev": true, "license": "MIT", "engines": { @@ -451,9 +465,9 @@ } }, "node_modules/@babel/helper-validator-option": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.25.9.tgz", - "integrity": "sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", "dev": true, "license": "MIT", "engines": { @@ -476,27 +490,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.0.tgz", - "integrity": "sha512-U5eyP/CTFPuNE3qk+WZMxFkp/4zUzdceQlfzf7DdGdhp+Fezd7HD+i8Y24ZuTMKX3wQBld449jijbGq6OdGNQg==", + "version": "7.27.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.6.tgz", + "integrity": "sha512-muE8Tt8M22638HU31A3CgfSUciwz1fhATfoVai05aPXGor//CdWDCbnlY1yvBPo07njuVOCNGCSp/GTt12lIug==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.27.0", - "@babel/types": "^7.27.0" + "@babel/template": "^7.27.2", + "@babel/types": "^7.27.6" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.0.tgz", - "integrity": "sha512-iaepho73/2Pz7w2eMS0Q5f83+0RKI7i4xmiYeBmDzfRVbQtTOG7Ts0S4HzJVsTMGI9keU8rNfuZr8DKfSt7Yyg==", + "version": "7.27.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.5.tgz", + "integrity": "sha512-OsQd175SxWkGlzbny8J3K8TnnDD0N3lrIUtB92xwyRpzaenGZhxDvxN/JgU00U3CDZNj9tPuDJ5H0WS4Nt3vKg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.27.0" + "@babel/types": "^7.27.3" }, "bin": { "parser": "bin/babel-parser.js" @@ -1048,14 +1062,14 @@ } }, "node_modules/@babel/plugin-transform-modules-commonjs": { - "version": "7.26.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.26.3.tgz", - "integrity": "sha512-MgR55l4q9KddUDITEzEFYn5ZsGDXMSsU9E+kh7fjRXTIC3RHqfCo8RPRbyReYJh44HQ/yomFkqbOFohXvDCiIQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.27.1.tgz", + "integrity": "sha512-OJguuwlTYlN0gBZFRPqwOGNWssZjfIUdS7HMYtN8c1KmwpwHFBwTeFZrg9XZa+DFTitWOW5iTAG7tyCUPsCCyw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-transforms": "^7.26.0", - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-module-transforms": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1301,13 +1315,13 @@ } }, "node_modules/@babel/plugin-transform-react-jsx-self": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.25.9.tgz", - "integrity": "sha512-y8quW6p0WHkEhmErnfe58r7x0A70uKphQm8Sp8cV7tjNQwK56sNVK0M73LK3WuYmsuyrftut4xAkjjgU0twaMg==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1317,13 +1331,13 @@ } }, "node_modules/@babel/plugin-transform-react-jsx-source": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.25.9.tgz", - "integrity": "sha512-+iqjT8xmXhhYv4/uiYd8FNQsraMFZIfxVSqxxVSZP0WbbSAWvBXAul0m/zu+7Vv4O/3WtApy9pmaTMiumEZgfg==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1640,9 +1654,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.1.tgz", - "integrity": "sha512-1x3D2xEk2fRo3PAhwQwu5UubzgiVWSXTBfWpVd2Mx2AzRqJuDJCsgaDVZ7HB5iGzDW1Hl1sWN2mFyKjmR9uAog==", + "version": "7.27.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz", + "integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==", "dev": true, "license": "MIT", "engines": { @@ -1650,32 +1664,32 @@ } }, "node_modules/@babel/template": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.0.tgz", - "integrity": "sha512-2ncevenBqXI6qRMukPlXwHKHchC7RyMuu4xv5JBXRfOGVcTy1mXCD12qrp7Jsoxll1EV3+9sE4GugBVRjT2jFA==", + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.26.2", - "@babel/parser": "^7.27.0", - "@babel/types": "^7.27.0" + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.27.0.tgz", - "integrity": "sha512-19lYZFzYVQkkHkl4Cy4WrAVcqBkgvV2YM2TU3xG6DIwO7O3ecbDPfW3yM3bjAGcqcQHi+CCtjMR3dIEHxsd6bA==", + "version": "7.27.4", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.27.4.tgz", + "integrity": "sha512-oNcu2QbHqts9BtOWJosOVJapWjBDSxGCpFvikNR5TGDYDQf3JwpIoMzIKrvfoti93cLfPJEG4tH9SPVeyCGgdA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.26.2", - "@babel/generator": "^7.27.0", - "@babel/parser": "^7.27.0", - "@babel/template": "^7.27.0", - "@babel/types": "^7.27.0", + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.27.3", + "@babel/parser": "^7.27.4", + "@babel/template": "^7.27.2", + "@babel/types": "^7.27.3", "debug": "^4.3.1", "globals": "^11.1.0" }, @@ -1694,19 +1708,43 @@ } }, "node_modules/@babel/types": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.0.tgz", - "integrity": "sha512-H45s8fVLYjbhFH62dIJ3WtmJ6RSPt/3DRO0ZcT2SUiYiQyz3BLVb9ADEnLl91m74aQPS3AzzeajZHYOalWe3bg==", + "version": "7.27.6", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.6.tgz", + "integrity": "sha512-ETyHEk2VHHvl9b9jZP5IHPavHYk57EhanlRRuae9XCpb/j5bDCbPPMOBfCWhnl/7EDJz0jEMCi/RhccCE8r1+Q==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.25.9", - "@babel/helper-validator-identifier": "^7.25.9" + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" }, "engines": { "node": ">=6.9.0" } }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, "node_modules/@dnd-kit/accessibility": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", @@ -2297,9 +2335,9 @@ } }, "node_modules/@electron/node-gyp/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2494,9 +2532,9 @@ } }, "node_modules/@electron/universal/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2995,9 +3033,9 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.6.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.6.1.tgz", - "integrity": "sha512-KTsJMmobmbrFLe3LDh0PC2FXpcSYJt/MLjlkh/9LEnmKYLSYmT/0EW9JWANjeoemiuZrmogti0tW5Ch+qNUYDw==", + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", + "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", "dev": true, "license": "MIT", "dependencies": { @@ -3037,9 +3075,9 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.20.0.tgz", - "integrity": "sha512-fxlS1kkIjx8+vy2SjuCB94q3htSNrufYTXubwiBFeaQHbH6Ipi43gFJq2zCMt6PHhImH3Xmr0NksKDvchWlpQQ==", + "version": "0.20.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.20.1.tgz", + "integrity": "sha512-OL0RJzC/CBzli0DrrR31qzj6d6i6Mm3HByuhflhl4LOBiWxN+3i6/t/ZQQNii4tjksXi8r2CRW1wMpWA2ULUEw==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -3112,9 +3150,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.27.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.27.0.tgz", - "integrity": "sha512-G5JD9Tu5HJEu4z2Uo4aHY2sLV64B7CDMXxFzqzjl3NKd6RVzSXNoE80jk7Y0lJkTTkjiIhBAqmlYwjuBY3tvpA==", + "version": "9.29.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.29.0.tgz", + "integrity": "sha512-3PIF4cBw/y+1u2EazflInpV+lYsSG0aByVIQzAgb1m1MhHFSbqTyNqtBKHgWf/9Ykud+DhILS9EGkmekVhbKoQ==", "dev": true, "license": "MIT", "engines": { @@ -3493,9 +3531,9 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.9", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.9.tgz", - "integrity": "sha512-e9MeMtVWo186sgvFFJOPGy7/d2j2mZhLJIdVW0C/xDluuOvymEATqz6zKsP0ZmXGzQtqlyjz5sC1sYQUoJG98w==", + "version": "1.0.0-beta.11", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.11.tgz", + "integrity": "sha512-L/gAA/hyCSuzTF1ftlzUSI/IKr2POHsv1Dd78GfqkR83KMNuswWD61JxGV2L7nRwBBBSDr6R1gCkdTmoN7W4ag==", "dev": true, "license": "MIT" }, @@ -3913,15 +3951,15 @@ } }, "node_modules/@swc/core": { - "version": "1.11.24", - "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.11.24.tgz", - "integrity": "sha512-MaQEIpfcEMzx3VWWopbofKJvaraqmL6HbLlw2bFZ7qYqYw3rkhM0cQVEgyzbHtTWwCwPMFZSC2DUbhlZgrMfLg==", + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.12.1.tgz", + "integrity": "sha512-aKXdDTqxTVFl/bKQZ3EQUjEMBEoF6JBv29moMZq0kbVO43na6u/u+3Vcbhbrh+A2N0X5OL4RaveuWfAjEgOmeA==", "dev": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { "@swc/counter": "^0.1.3", - "@swc/types": "^0.1.21" + "@swc/types": "^0.1.23" }, "engines": { "node": ">=10" @@ -3931,16 +3969,16 @@ "url": "https://opencollective.com/swc" }, "optionalDependencies": { - "@swc/core-darwin-arm64": "1.11.24", - "@swc/core-darwin-x64": "1.11.24", - "@swc/core-linux-arm-gnueabihf": "1.11.24", - "@swc/core-linux-arm64-gnu": "1.11.24", - "@swc/core-linux-arm64-musl": "1.11.24", - "@swc/core-linux-x64-gnu": "1.11.24", - "@swc/core-linux-x64-musl": "1.11.24", - "@swc/core-win32-arm64-msvc": "1.11.24", - "@swc/core-win32-ia32-msvc": "1.11.24", - "@swc/core-win32-x64-msvc": "1.11.24" + "@swc/core-darwin-arm64": "1.12.1", + "@swc/core-darwin-x64": "1.12.1", + "@swc/core-linux-arm-gnueabihf": "1.12.1", + "@swc/core-linux-arm64-gnu": "1.12.1", + "@swc/core-linux-arm64-musl": "1.12.1", + "@swc/core-linux-x64-gnu": "1.12.1", + "@swc/core-linux-x64-musl": "1.12.1", + "@swc/core-win32-arm64-msvc": "1.12.1", + "@swc/core-win32-ia32-msvc": "1.12.1", + "@swc/core-win32-x64-msvc": "1.12.1" }, "peerDependencies": { "@swc/helpers": ">=0.5.17" @@ -3952,9 +3990,9 @@ } }, "node_modules/@swc/core-darwin-arm64": { - "version": "1.11.24", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.11.24.tgz", - "integrity": "sha512-dhtVj0PC1APOF4fl5qT2neGjRLgHAAYfiVP8poJelhzhB/318bO+QCFWAiimcDoyMgpCXOhTp757gnoJJrheWA==", + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.12.1.tgz", + "integrity": "sha512-nUjWVcJ3YS2N40ZbKwYO2RJ4+o2tWYRzNOcIQp05FqW0+aoUCVMdAUUzQinPDynfgwVshDAXCKemY8X7nN5MaA==", "cpu": [ "arm64" ], @@ -3969,9 +4007,9 @@ } }, "node_modules/@swc/core-darwin-x64": { - "version": "1.11.24", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.11.24.tgz", - "integrity": "sha512-H/3cPs8uxcj2Fe3SoLlofN5JG6Ny5bl8DuZ6Yc2wr7gQFBmyBkbZEz+sPVgsID7IXuz7vTP95kMm1VL74SO5AQ==", + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.12.1.tgz", + "integrity": "sha512-OGm4a4d3OeJn+tRt8H/eiHgTFrJbS6r8mi/Ob65tAEXZGHN900T2kR7c5ALr0V2hBOQ8BfhexwPoQlGQP/B95w==", "cpu": [ "x64" ], @@ -3986,9 +4024,9 @@ } }, "node_modules/@swc/core-linux-arm-gnueabihf": { - "version": "1.11.24", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.11.24.tgz", - "integrity": "sha512-PHJgWEpCsLo/NGj+A2lXZ2mgGjsr96ULNW3+T3Bj2KTc8XtMUkE8tmY2Da20ItZOvPNC/69KroU7edyo1Flfbw==", + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.12.1.tgz", + "integrity": "sha512-76YeeQKyK0EtNkQiNBZ0nbVGooPf9IucY0WqVXVpaU4wuG7ZyLEE2ZAIgXafIuzODGQoLfetue7I8boMxh1/MA==", "cpu": [ "arm" ], @@ -4003,9 +4041,9 @@ } }, "node_modules/@swc/core-linux-arm64-gnu": { - "version": "1.11.24", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.11.24.tgz", - "integrity": "sha512-C2FJb08+n5SD4CYWCTZx1uR88BN41ZieoHvI8A55hfVf2woT8+6ZiBzt74qW2g+ntZ535Jts5VwXAKdu41HpBg==", + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.12.1.tgz", + "integrity": "sha512-BxJDIJPq1+aCh9UsaSAN6wo3tuln8UhNXruOrzTI8/ElIig/3sAueDM6Eq7GvZSGGSA7ljhNATMJ0elD7lFatQ==", "cpu": [ "arm64" ], @@ -4020,9 +4058,9 @@ } }, "node_modules/@swc/core-linux-arm64-musl": { - "version": "1.11.24", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.11.24.tgz", - "integrity": "sha512-ypXLIdszRo0re7PNNaXN0+2lD454G8l9LPK/rbfRXnhLWDBPURxzKlLlU/YGd2zP98wPcVooMmegRSNOKfvErw==", + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.12.1.tgz", + "integrity": "sha512-NhLdbffSXvY0/FwUSAl4hKBlpe5GHQGXK8DxTo3HHjLsD9sCPYieo3vG0NQoUYAy4ZUY1WeGjyxeq4qZddJzEQ==", "cpu": [ "arm64" ], @@ -4037,9 +4075,9 @@ } }, "node_modules/@swc/core-linux-x64-gnu": { - "version": "1.11.24", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.11.24.tgz", - "integrity": "sha512-IM7d+STVZD48zxcgo69L0yYptfhaaE9cMZ+9OoMxirNafhKKXwoZuufol1+alEFKc+Wbwp+aUPe/DeWC/Lh3dg==", + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.12.1.tgz", + "integrity": "sha512-CrYnV8SZIgArQ9LKH0xEF95PKXzX9WkRSc5j55arOSBeDCeDUQk1Bg/iKdnDiuj5HC1hZpvzwMzSBJjv+Z70jA==", "cpu": [ "x64" ], @@ -4054,9 +4092,9 @@ } }, "node_modules/@swc/core-linux-x64-musl": { - "version": "1.11.24", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.11.24.tgz", - "integrity": "sha512-DZByJaMVzSfjQKKQn3cqSeqwy6lpMaQDQQ4HPlch9FWtDx/dLcpdIhxssqZXcR2rhaQVIaRQsCqwV6orSDGAGw==", + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.12.1.tgz", + "integrity": "sha512-BQMl3d0HaGB0/h2xcKlGtjk/cGRn2tnbsaChAKcjFdCepblKBCz1pgO/mL7w5iXq3s57wMDUn++71/a5RAkZOA==", "cpu": [ "x64" ], @@ -4071,9 +4109,9 @@ } }, "node_modules/@swc/core-win32-arm64-msvc": { - "version": "1.11.24", - "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.11.24.tgz", - "integrity": "sha512-Q64Ytn23y9aVDKN5iryFi8mRgyHw3/kyjTjT4qFCa8AEb5sGUuSj//AUZ6c0J7hQKMHlg9do5Etvoe61V98/JQ==", + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.12.1.tgz", + "integrity": "sha512-b7NeGnpqTfmIGtUqXBl0KqoSmOnH64nRZoT5l4BAGdvwY7nxitWR94CqZuwyLPty/bLywmyDA9uO12Kvgb3+gg==", "cpu": [ "arm64" ], @@ -4088,9 +4126,9 @@ } }, "node_modules/@swc/core-win32-ia32-msvc": { - "version": "1.11.24", - "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.11.24.tgz", - "integrity": "sha512-9pKLIisE/Hh2vJhGIPvSoTK4uBSPxNVyXHmOrtdDot4E1FUUI74Vi8tFdlwNbaj8/vusVnb8xPXsxF1uB0VgiQ==", + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.12.1.tgz", + "integrity": "sha512-iU/29X2D7cHBp1to62cUg/5Xk8K+lyOJiKIGGW5rdzTW/c2zz3d/ehgpzVP/rqC4NVr88MXspqHU4il5gmDajw==", "cpu": [ "ia32" ], @@ -4105,9 +4143,9 @@ } }, "node_modules/@swc/core-win32-x64-msvc": { - "version": "1.11.24", - "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.11.24.tgz", - "integrity": "sha512-sybnXtOsdB+XvzVFlBVGgRHLqp3yRpHK7CrmpuDKszhj/QhmsaZzY/GHSeALlMtLup13M0gqbcQvsTNlAHTg3w==", + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.12.1.tgz", + "integrity": "sha512-+Zh+JKDwiFqV5N9yAd2DhYVGPORGh9cfenu1ptr9yge+eHAf7vZJcC3rnj6QMR1QJh0Y5VC9+YBjRFjZVA7XDw==", "cpu": [ "x64" ], @@ -4129,9 +4167,9 @@ "license": "Apache-2.0" }, "node_modules/@swc/types": { - "version": "0.1.21", - "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.21.tgz", - "integrity": "sha512-2YEtj5HJVbKivud9N4bpPBAyZhj4S2Ipe5LkUG94alTpr7in/GU/EARgPAd3BwU+YOmFVJC2+kjqhGRi3r0ZpQ==", + "version": "0.1.23", + "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.23.tgz", + "integrity": "sha512-u1iIVZV9Q0jxY+yM2vw/hZGDNudsN85bBpTqzAQ9rzkxW9D+e3aEM4Han+ow518gSewkXgjmEK0BD79ZcNVgPw==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -4151,9 +4189,9 @@ } }, "node_modules/@tailwindcss/node": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.7.tgz", - "integrity": "sha512-9rsOpdY9idRI2NH6CL4wORFY0+Q6fnx9XP9Ju+iq/0wJwGD5IByIgFmwVbyy4ymuyprj8Qh4ErxMKTUL4uNh3g==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.10.tgz", + "integrity": "sha512-2ACf1znY5fpRBwRhMgj9ZXvb2XZW8qs+oTfotJ2C5xR0/WNL7UHZ7zXl6s+rUqedL1mNi+0O+WQr5awGowS3PQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4163,7 +4201,7 @@ "lightningcss": "1.30.1", "magic-string": "^0.30.17", "source-map-js": "^1.2.1", - "tailwindcss": "4.1.7" + "tailwindcss": "4.1.10" } }, "node_modules/@tailwindcss/node/node_modules/magic-string": { @@ -4177,9 +4215,9 @@ } }, "node_modules/@tailwindcss/oxide": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.7.tgz", - "integrity": "sha512-5SF95Ctm9DFiUyjUPnDGkoKItPX/k+xifcQhcqX5RA85m50jw1pT/KzjdvlqxRja45Y52nR4MR9fD1JYd7f8NQ==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.10.tgz", + "integrity": "sha512-v0C43s7Pjw+B9w21htrQwuFObSkio2aV/qPx/mhrRldbqxbWJK6KizM+q7BF1/1CmuLqZqX3CeYF7s7P9fbA8Q==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -4191,24 +4229,24 @@ "node": ">= 10" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.1.7", - "@tailwindcss/oxide-darwin-arm64": "4.1.7", - "@tailwindcss/oxide-darwin-x64": "4.1.7", - "@tailwindcss/oxide-freebsd-x64": "4.1.7", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.7", - "@tailwindcss/oxide-linux-arm64-gnu": "4.1.7", - "@tailwindcss/oxide-linux-arm64-musl": "4.1.7", - "@tailwindcss/oxide-linux-x64-gnu": "4.1.7", - "@tailwindcss/oxide-linux-x64-musl": "4.1.7", - "@tailwindcss/oxide-wasm32-wasi": "4.1.7", - "@tailwindcss/oxide-win32-arm64-msvc": "4.1.7", - "@tailwindcss/oxide-win32-x64-msvc": "4.1.7" + "@tailwindcss/oxide-android-arm64": "4.1.10", + "@tailwindcss/oxide-darwin-arm64": "4.1.10", + "@tailwindcss/oxide-darwin-x64": "4.1.10", + "@tailwindcss/oxide-freebsd-x64": "4.1.10", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.10", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.10", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.10", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.10", + "@tailwindcss/oxide-linux-x64-musl": "4.1.10", + "@tailwindcss/oxide-wasm32-wasi": "4.1.10", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.10", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.10" } }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.7.tgz", - "integrity": "sha512-IWA410JZ8fF7kACus6BrUwY2Z1t1hm0+ZWNEzykKmMNM09wQooOcN/VXr0p/WJdtHZ90PvJf2AIBS/Ceqx1emg==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.10.tgz", + "integrity": "sha512-VGLazCoRQ7rtsCzThaI1UyDu/XRYVyH4/EWiaSX6tFglE+xZB5cvtC5Omt0OQ+FfiIVP98su16jDVHDEIuH4iQ==", "cpu": [ "arm64" ], @@ -4223,9 +4261,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.7.tgz", - "integrity": "sha512-81jUw9To7fimGGkuJ2W5h3/oGonTOZKZ8C2ghm/TTxbwvfSiFSDPd6/A/KE2N7Jp4mv3Ps9OFqg2fEKgZFfsvg==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.10.tgz", + "integrity": "sha512-ZIFqvR1irX2yNjWJzKCqTCcHZbgkSkSkZKbRM3BPzhDL/18idA8uWCoopYA2CSDdSGFlDAxYdU2yBHwAwx8euQ==", "cpu": [ "arm64" ], @@ -4240,9 +4278,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.7.tgz", - "integrity": "sha512-q77rWjEyGHV4PdDBtrzO0tgBBPlQWKY7wZK0cUok/HaGgbNKecegNxCGikuPJn5wFAlIywC3v+WMBt0PEBtwGw==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.10.tgz", + "integrity": "sha512-eCA4zbIhWUFDXoamNztmS0MjXHSEJYlvATzWnRiTqJkcUteSjO94PoRHJy1Xbwp9bptjeIxxBHh+zBWFhttbrQ==", "cpu": [ "x64" ], @@ -4257,9 +4295,9 @@ } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.7.tgz", - "integrity": "sha512-RfmdbbK6G6ptgF4qqbzoxmH+PKfP4KSVs7SRlTwcbRgBwezJkAO3Qta/7gDy10Q2DcUVkKxFLXUQO6J3CRvBGw==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.10.tgz", + "integrity": "sha512-8/392Xu12R0cc93DpiJvNpJ4wYVSiciUlkiOHOSOQNH3adq9Gi/dtySK7dVQjXIOzlpSHjeCL89RUUI8/GTI6g==", "cpu": [ "x64" ], @@ -4274,9 +4312,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.7.tgz", - "integrity": "sha512-OZqsGvpwOa13lVd1z6JVwQXadEobmesxQ4AxhrwRiPuE04quvZHWn/LnihMg7/XkN+dTioXp/VMu/p6A5eZP3g==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.10.tgz", + "integrity": "sha512-t9rhmLT6EqeuPT+MXhWhlRYIMSfh5LZ6kBrC4FS6/+M1yXwfCtp24UumgCWOAJVyjQwG+lYva6wWZxrfvB+NhQ==", "cpu": [ "arm" ], @@ -4291,9 +4329,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.7.tgz", - "integrity": "sha512-voMvBTnJSfKecJxGkoeAyW/2XRToLZ227LxswLAwKY7YslG/Xkw9/tJNH+3IVh5bdYzYE7DfiaPbRkSHFxY1xA==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.10.tgz", + "integrity": "sha512-3oWrlNlxLRxXejQ8zImzrVLuZ/9Z2SeKoLhtCu0hpo38hTO2iL86eFOu4sVR8cZc6n3z7eRXXqtHJECa6mFOvA==", "cpu": [ "arm64" ], @@ -4308,9 +4346,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.7.tgz", - "integrity": "sha512-PjGuNNmJeKHnP58M7XyjJyla8LPo+RmwHQpBI+W/OxqrwojyuCQ+GUtygu7jUqTEexejZHr/z3nBc/gTiXBj4A==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.10.tgz", + "integrity": "sha512-saScU0cmWvg/Ez4gUmQWr9pvY9Kssxt+Xenfx1LG7LmqjcrvBnw4r9VjkFcqmbBb7GCBwYNcZi9X3/oMda9sqQ==", "cpu": [ "arm64" ], @@ -4325,9 +4363,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.7.tgz", - "integrity": "sha512-HMs+Va+ZR3gC3mLZE00gXxtBo3JoSQxtu9lobbZd+DmfkIxR54NO7Z+UQNPsa0P/ITn1TevtFxXTpsRU7qEvWg==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.10.tgz", + "integrity": "sha512-/G3ao/ybV9YEEgAXeEg28dyH6gs1QG8tvdN9c2MNZdUXYBaIY/Gx0N6RlJzfLy/7Nkdok4kaxKPHKJUlAaoTdA==", "cpu": [ "x64" ], @@ -4342,9 +4380,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.7.tgz", - "integrity": "sha512-MHZ6jyNlutdHH8rd+YTdr3QbXrHXqwIhHw9e7yXEBcQdluGwhpQY2Eku8UZK6ReLaWtQ4gijIv5QoM5eE+qlsA==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.10.tgz", + "integrity": "sha512-LNr7X8fTiKGRtQGOerSayc2pWJp/9ptRYAa4G+U+cjw9kJZvkopav1AQc5HHD+U364f71tZv6XamaHKgrIoVzA==", "cpu": [ "x64" ], @@ -4359,9 +4397,9 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.7.tgz", - "integrity": "sha512-ANaSKt74ZRzE2TvJmUcbFQ8zS201cIPxUDm5qez5rLEwWkie2SkGtA4P+GPTj+u8N6JbPrC8MtY8RmJA35Oo+A==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.10.tgz", + "integrity": "sha512-d6ekQpopFQJAcIK2i7ZzWOYGZ+A6NzzvQ3ozBvWFdeyqfOZdYHU66g5yr+/HC4ipP1ZgWsqa80+ISNILk+ae/Q==", "bundleDependencies": [ "@napi-rs/wasm-runtime", "@emnapi/core", @@ -4380,7 +4418,7 @@ "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@emnapi/wasi-threads": "^1.0.2", - "@napi-rs/wasm-runtime": "^0.2.9", + "@napi-rs/wasm-runtime": "^0.2.10", "@tybys/wasm-util": "^0.9.0", "tslib": "^2.8.0" }, @@ -4389,9 +4427,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.7.tgz", - "integrity": "sha512-HUiSiXQ9gLJBAPCMVRk2RT1ZrBjto7WvqsPBwUrNK2BcdSxMnk19h4pjZjI7zgPhDxlAbJSumTC4ljeA9y0tEw==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.10.tgz", + "integrity": "sha512-i1Iwg9gRbwNVOCYmnigWCCgow8nDWSFmeTUU5nbNx3rqbe4p0kRbEqLwLJbYZKmSSp23g4N6rCDmm7OuPBXhDA==", "cpu": [ "arm64" ], @@ -4406,9 +4444,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.7.tgz", - "integrity": "sha512-rYHGmvoHiLJ8hWucSfSOEmdCBIGZIq7SpkPRSqLsH2Ab2YUNgKeAPT1Fi2cx3+hnYOrAb0jp9cRyode3bBW4mQ==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.10.tgz", + "integrity": "sha512-sGiJTjcBSfGq2DVRtaSljq5ZgZS2SDHSIfhOylkBvHVjwOsodBhnb3HdmiKkVuUGKD0I7G63abMOVaskj1KpOA==", "cpu": [ "x64" ], @@ -4500,17 +4538,17 @@ } }, "node_modules/@tailwindcss/postcss": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.7.tgz", - "integrity": "sha512-88g3qmNZn7jDgrrcp3ZXEQfp9CVox7xjP1HN2TFKI03CltPVd/c61ydn5qJJL8FYunn0OqBaW5HNUga0kmPVvw==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.10.tgz", + "integrity": "sha512-B+7r7ABZbkXJwpvt2VMnS6ujcDoR2OOcFaqrLIo1xbcdxje4Vf+VgJdBzNNbrAjBj/rLZ66/tlQ1knIGNLKOBQ==", "dev": true, "license": "MIT", "dependencies": { "@alloc/quick-lru": "^5.2.0", - "@tailwindcss/node": "4.1.7", - "@tailwindcss/oxide": "4.1.7", + "@tailwindcss/node": "4.1.10", + "@tailwindcss/oxide": "4.1.10", "postcss": "^8.4.41", - "tailwindcss": "4.1.7" + "tailwindcss": "4.1.10" } }, "node_modules/@tailwindcss/typography": { @@ -4530,15 +4568,15 @@ } }, "node_modules/@tailwindcss/vite": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.7.tgz", - "integrity": "sha512-tYa2fO3zDe41I7WqijyVbRd8oWT0aEID1Eokz5hMT6wShLIHj3yvwj9XbfuloHP9glZ6H+aG2AN/+ZrxJ1Y5RQ==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.10.tgz", + "integrity": "sha512-QWnD5HDY2IADv+vYR82lOhqOlS1jSCUUAmfem52cXAhRTKxpDh3ARX8TTXJTCCO7Rv7cD2Nlekabv02bwP3a2A==", "dev": true, "license": "MIT", "dependencies": { - "@tailwindcss/node": "4.1.7", - "@tailwindcss/oxide": "4.1.7", - "tailwindcss": "4.1.7" + "@tailwindcss/node": "4.1.10", + "@tailwindcss/oxide": "4.1.10", + "tailwindcss": "4.1.10" }, "peerDependencies": { "vite": "^5.2.0 || ^6" @@ -4554,6 +4592,34 @@ "node": ">= 10" } }, + "node_modules/@tsconfig/node10": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", + "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/appdmg": { "version": "0.5.5", "resolved": "https://registry.npmjs.org/@types/appdmg/-/appdmg-0.5.5.tgz", @@ -4610,6 +4676,17 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/body-parser": { + "version": "1.19.5", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", + "integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, "node_modules/@types/cacheable-request": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", @@ -4622,6 +4699,44 @@ "@types/responselike": "^1.0.0" } }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/electron": { + "version": "1.6.12", + "resolved": "https://registry.npmjs.org/@types/electron/-/electron-1.6.12.tgz", + "integrity": "sha512-NIJokDkGv9h+MStCL1IuiL1FOHYVkszoWeNxJtSI5dcEKRGbX83JcVYNAgk019qOQgJkHtz9WdP0CDXvrArrGg==", + "deprecated": "This is a stub types definition. electron provides its own type definitions, so you do not need this installed.", + "dev": true, + "license": "MIT", + "dependencies": { + "electron": "*" + } + }, + "node_modules/@types/electron-squirrel-startup": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@types/electron-squirrel-startup/-/electron-squirrel-startup-1.0.2.tgz", + "integrity": "sha512-AzxnvBzNh8K/0SmxMmZtpJf1/IWoGXLP+pQDuUaVkPyotI8ryvAtBSqgxR/qOSvxWHYWrxkeNsJ+Ca5xOuUxJQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", @@ -4629,6 +4744,31 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/express": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.3.tgz", + "integrity": "sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "*" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.0.6.tgz", + "integrity": "sha512-3xhRnjJPkULekpSzgtoNYYcTWgEZkp4myc+Saevii5JPnHNvHMRlBSHDbs7Bh1iPPoVTERHEZXyhyLbMEsExsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, "node_modules/@types/fs-extra": { "version": "9.0.13", "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz", @@ -4640,12 +4780,26 @@ "@types/node": "*" } }, + "node_modules/@types/he": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@types/he/-/he-1.2.3.tgz", + "integrity": "sha512-q67/qwlxblDzEDvzHhVkwc1gzVWxaNxeyHUBF4xElrvjL11O+Ytze+1fGpBHlr/H9myiBUaUXNnNPmBHxxfAcA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/http-cache-semantics": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz", "integrity": "sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==", "license": "MIT" }, + "node_modules/@types/http-errors": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", + "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==", + "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", @@ -4662,19 +4816,47 @@ "@types/node": "*" } }, + "node_modules/@types/lodash": { + "version": "4.17.17", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.17.tgz", + "integrity": "sha512-RRVJ+J3J+WmyOTqnz3PiBLA501eKwXl2noseKOrNo/6+XEHjTAxO4xHvxQB6QuNm+s4WRbn6rSiap8+EA+ykFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { - "version": "22.15.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.3.tgz", - "integrity": "sha512-lX7HFZeHf4QG/J7tBZqrCAXwz9J5RD56Y6MpP0eJkka8p+K0RY/yBTW7CYFJ4VGCclxqOLKmiGP5juQc6MKgcw==", + "version": "24.0.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.3.tgz", + "integrity": "sha512-R4I/kzCYAdRLzfiCabn9hxWfbuHS573x+r0dJMkkzThEa7pbrcDWK+9zu3e7aBOouf+rQAciqPFMnxwr0aWgKg==", "license": "MIT", "dependencies": { - "undici-types": "~6.21.0" + "undici-types": "~7.8.0" } }, + "node_modules/@types/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/react": { - "version": "19.1.5", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.5.tgz", - "integrity": "sha512-piErsCVVbpMMT2r7wbawdZsq4xMvIAhQuac2gedQHysu1TZYEigE6pnFfgZT+/jQnrRuF5r+SHzuehFjfRjr4g==", + "version": "19.1.8", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.8.tgz", + "integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==", "dev": true, "license": "MIT", "dependencies": { @@ -4682,9 +4864,9 @@ } }, "node_modules/@types/react-dom": { - "version": "19.1.5", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.5.tgz", - "integrity": "sha512-CMCjrWucUBZvohgZxkjd6S9h0nZxXjzus6yDfUb+xLxYM7VvjKNH1tQrE9GWLql1XoOP4/Ds3bwFqShHUYraGg==", + "version": "19.1.6", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.6.tgz", + "integrity": "sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==", "dev": true, "license": "MIT", "peerDependencies": { @@ -4707,6 +4889,29 @@ "@types/node": "*" } }, + "node_modules/@types/send": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz", + "integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.7", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.7.tgz", + "integrity": "sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "*" + } + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -4724,6 +4929,263 @@ "@types/node": "*" } }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.34.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.34.1.tgz", + "integrity": "sha512-STXcN6ebF6li4PxwNeFnqF8/2BNDvBupf2OPx2yWNzr6mKNGF7q49VM00Pz5FaomJyqvbXpY6PhO+T9w139YEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.34.1", + "@typescript-eslint/type-utils": "8.34.1", + "@typescript-eslint/utils": "8.34.1", + "@typescript-eslint/visitor-keys": "8.34.1", + "graphemer": "^1.4.0", + "ignore": "^7.0.0", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.34.1", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.34.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.34.1.tgz", + "integrity": "sha512-4O3idHxhyzjClSMJ0a29AcoK0+YwnEqzI6oz3vlRf3xw0zbzt15MzXwItOlnr5nIth6zlY2RENLsOPvhyrKAQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.34.1", + "@typescript-eslint/types": "8.34.1", + "@typescript-eslint/typescript-estree": "8.34.1", + "@typescript-eslint/visitor-keys": "8.34.1", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.34.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.34.1.tgz", + "integrity": "sha512-nuHlOmFZfuRwLJKDGQOVc0xnQrAmuq1Mj/ISou5044y1ajGNp2BNliIqp7F2LPQ5sForz8lempMFCovfeS1XoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.34.1", + "@typescript-eslint/types": "^8.34.1", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.34.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.34.1.tgz", + "integrity": "sha512-beu6o6QY4hJAgL1E8RaXNC071G4Kso2MGmJskCFQhRhg8VOH/FDbC8soP8NHN7e/Hdphwp8G8cE6OBzC8o41ZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.34.1", + "@typescript-eslint/visitor-keys": "8.34.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.34.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.34.1.tgz", + "integrity": "sha512-K4Sjdo4/xF9NEeA2khOb7Y5nY6NSXBnod87uniVYW9kHP+hNlDV8trUSFeynA2uxWam4gIWgWoygPrv9VMWrYg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.34.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.34.1.tgz", + "integrity": "sha512-Tv7tCCr6e5m8hP4+xFugcrwTOucB8lshffJ6zf1mF1TbU67R+ntCc6DzLNKM+s/uzDyv8gLq7tufaAhIBYeV8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/typescript-estree": "8.34.1", + "@typescript-eslint/utils": "8.34.1", + "debug": "^4.3.4", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.34.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.34.1.tgz", + "integrity": "sha512-rjLVbmE7HR18kDsjNIZQHxmv9RZwlgzavryL5Lnj2ujIRTeXlKtILHgRNmQ3j4daw7zd+mQgy+uyt6Zo6I0IGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.34.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.34.1.tgz", + "integrity": "sha512-rjCNqqYPuMUF5ODD+hWBNmOitjBWghkGKJg6hiCHzUvXRy6rK22Jd3rwbP2Xi+R7oYVvIKhokHVhH41BxPV5mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.34.1", + "@typescript-eslint/tsconfig-utils": "8.34.1", + "@typescript-eslint/types": "8.34.1", + "@typescript-eslint/visitor-keys": "8.34.1", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.34.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.34.1.tgz", + "integrity": "sha512-mqOwUdZ3KjtGk7xJJnLbHxTuWVn3GO2WZZuM+Slhkun4+qthLdXx32C8xIXbO1kfCECb3jIs3eoxK3eryk7aoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.34.1", + "@typescript-eslint/types": "8.34.1", + "@typescript-eslint/typescript-estree": "8.34.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.34.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.34.1.tgz", + "integrity": "sha512-xoh5rJ+tgsRKoXnkBPFRLZ7rjKM0AfVbC68UZ/ECXoDbfggb9RbEySN359acY1vS3qZ0jVTVWzbtfapwm5ztxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.34.1", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, "node_modules/@vidstack/react": { "version": "1.12.13", "resolved": "https://registry.npmjs.org/@vidstack/react/-/react-1.12.13.tgz", @@ -4743,16 +5205,16 @@ } }, "node_modules/@vitejs/plugin-react": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.5.0.tgz", - "integrity": "sha512-JuLWaEqypaJmOJPLWwO335Ig6jSgC1FTONCWAxnqcQthLTK/Yc9aH6hr9z/87xciejbQcnP3GnA1FWUSWeXaeg==", + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.5.2.tgz", + "integrity": "sha512-QNVT3/Lxx99nMQWJWF7K4N6apUEuT0KlZA3mx/mVaoGj3smm/8rc8ezz15J1pcbcjDK0V15rpHetVfya08r76Q==", "dev": true, "license": "MIT", "dependencies": { - "@babel/core": "^7.26.10", - "@babel/plugin-transform-react-jsx-self": "^7.25.9", - "@babel/plugin-transform-react-jsx-source": "^7.25.9", - "@rolldown/pluginutils": "1.0.0-beta.9", + "@babel/core": "^7.27.4", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.11", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, @@ -4760,21 +5222,21 @@ "node": "^14.18.0 || >=16.0.0" }, "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0 || ^6.0.0" + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" } }, "node_modules/@vitejs/plugin-react-swc": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-3.10.0.tgz", - "integrity": "sha512-ZmkdHw3wo/o/Rk05YsXZs/DJAfY2CdQ5DUAjoWji+PEr+hYADdGMCGgEAILbiKj+CjspBTuTACBcWDrmC8AUfw==", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-3.10.2.tgz", + "integrity": "sha512-xD3Rdvrt5LgANug7WekBn1KhcvLn1H3jNBfJRL3reeOIua/WnZOEV5qi5qIBq5T8R0jUDmRtxuvk4bPhzGHDWw==", "dev": true, "license": "MIT", "dependencies": { - "@rolldown/pluginutils": "1.0.0-beta.9", - "@swc/core": "^1.11.22" + "@rolldown/pluginutils": "1.0.0-beta.11", + "@swc/core": "^1.11.31" }, "peerDependencies": { - "vite": "^4 || ^5 || ^6" + "vite": "^4 || ^5 || ^6 || ^7.0.0-beta.0" } }, "node_modules/@wavesurfer/react": { @@ -4819,9 +5281,9 @@ } }, "node_modules/acorn": { - "version": "8.14.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", - "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", "bin": { @@ -4841,6 +5303,19 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/acorn-walk": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", + "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/agent-base": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", @@ -4943,6 +5418,20 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/appdmg": { "version": "0.6.6", "resolved": "https://registry.npmjs.org/appdmg/-/appdmg-0.6.6.tgz", @@ -4972,6 +5461,13 @@ "node": ">=8.5" } }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "license": "MIT" + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -5315,6 +5811,19 @@ ], "license": "MIT" }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/bl": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", @@ -5373,9 +5882,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, "license": "MIT", "dependencies": { @@ -5470,22 +5979,6 @@ "dev": true, "license": "MIT" }, - "node_modules/bundle-name": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", - "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "run-applescript": "^7.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -5526,9 +6019,9 @@ } }, "node_modules/cacache/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5713,8 +6206,46 @@ "engines": { "node": ">=10" }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" } }, "node_modules/chownr": { @@ -6095,6 +6626,13 @@ "node": ">= 0.10" } }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, "node_modules/cross-dirname": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz", @@ -6200,9 +6738,9 @@ "license": "MIT" }, "node_modules/daisyui": { - "version": "5.0.37", - "resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.0.37.tgz", - "integrity": "sha512-PLc+MhWAqTwolygEGPDi+ac+OsFqIt9nZylTIiyVlEx8loYL7Pt7hNWb8cp5pQQ9dhjYnda1ERiuM6OsJmvPGw==", + "version": "5.0.43", + "resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.0.43.tgz", + "integrity": "sha512-2pshHJ73vetSpsbAyaOncGnNYL0mwvgseS1EWy1I9Qpw8D11OuBoDNIWrPIME4UFcq2xuff3A9x+eXbuFR9fUQ==", "dev": true, "license": "MIT", "funding": { @@ -6324,36 +6862,6 @@ "node": ">=0.10.0" } }, - "node_modules/default-browser": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.2.1.tgz", - "integrity": "sha512-WY/3TUME0x3KPYdRRxEJJvXRHV4PyPoUsxtZa78lwItwRQRHhd2U9xOscaT/YTf8uCXIAjeJOFBVEh/7FtD8Xg==", - "dev": true, - "license": "MIT", - "dependencies": { - "bundle-name": "^4.1.0", - "default-browser-id": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/default-browser-id": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.0.tgz", - "integrity": "sha512-A6p/pu/6fyBcA1TRz/GqWYPViplrftcW2gZC9q79ngNCKAeR/X3gcEdXQHl4KNXV+3wgIJ1CPkJQ3IHM6lcsyA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/defaults": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", @@ -6395,16 +6903,13 @@ } }, "node_modules/define-lazy-prop": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", - "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", + "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", "dev": true, "license": "MIT", "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=8" } }, "node_modules/define-properties": { @@ -6474,6 +6979,16 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/dir-compare": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-4.2.0.tgz", @@ -6554,9 +7069,9 @@ } }, "node_modules/electron": { - "version": "36.3.1", - "resolved": "https://registry.npmjs.org/electron/-/electron-36.3.1.tgz", - "integrity": "sha512-LeOZ+tVahmctHaAssLCGRRUa2SAO09GXua3pKdG+WzkbSDMh+3iOPONNVPTqGp8HlWnzGj4r6mhsIbM2RgH+eQ==", + "version": "36.4.0", + "resolved": "https://registry.npmjs.org/electron/-/electron-36.4.0.tgz", + "integrity": "sha512-LLOOZEuW5oqvnjC7HBQhIqjIIJAZCIFjQxltQGLfEC7XFsBoZgQ3u3iFj+Kzw68Xj97u1n57Jdt7P98qLvUibQ==", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -7080,9 +7595,9 @@ } }, "node_modules/electron-log": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/electron-log/-/electron-log-5.4.0.tgz", - "integrity": "sha512-AXI5OVppskrWxEAmCxuv8ovX+s2Br39CpCAgkGMNHQtjYT3IiVbSQTncEjFVGPgoH35ZygRm/mvUMBDWwhRxgg==", + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/electron-log/-/electron-log-5.4.1.tgz", + "integrity": "sha512-QvisA18Z++8E3Th0zmhUelys9dEv7aIeXJlbFw3UrxCc8H9qSRW0j8/ooTef/EtHui8tVmbKSL+EIQzP9GoRLg==", "license": "MIT", "engines": { "node": ">= 14" @@ -7200,6 +7715,15 @@ "global-agent": "^3.0.0" } }, + "node_modules/electron/node_modules/@types/node": { + "version": "22.15.32", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.32.tgz", + "integrity": "sha512-3jigKqgSjsH6gYZv2nEsqdXfZqIFGAV36XYYjf9KGZ3PSG+IhLecqPnI310RvjutyMwifE2hhhNEklOUrvx/wA==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, "node_modules/electron/node_modules/fs-extra": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", @@ -7232,6 +7756,12 @@ "semver": "bin/semver.js" } }, + "node_modules/electron/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, "node_modules/electron/node_modules/universalify": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", @@ -7573,19 +8103,19 @@ } }, "node_modules/eslint": { - "version": "9.27.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.27.0.tgz", - "integrity": "sha512-ixRawFQuMB9DZ7fjU3iGGganFDp3+45bPOdaRurcFHSXO1e/sYwUX/FtQZpLZJR6SjMoJH8hR2pPEAfDyCoU2Q==", + "version": "9.29.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.29.0.tgz", + "integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.20.0", + "@eslint/config-array": "^0.20.1", "@eslint/config-helpers": "^0.2.1", "@eslint/core": "^0.14.0", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.27.0", + "@eslint/js": "9.29.0", "@eslint/plugin-kit": "^0.3.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", @@ -7597,9 +8127,9 @@ "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.3.0", - "eslint-visitor-keys": "^4.2.0", - "espree": "^10.3.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", @@ -7726,9 +8256,9 @@ } }, "node_modules/eslint-scope": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.3.0.tgz", - "integrity": "sha512-pUNxi75F8MJ/GdeKtVLSbYg4ZI34J6C0C7sbL4YOp2exGwen7ZsuBqKzUhXd0qMQ362yET3z+uPwKeg/0C2XCQ==", + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -7743,9 +8273,9 @@ } }, "node_modules/eslint-visitor-keys": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", - "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -7756,15 +8286,15 @@ } }, "node_modules/espree": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz", - "integrity": "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "acorn": "^8.14.0", + "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.0" + "eslint-visitor-keys": "^4.2.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -8148,9 +8678,9 @@ } }, "node_modules/filelist/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, "license": "MIT", "dependencies": { @@ -8986,9 +9516,9 @@ } }, "node_modules/globals": { - "version": "16.1.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-16.1.0.tgz", - "integrity": "sha512-aibexHNbb/jiUSObBgpHLj+sIuUmJnYcgXBlrfsiDZ9rt4aF2TFRbyLgZ2iFQuVZ1K5Mx3FVkbKRSgKrbK3K2g==", + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.2.0.tgz", + "integrity": "sha512-O+7l9tPdHCU320IigZZPj5zmRCFG9xHmx9cU8FqU2Rp+JN714seHV+2S9+JslCpY4gJwU2vOGox0wzgae/MCEg==", "dev": true, "license": "MIT", "engines": { @@ -9058,6 +9588,13 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, "node_modules/has-bigints": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", @@ -9278,9 +9815,9 @@ } }, "node_modules/i18next": { - "version": "25.2.0", - "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.2.0.tgz", - "integrity": "sha512-ERhJICsxkw1vE7G0lhCUYv4ZxdBEs03qblt1myJs94rYRK9loJF3xDj8mgQz3LmCyp0yYrNjbN/1/GWZTZDGCA==", + "version": "25.2.1", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.2.1.tgz", + "integrity": "sha512-+UoXK5wh+VlE1Zy5p6MjcvctHXAhRwQKCxiJD8noKZzIXmnAX8gdHX5fLPA3MEVxEN4vbZkQFy8N0LyD9tUqPw==", "dev": true, "funding": [ { @@ -9310,9 +9847,9 @@ } }, "node_modules/i18next-browser-languagedetector": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.1.0.tgz", - "integrity": "sha512-mHZxNx1Lq09xt5kCauZ/4bsXOEA2pfpwSoU11/QTJB+pD94iONFwp+ohqi///PwiFvjFOxe1akYCdHyFo1ng5Q==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.0.tgz", + "integrity": "sha512-P+3zEKLnOF0qmiesW383vsLdtQVyKtCNA9cjSoKCppTKPQVfKd2W8hbVo5ZhNJKDqeM7BOcvNoKJOjpHh4Js9g==", "dev": true, "license": "MIT", "dependencies": { @@ -9379,6 +9916,13 @@ "node": ">= 4" } }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true, + "license": "ISC" + }, "node_modules/image-size": { "version": "0.7.5", "resolved": "https://registry.npmjs.org/image-size/-/image-size-0.7.5.tgz", @@ -9583,6 +10127,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/is-boolean-object": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", @@ -9665,16 +10222,16 @@ } }, "node_modules/is-docker": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", - "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", "dev": true, "license": "MIT", "bin": { "is-docker": "cli.js" }, "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + "node": ">=8" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -9751,25 +10308,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-inside-container": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", - "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-docker": "^3.0.0" - }, - "bin": { - "is-inside-container": "cli.js" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-interactive": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", @@ -10057,19 +10595,16 @@ } }, "node_modules/is-wsl": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz", - "integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", "dev": true, "license": "MIT", "dependencies": { - "is-inside-container": "^1.0.0" + "is-docker": "^2.0.0" }, "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=8" } }, "node_modules/isarray": { @@ -10783,6 +11318,13 @@ "sourcemap-codec": "^1.4.8" } }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, "node_modules/make-fetch-happen": { "version": "10.2.1", "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-10.2.1.tgz", @@ -11272,6 +11814,58 @@ "dev": true, "license": "MIT" }, + "node_modules/nodemon": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz", + "integrity": "sha512-WDjw3pJ0/0jMFmyNDp3gvY2YizjLmmOUQo6DEBY+JgdvW/yQ9mEeSw6H5ythl5Ny2ytb7f9C2nIbjSxMNzbJXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^3.1.2", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/nodemon/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/nodemon/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/nopt": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/nopt/-/nopt-6.0.0.tgz", @@ -11311,6 +11905,16 @@ "semver": "bin/semver" } }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/normalize-range": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", @@ -11500,19 +12104,18 @@ } }, "node_modules/open": { - "version": "10.1.2", - "resolved": "https://registry.npmjs.org/open/-/open-10.1.2.tgz", - "integrity": "sha512-cxN6aIDPz6rm8hbebcP7vrQNhvRcveZoJU72Y7vskh4oIm+BZwBECnx5nTmrlres1Qapvx27Qo1Auukpf8PKXw==", + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", + "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", "dev": true, "license": "MIT", "dependencies": { - "default-browser": "^5.2.1", - "define-lazy-prop": "^3.0.0", - "is-inside-container": "^1.0.0", - "is-wsl": "^3.1.0" + "define-lazy-prop": "^2.0.0", + "is-docker": "^2.1.1", + "is-wsl": "^2.2.0" }, "engines": { - "node": ">=18" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -12063,9 +12666,9 @@ } }, "node_modules/postcss": { - "version": "8.5.3", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", - "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==", + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", "dev": true, "funding": [ { @@ -12083,7 +12686,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.8", + "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -12165,9 +12768,9 @@ } }, "node_modules/prettier-plugin-tailwindcss": { - "version": "0.6.11", - "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.6.11.tgz", - "integrity": "sha512-YxaYSIvZPAqhrrEpRtonnrXdghZg1irNg4qrjboCXrpybLWVs55cW2N3juhspVJiO0JBvYJT8SYsJpc8OQSnsA==", + "version": "0.6.12", + "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.6.12.tgz", + "integrity": "sha512-OuTQKoqNwV7RnxTPwXWzOFXy6Jc4z8oeRZYGuMpRyG3WbuR3jjXdQFK8qFBMBx8UHWdHrddARz2fgUenild6aw==", "dev": true, "license": "MIT", "engines": { @@ -12355,6 +12958,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true, + "license": "MIT" + }, "node_modules/pump": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz", @@ -12511,13 +13121,13 @@ } }, "node_modules/react-i18next": { - "version": "15.5.2", - "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.5.2.tgz", - "integrity": "sha512-ePODyXgmZQAOYTbZXQn5rRsSBu3Gszo69jxW6aKmlSgxKAI1fOhDwSu6bT4EKHciWPKQ7v7lPrjeiadR6Gi+1A==", + "version": "15.5.3", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.5.3.tgz", + "integrity": "sha512-ypYmOKOnjqPEJZO4m1BI0kS8kWqkBNsKYyhVUfij0gvjy9xJNoG/VcGkxq5dRlVwzmrmY1BQMAmpbbUBLwC4Kw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/runtime": "^7.25.0", + "@babel/runtime": "^7.27.6", "html-parse-stringify": "^3.0.1" }, "peerDependencies": { @@ -12575,9 +13185,9 @@ } }, "node_modules/react-router": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.6.0.tgz", - "integrity": "sha512-GGufuHIVCJDbnIAXP3P9Sxzq3UUsddG3rrI3ut1q6m0FI6vxVBF3JoPQ38+W/blslLH4a5Yutp8drkEpXoddGQ==", + "version": "7.6.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.6.2.tgz", + "integrity": "sha512-U7Nv3y+bMimgWjhlT5CRdzHPu2/KVmqPwKUCChW8en5P3znxUqwlYFlbmyj8Rgp1SF6zs5X4+77kBVknkg6a0w==", "dev": true, "license": "MIT", "dependencies": { @@ -12598,13 +13208,13 @@ } }, "node_modules/react-router-dom": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.6.0.tgz", - "integrity": "sha512-DYgm6RDEuKdopSyGOWZGtDfSm7Aofb8CCzgkliTjtu/eDuB0gcsv6qdFhhi8HdtmA+KHkt5MfZ5K2PdzjugYsA==", + "version": "7.6.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.6.2.tgz", + "integrity": "sha512-Q8zb6VlTbdYKK5JJBLQEN06oTUa/RAbG/oQS1auK1I0TbJOXktqm+QENEVJU6QvWynlXPRBXI3fiOQcSEA78rA==", "dev": true, "license": "MIT", "dependencies": { - "react-router": "7.6.0" + "react-router": "7.6.2" }, "engines": { "node": ">=20.0.0" @@ -12744,6 +13354,19 @@ "node": ">= 6" } }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, "node_modules/rechoir": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz", @@ -13106,13 +13729,13 @@ } }, "node_modules/rollup-plugin-visualizer": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/rollup-plugin-visualizer/-/rollup-plugin-visualizer-6.0.0.tgz", - "integrity": "sha512-9aXBJh1uzI6XNmAeATox2z5MWrEPzL6hQgEMOYxTltWOy5x2ycQCec0Y9fC19sBgf3FvIF36aG9DrvUcdnXPew==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/rollup-plugin-visualizer/-/rollup-plugin-visualizer-6.0.3.tgz", + "integrity": "sha512-ZU41GwrkDcCpVoffviuM9Clwjy5fcUxlz0oMoTXTYsK+tcIFzbdacnrr2n8TXcHxbGKKXtOdjxM2HUS4HjkwIw==", "dev": true, "license": "MIT", "dependencies": { - "open": "^10.1.2", + "open": "^8.0.0", "picomatch": "^4.0.2", "source-map": "^0.7.4", "yargs": "^17.5.1" @@ -13124,7 +13747,7 @@ "node": ">=18" }, "peerDependencies": { - "rolldown": "1.x", + "rolldown": "1.x || ^1.0.0-beta", "rollup": "2.x || 3.x || 4.x" }, "peerDependenciesMeta": { @@ -13165,19 +13788,6 @@ "node": ">= 18" } }, - "node_modules/run-applescript": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.0.0.tgz", - "integrity": "sha512-9by4Ij99JUr/MCFBUkDKLWK3G9HVXmabKz9U5MlIAIuvuzkiOicRYs8XJLxX+xahD+mLiiCYDqF9dKAgtzKP1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -13570,6 +14180,19 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "license": "ISC" }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/slice-ansi": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz", @@ -13649,9 +14272,9 @@ } }, "node_modules/sonner": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.3.tgz", - "integrity": "sha512-njQ4Hht92m0sMqqHVDL32V2Oun9W1+PHO9NDv9FHfJjT3JT22IG4Jpo3FPQy+mouRKCXFWO+r67v6MrHX2zeIA==", + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.5.tgz", + "integrity": "sha512-YwbHQO6cSso3HBXlbCkgrgzDNIhws14r4MO87Ofy+cV2X7ES4pOoAK3+veSmVTvqNx1BWUxlhPmZzP00Crk2aQ==", "dev": true, "license": "MIT", "peerDependencies": { @@ -14065,16 +14688,16 @@ } }, "node_modules/tailwindcss": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.7.tgz", - "integrity": "sha512-kr1o/ErIdNhTz8uzAYL7TpaUuzKIE6QPQ4qmSdxnoX/lo+5wmUHQA6h3L5yIqEImSRnAAURDirLu/BgiXGPAhg==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.10.tgz", + "integrity": "sha512-P3nr6WkvKV/ONsTzj6Gb57sWPMX29EPNPopo7+FcpkQaNsrNpZ1pv8QmrYI2RqEKD7mlGqLnGovlcYnBK0IqUA==", "dev": true, "license": "MIT" }, "node_modules/tapable": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", - "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.2.tgz", + "integrity": "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg==", "dev": true, "license": "MIT", "engines": { @@ -14341,14 +14964,14 @@ } }, "node_modules/terser": { - "version": "5.39.0", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.39.0.tgz", - "integrity": "sha512-LBAhFyLho16harJoWMg/nZsQYgTrg5jXOn2nCYjRUcZZEdE3qa2zb8QEDRUGVZBW4rlazf2fxkg8tztybTaqWw==", + "version": "5.42.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.42.0.tgz", + "integrity": "sha512-UYCvU9YQW2f/Vwl+P0GfhxJxbUGLwd+5QrrGgLajzWAtC/23AX0vcise32kkP7Eu0Wu9VlzzHAXkLObgjQfFlQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { "@jridgewell/source-map": "^0.3.3", - "acorn": "^8.8.2", + "acorn": "^8.14.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, @@ -14483,6 +15106,16 @@ "node": ">=0.6" } }, + "node_modules/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", @@ -14523,6 +15156,63 @@ "node": ">=0.8.0" } }, + "node_modules/ts-api-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -14647,6 +15337,43 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.34.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.34.1.tgz", + "integrity": "sha512-XjS+b6Vg9oT1BaIUfkW3M3LvqZE++rbzAMEHuccCfO/YkP43ha6w3jTEMilQxMF92nVOYCcdjv1ZUhAa1D/0ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.34.1", + "@typescript-eslint/parser": "8.34.1", + "@typescript-eslint/utils": "8.34.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, "node_modules/unbox-primitive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", @@ -14666,10 +15393,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true, + "license": "MIT" + }, "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz", + "integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==", "license": "MIT" }, "node_modules/unicode-canonical-property-names-ecmascript": { @@ -14871,6 +15605,13 @@ "dev": true, "license": "MIT" }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true, + "license": "MIT" + }, "node_modules/validate-npm-package-license": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", @@ -15801,6 +16542,16 @@ "fd-slicer": "~1.1.0" } }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -15815,26 +16566,25 @@ } }, "node_modules/zod": { - "version": "3.24.3", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.3.tgz", - "integrity": "sha512-HhY1oqzWCQWuUqvBFnsyrtZRhyPeR7SUGv+C4+MsisMuVfSPx8HpwWqH8tRahSlt6M3PiFAcoeFhZAqIXTxoSg==", - "dev": true, + "version": "3.25.67", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.67.tgz", + "integrity": "sha512-idA2YXwpCdqUSKRCACDE6ItZD9TZzy3OZMtpfLoh6oPR47lipysRrJfjzMqFxQ3uJuUPyUeWe1r9vLH33xO/Qw==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" } }, "node_modules/zod-validation-error": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-3.4.0.tgz", - "integrity": "sha512-ZOPR9SVY6Pb2qqO5XHt+MkkTRxGXb4EVtnjc9JpXUOtUB1T9Ru7mZOT361AN3MsetVe7R0a1KZshJDZdgp9miQ==", + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-3.4.1.tgz", + "integrity": "sha512-1KP64yqDPQ3rupxNv7oXhf7KdhHHgaqbKuspVoiN93TT0xrBjql+Svjkdjq/Qh/7GSMmgQs3AfvBT0heE35thw==", "dev": true, "license": "MIT", "engines": { "node": ">=18.0.0" }, "peerDependencies": { - "zod": "^3.18.0" + "zod": "^3.24.4" } } } diff --git a/package.json b/package.json index 1c2d5993f..7dcff503d 100644 --- a/package.json +++ b/package.json @@ -5,20 +5,21 @@ "private": true, "version": "3.5.0", "type": "module", - "main": "main.js", + "main": "dist-electron/main.js", "license": "Apache-2.0", "scripts": { + "start": "npm run clean && tsc -b && npm run minify-electronjs && concurrently \"cross-env NODE_ENV=development vite\" \"wait-on http://localhost:5173 && tsc --project src/electron/tsconfig.electron.json && cross-env NODE_ENV=development electron .\"", "dev": "vite", - "build": "vite build && node scripts/minify-dist-jsons.js", + "watch": "nodemon --exec \"npm run build\"", + "build": "npm run clean && vite build && node scripts/minify-dist-jsons.js", "lint": "eslint .", "preview": "vite preview", - "start": "concurrently \"cross-env NODE_ENV=development vite\" \"wait-on http://localhost:5173 && cross-env NODE_ENV=development electron .\"", - "package": "vite build --mode electron && node scripts/minify-dist-jsons.js && electron-forge package", - "make": "vite build --mode electron && node scripts/minify-dist-jsons.js && electron-forge make", - "vitebuildcli": "vite build --mode electron && node scripts/minify-dist-jsons.js", - "makecli": "electron-forge make", - "appx": "vite build --mode electron && node scripts/minify-dist-jsons.js && electron-forge package && cd ./out && electron-windows-store --input-directory ./iSpeakerReact-win32-x64 --output-directory ./ispeakerreact-appx --package-version ${npm_package_version}.0 --package-name ispeakerreact-x64 --assets ../public/images/icons/windows11 -m ./appxmanifest.xml", - "appxarm": "vite build --mode electron && node scripts/minify-dist-jsons.js && electron-forge package --arch=arm64 && cd ./out && electron-windows-store --input-directory ./iSpeakerReact-win32-arm64 --output-directory ./ispeakerreact-appx --package-version ${npm_package_version}.0 --package-name ispeakerreact-arm64 --assets ../public/images/icons/windows11 -m ./appxmanifestARM.xml" + "vitebuildcli": "npm run clean && tsc -b && vite build --mode electron && npm run minify-electronjs && node scripts/minify-dist-jsons.js && tsc --project src/electron/tsconfig.electron.json", + "appx": "npm run clean && tsc -b && vite build --mode electron && npm run minify-electronjs && node scripts/minify-dist-jsons.js && tsc --project src/electron/tsconfig.electron.json && electron-forge make && cd ./out && electron-windows-store --input-directory ./iSpeakerReact-win32-x64 --output-directory ./ispeakerreact-appx --package-version ${npm_package_version}.0 --package-name ispeakerreact-x64-${npm_package_version} --assets ../public/images/icons/windows11 -m ./appxmanifest.xml", + "appxarm": "npm run clean && tsc -b && vite build --mode electron && npm run minify-electronjs && node scripts/minify-dist-jsons.js && tsc --project src/electron/tsconfig.electron.json && electron-forge make --arch=arm64 && cd ./out && electron-windows-store --input-directory ./iSpeakerReact-win32-arm64 --output-directory ./ispeakerreact-appx --package-version ${npm_package_version}.0 --package-name ispeakerreact-arm64-${npm_package_version} --assets ../public/images/icons/windows11 -m ./appxmanifestARM.xml", + "make": "npm run clean && tsc -b && vite build --mode electron && npm run minify-electronjs && node scripts/minify-dist-jsons.js && tsc --project src/electron/tsconfig.electron.json && electron-forge make", + "clean": "node scripts/clean-dist.js", + "minify-electronjs": "node scripts/minify-dist-electron.js" }, "dependencies": { "@ffmpeg/ffmpeg": "^0.12.15", @@ -26,14 +27,15 @@ "@fix-webm-duration/fix": "^1.0.1", "cors": "^2.8.5", "electron-conf": "^1.3.0", - "electron-log": "^5.4.0", + "electron-log": "^5.4.1", "electron-squirrel-startup": "^1.0.1", "express": "^5.1.0", "express-rate-limit": "^7.5.0", "fkill": "^9.0.0", "js7z-tools": "^2.4.1", "masonry-layout": "^4.2.2", - "mime": "^4.0.7" + "mime": "^4.0.7", + "zod": "^3.25.67" }, "devDependencies": { "@dnd-kit/core": "^6.3.1", @@ -46,46 +48,59 @@ "@electron-forge/maker-zip": "^7.8.1", "@electron-forge/plugin-auto-unpack-natives": "^7.8.1", "@electron-forge/plugin-fuses": "^7.8.1", + "@electron-forge/shared-types": "^7.8.1", "@electron/fuses": "^1.8.0", - "@eslint/js": "^9.27.0", - "@tailwindcss/postcss": "^4.1.7", + "@eslint/js": "^9.29.0", + "@tailwindcss/postcss": "^4.1.10", "@tailwindcss/typography": "^0.5.16", - "@tailwindcss/vite": "^4.1.7", - "@types/react": "^19.1.5", - "@types/react-dom": "^19.1.5", + "@tailwindcss/vite": "^4.1.10", + "@types/cors": "^2.8.19", + "@types/electron": "^1.6.12", + "@types/electron-squirrel-startup": "^1.0.2", + "@types/express": "^5.0.3", + "@types/he": "^1.2.3", + "@types/lodash": "^4.17.17", + "@types/node": "^24.0.3", + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", "@vidstack/react": "^1.12.13", - "@vitejs/plugin-react": "^4.5.0", - "@vitejs/plugin-react-swc": "^3.10.0", + "@vitejs/plugin-react": "^4.5.2", + "@vitejs/plugin-react-swc": "^3.10.2", "@wavesurfer/react": "^1.0.11", "autoprefixer": "^10.4.21", "concurrently": "^9.1.2", "cross-env": "^7.0.3", - "daisyui": "^5.0.37", + "daisyui": "^5.0.43", "dexie": "^4.0.11", - "electron": "^36.3.1", - "eslint": "^9.27.0", + "electron": "^36.4.0", + "eslint": "^9.29.0", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^6.0.0-rc.1", "eslint-plugin-react-refresh": "^0.4.20", - "globals": "^16.1.0", + "globals": "^16.2.0", "he": "^1.2.0", - "i18next": "^25.2.0", - "i18next-browser-languagedetector": "^8.1.0", + "i18next": "^25.2.1", + "i18next-browser-languagedetector": "^8.2.0", "i18next-http-backend": "^3.0.2", "lodash": "^4.17.21", - "postcss": "^8.5.3", + "nodemon": "^3.1.10", + "postcss": "^8.5.6", "prettier": "^3.5.3", - "prettier-plugin-tailwindcss": "^0.6.11", + "prettier-plugin-tailwindcss": "^0.6.12", "react": "^19.1.0", "react-dom": "^19.1.0", "react-flip-toolkit": "^7.2.4", - "react-i18next": "^15.5.2", + "react-i18next": "^15.5.3", "react-icons": "^5.5.0", "react-loading-skeleton": "^3.5.0", - "react-router-dom": "^7.6.0", - "rollup-plugin-visualizer": "^6.0.0", - "sonner": "^2.0.3", - "tailwindcss": "^4.1.7", + "react-router-dom": "^7.6.2", + "rollup-plugin-visualizer": "^6.0.3", + "sonner": "^2.0.5", + "tailwindcss": "^4.1.10", + "terser": "^5.42.0", + "ts-node": "^10.9.2", + "typescript": "~5.8.3", + "typescript-eslint": "^8.34.1", "vidstack": "^1.12.12", "vite": "^6.3.5", "vite-plugin-pwa": "^1.0.0", diff --git a/preload.cjs b/preload.cjs deleted file mode 100644 index 7f4659045..000000000 --- a/preload.cjs +++ /dev/null @@ -1,36 +0,0 @@ -const { contextBridge, ipcRenderer } = require("electron"); - -contextBridge.exposeInMainWorld("electron", { - openExternal: (url) => ipcRenderer.invoke("open-external-link", url), - saveRecording: (key, arrayBuffer) => ipcRenderer.invoke("save-recording", key, arrayBuffer), - checkRecordingExists: (key) => ipcRenderer.invoke("check-recording-exists", key), - playRecording: (key) => ipcRenderer.invoke("play-recording", key), - ipcRenderer: { - invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args), - send: (channel, ...args) => ipcRenderer.send(channel, ...args), - on: (channel, func) => ipcRenderer.on(channel, func), - removeAllListeners: (channel) => ipcRenderer.removeAllListeners(channel), - removeListener: (channel, func) => ipcRenderer.removeListener(channel, func), - }, - getDirName: () => __dirname, - isUwp: () => process.windowsStore, - send: (channel, data) => { - ipcRenderer.send(channel, data); - }, - log: (level, message) => { - // Send log message to the main process - ipcRenderer.send("renderer-log", { level, message }); - }, - getRecordingBlob: async (key) => { - // Use IPC to ask the main process for the blob - return await ipcRenderer.invoke("get-recording-blob", key); - }, - getFfmpegWasmPath: async () => { - return await ipcRenderer.invoke("get-ffmpeg-wasm-path"); - }, - getFileAsBlobUrl: async (filePath, mimeType) => { - const arrayBuffer = await ipcRenderer.invoke("read-file-buffer", filePath); - const blob = new Blob([arrayBuffer], { type: mimeType }); - return URL.createObjectURL(blob); - }, -}); diff --git a/public/json/ex_data.json b/public/json/ex_data.json deleted file mode 100644 index 5f3e27d3b..000000000 --- a/public/json/ex_data.json +++ /dev/null @@ -1,449 +0,0 @@ -{ - "sounds_n_spelling": [ - { - "exercise": " / iː / ", - "info": "Listen and choose the correct spelling", - "b_s": "yes", - "a_s": "yes", - "b": [ - { - "h4": "British English", - "left_p": [ - "Listen to the word and choose the spelling that represents the sound / iː /.", - "You can listen to the word as many times as you like.", - "When you want to stop, select \"Quit\" to see your score in this session." - ], - "act": [] - } - ], - "a": [ - { - "h4": "American English", - "left_p": [ - "Listen to the word and choose the spelling that represents the sound / iː /.", - "You can listen to the word as many times as you like.", - "When you want to stop, select \"Quit\" to see your score in this session." - ], - "act": [] - } - ] - }, - { - "exercise": " / əʊ / ", - "info": "Listen and choose the correct spelling", - "b_s": "yes", - "a_s": "yes", - "b": [ - { - "h4": "British English", - "left_p": [ - "Listen to the word and choose the spelling that represents the sound / əʊ /.", - "You can listen to the word as many times as you like.", - "When you want to stop, select \"Quit\" to see your score in this session." - ], - "act": [] - } - ], - "a": [ - { - "h4": "American English", - "left_p": [ - "Listen to the word and choose the spelling that represents the sound / əʊ /.", - "You can listen to the word as many times as you like.", - "When you want to stop, select \"Quit\" to see your score in this session." - ], - "act": [] - } - ] - }, - { - "exercise": " / oʊ / ", - "info": "Listen and choose the correct spelling", - "b_s": "yes", - "a_s": "yes", - "b": [ - { - "h4": "British English", - "left_p": [ - "Listen to the word and choose the spelling that represents the sound / oʊ /.", - "You can listen to the word as many times as you like.", - "When you want to stop, select \"Quit\" to see your score in this session." - ], - "act": [] - } - ], - "a": [ - { - "h4": "American English", - "left_p": [ - "Listen to the word and choose the spelling that represents the sound / oʊ /.", - "You can listen to the word as many times as you like.", - "When you want to stop, select \"Quit\" to see your score in this session." - ], - "act": [] - } - ] - }, - { - "exercise": " / ə / ", - "info": "Listen and choose the correct spelling", - "b_s": "yes", - "a_s": "yes", - "b": [ - { - "h4": "British English", - "left_p": [ - "Listen to the word and choose the spelling that represents the sound / ə /.", - "You can listen to the word as many times as you like.", - "When you want to stop, select \"Quit\" to see your score in this session." - ], - "act": [] - } - ], - "a": [ - { - "h4": "American English", - "left_p": [ - "Listen to the word and choose the spelling that represents the sound / ə /.", - "You can listen to the word as many times as you like.", - "When you want to stop, select \"Quit\" to see your score in this session." - ], - "act": [] - } - ] - }, - { - "exercise": " / ɔː / ", - "info": "Listen and choose the correct spelling", - "b_s": "yes", - "a_s": "yes", - "b": [ - { - "h4": "British English", - "left_p": [ - "Listen to the word and choose the spelling that represents the sound / ɔː /.", - "You can listen to the word as many times as you like.", - "When you want to stop, select \"Quit\" to see your score in this session." - ], - "act": [] - } - ], - "a": [ - { - "h4": "American English", - "left_p": [ - "Listen to the word and choose the spelling that represents the sound / ɔː /.", - "You can listen to the word as many times as you like.", - "When you want to stop, select \"Quit\" to see your score in this session." - ], - "act": [] - } - ] - }, - { - "exercise": " / ɔː(r) / ", - "info": "Listen and choose the correct spelling", - "b_s": "yes", - "a_s": "yes", - "b": [ - { - "h4": "British English", - "left_p": [ - "Listen to the word and choose the spelling that represents the sound / ɔː(r) /.", - "You can listen to the word as many times as you like.", - "When you want to stop, select \"Quit\" to see your score in this session." - ], - "act": [] - } - ], - "a": [ - { - "h4": "American English", - "left_p": [ - "Listen to the word and choose the spelling that represents the sound / ɔː(r) /.", - "You can listen to the word as many times as you like.", - "When you want to stop, select \"Quit\" to see your score in this session." - ], - "act": [] - } - ] - }, - { - "exercise": " / ɜː / ", - "info": "Listen and choose the correct spelling", - "b_s": "yes", - "a_s": "yes", - "b": [ - { - "h4": "British English", - "left_p": [ - "Listen to the word and choose the spelling that represents the sound / ɜː /.", - "You can listen to the word as many times as you like.", - "When you want to stop, select \"Quit\" to see your score in this session." - ], - "act": [] - } - ], - "a": [ - { - "h4": "American English", - "left_p": [ - "Listen to the word and choose the spelling that represents the sound / ɜː /.", - "You can listen to the word as many times as you like.", - "When you want to stop, select \"Quit\" to see your score in this session." - ], - "act": [] - } - ] - }, - { - "exercise": " / ɜːr / ", - "info": "Listen and choose the correct spelling", - "b_s": "yes", - "a_s": "yes", - "b": [ - { - "h4": "British English", - "left_p": [ - "Listen to the word and choose the spelling that represents the sound / ɜːr /.", - "You can listen to the word as many times as you like.", - "When you want to stop, select \"Quit\" to see your score in this session." - ], - "act": [] - } - ], - "a": [ - { - "h4": "American English", - "left_p": [ - "Listen to the word and choose the spelling that represents the sound / ɜːr /.", - "You can listen to the word as many times as you like.", - "When you want to stop, select \"Quit\" to see your score in this session." - ], - "act": [] - } - ] - }, - { - "exercise": " / ɪə(r)/ ", - "info": "Listen and choose the correct spelling", - "b_s": "yes", - "a_s": "yes", - "b": [ - { - "h4": "British English", - "left_p": [ - "Listen to the word and choose the spelling that represents the sound / ɪə(r) /.", - "You can listen to the word as many times as you like.", - "When you want to stop, select \"Quit\" to see your score in this session." - ], - "act": [] - } - ], - "a": [ - { - "h4": "American English", - "left_p": [ - "Listen to the word and choose the spelling that represents the sound / ɪə(r) /.", - "You can listen to the word as many times as you like.", - "When you want to stop, select \"Quit\" to see your score in this session." - ], - "act": [] - } - ] - }, - { - "exercise": " / ɪr / ", - "info": "Listen and choose the correct spelling", - "b_s": "yes", - "a_s": "yes", - "b": [ - { - "h4": "British English", - "left_p": [ - "Listen to the word and choose the spelling that represents the sound / ɪr /.", - "You can listen to the word as many times as you like.", - "When you want to stop, select \"Quit\" to see your score in this session." - ], - "act": [] - } - ], - "a": [ - { - "h4": "American English", - "left_p": [ - "Listen to the word and choose the spelling that represents the sound / ɪr /.", - "You can listen to the word as many times as you like.", - "When you want to stop, select \"Quit\" to see your score in this session." - ], - "act": [] - } - ] - }, - { - "exercise": " / uː / ", - "info": "Listen and choose the correct spelling", - "b_s": "yes", - "a_s": "yes", - "b": [ - { - "h4": "British English", - "left_p": [ - "Listen to the word and choose the spelling that represents the sound / uː /.", - "You can listen to the word as many times as you like.", - "When you want to stop, select \"Quit\" to see your score in this session." - ], - "act": [] - } - ], - "a": [ - { - "h4": "American English", - "left_p": [ - "Listen to the word and choose the spelling that represents the sound / uː /.", - "You can listen to the word as many times as you like.", - "When you want to stop, select \"Quit\" to see your score in this session." - ], - "act": [] - } - ] - }, - { - "exercise": " / eɪ / ", - "info": "Listen and choose the correct spelling", - "b_s": "yes", - "a_s": "yes", - "b": [ - { - "h4": "British English", - "left_p": [ - "Listen to the word and choose the spelling that represents the sound / eɪ /.", - "You can listen to the word as many times as you like.", - "When you want to stop, select \"Quit\" to see your score in this session." - ], - "act": [] - } - ], - "a": [ - { - "h4": "American English", - "left_p": [ - "Listen to the word and choose the spelling that represents the sound / eɪ /.", - "You can listen to the word as many times as you like.", - "When you want to stop, select \"Quit\" to see your score in this session." - ], - "act": [] - } - ] - }, - { - "exercise": " / aɪ / ", - "info": "Listen and choose the correct spelling", - "b_s": "yes", - "a_s": "yes", - "b": [ - { - "h4": "British English", - "left_p": [ - "Listen to the word and choose the spelling that represents the sound / aɪ /.", - "You can listen to the word as many times as you like.", - "When you want to stop, select \"Quit\" to see your score in this session." - ], - "act": [] - } - ], - "a": [ - { - "h4": "American English", - "left_p": [ - "Listen to the word and choose the spelling that represents the sound / aɪ /.", - "You can listen to the word as many times as you like.", - "When you want to stop, select \"Quit\" to see your score in this session." - ], - "act": [] - } - ] - } - ], - "snap": [ - { - "exercise": "Homophones", - "info": "Do the two words sound the same or different?", - "b_s": "yes", - "a_s": "yes", - "b": [ - { - "h4": "British English", - "left_p": [ - "Listen and write the word you hear. You can listen to the word as many times as you like.", - "You must spell the word correctly!", - "When you want to stop, select \"Quit\" to see your score in this session." - ], - "act": [ - { - "data": [ - { "value": "creative", "index": "0" }, - { "value": "assistance", "index": "1" } - ], - "feedbacks": [ - { "value": "Yes", "index": "0", "answer": "false" }, - { "value": "No", "index": "1", "answer": "true" }, - { "correctAns": "No" } - ] - }, - { - "data": [ - { "value": "creative", "index": "0" }, - { "value": "assistance", "index": "1" } - ], - "feedbacks": [ - { "value": "Yes", "index": "0", "answer": "false" }, - { "value": "No", "index": "1", "answer": "true" }, - { "correctAns": "No" } - ] - } - ] - } - ], - "a": [ - { - "h4": "American English", - "left_p": [ - "Listen and write the word you hear. You can listen to the word as many times as you like.", - "You must spell the word correctly!", - "When you want to stop, select \"Quit\" to see your score in this session." - ], - "act": [ - { - "data": [ - { "value": "creative", "index": "0" }, - { "value": "assistance", "index": "1" } - ], - "feedbacks": [ - { "value": "Yes", "index": "0", "answer": "false" }, - { "value": "No", "index": "1", "answer": "true" }, - { "correctAns": "No" } - ] - }, - { - "data": [ - { "value": "creative", "index": "0" }, - { "value": "assistance", "index": "1" } - ], - "feedbacks": [ - { "value": "Yes", "index": "0", "answer": "false" }, - { "value": "No", "index": "1", "answer": "true" }, - { "correctAns": "No" } - ] - } - ] - } - ] - }, - { - "exercise": "Reading phonetics", - "info": "Does the phonetic transcription match the word?" - }, - { "exercise": "Random", "info": "A mixture of homophones and phonetics questions" } - ] -} diff --git a/public/json/json_file_hashes.json b/public/json/json_file_hashes.json deleted file mode 100644 index c306aea5d..000000000 --- a/public/json/json_file_hashes.json +++ /dev/null @@ -1 +0,0 @@ -[{"file":"conversation_data.json","sha256":"f5b8a7a9e2bf4dbbdeaa1c2891c1022cdb1af3115d795b0abd3a7521c4829e43"},{"file":"conversation_list.json","sha256":"b3b2ee257a9ccb540455b8f58e35edc1d01a02709110a9053862053b979a57bf"},{"file":"examspeaking_data.json","sha256":"a0e748ad87544006535431a7ff6c6d7ad341f8835e5d6ce8b47ca0af9f6d91b8"},{"file":"examspeaking_list.json","sha256":"97d9dfd5a0e6553c6299a9031eb4a5474a009ba1d714f986f0fdff07fc48ba06"},{"file":"exercise_dictation.json","sha256":"735628b3d3ad2a8f2a01d71c50dc27bf3dd21917b35756c6793d354a0127b368"},{"file":"exercise_list.json","sha256":"da0e8b802bd9dde836c03296465245dff4ddbde881f32d8ecb90b5493f506b43"},{"file":"exercise_matchup.json","sha256":"d0e6af1e56078368709e5578b39757d9dc75160549abd075dcb4702b1cd639cf"},{"file":"exercise_memory_match.json","sha256":"90504c5bf90c62a4590b8f7d473784154d5ab0fcfa302a6c19d1ed712a8322c8"},{"file":"exercise_odd_one_out.json","sha256":"e4159a3cc95e70cc3a444c0811db86c6e8c8b00ba2e3e72be8ec5cfb7e75543f"},{"file":"exercise_reordering.json","sha256":"f33de698bf8056907b1454ea9601a12109b6f3e271b0719662051dc365178b17"},{"file":"exercise_snap.json","sha256":"b10ae5fcf21e982555e712c5a2643a87781689176431e1a1b7136eefdd7a22b4"},{"file":"exercise_sorting.json","sha256":"0ae2b8ccba66eeb97bbbcf40c0bafc9d2ed1cfb758fb0615ad570fad980a4563"},{"file":"exercise_sound_n_spelling.json","sha256":"0c7ad8f7abd7efc1a16b135df3cea3e930d4f5c7c40b26df62c28abbde73e37f"},{"file":"ex_data.json","sha256":"b098514cfc8bb3e3a2e1b3bde0a3af5aa9949d34beab87ee528875e64230e1c2"},{"file":"sounds_data.json","sha256":"978c0394c7f2e4696a4d3db01d4eb1ce8fcf0bcfb9ed462eb48a11276130443e"}] \ No newline at end of file diff --git a/public/locales/en.json b/public/locales/en.json index 76cfd878c..57d7bfeaf 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -99,8 +99,10 @@ "You may use some of the language from this topic.", "Remember to save your text frequently!" ], + "practiceConversationPlaceholder": "Enter your conversation here…", "practiceConversationBox": "Enter your conversation:", "practiceExamTextbox": "Your notes:", + "practiceExamPlaceholder": "Enter your notes here…", "recordSectionText": "Use the script you have written above to record yourself. Then listen to how you sound.", "tipCardExam": "Tips", "doCardExam": "Dos", @@ -128,7 +130,7 @@ "tongueTwisterInstructions": "Practice these tongue twisters to improve your pronunciation, fluency, and control of tricky sounds. Try to say them as fast as possible, but correctly.", "soundInstructions": { "howToPronounce": "How to pronounce this sound", - "consonant": { + "consonants": { "pPen": [ "This is the voiceless bilabial plosive. It is a type of plosive consonant.", "To pronounce this phoneme, bring both lips together to completely block the airflow. Then, release the lips suddenly to produce a small burst of air. The vocal cords do not vibrate while making this sound." @@ -234,7 +236,7 @@ "To pronounce this phoneme, quickly tap the tip of your tongue against the alveolar ridge (the ridge just behind your upper front teeth). It is similar to a very fast /d/ sound and often occurs between two vowels." ] }, - "vowel": { + "vowels": { "eeSee": [ "This is the close front unrounded vowel. It is a type of long vowel sound.", "To pronounce this phoneme, stretch the lips slightly (like a smile), raise the tongue close to the roof of the mouth (but not touching), and keep it at the front. The sound is held slightly longer than short vowels." @@ -300,7 +302,7 @@ "To pronounce this phoneme, start with the tongue in the position for /ɑ/ (as in “father”), then lower the soft palate to allow air to pass through the nose. The lips stay relaxed and unrounded while the sound is nasalized." ] }, - "diphthong": { + "diphthongs": { "aySay": [ "This is the closing diphthong that starts with /e/ and moves to /ɪ/. It is a type of diphthong.", "To pronounce this phoneme, start with the tongue in the position for /e/, then quickly move it toward the position for /ɪ/. The lips start slightly spread and end in a more relaxed position." diff --git a/scripts/clean-dist.js b/scripts/clean-dist.js new file mode 100644 index 000000000..d656e1bb4 --- /dev/null +++ b/scripts/clean-dist.js @@ -0,0 +1,24 @@ +import * as fs from "fs"; +import path from "node:path"; + +const __dirname = path.resolve(); + +const deleteFolderRecursive = (directoryPath) => { + if (fs.existsSync(directoryPath)) { + fs.readdirSync(directoryPath).forEach((file) => { + const curPath = path.join(directoryPath, file); + if (fs.lstatSync(curPath).isDirectory()) { + // recurse + deleteFolderRecursive(curPath); + } else { + // delete file + fs.unlinkSync(curPath); + } + }); + fs.rmdirSync(directoryPath); + } +}; + +["dist", "dist-electron", "dist-react"].forEach((dir) => { + deleteFolderRecursive(path.join(__dirname, dir)); +}); diff --git a/scripts/minify-dist-electron.js b/scripts/minify-dist-electron.js new file mode 100644 index 000000000..f2de8942a --- /dev/null +++ b/scripts/minify-dist-electron.js @@ -0,0 +1,42 @@ +import * as fsPromise from "node:fs/promises"; +import path from "node:path"; +import { minify } from "terser"; + +const dir = "./dist-electron/electron-main"; + +const getAllJsFiles = async (dirPath, files = []) => { + const entries = await fsPromise.readdir(dirPath, { withFileTypes: true }); + + for (let entry of entries) { + const fullPath = path.join(dirPath, entry.name); + if (entry.isDirectory()) { + await getAllJsFiles(fullPath, files); + } else if (entry.isFile() && fullPath.endsWith(".js") && !fullPath.endsWith(".min.js")) { + files.push(fullPath); + } + } + return files; +}; + +(async () => { + const jsFiles = await getAllJsFiles(dir); + + for (const filePath of jsFiles) { + try { + const code = await fsPromise.readFile(filePath, "utf8"); + const result = await minify(code); + + if (result.code) { + const minPath = filePath.replace(/\.js$/, ".min.js"); + await fsPromise.writeFile(minPath, result.code); + await fsPromise.unlink(filePath); + await fsPromise.rename(minPath, filePath); + console.log(`Minified: ${filePath}`); + } else { + console.error(`Failed to minify: ${filePath}`); + } + } catch (err) { + console.error(`Error processing ${filePath}:`, err); + } + } +})(); diff --git a/scripts/minify-dist-jsons.js b/scripts/minify-dist-jsons.js index 6dd6ca5b9..cfe690d15 100644 --- a/scripts/minify-dist-jsons.js +++ b/scripts/minify-dist-jsons.js @@ -25,8 +25,8 @@ const minifyJsonFiles = (dir) => { }; // Minify dist/json -minifyJsonFiles(path.join(__dirname, "../dist/json")); +minifyJsonFiles(path.join(__dirname, "../dist-react/json")); // Minify dist/locales -minifyJsonFiles(path.join(__dirname, "../dist/locales")); +minifyJsonFiles(path.join(__dirname, "../dist-react/locales")); // Minify data/ //minifyJsonFiles(path.join(__dirname, "../data")); diff --git a/src/ErrorBoundary.jsx b/src/ErrorBoundary.jsx deleted file mode 100644 index 2e560ce2c..000000000 --- a/src/ErrorBoundary.jsx +++ /dev/null @@ -1,10 +0,0 @@ -import { useTranslation } from "react-i18next"; -import ErrorBoundaryInner from "./ErrorBoundaryInner"; - -const ErrorBoundary = (props) => { - const { t } = useTranslation(); - - return ; -}; - -export default ErrorBoundary; diff --git a/src/components/conversation_page/ReviewTab.jsx b/src/components/conversation_page/ReviewTab.jsx deleted file mode 100644 index fa97728c3..000000000 --- a/src/components/conversation_page/ReviewTab.jsx +++ /dev/null @@ -1,81 +0,0 @@ -import PropTypes from "prop-types"; -import { useEffect, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { sonnerSuccessToast } from "../../utils/sonnerCustomToast"; - -const ReviewTab = ({ reviews, accent, conversationId }) => { - const { t } = useTranslation(); - - const [reviewState, setReviewState] = useState({}); - - const reviewKey = `${accent}-${conversationId}-review`; - - // Load saved review states from localStorage - useEffect(() => { - const storedData = JSON.parse(localStorage.getItem("ispeaker")) || {}; - const savedReviews = storedData.conversationReview?.[accent] || {}; - const initialReviewState = reviews.reduce((acc, review, index) => { - const key = `${reviewKey}${index + 1}`; - acc[index + 1] = savedReviews[key] || false; - return acc; - }, {}); - setReviewState(initialReviewState); - }, [accent, conversationId, reviews, reviewKey]); - - // Handle checkbox change - const handleCheckboxChange = (index) => { - const newReviewState = { ...reviewState, [index]: !reviewState[index] }; - setReviewState(newReviewState); - - // Load existing data from localStorage - const storedData = JSON.parse(localStorage.getItem("ispeaker")) || {}; - const currentAccentData = storedData.conversationReview?.[accent] || {}; - - // Update the specific review state while preserving the rest of the data - const updatedReviews = { - ...currentAccentData, - [`${reviewKey}${index}`]: newReviewState[index], - }; - - // Save updated data back to localStorage - localStorage.setItem( - "ispeaker", - JSON.stringify({ - ...storedData, - conversationReview: { - ...storedData.conversationReview, - [accent]: updatedReviews, - }, - }) - ); - - sonnerSuccessToast(t("toast.reviewUpdated")); - }; - - return ( -
- {reviews.map((review, index) => ( -
- -
- ))} -
- ); -}; - -ReviewTab.propTypes = { - reviews: PropTypes.array.isRequired, - accent: PropTypes.string.isRequired, - conversationId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, -}; - -export default ReviewTab; diff --git a/src/components/exam_page/ReviewTab.jsx b/src/components/exam_page/ReviewTab.jsx deleted file mode 100644 index 7edcc968f..000000000 --- a/src/components/exam_page/ReviewTab.jsx +++ /dev/null @@ -1,61 +0,0 @@ -import PropTypes from "prop-types"; -import { useEffect, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { sonnerSuccessToast } from "../../utils/sonnerCustomToast"; - -const ReviewTab = ({ reviews, examId, accent }) => { - const { t } = useTranslation(); - - const [checkedReviews, setCheckedReviews] = useState(() => { - // Retrieve saved reviews from ispeaker -> examReview in localStorage - const savedData = JSON.parse(localStorage.getItem("ispeaker")) || {}; - return savedData.examReview?.[accent] || {}; - }); - - const handleCheckboxChange = (index) => { - const key = `${examId}-${index}`; - setCheckedReviews((prev) => ({ - ...prev, - [key]: !prev[key], - })); - sonnerSuccessToast(t("toast.reviewUpdated")); - }; - - useEffect(() => { - // Retrieve the existing ispeaker data - const savedData = JSON.parse(localStorage.getItem("ispeaker")) || {}; - // Update the examReview section - savedData.examReview = savedData.examReview || {}; - savedData.examReview[accent] = { ...checkedReviews }; - - // Save the updated ispeaker data back to localStorage - localStorage.setItem("ispeaker", JSON.stringify(savedData)); - }, [checkedReviews, examId, accent]); - - return ( -
- {reviews.map((review, index) => ( -
- -
- ))} -
- ); -}; - -ReviewTab.propTypes = { - reviews: PropTypes.array.isRequired, - examId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, - accent: PropTypes.string.isRequired, -}; - -export default ReviewTab; diff --git a/src/components/setting_page/ExerciseTimer.jsx b/src/components/setting_page/ExerciseTimer.jsx deleted file mode 100644 index deda5ef54..000000000 --- a/src/components/setting_page/ExerciseTimer.jsx +++ /dev/null @@ -1,185 +0,0 @@ -import { useEffect, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { sonnerSuccessToast } from "../../utils/sonnerCustomToast"; - -const defaultTimerSettings = { - enabled: false, - dictation: 5, - matchup: 5, - reordering: 5, - sound_n_spelling: 5, - sorting: 5, - odd_one_out: 5, -}; - -const ExerciseTimer = () => { - const { t } = useTranslation(); - - const [timerSettings, setTimerSettings] = useState(() => { - const savedSettings = JSON.parse(localStorage.getItem("ispeaker")); - if (savedSettings && savedSettings.timerSettings) { - return savedSettings.timerSettings; - } - return defaultTimerSettings; - }); - - const [inputEnabled, setInputEnabled] = useState(timerSettings.enabled); - const [tempSettings, setTempSettings] = useState(timerSettings); - const [isValid, setIsValid] = useState(true); - const [isModified, setIsModified] = useState(false); - - // Automatically save settings to localStorage whenever timerSettings change - useEffect(() => { - const savedSettings = JSON.parse(localStorage.getItem("ispeaker")) || {}; - savedSettings.timerSettings = timerSettings; - localStorage.setItem("ispeaker", JSON.stringify(savedSettings)); - }, [timerSettings]); - - const handleTimerToggle = (enabled) => { - setTimerSettings((prev) => ({ - ...prev, - enabled, - })); - setInputEnabled(enabled); - - sonnerSuccessToast(t("settingPage.changeSaved")); - }; - - // Validation function to check if the inputs are valid (0-10 numbers only) - const validateInputs = (settings) => { - return Object.values(settings).every( - (value) => value !== "" && !isNaN(value) && value >= 0 && value <= 10 - ); - }; - - const checkIfModified = (settings) => { - const savedSettings = - JSON.parse(localStorage.getItem("ispeaker"))?.timerSettings || defaultTimerSettings; - return JSON.stringify(settings) !== JSON.stringify(savedSettings); - }; - - const handleInputChange = (e, settingKey) => { - const { value } = e.target; - if (/^\d*$/.test(value) && value.length <= 2) { - const numValue = value === "" ? "" : parseInt(value, 10); - setTempSettings((prev) => ({ - ...prev, - [settingKey]: numValue, - })); - } - }; - - const handleApply = () => { - if (validateInputs(tempSettings)) { - setTimerSettings((prev) => ({ - ...prev, - ...tempSettings, // Apply modified fields - enabled: prev.enabled, // Ensure the `enabled` flag is preserved - })); - setIsModified(false); - sonnerSuccessToast(t("settingPage.changeSaved")); - } - }; - - const handleCancel = () => { - setTempSettings(timerSettings); // revert to original settings - setIsModified(false); // Reset modified state - }; - - // Update validity and modified state when temporary settings change - useEffect(() => { - setIsValid(validateInputs(tempSettings)); - setIsModified(checkIfModified(tempSettings)); // Check if values differ from localStorage or defaults - }, [tempSettings]); - - const exerciseNames = { - dictation: t("exercise_page.dictationHeading"), - matchup: t("exercise_page.matchUpHeading"), - reordering: t("exercise_page.reorderingHeading"), - sound_n_spelling: t("exercise_page.soundSpellingHeading"), - sorting: t("exercise_page.sortingHeading"), - odd_one_out: t("exercise_page.oddOneOutHeading"), - }; - - return ( -
-
-
- -

- {t("settingPage.exerciseSettings.timerDescription")} -

-
-
- handleTimerToggle(e.target.checked)} - /> -
-
- -
- {Object.keys(exerciseNames).map((exercise) => ( -
-
- - {exerciseNames[exercise]} - - - handleInputChange(e, exercise)} - className={`input input-bordered w-full max-w-xs ${ - tempSettings[exercise] === "" || - tempSettings[exercise] < 0 || - tempSettings[exercise] > 10 - ? "input-error" - : "" - }`} - disabled={!inputEnabled} - /> - - {tempSettings[exercise] === "" || - tempSettings[exercise] < 0 || - tempSettings[exercise] > 10 ? ( -

- {t("settingPage.exerciseSettings.textboxError")} -

- ) : null} -
-
- ))} -
- -

{t("settingPage.exerciseSettings.hint")}

- -
- - -
-
- ); -}; - -export default ExerciseTimer; diff --git a/src/components/word_page/ipaUtils.js b/src/components/word_page/ipaUtils.js deleted file mode 100644 index b6a4be8e2..000000000 --- a/src/components/word_page/ipaUtils.js +++ /dev/null @@ -1,139 +0,0 @@ -// IPA normalization and fuzzy matching utilities - -// Map of IPA variants to canonical forms -const IPA_NORMALIZATION_MAP = { - ɑː: "ɑ", - ɡ: "g", - r: "ɹ", - ɾ: "ɹ", - ɻ: "ɹ", - ɽ: "ɹ", - ɺ: "ɹ", - əʊ: "oʊ", - ou: "oʊ", - ei: "eɪ", - ai: "aɪ", - au: "aʊ", - oi: "ɔɪ", - ʧ: "t͡ʃ", - ʤ: "d͡ʒ", - ɚ: "ə", - ʃ: "ʃ", - ʒ: "ʒ", - ŋ: "ŋ", - er: "ɚ", - ər: "ɚ", - ɜr: "ɚ", - // Add more as needed -}; - -// Common learner substitutions that should be treated as very close matches -const LEARNER_SUBSTITUTIONS = [ - ["ʊ", "u"], - ["i", "ɪ"], - ["ɑ", "a"], - ["ɔ", "o"], -]; - -// Fuzzy phoneme groups (each array contains close phonemes) -const FUZZY_PHONEME_GROUPS = [ - ["ɑ", "ɑː"], - ["ə", "ɚ"], - ["oʊ", "ou", "əʊ"], - ["eɪ", "ei"], - ["aɪ", "ai"], - ["aʊ", "au"], - ["ɔɪ", "oi"], - ["t͡ʃ", "ʧ"], - ["d͡ʒ", "ʤ"], - ["g", "ɡ"], - ["ɹ", "r", "ɾ", "ɻ", "ɽ", "ɺ"], - ["ŋ", "ŋ"], - ["ɛ", "e"], - // Add more as needed -]; - -// Normalize a single IPA token -const normalizeIPAToken = (token) => IPA_NORMALIZATION_MAP[token] || token; - -// Normalize a full IPA string (tokenized by space) -const normalizeIPAString = (str) => { - if (!str) return ""; - return str - .toLowerCase() - .replace(/\s+/g, " ") - .trim() - .split(" ") - .map(normalizeIPAToken) - .join(" "); -}; - -// Check if two IPA tokens are fuzzy matches (close enough) -const arePhonemesClose = (a, b) => { - // Ignore the long mark ː in comparison - if (a === "ː" || b === "ː") return true; - a = normalizeIPAToken(a.replace(/ː/gu, "")); - b = normalizeIPAToken(b.replace(/ː/gu, "")); - if (a === b) return true; - for (const group of FUZZY_PHONEME_GROUPS) { - if (group.includes(a) && group.includes(b)) return true; - } - return false; -}; - -// Character-based Levenshtein with fuzzy matching -const charLevenshtein = (a, b) => { - const dp = Array(a.length + 1) - .fill(null) - .map(() => Array(b.length + 1).fill(0)); - for (let i = 0; i <= a.length; i++) dp[i][0] = i; - for (let j = 0; j <= b.length; j++) dp[0][j] = j; - for (let i = 1; i <= a.length; i++) { - for (let j = 1; j <= b.length; j++) { - if (arePhonemesClose(a[i - 1], b[j - 1])) { - dp[i][j] = dp[i - 1][j - 1]; - } else { - dp[i][j] = Math.min( - dp[i - 1][j] + 1, // deletion - dp[i][j - 1] + 1, // insertion - dp[i - 1][j - 1] + 1 // substitution - ); - } - } - } - return dp[a.length][b.length]; -}; - -// Format model output to match official phoneme's spacing -const formatToOfficialSpacing = (modelStr, officialStr) => { - // Remove spaces from both - const model = modelStr.replace(/ /g, ""); - const official = officialStr.trim().split(/\s+/); - let idx = 0; - const groups = official.map((syll) => { - const group = model.slice(idx, idx + syll.length); - idx += syll.length; - return group; - }); - // Add any extra phonemes from the model output - if (idx < model.length) { - groups.push(model.slice(idx)); - } - return groups.join(" "); -}; - -// Check if two phonemes are common learner substitutions -const isLearnerSubstitution = (a, b) => { - return LEARNER_SUBSTITUTIONS.some( - ([p1, p2]) => (a === p1 && b === p2) || (a === p2 && b === p1) - ); -}; - -export { - arePhonemesClose, - charLevenshtein, - formatToOfficialSpacing, - isLearnerSubstitution, - normalizeIPAString, - normalizeIPAToken, -}; diff --git a/electron-main/createWindow.js b/src/electron/electron-main/createWindow.ts similarity index 67% rename from electron-main/createWindow.js rename to src/electron/electron-main/createWindow.ts index 9de5e0066..18cb95269 100644 --- a/electron-main/createWindow.js +++ b/src/electron/electron-main/createWindow.ts @@ -1,4 +1,5 @@ -import { app, BrowserWindow, Menu, shell } from "electron"; +import { app, BrowserWindow, IpcMain, IpcMainInvokeEvent, Menu, shell } from "electron"; +import { Conf } from "electron-conf"; import applog from "electron-log"; import path from "node:path"; import process from "node:process"; @@ -6,31 +7,39 @@ import { startExpressServer } from "./expressServer.js"; const isDev = process.env.NODE_ENV === "development"; -let mainWindow; -let splashWindow; +let mainWindow: BrowserWindow | null; +let splashWindow: BrowserWindow | null; -const createSplashWindow = (rootDir, ipcMain, conf) => { +const createSplashWindow = ( + rootDir: string, + ipcMain: IpcMain, + conf: Conf> +) => { splashWindow = new BrowserWindow({ width: 854, height: 413, frame: false, // Remove window controls transparent: true, // Make the window background transparent alwaysOnTop: true, - icon: path.join(rootDir, "dist", "appicon.png"), + icon: path.join(rootDir, "dist-react", "appicon.png"), webPreferences: { - preload: path.join(rootDir, "preload.cjs"), + preload: path.join(app.getAppPath(), "dist-electron", "preload.mjs"), nodeIntegration: false, contextIsolation: true, - enableRemoteModule: false, devTools: isDev ? true : false, + sandbox: false, // sandbox: true will make preload.mts stop working. }, }); // For splash screen - ipcMain.handle("get-conf", (event, key) => { + ipcMain.handle("get-conf", (event: IpcMainInvokeEvent, key: string) => { return conf.get(key); }); + ipcMain.handle("get-node-env", () => { + return process.env.NODE_ENV; + }); + // Load the splash screen HTML splashWindow.loadFile(path.join(rootDir, "data", "splash.html")); @@ -42,38 +51,39 @@ const createSplashWindow = (rootDir, ipcMain, conf) => { }); }; -const createWindow = (rootDir, onServerReady) => { +const createWindow = (rootDir: string) => { mainWindow = new BrowserWindow({ width: 1280, height: 720, show: false, webPreferences: { - preload: path.join(rootDir, "preload.cjs"), + preload: path.join(app.getAppPath(), "dist-electron", "preload.mjs"), nodeIntegration: false, contextIsolation: true, - enableRemoteModule: false, devTools: isDev ? true : false, + sandbox: false, // sandbox: true will make preload.mts stop working. }, - icon: path.join(rootDir, "dist", "appicon.png"), + icon: path.join(rootDir, "dist-react", "appicon.png"), }); if (isDev) { mainWindow.loadURL("http://localhost:5173"); // Point to Vite dev server } else { - mainWindow.loadFile(path.join(rootDir, "./dist/index.html")); // Load the built HTML file + mainWindow.loadFile(path.join(rootDir, "dist-react", "index.html")); // Load the built HTML file } // Show the main window only when it's ready mainWindow.once("ready-to-show", () => { setTimeout(() => { - splashWindow.close(); - mainWindow.maximize(); - mainWindow.show(); - + if (splashWindow) { + splashWindow.close(); + } + if (mainWindow) { + mainWindow.maximize(); + mainWindow.show(); + } // Start Express server in the background after main window is shown - startExpressServer().then((srv) => { - if (onServerReady) onServerReady(srv); - }); + startExpressServer(); }, 500); }); @@ -82,11 +92,15 @@ const createWindow = (rootDir, onServerReady) => { }); mainWindow.on("enter-full-screen", () => { - mainWindow.setMenuBarVisibility(false); + if (mainWindow) { + mainWindow.setMenuBarVisibility(false); + } }); mainWindow.on("leave-full-screen", () => { - mainWindow.setMenuBarVisibility(true); + if (mainWindow) { + mainWindow.setMenuBarVisibility(true); + } }); const menu = Menu.buildFromTemplate([ @@ -116,8 +130,8 @@ const createWindow = (rootDir, onServerReady) => { { role: "zoomOut" }, { type: "separator" }, { role: "togglefullscreen" }, - isDev ? { role: "toggleDevTools" } : null, - ].filter(Boolean), + ...(isDev ? [{ role: "toggleDevTools" as const }] : []), + ], }, { label: "Window", diff --git a/electron-main/customFolderLocationOperation.js b/src/electron/electron-main/customFolderLocationOperation.ts similarity index 82% rename from electron-main/customFolderLocationOperation.js rename to src/electron/electron-main/customFolderLocationOperation.ts index a53f1dfa5..1dbd88f87 100644 --- a/electron-main/customFolderLocationOperation.js +++ b/src/electron/electron-main/customFolderLocationOperation.ts @@ -1,21 +1,21 @@ -import { ipcMain } from "electron"; +import { ipcMain, IpcMainInvokeEvent } from "electron"; import applog from "electron-log"; import fs from "node:fs"; import * as fsPromises from "node:fs/promises"; import path from "node:path"; import process from "node:process"; import { + deleteEmptyDataSubfolder, + getDataSubfolder, getSaveFolder, settingsConf, - getDataSubfolder, - deleteEmptyDataSubfolder, } from "./filePath.js"; import isDeniedSystemFolder from "./isDeniedSystemFolder.js"; import { generateLogFileName } from "./logOperations.js"; // Helper: Recursively collect all files in a directory -const getAllFiles = async (dir, base = dir) => { - let files = []; +const getAllFiles = async (dir: string, base = dir): Promise<{ abs: string; rel: string }[]> => { + let files: { abs: string; rel: string }[] = []; const entries = await fsPromises.readdir(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dir, entry.name); @@ -32,10 +32,10 @@ const getAllFiles = async (dir, base = dir) => { }; // Helper: Determine if folder contents should be moved -const shouldMoveContents = (src, dest) => { +const shouldMoveContents = (src: string, dest: string): boolean => { return ( - src && - dest && + Boolean(src) && + Boolean(dest) && src !== dest && fs.existsSync(src) && fs.existsSync(dest) && @@ -45,7 +45,7 @@ const shouldMoveContents = (src, dest) => { }; // Helper: Delete pronunciation-venv in oldSaveFolder before moving -const deletePronunciationVenv = async (oldSaveFolder, event) => { +const deletePronunciationVenv = async (oldSaveFolder: string, event: IpcMainInvokeEvent) => { const oldVenvPath = path.join(oldSaveFolder, "pronunciation-venv"); if (fs.existsSync(oldVenvPath)) { event.sender.send("venv-delete-status", { status: "deleting", path: oldVenvPath }); @@ -55,20 +55,21 @@ const deletePronunciationVenv = async (oldSaveFolder, event) => { applog.info("Deleted old pronunciation-venv at:", oldVenvPath); event.sender.send("venv-delete-status", { status: "deleted", path: oldVenvPath }); } catch (venvErr) { + const errorMsg = venvErr instanceof Error ? venvErr.message : String(venvErr); // Log but do not block move if venv doesn't exist or can't be deleted - console.log("Could not delete old pronunciation-venv:", venvErr.message); - applog.warn("Could not delete old pronunciation-venv:", venvErr.message); + console.log("Could not delete old pronunciation-venv:", errorMsg); + applog.warn("Could not delete old pronunciation-venv:", errorMsg); event.sender.send("venv-delete-status", { status: "error", path: oldVenvPath, - error: venvErr.message, + error: errorMsg, }); } } }; // Helper: Move all contents from one folder to another (copy then delete, robust for cross-device) -const moveFolderContents = async (src, dest, event) => { +const moveFolderContents = async (src: string, dest: string, event: IpcMainInvokeEvent) => { // Recursively collect all files for accurate progress const files = await getAllFiles(src); const total = files.length; @@ -104,7 +105,7 @@ const moveFolderContents = async (src, dest, event) => { } // 3. Remove empty directories in src (track progress for dirs) // We'll collect all directories and send progress for each - const collectDirs = async (dir) => { + const collectDirs = async (dir: string): Promise => { let dirs = [dir]; const entries = await fsPromises.readdir(dir, { withFileTypes: true }); for (const entry of entries) { @@ -147,20 +148,20 @@ const moveFolderContents = async (src, dest, event) => { const setCustomSaveFolderIPC = () => { ipcMain.handle("set-custom-save-folder", async (event, folderPath) => { const oldSaveFolder = await getSaveFolder(); - let newSaveFolder; - let prevCustomFolder = null; + let newSaveFolder: string; + let prevCustomFolder: string | null = null; if (!folderPath) { // Reset to default const userSettings = settingsConf.store || {}; if (userSettings.customSaveFolder) { - prevCustomFolder = userSettings.customSaveFolder; + prevCustomFolder = userSettings.customSaveFolder as string; } settingsConf.delete("customSaveFolder"); // Use getSaveFolder to get the default save folder newSaveFolder = await getSaveFolder(); applog.info("Reset to default save folder:", newSaveFolder); // Move contents back from previous custom folder's data subfolder if it exists - let prevDataSubfolder = null; + let prevDataSubfolder: string | null = null; if (prevCustomFolder) { prevDataSubfolder = getDataSubfolder(prevCustomFolder); } @@ -168,17 +169,20 @@ const setCustomSaveFolderIPC = () => { try { await fsPromises.mkdir(newSaveFolder, { recursive: true }); } catch (e) { - console.log("Failed to create default save folder:", e); - applog.error("Failed to create default save folder:", e); + const errorMsg = e instanceof Error ? e.message : String(e); + console.log("Failed to create default save folder:", errorMsg); + applog.error("Failed to create default save folder:", errorMsg); return { success: false, error: "folderChangeError", - reason: e.message || "Unknown error", + reason: errorMsg, }; } applog.info("[DEBUG] prevDataSubfolder:", prevDataSubfolder); applog.info("[DEBUG] newSaveFolder:", newSaveFolder); - const shouldMove = shouldMoveContents(prevDataSubfolder, newSaveFolder); + const shouldMove = prevDataSubfolder + ? shouldMoveContents(prevDataSubfolder, newSaveFolder) + : false; applog.info("[DEBUG] shouldMoveContents:", shouldMove); if (shouldMove) { applog.info( @@ -187,15 +191,19 @@ const setCustomSaveFolderIPC = () => { newSaveFolder ); try { - await deletePronunciationVenv(prevDataSubfolder, event); - await moveFolderContents(prevDataSubfolder, newSaveFolder, event); + if (prevDataSubfolder) { + await deletePronunciationVenv(prevDataSubfolder, event); + await moveFolderContents(prevDataSubfolder, newSaveFolder, event); + } } catch (moveBackErr) { - console.log("Failed to move contents back to default folder:", moveBackErr); - applog.error("Failed to move contents back to default folder:", moveBackErr); + const errorMsg = + moveBackErr instanceof Error ? moveBackErr.message : String(moveBackErr); + console.log("Failed to move contents back to default folder:", errorMsg); + applog.error("Failed to move contents back to default folder:", errorMsg); return { success: false, error: "folderMoveError", - reason: moveBackErr.message || "Unknown move error", + reason: errorMsg, }; } } @@ -248,23 +256,25 @@ const setCustomSaveFolderIPC = () => { try { await fsPromises.mkdir(newSaveFolder, { recursive: true }); } catch (e) { - console.log("Failed to create data subfolder:", e); - applog.error("Failed to create data subfolder:", e); + const errorMsg = e instanceof Error ? e.message : String(e); + console.log("Failed to create data subfolder:", errorMsg); + applog.error("Failed to create data subfolder:", errorMsg); return { success: false, error: "folderChangeError", - reason: e.message || "Unknown error", + reason: errorMsg, }; } console.log("New save folder:", newSaveFolder); applog.info("New save folder:", newSaveFolder); } catch (err) { - console.log("Error setting custom save folder:", err); - applog.error("Error setting custom save folder:", err); + const errorMsg = err instanceof Error ? err.message : String(err); + console.log("Error setting custom save folder:", errorMsg); + applog.error("Error setting custom save folder:", errorMsg); return { success: false, error: "folderChangeError", - reason: err.message || "Unknown error", + reason: errorMsg, }; } } @@ -279,8 +289,9 @@ const setCustomSaveFolderIPC = () => { try { await fsPromises.mkdir(currentLogFolder, { recursive: true }); } catch (e) { - console.log("Failed to create new log directory:", e); - applog.warn("Failed to create new log directory:", e); + const errorMsg = e instanceof Error ? e.message : String(e); + console.log("Failed to create new log directory:", errorMsg); + applog.warn("Failed to create new log directory:", errorMsg); } applog.transports.file.fileName = generateLogFileName(); applog.transports.file.resolvePathFn = () => @@ -293,12 +304,13 @@ const setCustomSaveFolderIPC = () => { } return { success: true, newPath: newSaveFolder }; } catch (moveErr) { - console.log("Failed to move folder contents:", moveErr); - applog.error("Failed to move folder contents:", moveErr); + const errorMsg = moveErr instanceof Error ? moveErr.message : String(moveErr); + console.log("Failed to move folder contents:", errorMsg); + applog.error("Failed to move folder contents:", errorMsg); return { success: false, error: "folderMoveError", - reason: moveErr.message || "Unknown move error", + reason: errorMsg, }; } }); diff --git a/electron-main/expressServer.js b/src/electron/electron-main/expressServer.ts similarity index 51% rename from electron-main/expressServer.js rename to src/electron/electron-main/expressServer.ts index f27734d6a..dffbe6983 100644 --- a/electron-main/expressServer.js +++ b/src/electron/electron-main/expressServer.ts @@ -1,44 +1,36 @@ +import { ipcMain } from "electron"; import applog from "electron-log"; import express from "express"; -import net from "net"; +import { Server } from "node:http"; +import net from "node:net"; const DEFAULT_PORT = 8998; const MIN_PORT = 1024; // Minimum valid port number const MAX_PORT = 65535; // Maximum valid port number -// Create Express server const expressApp = express(); -// Function to generate a random port number within the range -const getRandomPort = () => { - return Math.floor(Math.random() * (MAX_PORT - MIN_PORT + 1)) + MIN_PORT; -}; +let currentPort: number | null = null; +let httpServer: Server | null = null; -// Function to check if a port is available -const checkPortAvailability = (port) => { - return new Promise((resolve, reject) => { - const server = net.createServer(); +const getRandomPort = () => Math.floor(Math.random() * (MAX_PORT - MIN_PORT + 1)) + MIN_PORT; - server.once("error", (err) => { +const checkPortAvailability = (port: number) => { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.once("error", (err: NodeJS.ErrnoException) => { if (err.code === "EADDRINUSE" || err.code === "ECONNREFUSED") { - resolve(false); // Port is in use - applog.log("Port is in use. Error:", err.code); + resolve(false); } else { - reject(err); // Some other error occurred - applog.log("Another error related to the Express server. Error:", err.code); + reject(err); } }); - server.once("listening", () => { - server.close(() => { - resolve(true); // Port is available - }); + server.close(() => resolve(true)); }); - server.listen(port); }); }; -// Function to start the Express server with the default port first, then randomize if necessary const startExpressServer = async () => { let port = DEFAULT_PORT; let isPortAvailable = await checkPortAvailability(port); @@ -51,9 +43,17 @@ const startExpressServer = async () => { } while (!isPortAvailable); } - return expressApp.listen(port, () => { + httpServer = expressApp.listen(port, () => { + currentPort = port; applog.info(`Express server is running on http://localhost:${port}`); }); + return httpServer; }; -export { expressApp, startExpressServer }; +const getExpressPort = () => currentPort; + +// IPC handler for renderer to get port +ipcMain.handle("get-port", () => getExpressPort()); + +export { expressApp, getExpressPort, startExpressServer }; + diff --git a/electron-main/filePath.js b/src/electron/electron-main/filePath.ts similarity index 81% rename from electron-main/filePath.js rename to src/electron/electron-main/filePath.ts index c9e5f140a..37f10af6c 100644 --- a/electron-main/filePath.js +++ b/src/electron/electron-main/filePath.ts @@ -6,17 +6,17 @@ import path from "node:path"; // Singleton instance for settings const settingsConf = new Conf({ name: "ispeakerreact_config" }); -const readUserSettings = async () => { +const readUserSettings = async (): Promise> => { return settingsConf.store || {}; }; -const getSaveFolder = async () => { +const getSaveFolder = async (): Promise => { // Try to get custom folder from user settings const userSettings = await readUserSettings(); - let saveFolder; - if (userSettings.customSaveFolder) { + let saveFolder: string; + if (userSettings.customSaveFolder && typeof userSettings.customSaveFolder === "string") { // For custom folder, use a subfolder 'ispeakerreact_data' - const baseFolder = userSettings.customSaveFolder; + const baseFolder: string = userSettings.customSaveFolder; // Ensure the base directory exists try { await fsPromises.access(baseFolder); @@ -44,7 +44,7 @@ const getSaveFolder = async () => { return saveFolder; }; -const getLogFolder = async () => { +const getLogFolder = async (): Promise => { const saveFolder = path.join(await getSaveFolder(), "logs"); // Ensure the directory exists try { @@ -56,15 +56,15 @@ const getLogFolder = async () => { }; // Helper to get the data subfolder path -const getDataSubfolder = (baseFolder) => { +const getDataSubfolder = (baseFolder: string): string => { return path.join(baseFolder, "ispeakerreact_data"); }; // Synchronous version to get the log folder path -const getLogFolderSync = () => { - let saveFolder; +const getLogFolderSync = (): string => { + let saveFolder: string; const userSettings = settingsConf.store || {}; - if (userSettings.customSaveFolder) { + if (userSettings.customSaveFolder && typeof userSettings.customSaveFolder === "string") { // For custom folder, use the data subfolder saveFolder = path.join(userSettings.customSaveFolder, "ispeakerreact_data"); } else { @@ -75,7 +75,7 @@ const getLogFolderSync = () => { }; // Helper to delete the empty ispeakerreact_data subfolder -const deleteEmptyDataSubfolder = async (baseFolder) => { +const deleteEmptyDataSubfolder = async (baseFolder: string): Promise => { const dataFolder = path.join(baseFolder, "ispeakerreact_data"); try { const files = await fsPromises.readdir(dataFolder); diff --git a/electron-main/getFileAndFolder.js b/src/electron/electron-main/getFileAndFolder.ts similarity index 82% rename from electron-main/getFileAndFolder.js rename to src/electron/electron-main/getFileAndFolder.ts index db95a3507..380decf82 100644 --- a/electron-main/getFileAndFolder.js +++ b/src/electron/electron-main/getFileAndFolder.ts @@ -1,12 +1,17 @@ import { ipcMain, shell } from "electron"; +import fs from "node:fs"; import * as fsPromises from "node:fs/promises"; import path from "node:path"; +import process from "node:process"; import { getSaveFolder, readUserSettings } from "./filePath.js"; -import fs from "node:fs"; -const getVideoFileDataIPC = (rootDir) => { +const isDev = process.env.NODE_ENV === "development"; + +const getVideoFileDataIPC = (rootDir: string) => { ipcMain.handle("get-video-file-data", async () => { - const jsonPath = path.join(rootDir, "dist", "json", "videoFilesInfo.json"); + const jsonPath = isDev + ? path.join(rootDir, "public", "json", "videoFilesInfo.json") + : path.join(rootDir, "dist-react", "json", "videoFilesInfo.json"); try { const jsonData = await fsPromises.readFile(jsonPath, "utf-8"); // Asynchronously read the JSON file return JSON.parse(jsonData); // Parse the JSON string and return it @@ -19,7 +24,7 @@ const getVideoFileDataIPC = (rootDir) => { const getVideoSaveFolderIPC = () => { ipcMain.handle("get-video-save-folder", async () => { - const saveFolder = await getSaveFolder(readUserSettings); + const saveFolder = await getSaveFolder(); const videoFolder = path.join(saveFolder, "video_files"); // Ensure the directory exists @@ -39,7 +44,7 @@ const getVideoSaveFolderIPC = () => { // IPC: Get current save folder (resolved) const getSaveFolderIPC = () => { ipcMain.handle("get-save-folder", async () => { - return await getSaveFolder(readUserSettings); + return await getSaveFolder(); }); }; @@ -52,7 +57,7 @@ const getCustomSaveFolderIPC = () => { }; // IPC: Get ffmpeg wasm absolute path -const getFfmpegWasmPathIPC = (rootDir) => { +const getFfmpegWasmPathIPC = (rootDir: string) => { ipcMain.handle("get-ffmpeg-wasm-path", async () => { // Adjust the path as needed if you move the file elsewhere return path.resolve(rootDir, "data", "ffmpeg"); diff --git a/electron-main/isDeniedSystemFolder.js b/src/electron/electron-main/isDeniedSystemFolder.ts similarity index 85% rename from electron-main/isDeniedSystemFolder.js rename to src/electron/electron-main/isDeniedSystemFolder.ts index 9bf3973ec..1246b43ed 100644 --- a/electron-main/isDeniedSystemFolder.js +++ b/src/electron/electron-main/isDeniedSystemFolder.ts @@ -4,17 +4,21 @@ import path from "node:path"; import process from "node:process"; import fs from "node:fs"; -const isDeniedSystemFolder = (folderPath) => { +const isDeniedSystemFolder = (folderPath: string) => { // Normalize and resolve the path to prevent path traversal attacks // This converts to absolute path and resolves ".." and "." segments - let absoluteInput; + let absoluteInput: string | undefined; try { // Resolve symlinks for robust security absoluteInput = fs.realpathSync.native(path.resolve(folderPath)); } catch (err) { - console.warn("Error getting realpath:", err.message); - applog.error("Error getting realpath:", err.message); + const errorMsg = err instanceof Error ? err.message : String(err); + console.warn("Error getting realpath:", errorMsg); + applog.error("Error getting realpath:", errorMsg); + // If we can't resolve the path, deny access for safety + return true; } + const platform = process.platform; const isCaseSensitive = platform !== "win32"; // Handle potential trailing slashes inconsistencies @@ -84,8 +88,9 @@ const isDeniedSystemFolder = (folderPath) => { } } } catch (err) { - console.warn("Error getting users directory:", err.message); - applog.error("Error getting users directory:", err.message); + const errorMsg = err instanceof Error ? err.message : String(err); + console.warn("Error getting users directory:", errorMsg); + applog.error("Error getting users directory:", errorMsg); } } else { // Linux and other Unix-like - using path.sep for consistency @@ -131,8 +136,9 @@ const isDeniedSystemFolder = (folderPath) => { } } } catch (err) { - console.warn("Error getting home directory:", err.message); - applog.error("Error getting home directory:", err.message); + const errorMsg = err instanceof Error ? err.message : String(err); + console.warn("Error getting home directory:", errorMsg); + applog.error("Error getting home directory:", errorMsg); } } @@ -148,8 +154,9 @@ const isDeniedSystemFolder = (folderPath) => { ]; denyList = [...denyList, ...appPaths]; } catch (err) { - console.warn("Error getting app paths:", err.message); - applog.error("Error getting app paths:", err.message); + const errorMsg = err instanceof Error ? err.message : String(err); + console.warn("Error getting app paths:", errorMsg); + applog.error("Error getting app paths:", errorMsg); } // De-duplicate and filter out any undefined or empty values @@ -164,7 +171,10 @@ const isDeniedSystemFolder = (folderPath) => { let formatted; try { formatted = fs.realpathSync.native(path.resolve(p)); - } catch { + } catch (err) { + const errorMsg = err instanceof Error ? err.message : String(err); + console.warn("Error formatting path:", errorMsg); + applog.error("Error formatting path:", errorMsg); formatted = path.resolve(p); } if (!isCaseSensitive) formatted = formatted.toLowerCase(); diff --git a/electron-main/logOperations.js b/src/electron/electron-main/logOperations.ts similarity index 72% rename from electron-main/logOperations.js rename to src/electron/electron-main/logOperations.ts index e3eab366c..cbf473fd4 100644 --- a/electron-main/logOperations.js +++ b/src/electron/electron-main/logOperations.ts @@ -1,8 +1,8 @@ -import { ipcMain } from "electron"; -import applog from "electron-log"; +import { ipcMain, IpcMainEvent } from "electron"; +import applog, { LevelOption } from "electron-log"; import * as fsPromises from "node:fs/promises"; import path from "node:path"; -import { getLogFolder, getLogFolderSync, readUserSettings, settingsConf } from "./filePath.js"; +import { getLogFolder, getLogFolderSync, settingsConf } from "./filePath.js"; const defaultLogSettings = { numOfLogs: 10, @@ -23,7 +23,7 @@ const getCurrentLogSettings = () => { return currentLogSettings; }; -const setCurrentLogSettings = (newSettings) => { +const setCurrentLogSettings = (newSettings: Record) => { currentLogSettings = { ...currentLogSettings, ...newSettings }; }; @@ -46,18 +46,21 @@ applog.transports.file.resolvePathFn = () => { return path.join(logFolder, applog.transports.file.fileName); }; applog.transports.file.maxSize = currentLogSettings.maxLogSize; -applog.transports.console.level = currentLogSettings.logLevel; +applog.transports.console.level = currentLogSettings.logLevel as LevelOption; // Handle updated log settings from the renderer -ipcMain.on("update-log-settings", async (event, newSettings) => { - setCurrentLogSettings(newSettings); - applog.info("Log settings updated:", currentLogSettings); +ipcMain.on( + "update-log-settings", + async (event: IpcMainEvent, newSettings: Record) => { + setCurrentLogSettings(newSettings); + applog.info("Log settings updated:", currentLogSettings); - // Save to user settings file - settingsConf.set("logSettings", currentLogSettings); + // Save to user settings file + settingsConf.set("logSettings", currentLogSettings); - manageLogFiles(); -}); + manageLogFiles(); + } +); // Function to check and manage log files based on the currentLogSettings const manageLogFiles = async () => { @@ -67,7 +70,7 @@ const manageLogFiles = async () => { applog.info("Log settings:", currentLogSettings); // Get the current log folder dynamically - const logFolder = await getLogFolder(readUserSettings); + const logFolder = await getLogFolder(); // Get all log files const logFiles = await fsPromises.readdir(logFolder); @@ -81,15 +84,16 @@ const manageLogFiles = async () => { birthtime: stats.birthtime, }); } catch (err) { - if (err.code !== "ENOENT") { + const errorMsg = err instanceof Error ? err.message : String(err); + // If ENOENT, just skip this file + if (errorMsg !== "ENOENT") { applog.error(`Error stating log file: ${filePath}`, err); } - // If ENOENT, just skip this file } } // Sort log files by creation time (oldest first) - logFilesResolved.sort((a, b) => a.birthtime - b.birthtime); + logFilesResolved.sort((a, b) => a.birthtime.getTime() - b.birthtime.getTime()); // Remove logs if they exceed the specified limit (excluding 0 for unlimited) if (numOfLogs > 0 && logFilesResolved.length > numOfLogs) { @@ -99,7 +103,8 @@ const manageLogFiles = async () => { await fsPromises.unlink(file.path); applog.info(`Deleted log file: ${file.path}`); } catch (err) { - if (err.code !== "ENOENT") { + const errorMsg = err instanceof Error ? err.message : String(err); + if (errorMsg !== "ENOENT") { applog.error(`Error deleting log file: ${file.path}`, err); } } @@ -110,13 +115,15 @@ const manageLogFiles = async () => { if (keepForDays > 0) { const now = new Date(); for (const file of logFilesResolved) { - const ageInDays = (now - new Date(file.birthtime)) / (1000 * 60 * 60 * 24); + const ageInDays = + (now.getTime() - new Date(file.birthtime).getTime()) / (1000 * 60 * 60 * 24); if (ageInDays > keepForDays) { try { await fsPromises.unlink(file.path); applog.info(`Deleted old log file: ${file.path}`); } catch (err) { - if (err.code !== "ENOENT") { + const errorMsg = err instanceof Error ? err.message : String(err); + if (errorMsg !== "ENOENT") { applog.error(`Error deleting old log file: ${file.path}`, err); } } @@ -124,7 +131,8 @@ const manageLogFiles = async () => { } } } catch (error) { - applog.error("Error managing log files:", error); + const errorMsg = error instanceof Error ? error.message : String(error); + applog.error("Error managing log files:", errorMsg); } }; diff --git a/electron-main/pronunciationCheckerIPC.js b/src/electron/electron-main/pronunciationCheckerIPC.ts similarity index 90% rename from electron-main/pronunciationCheckerIPC.js rename to src/electron/electron-main/pronunciationCheckerIPC.ts index e4dea8254..f1e769c32 100644 --- a/electron-main/pronunciationCheckerIPC.js +++ b/src/electron/electron-main/pronunciationCheckerIPC.ts @@ -1,21 +1,22 @@ import { spawn } from "child_process"; import { ipcMain } from "electron"; -import applog from "electron-log"; +import applog, { LevelOption } from "electron-log"; import * as fsPromises from "node:fs/promises"; import path from "node:path"; import { getSaveFolder, readUserSettings } from "./filePath.js"; import { getCurrentLogSettings } from "./logOperations.js"; -import { getVenvPythonPath, ensureVenvExists } from "./pronunciationOperations.js"; +import { ensureVenvExists, getVenvPythonPath } from "./pronunciationOperations.js"; -const startProcess = (cmd, args) => { +const startProcess = (cmd: string, args: string[], callback: (err: Error) => void) => { const proc = spawn(cmd, args, { shell: true }); + proc.on("error", callback); return proc; }; // IPC handler to check pronunciation const setupPronunciationCheckerIPC = () => { ipcMain.handle("pronunciation-check", async (event, audioPath, modelName) => { - const saveFolder = await getSaveFolder(readUserSettings); + const saveFolder = await getSaveFolder(); // If modelName is not provided, read from user settings if (!modelName) { const userSettings = await readUserSettings(); @@ -34,7 +35,7 @@ const setupPronunciationCheckerIPC = () => { // Configure electron-log with current settings applog.transports.file.maxSize = logSettings.maxLogSize; - applog.transports.console.level = logSettings.logLevel; + applog.transports.console.level = logSettings.logLevel as LevelOption; applog.info(`[PronunciationChecker] audioPath: ${audioPath}`); applog.info(`[PronunciationChecker] modelDir: ${modelDir}`); @@ -133,17 +134,18 @@ if __name__ == "__main__": const tempPyPath = path.join(saveFolder, "pronunciation_checker_temp.py"); await fsPromises.writeFile(tempPyPath, pyCode, "utf-8"); applog.info(`[PronunciationChecker] tempPyPath: ${tempPyPath}`); - let venvPython; + let venvPython: string; try { await ensureVenvExists(); venvPython = await getVenvPythonPath(); - } catch (err) { - applog.error(`[PronunciationChecker] Failed to create or find venv: ${err.message}`); - return { status: "error", message: `Failed to create or find venv: ${err.message}` }; + } catch (err: unknown) { + const errorMsg = err instanceof Error ? err.message : String(err); + applog.error(`[PronunciationChecker] Failed to create or find venv: ${errorMsg}`); + return { status: "error", message: `Failed to create or find venv: ${errorMsg}` }; } applog.info(`[PronunciationChecker] About to run: ${venvPython} -u ${tempPyPath}`); return new Promise((resolve) => { - const py = startProcess(venvPython, ["-u", tempPyPath], (err) => { + const py = startProcess(venvPython, ["-u", tempPyPath], (err: Error) => { fsPromises.unlink(tempPyPath).catch(() => { applog.warn( "[PronunciationChecker] Failed to delete temp pronunciation checker file" @@ -151,11 +153,11 @@ if __name__ == "__main__": }); if (err) { applog.error( - `[PronunciationChecker] Python process exited with code: ${err.code}` + `[PronunciationChecker] Python process exited with code: ${(err as Error).message}` ); } }); - let lastJson = null; + let lastJson: Record | null = null; py.stdout.on("data", (data) => { const lines = data.toString().split(/\r?\n/); for (const line of lines) { @@ -223,13 +225,14 @@ if __name__ == "__main__": // IPC handler to get the recording blob for a given key const setupGetRecordingBlobIPC = () => { ipcMain.handle("get-recording-blob", async (_event, key) => { - const saveFolder = await getSaveFolder(readUserSettings); + const saveFolder = await getSaveFolder(); const filePath = path.join(saveFolder, "saved_recordings", `${key}.wav`); try { const data = await fsPromises.readFile(filePath); return data.buffer; // ArrayBuffer for renderer - } catch (err) { - throw new Error(`Failed to read recording: ${err.message}`); + } catch (err: unknown) { + const errorMsg = err instanceof Error ? err.message : String(err); + throw new Error(`Failed to read recording: ${errorMsg}`); } }); }; diff --git a/electron-main/pronunciationOperations.js b/src/electron/electron-main/pronunciationOperations.ts similarity index 73% rename from electron-main/pronunciationOperations.js rename to src/electron/electron-main/pronunciationOperations.ts index 0dd4f1770..8b50d3309 100644 --- a/electron-main/pronunciationOperations.js +++ b/src/electron/electron-main/pronunciationOperations.ts @@ -5,9 +5,9 @@ import { spawn } from "node:child_process"; import * as fsPromises from "node:fs/promises"; import path from "node:path"; import process from "node:process"; -import { getSaveFolder, readUserSettings, settingsConf } from "./filePath.js"; +import { getSaveFolder, settingsConf } from "./filePath.js"; -let currentPythonProcess = null; +let currentPythonProcess: ReturnType | null = null; let pendingCancel = false; let isGloballyCancelled = false; @@ -18,7 +18,10 @@ const checkPythonInstalled = async () => { let log = ""; return new Promise((resolve) => { // Try python3 first - const tryPython = (cmd, cb) => { + const tryPython = ( + cmd: string, + cb: (err: number | null, stdout: string, stderr: string) => void + ) => { const proc = spawn(cmd, ["--version"], { shell: true }); let stdout = ""; let stderr = ""; @@ -65,29 +68,43 @@ const checkPythonInstalled = async () => { }); }; -const startProcess = (cmd, args, onExit) => { +const startProcess = ( + cmd: string, + args: string[], + onExit: (err: { code: number } | null) => void +): ReturnType => { const proc = spawn(cmd, args, { shell: true }); currentPythonProcess = proc; if (pendingCancel) { // Wait 0.5s, then kill the process setTimeout(() => { - fkill(proc.pid, { force: true, tree: true }) - .then(() => { - console.log("[Cancel] Process killed after short delay due to pending cancel."); - }) - .catch((err) => { - if (err.message && err.message.includes("Process doesn't exist")) { - // Already dead, ignore - } else { - console.error("[Cancel] Error killing process after delay:", err); - } - }); - proc._wasCancelledImmediately = true; // Mark for downstream logic + if (typeof proc.pid !== "undefined") { + fkill(proc.pid, { force: true, tree: true }) + .then(() => { + console.log( + "[Cancel] Process killed after short delay due to pending cancel." + ); + }) + .catch((err: unknown) => { + if ( + err && + typeof err === "object" && + "message" in err && + typeof (err as { message: string }).message === "string" && + (err as { message: string }).message.includes("Process doesn't exist") + ) { + // Already dead, ignore + } else { + console.error("[Cancel] Error killing process after delay:", err); + } + }); + } + (proc as { _wasCancelledImmediately?: boolean })._wasCancelledImmediately = true; // Mark for downstream logic }, 500); // 0.5 second delay pendingCancel = false; } proc.on("close", (code) => { - onExit && onExit(code !== 0 ? { code } : null); + if (onExit) onExit(code !== 0 ? { code: code ?? -1 } : null); }); return proc; }; @@ -108,7 +125,7 @@ const resetGlobalCancel = () => { // --- VENV HELPERS --- const getVenvDir = async () => { - const saveFolder = await getSaveFolder(readUserSettings); + const saveFolder = await getSaveFolder(); return path.join(saveFolder, "pronunciation-venv"); }; @@ -156,7 +173,7 @@ const upgradeVenvPip = async () => { const ensureVenvExists = async () => { const venvDir = await getVenvDir(); - let venvPython = await getVenvPythonPath(); + const venvPython = await getVenvPythonPath(); try { await fsPromises.access(venvPython); // Already exists @@ -167,18 +184,18 @@ const ensureVenvExists = async () => { let systemPython = "python"; // Try to use python3 if available try { - await new Promise((resolve, reject) => { + await new Promise((resolve, reject) => { const proc = spawn("python3", ["--version"], { shell: true }); proc.on("close", (code) => { if (code === 0) resolve(); - else reject(); + else reject(void 0); }); }); systemPython = "python3"; } catch { // fallback to python } - await new Promise((resolve, reject) => { + await new Promise((resolve, reject) => { const proc = spawn(systemPython, ["-m", "venv", venvDir], { shell: true }); proc.on("close", (code) => { if (code === 0) resolve(); @@ -207,10 +224,10 @@ const installDependencies = () => { await upgradeVenvPip(); log += "Upgraded pip to latest version.\n"; } catch (err) { - log += `Failed to upgrade pip: ${err.message}\n`; + log += `Failed to upgrade pip: ${(err as Error).message}\n`; } } catch (err) { - log += `Failed to create virtual environment: ${err.message}\n`; + log += `Failed to create virtual environment: ${(err as Error).message}\n`; event.sender.send("pronunciation-dep-progress", { name: "all", status: "error", @@ -229,28 +246,36 @@ const installDependencies = () => { }); resolve({ deps: [{ name: "all", status }], log }); }); - pipProcess.stdout.on("data", (data) => { - log += data.toString(); - event.sender.send("pronunciation-dep-progress", { - name: "all", - status: "pending", - log: log.trim(), + if (pipProcess.stdout) { + pipProcess.stdout.on("data", (data) => { + log += data.toString(); + event.sender.send("pronunciation-dep-progress", { + name: "all", + status: "pending", + log: log.trim(), + }); }); - }); - pipProcess.stderr.on("data", (data) => { - log += data.toString(); - event.sender.send("pronunciation-dep-progress", { - name: "all", - status: "pending", - log: log.trim(), + } + if (pipProcess.stderr) { + pipProcess.stderr.on("data", (data) => { + log += data.toString(); + event.sender.send("pronunciation-dep-progress", { + name: "all", + status: "pending", + log: log.trim(), + }); }); - }); + } }); }); }; // Extracted model download logic -const downloadModelToDir = async (modelDir, modelName, onProgress) => { +const downloadModelToDir = async ( + modelDir: string, + modelName: string, + onProgress: ((msg: Record) => void) | undefined +) => { if (isGloballyCancelled) { if (onProgress) onProgress({ status: "cancelled", message: "Cancelled before start" }); return { status: "cancelled", message: "Cancelled before start" }; @@ -270,9 +295,12 @@ const downloadModelToDir = async (modelDir, modelName, onProgress) => { if (onProgress) onProgress({ status: "error", - message: `Failed to create virtual environment: ${err.message}`, + message: `Failed to create virtual environment: ${(err as Error).message}`, }); - return { status: "error", message: `Failed to create virtual environment: ${err.message}` }; + return { + status: "error", + message: `Failed to create virtual environment: ${(err as Error).message}`, + }; } // Write Python code to temp file in save folder const pyCode = ` @@ -376,7 +404,7 @@ else: ) sys.exit(1) `; - const saveFolder = await getSaveFolder(readUserSettings); + const saveFolder = await getSaveFolder(); const tempPyPath = path.join(saveFolder, "download_model_temp.py"); await fsPromises.writeFile(tempPyPath, pyCode, "utf-8"); // Emit 'downloading' status before launching Python process @@ -405,40 +433,44 @@ else: } }); // If process was cancelled immediately, resolve and do not proceed - if (py._wasCancelledImmediately) { + if ((py as { _wasCancelledImmediately?: boolean })._wasCancelledImmediately) { if (onProgress) onProgress({ status: "cancelled", message: "Process cancelled before start" }); resolve({ status: "cancelled", message: "Process cancelled before start" }); return; } - let lastStatus = null; + let lastStatus: Record | null = null; let hadError = false; - py.stdout.on("data", (data) => { - const str = data.toString(); - // Always forward raw output to renderer for logging - if (onProgress) onProgress({ status: "log", message: str }); - // Try to parse JSON lines as before - str.split(/\r?\n/).forEach((line) => { - if (line.trim()) { - try { - const msg = JSON.parse(line); - lastStatus = msg; - if (msg.status === "error") { - hadError = true; - if (onProgress) onProgress(msg); - } else { - if (onProgress) onProgress(msg); + if (py.stdout) { + py.stdout.on("data", (data) => { + const str = data.toString(); + // Always forward raw output to renderer for logging + if (onProgress) onProgress({ status: "log", message: str }); + // Try to parse JSON lines as before + str.split(/\r?\n/).forEach((line: string) => { + if (line.trim()) { + try { + const msg = JSON.parse(line); + lastStatus = msg; + if (msg.status === "error") { + hadError = true; + if (onProgress) onProgress(msg); + } else { + if (onProgress) onProgress(msg); + } + } catch { + // Ignore parse errors for non-JSON lines } - } catch { - // Ignore parse errors for non-JSON lines } - } + }); }); - }); - py.stderr.on("data", (data) => { - // Only log stderr, do not send error status unless process exits with error - if (onProgress) onProgress({ status: "log", message: data.toString() }); - }); + } + if (py.stderr) { + py.stderr.on("data", (data) => { + // Only log stderr, do not send error status unless process exits with error + if (onProgress) onProgress({ status: "log", message: data.toString() }); + }); + } py.on("exit", (code) => { currentPythonProcess = null; fsPromises.unlink(tempPyPath).catch(() => { @@ -463,20 +495,29 @@ else: }; const downloadModel = () => { - ipcMain.handle("pronunciation-download-model", async (event, modelName) => { - const saveFolder = await getSaveFolder(readUserSettings); + ipcMain.handle("pronunciation-download-model", async (event, modelName: string) => { + const saveFolder = await getSaveFolder(); // Replace / with _ for folder name const safeModelFolder = modelName.replace(/\//g, "_"); const modelDir = path.join(saveFolder, "phoneme-model", safeModelFolder); // Forward progress to renderer - const finalStatus = await downloadModelToDir(modelDir, modelName, (msg) => { - event.sender.send("pronunciation-model-progress", msg); - }); + const finalStatus = await downloadModelToDir( + modelDir, + modelName, + (msg: Record) => { + event.sender.send("pronunciation-model-progress", msg); + } + ); // After successful download, update user settings with modelName console.log( `[PronunciationOperations] finalStatus: ${JSON.stringify(finalStatus, null, 2)}` ); - if (finalStatus && (finalStatus.status === "success" || finalStatus.status === "found")) { + if ( + finalStatus && + typeof (finalStatus as { status?: string }).status === "string" && + ((finalStatus as { status?: string }).status === "success" || + (finalStatus as { status?: string }).status === "found") + ) { settingsConf.set("modelName", modelName); console.log(`[PronunciationOperations] modelName updated to ${modelName}`); console.log(`[PronunciationOperations] settingsConf: ${settingsConf.get("modelName")}`); @@ -495,8 +536,12 @@ const cancelProcess = () => { ); if (currentPythonProcess) { try { - await fkill(currentPythonProcess.pid, { force: true, tree: true }); - console.log("[Cancel] Python process tree killed with fkill."); + if (typeof currentPythonProcess.pid === "number") { + await fkill(currentPythonProcess.pid, { force: true, tree: true }); + console.log("[Cancel] Python process tree killed with fkill."); + } else { + console.log("[Cancel] No valid pid to kill."); + } } catch (err) { console.error("[Cancel] Error killing Python process tree:", err); } @@ -517,8 +562,12 @@ const cancelProcess = () => { const killCurrentPythonProcess = async () => { if (currentPythonProcess) { try { - await fkill(currentPythonProcess.pid, { force: true, tree: true }); - console.log("[Cleanup] Python process tree killed on app quit."); + if (typeof currentPythonProcess.pid === "number") { + await fkill(currentPythonProcess.pid, { force: true, tree: true }); + console.log("[Cleanup] Python process tree killed on app quit."); + } else { + console.log("[Cleanup] No valid pid to kill."); + } } catch (err) { console.error("[Cleanup] Error killing Python process tree on app quit:", err); } @@ -560,30 +609,31 @@ const setupPronunciationInstallStatusIPC = () => { }; // Helper to migrate old flat status to new structured format -const migrateOldStatusToStructured = (status) => { - if (!status) return null; +const migrateOldStatusToStructured = (status: unknown) => { + if (!status || typeof status !== "object" || status === null) return null; + const s = status as Record; // Try to extract python info const python = { - found: status.found, - version: status.version, + found: s.found, + version: s.version, }; // Dependencies: if array, use as is; if single dep, wrap in array - let dependencies = status.deps; + let dependencies = s.deps; if (!Array.isArray(dependencies)) { dependencies = dependencies ? [dependencies] : []; } // Model info const model = { - status: status.modelStatus || status.status, - message: status.modelMessage || status.message, - log: status.modelLog || "", + status: s.modelStatus || s.status, + message: s.modelMessage || s.message, + log: s.modelLog || "", }; return { python, dependencies, model, - stderr: status.stderr || "", - log: status.log || "", + stderr: s.stderr || "", + log: s.log || "", timestamp: Date.now(), }; }; @@ -592,10 +642,10 @@ export { cancelProcess, checkPythonInstalled, downloadModel, + ensureVenvExists, + getVenvPythonPath, installDependencies, killCurrentPythonProcess, resetGlobalCancel, setupPronunciationInstallStatusIPC, - ensureVenvExists, - getVenvPythonPath, }; diff --git a/electron-main/videoFileOperations.js b/src/electron/electron-main/videoFileOperations.ts similarity index 84% rename from electron-main/videoFileOperations.js rename to src/electron/electron-main/videoFileOperations.ts index 794520cb0..5731c4981 100644 --- a/electron-main/videoFileOperations.js +++ b/src/electron/electron-main/videoFileOperations.ts @@ -1,11 +1,11 @@ import { ipcMain } from "electron"; import * as fsPromises from "node:fs/promises"; import path from "node:path"; -import { getSaveFolder, readUserSettings } from "./filePath.js"; +import { getSaveFolder } from "./filePath.js"; const checkDownloads = () => { ipcMain.handle("check-downloads", async () => { - const saveFolder = await getSaveFolder(readUserSettings); + const saveFolder = await getSaveFolder(); const videoFolder = path.join(saveFolder, "video_files"); // Ensure the directory exists try { @@ -24,7 +24,7 @@ const checkDownloads = () => { // Check video extracted folder const checkExtractedFolder = () => { ipcMain.handle("check-extracted-folder", async (event, folderName, zipContents) => { - const saveFolder = await getSaveFolder(readUserSettings); + const saveFolder = await getSaveFolder(); const extractedFolder = path.join(saveFolder, "video_files", folderName); // Check if extracted folder exists @@ -33,9 +33,11 @@ const checkExtractedFolder = () => { const extractedFiles = await fsPromises.readdir(extractedFolder); // Check if all expected files are present in the extracted folder - const allFilesExtracted = zipContents[0].extractedFiles.every((file) => { - return extractedFiles.includes(file.name); - }); + const allFilesExtracted = zipContents[0].extractedFiles.every( + (file: { name: string }) => { + return extractedFiles.includes(file.name); + } + ); event.sender.send("progress-update", 0); diff --git a/electron-main/zipOperation.js b/src/electron/electron-main/zipOperation.ts similarity index 70% rename from electron-main/zipOperation.js rename to src/electron/electron-main/zipOperation.ts index 2037ef523..1d5ee944c 100644 --- a/electron-main/zipOperation.js +++ b/src/electron/electron-main/zipOperation.ts @@ -1,14 +1,14 @@ -import { ipcMain } from "electron"; +import { ipcMain, IpcMainEvent } from "electron"; import applog from "electron-log"; import JS7z from "js7z-tools"; import crypto from "node:crypto"; import fs from "node:fs"; import * as fsPromises from "node:fs/promises"; import path from "node:path"; -import { getSaveFolder, readUserSettings } from "./filePath.js"; +import { getSaveFolder } from "./filePath.js"; // Function to calculate the SHA-256 hash of a file -const calculateFileHash = (filePath) => { +const calculateFileHash = (filePath: string) => { return new Promise((resolve, reject) => { const hash = crypto.createHash("sha256"); const stream = fs.createReadStream(filePath); @@ -18,11 +18,15 @@ const calculateFileHash = (filePath) => { }); }; -const fileVerification = async (event, zipContents, extractedFolder) => { +const fileVerification = async ( + event: IpcMainEvent, + zipContents: { extractedFiles: { name: string; hash: string }[] }[], + extractedFolder: string +): Promise => { // Verify existing extracted files const totalFiles = zipContents[0].extractedFiles.length; let filesProcessed = 0; - let fileErrors = []; + const fileErrors: { type: string; name: string; message: string }[] = []; for (const file of zipContents[0].extractedFiles) { const extractedFilePath = path.join(extractedFolder, file.name); @@ -65,7 +69,7 @@ const fileVerification = async (event, zipContents, extractedFolder) => { param: extractedFolder, }); applog.log(`All extracted files are verified for "${extractedFolder}"`); - return; + return true; } else { // Send all errors as an array event.sender.send("verification-errors", fileErrors); @@ -73,7 +77,7 @@ const fileVerification = async (event, zipContents, extractedFolder) => { `Verification found errors in extracted files for "${extractedFolder}"`, fileErrors ); - return; + return false; } }; @@ -81,7 +85,7 @@ const fileVerification = async (event, zipContents, extractedFolder) => { const verifyAndExtractIPC = () => { ipcMain.on("verify-and-extract", async (event, zipFileData) => { const { zipFile, zipHash, zipContents } = zipFileData; - const saveFolder = await getSaveFolder(readUserSettings); + const saveFolder = await getSaveFolder(); const videoFolder = path.join(saveFolder, "video_files"); const zipFilePath = path.join(videoFolder, zipFile); const extractedFolder = path.join(videoFolder, zipFile.replace(".7z", "")); @@ -100,8 +104,10 @@ const verifyAndExtractIPC = () => { console.log(`Starting verification for ${zipFile}`); applog.log(`Starting verification for ${zipFile}`); try { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore: Potential issues with js7z-tools package (TS2349) const js7z = await JS7z({ - print: (text) => { + print: (text: string) => { console.log(`7-Zip output: ${text}`); applog.log(`7-Zip output: ${text}`); if (text.includes("%")) { @@ -112,20 +118,57 @@ const verifyAndExtractIPC = () => { } } }, - printErr: (errText) => { + printErr: (errText: string) => { console.error(`7-Zip error: ${errText}`); applog.error(`7-Zip error: ${errText}`); event.sender.send("verification-error", `7-Zip error: ${errText}`); }, - onAbort: (reason) => { + onAbort: (reason: string) => { console.error(`7-Zip aborted: ${reason}`); applog.error(`7-Zip aborted: ${reason}`); event.sender.send("verification-error", `7-Zip aborted: ${reason}`); }, - onExit: (exitCode) => { + onExit: async (exitCode: number) => { if (exitCode === 0) { console.log(`7-Zip exited successfully with code ${exitCode}`); applog.log(`7-Zip exited successfully with code ${exitCode}`); + + // Step 3: Verifying extracted files + event.sender.send( + "progress-text", + "settingPage.videoDownloadSettings.verifyinProgressMsg" + ); + const verificationSuccess = await fileVerification( + event, + zipContents, + extractedFolder + ); + + // Only clean up the ZIP file after successful extraction and verification + if (verificationSuccess) { + try { + await fsPromises.unlink(zipFilePath); + console.log(`Deleted ZIP file: ${zipFilePath}`); + applog.log(`Extraction successful for ${zipFile}`); + } catch (err) { + const errorMsg = + err instanceof Error ? err.message : String(err); + console.error(`Failed to delete ZIP file: ${errorMsg}`); + applog.error(`Failed to delete ZIP file: ${errorMsg}`); + } + + event.sender.send("verification-success", { + messageKey: + "settingPage.videoDownloadSettings.electronVerifyMessage.zipSuccessMsg", + param: zipFile, + }); + applog.log(`Successfully verified and extracted ${zipFile}`); + } else { + // Do not delete the ZIP file if verification failed + applog.log( + `Verification failed for ${zipFile}, ZIP file not deleted.` + ); + } } else { console.error(`7-Zip exited with error code: ${exitCode}`); applog.error(`7-Zip exited with error code: ${exitCode}`); @@ -163,58 +206,38 @@ const verifyAndExtractIPC = () => { } event.sender.send("progress-text", "ZIP file verified"); - // Step 2: Extracting ZIP file + // Step 2: Extracting ZIP file (using onExit handler) event.sender.send( "progress-text", "settingPage.videoDownloadSettings.electronVerifyMessage.zipExtractingMsg" ); - js7z.callMain(["x", emZipFilePath, `-o${emExtractedFolder}`]); - - js7z.onExit = async (exitCode) => { - if (exitCode !== 0) { - applog.error(`Error extracting ${zipFile}`); - event.sender.send("verification-error", { - messageKey: - "settingPage.videoDownloadSettings.electronVerifyMessage.zipErrorMsg", - param: zipFile, - }); - return; - } - - console.log(`Extraction successful for ${zipFile}`); - applog.log(`Extraction successful for ${zipFile}`); - - // Step 3: Verifying extracted files - event.sender.send( - "progress-text", - "settingPage.videoDownloadSettings.verifyinProgressMsg" - ); - await fileVerification(event, zipContents, extractedFolder); - - // Clean up the ZIP file after successful extraction and verification - try { - await fsPromises.unlink(zipFilePath); - console.log(`Deleted ZIP file: ${zipFilePath}`); - applog.log(`Extraction successful for ${zipFile}`); - } catch (err) { - console.error(`Failed to delete ZIP file: ${err.message}`); - applog.error(`Failed to delete ZIP file: ${err.message}`); - } - - event.sender.send("verification-success", { + + const extractionResult = await js7z.callMain([ + "x", + emZipFilePath, + `-o${emExtractedFolder}`, + ]); + + if (extractionResult !== 0) { + applog.error(`Error extracting ${zipFile}`); + event.sender.send("verification-error", { messageKey: - "settingPage.videoDownloadSettings.electronVerifyMessage.zipSuccessMsg", + "settingPage.videoDownloadSettings.electronVerifyMessage.zipErrorMsg", param: zipFile, }); - applog.log(`Successfully verified and extracted ${zipFile}`); - }; + return; + } + + console.log(`Extraction successful for ${zipFile}`); + applog.log(`Extraction successful for ${zipFile}`); } catch (err) { - console.error(`Error processing ${zipFile}: ${err.message}`); + const errorMsg = err instanceof Error ? err.message : String(err); + console.error(`Error processing ${zipFile}: ${errorMsg}`); event.sender.send("verification-error", { messageKey: "settingPage.videoDownloadSettings.electronVerifyMessage.zipErrorMsg", param: zipFile, - errorMessage: err.message, + errorMessage: errorMsg, }); } } else { diff --git a/main.js b/src/electron/main.ts similarity index 85% rename from main.js rename to src/electron/main.ts index e85bd3896..f19aa41fd 100644 --- a/main.js +++ b/src/electron/main.ts @@ -1,19 +1,20 @@ /* global setImmediate */ // for eslint because setImmediate is node global import cors from "cors"; +import type { IpcMainEvent } from "electron"; import { app, BrowserWindow, dialog, ipcMain, shell } from "electron"; +import type { LogFunctions, LogLevel } from "electron-log"; import applog from "electron-log"; import { Buffer } from "node:buffer"; import fs from "node:fs"; import * as fsPromises from "node:fs/promises"; import path from "node:path"; import process from "node:process"; -import { fileURLToPath } from "node:url"; import { Conf } from "electron-conf/main"; import { createSplashWindow, createWindow } from "./electron-main/createWindow.js"; import { setCustomSaveFolderIPC } from "./electron-main/customFolderLocationOperation.js"; import { expressApp } from "./electron-main/expressServer.js"; -import { getLogFolder, getSaveFolder, readUserSettings } from "./electron-main/filePath.js"; +import { getLogFolder, getSaveFolder } from "./electron-main/filePath.js"; import { getCustomSaveFolderIPC, getFfmpegWasmPathIPC, @@ -26,6 +27,10 @@ import { manageLogFiles, setCurrentLogSettings, } from "./electron-main/logOperations.js"; +import { + setupGetRecordingBlobIPC, + setupPronunciationCheckerIPC, +} from "./electron-main/pronunciationCheckerIPC.js"; import { cancelProcess, checkPythonInstalled, @@ -37,17 +42,6 @@ import { } from "./electron-main/pronunciationOperations.js"; import { checkDownloads, checkExtractedFolder } from "./electron-main/videoFileOperations.js"; import { verifyAndExtractIPC } from "./electron-main/zipOperation.js"; -import { - setupPronunciationCheckerIPC, - setupGetRecordingBlobIPC, -} from "./electron-main/pronunciationCheckerIPC.js"; - -const DEFAULT_PORT = 8998; - -let server; // Declare server at the top so it's in scope for all uses - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); let electronSquirrelStartup = false; try { @@ -78,7 +72,7 @@ expressApp.use(cors({ origin: "http://localhost:5173" })); // Set up the express server to serve video files expressApp.get("/video/:folderName/:fileName", async (req, res) => { const { folderName, fileName } = req.params; - const documentsPath = await getSaveFolder(readUserSettings); + const documentsPath = await getSaveFolder(); const videoFolder = path.resolve(documentsPath, "video_files", folderName); const videoFilePath = path.resolve(videoFolder, fileName); @@ -130,7 +124,7 @@ ipcMain.handle("open-external-link", async (event, url) => { // Handle saving a recording ipcMain.handle("save-recording", async (event, key, arrayBuffer) => { - const saveFolder = await getSaveFolder(readUserSettings); + const saveFolder = await getSaveFolder(); const recordingFolder = path.join(saveFolder, "saved_recordings"); const filePath = path.join(recordingFolder, `${key}.wav`); @@ -155,7 +149,7 @@ ipcMain.handle("save-recording", async (event, key, arrayBuffer) => { // Handle checking if a recording exists ipcMain.handle("check-recording-exists", async (event, key) => { - const saveFolder = await getSaveFolder(readUserSettings); + const saveFolder = await getSaveFolder(); const filePath = path.join(saveFolder, "saved_recordings", `${key}.wav`); try { @@ -168,11 +162,7 @@ ipcMain.handle("check-recording-exists", async (event, key) => { // Handle playing a recording (this can be improved for streaming) ipcMain.handle("play-recording", async (event, key) => { - const filePath = path.join( - await getSaveFolder(readUserSettings), - "saved_recordings", - `${key}.wav` - ); + const filePath = path.join(await getSaveFolder(), "saved_recordings", `${key}.wav`); // Check if the file exists try { @@ -187,7 +177,7 @@ ipcMain.handle("play-recording", async (event, key) => { /* Video file operations */ // Get video file data -getVideoFileDataIPC(__dirname); +getVideoFileDataIPC(app.getAppPath()); // IPC event to get and open the video folder getVideoSaveFolderIPC(); @@ -200,21 +190,16 @@ checkExtractedFolder(); /* End video file operations */ -// IPC event to get the current server port -ipcMain.handle("get-port", () => { - return server?.address()?.port || DEFAULT_PORT; -}); - ipcMain.handle("open-log-folder", async () => { // Open the folder in the file manager - const logFolder = await getLogFolder(readUserSettings); + const logFolder = await getLogFolder(); await shell.openPath(logFolder); // Open the folder return logFolder; // Send the path back to the renderer }); ipcMain.handle("open-recording-folder", async () => { // Open the folder in the file manager - const recordingFolder = await getSaveFolder(readUserSettings); + const recordingFolder = await getSaveFolder(); const recordingFolderPath = path.join(recordingFolder, "saved_recordings"); try { await fsPromises.access(recordingFolderPath); @@ -229,12 +214,17 @@ ipcMain.handle("open-recording-folder", async () => { verifyAndExtractIPC(); // Listen for logging messages from the renderer process -ipcMain.on("renderer-log", (event, logMessage) => { - const { level, message } = logMessage; - if (applog[level]) { - applog[level](`Renderer log: ${message}`); +ipcMain.on( + "renderer-log", + (event: IpcMainEvent, logMessage: { level: LogLevel; message: string }) => { + const { level, message } = logMessage; + // Only allow valid log levels + const logFn = (applog as LogFunctions)[level]; + if (typeof logFn === "function") { + logFn(`Renderer log: ${message}`); + } } -}); +); // Handle uncaught exceptions globally and quit the app process.on("uncaughtException", (error) => { @@ -250,10 +240,13 @@ process.on("unhandledRejection", (reason, promise) => { app.quit(); // Quit the app on an unhandled promise rejection }); -app.on("renderer-process-crashed", (event, webContents, killed) => { - applog.error("Renderer process crashed", { event, killed }); - app.quit(); -}); +/*app.on( + "render-process-gone", + (event: Electron.Event, details: Electron.RenderProcessGoneDetails) => { + applog.error("Renderer process crashed", { event, details }); + app.quit(); + } +);*/ // Quit when all windows are closed, except on macOS. app.on("window-all-closed", () => { @@ -265,9 +258,7 @@ app.on("window-all-closed", () => { // Recreate the window on macOS when the dock icon is clicked. app.on("activate", () => { if (mainWindow === null) { - createWindow(__dirname, (srv) => { - server = srv; - }); + createWindow(app.getAppPath()); } }); @@ -280,14 +271,12 @@ if (!gotTheLock) { app.whenReady() .then(() => { // 1. Show splash window immediately - createSplashWindow(__dirname, ipcMain, conf); + createSplashWindow(app.getAppPath(), ipcMain, conf); // 2. Start heavy work in parallel after splash is shown setImmediate(() => { // Create main window (can be shown after splash) - createWindow(__dirname, (srv) => { - server = srv; - }); + createWindow(app.getAppPath()); // Wait for log settings and manage logs in background ipcMain.once("update-log-settings", (event, settings) => { @@ -305,7 +294,7 @@ if (!gotTheLock) { }); } -getFfmpegWasmPathIPC(__dirname); +getFfmpegWasmPathIPC(app.getAppPath()); /* Custom save folder operations */ @@ -323,8 +312,11 @@ setCustomSaveFolderIPC(); // IPC: Show open dialog for folder selection ipcMain.handle("show-open-dialog", async (event, options) => { const win = BrowserWindow.getFocusedWindow(); - const result = await dialog.showOpenDialog(win, options); - return result.filePaths; + if (!win) { + throw new Error("No focused window found"); + } + const filePaths = dialog.showOpenDialog(win, options); + return filePaths; }); ipcMain.handle("get-log-settings", async () => { @@ -345,7 +337,11 @@ console.log = (...args) => { ipcMain.handle("check-python-installed", async () => { try { - const result = await checkPythonInstalled(); + const result = (await checkPythonInstalled()) as { + found: boolean; + version: string | null; + stderr: string | null; + }; if (result.found) { applog.info("Python found:", result.version); } else { @@ -375,7 +371,7 @@ ipcMain.handle("pronunciation-reset-cancel-flag", async () => { /* End pronunciation checker operations */ ipcMain.handle("get-recording-path", async (_event, wordKey) => { - const saveFolder = await getSaveFolder(readUserSettings); + const saveFolder = await getSaveFolder(); return path.join(saveFolder, "saved_recordings", `${wordKey}.wav`); }); diff --git a/src/electron/preload.mts b/src/electron/preload.mts new file mode 100644 index 000000000..a9d0f9b2d --- /dev/null +++ b/src/electron/preload.mts @@ -0,0 +1,38 @@ +import { contextBridge, ipcRenderer } from "electron"; + +contextBridge.exposeInMainWorld("electron", { + openExternal: (url: string) => ipcRenderer.invoke("open-external-link", url), + saveRecording: (key: string, arrayBuffer: ArrayBuffer) => + ipcRenderer.invoke("save-recording", key, arrayBuffer), + checkRecordingExists: (key: string) => ipcRenderer.invoke("check-recording-exists", key), + playRecording: (key: string) => ipcRenderer.invoke("play-recording", key), + ipcRenderer: { + invoke: (channel: string, ...args: unknown[]) => ipcRenderer.invoke(channel, ...args), + send: (channel: string, ...args: unknown[]) => ipcRenderer.send(channel, ...args), + on: (channel: string, func: (...args: unknown[]) => void) => ipcRenderer.on(channel, func), + removeAllListeners: (channel: string) => ipcRenderer.removeAllListeners(channel), + removeListener: (channel: string, func: (...args: unknown[]) => void) => + ipcRenderer.removeListener(channel, func), + }, + getDirName: () => __dirname, + isUwp: () => process.windowsStore, + send: (channel: string, data: unknown) => { + ipcRenderer.send(channel, data); + }, + log: (level: string, message: string) => { + // Send log message to the main process + ipcRenderer.send("renderer-log", { level, message }); + }, + getRecordingBlob: async (key: string) => { + // Use IPC to ask the main process for the blob + return await ipcRenderer.invoke("get-recording-blob", key); + }, + getFfmpegWasmPath: async () => { + return await ipcRenderer.invoke("get-ffmpeg-wasm-path"); + }, + getFileAsBlobUrl: async (filePath: string, mimeType: string) => { + const arrayBuffer = await ipcRenderer.invoke("read-file-buffer", filePath); + const blob = new Blob([arrayBuffer], { type: mimeType }); + return URL.createObjectURL(blob); + }, +}); diff --git a/src/electron/tsconfig.electron.json b/src/electron/tsconfig.electron.json new file mode 100644 index 000000000..315358cdc --- /dev/null +++ b/src/electron/tsconfig.electron.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "composite": true, + "declaration": true, + "allowSyntheticDefaultImports": true, // if esModuleInterop is set to true, this property is automatically true + "esModuleInterop": true, + "module": "NodeNext", // tell TypeScript to require ESM Syntax as input ( includin .js file imports) when we write our code + "moduleResolution": "nodenext", + "outDir": "../../dist-electron", + "resolveJsonModule": true, + "skipLibCheck": true, // ignore errors from dependencies that are not fully ready for typescript + "sourceMap": false, + "strict": true, // require strict types (null-save) + "strictPropertyInitialization": false, + "target": "ESNext", // tell TypeScript to generate ESM Syntax when build + "types": ["./window.d.ts"], + "forceConsistentCasingInFileNames": true, + "baseUrl": ".", + "paths": { + "~/*": ["../../*"] + } + }, + "include": ["."] +} diff --git a/src/electron/window.d.ts b/src/electron/window.d.ts new file mode 100644 index 000000000..09ff3b753 --- /dev/null +++ b/src/electron/window.d.ts @@ -0,0 +1,26 @@ +declare global { + interface Window { + electron: { + openExternal: (url: string) => Promise; + saveRecording: (key: string, arrayBuffer: ArrayBuffer) => Promise; + checkRecordingExists: (key: string) => Promise; + playRecording: (key: string) => Promise; + ipcRenderer: { + invoke: (channel: string, ...args: unknown[]) => Promise; + send: (channel: string, ...args: unknown[]) => void; + on: (channel: string, func: (...args: unknown[]) => void) => void; + removeAllListeners: (channel: string) => void; + removeListener: (channel: string, func: (...args: unknown[]) => void) => void; + }; + getDirName: () => string; + isUwp: () => boolean | undefined; + send: (channel: string, data: unknown) => void; + log: (level: string, message: string) => void; + getRecordingBlob: (key: string) => Promise; + getFfmpegWasmPath: () => Promise; + getFileAsBlobUrl: (filePath: string, mimeType: string) => Promise; + }; + } +} + +export {}; diff --git a/src/App.jsx b/src/frontend/App.tsx similarity index 80% rename from src/App.jsx rename to src/frontend/App.tsx index bde6870de..1a9ab6809 100644 --- a/src/App.jsx +++ b/src/frontend/App.tsx @@ -1,45 +1,43 @@ import { Suspense, lazy } from "react"; import { BrowserRouter, HashRouter, Route, Routes } from "react-router-dom"; import { Toaster } from "sonner"; -import LoadingOverlay from "./components/general/LoadingOverlay"; -import NotFound from "./components/general/NotFound"; -import Homepage from "./components/Homepage"; -import ErrorBoundary from "./ErrorBoundary"; -import isElectron from "./utils/isElectron"; -import ThemeProvider from "./utils/ThemeContext/ThemeProvider"; -import { useTheme } from "./utils/ThemeContext/useTheme"; -import VersionUpdateDialog from "./components/general/VersionUpdateDialog"; +import LoadingOverlay from "./components/general/LoadingOverlay.js"; +import NotFound from "./components/general/NotFound.js"; +import Homepage from "./components/Homepage.js"; +import ErrorBoundary from "./ErrorBoundary.js"; +import isElectron from "./utils/isElectron.js"; +import ThemeProvider from "./utils/ThemeContext/ThemeProvider.js"; +import useTheme from "./utils/ThemeContext/useTheme.js"; +import VersionUpdateDialog from "./components/general/VersionUpdateDialog.js"; import { useState, useEffect } from "react"; -const SoundList = lazy(() => import("./components/sound_page/SoundList")); -const WordList = lazy(() => import("./components/word_page/WordList")); -const ConversationMenu = lazy(() => import("./components/conversation_page/ConversationMenu")); -const ExamPage = lazy(() => import("./components/exam_page/ExamPage")); -const ExercisePage = lazy(() => import("./components/exercise_page/ExercisePage")); -const SettingsPage = lazy(() => import("./components/setting_page/Settings")); -const DownloadPage = lazy(() => import("./components/download_page/DownloadPage")); +const SoundList = lazy(() => import("./components/sound_page/SoundList.js")); +const WordList = lazy(() => import("./components/word_page/WordList.js")); +const ConversationMenu = lazy(() => import("./components/conversation_page/ConversationMenu.js")); +const ExamPage = lazy(() => import("./components/exam_page/ExamPage.js")); +const ExercisePage = lazy(() => import("./components/exercise_page/ExercisePage.js")); +const SettingsPage = lazy(() => import("./components/setting_page/Settings.js")); +const DownloadPage = lazy(() => import("./components/download_page/DownloadPage.js")); const RouterComponent = isElectron() ? HashRouter : BrowserRouter; const PROD_BASE_URL = "https://learnercraft.github.io/ispeakerreact"; const isProdWeb = - import.meta.env.PROD && - !isElectron() && - window.location.href.startsWith(PROD_BASE_URL); + import.meta.env.PROD && !isElectron() && window.location.href.startsWith(PROD_BASE_URL); // Ensure baseUrl does not add unnecessary slashes const baseUrl = isElectron() ? "" : (() => { - switch (import.meta.env.BASE_URL) { - case "/": - case "./": - return ""; // Use no basename for "/" or "./" - default: - return import.meta.env.BASE_URL; - } - })(); + switch (import.meta.env.BASE_URL) { + case "/": + case "./": + return ""; // Use no basename for "/" or "./" + default: + return import.meta.env.BASE_URL; + } + })(); // Clear web cache if a newer version is found @@ -48,7 +46,7 @@ const AppContent = () => { const { theme } = useTheme(); const toastTheme = theme === "dark" || - (theme === "auto" && window.matchMedia("(prefers-color-scheme: dark)").matches) + (theme === "auto" && window.matchMedia("(prefers-color-scheme: dark)").matches) ? "dark" : "light"; @@ -131,7 +129,7 @@ const AppContent = () => { @@ -140,7 +138,7 @@ const AppContent = () => { const App = () => ( - + diff --git a/src/frontend/ErrorBoundary.tsx b/src/frontend/ErrorBoundary.tsx new file mode 100644 index 000000000..35ef2199d --- /dev/null +++ b/src/frontend/ErrorBoundary.tsx @@ -0,0 +1,11 @@ +import { useTranslation } from "react-i18next"; +import ErrorBoundaryInner from "./ErrorBoundaryInner.js"; +import type { ErrorBoundaryProps } from "./ErrorBoundaryInner.js"; + +const ErrorBoundary = (props: Omit) => { + const { t } = useTranslation(); + + return {props.children}; +}; + +export default ErrorBoundary; diff --git a/src/ErrorBoundaryInner.jsx b/src/frontend/ErrorBoundaryInner.tsx similarity index 80% rename from src/ErrorBoundaryInner.jsx rename to src/frontend/ErrorBoundaryInner.tsx index fbbfa794d..3f9d24cbd 100644 --- a/src/ErrorBoundaryInner.jsx +++ b/src/frontend/ErrorBoundaryInner.tsx @@ -1,24 +1,35 @@ -import PropTypes from "prop-types"; import React from "react"; import { FaGithub } from "react-icons/fa"; import { FiRefreshCw } from "react-icons/fi"; import { HiOutlineClipboardCopy } from "react-icons/hi"; import { Toaster } from "sonner"; -import Container from "./ui/Container"; -import openExternal from "./utils/openExternal"; -import { sonnerSuccessToast } from "./utils/sonnerCustomToast"; +import Container from "./ui/Container.js"; +import openExternal from "./utils/openExternal.js"; +import { sonnerSuccessToast } from "./utils/sonnerCustomToast.js"; -export default class ErrorBoundary extends React.Component { - constructor(props) { +// Define the props and state types +export interface ErrorBoundaryProps { + t: (key: string) => string; + children: React.ReactNode; +} + +interface ErrorBoundaryState { + hasError: boolean; + error: Error | null; + errorInfo: React.ErrorInfo | null; +} + +export default class ErrorBoundary extends React.Component { + constructor(props: ErrorBoundaryProps) { super(props); this.state = { hasError: false, error: null, errorInfo: null }; } - static getDerivedStateFromError(error) { + static getDerivedStateFromError(error: Error) { return { hasError: true, error }; } - componentDidCatch(error, errorInfo) { + componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { console.error("React Error Boundary caught an error:", error, errorInfo); this.setState({ errorInfo }); } @@ -68,6 +79,7 @@ App version: v${__APP_VERSION__}\n${error?.toString()}\n\nStack Trace:\n${errorI {t("appCrash.copyBtn")} - @@ -88,7 +100,7 @@ App version: v${__APP_VERSION__}\n${error?.toString()}\n\nStack Trace:\n${errorI ); @@ -97,8 +109,3 @@ App version: v${__APP_VERSION__}\n${error?.toString()}\n\nStack Trace:\n${errorI return this.props.children; } } - -ErrorBoundary.propTypes = { - t: PropTypes.func.isRequired, - children: PropTypes.node.isRequired, -}; diff --git a/src/components/Homepage.jsx b/src/frontend/components/Homepage.tsx similarity index 93% rename from src/components/Homepage.jsx rename to src/frontend/components/Homepage.tsx index d7d10a24f..43131783f 100644 --- a/src/components/Homepage.jsx +++ b/src/frontend/components/Homepage.tsx @@ -1,18 +1,18 @@ import { useEffect } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; -import Container from "../ui/Container"; -import isElectron from "../utils/isElectron"; -import Footer from "./general/Footer"; -import LogoLightOrDark from "./general/LogoLightOrDark"; -import TopNavBar from "./general/TopNavBar"; +import Container from "../ui/Container.js"; +import isElectron from "../utils/isElectron.js"; +import Footer from "./general/Footer.js"; +import LogoLightOrDark from "./general/LogoLightOrDark.js"; +import TopNavBar from "./general/TopNavBar.js"; const Homepage = () => { const { t } = useTranslation(); const navigate = useNavigate(); - const handleNavigate = (path) => { + const handleNavigate = (path: string) => { navigate(path); }; @@ -80,7 +80,7 @@ const Homepage = () => {
- +

iSpeakerReact

diff --git a/src/components/conversation_page/ConversationDetailPage.jsx b/src/frontend/components/conversation_page/ConversationDetailPage.tsx similarity index 80% rename from src/components/conversation_page/ConversationDetailPage.jsx rename to src/frontend/components/conversation_page/ConversationDetailPage.tsx index 3036292ef..17532a1b0 100644 --- a/src/components/conversation_page/ConversationDetailPage.jsx +++ b/src/frontend/components/conversation_page/ConversationDetailPage.tsx @@ -1,27 +1,29 @@ -import PropTypes from "prop-types"; import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { IoChevronBackOutline } from "react-icons/io5"; import { MdChecklist, MdHeadphones, MdKeyboardVoice, MdOutlineOndemandVideo } from "react-icons/md"; -import isElectron from "../../utils/isElectron"; -import { useScrollTo } from "../../utils/useScrollTo"; -import LoadingOverlay from "../general/LoadingOverlay"; -import ListeningTab from "./ListeningTab"; -import PracticeTab from "./PracticeTab"; -import ReviewTab from "./ReviewTab"; -import WatchAndStudyTab from "./WatchAndStudyTab"; - -const ConversationDetailPage = ({ id, accent, title, onBack }) => { +import isElectron from "../../utils/isElectron.js"; +import useScrollTo from "../../utils/useScrollTo.js"; +import LoadingOverlay from "../general/LoadingOverlay.js"; +import ListeningTab from "./ListeningTab.js"; +import PracticeTab from "./PracticeTab.js"; +import ReviewTab from "./ReviewTab.js"; +import WatchAndStudyTab from "./WatchAndStudyTab.js"; +import type { AccentData, ConversationDetailPageProps } from "./types.js"; + +const ConversationDetailPage = ({ id, accent, title, onBack }: ConversationDetailPageProps) => { const { t } = useTranslation(); const { ref: scrollRef, scrollTo } = useScrollTo(); - const [activeTab, setActiveTab] = useState("watchStudyTab"); - const [loading, setLoading] = useState(true); - const [accentData, setAccentData] = useState(null); + const [activeTab, setActiveTab] = useState< + "watchStudyTab" | "listenTab" | "practiceTab" | "reviewTab" + >("watchStudyTab"); + const [loading, setLoading] = useState(true); + const [accentData, setAccentData] = useState(null); - const [videoUrl, setVideoUrl] = useState(null); - const [videoLoading, setVideoLoading] = useState(true); - const [port, setPort] = useState(null); + const [videoUrl, setVideoUrl] = useState(""); + const [videoLoading, setVideoLoading] = useState(true); + const [port, setPort] = useState(null); useEffect(() => { const fetchData = async () => { @@ -37,7 +39,8 @@ const ConversationDetailPage = ({ id, accent, title, onBack }) => { const conversationData = data[id]?.[0]; if (conversationData) { - const accentData = conversationData[accent === "british" ? "BrE" : "AmE"]; + const accentData: AccentData = + conversationData[accent === "british" ? "BrE" : "AmE"]; setAccentData(accentData); // Set the accent-specific data setLoading(false); @@ -59,9 +62,14 @@ const ConversationDetailPage = ({ id, accent, title, onBack }) => { // Fetch the dynamic port if running in Electron useEffect(() => { const fetchPort = async () => { - if (window.electron?.ipcRenderer) { - const dynamicPort = await window.electron.ipcRenderer.invoke("get-port"); - setPort(dynamicPort); + const electron = ( + window as unknown as { + electron?: { ipcRenderer?: { invoke: (channel: string) => Promise } }; + } + ).electron; + if (electron?.ipcRenderer) { + const dynamicPort = await electron.ipcRenderer.invoke("get-port"); + setPort(Number(dynamicPort)); } }; fetchPort(); @@ -191,7 +199,7 @@ const ConversationDetailPage = ({ id, accent, title, onBack }) => { className="card card-lg card-border mb-6 w-full shadow-md dark:border-slate-600" >
- {activeTab === "watchStudyTab" && ( + {activeTab === "watchStudyTab" && accentData && ( { skillCheckmark={ accentData.watch_and_study.study.skill_checkmark } - scrollTo={scrollTo} /> )} - {activeTab === "listenTab" && ( - + {activeTab === "listenTab" && accentData && ( + )} - {activeTab === "practiceTab" && ( - + {activeTab === "practiceTab" && accentData && ( + )} - {activeTab === "reviewTab" && ( + {activeTab === "reviewTab" && accentData && ( )}
@@ -234,11 +233,4 @@ const ConversationDetailPage = ({ id, accent, title, onBack }) => { ); }; -ConversationDetailPage.propTypes = { - id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, - accent: PropTypes.string.isRequired, - title: PropTypes.string.isRequired, - onBack: PropTypes.func.isRequired, -}; - export default ConversationDetailPage; diff --git a/src/components/conversation_page/ConversationMenu.jsx b/src/frontend/components/conversation_page/ConversationMenu.tsx similarity index 82% rename from src/components/conversation_page/ConversationMenu.jsx rename to src/frontend/components/conversation_page/ConversationMenu.tsx index 088951e0c..2d594fb4d 100644 --- a/src/components/conversation_page/ConversationMenu.jsx +++ b/src/frontend/components/conversation_page/ConversationMenu.tsx @@ -1,29 +1,39 @@ -import PropTypes from "prop-types"; import { Suspense, lazy, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { IoInformationCircleOutline } from "react-icons/io5"; -import Container from "../../ui/Container"; -import AccentLocalStorage from "../../utils/AccentLocalStorage"; -import isElectron from "../../utils/isElectron"; -import AccentDropdown from "../general/AccentDropdown"; -import LoadingOverlay from "../general/LoadingOverlay"; -import TopNavBar from "../general/TopNavBar"; - -const ConversationDetailPage = lazy(() => import("./ConversationDetailPage")); +import Container from "../../ui/Container.js"; +import AccentLocalStorage from "../../utils/AccentLocalStorage.js"; +import isElectron from "../../utils/isElectron.js"; +import AccentDropdown from "../general/AccentDropdown.js"; +import LoadingOverlay from "../general/LoadingOverlay.js"; +import TopNavBar from "../general/TopNavBar.js"; +import type { + ConversationCardProps, + ConversationSection, + SelectedConversation, + TooltipIconProps, +} from "./types.js"; + +// AccentLocalStorage returns [string, (accent: string) => void] +type AccentLocalStorageReturn = [string, (accent: string) => void]; + +const ConversationDetailPage = lazy(() => import("./ConversationDetailPage.js")); const ConversationListPage = () => { const { t } = useTranslation(); - const [data, setData] = useState([]); - const [loading, setLoading] = useState(true); - const [selectedAccent, setSelectedAccent] = AccentLocalStorage(); - const [selectedConversation, setSelectedConversation] = useState(null); + const [data, setData] = useState([]); + const [loading, setLoading] = useState(true); + const [selectedAccent, setSelectedAccent] = AccentLocalStorage() as AccentLocalStorageReturn; + const [selectedConversation, setSelectedConversation] = useState( + null + ); // Modal state - const modalRef = useRef(null); - const [modalInfo, setModalInfo] = useState(null); + const modalRef = useRef(null); + const [modalInfo, setModalInfo] = useState(null); // Handle showing the modal - const handleShowModal = (info) => { + const handleShowModal = (info: string) => { setModalInfo(info); if (modalRef.current) { modalRef.current.showModal(); @@ -38,7 +48,7 @@ const ConversationListPage = () => { setModalInfo(null); }; - const handleSelectConversation = (id, title) => { + const handleSelectConversation = (id: string, title: string) => { const selected = data.find((section) => section.titles.some((item) => item.id === id)); if (selected) { setSelectedConversation({ @@ -49,7 +59,7 @@ const ConversationListPage = () => { } }; - const TooltipIcon = ({ info, onClick }) => ( + const TooltipIcon = ({ info, onClick }: TooltipIconProps) => ( <> {/* Tooltip for larger screens */}
{ ); - TooltipIcon.propTypes = { - info: PropTypes.string.isRequired, - onClick: PropTypes.func.isRequired, - }; - - const ConversationCard = ({ heading, titles, onShowModal }) => ( + const ConversationCard = ({ heading, titles, onShowModal }: ConversationCardProps) => (
{t(heading)}
@@ -96,18 +101,6 @@ const ConversationListPage = () => {
); - ConversationCard.propTypes = { - heading: PropTypes.string.isRequired, - titles: PropTypes.arrayOf( - PropTypes.shape({ - title: PropTypes.string.isRequired, - id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, - info: PropTypes.string.isRequired, - }) - ).isRequired, - onShowModal: PropTypes.func.isRequired, - }; - useEffect(() => { const fetchData = async () => { try { @@ -132,7 +125,8 @@ const ConversationListPage = () => { }, []); useEffect(() => { - const storedData = JSON.parse(localStorage.getItem("ispeaker")) || {}; + const stored = localStorage.getItem("ispeaker"); + const storedData = stored ? JSON.parse(stored) : {}; localStorage.setItem("ispeaker", JSON.stringify({ ...storedData, selectedAccent })); }, [selectedAccent]); diff --git a/src/components/conversation_page/ListeningTab.jsx b/src/frontend/components/conversation_page/ListeningTab.tsx similarity index 88% rename from src/components/conversation_page/ListeningTab.jsx rename to src/frontend/components/conversation_page/ListeningTab.tsx index 92a15d12a..101919d06 100644 --- a/src/components/conversation_page/ListeningTab.jsx +++ b/src/frontend/components/conversation_page/ListeningTab.tsx @@ -1,17 +1,19 @@ -import PropTypes from "prop-types"; import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { IoVolumeHigh, IoVolumeHighOutline } from "react-icons/io5"; -import { sonnerErrorToast } from "../../utils/sonnerCustomToast"; +import { sonnerErrorToast } from "../../utils/sonnerCustomToast.js"; +import type { ListeningTabProps } from "./types.js"; -const ListeningTab = ({ sentences }) => { +const ListeningTab = ({ sentences }: ListeningTabProps) => { const { t } = useTranslation(); - const [currentAudio, setCurrentAudio] = useState(null); - const [playingIndex, setPlayingIndex] = useState(null); - const [loadingIndex, setLoadingIndex] = useState(null); + const [currentAudio, setCurrentAudio] = useState(null); + const [playingIndex, setPlayingIndex] = useState(null); + const [loadingIndex, setLoadingIndex] = useState(null); - const handlePlayPause = (index, audioSrc) => { + const abortController = useRef(null); + + const handlePlayPause = (index: string, audioSrc: string) => { if (loadingIndex === index) { // Cancel the loading process if clicked again if (abortController.current) { @@ -89,8 +91,6 @@ const ListeningTab = ({ sentences }) => { } }; - const abortController = useRef(null); - useEffect(() => { return () => { if (abortController.current) { @@ -124,14 +124,9 @@ const ListeningTab = ({ sentences }) => {
  • handlePlayPause(uniqueIdx, sentenceObj.audioSrc) } - aria-pressed={playingIndex === uniqueIdx} - aria-disabled={ - loadingIndex !== null && loadingIndex !== uniqueIdx - } >
    @@ -165,8 +160,4 @@ const ListeningTab = ({ sentences }) => { ); }; -ListeningTab.propTypes = { - sentences: PropTypes.array.isRequired, -}; - export default ListeningTab; diff --git a/src/components/conversation_page/PracticeTab.jsx b/src/frontend/components/conversation_page/PracticeTab.tsx similarity index 74% rename from src/components/conversation_page/PracticeTab.jsx rename to src/frontend/components/conversation_page/PracticeTab.tsx index d4b02b489..982e73799 100644 --- a/src/components/conversation_page/PracticeTab.jsx +++ b/src/frontend/components/conversation_page/PracticeTab.tsx @@ -1,35 +1,36 @@ import { useEffect, useRef, useState } from "react"; -import { BsFloppy, BsPlayCircle, BsRecordCircle, BsStopCircle, BsTrash } from "react-icons/bs"; -import PropTypes from "prop-types"; - import { useTranslation } from "react-i18next"; +import { BsFloppy, BsPlayCircle, BsRecordCircle, BsStopCircle, BsTrash } from "react-icons/bs"; import { checkRecordingExists, openDatabase, playRecording, saveRecording, -} from "../../utils/databaseOperations"; -import isElectron from "../../utils/isElectron"; +} from "../../utils/databaseOperations.js"; +import isElectron from "../../utils/isElectron.js"; import { sonnerErrorToast, sonnerSuccessToast, sonnerWarningToast, -} from "../../utils/sonnerCustomToast"; +} from "../../utils/sonnerCustomToast.js"; +import type { PracticeTabProps } from "./types.js"; -const PracticeTab = ({ accent, conversationId }) => { +const PracticeTab = ({ accent, conversationId }: PracticeTabProps) => { const { t } = useTranslation(); - const [textValue, setTextValue] = useState(""); - const [isRecording, setIsRecording] = useState(false); - const [mediaRecorder, setMediaRecorder] = useState(null); - const [isRecordingPlaying, setIsRecordingPlaying] = useState(false); - const [recordingExists, setRecordingExists] = useState(false); - const [currentAudioSource, setCurrentAudioSource] = useState(null); // For AudioContext source node - const [currentAudioElement, setCurrentAudioElement] = useState(null); // For Audio element (fallback) - const textAreaRef = useRef(null); + const [textValue, setTextValue] = useState(""); + const [isRecording, setIsRecording] = useState(false); + const [mediaRecorder, setMediaRecorder] = useState(null); + const [isRecordingPlaying, setIsRecordingPlaying] = useState(false); + const [recordingExists, setRecordingExists] = useState(false); + const [currentAudioSource, setCurrentAudioSource] = useState( + null + ); // For AudioContext source node + const [currentAudioElement, setCurrentAudioElement] = useState(null); // For Audio element (fallback) + const textAreaRef = useRef(null); - const textKey = `${accent}-${conversationId}-text`; - const recordingKey = `${accent}-conversation-${conversationId}`; + const textKey = `conversation-${conversationId}-${accent}-text`; + const recordingKey = `conversation-${conversationId}-${accent}-recording`; // Load saved text from IndexedDB useEffect(() => { @@ -83,13 +84,15 @@ const PracticeTab = ({ accent, conversationId }) => { request.onsuccess = () => { sonnerSuccessToast(t("toast.textSaveSuccess")); }; - request.onerror = (error) => { - isElectron() && window.electron.log("error", `Error saving text: ${error}`); - sonnerErrorToast(t("toast.textSaveFailed") + error.message); + request.onerror = (error: Event) => { + if (isElectron()) window.electron.log("error", `Error saving text: ${error}`); + sonnerErrorToast( + t("toast.textSaveFailed") + (error instanceof Error ? error.message : "") + ); }; } catch (error) { console.error("Error saving text: ", error); - isElectron() && window.electron.log("error", `Error saving text: ${error}`); + if (isElectron()) window.electron.log("error", `Error saving text: ${error}`); } }; @@ -105,14 +108,18 @@ const PracticeTab = ({ accent, conversationId }) => { setTextValue(""); sonnerSuccessToast(t("toast.textClearSuccess")); }; - request.onerror = (error) => { - sonnerErrorToast(t("toast.textClearFailed") + error.message); - isElectron() && window.electron.log("error", `Error clearing text: ${error}`); + request.onerror = (error: Event) => { + sonnerErrorToast( + t("toast.textClearFailed") + (error instanceof Error ? error.message : "") + ); + if (isElectron()) window.electron.log("error", `Error clearing text: ${error}`); }; } catch (error) { console.error("Error clearing text: ", error); - isElectron() && window.electron.log("error", `Error clearing text: ${error}`); - sonnerErrorToast(t("toast.textClearFailed") + error.message); + if (isElectron()) window.electron.log("error", `Error clearing text: ${error}`); + sonnerErrorToast( + t("toast.textClearFailed") + (error instanceof Error ? error.message : "") + ); } }; @@ -127,20 +134,20 @@ const PracticeTab = ({ accent, conversationId }) => { audioBitsPerSecond: 128000, }; const mediaRecorder = new MediaRecorder(stream, recordOptions); - let audioChunks = []; + let audioChunks: Blob[] = []; mediaRecorder.start(); setIsRecording(true); setMediaRecorder(mediaRecorder); - mediaRecorder.addEventListener("dataavailable", (event) => { + mediaRecorder.addEventListener("dataavailable", (event: BlobEvent) => { audioChunks.push(event.data); if (mediaRecorder.state === "inactive") { const audioBlob = new Blob(audioChunks, { type: event.data.type }); saveRecording(audioBlob, recordingKey, event.data.type); sonnerSuccessToast(t("toast.recordingSuccess")); - isElectron() && + if (isElectron()) { window.electron.log("log", `Recording saved: ${recordingKey}`); - + } setRecordingExists(true); audioChunks = []; } @@ -158,13 +165,14 @@ const PracticeTab = ({ accent, conversationId }) => { 15 * 60 * 1000 ); }) - .catch((error) => { + .catch((error: Error) => { sonnerErrorToast(t("toast.recordingFailed") + error.message); - isElectron() && window.electron.log("error", `Recording failed: ${error}`); + if (isElectron()) window.electron.log("error", `Recording failed: ${error}`); }); } else { - // Stop recording - mediaRecorder.stop(); + if (mediaRecorder) { + mediaRecorder.stop(); + } setIsRecording(false); } }; @@ -185,7 +193,7 @@ const PracticeTab = ({ accent, conversationId }) => { } else { playRecording( recordingKey, - (audio, audioSource) => { + (audio: HTMLAudioElement | null, audioSource: AudioBufferSourceNode | null) => { setIsRecordingPlaying(true); if (audioSource) { setCurrentAudioSource(audioSource); @@ -193,10 +201,11 @@ const PracticeTab = ({ accent, conversationId }) => { setCurrentAudioElement(audio); } }, - (error) => { - sonnerErrorToast(t("toast.playbackError") + error.message); - isElectron() && window.electron.log("error", `Error saving text: ${error}`); - + (error: unknown) => { + sonnerErrorToast( + t("toast.playbackError") + (error instanceof Error ? error.message : "") + ); + if (isElectron()) window.electron.log("error", `Error saving text: ${error}`); setIsRecordingPlaying(false); }, () => { @@ -210,13 +219,19 @@ const PracticeTab = ({ accent, conversationId }) => { return (
    - {t("tabConversationExam.practiceConversationText", { returnObjects: true }).map( - (text, index) => ( -

    - {text} -

    - ) - )} + {Array.isArray( + t("tabConversationExam.practiceConversationText", { returnObjects: true }) + ) + ? ( + t("tabConversationExam.practiceConversationText", { + returnObjects: true, + }) as string[] + ).map((text, index) => ( +

    + {text} +

    + )) + : null}
    @@ -228,6 +243,8 @@ const PracticeTab = ({ accent, conversationId }) => { value={textValue} onChange={(e) => setTextValue(e.target.value)} onInput={autoExpand} + placeholder={t("tabConversationExam.practiceConversationPlaceholder")} + title={t("tabConversationExam.practiceConversationBox")} >
    @@ -297,9 +314,4 @@ const PracticeTab = ({ accent, conversationId }) => { ); }; -PracticeTab.propTypes = { - accent: PropTypes.string.isRequired, - conversationId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, -}; - export default PracticeTab; diff --git a/src/frontend/components/conversation_page/ReviewTab.tsx b/src/frontend/components/conversation_page/ReviewTab.tsx new file mode 100644 index 000000000..77a19f399 --- /dev/null +++ b/src/frontend/components/conversation_page/ReviewTab.tsx @@ -0,0 +1,190 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import * as z from "zod/v4"; +import { sonnerSuccessToast } from "../../utils/sonnerCustomToast.js"; +import type { Review, ReviewTabProps } from "./types.js"; + +// Create Zod schemas for validation +const ReviewStateSchema = z.record(z.string(), z.boolean()); + +const ConversationReviewSchema = z.record(z.string(), ReviewStateSchema); + +const SavedSettingsSchema = z + .object({ + conversationReview: ConversationReviewSchema.optional(), + }) + .catchall(z.unknown()); // Allow other properties + +// Infer TypeScript types +type ReviewState = z.infer; +type SavedSettings = z.infer; + +const ReviewTab = ({ reviews, accent, conversationId }: ReviewTabProps) => { + const { t } = useTranslation(); + + const reviewKey = `conversation-${conversationId}-${accent}-review`; + + // Safe localStorage operations with Zod validation + const getSavedSettings = (): SavedSettings => { + try { + const raw = localStorage.getItem("ispeaker"); + if (!raw) return {}; + + const parsed = JSON.parse(raw); + const result = SavedSettingsSchema.safeParse(parsed); + + if (result.success) { + return result.data; + } else { + console.warn("❌ Failed to validate saved settings:", result.error); + return {}; + } + } catch (error) { + console.warn("❌ Failed to parse saved settings:", error); + return {}; + } + }; + + const setSavedSettings = (settings: SavedSettings): void => { + try { + localStorage.setItem("ispeaker", JSON.stringify(settings)); + } catch (error) { + console.error("❌ Failed to save settings:", error); + } + }; + + const [reviewState, setReviewState] = useState(() => { + const savedSettings = getSavedSettings(); + const savedReviews = savedSettings.conversationReview?.[accent] || {}; + + // Validate and build initial review state + const reviewStateResult = ReviewStateSchema.safeParse(savedReviews); + const validSavedReviews = reviewStateResult.success ? reviewStateResult.data : {}; + + const initialReviewState: ReviewState = reviews.reduce( + (acc: ReviewState, _review: Review, index: number) => { + const key = `${reviewKey}${index + 1}`; + acc[key] = validSavedReviews[key] || false; + return acc; + }, + {} + ); + + // Validate the initial state + const initialResult = ReviewStateSchema.safeParse(initialReviewState); + return initialResult.success ? initialResult.data : {}; + }); + + // Load saved review states from localStorage with validation + useEffect(() => { + const savedSettings = getSavedSettings(); + const savedReviews = savedSettings.conversationReview?.[accent] || {}; + + // Validate saved reviews + const reviewsResult = ReviewStateSchema.safeParse(savedReviews); + const validSavedReviews = reviewsResult.success ? reviewsResult.data : {}; + + const initialReviewState: ReviewState = reviews.reduce( + (acc: ReviewState, _review: Review, index: number) => { + const key = `${reviewKey}${index + 1}`; + acc[key] = validSavedReviews[key] || false; + return acc; + }, + {} + ); + + // Validate the initial state before setting + const initialResult = ReviewStateSchema.safeParse(initialReviewState); + if (initialResult.success) { + setReviewState(initialResult.data); + } else { + console.warn("❌ Failed to validate initial review state:", initialResult.error); + setReviewState({}); + } + }, [accent, conversationId, reviews, reviewKey]); + + // Handle checkbox change with validation + const handleCheckboxChange = (index: number) => { + const key = `${reviewKey}${index}`; + + setReviewState((prev) => { + // Validate current state + const currentResult = ReviewStateSchema.safeParse(prev); + const currentState = currentResult.success ? currentResult.data : {}; + + const newReviewState: ReviewState = { + ...currentState, + [key]: !currentState[key], + }; + + // Validate new state + const newResult = ReviewStateSchema.safeParse(newReviewState); + if (!newResult.success) { + console.warn("❌ Failed to validate new review state:", newResult.error); + return currentState; + } + + // Save to localStorage with validation + const savedSettings = getSavedSettings(); + + // Ensure conversationReview exists + if (!savedSettings.conversationReview) { + savedSettings.conversationReview = {}; + } + + // Get current accent data and validate it + const currentAccentDataResult = ReviewStateSchema.safeParse( + savedSettings.conversationReview[accent] || {} + ); + const currentAccentData = currentAccentDataResult.success + ? currentAccentDataResult.data + : {}; + + // Update the specific review state while preserving the rest of the data + const updatedReviews = { + ...currentAccentData, + [key]: newReviewState[key], + }; + + // Validate updated reviews before saving + const updatedResult = ReviewStateSchema.safeParse(updatedReviews); + if (updatedResult.success) { + savedSettings.conversationReview[accent] = updatedResult.data; + setSavedSettings(savedSettings); + } else { + console.warn("❌ Failed to validate updated reviews:", updatedResult.error); + } + + return newResult.data; + }); + + sonnerSuccessToast(t("toast.reviewUpdated")); + }; + + return ( +
    + {reviews.map((review, index) => { + const reviewIndex = index + 1; + const key = `${reviewKey}${reviewIndex}`; + const isChecked = !!reviewState[key]; + + return ( +
    + +
    + ); + })} +
    + ); +}; + +export default ReviewTab; diff --git a/src/components/conversation_page/WatchAndStudyTab.jsx b/src/frontend/components/conversation_page/WatchAndStudyTab.tsx similarity index 84% rename from src/components/conversation_page/WatchAndStudyTab.jsx rename to src/frontend/components/conversation_page/WatchAndStudyTab.tsx index f569fa866..037be6630 100644 --- a/src/components/conversation_page/WatchAndStudyTab.jsx +++ b/src/frontend/components/conversation_page/WatchAndStudyTab.tsx @@ -2,24 +2,29 @@ import { MediaPlayer, MediaProvider, Track } from "@vidstack/react"; import { defaultLayoutIcons, DefaultVideoLayout } from "@vidstack/react/player/layouts/default"; import "@vidstack/react/player/styles/default/layouts/video.css"; import "@vidstack/react/player/styles/default/theme.css"; -import PropTypes from "prop-types"; import { useState } from "react"; import { useTranslation } from "react-i18next"; import { IoInformationCircleOutline } from "react-icons/io5"; -import isElectron from "../../utils/isElectron"; -import useAutoDetectTheme from "../../utils/ThemeContext/useAutoDetectTheme"; +import isElectron from "../../utils/isElectron.js"; +import useAutoDetectTheme from "../../utils/ThemeContext/useAutoDetectTheme.js"; +import type { DialogLine, SkillCheckmark, WatchAndStudyTabProps } from "./types.js"; -const WatchAndStudyTab = ({ videoUrl, subtitleUrl, dialog, skillCheckmark }) => { +const WatchAndStudyTab = ({ + videoUrl, + subtitleUrl, + dialog, + skillCheckmark, +}: WatchAndStudyTabProps) => { const { t } = useTranslation(); - const [highlightState, setHighlightState] = useState({}); + const [highlightState, setHighlightState] = useState>({}); const [iframeLoading, setiFrameLoading] = useState(true); const { autoDetectedTheme } = useAutoDetectTheme(); - const handleIframeLoad = () => setiFrameLoading(false); + const handleIframeLoad = (): void => setiFrameLoading(false); // Handle checkbox change - const handleCheckboxChange = (index) => { + const handleCheckboxChange = (index: number): void => { setHighlightState((prevState) => ({ ...prevState, [index]: !prevState[index], @@ -39,7 +44,13 @@ const WatchAndStudyTab = ({ videoUrl, subtitleUrl, dialog, skillCheckmark }) =>
    {t("tabConversationExam.studyCard")}
    - +
    - {dialog.map((line, index) => ( + {dialog.map((line: DialogLine, index: number) => (

    {line.speaker}:{" "} + (match: string, p1: string) => highlightState[p1] ? `${p1 === "1" ? "bg-primary text-primary-content font-semibold" : "bg-secondary text-secondary-content font-semibold"}` : "" @@ -108,7 +123,7 @@ const WatchAndStudyTab = ({ videoUrl, subtitleUrl, dialog, skillCheckmark }) =>

    - {skillCheckmark.map((skill, index) => ( + {skillCheckmark.map((skill: SkillCheckmark, index: number) => (
    {/* Screenshot on the right, vertically centered */} @@ -212,7 +215,11 @@ const DownloadPage = () => { key={idx} className="collapse-arrow join-item border-base-300 collapse border dark:border-slate-600" > - +
    {t(`downloadPage.${item.questionKey}`)}
    diff --git a/src/components/exam_page/ExamDetailPage.jsx b/src/frontend/components/exam_page/ExamDetailPage.tsx similarity index 72% rename from src/components/exam_page/ExamDetailPage.jsx rename to src/frontend/components/exam_page/ExamDetailPage.tsx index d863f1010..dcf4fcc9f 100644 --- a/src/components/exam_page/ExamDetailPage.jsx +++ b/src/frontend/components/exam_page/ExamDetailPage.tsx @@ -1,30 +1,32 @@ -import PropTypes from "prop-types"; import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { IoChevronBackOutline, IoInformationCircleOutline } from "react-icons/io5"; import { MdChecklist, MdHeadphones, MdKeyboardVoice, MdOutlineOndemandVideo } from "react-icons/md"; -import isElectron from "../../utils/isElectron"; -import { sonnerErrorToast } from "../../utils/sonnerCustomToast"; -import { useScrollTo } from "../../utils/useScrollTo"; -import LoadingOverlay from "../general/LoadingOverlay"; -import ListeningTab from "./ListeningTab"; -import PracticeTab from "./PracticeTab"; -import ReviewTab from "./ReviewTab"; -import WatchAndStudyTab from "./WatchAndStudyTab"; +import isElectron from "../../utils/isElectron.js"; +import { sonnerErrorToast } from "../../utils/sonnerCustomToast.js"; +import useScrollTo from "../../utils/useScrollTo.js"; +import LoadingOverlay from "../general/LoadingOverlay.js"; +import ListeningTab from "./ListeningTab.js"; +import PracticeTab from "./PracticeTab.js"; +import ReviewTab from "./ReviewTab.js"; +import WatchAndStudyTab from "./WatchAndStudyTab.js"; +import { ExamData, ExamDetailPageProps } from "./types.js"; -const ExamDetailPage = ({ id, title, onBack, accent }) => { +const ExamDetailPage = ({ id, title, onBack, accent }: ExamDetailPageProps) => { const { t } = useTranslation(); const { ref: scrollRef, scrollTo } = useScrollTo(); - const [activeTab, setActiveTab] = useState("watchStudyTab"); - const [examData, setExamData] = useState(null); - const [loading, setLoading] = useState(true); + const [activeTab, setActiveTab] = useState< + "watchStudyTab" | "listenTab" | "practiceTab" | "reviewTab" + >("watchStudyTab"); + const [examData, setExamData] = useState(null); + const [loading, setLoading] = useState(true); - const [videoUrl, setVideoUrl] = useState(null); - const [videoLoading, setVideoLoading] = useState(true); - const [port, setPort] = useState(null); + const [videoUrl, setVideoUrl] = useState(""); + const [videoLoading, setVideoLoading] = useState(true); + const [port, setPort] = useState(null); - const examMainInfoModal = useRef(null); + const examMainInfoModal = useRef(null); useEffect(() => { const fetchData = async () => { @@ -35,7 +37,7 @@ const ExamDetailPage = ({ id, title, onBack, accent }) => { const response = await fetch( `${import.meta.env.BASE_URL}json/examspeaking_data.json` ); - const data = await response.json(); + const data: ExamData = await response.json(); setExamData(data); setLoading(false); @@ -52,9 +54,16 @@ const ExamDetailPage = ({ id, title, onBack, accent }) => { // Fetch the dynamic port if running in Electron useEffect(() => { const fetchPort = async () => { - if (window.electron?.ipcRenderer) { - const dynamicPort = await window.electron.ipcRenderer.invoke("get-port"); - setPort(dynamicPort); + const win = window as typeof window & { + electron?: { + ipcRenderer?: { + invoke: (channel: string) => Promise; + }; + }; + }; + if (win.electron?.ipcRenderer) { + const dynamicPort = await win.electron.ipcRenderer.invoke("get-port"); + setPort(Number(dynamicPort)); } }; fetchPort(); @@ -100,12 +109,17 @@ const ExamDetailPage = ({ id, title, onBack, accent }) => { // Check if examData is available if (!examData || !examData[id]) { - return sonnerErrorToast(t("toast.loadingError")); + sonnerErrorToast(t("toast.loadingError")); + return ( +
    +

    {t("toast.loadingError")}

    +
    + ); } const examDetails = examData[id]; - const examLocalizedDescArray = t(examDetails.description, { returnObjects: true }); + const examLocalizedDescArray = t(examDetails.description, { returnObjects: true }) as string[]; const videoSubtitle = examData[id].watch_and_study.subtitle; const subtitleUrl = `${import.meta.env.BASE_URL}media/exam/subtitles/${videoSubtitle}`; @@ -117,6 +131,7 @@ const ExamDetailPage = ({ id, title, onBack, accent }) => {
    @@ -237,16 +236,20 @@ const ExamDetailPage = ({ id, title, onBack, accent }) => {

    {t("examPage.taskInfo")}

    - {examLocalizedDescArray.map((desc, index) => ( -

    - {desc} -

    - ))} + {Array.isArray(examLocalizedDescArray) + ? examLocalizedDescArray.map((desc, index) => ( +

    + {desc} +

    + )) + : null}
    @@ -259,11 +262,4 @@ const ExamDetailPage = ({ id, title, onBack, accent }) => { ); }; -ExamDetailPage.propTypes = { - id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, - title: PropTypes.string.isRequired, - onBack: PropTypes.func.isRequired, - accent: PropTypes.string.isRequired, -}; - export default ExamDetailPage; diff --git a/src/components/exam_page/ExamPage.jsx b/src/frontend/components/exam_page/ExamPage.tsx similarity index 79% rename from src/components/exam_page/ExamPage.jsx rename to src/frontend/components/exam_page/ExamPage.tsx index fa9ad17dc..406d417d7 100644 --- a/src/components/exam_page/ExamPage.jsx +++ b/src/frontend/components/exam_page/ExamPage.tsx @@ -1,29 +1,29 @@ -import PropTypes from "prop-types"; import { Suspense, lazy, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { IoInformationCircleOutline } from "react-icons/io5"; -import Container from "../../ui/Container"; -import AccentLocalStorage from "../../utils/AccentLocalStorage"; -import isElectron from "../../utils/isElectron"; -import AccentDropdown from "../general/AccentDropdown"; -import LoadingOverlay from "../general/LoadingOverlay"; -import TopNavBar from "../general/TopNavBar"; +import Container from "../../ui/Container.js"; +import AccentLocalStorage from "../../utils/AccentLocalStorage.js"; +import isElectron from "../../utils/isElectron.js"; +import AccentDropdown from "../general/AccentDropdown.js"; +import LoadingOverlay from "../general/LoadingOverlay.js"; +import TopNavBar from "../general/TopNavBar.js"; +import { ExamCardProps, ExamSection, SelectedExam, TooltipIconProps } from "./types.js"; -const ExamDetailPage = lazy(() => import("./ExamDetailPage")); +const ExamDetailPage = lazy(() => import("./ExamDetailPage.js")); const ExamPage = () => { const { t } = useTranslation(); - const [data, setData] = useState([]); - const [loading, setLoading] = useState(true); + const [data, setData] = useState([]); + const [loading, setLoading] = useState(true); const [selectedAccent, setSelectedAccent] = AccentLocalStorage(); - const [selectedExam, setSelectedExam] = useState(null); + const [selectedExam, setSelectedExam] = useState(null); // Ref and state for modal tooltip handling - const modalRef = useRef(null); - const [tooltipContent, setTooltipContent] = useState(""); + const modalRef = useRef(null); + const [tooltipContent, setTooltipContent] = useState(""); - const handleShowTooltip = (content) => { + const handleShowTooltip = (content: string) => { setTooltipContent(content); if (modalRef.current) { modalRef.current.showModal(); @@ -37,7 +37,7 @@ const ExamPage = () => { setTooltipContent(""); }; - const handleSelectExam = (id, title) => { + const handleSelectExam = (id: string, title: string) => { const selected = data.find((section) => section.titles.some((item) => item.id === id)); if (selected) { setSelectedExam({ @@ -48,10 +48,11 @@ const ExamPage = () => { } }; - const TooltipIcon = ({ exam_popup }) => { - const lines = t(exam_popup, { returnObjects: true }); + const TooltipIcon = ({ exam_popup }: TooltipIconProps) => { + const lines = t(exam_popup, { returnObjects: true }) as string | string[]; const tooltipArray = Array.isArray(lines) ? lines : [lines]; const tooltipText = tooltipArray.map((line, index) =>

    {line}

    ); + const tooltipString = tooltipArray.join("\n"); return ( <> @@ -68,7 +69,7 @@ const ExamPage = () => { type="button" title={t("examPage.expandInfoBtn")} className="btn btn-circle btn-sm items-center sm:hidden" - onClick={() => handleShowTooltip(tooltipText)} + onClick={() => handleShowTooltip(tooltipString)} > @@ -76,11 +77,7 @@ const ExamPage = () => { ); }; - TooltipIcon.propTypes = { - exam_popup: PropTypes.string.isRequired, - }; - - const ExamCard = ({ heading, titles }) => ( + const ExamCard = ({ heading, titles }: ExamCardProps) => (
    {t(heading)}
    @@ -100,17 +97,6 @@ const ExamPage = () => {
    ); - ExamCard.propTypes = { - heading: PropTypes.string.isRequired, - titles: PropTypes.arrayOf( - PropTypes.shape({ - title: PropTypes.string.isRequired, - exam_popup: PropTypes.string.isRequired, - id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, - }) - ).isRequired, - }; - useEffect(() => { const fetchData = async () => { try { @@ -121,7 +107,7 @@ const ExamPage = () => { ); const fetchedData = await response.json(); - setData(fetchedData.examList); + setData(fetchedData.examList as ExamSection[]); setLoading(false); } catch (error) { console.error("Error fetching data:", error); @@ -134,7 +120,7 @@ const ExamPage = () => { }, []); useEffect(() => { - const savedSettings = JSON.parse(localStorage.getItem("ispeaker")) || {}; + const savedSettings = JSON.parse(localStorage.getItem("ispeaker") || "{}") || {}; savedSettings.selectedAccent = selectedAccent; localStorage.setItem("ispeaker", JSON.stringify(savedSettings)); }, [selectedAccent]); @@ -182,7 +168,11 @@ const ExamPage = () => {

    {t("examPage.examModalInfo")}

    -
    {tooltipContent}
    +
    + {tooltipContent.split("\n").map((line, i) => ( +

    {line}

    + ))} +
    )}
    - {examLocalizedPara.map((paragraph, index) => ( -

    {paragraph}

    - ))} - {examLocalizedListItems.length > 0 && ( -
      - {examLocalizedListItems.map((item, index) => ( -
    • {item}
    • + {Array.isArray(examLocalizedPara) && + (examLocalizedPara as unknown[]) + .filter((p): p is string => typeof p === "string") + .map((paragraph, index) => ( +

      {paragraph}

      ))} -
    - )} + {Array.isArray(examLocalizedListItems) && + (examLocalizedListItems as unknown[]).filter( + (item): item is string => typeof item === "string" + ).length > 0 && ( +
      + {(examLocalizedListItems as unknown[]) + .filter( + (item): item is string => + typeof item === "string" + ) + .map((item, index) => ( +
    • {item}
    • + ))} +
    + )}
    @@ -301,9 +342,11 @@ const PracticeTab = ({ accent, examId, taskData, tips }) => {
    @@ -476,11 +523,4 @@ const PracticeTab = ({ accent, examId, taskData, tips }) => { ); }; -PracticeTab.propTypes = { - accent: PropTypes.string.isRequired, - examId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, - taskData: PropTypes.array.isRequired, - tips: PropTypes.object.isRequired, -}; - export default PracticeTab; diff --git a/src/frontend/components/exam_page/ReviewTab.tsx b/src/frontend/components/exam_page/ReviewTab.tsx new file mode 100644 index 000000000..a47f1f661 --- /dev/null +++ b/src/frontend/components/exam_page/ReviewTab.tsx @@ -0,0 +1,136 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import * as z from "zod/v4"; +import { sonnerSuccessToast } from "../../utils/sonnerCustomToast.js"; +import { ReviewTabProps } from "./types.js"; + +// Create Zod schemas for validation +const CheckedReviewsSchema = z.record(z.string(), z.boolean()); + +const ExamReviewSchema = z.record(z.string(), CheckedReviewsSchema); + +const SavedSettingsSchema = z + .object({ + examReview: ExamReviewSchema.optional(), + }) + .catchall(z.unknown()); // Allow other properties + +// Infer TypeScript types +type CheckedReviews = z.infer; +type SavedSettings = z.infer; + +const ReviewTab = ({ reviews, examId, accent }: ReviewTabProps) => { + const { t } = useTranslation(); + + // Safe localStorage operations with Zod validation + const getSavedSettings = (): SavedSettings => { + try { + const raw = localStorage.getItem("ispeaker"); + if (!raw) return {}; + + const parsed = JSON.parse(raw); + const result = SavedSettingsSchema.safeParse(parsed); + + if (result.success) { + return result.data; + } else { + console.warn("❌ Failed to validate saved settings:", result.error); + return {}; + } + } catch (error) { + console.warn("❌ Failed to parse saved settings:", error); + return {}; + } + }; + + const setSavedSettings = (settings: SavedSettings): void => { + try { + localStorage.setItem("ispeaker", JSON.stringify(settings)); + } catch (error) { + console.error("❌ Failed to save settings:", error); + } + }; + + const [checkedReviews, setCheckedReviews] = useState(() => { + const savedSettings = getSavedSettings(); + const examReviewData = savedSettings.examReview?.[accent]; + + // Validate the retrieved data with Zod + const result = CheckedReviewsSchema.safeParse(examReviewData || {}); + return result.success ? result.data : {}; + }); + + const handleCheckboxChange = (index: number) => { + const key = `${examId}-${index}`; + + setCheckedReviews((prev) => { + // Validate the current state before updating + const currentResult = CheckedReviewsSchema.safeParse(prev); + const currentState = currentResult.success ? currentResult.data : {}; + + const newState = { + ...currentState, + [key]: !currentState[key], + }; + + // Validate the new state + const newResult = CheckedReviewsSchema.safeParse(newState); + if (newResult.success) { + return newResult.data; + } else { + console.warn("❌ Failed to validate new checkbox state:", newResult.error); + return currentState; + } + }); + + sonnerSuccessToast(t("toast.reviewUpdated")); + }; + + useEffect(() => { + // Get current settings and validate them + const savedSettings = getSavedSettings(); + + // Ensure examReview exists and is valid + if (!savedSettings.examReview) { + savedSettings.examReview = {}; + } + + // Validate and update the examReview for the current accent + const examReviewResult = CheckedReviewsSchema.safeParse(checkedReviews); + if (examReviewResult.success) { + savedSettings.examReview[accent] = examReviewResult.data; + setSavedSettings(savedSettings); + } else { + console.warn( + "❌ Failed to validate checked reviews before saving:", + examReviewResult.error + ); + } + }, [checkedReviews, examId, accent]); + + return ( +
    + {reviews.map((review, index) => { + const key = `${examId}-${index}`; + const isChecked = !!checkedReviews[key]; + + return ( +
    + +
    + ); + })} +
    + ); +}; + +export default ReviewTab; diff --git a/src/components/exam_page/WatchAndStudyTab.jsx b/src/frontend/components/exam_page/WatchAndStudyTab.tsx similarity index 86% rename from src/components/exam_page/WatchAndStudyTab.jsx rename to src/frontend/components/exam_page/WatchAndStudyTab.tsx index eec9892fc..792ef3ef8 100644 --- a/src/components/exam_page/WatchAndStudyTab.jsx +++ b/src/frontend/components/exam_page/WatchAndStudyTab.tsx @@ -2,24 +2,30 @@ import { MediaPlayer, MediaProvider, Track } from "@vidstack/react"; import { defaultLayoutIcons, DefaultVideoLayout } from "@vidstack/react/player/layouts/default"; import "@vidstack/react/player/styles/default/layouts/video.css"; import "@vidstack/react/player/styles/default/theme.css"; -import PropTypes from "prop-types"; import { useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { IoCloseOutline, IoInformationCircleOutline } from "react-icons/io5"; -import isElectron from "../../utils/isElectron"; -import useAutoDetectTheme from "../../utils/ThemeContext/useAutoDetectTheme"; +import isElectron from "../../utils/isElectron.js"; +import useAutoDetectTheme from "../../utils/ThemeContext/useAutoDetectTheme.js"; +import { WatchAndStudyTabProps } from "./types.js"; -const WatchAndStudyTab = ({ videoUrl, subtitleUrl, taskData, dialog, skills }) => { +const WatchAndStudyTab = ({ + videoUrl, + subtitleUrl, + taskData, + dialog, + skills, +}: WatchAndStudyTabProps) => { const { t } = useTranslation(); - const [modalImage, setModalImage] = useState(""); - const [imageLoading, setImageLoading] = useState(false); - const imageModalRef = useRef(null); - const [iframeLoading, setiFrameLoading] = useState(true); + const [modalImage, setModalImage] = useState(""); + const [imageLoading, setImageLoading] = useState(false); + const imageModalRef = useRef(null); + const [iframeLoading, setiFrameLoading] = useState(true); const { autoDetectedTheme } = useAutoDetectTheme(); - const handleImageClick = (imageName) => { + const handleImageClick = (imageName: string) => { const newImage = `${import.meta.env.BASE_URL}images/ispeaker/exam_images/fullsize/${imageName}.webp`; // Only set loading if the image is different @@ -34,7 +40,7 @@ const WatchAndStudyTab = ({ videoUrl, subtitleUrl, taskData, dialog, skills }) = const handleIframeLoad = () => setiFrameLoading(false); // State for highlighting the dialog - const [highlightState, setHighlightState] = useState({ + const [highlightState, setHighlightState] = useState>({ 1: false, 2: false, 3: false, @@ -43,14 +49,14 @@ const WatchAndStudyTab = ({ videoUrl, subtitleUrl, taskData, dialog, skills }) = 6: false, }); - const handleCheckboxChange = (index) => { + const handleCheckboxChange = (index: number) => { setHighlightState((prevState) => ({ ...prevState, [index]: !prevState[index], })); }; - const getHighlightClass = (index) => { + const getHighlightClass = (index: number) => { switch (index) { case 1: return "font-semibold bg-primary text-primary-content"; @@ -69,7 +75,7 @@ const WatchAndStudyTab = ({ videoUrl, subtitleUrl, taskData, dialog, skills }) = } }; - const getCheckboxHighlightClass = (index) => { + const getCheckboxHighlightClass = (index: number) => { switch (index) { case 1: return "checkbox-primary"; @@ -88,15 +94,17 @@ const WatchAndStudyTab = ({ videoUrl, subtitleUrl, taskData, dialog, skills }) = } }; - const highlightDialog = (speech) => { - return speech.replace(/highlight-dialog-(\d+)/g, (match, p1) => { - const className = getHighlightClass(parseInt(p1, 10)); - return highlightState[p1] ? `${className} ${match}` : `${match}`; + const highlightDialog = (speech: string) => { + return speech.replace(/highlight-dialog-(\d+)/g, (match: string, p1: string) => { + const idx = parseInt(p1, 10); + const className = getHighlightClass(idx); + return highlightState[idx] ? `${className} ${match}` : `${match}`; }); }; - const examTaskQuestion = t(taskData.para, { returnObjects: true }); - const examTaskList = taskData.listItems && t(taskData.listItems, { returnObjects: true }); + const examTaskQuestion = t(taskData.para, { returnObjects: true }) as string[]; + const examTaskList = + taskData.listItems && (t(taskData.listItems, { returnObjects: true }) as string[]); return ( <> @@ -109,6 +117,7 @@ const WatchAndStudyTab = ({ videoUrl, subtitleUrl, taskData, dialog, skills }) =
    {`Thumbnail
    - + @@ -295,12 +314,4 @@ const WatchAndStudyTab = ({ videoUrl, subtitleUrl, taskData, dialog, skills }) = ); }; -WatchAndStudyTab.propTypes = { - videoUrl: PropTypes.string.isRequired, - subtitleUrl: PropTypes.string.isRequired, - taskData: PropTypes.object.isRequired, - dialog: PropTypes.array.isRequired, - skills: PropTypes.array.isRequired, -}; - export default WatchAndStudyTab; diff --git a/src/frontend/components/exam_page/types.ts b/src/frontend/components/exam_page/types.ts new file mode 100644 index 000000000..7be259692 --- /dev/null +++ b/src/frontend/components/exam_page/types.ts @@ -0,0 +1,136 @@ +export type AccentType = "british" | "american"; + +export type ExamData = Record; + +export interface DialogLine { + speaker: string; + speech: string; +} + +export interface SkillCheckmark { + label: string; +} + +export interface TaskData { + para: string; + listItems: string[]; + images: string[]; +} + +export interface WatchAndStudy { + videoLink: string; + offlineFile: string; + subtitle: string; + taskData: TaskData; + study: { + dialog: DialogLine[]; + skills: SkillCheckmark[]; + }; +} + +export interface Sentence { + audioSrc: string; + sentence: string; +} + +export interface Subtopic { + title: string; + sentences: Sentence[]; +} + +export interface Listen { + BrE: { + subtopics: Subtopic[]; + }; + AmE: { + subtopics: Subtopic[]; + }; +} + +export interface Tips { + dos: string[]; + donts: string[]; +} + +export interface Practise { + task: TaskData[]; + tips: Tips; +} + +export interface Review { + text: string; +} + +// ExamDetailPage +export interface ExamDetails { + description: string; + watch_and_study: WatchAndStudy; + listen: Listen; + practise: Practise; + reviews: Review[]; +} + +export interface ExamDetailPageProps { + id: string; + title: string; + onBack: () => void; + accent: AccentType; +} + +//ExamPage +export interface ExamTitle { + title: string; + id: string; + exam_popup: string; +} + +export interface ExamSection { + heading: string; + titles: ExamTitle[]; +} + +export interface SelectedExam { + id: string; + title: string; + heading: string; +} + +export interface TooltipIconProps { + exam_popup: string; +} + +export interface ExamCardProps { + heading: string; + titles: ExamTitle[]; +} + +// ListeningTab +export interface ListeningTabProps { + subtopicsBre: Subtopic[]; + subtopicsAme: Subtopic[]; + currentAccent: AccentType; +} + +// PracticeTab +export interface PracticeTabProps { + accent: AccentType; + examId: string | number; + taskData: TaskData[]; + tips: Tips; +} + +// ReviewTab +export interface ReviewTabProps { + reviews: Review[]; + accent: AccentType; + examId: string | number; +} + +// WatchAndStudyTab +export interface WatchAndStudyTabProps { + videoUrl: string; + subtitleUrl: string; + taskData: TaskData; + dialog: DialogLine[]; + skills: SkillCheckmark[]; +} diff --git a/src/components/exercise_page/DictationQuiz.jsx b/src/frontend/components/exercise_page/DictationQuiz.tsx similarity index 87% rename from src/components/exercise_page/DictationQuiz.jsx rename to src/frontend/components/exercise_page/DictationQuiz.tsx index 12545aaa8..aff28c7bd 100644 --- a/src/components/exercise_page/DictationQuiz.jsx +++ b/src/frontend/components/exercise_page/DictationQuiz.tsx @@ -1,36 +1,36 @@ import _ from "lodash"; -import PropTypes from "prop-types"; import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { AiOutlineCheckCircle, AiOutlineCloseCircle } from "react-icons/ai"; import { IoInformationCircleOutline, IoVolumeHigh, IoVolumeHighOutline } from "react-icons/io5"; import { LiaCheckCircle, LiaChevronCircleRightSolid, LiaTimesCircle } from "react-icons/lia"; -import { sonnerErrorToast } from "../../utils/sonnerCustomToast"; -import useCountdownTimer from "../../utils/useCountdownTimer"; - -const DictationQuiz = ({ quiz, timer, onAnswer, onQuit, setTimeIsUp }) => { - const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0); - const [answer, setAnswer] = useState(""); - const [showValidation, setShowValidation] = useState(false); - const [validationVariant, setValidationVariant] = useState("danger"); - const [validationMessage, setValidationMessage] = useState(""); - const [isTextboxDisabled, setIsTextboxDisabled] = useState(false); - const [shuffledQuiz, setShuffledQuiz] = useState([]); - const [isSubmitButtonEnabled, setIsSubmitButtonEnabled] = useState(false); - const [hasAnswered, setHasAnswered] = useState(false); - - const [isPlaying, setIsPlaying] = useState(false); - const [isLoading, setIsLoading] = useState(false); +import { sonnerErrorToast } from "../../utils/sonnerCustomToast.js"; +import useCountdownTimer from "../../utils/useCountdownTimer.js"; +import type { DictationQuizItem, DictationQuizProps } from "./types.js"; + +const DictationQuiz = ({ quiz, timer, onAnswer, onQuit, setTimeIsUp }: DictationQuizProps) => { + const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0); + const [answer, setAnswer] = useState(""); + const [showValidation, setShowValidation] = useState(false); + const [validationVariant, setValidationVariant] = useState<"success" | "danger">("danger"); + const [validationMessage, setValidationMessage] = useState(""); + const [isTextboxDisabled, setIsTextboxDisabled] = useState(false); + const [shuffledQuiz, setShuffledQuiz] = useState([]); + const [isSubmitButtonEnabled, setIsSubmitButtonEnabled] = useState(false); + const [hasAnswered, setHasAnswered] = useState(false); + + const [isPlaying, setIsPlaying] = useState(false); + const [isLoading, setIsLoading] = useState(false); const { formatTime, clearTimer, startTimer } = useCountdownTimer(timer, () => setTimeIsUp(true) ); - const audioRef = useRef(null); + const audioRef = useRef(null); const { t } = useTranslation(); - const filterAndShuffleQuiz = (quiz) => { + const filterAndShuffleQuiz = (quiz: DictationQuizItem[]): DictationQuizItem[] => { const uniqueQuiz = _.uniqWith(quiz, _.isEqual); return _.shuffle(uniqueQuiz); }; @@ -57,11 +57,15 @@ const DictationQuiz = ({ quiz, timer, onAnswer, onQuit, setTimeIsUp }) => { } }; - const handleSubmit = (e) => { + const handleSubmit = ( + e: React.FormEvent | React.MouseEvent + ) => { e.preventDefault(); if (!shuffledQuiz[currentQuestionIndex]) return; - const textboxWord = shuffledQuiz[currentQuestionIndex].words.find((word) => word.textbox); + const textboxWord = shuffledQuiz[currentQuestionIndex].words.find( + (word): word is { textbox: string } => "textbox" in word + ); if (!textboxWord) { console.error("No textbox found in the current question."); return; @@ -164,10 +168,11 @@ const DictationQuiz = ({ quiz, timer, onAnswer, onQuit, setTimeIsUp }) => { // Check if the current word has both `value` and `textbox` const hasValueAndTextbox = - currentWords.some((w) => w.value) && currentWords.some((w) => w.textbox); + currentWords.some((w): w is { value: string } => "value" in w) && + currentWords.some((w): w is { textbox: string } => "textbox" in w); return currentWords.map((word, index) => { - if (word.value) { + if ("value" in word) { return ( {word.value} @@ -175,7 +180,7 @@ const DictationQuiz = ({ quiz, timer, onAnswer, onQuit, setTimeIsUp }) => { ); } - if (word.textbox) { + if ("textbox" in word) { const isCorrect = answer.trim().toLowerCase() === word.textbox.toLowerCase(); return ( @@ -301,12 +306,4 @@ const DictationQuiz = ({ quiz, timer, onAnswer, onQuit, setTimeIsUp }) => { ); }; -DictationQuiz.propTypes = { - quiz: PropTypes.arrayOf(PropTypes.object).isRequired, - timer: PropTypes.number.isRequired, - onAnswer: PropTypes.func.isRequired, - onQuit: PropTypes.func.isRequired, - setTimeIsUp: PropTypes.func.isRequired, -}; - export default DictationQuiz; diff --git a/src/components/exercise_page/ExerciseDetailPage.jsx b/src/frontend/components/exercise_page/ExerciseDetailPage.tsx similarity index 62% rename from src/components/exercise_page/ExerciseDetailPage.jsx rename to src/frontend/components/exercise_page/ExerciseDetailPage.tsx index bfc71207e..e2ddda171 100644 --- a/src/components/exercise_page/ExerciseDetailPage.jsx +++ b/src/frontend/components/exercise_page/ExerciseDetailPage.tsx @@ -3,8 +3,7 @@ import { Suspense, lazy, useCallback, useEffect, useRef, useState } from "react" import { useTranslation } from "react-i18next"; import { BsChevronLeft } from "react-icons/bs"; import { PiArrowsCounterClockwise } from "react-icons/pi"; -import LoadingOverlay from "../general/LoadingOverlay"; -import PropTypes from "prop-types"; +import LoadingOverlay from "../general/LoadingOverlay.js"; // Emoji SVGs import import seedlingEmoji from "../../emojiSvg/emoji_u1f331.svg"; @@ -16,24 +15,45 @@ import rocketEmoji from "../../emojiSvg/emoji_u1f680.svg"; import railwayPathEmoji from "../../emojiSvg/emoji_u1f6e4.svg"; // Lazy load the quiz components -const DictationQuiz = lazy(() => import("./DictationQuiz")); -const MatchUp = lazy(() => import("./MatchUp")); -const Reordering = lazy(() => import("./Reordering")); -const SoundAndSpelling = lazy(() => import("./SoundAndSpelling")); -const SortingExercise = lazy(() => import("./SortingExercise")); -const OddOneOut = lazy(() => import("./OddOneOut")); -const Snap = lazy(() => import("./Snap")); -const MemoryMatch = lazy(() => import("./MemoryMatch")); - -const ExerciseDetailPage = ({ heading, id, title, accent, file, onBack }) => { - const [instructions, setInstructions] = useState([]); - const [quiz, setQuiz] = useState([]); - const [split] = useState(""); +const DictationQuiz = lazy(() => import("./DictationQuiz.js")); +const MatchUp = lazy(() => import("./MatchUp.js")); +const Reordering = lazy(() => import("./Reordering.js")); +const SoundAndSpelling = lazy(() => import("./SoundAndSpelling.js")); +const SortingExercise = lazy(() => import("./SortingExercise.js")); +const OddOneOut = lazy(() => import("./OddOneOut.js")); +const Snap = lazy(() => import("./Snap.js")); +const MemoryMatch = lazy(() => import("./MemoryMatch.js")); + +import type { + DictationQuizItem, + ExerciseDataType, + ExerciseDetailPageProps, + ExerciseDetailsType, + MatchUpQuizItem, + MemoryMatchQuizItem, + OddOneOutQuestion, + ReorderingQuizData, + SnapQuizItem, + SortingQuizItem, + SoundAndSpellingQuizItem, + QuizItemWithExtras, +} from "./types.js"; + +const ExerciseDetailPage = ({ + heading, + id, + title, + accent, + file, + onBack, +}: ExerciseDetailPageProps) => { + const [instructions, setInstructions] = useState([]); + const [quiz, setQuiz] = useState([]); const [quizCompleted, setQuizCompleted] = useState(false); const [score, setScore] = useState(0); const [totalAnswered, setTotalAnswered] = useState(0); const [currentExerciseType, setCurrentExerciseType] = useState(""); - const [timer, setTimer] = useState(null); + const [timer, setTimer] = useState(0); const [timeIsUp, setTimeIsUp] = useState(false); const [onMatchFinished, setOnMatchFinished] = useState(false); // Track if all cards in Memory Match are matched @@ -41,47 +61,49 @@ const ExerciseDetailPage = ({ heading, id, title, accent, file, onBack }) => { const { t } = useTranslation(); - const instructionModal = useRef(null); + const instructionModal = useRef(null); - const getInstructionKey = (exerciseKey, exerciseId) => { + const getInstructionKey = (exerciseKey: string, exerciseId: string | number) => { if (exerciseKey === "sound_n_spelling") return `exercise_page.exerciseInstruction.sound_n_spelling.sound`; return `exercise_page.exerciseInstruction.${exerciseKey}.${exerciseId}`; }; const fetchInstructions = useCallback( - (exerciseKey, exerciseId, ipaSound) => { + (exerciseKey: string, exerciseId: string | number, ipaSound?: string) => { const instructionKey = getInstructionKey(exerciseKey, exerciseId); const instructions = t(instructionKey, { ipaSound: ipaSound || "", returnObjects: true, }); - return Array.isArray(instructions) ? instructions : []; // Ensure it's always an array + return Array.isArray(instructions) ? instructions : []; }, [t] ); // Helper function to handle the exercise data logic (setting quiz, instructions, etc.) const handleExerciseData = useCallback( - (exerciseDetails, data, exerciseKey) => { - const savedSettings = JSON.parse(localStorage.getItem("ispeaker")); + (exerciseDetails: ExerciseDetailsType, data: ExerciseDataType, exerciseKey: string) => { + const savedSettings = localStorage.getItem("ispeaker") + ? JSON.parse(localStorage.getItem("ispeaker") as string) + : undefined; const fixedTimers = { memory_match: 4, snap: 2, - }; + } as const; const timerValue = - fixedTimers[exerciseKey] ?? + fixedTimers[exerciseKey as keyof typeof fixedTimers] ?? ((savedSettings?.timerSettings?.enabled === true && savedSettings?.timerSettings?.[exerciseKey]) || 0); setTimer(timerValue); - let selectedAccentData; - let combinedQuizzes = []; + let selectedAccentData: ExerciseDetailsType | undefined; + const combinedQuizzes: Record[] = []; const ipaSound = - (exerciseKey === "sound_n_spelling" && exerciseDetails.exercise.trim()) || ""; + (exerciseKey === "sound_n_spelling" && exerciseDetails.exercise?.trim()) || ""; const loadInstructions = fetchInstructions(exerciseKey, id, ipaSound); if (id === "random") { @@ -96,10 +118,10 @@ const ExerciseDetailPage = ({ heading, id, title, accent, file, onBack }) => { : exercise.british?.[0]; } - if (selectedAccentData) { + if (selectedAccentData && selectedAccentData.quiz) { combinedQuizzes.push( ...selectedAccentData.quiz.map((quiz) => ({ - ...quiz, + ...(quiz as QuizItemWithExtras), split: exercise.split, type: exercise.type, })) @@ -108,9 +130,12 @@ const ExerciseDetailPage = ({ heading, id, title, accent, file, onBack }) => { } }); + // Remove duplicates and shuffle const uniqueShuffledCombinedQuizzes = _.shuffle( - Array.from(new Set(combinedQuizzes.map(JSON.stringify))).map(JSON.parse) - ); + Array.from(new Set(combinedQuizzes.map((q) => JSON.stringify(q)))).map((q) => + JSON.parse(q) + ) + ) as QuizItemWithExtras[]; setQuiz(uniqueShuffledCombinedQuizzes); if (exerciseDetails.british_american) { @@ -133,14 +158,17 @@ const ExerciseDetailPage = ({ heading, id, title, accent, file, onBack }) => { : exerciseDetails.british?.[0]; } - if (selectedAccentData) { + if (selectedAccentData && selectedAccentData.quiz) { setInstructions(loadInstructions || selectedAccentData.instructions); setQuiz( - selectedAccentData.quiz.map((quiz) => ({ - ...quiz, - split: exerciseDetails.split, - type: exerciseDetails.type, - })) + selectedAccentData.quiz.map((quiz) => { + const base = { ...(quiz as QuizItemWithExtras) }; + if (typeof exerciseDetails.split === "string") + base.split = exerciseDetails.split; + if (typeof exerciseDetails.type === "string") + base.type = exerciseDetails.type; + return base; + }) ); } } @@ -158,12 +186,10 @@ const ExerciseDetailPage = ({ heading, id, title, accent, file, onBack }) => { throw new Error("Failed to fetch exercise data"); } - const data = await response.json(); + const data: ExerciseDataType = await response.json(); const exerciseKey = file.replace("exercise_", "").replace(".json", ""); const exerciseDetails = data[exerciseKey]?.find((exercise) => exercise.id === id); - // Save fetched data to IndexedDB (excluding Electron) - setCurrentExerciseType(exerciseKey); if (exerciseDetails) { @@ -181,17 +207,22 @@ const ExerciseDetailPage = ({ heading, id, title, accent, file, onBack }) => { fetchExerciseData(); }, [fetchExerciseData]); - const handleAnswer = (correctCountOrBoolean, quizType = "single", quizAnswerNum = 1) => { + const handleAnswer = ( + correctCountOrBoolean: boolean | number, + quizType = "single", + quizAnswerNum = 1 + ) => { if (quizType === "single") { - // For single answer quizzes like DictationQuiz setTotalAnswered((prev) => prev + 1); if (correctCountOrBoolean) { setScore((prev) => prev + 1); } } else if (quizType === "multiple") { - // For multiple answer quizzes like MatchUp setTotalAnswered((prev) => prev + quizAnswerNum); - setScore((prev) => prev + correctCountOrBoolean); + setScore( + (prev) => + prev + (typeof correctCountOrBoolean === "number" ? correctCountOrBoolean : 0) + ); } }; @@ -209,7 +240,7 @@ const ExerciseDetailPage = ({ heading, id, title, accent, file, onBack }) => { }; const handleMatchFinished = () => { - setOnMatchFinished(true); // Set match finished to true when all cards are revealed + setOnMatchFinished(true); }; const getEncouragementMessage = () => { @@ -217,13 +248,13 @@ const ExerciseDetailPage = ({ heading, id, title, accent, file, onBack }) => { return (
    {t("exercise_page.encouragementMsg.level0")} - + Rocket Emoji
    ); const percentage = (score / totalAnswered) * 100; - let level; + let level: 1 | 2 | 3 | 4 | 5 | 6; switch (true) { case percentage === 100: level = 6; @@ -244,7 +275,7 @@ const ExerciseDetailPage = ({ heading, id, title, accent, file, onBack }) => { level = 1; } - const emojis = { + const emojis: Record<1 | 2 | 3 | 4 | 5 | 6, string> = { 6: partyPopperEmoji, 5: thumbUpEmoji, 4: smilingFaceWithSmilingEyesEmoji, @@ -256,7 +287,11 @@ const ExerciseDetailPage = ({ heading, id, title, accent, file, onBack }) => { return (
    {t(`exercise_page.encouragementMsg.level${level}`)} - + {`Level
    ); }; @@ -265,40 +300,190 @@ const ExerciseDetailPage = ({ heading, id, title, accent, file, onBack }) => { quizCompleted && totalAnswered > 0 ? getEncouragementMessage() : null; const renderQuizComponent = () => { - // Remove "exercise_" prefix and ".json" suffix const exerciseType = file.replace("exercise_", "").replace(".json", ""); - const componentsMap = { - dictation: DictationQuiz, - matchup: MatchUp, - reordering: Reordering, - sound_n_spelling: SoundAndSpelling, - sorting: SortingExercise, - odd_one_out: OddOneOut, - snap: Snap, - memory_match: MemoryMatch, + // Helper to cast quiz to the correct type + const getQuizForType = () => { + switch (exerciseType) { + case "dictation": + return quiz as DictationQuizItem[]; + case "matchup": + return quiz as MatchUpQuizItem[]; + case "reordering": + return quiz as ReorderingQuizData[]; + case "sound_n_spelling": + return quiz as SoundAndSpellingQuizItem[]; + case "sorting": + return quiz as SortingQuizItem[]; + case "odd_one_out": + return quiz as OddOneOutQuestion[]; + case "snap": + return quiz as SnapQuizItem[]; + case "memory_match": + return quiz as MemoryMatchQuizItem[]; + default: + return quiz; + } }; - const QuizComponent = componentsMap[exerciseType]; + // Helper to provide the correct handleAnswer signature + const getHandleAnswer = () => { + switch (exerciseType) { + case "dictation": + return (isCorrect: boolean, mode: string) => + handleAnswer(isCorrect, mode as "single" | "multiple"); + case "matchup": + case "sorting": + return (correctCount: number, type: string, total: number) => + handleAnswer(correctCount, type, total); + case "reordering": + return (isCorrect: number, type: "single" | "multiple", total?: number) => + handleAnswer(isCorrect, type, total); + case "sound_n_spelling": + return (score: number, type: string) => handleAnswer(score, type); + case "odd_one_out": + return (isCorrect: number, type: string) => handleAnswer(isCorrect, type); + case "snap": + return (isCorrect: number, type: "single" | "multiple", total?: number) => + handleAnswer(isCorrect, type, total); + default: + return handleAnswer; + } + }; - return ( - }> - {QuizComponent ? ( - - ) : ( -
    This quiz type is not yet implemented.
    - )} -
    - ); + // Render with correct props for each component + switch (exerciseType) { + case "dictation": + return ( + }> + void + } + onQuit={handleQuizQuit} + setTimeIsUp={setTimeIsUp} + /> + + ); + case "matchup": + return ( + }> + void + } + onQuit={handleQuizQuit} + setTimeIsUp={setTimeIsUp} + /> + + ); + case "reordering": + return ( + }> + void + } + onQuit={handleQuizQuit} + setTimeIsUp={setTimeIsUp} + /> + + ); + case "sound_n_spelling": + return ( + }> + void} + onQuit={handleQuizQuit} + setTimeIsUp={setTimeIsUp} + /> + + ); + case "sorting": + return ( + }> + void + } + onQuit={handleQuizQuit} + setTimeIsUp={setTimeIsUp} + /> + + ); + case "odd_one_out": + return ( + }> + void + } + onQuit={handleQuizQuit} + setTimeIsUp={setTimeIsUp} + /> + + ); + case "snap": + return ( + }> + void + } + onQuit={handleQuizQuit} + setTimeIsUp={setTimeIsUp} + /> + + ); + case "memory_match": + return ( + }> + + + ); + default: + return ( + }> +
    This quiz type is not yet implemented.
    +
    + ); + } }; return ( @@ -369,7 +554,7 @@ const ExerciseDetailPage = ({ heading, id, title, accent, file, onBack }) => {
    - + ) : ( @@ -73,18 +78,10 @@ const SortableWord = ({ word, item, isCorrect, disabled, isOverlay }) => { } pointer-events-none ${isCorrect ? "btn-success" : "btn-error"}`} >

    - {he.decode(word?.text || item?.value)} {renderTrueFalseIcon()} + {he.decode(word?.text || item?.value || "")} {renderTrueFalseIcon()}

    ); }; -SortableWord.propTypes = { - word: PropTypes.object, - item: PropTypes.object, - isCorrect: PropTypes.oneOfType([PropTypes.bool, PropTypes.oneOf([null])]), - disabled: PropTypes.bool, - isOverlay: PropTypes.bool, -}; - export default SortableWord; diff --git a/src/components/exercise_page/SortingExercise.jsx b/src/frontend/components/exercise_page/SortingExercise.tsx similarity index 81% rename from src/components/exercise_page/SortingExercise.jsx rename to src/frontend/components/exercise_page/SortingExercise.tsx index c983c0d61..d6c037694 100644 --- a/src/components/exercise_page/SortingExercise.jsx +++ b/src/frontend/components/exercise_page/SortingExercise.tsx @@ -5,6 +5,8 @@ import { closestCenter, useSensor, useSensors, + type DragStartEvent, + type DragEndEvent, } from "@dnd-kit/core"; import { SortableContext, @@ -14,13 +16,13 @@ import { } from "@dnd-kit/sortable"; import he from "he"; import _ from "lodash"; -import PropTypes from "prop-types"; import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { LiaCheckCircle, LiaChevronCircleRightSolid, LiaTimesCircle } from "react-icons/lia"; -import { ShuffleArray } from "../../utils/ShuffleArray"; -import useCountdownTimer from "../../utils/useCountdownTimer"; -import SortableWord from "./SortableWord"; +import ShuffleArray from "../../utils/ShuffleArray.js"; +import useCountdownTimer from "../../utils/useCountdownTimer.js"; +import SortableWord from "./SortableWord.js"; +import type { RowOption, TableHeading, SortingQuizItem, SortingExerciseProps } from "./types.js"; const SortingExercise = ({ quiz, @@ -29,15 +31,15 @@ const SortingExercise = ({ useHorizontalStrategy = false, timer, setTimeIsUp, -}) => { - const [currentQuestionIndex, setcurrentQuestionIndex] = useState(0); - const [itemsLeft, setItemsLeft] = useState([]); - const [itemsRight, setItemsRight] = useState([]); - const [activeId, setActiveId] = useState(null); - const [buttonsDisabled, setButtonsDisabled] = useState(false); - const [currentTableHeading, setCurrentTableHeading] = useState([]); - const [shuffledQuiz, setShuffledQuiz] = useState([]); - const [hasSubmitted, setHasSubmitted] = useState(false); +}: SortingExerciseProps) => { + const [currentQuestionIndex, setcurrentQuestionIndex] = useState(0); + const [itemsLeft, setItemsLeft] = useState([]); + const [itemsRight, setItemsRight] = useState([]); + const [activeId, setActiveId] = useState(null); + const [buttonsDisabled, setButtonsDisabled] = useState(false); + const [currentTableHeading, setCurrentTableHeading] = useState([]); + const [shuffledQuiz, setShuffledQuiz] = useState([]); + const [hasSubmitted, setHasSubmitted] = useState(false); const { formatTime, clearTimer, startTimer } = useCountdownTimer(timer, () => setTimeIsUp(true) @@ -53,19 +55,19 @@ const SortingExercise = ({ const sensors = useSensors(useSensor(PointerSensor)); - const filterAndShuffleQuiz = useCallback((quiz) => { + const filterAndShuffleQuiz = useCallback((quiz: SortingQuizItem[]): SortingQuizItem[] => { const uniqueQuiz = _.uniqWith(quiz, _.isEqual); return ShuffleArray(uniqueQuiz); }, []); - const generateUniqueItems = (items) => { + const generateUniqueItems = (items: Omit[]): RowOption[] => { return items.map((item, index) => ({ ...item, id: `${item.value}-${index}-${Math.random().toString(36).substring(2, 11)}`, })); }; - const loadQuiz = useCallback((quizData) => { + const loadQuiz = useCallback((quizData: SortingQuizItem) => { const shuffledOptions = ShuffleArray([...quizData.rowOptions]); const uniqueItems = generateUniqueItems(shuffledOptions); @@ -94,11 +96,11 @@ const SortingExercise = ({ } }, [shuffledQuiz, currentQuestionIndex, loadQuiz]); - const handleDragStart = (event) => { - setActiveId(event.active.id); + const handleDragStart = (event: DragStartEvent) => { + setActiveId(event.active.id as string); }; - const handleDragEnd = (event) => { + const handleDragEnd = (event: DragEndEvent) => { const { active, over } = event; if (!over) { setActiveId(null); @@ -175,6 +177,16 @@ const SortingExercise = ({ ? horizontalListSortingStrategy : verticalListSortingStrategy; + interface SortableColumnProps { + items: RowOption[]; + heading?: TableHeading; + columnPos: number; + sortableStrategy: typeof horizontalListSortingStrategy | typeof verticalListSortingStrategy; + hasSubmitted: boolean; + buttonsDisabled: boolean; + t: (key: string) => string; + } + const SortableColumn = ({ items, heading, @@ -183,7 +195,7 @@ const SortingExercise = ({ hasSubmitted, buttonsDisabled, t, - }) => ( + }: SortableColumnProps) => (
    {heading && ( @@ -197,7 +209,7 @@ const SortingExercise = ({
    - + item.id)} strategy={sortableStrategy}> {items.length > 0 ? ( items.map((item) => ( ); - SortingExercise.propTypes = { - quiz: PropTypes.arrayOf(PropTypes.object).isRequired, - onAnswer: PropTypes.func.isRequired, - onQuit: PropTypes.func.isRequired, - useHorizontalStrategy: PropTypes.bool, - timer: PropTypes.number.isRequired, - setTimeIsUp: PropTypes.func.isRequired, - }; - - SortableColumn.propTypes = { - items: PropTypes.arrayOf(PropTypes.object).isRequired, - heading: PropTypes.object, - columnPos: PropTypes.number.isRequired, - sortableStrategy: PropTypes.func.isRequired, - hasSubmitted: PropTypes.bool.isRequired, - buttonsDisabled: PropTypes.bool.isRequired, - t: PropTypes.func.isRequired, - }; - return ( <>
    @@ -280,14 +273,20 @@ const SortingExercise = ({ ))} - {activeId ? ( - item.id === activeId)} - isOverlay - /> - ) : null} + {activeId + ? (() => { + const foundItem = itemsLeft + .concat(itemsRight) + .find((item) => item.id === activeId); + return foundItem ? ( + + ) : null; + })() + : null}
    diff --git a/src/components/exercise_page/SoundAndSpelling.jsx b/src/frontend/components/exercise_page/SoundAndSpelling.tsx similarity index 86% rename from src/components/exercise_page/SoundAndSpelling.jsx rename to src/frontend/components/exercise_page/SoundAndSpelling.tsx index 2b83b1e5b..22052c376 100644 --- a/src/components/exercise_page/SoundAndSpelling.jsx +++ b/src/frontend/components/exercise_page/SoundAndSpelling.tsx @@ -1,25 +1,34 @@ import he from "he"; import _ from "lodash"; -import PropTypes from "prop-types"; import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { BsCheckCircleFill, BsXCircleFill } from "react-icons/bs"; import { IoVolumeHigh, IoVolumeHighOutline } from "react-icons/io5"; import { LiaChevronCircleRightSolid, LiaTimesCircle } from "react-icons/lia"; -import { ShuffleArray } from "../../utils/ShuffleArray"; -import { sonnerErrorToast } from "../../utils/sonnerCustomToast"; -import useCountdownTimer from "../../utils/useCountdownTimer"; +import ShuffleArray from "../../utils/ShuffleArray.js"; +import { sonnerErrorToast } from "../../utils/sonnerCustomToast.js"; +import useCountdownTimer from "../../utils/useCountdownTimer.js"; +import type { QuizOption, SoundAndSpellingProps, SoundAndSpellingQuizItem } from "./types.js"; -const SoundAndSpelling = ({ quiz, onAnswer, onQuit, timer, setTimeIsUp }) => { - const [currentQuestionIndex, setcurrentQuestionIndex] = useState(0); - const [shuffledQuiz, setShuffledQuiz] = useState([]); - const [shuffledOptions, setShuffledOptions] = useState([]); - const [isPlaying, setIsPlaying] = useState(false); - const [buttonsDisabled, setButtonsDisabled] = useState(false); - const [currentQuestionText, setCurrentQuestionText] = useState(""); - const [currentAudioSrc, setCurrentAudioSrc] = useState(""); - const [selectedOption, setSelectedOption] = useState(null); - const [isLoading, setIsLoading] = useState(false); +const SoundAndSpelling = ({ + quiz, + onAnswer, + onQuit, + timer, + setTimeIsUp, +}: SoundAndSpellingProps) => { + const [currentQuestionIndex, setcurrentQuestionIndex] = useState(0); + const [shuffledQuiz, setShuffledQuiz] = useState([]); + const [shuffledOptions, setShuffledOptions] = useState([]); + const [isPlaying, setIsPlaying] = useState(false); + const [buttonsDisabled, setButtonsDisabled] = useState(false); + const [currentQuestionText, setCurrentQuestionText] = useState(""); + const [currentAudioSrc, setCurrentAudioSrc] = useState(""); + const [selectedOption, setSelectedOption] = useState<{ + index: number; + isCorrect: boolean; + } | null>(null); + const [isLoading, setIsLoading] = useState(false); const { formatTime, clearTimer, startTimer } = useCountdownTimer(timer, () => setTimeIsUp(true) @@ -28,14 +37,14 @@ const SoundAndSpelling = ({ quiz, onAnswer, onQuit, timer, setTimeIsUp }) => { const { t } = useTranslation(); // Use a ref to manage the audio element - const audioRef = useRef(null); + const audioRef = useRef(null); - const filterAndShuffleQuiz = (quiz) => { + const filterAndShuffleQuiz = (quiz: SoundAndSpellingQuizItem[]): SoundAndSpellingQuizItem[] => { const uniqueQuiz = _.uniqWith(quiz, _.isEqual); return ShuffleArray(uniqueQuiz); }; - const loadQuiz = useCallback((quizData) => { + const loadQuiz = useCallback((quizData: SoundAndSpellingQuizItem) => { // Shuffle the answer options const shuffledOptions = ShuffleArray([...quizData.data]); setShuffledOptions(shuffledOptions); @@ -94,7 +103,7 @@ const SoundAndSpelling = ({ quiz, onAnswer, onQuit, timer, setTimeIsUp }) => { setIsPlaying(false); } else { setIsLoading(true); - const newAudio = new Audio(currentAudioSrc); + const newAudio = new window.Audio(currentAudioSrc); audioRef.current = newAudio; newAudio.load(); @@ -121,7 +130,7 @@ const SoundAndSpelling = ({ quiz, onAnswer, onQuit, timer, setTimeIsUp }) => { } }; - const handleOptionClick = (isCorrect, index) => { + const handleOptionClick = (isCorrect: boolean, index: number) => { startTimer(); setButtonsDisabled(true); setSelectedOption({ index, isCorrect }); @@ -259,12 +268,4 @@ const SoundAndSpelling = ({ quiz, onAnswer, onQuit, timer, setTimeIsUp }) => { ); }; -SoundAndSpelling.propTypes = { - quiz: PropTypes.arrayOf(PropTypes.object).isRequired, - onAnswer: PropTypes.func.isRequired, - onQuit: PropTypes.func.isRequired, - timer: PropTypes.number.isRequired, - setTimeIsUp: PropTypes.func.isRequired, -}; - export default SoundAndSpelling; diff --git a/src/frontend/components/exercise_page/types.ts b/src/frontend/components/exercise_page/types.ts new file mode 100644 index 000000000..49f60bbfc --- /dev/null +++ b/src/frontend/components/exercise_page/types.ts @@ -0,0 +1,291 @@ +import type { UniqueIdentifier } from "@dnd-kit/core"; +import type { horizontalListSortingStrategy, verticalListSortingStrategy } from "@dnd-kit/sortable"; + +interface TimedExerciseProps { + timer: number; + onQuit: () => void; + setTimeIsUp: (isUp: boolean) => void; +} + +export type AllQuizItems = + | DictationQuizItem + | MatchUpQuizItem + | MemoryMatchQuizItem + | OddOneOutQuestion + | ReorderingQuizData + | SnapQuizItem + | SortingQuizItem + | SoundAndSpellingQuizItem; + +export type QuizItemWithExtras = AllQuizItems & { split?: string; type?: string }; + +// ExercisePage +export interface Exercise { + id: string | number; + title: string; + titleKey?: string; + infoKey?: string; + american?: boolean; + british?: boolean; + file?: string; +} + +export interface ExerciseSection { + heading: string; + titles: Exercise[]; + infoKey: string; + file?: string; +} + +export interface SelectedExercise { + id: string | number; + title: string; + accent: string; + file: string; + heading: string; +} + +export interface TooltipIconProps { + info: string; + onClick: () => void; +} + +export interface ExerciseCardProps { + heading: string; + titles: Exercise[]; + infoKey: string; + file?: string; + onShowModal: (info: string) => void; +} + +// ExerciseDetailPage +export interface ExerciseDetailPageProps { + heading: string; + id: string | number; + title: string; + accent: string; + file: string; + onBack: () => void; +} + +export interface ExerciseDetailsType { + id: string | number; + split?: string; + type?: string; + british_american?: ExerciseDetailsType[]; + american?: ExerciseDetailsType[]; + british?: ExerciseDetailsType[]; + quiz?: AllQuizItems[]; + instructions?: string[]; + exercise?: string; +} +export type ExerciseDataType = Record; + +// DictationQuiz +export type DictationWord = { textbox: string; level?: string } | { value: string }; + +export interface DictationQuizItem { + words: DictationWord[]; + audio: { src: string }; + type?: string; +} + +export interface DictationQuizProps extends TimedExerciseProps { + quiz: DictationQuizItem[]; + onAnswer: (isCorrect: boolean, mode: string) => void; +} + +// MatchUp +export interface AudioItem { + src: string; +} + +export interface WordItem { + text: string; + drag: boolean; + id: string; // Always present +} + +export interface MatchUpQuizItem { + audio: AudioItem[]; + words: WordItem[]; + type?: string; +} + +export interface MatchUpProps extends TimedExerciseProps { + quiz: MatchUpQuizItem[]; + onAnswer: (correctCount: number, type: string, total: number) => void; +} + +// MemoryMatch +export type CardFeedbackType = "correctPair" | "incorrectPair"; + +export interface MemoryMatchCard { + value: string; + text: string; +} + +export interface MemoryMatchQuizItem { + data: MemoryMatchCard[]; +} + +export interface MemoryMatchProps extends TimedExerciseProps { + quiz: MemoryMatchQuizItem[]; + onMatchFinished: () => void; +} + +export interface ShuffledCard extends MemoryMatchCard { + id: number; +} + +// OddOneOut +export interface OddOneOutOption { + value: string; + index: string; + answer: "true" | "false"; +} + +export interface OddOneOutQuestion { + data: OddOneOutOption[]; + question: { correctAns: string }[]; + split?: string; + type?: string; +} + +export interface OddOneOutProps extends TimedExerciseProps { + quiz: OddOneOutQuestion[]; + onAnswer: (isCorrect: number, type: string) => void; +} + +// Reordering +export interface ReorderingQuizData { + data: { value: string }[]; + answer: string[]; + audio: { src: string }; + split: "word" | "sentence" | string; +} + +export interface ReorderingProps extends TimedExerciseProps { + quiz: ReorderingQuizData[]; + onAnswer: (isCorrect: number, type: "single" | "multiple", total?: number) => void; +} + +export interface ShuffledItem { + id: UniqueIdentifier; + value: string; + isCorrect?: boolean; +} + +// Snap +export interface SnapQuizDataItem { + value: string; + index: string; +} + +export interface SnapQuizFeedback { + value?: string; + index?: string; + answer?: string; + correctAns?: string; +} + +export interface SnapQuizItem { + data: SnapQuizDataItem[]; + feedbacks: SnapQuizFeedback[]; +} + +export interface SnapProps extends TimedExerciseProps { + quiz: SnapQuizItem[]; + onAnswer: (isCorrect: number, type: "single" | "multiple", total?: number) => void; +} + +export type ResultType = "success" | "danger" | null; + +export interface DroppableAreaProps { + feedback: SnapQuizFeedback; + isDropped: boolean; + result: ResultType; + droppedOn: string | null; +} + +export interface DraggableItemProps { + isDropped: boolean; +} + +// SortableWord +export interface Word { + id: string; + text?: string; +} + +export interface Item { + id: string; + value?: string; +} + +export interface SortableWordProps { + word?: Word; + item?: Item; + isCorrect: boolean | null; + disabled?: boolean; + isOverlay?: boolean; +} + +// SortingExercise +export interface RowOption { + value: string; + columnPos: number; + id: string; +} + +export interface TableHeading { + text: string; +} + +export interface SortingQuizItem { + tableHeading: TableHeading[]; + rowOptions: RowOption[]; +} + +export interface SortingExerciseProps extends TimedExerciseProps { + quiz: SortingQuizItem[]; + onAnswer: (correctCount: number, type: string, total: number) => void; + useHorizontalStrategy?: boolean; +} + +export interface SortableColumnProps { + items: RowOption[]; + heading?: TableHeading; + columnPos: number; + sortableStrategy: typeof horizontalListSortingStrategy | typeof verticalListSortingStrategy; + hasSubmitted: boolean; + buttonsDisabled: boolean; + t: (key: string) => string; +} + +// SoundAndSpelling +export interface QuizOption { + value: string; + index: string; + answer: "true" | "false"; +} + +export interface QuizQuestion { + text: string; + correctAns: string; +} + +export interface QuizAudio { + src: string; +} + +export interface SoundAndSpellingQuizItem { + data: QuizOption[]; + question: QuizQuestion[]; + audio: QuizAudio; +} + +export interface SoundAndSpellingProps extends TimedExerciseProps { + quiz: SoundAndSpellingQuizItem[]; + onAnswer: (score: number, type: string) => void; +} diff --git a/src/components/general/AccentDropdown.jsx b/src/frontend/components/general/AccentDropdown.tsx similarity index 73% rename from src/components/general/AccentDropdown.jsx rename to src/frontend/components/general/AccentDropdown.tsx index 53cf49e61..6c764dd56 100644 --- a/src/components/general/AccentDropdown.jsx +++ b/src/frontend/components/general/AccentDropdown.tsx @@ -1,14 +1,13 @@ -import PropTypes from "prop-types"; import { useEffect } from "react"; import { useTranslation } from "react-i18next"; -import AccentLocalStorage from "../../utils/AccentLocalStorage"; -import { sonnerSuccessToast } from "../../utils/sonnerCustomToast"; +import AccentLocalStorage from "../../utils/AccentLocalStorage.js"; +import { sonnerSuccessToast } from "../../utils/sonnerCustomToast.js"; // Emoji SVGs import import UKFlagEmoji from "../../emojiSvg/emoji_u1f1ec_1f1e7.svg"; import USFlagEmoji from "../../emojiSvg/emoji_u1f1fa_1f1f8.svg"; -const AccentDropdown = ({ onAccentChange }) => { +const AccentDropdown = ({ onAccentChange }: { onAccentChange: (value: string) => void }) => { const [selectedAccent, setSelectedAccent] = AccentLocalStorage(); const { t } = useTranslation(); @@ -18,17 +17,19 @@ const AccentDropdown = ({ onAccentChange }) => { ]; useEffect(() => { - const currentSettings = JSON.parse(localStorage.getItem("ispeaker")) || {}; + const currentSettings = JSON.parse(localStorage.getItem("ispeaker") || "{}") || {}; const updatedSettings = { ...currentSettings, selectedAccent: selectedAccent }; localStorage.setItem("ispeaker", JSON.stringify(updatedSettings)); }, [selectedAccent]); - const handleAccentChange = (value) => { + const handleAccentChange = (value: string) => { setSelectedAccent(value); onAccentChange(value); sonnerSuccessToast(t("settingPage.changeSaved")); }; + const selectedAccentOption = selectedAccentOptions.find((item) => item.value === selectedAccent); + return ( <>
    @@ -36,13 +37,11 @@ const AccentDropdown = ({ onAccentChange }) => {
    item.value === selectedAccent) - .emoji - } + src={selectedAccentOption?.emoji} className="inline-block h-6 w-6" + title={selectedAccentOption?.name} /> - {selectedAccentOptions.find((item) => item.value === selectedAccent).name} + {selectedAccentOption?.name}
      { {selectedAccentOptions.map((item) => (
    • handleAccentChange(item.value)} > - {" "} + {item.name}{" "} {item.name}
    • @@ -68,8 +66,4 @@ const AccentDropdown = ({ onAccentChange }) => { ); }; -AccentDropdown.propTypes = { - onAccentChange: PropTypes.func.isRequired, -}; - export default AccentDropdown; diff --git a/src/components/general/Footer.jsx b/src/frontend/components/general/Footer.tsx similarity index 87% rename from src/components/general/Footer.jsx rename to src/frontend/components/general/Footer.tsx index a0e752daf..264178cee 100644 --- a/src/components/general/Footer.jsx +++ b/src/frontend/components/general/Footer.tsx @@ -1,5 +1,5 @@ -import openExternal from "../../utils/openExternal"; -import LogoLightOrDark from "./LogoLightOrDark"; +import openExternal from "../../utils/openExternal.js"; +import LogoLightOrDark from "./LogoLightOrDark.js"; const Footer = () => { return ( @@ -8,14 +8,13 @@ const Footer = () => { className="footer sm:footer-horizontal bg-base-200 text-base-content p-6 pb-20 md:p-10" >
    diff --git a/src/components/general/TopNavBar.jsx b/src/frontend/components/general/TopNavBar.tsx similarity index 97% rename from src/components/general/TopNavBar.jsx rename to src/frontend/components/general/TopNavBar.tsx index cf486f01e..11831fd41 100644 --- a/src/components/general/TopNavBar.jsx +++ b/src/frontend/components/general/TopNavBar.tsx @@ -8,8 +8,8 @@ import { PiExam } from "react-icons/pi"; import { useTranslation } from "react-i18next"; import { NavLink, useLocation } from "react-router-dom"; -import useAutoDetectTheme from "../../utils/ThemeContext/useAutoDetectTheme"; -import openExternal from "../../utils/openExternal"; +import useAutoDetectTheme from "../../utils/ThemeContext/useAutoDetectTheme.js"; +import openExternal from "../../utils/openExternal.js"; const TopNavBar = () => { const { t } = useTranslation(); @@ -83,7 +83,7 @@ const TopNavBar = () => { - +
      @@ -154,9 +154,8 @@ const TopNavBar = () => {
      {item.icon} {item.label} diff --git a/src/components/general/VersionUpdateDialog.jsx b/src/frontend/components/general/VersionUpdateDialog.tsx similarity index 89% rename from src/components/general/VersionUpdateDialog.jsx rename to src/frontend/components/general/VersionUpdateDialog.tsx index cc2609025..8b795a122 100644 --- a/src/components/general/VersionUpdateDialog.jsx +++ b/src/frontend/components/general/VersionUpdateDialog.tsx @@ -1,4 +1,3 @@ -import PropTypes from "prop-types"; import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -7,14 +6,14 @@ const RATE_LIMIT_THRESHOLD = 45; const currentVersion = __APP_VERSION__; -const VersionUpdateDialog = ({ open, onRefresh }) => { +const VersionUpdateDialog = ({ open, onRefresh }: { open: boolean; onRefresh: () => void }) => { const { t } = useTranslation(); - const dialogRef = useRef(null); - const [latestVersion, setLatestVersion] = useState(null); + const dialogRef = useRef(null); + const [latestVersion, setLatestVersion] = useState(null); const [checking, setChecking] = useState(false); - const [error, setError] = useState(null); + const [error, setError] = useState(null); const [isRefreshing, setIsRefreshing] = useState(false); - const abortControllerRef = useRef(null); + const abortControllerRef = useRef(null); useEffect(() => { if (!open) return; @@ -98,11 +97,13 @@ const VersionUpdateDialog = ({ open, onRefresh }) => { // Only show dialog if versions don't match and component is still mounted if (latest !== currentVersion && dialogRef.current) { - dialogRef.current.showModal(); + if (typeof dialogRef.current.showModal === 'function') { + dialogRef.current.showModal(); + } } } catch (err) { // Only set error if it's not an abort error - if (err.name !== "AbortError") { + if (err instanceof Error && err.name !== "AbortError") { setError(err.message); } setChecking(false); @@ -120,7 +121,9 @@ const VersionUpdateDialog = ({ open, onRefresh }) => { useEffect(() => { if (!open && dialogRef.current) { - dialogRef.current.close(); + if (typeof dialogRef.current.close === 'function') { + dialogRef.current.close(); + } } }, [open]); @@ -141,7 +144,11 @@ const VersionUpdateDialog = ({ open, onRefresh }) => { onRefresh(); } catch (err) { - setError(err.message); + if (err instanceof Error) { + setError(err.message); + } else { + setError(String(err)); + } setIsRefreshing(false); } }; @@ -161,7 +168,7 @@ const VersionUpdateDialog = ({ open, onRefresh }) => { <>

      {t("alert.appUpdateError")}

      - {t("alert.appNewVersionErrorReason")}{" "} + {t("alert.appNewVersionErrorReason")} {" "} {error}

      @@ -199,7 +206,7 @@ const VersionUpdateDialog = ({ open, onRefresh }) => { type="button" className="btn" onClick={() => { - if (dialogRef.current) dialogRef.current.close(); + if (dialogRef.current && typeof dialogRef.current.close === 'function') dialogRef.current.close(); }} > {t("sound_page.closeBtn")} @@ -223,9 +230,4 @@ const VersionUpdateDialog = ({ open, onRefresh }) => { ); }; -VersionUpdateDialog.propTypes = { - open: PropTypes.bool.isRequired, - onRefresh: PropTypes.func.isRequired, -}; - export default VersionUpdateDialog; diff --git a/src/components/setting_page/AppInfo.jsx b/src/frontend/components/setting_page/AppInfo.tsx similarity index 96% rename from src/components/setting_page/AppInfo.jsx rename to src/frontend/components/setting_page/AppInfo.tsx index c79f118a9..dd1f00474 100644 --- a/src/components/setting_page/AppInfo.jsx +++ b/src/frontend/components/setting_page/AppInfo.tsx @@ -36,7 +36,7 @@ const AppInfo = () => { try { const now = Math.floor(Date.now() / 1000); // Current timestamp in seconds const savedResetTime = localStorage.getItem(RATE_LIMIT_KEY); - const resetTime = new Date(parseInt(savedResetTime, 10) * 1000).toLocaleString(); + const resetTime = new Date(parseInt(savedResetTime || "0", 10) * 1000).toLocaleString(); // If a reset time is stored and it's in the future, skip API request if (savedResetTime && now < parseInt(savedResetTime, 10)) { @@ -87,7 +87,7 @@ const AppInfo = () => { `Rate limit is low (${rateLimitRemaining} remaining). Skipping update check.` ); const resetTimeFirst = new Date( - parseInt(rateLimitReset + 5 * 3600, 10) * 1000 + parseInt((rateLimitReset + 5 * 3600).toString(), 10) * 1000 ).toLocaleString(); localStorage.setItem(RATE_LIMIT_KEY, (rateLimitReset + 5 * 3600).toString()); setAlertMessage(t("alert.rateLimited", { time: resetTimeFirst })); @@ -147,6 +147,8 @@ const AppInfo = () => { )}
      + +
      +
      + ); +}; + +export default ExerciseTimer; diff --git a/src/components/setting_page/LanguageSwitcher.jsx b/src/frontend/components/setting_page/LanguageSwitcher.tsx similarity index 95% rename from src/components/setting_page/LanguageSwitcher.jsx rename to src/frontend/components/setting_page/LanguageSwitcher.tsx index e206a9730..56cd0570c 100644 --- a/src/components/setting_page/LanguageSwitcher.jsx +++ b/src/frontend/components/setting_page/LanguageSwitcher.tsx @@ -1,7 +1,7 @@ import { useTranslation } from "react-i18next"; import { LuExternalLink } from "react-icons/lu"; -import openExternal from "../../utils/openExternal"; -import { sonnerSuccessToast } from "../../utils/sonnerCustomToast"; +import openExternal from "../../utils/openExternal.js"; +import { sonnerSuccessToast } from "../../utils/sonnerCustomToast.js"; // Supported languages const supportedLanguages = [ @@ -19,11 +19,13 @@ const supportedLanguages = [ const LanguageSwitcher = () => { const { t, i18n } = useTranslation(); - const handleLanguageChange = (lng) => { + const handleLanguageChange = (lng: string) => { i18n.changeLanguage(lng); document.documentElement.setAttribute("lang", lng); // Update HTML lang attribute - const ispeakerSettings = JSON.parse(localStorage.getItem("ispeaker")) || {}; + const ispeakerSettings = JSON.parse(localStorage.getItem("ispeaker") || "{}") as { + language: string; + }; ispeakerSettings.language = lng; localStorage.setItem("ispeaker", JSON.stringify(ispeakerSettings)); diff --git a/src/components/setting_page/LogSettings.jsx b/src/frontend/components/setting_page/LogSettings.tsx similarity index 92% rename from src/components/setting_page/LogSettings.jsx rename to src/frontend/components/setting_page/LogSettings.tsx index ef3c85c8c..c6605f5d1 100644 --- a/src/components/setting_page/LogSettings.jsx +++ b/src/frontend/components/setting_page/LogSettings.tsx @@ -1,11 +1,19 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { LuExternalLink } from "react-icons/lu"; -import { sonnerSuccessToast } from "../../utils/sonnerCustomToast"; +import { sonnerSuccessToast } from "../../utils/sonnerCustomToast.js"; + +interface LogSettings { + numOfLogs: number; + keepForDays: number; + logLevel: string; + logFormat: string; + maxLogSize: number; +} const LogSettings = () => { const { t } = useTranslation(); - const [, setFolderPath] = useState(null); + const [, setFolderPath] = useState(null); const maxLogOptions = useMemo( () => [ @@ -73,7 +81,9 @@ const LogSettings = () => { let isMounted = true; async function fetchLogSettings() { try { - const settings = await window.electron.ipcRenderer.invoke("get-log-settings"); + const settings = (await window.electron.ipcRenderer.invoke( + "get-log-settings" + )) as LogSettings; if (!isMounted) return; // Find the corresponding options based on stored values const initialMaxLog = @@ -100,7 +110,7 @@ const LogSettings = () => { // Memoize the function so that it doesn't change on every render const handleApplySettings = useCallback( - (maxLogWrittenValue, deleteLogsOlderThanValue) => { + (maxLogWrittenValue: string, deleteLogsOlderThanValue: string) => { const selectedMaxLogOption = maxLogOptions.find( (option) => option.value === maxLogWrittenValue ); @@ -108,6 +118,10 @@ const LogSettings = () => { (option) => option.value === deleteLogsOlderThanValue ); + if (!selectedMaxLogOption || !selectedDeleteLogOption) { + return; + } + const electronSettings = { numOfLogs: selectedMaxLogOption.numOfLogs, keepForDays: selectedDeleteLogOption.keepForDays, @@ -121,7 +135,7 @@ const LogSettings = () => { ); // Helper function to get the label based on the current value - const getLabel = (options, value) => { + const getLabel = (options: { value: string; label: string }[], value: string) => { const selectedOption = options.find((option) => option.value === value); return selectedOption ? selectedOption.label : value; }; @@ -129,7 +143,7 @@ const LogSettings = () => { const handleOpenLogFolder = async () => { // Send an IPC message to open the folder and get the folder path const logFolder = await window.electron.ipcRenderer.invoke("open-log-folder"); - setFolderPath(logFolder); // Save the folder path in state + setFolderPath(logFolder as string); // Save the folder path in state }; if (loading) { diff --git a/src/components/setting_page/PronunciationCheckerDialogContent.jsx b/src/frontend/components/setting_page/PronunciationCheckerDialogContent.tsx similarity index 87% rename from src/components/setting_page/PronunciationCheckerDialogContent.jsx rename to src/frontend/components/setting_page/PronunciationCheckerDialogContent.tsx index 842cca9ee..1559415e8 100644 --- a/src/components/setting_page/PronunciationCheckerDialogContent.jsx +++ b/src/frontend/components/setting_page/PronunciationCheckerDialogContent.tsx @@ -1,8 +1,17 @@ -import PropTypes from "prop-types"; import { Trans } from "react-i18next"; import { IoInformationCircleOutline } from "react-icons/io5"; -import openExternal from "../../utils/openExternal"; -import modelOptions from "./modelOptions"; +import openExternal from "../../utils/openExternal.js"; +import modelOptions from "./modelOptions.js"; + +interface PronunciationCheckerDialogContentProps { + t: (key: string, options?: Record) => string; + checking: boolean; + closeConfirmDialog: () => void; + handleProceed: () => void; + installState: "not_installed" | "failed" | "complete"; + modelValue: string; + onModelChange: (value: string) => void; +} const PronunciationCheckerDialogContent = ({ t, @@ -12,7 +21,8 @@ const PronunciationCheckerDialogContent = ({ installState, modelValue, onModelChange, -}) => { +}: PronunciationCheckerDialogContentProps) => { + const selectedModel = modelOptions.find((opt) => opt.value === modelValue); return (

      @@ -24,13 +34,15 @@ const PronunciationCheckerDialogContent = ({

      - {t("settingPage.saveFolderSettings.saveFolderConfirmDescription", { - returnObjects: true, - }).map((desc, index) => ( + {confirmDescriptionArr.map((desc, index) => (

      {desc}

      diff --git a/src/components/setting_page/SavedRecordingLocationMenu.jsx b/src/frontend/components/setting_page/SavedRecordingLocationMenu.tsx similarity index 94% rename from src/components/setting_page/SavedRecordingLocationMenu.jsx rename to src/frontend/components/setting_page/SavedRecordingLocationMenu.tsx index 6ea4da475..ecaed7e1f 100644 --- a/src/components/setting_page/SavedRecordingLocationMenu.jsx +++ b/src/frontend/components/setting_page/SavedRecordingLocationMenu.tsx @@ -9,7 +9,7 @@ const SavedRecordingLocationMenu = () => { const handleOpenRecordingFolder = async () => { // Send an IPC message to open the folder and get the folder path const recordingFolder = await window.electron.ipcRenderer.invoke("open-recording-folder"); - setFolderPath(recordingFolder); // Save the folder path in state + setFolderPath(recordingFolder as string); // Save the folder path in state }; return ( diff --git a/src/components/setting_page/Settings.jsx b/src/frontend/components/setting_page/Settings.tsx similarity index 86% rename from src/components/setting_page/Settings.jsx rename to src/frontend/components/setting_page/Settings.tsx index 574855f4a..a0891dd64 100644 --- a/src/components/setting_page/Settings.jsx +++ b/src/frontend/components/setting_page/Settings.tsx @@ -1,19 +1,19 @@ import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import Container from "../../ui/Container"; -import isElectron from "../../utils/isElectron"; -import TopNavBar from "../general/TopNavBar"; -import AppearanceSettings from "./Appearance"; -import AppInfo from "./AppInfo"; -import ExerciseTimer from "./ExerciseTimer"; -import LanguageSwitcher from "./LanguageSwitcher"; -import LogSettings from "./LogSettings"; -import PronunciationSettings from "./PronunciationSettings"; -import ResetSettings from "./ResetSettings"; -import SavedRecordingLocationMenu from "./SavedRecordingLocationMenu"; -import SaveFolderSettings from "./SaveFolderSettings"; -import VideoDownloadMenu from "./VideoDownloadMenu"; -import VideoDownloadSubPage from "./VideoDownloadSubPage"; +import Container from "../../ui/Container.js"; +import isElectron from "../../utils/isElectron.js"; +import TopNavBar from "../general/TopNavBar.js"; +import AppearanceSettings from "./Appearance.js"; +import AppInfo from "./AppInfo.js"; +import ExerciseTimer from "./ExerciseTimer.js"; +import LanguageSwitcher from "./LanguageSwitcher.js"; +import LogSettings from "./LogSettings.js"; +import PronunciationSettings from "./PronunciationSettings.js"; +import ResetSettings from "./ResetSettings.js"; +import SavedRecordingLocationMenu from "./SavedRecordingLocationMenu.js"; +import SaveFolderSettings from "./SaveFolderSettings.js"; +import VideoDownloadMenu from "./VideoDownloadMenu.js"; +import VideoDownloadSubPage from "./VideoDownloadSubPage.js"; const SettingsPage = () => { const { t } = useTranslation(); diff --git a/src/components/setting_page/VideoDownloadMenu.jsx b/src/frontend/components/setting_page/VideoDownloadMenu.tsx similarity index 83% rename from src/components/setting_page/VideoDownloadMenu.jsx rename to src/frontend/components/setting_page/VideoDownloadMenu.tsx index 4ea9b7c00..3b3642442 100644 --- a/src/components/setting_page/VideoDownloadMenu.jsx +++ b/src/frontend/components/setting_page/VideoDownloadMenu.tsx @@ -1,7 +1,6 @@ -import PropTypes from "prop-types"; import { useTranslation } from "react-i18next"; -const VideoDownloadMenu = ({ onClick }) => { +const VideoDownloadMenu = ({ onClick }: { onClick: () => void }) => { const { t } = useTranslation(); return ( @@ -22,8 +21,4 @@ const VideoDownloadMenu = ({ onClick }) => { ); }; -VideoDownloadMenu.propTypes = { - onClick: PropTypes.func.isRequired, -}; - export default VideoDownloadMenu; diff --git a/src/components/setting_page/VideoDownloadSubPage.jsx b/src/frontend/components/setting_page/VideoDownloadSubPage.tsx similarity index 78% rename from src/components/setting_page/VideoDownloadSubPage.jsx rename to src/frontend/components/setting_page/VideoDownloadSubPage.tsx index 62dc20f7b..bdc7cb8eb 100644 --- a/src/components/setting_page/VideoDownloadSubPage.jsx +++ b/src/frontend/components/setting_page/VideoDownloadSubPage.tsx @@ -1,24 +1,23 @@ -import PropTypes from "prop-types"; import { useCallback, useEffect, useState } from "react"; import { Trans, useTranslation } from "react-i18next"; import { BsArrowLeft } from "react-icons/bs"; import { IoWarningOutline } from "react-icons/io5"; import { LuExternalLink } from "react-icons/lu"; -import isElectron from "../../utils/isElectron"; -import VideoDownloadTable from "./VideoDownloadTable"; +import isElectron from "../../utils/isElectron.js"; +import VideoDownloadTable, { VideoFileData, DownloadStatus } from "./VideoDownloadTable.js"; -const VideoDownloadSubPage = ({ onGoBack }) => { +const VideoDownloadSubPage = ({ onGoBack }: { onGoBack: () => void }) => { const { t } = useTranslation(); - const [, setFolderPath] = useState(null); - const [zipFileData, setZipFileData] = useState([]); - const [isDownloaded, setIsDownloaded] = useState([]); + const [, setFolderPath] = useState(null); + const [zipFileData, setZipFileData] = useState([]); + const [isDownloaded, setIsDownloaded] = useState([]); const [tableLoading, setTableLoading] = useState(true); const handleOpenFolder = async () => { // Send an IPC message to open the folder and get the folder path const videoFolder = await window.electron.ipcRenderer.invoke("get-video-save-folder"); - setFolderPath(videoFolder); // Save the folder path in state + setFolderPath(videoFolder as string); // Save the folder path in state }; // Fetch JSON data from Electron's main process via IPC when component mounts @@ -26,10 +25,12 @@ const VideoDownloadSubPage = ({ onGoBack }) => { const fetchData = async () => { try { const data = await window.electron.ipcRenderer.invoke("get-video-file-data"); - setZipFileData(data); // Set the JSON data into the state + setZipFileData(data as VideoFileData[]); // Set the JSON data into the state } catch (error) { console.error("Error reading JSON file:", error); // Handle any error - isElectron() && window.electron.log("error", `Error reading JSON file: ${error}`); + if (isElectron()) { + window.electron.log("error", `Error reading JSON file: ${error}`); + } } }; @@ -39,32 +40,41 @@ const VideoDownloadSubPage = ({ onGoBack }) => { const checkDownloadedFiles = useCallback(async () => { try { const downloadedFiles = await window.electron.ipcRenderer.invoke("check-downloads"); - console.log("Downloaded files in folder:", downloadedFiles); - isElectron() && - window.electron.log("log", `Downloaded files in folder: ${downloadedFiles}`); + let downloadedList: string[] = []; + if (Array.isArray(downloadedFiles)) { + downloadedList = downloadedFiles as string[]; + } else if (typeof downloadedFiles === "string") { + // If the string is "no zip files downloaded", treat as empty + downloadedList = []; + } + if (isElectron()) { + window.electron.log("log", `Downloaded files in folder: ${downloadedList}`); + } // Initialize fileStatus as an array to hold individual statuses - const newFileStatus = []; + const newFileStatus: DownloadStatus[] = []; for (const item of zipFileData) { - let extractedFolderExists; + let extractedFolderExists = false; try { - extractedFolderExists = await window.electron.ipcRenderer.invoke( + const result = await window.electron.ipcRenderer.invoke( "check-extracted-folder", item.zipFile.replace(".7z", ""), item.zipContents ); + extractedFolderExists = Boolean(result); } catch (error) { console.error(`Error checking extracted folder for ${item.zipFile}:`, error); - isElectron() && + if (isElectron()) { window.electron.log( "error", `Error checking extracted folder for ${item.zipFile}: ${error}` ); + } extractedFolderExists = false; // Default to false if there's an error } - const isDownloadedFile = downloadedFiles.includes(item.zipFile); + const isDownloadedFile = downloadedList.includes(item.zipFile); newFileStatus.push({ zipFile: item.zipFile, isDownloaded: isDownloadedFile, @@ -77,11 +87,12 @@ const VideoDownloadSubPage = ({ onGoBack }) => { console.log(newFileStatus); } catch (error) { console.error("Error checking downloaded or extracted files:", error); - isElectron() && + if (isElectron()) { window.electron.log( "error", `Error checking downloaded or extracted files: ${error}` ); + } } }, [zipFileData]); @@ -92,11 +103,11 @@ const VideoDownloadSubPage = ({ onGoBack }) => { } }, [zipFileData, checkDownloadedFiles]); + // i18next returns $SpecialObject for returnObjects: true, so cast to string[] const localizedInstructionStep = t("settingPage.videoDownloadSettings.steps", { returnObjects: true, - }); - - const stepCount = localizedInstructionStep.length; + }) as string[]; + const stepCount = Array.isArray(localizedInstructionStep) ? localizedInstructionStep.length : 0; const stepKeys = Array.from( { length: stepCount }, (_, i) => `settingPage.videoDownloadSettings.steps.${i}` @@ -176,8 +187,4 @@ const VideoDownloadSubPage = ({ onGoBack }) => { ); }; -VideoDownloadSubPage.propTypes = { - onGoBack: PropTypes.func.isRequired, -}; - export default VideoDownloadSubPage; diff --git a/src/components/setting_page/VideoDownloadTable.jsx b/src/frontend/components/setting_page/VideoDownloadTable.tsx similarity index 78% rename from src/components/setting_page/VideoDownloadTable.jsx rename to src/frontend/components/setting_page/VideoDownloadTable.tsx index 7fea6aeb7..700c347be 100644 --- a/src/components/setting_page/VideoDownloadTable.jsx +++ b/src/frontend/components/setting_page/VideoDownloadTable.tsx @@ -1,68 +1,96 @@ -import PropTypes from "prop-types"; import { useEffect, useRef, useState } from "react"; -import { Trans } from "react-i18next"; import { BsCheckCircleFill, BsXCircleFill } from "react-icons/bs"; import { LuExternalLink } from "react-icons/lu"; -const VideoDownloadTable = ({ t, data, isDownloaded, onStatusChange }) => { - const [, setShowVerifyModal] = useState(false); - const [showProgressModal, setShowProgressModal] = useState(false); - const [selectedZip, setSelectedZip] = useState(null); - const [verifyFiles, setVerifyFiles] = useState([]); - const [progress, setProgress] = useState(0); - const [modalMessage, setModalMessage] = useState(""); - const [isSuccess, setIsSuccess] = useState(null); - const [progressText, setProgressText] = useState(""); - const [isPercentage, setIsPercentage] = useState(false); - const [progressError, setProgressError] = useState(false); - const [verificationErrors, setVerificationErrors] = useState([]); +interface ExtractedFile { + name: string; + hash: string; +} - const verifyModal = useRef(null); - const progressModal = useRef(null); +interface ZipContent { + extractedFiles: ExtractedFile[]; +} + +export interface VideoFileData { + zipFile: string; + name: string; + fileSize: number; + link: string; + zipHash: string; + zipContents: ZipContent[]; +} + +export interface DownloadStatus { + zipFile: string; + isDownloaded: boolean; + hasExtractedFolder: boolean; +} + +interface VerificationError { + type: string; + name: string; + message: string; +} + +interface VideoDownloadTableProps { + t: (key: string) => string; + data: VideoFileData[]; + isDownloaded: DownloadStatus[]; + onStatusChange?: () => void | Promise; +} + +const VideoDownloadTable = ({ t, data, isDownloaded, onStatusChange }: VideoDownloadTableProps) => { + const [, setShowVerifyModal] = useState(false); + const [showProgressModal, setShowProgressModal] = useState(false); + const [selectedZip, setSelectedZip] = useState(null); + const [verifyFiles, setVerifyFiles] = useState([]); + const [progress, setProgress] = useState(0); + const [modalMessage, setModalMessage] = useState(""); + const [isSuccess, setIsSuccess] = useState(null); + const [progressText, setProgressText] = useState(""); + const [isPercentage, setIsPercentage] = useState(false); + const [progressError, setProgressError] = useState(false); + const [verificationErrors, setVerificationErrors] = useState([]); + + const verifyModal = useRef(null); + const progressModal = useRef(null); useEffect(() => { window.scrollTo(0, 0); - const handleProgressUpdate = (event, percentage) => { + const handleProgressUpdate = (...args: unknown[]) => { + const percentage = args[1] as number; setProgress(percentage); - setIsPercentage(true); // Use percentage-based progress + setIsPercentage(true); }; - const handleProgressText = (event, text) => { - setProgressText(t(text)); // Update progress text - setIsPercentage(false); // Not percentage-based, show full progress bar + const handleProgressText = (...args: unknown[]) => { + const text = args[1] as string; + setProgressText(t(text)); + setIsPercentage(false); }; - const handleVerificationSuccess = (event, data) => { - setModalMessage( - <> - {t(data.messageKey)}{" "} - - {data.param} - - - ); + const handleVerificationSuccess = (...args: unknown[]) => { + const data = args[1] as { messageKey: string; param: string }; + setModalMessage(`${t(data.messageKey)} ${data.param}`); setIsSuccess(true); - setShowProgressModal(false); // Hide modal after success + setShowProgressModal(false); setVerificationErrors([]); - if (onStatusChange) onStatusChange(); // Trigger parent to refresh status + if (onStatusChange) onStatusChange(); }; - const handleVerificationError = (event, data) => { + const handleVerificationError = (...args: unknown[]) => { + const data = args[1] as { messageKey: string; param: string; errorMessage?: string }; setModalMessage( - - - {data.param} - - {data.errorMessage ? `Error message: ${data.errorMessage}` : ""} - + `${t(data.messageKey)} ${data.param} ${data.errorMessage ? `Error message: ${data.errorMessage}` : ""}` ); setIsSuccess(false); - setShowProgressModal(false); // Hide modal on error + setShowProgressModal(false); setVerificationErrors([]); - if (onStatusChange) onStatusChange(); // Trigger parent to refresh status + if (onStatusChange) onStatusChange(); }; - const handleVerificationErrors = (event, errors) => { + const handleVerificationErrors = (...args: unknown[]) => { + const errors = args[1] as VerificationError[]; setVerificationErrors(errors); setIsSuccess(false); setShowProgressModal(false); @@ -82,9 +110,9 @@ const VideoDownloadTable = ({ t, data, isDownloaded, onStatusChange }) => { window.electron.ipcRenderer.removeAllListeners("verification-error"); window.electron.ipcRenderer.removeAllListeners("verification-errors"); }; - }, [t, data.messageKey, data.param, onStatusChange]); + }, [t, onStatusChange]); - const handleVerify = async (zip) => { + const handleVerify = async (zip: VideoFileData) => { if (onStatusChange) { // Await refresh if onStatusChange returns a promise await onStatusChange(); @@ -94,9 +122,9 @@ const VideoDownloadTable = ({ t, data, isDownloaded, onStatusChange }) => { // Allow verify if extracted folder exists, even if not downloaded const fileToVerify = fileStatus && zip.name && (fileStatus.isDownloaded || fileStatus.hasExtractedFolder) - ? [{ name: zip.name }] + ? [{ name: zip.name, hash: "" }] : fileStatus && fileStatus.hasExtractedFolder && zip.name - ? [{ name: zip.name }] + ? [{ name: zip.name, hash: "" }] : []; setSelectedZip(zip); setVerifyFiles(fileToVerify); @@ -114,9 +142,9 @@ const VideoDownloadTable = ({ t, data, isDownloaded, onStatusChange }) => { fileStatus && selectedZip?.name && (fileStatus.isDownloaded || fileStatus.hasExtractedFolder) - ? [{ name: selectedZip.name }] + ? [{ name: selectedZip.name, hash: "" }] : fileStatus && fileStatus.hasExtractedFolder && selectedZip?.name - ? [{ name: selectedZip.name }] + ? [{ name: selectedZip.name, hash: "" }] : []; if (fileToVerify.length === 0) { setShowVerifyModal(false); @@ -141,14 +169,14 @@ const VideoDownloadTable = ({ t, data, isDownloaded, onStatusChange }) => { const handleCloseVerifyModal = () => { setShowVerifyModal(false); setVerificationErrors([]); - setModalMessage(""); verifyModal.current?.close(); + setModalMessage(""); }; const handleCloseProgressModal = () => { - setModalMessage(""); setVerificationErrors([]); progressModal.current?.close(); + setModalMessage(""); }; return ( @@ -276,10 +304,11 @@ const VideoDownloadTable = ({ t, data, isDownloaded, onStatusChange }) => { )}
      -
    ) : ( -

    {modalMessage}

    +

    {modalMessage}

    )}
    @@ -120,34 +119,23 @@ const SoundCard = ({ ); }; -SoundCard.propTypes = { - sound: PropTypes.shape({ - phoneme: PropTypes.string.isRequired, - word: PropTypes.string.isRequired, - british: PropTypes.bool.isRequired, - american: PropTypes.bool.isRequired, - id: PropTypes.number.isRequired, - }).isRequired, - index: PropTypes.number.isRequired, - selectedAccent: PropTypes.string.isRequired, - handlePracticeClick: PropTypes.func.isRequired, - getBadgeColor: PropTypes.func.isRequired, - getReviewText: PropTypes.func.isRequired, - getReviewKey: PropTypes.func.isRequired, - reviews: PropTypes.object.isRequired, - t: PropTypes.func.isRequired, -}; - const SoundList = () => { const { t } = useTranslation(); const { ref: scrollRef, scrollTo } = useScrollTo(); - const [selectedSound, setSelectedSound] = useState(null); + const [selectedSound, setSelectedSound] = useState<{ + sound: SoundMenuItem; + accent: AccentType; + index: number; + } | null>(null); const [loading, setLoading] = useState(true); - const [selectedAccent, setSelectedAccent] = AccentLocalStorage(); - const [activeTab, setActiveTab] = useState("consonants"); + const [selectedAccent, setSelectedAccent] = AccentLocalStorage() as [ + AccentType, + (accent: AccentType) => void, + ]; + const [activeTab, setActiveTab] = useState("consonants"); - const [phonemesData, setPhonemesData] = useState({ + const [phonemesData, setPhonemesData] = useState({ consonants: [], vowels: [], diphthongs: [], @@ -155,7 +143,7 @@ const SoundList = () => { const { reviews, triggerReviewsUpdate } = useReviews(selectedAccent); - const handlePracticeClick = (sound, accent, index) => { + const handlePracticeClick = (sound: SoundMenuItem, accent: AccentType, index: number) => { setSelectedSound({ sound: { ...sound, type: activeTab }, accent, @@ -168,15 +156,15 @@ const SoundList = () => { triggerReviewsUpdate(); }; - const getReviewKey = (sound, index) => `${activeTab}${index + 1}`; + const getReviewKey = (sound: SoundMenuItem, index: number) => `${activeTab}${index + 1}`; - const getBadgeColor = (sound, index) => { + const getBadgeColor = (sound: SoundMenuItem, index: number): string | null => { const reviewKey = getReviewKey(sound, index); return BADGE_COLORS[reviews[reviewKey]] || null; }; - const getReviewText = (review) => - t(`sound_page.review${review?.charAt(0).toUpperCase() + review?.slice(1)}`); + const getReviewText = (review: string | undefined) => + review ? t(`sound_page.review${review.charAt(0).toUpperCase() + review.slice(1)}`) : ""; useEffect(() => { const fetchData = async () => { @@ -206,7 +194,12 @@ const SoundList = () => { const filteredSounds = useMemo(() => { const currentTabData = phonemesData[activeTab] || []; - return currentTabData.filter((sound) => sound[selectedAccent]); + return currentTabData.filter((sound) => { + return ( + Object.prototype.hasOwnProperty.call(sound, selectedAccent) && + Boolean((sound as unknown as Record)[selectedAccent]) + ); + }); }, [activeTab, selectedAccent, phonemesData]); return ( @@ -219,14 +212,14 @@ const SoundList = () => { ) : ( <> - + void} + />
    {loading ? ( @@ -234,7 +227,9 @@ const SoundList = () => { <> + setActiveTab(tab as SoundType) + } scrollTo={scrollTo} t={t} /> diff --git a/src/components/sound_page/SoundMain.jsx b/src/frontend/components/sound_page/SoundMain.tsx similarity index 68% rename from src/components/sound_page/SoundMain.jsx rename to src/frontend/components/sound_page/SoundMain.tsx index 867e40d82..975e7227d 100644 --- a/src/components/sound_page/SoundMain.jsx +++ b/src/frontend/components/sound_page/SoundMain.tsx @@ -1,22 +1,28 @@ import he from "he"; -import PropTypes from "prop-types"; import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { IoChevronBackOutline } from "react-icons/io5"; import { MdChecklist, MdKeyboardVoice, MdOutlineOndemandVideo } from "react-icons/md"; -import { useScrollTo } from "../../utils/useScrollTo"; -import LoadingOverlay from "../general/LoadingOverlay"; -import { SoundVideoDialogProvider } from "./hooks/useSoundVideoDialog"; -import ReviewCard from "./ReviewCard"; -import SoundPracticeCard from "./SoundPracticeCard"; -import TongueTwister from "./TongueTwister"; -import WatchVideoCard from "./WatchVideoCard"; +import useScrollTo from "../../utils/useScrollTo.js"; +import LoadingOverlay from "../general/LoadingOverlay.js"; +import { SoundVideoDialogProvider } from "./hooks/useSoundVideoDialog.js"; +import ReviewCard from "./ReviewCard.js"; +import SoundPracticeCard from "./SoundPracticeCard.js"; +import TongueTwister from "./TongueTwister.js"; +import type { + PhonemeData, + PracticeSoundProps, + SoundData, + SoundsData, + SoundType +} from "./types.js"; +import WatchVideoCard from "./WatchVideoCard.js"; -const PracticeSound = ({ sound, accent, onBack }) => { +const SoundMain = ({ sound, accent, onBack }: PracticeSoundProps) => { const { t } = useTranslation(); - const [soundsData, setSoundsData] = useState(null); + const [soundsData, setSoundsData] = useState(null); const [loading, setLoading] = useState(true); - const [activeTab, setActiveTab] = useState("watchTab"); + const [activeTab, setActiveTab] = useState("watchTab"); const { ref: scrollRef, scrollTo } = useScrollTo(); // Fetch sounds data @@ -37,8 +43,10 @@ const PracticeSound = ({ sound, accent, onBack }) => { }, []); // Find the sound data using the type and phoneme from sounds_menu.json - const soundData = soundsData?.[sound.type]?.find((item) => item.id === sound.id); - const accentData = soundData?.[accent]?.[0]; + const soundData = (soundsData?.[sound.type] as SoundData[] | undefined)?.find( + (item) => item.id === sound.id + ); + const accentData = (soundData?.[accent] as PhonemeData[] | null)?.[0]; const handleReviewUpdate = () => { // This function is intentionally empty as the review update is handled by the ReviewCard component @@ -70,7 +78,9 @@ const PracticeSound = ({ sound, accent, onBack }) => { lang="en" key={position} dangerouslySetInnerHTML={{ - __html: he.decode(accentData[position]), + __html: he.decode( + (accentData as unknown as Record)[position] + ), }} >

    ))} @@ -90,9 +100,7 @@ const PracticeSound = ({ sound, accent, onBack }) => { setActiveTab("watchTab"); scrollTo(); }} - className={`tab md:text-base ${ - activeTab === "watchTab" ? "tab-active font-semibold" : "" - }`} + className={`tab md:text-base ${activeTab === "watchTab" ? "tab-active font-semibold" : ""}`} > {t("buttonConversationExam.watchBtn")} @@ -103,9 +111,7 @@ const PracticeSound = ({ sound, accent, onBack }) => { setActiveTab("practieTab"); scrollTo(); }} - className={`tab md:text-base ${ - activeTab === "practieTab" ? "tab-active font-semibold" : "" - }`} + className={`tab md:text-base ${activeTab === "practieTab" ? "tab-active font-semibold" : ""}`} > {t("buttonConversationExam.practiceBtn")} @@ -116,9 +122,7 @@ const PracticeSound = ({ sound, accent, onBack }) => { setActiveTab("reviewTab"); scrollTo(); }} - className={`tab md:text-base ${ - activeTab === "reviewTab" ? "tab-active font-semibold" : "" - }`} + className={`tab md:text-base ${activeTab === "reviewTab" ? "tab-active font-semibold" : ""}`} > {t("buttonConversationExam.reviewBtn")} @@ -128,39 +132,48 @@ const PracticeSound = ({ sound, accent, onBack }) => {
    - {activeTab === "watchTab" && ( - - )} + {activeTab === "watchTab" && + accentData && + accentData.mainOnlineVideo && + accentData.mainOfflineVideo && ( + + )} {activeTab === "practieTab" && (
    {accentData && ( <> - {["main", "initial", "medial", "final"].map( + {(["main", "initial", "medial", "final"] as const).map( (position, index) => { const isMain = position === "main"; const videoUrl = isMain ? accentData.mainOnlineVideo - : accentData.practiceOnlineVideos[index]; + : (accentData.practiceOnlineVideos[index] ?? + ""); const offlineVideo = isMain ? accentData.mainOfflineVideo - : accentData.practiceOfflineVideos[index]; + : (accentData.practiceOfflineVideos[index] ?? + ""); const textContent = isMain ? sound.phoneme - : accentData[position]; + : typeof accentData[ + position as keyof PhonemeData + ] === "string" + ? (accentData[ + position as keyof PhonemeData + ] as string) + : ""; const cardIndex = isMain ? 0 : index; const type = sound.type === "consonants" @@ -168,9 +181,11 @@ const PracticeSound = ({ sound, accent, onBack }) => { : sound.type === "vowels" ? "vowel" : "dipthong"; - return ( - (isMain || accentData[position]) && ( + (isMain || + accentData[ + position as keyof PhonemeData + ]) && ( { phoneme={sound.phoneme} phonemeId={sound.id} index={cardIndex} - type={type} + type={type as SoundType} shouldShowPhoneme={ isMain - ? soundData?.shouldShowPhoneme !== - false + ? ( + soundData as { + shouldShowPhoneme?: boolean; + } + )?.shouldShowPhoneme !== false : undefined } /> @@ -196,7 +214,7 @@ const PracticeSound = ({ sound, accent, onBack }) => { )} { ); }; -PracticeSound.propTypes = { - sound: PropTypes.shape({ - phoneme: PropTypes.string.isRequired, - id: PropTypes.number.isRequired, - type: PropTypes.oneOf(["consonants", "vowels", "diphthongs"]).isRequired, - key: PropTypes.string.isRequired, - }).isRequired, - accent: PropTypes.oneOf(["british", "american"]).isRequired, - onBack: PropTypes.func.isRequired, -}; - -export default PracticeSound; +export default SoundMain; diff --git a/src/components/sound_page/SoundPracticeCard.jsx b/src/frontend/components/sound_page/SoundPracticeCard.tsx similarity index 80% rename from src/components/sound_page/SoundPracticeCard.jsx rename to src/frontend/components/sound_page/SoundPracticeCard.tsx index c9ee32f1f..3fd37be01 100644 --- a/src/components/sound_page/SoundPracticeCard.jsx +++ b/src/frontend/components/sound_page/SoundPracticeCard.tsx @@ -1,14 +1,18 @@ -import PropTypes from "prop-types"; import { useEffect, useRef, useState } from "react"; import { MdMic, MdOutlineOndemandVideo, MdPlayArrow, MdStop } from "react-icons/md"; -import { checkRecordingExists, playRecording, saveRecording } from "../../utils/databaseOperations"; -import isElectron from "../../utils/isElectron"; +import { + checkRecordingExists, + playRecording, + saveRecording, +} from "../../utils/databaseOperations.js"; +import isElectron from "../../utils/isElectron.js"; import { sonnerErrorToast, sonnerSuccessToast, sonnerWarningToast, -} from "../../utils/sonnerCustomToast"; -import { useSoundVideoDialog } from "./hooks/useSoundVideoDialogContext"; +} from "../../utils/sonnerCustomToast.js"; +import { useSoundVideoDialog } from "./hooks/useSoundVideoDialogContext.js"; +import type { SoundPracticeCardProps, SoundVideoDialogContextType } from "./types.js"; const MAX_RECORDING_DURATION_MS = 2 * 60 * 1000; // 2 minutes @@ -23,22 +27,25 @@ const SoundPracticeCard = ({ index, type, shouldShowPhoneme = true, -}) => { - const [localVideoUrl, setLocalVideoUrl] = useState(null); +}: SoundPracticeCardProps) => { + const [localVideoUrl, setLocalVideoUrl] = useState(null); const [useOnlineVideo, setUseOnlineVideo] = useState(false); - const [iframeLoadingStates, setIframeLoadingStates] = useState({ + const [iframeLoadingStates, setIframeLoadingStates] = useState<{ modalIframe: boolean }>({ modalIframe: true, }); const [isRecording, setIsRecording] = useState(false); const [hasRecording, setHasRecording] = useState(false); const [isPlaying, setIsPlaying] = useState(false); - const mediaRecorderRef = useRef(null); - const audioChunksRef = useRef([]); - const recordingStartTimeRef = useRef(null); - const [currentAudioSource, setCurrentAudioSource] = useState(null); - const [currentAudioElement, setCurrentAudioElement] = useState(null); + const mediaRecorderRef = useRef(null); + const audioChunksRef = useRef([]); + const recordingStartTimeRef = useRef(null); + const [currentAudioSource, setCurrentAudioSource] = useState( + null + ); + const [currentAudioElement, setCurrentAudioElement] = useState(null); - const { showDialog, isAnyCardActive, setCardActive } = useSoundVideoDialog(); + const { showDialog, isAnyCardActive, setCardActive } = + useSoundVideoDialog() as SoundVideoDialogContextType; const recordingKey = `${type}-${accent}-${phonemeId}-${index}`; const cardId = `${type}-${accent}-${phonemeId}-${index}`; @@ -73,7 +80,7 @@ const SoundPracticeCard = ({ audioChunksRef.current = []; recordingStartTimeRef.current = Date.now(); - mediaRecorder.ondataavailable = (event) => { + mediaRecorder.ondataavailable = (event: BlobEvent) => { audioChunksRef.current.push(event.data); }; @@ -82,7 +89,7 @@ const SoundPracticeCard = ({ await saveRecording(audioBlob, recordingKey, supportedMimeType); setHasRecording(true); stream.getTracks().forEach((track) => track.stop()); - sonnerSuccessToast(t("toast.recordingSuccess")); + sonnerSuccessToast(t("toast.recordingSuccess") as string); }; // Start recording @@ -93,16 +100,18 @@ const SoundPracticeCard = ({ setTimeout(() => { if (mediaRecorder.state !== "inactive") { mediaRecorder.stop(); - sonnerWarningToast(t("toast.recordingExceeded")); + sonnerWarningToast(t("toast.recordingExceeded") as string); setIsRecording(false); } }, MAX_RECORDING_DURATION_MS); } catch (error) { - console.error("Error starting recording:", error); + // error is unknown, so cast to Error for message + const err = error instanceof Error ? error : new Error(String(error)); + console.error("Error starting recording:", err); if (isElectron()) { - window.electron.log("error", `Error accessing the microphone: ${error}`); + window.electron.log("error", `Error accessing the microphone: ${err}`); } - sonnerErrorToast(`${t("toast.recordingFailed")} ${error.message}`); + sonnerErrorToast(`${t("toast.recordingFailed")} ${err.message}`); setIsRecording(false); setCardActive(cardId, false); } @@ -110,9 +119,9 @@ const SoundPracticeCard = ({ const stopRecording = () => { if (mediaRecorderRef.current && isRecording) { - const recordingDuration = Date.now() - recordingStartTimeRef.current; + const recordingDuration = Date.now() - (recordingStartTimeRef.current ?? 0); if (recordingDuration > MAX_RECORDING_DURATION_MS) { - sonnerWarningToast(t("toast.recordingExceeded")); + sonnerWarningToast(t("toast.recordingExceeded") as string); } mediaRecorderRef.current.stop(); setIsRecording(false); @@ -141,16 +150,17 @@ const SoundPracticeCard = ({ setCardActive(cardId, true); await playRecording( recordingKey, - (audio, audioSource) => { + (audio: HTMLAudioElement | null, audioSource: AudioBufferSourceNode | null) => { if (audioSource) { setCurrentAudioSource(audioSource); - } else { + } else if (audio) { setCurrentAudioElement(audio); } }, (error) => { - console.error("Error playing recording:", error); - sonnerErrorToast(t("toast.playbackFailed")); + const err = error instanceof Error ? error : new Error(String(error)); + console.error("Error playing recording:", err); + sonnerErrorToast(t("toast.playbackFailed") as string); setIsPlaying(false); setCardActive(cardId, false); }, @@ -168,7 +178,7 @@ const SoundPracticeCard = ({ ? `${import.meta.env.BASE_URL}images/ispeaker/sound_images/sounds_american.webp` : `${import.meta.env.BASE_URL}images/ispeaker/sound_images/sounds_british.webp`; - const handleIframeLoad = (iframeKey) => { + const handleIframeLoad = (iframeKey: string) => { setIframeLoadingStates((prevStates) => ({ ...prevStates, [iframeKey]: false, @@ -180,7 +190,7 @@ const SoundPracticeCard = ({ videoUrl: isElectron() && !useOnlineVideo ? localVideoUrl : videoUrl, title: textContent.split(" - ")[0], phoneme, - isLocalVideo: localVideoUrl && !useOnlineVideo, + isLocalVideo: !!localVideoUrl && !useOnlineVideo, onIframeLoad: () => handleIframeLoad("modalIframe"), iframeLoading: iframeLoadingStates.modalIframe, showOnlineVideoAlert: isElectron() && useOnlineVideo, @@ -234,8 +244,10 @@ const SoundPracticeCard = ({ } // Cleanup any active media streams - if (mediaRecorderRef.current?.stream) { - mediaRecorderRef.current.stream.getTracks().forEach((track) => track.stop()); + // stream is not a standard property on MediaRecorder, so use type assertion + const recorder = mediaRecorderRef.current as MediaRecorder & { stream?: MediaStream }; + if (recorder && recorder.stream) { + recorder.stream.getTracks().forEach((track: MediaStreamTrack) => track.stop()); } }; }, [currentAudioSource, currentAudioElement]); @@ -268,6 +280,8 @@ const SoundPracticeCard = ({
    ) : (

    - {he.decode(twister.text)} + {he.decode(twister.text ?? "")}

    )}
    @@ -255,20 +265,4 @@ const TongueTwister = ({ tongueTwisters, t, sound, accent }) => { ); }; -TongueTwister.propTypes = { - tongueTwisters: PropTypes.arrayOf( - PropTypes.shape({ - text: PropTypes.string, - title: PropTypes.string, - lines: PropTypes.arrayOf(PropTypes.string), - }) - ), - t: PropTypes.func.isRequired, - sound: PropTypes.shape({ - type: PropTypes.oneOf(["consonants", "vowels", "diphthongs"]).isRequired, - id: PropTypes.number.isRequired, - }).isRequired, - accent: PropTypes.oneOf(["british", "american"]).isRequired, -}; - export default TongueTwister; diff --git a/src/components/sound_page/WatchVideoCard.jsx b/src/frontend/components/sound_page/WatchVideoCard.tsx similarity index 82% rename from src/components/sound_page/WatchVideoCard.jsx rename to src/frontend/components/sound_page/WatchVideoCard.tsx index b5ec1fe82..b9dee44f8 100644 --- a/src/components/sound_page/WatchVideoCard.jsx +++ b/src/frontend/components/sound_page/WatchVideoCard.tsx @@ -2,16 +2,16 @@ import { MediaPlayer, MediaProvider } from "@vidstack/react"; import { defaultLayoutIcons, DefaultVideoLayout } from "@vidstack/react/player/layouts/default"; import "@vidstack/react/player/styles/default/layouts/video.css"; import "@vidstack/react/player/styles/default/theme.css"; -import PropTypes from "prop-types"; import { useEffect, useState } from "react"; import { IoInformationCircleOutline } from "react-icons/io5"; -import isElectron from "../../utils/isElectron"; -import useAutoDetectTheme from "../../utils/ThemeContext/useAutoDetectTheme"; +import isElectron from "../../utils/isElectron.js"; +import useAutoDetectTheme from "../../utils/ThemeContext/useAutoDetectTheme.js"; +import type { WatchVideoCardProps } from "./types.js"; -const WatchVideoCard = ({ videoData, accent, t, phoneme }) => { - const [iframeLoading, setIframeLoading] = useState(true); - const [localVideoUrl, setLocalVideoUrl] = useState(null); - const [useOnlineVideo, setUseOnlineVideo] = useState(false); +const WatchVideoCard = ({ videoData, accent, t, phoneme }: WatchVideoCardProps) => { + const [iframeLoading, setIframeLoading] = useState(true); + const [localVideoUrl, setLocalVideoUrl] = useState(null); + const [useOnlineVideo, setUseOnlineVideo] = useState(false); const { autoDetectedTheme } = useAutoDetectTheme(); useEffect(() => { @@ -53,7 +53,7 @@ const WatchVideoCard = ({ videoData, accent, t, phoneme }) => { const videoUrl = isElectron() && !useOnlineVideo ? localVideoUrl : videoData?.mainOnlineVideo; // Get the pronunciation instructions based on the phoneme type - const getPronunciationInstructions = () => { + const getPronunciationInstructions = (): string[] | null => { if (!phoneme) return null; const phonemeType = phoneme.type; // 'consonant', 'vowel', or 'diphthong' @@ -62,7 +62,10 @@ const WatchVideoCard = ({ videoData, accent, t, phoneme }) => { const instructions = t(`sound_page.soundInstructions.${phonemeType}.${phonemeKey}`, { returnObjects: true, }); - return instructions; + // t may return string or string[] + if (Array.isArray(instructions)) return instructions as string[]; + if (typeof instructions === "string") return [instructions]; + return null; }; const pronunciationInstructions = getPronunciationInstructions(); @@ -83,7 +86,13 @@ const WatchVideoCard = ({ videoData, accent, t, phoneme }) => { ) : ( @@ -92,7 +101,7 @@ const WatchVideoCard = ({ videoData, accent, t, phoneme }) => {
    )}