Compare commits

..

34 Commits

Author SHA1 Message Date
ee387d9637 feat: add extension packing script and update build script
All checks were successful
Deploy to Production / deploy (push) Successful in 14s
2026-03-03 11:03:43 +00:00
f2203e896e chore: bump version to 0.6.16
All checks were successful
Deploy to Production / deploy (push) Successful in 13s
2026-03-03 10:03:44 +00:00
cb8d3d01ec fix: update extension link to Chrome Web Store 2026-03-03 10:03:41 +00:00
5b31171964 0.6.15
All checks were successful
Deploy to Production / deploy (push) Successful in 13s
2026-03-03 00:02:34 +00:00
e98761a18e feat: make header title a link to home page 2026-03-03 00:02:17 +00:00
bc8168e724 0.6.14
All checks were successful
Deploy to Production / deploy (push) Successful in 14s
2026-03-02 23:59:04 +00:00
dcde3b0799 feat: add ESC key support to close all modals and fullscreen modes 2026-03-02 23:58:50 +00:00
60fc774586 0.6.13
All checks were successful
Deploy to Production / deploy (push) Successful in 14s
2026-03-02 23:37:12 +00:00
d65c0d0357 docs: replace problematic emoji in README.md with safer alternative 2026-03-02 23:36:57 +00:00
02736ecc70 0.6.12
All checks were successful
Deploy to Production / deploy (push) Successful in 14s
2026-03-02 23:32:21 +00:00
7b1d19ca7a docs: fix corrupted emoji in README.md 2026-03-02 23:32:12 +00:00
4846d0e61c 0.6.11
All checks were successful
Deploy to Production / deploy (push) Successful in 13s
2026-03-02 23:26:45 +00:00
3155e12b84 feat: display README.md on home page using marked 2026-03-02 23:26:36 +00:00
b8bbe84aa9 docs: add descriptions for all new tools (QR Gen, QR Scanner, URL Cleaner) to README 2026-03-02 23:24:03 +00:00
74984caf9e 0.6.10
All checks were successful
Deploy to Production / deploy (push) Successful in 13s
2026-02-28 18:07:22 +00:00
c8b799b078 fix: mirror background canvas for front camera in fullscreen mode 2026-02-28 18:07:10 +00:00
f3a4c1af05 0.6.9
All checks were successful
Deploy to Production / deploy (push) Successful in 14s
2026-02-28 18:05:14 +00:00
616f615d7c fix: improve front camera detection on macOS by checking video track label 2026-02-28 18:04:28 +00:00
4d572b55ca 0.6.8
All checks were successful
Deploy to Production / deploy (push) Successful in 13s
2026-02-28 17:51:40 +00:00
9822cab93e feat: restore QR code detection overlay in custom QrScanner 2026-02-28 17:50:29 +00:00
9409bd3e21 0.6.7
All checks were successful
Deploy to Production / deploy (push) Successful in 14s
2026-02-28 17:46:21 +00:00
346ded460a chore: remove wasm file from repo and add to gitignore 2026-02-28 17:45:21 +00:00
170539a62f 0.6.6
All checks were successful
Deploy to Production / deploy (push) Successful in 13s
2026-02-28 17:41:06 +00:00
cfc1785863 chore: cleanup unused code in QrScanner and remove vue-qrcode-reader dependency 2026-02-28 17:40:42 +00:00
712b1238a5 0.6.5
All checks were successful
Deploy to Production / deploy (push) Successful in 14s
2026-02-28 17:34:35 +00:00
3732d365dd refactor: replace vue-qrcode-reader with custom native QR scanner using local BarcodeDetector polyfill 2026-02-28 17:31:04 +00:00
34fd8bb2b3 0.6.4
All checks were successful
Deploy to Production / deploy (push) Successful in 14s
2026-02-28 17:21:46 +00:00
90dc663393 fix: use local zxing-wasm file to resolve CSP issues in QrScanner 2026-02-28 17:21:29 +00:00
1c4bdeff0e 0.6.3
All checks were successful
Deploy to Production / deploy (push) Successful in 15s
2026-02-28 17:10:17 +00:00
35c5ff4c51 fix: ensure useFillHeight recalculates when element appears (v-if) 2026-02-28 17:09:42 +00:00
c8f9dfb37e 0.6.2
All checks were successful
Deploy to Production / deploy (push) Successful in 15s
2026-02-28 04:19:41 +00:00
70d7c8873e refactor: centralize UI config and inject CSS variables dynamically 2026-02-28 04:18:53 +00:00
d5d3d37804 refactor: simplify useLocalStorage (remove APP_PREFIX) and finalize QR Code tool 2026-02-28 04:05:07 +00:00
2a1897f68d feat: enhance QR generator with responsive layout, custom focus styles, and download options 2026-02-28 03:50:47 +00:00
24 changed files with 1323 additions and 176 deletions

7
.gitignore vendored
View File

