Compare commits
18 Commits
ac425d3df2
...
v0.4.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
8b5705c12f
|
|||
|
a3bc069029
|
|||
|
a5fc242a97
|
|||
|
a0346a64f0
|
|||
|
4c2d423715
|
|||
|
204aeda00c
|
|||
|
7d989be27f
|
|||
|
efe23a99ac
|
|||
|
bebb63c1de
|
|||
|
98d76e3a35
|
|||
|
cc7e80a807
|
|||
|
1f5500f7d7
|
|||
|
d404370027
|
|||
|
8fb3ee1069
|
|||
|
348c78612d
|
|||
|
dc99dce485
|
|||
|
20dc18dd28
|
|||
|
30d67472cb
|
3
.gitignore
vendored
3
.gitignore
vendored
@@ -23,3 +23,6 @@ dist-ssr
|
||||
*.sln
|
||||
*.sw?
|
||||
dev-dist
|
||||
extension-release.zip
|
||||
*.zip
|
||||
tools-app-extension-*.zip
|
||||
|
||||
68
README.md
68
README.md
@@ -1 +1,67 @@
|
||||
# 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.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
@@ -122,6 +122,17 @@ chrome.runtime.onMessage.addListener(async (request, sender, sendResponse) => {
|
||||
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) {
|
||||
|
||||
@@ -46,6 +46,17 @@ window.addEventListener('message', (event) => {
|
||||
// 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
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Tools App Extension",
|
||||
"version": "1.0",
|
||||
"version": "1.1",
|
||||
"description": "Browser extension for Tools App",
|
||||
"permissions": [
|
||||
"clipboardRead",
|
||||
"clipboardWrite",
|
||||
"offscreen",
|
||||
"storage",
|
||||
"alarms",
|
||||
|
||||
@@ -42,12 +42,24 @@ setInterval(async () => {
|
||||
}
|
||||
}, 50);
|
||||
|
||||
// Listen for messages from background if we need to change behavior
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "tools-app",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "tools-app",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.2",
|
||||
"dependencies": {
|
||||
"lucide-vue-next": "^0.575.0",
|
||||
"vue": "^3.5.25",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "tools-app",
|
||||
"private": true,
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
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
|
||||
@@ -5,6 +5,7 @@ import Header from './components/Header.vue'
|
||||
import Footer from './components/Footer.vue'
|
||||
import Sidebar from './components/Sidebar.vue'
|
||||
import InstallPrompt from './components/InstallPrompt.vue'
|
||||
import ReloadPrompt from './components/ReloadPrompt.vue'
|
||||
|
||||
const isSidebarOpen = ref(window.innerWidth >= 768)
|
||||
const router = useRouter()
|
||||
@@ -53,6 +54,7 @@ onUnmounted(() => {
|
||||
</div>
|
||||
<Footer />
|
||||
<InstallPrompt />
|
||||
<ReloadPrompt />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
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>
|
||||
@@ -12,6 +12,7 @@ defineProps({
|
||||
<nav class="sidebar-nav">
|
||||
<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>
|
||||
</nav>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
@@ -1,91 +1,29 @@
|
||||
<script setup>
|
||||
import { ref, onUnmounted, nextTick, onMounted } from 'vue'
|
||||
import { ref, watch, nextTick } from 'vue'
|
||||
import { useFillHeight } from '../../composables/useFillHeight'
|
||||
import { Plug, Info, X } from 'lucide-vue-next'
|
||||
import { useExtension } from '../../composables/useExtension'
|
||||
import { useLocalStorage } from '../../composables/useLocalStorage'
|
||||
import ExtensionStatus from './common/ExtensionStatus.vue'
|
||||
|
||||
const clipboardContent = ref('')
|
||||
const isListening = ref(false)
|
||||
const lastClipboardText = ref('')
|
||||
const clipboardContent = useLocalStorage('clipboard-sniffer-content', '')
|
||||
const textareaRef = ref(null)
|
||||
const isExtensionReady = ref(false)
|
||||
const showExtensionModal = ref(false)
|
||||
let intervalId = null
|
||||
let extensionCheckInterval = null
|
||||
|
||||
const { height: textareaHeight } = useFillHeight(textareaRef, 40) // 40px margin bottom
|
||||
const {
|
||||
isExtensionReady,
|
||||
isListening,
|
||||
lastClipboardText,
|
||||
startListening,
|
||||
stopListening
|
||||
} = useExtension()
|
||||
|
||||
// Listen for extension messages
|
||||
const handleExtensionMessage = (event) => {
|
||||
if (event.source !== window) return
|
||||
const { height: textareaHeight } = useFillHeight(textareaRef, 40)
|
||||
|
||||
if (event.data.type === 'TOOLS_APP_EXTENSION_READY' || event.data.type === 'TOOLS_APP_PONG') {
|
||||
isExtensionReady.value = true
|
||||
lastPongTime = Date.now()
|
||||
// console.log('Extension is ready')
|
||||
}
|
||||
|
||||
if (event.data.type === 'TOOLS_APP_CLIPBOARD_UPDATE' && isListening.value) {
|
||||
const text = event.data.content
|
||||
if (text && text !== lastClipboardText.value) {
|
||||
lastClipboardText.value = text
|
||||
clipboardContent.value += (clipboardContent.value ? '\n' : '') + text
|
||||
// Watch for clipboard updates from extension
|
||||
watch(lastClipboardText, (newText) => {
|
||||
if (newText) {
|
||||
clipboardContent.value += (clipboardContent.value ? '\n' : '') + newText
|
||||
scrollToBottom()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const closeModalOnEsc = (e) => {
|
||||
if (e.key === 'Escape' && showExtensionModal.value) {
|
||||
showExtensionModal.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Watchdog for extension
|
||||
let lastPongTime = Date.now()
|
||||
const PING_INTERVAL = 200
|
||||
const TIMEOUT_THRESHOLD = 500
|
||||
|
||||
const startExtensionWatchdog = () => {
|
||||
extensionCheckInterval = setInterval(() => {
|
||||
// 1. Send Ping
|
||||
window.postMessage({ type: 'TOOLS_APP_PING' }, '*')
|
||||
|
||||
// 2. Check timeout
|
||||
// If current time - lastPongTime > threshold, then disconnected
|
||||
if (Date.now() - lastPongTime > TIMEOUT_THRESHOLD) {
|
||||
isExtensionReady.value = false
|
||||
}
|
||||
}, PING_INTERVAL)
|
||||
}
|
||||
|
||||
// Wrapper to intercept PONG and update heartbeat
|
||||
const messageListener = (event) => {
|
||||
if (event.source !== window) return
|
||||
|
||||
if (event.data.type === 'TOOLS_APP_PONG' || event.data.type === 'TOOLS_APP_EXTENSION_READY') {
|
||||
lastPongTime = Date.now()
|
||||
isExtensionReady.value = true
|
||||
}
|
||||
|
||||
handleExtensionMessage(event)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('message', messageListener)
|
||||
window.addEventListener('keydown', closeModalOnEsc)
|
||||
|
||||
// Initial check
|
||||
window.postMessage({ type: 'TOOLS_APP_INIT' }, '*')
|
||||
|
||||
// Start heartbeat
|
||||
startExtensionWatchdog()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopListening()
|
||||
if (extensionCheckInterval) clearInterval(extensionCheckInterval)
|
||||
window.removeEventListener('message', messageListener)
|
||||
window.removeEventListener('keydown', closeModalOnEsc)
|
||||
})
|
||||
|
||||
const scrollToBottom = () => {
|
||||
@@ -97,97 +35,24 @@ const scrollToBottom = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const startListening = async () => {
|
||||
try {
|
||||
isListening.value = true
|
||||
|
||||
// Try native API first (for web app usage without extension)
|
||||
// Initial read
|
||||
try {
|
||||
const text = await navigator.clipboard.readText()
|
||||
if (text) {
|
||||
lastClipboardText.value = text
|
||||
clipboardContent.value += (clipboardContent.value ? '\n' : '') + text
|
||||
scrollToBottom()
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('Native clipboard read failed (expected if not focused), relying on extension if available')
|
||||
}
|
||||
|
||||
// If extension is ready, ask it to start sniffing
|
||||
if (isExtensionReady.value) {
|
||||
window.postMessage({ type: 'TOOLS_APP_START_SNIFFING' }, '*')
|
||||
}
|
||||
|
||||
// Fallback polling for web app (only works when focused usually)
|
||||
intervalId = setInterval(async () => {
|
||||
try {
|
||||
const currentText = await navigator.clipboard.readText()
|
||||
if (currentText && currentText !== lastClipboardText.value) {
|
||||
lastClipboardText.value = currentText
|
||||
clipboardContent.value += (clipboardContent.value ? '\n' : '') + currentText
|
||||
scrollToBottom()
|
||||
}
|
||||
} catch (err) {
|
||||
// Ignore errors in polling (e.g. lost focus)
|
||||
}
|
||||
}, 1000)
|
||||
} catch (err) {
|
||||
console.error('Permission denied or clipboard error:', err)
|
||||
alert('Clipboard access denied. Please allow clipboard access to use this tool.')
|
||||
}
|
||||
}
|
||||
|
||||
const stopListening = () => {
|
||||
isListening.value = false
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId)
|
||||
intervalId = null
|
||||
}
|
||||
|
||||
if (isExtensionReady.value) {
|
||||
window.postMessage({ type: 'TOOLS_APP_STOP_SNIFFING' }, '*')
|
||||
const copyToClipboard = () => {
|
||||
if (clipboardContent.value) {
|
||||
navigator.clipboard.writeText(clipboardContent.value)
|
||||
}
|
||||
}
|
||||
|
||||
const clearText = () => {
|
||||
clipboardContent.value = ''
|
||||
// Don't reset lastClipboardText so if they copy the same thing again it's detected?
|
||||
// No, if they clear, they might want to see it again if they copy it again.
|
||||
// But usually "change" means diff from clipboard.
|
||||
// If I clear text, but clipboard still has "A", and I copy "A" again (refresh clipboard), readText still returns "A".
|
||||
// So it won't be detected as a change.
|
||||
// If user wants to capture "A" again, they need to copy something else then "A".
|
||||
// That's standard behavior for "sniffer" (detect changes).
|
||||
}
|
||||
|
||||
const copyToClipboard = async () => {
|
||||
if (!clipboardContent.value) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(clipboardContent.value)
|
||||
} catch (err) {
|
||||
console.error('Failed to copy to clipboard:', err)
|
||||
}
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
stopListening()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tool-container" style="max-width: 100%;">
|
||||
<div class="tool-container full-width">
|
||||
<div class="tool-panel">
|
||||
<div class="tool-header">
|
||||
<h2 class="tool-title">Clipboard Sniffer</h2>
|
||||
<div
|
||||
class="extension-status"
|
||||
:class="{ 'connected': isExtensionReady }"
|
||||
@click="showExtensionModal = true"
|
||||
:title="isExtensionReady ? 'Extension connected' : 'Extension not detected - Click for info'"
|
||||
>
|
||||
<Plug v-if="isExtensionReady" size="20" />
|
||||
<Info v-else size="20" />
|
||||
<div class="extension-indicator-wrapper">
|
||||
<ExtensionStatus :isReady="isExtensionReady" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -196,6 +61,8 @@ onUnmounted(() => {
|
||||
v-if="!isListening"
|
||||
class="btn-neon"
|
||||
@click="startListening"
|
||||
:disabled="!isExtensionReady"
|
||||
:title="!isExtensionReady ? 'Extension required' : 'Start capturing clipboard'"
|
||||
v-ripple
|
||||
>
|
||||
Start Sniffing
|
||||
@@ -224,49 +91,10 @@ onUnmounted(() => {
|
||||
v-model="clipboardContent"
|
||||
class="tool-textarea"
|
||||
placeholder="Clipboard content will appear here line by line..."
|
||||
readonly
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Extension Info Modal -->
|
||||
<div v-if="showExtensionModal" class="modal-overlay" @click.self="showExtensionModal = false">
|
||||
<div class="modal-content glass-panel">
|
||||
<button class="close-btn" @click="showExtensionModal = false">
|
||||
<X size="24" />
|
||||
</button>
|
||||
|
||||
<div v-if="!isExtensionReady">
|
||||
<h3>Enhance Your Experience</h3>
|
||||
<p>
|
||||
Install our browser extension to enable <strong>background clipboard sniffing</strong>!
|
||||
</p>
|
||||
<p class="description">
|
||||
Without the extension, this tool can only capture clipboard content when the tab is active.
|
||||
With the extension, you can capture content even when you're working in other apps.
|
||||
</p>
|
||||
<div class="modal-actions">
|
||||
<a href="#" class="btn-neon" @click.prevent>Extension Coming Soon</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<h3>Extension Connected!</h3>
|
||||
<p>
|
||||
You have successfully enabled <strong>background clipboard sniffing</strong>.
|
||||
</p>
|
||||
<p class="description">
|
||||
The extension is active and monitoring your clipboard in the background.
|
||||
You can now switch to other apps and copy text - it will appear here automatically.
|
||||
</p>
|
||||
<div class="modal-actions">
|
||||
<button class="btn-neon" @click="showExtensionModal = false">Got it!</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -279,145 +107,51 @@ onUnmounted(() => {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.extension-status {
|
||||
.controls {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.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;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
:global(:root[data-theme="light"]) .extension-status {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.extension-status:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
:global(:root[data-theme="light"]) .extension-status:hover {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.extension-status.connected {
|
||||
color: #4ade80; /* Green for connected */
|
||||
cursor: pointer; /* Allow clicking to see status */
|
||||
}
|
||||
|
||||
/* :global(:root[data-theme="light"]) .extension-status.connected {
|
||||
color: #16a34a;
|
||||
} */
|
||||
|
||||
.extension-status.connected:hover {
|
||||
background: rgba(74, 222, 128, 0.1);
|
||||
}
|
||||
|
||||
/* :global(:root[data-theme="light"]) .extension-status.connected:hover {
|
||||
background: rgba(22, 163, 74, 0.1);
|
||||
} */
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
position: relative;
|
||||
width: 90%;
|
||||
max-width: 500px;
|
||||
padding: 2rem;
|
||||
border-radius: 16px;
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
padding: 0.5rem;
|
||||
border-radius: 50%;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.modal-content h3 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1.5rem;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.modal-content p {
|
||||
margin-bottom: 1rem;
|
||||
line-height: 1.6;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.description {
|
||||
color: var(--text-secondary) !important;
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 2rem !important;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn-neon {
|
||||
padding: 0.75rem 1.5rem;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.btn-neon.active {
|
||||
background: rgba(255, 0, 0, 0.2);
|
||||
border-color: rgba(255, 0, 0, 0.5);
|
||||
box-shadow: 0 0 15px rgba(255, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.tool-textarea {
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -75,7 +75,7 @@ const generatePasswords = () => {
|
||||
<div class="tool-panel">
|
||||
<div class="panel-header">
|
||||
<h2 class="tool-title">Bulk Passwords Generator</h2>
|
||||
<div class="action-area">
|
||||
<div class="action-area desktop-only">
|
||||
<button class="btn-neon generate-btn" @click="generatePasswords" v-ripple>
|
||||
Generate
|
||||
</button>
|
||||
@@ -131,6 +131,12 @@ const generatePasswords = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mobile-only" style="margin-top: 1rem; width: 100%;">
|
||||
<button class="btn-neon generate-btn" @click="generatePasswords" v-ripple style="width: 100%;">
|
||||
Generate
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="result-area" :style="{ height: textareaHeight }">
|
||||
<textarea
|
||||
class="tool-textarea"
|
||||
@@ -189,9 +195,10 @@ const generatePasswords = () => {
|
||||
|
||||
.inputs-group {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
gap: 1rem;
|
||||
flex: 1;
|
||||
min-width: 300px;
|
||||
min-width: 200px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
@@ -199,6 +206,7 @@ const generatePasswords = () => {
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
flex: 1;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
@@ -351,6 +359,14 @@ const generatePasswords = () => {
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.desktop-only {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mobile-only {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.options-grid {
|
||||
flex-direction: column;
|
||||
@@ -370,5 +386,13 @@ const generatePasswords = () => {
|
||||
.generate-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.desktop-only {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-only {
|
||||
display: block !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
533
src/components/tools/UrlCleaner.vue
Normal file
533
src/components/tools/UrlCleaner.vue
Normal file
@@ -0,0 +1,533 @@
|
||||
<script setup>
|
||||
import { ref, watch, onUnmounted } from 'vue'
|
||||
import { Copy, Trash2, ExternalLink, Power, Zap, X, Settings } 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
|
||||
}
|
||||
|
||||
// Manual clean
|
||||
const handleClean = () => {
|
||||
if (inputUrl.value) {
|
||||
processUrl(inputUrl.value, 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">
|
||||
<input
|
||||
v-model="inputUrl"
|
||||
type="text"
|
||||
placeholder="Paste URL here to clean..."
|
||||
class="url-input"
|
||||
@keyup.enter="handleClean"
|
||||
>
|
||||
<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>
|
||||
<button class="icon-btn" @click="clearHistory" title="Clear History">
|
||||
<Trash2 size="18" />
|
||||
</button>
|
||||
</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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.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-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.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: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.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>
|
||||
566
src/components/tools/UrlCleanerExceptionsModal.vue
Normal file
566
src/components/tools/UrlCleanerExceptionsModal.vue
Normal file
@@ -0,0 +1,566 @@
|
||||
<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 removeRule = (id) => {
|
||||
localExceptions.value = localExceptions.value.filter(r => r.id !== id)
|
||||
}
|
||||
|
||||
const toggleRule = (id) => {
|
||||
localExceptions.value = localExceptions.value.map(r => {
|
||||
if (r.id === id) {
|
||||
return { ...r, isEnabled: !r.isEnabled }
|
||||
}
|
||||
return r
|
||||
})
|
||||
}
|
||||
|
||||
const removeParam = (ruleId, param) => {
|
||||
localExceptions.value = localExceptions.value.map(r => {
|
||||
if (r.id === ruleId) {
|
||||
return {
|
||||
...r,
|
||||
keepParams: r.keepParams.filter(p => p !== param)
|
||||
}
|
||||
}
|
||||
return r
|
||||
})
|
||||
}
|
||||
|
||||
const toggleKeepHash = (ruleId) => {
|
||||
localExceptions.value = localExceptions.value.map(r => {
|
||||
if (r.id === ruleId) {
|
||||
return { ...r, keepHash: !r.keepHash }
|
||||
}
|
||||
return r
|
||||
})
|
||||
}
|
||||
|
||||
const toggleKeepAllParams = (ruleId) => {
|
||||
localExceptions.value = localExceptions.value.map(r => {
|
||||
if (r.id === ruleId) {
|
||||
return { ...r, keepAllParams: !r.keepAllParams }
|
||||
}
|
||||
return r
|
||||
})
|
||||
}
|
||||
|
||||
const resetToDefault = (ruleId) => {
|
||||
const defaultRule = (props.defaultRules || []).find(r => r.id === ruleId)
|
||||
if (!defaultRule) return
|
||||
|
||||
localExceptions.value = localExceptions.value.map(r => {
|
||||
if (r.id !== ruleId) return r
|
||||
return {
|
||||
...r,
|
||||
domainPattern: defaultRule.domainPattern,
|
||||
keepParams: Array.isArray(defaultRule.keepParams) ? [...defaultRule.keepParams] : [],
|
||||
keepHash: !!defaultRule.keepHash,
|
||||
keepAllParams: !!defaultRule.keepAllParams,
|
||||
isEnabled: true,
|
||||
isDefault: true
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="isOpen" class="modal-overlay" @click.self="$emit('close')">
|
||||
<div class="modal-content glass-panel">
|
||||
<div class="modal-header">
|
||||
<h3>URL Cleaning Exceptions</h3>
|
||||
<button class="close-btn" @click="$emit('close')">
|
||||
<X size="24" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<p class="description">
|
||||
Define rules to keep specific parameters or anchors for certain domains.
|
||||
Standard cleaning removes all parameters and anchors.
|
||||
</p>
|
||||
|
||||
<div class="add-rule-form">
|
||||
<h4>Add New Exception</h4>
|
||||
<div class="form-row">
|
||||
<input
|
||||
v-model="newRule.domainPattern"
|
||||
placeholder="Domain (e.g. *.youtube.com)"
|
||||
class="input-field"
|
||||
@keyup.enter="addRule"
|
||||
>
|
||||
<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">{{ 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;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 1.5rem;
|
||||
border-bottom: 1px solid var(--glass-border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.modal-header {
|
||||
padding: 0.8rem 1.2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.4rem;
|
||||
background: var(--title-gradient);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1.5rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.modal-body {
|
||||
padding: 1rem 1.2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.description {
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.add-rule-form {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 2rem;
|
||||
border: 1px solid var(--glass-border);
|
||||
}
|
||||
|
||||
.add-rule-form h4, .rules-list h4 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1rem;
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: 0.8rem;
|
||||
margin-bottom: 0.8rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.form-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.checkbox-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.checkbox-group {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.btn-neon {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.checkbox-row {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.checkbox-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.2rem;
|
||||
}
|
||||
|
||||
.input-field {
|
||||
flex: 1;
|
||||
padding: 0.6rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--toggle-border);
|
||||
background: var(--toggle-bg);
|
||||
color: var(--text-color);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.input-field:focus {
|
||||
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;
|
||||
}
|
||||
|
||||
.rule-item.disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.rule-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.rule-domain {
|
||||
font-weight: 600;
|
||||
color: var(--primary-accent);
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
.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
|
||||
}
|
||||
}
|
||||
18
src/composables/useLocalStorage.js
Normal file
18
src/composables/useLocalStorage.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
export function useLocalStorage(key, defaultValue) {
|
||||
// Initialize state from local storage or default value
|
||||
const storedValue = localStorage.getItem(key)
|
||||
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(key)
|
||||
} else {
|
||||
localStorage.setItem(key, JSON.stringify(newValue))
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
return data
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import { createRouter, createWebHistory } from 'vue-router'
|
||||
import Main from '../components/Main.vue'
|
||||
import Passwords from '../components/tools/Passwords.vue'
|
||||
import ClipboardSniffer from '../components/tools/ClipboardSniffer.vue'
|
||||
import UrlCleaner from '../components/tools/UrlCleaner.vue'
|
||||
import PrivacyPolicy from '../views/PrivacyPolicy.vue'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
@@ -18,6 +20,16 @@ const routes = [
|
||||
path: '/clipboard-sniffer',
|
||||
name: 'ClipboardSniffer',
|
||||
component: ClipboardSniffer
|
||||
},
|
||||
{
|
||||
path: '/url-cleaner',
|
||||
name: 'UrlCleaner',
|
||||
component: UrlCleaner
|
||||
},
|
||||
{
|
||||
path: '/extension-privacy-policy',
|
||||
name: 'PrivacyPolicy',
|
||||
component: PrivacyPolicy
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
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>
|
||||
Reference in New Issue
Block a user