diff --git a/.gitignore b/.gitignore index 90d1628..50135ec 100644 --- a/.gitignore +++ b/.gitignore @@ -68,4 +68,12 @@ DEVELOPMENT_PLAN.md # Copilot Instructions .github/copilot-instructions.md -*.tsbuildinfo \ No newline at end of file +*.tsbuildinfo + +# TypeScript build artifacts (config files) +vite.config.js +vite.config.d.ts +tsup.config.js +tsup.config.d.ts +vitest.config.js +vitest.config.d.ts \ No newline at end of file diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..1d9b783 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22.12.0 diff --git a/README.md b/README.md index 2ea9ba2..c7ae1f6 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # ๐ŸŽ™๏ธ WhisperDesk -[![CI](https://github.com/PVAS-Development/whisperdesk/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/PVAS-Development/whisperdesk/actions/workflows/ci.yml) +[![Downloads](https://img.shields.io/github/downloads/PVAS-Development/whisperdesk/total.svg)](https://github.com/PVAS-Development/whisperdesk/releases) [![Release Version](https://img.shields.io/github/v/release/PVAS-Development/whisperdesk?label=release&logo=github)](https://github.com/PVAS-Development/whisperdesk/releases) [![Lint](https://img.shields.io/badge/lint-passing-brightgreen.svg?logo=eslint&logoColor=white)](https://github.com/PVAS-Development/whisperdesk/actions/workflows/ci.yml) [![Stars](https://img.shields.io/github/stars/PVAS-Development/whisperdesk?style=social)](https://github.com/PVAS-Development/whisperdesk/stargazers) @@ -136,7 +136,7 @@ Models are downloaded automatically on first use and cached in: ### Prerequisites -- Node.js 18+ +- Node.js 22.12+ (use `nvm use` to auto-switch via `.nvmrc`) - CMake (for building whisper.cpp) - FFmpeg @@ -348,11 +348,23 @@ Contributions are welcome! Please see our [Contributing Guide](CONTRIBUTING.md) - Code style and commit conventions - Submitting pull requests -## ๐Ÿ”’ Security +## ๐Ÿ”’ Privacy & Security -- All audio/video processing happens **locally** on your device -- No data is sent to external servers -- The app is **code-signed and notarized** by Apple +- **Local Processing**: All audio/video processing happens **locally** on your device. Your files never leave your computer. +- **No Cloud Uploads**: We do not upload your media files or transcriptions to any server. +- **Anonymous Analytics**: We collect minimal, anonymous usage data (e.g., app launches, feature usage) to improve the app. No personal data or file content is collected. +- **Code Signing**: The app is **code-signed and notarized** by Apple for your safety. + +For more details, please read our [Privacy Policy](https://whisperdesk.org/privacy.html). + +## โ˜• Support the Project + +WhisperDesk is free and open-source software. If you find it useful, please consider supporting its development: + +- [**Donate via PayPal**](https://www.paypal.com/donate/?hosted_button_id=HTJXGMEGMWWD6) +- [**Buy me a coffee**](https://www.buymeacoffee.com/pedrovsiqueira) + +Your support helps cover the costs of Apple Developer Program fees and keeps the project alive! ## ๐Ÿ“„ License diff --git a/docs/index.html b/docs/index.html index 8bfe172..8c0ce71 100644 --- a/docs/index.html +++ b/docs/index.html @@ -339,6 +339,7 @@

Open Source & Community Drive rel="noopener noreferrer" class="footer-link">License Contributing + Privacy & Terms GitHub diff --git a/docs/privacy.html b/docs/privacy.html new file mode 100644 index 0000000..dc2df45 --- /dev/null +++ b/docs/privacy.html @@ -0,0 +1,184 @@ + + + + + + + Privacy Policy & Terms - WhisperDesk + + + + + + + + + + +
+ + + Back to Home + + +

Privacy Policy & Terms

+

Last updated: December 17, 2025

+ +
+

Privacy Policy

+

At WhisperDesk, we believe your data belongs to you. Our application is designed with privacy as its core principle.

+ +

1. Local Processing

+

All audio and video transcription is performed locally on your device.

+

WhisperDesk uses the whisper.cpp engine to process files directly on your Mac. Your audio files, video files, and transcriptions are never uploaded to any cloud server or third-party service for processing. They never leave your computer.

+ +

2. Data Collection

+

We collect anonymous usage data to help us improve the application, fix bugs, and understand which features are most valuable to our users. This data is collected via Aptabase, a privacy-first analytics provider that is fully GDPR, CCPA, and PECR compliant.

+ +

3. What We Collect

+
    +
  • App Lifecycle: When the app is opened or closed.
  • +
  • Feature Usage: Anonymous statistics such as "transcription started", "model downloaded", or "export saved".
  • +
  • System Information: Basic details like your OS version (e.g., macOS 14.0) and App version (e.g., v1.6.2).
  • +
  • Error Logs: If the app crashes or encounters an error, we receive a log to help us fix it. These logs are sanitized to remove any file paths, file names, or personal information before they are sent.
  • +
+ +

4. What We Do NOT Collect

+
    +
  • No File Content: We never access or transmit the audio/video content you transcribe.
  • +
  • No Transcriptions: We never access or transmit the text generated by the transcription.
  • +
  • No File Names: We do not track the names of the files you open.
  • +
  • No Personal Information: We do not collect your name, email, IP address, or any other personally identifiable information (PII).
  • +
+
+ +
+

Terms of Service

+ +

1. License

+

WhisperDesk is open-source software licensed under the MIT License. You are free to use, modify, and distribute the software in accordance with the license terms.

+ +

2. Disclaimer

+

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

+ +

3. User Responsibility

+

You are solely responsible for the content you transcribe using WhisperDesk. Please ensure you have the necessary rights and permissions to process any audio or video files you use with the application.