@@ -11,6 +11,7 @@ node_modules
dist dist
dist-ssr dist-ssr
*.local *.local
public/wasm
# Editor directories and files # Editor directories and files
.vscode/* .vscode/*
@@ -26,3 +27,9 @@ dev-dist
extension-release.zip extension-release.zip
*.zip *.zip
tools-app-extension-*.zip tools-app-extension-*.zip
tools-app-extension-*.crx
# Extension keys and builds
*.pem
*.crx
scripts/*.pub

View File

@@ -20,6 +20,26 @@ Monitor and capture your clipboard history in real-time.
- Clears history on demand. - Clears history on demand.
- Privacy-focused: Data is processed locally and never sent to any server. - Privacy-focused: Data is processed locally and never sent to any server.
### 🔗 URL Cleaner
Clean tracking parameters and clutter from URLs.
- **Privacy:** Removes known tracking parameters (UTM, fbclid, gclid, etc.).
- **Bulk Processing:** Clean list of URLs at once.
- **Customizable:** Manage exceptions to keep specific parameters.
- **Smart:** "Direct Clean" mode for quick single-URL cleaning.
### ⬛ QR Generator
Create custom QR codes instantly.
- **Customizable:** Adjustable error correction level and size.
- **Download:** Save as PNG (raster) or SVG (vector) for high-quality printing.
- **Persistent Settings:** Remembers your preferences.
### 📷 QR Scanner
Scan QR codes directly from your camera or device.
- **Privacy First:** Works entirely in the browser using local `BarcodeDetector` API. No images are sent to any server.
- **Multi-Camera:** Support for front and back cameras with easy switching.
- **History:** Keeps a log of scanned codes with options to copy or download as JSON.
- **Responsive:** Fullscreen mode for immersive scanning experience.
--- ---
## Chrome Extension 🧩 ## Chrome Extension 🧩

414
package-lock.json generated
View File

@@ -1,16 +1,19 @@
{ {
"name": "tools-app", "name": "tools-app",
"version": "0.6.1", "version": "0.6.16",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "tools-app", "name": "tools-app",
"version": "0.6.1", "version": "0.6.16",
"hasInstallScript": true,
"dependencies": { "dependencies": {
"barcode-detector": "^3.1.0",
"lucide-vue-next": "^0.575.0", "lucide-vue-next": "^0.575.0",
"marked": "^17.0.3",
"qrcode": "^1.5.4",
"vue": "^3.5.25", "vue": "^3.5.25",
"vue-qrcode-reader": "^5.7.3",
"vue-router": "^5.0.3" "vue-router": "^5.0.3"
}, },
"devDependencies": { "devDependencies": {
@@ -2515,12 +2518,6 @@
"sourcemap-codec": "^1.4.8" "sourcemap-codec": "^1.4.8"
} }
}, },
"node_modules/@types/dom-webcodecs": {
"version": "0.1.18",
"resolved": "https://registry.npmjs.org/@types/dom-webcodecs/-/dom-webcodecs-0.1.18.tgz",
"integrity": "sha512-vAvE8C9DGWR+tkb19xyjk1TSUlJ7RUzzp4a9Anu7mwBT+fpyePWK1UxmH14tMO5zHmrnrRIMg5NutnnDztLxgg==",
"license": "MIT"
},
"node_modules/@types/emscripten": { "node_modules/@types/emscripten": {
"version": "1.41.5", "version": "1.41.5",
"resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.5.tgz", "resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.5.tgz",
@@ -2754,6 +2751,30 @@
"url": "https://github.com/sponsors/epoberezkin" "url": "https://github.com/sponsors/epoberezkin"
} }
}, },
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/array-buffer-byte-length": { "node_modules/array-buffer-byte-length": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz",
@@ -2921,13 +2942,12 @@
} }
}, },
"node_modules/barcode-detector": { "node_modules/barcode-detector": {
"version": "2.2.2", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/barcode-detector/-/barcode-detector-2.2.2.tgz", "resolved": "https://registry.npmjs.org/barcode-detector/-/barcode-detector-3.1.0.tgz",
"integrity": "sha512-JcSekql+EV93evfzF9zBr+Y6aRfkR+QFvgyzbwQ0dbymZXoAI9+WgT7H1E429f+3RKNncHz2CW98VQtaaKpmfQ==", "integrity": "sha512-aQjGxrgsb/WTlw6pHZwFRO6NhFMhwHGEkd0pzV25fBn8dnRA1PA1G7bLeAzvSea646S/96nW5W3jD8wezQZ1vQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/dom-webcodecs": "^0.1.11", "zxing-wasm": "3.0.0"
"zxing-wasm": "1.1.3"
} }
}, },
"node_modules/baseline-browser-mapping": { "node_modules/baseline-browser-mapping": {
@@ -3056,6 +3076,15 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/caniuse-lite": { "node_modules/caniuse-lite": {
"version": "1.0.30001774", "version": "1.0.30001774",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001774.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001774.tgz",
@@ -3092,6 +3121,35 @@
"url": "https://paulmillr.com/funding/" "url": "https://paulmillr.com/funding/"
} }
}, },
"node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT"
},
"node_modules/commander": { "node_modules/commander": {
"version": "2.20.3", "version": "2.20.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
@@ -3254,6 +3312,15 @@
} }
} }
}, },
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/deepmerge": { "node_modules/deepmerge": {
"version": "4.3.1", "version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
@@ -3300,6 +3367,12 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/dunder-proto": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -3338,6 +3411,12 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/entities": { "node_modules/entities": {
"version": "7.0.1", "version": "7.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
@@ -3648,6 +3727,19 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/for-each": { "node_modules/for-each": {
"version": "0.3.5", "version": "0.3.5",
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
@@ -3773,6 +3865,15 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/get-intrinsic": { "node_modules/get-intrinsic": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
@@ -4162,6 +4263,15 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/is-generator-function": { "node_modules/is-generator-function": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz",
@@ -4565,6 +4675,18 @@
"url": "https://github.com/sponsors/antfu" "url": "https://github.com/sponsors/antfu"
} }
}, },
"node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/lodash": { "node_modules/lodash": {
"version": "4.17.23", "version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
@@ -4629,6 +4751,18 @@
"url": "https://github.com/sponsors/sxzz" "url": "https://github.com/sponsors/sxzz"
} }
}, },
"node_modules/marked": {
"version": "17.0.3",
"resolved": "https://registry.npmjs.org/marked/-/marked-17.0.3.tgz",
"integrity": "sha512-jt1v2ObpyOKR8p4XaUJVk3YWRJ5n+i4+rjQopxvV32rSndTJXvIzuUdWWIy/1pFQMkQmvTXawzDNqOH/CUmx6A==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/math-intrinsics": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -4800,6 +4934,42 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/package-json-from-dist": { "node_modules/package-json-from-dist": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
@@ -4807,6 +4977,15 @@
"dev": true, "dev": true,
"license": "BlueOak-1.0.0" "license": "BlueOak-1.0.0"
}, },
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/path-key": { "node_modules/path-key": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@@ -4892,6 +5071,15 @@
"pathe": "^2.0.3" "pathe": "^2.0.3"
} }
}, },
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/possible-typed-array-names": { "node_modules/possible-typed-array-names": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
@@ -4953,6 +5141,23 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/quansync": { "node_modules/quansync": {
"version": "0.2.11", "version": "0.2.11",
"resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz",
@@ -5094,6 +5299,15 @@
"regjsparser": "bin/parser" "regjsparser": "bin/parser"
} }
}, },
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-from-string": { "node_modules/require-from-string": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@@ -5104,6 +5318,12 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/resolve": { "node_modules/resolve": {
"version": "1.22.11", "version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
@@ -5258,12 +5478,6 @@
"integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==", "integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/sdp": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/sdp/-/sdp-3.2.1.tgz",
"integrity": "sha512-lwsAIzOPlH8/7IIjjz3K0zYBk7aBVVcvjMwt3M4fLxpjMYyy7i3I97SLHebgn4YBjirkzfp3RvRDWSKsh/+WFw==",
"license": "MIT"
},
"node_modules/semver": { "node_modules/semver": {
"version": "6.3.1", "version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
@@ -5284,6 +5498,12 @@
"randombytes": "^2.1.0" "randombytes": "^2.1.0"
} }
}, },
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/set-function-length": { "node_modules/set-function-length": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
@@ -5530,6 +5750,20 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/string.prototype.matchall": { "node_modules/string.prototype.matchall": {
"version": "4.0.12", "version": "4.0.12",
"resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz",
@@ -5632,6 +5866,18 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-comments": { "node_modules/strip-comments": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/strip-comments/-/strip-comments-2.0.1.tgz", "resolved": "https://registry.npmjs.org/strip-comments/-/strip-comments-2.0.1.tgz",
@@ -5667,6 +5913,18 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/tagged-tag": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz",
"integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==",
"license": "MIT",
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/temp-dir": { "node_modules/temp-dir": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz", "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz",
@@ -6123,19 +6381,6 @@
} }
} }
}, },
"node_modules/vue-qrcode-reader": {
"version": "5.7.3",
"resolved": "https://registry.npmjs.org/vue-qrcode-reader/-/vue-qrcode-reader-5.7.3.tgz",
"integrity": "sha512-iSGko42FsEvdHyizBMBs/X+HMO9Z5ONDxjW+mQdoraOR5emRNedmjC5SEJdYzGz8ZP5ME3lwB4iHy3S7MOt5Qw==",
"license": "MIT",
"dependencies": {
"barcode-detector": "2.2.2",
"webrtc-adapter": "8.2.3"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/vue-router": { "node_modules/vue-router": {
"version": "5.0.3", "version": "5.0.3",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-5.0.3.tgz", "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-5.0.3.tgz",
@@ -6194,19 +6439,6 @@
"integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/webrtc-adapter": {
"version": "8.2.3",
"resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-8.2.3.tgz",
"integrity": "sha512-gnmRz++suzmvxtp3ehQts6s2JtAGPuDPjA1F3a9ckNpG1kYdYuHWYpazoAnL9FS5/B21tKlhkorbdCXat0+4xQ==",
"license": "BSD-3-Clause",
"dependencies": {
"sdp": "^3.2.0"
},
"engines": {
"node": ">=6.0.0",
"npm": ">=3.10.0"
}
},
"node_modules/whatwg-url": { "node_modules/whatwg-url": {
"version": "7.1.0", "version": "7.1.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz",
@@ -6302,6 +6534,12 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/which-typed-array": { "node_modules/which-typed-array": {
"version": "1.1.20", "version": "1.1.20",
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz",
@@ -6653,6 +6891,26 @@
"workbox-core": "7.4.0" "workbox-core": "7.4.0"
} }
}, },
"node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/yallist": { "node_modules/yallist": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
@@ -6675,13 +6933,67 @@
"url": "https://github.com/sponsors/eemeli" "url": "https://github.com/sponsors/eemeli"
} }
}, },
"node_modules/zxing-wasm": { "node_modules/yargs": {
"version": "1.1.3", "version": "15.4.1",
"resolved": "https://registry.npmjs.org/zxing-wasm/-/zxing-wasm-1.1.3.tgz", "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-MYm9k/5YVs4ZOTIFwlRjfFKD0crhefgbnt1+6TEpmKUDFp3E2uwqGSKwQOd2hOIsta/7Usq4hnpNRYTLoljnfA==", "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/emscripten": "^1.39.10" "cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/zxing-wasm": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/zxing-wasm/-/zxing-wasm-3.0.0.tgz",
"integrity": "sha512-s7ASCPKX+QnH7Y83f4Byxmq/vDzYW7B9m6jMP5S30JGfN2A6WAUn6P3vcBmNguDhPLE6ny2fjTooQVyKBXI1qA==",
"license": "MIT",
"dependencies": {
"@types/emscripten": "^1.41.5",
"type-fest": "^5.4.4"
},
"peerDependencies": {
"@types/emscripten": ">=1.39.6"
}
},
"node_modules/zxing-wasm/node_modules/type-fest": {
"version": "5.4.4",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.4.4.tgz",
"integrity": "sha512-JnTrzGu+zPV3aXIUhnyWJj4z/wigMsdYajGLIYakqyOW1nPllzXEJee0QQbHj+CTIQtXGlAjuK0UY+2xTyjVAw==",
"license": "(MIT OR CC0-1.0)",
"dependencies": {
"tagged-tag": "^1.0.0"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
} }
} }
} }

View File

@@ -1,17 +1,21 @@
{ {
"name": "tools-app", "name": "tools-app",
"private": true, "private": true,
"version": "0.6.1", "version": "0.6.17",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview" "preview": "vite preview",
"pack-extension": "node scripts/pack_crx.js",
"postinstall": "mkdir -p public/wasm && cp node_modules/zxing-wasm/dist/reader/zxing_reader.wasm public/wasm/"
}, },
"dependencies": { "dependencies": {
"barcode-detector": "^3.1.0",
"lucide-vue-next": "^0.575.0", "lucide-vue-next": "^0.575.0",
"marked": "^17.0.3",
"qrcode": "^1.5.4",
"vue": "^3.5.25", "vue": "^3.5.25",
"vue-qrcode-reader": "^5.7.3",
"vue-router": "^5.0.3" "vue-router": "^5.0.3"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -6,9 +6,14 @@ import zipfile
# Configuration # Configuration
SOURCE_DIR = "extension" SOURCE_DIR = "extension"
BUILD_DIR = "dist-extension" BUILD_DIR = "dist-extension"
OUTPUT_ZIP = "extension-release.zip"
MANIFEST_FILE = "manifest.json" MANIFEST_FILE = "manifest.json"
# Read version to create dynamic zip name
with open(os.path.join(SOURCE_DIR, MANIFEST_FILE), "r") as f:
source_manifest = json.load(f)
version = source_manifest.get("version", "unknown")
OUTPUT_ZIP = f"tools-app-extension-v{version}.zip"
# Remove build directory if exists # Remove build directory if exists
if os.path.exists(BUILD_DIR): if os.path.exists(BUILD_DIR):
shutil.rmtree(BUILD_DIR) shutil.rmtree(BUILD_DIR)

110
scripts/pack_crx.js Normal file
View File

@@ -0,0 +1,110 @@
import { execSync } from 'child_process';
import fs from 'fs';
import path from 'path';
function findKey() {
// 1. Check if key provided via CLI
if (process.argv[3]) return process.argv[3];
// 2. Check local project directory (gitignored)
const localKey = path.join(process.cwd(), 'scripts', 'key.pem');
if (fs.existsSync(localKey)) return localKey;
// 3. Check common SSH key locations in ~/.ssh
const sshDir = path.join(process.env.HOME, '.ssh');
const commonKeys = ['id_rsa', 'id_ecdsa', 'id_ed25519'];
for (const keyName of commonKeys) {
const fullPath = path.join(sshDir, keyName);
if (fs.existsSync(fullPath)) return fullPath;
}
return null;
}
const KEY_PATH = findKey();
const INPUT_SOURCE = process.argv[2] || path.join(process.cwd(), 'extension');
const TEMP_DIR = path.join(process.cwd(), 'temp_extension_build');
function pack() {
console.log('📦 Packing extension to CRX...');
if (!KEY_PATH) {
console.error('❌ Error: No private key found.');
console.log('Tried local scripts/key.pem and common ~/.ssh/id_* keys.');
console.log('You can provide a path manually: npm run pack-extension <source> <path/to/key>');
process.exit(1);
}
let extensionDir = INPUT_SOURCE;
let isTemp = false;
try {
// If input is a zip file, unzip it first
if (INPUT_SOURCE.endsWith('.zip')) {
console.log('🤐 Unzipping extension...');
if (fs.existsSync(TEMP_DIR)) fs.rmSync(TEMP_DIR, { recursive: true, force: true });
fs.mkdirSync(TEMP_DIR);
execSync(`unzip -o "${INPUT_SOURCE}" -d "${TEMP_DIR}"`, { stdio: 'pipe' });
extensionDir = TEMP_DIR;
isTemp = true;
}
// Resolve actual extension directory (handle subdirs in zip)
if (!fs.existsSync(path.join(extensionDir, 'manifest.json'))) {
const subdirs = fs.readdirSync(extensionDir).filter(f => fs.statSync(path.join(extensionDir, f)).isDirectory());
if (subdirs.length === 1 && fs.existsSync(path.join(extensionDir, subdirs[0], 'manifest.json'))) {
extensionDir = path.join(extensionDir, subdirs[0]);
console.log(`📂 Found manifest in subdirectory: ${extensionDir}`);
} else {
console.error(`❌ Error: manifest.json not found in ${extensionDir}`);
process.exit(1);
}
}
// Determine output filename
let outputName;
if (INPUT_SOURCE.endsWith('.zip')) {
outputName = path.basename(INPUT_SOURCE).replace('.zip', '.crx');
} else {
const manifest = JSON.parse(fs.readFileSync(path.join(extensionDir, 'manifest.json'), 'utf8'));
outputName = `tools-app-extension-v${manifest.version}.crx`;
}
const outputFull = path.join(process.cwd(), outputName);
// Get version for logging
const manifest = JSON.parse(fs.readFileSync(path.join(extensionDir, 'manifest.json'), 'utf8'));
const version = manifest.version;
console.log(`🔑 Using key: ${KEY_PATH}`);
console.log(`📂 Source: ${extensionDir} (v${version})`);
console.log(`🚀 Running crx3 via npx...`);
// Command: npx -y crx3 --key <key> --crx <output> <dir>
execSync(`npx -y crx3 --key "${KEY_PATH}" --crx "${outputFull}" "${extensionDir}"`, {
stdio: 'inherit'
});
// Cleanup any file that crx3 might have created automatically based on temp dir name
const unintendedFile = extensionDir + '.crx';
if (fs.existsSync(unintendedFile) && unintendedFile !== outputFull) {
fs.unlinkSync(unintendedFile);
}
console.log(`\n✅ Success! Extension packed to: ${outputFull}`);
} catch (error) {
console.error('\n❌ Failed to pack extension.');
if (error.message.includes('algorithm')) {
console.error('⚠️ Note: Chrome CRX format requires RSA or ECDSA (P-256) keys.');
} else {
console.error('Error details:', error.message);
}
process.exit(1);
} finally {
if (isTemp && fs.existsSync(TEMP_DIR)) {
fs.rmSync(TEMP_DIR, { recursive: true, force: true });
}
}
}
pack();

View File

@@ -6,6 +6,7 @@ import Footer from './components/Footer.vue'
import Sidebar from './components/Sidebar.vue' import Sidebar from './components/Sidebar.vue'
import InstallPrompt from './components/InstallPrompt.vue' import InstallPrompt from './components/InstallPrompt.vue'
import ReloadPrompt from './components/ReloadPrompt.vue' import ReloadPrompt from './components/ReloadPrompt.vue'
import { UI_CONFIG } from './config/ui'
const isSidebarOpen = ref(window.innerWidth >= 768) const isSidebarOpen = ref(window.innerWidth >= 768)
const router = useRouter() const router = useRouter()
@@ -31,6 +32,11 @@ const handleResize = () => {
} }
onMounted(() => { onMounted(() => {
// Set global CSS variables from config
document.documentElement.style.setProperty('--header-height', `${UI_CONFIG.headerHeight}px`)
document.documentElement.style.setProperty('--footer-height', `${UI_CONFIG.footerHeight}px`)
document.documentElement.style.setProperty('--page-padding', `${UI_CONFIG.pagePadding}px`)
window.addEventListener('resize', handleResize) window.addEventListener('resize', handleResize)
}) })
@@ -70,14 +76,14 @@ onUnmounted(() => {
padding: 1rem; padding: 1rem;
width: 100%; width: 100%;
max-width: 100%; max-width: 100%;
/* Space for fixed footer on mobile + extra margin (match top padding 2rem + footer height ~40px) */ /* Space for fixed footer on mobile + extra margin */
padding-bottom: calc(1rem + 40px + env(safe-area-inset-bottom)); padding-bottom: calc(1rem + var(--footer-height) + env(safe-area-inset-bottom));
} }
@media (max-width: 640px) { @media (max-width: 640px) {
.main-content { .main-content {
padding: 0.5rem; padding: 0.5rem;
padding-bottom: calc(0.5rem + 40px + env(safe-area-inset-bottom)); padding-bottom: calc(0.5rem + var(--footer-height) + env(safe-area-inset-bottom));
} }
} }

