Compare commits
76 Commits
v0.1.0
...
60fc774586
| Author | SHA1 | Date | |
|---|---|---|---|
|
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
|
|||
|
e817ff6169
|
|||
|
8e8bf47297
|
|||
|
1d7fd9a5bc
|
|||
|
8bf44cde6a
|
|||
|
782786ec7e
|
|||
|
c8a009dda1
|
|||
|
7c4f1b20b3
|
|||
|
c3f96bacc7
|
|||
|
8655533a2d
|
|||
|
bec57f9e49
|
|||
|
9ec71db87c
|
|||
|
3b229b4719
|
|||
|
c6baace721
|
|||
|
ab2da36aa1
|
|||
|
8691106d2f
|
5
.gitignore
vendored
5
.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,7 @@ dist-ssr
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
dev-dist
|
||||||
|
extension-release.zip
|
||||||
|
*.zip
|
||||||
|
tools-app-extension-*.zip
|
||||||
|
|||||||
88
README.md
88
README.md
@@ -1 +1,87 @@
|
|||||||
# 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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|||||||
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.
|
||||||
167
extension/background.js
Normal file
167
extension/background.js
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
// background.js
|
||||||
|
// Listen for messages from content scripts or offscreen document
|
||||||
|
|
||||||
|
let isSniffing = false;
|
||||||
|
let lastClipboardContent = '';
|
||||||
|
let creatingOffscreenDocument;
|
||||||
|
|
||||||
|
// Hot-reconnect: Inject content script into existing tabs upon installation/update/restart
|
||||||
|
const injectContentScriptIfNeeded = async () => {
|
||||||
|
const tabs = await chrome.tabs.query({ url: ['http://localhost/*', 'http://localhost:*/*', 'https://tools.7u.pl/*'] });
|
||||||
|
for (const tab of tabs) {
|
||||||
|
try {
|
||||||
|
// Try to ping the tab first
|
||||||
|
try {
|
||||||
|
await chrome.tabs.sendMessage(tab.id, { action: 'ping' });
|
||||||
|
// console.log('Content script already active in tab:', tab.id);
|
||||||
|
} catch (e) {
|
||||||
|
// If ping fails (no listener), inject script
|
||||||
|
await chrome.scripting.executeScript({
|
||||||
|
target: { tabId: tab.id },
|
||||||
|
files: ['content.js']
|
||||||
|
});
|
||||||
|
// console.log('Injected content script into existing tab:', tab.id);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// console.error('Failed to handle tab:', tab.id, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
chrome.runtime.onInstalled.addListener(injectContentScriptIfNeeded);
|
||||||
|
// Also run on startup (when extension is enabled/reloaded)
|
||||||
|
injectContentScriptIfNeeded();
|
||||||
|
|
||||||
|
// Listen for alarms
|
||||||
|
try {
|
||||||
|
if (chrome.alarms) {
|
||||||
|
chrome.alarms.onAlarm.addListener((alarm) => {
|
||||||
|
if (alarm.name === 'keepAlive') {
|
||||||
|
refreshOffscreenDocument();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// console.warn('chrome.alarms API is not available.');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// console.error('Error initializing alarms:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup offscreen document
|
||||||
|
async function setupOffscreenDocument(path) {
|
||||||
|
// Check if an offscreen document already exists
|
||||||
|
const existingContexts = await chrome.runtime.getContexts({
|
||||||
|
contextTypes: ['OFFSCREEN_DOCUMENT'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingContexts.length > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create an offscreen document
|
||||||
|
if (creatingOffscreenDocument) {
|
||||||
|
await creatingOffscreenDocument;
|
||||||
|
} else {
|
||||||
|
creatingOffscreenDocument = chrome.offscreen.createDocument({
|
||||||
|
url: path,
|
||||||
|
reasons: ['CLIPBOARD', 'AUDIO_PLAYBACK'],
|
||||||
|
justification: 'To read clipboard content in the background and play notification sounds',
|
||||||
|
});
|
||||||
|
await creatingOffscreenDocument;
|
||||||
|
creatingOffscreenDocument = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lifecycle management: Refresh offscreen document every 25s to avoid 30s timeout
|
||||||
|
async function refreshOffscreenDocument() {
|
||||||
|
if (isSniffing) {
|
||||||
|
await chrome.offscreen.closeDocument();
|
||||||
|
await setupOffscreenDocument('offscreen.html');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start sniffing when requested
|
||||||
|
chrome.runtime.onMessage.addListener(async (request, sender, sendResponse) => {
|
||||||
|
if (request.action === 'startSniffing') {
|
||||||
|
if (isSniffing) {
|
||||||
|
sendResponse({ status: 'already_started' });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
isSniffing = true;
|
||||||
|
// console.log('Starting sniffing process...');
|
||||||
|
await setupOffscreenDocument('offscreen.html');
|
||||||
|
|
||||||
|
// Setup interval to keep offscreen alive - more aggressive
|
||||||
|
chrome.alarms.create('keepAlive', { periodInMinutes: 0.1 }); // every 6 seconds
|
||||||
|
|
||||||
|
sendResponse({ status: 'started' });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.action === 'stopSniffing') {
|
||||||
|
if (!isSniffing) {
|
||||||
|
sendResponse({ status: 'not_running' });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
isSniffing = false;
|
||||||
|
// console.log('Stopping sniffing process...');
|
||||||
|
|
||||||
|
// Stop alarm
|
||||||
|
chrome.alarms.clear('keepAlive');
|
||||||
|
|
||||||
|
// Close offscreen document
|
||||||
|
if (creatingOffscreenDocument) {
|
||||||
|
await creatingOffscreenDocument;
|
||||||
|
}
|
||||||
|
await chrome.offscreen.closeDocument().catch(() => {});
|
||||||
|
creatingOffscreenDocument = null;
|
||||||
|
|
||||||
|
sendResponse({ status: 'stopped' });
|
||||||
|
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') {
|
||||||
|
// Received data from offscreen document
|
||||||
|
if (isSniffing && request.data && request.data !== lastClipboardContent) {
|
||||||
|
lastClipboardContent = request.data;
|
||||||
|
// console.log('Clipboard changed:', request.data.substring(0, 20) + '...');
|
||||||
|
|
||||||
|
// Check if sound should be played
|
||||||
|
chrome.storage.local.get(['playSound'], (result) => {
|
||||||
|
if (result.playSound !== false) {
|
||||||
|
// Send message to offscreen document to play sound
|
||||||
|
chrome.runtime.sendMessage({
|
||||||
|
target: 'offscreen',
|
||||||
|
type: 'play-sound'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Broadcast to all active tabs (content scripts)
|
||||||
|
// We could filter by sender.tab.id if we knew which tab started sniffing,
|
||||||
|
// but broadcasting is simpler for now and covers multiple open tabs of the app.
|
||||||
|
const tabs = await chrome.tabs.query({ url: ['http://localhost/*', 'http://localhost:*/*', 'https://tools.7u.pl/*'] });
|
||||||
|
for (const tab of tabs) {
|
||||||
|
chrome.tabs.sendMessage(tab.id, {
|
||||||
|
action: 'clipboardUpdate',
|
||||||
|
content: request.data
|
||||||
|
}).catch(() => {
|
||||||
|
// Tab might be closed or content script not injected yet
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
77
extension/content.js
Normal file
77
extension/content.js
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
// content.js
|
||||||
|
// This script runs on the web app page (e.g. localhost:5173)
|
||||||
|
|
||||||
|
console.log('Tools App Extension: Content script injected');
|
||||||
|
|
||||||
|
// Listen for messages from the Web App (Vue)
|
||||||
|
window.addEventListener('message', (event) => {
|
||||||
|
// We should verify the origin, but since we are running on the page itself, we trust window messages
|
||||||
|
// from our own app.
|
||||||
|
if (event.source !== window) return;
|
||||||
|
|
||||||
|
if (event.data.type && event.data.type === 'TOOLS_APP_INIT') {
|
||||||
|
// console.log('Tools App Extension: Received init from Web App');
|
||||||
|
window.postMessage({ type: 'TOOLS_APP_EXTENSION_READY', version: '1.0' }, '*');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Heartbeat check
|
||||||
|
if (event.data.type === 'TOOLS_APP_PING') {
|
||||||
|
try {
|
||||||
|
// Only respond if the extension context is still valid
|
||||||
|
if (chrome.runtime && chrome.runtime.id) {
|
||||||
|
window.postMessage({ type: 'TOOLS_APP_PONG' }, '*');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Extension context invalidated
|
||||||
|
// console.warn('Extension context invalidated during ping');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Example: Receive request to sniff clipboard
|
||||||
|
if (event.data.type === 'TOOLS_APP_START_SNIFFING') {
|
||||||
|
// console.log('Tools App Extension: Start sniffing request');
|
||||||
|
// Relay to background script
|
||||||
|
try {
|
||||||
|
chrome.runtime.sendMessage({ action: 'startSniffing' });
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Tools App Extension: Connection lost, please reload the page', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.data.type === 'TOOLS_APP_STOP_SNIFFING') {
|
||||||
|
// console.log('Tools App Extension: Stop sniffing request');
|
||||||
|
try {
|
||||||
|
chrome.runtime.sendMessage({ action: 'stopSniffing' });
|
||||||
|
} catch (e) {
|
||||||
|
// 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
|
||||||
|
try {
|
||||||
|
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
|
||||||
|
if (request.action === 'clipboardUpdate') {
|
||||||
|
// Send to Web App
|
||||||
|
window.postMessage({ type: 'TOOLS_APP_CLIPBOARD_UPDATE', content: request.content }, '*');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Respond to background ping to confirm we are alive
|
||||||
|
if (request.action === 'ping') {
|
||||||
|
sendResponse('pong');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Tools App Extension: Could not add listener', e);
|
||||||
|
}
|
||||||
BIN
extension/icon-128.png
Normal file
BIN
extension/icon-128.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
BIN
extension/icon-16.png
Normal file
BIN
extension/icon-16.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 862 B |
BIN
extension/icon-48.png
Normal file
BIN
extension/icon-48.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.6 KiB |
48
extension/manifest.json
Normal file
48
extension/manifest.json
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
"manifest_version": 3,
|
||||||
|
"name": "Tools App Extension",
|
||||||
|
"version": "1.1",
|
||||||
|
"description": "Browser extension for Tools App",
|
||||||
|
"permissions": [
|
||||||
|
"clipboardRead",
|
||||||
|
"clipboardWrite",
|
||||||
|
"offscreen",
|
||||||
|
"storage",
|
||||||
|
"alarms",
|
||||||
|
"scripting"
|
||||||
|
],
|
||||||
|
"host_permissions": [
|
||||||
|
"http://localhost/*",
|
||||||
|
"http://localhost:*/*",
|
||||||
|
"https://tools.7u.pl/*"
|
||||||
|
],
|
||||||
|
"background": {
|
||||||
|
"service_worker": "background.js"
|
||||||
|
},
|
||||||
|
"content_scripts": [
|
||||||
|
{
|
||||||
|
"matches": [
|
||||||
|
"http://localhost/*",
|
||||||
|
"http://localhost:*/*",
|
||||||
|
"https://tools.7u.pl/*"
|
||||||
|
],
|
||||||
|
"js": [
|
||||||
|
"content.js"
|
||||||
|
],
|
||||||
|
"run_at": "document_start"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"action": {
|
||||||
|
"default_popup": "popup.html",
|
||||||
|
"default_icon": {
|
||||||
|
"16": "icon-16.png",
|
||||||
|
"48": "icon-48.png",
|
||||||
|
"128": "icon-128.png"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"icons": {
|
||||||
|
"16": "icon-16.png",
|
||||||
|
"48": "icon-48.png",
|
||||||
|
"128": "icon-128.png"
|
||||||
|
}
|
||||||
|
}
|
||||||
10
extension/offscreen.html
Normal file
10
extension/offscreen.html
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Offscreen Clipboard Access</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<textarea id="text"></textarea>
|
||||||
|
<script src="offscreen.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
84
extension/offscreen.js
Normal file
84
extension/offscreen.js
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
// offscreen.js
|
||||||
|
// This script runs in the offscreen document to access DOM APIs like navigator.clipboard
|
||||||
|
|
||||||
|
const textEl = document.querySelector('#text');
|
||||||
|
|
||||||
|
let lastText = '';
|
||||||
|
|
||||||
|
setInterval(async () => {
|
||||||
|
try {
|
||||||
|
textEl.focus();
|
||||||
|
textEl.value = '';
|
||||||
|
textEl.select();
|
||||||
|
|
||||||
|
// Method 1: execCommand
|
||||||
|
try {
|
||||||
|
document.execCommand('paste');
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
let text = textEl.value;
|
||||||
|
|
||||||
|
// Method 2: navigator.clipboard (Fallback)
|
||||||
|
if (!text) {
|
||||||
|
try {
|
||||||
|
text = await navigator.clipboard.readText();
|
||||||
|
} catch (e) {
|
||||||
|
// Silent fail for navigator
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text && text.trim().length > 0 && text !== lastText) {
|
||||||
|
lastText = text;
|
||||||
|
chrome.runtime.sendMessage({
|
||||||
|
type: 'clipboard-data',
|
||||||
|
target: 'background',
|
||||||
|
data: text
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Ignore critical errors to keep running
|
||||||
|
}
|
||||||
|
}, 50);
|
||||||
|
|
||||||
|
// Listen for messages from background
|
||||||
|
chrome.runtime.onMessage.addListener((message) => {
|
||||||
|
if (message.target === 'offscreen') {
|
||||||
|
// Handle commands
|
||||||
|
if (message.type === 'play-sound') {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function playNotificationSound() {
|
||||||
|
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
||||||
|
const oscillator = audioContext.createOscillator();
|
||||||
|
const gainNode = audioContext.createGain();
|
||||||
|
|
||||||
|
oscillator.connect(gainNode);
|
||||||
|
gainNode.connect(audioContext.destination);
|
||||||
|
|
||||||
|
oscillator.type = 'sine';
|
||||||
|
oscillator.frequency.setValueAtTime(500, audioContext.currentTime);
|
||||||
|
oscillator.frequency.exponentialRampToValueAtTime(1000, audioContext.currentTime + 0.1);
|
||||||
|
|
||||||
|
gainNode.gain.setValueAtTime(0.1, audioContext.currentTime);
|
||||||
|
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.1);
|
||||||
|
|
||||||
|
oscillator.start();
|
||||||
|
oscillator.stop(audioContext.currentTime + 0.1);
|
||||||
|
}
|
||||||
55
extension/popup.html
Normal file
55
extension/popup.html
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Tools App Extension</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
width: 250px;
|
||||||
|
padding: 15px;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
.status {
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #e0e0e0;
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.status.active {
|
||||||
|
background: #e3f2fd;
|
||||||
|
color: #1565c0;
|
||||||
|
border: 1px solid #90caf9;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
margin-top: 15px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h3>Tools App Extension</h3>
|
||||||
|
<div class="status active">
|
||||||
|
Extension is active and ready to communicate with Tools App.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top: 15px;">
|
||||||
|
<label style="display: flex; align-items: center; cursor: pointer;">
|
||||||
|
<input type="checkbox" id="soundToggle" style="margin-right: 8px;">
|
||||||
|
<span>Play sound on capture</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
Visit <a href="https://tools.7u.pl" target="_blank">tools.7u.pl</a>
|
||||||
|
</div>
|
||||||
|
<script src="popup.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
34
extension/popup.js
Normal file
34
extension/popup.js
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
// popup.js
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const soundToggle = document.getElementById('soundToggle');
|
||||||
|
|
||||||
|
// Load saved setting
|
||||||
|
chrome.storage.local.get(['playSound'], (result) => {
|
||||||
|
soundToggle.checked = result.playSound !== false; // Default to true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save setting on change
|
||||||
|
soundToggle.addEventListener('change', () => {
|
||||||
|
chrome.storage.local.set({ playSound: soundToggle.checked });
|
||||||
|
|
||||||
|
// Play test sound if enabled
|
||||||
|
if (soundToggle.checked) {
|
||||||
|
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
||||||
|
const oscillator = audioContext.createOscillator();
|
||||||
|
const gainNode = audioContext.createGain();
|
||||||
|
|
||||||
|
oscillator.connect(gainNode);
|
||||||
|
gainNode.connect(audioContext.destination);
|
||||||
|
|
||||||
|
oscillator.type = 'sine';
|
||||||
|
oscillator.frequency.setValueAtTime(500, audioContext.currentTime);
|
||||||
|
oscillator.frequency.exponentialRampToValueAtTime(1000, audioContext.currentTime + 0.1);
|
||||||
|
|
||||||
|
gainNode.gain.setValueAtTime(0.1, audioContext.currentTime);
|
||||||
|
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.1);
|
||||||
|
|
||||||
|
oscillator.start();
|
||||||
|
oscillator.stop(audioContext.currentTime + 0.1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,9 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<link rel="apple-touch-icon" href="/favicon.svg" />
|
||||||
|
<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>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
5152
package-lock.json
generated
5152
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
11
package.json
11
package.json
@@ -1,20 +1,25 @@
|
|||||||
{
|
{
|
||||||
"name": "tools-app",
|
"name": "tools-app",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0",
|
"version": "0.6.13",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"postinstall": "mkdir -p public/wasm && cp node_modules/zxing-wasm/dist/reader/zxing_reader.wasm public/wasm/"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"barcode-detector": "^3.1.0",
|
||||||
"lucide-vue-next": "^0.575.0",
|
"lucide-vue-next": "^0.575.0",
|
||||||
|
"marked": "^17.0.3",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"vue": "^3.5.25",
|
"vue": "^3.5.25",
|
||||||
"vue-router": "^5.0.3"
|
"vue-router": "^5.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-vue": "^6.0.2",
|
"@vitejs/plugin-vue": "^6.0.2",
|
||||||
"vite": "^7.3.1"
|
"vite": "^7.3.1",
|
||||||
|
"vite-plugin-pwa": "^1.2.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
20
public/favicon.svg
Normal file
20
public/favicon.svg
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" style="stop-color:#6366f1;stop-opacity:1" />
|
||||||
|
<stop offset="100%" style="stop-color:#0ea5e9;stop-opacity:1" />
|
||||||
|
</linearGradient>
|
||||||
|
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||||
|
<feDropShadow dx="0" dy="4" stdDeviation="4" flood-opacity="0.25"/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- Background Circle with Gradient -->
|
||||||
|
<circle cx="256" cy="256" r="240" fill="url(#grad1)" />
|
||||||
|
|
||||||
|
<!-- Gear Icon -->
|
||||||
|
<g transform="translate(106, 106) scale(0.6)" filter="url(#shadow)">
|
||||||
|
<path fill="white" d="M487.4 315.7l-42.6-24.6c4.3-23.2 4.3-47 0-70.2l42.6-24.6c4.9-2.8 7.1-8.6 5.5-14-11.1-35.6-30-67.8-54.7-94.6-3.8-4.1-10-5.1-14.8-2.3L380.8 110c-17.9-15.4-38.5-27.3-60.8-35.1V25.8c0-5.6-3.9-10.5-9.4-11.7-36.7-8.2-74.3-7.8-109.2 0-5.5 1.2-9.4 6.1-9.4 11.7V75c-22.2 7.9-42.8 19.8-60.8 35.1L88.7 85.5c-4.9-2.8-11-1.9-14.8 2.3-24.7 26.7-43.6 58.9-54.7 94.6-1.7 5.4.6 11.2 5.5 14L67.3 221c-4.3 23.2-4.3 47 0 70.2l-42.6 24.6c-4.9 2.8-7.1 8.6-5.5 14 11.1 35.6 30 67.8 54.7 94.6 3.8 4.1 10 5.1 14.8 2.3l42.6-24.6c17.9 15.4 38.5 27.3 60.8 35.1v49.2c0 5.6 3.9 10.5 9.4 11.7 36.7 8.2 74.3 7.8 109.2 0 5.5-1.2 9.4-6.1 9.4-11.7v-49.2c22.2-7.9 42.8-19.8 60.8-35.1l42.6 24.6c4.9 2.8 11 1.9 14.8-2.3 24.7-26.7 43.6-58.9 54.7-94.6 1.5-5.5-.7-11.3-5.6-14.1zM256 336c-44.1 0-80-35.9-80-80s35.9-80 80-80 80 35.9 80 80-35.9 80-80 80z"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
58
scripts/build_extension.py
Normal file
58
scripts/build_extension.py
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
SOURCE_DIR = "extension"
|
||||||
|
BUILD_DIR = "dist-extension"
|
||||||
|
OUTPUT_ZIP = "extension-release.zip"
|
||||||
|
MANIFEST_FILE = "manifest.json"
|
||||||
|
|
||||||
|
# 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.")
|
||||||
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
|
||||||
38
src/App.vue
38
src/App.vue
@@ -4,6 +4,9 @@ import { useRouter } from 'vue-router'
|
|||||||
import Header from './components/Header.vue'
|
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 ReloadPrompt from './components/ReloadPrompt.vue'
|
||||||
|
import { UI_CONFIG } from './config/ui'
|
||||||
|
|
||||||
const isSidebarOpen = ref(window.innerWidth >= 768)
|
const isSidebarOpen = ref(window.innerWidth >= 768)
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -29,6 +32,11 @@ const handleResize = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
// Set global CSS variables from config
|
||||||
|
document.documentElement.style.setProperty('--header-height', `${UI_CONFIG.headerHeight}px`)
|
||||||
|
document.documentElement.style.setProperty('--footer-height', `${UI_CONFIG.footerHeight}px`)
|
||||||
|
document.documentElement.style.setProperty('--page-padding', `${UI_CONFIG.pagePadding}px`)
|
||||||
|
|
||||||
window.addEventListener('resize', handleResize)
|
window.addEventListener('resize', handleResize)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -51,6 +59,8 @@ onUnmounted(() => {
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
<Footer />
|
<Footer />
|
||||||
|
<InstallPrompt />
|
||||||
|
<ReloadPrompt />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -59,16 +69,34 @@ onUnmounted(() => {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden; /* Ensure body doesn't scroll */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-content {
|
.main-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 2rem;
|
padding: 1rem;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 100%; /* Ensure it doesn't overflow */
|
max-width: 100%;
|
||||||
overflow-y: auto; /* Allow content to scroll independently */
|
/* Space for fixed footer on mobile + extra margin */
|
||||||
height: 100%; /* Take full height of app-body */
|
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) {
|
||||||
|
.app-body {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
overflow: visible;
|
||||||
|
height: auto;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-overlay {
|
.sidebar-overlay {
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -23,9 +24,17 @@ const version = __APP_VERSION__;
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
margin-top: auto; /* Push to bottom in flex container */
|
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
height: 30px;
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.app-footer {
|
||||||
|
position: static;
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer-content {
|
.footer-content {
|
||||||
|
|||||||
@@ -31,7 +31,13 @@ onMounted(() => {
|
|||||||
<header class="app-header glass-panel unselectable">
|
<header class="app-header glass-panel unselectable">
|
||||||
<div class="header-content">
|
<div class="header-content">
|
||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
<button class="menu-btn" @click="$emit('toggleSidebar')" aria-label="Toggle Menu" v-ripple>
|
<button
|
||||||
|
class="menu-btn"
|
||||||
|
@click="$emit('toggleSidebar')"
|
||||||
|
aria-label="Toggle Menu"
|
||||||
|
title="Toggle Menu"
|
||||||
|
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">
|
||||||
<line x1="3" y1="12" x2="21" y2="12"></line>
|
<line x1="3" y1="12" x2="21" y2="12"></line>
|
||||||
<line x1="3" y1="6" x2="21" y2="6"></line>
|
<line x1="3" y1="6" x2="21" y2="6"></line>
|
||||||
@@ -57,11 +63,15 @@ 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: relative;
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
border-left: none;
|
border-left: none;
|
||||||
border-right: none;
|
border-right: none;
|
||||||
@@ -70,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;
|
||||||
|
|||||||
153
src/components/InstallPrompt.vue
Normal file
153
src/components/InstallPrompt.vue
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="showInstallPrompt" class="install-prompt glass-panel unselectable">
|
||||||
|
<div class="prompt-content">
|
||||||
|
<span class="prompt-text">Install app for faster access</span>
|
||||||
|
<div class="prompt-actions">
|
||||||
|
<button @click="installPWA" class="install-btn">Install</button>
|
||||||
|
<button @click="dismissPrompt" class="dismiss-btn">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||||
|
|
||||||
|
const showInstallPrompt = ref(false)
|
||||||
|
let deferredPrompt = null
|
||||||
|
|
||||||
|
const handleBeforeInstallPrompt = (e) => {
|
||||||
|
// Prevent Chrome 67 and earlier from automatically showing the prompt
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
// Only show install prompt if we are on the specific domain
|
||||||
|
if (window.location.hostname !== 'tools.7u.pl') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stash the event so it can be triggered later.
|
||||||
|
deferredPrompt = e
|
||||||
|
// Update UI to notify the user they can add to home screen
|
||||||
|
showInstallPrompt.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const installPWA = async () => {
|
||||||
|
if (!deferredPrompt) return
|
||||||
|
|
||||||
|
// Show the install prompt
|
||||||
|
deferredPrompt.prompt()
|
||||||
|
|
||||||
|
// Wait for the user to respond to the prompt
|
||||||
|
const { outcome } = await deferredPrompt.userChoice
|
||||||
|
console.log(`User response to the install prompt: ${outcome}`)
|
||||||
|
|
||||||
|
// We've used the prompt, and can't use it again, throw it away
|
||||||
|
deferredPrompt = null
|
||||||
|
showInstallPrompt.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const dismissPrompt = () => {
|
||||||
|
showInstallPrompt.value = false
|
||||||
|
deferredPrompt = null
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.install-prompt {
|
||||||
|
position: fixed;
|
||||||
|
bottom: calc(5rem + env(safe-area-inset-bottom)); /* Above footer with extra space */
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 90%;
|
||||||
|
max-width: 400px;
|
||||||
|
padding: 1rem;
|
||||||
|
z-index: 9999;
|
||||||
|
animation: slideUp 0.3s ease-out;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-text {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.install-btn {
|
||||||
|
background-color: var(--primary-accent);
|
||||||
|
color: #000; /* Dark text on bright cyan accent for dark mode */
|
||||||
|
border: none;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dismiss-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate(-50%, 20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translate(-50%, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.install-prompt {
|
||||||
|
bottom: 2rem;
|
||||||
|
right: 2rem;
|
||||||
|
left: auto;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Light theme overrides for better contrast */
|
||||||
|
:global(:root[data-theme="light"]) .install-btn {
|
||||||
|
background-color: var(--accent-purple);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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()">
|
||||||
|
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>
|
||||||
@@ -11,6 +11,10 @@ defineProps({
|
|||||||
<aside class="sidebar unselectable" :class="{ 'is-open': isOpen }">
|
<aside class="sidebar unselectable" :class="{ 'is-open': isOpen }">
|
||||||
<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="/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>
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
166
src/components/tools/ClipboardSniffer.vue
Normal file
166
src/components/tools/ClipboardSniffer.vue
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, watch, nextTick } from 'vue'
|
||||||
|
import { useFillHeight } from '../../composables/useFillHeight'
|
||||||
|
import { useExtension } from '../../composables/useExtension'
|
||||||
|
import { useLocalStorage } from '../../composables/useLocalStorage'
|
||||||
|
import ExtensionStatus from './common/ExtensionStatus.vue'
|
||||||
|
|
||||||
|
const clipboardContent = useLocalStorage('clipboard-sniffer-content', '')
|
||||||
|
const textareaRef = ref(null)
|
||||||
|
|
||||||
|
const {
|
||||||
|
isExtensionReady,
|
||||||
|
isListening,
|
||||||
|
lastClipboardText,
|
||||||
|
startListening,
|
||||||
|
stopListening
|
||||||
|
} = useExtension()
|
||||||
|
|
||||||
|
const { height: textareaHeight } = useFillHeight(textareaRef, 40)
|
||||||
|
|
||||||
|
// Watch for clipboard updates from extension
|
||||||
|
watch(lastClipboardText, (newText) => {
|
||||||
|
if (newText) {
|
||||||
|
clipboardContent.value += (clipboardContent.value ? '\n' : '') + newText
|
||||||
|
scrollToBottom()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const scrollToBottom = () => {
|
||||||
|
nextTick(() => {
|
||||||
|
const textarea = document.querySelector('.tool-textarea')
|
||||||
|
if (textarea) {
|
||||||
|
textarea.scrollTop = textarea.scrollHeight
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyToClipboard = () => {
|
||||||
|
if (clipboardContent.value) {
|
||||||
|
navigator.clipboard.writeText(clipboardContent.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearText = () => {
|
||||||
|
clipboardContent.value = ''
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="tool-container full-width">
|
||||||
|
<div class="tool-panel">
|
||||||
|
<div class="tool-header">
|
||||||
|
<h2 class="tool-title">Clipboard Sniffer</h2>
|
||||||
|
<div class="extension-indicator-wrapper">
|
||||||
|
<ExtensionStatus :isReady="isExtensionReady" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="controls">
|
||||||
|
<button
|
||||||
|
v-if="!isListening"
|
||||||
|
class="btn-neon"
|
||||||
|
@click="startListening"
|
||||||
|
:disabled="!isExtensionReady"
|
||||||
|
:title="!isExtensionReady ? 'Extension required' : 'Start capturing clipboard'"
|
||||||
|
v-ripple
|
||||||
|
>
|
||||||
|
Start Sniffing
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
class="btn-neon danger"
|
||||||
|
@click="stopListening"
|
||||||
|
v-ripple
|
||||||
|
>
|
||||||
|
Stop Sniffing
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="btn-neon" @click="copyToClipboard" v-ripple>
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="btn-neon" @click="clearText" v-ripple>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="result-area" style="margin-top: 1rem;">
|
||||||
|
<div ref="textareaRef" :style="{ height: textareaHeight, width: '100%' }">
|
||||||
|
<textarea
|
||||||
|
v-model="clipboardContent"
|
||||||
|
class="tool-textarea"
|
||||||
|
placeholder="Clipboard content will appear here line by line..."
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.tool-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
/* Override shared tool-panel scroll for this tool */
|
||||||
|
max-height: none;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-textarea {
|
||||||
|
width: 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.extension-indicator-wrapper {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-area {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
|
import { useFillHeight } from '../../composables/useFillHeight';
|
||||||
|
|
||||||
// Options
|
// Options
|
||||||
const useLower = ref(true);
|
const useLower = ref(true);
|
||||||
@@ -12,6 +13,8 @@ const count = ref(20);
|
|||||||
|
|
||||||
// Result
|
// Result
|
||||||
const result = ref('');
|
const result = ref('');
|
||||||
|
const textareaRef = ref(null);
|
||||||
|
const { height: textareaHeight } = useFillHeight(textareaRef, 40);
|
||||||
|
|
||||||
// Character Sets
|
// Character Sets
|
||||||
const CHARS_LOWER = 'abcdefghijklmnopqrstuvwxyz';
|
const CHARS_LOWER = 'abcdefghijklmnopqrstuvwxyz';
|
||||||
@@ -68,9 +71,16 @@ const generatePasswords = () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="passwords-tool">
|
<div class="tool-container full-width">
|
||||||
<div class="glass-panel tool-panel">
|
<div class="tool-panel">
|
||||||
<h2 class="tool-title">Bulk Passwords Generator</h2>
|
<div class="panel-header">
|
||||||
|
<h2 class="tool-title">Bulk Passwords Generator</h2>
|
||||||
|
<div class="action-area desktop-only">
|
||||||
|
<button class="btn-neon generate-btn" @click="generatePasswords" v-ripple>
|
||||||
|
Generate
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="options-grid">
|
<div class="options-grid">
|
||||||
<div class="checkbox-group">
|
<div class="checkbox-group">
|
||||||
@@ -97,32 +107,39 @@ const generatePasswords = () => {
|
|||||||
<label class="checkbox-label">
|
<label class="checkbox-label">
|
||||||
<input type="checkbox" v-model="skipSimilar">
|
<input type="checkbox" v-model="skipSimilar">
|
||||||
<span class="checkmark"></span>
|
<span class="checkmark"></span>
|
||||||
Skip Similar Chars (I, l, 1, O, 0, o)
|
Skip Similar (I, l, 1, O, 0, o)
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="inputs-group">
|
<div class="inputs-group">
|
||||||
<div class="input-wrapper">
|
<div class="input-wrapper">
|
||||||
<label>Password Length</label>
|
<label>Length</label>
|
||||||
<input type="number" v-model="length" min="4" max="128" class="number-input">
|
<div class="number-control">
|
||||||
|
<button class="control-btn" @click="length > 4 ? length-- : null">-</button>
|
||||||
|
<input type="number" v-model="length" min="4" max="128" class="number-input">
|
||||||
|
<button class="control-btn" @click="length < 128 ? length++ : null">+</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="input-wrapper">
|
<div class="input-wrapper">
|
||||||
<label>Passwords Number</label>
|
<label>Count</label>
|
||||||
<input type="number" v-model="count" min="1" max="1000" class="number-input">
|
<div class="number-control">
|
||||||
|
<button class="control-btn" @click="count > 1 ? count-- : null">-</button>
|
||||||
|
<input type="number" v-model="count" min="1" max="1000" class="number-input">
|
||||||
|
<button class="control-btn" @click="count < 1000 ? count++ : null">+</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="action-area">
|
<div class="mobile-only" style="margin-top: 1rem; width: 100%;">
|
||||||
<button class="btn-neon generate-btn" @click="generatePasswords" v-ripple>
|
<button class="btn-neon generate-btn" @click="generatePasswords" v-ripple style="width: 100%;">
|
||||||
Generate
|
Generate
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="result-area">
|
<div class="result-area" :style="{ height: textareaHeight }">
|
||||||
<label>Passwords</label>
|
|
||||||
<textarea
|
<textarea
|
||||||
class="result-textarea glass-panel"
|
class="tool-textarea"
|
||||||
v-model="result"
|
v-model="result"
|
||||||
placeholder="Generated passwords will appear here..."
|
placeholder="Generated passwords will appear here..."
|
||||||
></textarea>
|
></textarea>
|
||||||
@@ -132,43 +149,63 @@ const generatePasswords = () => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.passwords-tool {
|
.tool-container.full-width {
|
||||||
|
max-width: 100%;
|
||||||
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
flex-direction: column;
|
||||||
width: 100%;
|
|
||||||
max-width: 800px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-panel {
|
.tool-panel {
|
||||||
width: 100%;
|
|
||||||
padding: 2rem;
|
|
||||||
border-radius: 16px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.tool-title {
|
.tool-title {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.5rem;
|
|
||||||
font-weight: 600;
|
|
||||||
background: var(--title-gradient);
|
|
||||||
-webkit-background-clip: text;
|
|
||||||
-webkit-text-fill-color: transparent;
|
|
||||||
filter: drop-shadow(0 0 10px var(--title-glow));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.options-grid {
|
.options-grid {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-wrap: wrap;
|
||||||
gap: 1.5rem;
|
gap: 2rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--toggle-bg);
|
||||||
|
border: 1px solid var(--toggle-border);
|
||||||
|
border-radius: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.checkbox-group {
|
.checkbox-group {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
flex-wrap: wrap;
|
||||||
|
gap: 1.5rem;
|
||||||
|
flex: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inputs-group {
|
||||||
|
display: flex;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 200px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 140px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.checkbox-label {
|
.checkbox-label {
|
||||||
@@ -179,8 +216,10 @@ const generatePasswords = () => {
|
|||||||
user-select: none;
|
user-select: none;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Custom Checkbox */
|
||||||
.checkbox-label input {
|
.checkbox-label input {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
@@ -196,16 +235,17 @@ const generatePasswords = () => {
|
|||||||
background-color: var(--toggle-bg);
|
background-color: var(--toggle-bg);
|
||||||
border: 1px solid var(--toggle-border);
|
border: 1px solid var(--toggle-border);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.checkbox-label:hover input ~ .checkmark {
|
.checkbox-label:hover .checkmark {
|
||||||
border-color: var(--accent-cyan);
|
border-color: var(--toggle-hover-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.checkbox-label input:checked ~ .checkmark {
|
.checkbox-label input:checked ~ .checkmark {
|
||||||
background-color: var(--accent-cyan);
|
background-color: var(--primary-accent);
|
||||||
border-color: var(--accent-cyan);
|
border-color: var(--primary-accent);
|
||||||
|
box-shadow: 0 0 10px var(--primary-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.checkmark:after {
|
.checkmark:after {
|
||||||
@@ -223,88 +263,135 @@ const generatePasswords = () => {
|
|||||||
top: 2px;
|
top: 2px;
|
||||||
width: 5px;
|
width: 5px;
|
||||||
height: 10px;
|
height: 10px;
|
||||||
border: solid white;
|
border: solid #000;
|
||||||
border-width: 0 2px 2px 0;
|
border-width: 0 2px 2px 0;
|
||||||
transform: rotate(45deg);
|
transform: rotate(45deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.inputs-group {
|
/* Number Control */
|
||||||
|
.number-control {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 2rem;
|
align-items: stretch;
|
||||||
flex-wrap: wrap;
|
background: var(--toggle-bg);
|
||||||
|
border: 1px solid var(--toggle-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
gap: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-wrapper {
|
.control-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-color);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
width: 40px;
|
||||||
|
height: auto;
|
||||||
|
min-height: 40px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
justify-content: center;
|
||||||
flex: 1;
|
|
||||||
min-width: 200px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-wrapper label {
|
.control-btn:hover {
|
||||||
font-size: 0.9rem;
|
background: var(--button-hover-bg);
|
||||||
color: var(--text-muted);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.number-input {
|
.number-input {
|
||||||
background: var(--toggle-bg);
|
background: none;
|
||||||
border: 1px solid var(--toggle-border);
|
border: none;
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
padding: 10px 15px;
|
width: 100%;
|
||||||
border-radius: 8px;
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
outline: none;
|
font-weight: bold;
|
||||||
transition: border-color 0.2s;
|
appearance: textfield;
|
||||||
|
-moz-appearance: textfield;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 0;
|
||||||
|
min-width: 60px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.number-input:focus {
|
.number-input:focus {
|
||||||
border-color: var(--accent-cyan);
|
outline: none;
|
||||||
|
box-shadow: none;
|
||||||
|
background: rgba(0, 0, 0, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-area {
|
.number-input::-webkit-outer-spin-button,
|
||||||
display: flex;
|
.number-input::-webkit-inner-spin-button {
|
||||||
justify-content: flex-end; /* Align button to right */
|
-webkit-appearance: none;
|
||||||
}
|
margin: 0;
|
||||||
|
|
||||||
.generate-btn {
|
|
||||||
width: 100%;
|
|
||||||
padding: 12px;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.result-area {
|
.result-area {
|
||||||
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.5rem;
|
min-height: 200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.result-area label {
|
.tool-textarea {
|
||||||
font-size: 0.9rem;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-weight: 600;
|
|
||||||
border-left: 3px solid var(--accent-purple);
|
|
||||||
padding-left: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-textarea {
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 300px;
|
height: 100%;
|
||||||
background: rgba(0, 0, 0, 0.2); /* Slightly darker for contrast */
|
|
||||||
color: var(--text-color);
|
|
||||||
border: 1px solid var(--accent-purple);
|
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
border-radius: 8px;
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
background: var(--glass-bg);
|
||||||
|
color: var(--text-color);
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
font-size: 1rem;
|
font-size: 0.9rem;
|
||||||
line-height: 1.5;
|
resize: none;
|
||||||
resize: vertical;
|
|
||||||
outline: none;
|
outline: none;
|
||||||
|
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-theme="light"] .result-textarea {
|
.generate-btn {
|
||||||
background: rgba(255, 255, 255, 0.5);
|
padding: 0.75rem 2rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: bold;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-only {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-only {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.options-grid {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inputs-group {
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.generate-btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-only {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-only {
|
||||||
|
display: block !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
299
src/components/tools/QrCode.vue
Normal file
299
src/components/tools/QrCode.vue
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, watch, onMounted } from 'vue'
|
||||||
|
import { Download } from 'lucide-vue-next'
|
||||||
|
import QRCode from 'qrcode'
|
||||||
|
import { useFillHeight } from '../../composables/useFillHeight'
|
||||||
|
import { useLocalStorage } from '../../composables/useLocalStorage'
|
||||||
|
|
||||||
|
const text = useLocalStorage('text', '', 'qr-code')
|
||||||
|
const ecc = useLocalStorage('ecc', 'M', 'qr-code')
|
||||||
|
const size = useLocalStorage('size', 300, 'qr-code')
|
||||||
|
const format = useLocalStorage('format', 'png', 'qr-code')
|
||||||
|
const svgContent = ref('')
|
||||||
|
const previewRef = ref(null)
|
||||||
|
|
||||||
|
const { height: previewHeight } = useFillHeight(previewRef, 40) // 40px extra margin
|
||||||
|
|
||||||
|
const generateQR = async () => {
|
||||||
|
if (!text.value) {
|
||||||
|
svgContent.value = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// Generate SVG for preview (always sharp)
|
||||||
|
svgContent.value = await QRCode.toString(text.value, {
|
||||||
|
type: 'svg',
|
||||||
|
errorCorrectionLevel: ecc.value,
|
||||||
|
margin: 1,
|
||||||
|
// No fixed width, allow scaling via CSS
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.error('QR Generation failed', err)
|
||||||
|
svgContent.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debounce generation slightly to avoid lag on typing
|
||||||
|
let timeout
|
||||||
|
watch([text, ecc], () => { // size is not relevant for preview
|
||||||
|
clearTimeout(timeout)
|
||||||
|
timeout = setTimeout(generateQR, 300)
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (text.value) generateQR()
|
||||||
|
})
|
||||||
|
|
||||||
|
const downloadFile = async () => {
|
||||||
|
if (!text.value) return
|
||||||
|
|
||||||
|
const filename = `qr-code-${Date.now()}.${format.value}`
|
||||||
|
|
||||||
|
if (format.value === 'svg') {
|
||||||
|
// For SVG download, we might want to inject the size if user specifically requested it,
|
||||||
|
// but usually raw SVG is better.
|
||||||
|
// If we want to support the "Size" dropdown for SVG download, we can regenerate with specific width.
|
||||||
|
const svgWithSize = await QRCode.toString(text.value, {
|
||||||
|
type: 'svg',
|
||||||
|
errorCorrectionLevel: ecc.value,
|
||||||
|
margin: 1,
|
||||||
|
width: size.value
|
||||||
|
})
|
||||||
|
const blob = new Blob([svgWithSize], { type: 'image/svg+xml' })
|
||||||
|
triggerDownload(blob, filename)
|
||||||
|
} else {
|
||||||
|
// For raster formats, render to canvas first
|
||||||
|
try {
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
await QRCode.toCanvas(canvas, text.value, {
|
||||||
|
errorCorrectionLevel: ecc.value,
|
||||||
|
margin: 1,
|
||||||
|
width: size.value
|
||||||
|
})
|
||||||
|
|
||||||
|
const mime = `image/${format.value}`
|
||||||
|
canvas.toBlob((blob) => {
|
||||||
|
if (blob) triggerDownload(blob, filename)
|
||||||
|
}, mime)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Download failed', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const triggerDownload = (blob, filename) => {
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = filename
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
document.body.removeChild(a)
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="tool-container full-width">
|
||||||
|
<div class="tool-panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<h2 class="tool-title">QR Generator</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="input-section">
|
||||||
|
<textarea
|
||||||
|
v-model="text"
|
||||||
|
class="tool-textarea"
|
||||||
|
placeholder="Enter text to generate QR code..."
|
||||||
|
rows="3"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="controls-section">
|
||||||
|
<div class="control-group">
|
||||||
|
<label>Error Correction</label>
|
||||||
|
<select v-model="ecc" class="select-input">
|
||||||
|
<option value="L">Low (7%)</option>
|
||||||
|
<option value="M">Medium (15%)</option>
|
||||||
|
<option value="Q">Quartile (25%)</option>
|
||||||
|
<option value="H">High (30%)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="control-group">
|
||||||
|
<label>Size (px)</label>
|
||||||
|
<select v-model="size" class="select-input">
|
||||||
|
<option :value="150">150x150</option>
|
||||||
|
<option :value="300">300x300</option>
|
||||||
|
<option :value="500">500x500</option>
|
||||||
|
<option :value="1000">1000x1000</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="control-group">
|
||||||
|
<label>Format</label>
|
||||||
|
<select v-model="format" class="select-input">
|
||||||
|
<option value="png">PNG</option>
|
||||||
|
<option value="jpeg">JPG</option>
|
||||||
|
<option value="webp">WebP</option>
|
||||||
|
<option value="svg">SVG</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="preview-section" v-if="text" ref="previewRef" :style="{ height: previewHeight }">
|
||||||
|
<div class="qr-frame" v-html="svgContent"></div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button class="action-btn" @click="downloadFile">
|
||||||
|
<Download size="18" />
|
||||||
|
Download {{ format.toUpperCase() }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.tool-container.full-width {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-panel {
|
||||||
|
padding: 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden; /* Prevent scrolling, force fit */
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--primary-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
color: var(--text-color);
|
||||||
|
font-size: 1rem;
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary-accent);
|
||||||
|
box-shadow: 0 0 0 2px rgba(0, 242, 254, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls-section {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-group label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-input {
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
color: var(--text-color);
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(0, 0, 0, 0.1);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1rem;
|
||||||
|
overflow: hidden; /* Prevent overflow if QR is too big */
|
||||||
|
min-height: 0;
|
||||||
|
container-type: size;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-frame {
|
||||||
|
width: calc(100cqmin - 4rem);
|
||||||
|
height: calc(100cqmin - 4rem);
|
||||||
|
background: white;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-frame :deep(svg) {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.6rem 1.2rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--primary-accent);
|
||||||
|
color: #000;
|
||||||
|
border: none;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
916
src/components/tools/QrScanner.vue
Normal file
916
src/components/tools/QrScanner.vue
Normal file
@@ -0,0 +1,916 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, onUnmounted, watch, computed } from 'vue'
|
||||||
|
import { SwitchCamera, Trash2, Copy, Download, X, Maximize2, Minimize2 } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const error = ref('')
|
||||||
|
const facingMode = ref('environment')
|
||||||
|
const scannedCodes = ref([])
|
||||||
|
const hasMultipleCameras = ref(false)
|
||||||
|
const isFullscreen = ref(false)
|
||||||
|
const videoAspect = ref(1)
|
||||||
|
const isMirrored = ref(false)
|
||||||
|
const wrapperRef = ref(null)
|
||||||
|
const bgCanvas = ref(null)
|
||||||
|
let bgRafId = null
|
||||||
|
|
||||||
|
// Native scanner state
|
||||||
|
const videoRef = ref(null)
|
||||||
|
let stream = null
|
||||||
|
let scanRafId = null
|
||||||
|
let barcodeDetector = null
|
||||||
|
|
||||||
|
const overlayCanvas = ref(null)
|
||||||
|
|
||||||
|
const paintDetections = (codes) => {
|
||||||
|
const canvas = overlayCanvas.value
|
||||||
|
const video = videoRef.value
|
||||||
|
|
||||||
|
if (!canvas || !video) return
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
const { width, height } = canvas.getBoundingClientRect()
|
||||||
|
|
||||||
|
// Update canvas size if needed (to match CSS size)
|
||||||
|
if (canvas.width !== width || canvas.height !== height) {
|
||||||
|
canvas.width = width
|
||||||
|
canvas.height = height
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, width, height)
|
||||||
|
|
||||||
|
if (!codes || codes.length === 0) return
|
||||||
|
|
||||||
|
const vw = video.videoWidth
|
||||||
|
const vh = video.videoHeight
|
||||||
|
if (!vw || !vh) return
|
||||||
|
|
||||||
|
// Calculate object-fit: cover scaling
|
||||||
|
const videoRatio = vw / vh
|
||||||
|
const canvasRatio = width / height
|
||||||
|
|
||||||
|
let drawWidth, drawHeight, startX, startY
|
||||||
|
|
||||||
|
if (canvasRatio > videoRatio) {
|
||||||
|
// Canvas is wider than video (video cropped top/bottom)
|
||||||
|
drawWidth = width
|
||||||
|
drawHeight = width / videoRatio
|
||||||
|
startX = 0
|
||||||
|
startY = (height - drawHeight) / 2
|
||||||
|
} else {
|
||||||
|
// Canvas is taller than video (video cropped left/right)
|
||||||
|
drawHeight = height
|
||||||
|
drawWidth = height * videoRatio
|
||||||
|
startY = 0
|
||||||
|
startX = (width - drawWidth) / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
const scale = drawWidth / vw
|
||||||
|
// Canvas is mirrored via CSS if isMirrored is true, so no manual coordinate mirroring needed
|
||||||
|
|
||||||
|
// Styles
|
||||||
|
const styles = getComputedStyle(document.documentElement)
|
||||||
|
const accent = styles.getPropertyValue('--primary-accent').trim() || '#00f2fe'
|
||||||
|
|
||||||
|
ctx.lineWidth = 4
|
||||||
|
ctx.strokeStyle = accent
|
||||||
|
ctx.fillStyle = accent
|
||||||
|
|
||||||
|
codes.forEach(code => {
|
||||||
|
const points = code.cornerPoints
|
||||||
|
if (!points || points.length < 4) return
|
||||||
|
|
||||||
|
ctx.beginPath()
|
||||||
|
|
||||||
|
const transform = (p) => {
|
||||||
|
let x = p.x * scale + startX
|
||||||
|
let y = p.y * scale + startY
|
||||||
|
return { x, y }
|
||||||
|
}
|
||||||
|
|
||||||
|
const p0 = transform(points[0])
|
||||||
|
ctx.moveTo(p0.x, p0.y)
|
||||||
|
|
||||||
|
for (let i = 1; i < points.length; i++) {
|
||||||
|
const p = transform(points[i])
|
||||||
|
ctx.lineTo(p.x, p.y)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.closePath()
|
||||||
|
ctx.stroke()
|
||||||
|
|
||||||
|
// Draw corners
|
||||||
|
points.forEach(p => {
|
||||||
|
const tp = transform(p)
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(tp.x, tp.y, 4, 0, Math.PI * 2)
|
||||||
|
ctx.fill()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateVideoAspect = () => {
|
||||||
|
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) {
|
||||||
|
// cover horizontally: scale by width, crop top/bottom
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const initDetector = async () => {
|
||||||
|
if (!barcodeDetector) {
|
||||||
|
if ('BarcodeDetector' in window) {
|
||||||
|
try {
|
||||||
|
// Formats are optional, but specifying qr_code might be faster
|
||||||
|
const formats = await window.BarcodeDetector.getSupportedFormats()
|
||||||
|
if (formats.includes('qr_code')) {
|
||||||
|
barcodeDetector = new window.BarcodeDetector({ formats: ['qr_code'] })
|
||||||
|
} else {
|
||||||
|
barcodeDetector = new window.BarcodeDetector()
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Fallback
|
||||||
|
barcodeDetector = new window.BarcodeDetector()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
error.value = 'Barcode Detection API not supported'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const startScan = async () => {
|
||||||
|
stopScan()
|
||||||
|
error.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
await initDetector()
|
||||||
|
if (!barcodeDetector) {
|
||||||
|
error.value = 'Barcode Detector failed to initialize'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const constraints = {
|
||||||
|
video: {
|
||||||
|
facingMode: facingMode.value,
|
||||||
|
width: { ideal: 1280 },
|
||||||
|
height: { ideal: 720 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stream = await navigator.mediaDevices.getUserMedia(constraints)
|
||||||
|
|
||||||
|
// Detect actual facing mode to mirror front camera correctly
|
||||||
|
const videoTrack = stream.getVideoTracks()[0]
|
||||||
|
if (videoTrack) {
|
||||||
|
const settings = videoTrack.getSettings()
|
||||||
|
if (settings.facingMode) {
|
||||||
|
isMirrored.value = settings.facingMode === 'user'
|
||||||
|
} else {
|
||||||
|
// Fallback: check label for desktop cameras or assume requested mode
|
||||||
|
const label = videoTrack.label ? videoTrack.label.toLowerCase() : ''
|
||||||
|
if (label.includes('front') || label.includes('facetime') || label.includes('macbook')) {
|
||||||
|
isMirrored.value = true
|
||||||
|
} else {
|
||||||
|
isMirrored.value = facingMode.value === 'user'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (videoRef.value) {
|
||||||
|
videoRef.value.srcObject = stream
|
||||||
|
// Wait for metadata to play
|
||||||
|
videoRef.value.onloadedmetadata = () => {
|
||||||
|
videoRef.value.play().catch(e => console.error('Play error', e))
|
||||||
|
updateVideoAspect()
|
||||||
|
detectLoop()
|
||||||
|
startBackgroundLoop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
onError(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopScan = () => {
|
||||||
|
if (scanRafId) cancelAnimationFrame(scanRafId)
|
||||||
|
if (stream) {
|
||||||
|
stream.getTracks().forEach(t => t.stop())
|
||||||
|
stream = null
|
||||||
|
}
|
||||||
|
stopBackgroundLoop()
|
||||||
|
}
|
||||||
|
|
||||||
|
const detectLoop = async () => {
|
||||||
|
if (!videoRef.value || videoRef.value.paused || videoRef.value.ended) {
|
||||||
|
scanRafId = requestAnimationFrame(detectLoop)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const codes = await barcodeDetector.detect(videoRef.value)
|
||||||
|
paintDetections(codes)
|
||||||
|
if (codes.length > 0) {
|
||||||
|
onDetect(codes)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// console.error('Detection error', e)
|
||||||
|
}
|
||||||
|
scanRafId = requestAnimationFrame(detectLoop)
|
||||||
|
}
|
||||||
|
|
||||||
|
const desktopFullscreenStyle = computed(() => {
|
||||||
|
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 fullscreen, accept all detected codes (as the user sees the full camera view mostly)
|
||||||
|
if (isFullscreen.value) {
|
||||||
|
processCodes(detectedCodes)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to find video element to calculate visible area
|
||||||
|
const videoEl = document.querySelector('.camera-wrapper video')
|
||||||
|
|
||||||
|
if (!videoEl || !videoEl.videoWidth || !videoEl.videoHeight) {
|
||||||
|
processCodes(detectedCodes)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { videoWidth, videoHeight } = videoEl
|
||||||
|
|
||||||
|
// Calculate visible square area (assuming object-fit: cover and 1:1 container)
|
||||||
|
const isLandscape = videoWidth > videoHeight
|
||||||
|
let visibleX, visibleY, visibleW, visibleH
|
||||||
|
|
||||||
|
if (isLandscape) {
|
||||||
|
// Landscape: sides are cropped, height is fully visible
|
||||||
|
visibleH = videoHeight
|
||||||
|
visibleW = videoHeight // Square
|
||||||
|
visibleX = (videoWidth - videoHeight) / 2
|
||||||
|
visibleY = 0
|
||||||
|
} else {
|
||||||
|
// Portrait: top/bottom are cropped, width is fully visible
|
||||||
|
visibleW = videoWidth
|
||||||
|
visibleH = videoWidth // Square
|
||||||
|
visibleX = 0
|
||||||
|
visibleY = (videoHeight - videoWidth) / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add margin to be safe (code center must be within visible area)
|
||||||
|
// We allow codes slightly outside if their center is inside
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(facingMode, () => {
|
||||||
|
startScan()
|
||||||
|
})
|
||||||
|
|
||||||
|
const onError = (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}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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 })
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
checkCameras()
|
||||||
|
loadHistory()
|
||||||
|
window.addEventListener('resize', updateVideoAspect)
|
||||||
|
window.addEventListener('resize', startBackgroundLoop)
|
||||||
|
watch(isFullscreen, (fs) => {
|
||||||
|
if (fs) {
|
||||||
|
startBackgroundLoop()
|
||||||
|
} else {
|
||||||
|
stopBackgroundLoop()
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
startScan()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('resize', updateVideoAspect)
|
||||||
|
window.removeEventListener('resize', startBackgroundLoop)
|
||||||
|
stopScan()
|
||||||
|
})
|
||||||
|
|
||||||
|
const switchCamera = (event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
facingMode.value = facingMode.value === 'environment' ? 'user' : 'environment'
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
|
<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">Retry</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="hasMultipleCameras"
|
||||||
|
class="switch-camera-btn"
|
||||||
|
@click.stop="switchCamera"
|
||||||
|
title="Switch Camera"
|
||||||
|
>
|
||||||
|
<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" title="Copy All">
|
||||||
|
<Copy size="18" />
|
||||||
|
</button>
|
||||||
|
<button class="icon-btn" @click="downloadJson" title="Download JSON">
|
||||||
|
<Download size="18" />
|
||||||
|
</button>
|
||||||
|
<button class="icon-btn delete-btn" @click="clearHistory" title="Clear All">
|
||||||
|
<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)" title="Copy">
|
||||||
|
<Copy size="18" />
|
||||||
|
</button>
|
||||||
|
<button class="icon-btn delete-btn" @click="removeCode(code.id)" title="Remove">
|
||||||
|
<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>
|
||||||
|
.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;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
margin-top: 0;
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* front mirror canvas removed */
|
||||||
|
|
||||||
|
.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;
|
||||||
|
top: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Removed legacy scan frame overlay - using shape detection rendering via track instead */
|
||||||
|
|
||||||
|
.results-section {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--glass-bg);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 rgba(255, 255, 255, 0.2);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-header {
|
||||||
|
padding: 1rem;
|
||||||
|
border-bottom: 1px solid var(--glass-border);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: var(--text-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codes-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem;
|
||||||
|
border-bottom: 1px solid var(--glass-border);
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-item:hover {
|
||||||
|
background: var(--list-hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
padding-right: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.4rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn:hover {
|
||||||
|
color: var(--text-color);
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(:root[data-theme="light"]) .icon-btn:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-btn:hover {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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>
|
||||||
594
src/components/tools/UrlCleaner.vue
Normal file
594
src/components/tools/UrlCleaner.vue
Normal file
@@ -0,0 +1,594 @@
|
|||||||
|
<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 { useLocalStorage } from '../../composables/useLocalStorage'
|
||||||
|
import ExtensionStatus from './common/ExtensionStatus.vue'
|
||||||
|
import UrlCleanerExceptionsModal from './UrlCleanerExceptionsModal.vue'
|
||||||
|
|
||||||
|
// Extension integration
|
||||||
|
const { isExtensionReady, isListening, lastClipboardText, startListening, stopListening, writeClipboard } = useExtension()
|
||||||
|
|
||||||
|
const inputUrl = ref('')
|
||||||
|
// Use local storage for history persistence
|
||||||
|
const cleanedHistory = useLocalStorage('url-cleaner-history', [])
|
||||||
|
const isWatchEnabled = useLocalStorage('url-cleaner-watch-enabled', false)
|
||||||
|
|
||||||
|
// Exceptions management
|
||||||
|
const showExceptionsModal = ref(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)
|
||||||
|
|
||||||
|
// Helper to match domain with glob pattern
|
||||||
|
const matchDomain = (pattern, domain) => {
|
||||||
|
// Escape regex chars except *
|
||||||
|
const regexString = '^' + pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') + '$'
|
||||||
|
return new RegExp(regexString, 'i').test(domain)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watch for clipboard changes from extension
|
||||||
|
watch(lastClipboardText, (newText) => {
|
||||||
|
if (isWatchEnabled.value && newText) {
|
||||||
|
processUrl(newText, true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 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 => {
|
||||||
|
processUrl(url.trim(), false)
|
||||||
|
})
|
||||||
|
inputUrl.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const processUrl = (text, autoClipboard = false) => {
|
||||||
|
try {
|
||||||
|
// Basic URL validation
|
||||||
|
if (!text.match(/^https?:\/\//i)) {
|
||||||
|
// Not a URL, ignore in watch mode
|
||||||
|
if (autoClipboard) return
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalLength = text.length
|
||||||
|
let cleanedUrl = text
|
||||||
|
|
||||||
|
try {
|
||||||
|
const urlObj = new URL(text)
|
||||||
|
const hostname = urlObj.hostname
|
||||||
|
|
||||||
|
// Check for exceptions
|
||||||
|
const matchedRule = exceptions.value.find(rule =>
|
||||||
|
rule.isEnabled && matchDomain(rule.domainPattern, hostname)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (matchedRule) {
|
||||||
|
if (!matchedRule.keepAllParams) {
|
||||||
|
// Exception logic: keep specific params
|
||||||
|
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 {
|
||||||
|
// Default behavior: remove all query params and hash
|
||||||
|
if (urlObj.search || urlObj.hash) {
|
||||||
|
urlObj.search = ''
|
||||||
|
urlObj.hash = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanedUrl = urlObj.toString()
|
||||||
|
// Remove trailing slash if it wasn't there before? usually keep it standard
|
||||||
|
} catch (e) {
|
||||||
|
// Invalid URL format
|
||||||
|
if (!autoClipboard) {
|
||||||
|
// Show error or just return original
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no change, ignore in watch mode to avoid loops
|
||||||
|
if (cleanedUrl === text && autoClipboard) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const newLength = cleanedUrl.length
|
||||||
|
const savedChars = originalLength - newLength
|
||||||
|
const savedPercent = originalLength > 0 ? Math.round((savedChars / originalLength) * 100) : 0
|
||||||
|
|
||||||
|
// Add to history
|
||||||
|
const entry = {
|
||||||
|
id: Date.now(),
|
||||||
|
original: text,
|
||||||
|
cleaned: cleanedUrl,
|
||||||
|
savedPercent,
|
||||||
|
timestamp: new Date().toLocaleTimeString()
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanedHistory.value.unshift(entry)
|
||||||
|
|
||||||
|
// Limit history
|
||||||
|
if (cleanedHistory.value.length > 50) {
|
||||||
|
cleanedHistory.value.pop()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-copy back to clipboard if in watch mode
|
||||||
|
if (autoClipboard && savedChars > 0) {
|
||||||
|
writeClipboard(cleanedUrl)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error processing URL:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyToClipboard = (text) => {
|
||||||
|
navigator.clipboard.writeText(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeEntry = (id) => {
|
||||||
|
cleanedHistory.value = cleanedHistory.value.filter(item => item.id !== id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearHistory = () => {
|
||||||
|
cleanedHistory.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
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" title="Cleaning Exceptions">
|
||||||
|
<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="url-input"
|
||||||
|
@keydown.enter.prevent="handleClean"
|
||||||
|
rows="1"
|
||||||
|
></textarea>
|
||||||
|
<button class="btn-neon" @click="handleClean">
|
||||||
|
Clean
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="watch-toggle">
|
||||||
|
<button
|
||||||
|
class="btn-neon toggle-btn"
|
||||||
|
:class="{ 'active': isWatchEnabled && isExtensionReady }"
|
||||||
|
@click="toggleWatch"
|
||||||
|
:disabled="!isExtensionReady"
|
||||||
|
:title="!isExtensionReady ? 'Extension required for auto-watch' : 'Automatically clean URLs from clipboard'"
|
||||||
|
>
|
||||||
|
<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</h3>
|
||||||
|
<div class="history-actions">
|
||||||
|
<button class="icon-btn" @click="copyAllUrls" title="Copy all URLs">
|
||||||
|
<Copy size="18" />
|
||||||
|
</button>
|
||||||
|
<button class="icon-btn" @click="downloadJson" title="Download JSON">
|
||||||
|
<Download size="18" />
|
||||||
|
</button>
|
||||||
|
<button class="icon-btn delete-btn" @click="clearHistory" title="Clear History">
|
||||||
|
<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)" title="Copy">
|
||||||
|
<Copy size="18" />
|
||||||
|
</button>
|
||||||
|
<a :href="item.cleaned" target="_blank" class="icon-btn" title="Open">
|
||||||
|
<ExternalLink size="18" />
|
||||||
|
</a>
|
||||||
|
<button class="icon-btn delete-btn" @click="removeEntry(item.id)" title="Remove">
|
||||||
|
<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>
|
||||||
|
.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;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
padding: 0.8rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--toggle-border);
|
||||||
|
background: var(--toggle-bg);
|
||||||
|
color: var(--text-color);
|
||||||
|
font-size: 1rem;
|
||||||
|
outline: none;
|
||||||
|
transition: all 0.2s;
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 46px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-input:focus {
|
||||||
|
border-color: var(--primary-accent);
|
||||||
|
box-shadow: 0 0 0 2px rgba(var(--primary-accent-rgb), 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.watch-toggle {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: #4ade80; /* Green */
|
||||||
|
box-shadow: 0 0 8px #4ade80;
|
||||||
|
margin-left: 0.2rem;
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0% { opacity: 1; }
|
||||||
|
50% { opacity: 0.5; }
|
||||||
|
100% { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 {
|
||||||
|
padding: 1rem;
|
||||||
|
border-bottom: 1px solid var(--glass-border);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: var(--text-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.8rem;
|
||||||
|
border-bottom: 1px solid var(--glass-border);
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-item:hover {
|
||||||
|
background: var(--list-hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-info {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
padding-right: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(:root[data-theme="light"]) .savings {
|
||||||
|
color: #16a34a;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.4rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn:hover {
|
||||||
|
color: var(--text-color);
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-btn:hover {
|
||||||
|
background: none;
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(:root[data-theme="light"]) .delete-btn:hover {
|
||||||
|
background: none;
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.8rem;
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(:root[data-theme="light"]) .settings-btn {
|
||||||
|
background: rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(:root[data-theme="light"]) .settings-btn:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
601
src/components/tools/UrlCleanerExceptionsModal.vue
Normal file
601
src/components/tools/UrlCleanerExceptionsModal.vue
Normal file
@@ -0,0 +1,601 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed } 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 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
|
||||||
|
|
||||||
|
const params = newRule.value.keepParams
|
||||||
|
.split(',')
|
||||||
|
.map(p => p.trim())
|
||||||
|
.filter(p => p)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const editRule = (rule) => {
|
||||||
|
newRule.value = {
|
||||||
|
domainPattern: rule.domainPattern,
|
||||||
|
keepParams: Array.isArray(rule.keepParams) ? rule.keepParams.join(', ') : '',
|
||||||
|
keepHash: !!rule.keepHash,
|
||||||
|
keepAllParams: !!rule.keepAllParams
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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">
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="newRule.keepParams"
|
||||||
|
placeholder="Keep params (comma separated, e.g. v, id)"
|
||||||
|
class="input-field"
|
||||||
|
@keyup.enter="addRule"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="form-row checkbox-row">
|
||||||
|
<div class="checkbox-group">
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" v-model="newRule.keepHash">
|
||||||
|
Keep Anchor (#)
|
||||||
|
</label>
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" v-model="newRule.keepAllParams">
|
||||||
|
Keep all params
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button class="btn-neon small" @click="addRule" :disabled="!newRule.domainPattern">
|
||||||
|
<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)" title="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)" title="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)" title="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)" title="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)"
|
||||||
|
:title="rule.isEnabled ? 'Disable rule' : 'Enable rule'"
|
||||||
|
>
|
||||||
|
<div class="toggle-switch" :class="{ active: rule.isEnabled }"></div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="!rule.isDefault"
|
||||||
|
class="icon-btn delete-btn"
|
||||||
|
@click="removeRule(rule.id)"
|
||||||
|
title="Remove rule"
|
||||||
|
>
|
||||||
|
<Trash2 size="18" />
|
||||||
|
</button>
|
||||||
|
<button v-else class="btn-neon small default-reset" @click="resetToDefault(rule.id)" title="Restore default rule">
|
||||||
|
<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;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(:root[data-theme="light"]) .modal-overlay {
|
||||||
|
background: rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background: var(--glass-bg);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
-webkit-backdrop-filter: blur(16px);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 0;
|
||||||
|
max-width: 800px;
|
||||||
|
width: 100%;
|
||||||
|
max-height: 85vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-shadow: var(--glass-shadow);
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(:root[data-theme="light"]) .modal-content {
|
||||||
|
background: rgba(255, 255, 255, 0.98);
|
||||||
|
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(:root[data-theme="light"]) .add-rule-form {
|
||||||
|
background: rgba(0, 0, 0, 0.04);
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 {
|
||||||
|
border-color: var(--primary-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(:root[data-theme="light"]) .rule-item {
|
||||||
|
background: rgba(0, 0, 0, 0.04);
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-tag {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(:root[data-theme="light"]) .delete-btn:hover {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
192
src/components/tools/common/ExtensionStatus.vue
Normal file
192
src/components/tools/common/ExtensionStatus.vue
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
inheritAttrs: false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { Plug, Plus, X } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
isReady: Boolean
|
||||||
|
})
|
||||||
|
|
||||||
|
const showModal = ref(false)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="extension-status" v-bind="$attrs" :class="{ 'is-ready': isReady }" @click="showModal = true" :title="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="#" 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 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">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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background: var(--glass-bg);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
-webkit-backdrop-filter: blur(16px);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 2.5rem; /* Większy padding */
|
||||||
|
max-width: 500px;
|
||||||
|
width: 90%;
|
||||||
|
position: relative;
|
||||||
|
box-shadow: var(--glass-shadow);
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-color); /* Wymuś kolor tekstu */
|
||||||
|
}
|
||||||
|
|
||||||
|
.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>
|
||||||
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
|
||||||
|
}
|
||||||
|
}
|
||||||
49
src/composables/useFillHeight.js
Normal file
49
src/composables/useFillHeight.js
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { onMounted, onUnmounted, ref, nextTick, watch } from 'vue'
|
||||||
|
import { UI_CONFIG } from '../config/ui'
|
||||||
|
|
||||||
|
export function useFillHeight(elementRef, extraMargin = 0) {
|
||||||
|
const height = ref('auto')
|
||||||
|
|
||||||
|
const updateHeight = () => {
|
||||||
|
if (!elementRef.value) return
|
||||||
|
|
||||||
|
const rect = elementRef.value.getBoundingClientRect()
|
||||||
|
const windowHeight = window.innerHeight
|
||||||
|
|
||||||
|
// Calculate available space: window height - element top position - footer height - padding - extra margin
|
||||||
|
const bottomOffset = UI_CONFIG.footerHeight + UI_CONFIG.pagePadding + extraMargin
|
||||||
|
const availableHeight = windowHeight - rect.top - bottomOffset
|
||||||
|
|
||||||
|
// Ensure minimum height
|
||||||
|
if (availableHeight > 100) {
|
||||||
|
height.value = `${availableHeight}px`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// Initial update after render
|
||||||
|
nextTick(() => {
|
||||||
|
updateHeight()
|
||||||
|
window.addEventListener('resize', updateHeight)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Also update after a short delay to ensure layout is settled (e.g. sidebar transitions)
|
||||||
|
setTimeout(updateHeight, 300)
|
||||||
|
|
||||||
|
// Watch for element appearing (v-if) or changing
|
||||||
|
watch(elementRef, () => {
|
||||||
|
nextTick(updateHeight)
|
||||||
|
// Additional update for layout stability
|
||||||
|
setTimeout(updateHeight, 100)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('resize', updateHeight)
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
height,
|
||||||
|
updateHeight
|
||||||
|
}
|
||||||
|
}
|
||||||
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
|
||||||
|
}
|
||||||
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)
|
||||||
|
}
|
||||||
21
src/main.js
21
src/main.js
@@ -3,6 +3,27 @@ import './style.css'
|
|||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import router from './router'
|
import router from './router'
|
||||||
import Ripple from './directives/ripple'
|
import Ripple from './directives/ripple'
|
||||||
|
import { BarcodeDetector, prepareZXingModule } from 'barcode-detector/ponyfill'
|
||||||
|
|
||||||
|
// Configure BarcodeDetector polyfill to use local WASM file
|
||||||
|
try {
|
||||||
|
prepareZXingModule({
|
||||||
|
overrides: {
|
||||||
|
locateFile: (path, prefix) => {
|
||||||
|
if (path.endsWith('.wasm')) {
|
||||||
|
return '/wasm/zxing_reader.wasm'
|
||||||
|
}
|
||||||
|
return prefix + path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Force usage of polyfill to avoid CSP issues and ensure consistent behavior
|
||||||
|
// Native implementation might fail or be missing in some browsers
|
||||||
|
window.BarcodeDetector = BarcodeDetector
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to initialize BarcodeDetector polyfill', e)
|
||||||
|
}
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { createRouter, createWebHistory } from 'vue-router'
|
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 UrlCleaner from '../components/tools/UrlCleaner.vue'
|
||||||
|
import QrScanner from '../components/tools/QrScanner.vue'
|
||||||
|
import QrCode from '../components/tools/QrCode.vue'
|
||||||
|
import PrivacyPolicy from '../views/PrivacyPolicy.vue'
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
{
|
{
|
||||||
@@ -12,6 +17,31 @@ const routes = [
|
|||||||
path: '/passwords',
|
path: '/passwords',
|
||||||
name: 'Passwords',
|
name: 'Passwords',
|
||||||
component: Passwords
|
component: Passwords
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/clipboard-sniffer',
|
||||||
|
name: 'ClipboardSniffer',
|
||||||
|
component: ClipboardSniffer
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/url-cleaner',
|
||||||
|
name: 'UrlCleaner',
|
||||||
|
component: UrlCleaner
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/qr-scanner',
|
||||||
|
name: 'QrScanner',
|
||||||
|
component: QrScanner
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/qr-code',
|
||||||
|
name: 'QrCode',
|
||||||
|
component: QrCode
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/extension-privacy-policy',
|
||||||
|
name: 'PrivacyPolicy',
|
||||||
|
component: PrivacyPolicy
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
196
src/style.css
196
src/style.css
@@ -1,3 +1,7 @@
|
|||||||
|
/* Box sizing reset */
|
||||||
|
*, *::before, *::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
: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;
|
||||||
@@ -36,6 +40,8 @@
|
|||||||
--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);
|
||||||
|
--header-bg: rgba(0, 0, 0, 0.6);
|
||||||
|
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
background-color: #242424; /* Fallback */
|
background-color: #242424; /* Fallback */
|
||||||
@@ -49,7 +55,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root[data-theme="light"] {
|
:root[data-theme="light"] {
|
||||||
--bg-gradient: radial-gradient(circle at center, #ffffff 0%, #cccccc 100%);
|
--bg-gradient: radial-gradient(circle at center, #ffffff 0%, #e5e7eb 100%);
|
||||||
--glass-bg: rgba(255, 255, 255, 0.75);
|
--glass-bg: rgba(255, 255, 255, 0.75);
|
||||||
--glass-border: rgba(15, 23, 42, 0.12);
|
--glass-border: rgba(15, 23, 42, 0.12);
|
||||||
--glass-shadow: 0 8px 32px 0 rgba(15, 23, 42, 0.12);
|
--glass-shadow: 0 8px 32px 0 rgba(15, 23, 42, 0.12);
|
||||||
@@ -78,22 +84,154 @@
|
|||||||
--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(0, 0, 0, 0.05);
|
||||||
|
--header-bg: rgba(255, 255, 255, 0.9);
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
min-width: 320px;
|
min-width: 320px;
|
||||||
height: 100vh;
|
min-height: 100vh;
|
||||||
overflow: hidden; /* Prevent body scroll */
|
overflow-x: hidden;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectable {
|
||||||
|
user-select: text;
|
||||||
|
-webkit-user-select: text;
|
||||||
|
cursor: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
#app {
|
#app {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100vh;
|
min-height: 100vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden; /* Prevent app scroll */
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
min-height: 100vh;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Removed global front camera mirror to restore stability */
|
||||||
|
|
||||||
|
/* --- Shared styles for all tools (moved from tools.css) --- */
|
||||||
|
|
||||||
|
.tool-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
height: 100%;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-panel {
|
||||||
|
width: 100%;
|
||||||
|
padding: 1rem;
|
||||||
|
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 !important;
|
||||||
|
-webkit-background-clip: unset !important;
|
||||||
|
background-clip: unset !important;
|
||||||
|
color: #000000 !important;
|
||||||
|
-webkit-text-fill-color: #000000 !important;
|
||||||
|
filter: none !important; */
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-textarea {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
padding: 1rem;
|
||||||
|
background-color: rgba(0, 0, 0, 0.2) !important;
|
||||||
|
border: 1px solid var(--toggle-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
color: #ffffff !important; /* 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 !important;
|
||||||
|
background-color: rgba(255, 255, 255, 0.5) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #00f2fe !important; /* Force cyan accent */
|
||||||
|
box-shadow: 0 0 0 1px #00f2fe !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Common UI Element Styles */
|
/* Common UI Element Styles */
|
||||||
@@ -105,6 +243,16 @@ 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);
|
||||||
@@ -122,6 +270,12 @@ body {
|
|||||||
outline: none; /* Remove focus outline */
|
outline: none; /* Remove focus outline */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Global button styles */
|
||||||
|
button {
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* Remove focus outline for all buttons */
|
/* Remove focus outline for all buttons */
|
||||||
button:focus {
|
button:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
@@ -139,6 +293,22 @@ button:focus {
|
|||||||
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%;
|
||||||
@@ -167,3 +337,19 @@ span.ripple {
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Global Input/Select Focus Styles --- */
|
||||||
|
input:focus,
|
||||||
|
select:focus,
|
||||||
|
textarea:focus,
|
||||||
|
button:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom focus ring for all form elements */
|
||||||
|
input:focus,
|
||||||
|
select:focus,
|
||||||
|
textarea:focus {
|
||||||
|
border-color: var(--primary-accent);
|
||||||
|
box-shadow: 0 0 0 1px var(--primary-accent);
|
||||||
|
}
|
||||||
|
|||||||
107
src/styles/tools.css
Normal file
107
src/styles/tools.css
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
/* 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>
|
||||||
@@ -1,11 +1,53 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import vue from '@vitejs/plugin-vue'
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import { VitePWA } from 'vite-plugin-pwa'
|
||||||
import packageJson from './package.json'
|
import packageJson from './package.json'
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [vue()],
|
plugins: [
|
||||||
|
vue(),
|
||||||
|
VitePWA({
|
||||||
|
registerType: 'autoUpdate',
|
||||||
|
includeAssets: ['favicon.svg'],
|
||||||
|
manifest: {
|
||||||
|
name: 'Tools App',
|
||||||
|
short_name: 'Tools',
|
||||||
|
description: 'A collection of useful tools',
|
||||||
|
theme_color: '#4facfe',
|
||||||
|
background_color: '#242424',
|
||||||
|
display: 'standalone',
|
||||||
|
orientation: 'portrait',
|
||||||
|
icons: [
|
||||||
|
{
|
||||||
|
src: 'favicon.svg',
|
||||||
|
sizes: 'any',
|
||||||
|
type: 'image/svg+xml',
|
||||||
|
purpose: 'any maskable'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
workbox: {
|
||||||
|
globPatterns: ['**/*.{js,css,html,ico,png,svg,json,vue,txt,woff2}'],
|
||||||
|
cleanupOutdatedCaches: true,
|
||||||
|
clientsClaim: true,
|
||||||
|
skipWaiting: true
|
||||||
|
},
|
||||||
|
devOptions: {
|
||||||
|
enabled: true,
|
||||||
|
/* when using generateSW the PWA plugin will switch to classic */
|
||||||
|
type: 'module',
|
||||||
|
navigateFallback: 'index.html',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
],
|
||||||
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