+
+
+ + + + + + diff --git a/package-lock.json b/package-lock.json index b57da62..6998e79 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,7 +44,7 @@ "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", "husky": "^9.1.7", - "jsdom": "^27.2.0", + "jsdom": "^25.0.1", "lint-staged": "^16.2.7", "prettier": "^3.7.3", "semantic-release": "^24.2.9", @@ -58,13 +58,6 @@ "wait-on": "^9.0.3" } }, - "node_modules/@acemir/cssom": { - "version": "0.9.24", - "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.24.tgz", - "integrity": "sha512-5YjgMmAiT2rjJZU7XK1SNI7iqTy92DpaYVgG6x63FxkJ11UpYfLndHJATtinWJClAXiOlW9XWaUyAQf8pMrQPg==", - "dev": true, - "license": "MIT" - }, "node_modules/@adobe/css-tools": { "version": "4.4.4", "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", @@ -82,59 +75,25 @@ } }, "node_modules/@asamuzakjp/css-color": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.0.tgz", - "integrity": "sha512-9xiBAtLn4aNsa4mDnpovJvBn72tNEIACyvlqaNJ+ADemR+yeMJWnBudOi2qGDviJa7SwcDOU/TRh5dnET7qk0w==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", "dev": true, "license": "MIT", "dependencies": { - "@csstools/css-calc": "^2.1.4", - "@csstools/css-color-parser": "^3.1.0", - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4", - "lru-cache": "^11.2.2" + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" } }, "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { - "version": "11.2.4", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", - "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@asamuzakjp/dom-selector": { - "version": "6.7.5", - "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.5.tgz", - "integrity": "sha512-Eks6dY8zau4m4wNRQjRVaKQRTalNcPcBvU1ZQ35w5kKRk1gUeNCkVLsRiATurjASTp3TKM4H10wsI50nx3NZdw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@asamuzakjp/nwsapi": "^2.3.9", - "bidi-js": "^1.0.3", - "css-tree": "^3.1.0", - "is-potential-custom-element-name": "^1.0.1", - "lru-cache": "^11.2.2" - } - }, - "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { - "version": "11.2.4", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", - "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@asamuzakjp/nwsapi": { - "version": "2.3.9", - "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", - "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "dev": true, - "license": "MIT" + "license": "ISC" }, "node_modules/@babel/code-frame": { "version": "7.27.1", @@ -988,26 +947,6 @@ "@csstools/css-tokenizer": "^3.0.4" } }, - "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.0.20", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.20.tgz", - "integrity": "sha512-8BHsjXfSciZxjmHQOuVdW2b8WLUPts9a+mfL13/PzEviufUEW2xnvQuOlKs9dRBHgRqJ53SF/DUoK9+MZk72oQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "engines": { - "node": ">=18" - } - }, "node_modules/@csstools/css-tokenizer": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", @@ -5617,16 +5556,6 @@ "dev": true, "license": "Apache-2.0" }, - "node_modules/bidi-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", - "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", - "dev": true, - "license": "MIT", - "dependencies": { - "require-from-string": "^2.0.2" - } - }, "node_modules/bl": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", @@ -6776,20 +6705,6 @@ "utrie": "^1.0.2" } }, - "node_modules/css-tree": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", - "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "mdn-data": "2.12.2", - "source-map-js": "^1.0.1" - }, - "engines": { - "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" - } - }, "node_modules/css.escape": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", @@ -6798,20 +6713,26 @@ "license": "MIT" }, "node_modules/cssstyle": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.3.tgz", - "integrity": "sha512-OytmFH+13/QXONJcC75QNdMtKpceNk3u8ThBjyyYjkEcy/ekBwR1mMAuNvi3gdBPW3N5TlCzQ0WZw8H0lN/bDw==", + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", "dev": true, "license": "MIT", "dependencies": { - "@asamuzakjp/css-color": "^4.0.3", - "@csstools/css-syntax-patches-for-csstree": "^1.0.14", - "css-tree": "^3.1.0" + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" }, "engines": { - "node": ">=20" + "node": ">=18" } }, + "node_modules/cssstyle/node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -6833,17 +6754,17 @@ } }, "node_modules/data-urls": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.0.tgz", - "integrity": "sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", "dev": true, "license": "MIT", "dependencies": { "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^15.0.0" + "whatwg-url": "^14.0.0" }, "engines": { - "node": ">=20" + "node": ">=18" } }, "node_modules/debug": { @@ -9853,38 +9774,39 @@ } }, "node_modules/jsdom": { - "version": "27.2.0", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.2.0.tgz", - "integrity": "sha512-454TI39PeRDW1LgpyLPyURtB4Zx1tklSr6+OFOipsxGUH1WMTvk6C65JQdrj455+DP2uJ1+veBEHTGFKWVLFoA==", + "version": "25.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.1.tgz", + "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", "dev": true, "license": "MIT", "dependencies": { - "@acemir/cssom": "^0.9.23", - "@asamuzakjp/dom-selector": "^6.7.4", - "cssstyle": "^5.3.3", - "data-urls": "^6.0.0", - "decimal.js": "^10.6.0", + "cssstyle": "^4.1.0", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", "html-encoding-sniffer": "^4.0.0", "http-proxy-agent": "^7.0.2", - "https-proxy-agent": "^7.0.6", + "https-proxy-agent": "^7.0.5", "is-potential-custom-element-name": "^1.0.1", - "parse5": "^8.0.0", + "nwsapi": "^2.2.12", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.7.1", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", - "tough-cookie": "^6.0.0", + "tough-cookie": "^5.0.0", "w3c-xmlserializer": "^5.0.0", - "webidl-conversions": "^8.0.0", + "webidl-conversions": "^7.0.0", "whatwg-encoding": "^3.1.1", "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^15.1.0", - "ws": "^8.18.3", + "whatwg-url": "^14.0.0", + "ws": "^8.18.0", "xml-name-validator": "^5.0.0" }, "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + "node": ">=18" }, "peerDependencies": { - "canvas": "^3.0.0" + "canvas": "^2.11.2" }, "peerDependenciesMeta": { "canvas": { @@ -9893,9 +9815,9 @@ } }, "node_modules/jsdom/node_modules/parse5": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", - "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", "dev": true, "license": "MIT", "dependencies": { @@ -11007,13 +10929,6 @@ "node": ">= 0.4" } }, - "node_modules/mdn-data": { - "version": "2.12.2", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", - "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", - "dev": true, - "license": "CC0-1.0" - }, "node_modules/meow": { "version": "12.1.1", "resolved": "https://registry.npmjs.org/meow/-/meow-12.1.1.tgz", @@ -14245,6 +14160,13 @@ "inBundle": true, "license": "ISC" }, + "node_modules/nwsapi": { + "version": "2.2.23", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", + "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", + "dev": true, + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -15549,6 +15471,13 @@ "fsevents": "~2.3.2" } }, + "node_modules/rrweb-cssom": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", + "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", + "dev": true, + "license": "MIT" + }, "node_modules/rxjs": { "version": "7.8.2", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", @@ -17158,22 +17087,22 @@ } }, "node_modules/tldts": { - "version": "7.0.19", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.19.tgz", - "integrity": "sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==", + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", "dev": true, "license": "MIT", "dependencies": { - "tldts-core": "^7.0.19" + "tldts-core": "^6.1.86" }, "bin": { "tldts": "bin/cli.js" } }, "node_modules/tldts-core": { - "version": "7.0.19", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.19.tgz", - "integrity": "sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A==", + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", "dev": true, "license": "MIT" }, @@ -17221,29 +17150,29 @@ } }, "node_modules/tough-cookie": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", - "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", "dev": true, "license": "BSD-3-Clause", "dependencies": { - "tldts": "^7.0.5" + "tldts": "^6.1.32" }, "engines": { "node": ">=16" } }, "node_modules/tr46": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", - "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", "dev": true, "license": "MIT", "dependencies": { "punycode": "^2.3.1" }, "engines": { - "node": ">=20" + "node": ">=18" } }, "node_modules/traverse": { @@ -18858,13 +18787,13 @@ "license": "Apache-2.0" }, "node_modules/webidl-conversions": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.0.tgz", - "integrity": "sha512-n4W4YFyz5JzOfQeA8oN7dUYpR+MBP3PIUsn2jLjWXwK5ASUzt0Jc/A5sAUZoCYFJRGF0FBKJ+1JjN43rNdsQzA==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", "dev": true, "license": "BSD-2-Clause", "engines": { - "node": ">=20" + "node": ">=12" } }, "node_modules/whatwg-encoding": { @@ -18891,17 +18820,17 @@ } }, "node_modules/whatwg-url": { - "version": "15.1.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz", - "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==", + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", "dev": true, "license": "MIT", "dependencies": { - "tr46": "^6.0.0", - "webidl-conversions": "^8.0.0" + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" }, "engines": { - "node": ">=20" + "node": ">=18" } }, "node_modules/which": { diff --git a/package.json b/package.json index 6d1bf97..2e55a39 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,9 @@ "url": "https://github.com/PVAS-Development/whisperdesk/issues" }, "homepage": "https://github.com/PVAS-Development/whisperdesk#readme", + "engines": { + "node": ">=22.12.0" + }, "main": "dist-electron/main.cjs", "author": { "name": "Pedro Siqueira", @@ -97,7 +100,7 @@ "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", "husky": "^9.1.7", - "jsdom": "^27.2.0", + "jsdom": "^25.0.1", "lint-staged": "^16.2.7", "prettier": "^3.7.3", "semantic-release": "^24.2.9", @@ -155,7 +158,7 @@ }, "dmg": { "title": "WhisperDesk", - "backgroundColor": "#1a1a2e", + "backgroundColor": "#f5f5f7", "window": { "width": 540, "height": 380 diff --git a/src/main/ipc/index.ts b/src/main/ipc/index.ts index fec39b6..6628154 100644 --- a/src/main/ipc/index.ts +++ b/src/main/ipc/index.ts @@ -174,6 +174,7 @@ export function registerIpcHandlers(getMainWindow: () => BrowserWindow | null) { isDev: !app.isPackaged, version: app.getVersion(), platform: process.platform, + osVersion: process.getSystemVersion(), }; }); diff --git a/src/main/services/whisper.ts b/src/main/services/whisper.ts index 0ed9d8c..d6a3e2b 100644 --- a/src/main/services/whisper.ts +++ b/src/main/services/whisper.ts @@ -15,6 +15,7 @@ import type { GpuInfo, QualityLevel, } from '../../shared/types'; +import { sanitizePath } from '../../shared/utils'; import { detectGpuStatus } from './gpu-detector'; interface WhisperModelInfo { @@ -576,11 +577,15 @@ export function transcribe( if (!text && !vtt) { console.error('Transcription failed: No output generated.', { - txtPath, - vttPath, + txtPath: sanitizePath(txtPath), + vttPath: sanitizePath(vttPath), stdoutLength: stdout.length, }); - reject(new Error('Transcription produced no output')); + reject( + new Error( + 'Transcription produced no output. The audio file might be empty, silent, or contain no valid audio stream.' + ) + ); return; } diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 3a768b0..69a70e6 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1,24 +1,27 @@ import React from 'react'; import { AppProvider } from './contexts'; import { AppHeader, LeftPanel, RightPanel } from './components'; +import { ErrorBoundary } from './components/ui'; import { UpdateNotification } from './features/auto-update'; import './App.css'; function App(): React.JSX.Element { return ( - -
- + + +
+ -
- - -
+
+ + +
- -
-
+ +
+
+ ); } -export default App; +export { App }; diff --git a/src/renderer/components/layout/AppHeader/AppHeader.tsx b/src/renderer/components/layout/AppHeader/AppHeader.tsx index 241fdbb..15025ad 100644 --- a/src/renderer/components/layout/AppHeader/AppHeader.tsx +++ b/src/renderer/components/layout/AppHeader/AppHeader.tsx @@ -1,47 +1,77 @@ import React from 'react'; -import { Moon, Sun, History } from 'lucide-react'; +import { Moon, Sun, History, Terminal } from 'lucide-react'; import { useAppTheme, useAppHistory } from '../../../contexts'; +import { useDebugLogs } from '../../../hooks'; +import { DebugLogsModal } from '../../ui/DebugLogsModal'; import appIcon from '../../../assets/icon.png'; function AppHeader(): React.JSX.Element { const { theme, toggleTheme } = useAppTheme(); const { history, showHistory, toggleHistory } = useAppHistory(); + const { + logs, + isOpen: isDebugLogsOpen, + openModal: openDebugLogs, + closeModal: closeDebugLogs, + copyLogs, + copyLogsWithSystemInfo, + clearLogs, + } = useDebugLogs(); return ( -
-
-
- WhisperDesk -
-

WhisperDesk

-

Transcribe audio & video with AI

+ <> +
+
+
+ WhisperDesk +
+

WhisperDesk

+

Transcribe audio & video with AI

+
+
+
+ + +
-
- - -
-
-
+ + + + ); } -export default AppHeader; +export { AppHeader }; diff --git a/src/renderer/components/layout/AppHeader/index.ts b/src/renderer/components/layout/AppHeader/index.ts index 6b822c4..ffd39ab 100644 --- a/src/renderer/components/layout/AppHeader/index.ts +++ b/src/renderer/components/layout/AppHeader/index.ts @@ -1 +1 @@ -export { default as AppHeader } from './AppHeader'; +export { AppHeader } from './AppHeader'; diff --git a/src/renderer/components/layout/LeftPanel/DonationSection/DonationSection.tsx b/src/renderer/components/layout/LeftPanel/DonationSection/DonationSection.tsx index b060664..27b9350 100644 --- a/src/renderer/components/layout/LeftPanel/DonationSection/DonationSection.tsx +++ b/src/renderer/components/layout/LeftPanel/DonationSection/DonationSection.tsx @@ -1,20 +1,20 @@ import React from 'react'; import { Heart, Coffee } from 'lucide-react'; -import { openExternal, trackEvent } from '../../../../services/electronAPI'; +import { openExternal, trackEvent, logger } from '../../../../services'; import './DonationSection.css'; const PAYPAL_DONATION_URL = 'https://www.paypal.com/donate/?hosted_button_id=HTJXGMEGMWWD6'; const BUY_ME_A_COFFEE_URL = 'https://www.buymeacoffee.com/pedrovsiqueira'; -export function DonationSection(): React.JSX.Element { +function DonationSection(): React.JSX.Element { const handleDonationClick = async (url: string, type: string) => { trackEvent('donation_clicked', { location: 'left_panel', type }).catch((error) => { - console.error(`Failed to track donation click (type: ${type}, url: ${url}):`, error); + logger.error(`Failed to track donation click (type: ${type}, url: ${url}):`, error); }); try { await openExternal(url); } catch (error) { - console.error('Failed to open donation link:', error); + logger.error('Failed to open donation link:', error); } }; @@ -41,3 +41,5 @@ export function DonationSection(): React.JSX.Element { ); } + +export { DonationSection }; diff --git a/src/renderer/components/layout/LeftPanel/DonationSection/__tests__/DonationSection.test.tsx b/src/renderer/components/layout/LeftPanel/DonationSection/__tests__/DonationSection.test.tsx index 266d852..b00157a 100644 --- a/src/renderer/components/layout/LeftPanel/DonationSection/__tests__/DonationSection.test.tsx +++ b/src/renderer/components/layout/LeftPanel/DonationSection/__tests__/DonationSection.test.tsx @@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { DonationSection } from '../DonationSection'; import { overrideElectronAPI } from '../../../../../test/utils'; +import { logger } from '../../../../../services/logger'; describe('DonationSection', () => { beforeEach(() => { @@ -52,7 +53,6 @@ describe('DonationSection', () => { it('should handle error when openExternal fails for PayPal', async () => { const mockOpenExternal = vi.fn().mockRejectedValue(new Error('Failed to open link')); - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); overrideElectronAPI({ openExternal: mockOpenExternal, @@ -64,18 +64,12 @@ describe('DonationSection', () => { fireEvent.click(paypalButton); await waitFor(() => { - expect(consoleErrorSpy).toHaveBeenCalledWith( - 'Failed to open donation link:', - expect.any(Error) - ); + expect(logger.error).toHaveBeenCalledWith('Failed to open donation link:', expect.any(Error)); }); - - consoleErrorSpy.mockRestore(); }); it('should handle error when openExternal fails for Buy Me a Coffee', async () => { const mockOpenExternal = vi.fn().mockRejectedValue(new Error('Failed to open link')); - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); overrideElectronAPI({ openExternal: mockOpenExternal, @@ -87,18 +81,12 @@ describe('DonationSection', () => { fireEvent.click(coffeeButton); await waitFor(() => { - expect(consoleErrorSpy).toHaveBeenCalledWith( - 'Failed to open donation link:', - expect.any(Error) - ); + expect(logger.error).toHaveBeenCalledWith('Failed to open donation link:', expect.any(Error)); }); - - consoleErrorSpy.mockRestore(); }); it('should handle error when trackEvent fails for PayPal', async () => { const mockTrackEvent = vi.fn().mockRejectedValue(new Error('Failed to track')); - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); overrideElectronAPI({ trackEvent: mockTrackEvent, @@ -111,18 +99,15 @@ describe('DonationSection', () => { fireEvent.click(paypalButton); await waitFor(() => { - expect(consoleErrorSpy).toHaveBeenCalledWith( + expect(logger.error).toHaveBeenCalledWith( expect.stringContaining('Failed to track donation click'), expect.any(Error) ); }); - - consoleErrorSpy.mockRestore(); }); it('should handle error when trackEvent fails for Buy Me a Coffee', async () => { const mockTrackEvent = vi.fn().mockRejectedValue(new Error('Failed to track')); - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); overrideElectronAPI({ trackEvent: mockTrackEvent, @@ -135,13 +120,11 @@ describe('DonationSection', () => { fireEvent.click(coffeeButton); await waitFor(() => { - expect(consoleErrorSpy).toHaveBeenCalledWith( + expect(logger.error).toHaveBeenCalledWith( expect.stringContaining('Failed to track donation click'), expect.any(Error) ); }); - - consoleErrorSpy.mockRestore(); }); it('should have proper aria-labels for accessibility', () => { diff --git a/src/renderer/components/layout/LeftPanel/ErrorMessage.tsx b/src/renderer/components/layout/LeftPanel/ErrorMessage/ErrorMessage.tsx similarity index 69% rename from src/renderer/components/layout/LeftPanel/ErrorMessage.tsx rename to src/renderer/components/layout/LeftPanel/ErrorMessage/ErrorMessage.tsx index 154b177..2d8760a 100644 --- a/src/renderer/components/layout/LeftPanel/ErrorMessage.tsx +++ b/src/renderer/components/layout/LeftPanel/ErrorMessage/ErrorMessage.tsx @@ -1,8 +1,8 @@ import React from 'react'; import { AlertTriangle } from 'lucide-react'; -import { useAppTranscription } from '../../../contexts'; +import { useAppTranscription } from '../../../../contexts'; -export function ErrorMessage(): React.JSX.Element | null { +function ErrorMessage(): React.JSX.Element | null { const { error } = useAppTranscription(); if (!error) { @@ -15,3 +15,5 @@ export function ErrorMessage(): React.JSX.Element | null { ); } + +export { ErrorMessage }; diff --git a/src/renderer/components/layout/LeftPanel/ErrorMessage/index.ts b/src/renderer/components/layout/LeftPanel/ErrorMessage/index.ts new file mode 100644 index 0000000..1f26cdd --- /dev/null +++ b/src/renderer/components/layout/LeftPanel/ErrorMessage/index.ts @@ -0,0 +1 @@ +export { ErrorMessage } from './ErrorMessage'; diff --git a/src/renderer/components/layout/LeftPanel/LeftPanel.tsx b/src/renderer/components/layout/LeftPanel/LeftPanel.tsx index 8e79218..3318876 100644 --- a/src/renderer/components/layout/LeftPanel/LeftPanel.tsx +++ b/src/renderer/components/layout/LeftPanel/LeftPanel.tsx @@ -1,13 +1,13 @@ -import React, { useEffect, useState, useCallback } from 'react'; +import React from 'react'; import { FileDropZone } from '../../../features/transcription'; import { SettingsPanel } from '../../../features/settings'; import { useAppTranscription } from '../../../contexts'; +import { useFFmpegStatus } from '../../../hooks'; import { TranscriptionActions } from './TranscriptionActions'; import { TranscriptionProgress } from './TranscriptionProgress'; import { ErrorMessage } from './ErrorMessage'; import { DonationSection } from './DonationSection'; import { SystemWarning } from '../../ui'; -import { checkFFmpeg } from '../../../services/electronAPI'; function LeftPanel(): React.JSX.Element { const { @@ -20,32 +20,16 @@ function LeftPanel(): React.JSX.Element { handleFileSelect, } = useAppTranscription(); - const [isFFmpegAvailable, setIsFFmpegAvailable] = useState(null); - - const checkStatus = useCallback(async () => { - try { - const available = await checkFFmpeg(); - setIsFFmpegAvailable(available); - return available; - } catch (error) { - console.error('Failed to check FFmpeg status:', error); - setIsFFmpegAvailable(false); - return false; - } - }, []); - - useEffect(() => { - checkStatus(); - }, [checkStatus]); + const { isFFmpegAvailable, isChecking, recheckStatus } = useFFmpegStatus(); return (
- {isFFmpegAvailable === null && ( + {isChecking && isFFmpegAvailable === null && (
Checking system requirements...
)} - {isFFmpegAvailable === false && } + {isFFmpegAvailable === false && } - + @@ -72,4 +56,4 @@ function LeftPanel(): React.JSX.Element { ); } -export default LeftPanel; +export { LeftPanel }; diff --git a/src/renderer/components/layout/LeftPanel/TranscriptionActions.tsx b/src/renderer/components/layout/LeftPanel/TranscriptionActions/TranscriptionActions.tsx similarity index 53% rename from src/renderer/components/layout/LeftPanel/TranscriptionActions.tsx rename to src/renderer/components/layout/LeftPanel/TranscriptionActions/TranscriptionActions.tsx index 313f49c..132e6ec 100644 --- a/src/renderer/components/layout/LeftPanel/TranscriptionActions.tsx +++ b/src/renderer/components/layout/LeftPanel/TranscriptionActions/TranscriptionActions.tsx @@ -1,20 +1,32 @@ import React from 'react'; import { Zap } from 'lucide-react'; -import { useAppTranscription } from '../../../contexts'; +import { useAppTranscription } from '../../../../contexts'; -export function TranscriptionActions(): React.JSX.Element { +export interface TranscriptionActionsProps { + isFFmpegAvailable: boolean | null; +} + +function TranscriptionActions({ isFFmpegAvailable }: TranscriptionActionsProps): React.JSX.Element { const { selectedFile, isTranscribing, modelDownloaded, handleTranscribe, handleCancel } = useAppTranscription(); + const canTranscribe = selectedFile && modelDownloaded && isFFmpegAvailable === true; + + const getDisabledReason = (): string => { + if (!isFFmpegAvailable) return 'Please install FFmpeg first'; + if (!modelDownloaded) return 'Please download the selected model first'; + return ''; + }; + return (
{!isTranscribing ? ( @@ -30,3 +42,5 @@ export function TranscriptionActions(): React.JSX.Element {
); } + +export { TranscriptionActions }; diff --git a/src/renderer/components/layout/LeftPanel/TranscriptionActions/index.ts b/src/renderer/components/layout/LeftPanel/TranscriptionActions/index.ts new file mode 100644 index 0000000..4b974a8 --- /dev/null +++ b/src/renderer/components/layout/LeftPanel/TranscriptionActions/index.ts @@ -0,0 +1,2 @@ +export { TranscriptionActions } from './TranscriptionActions'; +export type { TranscriptionActionsProps } from './TranscriptionActions'; diff --git a/src/renderer/components/layout/LeftPanel/TranscriptionProgress.tsx b/src/renderer/components/layout/LeftPanel/TranscriptionProgress/TranscriptionProgress.tsx similarity index 64% rename from src/renderer/components/layout/LeftPanel/TranscriptionProgress.tsx rename to src/renderer/components/layout/LeftPanel/TranscriptionProgress/TranscriptionProgress.tsx index 9d978ba..be0f406 100644 --- a/src/renderer/components/layout/LeftPanel/TranscriptionProgress.tsx +++ b/src/renderer/components/layout/LeftPanel/TranscriptionProgress/TranscriptionProgress.tsx @@ -1,8 +1,8 @@ import React from 'react'; -import { ProgressBar } from '../../ui'; -import { useAppTranscription } from '../../../contexts'; +import { ProgressBar } from '../../../ui'; +import { useAppTranscription } from '../../../../contexts'; -export function TranscriptionProgress(): React.JSX.Element | null { +function TranscriptionProgress(): React.JSX.Element | null { const { isTranscribing, progress, transcriptionStartTime } = useAppTranscription(); if (!isTranscribing && !progress.status) { @@ -18,3 +18,5 @@ export function TranscriptionProgress(): React.JSX.Element | null { /> ); } + +export { TranscriptionProgress }; diff --git a/src/renderer/components/layout/LeftPanel/TranscriptionProgress/index.ts b/src/renderer/components/layout/LeftPanel/TranscriptionProgress/index.ts new file mode 100644 index 0000000..a1936ee --- /dev/null +++ b/src/renderer/components/layout/LeftPanel/TranscriptionProgress/index.ts @@ -0,0 +1 @@ +export { TranscriptionProgress } from './TranscriptionProgress'; diff --git a/src/renderer/components/layout/LeftPanel/index.ts b/src/renderer/components/layout/LeftPanel/index.ts index 9a0b431..4939aae 100644 --- a/src/renderer/components/layout/LeftPanel/index.ts +++ b/src/renderer/components/layout/LeftPanel/index.ts @@ -1,4 +1,4 @@ -export { default as LeftPanel } from './LeftPanel'; +export { LeftPanel } from './LeftPanel'; export { TranscriptionActions } from './TranscriptionActions'; export { TranscriptionProgress } from './TranscriptionProgress'; export { ErrorMessage } from './ErrorMessage'; diff --git a/src/renderer/components/layout/RightPanel/RightPanel.tsx b/src/renderer/components/layout/RightPanel/RightPanel.tsx index 0824b60..2de6872 100644 --- a/src/renderer/components/layout/RightPanel/RightPanel.tsx +++ b/src/renderer/components/layout/RightPanel/RightPanel.tsx @@ -40,4 +40,4 @@ function RightPanel(): React.JSX.Element { ); } -export default RightPanel; +export { RightPanel }; diff --git a/src/renderer/components/layout/RightPanel/index.ts b/src/renderer/components/layout/RightPanel/index.ts index 91c7766..7e0c5a0 100644 --- a/src/renderer/components/layout/RightPanel/index.ts +++ b/src/renderer/components/layout/RightPanel/index.ts @@ -1 +1 @@ -export { default as RightPanel } from './RightPanel'; +export { RightPanel } from './RightPanel'; diff --git a/src/renderer/components/ui/DebugLogsModal/DebugLogsModal.css b/src/renderer/components/ui/DebugLogsModal/DebugLogsModal.css new file mode 100644 index 0000000..407ae2e --- /dev/null +++ b/src/renderer/components/ui/DebugLogsModal/DebugLogsModal.css @@ -0,0 +1,196 @@ +.debug-logs-overlay { + position: fixed; + inset: 0; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + backdrop-filter: blur(4px); + animation: fadeIn 0.2s ease-out; +} + +.debug-logs-modal { + background-color: var(--bg-primary); + border-radius: var(--radius); + box-shadow: var(--shadow-lg); + width: min(90vw, 800px); + max-height: 80vh; + display: flex; + flex-direction: column; + overflow: hidden; + border: 1px solid var(--border); + animation: slideIn 0.3s cubic-bezier(0.16, 1, 0.3, 1); +} + +.debug-logs-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + border-bottom: 1px solid var(--border); + background-color: var(--bg-secondary); +} + +.debug-logs-header h2 { + margin: 0; + font-size: 1.125rem; + font-weight: 600; + display: flex; + align-items: center; + gap: 8px; +} + +.debug-logs-header .log-count { + font-size: 0.875rem; + color: var(--text-secondary); + font-weight: 400; +} + +.debug-logs-close { + background: none; + border: none; + padding: 8px; + cursor: pointer; + color: var(--text-secondary); + border-radius: var(--radius-sm); + transition: all var(--transition-base); + display: flex; + align-items: center; + justify-content: center; +} + +.debug-logs-close:hover { + background-color: var(--bg-tertiary); + color: var(--text-primary); +} + +.debug-logs-content { + flex: 1; + overflow-y: auto; + padding: 16px; + font-family: var(--font-mono, 'SF Mono', 'Monaco', 'Consolas', monospace); + font-size: 0.8125rem; + line-height: 1.5; +} + +.debug-logs-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 48px; + color: var(--text-secondary); + text-align: center; +} + +.debug-logs-empty svg { + margin-bottom: 12px; + opacity: 0.5; +} + +.log-entry { + padding: 4px 8px; + border-radius: 4px; + margin-bottom: 2px; + white-space: pre-wrap; + word-break: break-word; +} + +.log-entry.level-debug { + color: var(--text-secondary); +} + +.log-entry.level-info { + color: var(--accent); +} + +.log-entry.level-warn { + color: var(--warning); + background-color: var(--warning-surface-dim); +} + +.log-entry.level-error { + color: var(--danger); + background-color: var(--danger-light); +} + +.debug-logs-footer { + display: flex; + gap: 8px; + padding: 16px 20px; + border-top: 1px solid var(--border); + background-color: var(--bg-secondary); +} + +.debug-logs-footer button { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 10px 16px; + border-radius: var(--radius-sm); + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: all var(--transition-base); +} + +.btn-copy-logs { + background-color: var(--accent); + color: white; + border: none; +} + +.btn-copy-logs:hover { + background-color: var(--accent-hover); +} + +.btn-copy-with-info { + background-color: var(--bg-tertiary); + color: var(--text-primary); + border: 1px solid var(--border); +} + +.btn-copy-with-info:hover { + background-color: var(--bg-hover); +} + +.btn-clear-logs { + background-color: transparent; + color: var(--danger); + border: 1px solid var(--danger); + flex: 0.5; +} + +.btn-clear-logs:hover { + background-color: var(--danger-light); +} + +.btn-copy-logs.copied, +.btn-copy-with-info.copied { + background-color: var(--success); + color: white; + border-color: transparent; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes slideIn { + from { + transform: translateY(10px) scale(0.98); + opacity: 0; + } + to { + transform: translateY(0) scale(1); + opacity: 1; + } +} diff --git a/src/renderer/components/ui/DebugLogsModal/DebugLogsModal.tsx b/src/renderer/components/ui/DebugLogsModal/DebugLogsModal.tsx new file mode 100644 index 0000000..9036d9a --- /dev/null +++ b/src/renderer/components/ui/DebugLogsModal/DebugLogsModal.tsx @@ -0,0 +1,143 @@ +import React, { useEffect, useCallback } from 'react'; +import { X, Terminal, Copy, Clipboard, Trash2 } from 'lucide-react'; +import type { LogEntry } from '../../../services/logger'; +import './DebugLogsModal.css'; + +interface DebugLogsModalProps { + isOpen: boolean; + logs: LogEntry[]; + onClose: () => void; + onCopyLogs: () => Promise; + onCopyLogsWithSystemInfo: () => Promise; + onClearLogs: () => void; +} + +function formatLogEntry(entry: LogEntry): string { + const timestamp = entry.timestamp.toISOString().substring(11, 23); + const level = entry.level.toUpperCase().padEnd(5); + const data = entry.data !== undefined ? ` | ${JSON.stringify(entry.data)}` : ''; + return `[${timestamp}] [${level}] ${entry.message}${data}`; +} + +function DebugLogsModal({ + isOpen, + logs, + onClose, + onCopyLogs, + onCopyLogsWithSystemInfo, + onClearLogs, +}: DebugLogsModalProps): React.JSX.Element | null { + const [copyState, setCopyState] = React.useState<'idle' | 'logs' | 'info'>('idle'); + + const handleKeyDown = useCallback( + (event: KeyboardEvent) => { + if (event.key === 'Escape') { + onClose(); + } + }, + [onClose] + ); + + useEffect(() => { + if (isOpen) { + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('keydown', handleKeyDown); + }; + } + return undefined; + }, [isOpen, handleKeyDown]); + + const handleCopyLogs = async () => { + const success = await onCopyLogs(); + if (success) { + setCopyState('logs'); + setTimeout(() => setCopyState('idle'), 2000); + } + }; + + const handleCopyWithInfo = async () => { + const success = await onCopyLogsWithSystemInfo(); + if (success) { + setCopyState('info'); + setTimeout(() => setCopyState('idle'), 2000); + } + }; + + const handleOverlayClick = (event: React.MouseEvent) => { + if (event.target === event.currentTarget) { + onClose(); + } + }; + + if (!isOpen) { + return null; + } + + return ( +
+
+
+

+

+ +
+ +
+ {logs.length === 0 ? ( +
+
+ ) : ( + logs.map((entry, index) => ( +
+ {formatLogEntry(entry)} +
+ )) + )} +
+ +
+ + + +
+
+
+ ); +} + +export { DebugLogsModal }; +export type { DebugLogsModalProps }; diff --git a/src/renderer/components/ui/DebugLogsModal/index.ts b/src/renderer/components/ui/DebugLogsModal/index.ts new file mode 100644 index 0000000..ab1aa3e --- /dev/null +++ b/src/renderer/components/ui/DebugLogsModal/index.ts @@ -0,0 +1,2 @@ +export { DebugLogsModal } from './DebugLogsModal'; +export type { DebugLogsModalProps } from './DebugLogsModal'; diff --git a/src/renderer/components/ui/ErrorBoundary/ErrorBoundary.css b/src/renderer/components/ui/ErrorBoundary/ErrorBoundary.css new file mode 100644 index 0000000..63ad10f --- /dev/null +++ b/src/renderer/components/ui/ErrorBoundary/ErrorBoundary.css @@ -0,0 +1,87 @@ +.error-boundary { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + padding: 2rem; + background: var(--bg-primary, #1a1a2e); + color: var(--text-primary, #e0e0e0); +} + +.error-boundary-content { + max-width: 480px; + text-align: center; +} + +.error-boundary-icon { + color: var(--color-warning, #f59e0b); + margin-bottom: 1.5rem; +} + +.error-boundary-title { + font-size: 1.5rem; + font-weight: 600; + margin: 0 0 0.75rem; + color: var(--text-primary, #e0e0e0); +} + +.error-boundary-message { + font-size: 0.95rem; + color: var(--text-secondary, #a0a0a0); + margin: 0 0 1.5rem; + line-height: 1.5; +} + +.error-boundary-details { + background: var(--bg-secondary, #252540); + border: 1px solid var(--border-color, #3a3a5c); + border-radius: 8px; + padding: 1rem; + margin-bottom: 1.5rem; + font-family: monospace; + font-size: 0.85rem; + color: var(--color-error, #ef4444); + text-align: left; + overflow-x: auto; + white-space: pre-wrap; + word-break: break-word; +} + +.error-boundary-actions { + display: flex; + gap: 0.75rem; + justify-content: center; + flex-wrap: wrap; +} + +.error-boundary-btn { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1.25rem; + border-radius: 8px; + font-size: 0.9rem; + font-weight: 500; + cursor: pointer; + transition: all 0.15s ease; + border: none; +} + +.error-boundary-btn-primary { + background: var(--color-primary, #6366f1); + color: white; +} + +.error-boundary-btn-primary:hover { + background: var(--color-primary-hover, #5558e3); +} + +.error-boundary-btn-secondary { + background: var(--bg-secondary, #252540); + color: var(--text-primary, #e0e0e0); + border: 1px solid var(--border-color, #3a3a5c); +} + +.error-boundary-btn-secondary:hover { + background: var(--bg-tertiary, #2d2d4a); +} diff --git a/src/renderer/components/ui/ErrorBoundary/ErrorBoundary.tsx b/src/renderer/components/ui/ErrorBoundary/ErrorBoundary.tsx new file mode 100644 index 0000000..14409ef --- /dev/null +++ b/src/renderer/components/ui/ErrorBoundary/ErrorBoundary.tsx @@ -0,0 +1,83 @@ +import React, { type ReactNode } from 'react'; +import { AlertTriangle, RefreshCw } from 'lucide-react'; +import { logger } from '../../../services/logger'; +import './ErrorBoundary.css'; + +export interface ErrorBoundaryProps { + children: ReactNode; + fallback?: ReactNode; +} + +interface ErrorBoundaryState { + hasError: boolean; + error: Error | null; +} + +class ErrorBoundary extends React.Component { + constructor(props: ErrorBoundaryProps) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + return { hasError: true, error }; + } + + override componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void { + logger.error('ErrorBoundary caught an error', { + error: error.message, + stack: error.stack, + componentStack: errorInfo.componentStack, + }); + } + + handleReload = (): void => { + window.location.reload(); + }; + + handleReset = (): void => { + this.setState({ hasError: false, error: null }); + }; + + override render(): ReactNode { + if (this.state.hasError) { + if (this.props.fallback) { + return this.props.fallback; + } + + return ( +
+
+
+
+ ); + } + + return this.props.children; + } +} + +export { ErrorBoundary }; diff --git a/src/renderer/components/ui/ErrorBoundary/__tests__/ErrorBoundary.test.tsx b/src/renderer/components/ui/ErrorBoundary/__tests__/ErrorBoundary.test.tsx new file mode 100644 index 0000000..8817b86 --- /dev/null +++ b/src/renderer/components/ui/ErrorBoundary/__tests__/ErrorBoundary.test.tsx @@ -0,0 +1,125 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { ErrorBoundary } from '../ErrorBoundary'; + +function ThrowingComponent(): never { + throw new Error('Test error message'); +} + +describe('ErrorBoundary', () => { + const originalError = console.error; + beforeEach(() => { + console.error = vi.fn(); + }); + + afterEach(() => { + console.error = originalError; + }); + + it('renders children when there is no error', () => { + render( + +
Child content
+
+ ); + + expect(screen.getByText('Child content')).toBeInTheDocument(); + }); + + it('renders fallback UI when child throws', () => { + render( + + + + ); + + expect(screen.getByText('Something went wrong')).toBeInTheDocument(); + expect( + screen.getByText('An unexpected error occurred. Please try reloading the application.') + ).toBeInTheDocument(); + }); + + it('renders custom fallback when provided', () => { + render( + Custom fallback
}> + + + ); + + expect(screen.getByText('Custom fallback')).toBeInTheDocument(); + expect(screen.queryByText('Something went wrong')).not.toBeInTheDocument(); + }); + + it('shows reload and try again buttons', () => { + render( + + + + ); + + expect(screen.getByRole('button', { name: /reload application/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /try again/i })).toBeInTheDocument(); + }); + + it('calls window.location.reload when reload button is clicked', () => { + const reloadMock = vi.fn(); + Object.defineProperty(window, 'location', { + value: { reload: reloadMock }, + writable: true, + }); + + render( + + + + ); + + fireEvent.click(screen.getByRole('button', { name: /reload application/i })); + expect(reloadMock).toHaveBeenCalled(); + }); + + it('resets error state when try again button is clicked', () => { + let shouldThrow = true; + + function ToggleableThrower(): React.JSX.Element { + if (shouldThrow) { + throw new Error('Initial error'); + } + return
Recovered content
; + } + + const { rerender } = render( + + + + ); + + expect(screen.getByText('Something went wrong')).toBeInTheDocument(); + + shouldThrow = false; + fireEvent.click(screen.getByRole('button', { name: /try again/i })); + + rerender( + + + + ); + + expect(screen.getByText('Recovered content')).toBeInTheDocument(); + }); + + it('does not show error details in production', () => { + const originalNodeEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'production'; + + render( + + + + ); + + expect(screen.queryByText('Test error message')).not.toBeInTheDocument(); + + process.env.NODE_ENV = originalNodeEnv; + }); +}); diff --git a/src/renderer/components/ui/ErrorBoundary/index.ts b/src/renderer/components/ui/ErrorBoundary/index.ts new file mode 100644 index 0000000..7aff0c7 --- /dev/null +++ b/src/renderer/components/ui/ErrorBoundary/index.ts @@ -0,0 +1,2 @@ +export { ErrorBoundary } from './ErrorBoundary'; +export type { ErrorBoundaryProps } from './ErrorBoundary'; diff --git a/src/renderer/components/ui/ProgressBar/ProgressBar.tsx b/src/renderer/components/ui/ProgressBar/ProgressBar.tsx index cb48135..85d3984 100644 --- a/src/renderer/components/ui/ProgressBar/ProgressBar.tsx +++ b/src/renderer/components/ui/ProgressBar/ProgressBar.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react'; import { formatTime } from '../../../utils'; import './ProgressBar.css'; -interface ProgressBarProps { +export interface ProgressBarProps { percent: number; status: string; startTime: number | null; @@ -59,4 +59,4 @@ function ProgressBar({ ); } -export default ProgressBar; +export { ProgressBar }; diff --git a/src/renderer/components/ui/ProgressBar/index.ts b/src/renderer/components/ui/ProgressBar/index.ts index e072ff5..3b5109c 100644 --- a/src/renderer/components/ui/ProgressBar/index.ts +++ b/src/renderer/components/ui/ProgressBar/index.ts @@ -1 +1,2 @@ -export { default as ProgressBar } from './ProgressBar'; +export { ProgressBar } from './ProgressBar'; +export type { ProgressBarProps } from './ProgressBar'; diff --git a/src/renderer/components/ui/SystemWarning/SystemWarning.tsx b/src/renderer/components/ui/SystemWarning/SystemWarning.tsx index 2bc6627..6669ba2 100644 --- a/src/renderer/components/ui/SystemWarning/SystemWarning.tsx +++ b/src/renderer/components/ui/SystemWarning/SystemWarning.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect, useRef } from 'react'; import { Copy, Check, AlertTriangle, RefreshCw } from 'lucide-react'; import './SystemWarning.css'; import { useCopyToClipboard } from '../../../hooks/useCopyToClipboard'; -import { trackEvent, openExternal } from '../../../services/electronAPI'; +import { trackEvent, openExternal, getAppInfo, logger } from '../../../services'; const FFMPEG_DOWNLOAD_URL = 'https://ffmpeg.org/download.html'; const VERIFICATION_RETRY_DELAY_MS = 1000; @@ -25,19 +25,17 @@ function SystemWarning({ onRefresh }: SystemWarningProps): React.JSX.Element { useEffect(() => { const getPlatform = async () => { - if (window.electronAPI?.getAppInfo) { - try { - const appInfo = await window.electronAPI.getAppInfo(); - if (appInfo.platform === 'win32') { - setInstallCommand('winget install ffmpeg'); - } else if (appInfo.platform === 'linux') { - setInstallCommand('sudo apt install ffmpeg'); - } else { - setInstallCommand('brew install ffmpeg'); - } - } catch (error) { - console.error('Failed to get platform info:', error); + try { + const appInfo = await getAppInfo(); + if (appInfo.platform === 'win32') { + setInstallCommand('winget install ffmpeg'); + } else if (appInfo.platform === 'linux') { + setInstallCommand('sudo apt install ffmpeg'); + } else { + setInstallCommand('brew install ffmpeg'); } + } catch (error) { + logger.error('Failed to get platform info:', error); } }; getPlatform(); @@ -46,13 +44,13 @@ function SystemWarning({ onRefresh }: SystemWarningProps): React.JSX.Element { const handleCopy = () => { copyToClipboard(installCommand); trackEvent('ffmpeg_install_command_copied', { command: installCommand }).catch((error) => { - console.error('Failed to track copy event:', error); + logger.error('Failed to track copy event:', error); }); }; const handleDownloadLink = async () => { trackEvent('ffmpeg_download_link_clicked').catch((error) => { - console.error( + logger.error( `Failed to track FFmpeg download link click (event: 'ffmpeg_download_link_clicked', url: ${FFMPEG_DOWNLOAD_URL}):`, error ); @@ -60,14 +58,14 @@ function SystemWarning({ onRefresh }: SystemWarningProps): React.JSX.Element { try { await openExternal(FFMPEG_DOWNLOAD_URL); } catch (error) { - console.error('Failed to open link:', error); + logger.error('Failed to open link:', error); } }; const handleRefresh = async () => { setIsChecking(true); trackEvent('ffmpeg_check_again_clicked').catch((error) => { - console.error('Failed to track refresh event:', error); + logger.error('Failed to track refresh event:', error); }); try { const isAvailable = await onRefresh(); @@ -82,7 +80,7 @@ function SystemWarning({ onRefresh }: SystemWarningProps): React.JSX.Element { await onRefresh(); } catch (error) { - console.error('Failed to refresh FFmpeg status:', error); + logger.error('Failed to refresh FFmpeg status:', error); } finally { if (isMounted.current) { setIsChecking(false); @@ -150,4 +148,4 @@ function SystemWarning({ onRefresh }: SystemWarningProps): React.JSX.Element { ); } -export default SystemWarning; +export { SystemWarning }; diff --git a/src/renderer/components/ui/SystemWarning/__tests__/SystemWarning.test.tsx b/src/renderer/components/ui/SystemWarning/__tests__/SystemWarning.test.tsx index 1f5fc60..6a65f9b 100644 --- a/src/renderer/components/ui/SystemWarning/__tests__/SystemWarning.test.tsx +++ b/src/renderer/components/ui/SystemWarning/__tests__/SystemWarning.test.tsx @@ -1,7 +1,8 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, screen, fireEvent, waitFor } from '@testing-library/react'; -import SystemWarning from '../SystemWarning'; +import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; +import { SystemWarning } from '../SystemWarning'; import { overrideElectronAPI } from '../../../../test/utils'; +import { logger } from '../../../../services/logger'; const mockCopyToClipboard = vi.fn(); vi.mock('../../../../hooks/useCopyToClipboard', () => ({ @@ -62,13 +63,28 @@ describe('SystemWarning', () => { }); it('calls onRefresh when check button is clicked', async () => { + let resolveRefresh: (value: boolean) => void; + const refreshPromise = new Promise((resolve) => { + resolveRefresh = resolve; + }); + mockOnRefresh.mockReturnValue(refreshPromise); + render(); const refreshButton = screen.getByRole('button', { name: /I have installed FFmpeg/i }); + fireEvent.click(refreshButton); - expect(mockOnRefresh).toHaveBeenCalled(); expect(screen.getByText(/Verifying Installation.../i)).toBeInTheDocument(); + expect(refreshButton).toBeDisabled(); + expect(mockOnRefresh).toHaveBeenCalled(); + + resolveRefresh!(true); + + await waitFor(() => { + expect(refreshButton).not.toBeDisabled(); + }); + expect(screen.queryByText(/Verifying Installation.../i)).not.toBeInTheDocument(); }); it('copies command to clipboard', async () => { @@ -109,7 +125,6 @@ describe('SystemWarning', () => { it('handles openExternal error when clicking download link', async () => { const mockOpenExternal = vi.fn().mockRejectedValue(new Error('Failed to open')); - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); overrideElectronAPI({ openExternal: mockOpenExternal, trackEvent: vi.fn().mockResolvedValue(undefined), @@ -121,15 +136,12 @@ describe('SystemWarning', () => { fireEvent.click(downloadButton); await waitFor(() => { - expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to open link:', expect.any(Error)); + expect(logger.error).toHaveBeenCalledWith('Failed to open link:', expect.any(Error)); }); - - consoleErrorSpy.mockRestore(); }); it('handles trackEvent error when clicking download link', async () => { const mockTrackEvent = vi.fn().mockRejectedValue(new Error('Failed to track')); - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); overrideElectronAPI({ openExternal: vi.fn().mockResolvedValue(undefined), trackEvent: mockTrackEvent, @@ -141,18 +153,15 @@ describe('SystemWarning', () => { fireEvent.click(downloadButton); await waitFor(() => { - expect(consoleErrorSpy).toHaveBeenCalledWith( + expect(logger.error).toHaveBeenCalledWith( expect.stringContaining('Failed to track FFmpeg download link click'), expect.any(Error) ); }); - - consoleErrorSpy.mockRestore(); }); it('handles trackEvent error when clicking refresh', async () => { const mockTrackEvent = vi.fn().mockRejectedValue(new Error('Failed to track')); - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); overrideElectronAPI({ trackEvent: mockTrackEvent, }); @@ -160,21 +169,21 @@ describe('SystemWarning', () => { render(); const refreshButton = screen.getByRole('button', { name: /I have installed FFmpeg/i }); - fireEvent.click(refreshButton); + + await act(async () => { + fireEvent.click(refreshButton); + }); await waitFor(() => { - expect(consoleErrorSpy).toHaveBeenCalledWith( + expect(logger.error).toHaveBeenCalledWith( 'Failed to track refresh event:', expect.any(Error) ); }); - - consoleErrorSpy.mockRestore(); }); it('handles trackEvent error when clicking copy', async () => { const mockTrackEvent = vi.fn().mockRejectedValue(new Error('Failed to track')); - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); overrideElectronAPI({ getAppInfo: vi.fn().mockResolvedValue({ platform: 'darwin', version: '1.0.0', isDev: true }), trackEvent: mockTrackEvent, @@ -190,17 +199,11 @@ describe('SystemWarning', () => { fireEvent.click(copyButton); await waitFor(() => { - expect(consoleErrorSpy).toHaveBeenCalledWith( - 'Failed to track copy event:', - expect.any(Error) - ); + expect(logger.error).toHaveBeenCalledWith('Failed to track copy event:', expect.any(Error)); }); - - consoleErrorSpy.mockRestore(); }); it('handles getAppInfo error', async () => { - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); overrideElectronAPI({ getAppInfo: vi.fn().mockRejectedValue(new Error('Failed to get info')), }); @@ -208,13 +211,8 @@ describe('SystemWarning', () => { render(); await waitFor(() => { - expect(consoleErrorSpy).toHaveBeenCalledWith( - 'Failed to get platform info:', - expect.any(Error) - ); + expect(logger.error).toHaveBeenCalledWith('Failed to get platform info:', expect.any(Error)); }); - - consoleErrorSpy.mockRestore(); }); it('retries onRefresh if first attempt fails (returns false)', async () => { @@ -224,11 +222,16 @@ describe('SystemWarning', () => { render(); const refreshButton = screen.getByRole('button', { name: /I have installed FFmpeg/i }); - fireEvent.click(refreshButton); + + await act(async () => { + fireEvent.click(refreshButton); + }); expect(mockOnRefresh).toHaveBeenCalledTimes(1); - await vi.advanceTimersByTimeAsync(1000); + await act(async () => { + await vi.advanceTimersByTimeAsync(1000); + }); expect(mockOnRefresh).toHaveBeenCalledTimes(2); @@ -241,7 +244,10 @@ describe('SystemWarning', () => { render(); const refreshButton = screen.getByRole('button', { name: /I have installed FFmpeg/i }); - fireEvent.click(refreshButton); + + await act(async () => { + fireEvent.click(refreshButton); + }); await waitFor(() => { expect(mockOnRefresh).toHaveBeenCalled(); diff --git a/src/renderer/components/ui/SystemWarning/index.ts b/src/renderer/components/ui/SystemWarning/index.ts index df65498..698c7f7 100644 --- a/src/renderer/components/ui/SystemWarning/index.ts +++ b/src/renderer/components/ui/SystemWarning/index.ts @@ -1 +1,2 @@ -export { default as SystemWarning } from './SystemWarning'; +export { SystemWarning } from './SystemWarning'; +export type { SystemWarningProps } from './SystemWarning'; diff --git a/src/renderer/components/ui/index.ts b/src/renderer/components/ui/index.ts index b82acb0..f450df6 100644 --- a/src/renderer/components/ui/index.ts +++ b/src/renderer/components/ui/index.ts @@ -1,2 +1,6 @@ +export { ErrorBoundary } from './ErrorBoundary'; +export type { ErrorBoundaryProps } from './ErrorBoundary'; export { ProgressBar } from './ProgressBar'; export { SystemWarning } from './SystemWarning'; +export { DebugLogsModal } from './DebugLogsModal'; +export type { DebugLogsModalProps } from './DebugLogsModal'; diff --git a/src/renderer/contexts/AppContext.tsx b/src/renderer/contexts/AppContext.tsx index e3e28e8..08ef10b 100644 --- a/src/renderer/contexts/AppContext.tsx +++ b/src/renderer/contexts/AppContext.tsx @@ -3,8 +3,18 @@ import { useTranscription } from '../features/transcription'; import { useHistory } from '../features/history'; import { useTheme, useCopyToClipboard, useElectronMenu } from '../hooks'; import type { HistoryItem } from '../types'; -import { ThemeContext, HistoryContext, TranscriptionContext } from './contexts'; -import type { ThemeContextValue, HistoryContextValue, TranscriptionContextValue } from './types'; +import { + ThemeContext, + HistoryContext, + TranscriptionStateContext, + TranscriptionActionsContext, +} from './contexts'; +import type { + ThemeContextValue, + HistoryContextValue, + TranscriptionStateContextValue, + TranscriptionActionsContextValue, +} from './types'; interface AppProviderProps { children: ReactNode; @@ -116,7 +126,7 @@ export function AppProvider({ children }: AppProviderProps): React.JSX.Element { ] ); - const transcriptionContextValue = useMemo( + const transcriptionStateValue = useMemo( () => ({ selectedFile, settings, @@ -127,14 +137,6 @@ export function AppProvider({ children }: AppProviderProps): React.JSX.Element { error, modelDownloaded, copySuccess, - setSelectedFile, - setSettings, - setModelDownloaded, - handleFileSelect, - handleTranscribe, - handleCancel, - handleSave, - handleCopy: onCopy, }), [ selectedFile, @@ -146,6 +148,21 @@ export function AppProvider({ children }: AppProviderProps): React.JSX.Element { error, modelDownloaded, copySuccess, + ] + ); + + const transcriptionActionsValue = useMemo( + () => ({ + setSelectedFile, + setSettings, + setModelDownloaded, + handleFileSelect, + handleTranscribe, + handleCancel, + handleSave, + handleCopy: onCopy, + }), + [ setSelectedFile, setSettings, setModelDownloaded, @@ -160,9 +177,11 @@ export function AppProvider({ children }: AppProviderProps): React.JSX.Element { return ( - - {children} - + + + {children} + + ); diff --git a/src/renderer/contexts/contexts.ts b/src/renderer/contexts/contexts.ts index 24f86b3..21bf34d 100644 --- a/src/renderer/contexts/contexts.ts +++ b/src/renderer/contexts/contexts.ts @@ -1,6 +1,14 @@ import { createContext } from 'react'; -import type { ThemeContextValue, HistoryContextValue, TranscriptionContextValue } from './types'; +import type { + ThemeContextValue, + HistoryContextValue, + TranscriptionStateContextValue, + TranscriptionActionsContextValue, +} from './types'; export const ThemeContext = createContext(null); export const HistoryContext = createContext(null); -export const TranscriptionContext = createContext(null); +export const TranscriptionStateContext = createContext(null); +export const TranscriptionActionsContext = createContext( + null +); diff --git a/src/renderer/contexts/hooks.ts b/src/renderer/contexts/hooks.ts index aae54bc..f0380d8 100644 --- a/src/renderer/contexts/hooks.ts +++ b/src/renderer/contexts/hooks.ts @@ -1,6 +1,17 @@ import { useContext } from 'react'; -import { ThemeContext, HistoryContext, TranscriptionContext } from './contexts'; -import type { ThemeContextValue, HistoryContextValue, TranscriptionContextValue } from './types'; +import { + ThemeContext, + HistoryContext, + TranscriptionStateContext, + TranscriptionActionsContext, +} from './contexts'; +import type { + ThemeContextValue, + HistoryContextValue, + TranscriptionContextValue, + TranscriptionStateContextValue, + TranscriptionActionsContextValue, +} from './types'; export function useAppTheme(): ThemeContextValue { const context = useContext(ThemeContext); @@ -19,9 +30,28 @@ export function useAppHistory(): HistoryContextValue { } export function useAppTranscription(): TranscriptionContextValue { - const context = useContext(TranscriptionContext); - if (!context) { + const state = useContext(TranscriptionStateContext); + const actions = useContext(TranscriptionActionsContext); + + if (!state || !actions) { throw new Error('useAppTranscription must be used within AppProvider'); } + + return { ...state, ...actions }; +} + +export function useAppTranscriptionState(): TranscriptionStateContextValue { + const context = useContext(TranscriptionStateContext); + if (!context) { + throw new Error('useAppTranscriptionState must be used within AppProvider'); + } + return context; +} + +export function useAppTranscriptionActions(): TranscriptionActionsContextValue { + const context = useContext(TranscriptionActionsContext); + if (!context) { + throw new Error('useAppTranscriptionActions must be used within AppProvider'); + } return context; } diff --git a/src/renderer/contexts/types.ts b/src/renderer/contexts/types.ts index e9f0804..64d2456 100644 --- a/src/renderer/contexts/types.ts +++ b/src/renderer/contexts/types.ts @@ -23,7 +23,7 @@ export interface HistoryContextValue { selectHistoryItem: (item: HistoryItem) => void; } -export interface TranscriptionContextValue { +export interface TranscriptionStateContextValue { selectedFile: SelectedFile | null; settings: TranscriptionSettings; isTranscribing: boolean; @@ -33,7 +33,9 @@ export interface TranscriptionContextValue { error: string | null; modelDownloaded: boolean; copySuccess: boolean; +} +export interface TranscriptionActionsContextValue { setSelectedFile: (file: SelectedFile | null) => void; setSettings: (settings: TranscriptionSettings) => void; setModelDownloaded: (downloaded: boolean) => void; @@ -43,3 +45,6 @@ export interface TranscriptionContextValue { handleSave: (format?: OutputFormat) => Promise; handleCopy: () => Promise; } + +export interface TranscriptionContextValue + extends TranscriptionStateContextValue, TranscriptionActionsContextValue {} diff --git a/src/renderer/features/auto-update/components/UpdateNotification/UpdateNotification.tsx b/src/renderer/features/auto-update/components/UpdateNotification/UpdateNotification.tsx index 5a2e162..7533701 100644 --- a/src/renderer/features/auto-update/components/UpdateNotification/UpdateNotification.tsx +++ b/src/renderer/features/auto-update/components/UpdateNotification/UpdateNotification.tsx @@ -3,7 +3,7 @@ import { convertHtmlToText, formatFileSize } from '../../../../utils'; import { X, AlertTriangle, Sparkles, Download, CheckCircle } from 'lucide-react'; import './UpdateNotification.css'; -export function UpdateNotification() { +function UpdateNotification() { const { updateStatus, isDownloading, @@ -139,3 +139,5 @@ export function UpdateNotification() { ); } + +export { UpdateNotification }; diff --git a/src/renderer/features/history/__tests__/useHistory.test.ts b/src/renderer/features/history/__tests__/useHistory.test.ts index 4e51af0..eb6dbb8 100644 --- a/src/renderer/features/history/__tests__/useHistory.test.ts +++ b/src/renderer/features/history/__tests__/useHistory.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi } from 'vitest'; import { renderHook, act } from '@testing-library/react'; +import { logger } from '../../../services/logger'; import { useHistory } from '@/features/history'; import type { HistoryItem } from '@/types'; import { createMockHistoryItem, createHistoryItems } from '@/test/fixtures'; @@ -190,7 +191,6 @@ describe('useHistory', () => { }); it('should handle localStorage save failure gracefully', () => { - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); const originalSetItem = localStorage.setItem.bind(localStorage); localStorage.setItem = vi.fn().mockImplementation(() => { @@ -203,9 +203,8 @@ describe('useHistory', () => { result.current.addHistoryItem(createMockHistoryItem()); }); - expect(consoleSpy).toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalled(); localStorage.setItem = originalSetItem; - consoleSpy.mockRestore(); }); }); diff --git a/src/renderer/features/history/components/TranscriptionHistory/TranscriptionHistory.tsx b/src/renderer/features/history/components/TranscriptionHistory/TranscriptionHistory.tsx index 28ca2d0..b82ab2a 100644 --- a/src/renderer/features/history/components/TranscriptionHistory/TranscriptionHistory.tsx +++ b/src/renderer/features/history/components/TranscriptionHistory/TranscriptionHistory.tsx @@ -6,7 +6,7 @@ import './TranscriptionHistory.css'; import type { HistoryItem } from '../../../../types'; -interface TranscriptionHistoryProps { +export interface TranscriptionHistoryProps { history: HistoryItem[]; onClear: () => void; onClose: () => void; @@ -110,4 +110,4 @@ function TranscriptionHistory({ ); } -export default TranscriptionHistory; +export { TranscriptionHistory }; diff --git a/src/renderer/features/history/__tests__/TranscriptionHistory.test.tsx b/src/renderer/features/history/components/TranscriptionHistory/__tests__/TranscriptionHistory.test.tsx similarity index 100% rename from src/renderer/features/history/__tests__/TranscriptionHistory.test.tsx rename to src/renderer/features/history/components/TranscriptionHistory/__tests__/TranscriptionHistory.test.tsx diff --git a/src/renderer/features/history/components/TranscriptionHistory/index.ts b/src/renderer/features/history/components/TranscriptionHistory/index.ts index db631d8..1c29297 100644 --- a/src/renderer/features/history/components/TranscriptionHistory/index.ts +++ b/src/renderer/features/history/components/TranscriptionHistory/index.ts @@ -1 +1,2 @@ -export { default as TranscriptionHistory } from './TranscriptionHistory'; +export { TranscriptionHistory } from './TranscriptionHistory'; +export type { TranscriptionHistoryProps } from './TranscriptionHistory'; diff --git a/src/renderer/features/history/components/index.ts b/src/renderer/features/history/components/index.ts index 4755226..1c29297 100644 --- a/src/renderer/features/history/components/index.ts +++ b/src/renderer/features/history/components/index.ts @@ -1 +1,2 @@ export { TranscriptionHistory } from './TranscriptionHistory'; +export type { TranscriptionHistoryProps } from './TranscriptionHistory'; diff --git a/src/renderer/features/history/hooks/useHistory.ts b/src/renderer/features/history/hooks/useHistory.ts index ede5d3f..c664487 100644 --- a/src/renderer/features/history/hooks/useHistory.ts +++ b/src/renderer/features/history/hooks/useHistory.ts @@ -2,6 +2,7 @@ import { useState, useCallback } from 'react'; import type { HistoryItem } from '../../../types'; import { STORAGE_KEYS } from '../../../utils/storage'; import { APP_CONFIG } from '../../../config'; +import { logger } from '../../../services'; const STORAGE_KEY = STORAGE_KEYS.HISTORY; const MAX_HISTORY_ITEMS = APP_CONFIG.MAX_HISTORY_ITEMS; @@ -23,7 +24,7 @@ const saveHistoryToStorage = (history: HistoryItem[]): void => { const trimmed = history.slice(0, MAX_HISTORY_ITEMS); localStorage.setItem(STORAGE_KEY, JSON.stringify(trimmed)); } catch (e) { - console.error('Failed to save history:', e); + logger.error('Failed to save history:', e); } }; diff --git a/src/renderer/features/settings/__tests__/SettingsPanel.test.tsx b/src/renderer/features/settings/__tests__/SettingsPanel.test.tsx deleted file mode 100644 index e419c57..0000000 --- a/src/renderer/features/settings/__tests__/SettingsPanel.test.tsx +++ /dev/null @@ -1,353 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; -import { SettingsPanel } from '@/features/settings'; -import { overrideElectronAPI } from '@/test/utils'; -import { MOCK_SETTINGS, createMockModels } from '@/test/fixtures'; - -describe('SettingsPanel', () => { - const mockSettings = { ...MOCK_SETTINGS }; - const mockModels = createMockModels(3, [false, true, false]); - - beforeEach(() => { - vi.clearAllMocks(); - overrideElectronAPI({ - listModels: vi.fn().mockResolvedValue({ models: mockModels }), - getGpuStatus: vi.fn().mockResolvedValue({ - available: true, - name: 'NVIDIA RTX 3080', - memory: '10 GB', - }), - onModelDownloadProgress: vi.fn().mockReturnValue(() => {}), - }); - }); - - it('should render settings panel with title', async () => { - const onChange = vi.fn(); - - render(); - - await waitFor(() => { - expect(screen.getByText(/Settings/i)).toBeInTheDocument(); - }); - }); - - it('should load and display models on mount', async () => { - const onChange = vi.fn(); - - render(); - - await waitFor(() => { - expect(window.electronAPI?.listModels).toHaveBeenCalled(); - }); - }); - - it('should display model selection dropdown', async () => { - const onChange = vi.fn(); - - render(); - - await waitFor(() => { - expect(screen.getByLabelText(/Select Whisper model/i)).toBeInTheDocument(); - }); - }); - - it('should display language selection dropdown', async () => { - const onChange = vi.fn(); - - render(); - - await waitFor(() => { - const langSelect = screen.getByLabelText(/Select transcription language/i); - expect(langSelect).toBeInTheDocument(); - }); - }); - - it('should call onChange when model selection changes', async () => { - const onChange = vi.fn(); - - render(); - - await waitFor(() => { - expect(screen.getByLabelText(/Select Whisper model/i)).toBeInTheDocument(); - }); - - const modelSelect = screen.getByLabelText(/Select Whisper model/i) as HTMLSelectElement; - fireEvent.change(modelSelect, { target: { value: 'small' } }); - - await waitFor(() => { - expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ model: 'small' })); - }); - }); - - it('should call onChange when language selection changes', async () => { - const onChange = vi.fn(); - - render(); - - await waitFor(() => { - const languageSelect = screen.getByLabelText(/Select transcription language/i); - fireEvent.change(languageSelect, { target: { value: 'en' } }); - }); - - await waitFor(() => { - expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ language: 'en' })); - }); - }); - - it('should disable controls when disabled prop is true', async () => { - const onChange = vi.fn(); - - render(); - - await waitFor(() => { - const modelSelect = screen.getByLabelText(/Select Whisper model/i); - expect(modelSelect).toBeDisabled(); - }); - }); - - it('should call onModelStatusChange when model download status changes', async () => { - const onChange = vi.fn(); - const onModelStatusChange = vi.fn(); - - render( - - ); - - await waitFor(() => { - expect(window.electronAPI?.listModels).toHaveBeenCalled(); - }); - }); - - it('should display GPU status when available', async () => { - const onChange = vi.fn(); - - render(); - - await waitFor(() => { - expect(screen.getByText(/GPU|RTX 3080/i)).toBeInTheDocument(); - }); - }); - - it('should handle loadModelInfo error gracefully', async () => { - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - overrideElectronAPI({ - listModels: vi.fn().mockRejectedValue(new Error('Network error')), - getGpuStatus: vi.fn().mockRejectedValue(new Error('GPU error')), - onModelDownloadProgress: vi.fn().mockReturnValue(() => {}), - }); - - const onChange = vi.fn(); - - render(); - - await waitFor(() => { - expect(consoleSpy).toHaveBeenCalled(); - }); - - consoleSpy.mockRestore(); - }); - - it('should handle model download error', async () => { - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - const downloadedModels = createMockModels(3, [false, false, false]); - overrideElectronAPI({ - listModels: vi.fn().mockResolvedValue({ models: downloadedModels }), - getGpuStatus: vi.fn().mockResolvedValue({ - available: true, - name: 'GPU', - memory: '8 GB', - }), - onModelDownloadProgress: vi.fn().mockReturnValue(() => {}), - downloadModel: vi.fn().mockRejectedValue(new Error('Download failed')), - }); - - const onChange = vi.fn(); - - render(); - - await waitFor(() => { - expect(screen.getByLabelText(/Download/i)).toBeInTheDocument(); - }); - - const downloadButton = screen.getByLabelText(/Download/i); - fireEvent.click(downloadButton); - - await waitFor(() => { - expect(consoleSpy).toHaveBeenCalled(); - }); - - consoleSpy.mockRestore(); - }); - - it('should persist model selection to localStorage', async () => { - const onChange = vi.fn(); - - render(); - - await waitFor(() => { - expect(screen.getByLabelText(/Select Whisper model/i)).toBeInTheDocument(); - }); - - const modelSelect = screen.getByLabelText(/Select Whisper model/i) as HTMLSelectElement; - fireEvent.change(modelSelect, { target: { value: 'small' } }); - - expect(localStorage.getItem('whisperdesk_lastModel')).toBe('small'); - }); - - it('should show download progress during model download', async () => { - let progressCallback: ((data: { status: string; percent: number }) => void) | undefined; - const downloadedModels = createMockModels(3, [false, false, false]); - - overrideElectronAPI({ - listModels: vi.fn().mockResolvedValue({ models: downloadedModels }), - getGpuStatus: vi.fn().mockResolvedValue({ - available: true, - name: 'GPU', - memory: '8 GB', - }), - onModelDownloadProgress: vi.fn((callback) => { - progressCallback = callback; - return () => {}; - }), - downloadModel: vi.fn().mockImplementation( - () => - new Promise((resolve) => { - setTimeout(resolve, 1000); - }) - ), - }); - - const onChange = vi.fn(); - - render(); - - await waitFor(() => { - expect(screen.getByLabelText(/Download/i)).toBeInTheDocument(); - }); - - const downloadButton = screen.getByLabelText(/Download/i); - fireEvent.click(downloadButton); - - await waitFor(() => { - expect(screen.getByText(/Downloading/i)).toBeInTheDocument(); - }); - - const cb = progressCallback; - if (cb) { - act(() => { - cb({ status: 'progress', percent: 50 }); - }); - } - - await waitFor(() => { - expect(screen.getByText(/50%/i)).toBeInTheDocument(); - }); - }); - - it('should handle delete model confirmation cancel', async () => { - const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false); - const downloadedModels = createMockModels(3, [true, true, true]); - - overrideElectronAPI({ - listModels: vi.fn().mockResolvedValue({ models: downloadedModels }), - getGpuStatus: vi.fn().mockResolvedValue({ - available: true, - name: 'GPU', - memory: '8 GB', - }), - onModelDownloadProgress: vi.fn().mockReturnValue(() => {}), - deleteModel: vi.fn(), - }); - - const onChange = vi.fn(); - - render(); - - await waitFor(() => { - expect(screen.getByLabelText(/Delete/i)).toBeInTheDocument(); - }); - - const deleteButton = screen.getByLabelText(/Delete/i); - fireEvent.click(deleteButton); - - expect(window.electronAPI?.deleteModel).not.toHaveBeenCalled(); - confirmSpy.mockRestore(); - }); - - it('should handle delete model failure', async () => { - const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true); - const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {}); - const downloadedModels = createMockModels(3, [true, true, true]); - - overrideElectronAPI({ - listModels: vi.fn().mockResolvedValue({ models: downloadedModels }), - getGpuStatus: vi.fn().mockResolvedValue({ - available: true, - name: 'GPU', - memory: '8 GB', - }), - onModelDownloadProgress: vi.fn().mockReturnValue(() => {}), - deleteModel: vi.fn().mockResolvedValue({ success: false, error: 'Permission denied' }), - }); - - const onChange = vi.fn(); - - render(); - - await waitFor(() => { - expect(screen.getByLabelText(/Delete/i)).toBeInTheDocument(); - }); - - const deleteButton = screen.getByLabelText(/Delete/i); - fireEvent.click(deleteButton); - - await waitFor(() => { - expect(alertSpy).toHaveBeenCalledWith(expect.stringContaining('Permission denied')); - }); - - confirmSpy.mockRestore(); - alertSpy.mockRestore(); - }); - - it('should handle delete model exception', async () => { - const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true); - const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {}); - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - const downloadedModels = createMockModels(3, [true, true, true]); - - overrideElectronAPI({ - listModels: vi.fn().mockResolvedValue({ models: downloadedModels }), - getGpuStatus: vi.fn().mockResolvedValue({ - available: true, - name: 'GPU', - memory: '8 GB', - }), - onModelDownloadProgress: vi.fn().mockReturnValue(() => {}), - deleteModel: vi.fn().mockRejectedValue(new Error('Network error')), - }); - - const onChange = vi.fn(); - - render(); - - await waitFor(() => { - expect(screen.getByLabelText(/Delete/i)).toBeInTheDocument(); - }); - - const deleteButton = screen.getByLabelText(/Delete/i); - fireEvent.click(deleteButton); - - await waitFor(() => { - expect(consoleSpy).toHaveBeenCalled(); - }); - - confirmSpy.mockRestore(); - alertSpy.mockRestore(); - consoleSpy.mockRestore(); - }); -}); diff --git a/src/renderer/features/settings/components/GpuStatus/GpuStatus.css b/src/renderer/features/settings/components/GpuStatus/GpuStatus.css new file mode 100644 index 0000000..abeb51a --- /dev/null +++ b/src/renderer/features/settings/components/GpuStatus/GpuStatus.css @@ -0,0 +1,26 @@ +.gpu-status { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 12px; + border-radius: var(--radius-sm); + margin-bottom: 14px; + font-size: 0.85rem; +} + +.gpu-available { + background: var(--success-light); + border: 1px solid var(--success); + color: var(--success); +} + +.gpu-unavailable { + background: var(--bg-tertiary); + border: 1px solid var(--border); + color: var(--text-secondary); +} + +.gpu-icon { + display: flex; + align-items: center; +} diff --git a/src/renderer/features/settings/components/GpuStatus/GpuStatus.tsx b/src/renderer/features/settings/components/GpuStatus/GpuStatus.tsx new file mode 100644 index 0000000..9569296 --- /dev/null +++ b/src/renderer/features/settings/components/GpuStatus/GpuStatus.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import { Zap, Cpu } from 'lucide-react'; +import './GpuStatus.css'; +import type { GpuInfo } from '../../../../types'; + +export interface GpuStatusProps { + gpuInfo: GpuInfo | null; +} + +function GpuStatus({ gpuInfo }: GpuStatusProps): React.JSX.Element | null { + if (!gpuInfo) return null; + + return ( +
+ + {gpuInfo.name} +
+ ); +} + +export { GpuStatus }; diff --git a/src/renderer/features/settings/components/GpuStatus/index.ts b/src/renderer/features/settings/components/GpuStatus/index.ts new file mode 100644 index 0000000..d5f2fe1 --- /dev/null +++ b/src/renderer/features/settings/components/GpuStatus/index.ts @@ -0,0 +1,2 @@ +export { GpuStatus } from './GpuStatus'; +export type { GpuStatusProps } from './GpuStatus'; diff --git a/src/renderer/features/settings/components/LanguageSelector/LanguageSelector.tsx b/src/renderer/features/settings/components/LanguageSelector/LanguageSelector.tsx new file mode 100644 index 0000000..52b2ec1 --- /dev/null +++ b/src/renderer/features/settings/components/LanguageSelector/LanguageSelector.tsx @@ -0,0 +1,41 @@ +import React, { type ChangeEvent } from 'react'; +import '../ModelSelector/ModelSelector.css'; +import type { LanguageCode } from '../../../../types'; +import { LANGUAGES } from '../../../../config'; + +export interface LanguageSelectorProps { + selectedLanguage: LanguageCode; + disabled: boolean; + onChange: (language: LanguageCode) => void; +} + +function LanguageSelector({ + selectedLanguage, + disabled, + onChange, +}: LanguageSelectorProps): React.JSX.Element { + const handleChange = (e: ChangeEvent): void => { + onChange(e.target.value as LanguageCode); + }; + + return ( +
+ + +
+ ); +} + +export { LanguageSelector }; diff --git a/src/renderer/features/settings/components/LanguageSelector/index.ts b/src/renderer/features/settings/components/LanguageSelector/index.ts new file mode 100644 index 0000000..3c10aed --- /dev/null +++ b/src/renderer/features/settings/components/LanguageSelector/index.ts @@ -0,0 +1,2 @@ +export { LanguageSelector } from './LanguageSelector'; +export type { LanguageSelectorProps } from './LanguageSelector'; diff --git a/src/renderer/features/settings/components/ModelDetails/ModelDetails.css b/src/renderer/features/settings/components/ModelDetails/ModelDetails.css new file mode 100644 index 0000000..ef019fd --- /dev/null +++ b/src/renderer/features/settings/components/ModelDetails/ModelDetails.css @@ -0,0 +1,132 @@ +.model-details { + margin-top: 10px; + padding: 12px; + background: var(--surface); + border-radius: var(--radius-sm); + border: 1px solid var(--border); + transition: all var(--transition-base); +} + +.model-info-row { + display: flex; + justify-content: space-between; + margin-bottom: 10px; +} + +.model-stat { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.85rem; +} + +.stat-label { + color: var(--text-muted); +} + +.stat-value { + color: var(--text-primary); + font-weight: 500; +} + +.stat-value.quality { + color: var(--warning); + letter-spacing: 2px; +} + +.model-download { + margin-top: 10px; +} + +.btn-download { + width: 100%; + padding: 8px 12px; + font-size: 0.85rem; + background: var(--accent-light); + border: 1px solid var(--accent-border); + color: var(--accent); + border-radius: var(--radius-sm); + cursor: pointer; + transition: all var(--transition-fast); +} + +.btn-download:hover { + background: var(--accent); + color: white; + border-color: var(--accent); +} + +.downloading { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 8px; + font-size: 0.85rem; + color: var(--text-secondary); +} + +.spinner { + width: 14px; + height: 14px; + border: 2px solid var(--border); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.model-ready { + font-size: 0.85rem; + color: var(--success); + display: flex; + align-items: center; + gap: 4px; +} + +.download-progress { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; +} + +.progress-text { + font-size: 0.75rem; + color: var(--text-muted); +} + +.model-ready-container { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 8px; +} + +.btn-delete-model { + background: transparent; + border: none; + cursor: pointer; + padding: 4px; + border-radius: var(--radius-sm); + transition: all var(--transition-fast); + font-size: 1rem; + opacity: 0.6; + color: var(--text-muted); +} + +.btn-delete-model:hover { + background: var(--danger-light); + opacity: 1; + color: var(--danger); +} + +.btn-delete-model:disabled { + opacity: 0.3; + cursor: not-allowed; +} diff --git a/src/renderer/features/settings/components/ModelDetails/ModelDetails.tsx b/src/renderer/features/settings/components/ModelDetails/ModelDetails.tsx new file mode 100644 index 0000000..65da7f2 --- /dev/null +++ b/src/renderer/features/settings/components/ModelDetails/ModelDetails.tsx @@ -0,0 +1,88 @@ +import React from 'react'; +import { Download, Check, Trash2 } from 'lucide-react'; +import './ModelDetails.css'; +import type { ModelInfo, ModelDownloadProgress } from '../../../../types'; +import { QUALITY_STARS } from '../../../../config'; + +export interface ModelDetailsProps { + model: ModelInfo | undefined; + downloading: string | null; + downloadProgress: ModelDownloadProgress | null; + disabled: boolean; + onDownload: (modelName: string) => void; + onDelete: (modelName: string) => void; +} + +function ModelDetails({ + model, + downloading, + downloadProgress, + disabled, + onDownload, + onDelete, +}: ModelDetailsProps): React.JSX.Element | null { + if (!model) return null; + + const trimmedRemainingTime = downloadProgress?.remainingTime?.trim() ?? ''; + + return ( +
+
+ + Speed: + {model.speed} + + + Quality: + {QUALITY_STARS[model.quality - 1]} + +
+ + {!model.downloaded && ( +
+ {downloading === model.name ? ( +
+ + Downloading... + + {downloadProgress && downloadProgress.percent !== undefined && ( + + {downloadProgress.percent}% + {trimmedRemainingTime && ` (${trimmedRemainingTime} left)`} + + )} +
+ ) : ( + + )} +
+ )} + + {model.downloaded && ( +
+
+
+ +
+ )} +
+ ); +} + +export { ModelDetails }; diff --git a/src/renderer/features/settings/components/ModelDetails/index.ts b/src/renderer/features/settings/components/ModelDetails/index.ts new file mode 100644 index 0000000..698c3e2 --- /dev/null +++ b/src/renderer/features/settings/components/ModelDetails/index.ts @@ -0,0 +1,2 @@ +export { ModelDetails } from './ModelDetails'; +export type { ModelDetailsProps } from './ModelDetails'; diff --git a/src/renderer/features/settings/components/ModelSelector/ModelSelector.css b/src/renderer/features/settings/components/ModelSelector/ModelSelector.css new file mode 100644 index 0000000..4c5ec3e --- /dev/null +++ b/src/renderer/features/settings/components/ModelSelector/ModelSelector.css @@ -0,0 +1,42 @@ +.setting-group { + margin-bottom: 14px; +} + +.setting-group:last-child { + margin-bottom: 0; +} + +.setting-group label { + display: block; + font-size: 0.85rem; + font-weight: 500; + margin-bottom: 6px; + color: var(--text-primary); +} + +.setting-group select { + width: 100%; + padding: 10px 12px; + border-radius: var(--radius-sm); + border: 1px solid var(--border); + background: var(--bg-secondary); + color: var(--text-primary); + font-size: 0.9rem; + cursor: pointer; + transition: all var(--transition-fast); + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%238a8f98' d='M2 4l4 4 4-4'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 12px center; + padding-right: 32px; +} + +.setting-group select:hover { + border-color: var(--accent-border); +} + +.setting-group select:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-light); +} diff --git a/src/renderer/features/settings/components/ModelSelector/ModelSelector.tsx b/src/renderer/features/settings/components/ModelSelector/ModelSelector.tsx new file mode 100644 index 0000000..529dc04 --- /dev/null +++ b/src/renderer/features/settings/components/ModelSelector/ModelSelector.tsx @@ -0,0 +1,48 @@ +import React, { type ChangeEvent } from 'react'; +import './ModelSelector.css'; +import type { ModelInfo, WhisperModelName } from '../../../../types'; + +export interface ModelSelectorProps { + models: ModelInfo[]; + selectedModel: WhisperModelName; + disabled: boolean; + loading: boolean; + onChange: (model: WhisperModelName) => void; + ariaDescribedBy?: string; +} + +function ModelSelector({ + models, + selectedModel, + disabled, + loading, + onChange, + ariaDescribedBy, +}: ModelSelectorProps): React.JSX.Element { + const handleChange = (e: ChangeEvent): void => { + onChange(e.target.value as WhisperModelName); + }; + + return ( +
+ + +
+ ); +} + +export { ModelSelector }; diff --git a/src/renderer/features/settings/components/ModelSelector/index.ts b/src/renderer/features/settings/components/ModelSelector/index.ts new file mode 100644 index 0000000..7d8d66f --- /dev/null +++ b/src/renderer/features/settings/components/ModelSelector/index.ts @@ -0,0 +1,2 @@ +export { ModelSelector } from './ModelSelector'; +export type { ModelSelectorProps } from './ModelSelector'; diff --git a/src/renderer/features/settings/components/SettingsPanel/SettingsPanel.css b/src/renderer/features/settings/components/SettingsPanel/SettingsPanel.css index fe767cd..93fa206 100644 --- a/src/renderer/features/settings/components/SettingsPanel/SettingsPanel.css +++ b/src/renderer/features/settings/components/SettingsPanel/SettingsPanel.css @@ -19,206 +19,3 @@ text-transform: uppercase; letter-spacing: 0.8px; } - -.gpu-status { - display: flex; - align-items: center; - gap: 8px; - padding: 10px 12px; - border-radius: var(--radius-sm); - margin-bottom: 14px; - font-size: 0.85rem; -} - -.gpu-available { - background: var(--success-light); - border: 1px solid var(--success); - color: var(--success); -} - -.gpu-unavailable { - background: var(--bg-tertiary); - border: 1px solid var(--border); - color: var(--text-secondary); -} - -.gpu-icon { - display: flex; - align-items: center; -} - -.setting-group { - margin-bottom: 14px; -} - -.setting-group:last-child { - margin-bottom: 0; -} - -.setting-group label { - display: block; - font-size: 0.85rem; - font-weight: 500; - margin-bottom: 6px; - color: var(--text-primary); -} - -.setting-group select { - width: 100%; - padding: 10px 12px; - border-radius: var(--radius-sm); - border: 1px solid var(--border); - background: var(--bg-secondary); - color: var(--text-primary); - font-size: 0.9rem; - cursor: pointer; - transition: all var(--transition-fast); - appearance: none; - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%238a8f98' d='M2 4l4 4 4-4'/%3E%3C/svg%3E"); - background-repeat: no-repeat; - background-position: right 12px center; - padding-right: 32px; -} - -.setting-group select:hover { - border-color: var(--accent-border); -} - -.setting-group select:focus { - outline: none; - border-color: var(--accent); - box-shadow: 0 0 0 3px var(--accent-light); -} - -.model-details { - margin-top: 10px; - padding: 12px; - background: var(--surface); - border-radius: var(--radius-sm); - border: 1px solid var(--border); - transition: all var(--transition-base); -} - -.model-info-row { - display: flex; - justify-content: space-between; - margin-bottom: 10px; -} - -.model-stat { - display: flex; - align-items: center; - gap: 6px; - font-size: 0.85rem; -} - -.stat-label { - color: var(--text-muted); -} - -.stat-value { - color: var(--text-primary); - font-weight: 500; -} - -.stat-value.quality { - color: var(--warning); - letter-spacing: 2px; -} - -.model-download { - margin-top: 10px; -} - -.btn-download { - width: 100%; - padding: 8px 12px; - font-size: 0.85rem; - background: var(--accent-light); - border: 1px solid var(--accent-border); - color: var(--accent); - border-radius: var(--radius-sm); - cursor: pointer; - transition: all var(--transition-fast); -} - -.btn-download:hover { - background: var(--accent); - color: white; - border-color: var(--accent); -} - -.downloading { - display: flex; - align-items: center; - justify-content: center; - gap: 8px; - padding: 8px; - font-size: 0.85rem; - color: var(--text-secondary); -} - -.spinner { - width: 14px; - height: 14px; - border: 2px solid var(--border); - border-top-color: var(--accent); - border-radius: 50%; - animation: spin 0.8s linear infinite; -} - -@keyframes spin { - to { - transform: rotate(360deg); - } -} - -.model-ready { - font-size: 0.85rem; - color: var(--success); - display: flex; - align-items: center; - gap: 4px; -} - -.download-progress { - display: flex; - flex-direction: column; - align-items: center; - gap: 4px; -} - -.progress-text { - font-size: 0.75rem; - color: var(--text-muted); -} - -.model-ready-container { - display: flex; - align-items: center; - justify-content: space-between; - margin-top: 8px; -} - -.btn-delete-model { - background: transparent; - border: none; - cursor: pointer; - padding: 4px; - border-radius: var(--radius-sm); - transition: all var(--transition-fast); - font-size: 1rem; - opacity: 0.6; - color: var(--text-muted); -} - -.btn-delete-model:hover { - background: var(--danger-light); - opacity: 1; - color: var(--danger); -} - -.btn-delete-model:disabled { - opacity: 0.3; - cursor: not-allowed; -} diff --git a/src/renderer/features/settings/components/SettingsPanel/SettingsPanel.tsx b/src/renderer/features/settings/components/SettingsPanel/SettingsPanel.tsx index 199105e..05c0e1f 100644 --- a/src/renderer/features/settings/components/SettingsPanel/SettingsPanel.tsx +++ b/src/renderer/features/settings/components/SettingsPanel/SettingsPanel.tsx @@ -1,5 +1,4 @@ -import React, { useState, useEffect, type ChangeEvent } from 'react'; -import { Download, Check, Trash2, Zap, Cpu } from 'lucide-react'; +import React, { useState, useEffect } from 'react'; import './SettingsPanel.css'; import type { @@ -10,10 +9,22 @@ import type { WhisperModelName, LanguageCode, } from '../../../../types'; -import { LANGUAGES, QUALITY_STARS } from '../../../../config'; import { DEFAULT_MODELS } from '../../services/modelService'; - -interface SettingsPanelProps { +import { + listModels, + getGpuStatus, + onModelDownloadProgress, + downloadModel, + deleteModel, + logger, +} from '../../../../services'; + +import { GpuStatus } from '../GpuStatus'; +import { ModelSelector } from '../ModelSelector'; +import { ModelDetails } from '../ModelDetails'; +import { LanguageSelector } from '../LanguageSelector'; + +export interface SettingsPanelProps { settings: TranscriptionSettings; onChange: (settings: TranscriptionSettings) => void; disabled: boolean; @@ -35,10 +46,7 @@ function SettingsPanel({ const loadModelInfo = async (): Promise => { try { setLoading(true); - const [modelList, gpu] = await Promise.all([ - window.electronAPI?.listModels(), - window.electronAPI?.getGpuStatus(), - ]); + const [modelList, gpu] = await Promise.all([listModels(), getGpuStatus()]); if (modelList?.models) { setModels(modelList.models); @@ -47,7 +55,7 @@ function SettingsPanel({ setGpuInfo(gpu); } } catch (err) { - console.error('Failed to load model info:', err); + logger.error('Failed to load model info:', err); setModels(DEFAULT_MODELS); } finally { setLoading(false); @@ -57,22 +65,20 @@ function SettingsPanel({ useEffect(() => { loadModelInfo(); - const unsubscribe = window.electronAPI?.onModelDownloadProgress?.( - (data: ModelDownloadProgress) => { - setDownloadProgress(data); - if (data.status === 'complete') { - setDownloading(null); - setDownloadProgress(null); - loadModelInfo(); - } else if (data.status === 'error') { - setDownloading(null); - setDownloadProgress(null); - } + const unsubscribe = onModelDownloadProgress((data: ModelDownloadProgress) => { + setDownloadProgress(data); + if (data.status === 'complete') { + setDownloading(null); + setDownloadProgress(null); + loadModelInfo(); + } else if (data.status === 'error') { + setDownloading(null); + setDownloadProgress(null); } - ); + }); return () => { - unsubscribe?.(); + unsubscribe(); }; }, []); @@ -93,22 +99,22 @@ function SettingsPanel({ } }, [models, onChange, settings]); - const handleChange = (key: keyof TranscriptionSettings, value: string): void => { - if (key === 'model') { - onChange({ ...settings, model: value as WhisperModelName }); - localStorage.setItem('whisperdesk_lastModel', value); - } else if (key === 'language') { - onChange({ ...settings, language: value as LanguageCode }); - } + const handleModelChange = (model: WhisperModelName): void => { + onChange({ ...settings, model }); + localStorage.setItem('whisperdesk_lastModel', model); + }; + + const handleLanguageChange = (language: LanguageCode): void => { + onChange({ ...settings, language }); }; const handleDownloadModel = async (modelName: string): Promise => { try { setDownloading(modelName); - await window.electronAPI?.downloadModel(modelName); + await downloadModel(modelName); await loadModelInfo(); } catch (err) { - console.error('Failed to download model:', err); + logger.error('Failed to download model:', err); } finally { setDownloading(null); } @@ -120,14 +126,14 @@ function SettingsPanel({ } try { setLoading(true); - const result = await window.electronAPI?.deleteModel(modelName); + const result = await deleteModel(modelName); if (!result?.success) { window.alert(`Failed to delete model: ${result?.error || 'Unknown error'}`); return; } await loadModelInfo(); } catch (err) { - console.error('Failed to delete model:', err); + logger.error('Failed to delete model:', err); window.alert( `Failed to delete model: ${err && typeof err === 'object' && 'message' in err ? err.message : String(err)}` ); @@ -136,133 +142,39 @@ function SettingsPanel({ } }; - const handleModelChange = (e: ChangeEvent): void => { - handleChange('model', e.target.value); - }; - - const handleLanguageChange = (e: ChangeEvent): void => { - handleChange('language', e.target.value); - }; - - const selectedModel = models.find((m) => m.name === settings.model); - const trimmedRemainingTime = downloadProgress?.remainingTime?.trim() ?? ''; + const selectedModelInfo = models.find((m) => m.name === settings.model); return (

Settings

- {gpuInfo && ( -
- - {gpuInfo.name} -
- )} - -
- - - - {selectedModel && ( -
-
- - Speed: - {selectedModel.speed} - - - Quality: - - {QUALITY_STARS[selectedModel.quality - 1]} - - -
- - {!selectedModel.downloaded && ( -
- {downloading === selectedModel.name ? ( -
- - Downloading... - - {downloadProgress && downloadProgress.percent !== undefined && ( - - {downloadProgress.percent}% - {trimmedRemainingTime && ` (${trimmedRemainingTime} left)`} - - )} -
- ) : ( - - )} -
- )} - - {selectedModel.downloaded && ( -
-
-
- -
- )} -
- )} -
- -
- - -
+ + + + + + +
); } -export default SettingsPanel; +export { SettingsPanel }; diff --git a/src/renderer/features/settings/components/SettingsPanel/__tests__/SettingsPanel.test.tsx b/src/renderer/features/settings/components/SettingsPanel/__tests__/SettingsPanel.test.tsx index 5c3a0bd..327e61b 100644 --- a/src/renderer/features/settings/components/SettingsPanel/__tests__/SettingsPanel.test.tsx +++ b/src/renderer/features/settings/components/SettingsPanel/__tests__/SettingsPanel.test.tsx @@ -1,9 +1,10 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { render, screen, fireEvent, waitFor } from '@testing-library/react'; -import SettingsPanel from '../SettingsPanel'; +import { SettingsPanel } from '../SettingsPanel'; import type { TranscriptionSettings } from '../../../../../types'; import { overrideElectronAPI } from '../../../../../test/utils'; import { MOCK_SETTINGS, createMockModels, MOCK_GPU_INFO } from '../../../../../test/fixtures'; +import { logger } from '../../../../../services/logger'; describe('SettingsPanel', () => { const mockSettings = MOCK_SETTINGS; @@ -514,7 +515,6 @@ describe('SettingsPanel', () => { it('handles download model failure gracefully', async () => { const downloadModel = vi.fn().mockRejectedValue(new Error('Download failed')); const listModels = vi.fn().mockResolvedValue({ models: mockModels }); - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); overrideElectronAPI({ listModels, @@ -534,10 +534,8 @@ describe('SettingsPanel', () => { fireEvent.click(downloadButton); await waitFor(() => { - expect(consoleSpy).toHaveBeenCalledWith('Failed to download model:', expect.any(Error)); + expect(logger.error).toHaveBeenCalledWith('Failed to download model:', expect.any(Error)); }); - - consoleSpy.mockRestore(); }); it('shows GPU status as unavailable when GPU is not available', async () => { @@ -558,8 +556,6 @@ describe('SettingsPanel', () => { }); it('handles model info loading failure', async () => { - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - overrideElectronAPI({ listModels: vi.fn().mockRejectedValue(new Error('Failed to load')), getGpuStatus: vi.fn().mockRejectedValue(new Error('Failed to get GPU')), @@ -571,9 +567,7 @@ describe('SettingsPanel', () => { expect(screen.getByLabelText('Select Whisper model')).toBeInTheDocument(); }); - expect(consoleSpy).toHaveBeenCalledWith('Failed to load model info:', expect.any(Error)); - - consoleSpy.mockRestore(); + expect(logger.error).toHaveBeenCalledWith('Failed to load model info:', expect.any(Error)); }); it('displays download progress without remaining time', async () => { diff --git a/src/renderer/features/settings/components/SettingsPanel/index.ts b/src/renderer/features/settings/components/SettingsPanel/index.ts index 975f4d8..ccbadc7 100644 --- a/src/renderer/features/settings/components/SettingsPanel/index.ts +++ b/src/renderer/features/settings/components/SettingsPanel/index.ts @@ -1 +1,2 @@ -export { default as SettingsPanel } from './SettingsPanel'; +export { SettingsPanel } from './SettingsPanel'; +export type { SettingsPanelProps } from './SettingsPanel'; diff --git a/src/renderer/features/settings/components/index.ts b/src/renderer/features/settings/components/index.ts index a71f257..7037834 100644 --- a/src/renderer/features/settings/components/index.ts +++ b/src/renderer/features/settings/components/index.ts @@ -1 +1,7 @@ export { SettingsPanel } from './SettingsPanel'; +export type { SettingsPanelProps } from './SettingsPanel'; + +export * from './GpuStatus'; +export * from './ModelSelector'; +export * from './ModelDetails'; +export * from './LanguageSelector'; diff --git a/src/renderer/features/transcription/__tests__/useTranscription.test.ts b/src/renderer/features/transcription/__tests__/useTranscription.test.ts index 09e31c5..e45f238 100644 --- a/src/renderer/features/transcription/__tests__/useTranscription.test.ts +++ b/src/renderer/features/transcription/__tests__/useTranscription.test.ts @@ -552,7 +552,7 @@ describe('useTranscription', () => { expect(result.current.isTranscribing).toBe(false); }); - expect(result.current.error).toContain('No response'); + expect(result.current.error).toContain('Electron API not available'); }); it('should handle transcription progress updates', async () => { diff --git a/src/renderer/features/transcription/components/FileDropZone/FileDropZone.tsx b/src/renderer/features/transcription/components/FileDropZone/FileDropZone.tsx index df3470c..a781638 100644 --- a/src/renderer/features/transcription/components/FileDropZone/FileDropZone.tsx +++ b/src/renderer/features/transcription/components/FileDropZone/FileDropZone.tsx @@ -2,6 +2,7 @@ import React, { useCallback, type DragEvent, type KeyboardEvent } from 'react'; import { X } from 'lucide-react'; import { isValidMediaFile, formatFileSize } from '../../../../utils'; import type { SelectedFile } from '../../../../types'; +import { openFileDialog, getPathForFile } from '../../../../services/electronAPI'; import './FileDropZone.css'; export interface FileDropZoneProps { @@ -20,7 +21,7 @@ function FileDropZone({ const handleClick = async (): Promise => { if (disabled) return; - const filePath = await window.electronAPI?.openFile(); + const filePath = await openFileDialog(); if (filePath) { const fileName = filePath.split('/').pop(); if (fileName && isValidMediaFile(fileName)) { @@ -36,7 +37,7 @@ function FileDropZone({ const file = e.dataTransfer.files[0]; if (file && isValidMediaFile(file.name)) { - const filePath = window.electronAPI?.getPathForFile(file); + const filePath = getPathForFile(file); if (filePath) { onFileSelect({ path: filePath, name: file.name }); } @@ -109,4 +110,4 @@ function FileDropZone({ ); } -export default FileDropZone; +export { FileDropZone }; diff --git a/src/renderer/features/transcription/__tests__/FileDropZone.test.tsx b/src/renderer/features/transcription/components/FileDropZone/__tests__/FileDropZone.test.tsx similarity index 100% rename from src/renderer/features/transcription/__tests__/FileDropZone.test.tsx rename to src/renderer/features/transcription/components/FileDropZone/__tests__/FileDropZone.test.tsx diff --git a/src/renderer/features/transcription/components/FileDropZone/index.ts b/src/renderer/features/transcription/components/FileDropZone/index.ts index 52a69c7..4d955c3 100644 --- a/src/renderer/features/transcription/components/FileDropZone/index.ts +++ b/src/renderer/features/transcription/components/FileDropZone/index.ts @@ -1,2 +1,2 @@ -export { default as FileDropZone } from './FileDropZone'; +export { FileDropZone } from './FileDropZone'; export type { FileDropZoneProps } from './FileDropZone'; diff --git a/src/renderer/features/transcription/components/OutputDisplay/OutputDisplay.css b/src/renderer/features/transcription/components/OutputDisplay/OutputDisplay.css index 1f85999..241b7db 100644 --- a/src/renderer/features/transcription/components/OutputDisplay/OutputDisplay.css +++ b/src/renderer/features/transcription/components/OutputDisplay/OutputDisplay.css @@ -10,234 +10,3 @@ min-height: 0; transition: all var(--transition-base); } - -.output-header { - display: flex; - justify-content: space-between; - align-items: center; - padding: 14px 18px; - border-bottom: 1px solid var(--border); - gap: 12px; - background: var(--surface); -} - -.output-header h3 { - font-size: 0.75rem; - font-weight: 600; - color: var(--text-muted); - text-transform: uppercase; - letter-spacing: 0.8px; -} - -.output-meta { - flex: 1; -} - -.word-count { - font-size: 0.8rem; - color: var(--text-muted); -} - -.output-actions { - display: flex; - gap: 8px; -} - -.output-actions .btn-icon { - padding: 6px 12px; - font-size: 0.8rem; - background: var(--surface); - border: 1px solid var(--border); - color: var(--text-primary); - border-radius: var(--radius-sm); - transition: all var(--transition-fast); -} - -.output-actions .btn-icon:hover { - background: var(--surface-hover); - border-color: var(--border-hover); -} - -.output-actions .btn-icon.success { - background: var(--success-light); - border-color: var(--success); - color: var(--success); -} - -.save-dropdown { - position: relative; -} - -.save-menu { - position: absolute; - top: 100%; - right: 0; - margin-top: 4px; - background: var(--bg-secondary); - border: 1px solid var(--border); - border-radius: var(--radius-sm); - box-shadow: var(--shadow-lg); - min-width: 160px; - z-index: 100; - overflow: hidden; -} - -.save-menu-item { - display: flex; - justify-content: space-between; - align-items: center; - width: 100%; - padding: 10px 14px; - font-size: 0.85rem; - background: transparent; - border: none; - color: var(--text-primary); - cursor: pointer; - transition: background var(--transition-fast); -} - -.save-menu-item:hover { - background: var(--surface-hover); -} - -.format-ext { - color: var(--text-muted); - font-size: 0.75rem; -} - -.output-actions .btn-icon.active { - background: var(--accent-light); - border-color: var(--accent); - color: var(--accent); -} - -.search-bar { - display: flex; - align-items: center; - gap: 8px; - padding: 10px 18px; - background: var(--surface); - border-bottom: 1px solid var(--border); -} - -.search-input { - flex: 1; - padding: 8px 12px; - font-size: 0.9rem; - background: var(--bg-secondary); - border: 1px solid var(--border); - border-radius: var(--radius-sm); - color: var(--text-primary); - outline: none; - transition: all var(--transition-fast); -} - -.search-input:focus { - border-color: var(--accent); -} - -.search-input::placeholder { - color: var(--text-muted); -} - -.search-nav { - display: flex; - align-items: center; - gap: 4px; -} - -.search-count { - font-size: 0.8rem; - color: var(--text-secondary); - padding: 0 8px; - white-space: nowrap; -} - -.search-nav-btn { - display: flex; - align-items: center; - justify-content: center; - width: 28px; - height: 28px; - padding: 0; - font-size: 0.85rem; - background: var(--surface); - border: 1px solid var(--border); - border-radius: var(--radius-sm); - color: var(--text-primary); - cursor: pointer; - transition: all var(--transition-fast); -} - -.search-nav-btn:hover:not(:disabled) { - background: var(--surface-hover); - border-color: var(--border-hover); -} - -.search-nav-btn:disabled { - opacity: 0.4; - cursor: not-allowed; -} - -.search-nav-btn.close:hover { - background: var(--danger-light); - border-color: var(--danger); - color: var(--danger); -} - -.search-highlight { - background: rgba(255, 213, 0, 0.4); - border-radius: 2px; - padding: 0 1px; -} - -.search-highlight.current { - background: rgba(255, 170, 0, 0.7); - outline: 2px solid var(--accent); - outline-offset: 1px; -} - -.output-content { - flex: 1; - padding: 20px; - overflow-y: auto; - background: var(--bg-secondary); - min-height: 0; -} - -.transcription-text { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; - font-size: 0.95rem; - line-height: 1.8; - white-space: pre-wrap; - word-wrap: break-word; - color: var(--text-primary); - margin: 0; - -webkit-user-select: text; - user-select: text; - cursor: text; -} - -.output-placeholder { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - height: 100%; - gap: 12px; - text-align: center; - color: var(--text-secondary); -} - -.placeholder-icon { - opacity: 0.5; -} - -.output-placeholder span { - font-size: 1rem; -} - -.placeholder-hint { - font-size: 0.85rem; - color: var(--text-muted); -} diff --git a/src/renderer/features/transcription/components/OutputDisplay/OutputDisplay.tsx b/src/renderer/features/transcription/components/OutputDisplay/OutputDisplay.tsx index 3b68166..02dd9b0 100644 --- a/src/renderer/features/transcription/components/OutputDisplay/OutputDisplay.tsx +++ b/src/renderer/features/transcription/components/OutputDisplay/OutputDisplay.tsx @@ -1,8 +1,10 @@ -import React, { useState, useEffect, useRef, useMemo, type ChangeEvent } from 'react'; -import { Search, Check, Copy, Save, ChevronUp, ChevronDown, X, FileText } from 'lucide-react'; -import type { OutputFormat } from '../../../../types'; -import { OUTPUT_FORMATS } from '../../../../config'; +import React, { useState, useEffect, useMemo } from 'react'; import './OutputDisplay.css'; +import type { OutputFormat } from '../../../../types'; + +import { TranscriptionToolbar } from '../TranscriptionToolbar'; +import { TranscriptionSearch } from '../TranscriptionSearch'; +import { TranscriptionContent } from '../TranscriptionContent'; export interface OutputDisplayProps { text: string; @@ -22,16 +24,11 @@ function OutputDisplay({ onCopy, copySuccess, }: OutputDisplayProps): React.JSX.Element { - const [showSaveMenu, setShowSaveMenu] = useState(false); const [showSearch, setShowSearch] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [currentMatchIndex, setCurrentMatchIndex] = useState(0); - const saveMenuRef = useRef(null); - const searchInputRef = useRef(null); - const contentRef = useRef(null); - - const hasText = text && text.length > 0; + const hasText = text.length > 0; const wordCount = hasText ? text.trim().split(/\s+/).length : 0; const charCount = hasText ? text.length : 0; @@ -54,12 +51,6 @@ function OutputDisplay({ setCurrentMatchIndex(0); }, [searchQuery]); - useEffect(() => { - if (showSearch && searchInputRef.current) { - searchInputRef.current.focus(); - } - }, [showSearch]); - useEffect(() => { const handleKeyDown = (e: globalThis.KeyboardEvent): void => { if ((e.metaKey || e.ctrlKey) && e.key === 'f' && hasText) { @@ -84,28 +75,6 @@ function OutputDisplay({ return () => document.removeEventListener('keydown', handleKeyDown); }, [hasText, showSearch, matches.length]); - useEffect(() => { - const handleClickOutside = (e: MouseEvent): void => { - if (saveMenuRef.current && !saveMenuRef.current.contains(e.target as Node)) { - setShowSaveMenu(false); - } - }; - - if (showSaveMenu) { - document.addEventListener('mousedown', handleClickOutside); - } - return () => document.removeEventListener('mousedown', handleClickOutside); - }, [showSaveMenu]); - - const handleSaveFormat = (format: OutputFormat): void => { - setShowSaveMenu(false); - onSave(format); - }; - - const handleSearchChange = (e: ChangeEvent): void => { - setSearchQuery(e.target.value); - }; - const handleToggleSearch = (): void => { setShowSearch(!showSearch); if (showSearch) { @@ -155,151 +124,40 @@ function OutputDisplay({ return parts; }, [text, searchQuery, matches, currentMatchIndex]); - useEffect(() => { - if (matches.length > 0 && contentRef.current) { - const currentMark = contentRef.current.querySelector('.search-highlight.current'); - if (currentMark) { - currentMark.scrollIntoView({ behavior: 'smooth', block: 'center' }); - } - } - }, [currentMatchIndex, matches.length]); - return (
-
-

Transcription

-
- {hasText && ( - - {wordCount} words ยท {charCount} chars - - )} -
- {hasText && ( -
- - -
- - {showSaveMenu && ( -
- {OUTPUT_FORMATS.map((format) => ( - - ))} -
- )} -
-
- )} -
+ {showSearch && hasText && ( -
- -
- {searchQuery && ( - - {matches.length > 0 - ? `${currentMatchIndex + 1} of ${matches.length}` - : 'No matches'} - - )} - - - -
-
+ )} -
- {hasText ? ( -
-            {highlightedText || text}
-          
- ) : ( -
- - - Transcription will appear here - Select a file and click Transcribe to start -
- )} -
+
); } -export default OutputDisplay; +export { OutputDisplay }; diff --git a/src/renderer/features/transcription/__tests__/OutputDisplay.test.tsx b/src/renderer/features/transcription/components/OutputDisplay/__tests__/OutputDisplay.test.tsx similarity index 100% rename from src/renderer/features/transcription/__tests__/OutputDisplay.test.tsx rename to src/renderer/features/transcription/components/OutputDisplay/__tests__/OutputDisplay.test.tsx diff --git a/src/renderer/features/transcription/components/OutputDisplay/index.ts b/src/renderer/features/transcription/components/OutputDisplay/index.ts index 2b9f4db..307c215 100644 --- a/src/renderer/features/transcription/components/OutputDisplay/index.ts +++ b/src/renderer/features/transcription/components/OutputDisplay/index.ts @@ -1,2 +1,2 @@ -export { default as OutputDisplay } from './OutputDisplay'; +export { OutputDisplay } from './OutputDisplay'; export type { OutputDisplayProps } from './OutputDisplay'; diff --git a/src/renderer/features/transcription/components/TranscriptionContent/TranscriptionContent.css b/src/renderer/features/transcription/components/TranscriptionContent/TranscriptionContent.css new file mode 100644 index 0000000..b7c7b6c --- /dev/null +++ b/src/renderer/features/transcription/components/TranscriptionContent/TranscriptionContent.css @@ -0,0 +1,56 @@ +.output-content { + flex: 1; + padding: 20px; + overflow-y: auto; + background: var(--bg-secondary); + min-height: 0; +} + +.transcription-text { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-size: 0.95rem; + line-height: 1.8; + white-space: pre-wrap; + word-wrap: break-word; + color: var(--text-primary); + margin: 0; + -webkit-user-select: text; + user-select: text; + cursor: text; +} + +.output-placeholder { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + gap: 12px; + text-align: center; + color: var(--text-secondary); +} + +.placeholder-icon { + opacity: 0.5; +} + +.output-placeholder span { + font-size: 1rem; +} + +.placeholder-hint { + font-size: 0.85rem; + color: var(--text-muted); +} + +.search-highlight { + background: rgba(255, 213, 0, 0.4); + border-radius: 2px; + padding: 0 1px; +} + +.search-highlight.current { + background: rgba(255, 170, 0, 0.7); + outline: 2px solid var(--accent); + outline-offset: 1px; +} diff --git a/src/renderer/features/transcription/components/TranscriptionContent/TranscriptionContent.tsx b/src/renderer/features/transcription/components/TranscriptionContent/TranscriptionContent.tsx new file mode 100644 index 0000000..39f36e2 --- /dev/null +++ b/src/renderer/features/transcription/components/TranscriptionContent/TranscriptionContent.tsx @@ -0,0 +1,55 @@ +import React, { useRef, useEffect } from 'react'; +import { FileText } from 'lucide-react'; +import './TranscriptionContent.css'; + +export interface TranscriptionContentProps { + hasText: boolean; + text: string; + highlightedText: React.JSX.Element[] | null; + currentMatchIndex: number; + matchCount: number; +} + +function TranscriptionContent({ + hasText, + text, + highlightedText, + currentMatchIndex, + matchCount, +}: TranscriptionContentProps): React.JSX.Element { + const contentRef = useRef(null); + + useEffect(() => { + if (matchCount > 0 && contentRef.current) { + const currentMark = contentRef.current.querySelector('.search-highlight.current'); + if (currentMark) { + currentMark.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + } + }, [currentMatchIndex, matchCount]); + + return ( +
+ {hasText ? ( +
+          {highlightedText || text}
+        
+ ) : ( +
+ + + Transcription will appear here + Select a file and click Transcribe to start +
+ )} +
+ ); +} + +export { TranscriptionContent }; diff --git a/src/renderer/features/transcription/components/TranscriptionContent/index.ts b/src/renderer/features/transcription/components/TranscriptionContent/index.ts new file mode 100644 index 0000000..a86b412 --- /dev/null +++ b/src/renderer/features/transcription/components/TranscriptionContent/index.ts @@ -0,0 +1,2 @@ +export { TranscriptionContent } from './TranscriptionContent'; +export type { TranscriptionContentProps } from './TranscriptionContent'; diff --git a/src/renderer/features/transcription/components/TranscriptionSearch/TranscriptionSearch.css b/src/renderer/features/transcription/components/TranscriptionSearch/TranscriptionSearch.css new file mode 100644 index 0000000..135a9da --- /dev/null +++ b/src/renderer/features/transcription/components/TranscriptionSearch/TranscriptionSearch.css @@ -0,0 +1,73 @@ +.search-bar { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 18px; + background: var(--surface); + border-bottom: 1px solid var(--border); +} + +.search-input { + flex: 1; + padding: 8px 12px; + font-size: 0.9rem; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text-primary); + outline: none; + transition: all var(--transition-fast); +} + +.search-input:focus { + border-color: var(--accent); +} + +.search-input::placeholder { + color: var(--text-muted); +} + +.search-nav { + display: flex; + align-items: center; + gap: 4px; +} + +.search-count { + font-size: 0.8rem; + color: var(--text-secondary); + padding: 0 8px; + white-space: nowrap; +} + +.search-nav-btn { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + font-size: 0.85rem; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text-primary); + cursor: pointer; + transition: all var(--transition-fast); +} + +.search-nav-btn:hover:not(:disabled) { + background: var(--surface-hover); + border-color: var(--border-hover); +} + +.search-nav-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.search-nav-btn.close:hover { + background: var(--danger-light); + border-color: var(--danger); + color: var(--danger); +} diff --git a/src/renderer/features/transcription/components/TranscriptionSearch/TranscriptionSearch.tsx b/src/renderer/features/transcription/components/TranscriptionSearch/TranscriptionSearch.tsx new file mode 100644 index 0000000..eb47ab9 --- /dev/null +++ b/src/renderer/features/transcription/components/TranscriptionSearch/TranscriptionSearch.tsx @@ -0,0 +1,84 @@ +import React, { useRef, useEffect, type ChangeEvent } from 'react'; +import { ChevronUp, ChevronDown, X } from 'lucide-react'; +import './TranscriptionSearch.css'; + +export interface TranscriptionSearchProps { + searchQuery: string; + onSearchChange: (query: string) => void; + currentMatchIndex: number; + totalMatches: number; + onPrevMatch: () => void; + onNextMatch: () => void; + onClose: () => void; +} + +function TranscriptionSearch({ + searchQuery, + onSearchChange, + currentMatchIndex, + totalMatches, + onPrevMatch, + onNextMatch, + onClose, +}: TranscriptionSearchProps): React.JSX.Element { + const searchInputRef = useRef(null); + + useEffect(() => { + if (searchInputRef.current) { + searchInputRef.current.focus(); + } + }, []); + + const handleSearchChange = (e: ChangeEvent): void => { + onSearchChange(e.target.value); + }; + + return ( +
+ +
+ {searchQuery && ( + + {totalMatches > 0 ? `${currentMatchIndex + 1} of ${totalMatches}` : 'No matches'} + + )} + + + +
+
+ ); +} + +export { TranscriptionSearch }; diff --git a/src/renderer/features/transcription/components/TranscriptionSearch/index.ts b/src/renderer/features/transcription/components/TranscriptionSearch/index.ts new file mode 100644 index 0000000..0a96e92 --- /dev/null +++ b/src/renderer/features/transcription/components/TranscriptionSearch/index.ts @@ -0,0 +1,2 @@ +export { TranscriptionSearch } from './TranscriptionSearch'; +export type { TranscriptionSearchProps } from './TranscriptionSearch'; diff --git a/src/renderer/features/transcription/components/TranscriptionToolbar/TranscriptionToolbar.css b/src/renderer/features/transcription/components/TranscriptionToolbar/TranscriptionToolbar.css new file mode 100644 index 0000000..c2d10a4 --- /dev/null +++ b/src/renderer/features/transcription/components/TranscriptionToolbar/TranscriptionToolbar.css @@ -0,0 +1,99 @@ +.output-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 14px 18px; + border-bottom: 1px solid var(--border); + gap: 12px; + background: var(--surface); +} + +.output-header h3 { + font-size: 0.75rem; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.8px; +} + +.output-meta { + flex: 1; +} + +.word-count { + font-size: 0.8rem; + color: var(--text-muted); +} + +.output-actions { + display: flex; + gap: 8px; +} + +.output-actions .btn-icon { + padding: 6px 12px; + font-size: 0.8rem; + background: var(--surface); + border: 1px solid var(--border); + color: var(--text-primary); + border-radius: var(--radius-sm); + transition: all var(--transition-fast); +} + +.output-actions .btn-icon:hover { + background: var(--surface-hover); + border-color: var(--border-hover); +} + +.output-actions .btn-icon.success { + background: var(--success-light); + border-color: var(--success); + color: var(--success); +} + +.save-dropdown { + position: relative; +} + +.save-menu { + position: absolute; + top: 100%; + right: 0; + margin-top: 4px; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + box-shadow: var(--shadow-lg); + min-width: 160px; + z-index: 100; + overflow: hidden; +} + +.save-menu-item { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + padding: 10px 14px; + font-size: 0.85rem; + background: transparent; + border: none; + color: var(--text-primary); + cursor: pointer; + transition: background var(--transition-fast); +} + +.save-menu-item:hover { + background: var(--surface-hover); +} + +.format-ext { + color: var(--text-muted); + font-size: 0.75rem; +} + +.output-actions .btn-icon.active { + background: var(--accent-light); + border-color: var(--accent); + color: var(--accent); +} diff --git a/src/renderer/features/transcription/components/TranscriptionToolbar/TranscriptionToolbar.tsx b/src/renderer/features/transcription/components/TranscriptionToolbar/TranscriptionToolbar.tsx new file mode 100644 index 0000000..41a23cc --- /dev/null +++ b/src/renderer/features/transcription/components/TranscriptionToolbar/TranscriptionToolbar.tsx @@ -0,0 +1,115 @@ +import React, { useState, useRef, useEffect } from 'react'; +import { Search, Check, Copy, Save } from 'lucide-react'; +import './TranscriptionToolbar.css'; +import type { OutputFormat } from '../../../../types'; +import { OUTPUT_FORMATS } from '../../../../config'; + +export interface TranscriptionToolbarProps { + hasText: boolean; + onCopy: () => void; + onSave: (format: OutputFormat) => void; + copySuccess: boolean; + wordCount: number; + charCount: number; + onToggleSearch: () => void; + isSearchActive: boolean; +} + +function TranscriptionToolbar({ + hasText, + onCopy, + onSave, + copySuccess, + wordCount, + charCount, + onToggleSearch, + isSearchActive, +}: TranscriptionToolbarProps): React.JSX.Element { + const [showSaveMenu, setShowSaveMenu] = useState(false); + const saveMenuRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (e: MouseEvent): void => { + if (saveMenuRef.current && !saveMenuRef.current.contains(e.target as Node)) { + setShowSaveMenu(false); + } + }; + + if (showSaveMenu) { + document.addEventListener('mousedown', handleClickOutside); + } + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [showSaveMenu]); + + const handleSaveFormat = (format: OutputFormat): void => { + setShowSaveMenu(false); + onSave(format); + }; + + return ( +
+

Transcription

+
+ {hasText && ( + + {wordCount} words ยท {charCount} chars + + )} +
+ {hasText && ( +
+ + +
+ + {showSaveMenu && ( +
+ {OUTPUT_FORMATS.map((format) => ( + + ))} +
+ )} +
+
+ )} +
+ ); +} + +export { TranscriptionToolbar }; diff --git a/src/renderer/features/transcription/components/TranscriptionToolbar/index.ts b/src/renderer/features/transcription/components/TranscriptionToolbar/index.ts new file mode 100644 index 0000000..b680b3e --- /dev/null +++ b/src/renderer/features/transcription/components/TranscriptionToolbar/index.ts @@ -0,0 +1,2 @@ +export { TranscriptionToolbar } from './TranscriptionToolbar'; +export type { TranscriptionToolbarProps } from './TranscriptionToolbar'; diff --git a/src/renderer/features/transcription/components/index.ts b/src/renderer/features/transcription/components/index.ts index 7335718..9d79be4 100644 --- a/src/renderer/features/transcription/components/index.ts +++ b/src/renderer/features/transcription/components/index.ts @@ -3,3 +3,7 @@ export type { FileDropZoneProps } from './FileDropZone'; export { OutputDisplay } from './OutputDisplay'; export type { OutputDisplayProps } from './OutputDisplay'; + +export * from './TranscriptionToolbar'; +export * from './TranscriptionSearch'; +export * from './TranscriptionContent'; diff --git a/src/renderer/features/transcription/hooks/useTranscription.ts b/src/renderer/features/transcription/hooks/useTranscription.ts index 9cf93d1..dc577ed 100644 --- a/src/renderer/features/transcription/hooks/useTranscription.ts +++ b/src/renderer/features/transcription/hooks/useTranscription.ts @@ -7,6 +7,16 @@ import type { OutputFormat, } from '../../../types'; import { APP_CONFIG } from '../../../config'; +import { + openFileDialog, + getFileInfo, + startTranscription, + cancelTranscription, + onTranscriptionProgress, + saveFile, +} from '../../../services/electronAPI'; +import { logger } from '../../../services/logger'; +import { sanitizePath } from '../../../../shared/utils'; interface UseTranscriptionOptions { onHistoryAdd?: (item: HistoryItem) => void; @@ -75,13 +85,11 @@ export function useTranscription(options: UseTranscriptionOptions = {}): UseTran ); useEffect(() => { - const unsubscribe = window.electronAPI?.onTranscriptionProgress( - (data: TranscriptionProgress) => { - setProgress(data); - } - ); + const unsubscribe = onTranscriptionProgress((data: TranscriptionProgress) => { + setProgress(data); + }); return () => { - unsubscribe?.(); + unsubscribe(); }; }, []); @@ -99,14 +107,21 @@ export function useTranscription(options: UseTranscriptionOptions = {}): UseTran setError(null); setProgress({ percent: 0, status: '' }); setTranscriptionStartTime(null); + + logger.info('File selected', { + name: file.name, + path: sanitizePath(file.path), + + size: file.size, + }); }, [clearProgressMessageTimeout] ); const handleFileSelectFromMenu = useCallback(async (): Promise => { - const filePath = await window.electronAPI?.openFile(); + const filePath = await openFileDialog(); if (filePath) { - const fileInfo = await window.electronAPI?.getFileInfo(filePath); + const fileInfo = await getFileInfo(filePath); if (fileInfo) { handleFileSelect(fileInfo); } @@ -122,10 +137,16 @@ export function useTranscription(options: UseTranscriptionOptions = {}): UseTran setProgress({ percent: 0, status: 'Starting transcription...' }); setTranscriptionStartTime(Date.now()); + logger.info('Starting transcription', { + file: sanitizePath(selectedFile.path), + model: settings.model, + language: settings.language, + }); + const startTime = Date.now(); try { - const result = await window.electronAPI?.startTranscription({ + const result = await startTranscription({ filePath: selectedFile.path, model: settings.model, language: settings.language, @@ -136,18 +157,34 @@ export function useTranscription(options: UseTranscriptionOptions = {}): UseTran throw new Error('No response from transcription service'); } + if (result.error) { + throw new Error(result.error); + } + + if (!result.success) { + throw new Error('Transcription failed with unknown error'); + } + if (result.cancelled) { setProgress({ percent: 0, status: 'Cancelled' }); + logger.info('Transcription cancelled by service'); return; } if (!result.text) { - throw new Error('Transcription produced no output'); + throw new Error( + 'Transcription produced no output. The file may be silent or contain no audio stream.' + ); } setTranscription(result.text); setProgress({ percent: 100, status: 'Complete!' }); + logger.info('Transcription complete', { + durationMs: Date.now() - startTime, + length: result.text.length, + }); + const historyItem: HistoryItem = { id: Date.now(), fileName: selectedFile.name, @@ -162,8 +199,16 @@ export function useTranscription(options: UseTranscriptionOptions = {}): UseTran onHistoryAdd?.(historyItem); scheduleProgressReset(APP_CONFIG.TRANSCRIPTION_COMPLETE_MESSAGE_DURATION); } catch (err) { - const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred'; - setError(errorMessage); + const fullError = err instanceof Error ? err.message : 'Unknown error occurred'; + + const uiError = + fullError.includes('FFmpeg conversion failed') || + fullError.includes('whisper process exited') + ? 'Transcription produced no output. The file may be invalid or missing audio. (See Debug Logs for details)' + : fullError; + + setError(uiError); + logger.error('Transcription failed', { error: err, message: fullError }); setProgress({ percent: 0, status: '' }); setTranscriptionStartTime(null); clearProgressMessageTimeout(); @@ -173,10 +218,11 @@ export function useTranscription(options: UseTranscriptionOptions = {}): UseTran }, [selectedFile, settings, onHistoryAdd, scheduleProgressReset, clearProgressMessageTimeout]); const handleCancel = useCallback(async (): Promise => { - await window.electronAPI?.cancelTranscription(); + await cancelTranscription(); clearProgressMessageTimeout(); setIsTranscribing(false); setProgress({ percent: 0, status: 'Cancelled' }); + logger.warn('Transcription cancelled by user'); setTranscriptionStartTime(null); scheduleProgressReset(APP_CONFIG.TRANSCRIPTION_COMPLETE_MESSAGE_DURATION); }, [clearProgressMessageTimeout, scheduleProgressReset]); @@ -218,7 +264,7 @@ export function useTranscription(options: UseTranscriptionOptions = {}): UseTran // For docx, pdf, md formats, the main process will handle the conversion - const result = await window.electronAPI?.saveFile({ + const result = await saveFile({ defaultName: `${fileName}.${format}`, content, format, @@ -226,9 +272,11 @@ export function useTranscription(options: UseTranscriptionOptions = {}): UseTran if (result?.success && result.filePath) { setProgress({ percent: 100, status: `Saved to ${result.filePath}` }); + logger.info('File saved', { path: sanitizePath(result.filePath), format }); scheduleProgressReset(APP_CONFIG.SAVE_SUCCESS_MESSAGE_DURATION); } else if (result?.error) { setError(`Failed to save: ${result.error}`); + logger.error('Failed to save file', { error: result.error, format }); } }, [transcription, selectedFile, scheduleProgressReset] @@ -240,6 +288,9 @@ export function useTranscription(options: UseTranscriptionOptions = {}): UseTran const success = await copyToClipboard(transcription); if (!success) { setError('Failed to copy to clipboard'); + logger.error('Failed to copy transcription to clipboard'); + } else { + logger.info('Copied transcription to clipboard'); } return success; }, diff --git a/src/renderer/hooks/index.ts b/src/renderer/hooks/index.ts index 63bb717..6fe7f3f 100644 --- a/src/renderer/hooks/index.ts +++ b/src/renderer/hooks/index.ts @@ -6,3 +6,9 @@ export type { ElectronMenuHandlers } from './useElectronMenu'; export { useCopyToClipboard } from './useCopyToClipboard'; export type { UseCopyToClipboardReturn } from './useCopyToClipboard'; + +export { useFFmpegStatus } from './useFFmpegStatus'; +export type { UseFFmpegStatusReturn } from './useFFmpegStatus'; + +export { useDebugLogs } from './useDebugLogs'; +export type { UseDebugLogsReturn } from './useDebugLogs'; diff --git a/src/renderer/hooks/useDebugLogs.ts b/src/renderer/hooks/useDebugLogs.ts new file mode 100644 index 0000000..f47f1a3 --- /dev/null +++ b/src/renderer/hooks/useDebugLogs.ts @@ -0,0 +1,121 @@ +import { useState, useCallback, useEffect } from 'react'; +import { logger, type LogEntry } from '../services/logger'; +import { useCopyToClipboard } from './useCopyToClipboard'; +import { getAppInfo } from '../services/electronAPI'; + +interface UseDebugLogsReturn { + logs: LogEntry[]; + isOpen: boolean; + openModal: () => void; + closeModal: () => void; + copyLogs: () => Promise; + copyLogsWithSystemInfo: () => Promise; + clearLogs: () => void; + refreshLogs: () => void; +} + +function formatLogEntryForExport(entry: LogEntry): string { + const timestamp = entry.timestamp.toISOString(); + const level = entry.level.toUpperCase().padEnd(5); + const data = entry.data !== undefined ? ` | ${JSON.stringify(entry.data)}` : ''; + return `[${timestamp}] [${level}] ${entry.message}${data}`; +} + +function formatLogsForExport(logs: LogEntry[]): string { + if (logs.length === 0) { + return 'No logs captured.'; + } + return logs.map(formatLogEntryForExport).join('\n'); +} + +function getSystemInfo(osVersion?: string): string { + const info: string[] = [ + '## System Information', + '', + `- **Platform**: ${navigator.platform}`, + `- **OS Version**: ${osVersion || 'Unknown'}`, + `- **User Agent**: ${navigator.userAgent}`, + `- **Language**: ${navigator.language}`, + `- **Online**: ${navigator.onLine}`, + `- **Timestamp**: ${new Date().toISOString()}`, + ]; + + if ('memory' in performance) { + const memory = ( + performance as { + memory: { usedJSHeapSize: number; totalJSHeapSize: number; jsHeapSizeLimit: number }; + } + ).memory; + info.push(`- **JS Heap Used**: ${Math.round(memory.usedJSHeapSize / 1024 / 1024)} MB`); + info.push(`- **JS Heap Total**: ${Math.round(memory.totalJSHeapSize / 1024 / 1024)} MB`); + } + + return info.join('\n'); +} + +function useDebugLogs(): UseDebugLogsReturn { + const [logs, setLogs] = useState([]); + const [isOpen, setIsOpen] = useState(false); + const { copyToClipboard } = useCopyToClipboard(); + + const refreshLogs = useCallback(() => { + setLogs(logger.getLogs()); + }, []); + + const openModal = useCallback(() => { + refreshLogs(); + setIsOpen(true); + }, [refreshLogs]); + + const closeModal = useCallback(() => { + setIsOpen(false); + }, []); + + const clearLogs = useCallback(() => { + logger.clearLogs(); + setLogs([]); + }, []); + + const copyLogs = useCallback(async (): Promise => { + const formattedLogs = formatLogsForExport(logs); + const content = ['## Debug Logs', '', '```', formattedLogs, '```'].join('\n'); + + return copyToClipboard(content); + }, [logs, copyToClipboard]); + + const copyLogsWithSystemInfo = useCallback(async (): Promise => { + let osVersion = 'Unknown'; + try { + const appInfo = await getAppInfo(); + if (appInfo.osVersion) osVersion = appInfo.osVersion; + } catch (error) { + console.error('Failed to get app info for system logs:', error); + } + + const formattedLogs = formatLogsForExport(logs); + const systemInfo = getSystemInfo(osVersion); + const content = [systemInfo, '', '## Debug Logs', '', '```', formattedLogs, '```'].join('\n'); + + return copyToClipboard(content); + }, [logs, copyToClipboard]); + + useEffect(() => { + if (isOpen) { + refreshLogs(); + } + }, [isOpen, refreshLogs]); + + return { + logs, + isOpen, + openModal, + closeModal, + copyLogs, + copyLogsWithSystemInfo, + clearLogs, + refreshLogs, + }; +} + +export { useDebugLogs }; +export type { UseDebugLogsReturn }; diff --git a/src/renderer/hooks/useElectronMenu.ts b/src/renderer/hooks/useElectronMenu.ts index 98874ce..09c4cb5 100644 --- a/src/renderer/hooks/useElectronMenu.ts +++ b/src/renderer/hooks/useElectronMenu.ts @@ -1,5 +1,13 @@ import { useEffect, useRef } from 'react'; import type { Unsubscribe } from '../types'; +import { + onMenuOpenFile, + onMenuSaveFile, + onMenuCopyTranscription, + onMenuStartTranscription, + onMenuCancelTranscription, + onMenuToggleHistory, +} from '../services/electronAPI'; export interface ElectronMenuHandlers { onOpenFile?: () => void; @@ -18,46 +26,46 @@ export function useElectronMenu(handlers: ElectronMenuHandlers): void { }); useEffect(() => { - const unsubscribers: (Unsubscribe | undefined)[] = []; + const unsubscribers: Unsubscribe[] = []; unsubscribers.push( - window.electronAPI?.onMenuOpenFile(() => { + onMenuOpenFile(() => { handlersRef.current.onOpenFile?.(); }) ); unsubscribers.push( - window.electronAPI?.onMenuSaveFile(() => { + onMenuSaveFile(() => { handlersRef.current.onSaveFile?.(); }) ); unsubscribers.push( - window.electronAPI?.onMenuCopyTranscription(() => { + onMenuCopyTranscription(() => { handlersRef.current.onCopyTranscription?.(); }) ); unsubscribers.push( - window.electronAPI?.onMenuStartTranscription(() => { + onMenuStartTranscription(() => { handlersRef.current.onStartTranscription?.(); }) ); unsubscribers.push( - window.electronAPI?.onMenuCancelTranscription(() => { + onMenuCancelTranscription(() => { handlersRef.current.onCancelTranscription?.(); }) ); unsubscribers.push( - window.electronAPI?.onMenuToggleHistory(() => { + onMenuToggleHistory(() => { handlersRef.current.onToggleHistory?.(); }) ); return () => { - unsubscribers.forEach((unsub) => unsub?.()); + unsubscribers.forEach((unsub) => unsub()); }; }, []); } diff --git a/src/renderer/hooks/useFFmpegStatus.ts b/src/renderer/hooks/useFFmpegStatus.ts new file mode 100644 index 0000000..355072a --- /dev/null +++ b/src/renderer/hooks/useFFmpegStatus.ts @@ -0,0 +1,38 @@ +import { useState, useEffect, useCallback } from 'react'; +import { checkFFmpeg, logger } from '../services'; + +export interface UseFFmpegStatusReturn { + isFFmpegAvailable: boolean | null; + isChecking: boolean; + recheckStatus: () => Promise; +} + +export function useFFmpegStatus(): UseFFmpegStatusReturn { + const [isFFmpegAvailable, setIsFFmpegAvailable] = useState(null); + const [isChecking, setIsChecking] = useState(true); + + const recheckStatus = useCallback(async (): Promise => { + setIsChecking(true); + try { + const available = await checkFFmpeg(); + setIsFFmpegAvailable(available); + return available; + } catch (error) { + logger.error('Failed to check FFmpeg status:', error); + setIsFFmpegAvailable(false); + return false; + } finally { + setIsChecking(false); + } + }, []); + + useEffect(() => { + recheckStatus(); + }, [recheckStatus]); + + return { + isFFmpegAvailable, + isChecking, + recheckStatus, + }; +} diff --git a/src/renderer/main.tsx b/src/renderer/main.tsx index 6efc256..7a58780 100644 --- a/src/renderer/main.tsx +++ b/src/renderer/main.tsx @@ -1,6 +1,6 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; -import App from './App'; +import { App } from './App'; import './index.css'; const rootElement = document.getElementById('root'); diff --git a/src/renderer/services/__tests__/electronAPI.test.ts b/src/renderer/services/__tests__/electronAPI.test.ts index 0f932ca..c634372 100644 --- a/src/renderer/services/__tests__/electronAPI.test.ts +++ b/src/renderer/services/__tests__/electronAPI.test.ts @@ -3,6 +3,7 @@ import { isElectronAvailable, openFileDialog, getFileInfo, + getPathForFile, saveFile, startTranscription, cancelTranscription, @@ -12,8 +13,21 @@ import { deleteModel, onModelDownloadProgress, getGpuStatus, + checkFFmpeg, getAppInfo, getMemoryUsage, + trackEvent, + checkForUpdates, + downloadUpdate, + installUpdate, + onUpdateStatus, + openExternal, + onMenuOpenFile, + onMenuSaveFile, + onMenuCopyTranscription, + onMenuStartTranscription, + onMenuCancelTranscription, + onMenuToggleHistory, } from '@/services'; import type { ElectronAPI } from '@/types/electron'; import { createFullElectronAPIMock } from '@/test/electronAPIMocks'; @@ -37,6 +51,9 @@ describe('electronAPI wrapper', () => { const info = await getFileInfo('/missing'); expect(info).toBeNull(); + const pathForFile = getPathForFile(new File([''], 'test.mp3')); + expect(pathForFile).toBeUndefined(); + const saveRes = await saveFile({ defaultName: 'x.txt', content: 'c', format: 'txt' }); expect(saveRes.success).toBe(false); @@ -71,11 +88,54 @@ describe('electronAPI wrapper', () => { const gpu = await getGpuStatus(); expect(gpu.available).toBe(false); + const ffmpeg = await checkFFmpeg(); + expect(ffmpeg).toBe(false); + const appInfo = await getAppInfo(); expect(appInfo.isDev).toBe(true); const mem = await getMemoryUsage(); expect(mem.heapUsed).toBe(0); + + await expect(trackEvent('test_event', { prop: 'value' })).resolves.toBeUndefined(); + + const updateCheck = await checkForUpdates(); + expect(updateCheck.success).toBe(false); + + const updateDownload = await downloadUpdate(); + expect(updateDownload.success).toBe(false); + + expect(() => installUpdate()).not.toThrow(); + + const unsubscribeUpdate = onUpdateStatus(() => {}); + expect(typeof unsubscribeUpdate).toBe('function'); + unsubscribeUpdate(); + + await expect(openExternal('https://example.com')).resolves.toBeUndefined(); + + const unsubscribeMenuOpen = onMenuOpenFile(() => {}); + expect(typeof unsubscribeMenuOpen).toBe('function'); + unsubscribeMenuOpen(); + + const unsubscribeMenuSave = onMenuSaveFile(() => {}); + expect(typeof unsubscribeMenuSave).toBe('function'); + unsubscribeMenuSave(); + + const unsubscribeMenuCopy = onMenuCopyTranscription(() => {}); + expect(typeof unsubscribeMenuCopy).toBe('function'); + unsubscribeMenuCopy(); + + const unsubscribeMenuStart = onMenuStartTranscription(() => {}); + expect(typeof unsubscribeMenuStart).toBe('function'); + unsubscribeMenuStart(); + + const unsubscribeMenuCancel = onMenuCancelTranscription(() => {}); + expect(typeof unsubscribeMenuCancel).toBe('function'); + unsubscribeMenuCancel(); + + const unsubscribeMenuHistory = onMenuToggleHistory(() => {}); + expect(typeof unsubscribeMenuHistory).toBe('function'); + unsubscribeMenuHistory(); }); it('delegates to underlying window.electronAPI when available', async () => { @@ -88,6 +148,10 @@ describe('electronAPI wrapper', () => { await getFileInfo('/path/file.mp3'); expect(api.getFileInfo).toHaveBeenCalledWith('/path/file.mp3'); + const testFile = new File([''], 'test.mp3'); + getPathForFile(testFile); + expect(api.getPathForFile).toHaveBeenCalledWith(testFile); + await saveFile({ defaultName: 'x.txt', content: 'c', format: 'txt' }); expect(api.saveFile).toHaveBeenCalled(); @@ -120,10 +184,49 @@ describe('electronAPI wrapper', () => { await getGpuStatus(); expect(api.getGpuStatus).toHaveBeenCalled(); + await checkFFmpeg(); + expect(api.checkFFmpeg).toHaveBeenCalled(); + await getAppInfo(); expect(api.getAppInfo).toHaveBeenCalled(); await getMemoryUsage(); expect(api.getMemoryUsage).toHaveBeenCalled(); + + await trackEvent('test_event', { prop: 'value' }); + expect(api.trackEvent).toHaveBeenCalledWith('test_event', { prop: 'value' }); + + await checkForUpdates(); + expect(api.checkForUpdates).toHaveBeenCalled(); + + await downloadUpdate(); + expect(api.downloadUpdate).toHaveBeenCalled(); + + installUpdate(); + expect(api.installUpdate).toHaveBeenCalled(); + + onUpdateStatus(() => {}); + expect(api.onUpdateStatus).toHaveBeenCalled(); + + await openExternal('https://example.com'); + expect(api.openExternal).toHaveBeenCalledWith('https://example.com'); + + onMenuOpenFile(() => {}); + expect(api.onMenuOpenFile).toHaveBeenCalled(); + + onMenuSaveFile(() => {}); + expect(api.onMenuSaveFile).toHaveBeenCalled(); + + onMenuCopyTranscription(() => {}); + expect(api.onMenuCopyTranscription).toHaveBeenCalled(); + + onMenuStartTranscription(() => {}); + expect(api.onMenuStartTranscription).toHaveBeenCalled(); + + onMenuCancelTranscription(() => {}); + expect(api.onMenuCancelTranscription).toHaveBeenCalled(); + + onMenuToggleHistory(() => {}); + expect(api.onMenuToggleHistory).toHaveBeenCalled(); }); }); diff --git a/src/renderer/services/electronAPI.ts b/src/renderer/services/electronAPI.ts index d0fab5b..9b495b9 100644 --- a/src/renderer/services/electronAPI.ts +++ b/src/renderer/services/electronAPI.ts @@ -28,6 +28,10 @@ export async function getFileInfo(filePath: string): Promise { const result = await window.electronAPI?.saveFile(options); return result ?? { success: false, error: 'Electron API not available' }; @@ -123,3 +127,27 @@ export function onUpdateStatus(callback: (status: UpdateStatus) => void): Unsubs export async function openExternal(url: string): Promise { await window.electronAPI?.openExternal(url); } + +export function onMenuOpenFile(callback: () => void): Unsubscribe { + return window.electronAPI?.onMenuOpenFile(callback) ?? (() => {}); +} + +export function onMenuSaveFile(callback: () => void): Unsubscribe { + return window.electronAPI?.onMenuSaveFile(callback) ?? (() => {}); +} + +export function onMenuCopyTranscription(callback: () => void): Unsubscribe { + return window.electronAPI?.onMenuCopyTranscription(callback) ?? (() => {}); +} + +export function onMenuStartTranscription(callback: () => void): Unsubscribe { + return window.electronAPI?.onMenuStartTranscription(callback) ?? (() => {}); +} + +export function onMenuCancelTranscription(callback: () => void): Unsubscribe { + return window.electronAPI?.onMenuCancelTranscription(callback) ?? (() => {}); +} + +export function onMenuToggleHistory(callback: () => void): Unsubscribe { + return window.electronAPI?.onMenuToggleHistory(callback) ?? (() => {}); +} diff --git a/src/renderer/services/index.ts b/src/renderer/services/index.ts index 08c5863..3ef7346 100644 --- a/src/renderer/services/index.ts +++ b/src/renderer/services/index.ts @@ -1 +1,3 @@ export * from './electronAPI'; +export { logger } from './logger'; +export type { LogLevel, LogEntry } from './logger'; diff --git a/src/renderer/services/logger.ts b/src/renderer/services/logger.ts new file mode 100644 index 0000000..ee91b2b --- /dev/null +++ b/src/renderer/services/logger.ts @@ -0,0 +1,104 @@ +type LogLevel = 'debug' | 'info' | 'warn' | 'error'; + +interface LogEntry { + level: LogLevel; + message: string; + data?: unknown; + timestamp: Date; +} + +const isDev = process.env.NODE_ENV === 'development'; +const MAX_LOG_ENTRIES = 500; +const logBuffer: LogEntry[] = []; + +function formatMessage(level: LogLevel, message: string, data?: unknown): string { + const timestamp = new Date().toISOString(); + const prefix = `[${timestamp}] [${level.toUpperCase()}]`; + if (data !== undefined) { + try { + return `${prefix} ${message} ${JSON.stringify(data)}`; + } catch { + return `${prefix} ${message} [Data]`; + } + } + return `${prefix} ${message}`; +} + +function storeLogEntry(level: LogLevel, message: string, data?: unknown): void { + const entry: LogEntry = { + level, + message, + data, + timestamp: new Date(), + }; + + logBuffer.push(entry); + + if (logBuffer.length > MAX_LOG_ENTRIES) { + logBuffer.shift(); + } +} + +function log(level: LogLevel, message: string, data?: unknown): void { + storeLogEntry(level, message, data); + + if (!isDev && level !== 'error') return; + + const formattedMessage = formatMessage(level, message, data); + + switch (level) { + case 'debug': + if (data !== undefined) { + console.debug(formattedMessage, data); + } else { + console.debug(formattedMessage); + } + break; + case 'info': + if (data !== undefined) { + console.info(formattedMessage, data); + } else { + console.info(formattedMessage); + } + break; + case 'warn': + if (data !== undefined) { + console.warn(formattedMessage, data); + } else { + console.warn(formattedMessage); + } + break; + case 'error': + if (data !== undefined) { + console.error(formattedMessage, data); + } else { + console.error(formattedMessage); + } + break; + } +} + +function getLogs(): LogEntry[] { + return [...logBuffer]; +} + +function clearLogs(): void { + logBuffer.length = 0; +} + +function getLogCount(): number { + return logBuffer.length; +} + +export const logger = { + debug: (message: string, data?: unknown): void => log('debug', message, data), + info: (message: string, data?: unknown): void => log('info', message, data), + warn: (message: string, data?: unknown): void => log('warn', message, data), + error: (message: string, data?: unknown): void => log('error', message, data), + isEnabled: (): boolean => isDev, + getLogs, + clearLogs, + getLogCount, +}; + +export type { LogEntry, LogLevel }; diff --git a/src/renderer/test/setup.ts b/src/renderer/test/setup.ts index 9e70601..dcbb7b8 100644 --- a/src/renderer/test/setup.ts +++ b/src/renderer/test/setup.ts @@ -68,3 +68,13 @@ vi.stubGlobal('localStorage', { }); }), }); + +vi.mock('../services/logger', () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + isEnabled: vi.fn(() => true), + }, +})); diff --git a/src/renderer/utils/__tests__/storage.test.ts b/src/renderer/utils/__tests__/storage.test.ts index 46c8b32..85aac0c 100644 --- a/src/renderer/utils/__tests__/storage.test.ts +++ b/src/renderer/utils/__tests__/storage.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi } from 'vitest'; +import { logger } from '../../services/logger'; import { getStorageItem, setStorageItem, @@ -152,7 +153,6 @@ describe('storage', () => { describe('error handling', () => { it('should handle localStorage.setItem failure for setStorageItem', () => { const originalSetItem = localStorage.setItem.bind(localStorage); - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); localStorage.setItem = vi.fn().mockImplementation(() => { throw new Error('Storage quota exceeded'); @@ -161,15 +161,13 @@ describe('storage', () => { const result = setStorageItem('whisperdesk_theme' as StorageKey, { data: 'test' }); expect(result).toBe(false); - expect(consoleSpy).toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalled(); localStorage.setItem = originalSetItem; - consoleSpy.mockRestore(); }); it('should handle localStorage.setItem failure for setStorageString', () => { const originalSetItem = localStorage.setItem.bind(localStorage); - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); localStorage.setItem = vi.fn().mockImplementation(() => { throw new Error('Storage quota exceeded'); @@ -178,10 +176,9 @@ describe('storage', () => { const result = setStorageString('whisperdesk_theme' as StorageKey, 'test'); expect(result).toBe(false); - expect(consoleSpy).toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalled(); localStorage.setItem = originalSetItem; - consoleSpy.mockRestore(); }); it('should handle localStorage.getItem failure for getStorageItem', () => { diff --git a/src/renderer/utils/storage.ts b/src/renderer/utils/storage.ts index 02887ed..a820955 100644 --- a/src/renderer/utils/storage.ts +++ b/src/renderer/utils/storage.ts @@ -1,3 +1,5 @@ +import { logger } from '../services'; + const STORAGE_KEYS = { HISTORY: 'whisperdesk_history', THEME: 'whisperdesk_theme', @@ -23,7 +25,7 @@ export function setStorageItem(key: StorageKey, value: T): boolean { localStorage.setItem(key, JSON.stringify(value)); return true; } catch (e) { - console.error(`Failed to save to localStorage [${key}]:`, e); + logger.error(`Failed to save to localStorage [${key}]:`, e); return false; } } @@ -42,7 +44,7 @@ export function setStorageString(key: StorageKey, value: string): boolean { localStorage.setItem(key, value); return true; } catch (e) { - console.error(`Failed to save to localStorage [${key}]:`, e); + logger.error(`Failed to save to localStorage [${key}]:`, e); return false; } } diff --git a/src/shared/types.ts b/src/shared/types.ts index 1890346..fae40c0 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -114,6 +114,7 @@ export interface AppInfo { isDev: boolean; version: string; platform: NodeJS.Platform; + osVersion?: string; } export interface MemoryUsage { diff --git a/src/shared/utils.ts b/src/shared/utils.ts new file mode 100644 index 0000000..b00994c --- /dev/null +++ b/src/shared/utils.ts @@ -0,0 +1,6 @@ +export function sanitizePath(filePath: string): string { + if (!filePath) return ''; + + const parts = filePath.split(/[/\\]/); + return parts.pop() || ''; +}