View File

@@ -14,7 +14,8 @@ const version = __APP_VERSION__;
<style scoped> <style scoped>
.app-footer { .app-footer {
width: 100%; width: 100%;
padding: 0.5rem; height: var(--footer-height);
padding: 0 0.5rem;
/* Background handled by glass-panel */ /* Background handled by glass-panel */
border-left: none; border-left: none;
border-right: none; border-right: none;
@@ -24,12 +25,9 @@ const version = __APP_VERSION__;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
z-index: 10; z-index: 10;
/* Remove fixed height to allow content to dictate size */
/* height: 30px; */
position: fixed; position: fixed;
bottom: 0; bottom: 0;
left: 0; left: 0;
padding-bottom: max(0.5rem, env(safe-area-inset-bottom));
} }
@media (min-width: 768px) { @media (min-width: 768px) {

View File

@@ -44,7 +44,7 @@ onMounted(() => {
<line x1="3" y1="18" x2="21" y2="18"></line> <line x1="3" y1="18" x2="21" y2="18"></line>
</svg> </svg>
</button> </button>
<h1 class="app-title">Tools App</h1> <router-link to="/" class="app-title">Tools App</router-link>
</div> </div>
<button <button
@@ -65,7 +65,10 @@ onMounted(() => {
/* Remove hardcoded colors and use theme variables */ /* Remove hardcoded colors and use theme variables */
background: var(--header-bg); background: var(--header-bg);
color: var(--text-color); color: var(--text-color);
padding: 1rem; height: var(--header-height);
padding: 0 1rem;
display: flex;
align-items: center;
/* box-shadow handled by glass-panel class */ /* box-shadow handled by glass-panel class */
position: sticky; position: sticky;
top: 0; top: 0;
@@ -77,7 +80,7 @@ onMounted(() => {
} }
.header-content { .header-content {
max-width: 100%; width: 100%;
padding: 0; padding: 0;
margin: 0 auto; margin: 0 auto;
display: flex; display: flex;
@@ -112,10 +115,11 @@ onMounted(() => {
.app-title { .app-title {
margin: 0; margin: 0;
font-size: 1.5rem; font-size: 1.5rem;
font-weight: 600;
background: var(--title-gradient); background: var(--title-gradient);
-webkit-background-clip: text; -webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text; background-clip: text;
color: transparent;
text-decoration: none;
font-weight: bold;
} }
</style> </style>

View File

@@ -51,12 +51,20 @@ const dismissPrompt = () => {
deferredPrompt = null deferredPrompt = null
} }
const handleKeydown = (e) => {
if (e.key === 'Escape' && showInstallPrompt.value) {
dismissPrompt()
}
}
onMounted(() => { onMounted(() => {
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt) window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
window.addEventListener('keydown', handleKeydown)
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt) window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
window.removeEventListener('keydown', handleKeydown)
}) })
</script> </script>

View File

@@ -1,11 +1,99 @@
<script setup> <script setup>
import { ref } from 'vue' import { marked } from 'marked'
import { computed } from 'vue'
import readmeContent from '../../README.md?raw'
const htmlContent = computed(() => {
return marked(readmeContent)
})
</script> </script>
<template> <template>
main <div class="readme-container glass-panel">
<div class="markdown-body" v-html="htmlContent"></div>
</div>
</template> </template>
<style scoped> <style scoped>
.readme-container {
padding: 2rem;
max-width: 800px;
margin: 0 auto;
}
.markdown-body :deep(h1) {
font-size: 2rem;
margin-bottom: 1rem;
color: var(--primary-accent);
}
.markdown-body :deep(h2) {
font-size: 1.5rem;
margin-top: 2rem;
margin-bottom: 1rem;
border-bottom: 1px solid var(--glass-border);
padding-bottom: 0.5rem;
color: var(--text-strong);
}
.markdown-body :deep(h3) {
font-size: 1.25rem;
margin-top: 1.5rem;
margin-bottom: 0.75rem;
color: var(--text-strong);
}
.markdown-body :deep(p) {
margin-bottom: 1rem;
line-height: 1.6;
color: var(--text-color);
}
.markdown-body :deep(ul) {
margin-bottom: 1rem;
padding-left: 1.5rem;
}
.markdown-body :deep(li) {
margin-bottom: 0.5rem;
color: var(--text-color);
}
.markdown-body :deep(a) {
color: var(--primary-accent);
text-decoration: none;
}
.markdown-body :deep(a:hover) {
text-decoration: underline;
}
.markdown-body :deep(code) {
background: rgba(255, 255, 255, 0.1);
padding: 0.2em 0.4em;
border-radius: 4px;
font-family: monospace;
font-size: 0.9em;
}
.markdown-body :deep(pre) {
background: rgba(0, 0, 0, 0.3);
padding: 1rem;
border-radius: 8px;
overflow-x: auto;
margin-bottom: 1rem;
border: 1px solid var(--glass-border);
}
.markdown-body :deep(pre code) {
background: none;
padding: 0;
color: #e6e6e6;
}
.markdown-body :deep(hr) {
border: none;
border-top: 1px solid var(--glass-border);
margin: 2rem 0;
}
</style> </style>

View File

@@ -14,6 +14,7 @@ defineProps({
<router-link to="/clipboard-sniffer" class="nav-item" v-ripple>Clipboard Sniffer</router-link> <router-link to="/clipboard-sniffer" class="nav-item" v-ripple>Clipboard Sniffer</router-link>
<router-link to="/url-cleaner" class="nav-item" v-ripple>URL Cleaner</router-link> <router-link to="/url-cleaner" class="nav-item" v-ripple>URL Cleaner</router-link>
<router-link to="/qr-scanner" class="nav-item" v-ripple>QR Scanner</router-link> <router-link to="/qr-scanner" class="nav-item" v-ripple>QR Scanner</router-link>
<router-link to="/qr-code" class="nav-item" v-ripple>QR Code</router-link>
</nav> </nav>
</aside> </aside>
</template> </template>

View File

@@ -16,7 +16,7 @@ const {
stopListening stopListening
} = useExtension() } = useExtension()
const { height: textareaHeight } = useFillHeight(textareaRef, 120) const { height: textareaHeight } = useFillHeight(textareaRef, 40)
// Watch for clipboard updates from extension // Watch for clipboard updates from extension
watch(lastClipboardText, (newText) => { watch(lastClipboardText, (newText) => {

View File

@@ -142,7 +142,6 @@ const generatePasswords = () => {
class="tool-textarea" class="tool-textarea"
v-model="result" v-model="result"
placeholder="Generated passwords will appear here..." placeholder="Generated passwords will appear here..."
readonly
></textarea> ></textarea>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,299 @@
<script setup>
import { ref, watch, onMounted } from 'vue'
import { Download } from 'lucide-vue-next'
import QRCode from 'qrcode'
import { useFillHeight } from '../../composables/useFillHeight'
import { useLocalStorage } from '../../composables/useLocalStorage'
const text = useLocalStorage('text', '', 'qr-code')
const ecc = useLocalStorage('ecc', 'M', 'qr-code')
const size = useLocalStorage('size', 300, 'qr-code')
const format = useLocalStorage('format', 'png', 'qr-code')
const svgContent = ref('')
const previewRef = ref(null)
const { height: previewHeight } = useFillHeight(previewRef, 40) // 40px extra margin
const generateQR = async () => {
if (!text.value) {
svgContent.value = ''
return
}
try {
// Generate SVG for preview (always sharp)
svgContent.value = await QRCode.toString(text.value, {
type: 'svg',
errorCorrectionLevel: ecc.value,
margin: 1,
// No fixed width, allow scaling via CSS
})
} catch (err) {
console.error('QR Generation failed', err)
svgContent.value = ''
}
}
// Debounce generation slightly to avoid lag on typing
let timeout
watch([text, ecc], () => { // size is not relevant for preview
clearTimeout(timeout)
timeout = setTimeout(generateQR, 300)
})
onMounted(() => {
if (text.value) generateQR()
})
const downloadFile = async () => {
if (!text.value) return
const filename = `qr-code-${Date.now()}.${format.value}`
if (format.value === 'svg') {
// For SVG download, we might want to inject the size if user specifically requested it,
// but usually raw SVG is better.
// If we want to support the "Size" dropdown for SVG download, we can regenerate with specific width.
const svgWithSize = await QRCode.toString(text.value, {
type: 'svg',
errorCorrectionLevel: ecc.value,
margin: 1,
width: size.value
})
const blob = new Blob([svgWithSize], { type: 'image/svg+xml' })
triggerDownload(blob, filename)
} else {
// For raster formats, render to canvas first
try {
const canvas = document.createElement('canvas')
await QRCode.toCanvas(canvas, text.value, {
errorCorrectionLevel: ecc.value,
margin: 1,
width: size.value
})
const mime = `image/${format.value}`
canvas.toBlob((blob) => {
if (blob) triggerDownload(blob, filename)
}, mime)
} catch (err) {
console.error('Download failed', err)
}
}
}
const triggerDownload = (blob, filename) => {
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
</script>
<template>
<div class="tool-container full-width">
<div class="tool-panel">
<div class="panel-header">
<h2 class="tool-title">QR Generator</h2>
</div>
<div class="input-section">
<textarea
v-model="text"
class="tool-textarea"
placeholder="Enter text to generate QR code..."
rows="3"
></textarea>
</div>
<div class="controls-section">
<div class="control-group">
<label>Error Correction</label>
<select v-model="ecc" class="select-input">
<option value="L">Low (7%)</option>
<option value="M">Medium (15%)</option>
<option value="Q">Quartile (25%)</option>
<option value="H">High (30%)</option>
</select>
</div>
<div class="control-group">
<label>Size (px)</label>
<select v-model="size" class="select-input">
<option :value="150">150x150</option>
<option :value="300">300x300</option>
<option :value="500">500x500</option>
<option :value="1000">1000x1000</option>
</select>
</div>
<div class="control-group">
<label>Format</label>
<select v-model="format" class="select-input">
<option value="png">PNG</option>
<option value="jpeg">JPG</option>
<option value="webp">WebP</option>
<option value="svg">SVG</option>
</select>
</div>
</div>
<div class="preview-section" v-if="text" ref="previewRef" :style="{ height: previewHeight }">
<div class="qr-frame" v-html="svgContent"></div>
<div class="actions">
<button class="action-btn" @click="downloadFile">
<Download size="18" />
Download {{ format.toUpperCase() }}
</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.tool-container.full-width {
height: 100%;
display: flex;
flex-direction: column;
}
.tool-panel {
padding: 1rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
height: 100%;
overflow: hidden; /* Prevent scrolling, force fit */
}
.panel-header {
display: flex;
justify-content: center;
align-items: center;
position: relative;
width: 100%;
margin-bottom: 0.5rem;
flex-shrink: 0;
}
.tool-title {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
color: var(--primary-accent);
}
.input-section {
display: flex;
flex-direction: column;
flex-shrink: 0;
}
.tool-textarea {
width: 100%;
padding: 1rem;
border-radius: 8px;
background: rgba(0, 0, 0, 0.2);
border: 1px solid var(--glass-border);
color: var(--text-color);
font-size: 1rem;
resize: vertical;
min-height: 80px;
}
.tool-textarea:focus {
outline: none;
border-color: var(--primary-accent);
box-shadow: 0 0 0 2px rgba(0, 242, 254, 0.1);
}
.controls-section {
display: flex;
gap: 1.5rem;
flex-wrap: wrap;
justify-content: center;
flex-shrink: 0;
}
.control-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.control-group label {
font-size: 0.85rem;
color: var(--text-secondary);
}
.select-input {
padding: 0.5rem;
border-radius: 6px;
background: rgba(0, 0, 0, 0.2);
border: 1px solid var(--glass-border);
color: var(--text-color);
min-width: 120px;
}
.preview-section {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
justify-content: center;
background: rgba(0, 0, 0, 0.1);
border-radius: 12px;
padding: 1rem;
overflow: hidden; /* Prevent overflow if QR is too big */
min-height: 0;
container-type: size;
}
.qr-frame {
width: calc(100cqmin - 4rem);
height: calc(100cqmin - 4rem);
background: white;
padding: 1rem;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
display: flex;
justify-content: center;
align-items: center;
overflow: hidden;
}
.qr-frame :deep(svg) {
display: block;
width: 100%;
height: 100%;
}
.actions {
display: flex;
gap: 1rem;
}
.action-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 1.2rem;
border-radius: 6px;
background: var(--primary-accent);
color: #000;
border: none;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.action-btn:hover {
opacity: 0.9;
transform: translateY(-1px);
}
</style>

View File

@@ -1,7 +1,6 @@
<script setup> <script setup>
import { ref, onMounted, onUnmounted, watch, computed } from 'vue' import { ref, onMounted, onUnmounted, watch, computed } from 'vue'
import { QrcodeStream } from 'vue-qrcode-reader' import { SwitchCamera, Trash2, Copy, Download, X, Maximize2, Minimize2 } from 'lucide-vue-next'
import { SwitchCamera, Trash2, Copy, Download, X } from 'lucide-vue-next'
const error = ref('') const error = ref('')
const facingMode = ref('environment') const facingMode = ref('environment')
@@ -9,22 +8,117 @@ const scannedCodes = ref([])
const hasMultipleCameras = ref(false) const hasMultipleCameras = ref(false)
const isFullscreen = ref(false) const isFullscreen = ref(false)
const videoAspect = ref(1) const videoAspect = ref(1)
const isFront = computed(() => facingMode.value === 'user') const isMirrored = ref(false)
const wrapperRef = ref(null) const wrapperRef = ref(null)
let frontMirrorObserver = null
const bgCanvas = ref(null) const bgCanvas = ref(null)
let bgRafId = null let bgRafId = null
// Native scanner state
const videoRef = ref(null)
let stream = null
let scanRafId = null
let barcodeDetector = null
const overlayCanvas = ref(null)
const paintDetections = (codes) => {
const canvas = overlayCanvas.value
const video = videoRef.value
if (!canvas || !video) return
const ctx = canvas.getContext('2d')
const { width, height } = canvas.getBoundingClientRect()
// Update canvas size if needed (to match CSS size)
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width
canvas.height = height
}
ctx.clearRect(0, 0, width, height)
if (!codes || codes.length === 0) return
const vw = video.videoWidth
const vh = video.videoHeight
if (!vw || !vh) return
// Calculate object-fit: cover scaling
const videoRatio = vw / vh
const canvasRatio = width / height
let drawWidth, drawHeight, startX, startY
if (canvasRatio > videoRatio) {
// Canvas is wider than video (video cropped top/bottom)
drawWidth = width
drawHeight = width / videoRatio
startX = 0
startY = (height - drawHeight) / 2
} else {
// Canvas is taller than video (video cropped left/right)
drawHeight = height
drawWidth = height * videoRatio
startY = 0
startX = (width - drawWidth) / 2
}
const scale = drawWidth / vw
// Canvas is mirrored via CSS if isMirrored is true, so no manual coordinate mirroring needed
// Styles
const styles = getComputedStyle(document.documentElement)
const accent = styles.getPropertyValue('--primary-accent').trim() || '#00f2fe'
ctx.lineWidth = 4
ctx.strokeStyle = accent
ctx.fillStyle = accent
codes.forEach(code => {
const points = code.cornerPoints
if (!points || points.length < 4) return
ctx.beginPath()
const transform = (p) => {
let x = p.x * scale + startX
let y = p.y * scale + startY
return { x, y }
}
const p0 = transform(points[0])
ctx.moveTo(p0.x, p0.y)
for (let i = 1; i < points.length; i++) {
const p = transform(points[i])
ctx.lineTo(p.x, p.y)
}
ctx.closePath()
ctx.stroke()
// Draw corners
points.forEach(p => {
const tp = transform(p)
ctx.beginPath()
ctx.arc(tp.x, tp.y, 4, 0, Math.PI * 2)
ctx.fill()
})
})
}
const updateVideoAspect = () => { const updateVideoAspect = () => {
const videoEl = document.querySelector('.camera-wrapper video') if (videoRef.value && videoRef.value.videoWidth && videoRef.value.videoHeight) {
if (videoEl && videoEl.videoWidth && videoEl.videoHeight) { videoAspect.value = videoRef.value.videoWidth / videoRef.value.videoHeight
videoAspect.value = videoEl.videoWidth / videoEl.videoHeight
} }
} }
const startBackgroundLoop = () => { const startBackgroundLoop = () => {
const draw = () => { const draw = () => {
const videoEl = document.querySelector('.camera-wrapper video') const videoEl = videoRef.value
const canvas = bgCanvas.value const canvas = bgCanvas.value
if (!videoEl || !canvas) { if (!videoEl || !canvas || videoEl.paused || videoEl.ended) {
bgRafId = requestAnimationFrame(draw) bgRafId = requestAnimationFrame(draw)
return return
} }
@@ -56,13 +150,114 @@ const startBackgroundLoop = () => {
bgRafId = requestAnimationFrame(draw) bgRafId = requestAnimationFrame(draw)
} }
// front mirror canvas removed to restore stable behavior
const stopBackgroundLoop = () => { const stopBackgroundLoop = () => {
if (bgRafId) { if (bgRafId) {
cancelAnimationFrame(bgRafId) cancelAnimationFrame(bgRafId)
bgRafId = null bgRafId = null
} }
} }
const initDetector = async () => {
if (!barcodeDetector) {
if ('BarcodeDetector' in window) {
try {
// Formats are optional, but specifying qr_code might be faster
const formats = await window.BarcodeDetector.getSupportedFormats()
if (formats.includes('qr_code')) {
barcodeDetector = new window.BarcodeDetector({ formats: ['qr_code'] })
} else {
barcodeDetector = new window.BarcodeDetector()
}
} catch (e) {
// Fallback
barcodeDetector = new window.BarcodeDetector()
}
} else {
error.value = 'Barcode Detection API not supported'
}
}
}
const startScan = async () => {
stopScan()
error.value = ''
try {
await initDetector()
if (!barcodeDetector) {
error.value = 'Barcode Detector failed to initialize'
return
}
const constraints = {
video: {
facingMode: facingMode.value,
width: { ideal: 1280 },
height: { ideal: 720 }
}
}
stream = await navigator.mediaDevices.getUserMedia(constraints)
// Detect actual facing mode to mirror front camera correctly
const videoTrack = stream.getVideoTracks()[0]
if (videoTrack) {
const settings = videoTrack.getSettings()
if (settings.facingMode) {
isMirrored.value = settings.facingMode === 'user'
} else {
// Fallback: check label for desktop cameras or assume requested mode
const label = videoTrack.label ? videoTrack.label.toLowerCase() : ''
if (label.includes('front') || label.includes('facetime') || label.includes('macbook')) {
isMirrored.value = true
} else {
isMirrored.value = facingMode.value === 'user'
}
}
}
if (videoRef.value) {
videoRef.value.srcObject = stream
// Wait for metadata to play
videoRef.value.onloadedmetadata = () => {
videoRef.value.play().catch(e => console.error('Play error', e))
updateVideoAspect()
detectLoop()
startBackgroundLoop()
}
}
} catch (err) {
onError(err)
}
}
const stopScan = () => {
if (scanRafId) cancelAnimationFrame(scanRafId)
if (stream) {
stream.getTracks().forEach(t => t.stop())
stream = null
}
stopBackgroundLoop()
}
const detectLoop = async () => {
if (!videoRef.value || videoRef.value.paused || videoRef.value.ended) {
scanRafId = requestAnimationFrame(detectLoop)
return
}
try {
const codes = await barcodeDetector.detect(videoRef.value)
paintDetections(codes)
if (codes.length > 0) {
onDetect(codes)
}
} catch (e) {
// console.error('Detection error', e)
}
scanRafId = requestAnimationFrame(detectLoop)
}
const desktopFullscreenStyle = computed(() => { const desktopFullscreenStyle = computed(() => {
if (!isFullscreen.value) return {} if (!isFullscreen.value) return {}
const isDesktop = window.innerWidth >= 768 const isDesktop = window.innerWidth >= 768
@@ -140,61 +335,10 @@ const onDetect = (detectedCodes) => {
processCodes(validCodes) processCodes(validCodes)
} }
const onCameraOn = async (capabilities) => { watch(facingMode, () => {
// Camera is ready startScan()
setTimeout(updateVideoAspect, 100)
setTimeout(startBackgroundLoop, 150)
// Flip is handled via global CSS; no JS flips needed
}
const ensureFrontMirror = () => {
// No-op: mirror is applied via CSS selectors
}
const startFrontMirrorObserver = () => {
// No-op: mirror is applied via CSS selectors
}
const stopFrontMirrorObserver = () => {
// No-op
}
watch(isFront, () => {
// CSS-based; nothing to do
}) })
const paintDetections = (detectedCodes, ctx) => {
try {
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height)
const styles = getComputedStyle(document.documentElement)
const accent = styles.getPropertyValue('--primary-accent').trim() || '#00f2fe'
detectedCodes.forEach(code => {
if (code.format && code.format !== 'qr_code') return
const points = code.cornerPoints || []
if (points.length < 4) return
ctx.beginPath()
ctx.moveTo(points[0].x, points[0].y)
for (let i = 1; i < points.length; i++) {
ctx.lineTo(points[i].x, points[i].y)
}
ctx.closePath()
ctx.lineWidth = 3
ctx.strokeStyle = accent
ctx.shadowColor = accent
ctx.shadowBlur = 8
ctx.stroke()
ctx.shadowBlur = 0
ctx.fillStyle = accent
points.forEach(p => {
ctx.beginPath()
ctx.arc(p.x, p.y, 2.5, 0, Math.PI * 2)
ctx.fill()
})
})
} catch (e) {
// ignore drawing errors
}
}
const onError = (err) => { const onError = (err) => {
if (err.name === 'NotAllowedError') { if (err.name === 'NotAllowedError') {
error.value = 'Camera permission denied' error.value = 'Camera permission denied'
@@ -233,11 +377,18 @@ watch(scannedCodes, (newVal) => {
localStorage.setItem('qr-history', JSON.stringify(newVal)) localStorage.setItem('qr-history', JSON.stringify(newVal))
}, { deep: true }) }, { deep: true })
const handleKeydown = (e) => {
if (e.key === 'Escape' && isFullscreen.value) {
toggleFullscreen()
}
}
onMounted(() => { onMounted(() => {
checkCameras() checkCameras()
loadHistory() loadHistory()
window.addEventListener('resize', updateVideoAspect) window.addEventListener('resize', updateVideoAspect)
window.addEventListener('resize', startBackgroundLoop) window.addEventListener('resize', startBackgroundLoop)
window.addEventListener('keydown', handleKeydown)
watch(isFullscreen, (fs) => { watch(isFullscreen, (fs) => {
if (fs) { if (fs) {
startBackgroundLoop() startBackgroundLoop()
@@ -245,12 +396,15 @@ onMounted(() => {
stopBackgroundLoop() stopBackgroundLoop()
} }
}, { immediate: true }) }, { immediate: true })
startScan()
}) })
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener('resize', updateVideoAspect) window.removeEventListener('resize', updateVideoAspect)
window.removeEventListener('resize', startBackgroundLoop) window.removeEventListener('resize', startBackgroundLoop)
stopBackgroundLoop() window.removeEventListener('keydown', handleKeydown)
stopScan()
}) })
const switchCamera = (event) => { const switchCamera = (event) => {
@@ -324,6 +478,7 @@ const isUrl = (string) => {
v-if="isFullscreen" v-if="isFullscreen"
ref="bgCanvas" ref="bgCanvas"
class="camera-bg" class="camera-bg"
:class="{ 'is-mirrored': isMirrored }"
></canvas> ></canvas>
<button v-if="isFullscreen" class="close-fullscreen-btn" @click="toggleFullscreen"> <button v-if="isFullscreen" class="close-fullscreen-btn" @click="toggleFullscreen">
<X size="24" /> <X size="24" />
@@ -331,20 +486,25 @@ const isUrl = (string) => {
<div <div
class="camera-wrapper" class="camera-wrapper"
:class="{ 'clickable': !isFullscreen, 'is-front': isFront }" :class="{ 'clickable': !isFullscreen, 'is-mirrored': isMirrored }"
:style="desktopFullscreenStyle" :style="desktopFullscreenStyle"
ref="wrapperRef" ref="wrapperRef"
@click="!isFullscreen && toggleFullscreen()" @click="!isFullscreen && toggleFullscreen()"
> >
<QrcodeStream <video
:constraints="{ facingMode }" ref="videoRef"
@detect="onDetect" class="camera-feed"
@error="onError" :class="{ 'is-mirrored': isMirrored }"
@camera-on="onCameraOn" autoplay
:track="paintDetections" playsinline
> muted
></video>
<canvas ref="overlayCanvas" class="scan-overlay-canvas" :class="{ 'is-mirrored': isMirrored }"></canvas>
<div v-if="error" class="error-overlay"> <div v-if="error" class="error-overlay">
<p>{{ error }}</p> <p>{{ error }}</p>
<button @click="startScan" class="retry-btn">Retry</button>
</div> </div>
<button <button
@@ -355,7 +515,6 @@ const isUrl = (string) => {
> >
<SwitchCamera size="24" /> <SwitchCamera size="24" />
</button> </button>
</QrcodeStream>
</div> </div>
<div class="results-section"> <div class="results-section">
@@ -488,6 +647,35 @@ const isUrl = (string) => {
z-index: 0; z-index: 0;
} }
.camera-bg.is-mirrored {
transform: scaleX(-1);
}
.camera-feed {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.camera-feed.is-mirrored {
transform: scaleX(-1);
}
.scan-overlay-canvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 5;
}
.scan-overlay-canvas.is-mirrored {
transform: scaleX(-1);
}
/* front mirror canvas removed */ /* front mirror canvas removed */
.error-overlay { .error-overlay {

View File

@@ -1,5 +1,5 @@
<script setup> <script setup>
import { ref, computed } from 'vue' import { ref, computed, watch, onUnmounted } from 'vue'
import { X, Plus, Trash2, RotateCcw } from 'lucide-vue-next' import { X, Plus, Trash2, RotateCcw } from 'lucide-vue-next'
const props = defineProps({ const props = defineProps({
@@ -10,6 +10,24 @@ const props = defineProps({
const emit = defineEmits(['close', 'update:exceptions']) const emit = defineEmits(['close', 'update:exceptions'])
const handleKeydown = (e) => {
if (e.key === 'Escape') {
emit('close')
}
}
watch(() => props.isOpen, (isOpen) => {
if (isOpen) {
window.addEventListener('keydown', handleKeydown)
} else {
window.removeEventListener('keydown', handleKeydown)
}
})
onUnmounted(() => {
window.removeEventListener('keydown', handleKeydown)
})
const newRule = ref({ const newRule = ref({
domainPattern: '', domainPattern: '',
keepParams: '', keepParams: '',

View File

@@ -5,7 +5,7 @@ export default {
</script> </script>
<script setup> <script setup>
import { ref } from 'vue' import { ref, watch, onUnmounted } from 'vue'
import { Plug, Plus, X } from 'lucide-vue-next' import { Plug, Plus, X } from 'lucide-vue-next'
const props = defineProps({ const props = defineProps({
@@ -13,6 +13,24 @@ const props = defineProps({
}) })
const showModal = ref(false) const showModal = ref(false)
const handleKeydown = (e) => {
if (e.key === 'Escape' && showModal.value) {
showModal.value = false
}
}
watch(showModal, (isOpen) => {
if (isOpen) {
window.addEventListener('keydown', handleKeydown)
} else {
window.removeEventListener('keydown', handleKeydown)
}
})
onUnmounted(() => {
window.removeEventListener('keydown', handleKeydown)
})
</script> </script>
<template> <template>
@@ -38,7 +56,14 @@ const showModal = ref(false)
With the extension, you can capture and process content even when you're working in other apps. With the extension, you can capture and process content even when you're working in other apps.
</p> </p>
<div class="modal-actions"> <div class="modal-actions">
<a href="#" class="btn-neon" @click.prevent>Extension Coming Soon</a> <a
href="https://chromewebstore.google.com/detail/tools-app-extension/bhcpbmfncohogehbhebiffcgjcndnneg"
target="_blank"
rel="noopener noreferrer"
class="btn-neon"
>
Install Extension
</a>
</div> </div>
</div> </div>

View File

@@ -1,6 +1,7 @@
import { onMounted, onUnmounted, ref, nextTick } from 'vue' import { onMounted, onUnmounted, ref, nextTick, watch } from 'vue'
import { UI_CONFIG } from '../config/ui'
export function useFillHeight(elementRef, marginBottom = 20) { export function useFillHeight(elementRef, extraMargin = 0) {
const height = ref('auto') const height = ref('auto')
const updateHeight = () => { const updateHeight = () => {
@@ -8,16 +9,10 @@ export function useFillHeight(elementRef, marginBottom = 20) {
const rect = elementRef.value.getBoundingClientRect() const rect = elementRef.value.getBoundingClientRect()
const windowHeight = window.innerHeight const windowHeight = window.innerHeight
// Calculate available space: window height - element top position - margin bottom
// We also need to account for the footer height if it's fixed or layout related
// The user mentioned "margin bottom from footer".
// If footer is in the flow, we might just want to fill the parent container?
// But user asked for JS resizing.
// Let's assume we want to fill down to (windowHeight - marginBottom). // Calculate available space: window height - element top position - footer height - padding - extra margin
// This assumes the element should stretch to the bottom of the viewport. const bottomOffset = UI_CONFIG.footerHeight + UI_CONFIG.pagePadding + extraMargin
const availableHeight = windowHeight - rect.top - bottomOffset
const availableHeight = windowHeight - rect.top - marginBottom
// Ensure minimum height // Ensure minimum height
if (availableHeight > 100) { if (availableHeight > 100) {
@@ -34,6 +29,13 @@ export function useFillHeight(elementRef, marginBottom = 20) {
// Also update after a short delay to ensure layout is settled (e.g. sidebar transitions) // Also update after a short delay to ensure layout is settled (e.g. sidebar transitions)
setTimeout(updateHeight, 300) setTimeout(updateHeight, 300)
// Watch for element appearing (v-if) or changing
watch(elementRef, () => {
nextTick(updateHeight)
// Additional update for layout stability
setTimeout(updateHeight, 100)
})
}) })
onUnmounted(() => { onUnmounted(() => {

View File

@@ -1,16 +1,20 @@
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
export function useLocalStorage(key, defaultValue) { export function useLocalStorage(key, defaultValue, toolPrefix = '') {
// Construct prefixed key: [toolPrefix-]key
const prefixPart = toolPrefix ? `${toolPrefix}-` : ''
const prefixedKey = `${prefixPart}${key}`
// Initialize state from local storage or default value // Initialize state from local storage or default value
const storedValue = localStorage.getItem(key) const storedValue = localStorage.getItem(prefixedKey)
const data = ref(storedValue ? JSON.parse(storedValue) : defaultValue) const data = ref(storedValue ? JSON.parse(storedValue) : defaultValue)
// Watch for changes and update local storage // Watch for changes and update local storage
watch(data, (newValue) => { watch(data, (newValue) => {
if (newValue === null || newValue === undefined) { if (newValue === null || newValue === undefined) {
localStorage.removeItem(key) localStorage.removeItem(prefixedKey)
} else { } else {
localStorage.setItem(key, JSON.stringify(newValue)) localStorage.setItem(prefixedKey, JSON.stringify(newValue))
} }
}, { deep: true }) }, { deep: true })

6
src/config/ui.js Normal file
View File

@@ -0,0 +1,6 @@
export const UI_CONFIG = {
headerHeight: 64,
footerHeight: 50,
pagePadding: 16 // Single side padding (1rem)
}

View File

@@ -3,6 +3,27 @@ import './style.css'
import App from './App.vue' import App from './App.vue'
import router from './router' import router from './router'
import Ripple from './directives/ripple' import Ripple from './directives/ripple'
import { BarcodeDetector, prepareZXingModule } from 'barcode-detector/ponyfill'
// Configure BarcodeDetector polyfill to use local WASM file
try {
prepareZXingModule({
overrides: {
locateFile: (path, prefix) => {
if (path.endsWith('.wasm')) {
return '/wasm/zxing_reader.wasm'
}
return prefix + path
}
}
})
// Force usage of polyfill to avoid CSP issues and ensure consistent behavior
// Native implementation might fail or be missing in some browsers
window.BarcodeDetector = BarcodeDetector
} catch (e) {
console.error('Failed to initialize BarcodeDetector polyfill', e)
}
const app = createApp(App) const app = createApp(App)

View File

@@ -4,6 +4,7 @@ import Passwords from '../components/tools/Passwords.vue'
import ClipboardSniffer from '../components/tools/ClipboardSniffer.vue' import ClipboardSniffer from '../components/tools/ClipboardSniffer.vue'
import UrlCleaner from '../components/tools/UrlCleaner.vue' import UrlCleaner from '../components/tools/UrlCleaner.vue'
import QrScanner from '../components/tools/QrScanner.vue' import QrScanner from '../components/tools/QrScanner.vue'
import QrCode from '../components/tools/QrCode.vue'
import PrivacyPolicy from '../views/PrivacyPolicy.vue' import PrivacyPolicy from '../views/PrivacyPolicy.vue'
const routes = [ const routes = [
@@ -32,6 +33,11 @@ const routes = [
name: 'QrScanner', name: 'QrScanner',
component: QrScanner component: QrScanner
}, },
{
path: '/qr-code',
name: 'QrCode',
component: QrCode
},
{ {
path: '/extension-privacy-policy', path: '/extension-privacy-policy',
name: 'PrivacyPolicy', name: 'PrivacyPolicy',

View File

@@ -337,3 +337,19 @@ span.ripple {
opacity: 0; opacity: 0;
} }
} }
/* --- Global Input/Select Focus Styles --- */
input:focus,
select:focus,
textarea:focus,
button:focus {
outline: none;
}
/* Custom focus ring for all form elements */
input:focus,
select:focus,
textarea:focus {
border-color: var(--primary-accent);
box-shadow: 0 0 0 1px var(--primary-accent);
}