Compare commits
100 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
65089fb6d2
|
|||
|
ba6514add2
|
|||
|
15a6a143ee
|
|||
|
024bd0f20d
|
|||
|
d395d8754a
|
|||
|
4c1815b3b3
|
|||
|
d82f5ec7c5
|
|||
|
4711102407
|
|||
|
a367d364df
|
|||
|
27fee3ac34
|
|||
|
b5a79f8dbe
|
|||
|
b6eb33f205
|
|||
|
bec6a0ec8f
|
|||
|
52024ad7c6
|
|||
|
805b986a7b
|
|||
|
8fa0c9bd44
|
|||
|
9f9ea255a8
|
|||
|
858e880c38
|
|||
|
f8953984ef
|
|||
|
6be7abfe02
|
|||
|
fdd841177b
|
|||
| 2c286af429 | |||
| 10286c2924 | |||
| b98a14950c | |||
| 18912368a4 | |||
|
b51bc61cf3
|
|||
|
e40762873c
|
|||
|
011db26ec4
|
|||
|
6f95dce55a
|
|||
|
c5293ca9fe
|
|||
|
b1cc8ab5a1
|
|||
|
dff8a2a0ab
|
|||
|
ee387d9637
|
|||
|
f2203e896e
|
|||
|
cb8d3d01ec
|
|||
|
5b31171964
|
|||
|
e98761a18e
|
|||
|
bc8168e724
|
|||
|
dcde3b0799
|
|||
|
60fc774586
|
|||
|
d65c0d0357
|
|||
|
02736ecc70
|
|||
|
7b1d19ca7a
|
|||
|
4846d0e61c
|
|||
|
3155e12b84
|
|||
|
b8bbe84aa9
|
|||
|
74984caf9e
|
|||
|
c8b799b078
|
|||
|
f3a4c1af05
|
|||
|
616f615d7c
|
|||
|
4d572b55ca
|
|||
|
9822cab93e
|
|||
|
9409bd3e21
|
|||
|
346ded460a
|
|||
|
170539a62f
|
|||
|
cfc1785863
|
|||
|
712b1238a5
|
|||
|
3732d365dd
|
|||
|
34fd8bb2b3
|
|||
|
90dc663393
|
|||
|
1c4bdeff0e
|
|||
|
35c5ff4c51
|
|||
|
c8f9dfb37e
|
|||
|
70d7c8873e
|
|||
|
d5d3d37804
|
|||
|
2a1897f68d
|
|||
|
b2e8f23d60
|
|||
|
d2ea9e3fc7
|
|||
|
1765742574
|
|||
|
5b1a50f148
|
|||
|
613604f3c4
|
|||
|
a699b432d7
|
|||
|
839a98a658
|
|||
|
fd23860bcf
|
|||
|
74c0251535
|
|||
|
06b2815dd9
|
|||
|
1346de684c
|
|||
|
cfc9ac73b2
|
|||
|
e095c0190b
|
|||
|
45342d456a
|
|||
|
3ea7f63b83
|
|||
|
8b5705c12f
|
|||
|
a3bc069029
|
|||
|
a5fc242a97
|
|||
|
a0346a64f0
|
|||
|
4c2d423715
|
|||
|
204aeda00c
|
|||
|
7d989be27f
|
|||
|
efe23a99ac
|
|||
|
bebb63c1de
|
|||
|
98d76e3a35
|
|||
|
cc7e80a807
|
|||
|
1f5500f7d7
|
|||
|
d404370027
|
|||
|
8fb3ee1069
|
|||
|
348c78612d
|
|||
|
dc99dce485
|
|||
|
20dc18dd28
|
|||
|
30d67472cb
|
|||
|
ac425d3df2
|
11
.gitignore
vendored
11
.gitignore
vendored
@@ -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/*
|
||||||
@@ -22,3 +23,13 @@ dist-ssr
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
dev-dist
|
||||||
|
extension-release.zip
|
||||||
|
*.zip
|
||||||
|
tools-app-extension-*.zip
|
||||||
|
tools-app-extension-*.crx
|
||||||
|
|
||||||
|
# Extension keys and builds
|
||||||
|
*.pem
|
||||||
|
*.crx
|
||||||
|
scripts/*.pub
|
||||||
|
|||||||
11
.husky/pre-commit
Executable file
11
.husky/pre-commit
Executable file
@@ -0,0 +1,11 @@
|
|||||||
|
# Check if GPG signing is enabled
|
||||||
|
gpg_sign=$(git config --get commit.gpgsign || echo "false")
|
||||||
|
|
||||||
|
if [ "$gpg_sign" != "true" ]; then
|
||||||
|
echo "Error: GPG signing is not enabled or properly configured!"
|
||||||
|
echo "Please enable it globally using: git config --global commit.gpgsign true"
|
||||||
|
echo "Or locally by running: git config commit.gpgsign true"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
npm run lint
|
||||||
95
README.md
95
README.md
@@ -1 +1,94 @@
|
|||||||
# Tools App
|
# Tools App 🛠️
|
||||||
|
|
||||||
|
A collection of useful developer tools in one place. Built with Vue 3 and Vite.
|
||||||
|
|
||||||
|
**Live App:** [https://tools.7u.pl/](https://tools.7u.pl/)
|
||||||
|
|
||||||
|
## Available Tools
|
||||||
|
|
||||||
|
### 🔐 Bulk Passwords Generator
|
||||||
|
Generate strong, secure passwords in bulk.
|
||||||
|
- Customizable character sets (lowercase, uppercase, digits, special characters).
|
||||||
|
- Option to skip similar characters (e.g., `l`, `1`, `I`, `O`, `0`).
|
||||||
|
- Adjustable length and quantity.
|
||||||
|
- Generates thousands of passwords instantly.
|
||||||
|
|
||||||
|
### 📋 Clipboard Sniffer
|
||||||
|
Monitor and capture your clipboard history in real-time.
|
||||||
|
- **Web Mode:** Works when the tab is active and focused.
|
||||||
|
- **Background Mode (with Extension):** Captures clipboard changes even when you are working in other applications or tabs.
|
||||||
|
- Clears history on demand.
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
### 🎵 Tone Generator
|
||||||
|
Generate precise audio frequencies directly in the browser.
|
||||||
|
- **Customizable:** Slide from 20 Hz to 20,000 Hz or type exact values.
|
||||||
|
- **Waveforms:** Choose between Sine, Square, Sawtooth, and Triangle waves.
|
||||||
|
- **Presets:** Quick access to common frequencies like 440 Hz (A4) and 528 Hz.
|
||||||
|
- **Safe:** Smooth volume ramping to protect against audio clicking.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Chrome Extension 🧩
|
||||||
|
|
||||||
|
To unlock the full potential of the **Clipboard Sniffer**, you can install the companion Chrome Extension.
|
||||||
|
|
||||||
|
### Why install the extension?
|
||||||
|
By default, web browsers restrict clipboard access to when the tab is active and focused. The **Tools App Extension** runs in the background, allowing the application to detect clipboard changes even when you are using other apps or browsing different websites.
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- **Background Monitoring:** seamlessly captures copied text while you work.
|
||||||
|
- **Smart Integration:** automatically connects to the Tools App when open.
|
||||||
|
- **Privacy First:** The extension only communicates with the Tools App (`tools.7u.pl` or `localhost`). No data is sent to third-party servers. [Privacy Policy](https://tools.7u.pl/extension-privacy-policy)
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
1. Download the latest release or build from source.
|
||||||
|
2. Open Chrome and navigate to `chrome://extensions/`.
|
||||||
|
3. Enable "Developer mode" in the top right.
|
||||||
|
4. Click "Load unpacked" and select the `extension` folder (or drag and drop the `.zip` file).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Project Setup
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run for Development
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build for Production
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build Extension
|
||||||
|
To create a production-ready zip file for the Chrome Extension:
|
||||||
|
```bash
|
||||||
|
python3 scripts/build_extension.py
|
||||||
|
```
|
||||||
|
This will generate `extension-release.zip` in the project root.
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
if('serviceWorker' in navigator) navigator.serviceWorker.register('/dev-sw.js?dev-sw', { scope: '/', type: 'classic' })
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright 2018 Google Inc. All Rights Reserved.
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
// If the loader is already loaded, just stop.
|
|
||||||
if (!self.define) {
|
|
||||||
let registry = {};
|
|
||||||
|
|
||||||
// Used for `eval` and `importScripts` where we can't get script URL by other means.
|
|
||||||
// In both cases, it's safe to use a global var because those functions are synchronous.
|
|
||||||
let nextDefineUri;
|
|
||||||
|
|
||||||
const singleRequire = (uri, parentUri) => {
|
|
||||||
uri = new URL(uri + ".js", parentUri).href;
|
|
||||||
return registry[uri] || (
|
|
||||||
|
|
||||||
new Promise(resolve => {
|
|
||||||
if ("document" in self) {
|
|
||||||
const script = document.createElement("script");
|
|
||||||
script.src = uri;
|
|
||||||
script.onload = resolve;
|
|
||||||
document.head.appendChild(script);
|
|
||||||
} else {
|
|
||||||
nextDefineUri = uri;
|
|
||||||
importScripts(uri);
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
.then(() => {
|
|
||||||
let promise = registry[uri];
|
|
||||||
if (!promise) {
|
|
||||||
throw new Error(`Module ${uri} didn’t register its module`);
|
|
||||||
}
|
|
||||||
return promise;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
self.define = (depsNames, factory) => {
|
|
||||||
const uri = nextDefineUri || ("document" in self ? document.currentScript.src : "") || location.href;
|
|
||||||
if (registry[uri]) {
|
|
||||||
// Module is already loading or loaded.
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let exports = {};
|
|
||||||
const require = depUri => singleRequire(depUri, uri);
|
|
||||||
const specialDeps = {
|
|
||||||
module: { uri },
|
|
||||||
exports,
|
|
||||||
require
|
|
||||||
};
|
|
||||||
registry[uri] = Promise.all(depsNames.map(
|
|
||||||
depName => specialDeps[depName] || require(depName)
|
|
||||||
)).then(deps => {
|
|
||||||
factory(...deps);
|
|
||||||
return exports;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}
|
|
||||||
define(['./workbox-5a5d9309'], (function (workbox) { 'use strict';
|
|
||||||
|
|
||||||
self.skipWaiting();
|
|
||||||
workbox.clientsClaim();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The precacheAndRoute() method efficiently caches and responds to
|
|
||||||
* requests for URLs in the manifest.
|
|
||||||
* See https://goo.gl/S9QRab
|
|
||||||
*/
|
|
||||||
workbox.precacheAndRoute([{
|
|
||||||
"url": "registerSW.js",
|
|
||||||
"revision": "3ca0b8505b4bec776b69afdba2768812"
|
|
||||||
}, {
|
|
||||||
"url": "index.html",
|
|
||||||
"revision": "0.mj22prstr4"
|
|
||||||
}], {});
|
|
||||||
workbox.cleanupOutdatedCaches();
|
|
||||||
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
|
|
||||||
allowlist: [/^\/$/]
|
|
||||||
}));
|
|
||||||
|
|
||||||
}));
|
|
||||||
File diff suppressed because it is too large
Load Diff
50
eslint.config.js
Normal file
50
eslint.config.js
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import pluginVue from 'eslint-plugin-vue'
|
||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
|
||||||
|
export default [
|
||||||
|
// Base JS recommended rules (all off for now to allow pre-commit)
|
||||||
|
{
|
||||||
|
rules: Object.keys(js.configs.recommended.rules).reduce((acc, rule) => {
|
||||||
|
acc[rule] = 'off';
|
||||||
|
return acc;
|
||||||
|
}, {}),
|
||||||
|
},
|
||||||
|
// Vue essential rules
|
||||||
|
...pluginVue.configs['flat/essential'],
|
||||||
|
{
|
||||||
|
// Apply to all JS and Vue files
|
||||||
|
files: ['**/*.js', '**/*.vue'],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 'latest',
|
||||||
|
sourceType: 'module',
|
||||||
|
globals: {
|
||||||
|
...globals.browser,
|
||||||
|
...globals.node,
|
||||||
|
crypto: 'readonly',
|
||||||
|
BarcodeDetector: 'readonly',
|
||||||
|
chrome: 'readonly',
|
||||||
|
__APP_VERSION__: 'readonly',
|
||||||
|
VITE_APP_VERSION: 'readonly',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'vue/multi-word-component-names': 'off',
|
||||||
|
'no-unused-vars': 'off',
|
||||||
|
'no-undef': 'off',
|
||||||
|
'no-debugger': 'off',
|
||||||
|
'indent': ['error', 2]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Global ignores
|
||||||
|
ignores: [
|
||||||
|
'dist/**',
|
||||||
|
'dev-dist/**',
|
||||||
|
'node_modules/**',
|
||||||
|
'public/**',
|
||||||
|
'scripts/pack_crx.js',
|
||||||
|
'src/app.config.mjs'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
28
extension/README.md
Normal file
28
extension/README.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Tools App Extension 🚀
|
||||||
|
|
||||||
|
This is the companion Chrome Extension for **Tools App** ([tools.7u.pl](https://tools.7u.pl/)).
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
**Tools App Extension** enhances your experience with the Tools App, specifically designed to power the **Clipboard Sniffer** tool. By default, web applications can only read your clipboard when the browser tab is active and focused. This extension runs in the background, allowing you to capture clipboard history seamlessly while you work in other applications or browse different websites.
|
||||||
|
|
||||||
|
## Key Features
|
||||||
|
- **Background Clipboard Monitoring:** Automatically captures copied text even when the Tools App tab is in the background.
|
||||||
|
- **Privacy-First Design:** All clipboard data is processed locally within your browser and sent directly to the open Tools App tab. **No data is ever sent to external servers.**
|
||||||
|
- **Smart Activation:** The extension only activates when you explicitly start the "Clipboard Sniffer" tool in the web app. It stops monitoring immediately when you stop the tool or close the tab.
|
||||||
|
- **Visual Feedback:** The extension icon changes to indicate when it is actively monitoring your clipboard.
|
||||||
|
|
||||||
|
## How it Works
|
||||||
|
1. Open [tools.7u.pl](https://tools.7u.pl/) and navigate to the **Clipboard Sniffer** tool.
|
||||||
|
2. The web app will detect the extension and show a "Connected" status.
|
||||||
|
3. Click "Start Sniffing". The extension will begin monitoring clipboard changes in the background.
|
||||||
|
4. Any text you copy (from any application) will appear in the Tools App list.
|
||||||
|
5. Click "Stop Sniffing" to disable monitoring.
|
||||||
|
|
||||||
|
## Permissions Explained
|
||||||
|
- **Read data from the clipboard (`clipboardRead`):** Essential for detecting when you copy text.
|
||||||
|
- **Run scripts on tools.7u.pl (`scripting`):** Allows the extension to communicate with the Tools App web page to send clipboard updates.
|
||||||
|
- **Storage:** Used to save user preferences (e.g., sound notifications).
|
||||||
|
- **Alarms:** Used to keep the background process alive while sniffing is active (to prevent timeout).
|
||||||
|
|
||||||
|
## Privacy Policy
|
||||||
|
We value your privacy. This extension does not collect, store, or transmit any personal data. Clipboard content is only temporarily read and passed to the local instance of the Tools App running in your browser tab.
|
||||||
@@ -84,8 +84,8 @@ async function refreshOffscreenDocument() {
|
|||||||
chrome.runtime.onMessage.addListener(async (request, sender, sendResponse) => {
|
chrome.runtime.onMessage.addListener(async (request, sender, sendResponse) => {
|
||||||
if (request.action === 'startSniffing') {
|
if (request.action === 'startSniffing') {
|
||||||
if (isSniffing) {
|
if (isSniffing) {
|
||||||
sendResponse({ status: 'already_started' });
|
sendResponse({ status: 'already_started' });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
isSniffing = true;
|
isSniffing = true;
|
||||||
@@ -122,6 +122,17 @@ chrome.runtime.onMessage.addListener(async (request, sender, sendResponse) => {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (request.action === 'writeClipboard') {
|
||||||
|
if (isSniffing) {
|
||||||
|
chrome.runtime.sendMessage({
|
||||||
|
target: 'offscreen',
|
||||||
|
type: 'write-clipboard',
|
||||||
|
data: request.content
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if (request.type === 'clipboard-data' && request.target === 'background') {
|
if (request.type === 'clipboard-data' && request.target === 'background') {
|
||||||
// Received data from offscreen document
|
// Received data from offscreen document
|
||||||
if (isSniffing && request.data && request.data !== lastClipboardContent) {
|
if (isSniffing && request.data && request.data !== lastClipboardContent) {
|
||||||
|
|||||||
@@ -46,6 +46,17 @@ window.addEventListener('message', (event) => {
|
|||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (event.data.type === 'TOOLS_APP_CLIPBOARD_WRITE') {
|
||||||
|
try {
|
||||||
|
chrome.runtime.sendMessage({
|
||||||
|
action: 'writeClipboard',
|
||||||
|
content: event.data.content
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Tools App Extension: Write clipboard failed', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Listen for messages from the Extension Background
|
// Listen for messages from the Extension Background
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "Tools App Extension",
|
"name": "Tools App Extension",
|
||||||
"version": "1.0",
|
"version": "1.1",
|
||||||
"description": "Browser extension for Tools App",
|
"description": "Browser extension for Tools App",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"clipboardRead",
|
"clipboardRead",
|
||||||
|
"clipboardWrite",
|
||||||
"offscreen",
|
"offscreen",
|
||||||
"storage",
|
"storage",
|
||||||
"alarms",
|
"alarms",
|
||||||
|
|||||||
@@ -42,12 +42,24 @@ setInterval(async () => {
|
|||||||
}
|
}
|
||||||
}, 50);
|
}, 50);
|
||||||
|
|
||||||
// Listen for messages from background if we need to change behavior
|
// Listen for messages from background
|
||||||
chrome.runtime.onMessage.addListener((message) => {
|
chrome.runtime.onMessage.addListener((message) => {
|
||||||
if (message.target === 'offscreen') {
|
if (message.target === 'offscreen') {
|
||||||
// Handle commands
|
// Handle commands
|
||||||
if (message.type === 'play-sound') {
|
if (message.type === 'play-sound') {
|
||||||
playNotificationSound();
|
playNotificationSound();
|
||||||
|
} else if (message.type === 'write-clipboard') {
|
||||||
|
try {
|
||||||
|
const text = message.data;
|
||||||
|
if (text) {
|
||||||
|
textEl.value = text;
|
||||||
|
textEl.select();
|
||||||
|
document.execCommand('copy');
|
||||||
|
lastText = text; // Update internal state to avoid re-triggering update
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to write clipboard:', e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,10 @@
|
|||||||
<meta name="theme-color" content="#4facfe" />
|
<meta name="theme-color" content="#4facfe" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Tools App</title>
|
<title>Tools App</title>
|
||||||
|
<meta property="og:title" content="Tools App" />
|
||||||
|
<meta property="og:description" content="A versatile collection of developer and everyday tools including a Password Generator, QR Code Scanner/Generator, URL Cleaner, and more." />
|
||||||
|
<meta property="og:image" content="/preview.png" />
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
1968
package-lock.json
generated
1968
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
20
package.json
20
package.json
@@ -1,21 +1,35 @@
|
|||||||
{
|
{
|
||||||
"name": "tools-app",
|
"name": "tools-app",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.3.0",
|
"version": "0.7.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"pack-extension": "node scripts/pack_crx.js",
|
||||||
|
"postinstall": "mkdir -p public/wasm && cp node_modules/zxing-wasm/dist/reader/zxing_reader.wasm public/wasm/",
|
||||||
|
"prepare": "husky"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@gkucmierz/utils": "^1.28.7",
|
||||||
|
"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-router": "^5.0.3"
|
"vue-router": "^5.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^10.0.1",
|
||||||
"@vitejs/plugin-vue": "^6.0.2",
|
"@vitejs/plugin-vue": "^6.0.2",
|
||||||
|
"eslint": "^10.0.2",
|
||||||
|
"eslint-plugin-vue": "^10.8.0",
|
||||||
|
"globals": "^17.4.0",
|
||||||
|
"husky": "^9.1.7",
|
||||||
"vite": "^7.3.1",
|
"vite": "^7.3.1",
|
||||||
"vite-plugin-pwa": "^1.2.0"
|
"vite-plugin-pwa": "^1.2.0",
|
||||||
|
"worker-loader": "^3.0.8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
public/preview.png
Normal file
BIN
public/preview.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 245 KiB |
63
scripts/build_extension.py
Normal file
63
scripts/build_extension.py
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
SOURCE_DIR = "extension"
|
||||||
|
BUILD_DIR = "dist-extension"
|
||||||
|
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
|
||||||
|
if os.path.exists(BUILD_DIR):
|
||||||
|
shutil.rmtree(BUILD_DIR)
|
||||||
|
|
||||||
|
# Copy source directory to build directory
|
||||||
|
shutil.copytree(SOURCE_DIR, BUILD_DIR)
|
||||||
|
|
||||||
|
# Modify manifest.json for production
|
||||||
|
manifest_path = os.path.join(BUILD_DIR, MANIFEST_FILE)
|
||||||
|
with open(manifest_path, "r") as f:
|
||||||
|
manifest = json.load(f)
|
||||||
|
|
||||||
|
# Filter permissions and content scripts (remove localhost)
|
||||||
|
print("Removing localhost from manifest...")
|
||||||
|
|
||||||
|
# Filter host_permissions
|
||||||
|
if "host_permissions" in manifest:
|
||||||
|
manifest["host_permissions"] = [
|
||||||
|
perm for perm in manifest["host_permissions"]
|
||||||
|
if "localhost" not in perm
|
||||||
|
]
|
||||||
|
|
||||||
|
# Filter content_scripts matches
|
||||||
|
if "content_scripts" in manifest:
|
||||||
|
for script in manifest["content_scripts"]:
|
||||||
|
if "matches" in script:
|
||||||
|
script["matches"] = [
|
||||||
|
match for match in script["matches"]
|
||||||
|
if "localhost" not in match
|
||||||
|
]
|
||||||
|
|
||||||
|
# Save modified manifest
|
||||||
|
with open(manifest_path, "w") as f:
|
||||||
|
json.dump(manifest, f, indent=2)
|
||||||
|
|
||||||
|
# Create ZIP file
|
||||||
|
print(f"Creating {OUTPUT_ZIP}...")
|
||||||
|
with zipfile.ZipFile(OUTPUT_ZIP, "w", zipfile.ZIP_DEFLATED) as zipf:
|
||||||
|
for root, dirs, files in os.walk(BUILD_DIR):
|
||||||
|
for file in files:
|
||||||
|
file_path = os.path.join(root, file)
|
||||||
|
arcname = os.path.relpath(file_path, BUILD_DIR)
|
||||||
|
zipf.write(file_path, arcname)
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
shutil.rmtree(BUILD_DIR)
|
||||||
|
print(f"Done! {OUTPUT_ZIP} is ready for upload to Chrome Web Store.")
|
||||||
110
scripts/pack_crx.js
Normal file
110
scripts/pack_crx.js
Normal 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();
|
||||||
61
scripts/resize_screenshot.py
Normal file
61
scripts/resize_screenshot.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
from PIL import Image, ImageOps
|
||||||
|
|
||||||
|
def resize_image_canvas(input_path, output_path, canvas_width, canvas_height, bg_color=(255, 255, 255)):
|
||||||
|
"""
|
||||||
|
Resizes an image to fit within a specific canvas size, centering it and adding padding.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
original_image = Image.open(input_path)
|
||||||
|
|
||||||
|
# Calculate aspect ratios
|
||||||
|
img_ratio = original_image.width / original_image.height
|
||||||
|
canvas_ratio = canvas_width / canvas_height
|
||||||
|
|
||||||
|
# Determine new dimensions
|
||||||
|
if img_ratio > canvas_ratio:
|
||||||
|
# Image is wider than canvas
|
||||||
|
new_width = canvas_width
|
||||||
|
new_height = int(canvas_width / img_ratio)
|
||||||
|
else:
|
||||||
|
# Image is taller than canvas
|
||||||
|
new_height = canvas_height
|
||||||
|
new_width = int(canvas_height * img_ratio)
|
||||||
|
|
||||||
|
# Resize the original image
|
||||||
|
resized_image = original_image.resize((new_width, new_height), Image.Resampling.LANCZOS)
|
||||||
|
|
||||||
|
# Create a new canvas with the specified background color
|
||||||
|
new_image = Image.new("RGB", (canvas_width, canvas_height), bg_color)
|
||||||
|
|
||||||
|
# Calculate position to center the image
|
||||||
|
x_offset = (canvas_width - new_width) // 2
|
||||||
|
y_offset = (canvas_height - new_height) // 2
|
||||||
|
|
||||||
|
# Paste the resized image onto the canvas
|
||||||
|
new_image.paste(resized_image, (x_offset, y_offset))
|
||||||
|
|
||||||
|
# Save the result
|
||||||
|
new_image.save(output_path, quality=95)
|
||||||
|
print(f"Success! Image saved to {output_path}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# Example usage:
|
||||||
|
# Replace 'screenshot.png' with your actual file name
|
||||||
|
# We will look for png or jpg files in current dir if not specified
|
||||||
|
import glob
|
||||||
|
import sys
|
||||||
|
|
||||||
|
files = glob.glob("*.png") + glob.glob("*.jpg") + glob.glob("*.jpeg")
|
||||||
|
|
||||||
|
if not files:
|
||||||
|
print("No image files found in the current directory.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print("Found images:", files)
|
||||||
|
input_file = files[0] # Take the first one found
|
||||||
|
print(f"Processing {input_file}...")
|
||||||
|
|
||||||
|
resize_image_canvas(input_file, "cws_screenshot_1280x800.png", 1280, 800, bg_color=(245, 247, 250)) # Light gray-ish background matching the app
|
||||||
33
src/App.vue
33
src/App.vue
@@ -5,6 +5,9 @@ import Header from './components/Header.vue'
|
|||||||
import Footer from './components/Footer.vue'
|
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 GlobalTooltip from './components/common/GlobalTooltip.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()
|
||||||
@@ -30,6 +33,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)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -53,6 +61,8 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
<Footer />
|
<Footer />
|
||||||
<InstallPrompt />
|
<InstallPrompt />
|
||||||
|
<ReloadPrompt />
|
||||||
|
<GlobalTooltip />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -65,22 +75,31 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
.main-content {
|
.main-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 2rem;
|
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) */
|
display: flex;
|
||||||
padding-bottom: calc(2rem + 40px + env(safe-area-inset-bottom));
|
flex-direction: column;
|
||||||
|
/* Space for fixed footer on mobile + extra margin */
|
||||||
|
padding-bottom: calc(1rem + var(--footer-height) + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.main-content {
|
||||||
|
padding: 0.5rem;
|
||||||
|
padding-bottom: calc(0.5rem + var(--footer-height) + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
.app-body {
|
.app-body {
|
||||||
overflow: hidden;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-content {
|
.main-content {
|
||||||
overflow-y: auto;
|
overflow: visible;
|
||||||
height: 100%;
|
flex: 1;
|
||||||
padding-bottom: 2rem;
|
padding-bottom: 1rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ onMounted(() => {
|
|||||||
class="menu-btn"
|
class="menu-btn"
|
||||||
@click="$emit('toggleSidebar')"
|
@click="$emit('toggleSidebar')"
|
||||||
aria-label="Toggle Menu"
|
aria-label="Toggle Menu"
|
||||||
title="Toggle Menu"
|
v-tooltip="'Toggle Menu'"
|
||||||
v-ripple
|
v-ripple
|
||||||
>
|
>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
@@ -44,13 +44,13 @@ 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
|
||||||
class="btn-neon nav-btn icon-only"
|
class="btn-neon nav-btn icon-only"
|
||||||
@click="toggleTheme"
|
@click="toggleTheme"
|
||||||
:title="isDark ? 'Switch to light mode' : 'Switch to dark mode'"
|
v-tooltip="isDark ? 'Switch to light mode' : 'Switch to dark mode'"
|
||||||
v-ripple
|
v-ripple
|
||||||
>
|
>
|
||||||
<Sun v-if="isDark" :size="20" />
|
<Sun v-if="isDark" :size="20" />
|
||||||
@@ -63,9 +63,12 @@ onMounted(() => {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.app-header {
|
.app-header {
|
||||||
/* Remove hardcoded colors and use theme variables */
|
/* Remove hardcoded colors and use theme variables */
|
||||||
background: var(--glass-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>
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
<div class="prompt-content">
|
<div class="prompt-content">
|
||||||
<span class="prompt-text">Install app for faster access</span>
|
<span class="prompt-text">Install app for faster access</span>
|
||||||
<div class="prompt-actions">
|
<div class="prompt-actions">
|
||||||
<button @click="installPWA" class="install-btn">Install</button>
|
<button @click="installPWA" class="install-btn" v-ripple>Install</button>
|
||||||
<button @click="dismissPrompt" class="dismiss-btn">✕</button>
|
<button @click="dismissPrompt" class="dismiss-btn" v-ripple>✕</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
94
src/components/ReloadPrompt.vue
Normal file
94
src/components/ReloadPrompt.vue
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
<script setup>
|
||||||
|
import { useRegisterSW } from 'virtual:pwa-register/vue'
|
||||||
|
import { watch, onMounted } from 'vue'
|
||||||
|
|
||||||
|
// Zmieniamy na autoUpdate w configu, więc tutaj tylko nasłuchujemy i ewentualnie wymuszamy
|
||||||
|
const {
|
||||||
|
needRefresh,
|
||||||
|
updateServiceWorker,
|
||||||
|
} = useRegisterSW({
|
||||||
|
immediate: true,
|
||||||
|
onRegistered(r) {
|
||||||
|
// Sprawdzaj aktualizacje co godzinę
|
||||||
|
r && setInterval(() => {
|
||||||
|
r.update()
|
||||||
|
}, 60 * 60 * 1000)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateSW = async () => {
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
try {
|
||||||
|
const registration = await navigator.serviceWorker.ready
|
||||||
|
console.log('Checking for SW update...')
|
||||||
|
await registration.update()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to update SW:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// Check on load
|
||||||
|
updateSW()
|
||||||
|
|
||||||
|
// Check when app becomes visible again (e.g. switching tabs/apps)
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (document.visibilityState === 'visible') {
|
||||||
|
updateSW()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="needRefresh"
|
||||||
|
class="pwa-toast"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
<div class="message">
|
||||||
|
New content available, click on reload button to update.
|
||||||
|
</div>
|
||||||
|
<button @click="updateServiceWorker()" class="btn-neon" v-ripple>
|
||||||
|
Reload
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.pwa-toast {
|
||||||
|
position: fixed;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
margin: 16px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
z-index: 10000;
|
||||||
|
text-align: left;
|
||||||
|
box-shadow: var(--glass-shadow);
|
||||||
|
background-color: var(--glass-bg);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
color: var(--text-color);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pwa-toast button {
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
outline: none;
|
||||||
|
margin-right: 5px;
|
||||||
|
border-radius: 2px;
|
||||||
|
padding: 3px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
background: var(--button-bg);
|
||||||
|
color: var(--text-color);
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pwa-toast button:hover {
|
||||||
|
background: var(--button-hover-bg);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -12,6 +12,10 @@ defineProps({
|
|||||||
<nav class="sidebar-nav">
|
<nav class="sidebar-nav">
|
||||||
<router-link to="/passwords" class="nav-item" v-ripple>Passwords</router-link>
|
<router-link to="/passwords" class="nav-item" v-ripple>Passwords</router-link>
|
||||||
<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="/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>
|
||||||
|
<router-link to="/tone-generator" class="nav-item" v-ripple>Tone Generator</router-link>
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
</template>
|
</template>
|
||||||
@@ -24,7 +28,7 @@ defineProps({
|
|||||||
left: 0;
|
left: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
width: 250px;
|
width: 250px;
|
||||||
background-color: var(--panel-bg);
|
background-color: var(--glass-bg);
|
||||||
backdrop-filter: blur(12px);
|
backdrop-filter: blur(12px);
|
||||||
-webkit-backdrop-filter: blur(12px);
|
-webkit-backdrop-filter: blur(12px);
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
@@ -86,6 +90,11 @@ defineProps({
|
|||||||
color: var(--text-strong);
|
color: var(--text-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:global(:root[data-theme="light"]) .nav-item:hover {
|
||||||
|
background-color: rgba(15, 23, 42, 0.1);
|
||||||
|
color: var(--text-strong);
|
||||||
|
}
|
||||||
|
|
||||||
.nav-item.router-link-active {
|
.nav-item.router-link-active {
|
||||||
color: var(--primary-accent);
|
color: var(--primary-accent);
|
||||||
background-color: var(--toggle-bg);
|
background-color: var(--toggle-bg);
|
||||||
|
|||||||
138
src/components/common/GlobalTooltip.vue
Normal file
138
src/components/common/GlobalTooltip.vue
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
<script setup>
|
||||||
|
import { tooltipState } from '../../composables/useTooltip'
|
||||||
|
import { ref, watch, nextTick } from 'vue'
|
||||||
|
|
||||||
|
const tooltipRef = ref(null)
|
||||||
|
const x = ref(0)
|
||||||
|
const y = ref(0)
|
||||||
|
const arrowX = ref(0)
|
||||||
|
const isBottom = ref(false)
|
||||||
|
|
||||||
|
watch(() => tooltipState.isVisible, async (visible) => {
|
||||||
|
if (visible) {
|
||||||
|
// Wait for DOM update to ensure the text content affects height before measuring
|
||||||
|
await nextTick()
|
||||||
|
if (!tooltipRef.value || !tooltipState.targetRect) return
|
||||||
|
|
||||||
|
const rect = tooltipState.targetRect
|
||||||
|
const tooltipRect = tooltipRef.value.getBoundingClientRect()
|
||||||
|
|
||||||
|
let top = rect.top - tooltipRect.height - 8
|
||||||
|
let idealCenter = rect.left + (rect.width / 2)
|
||||||
|
let left = idealCenter - (tooltipRect.width / 2)
|
||||||
|
|
||||||
|
isBottom.value = false
|
||||||
|
if (top < 8) {
|
||||||
|
top = rect.bottom + 8
|
||||||
|
isBottom.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bounds checking for the tooltip box
|
||||||
|
let actualLeft = left
|
||||||
|
if (actualLeft < 8) {
|
||||||
|
actualLeft = 8
|
||||||
|
} else if (actualLeft + tooltipRect.width > window.innerWidth - 8) {
|
||||||
|
actualLeft = window.innerWidth - tooltipRect.width - 8
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate the difference between where the box is forced to be,
|
||||||
|
// and where it naturally wanted to be. We move the arrow by the opposite amount.
|
||||||
|
arrowX.value = idealCenter - (actualLeft + (tooltipRect.width / 2))
|
||||||
|
|
||||||
|
x.value = actualLeft
|
||||||
|
y.value = top
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
ref="tooltipRef"
|
||||||
|
class="global-tooltip"
|
||||||
|
:class="{ 'visible': tooltipState.isVisible, 'tooltip-bottom': isBottom }"
|
||||||
|
:style="{
|
||||||
|
transform: `translate(${x}px, ${y}px)`,
|
||||||
|
'--arrow-offset': `${arrowX}px`
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{ tooltipState.text }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.global-tooltip {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 99999;
|
||||||
|
background: rgba(15, 23, 42, 0.95);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
color: #f8fafc;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
pointer-events: none;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
transform: translateY(4px) scale(0.95);
|
||||||
|
transition: opacity 0.2s cubic-bezier(0.16, 1, 0.3, 1), transform 0.2s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-tooltip::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: calc(50% + var(--arrow-offset, 0px));
|
||||||
|
bottom: -5px;
|
||||||
|
margin-left: -5px;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
background: rgba(15, 23, 42, 0.95);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
border-right: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
transform: rotate(45deg);
|
||||||
|
border-radius: 0 0 2px 0;
|
||||||
|
clip-path: polygon(100% 0, 100% 100%, 0 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-tooltip.tooltip-bottom::after {
|
||||||
|
bottom: auto;
|
||||||
|
top: -5px;
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: none;
|
||||||
|
border-left: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 2px 0 0 0;
|
||||||
|
clip-path: polygon(0 0, 100% 0, 0 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-tooltip.visible {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
/* Transform returns to natural pos defined by inline styles when visible */
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="light"] .global-tooltip {
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
color: #0f172a;
|
||||||
|
border-color: rgba(15, 23, 42, 0.1);
|
||||||
|
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="light"] .global-tooltip::after {
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
border-right-color: rgba(15, 23, 42, 0.1);
|
||||||
|
border-bottom-color: rgba(15, 23, 42, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="light"] .global-tooltip.tooltip-bottom::after {
|
||||||
|
border-left-color: rgba(15, 23, 42, 0.1);
|
||||||
|
border-top-color: rgba(15, 23, 42, 0.1);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,91 +1,29 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onUnmounted, nextTick, onMounted } from 'vue'
|
import { ref, watch, nextTick } from 'vue'
|
||||||
import { useFillHeight } from '../../composables/useFillHeight'
|
import { useFillHeight } from '../../composables/useFillHeight'
|
||||||
import { Plug, Info, X } from 'lucide-vue-next'
|
import { useExtension } from '../../composables/useExtension'
|
||||||
|
import { useLocalStorage } from '../../composables/useLocalStorage'
|
||||||
|
import ExtensionStatus from './common/ExtensionStatus.vue'
|
||||||
|
|
||||||
const clipboardContent = ref('')
|
const clipboardContent = useLocalStorage('clipboard-sniffer-content', '')
|
||||||
const isListening = ref(false)
|
|
||||||
const lastClipboardText = ref('')
|
|
||||||
const textareaRef = ref(null)
|
const textareaRef = ref(null)
|
||||||
const isExtensionReady = ref(false)
|
|
||||||
const showExtensionModal = ref(false)
|
|
||||||
let intervalId = null
|
|
||||||
let extensionCheckInterval = null
|
|
||||||
|
|
||||||
const { height: textareaHeight } = useFillHeight(textareaRef, 40) // 40px margin bottom
|
const {
|
||||||
|
isExtensionReady,
|
||||||
|
isListening,
|
||||||
|
lastClipboardText,
|
||||||
|
startListening,
|
||||||
|
stopListening
|
||||||
|
} = useExtension()
|
||||||
|
|
||||||
// Listen for extension messages
|
const { height: textareaHeight } = useFillHeight(textareaRef, 40)
|
||||||
const handleExtensionMessage = (event) => {
|
|
||||||
if (event.source !== window) return
|
|
||||||
|
|
||||||
if (event.data.type === 'TOOLS_APP_EXTENSION_READY' || event.data.type === 'TOOLS_APP_PONG') {
|
// Watch for clipboard updates from extension
|
||||||
isExtensionReady.value = true
|
watch(lastClipboardText, (newText) => {
|
||||||
lastPongTime = Date.now()
|
if (newText) {
|
||||||
// console.log('Extension is ready')
|
clipboardContent.value += (clipboardContent.value ? '\n' : '') + newText
|
||||||
|
scrollToBottom()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.data.type === 'TOOLS_APP_CLIPBOARD_UPDATE' && isListening.value) {
|
|
||||||
const text = event.data.content
|
|
||||||
if (text && text !== lastClipboardText.value) {
|
|
||||||
lastClipboardText.value = text
|
|
||||||
clipboardContent.value += (clipboardContent.value ? '\n' : '') + text
|
|
||||||
scrollToBottom()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const closeModalOnEsc = (e) => {
|
|
||||||
if (e.key === 'Escape' && showExtensionModal.value) {
|
|
||||||
showExtensionModal.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Watchdog for extension
|
|
||||||
let lastPongTime = Date.now()
|
|
||||||
const PING_INTERVAL = 200
|
|
||||||
const TIMEOUT_THRESHOLD = 500
|
|
||||||
|
|
||||||
const startExtensionWatchdog = () => {
|
|
||||||
extensionCheckInterval = setInterval(() => {
|
|
||||||
// 1. Send Ping
|
|
||||||
window.postMessage({ type: 'TOOLS_APP_PING' }, '*')
|
|
||||||
|
|
||||||
// 2. Check timeout
|
|
||||||
// If current time - lastPongTime > threshold, then disconnected
|
|
||||||
if (Date.now() - lastPongTime > TIMEOUT_THRESHOLD) {
|
|
||||||
isExtensionReady.value = false
|
|
||||||
}
|
|
||||||
}, PING_INTERVAL)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wrapper to intercept PONG and update heartbeat
|
|
||||||
const messageListener = (event) => {
|
|
||||||
if (event.source !== window) return
|
|
||||||
|
|
||||||
if (event.data.type === 'TOOLS_APP_PONG' || event.data.type === 'TOOLS_APP_EXTENSION_READY') {
|
|
||||||
lastPongTime = Date.now()
|
|
||||||
isExtensionReady.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
handleExtensionMessage(event)
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
window.addEventListener('message', messageListener)
|
|
||||||
window.addEventListener('keydown', closeModalOnEsc)
|
|
||||||
|
|
||||||
// Initial check
|
|
||||||
window.postMessage({ type: 'TOOLS_APP_INIT' }, '*')
|
|
||||||
|
|
||||||
// Start heartbeat
|
|
||||||
startExtensionWatchdog()
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
stopListening()
|
|
||||||
if (extensionCheckInterval) clearInterval(extensionCheckInterval)
|
|
||||||
window.removeEventListener('message', messageListener)
|
|
||||||
window.removeEventListener('keydown', closeModalOnEsc)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const scrollToBottom = () => {
|
const scrollToBottom = () => {
|
||||||
@@ -97,97 +35,24 @@ const scrollToBottom = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const startListening = async () => {
|
const copyToClipboard = () => {
|
||||||
try {
|
if (clipboardContent.value) {
|
||||||
isListening.value = true
|
navigator.clipboard.writeText(clipboardContent.value)
|
||||||
|
|
||||||
// Try native API first (for web app usage without extension)
|
|
||||||
// Initial read
|
|
||||||
try {
|
|
||||||
const text = await navigator.clipboard.readText()
|
|
||||||
if (text) {
|
|
||||||
lastClipboardText.value = text
|
|
||||||
clipboardContent.value += (clipboardContent.value ? '\n' : '') + text
|
|
||||||
scrollToBottom()
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.log('Native clipboard read failed (expected if not focused), relying on extension if available')
|
|
||||||
}
|
|
||||||
|
|
||||||
// If extension is ready, ask it to start sniffing
|
|
||||||
if (isExtensionReady.value) {
|
|
||||||
window.postMessage({ type: 'TOOLS_APP_START_SNIFFING' }, '*')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback polling for web app (only works when focused usually)
|
|
||||||
intervalId = setInterval(async () => {
|
|
||||||
try {
|
|
||||||
const currentText = await navigator.clipboard.readText()
|
|
||||||
if (currentText && currentText !== lastClipboardText.value) {
|
|
||||||
lastClipboardText.value = currentText
|
|
||||||
clipboardContent.value += (clipboardContent.value ? '\n' : '') + currentText
|
|
||||||
scrollToBottom()
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
// Ignore errors in polling (e.g. lost focus)
|
|
||||||
}
|
|
||||||
}, 1000)
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Permission denied or clipboard error:', err)
|
|
||||||
alert('Clipboard access denied. Please allow clipboard access to use this tool.')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const stopListening = () => {
|
|
||||||
isListening.value = false
|
|
||||||
if (intervalId) {
|
|
||||||
clearInterval(intervalId)
|
|
||||||
intervalId = null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isExtensionReady.value) {
|
|
||||||
window.postMessage({ type: 'TOOLS_APP_STOP_SNIFFING' }, '*')
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const clearText = () => {
|
const clearText = () => {
|
||||||
clipboardContent.value = ''
|
clipboardContent.value = ''
|
||||||
// Don't reset lastClipboardText so if they copy the same thing again it's detected?
|
|
||||||
// No, if they clear, they might want to see it again if they copy it again.
|
|
||||||
// But usually "change" means diff from clipboard.
|
|
||||||
// If I clear text, but clipboard still has "A", and I copy "A" again (refresh clipboard), readText still returns "A".
|
|
||||||
// So it won't be detected as a change.
|
|
||||||
// If user wants to capture "A" again, they need to copy something else then "A".
|
|
||||||
// That's standard behavior for "sniffer" (detect changes).
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const copyToClipboard = async () => {
|
|
||||||
if (!clipboardContent.value) return
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(clipboardContent.value)
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to copy to clipboard:', err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
stopListening()
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="tool-container" style="max-width: 100%;">
|
<div class="tool-container full-width">
|
||||||
<div class="tool-panel">
|
<div class="tool-panel">
|
||||||
<div class="tool-header">
|
<div class="panel-header">
|
||||||
<h2 class="tool-title">Clipboard Sniffer</h2>
|
<h2 class="tool-title">Clipboard Sniffer</h2>
|
||||||
<div
|
<div class="header-actions">
|
||||||
class="extension-status"
|
<ExtensionStatus :isReady="isExtensionReady" />
|
||||||
:class="{ 'connected': isExtensionReady }"
|
|
||||||
@click="showExtensionModal = true"
|
|
||||||
:title="isExtensionReady ? 'Extension connected' : 'Extension not detected - Click for info'"
|
|
||||||
>
|
|
||||||
<Plug v-if="isExtensionReady" size="20" />
|
|
||||||
<Info v-else size="20" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -196,13 +61,15 @@ onUnmounted(() => {
|
|||||||
v-if="!isListening"
|
v-if="!isListening"
|
||||||
class="btn-neon"
|
class="btn-neon"
|
||||||
@click="startListening"
|
@click="startListening"
|
||||||
|
:disabled="!isExtensionReady"
|
||||||
|
v-tooltip="!isExtensionReady ? 'Extension required' : 'Start capturing clipboard'"
|
||||||
v-ripple
|
v-ripple
|
||||||
>
|
>
|
||||||
Start Sniffing
|
Start Sniffing
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-else
|
v-else
|
||||||
class="btn-neon active"
|
class="btn-neon danger"
|
||||||
@click="stopListening"
|
@click="stopListening"
|
||||||
v-ripple
|
v-ripple
|
||||||
>
|
>
|
||||||
@@ -224,200 +91,62 @@ onUnmounted(() => {
|
|||||||
v-model="clipboardContent"
|
v-model="clipboardContent"
|
||||||
class="tool-textarea"
|
class="tool-textarea"
|
||||||
placeholder="Clipboard content will appear here line by line..."
|
placeholder="Clipboard content will appear here line by line..."
|
||||||
readonly
|
|
||||||
></textarea>
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Extension Info Modal -->
|
|
||||||
<div v-if="showExtensionModal" class="modal-overlay" @click.self="showExtensionModal = false">
|
|
||||||
<div class="modal-content glass-panel">
|
|
||||||
<button class="close-btn" @click="showExtensionModal = false">
|
|
||||||
<X size="24" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div v-if="!isExtensionReady">
|
|
||||||
<h3>Enhance Your Experience</h3>
|
|
||||||
<p>
|
|
||||||
Install our browser extension to enable <strong>background clipboard sniffing</strong>!
|
|
||||||
</p>
|
|
||||||
<p class="description">
|
|
||||||
Without the extension, this tool can only capture clipboard content when the tab is active.
|
|
||||||
With the extension, you can capture content even when you're working in other apps.
|
|
||||||
</p>
|
|
||||||
<div class="modal-actions">
|
|
||||||
<a href="#" class="btn-neon" @click.prevent>Extension Coming Soon</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else>
|
|
||||||
<h3>Extension Connected!</h3>
|
|
||||||
<p>
|
|
||||||
You have successfully enabled <strong>background clipboard sniffing</strong>.
|
|
||||||
</p>
|
|
||||||
<p class="description">
|
|
||||||
The extension is active and monitoring your clipboard in the background.
|
|
||||||
You can now switch to other apps and copy text - it will appear here automatically.
|
|
||||||
</p>
|
|
||||||
<div class="modal-actions">
|
|
||||||
<button class="btn-neon" @click="showExtensionModal = false">Got it!</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.tool-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.extension-status {
|
|
||||||
position: absolute;
|
|
||||||
right: 0;
|
|
||||||
top: 50%;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(:root[data-theme="light"]) .extension-status {
|
|
||||||
background: rgba(0, 0, 0, 0.05);
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
|
|
||||||
.extension-status:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.2);
|
|
||||||
color: var(--text-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(:root[data-theme="light"]) .extension-status:hover {
|
|
||||||
background: rgba(0, 0, 0, 0.1);
|
|
||||||
color: #000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.extension-status.connected {
|
|
||||||
color: #4ade80; /* Green for connected */
|
|
||||||
cursor: pointer; /* Allow clicking to see status */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* :global(:root[data-theme="light"]) .extension-status.connected {
|
|
||||||
color: #16a34a;
|
|
||||||
} */
|
|
||||||
|
|
||||||
.extension-status.connected:hover {
|
|
||||||
background: rgba(74, 222, 128, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* :global(:root[data-theme="light"]) .extension-status.connected:hover {
|
|
||||||
background: rgba(22, 163, 74, 0.1);
|
|
||||||
} */
|
|
||||||
|
|
||||||
.modal-overlay {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
background: rgba(0, 0, 0, 0.7);
|
|
||||||
backdrop-filter: blur(4px);
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
z-index: 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-content {
|
|
||||||
position: relative;
|
|
||||||
width: 90%;
|
|
||||||
max-width: 500px;
|
|
||||||
padding: 2rem;
|
|
||||||
border-radius: 16px;
|
|
||||||
background: var(--glass-bg);
|
|
||||||
border: 1px solid var(--glass-border);
|
|
||||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.close-btn {
|
|
||||||
position: absolute;
|
|
||||||
top: 1rem;
|
|
||||||
right: 1rem;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0.5rem;
|
|
||||||
border-radius: 50%;
|
|
||||||
transition: all 0.2s;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.close-btn:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
color: var(--text-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-content h3 {
|
|
||||||
margin-top: 0;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
font-size: 1.5rem;
|
|
||||||
color: var(--text-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-content p {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
line-height: 1.6;
|
|
||||||
color: var(--text-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.description {
|
|
||||||
color: var(--text-secondary) !important;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
margin-bottom: 2rem !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-actions {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.controls {
|
.controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
gap: 1rem;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-neon {
|
.tool-container.full-width {
|
||||||
padding: 0.75rem 1.5rem;
|
max-width: 100%;
|
||||||
min-width: 120px;
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-neon.active {
|
.tool-panel {
|
||||||
background: rgba(255, 0, 0, 0.2);
|
display: flex;
|
||||||
border-color: rgba(255, 0, 0, 0.5);
|
flex-direction: column;
|
||||||
box-shadow: 0 0 15px rgba(255, 0, 0, 0.3);
|
height: 100%;
|
||||||
|
gap: 1.5rem;
|
||||||
|
/* Override shared tool-panel scroll for this tool */
|
||||||
|
max-height: none;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-textarea {
|
.tool-textarea {
|
||||||
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
padding: 1rem;
|
||||||
|
background-color: var(--toggle-bg);
|
||||||
|
border: 1px solid var(--toggle-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-color);
|
||||||
|
font-family: monospace;
|
||||||
|
resize: none;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.result-area {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -74,9 +74,9 @@ const generatePasswords = () => {
|
|||||||
<div class="tool-container full-width">
|
<div class="tool-container full-width">
|
||||||
<div class="tool-panel">
|
<div class="tool-panel">
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<h2 class="tool-title">Bulk Passwords Generator</h2>
|
<h2 class="tool-title">Passwords Generator</h2>
|
||||||
<div class="action-area">
|
<div class="header-actions">
|
||||||
<button class="btn-neon generate-btn" @click="generatePasswords" v-ripple>
|
<button class="btn-neon generate-btn desktop-only" @click="generatePasswords" v-ripple>
|
||||||
Generate
|
Generate
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -115,28 +115,33 @@ const generatePasswords = () => {
|
|||||||
<div class="input-wrapper">
|
<div class="input-wrapper">
|
||||||
<label>Length</label>
|
<label>Length</label>
|
||||||
<div class="number-control">
|
<div class="number-control">
|
||||||
<button class="control-btn" @click="length > 4 ? length-- : null">-</button>
|
<button class="control-btn" @click="length > 4 ? length-- : null" v-ripple>-</button>
|
||||||
<input type="number" v-model="length" min="4" max="128" class="number-input">
|
<input type="number" v-model="length" min="4" max="128" class="number-input">
|
||||||
<button class="control-btn" @click="length < 128 ? length++ : null">+</button>
|
<button class="control-btn" @click="length < 128 ? length++ : null" v-ripple>+</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="input-wrapper">
|
<div class="input-wrapper">
|
||||||
<label>Count</label>
|
<label>Count</label>
|
||||||
<div class="number-control">
|
<div class="number-control">
|
||||||
<button class="control-btn" @click="count > 1 ? count-- : null">-</button>
|
<button class="control-btn" @click="count > 1 ? count-- : null" v-ripple>-</button>
|
||||||
<input type="number" v-model="count" min="1" max="1000" class="number-input">
|
<input type="number" v-model="count" min="1" max="1000" class="number-input">
|
||||||
<button class="control-btn" @click="count < 1000 ? count++ : null">+</button>
|
<button class="control-btn" @click="count < 1000 ? count++ : null" v-ripple>+</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="mobile-only" style="margin-top: 1rem; width: 100%;">
|
||||||
|
<button class="btn-neon generate-btn" @click="generatePasswords" v-ripple style="width: 100%;">
|
||||||
|
Generate
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="result-area" :style="{ height: textareaHeight }">
|
<div class="result-area" :style="{ height: textareaHeight }">
|
||||||
<textarea
|
<textarea
|
||||||
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>
|
||||||
@@ -144,32 +149,6 @@ const generatePasswords = () => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.tool-container.full-width {
|
|
||||||
max-width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tool-panel {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100%;
|
|
||||||
gap: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tool-title {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.options-grid {
|
.options-grid {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -189,9 +168,10 @@ const generatePasswords = () => {
|
|||||||
|
|
||||||
.inputs-group {
|
.inputs-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 2rem;
|
gap: 1rem;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 300px;
|
min-width: 200px;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-wrapper {
|
.input-wrapper {
|
||||||
@@ -199,114 +179,13 @@ const generatePasswords = () => {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-width: 140px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.checkbox-label {
|
.input-wrapper label {
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
cursor: pointer;
|
|
||||||
user-select: none;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-weight: 500;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Custom Checkbox */
|
|
||||||
.checkbox-label input {
|
|
||||||
position: absolute;
|
|
||||||
opacity: 0;
|
|
||||||
cursor: pointer;
|
|
||||||
height: 0;
|
|
||||||
width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkmark {
|
|
||||||
position: relative;
|
|
||||||
height: 20px;
|
|
||||||
width: 20px;
|
|
||||||
background-color: var(--toggle-bg);
|
|
||||||
border: 1px solid var(--toggle-border);
|
|
||||||
border-radius: 4px;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkbox-label:hover .checkmark {
|
|
||||||
border-color: var(--toggle-hover-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkbox-label input:checked ~ .checkmark {
|
|
||||||
background-color: var(--primary-accent);
|
|
||||||
border-color: var(--primary-accent);
|
|
||||||
box-shadow: 0 0 10px var(--primary-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkmark:after {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkbox-label input:checked ~ .checkmark:after {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkbox-label .checkmark:after {
|
|
||||||
left: 6px;
|
|
||||||
top: 2px;
|
|
||||||
width: 5px;
|
|
||||||
height: 10px;
|
|
||||||
border: solid #000;
|
|
||||||
border-width: 0 2px 2px 0;
|
|
||||||
transform: rotate(45deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Number Control */
|
|
||||||
.number-control {
|
|
||||||
display: flex;
|
|
||||||
align-items: stretch;
|
|
||||||
background: var(--toggle-bg);
|
|
||||||
border: 1px solid var(--toggle-border);
|
|
||||||
border-radius: 8px;
|
|
||||||
overflow: hidden;
|
|
||||||
gap: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.control-btn {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
font-size: 1.2rem;
|
font-weight: 400;
|
||||||
width: 40px;
|
margin-bottom: 0.2rem;
|
||||||
height: auto;
|
|
||||||
min-height: 40px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.2s;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.control-btn:hover {
|
|
||||||
background: var(--button-hover-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.number-input {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--text-color);
|
|
||||||
width: 100%;
|
|
||||||
flex: 1;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: bold;
|
|
||||||
appearance: textfield;
|
|
||||||
-moz-appearance: textfield;
|
|
||||||
height: 100%;
|
|
||||||
border-radius: 0;
|
|
||||||
min-width: 60px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.number-input:focus {
|
.number-input:focus {
|
||||||
@@ -315,12 +194,6 @@ const generatePasswords = () => {
|
|||||||
background: rgba(0, 0, 0, 0.05);
|
background: rgba(0, 0, 0, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.number-input::-webkit-outer-spin-button,
|
|
||||||
.number-input::-webkit-inner-spin-button {
|
|
||||||
-webkit-appearance: none;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-area {
|
.result-area {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -328,29 +201,19 @@ const generatePasswords = () => {
|
|||||||
min-height: 200px;
|
min-height: 200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-textarea {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
padding: 1rem;
|
|
||||||
border-radius: 12px;
|
|
||||||
border: 1px solid var(--glass-border);
|
|
||||||
background: var(--glass-bg);
|
|
||||||
color: var(--text-color);
|
|
||||||
font-family: monospace;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
resize: none;
|
|
||||||
outline: none;
|
|
||||||
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.generate-btn {
|
.generate-btn {
|
||||||
padding: 0.75rem 2rem;
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: bold;
|
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 1px;
|
letter-spacing: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.desktop-only {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-only {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.options-grid {
|
.options-grid {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -370,5 +233,13 @@ const generatePasswords = () => {
|
|||||||
.generate-btn {
|
.generate-btn {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.desktop-only {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-only {
|
||||||
|
display: block !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
633
src/components/tools/QrCode.vue
Normal file
633
src/components/tools/QrCode.vue
Normal file
@@ -0,0 +1,633 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, watch, onMounted, computed, onUnmounted } from 'vue'
|
||||||
|
import { Download, Eye, EyeOff } from 'lucide-vue-next'
|
||||||
|
import QRCode from 'qrcode'
|
||||||
|
import { useFillHeight } from '../../composables/useFillHeight'
|
||||||
|
import { useLocalStorage } from '../../composables/useLocalStorage'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { fromBase64Url, toBase64Url } from '@gkucmierz/utils'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const text = useLocalStorage('text', '', 'qr-code')
|
||||||
|
const ecc = useLocalStorage('ecc', 'M', 'qr-code')
|
||||||
|
const size = useLocalStorage('size', 300, 'qr-code')
|
||||||
|
const isBgTransparent = useLocalStorage('isBgTransparent', true, 'qr-code')
|
||||||
|
const bgType = useLocalStorage('bgType', 'solid', 'qr-code')
|
||||||
|
const bgColor1 = useLocalStorage('bgColor1', '#ffffff', 'qr-code')
|
||||||
|
const bgColor2 = useLocalStorage('bgColor2', '#e2e8f0', 'qr-code')
|
||||||
|
const bgGradPos = useLocalStorage('bgGradPos', { x1: 50, y1: 50, x2: 100, y2: 100 }, 'qr-code')
|
||||||
|
const fgType = useLocalStorage('fgType', 'solid', 'qr-code')
|
||||||
|
const fgColor1 = useLocalStorage('fgColor1', '#000000', 'qr-code')
|
||||||
|
const fgColor2 = useLocalStorage('fgColor2', '#10b981', 'qr-code')
|
||||||
|
const fgGradPos = useLocalStorage('fgGradPos', { x1: 0, y1: 0, x2: 100, y2: 100 }, 'qr-code')
|
||||||
|
const showHandles = useLocalStorage('showHandles', true, 'qr-code')
|
||||||
|
const format = useLocalStorage('format', 'png', 'qr-code')
|
||||||
|
|
||||||
|
const svgContent = ref('')
|
||||||
|
const previewRef = ref(null)
|
||||||
|
const qrFrameRef = ref(null)
|
||||||
|
|
||||||
|
const fgLinePts = computed(() => getLineEndPoints(fgGradPos.value))
|
||||||
|
const bgLinePts = computed(() => getLineEndPoints(bgGradPos.value))
|
||||||
|
|
||||||
|
const getLineEndPoints = (pos) => {
|
||||||
|
if (!qrFrameRef.value) return { x1: pos.x1, y1: pos.y1, x2: pos.x2, y2: pos.y2 }
|
||||||
|
const rect = qrFrameRef.value.getBoundingClientRect()
|
||||||
|
if (rect.width === 0 || rect.height === 0) return { x1: pos.x1, y1: pos.y1, x2: pos.x2, y2: pos.y2 }
|
||||||
|
|
||||||
|
// Handle radius in pixels (14/2 = 7px) plus half the line stroke width (1/2 = 0.5px) if needed
|
||||||
|
const VISUAL_OFFSET_PX = 1;
|
||||||
|
const rPx = 7 - VISUAL_OFFSET_PX;
|
||||||
|
|
||||||
|
const dx = (pos.x2 - pos.x1) * rect.width / 100
|
||||||
|
const dy = (pos.y2 - pos.y1) * rect.height / 100
|
||||||
|
const distPx = Math.sqrt(dx * dx + dy * dy)
|
||||||
|
|
||||||
|
if (distPx <= rPx * 2) {
|
||||||
|
// Too close, don't show line
|
||||||
|
return { x1: pos.x1, y1: pos.y1, x2: pos.x1, y2: pos.y1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
const angle = Math.atan2(dy, dx)
|
||||||
|
|
||||||
|
// Calculate offsets in percentages (using radius, not diameter)
|
||||||
|
// adding extra 1px to accommodate the stroke/shadow
|
||||||
|
const effectiveRPx = rPx + 1
|
||||||
|
const xOffsetPct = (Math.cos(angle) * effectiveRPx / rect.width) * 100
|
||||||
|
const yOffsetPct = (Math.sin(angle) * effectiveRPx / rect.height) * 100
|
||||||
|
|
||||||
|
return {
|
||||||
|
x1: pos.x1 + xOffsetPct,
|
||||||
|
y1: pos.y1 + yOffsetPct,
|
||||||
|
x2: pos.x2 - xOffsetPct,
|
||||||
|
y2: pos.y2 - yOffsetPct
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeHandle = ref(null)
|
||||||
|
|
||||||
|
const startDrag = (e, handleStr) => {
|
||||||
|
e.preventDefault()
|
||||||
|
activeHandle.value = handleStr
|
||||||
|
window.addEventListener('mousemove', onDrag)
|
||||||
|
window.addEventListener('mouseup', stopDrag)
|
||||||
|
window.addEventListener('touchmove', onDrag, { passive: false })
|
||||||
|
window.addEventListener('touchend', stopDrag)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onDrag = (e) => {
|
||||||
|
if (!activeHandle.value || !qrFrameRef.value) return
|
||||||
|
if (e.type === 'touchmove') e.preventDefault()
|
||||||
|
|
||||||
|
const rect = qrFrameRef.value.getBoundingClientRect()
|
||||||
|
const clientX = e.touches ? e.touches[0].clientX : e.clientX
|
||||||
|
const clientY = e.touches ? e.touches[0].clientY : e.clientY
|
||||||
|
|
||||||
|
let x = ((clientX - rect.left) / rect.width) * 100
|
||||||
|
let y = ((clientY - rect.top) / rect.height) * 100
|
||||||
|
x = Math.max(0, Math.min(100, x))
|
||||||
|
y = Math.max(0, Math.min(100, y))
|
||||||
|
|
||||||
|
// Snap to 5 points (Corners + Center)
|
||||||
|
// Distance threshold in percentages, roughly matching handle diameter mapping
|
||||||
|
const snapDist = 5
|
||||||
|
const snapPoints = [
|
||||||
|
{ x: 0, y: 0 }, { x: 100, y: 0 },
|
||||||
|
{ x: 0, y: 100 }, { x: 100, y: 100 },
|
||||||
|
{ x: 50, y: 50 }
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const pt of snapPoints) {
|
||||||
|
if (Math.abs(x - pt.x) < snapDist && Math.abs(y - pt.y) < snapDist) {
|
||||||
|
x = pt.x
|
||||||
|
y = pt.y
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [type, point] = activeHandle.value.split(':')
|
||||||
|
const posRef = type === 'fg' ? fgGradPos : bgGradPos
|
||||||
|
posRef.value[`x${point}`] = Math.round(x)
|
||||||
|
posRef.value[`y${point}`] = Math.round(y)
|
||||||
|
}
|
||||||
|
|
||||||
|
let justDragged = false
|
||||||
|
|
||||||
|
const stopDrag = () => {
|
||||||
|
if (activeHandle.value) {
|
||||||
|
justDragged = true
|
||||||
|
setTimeout(() => { justDragged = false }, 50)
|
||||||
|
}
|
||||||
|
activeHandle.value = null
|
||||||
|
window.removeEventListener('mousemove', onDrag)
|
||||||
|
window.removeEventListener('mouseup', stopDrag)
|
||||||
|
window.removeEventListener('touchmove', onDrag)
|
||||||
|
window.removeEventListener('touchend', stopDrag)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFrameClick = (event) => {
|
||||||
|
if (justDragged) return
|
||||||
|
|
||||||
|
if (!activeHandle.value) {
|
||||||
|
showHandles.value = !showHandles.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { height: previewHeight } = useFillHeight(previewRef, 40) // 40px extra margin
|
||||||
|
|
||||||
|
let worker = null
|
||||||
|
let latestJobId = 0
|
||||||
|
|
||||||
|
const generateQR = () => {
|
||||||
|
if (!text.value) {
|
||||||
|
svgContent.value = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create worker if not exists
|
||||||
|
if (!worker) {
|
||||||
|
worker = new Worker(new URL('../../workers/qrcode.worker.js', import.meta.url), { type: 'module' })
|
||||||
|
worker.onmessage = (e) => {
|
||||||
|
const { id, svgContent: newSvg, error } = e.data
|
||||||
|
|
||||||
|
// Only process the result of the most recently requested job
|
||||||
|
// to avoid race conditions overriding newer results with older ones
|
||||||
|
if (id !== latestJobId) return
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error('QR Generation worker failed', error)
|
||||||
|
svgContent.value = ''
|
||||||
|
} else {
|
||||||
|
svgContent.value = newSvg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Increment ID for each new Generation request
|
||||||
|
latestJobId++
|
||||||
|
worker.postMessage({
|
||||||
|
id: latestJobId,
|
||||||
|
text: text.value,
|
||||||
|
ecc: ecc.value,
|
||||||
|
isBgTransparent: isBgTransparent.value,
|
||||||
|
bgType: bgType.value,
|
||||||
|
bgColor1: bgColor1.value,
|
||||||
|
bgColor2: bgColor2.value,
|
||||||
|
bgGradPos: { ...bgGradPos.value },
|
||||||
|
fgType: fgType.value,
|
||||||
|
fgColor1: fgColor1.value,
|
||||||
|
fgColor2: fgColor2.value,
|
||||||
|
fgGradPos: { ...fgGradPos.value }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
watch([text, ecc, isBgTransparent, bgType, bgColor1, bgColor2, bgGradPos, fgType, fgColor1, fgColor2, fgGradPos], () => {
|
||||||
|
generateQR()
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
watch(text, (newText) => {
|
||||||
|
if (newText) {
|
||||||
|
router.replace({ name: 'QrCode', params: { payload: toBase64Url(newText) } })
|
||||||
|
} else {
|
||||||
|
router.replace({ name: 'QrCode', params: {} })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (route.params.payload) {
|
||||||
|
try {
|
||||||
|
const decodedPayload = fromBase64Url(route.params.payload)
|
||||||
|
text.value = decodedPayload
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to parse QR payload from URL', e)
|
||||||
|
}
|
||||||
|
} else if (text.value) {
|
||||||
|
router.replace({ name: 'QrCode', params: { payload: toBase64Url(text.value) } })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text.value) generateQR()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (worker) {
|
||||||
|
worker.terminate()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const downloadFile = async () => {
|
||||||
|
if (!text.value || !svgContent.value) return
|
||||||
|
|
||||||
|
const filename = `qr-code-${Date.now()}.${format.value}`
|
||||||
|
|
||||||
|
if (format.value === 'svg') {
|
||||||
|
let finalSvg = svgContent.value
|
||||||
|
if (!finalSvg.includes('width=')) {
|
||||||
|
finalSvg = finalSvg.replace('<svg ', `<svg width="${size.value}" height="${size.value}" `)
|
||||||
|
}
|
||||||
|
const blob = new Blob([finalSvg], { type: 'image/svg+xml' })
|
||||||
|
triggerDownload(blob, filename)
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
canvas.width = size.value
|
||||||
|
canvas.height = size.value
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
|
||||||
|
const img = new Image()
|
||||||
|
const svgSource = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgContent.value)}`
|
||||||
|
|
||||||
|
img.onload = () => {
|
||||||
|
if (format.value === 'jpeg' && isBgTransparent.value) {
|
||||||
|
ctx.fillStyle = '#ffffff'
|
||||||
|
ctx.fillRect(0, 0, size.value, size.value)
|
||||||
|
} else if (format.value === 'jpeg' && bgType.value !== 'solid') {
|
||||||
|
// Let the Canvas render the SVG's background gradient naturally instead of filling
|
||||||
|
// Though drawing bounding rect white might still be needed behind transparent parts
|
||||||
|
ctx.fillStyle = '#ffffff'
|
||||||
|
ctx.fillRect(0, 0, size.value, size.value)
|
||||||
|
}
|
||||||
|
ctx.drawImage(img, 0, 0, size.value, size.value)
|
||||||
|
|
||||||
|
const mime = format.value === 'jpeg' ? 'image/jpeg' : `image/${format.value}`
|
||||||
|
canvas.toBlob((blob) => {
|
||||||
|
if (blob) triggerDownload(blob, filename)
|
||||||
|
}, mime, 1.0)
|
||||||
|
}
|
||||||
|
img.onerror = (e) => {
|
||||||
|
console.error('Failed to load SVG for conversion', e)
|
||||||
|
}
|
||||||
|
img.src = svgSource
|
||||||
|
} 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 class="header-actions"></div>
|
||||||
|
</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>QR Style</label>
|
||||||
|
<select v-model="fgType" class="select-input">
|
||||||
|
<option value="solid">Solid Color</option>
|
||||||
|
<option value="linear">Linear Gradient</option>
|
||||||
|
<option value="radial">Radial Gradient</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="control-group">
|
||||||
|
<label>QR Color(s)</label>
|
||||||
|
<div class="color-picker-wrapper">
|
||||||
|
<input type="color" v-model="fgColor1" class="color-input">
|
||||||
|
<input type="color" v-model="fgColor2" v-if="fgType !== 'solid'" class="color-input">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="control-group">
|
||||||
|
<label>Background Style</label>
|
||||||
|
<select v-model="bgType" :disabled="isBgTransparent" class="select-input">
|
||||||
|
<option value="solid">Solid Color</option>
|
||||||
|
<option value="linear">Linear Gradient</option>
|
||||||
|
<option value="radial">Radial Gradient</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="control-group">
|
||||||
|
<label>Background Color(s)</label>
|
||||||
|
<div class="color-picker-wrapper">
|
||||||
|
<input type="color" v-model="bgColor1" :disabled="isBgTransparent" class="color-input">
|
||||||
|
<input type="color" v-model="bgColor2" v-if="bgType !== 'solid'" :disabled="isBgTransparent" class="color-input">
|
||||||
|
<label class="checkbox-label" style="margin-left: 0.5rem">
|
||||||
|
<input type="checkbox" v-model="isBgTransparent"> Transparent
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="preview-section" v-if="text" ref="previewRef" :style="{ height: previewHeight }">
|
||||||
|
<div class="qr-container">
|
||||||
|
<div
|
||||||
|
class="qr-frame"
|
||||||
|
:style="{
|
||||||
|
background: isBgTransparent ? 'white' : bgType === 'solid' ? bgColor1 : (bgType === 'linear' ? `linear-gradient(to bottom right, ${bgColor1}, ${bgColor2})` : `radial-gradient(circle, ${bgColor1}, ${bgColor2})`),
|
||||||
|
cursor: (fgType !== 'solid' || (!isBgTransparent && bgType !== 'solid')) ? 'pointer' : 'default'
|
||||||
|
}"
|
||||||
|
@click="handleFrameClick"
|
||||||
|
v-tooltip="(fgType !== 'solid' || (!isBgTransparent && bgType !== 'solid')) ? (showHandles ? 'Hide edit handles' : 'Show edit handles') : ''"
|
||||||
|
>
|
||||||
|
<div class="svg-wrapper" ref="qrFrameRef">
|
||||||
|
<div v-html="svgContent" class="svg-content-box"></div>
|
||||||
|
|
||||||
|
<template v-if="showHandles">
|
||||||
|
<!-- Background Gradient Handles -->
|
||||||
|
<template v-if="!isBgTransparent && bgType !== 'solid'">
|
||||||
|
<div class="grad-handle bg-handle handle-1" :style="{ left: bgGradPos.x1 + '%', top: bgGradPos.y1 + '%' }" @mousedown="startDrag($event, 'bg:1')" @touchstart.prevent="startDrag($event, 'bg:1')" @click.stop></div>
|
||||||
|
<div class="grad-handle bg-handle handle-2" :style="{ left: bgGradPos.x2 + '%', top: bgGradPos.y2 + '%' }" @mousedown="startDrag($event, 'bg:2')" @touchstart.prevent="startDrag($event, 'bg:2')" @click.stop></div>
|
||||||
|
<svg class="grad-line-svg"><line :x1="bgLinePts.x1 + '%'" :y1="bgLinePts.y1 + '%'" :x2="bgLinePts.x2 + '%'" :y2="bgLinePts.y2 + '%'" class="bg-line" /></svg>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Foreground Gradient Handles -->
|
||||||
|
<template v-if="fgType !== 'solid'">
|
||||||
|
<div class="grad-handle fg-handle handle-1" :style="{ left: fgGradPos.x1 + '%', top: fgGradPos.y1 + '%' }" @mousedown="startDrag($event, 'fg:1')" @touchstart.prevent="startDrag($event, 'fg:1')" @click.stop></div>
|
||||||
|
<div class="grad-handle fg-handle handle-2" :style="{ left: fgGradPos.x2 + '%', top: fgGradPos.y2 + '%' }" @mousedown="startDrag($event, 'fg:2')" @touchstart.prevent="startDrag($event, 'fg:2')" @click.stop></div>
|
||||||
|
<svg class="grad-line-svg"><line :x1="fgLinePts.x1 + '%'" :y1="fgLinePts.y1 + '%'" :x2="fgLinePts.x2 + '%'" :y2="fgLinePts.y2 + '%'" class="fg-line" /></svg>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="download-settings">
|
||||||
|
<div class="control-group">
|
||||||
|
<label>Size (px)</label>
|
||||||
|
<div class="number-control size-control">
|
||||||
|
<button class="control-btn" @click="size = Math.max(10, size - 100)" v-tooltip="'-100'" v-ripple>-100</button>
|
||||||
|
<button class="control-btn" @click="size = Math.max(10, size - 10)" v-tooltip="'-10'" v-ripple>-10</button>
|
||||||
|
<input type="number" v-model.number="size" class="number-input" />
|
||||||
|
<button class="control-btn" @click="size += 10" v-tooltip="'+10'" v-ripple>+10</button>
|
||||||
|
<button class="control-btn" @click="size += 100" v-tooltip="'+100'" v-ripple>+100</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="control-group">
|
||||||
|
<label>Format</label>
|
||||||
|
<select v-model="format" class="select-input format-select">
|
||||||
|
<option value="png">PNG</option>
|
||||||
|
<option value="jpeg">JPG</option>
|
||||||
|
<option value="webp">WebP</option>
|
||||||
|
<option value="svg">SVG</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn-neon primary" @click="downloadFile" v-ripple>
|
||||||
|
<Download size="18" />
|
||||||
|
Download
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.tool-panel {
|
||||||
|
padding: 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden; /* Prevent scrolling, force fit */
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 {
|
||||||
|
min-height: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-wrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
height: 38px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-input {
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid var(--panel-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
background: var(--panel-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-input:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.checkbox-label input {
|
||||||
|
cursor: pointer;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: rgba(0, 0, 0, 0.05);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 0.75rem 1rem 1.25rem;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-container {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
container-type: size;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
:root[data-theme="light"] .preview-section {
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-frame {
|
||||||
|
width: min(100cqw, 100cqh);
|
||||||
|
height: min(100cqw, 100cqh);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.svg-wrapper {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.svg-content-box {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.svg-content-box :deep(svg) {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grad-line-svg {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 15;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grad-handle {
|
||||||
|
position: absolute;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: grab;
|
||||||
|
z-index: 10;
|
||||||
|
touch-action: none;
|
||||||
|
background: rgb(255, 255, 255);
|
||||||
|
box-shadow: 0 0 4px 0px rgb(0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grad-handle:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fg-line, .bg-line {
|
||||||
|
stroke: rgb(255, 255, 255);
|
||||||
|
stroke-width: 1;
|
||||||
|
filter: drop-shadow(0px 0px 2px rgb(0, 0, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-settings {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.5rem;
|
||||||
|
align-items: flex-end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.size-control {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.size-control {
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.size-control .control-btn {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-select {
|
||||||
|
min-width: 100px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.preview-section {
|
||||||
|
padding: 1rem 0.5rem;
|
||||||
|
gap: 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-container {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
min-height: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-frame {
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
|
|
||||||
634
src/components/tools/QrScanner.vue
Normal file
634
src/components/tools/QrScanner.vue
Normal file
@@ -0,0 +1,634 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, onUnmounted, watch, computed } from 'vue'
|
||||||
|
import { SwitchCamera, Trash2, Copy, Download, X, QrCode } from 'lucide-vue-next'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { toBase64Url } from '@gkucmierz/utils'
|
||||||
|
import { useCamera } from '../../composables/useCamera'
|
||||||
|
import { useQrDetection } from '../../composables/useQrDetection'
|
||||||
|
|
||||||
|
const scannedCodes = ref([])
|
||||||
|
const isFullscreen = ref(false)
|
||||||
|
const videoAspect = ref(1)
|
||||||
|
const wrapperRef = ref(null)
|
||||||
|
const bgCanvas = ref(null)
|
||||||
|
let bgRafId = null
|
||||||
|
|
||||||
|
const videoRef = ref(null)
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const navigateToGenerateQr = (text) => {
|
||||||
|
const payload = toBase64Url(text)
|
||||||
|
router.push({ name: 'QrCode', params: { payload } })
|
||||||
|
}
|
||||||
|
const overlayCanvas = ref(null)
|
||||||
|
|
||||||
|
const {
|
||||||
|
stream,
|
||||||
|
facingMode,
|
||||||
|
hasMultipleCameras,
|
||||||
|
isMirrored,
|
||||||
|
error: cameraError,
|
||||||
|
checkCameras,
|
||||||
|
startCamera,
|
||||||
|
stopCamera,
|
||||||
|
switchCamera: baseSwitchCamera
|
||||||
|
} = useCamera(videoRef)
|
||||||
|
|
||||||
|
const {
|
||||||
|
error: detectionError,
|
||||||
|
isDetecting,
|
||||||
|
startDetection,
|
||||||
|
stopDetection
|
||||||
|
} = useQrDetection(videoRef, overlayCanvas)
|
||||||
|
|
||||||
|
const error = computed(() => cameraError.value || detectionError.value)
|
||||||
|
|
||||||
|
// Background Loop
|
||||||
|
const updateVideoAspect = () => {
|
||||||
|
if (videoRef.value && videoRef.value.videoWidth && videoRef.value.videoHeight) {
|
||||||
|
videoAspect.value = videoRef.value.videoWidth / videoRef.value.videoHeight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const startBackgroundLoop = () => {
|
||||||
|
const draw = () => {
|
||||||
|
const videoEl = videoRef.value
|
||||||
|
const canvas = bgCanvas.value
|
||||||
|
if (!videoEl || !canvas || videoEl.paused || videoEl.ended) {
|
||||||
|
bgRafId = requestAnimationFrame(draw)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const vw = videoEl.videoWidth || 0
|
||||||
|
const vh = videoEl.videoHeight || 0
|
||||||
|
if (!vw || !vh) {
|
||||||
|
bgRafId = requestAnimationFrame(draw)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const cw = Math.floor(window.innerWidth)
|
||||||
|
const ch = Math.floor(window.innerHeight * 0.5)
|
||||||
|
if (canvas.width !== cw || canvas.height !== ch) {
|
||||||
|
canvas.width = cw
|
||||||
|
canvas.height = ch
|
||||||
|
}
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
if (ctx) {
|
||||||
|
const scale = cw / vw
|
||||||
|
const srcH = ch / scale
|
||||||
|
const sx = 0
|
||||||
|
const sy = Math.max(0, (vh - srcH) / 2)
|
||||||
|
ctx.clearRect(0, 0, cw, ch)
|
||||||
|
ctx.drawImage(videoEl, sx, sy, vw, srcH, 0, 0, cw, ch)
|
||||||
|
}
|
||||||
|
bgRafId = requestAnimationFrame(draw)
|
||||||
|
}
|
||||||
|
if (bgRafId) cancelAnimationFrame(bgRafId)
|
||||||
|
bgRafId = requestAnimationFrame(draw)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopBackgroundLoop = () => {
|
||||||
|
if (bgRafId) {
|
||||||
|
cancelAnimationFrame(bgRafId)
|
||||||
|
bgRafId = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full screen styles
|
||||||
|
const desktopFullscreenStyle = computed(() => {
|
||||||
|
if (!isFullscreen.value) return {}
|
||||||
|
const isDesktop = window.innerWidth >= 768
|
||||||
|
if (!isDesktop) return {}
|
||||||
|
const halfHeight = Math.floor(window.innerHeight * 0.5)
|
||||||
|
const widthPx = Math.min(window.innerWidth, Math.floor(halfHeight * videoAspect.value))
|
||||||
|
return { height: `${halfHeight}px`, width: `${widthPx}px`, margin: '0 auto' }
|
||||||
|
})
|
||||||
|
|
||||||
|
const processCodes = (codes) => {
|
||||||
|
codes.forEach(code => {
|
||||||
|
const value = code.rawValue
|
||||||
|
if (value && !scannedCodes.value.some(c => c.value === value)) {
|
||||||
|
scannedCodes.value.unshift({
|
||||||
|
id: Date.now() + Math.random(),
|
||||||
|
value,
|
||||||
|
format: code.format,
|
||||||
|
timestamp: new Date().toLocaleTimeString()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const onDetect = (detectedCodes) => {
|
||||||
|
if (isFullscreen.value) {
|
||||||
|
processCodes(detectedCodes)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const videoEl = document.querySelector('.camera-wrapper video')
|
||||||
|
if (!videoEl || !videoEl.videoWidth || !videoEl.videoHeight) {
|
||||||
|
processCodes(detectedCodes)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { videoWidth, videoHeight } = videoEl
|
||||||
|
const isLandscape = videoWidth > videoHeight
|
||||||
|
let visibleX, visibleY, visibleW, visibleH
|
||||||
|
|
||||||
|
if (isLandscape) {
|
||||||
|
visibleH = videoHeight
|
||||||
|
visibleW = videoHeight
|
||||||
|
visibleX = (videoWidth - videoHeight) / 2
|
||||||
|
visibleY = 0
|
||||||
|
} else {
|
||||||
|
visibleW = videoWidth
|
||||||
|
visibleH = videoWidth
|
||||||
|
visibleX = 0
|
||||||
|
visibleY = (videoHeight - videoWidth) / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
const validCodes = detectedCodes.filter(code => {
|
||||||
|
if (!code.boundingBox) return true
|
||||||
|
const { x, y, width, height } = code.boundingBox
|
||||||
|
const centerX = x + width / 2
|
||||||
|
const centerY = y + height / 2
|
||||||
|
return (
|
||||||
|
centerX >= visibleX &&
|
||||||
|
centerX <= visibleX + visibleW &&
|
||||||
|
centerY >= visibleY &&
|
||||||
|
centerY <= visibleY + visibleH
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
processCodes(validCodes)
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadHistory = () => {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem('qr-history')
|
||||||
|
if (saved) {
|
||||||
|
scannedCodes.value = JSON.parse(saved)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load QR history', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(scannedCodes, (newVal) => {
|
||||||
|
localStorage.setItem('qr-history', JSON.stringify(newVal))
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
const handleKeydown = (e) => {
|
||||||
|
if (e.key === 'Escape' && isFullscreen.value) {
|
||||||
|
toggleFullscreen()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const startScan = async () => {
|
||||||
|
try {
|
||||||
|
await startCamera()
|
||||||
|
updateVideoAspect()
|
||||||
|
startBackgroundLoop()
|
||||||
|
startDetection(onDetect)
|
||||||
|
} catch (err) {
|
||||||
|
// Error is handled by error computed property
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
checkCameras()
|
||||||
|
loadHistory()
|
||||||
|
window.addEventListener('resize', updateVideoAspect)
|
||||||
|
window.addEventListener('resize', startBackgroundLoop)
|
||||||
|
window.addEventListener('keydown', handleKeydown)
|
||||||
|
|
||||||
|
watch(isFullscreen, (fs) => {
|
||||||
|
if (fs) {
|
||||||
|
startBackgroundLoop()
|
||||||
|
} else {
|
||||||
|
stopBackgroundLoop()
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
startScan()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('resize', updateVideoAspect)
|
||||||
|
window.removeEventListener('resize', startBackgroundLoop)
|
||||||
|
window.removeEventListener('keydown', handleKeydown)
|
||||||
|
stopDetection()
|
||||||
|
stopCamera()
|
||||||
|
})
|
||||||
|
|
||||||
|
const switchCamera = (event) => {
|
||||||
|
if (event) event.stopPropagation()
|
||||||
|
baseSwitchCamera()
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleFullscreen = () => {
|
||||||
|
isFullscreen.value = !isFullscreen.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearHistory = () => {
|
||||||
|
scannedCodes.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeCode = (id) => {
|
||||||
|
scannedCodes.value = scannedCodes.value.filter(c => c.id !== id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyAll = async () => {
|
||||||
|
if (scannedCodes.value.length === 0) return
|
||||||
|
const text = scannedCodes.value.map(c => c.value).join('\n')
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to copy', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const downloadJson = () => {
|
||||||
|
if (scannedCodes.value.length === 0) return
|
||||||
|
const data = JSON.stringify(scannedCodes.value, null, 2)
|
||||||
|
const blob = new Blob([data], { type: 'application/json' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = `qr-scan-history-${new Date().toISOString().slice(0, 10)}.json`
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
document.body.removeChild(a)
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyToClipboard = async (text) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to copy', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isUrl = (string) => {
|
||||||
|
try {
|
||||||
|
return Boolean(new URL(string))
|
||||||
|
} catch (_) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="tool-container full-width">
|
||||||
|
<div class="tool-panel">
|
||||||
|
<div class="panel-header" v-if="!isFullscreen">
|
||||||
|
<h2 class="tool-title">QR Scanner</h2>
|
||||||
|
<div class="header-actions"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Teleport to="body" :disabled="!isFullscreen">
|
||||||
|
<div class="scanner-content" :class="{ 'is-fullscreen': isFullscreen }">
|
||||||
|
<canvas
|
||||||
|
v-if="isFullscreen"
|
||||||
|
ref="bgCanvas"
|
||||||
|
class="camera-bg"
|
||||||
|
:class="{ 'is-mirrored': isMirrored }"
|
||||||
|
></canvas>
|
||||||
|
<button v-if="isFullscreen" class="close-fullscreen-btn" @click="toggleFullscreen">
|
||||||
|
<X size="24" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="camera-wrapper"
|
||||||
|
:class="{ 'clickable': !isFullscreen, 'is-mirrored': isMirrored }"
|
||||||
|
:style="desktopFullscreenStyle"
|
||||||
|
ref="wrapperRef"
|
||||||
|
@click="!isFullscreen && toggleFullscreen()"
|
||||||
|
>
|
||||||
|
<video
|
||||||
|
ref="videoRef"
|
||||||
|
class="camera-feed"
|
||||||
|
:class="{ 'is-mirrored': isMirrored }"
|
||||||
|
autoplay
|
||||||
|
playsinline
|
||||||
|
muted
|
||||||
|
></video>
|
||||||
|
|
||||||
|
<canvas ref="overlayCanvas" class="scan-overlay-canvas" :class="{ 'is-mirrored': isMirrored }"></canvas>
|
||||||
|
|
||||||
|
<div v-if="error" class="error-overlay">
|
||||||
|
<p>{{ error }}</p>
|
||||||
|
<button @click="startScan" class="retry-btn" v-ripple>Retry</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="hasMultipleCameras"
|
||||||
|
class="switch-camera-btn"
|
||||||
|
@click.stop="switchCamera"
|
||||||
|
v-tooltip="'Switch Camera'"
|
||||||
|
v-ripple
|
||||||
|
>
|
||||||
|
<SwitchCamera size="24" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="results-section">
|
||||||
|
<div class="results-header">
|
||||||
|
<h3>Scanned Codes ({{ scannedCodes.length }})</h3>
|
||||||
|
<div v-if="scannedCodes.length > 0" class="header-actions">
|
||||||
|
<button class="icon-btn" @click="copyAll" v-tooltip="'Copy All'" v-ripple>
|
||||||
|
<Copy size="18" />
|
||||||
|
</button>
|
||||||
|
<button class="icon-btn" @click="downloadJson" v-tooltip="'Download JSON'" v-ripple>
|
||||||
|
<Download size="18" />
|
||||||
|
</button>
|
||||||
|
<button class="icon-btn delete-btn" @click="clearHistory" v-tooltip="'Clear All'" v-ripple>
|
||||||
|
<Trash2 size="18" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="scannedCodes.length > 0" class="codes-list">
|
||||||
|
<div v-for="code in scannedCodes" :key="code.id" class="code-item">
|
||||||
|
<div class="code-content">
|
||||||
|
<div class="code-value">
|
||||||
|
<a v-if="isUrl(code.value)" :href="code.value" target="_blank" rel="noopener noreferrer">{{ code.value }}</a>
|
||||||
|
<span v-else>{{ code.value }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="code-meta">
|
||||||
|
<span class="timestamp">{{ code.timestamp }}</span>
|
||||||
|
<span class="format-badge">{{ code.format }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="item-actions">
|
||||||
|
<button class="icon-btn" @click="copyToClipboard(code.value)" v-tooltip="'Copy'" v-ripple>
|
||||||
|
<Copy size="18" />
|
||||||
|
</button>
|
||||||
|
<button class="icon-btn" @click="navigateToGenerateQr(code.value)" v-tooltip="'Generate QR Code'" v-ripple>
|
||||||
|
<QrCode size="18" />
|
||||||
|
</button>
|
||||||
|
<button class="icon-btn delete-btn" @click="removeCode(code.id)" v-tooltip="'Remove'" v-ripple>
|
||||||
|
<Trash2 size="18" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="empty-state">
|
||||||
|
Point camera at a QR code to scan
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
|
||||||
|
.scanner-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scanner-content.is-fullscreen {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 9999;
|
||||||
|
background: #000;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(:root[data-theme="light"] .scanner-content.is-fullscreen) {
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 500px;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
margin: 0 auto;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
background: #000;
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
box-shadow: var(--glass-shadow);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(:root[data-theme="light"] .camera-wrapper) {
|
||||||
|
background: #f1f5f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-wrapper.clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scanner-content.is-fullscreen .camera-wrapper {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
max-width: none;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 0;
|
||||||
|
border: none;
|
||||||
|
margin: 0;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-bg {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 50vh;
|
||||||
|
filter: blur(16px) saturate(110%);
|
||||||
|
opacity: 0.9;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
color: #ff4444;
|
||||||
|
padding: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-camera-btn {
|
||||||
|
position: absolute !important;
|
||||||
|
top: auto !important;
|
||||||
|
left: auto !important;
|
||||||
|
bottom: 0.75rem !important;
|
||||||
|
right: 0.75rem !important;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 20;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-camera-btn:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-fullscreen-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 1rem;
|
||||||
|
left: 1rem;
|
||||||
|
z-index: 20;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scanner-content.is-fullscreen .results-section {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
z-index: 2;
|
||||||
|
border-radius: 0;
|
||||||
|
background: var(--glass-bg);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid var(--glass-border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(:root[data-theme="light"] .scanner-content.is-fullscreen .results-section) {
|
||||||
|
background: rgba(255, 255, 255, 0.75);
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-value {
|
||||||
|
color: var(--primary-accent);
|
||||||
|
font-family: monospace;
|
||||||
|
word-break: break-all;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-value a {
|
||||||
|
color: var(--primary-accent);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-value a:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-badge {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
padding: 0 0.4rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(:root[data-theme="light"]) .format-badge {
|
||||||
|
background: rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(video) {
|
||||||
|
object-fit: cover !important;
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100% !important;
|
||||||
|
position: absolute !important;
|
||||||
|
left: 0 !important;
|
||||||
|
top: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.qrcode-stream-wrapper),
|
||||||
|
:deep(.qrcode-stream-overlay) {
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100% !important;
|
||||||
|
position: absolute !important;
|
||||||
|
inset: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Front camera mirror (CSS-only) */
|
||||||
|
.camera-wrapper.is-front :deep(video) {
|
||||||
|
transform: scaleX(-1);
|
||||||
|
transform-origin: center;
|
||||||
|
}
|
||||||
|
.camera-wrapper.is-front :deep(#qrcode-stream-pause-frame),
|
||||||
|
.camera-wrapper.is-front :deep(#qrcode-stream-overlay) {
|
||||||
|
transform: scaleX(-1);
|
||||||
|
transform-origin: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
:deep(.scanner-content.is-fullscreen .camera-wrapper video) {
|
||||||
|
object-fit: contain !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
464
src/components/tools/ToneGenerator.vue
Normal file
464
src/components/tools/ToneGenerator.vue
Normal file
@@ -0,0 +1,464 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, onUnmounted, watch } from 'vue'
|
||||||
|
import { Volume2, VolumeX, Play, Square, Activity } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const frequency = ref(440)
|
||||||
|
const volume = ref(20)
|
||||||
|
const waveform = ref('sine')
|
||||||
|
const isPlaying = ref(false)
|
||||||
|
|
||||||
|
const waveforms = [
|
||||||
|
{ value: 'sine', label: 'Sine', icon: '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12c3.5-8 5.5-8 9 0 3.5 8 5.5 8 9 0"/></svg>' },
|
||||||
|
{ value: 'square', label: 'Square', icon: '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 15h6V9h6v6h6"/></svg>' },
|
||||||
|
{ value: 'sawtooth', label: 'Sawtooth', icon: '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 15l8-8v8l8-8v8"/></svg>' },
|
||||||
|
{ value: 'triangle', label: 'Triangle', icon: '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12l4-6 8 12 6-8"/></svg>' }
|
||||||
|
]
|
||||||
|
|
||||||
|
let audioContext = null
|
||||||
|
let oscillator = null
|
||||||
|
let gainNode = null
|
||||||
|
|
||||||
|
const initAudio = () => {
|
||||||
|
if (!audioContext) {
|
||||||
|
audioContext = new (window.AudioContext || window.webkitAudioContext)()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateOscillator = () => {
|
||||||
|
if (oscillator && isPlaying.value) {
|
||||||
|
// Ramp to prevent clicking sounds
|
||||||
|
oscillator.frequency.setTargetAtTime(frequency.value, audioContext.currentTime, 0.05)
|
||||||
|
oscillator.type = waveform.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateVolume = () => {
|
||||||
|
if (gainNode && isPlaying.value) {
|
||||||
|
// Ramp to prevent clicking sounds
|
||||||
|
const gainValue = volume.value / 100
|
||||||
|
gainNode.gain.setTargetAtTime(gainValue, audioContext.currentTime, 0.05)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(frequency, updateOscillator)
|
||||||
|
watch(waveform, updateOscillator)
|
||||||
|
watch(volume, updateVolume)
|
||||||
|
|
||||||
|
const togglePlay = () => {
|
||||||
|
if (isPlaying.value) {
|
||||||
|
stopTone()
|
||||||
|
} else {
|
||||||
|
playTone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const playTone = () => {
|
||||||
|
initAudio()
|
||||||
|
|
||||||
|
if (audioContext.state === 'suspended') {
|
||||||
|
audioContext.resume()
|
||||||
|
}
|
||||||
|
|
||||||
|
oscillator = audioContext.createOscillator()
|
||||||
|
gainNode = audioContext.createGain()
|
||||||
|
|
||||||
|
oscillator.type = waveform.value
|
||||||
|
oscillator.frequency.setValueAtTime(frequency.value, audioContext.currentTime)
|
||||||
|
|
||||||
|
const gainValue = volume.value / 100
|
||||||
|
gainNode.gain.setValueAtTime(0, audioContext.currentTime)
|
||||||
|
gainNode.gain.setTargetAtTime(gainValue, audioContext.currentTime, 0.05)
|
||||||
|
|
||||||
|
oscillator.connect(gainNode)
|
||||||
|
gainNode.connect(audioContext.destination)
|
||||||
|
|
||||||
|
oscillator.start()
|
||||||
|
isPlaying.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopTone = () => {
|
||||||
|
if (oscillator && isPlaying.value) {
|
||||||
|
gainNode.gain.setTargetAtTime(0, audioContext.currentTime, 0.05)
|
||||||
|
setTimeout(() => {
|
||||||
|
if (oscillator) {
|
||||||
|
oscillator.stop()
|
||||||
|
oscillator.disconnect()
|
||||||
|
oscillator = null
|
||||||
|
}
|
||||||
|
if (gainNode) {
|
||||||
|
gainNode.disconnect()
|
||||||
|
gainNode = null
|
||||||
|
}
|
||||||
|
}, 100) // wait for ramp down
|
||||||
|
}
|
||||||
|
isPlaying.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFreqInput = (e) => {
|
||||||
|
let val = parseInt(e.target.value)
|
||||||
|
if (isNaN(val)) val = 440
|
||||||
|
// allow temporary out of bounds typing but bound eventually
|
||||||
|
frequency.value = val
|
||||||
|
}
|
||||||
|
|
||||||
|
const clampFreq = () => {
|
||||||
|
if (frequency.value < 1) frequency.value = 1
|
||||||
|
if (frequency.value > 24000) frequency.value = 24000
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
stopTone()
|
||||||
|
if (audioContext) {
|
||||||
|
audioContext.close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="tool-container full-width">
|
||||||
|
<div class="tool-panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<h2 class="tool-title">Tone Generator</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tone-controls">
|
||||||
|
<!-- Frequency Control -->
|
||||||
|
<div class="control-group">
|
||||||
|
<div class="number-input-container">
|
||||||
|
<span class="input-label">Frequency</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
class="bare-number-input"
|
||||||
|
v-model="frequency"
|
||||||
|
@change="clampFreq"
|
||||||
|
@input="handleFreqInput"
|
||||||
|
min="1"
|
||||||
|
max="24000"
|
||||||
|
/>
|
||||||
|
<span class="input-unit">Hz</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
class="slider"
|
||||||
|
v-model.number="frequency"
|
||||||
|
min="20"
|
||||||
|
max="10000"
|
||||||
|
step="1"
|
||||||
|
/>
|
||||||
|
<div class="presets">
|
||||||
|
<button class="btn-preset" @click="frequency = 440">A4 (440)</button>
|
||||||
|
<button class="btn-preset" @click="frequency = 528">C5 (528)</button>
|
||||||
|
<button class="btn-preset" @click="frequency = 432">A4 (432)</button>
|
||||||
|
<button class="btn-preset" @click="frequency = 1000">1 kHz</button>
|
||||||
|
<button class="btn-preset" @click="frequency = 10000">10 kHz</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Waveform Control -->
|
||||||
|
<div class="control-group">
|
||||||
|
<div class="control-header">
|
||||||
|
<label>Waveform</label>
|
||||||
|
<Activity size="18" class="label-icon" />
|
||||||
|
</div>
|
||||||
|
<div class="waveform-selector">
|
||||||
|
<button
|
||||||
|
v-for="wf in waveforms"
|
||||||
|
:key="wf.value"
|
||||||
|
class="wf-btn"
|
||||||
|
:class="{ active: waveform === wf.value }"
|
||||||
|
@click="waveform = wf.value"
|
||||||
|
v-ripple
|
||||||
|
>
|
||||||
|
<div class="wf-btn-content">
|
||||||
|
<span class="wf-label">{{ wf.label }}</span>
|
||||||
|
<span class="wf-icon" v-html="wf.icon"></span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Volume Control -->
|
||||||
|
<div class="control-group">
|
||||||
|
<div class="control-header">
|
||||||
|
<label>Volume</label>
|
||||||
|
<div class="vol-label">
|
||||||
|
<VolumeX v-if="volume == 0" size="18" />
|
||||||
|
<Volume2 v-else size="18" />
|
||||||
|
<span>{{ volume }}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
class="slider"
|
||||||
|
v-model.number="volume"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
step="1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Play Button -->
|
||||||
|
<div class="action-section">
|
||||||
|
<button
|
||||||
|
class="play-btn"
|
||||||
|
:class="{ 'is-playing': isPlaying }"
|
||||||
|
@click="togglePlay"
|
||||||
|
v-ripple
|
||||||
|
>
|
||||||
|
<template v-if="!isPlaying">
|
||||||
|
<Play size="24" fill="currentColor" />
|
||||||
|
<span>Play Tone</span>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<Square size="24" fill="currentColor" />
|
||||||
|
<span>Stop Tone</span>
|
||||||
|
</template>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.tone-controls {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
background: var(--glass-bg);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.tone-controls {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-header label {
|
||||||
|
font-weight: bold;
|
||||||
|
color: var(--text-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.label-icon {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.number-input-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
border: 1px solid var(--panel-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-label {
|
||||||
|
color: var(--text-strong);
|
||||||
|
font-weight: bold;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bare-number-input {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--primary-accent);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: bold;
|
||||||
|
text-align: right;
|
||||||
|
width: 80px;
|
||||||
|
outline: none;
|
||||||
|
font-family: inherit;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bare-number-input::-webkit-outer-spin-button,
|
||||||
|
.bare-number-input::-webkit-inner-spin-button {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.bare-number-input[type=number] {
|
||||||
|
-moz-appearance: textfield;
|
||||||
|
appearance: textfield;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-unit {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vol-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Common Slider Styles */
|
||||||
|
.slider {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 100%;
|
||||||
|
height: 6px;
|
||||||
|
background: var(--toggle-border);
|
||||||
|
border-radius: 5px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--primary-accent);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.1s;
|
||||||
|
box-shadow: 0 0 10px var(--primary-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider::-webkit-slider-thumb:hover {
|
||||||
|
transform: scale(1.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.presets {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: -0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-preset {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid var(--panel-border);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-preset:hover {
|
||||||
|
background: rgba(var(--primary-accent-rgb), 0.1);
|
||||||
|
color: var(--primary-accent);
|
||||||
|
border-color: var(--primary-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.waveform-selector {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.waveform-selector {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.wf-btn {
|
||||||
|
flex: 1;
|
||||||
|
min-width: calc(50% - 0.5rem);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.wf-btn {
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
border: 1px solid var(--panel-border);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: bold;
|
||||||
|
transition: all 0.2s;
|
||||||
|
flex: 1; /* make buttons take even space by default on desktop too */
|
||||||
|
}
|
||||||
|
|
||||||
|
.wf-btn-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wf-label {
|
||||||
|
flex: 1;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wf-icon {
|
||||||
|
display: flex;
|
||||||
|
opacity: 0.6;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wf-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wf-btn.active {
|
||||||
|
background: rgba(var(--primary-accent-rgb), 0.15);
|
||||||
|
color: var(--primary-accent);
|
||||||
|
border-color: var(--primary-accent);
|
||||||
|
box-shadow: 0 0 10px rgba(var(--primary-accent-rgb), 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wf-btn.active .wf-icon {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-section {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
background: var(--primary-accent);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 30px;
|
||||||
|
padding: 1rem 3rem;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
box-shadow: 0 4px 15px rgba(var(--primary-accent-rgb), 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-btn:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 20px rgba(var(--primary-accent-rgb), 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-btn.is-playing {
|
||||||
|
background: #ef4444; /* red for stop */
|
||||||
|
box-shadow: 0 4px 15px rgba(239, 68, 68, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-btn.is-playing:hover {
|
||||||
|
box-shadow: 0 6px 20px rgba(239, 68, 68, 0.4);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
323
src/components/tools/UrlCleaner.vue
Normal file
323
src/components/tools/UrlCleaner.vue
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, watch, onUnmounted } from 'vue'
|
||||||
|
import { Copy, Trash2, ExternalLink, Power, Zap, X, Settings, Download } from 'lucide-vue-next'
|
||||||
|
import { useExtension } from '../../composables/useExtension'
|
||||||
|
import { useUrlCleaner } from '../../composables/useUrlCleaner'
|
||||||
|
import ExtensionStatus from './common/ExtensionStatus.vue'
|
||||||
|
import UrlCleanerExceptionsModal from './UrlCleanerExceptionsModal.vue'
|
||||||
|
|
||||||
|
// Extension integration
|
||||||
|
const { isExtensionReady, isListening, lastClipboardText, startListening, stopListening, writeClipboard } = useExtension()
|
||||||
|
|
||||||
|
const inputUrl = ref('')
|
||||||
|
const showExceptionsModal = ref(false)
|
||||||
|
|
||||||
|
const {
|
||||||
|
cleanedHistory,
|
||||||
|
isWatchEnabled,
|
||||||
|
exceptions,
|
||||||
|
defaultExceptions,
|
||||||
|
processUrl: baseProcessUrl,
|
||||||
|
removeEntry,
|
||||||
|
clearHistory
|
||||||
|
} = useUrlCleaner()
|
||||||
|
|
||||||
|
// Watch for clipboard changes from extension
|
||||||
|
watch(lastClipboardText, (newText) => {
|
||||||
|
if (isWatchEnabled.value && newText) {
|
||||||
|
baseProcessUrl(newText, true, writeClipboard)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sync watch state with extension listener
|
||||||
|
watch(isWatchEnabled, (enabled) => {
|
||||||
|
if (enabled) {
|
||||||
|
startListening()
|
||||||
|
} else {
|
||||||
|
stopListening()
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
// Re-enable listening when extension becomes ready
|
||||||
|
watch(isExtensionReady, (ready) => {
|
||||||
|
if (ready && isWatchEnabled.value) {
|
||||||
|
startListening()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Toggle watch mode
|
||||||
|
const toggleWatch = () => {
|
||||||
|
isWatchEnabled.value = !isWatchEnabled.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyAllUrls = async () => {
|
||||||
|
if (cleanedHistory.value.length === 0) return
|
||||||
|
const text = cleanedHistory.value.map(item => item.cleaned).join('\n')
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to copy URLs', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const downloadJson = () => {
|
||||||
|
if (cleanedHistory.value.length === 0) return
|
||||||
|
|
||||||
|
const exportData = cleanedHistory.value.map(item => ({
|
||||||
|
url: item.cleaned,
|
||||||
|
original: item.original,
|
||||||
|
timestamp: item.timestamp
|
||||||
|
}))
|
||||||
|
|
||||||
|
const data = JSON.stringify(exportData, null, 2)
|
||||||
|
const blob = new Blob([data], { type: 'application/json' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = `cleaned-urls-${new Date().toISOString().slice(0, 10)}.json`
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
document.body.removeChild(a)
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manual clean
|
||||||
|
const handleClean = () => {
|
||||||
|
if (inputUrl.value) {
|
||||||
|
const urls = inputUrl.value.split(/\r?\n/).filter(line => line.trim().length > 0)
|
||||||
|
urls.forEach(url => {
|
||||||
|
baseProcessUrl(url.trim(), false, writeClipboard)
|
||||||
|
})
|
||||||
|
inputUrl.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyToClipboard = (text) => {
|
||||||
|
navigator.clipboard.writeText(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (isListening.value) {
|
||||||
|
stopListening()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="tool-container full-width">
|
||||||
|
<div class="tool-panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<h2 class="tool-title">URL Cleaner</h2>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button class="icon-btn settings-btn" @click="showExceptionsModal = true" v-tooltip="'Cleaning Exceptions'" v-ripple>
|
||||||
|
<Settings size="20" />
|
||||||
|
</button>
|
||||||
|
<ExtensionStatus :isReady="isExtensionReady" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="input-section">
|
||||||
|
<div class="input-wrapper">
|
||||||
|
<textarea
|
||||||
|
v-model="inputUrl"
|
||||||
|
placeholder="Paste URL(s) here to clean..."
|
||||||
|
class="tool-textarea url-input"
|
||||||
|
@keydown.enter.prevent="handleClean"
|
||||||
|
rows="1"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="watch-toggle">
|
||||||
|
<button class="btn-neon" @click="handleClean" v-ripple>
|
||||||
|
Clean
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn-neon toggle-btn"
|
||||||
|
:class="{ 'active': isWatchEnabled && isExtensionReady }"
|
||||||
|
@click="toggleWatch"
|
||||||
|
:disabled="!isExtensionReady"
|
||||||
|
v-tooltip="!isExtensionReady ? 'Extension required for auto-watch' : 'Automatically clean URLs from clipboard'"
|
||||||
|
v-ripple
|
||||||
|
>
|
||||||
|
<Power size="18" />
|
||||||
|
<span>Watch Clipboard</span>
|
||||||
|
<span v-if="isWatchEnabled && isExtensionReady" class="status-dot"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="history-section" v-if="cleanedHistory.length > 0">
|
||||||
|
<div class="history-header">
|
||||||
|
<h3>Cleaned URLs ({{ cleanedHistory.length }})</h3>
|
||||||
|
<div class="history-actions">
|
||||||
|
<button class="icon-btn" @click="copyAllUrls" v-tooltip="'Copy all URLs'" v-ripple>
|
||||||
|
<Copy size="18" />
|
||||||
|
</button>
|
||||||
|
<button class="icon-btn" @click="downloadJson" v-tooltip="'Download JSON'" v-ripple>
|
||||||
|
<Download size="18" />
|
||||||
|
</button>
|
||||||
|
<button class="icon-btn delete-btn" @click="clearHistory" v-tooltip="'Clear History'" v-ripple>
|
||||||
|
<Trash2 size="18" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="history-list">
|
||||||
|
<div v-for="item in cleanedHistory" :key="item.id" class="history-item">
|
||||||
|
<div class="item-info">
|
||||||
|
<div class="cleaned-url">{{ item.cleaned }}</div>
|
||||||
|
<div class="meta-info">
|
||||||
|
<span class="timestamp">{{ item.timestamp }}</span>
|
||||||
|
<span class="savings" v-if="item.savedPercent > 0">
|
||||||
|
<Zap size="12" /> -{{ item.savedPercent }}% junk removed
|
||||||
|
</span>
|
||||||
|
<span class="no-change" v-else>No junk found</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="item-actions">
|
||||||
|
<button class="icon-btn" @click="copyToClipboard(item.cleaned)" v-tooltip="'Copy'" v-ripple>
|
||||||
|
<Copy size="16" />
|
||||||
|
</button>
|
||||||
|
<a :href="item.cleaned" target="_blank" class="icon-btn" v-tooltip="'Open'" v-ripple>
|
||||||
|
<ExternalLink size="16" />
|
||||||
|
</a>
|
||||||
|
<button class="icon-btn delete-btn" @click="removeEntry(item.id)" v-tooltip="'Remove'" v-ripple>
|
||||||
|
<X size="18" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="empty-state" v-else>
|
||||||
|
<p>Paste a URL above or enable "Watch Clipboard" to automatically clean links.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UrlCleanerExceptionsModal
|
||||||
|
:isOpen="showExceptionsModal"
|
||||||
|
:exceptions="exceptions"
|
||||||
|
:defaultRules="defaultExceptions"
|
||||||
|
@update:exceptions="exceptions = $event"
|
||||||
|
@close="showExceptionsModal = false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.input-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
background: var(--glass-bg);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.input-section {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-wrapper {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.input-wrapper {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.watch-toggle {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-btn {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-input {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 120px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.watch-toggle {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-btn.active {
|
||||||
|
background: rgba(var(--primary-accent-rgb), 0.2);
|
||||||
|
border-color: var(--primary-accent);
|
||||||
|
color: var(--primary-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cleaned-url {
|
||||||
|
color: var(--primary-accent);
|
||||||
|
font-family: monospace;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-info {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.savings {
|
||||||
|
color: #4ade80;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-btn {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
color: var(--primary-accent);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
705
src/components/tools/UrlCleanerExceptionsModal.vue
Normal file
705
src/components/tools/UrlCleanerExceptionsModal.vue
Normal file
@@ -0,0 +1,705 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed, watch, onUnmounted } from 'vue'
|
||||||
|
import { X, Plus, Trash2, RotateCcw } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
isOpen: Boolean,
|
||||||
|
exceptions: Array,
|
||||||
|
defaultRules: Array
|
||||||
|
})
|
||||||
|
|
||||||
|
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({
|
||||||
|
domainPattern: '',
|
||||||
|
keepParams: [],
|
||||||
|
keepHash: false,
|
||||||
|
keepAllParams: false
|
||||||
|
})
|
||||||
|
|
||||||
|
const localExceptions = computed({
|
||||||
|
get: () => props.exceptions,
|
||||||
|
set: (val) => emit('update:exceptions', val)
|
||||||
|
})
|
||||||
|
|
||||||
|
const addRule = () => {
|
||||||
|
if (!newRule.value.domainPattern) return
|
||||||
|
|
||||||
|
// Flush any pending text in the param input before adding rule
|
||||||
|
if (pendingParamInput.value.trim()) {
|
||||||
|
addPendingParam(pendingParamInput.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = [...newRule.value.keepParams]
|
||||||
|
|
||||||
|
const existingRuleIndex = localExceptions.value.findIndex(
|
||||||
|
r => r.domainPattern === newRule.value.domainPattern
|
||||||
|
)
|
||||||
|
|
||||||
|
if (existingRuleIndex >= 0) {
|
||||||
|
// Merge with existing rule
|
||||||
|
const existingRule = localExceptions.value[existingRuleIndex]
|
||||||
|
|
||||||
|
// Merge params (unique)
|
||||||
|
const mergedParams = [...new Set([...existingRule.keepParams, ...params])]
|
||||||
|
|
||||||
|
// Merge keepHash
|
||||||
|
const mergedKeepHash = existingRule.keepHash || newRule.value.keepHash
|
||||||
|
const mergedKeepAllParams = !!existingRule.keepAllParams || !!newRule.value.keepAllParams
|
||||||
|
|
||||||
|
const updatedRule = {
|
||||||
|
...existingRule,
|
||||||
|
keepParams: mergedParams,
|
||||||
|
keepHash: mergedKeepHash,
|
||||||
|
keepAllParams: mergedKeepAllParams,
|
||||||
|
isEnabled: true // Re-enable if disabled
|
||||||
|
}
|
||||||
|
|
||||||
|
const newExceptions = [...localExceptions.value]
|
||||||
|
newExceptions[existingRuleIndex] = updatedRule
|
||||||
|
localExceptions.value = newExceptions
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// Create new rule
|
||||||
|
const rule = {
|
||||||
|
id: Date.now().toString(),
|
||||||
|
domainPattern: newRule.value.domainPattern,
|
||||||
|
keepParams: params,
|
||||||
|
keepHash: newRule.value.keepHash,
|
||||||
|
keepAllParams: newRule.value.keepAllParams,
|
||||||
|
isEnabled: true,
|
||||||
|
isDefault: false
|
||||||
|
}
|
||||||
|
localExceptions.value = [...localExceptions.value, rule]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset form
|
||||||
|
newRule.value = {
|
||||||
|
domainPattern: '',
|
||||||
|
keepParams: [],
|
||||||
|
keepHash: false,
|
||||||
|
keepAllParams: false
|
||||||
|
}
|
||||||
|
pendingParamInput.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingParamInput = ref('')
|
||||||
|
|
||||||
|
const handleParamInputKeydown = (e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ',' || e.key === ' ') {
|
||||||
|
e.preventDefault()
|
||||||
|
addPendingParam(pendingParamInput.value)
|
||||||
|
} else if (e.key === 'Backspace' && pendingParamInput.value === '') {
|
||||||
|
// Remove last param if backspace is pressed on empty input
|
||||||
|
if (newRule.value.keepParams.length > 0) {
|
||||||
|
newRule.value.keepParams.pop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const addPendingParam = (val) => {
|
||||||
|
const cleanVals = val.split(/[\s,]+/).map(v => v.trim()).filter(Boolean)
|
||||||
|
if (cleanVals.length > 0) {
|
||||||
|
const updatedParams = [...new Set([...newRule.value.keepParams, ...cleanVals])]
|
||||||
|
newRule.value.keepParams = updatedParams
|
||||||
|
}
|
||||||
|
pendingParamInput.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeNewRuleParam = (paramToRemove) => {
|
||||||
|
newRule.value.keepParams = newRule.value.keepParams.filter(p => p !== paramToRemove)
|
||||||
|
}
|
||||||
|
|
||||||
|
const editRule = (rule) => {
|
||||||
|
newRule.value = {
|
||||||
|
domainPattern: rule.domainPattern,
|
||||||
|
keepParams: Array.isArray(rule.keepParams) ? [...rule.keepParams] : [],
|
||||||
|
keepHash: !!rule.keepHash,
|
||||||
|
keepAllParams: !!rule.keepAllParams
|
||||||
|
}
|
||||||
|
pendingParamInput.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeRule = (id) => {
|
||||||
|
localExceptions.value = localExceptions.value.filter(r => r.id !== id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleRule = (id) => {
|
||||||
|
localExceptions.value = localExceptions.value.map(r => {
|
||||||
|
if (r.id === id) {
|
||||||
|
return { ...r, isEnabled: !r.isEnabled }
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeParam = (ruleId, param) => {
|
||||||
|
localExceptions.value = localExceptions.value.map(r => {
|
||||||
|
if (r.id === ruleId) {
|
||||||
|
return {
|
||||||
|
...r,
|
||||||
|
keepParams: r.keepParams.filter(p => p !== param)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleKeepHash = (ruleId) => {
|
||||||
|
localExceptions.value = localExceptions.value.map(r => {
|
||||||
|
if (r.id === ruleId) {
|
||||||
|
return { ...r, keepHash: !r.keepHash }
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleKeepAllParams = (ruleId) => {
|
||||||
|
localExceptions.value = localExceptions.value.map(r => {
|
||||||
|
if (r.id === ruleId) {
|
||||||
|
return { ...r, keepAllParams: !r.keepAllParams }
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetToDefault = (ruleId) => {
|
||||||
|
const defaultRule = (props.defaultRules || []).find(r => r.id === ruleId)
|
||||||
|
if (!defaultRule) return
|
||||||
|
|
||||||
|
localExceptions.value = localExceptions.value.map(r => {
|
||||||
|
if (r.id !== ruleId) return r
|
||||||
|
return {
|
||||||
|
...r,
|
||||||
|
domainPattern: defaultRule.domainPattern,
|
||||||
|
keepParams: Array.isArray(defaultRule.keepParams) ? [...defaultRule.keepParams] : [],
|
||||||
|
keepHash: !!defaultRule.keepHash,
|
||||||
|
keepAllParams: !!defaultRule.keepAllParams,
|
||||||
|
isEnabled: true,
|
||||||
|
isDefault: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="isOpen" class="modal-overlay" @click.self="$emit('close')">
|
||||||
|
<div class="modal-content glass-panel">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>URL Cleaning Exceptions</h3>
|
||||||
|
<button class="close-btn" @click="$emit('close')">
|
||||||
|
<X size="24" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-body">
|
||||||
|
<p class="description">
|
||||||
|
Define rules to keep specific parameters or anchors for certain domains.
|
||||||
|
Standard cleaning removes all parameters and anchors.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="add-rule-form">
|
||||||
|
<h4>Add New Exception</h4>
|
||||||
|
<div class="form-row">
|
||||||
|
<input
|
||||||
|
v-model="newRule.domainPattern"
|
||||||
|
placeholder="Domain (e.g. *.youtube.com)"
|
||||||
|
class="input-field"
|
||||||
|
@keyup.enter="addRule"
|
||||||
|
>
|
||||||
|
<div class="token-input-field input-field" @click="$refs.paramInput?.focus()">
|
||||||
|
<span v-for="param in newRule.keepParams" :key="param" class="token-badge">
|
||||||
|
{{ param }}
|
||||||
|
<button class="remove-token-btn" @click.stop="removeNewRuleParam(param)">
|
||||||
|
<X size="12" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
ref="paramInput"
|
||||||
|
v-model="pendingParamInput"
|
||||||
|
placeholder="Params (Space, Comma or Enter to add)"
|
||||||
|
class="token-raw-input"
|
||||||
|
@keydown="handleParamInputKeydown"
|
||||||
|
@blur="addPendingParam(pendingParamInput)"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row checkbox-row">
|
||||||
|
<div class="checkbox-group">
|
||||||
|
<label class="checkbox-label" v-ripple>
|
||||||
|
<input type="checkbox" v-model="newRule.keepHash">
|
||||||
|
Keep Anchor (#)
|
||||||
|
</label>
|
||||||
|
<label class="checkbox-label" v-ripple>
|
||||||
|
<input type="checkbox" v-model="newRule.keepAllParams">
|
||||||
|
Keep all params
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button class="btn-neon small" @click="addRule" :disabled="!newRule.domainPattern" v-ripple>
|
||||||
|
<Plus size="16" /> Add Rule
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rules-list">
|
||||||
|
<h4>Active Rules</h4>
|
||||||
|
<div v-if="localExceptions.length === 0" class="empty-rules">
|
||||||
|
No exceptions defined.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-for="rule in localExceptions" :key="rule.id" class="rule-item" :class="{ disabled: !rule.isEnabled }">
|
||||||
|
<div class="rule-info">
|
||||||
|
<div class="rule-domain" @click="editRule(rule)" v-tooltip="'Click to edit'">{{ rule.domainPattern }}</div>
|
||||||
|
<div class="rule-details">
|
||||||
|
<div class="params-list">
|
||||||
|
<template v-if="rule.keepAllParams">
|
||||||
|
<span class="detail-tag">
|
||||||
|
Keep all params
|
||||||
|
<button class="remove-param-btn" @click.stop="toggleKeepAllParams(rule.id)" v-tooltip="'Disable keep all params'">
|
||||||
|
<X size="12" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<span v-for="param in rule.keepParams" :key="param" class="detail-tag">
|
||||||
|
{{ param }}
|
||||||
|
<button class="remove-param-btn" @click.stop="removeParam(rule.id, param)" v-tooltip="'Remove parameter'">
|
||||||
|
<X size="12" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<span v-if="rule.keepHash" class="detail-tag hash-tag">
|
||||||
|
Keep #
|
||||||
|
<button class="remove-param-btn" @click.stop="toggleKeepHash(rule.id)" v-tooltip="'Remove hash exception'">
|
||||||
|
<X size="12" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
<span v-if="!rule.keepAllParams && rule.keepParams.length === 0 && !rule.keepHash" class="no-params">No params kept</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rule-actions">
|
||||||
|
<button
|
||||||
|
class="icon-btn"
|
||||||
|
@click="toggleRule(rule.id)"
|
||||||
|
v-tooltip="rule.isEnabled ? 'Disable rule' : 'Enable rule'"
|
||||||
|
v-ripple
|
||||||
|
>
|
||||||
|
<div class="toggle-switch" :class="{ active: rule.isEnabled }"></div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="!rule.isDefault"
|
||||||
|
class="icon-btn delete-btn"
|
||||||
|
@click="removeRule(rule.id)"
|
||||||
|
v-tooltip="'Remove rule'"
|
||||||
|
v-ripple
|
||||||
|
>
|
||||||
|
<Trash2 size="18" />
|
||||||
|
</button>
|
||||||
|
<button v-else class="btn-neon small default-reset" @click="resetToDefault(rule.id)" v-tooltip="'Restore default rule'" v-ripple>
|
||||||
|
<RotateCcw size="16" /> Default
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 1000;
|
||||||
|
backdrop-filter: blur(5px);
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 0;
|
||||||
|
max-width: 800px;
|
||||||
|
width: 100%;
|
||||||
|
max-height: 85vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--glass-border);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.modal-header {
|
||||||
|
padding: 0.8rem 1.2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
background: var(--title-gradient);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn:hover {
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: 1.5rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.modal-body {
|
||||||
|
padding: 1rem 1.2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-rule-form {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-rule-form h4, .rules-list h4 {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--text-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.8rem;
|
||||||
|
margin-bottom: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.form-row {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-row {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-group {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-neon {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-row {
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-field {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.6rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--toggle-border);
|
||||||
|
background: var(--toggle-bg);
|
||||||
|
color: var(--text-color);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-field:focus, .token-input-field:focus-within {
|
||||||
|
border-color: var(--primary-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-input-field {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4rem;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
cursor: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-raw-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 150px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-color);
|
||||||
|
outline: none;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
padding: 0.2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-raw-input:focus {
|
||||||
|
border: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
background: rgba(var(--primary-accent-rgb), 0.15);
|
||||||
|
border: 1px solid rgba(var(--primary-accent-rgb), 0.3);
|
||||||
|
color: var(--primary-accent);
|
||||||
|
padding: 0.15rem 0.4rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-token-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.7;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-token-btn:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.4rem 0.8rem;
|
||||||
|
background: var(--toggle-bg);
|
||||||
|
border: 1px solid var(--toggle-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label:hover {
|
||||||
|
border-color: var(--toggle-hover-border);
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label:has(input:checked) {
|
||||||
|
background: rgba(var(--primary-accent-rgb), 0.2);
|
||||||
|
border-color: var(--primary-accent);
|
||||||
|
color: var(--primary-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label input {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
height: 0;
|
||||||
|
width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-neon.small {
|
||||||
|
padding: 0.4rem 1rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rules-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.8rem;
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
transition: opacity 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-item.disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-info {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-domain {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--primary-accent);
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-domain:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-details {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.params-list {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.remove-param-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-param-btn:hover {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-params {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-switch {
|
||||||
|
width: 36px;
|
||||||
|
height: 20px;
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 10px;
|
||||||
|
position: relative;
|
||||||
|
transition: background 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-switch::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 2px;
|
||||||
|
top: 2px;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: transform 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-switch.active {
|
||||||
|
background: #4ade80;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-switch.active::after {
|
||||||
|
transform: translateX(16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.default-reset {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-rules {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-btn:hover {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
216
src/components/tools/common/ExtensionStatus.vue
Normal file
216
src/components/tools/common/ExtensionStatus.vue
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
inheritAttrs: false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, watch, onUnmounted } from 'vue'
|
||||||
|
import { Plug, Plus, X } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
isReady: Boolean
|
||||||
|
})
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="extension-status" v-bind="$attrs" :class="{ 'is-ready': isReady }" @click="showModal = true" v-tooltip="isReady ? 'Extension Connected' : 'Extension Not Connected'">
|
||||||
|
<Plug v-if="isReady" size="18" />
|
||||||
|
<Plus v-else size="18" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="showModal" class="modal-overlay" @click.self="showModal = false">
|
||||||
|
<div class="modal-content glass-panel">
|
||||||
|
<button class="close-btn" @click="showModal = false">
|
||||||
|
<X size="24" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div v-if="!isReady">
|
||||||
|
<h3>Enhance Your Experience</h3>
|
||||||
|
<p>
|
||||||
|
Install our browser extension to enable <strong>background clipboard features</strong>!
|
||||||
|
</p>
|
||||||
|
<p class="description">
|
||||||
|
Without the extension, this tool can only access clipboard when the tab is active.
|
||||||
|
With the extension, you can capture and process content even when you're working in other apps.
|
||||||
|
</p>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<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 v-else>
|
||||||
|
<h3>Extension Connected!</h3>
|
||||||
|
<p>
|
||||||
|
You have successfully enabled <strong>background clipboard integration</strong>.
|
||||||
|
</p>
|
||||||
|
<p class="description">
|
||||||
|
The extension is active and ready to process your clipboard in the background.
|
||||||
|
</p>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn-neon" @click="showModal = false" v-ripple>Got it!</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.extension-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(:root[data-theme="light"]) .extension-status {
|
||||||
|
background: rgba(0, 0, 0, 0.05);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.extension-status:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
color: var(--primary-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(:root[data-theme="light"]) .extension-status:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 1000;
|
||||||
|
backdrop-filter: blur(5px);
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(:root[data-theme="light"]) .modal-overlay {
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 2.5rem;
|
||||||
|
max-width: 500px;
|
||||||
|
width: 90%;
|
||||||
|
position: relative;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.5rem;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn:hover {
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
font-size: 1.8rem; /* Większy nagłówek */
|
||||||
|
background: var(--title-gradient);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
color: transparent;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: var(--text-color); /* Wymuś kolor */
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions .btn-neon {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
padding: 0.8rem 2.5rem;
|
||||||
|
min-width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
strong {
|
||||||
|
color: var(--primary-accent);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.extension-status.is-ready {
|
||||||
|
color: #4ade80;
|
||||||
|
background: rgba(74, 222, 128, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.extension-status.is-ready:hover {
|
||||||
|
background: rgba(74, 222, 128, 0.2);
|
||||||
|
color: #4ade80;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(:root[data-theme="light"]) .extension-status.is-ready {
|
||||||
|
background: rgba(74, 222, 128, 0.15);
|
||||||
|
color: #16a34a; /* Darker green for light mode */
|
||||||
|
}
|
||||||
|
</style>
|
||||||
110
src/composables/useCamera.js
Normal file
110
src/composables/useCamera.js
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { ref, watch, onUnmounted } from 'vue'
|
||||||
|
|
||||||
|
export function useCamera(videoRef) {
|
||||||
|
const stream = ref(null)
|
||||||
|
const facingMode = ref('environment')
|
||||||
|
const hasMultipleCameras = ref(false)
|
||||||
|
const isMirrored = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
|
||||||
|
const checkCameras = async () => {
|
||||||
|
try {
|
||||||
|
if (!navigator.mediaDevices || !navigator.mediaDevices.enumerateDevices) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const devices = await navigator.mediaDevices.enumerateDevices()
|
||||||
|
const cameras = devices.filter(d => d.kind === 'videoinput')
|
||||||
|
hasMultipleCameras.value = cameras.length > 1
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error checking cameras:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopCamera = () => {
|
||||||
|
if (stream.value) {
|
||||||
|
stream.value.getTracks().forEach(t => t.stop())
|
||||||
|
stream.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const startCamera = async () => {
|
||||||
|
stopCamera()
|
||||||
|
error.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const constraints = {
|
||||||
|
video: {
|
||||||
|
facingMode: facingMode.value,
|
||||||
|
width: { ideal: 1280 },
|
||||||
|
height: { ideal: 720 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mediaStream = await navigator.mediaDevices.getUserMedia(constraints)
|
||||||
|
stream.value = mediaStream
|
||||||
|
|
||||||
|
// Detect actual facing mode to mirror front camera correctly
|
||||||
|
const videoTrack = mediaStream.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 = mediaStream
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
videoRef.value.onloadedmetadata = () => {
|
||||||
|
videoRef.value.play().catch(e => console.error('Play error', e))
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (err.name === 'NotAllowedError') {
|
||||||
|
error.value = 'Camera permission denied'
|
||||||
|
} else if (err.name === 'NotFoundError') {
|
||||||
|
error.value = 'No camera found'
|
||||||
|
} else {
|
||||||
|
error.value = `Camera error: ${err.name}`
|
||||||
|
}
|
||||||
|
throw err // Let caller know it failed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const switchCamera = () => {
|
||||||
|
facingMode.value = facingMode.value === 'environment' ? 'user' : 'environment'
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(facingMode, () => {
|
||||||
|
if (stream.value) {
|
||||||
|
// Re-start if already running
|
||||||
|
startCamera().catch(() => { })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
stopCamera()
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
stream,
|
||||||
|
facingMode,
|
||||||
|
hasMultipleCameras,
|
||||||
|
isMirrored,
|
||||||
|
error,
|
||||||
|
checkCameras,
|
||||||
|
startCamera,
|
||||||
|
stopCamera,
|
||||||
|
switchCamera
|
||||||
|
}
|
||||||
|
}
|
||||||
79
src/composables/useExtension.js
Normal file
79
src/composables/useExtension.js
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
|
|
||||||
|
export function useExtension() {
|
||||||
|
const isExtensionReady = ref(false)
|
||||||
|
const isListening = ref(false)
|
||||||
|
const lastClipboardText = ref('')
|
||||||
|
let extensionCheckInterval = null
|
||||||
|
let lastPongTime = Date.now()
|
||||||
|
|
||||||
|
const PING_INTERVAL = 200
|
||||||
|
const TIMEOUT_THRESHOLD = 1000
|
||||||
|
|
||||||
|
const handleMessage = (event) => {
|
||||||
|
if (event.source !== window) return
|
||||||
|
|
||||||
|
if (event.data.type === 'TOOLS_APP_EXTENSION_READY' || event.data.type === 'TOOLS_APP_PONG') {
|
||||||
|
isExtensionReady.value = true
|
||||||
|
lastPongTime = Date.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.data.type === 'TOOLS_APP_CLIPBOARD_UPDATE') {
|
||||||
|
const text = event.data.content
|
||||||
|
// Only update if listening
|
||||||
|
if (isListening.value && text) {
|
||||||
|
lastClipboardText.value = text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const startWatchdog = () => {
|
||||||
|
extensionCheckInterval = setInterval(() => {
|
||||||
|
window.postMessage({ type: 'TOOLS_APP_PING' }, '*')
|
||||||
|
if (Date.now() - lastPongTime > TIMEOUT_THRESHOLD) {
|
||||||
|
isExtensionReady.value = false
|
||||||
|
}
|
||||||
|
}, PING_INTERVAL)
|
||||||
|
}
|
||||||
|
|
||||||
|
const startListening = () => {
|
||||||
|
window.postMessage({ type: 'TOOLS_APP_START_SNIFFING' }, '*')
|
||||||
|
isListening.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopListening = () => {
|
||||||
|
window.postMessage({ type: 'TOOLS_APP_STOP_SNIFFING' }, '*')
|
||||||
|
isListening.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const writeClipboard = (text) => {
|
||||||
|
// Send to extension (background -> offscreen -> clipboard)
|
||||||
|
window.postMessage({ type: 'TOOLS_APP_CLIPBOARD_WRITE', content: text }, '*')
|
||||||
|
// Update local state to avoid echo loop if we are listening
|
||||||
|
lastClipboardText.value = text
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener('message', handleMessage)
|
||||||
|
// Initial check
|
||||||
|
window.postMessage({ type: 'TOOLS_APP_INIT' }, '*')
|
||||||
|
startWatchdog()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (isListening.value) {
|
||||||
|
stopListening()
|
||||||
|
}
|
||||||
|
if (extensionCheckInterval) clearInterval(extensionCheckInterval)
|
||||||
|
window.removeEventListener('message', handleMessage)
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
isExtensionReady,
|
||||||
|
isListening,
|
||||||
|
lastClipboardText,
|
||||||
|
startListening,
|
||||||
|
stopListening,
|
||||||
|
writeClipboard
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(() => {
|
||||||
|
|||||||
22
src/composables/useLocalStorage.js
Normal file
22
src/composables/useLocalStorage.js
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { ref, watch } from 'vue'
|
||||||
|
|
||||||
|
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
|
||||||
|
const storedValue = localStorage.getItem(prefixedKey)
|
||||||
|
const data = ref(storedValue ? JSON.parse(storedValue) : defaultValue)
|
||||||
|
|
||||||
|
// Watch for changes and update local storage
|
||||||
|
watch(data, (newValue) => {
|
||||||
|
if (newValue === null || newValue === undefined) {
|
||||||
|
localStorage.removeItem(prefixedKey)
|
||||||
|
} else {
|
||||||
|
localStorage.setItem(prefixedKey, JSON.stringify(newValue))
|
||||||
|
}
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
return data
|
||||||
|
}
|
||||||
174
src/composables/useQrDetection.js
Normal file
174
src/composables/useQrDetection.js
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
|
|
||||||
|
export function useQrDetection(videoRef, overlayCanvasRef) {
|
||||||
|
let barcodeDetector = null // must be plain variable, NOT a Vue ref (Proxy breaks native private fields)
|
||||||
|
const isDetecting = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
let scanRafId = null
|
||||||
|
|
||||||
|
// Function to initialize detector
|
||||||
|
const initDetector = async () => {
|
||||||
|
if (!barcodeDetector) {
|
||||||
|
if ('BarcodeDetector' in window) {
|
||||||
|
try {
|
||||||
|
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) {
|
||||||
|
barcodeDetector = new window.BarcodeDetector()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
error.value = 'Barcode Detection API not supported on this device/browser.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const paintDetections = (codes) => {
|
||||||
|
const canvas = overlayCanvasRef.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
|
||||||
|
// 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 startDetection = async (onDetectCallback) => {
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
await initDetector()
|
||||||
|
if (!barcodeDetector) {
|
||||||
|
if (!error.value) error.value = 'Barcode Detector failed to initialize'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isDetecting.value = true
|
||||||
|
|
||||||
|
const detectLoop = async () => {
|
||||||
|
const video = videoRef.value
|
||||||
|
if (!isDetecting.value) return
|
||||||
|
if (!video || video.readyState < 2) {
|
||||||
|
scanRafId = requestAnimationFrame(detectLoop)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const codes = await barcodeDetector.detect(video)
|
||||||
|
paintDetections(codes)
|
||||||
|
if (codes.length > 0 && onDetectCallback) {
|
||||||
|
onDetectCallback(codes)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Silent catch for intermittent detection frames failing
|
||||||
|
}
|
||||||
|
if (isDetecting.value) {
|
||||||
|
scanRafId = requestAnimationFrame(detectLoop)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
detectLoop() // start loop
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
error.value = `Detection error: ${e.message}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopDetection = () => {
|
||||||
|
isDetecting.value = false
|
||||||
|
if (scanRafId) cancelAnimationFrame(scanRafId)
|
||||||
|
// Clear canvas
|
||||||
|
if (overlayCanvasRef.value) {
|
||||||
|
const ctx = overlayCanvasRef.value.getContext('2d')
|
||||||
|
ctx.clearRect(0, 0, overlayCanvasRef.value.width, overlayCanvasRef.value.height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
stopDetection()
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
error,
|
||||||
|
isDetecting,
|
||||||
|
startDetection,
|
||||||
|
stopDetection
|
||||||
|
}
|
||||||
|
}
|
||||||
34
src/composables/useTooltip.js
Normal file
34
src/composables/useTooltip.js
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { reactive } from 'vue'
|
||||||
|
|
||||||
|
export const tooltipState = reactive({
|
||||||
|
isVisible: false,
|
||||||
|
text: '',
|
||||||
|
targetRect: null
|
||||||
|
})
|
||||||
|
|
||||||
|
export function showTooltip(el, text) {
|
||||||
|
if (!text) return;
|
||||||
|
|
||||||
|
if (!el.hasAttribute('aria-label')) {
|
||||||
|
el.setAttribute('aria-label', text);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
tooltipState.targetRect = {
|
||||||
|
top: rect.top,
|
||||||
|
left: rect.left,
|
||||||
|
width: rect.width,
|
||||||
|
bottom: rect.bottom
|
||||||
|
};
|
||||||
|
tooltipState.text = text;
|
||||||
|
tooltipState.isVisible = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide tooltip on any scroll event to avoid floating detached tooltips
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.addEventListener('scroll', hideTooltip, { passive: true, capture: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hideTooltip() {
|
||||||
|
tooltipState.isVisible = false;
|
||||||
|
}
|
||||||
115
src/composables/useUrlCleaner.js
Normal file
115
src/composables/useUrlCleaner.js
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { useLocalStorage } from './useLocalStorage'
|
||||||
|
|
||||||
|
export function useUrlCleaner() {
|
||||||
|
const cleanedHistory = useLocalStorage('url-cleaner-history', [])
|
||||||
|
const isWatchEnabled = useLocalStorage('url-cleaner-watch-enabled', false)
|
||||||
|
|
||||||
|
const defaultExceptions = [
|
||||||
|
{ id: 'yt', domainPattern: '*.youtube.com', keepParams: ['v', 't'], keepHash: false, keepAllParams: false, isEnabled: true, isDefault: true },
|
||||||
|
{ id: 'yt-short', domainPattern: 'youtu.be', keepParams: ['t'], keepHash: false, keepAllParams: false, isEnabled: true, isDefault: true }
|
||||||
|
]
|
||||||
|
const exceptions = useLocalStorage('url-cleaner-exceptions', defaultExceptions)
|
||||||
|
|
||||||
|
const matchDomain = (pattern, domain) => {
|
||||||
|
// Escape regex chars except *
|
||||||
|
const regexString = '^' + pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') + '$'
|
||||||
|
return new RegExp(regexString, 'i').test(domain)
|
||||||
|
}
|
||||||
|
|
||||||
|
const processUrl = (text, autoClipboard = false, writeClipboardFn = null) => {
|
||||||
|
try {
|
||||||
|
// Basic URL validation
|
||||||
|
if (!text.match(/^https?:\/\//i)) {
|
||||||
|
if (autoClipboard) return text
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalLength = text.length
|
||||||
|
let cleanedUrl = text
|
||||||
|
|
||||||
|
try {
|
||||||
|
const urlObj = new URL(text)
|
||||||
|
const hostname = urlObj.hostname
|
||||||
|
|
||||||
|
const matchedRule = exceptions.value.find(rule =>
|
||||||
|
rule.isEnabled && matchDomain(rule.domainPattern, hostname)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (matchedRule) {
|
||||||
|
if (!matchedRule.keepAllParams) {
|
||||||
|
const params = new URLSearchParams(urlObj.search)
|
||||||
|
const keys = Array.from(params.keys())
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
if (!matchedRule.keepParams.includes(key)) {
|
||||||
|
params.delete(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
urlObj.search = params.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!matchedRule.keepHash) {
|
||||||
|
urlObj.hash = ''
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (urlObj.search || urlObj.hash) {
|
||||||
|
urlObj.search = ''
|
||||||
|
urlObj.hash = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanedUrl = urlObj.toString()
|
||||||
|
} catch (e) {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cleanedUrl === text && autoClipboard) {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
const newLength = cleanedUrl.length
|
||||||
|
const savedChars = originalLength - newLength
|
||||||
|
const savedPercent = originalLength > 0 ? Math.round((savedChars / originalLength) * 100) : 0
|
||||||
|
|
||||||
|
const entry = {
|
||||||
|
id: Date.now(),
|
||||||
|
original: text,
|
||||||
|
cleaned: cleanedUrl,
|
||||||
|
savedPercent,
|
||||||
|
timestamp: new Date().toLocaleTimeString()
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanedHistory.value.unshift(entry)
|
||||||
|
|
||||||
|
if (cleanedHistory.value.length > 50) {
|
||||||
|
cleanedHistory.value.pop()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (autoClipboard && savedChars > 0 && writeClipboardFn) {
|
||||||
|
writeClipboardFn(cleanedUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cleanedUrl
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error processing URL:', e)
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeEntry = (id) => {
|
||||||
|
cleanedHistory.value = cleanedHistory.value.filter(item => item.id !== id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearHistory = () => {
|
||||||
|
cleanedHistory.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
cleanedHistory,
|
||||||
|
isWatchEnabled,
|
||||||
|
exceptions,
|
||||||
|
defaultExceptions,
|
||||||
|
processUrl,
|
||||||
|
removeEntry,
|
||||||
|
clearHistory
|
||||||
|
}
|
||||||
|
}
|
||||||
6
src/config/ui.js
Normal file
6
src/config/ui.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
|
||||||
|
export const UI_CONFIG = {
|
||||||
|
headerHeight: 64,
|
||||||
|
footerHeight: 50,
|
||||||
|
pagePadding: 16 // Single side padding (1rem)
|
||||||
|
}
|
||||||
@@ -19,7 +19,7 @@ const Ripple = {
|
|||||||
|
|
||||||
// Allow custom color via directive value
|
// Allow custom color via directive value
|
||||||
if (binding.value && typeof binding.value === 'string') {
|
if (binding.value && typeof binding.value === 'string') {
|
||||||
circle.style.backgroundColor = binding.value;
|
circle.style.backgroundColor = binding.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
el.appendChild(circle);
|
el.appendChild(circle);
|
||||||
|
|||||||
84
src/directives/tooltip.js
Normal file
84
src/directives/tooltip.js
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { showTooltip, hideTooltip, tooltipState } from '../composables/useTooltip'
|
||||||
|
|
||||||
|
export const tooltipDirective = {
|
||||||
|
mounted(el, binding) {
|
||||||
|
el._tooltipText = binding.value;
|
||||||
|
let touchTimeout = null;
|
||||||
|
let isTouch = false;
|
||||||
|
|
||||||
|
el._handleMouseEnter = () => {
|
||||||
|
if (!isTouch) showTooltip(el, el._tooltipText);
|
||||||
|
};
|
||||||
|
el._handleMouseLeave = () => {
|
||||||
|
if (!isTouch) hideTooltip();
|
||||||
|
};
|
||||||
|
el._handleFocus = () => {
|
||||||
|
if (!isTouch) showTooltip(el, el._tooltipText);
|
||||||
|
};
|
||||||
|
el._handleBlur = () => {
|
||||||
|
if (!isTouch) hideTooltip();
|
||||||
|
};
|
||||||
|
|
||||||
|
el._handleTouchStart = () => {
|
||||||
|
isTouch = true;
|
||||||
|
if (touchTimeout) clearTimeout(touchTimeout);
|
||||||
|
touchTimeout = setTimeout(() => {
|
||||||
|
showTooltip(el, el._tooltipText);
|
||||||
|
}, 400); // 400ms long press threshold
|
||||||
|
};
|
||||||
|
|
||||||
|
el._handleTouchEnd = () => {
|
||||||
|
if (touchTimeout) clearTimeout(touchTimeout);
|
||||||
|
hideTooltip();
|
||||||
|
// Block ensuing simulated mouseenter events
|
||||||
|
setTimeout(() => { isTouch = false; }, 500);
|
||||||
|
};
|
||||||
|
|
||||||
|
el._handleTouchCancel = () => {
|
||||||
|
if (touchTimeout) clearTimeout(touchTimeout);
|
||||||
|
hideTooltip();
|
||||||
|
setTimeout(() => { isTouch = false; }, 500);
|
||||||
|
};
|
||||||
|
|
||||||
|
el._handleContextMenu = (e) => {
|
||||||
|
// Prevent the OS context menu if we're showing a tooltip via long press
|
||||||
|
if (isTouch && tooltipState.isVisible && tooltipState.text === el._tooltipText) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
el.addEventListener('mouseenter', el._handleMouseEnter);
|
||||||
|
el.addEventListener('mouseleave', el._handleMouseLeave);
|
||||||
|
el.addEventListener('focus', el._handleFocus);
|
||||||
|
el.addEventListener('blur', el._handleBlur);
|
||||||
|
|
||||||
|
el.addEventListener('touchstart', el._handleTouchStart, { passive: true });
|
||||||
|
el.addEventListener('touchend', el._handleTouchEnd);
|
||||||
|
el.addEventListener('touchmove', el._handleTouchCancel, { passive: true });
|
||||||
|
el.addEventListener('touchcancel', el._handleTouchCancel);
|
||||||
|
el.addEventListener('contextmenu', el._handleContextMenu);
|
||||||
|
},
|
||||||
|
updated(el, binding) {
|
||||||
|
el._tooltipText = binding.value;
|
||||||
|
|
||||||
|
if (tooltipState.isVisible && tooltipState.text !== binding.value) {
|
||||||
|
if (el.matches(':hover') || document.activeElement === el) {
|
||||||
|
showTooltip(el, binding.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
unmounted(el) {
|
||||||
|
if (el._handleMouseEnter) {
|
||||||
|
el.removeEventListener('mouseenter', el._handleMouseEnter);
|
||||||
|
el.removeEventListener('mouseleave', el._handleMouseLeave);
|
||||||
|
el.removeEventListener('focus', el._handleFocus);
|
||||||
|
el.removeEventListener('blur', el._handleBlur);
|
||||||
|
el.removeEventListener('touchstart', el._handleTouchStart);
|
||||||
|
el.removeEventListener('touchend', el._handleTouchEnd);
|
||||||
|
el.removeEventListener('touchmove', el._handleTouchCancel);
|
||||||
|
el.removeEventListener('touchcancel', el._handleTouchCancel);
|
||||||
|
el.removeEventListener('contextmenu', el._handleContextMenu);
|
||||||
|
}
|
||||||
|
hideTooltip();
|
||||||
|
}
|
||||||
|
};
|
||||||
23
src/main.js
23
src/main.js
@@ -3,9 +3,32 @@ 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 { tooltipDirective } from './directives/tooltip'
|
||||||
|
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)
|
||||||
|
|
||||||
app.directive('ripple', Ripple)
|
app.directive('ripple', Ripple)
|
||||||
|
app.directive('tooltip', tooltipDirective)
|
||||||
app.use(router)
|
app.use(router)
|
||||||
app.mount('#app')
|
app.mount('#app')
|
||||||
|
|||||||
@@ -2,6 +2,11 @@ import { createRouter, createWebHistory } from 'vue-router'
|
|||||||
import Main from '../components/Main.vue'
|
import Main from '../components/Main.vue'
|
||||||
import Passwords from '../components/tools/Passwords.vue'
|
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 QrScanner from '../components/tools/QrScanner.vue'
|
||||||
|
import QrCode from '../components/tools/QrCode.vue'
|
||||||
|
import ToneGenerator from '../components/tools/ToneGenerator.vue'
|
||||||
|
import PrivacyPolicy from '../views/PrivacyPolicy.vue'
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
{
|
{
|
||||||
@@ -18,6 +23,31 @@ const routes = [
|
|||||||
path: '/clipboard-sniffer',
|
path: '/clipboard-sniffer',
|
||||||
name: 'ClipboardSniffer',
|
name: 'ClipboardSniffer',
|
||||||
component: ClipboardSniffer
|
component: ClipboardSniffer
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/url-cleaner',
|
||||||
|
name: 'UrlCleaner',
|
||||||
|
component: UrlCleaner
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/qr-scanner',
|
||||||
|
name: 'QrScanner',
|
||||||
|
component: QrScanner
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/qr-code/:payload?',
|
||||||
|
name: 'QrCode',
|
||||||
|
component: QrCode
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/extension-privacy-policy',
|
||||||
|
name: 'PrivacyPolicy',
|
||||||
|
component: PrivacyPolicy
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/tone-generator',
|
||||||
|
name: 'ToneGenerator',
|
||||||
|
component: ToneGenerator
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
518
src/style.css
518
src/style.css
@@ -1,9 +1,10 @@
|
|||||||
/* Box sizing reset */
|
/* Box sizing reset */
|
||||||
*, *::before, *::after {
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
@import 'tailwindcss';
|
|
||||||
:root {
|
:root {
|
||||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
@@ -23,6 +24,7 @@
|
|||||||
--accent-cyan: #00f2fe;
|
--accent-cyan: #00f2fe;
|
||||||
--accent-purple: #4facfe;
|
--accent-purple: #4facfe;
|
||||||
--primary-accent: #00f2fe;
|
--primary-accent: #00f2fe;
|
||||||
|
--primary-accent-rgb: 0, 242, 254;
|
||||||
--title-glow: rgba(0, 255, 255, 0.2);
|
--title-glow: rgba(0, 255, 255, 0.2);
|
||||||
--toggle-bg: rgba(255, 255, 255, 0.08);
|
--toggle-bg: rgba(255, 255, 255, 0.08);
|
||||||
--toggle-border: rgba(255, 255, 255, 0.2);
|
--toggle-border: rgba(255, 255, 255, 0.2);
|
||||||
@@ -42,11 +44,9 @@
|
|||||||
--title-gradient: linear-gradient(45deg, #00f2fe, #4facfe);
|
--title-gradient: linear-gradient(45deg, #00f2fe, #4facfe);
|
||||||
--ripple-color: rgba(255, 255, 255, 0.3);
|
--ripple-color: rgba(255, 255, 255, 0.3);
|
||||||
--nav-item-weight: 400;
|
--nav-item-weight: 400;
|
||||||
|
--list-hover-bg: rgba(255, 255, 255, 0.05);
|
||||||
color: var(--text-color);
|
--list-border: rgba(255, 255, 255, 0.12);
|
||||||
background-color: #242424; /* Fallback */
|
--header-bg: rgba(0, 0, 0, 0.6);
|
||||||
background: var(--bg-gradient);
|
|
||||||
background-attachment: fixed;
|
|
||||||
|
|
||||||
font-synthesis: none;
|
font-synthesis: none;
|
||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
@@ -55,35 +55,39 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root[data-theme="light"] {
|
:root[data-theme="light"] {
|
||||||
--bg-gradient: radial-gradient(circle at center, #ffffff 0%, #e5e7eb 100%);
|
--bg-gradient: radial-gradient(circle at center, #ffffff 0%, #ddd 100%);
|
||||||
--glass-bg: rgba(255, 255, 255, 0.75);
|
--glass-bg: rgba(255, 255, 255, 0.45);
|
||||||
--glass-border: rgba(15, 23, 42, 0.12);
|
--glass-border: rgba(15, 23, 42, 0.2);
|
||||||
--glass-shadow: 0 8px 32px 0 rgba(15, 23, 42, 0.12);
|
--glass-shadow: 0 8px 32px 0 rgba(30, 41, 59, 0.15);
|
||||||
--text-color: #000000;
|
--text-color: #0f172a;
|
||||||
--text-strong: #000000;
|
--text-strong: #020617;
|
||||||
--text-secondary: #000000;
|
--text-secondary: #334155;
|
||||||
--text-muted: rgba(0, 0, 0, 0.7);
|
--text-muted: #64748b;
|
||||||
--accent-cyan: #0ea5e9;
|
--accent-cyan: #0ea5e9;
|
||||||
--accent-purple: #6366f1;
|
--accent-purple: #6366f1;
|
||||||
--primary-accent: #0ea5e9;
|
--primary-accent: #0ea5e9;
|
||||||
|
--primary-accent-rgb: 14, 165, 233;
|
||||||
--title-glow: rgba(14, 165, 233, 0.35);
|
--title-glow: rgba(14, 165, 233, 0.35);
|
||||||
--toggle-bg: rgba(255, 255, 255, 0.85);
|
--toggle-bg: rgba(255, 255, 255, 1);
|
||||||
--toggle-border: rgba(15, 23, 42, 0.12);
|
--toggle-border: rgba(15, 23, 42, 0.2);
|
||||||
--toggle-shadow: 0 8px 20px rgba(15, 23, 42, 0.12);
|
--toggle-shadow: 0 4px 12px rgba(15, 23, 42, 0.1);
|
||||||
--toggle-btn-border: rgba(15, 23, 42, 0.18);
|
--toggle-btn-border: rgba(15, 23, 42, 0.15);
|
||||||
--toggle-hover-border: rgba(15, 23, 42, 0.5);
|
--toggle-hover-border: rgba(14, 165, 233, 0.6);
|
||||||
--toggle-active-shadow: 0 0 12px rgba(14, 165, 233, 0.25);
|
--toggle-active-shadow: 0 0 12px rgba(14, 165, 233, 0.25);
|
||||||
--panel-bg: rgba(255, 255, 255, 0.7);
|
--panel-bg: rgba(255, 255, 255, 0.9);
|
||||||
--panel-border: rgba(15, 23, 42, 0.12);
|
--panel-border: rgba(15, 23, 42, 0.12);
|
||||||
--panel-shadow: 0 12px 24px rgba(15, 23, 42, 0.12);
|
--panel-shadow: 0 12px 24px rgba(15, 23, 42, 0.12);
|
||||||
--button-bg: rgba(255, 255, 255, 0.85);
|
--button-bg: rgba(255, 255, 255, 0.7);
|
||||||
--button-border: rgba(15, 23, 42, 0.16);
|
--button-border: rgba(255, 255, 255, 0.9);
|
||||||
--button-text: #0f172a;
|
--button-text: #0f172a;
|
||||||
--button-hover-bg: rgba(0, 0, 0, 0.05);
|
--button-hover-bg: rgba(15, 23, 42, 0.1);
|
||||||
--button-hover-shadow: 0 6px 18px rgba(15, 23, 42, 0.18);
|
--button-hover-shadow: 0 6px 18px rgba(15, 23, 42, 0.18);
|
||||||
--button-active-shadow: 0 0 18px rgba(14, 165, 233, 0.25);
|
--button-active-shadow: 0 0 18px rgba(14, 165, 233, 0.25);
|
||||||
--title-gradient: linear-gradient(45deg, #0ea5e9, #6366f1);
|
--title-gradient: linear-gradient(45deg, #0ea5e9, #6366f1);
|
||||||
--ripple-color: rgba(0, 0, 0, 0.1);
|
--ripple-color: rgba(0, 0, 0, 0.1);
|
||||||
|
--list-hover-bg: rgba(15, 23, 42, 0.05);
|
||||||
|
--list-border: rgba(15, 23, 42, 0.08);
|
||||||
|
--header-bg: rgba(255, 255, 255, 0.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@@ -94,6 +98,12 @@ body {
|
|||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
-webkit-user-select: none;
|
-webkit-user-select: none;
|
||||||
|
|
||||||
|
color: var(--text-color);
|
||||||
|
background-color: var(--bg-gradient);
|
||||||
|
/* fallback but works if variable contains simple color */
|
||||||
|
background: var(--bg-gradient);
|
||||||
|
background-attachment: fixed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.selectable {
|
.selectable {
|
||||||
@@ -111,35 +121,42 @@ body {
|
|||||||
|
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
body {
|
body {
|
||||||
height: 100vh;
|
min-height: 100vh;
|
||||||
overflow: hidden;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
#app {
|
#app {
|
||||||
height: 100vh;
|
min-height: 100vh;
|
||||||
overflow: hidden;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Removed global front camera mirror to restore stability */
|
||||||
|
|
||||||
/* --- Shared styles for all tools (moved from tools.css) --- */
|
/* --- Shared styles for all tools (moved from tools.css) --- */
|
||||||
|
|
||||||
.tool-container {
|
.tool-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 800px;
|
max-width: 100%;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
padding: 1rem;
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-container.full-width {
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-panel {
|
.tool-panel {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 2rem;
|
padding: 1rem;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
|
height: 100%;
|
||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
background: var(--glass-bg);
|
background: var(--glass-bg);
|
||||||
@@ -170,7 +187,7 @@ body {
|
|||||||
|
|
||||||
.tool-title {
|
.tool-title {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
text-align: center;
|
text-align: left;
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
background: var(--title-gradient);
|
background: var(--title-gradient);
|
||||||
@@ -181,40 +198,51 @@ body {
|
|||||||
filter: drop-shadow(0 0 10px var(--title-glow));
|
filter: drop-shadow(0 0 10px var(--title-glow));
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-theme="light"] .tool-title {
|
|
||||||
/* background: none !important;
|
|
||||||
-webkit-background-clip: unset !important;
|
|
||||||
background-clip: unset !important;
|
|
||||||
color: #000000 !important;
|
|
||||||
-webkit-text-fill-color: #000000 !important;
|
|
||||||
filter: none !important; */
|
|
||||||
}
|
|
||||||
|
|
||||||
.tool-textarea {
|
|
||||||
|
.tool-textarea,
|
||||||
|
.select-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
padding: 0.75rem 1rem;
|
||||||
padding: 1rem;
|
background-color: var(--toggle-bg);
|
||||||
background-color: rgba(0, 0, 0, 0.2) !important;
|
|
||||||
border: 1px solid var(--toggle-border);
|
border: 1px solid var(--toggle-border);
|
||||||
border-radius: 12px;
|
border-radius: 8px;
|
||||||
color: #ffffff !important; /* Explicit white color for dark mode */
|
color: var(--text-color);
|
||||||
font-family: monospace;
|
font-family: inherit;
|
||||||
font-size: 1rem;
|
font-size: 0.95rem;
|
||||||
line-height: 1.6;
|
|
||||||
resize: none;
|
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-theme="light"] .tool-textarea {
|
::placeholder {
|
||||||
color: #000000 !important;
|
color: var(--text-muted);
|
||||||
background-color: rgba(255, 255, 255, 0.5) !important;
|
opacity: 1;
|
||||||
|
/* Override Firefox default opacity */
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-textarea:focus {
|
.tool-textarea {
|
||||||
|
font-family: monospace;
|
||||||
|
resize: none;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-input {
|
||||||
|
appearance: none;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'%3E%3C/path%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right 0.75rem center;
|
||||||
|
background-size: 1rem;
|
||||||
|
padding-right: 2.5rem;
|
||||||
|
height: 40px;
|
||||||
|
cursor: pointer;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-textarea:focus,
|
||||||
|
.select-input:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: #00f2fe !important; /* Force cyan accent */
|
border-color: var(--primary-accent) !important;
|
||||||
box-shadow: 0 0 0 1px #00f2fe !important;
|
box-shadow: 0 0 0 1px var(--primary-accent) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.result-area {
|
.result-area {
|
||||||
@@ -239,13 +267,24 @@ body {
|
|||||||
box-shadow: var(--glass-shadow);
|
box-shadow: var(--glass-shadow);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.glass-panel:not(.modal-content) {
|
||||||
|
background: transparent;
|
||||||
|
backdrop-filter: none;
|
||||||
|
-webkit-backdrop-filter: none;
|
||||||
|
border: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.btn-neon {
|
.btn-neon {
|
||||||
background: var(--button-bg);
|
background: var(--button-bg);
|
||||||
border: 1px solid var(--button-border);
|
border: 1px solid var(--button-border);
|
||||||
color: var(--button-text);
|
color: var(--button-text);
|
||||||
padding: 8px 16px;
|
padding: 0 1.25rem;
|
||||||
|
height: 40px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
font-weight: 600;
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
|
||||||
@@ -253,7 +292,8 @@ body {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
outline: none; /* Remove focus outline */
|
outline: none;
|
||||||
|
/* Remove focus outline */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Global button styles */
|
/* Global button styles */
|
||||||
@@ -274,11 +314,45 @@ button:focus {
|
|||||||
border-color: var(--accent-cyan);
|
border-color: var(--accent-cyan);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-neon.primary {
|
||||||
|
background: var(--primary-accent);
|
||||||
|
color: #000;
|
||||||
|
border-color: var(--primary-accent);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-neon.primary:hover {
|
||||||
|
background: var(--primary-accent);
|
||||||
|
opacity: 0.9;
|
||||||
|
box-shadow: 0 0 20px rgba(0, 242, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="light"] .btn-neon.primary:hover {
|
||||||
|
box-shadow: 0 0 18px rgba(14, 165, 233, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
.btn-neon:active {
|
.btn-neon:active {
|
||||||
transform: translateY(1px);
|
transform: translateY(1px);
|
||||||
box-shadow: var(--button-active-shadow);
|
box-shadow: var(--button-active-shadow);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-neon.danger {
|
||||||
|
background: rgba(239, 68, 68, 0.2);
|
||||||
|
border-color: rgba(239, 68, 68, 0.6);
|
||||||
|
color: #fee2e2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-neon.danger:hover {
|
||||||
|
background: rgba(239, 68, 68, 0.3);
|
||||||
|
border-color: rgba(239, 68, 68, 0.85);
|
||||||
|
box-shadow: 0 8px 20px rgba(239, 68, 68, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="light"] .btn-neon.danger {
|
||||||
|
color: #7f1d1d;
|
||||||
|
}
|
||||||
|
|
||||||
.icon-only {
|
.icon-only {
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
@@ -307,3 +381,329 @@ span.ripple {
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Global Input/Select Focus Styles --- */
|
||||||
|
input:focus,
|
||||||
|
select:focus,
|
||||||
|
textarea:focus,
|
||||||
|
button:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:focus-visible,
|
||||||
|
a:focus-visible {
|
||||||
|
outline: 2px solid var(--primary-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus,
|
||||||
|
select:focus,
|
||||||
|
textarea:focus,
|
||||||
|
.number-control:focus-within {
|
||||||
|
border-color: var(--primary-accent) !important;
|
||||||
|
box-shadow: 0 0 0 1px var(--primary-accent) !important;
|
||||||
|
transition: border-color 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Global Checkbox Styles --- */
|
||||||
|
.checkbox-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label:hover {
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label input {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
height: 0;
|
||||||
|
width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkmark {
|
||||||
|
position: relative;
|
||||||
|
height: 20px;
|
||||||
|
width: 20px;
|
||||||
|
background-color: var(--toggle-bg);
|
||||||
|
border: 1px solid var(--toggle-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label:hover .checkmark {
|
||||||
|
border-color: var(--toggle-hover-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label input:checked~.checkmark {
|
||||||
|
background-color: var(--primary-accent);
|
||||||
|
border-color: var(--primary-accent);
|
||||||
|
box-shadow: 0 0 10px var(--primary-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkmark:after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label input:checked~.checkmark:after {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label .checkmark:after {
|
||||||
|
left: 6px;
|
||||||
|
top: 2px;
|
||||||
|
width: 5px;
|
||||||
|
height: 10px;
|
||||||
|
border: solid white;
|
||||||
|
border-width: 0 2px 2px 0;
|
||||||
|
transform: rotate(45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="light"] .checkmark:after {
|
||||||
|
border-color: white;
|
||||||
|
/* Keep checkmark white even in light mode if background is primary-accent */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Global Header/Action Patterns --- */
|
||||||
|
.panel-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Global Icon Button Styles --- */
|
||||||
|
.icon-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn:hover {
|
||||||
|
color: var(--text-color);
|
||||||
|
background: var(--button-hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn.delete-btn:hover {
|
||||||
|
color: #ef4444;
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="light"] .icon-btn.delete-btn:hover {
|
||||||
|
color: #dc2626;
|
||||||
|
background: rgba(220, 38, 38, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Component Specific Theme Overrides (Consolidated) --- */
|
||||||
|
:root[data-theme="light"] .settings-btn {
|
||||||
|
background: rgba(0, 0, 0, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="light"] .savings {
|
||||||
|
color: #16a34a;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="light"] .modal-overlay {
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="light"] .add-rule-form,
|
||||||
|
:root[data-theme="light"] .rule-item {
|
||||||
|
background: rgba(0, 0, 0, 0.04);
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-tag {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
background: var(--list-hover-bg);
|
||||||
|
border: 1px solid var(--list-border);
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Global Number Control Styles --- */
|
||||||
|
.number-control {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
background: var(--toggle-bg);
|
||||||
|
border: 1px solid var(--toggle-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
height: auto;
|
||||||
|
min-height: 40px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
/* allow wrapping on very small screens */
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-color);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
min-width: 40px;
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-btn:hover {
|
||||||
|
background: var(--button-hover-bg);
|
||||||
|
color: var(--primary-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.number-input {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-color);
|
||||||
|
flex: 1;
|
||||||
|
min-width: 40px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 400;
|
||||||
|
appearance: textfield;
|
||||||
|
padding: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.number-input::-webkit-outer-spin-button,
|
||||||
|
.number-input::-webkit-inner-spin-button {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Global List/History Styles --- */
|
||||||
|
.results-section,
|
||||||
|
.history-section {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--glass-bg);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-header,
|
||||||
|
.results-header {
|
||||||
|
padding: 1rem;
|
||||||
|
border-bottom: 1px solid var(--glass-border);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-header h3,
|
||||||
|
.results-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: var(--text-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-actions,
|
||||||
|
.results-actions,
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-list,
|
||||||
|
.codes-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-item,
|
||||||
|
.code-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem;
|
||||||
|
border-bottom: 1px solid var(--list-border);
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-item:last-child,
|
||||||
|
.code-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-item:hover,
|
||||||
|
.code-item:hover {
|
||||||
|
background: var(--list-hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-info,
|
||||||
|
.code-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
padding-right: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: #4ade80;
|
||||||
|
box-shadow: 0 0 8px #4ade80;
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
/* Shared styles for all tools */
|
|
||||||
|
|
||||||
.tool-container {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 800px;
|
|
||||||
margin: 0 auto;
|
|
||||||
height: 100%;
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tool-panel {
|
|
||||||
width: 100%;
|
|
||||||
padding: 2rem;
|
|
||||||
border-radius: 16px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1.5rem;
|
|
||||||
max-height: 100%;
|
|
||||||
overflow-y: auto;
|
|
||||||
background: var(--glass-bg);
|
|
||||||
backdrop-filter: blur(12px);
|
|
||||||
-webkit-backdrop-filter: blur(12px);
|
|
||||||
border: 1px solid var(--glass-border);
|
|
||||||
box-shadow: var(--glass-shadow);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Custom scrollbar for tool panel */
|
|
||||||
.tool-panel::-webkit-scrollbar {
|
|
||||||
width: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tool-panel::-webkit-scrollbar-track {
|
|
||||||
background: rgba(0, 0, 0, 0.1);
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tool-panel::-webkit-scrollbar-thumb {
|
|
||||||
background: rgba(255, 255, 255, 0.2);
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tool-panel::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tool-title {
|
|
||||||
margin: 0;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 1.5rem;
|
|
||||||
font-weight: 600;
|
|
||||||
background: var(--title-gradient);
|
|
||||||
-webkit-background-clip: text;
|
|
||||||
background-clip: text;
|
|
||||||
color: transparent;
|
|
||||||
-webkit-text-fill-color: transparent;
|
|
||||||
filter: drop-shadow(0 0 10px var(--title-glow));
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-theme="light"] .tool-title {
|
|
||||||
background: none;
|
|
||||||
-webkit-background-clip: unset;
|
|
||||||
background-clip: unset;
|
|
||||||
color: #000000;
|
|
||||||
-webkit-text-fill-color: #000000;
|
|
||||||
filter: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tool-textarea {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
padding: 1rem;
|
|
||||||
background-color: rgba(0, 0, 0, 0.2);
|
|
||||||
border: 1px solid var(--toggle-border);
|
|
||||||
border-radius: 12px;
|
|
||||||
color: #ffffff; /* Explicit white color for dark mode */
|
|
||||||
font-family: monospace;
|
|
||||||
font-size: 1rem;
|
|
||||||
line-height: 1.6;
|
|
||||||
resize: none;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-theme="light"] .tool-textarea {
|
|
||||||
color: #000000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tool-textarea:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: #00f2fe; /* Force cyan accent */
|
|
||||||
box-shadow: 0 0 0 1px #00f2fe;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-area {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
flex: 1;
|
|
||||||
min-height: 200px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-area label {
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
209
src/views/PrivacyPolicy.vue
Normal file
209
src/views/PrivacyPolicy.vue
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ArrowLeft } from 'lucide-vue-next'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="tool-container">
|
||||||
|
<div class="tool-panel privacy-panel">
|
||||||
|
<header class="privacy-header">
|
||||||
|
<router-link to="/" class="back-link">
|
||||||
|
<ArrowLeft size="20" />
|
||||||
|
Back to Tools
|
||||||
|
</router-link>
|
||||||
|
<h1>Privacy Policy</h1>
|
||||||
|
<p class="last-updated">Last Updated: February 27, 2026</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="privacy-body">
|
||||||
|
<section>
|
||||||
|
<h2>1. Introduction</h2>
|
||||||
|
<p>
|
||||||
|
Welcome to Tools App ("we," "our," or "us"). We are committed to protecting your privacy.
|
||||||
|
This Privacy Policy explains how our Chrome Extension ("Tools App Extension") handles your data.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>2. Data Collection and Usage</h2>
|
||||||
|
<p>
|
||||||
|
The Tools App Extension is designed with privacy as a priority.
|
||||||
|
<strong>We do not collect, store, or transmit any of your personal data to external servers.</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>Clipboard Data</h3>
|
||||||
|
<p>
|
||||||
|
The extension requires the <code>clipboardRead</code> permission to function.
|
||||||
|
It reads text from your clipboard <strong>only</strong> when you explicitly enable the "Clipboard Sniffer" tool in the Tools App web interface.
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>Clipboard data is processed locally within your browser.</li>
|
||||||
|
<li>Data is sent directly from the extension to the open Tools App tab via a secure local communication channel.</li>
|
||||||
|
<li>Once you close the tab or stop the tool, the extension stops monitoring the clipboard immediately.</li>
|
||||||
|
<li>We do not have access to your clipboard history, and it is never uploaded to any cloud storage or third-party service.</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>3. Permissions</h2>
|
||||||
|
<p>The extension requests the following permissions for specific functional purposes:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>clipboardRead:</strong> To detect copied text when the Sniffer tool is active.</li>
|
||||||
|
<li><strong>scripting:</strong> To communicate with the Tools App web page.</li>
|
||||||
|
<li><strong>storage:</strong> To save local user preferences (e.g., sound settings).</li>
|
||||||
|
<li><strong>alarms:</strong> To maintain the background process active during monitoring sessions.</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>4. Third-Party Services</h2>
|
||||||
|
<p>
|
||||||
|
Our extension operates independently and does not use any third-party analytics, tracking scripts, or advertising networks.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>5. Changes to This Policy</h2>
|
||||||
|
<p>
|
||||||
|
We may update our Privacy Policy from time to time. We will notify you of any changes by posting the new Privacy Policy on this page.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>6. Contact Us</h2>
|
||||||
|
<p>
|
||||||
|
If you have any questions about this Privacy Policy, please contact us via the repository or support channels provided in the Chrome Web Store listing.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.privacy-panel {
|
||||||
|
max-width: 900px; /* Slightly wider for reading */
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.privacy-body {
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom scrollbar for privacy body */
|
||||||
|
.privacy-body::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.privacy-body::-webkit-scrollbar-track {
|
||||||
|
background: rgba(0, 0, 0, 0.1);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.privacy-body::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.privacy-header {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
border-bottom: 1px solid var(--glass-border);
|
||||||
|
padding-bottom: 1.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link:hover {
|
||||||
|
color: var(--primary-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
background: var(--title-gradient);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
color: transparent;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.last-updated {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
section {
|
||||||
|
margin-bottom: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: var(--text-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
margin: 1.5rem 0 0.5rem 0;
|
||||||
|
color: var(--text-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
line-height: 1.7;
|
||||||
|
color: var(--text-color);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
strong {
|
||||||
|
color: var(--text-strong);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
padding: 0.2rem 0.4rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: var(--text-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(html[data-theme="light"]) code {
|
||||||
|
background: rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.privacy-container {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.privacy-content {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
66
src/workers/qrcode.worker.js
Normal file
66
src/workers/qrcode.worker.js
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import QRCode from 'qrcode'
|
||||||
|
|
||||||
|
self.onmessage = async (e) => {
|
||||||
|
const { id, text, ecc, isBgTransparent, bgType, bgColor1, bgColor2, bgGradPos, fgType, fgColor1, fgColor2, fgGradPos } = e.data
|
||||||
|
|
||||||
|
if (!text) {
|
||||||
|
self.postMessage({ id, svgContent: '' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let svgContent = await QRCode.toString(text, {
|
||||||
|
type: 'svg',
|
||||||
|
errorCorrectionLevel: ecc,
|
||||||
|
margin: 1,
|
||||||
|
color: {
|
||||||
|
dark: fgType === 'solid' ? fgColor1 : '#000000',
|
||||||
|
light: isBgTransparent ? '#00000000' : (bgType === 'solid' ? bgColor1 : '#00000000')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
let defsHtml = ''
|
||||||
|
|
||||||
|
if (fgType !== 'solid') {
|
||||||
|
const isLinear = fgType === 'linear'
|
||||||
|
const pos = fgGradPos || { x1: 0, y1: 0, x2: 100, y2: 100 }
|
||||||
|
const r = Math.sqrt(Math.pow(pos.x2 - pos.x1, 2) + Math.pow(pos.y2 - pos.y1, 2))
|
||||||
|
defsHtml += isLinear
|
||||||
|
? `<linearGradient id="qr-fg-grad" x1="${pos.x1}%" y1="${pos.y1}%" x2="${pos.x2}%" y2="${pos.y2}%"><stop offset="0%" stop-color="${fgColor1}" /><stop offset="100%" stop-color="${fgColor2}" /></linearGradient>`
|
||||||
|
: `<radialGradient id="qr-fg-grad" cx="${pos.x1}%" cy="${pos.y1}%" r="${r}%"><stop offset="0%" stop-color="${fgColor1}" /><stop offset="100%" stop-color="${fgColor2}" /></radialGradient>`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isBgTransparent && bgType !== 'solid') {
|
||||||
|
const isLinear = bgType === 'linear'
|
||||||
|
const pos = bgGradPos || { x1: 0, y1: 0, x2: 100, y2: 100 }
|
||||||
|
const r = Math.sqrt(Math.pow(pos.x2 - pos.x1, 2) + Math.pow(pos.y2 - pos.y1, 2))
|
||||||
|
defsHtml += isLinear
|
||||||
|
? `<linearGradient id="qr-bg-grad" x1="${pos.x1}%" y1="${pos.y1}%" x2="${pos.x2}%" y2="${pos.y2}%"><stop offset="0%" stop-color="${bgColor1}" /><stop offset="100%" stop-color="${bgColor2}" /></linearGradient>`
|
||||||
|
: `<radialGradient id="qr-bg-grad" cx="${pos.x1}%" cy="${pos.y1}%" r="${r}%"><stop offset="0%" stop-color="${bgColor1}" /><stop offset="100%" stop-color="${bgColor2}" /></radialGradient>`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (defsHtml) {
|
||||||
|
svgContent = svgContent.replace('shape-rendering="crispEdges">', `shape-rendering="crispEdges"><defs>${defsHtml}</defs>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fgType !== 'solid') {
|
||||||
|
// qrcode outputs <path stroke="#000000"...> so it's safe to replace
|
||||||
|
svgContent = svgContent.replace(/stroke="#000000"/g, 'stroke="url(#qr-fg-grad)"')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isBgTransparent && bgType !== 'solid') {
|
||||||
|
// Find viewBox to inject background rect
|
||||||
|
const viewBoxMatch = svgContent.match(/viewBox="0 0 (\d+) (\d+)"/)
|
||||||
|
if (viewBoxMatch) {
|
||||||
|
const w = viewBoxMatch[1]
|
||||||
|
const h = viewBoxMatch[2]
|
||||||
|
// Inject a rect immediately inside the svg
|
||||||
|
svgContent = svgContent.replace('</defs>', `</defs><rect width="${w}" height="${h}" fill="url(#qr-bg-grad)" />`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.postMessage({ id, svgContent })
|
||||||
|
} catch (err) {
|
||||||
|
self.postMessage({ id, error: err.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,5 +43,11 @@ export default defineConfig({
|
|||||||
],
|
],
|
||||||
define: {
|
define: {
|
||||||
'__APP_VERSION__': JSON.stringify(packageJson.version)
|
'__APP_VERSION__': JSON.stringify(packageJson.version)
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
host: true,
|
||||||
|
allowedHosts: [
|
||||||
|
'.trycloudflare.com'
|
||||||
|
]
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user