Compare commits

...

54 Commits

Author SHA1 Message Date
a367d364df 0.6.22
All checks were successful
Deploy to Production / deploy (push) Successful in 16s
2026-03-04 04:30:46 +00:00
27fee3ac34 feat: replace native titles with global vue tooltips 2026-03-04 04:30:38 +00:00
b5a79f8dbe 0.6.21
All checks were successful
Deploy to Production / deploy (push) Successful in 16s
2026-03-04 04:11:37 +00:00
b6eb33f205 feat(qr): refine gradient drag handles with visual offsets, magnetic snapping to corners, and floating icon toggle 2026-03-04 04:11:05 +00:00
bec6a0ec8f style(qr): synchronize gradient handle and line styles with CSS variables 2026-03-04 03:35:29 +00:00
52024ad7c6 style(qr): make gradient edit handles and lines thin black with delicate white glow 2026-03-04 03:27:31 +00:00
805b986a7b style(qr): enhance editable gradient handles with a glow and add visibility toggle 2026-03-04 03:24:53 +00:00
8fa0c9bd44 feat(qr): add draggable gradient handles for background and foreground styling 2026-03-04 03:22:56 +00:00
9f9ea255a8 feat(qr): implement background style and gradient support for QR generation 2026-03-04 03:18:06 +00:00
858e880c38 fix(qr): fix svg attribute malformation when injecting gradient defs 2026-03-04 03:15:19 +00:00
f8953984ef feat(qr): add background and foreground colors/gradient customization 2026-03-04 03:13:39 +00:00
6be7abfe02 feat(qr): sync generator text to url path payload and add generator button to scanner list
All checks were successful
Deploy to Production / deploy (push) Successful in 24s
2026-03-04 03:07:17 +00:00
fdd841177b 0.6.20
All checks were successful
Deploy to Production / deploy (push) Successful in 20s
2026-03-04 02:47:53 +00:00
2c286af429 chore(lint): format extension background script 2026-03-04 02:47:24 +00:00
10286c2924 chore(lint): enforce 2-space indent & add gpg pre-commit hook 2026-03-04 02:45:53 +00:00
b98a14950c chore: fix husky deprecation warning 2026-03-04 02:42:41 +00:00
18912368a4 feat(ui): refine input states, add tokenized params & ripple effects
- Refactored Url Cleaner Exception 'Keep params' into a tokenized input array (pills/badges)
- Standardized UI contrast: ensured proper label highlighting in light/dark themes
- Expanded v-ripple interaction effect to buttons across all remaining tools, header, and modals
- Moved base .detail-tag styles into global CSS variables
- Web worker generation for QR codes (WIP)
2026-03-04 02:41:58 +00:00
b51bc61cf3 feat: show count of cleaned URLs in header 2026-03-03 22:10:07 +00:00
e40762873c 0.6.19
All checks were successful
Deploy to Production / deploy (push) Successful in 13s
2026-03-03 14:30:19 +00:00
011db26ec4 fix: normalize font-weight for Length/Count labels in Passwords; refactor QR scanner composables; style fixes 2026-03-03 14:29:58 +00:00
6f95dce55a 0.6.18
All checks were successful
Deploy to Production / deploy (push) Successful in 14s
2026-03-03 13:40:20 +00:00
c5293ca9fe style: standardize UI across tools, optimize QR layout, and configure Husky/ESLint 2026-03-03 13:39:56 +00:00
b1cc8ab5a1 style: unify UI elements and improve light theme visibility 2026-03-03 12:33:06 +00:00
dff8a2a0ab refactor: extract logic from QrScanner and UrlCleaner to composables 2026-03-03 11:19:57 +00:00
ee387d9637 feat: add extension packing script and update build script
All checks were successful
Deploy to Production / deploy (push) Successful in 14s
2026-03-03 11:03:43 +00:00
f2203e896e chore: bump version to 0.6.16
All checks were successful
Deploy to Production / deploy (push) Successful in 13s
2026-03-03 10:03:44 +00:00
cb8d3d01ec fix: update extension link to Chrome Web Store 2026-03-03 10:03:41 +00:00
5b31171964 0.6.15
All checks were successful
Deploy to Production / deploy (push) Successful in 13s
2026-03-03 00:02:34 +00:00
e98761a18e feat: make header title a link to home page 2026-03-03 00:02:17 +00:00
bc8168e724 0.6.14
All checks were successful
Deploy to Production / deploy (push) Successful in 14s
2026-03-02 23:59:04 +00:00
dcde3b0799 feat: add ESC key support to close all modals and fullscreen modes 2026-03-02 23:58:50 +00:00
60fc774586 0.6.13
All checks were successful
Deploy to Production / deploy (push) Successful in 14s
2026-03-02 23:37:12 +00:00
d65c0d0357 docs: replace problematic emoji in README.md with safer alternative 2026-03-02 23:36:57 +00:00
02736ecc70 0.6.12
All checks were successful
Deploy to Production / deploy (push) Successful in 14s
2026-03-02 23:32:21 +00:00
7b1d19ca7a docs: fix corrupted emoji in README.md 2026-03-02 23:32:12 +00:00
4846d0e61c 0.6.11
All checks were successful
Deploy to Production / deploy (push) Successful in 13s
2026-03-02 23:26:45 +00:00
3155e12b84 feat: display README.md on home page using marked 2026-03-02 23:26:36 +00:00
b8bbe84aa9 docs: add descriptions for all new tools (QR Gen, QR Scanner, URL Cleaner) to README 2026-03-02 23:24:03 +00:00
74984caf9e 0.6.10
All checks were successful
Deploy to Production / deploy (push) Successful in 13s
2026-02-28 18:07:22 +00:00
c8b799b078 fix: mirror background canvas for front camera in fullscreen mode 2026-02-28 18:07:10 +00:00
f3a4c1af05 0.6.9
All checks were successful
Deploy to Production / deploy (push) Successful in 14s
2026-02-28 18:05:14 +00:00
616f615d7c fix: improve front camera detection on macOS by checking video track label 2026-02-28 18:04:28 +00:00
4d572b55ca 0.6.8
All checks were successful
Deploy to Production / deploy (push) Successful in 13s
2026-02-28 17:51:40 +00:00
9822cab93e feat: restore QR code detection overlay in custom QrScanner 2026-02-28 17:50:29 +00:00
9409bd3e21 0.6.7
All checks were successful
Deploy to Production / deploy (push) Successful in 14s
2026-02-28 17:46:21 +00:00
346ded460a chore: remove wasm file from repo and add to gitignore 2026-02-28 17:45:21 +00:00
170539a62f 0.6.6
All checks were successful
Deploy to Production / deploy (push) Successful in 13s
2026-02-28 17:41:06 +00:00
cfc1785863 chore: cleanup unused code in QrScanner and remove vue-qrcode-reader dependency 2026-02-28 17:40:42 +00:00
712b1238a5 0.6.5
All checks were successful
Deploy to Production / deploy (push) Successful in 14s
2026-02-28 17:34:35 +00:00
3732d365dd refactor: replace vue-qrcode-reader with custom native QR scanner using local BarcodeDetector polyfill 2026-02-28 17:31:04 +00:00
34fd8bb2b3 0.6.4
All checks were successful
Deploy to Production / deploy (push) Successful in 14s
2026-02-28 17:21:46 +00:00
90dc663393 fix: use local zxing-wasm file to resolve CSP issues in QrScanner 2026-02-28 17:21:29 +00:00
1c4bdeff0e 0.6.3
All checks were successful
Deploy to Production / deploy (push) Successful in 15s
2026-02-28 17:10:17 +00:00
35c5ff4c51 fix: ensure useFillHeight recalculates when element appears (v-if) 2026-02-28 17:09:42 +00:00
36 changed files with 3920 additions and 1156 deletions

7
.gitignore vendored
View File

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

11
.husky/pre-commit Executable file
View File

@@ -0,0 +1,11 @@
# Check if GPG signing is enabled
gpg_sign=$(git config --get commit.gpgsign || echo "false")
if [ "$gpg_sign" != "true" ]; then
echo "Error: GPG signing is not enabled or properly configured!"
echo "Please enable it globally using: git config --global commit.gpgsign true"
echo "Or locally by running: git config commit.gpgsign true"
exit 1
fi
npm run lint

View File

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

50
eslint.config.js Normal file
View File

@@ -0,0 +1,50 @@
import pluginVue from 'eslint-plugin-vue'
import js from '@eslint/js'
import globals from 'globals'
export default [
// Base JS recommended rules (all off for now to allow pre-commit)
{
rules: Object.keys(js.configs.recommended.rules).reduce((acc, rule) => {
acc[rule] = 'off';
return acc;
}, {}),
},
// Vue essential rules
...pluginVue.configs['flat/essential'],
{
// Apply to all JS and Vue files
files: ['**/*.js', '**/*.vue'],
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
globals: {
...globals.browser,
...globals.node,
crypto: 'readonly',
BarcodeDetector: 'readonly',
chrome: 'readonly',
__APP_VERSION__: 'readonly',
VITE_APP_VERSION: 'readonly',
},
},
rules: {
'vue/multi-word-component-names': 'off',
'no-unused-vars': 'off',
'no-undef': 'off',
'no-debugger': 'off',
'indent': ['error', 2]
}
},
{
// Global ignores
ignores: [
'dist/**',
'dev-dist/**',
'node_modules/**',
'public/**',
'scripts/pack_crx.js',
'src/app.config.mjs'
]
}
]

View File

@@ -84,8 +84,8 @@ async function refreshOffscreenDocument() {
chrome.runtime.onMessage.addListener(async (request, sender, sendResponse) => { chrome.runtime.onMessage.addListener(async (request, sender, sendResponse) => {
if (request.action === 'startSniffing') { if (request.action === 'startSniffing') {
if (isSniffing) { if (isSniffing) {
sendResponse({ status: 'already_started' }); sendResponse({ status: 'already_started' });
return true; return true;
} }
isSniffing = true; isSniffing = true;

1672
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,23 +1,35 @@
{ {
"name": "tools-app", "name": "tools-app",
"private": true, "private": true,
"version": "0.6.2", "version": "0.6.22",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview" "preview": "vite preview",
"lint": "eslint .",
"pack-extension": "node scripts/pack_crx.js",
"postinstall": "mkdir -p public/wasm && cp node_modules/zxing-wasm/dist/reader/zxing_reader.wasm public/wasm/",
"prepare": "husky"
}, },
"dependencies": { "dependencies": {
"@gkucmierz/utils": "^1.28.7",
"barcode-detector": "^3.1.0",
"lucide-vue-next": "^0.575.0", "lucide-vue-next": "^0.575.0",
"marked": "^17.0.3",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"vue": "^3.5.25", "vue": "^3.5.25",
"vue-qrcode-reader": "^5.7.3",
"vue-router": "^5.0.3" "vue-router": "^5.0.3"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1",
"@vitejs/plugin-vue": "^6.0.2", "@vitejs/plugin-vue": "^6.0.2",
"eslint": "^10.0.2",
"eslint-plugin-vue": "^10.8.0",
"globals": "^17.4.0",
"husky": "^9.1.7",
"vite": "^7.3.1", "vite": "^7.3.1",
"vite-plugin-pwa": "^1.2.0" "vite-plugin-pwa": "^1.2.0",
"worker-loader": "^3.0.8"
} }
} }

View File

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

110
scripts/pack_crx.js Normal file
View File

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

View File

@@ -6,6 +6,7 @@ import Footer from './components/Footer.vue'
import Sidebar from './components/Sidebar.vue' import Sidebar from './components/Sidebar.vue'
import InstallPrompt from './components/InstallPrompt.vue' import InstallPrompt from './components/InstallPrompt.vue'
import ReloadPrompt from './components/ReloadPrompt.vue' import ReloadPrompt from './components/ReloadPrompt.vue'
import GlobalTooltip from './components/common/GlobalTooltip.vue'
import { UI_CONFIG } from './config/ui' import { UI_CONFIG } from './config/ui'
const isSidebarOpen = ref(window.innerWidth >= 768) const isSidebarOpen = ref(window.innerWidth >= 768)
@@ -61,6 +62,7 @@ onUnmounted(() => {
<Footer /> <Footer />
<InstallPrompt /> <InstallPrompt />
<ReloadPrompt /> <ReloadPrompt />
<GlobalTooltip />
</template> </template>
<style scoped> <style scoped>
@@ -76,6 +78,8 @@ onUnmounted(() => {
padding: 1rem; padding: 1rem;
width: 100%; width: 100%;
max-width: 100%; max-width: 100%;
display: flex;
flex-direction: column;
/* Space for fixed footer on mobile + extra margin */ /* Space for fixed footer on mobile + extra margin */
padding-bottom: calc(1rem + var(--footer-height) + env(safe-area-inset-bottom)); padding-bottom: calc(1rem + var(--footer-height) + env(safe-area-inset-bottom));
} }
@@ -94,7 +98,7 @@ onUnmounted(() => {
.main-content { .main-content {
overflow: visible; overflow: visible;
height: auto; flex: 1;
padding-bottom: 1rem; padding-bottom: 1rem;
} }
} }

View File

@@ -35,7 +35,7 @@ onMounted(() => {
class="menu-btn" class="menu-btn"
@click="$emit('toggleSidebar')" @click="$emit('toggleSidebar')"
aria-label="Toggle Menu" aria-label="Toggle Menu"
title="Toggle Menu" v-tooltip="'Toggle Menu'"
v-ripple v-ripple
> >
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -44,13 +44,13 @@ onMounted(() => {
<line x1="3" y1="18" x2="21" y2="18"></line> <line x1="3" y1="18" x2="21" y2="18"></line>
</svg> </svg>
</button> </button>
<h1 class="app-title">Tools App</h1> <router-link to="/" class="app-title">Tools App</router-link>
</div> </div>
<button <button
class="btn-neon nav-btn icon-only" class="btn-neon nav-btn icon-only"
@click="toggleTheme" @click="toggleTheme"
:title="isDark ? 'Switch to light mode' : 'Switch to dark mode'" v-tooltip="isDark ? 'Switch to light mode' : 'Switch to dark mode'"
v-ripple v-ripple
> >
<Sun v-if="isDark" :size="20" /> <Sun v-if="isDark" :size="20" />
@@ -115,10 +115,11 @@ onMounted(() => {
.app-title { .app-title {
margin: 0; margin: 0;
font-size: 1.5rem; font-size: 1.5rem;
font-weight: 600;
background: var(--title-gradient); background: var(--title-gradient);
-webkit-background-clip: text; -webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text; background-clip: text;
color: transparent;
text-decoration: none;
font-weight: bold;
} }
</style> </style>

View File

@@ -3,8 +3,8 @@
<div class="prompt-content"> <div class="prompt-content">
<span class="prompt-text">Install app for faster access</span> <span class="prompt-text">Install app for faster access</span>
<div class="prompt-actions"> <div class="prompt-actions">
<button @click="installPWA" class="install-btn">Install</button> <button @click="installPWA" class="install-btn" v-ripple>Install</button>
<button @click="dismissPrompt" class="dismiss-btn"></button> <button @click="dismissPrompt" class="dismiss-btn" v-ripple></button>
</div> </div>
</div> </div>
</div> </div>
@@ -51,12 +51,20 @@ const dismissPrompt = () => {
deferredPrompt = null deferredPrompt = null
} }
const handleKeydown = (e) => {
if (e.key === 'Escape' && showInstallPrompt.value) {
dismissPrompt()
}
}
onMounted(() => { onMounted(() => {
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt) window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
window.addEventListener('keydown', handleKeydown)
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt) window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
window.removeEventListener('keydown', handleKeydown)
}) })
</script> </script>

View File

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

View File

@@ -50,7 +50,7 @@ onMounted(() => {
<div class="message"> <div class="message">
New content available, click on reload button to update. New content available, click on reload button to update.
</div> </div>
<button @click="updateServiceWorker()"> <button @click="updateServiceWorker()" class="btn-neon" v-ripple>
Reload Reload
</button> </button>
</div> </div>

View File

@@ -27,7 +27,7 @@ defineProps({
left: 0; left: 0;
bottom: 0; bottom: 0;
width: 250px; width: 250px;
background-color: var(--panel-bg); background-color: var(--glass-bg);
backdrop-filter: blur(12px); backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
overflow-x: hidden; overflow-x: hidden;
@@ -89,6 +89,11 @@ defineProps({
color: var(--text-strong); color: var(--text-strong);
} }
:global(:root[data-theme="light"]) .nav-item:hover {
background-color: rgba(15, 23, 42, 0.1);
color: var(--text-strong);
}
.nav-item.router-link-active { .nav-item.router-link-active {
color: var(--primary-accent); color: var(--primary-accent);
background-color: var(--toggle-bg); background-color: var(--toggle-bg);

View File

@@ -0,0 +1,138 @@
<script setup>
import { tooltipState } from '../../composables/useTooltip'
import { ref, watch, nextTick } from 'vue'
const tooltipRef = ref(null)
const x = ref(0)
const y = ref(0)
const arrowX = ref(0)
const isBottom = ref(false)
watch(() => tooltipState.isVisible, async (visible) => {
if (visible) {
// Wait for DOM update to ensure the text content affects height before measuring
await nextTick()
if (!tooltipRef.value || !tooltipState.targetRect) return
const rect = tooltipState.targetRect
const tooltipRect = tooltipRef.value.getBoundingClientRect()
let top = rect.top - tooltipRect.height - 8
let idealCenter = rect.left + (rect.width / 2)
let left = idealCenter - (tooltipRect.width / 2)
isBottom.value = false
if (top < 8) {
top = rect.bottom + 8
isBottom.value = true
}
// Bounds checking for the tooltip box
let actualLeft = left
if (actualLeft < 8) {
actualLeft = 8
} else if (actualLeft + tooltipRect.width > window.innerWidth - 8) {
actualLeft = window.innerWidth - tooltipRect.width - 8
}
// Calculate the difference between where the box is forced to be,
// and where it naturally wanted to be. We move the arrow by the opposite amount.
arrowX.value = idealCenter - (actualLeft + (tooltipRect.width / 2))
x.value = actualLeft
y.value = top
}
})
</script>
<template>
<div
ref="tooltipRef"
class="global-tooltip"
:class="{ 'visible': tooltipState.isVisible, 'tooltip-bottom': isBottom }"
:style="{
transform: `translate(${x}px, ${y}px)`,
'--arrow-offset': `${arrowX}px`
}"
>
{{ tooltipState.text }}
</div>
</template>
<style scoped>
.global-tooltip {
position: fixed;
top: 0;
left: 0;
z-index: 99999;
background: rgba(15, 23, 42, 0.95);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
color: #f8fafc;
padding: 6px 12px;
border-radius: 6px;
font-size: 0.8rem;
font-weight: 500;
white-space: nowrap;
pointer-events: none;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
border: 1px solid rgba(255, 255, 255, 0.1);
transform: translateY(4px) scale(0.95);
transition: opacity 0.2s cubic-bezier(0.16, 1, 0.3, 1), transform 0.2s cubic-bezier(0.16, 1, 0.3, 1);
opacity: 0;
visibility: hidden;
}
.global-tooltip::after {
content: '';
position: absolute;
left: calc(50% + var(--arrow-offset, 0px));
bottom: -5px;
margin-left: -5px;
width: 10px;
height: 10px;
background: rgba(15, 23, 42, 0.95);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border-right: 1px solid rgba(255, 255, 255, 0.1);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
transform: rotate(45deg);
border-radius: 0 0 2px 0;
clip-path: polygon(100% 0, 100% 100%, 0 100%);
}
.global-tooltip.tooltip-bottom::after {
bottom: auto;
top: -5px;
border-right: none;
border-bottom: none;
border-left: 1px solid rgba(255, 255, 255, 0.1);
border-top: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 2px 0 0 0;
clip-path: polygon(0 0, 100% 0, 0 100%);
}
.global-tooltip.visible {
opacity: 1;
visibility: visible;
/* Transform returns to natural pos defined by inline styles when visible */
}
:root[data-theme="light"] .global-tooltip {
background: rgba(255, 255, 255, 0.95);
color: #0f172a;
border-color: rgba(15, 23, 42, 0.1);
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.1);
}
:root[data-theme="light"] .global-tooltip::after {
background: rgba(255, 255, 255, 0.95);
border-right-color: rgba(15, 23, 42, 0.1);
border-bottom-color: rgba(15, 23, 42, 0.1);
}
:root[data-theme="light"] .global-tooltip.tooltip-bottom::after {
border-left-color: rgba(15, 23, 42, 0.1);
border-top-color: rgba(15, 23, 42, 0.1);
}
</style>

View File

@@ -49,9 +49,9 @@ const clearText = () => {
<template> <template>
<div class="tool-container full-width"> <div class="tool-container full-width">
<div class="tool-panel"> <div class="tool-panel">
<div class="tool-header"> <div class="panel-header">
<h2 class="tool-title">Clipboard Sniffer</h2> <h2 class="tool-title">Clipboard Sniffer</h2>
<div class="extension-indicator-wrapper"> <div class="header-actions">
<ExtensionStatus :isReady="isExtensionReady" /> <ExtensionStatus :isReady="isExtensionReady" />
</div> </div>
</div> </div>
@@ -62,7 +62,7 @@ const clearText = () => {
class="btn-neon" class="btn-neon"
@click="startListening" @click="startListening"
:disabled="!isExtensionReady" :disabled="!isExtensionReady"
:title="!isExtensionReady ? 'Extension required' : 'Start capturing clipboard'" v-tooltip="!isExtensionReady ? 'Extension required' : 'Start capturing clipboard'"
v-ripple v-ripple
> >
Start Sniffing Start Sniffing
@@ -99,13 +99,6 @@ const clearText = () => {
</template> </template>
<style scoped> <style scoped>
.tool-header {
display: flex;
justify-content: center;
align-items: center;
position: relative;
width: 100%;
}
.controls { .controls {
display: flex; display: flex;
@@ -151,13 +144,6 @@ const clearText = () => {
border-color: var(--primary-accent); border-color: var(--primary-accent);
} }
.extension-indicator-wrapper {
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
display: flex;
}
.result-area { .result-area {
flex: 1; flex: 1;

View File

@@ -74,9 +74,9 @@ const generatePasswords = () => {
<div class="tool-container full-width"> <div class="tool-container full-width">
<div class="tool-panel"> <div class="tool-panel">
<div class="panel-header"> <div class="panel-header">
<h2 class="tool-title">Bulk Passwords Generator</h2> <h2 class="tool-title">Passwords Generator</h2>
<div class="action-area desktop-only"> <div class="header-actions">
<button class="btn-neon generate-btn" @click="generatePasswords" v-ripple> <button class="btn-neon generate-btn desktop-only" @click="generatePasswords" v-ripple>
Generate Generate
</button> </button>
</div> </div>
@@ -115,17 +115,17 @@ const generatePasswords = () => {
<div class="input-wrapper"> <div class="input-wrapper">
<label>Length</label> <label>Length</label>
<div class="number-control"> <div class="number-control">
<button class="control-btn" @click="length > 4 ? length-- : null">-</button> <button class="control-btn" @click="length > 4 ? length-- : null" v-ripple>-</button>
<input type="number" v-model="length" min="4" max="128" class="number-input"> <input type="number" v-model="length" min="4" max="128" class="number-input">
<button class="control-btn" @click="length < 128 ? length++ : null">+</button> <button class="control-btn" @click="length < 128 ? length++ : null" v-ripple>+</button>
</div> </div>
</div> </div>
<div class="input-wrapper"> <div class="input-wrapper">
<label>Count</label> <label>Count</label>
<div class="number-control"> <div class="number-control">
<button class="control-btn" @click="count > 1 ? count-- : null">-</button> <button class="control-btn" @click="count > 1 ? count-- : null" v-ripple>-</button>
<input type="number" v-model="count" min="1" max="1000" class="number-input"> <input type="number" v-model="count" min="1" max="1000" class="number-input">
<button class="control-btn" @click="count < 1000 ? count++ : null">+</button> <button class="control-btn" @click="count < 1000 ? count++ : null" v-ripple>+</button>
</div> </div>
</div> </div>
</div> </div>
@@ -149,32 +149,6 @@ const generatePasswords = () => {
</template> </template>
<style scoped> <style scoped>
.tool-container.full-width {
max-width: 100%;
height: 100%;
display: flex;
flex-direction: column;
}
.tool-panel {
display: flex;
flex-direction: column;
height: 100%;
gap: 1.5rem;
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
}
.tool-title {
margin: 0;
}
.options-grid { .options-grid {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -208,112 +182,10 @@ const generatePasswords = () => {
min-width: 140px; min-width: 140px;
} }
.checkbox-label { .input-wrapper label {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
user-select: none;
color: var(--text-secondary);
font-weight: 500;
white-space: nowrap;
}
/* Custom Checkbox */
.checkbox-label input {
position: absolute;
opacity: 0;
cursor: pointer;
height: 0;
width: 0;
}
.checkmark {
position: relative;
height: 20px;
width: 20px;
background-color: var(--toggle-bg);
border: 1px solid var(--toggle-border);
border-radius: 4px;
transition: all 0.3s ease;
}
.checkbox-label:hover .checkmark {
border-color: var(--toggle-hover-border);
}
.checkbox-label input:checked ~ .checkmark {
background-color: var(--primary-accent);
border-color: var(--primary-accent);
box-shadow: 0 0 10px var(--primary-accent);
}
.checkmark:after {
content: "";
position: absolute;
display: none;
}
.checkbox-label input:checked ~ .checkmark:after {
display: block;
}
.checkbox-label .checkmark:after {
left: 6px;
top: 2px;
width: 5px;
height: 10px;
border: solid #000;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
/* Number Control */
.number-control {
display: flex;
align-items: stretch;
background: var(--toggle-bg);
border: 1px solid var(--toggle-border);
border-radius: 8px;
overflow: hidden;
gap: 0;
}
.control-btn {
background: none;
border: none;
color: var(--text-color); color: var(--text-color);
font-size: 1.2rem; font-weight: 400;
width: 40px; margin-bottom: 0.2rem;
height: auto;
min-height: 40px;
cursor: pointer;
transition: background 0.2s;
padding: 0;
margin: 0;
display: flex;
align-items: center;
justify-content: center;
}
.control-btn:hover {
background: var(--button-hover-bg);
}
.number-input {
background: none;
border: none;
color: var(--text-color);
width: 100%;
flex: 1;
text-align: center;
font-size: 1rem;
font-weight: bold;
appearance: textfield;
-moz-appearance: textfield;
height: 100%;
border-radius: 0;
min-width: 60px;
} }
.number-input:focus { .number-input:focus {
@@ -322,12 +194,6 @@ const generatePasswords = () => {
background: rgba(0, 0, 0, 0.05); background: rgba(0, 0, 0, 0.05);
} }
.number-input::-webkit-outer-spin-button,
.number-input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.result-area { .result-area {
flex: 1; flex: 1;
display: flex; display: flex;
@@ -335,25 +201,7 @@ const generatePasswords = () => {
min-height: 200px; min-height: 200px;
} }
.tool-textarea {
width: 100%;
height: 100%;
padding: 1rem;
border-radius: 12px;
border: 1px solid var(--glass-border);
background: var(--glass-bg);
color: var(--text-color);
font-family: monospace;
font-size: 0.9rem;
resize: none;
outline: none;
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.1);
}
.generate-btn { .generate-btn {
padding: 0.75rem 2rem;
font-size: 1rem;
font-weight: bold;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 1px; letter-spacing: 1px;
} }

View File

@@ -1,80 +1,250 @@
<script setup> <script setup>
import { ref, watch, onMounted } from 'vue' import { ref, watch, onMounted, computed, onUnmounted } from 'vue'
import { Download } from 'lucide-vue-next' import { Download, Eye, EyeOff } from 'lucide-vue-next'
import QRCode from 'qrcode' import QRCode from 'qrcode'
import { useFillHeight } from '../../composables/useFillHeight' import { useFillHeight } from '../../composables/useFillHeight'
import { useLocalStorage } from '../../composables/useLocalStorage' import { useLocalStorage } from '../../composables/useLocalStorage'
import { useRoute, useRouter } from 'vue-router'
import { fromBase64Url, toBase64Url } from '@gkucmierz/utils'
const route = useRoute()
const router = useRouter()
const text = useLocalStorage('text', '', 'qr-code') const text = useLocalStorage('text', '', 'qr-code')
const ecc = useLocalStorage('ecc', 'M', 'qr-code') const ecc = useLocalStorage('ecc', 'M', 'qr-code')
const size = useLocalStorage('size', 300, 'qr-code') const size = useLocalStorage('size', 300, 'qr-code')
const isBgTransparent = useLocalStorage('isBgTransparent', true, 'qr-code')
const bgType = useLocalStorage('bgType', 'solid', 'qr-code')
const bgColor1 = useLocalStorage('bgColor1', '#ffffff', 'qr-code')
const bgColor2 = useLocalStorage('bgColor2', '#e2e8f0', 'qr-code')
const bgGradPos = useLocalStorage('bgGradPos', { x1: 50, y1: 50, x2: 100, y2: 100 }, 'qr-code')
const fgType = useLocalStorage('fgType', 'solid', 'qr-code')
const fgColor1 = useLocalStorage('fgColor1', '#000000', 'qr-code')
const fgColor2 = useLocalStorage('fgColor2', '#10b981', 'qr-code')
const fgGradPos = useLocalStorage('fgGradPos', { x1: 0, y1: 0, x2: 100, y2: 100 }, 'qr-code')
const showHandles = useLocalStorage('showHandles', true, 'qr-code')
const format = useLocalStorage('format', 'png', 'qr-code') const format = useLocalStorage('format', 'png', 'qr-code')
const svgContent = ref('') const svgContent = ref('')
const previewRef = ref(null) const previewRef = ref(null)
const qrFrameRef = ref(null)
const fgLinePts = computed(() => getLineEndPoints(fgGradPos.value))
const bgLinePts = computed(() => getLineEndPoints(bgGradPos.value))
const getLineEndPoints = (pos) => {
if (!qrFrameRef.value) return { x1: pos.x1, y1: pos.y1, x2: pos.x2, y2: pos.y2 }
const rect = qrFrameRef.value.getBoundingClientRect()
if (rect.width === 0 || rect.height === 0) return { x1: pos.x1, y1: pos.y1, x2: pos.x2, y2: pos.y2 }
// Handle radius in pixels (14/2 = 7px) plus half the line stroke width (1/2 = 0.5px) if needed
const VISUAL_OFFSET_PX = 1;
const rPx = 7 - VISUAL_OFFSET_PX;
const dx = (pos.x2 - pos.x1) * rect.width / 100
const dy = (pos.y2 - pos.y1) * rect.height / 100
const distPx = Math.sqrt(dx * dx + dy * dy)
if (distPx <= rPx * 2) {
// Too close, don't show line
return { x1: pos.x1, y1: pos.y1, x2: pos.x1, y2: pos.y1 }
}
const angle = Math.atan2(dy, dx)
// Calculate offsets in percentages (using radius, not diameter)
// adding extra 1px to accommodate the stroke/shadow
const effectiveRPx = rPx + 1
const xOffsetPct = (Math.cos(angle) * effectiveRPx / rect.width) * 100
const yOffsetPct = (Math.sin(angle) * effectiveRPx / rect.height) * 100
return {
x1: pos.x1 + xOffsetPct,
y1: pos.y1 + yOffsetPct,
x2: pos.x2 - xOffsetPct,
y2: pos.y2 - yOffsetPct
}
}
const activeHandle = ref(null)
const startDrag = (e, handleStr) => {
e.preventDefault()
activeHandle.value = handleStr
window.addEventListener('mousemove', onDrag)
window.addEventListener('mouseup', stopDrag)
window.addEventListener('touchmove', onDrag, { passive: false })
window.addEventListener('touchend', stopDrag)
}
const onDrag = (e) => {
if (!activeHandle.value || !qrFrameRef.value) return
if (e.type === 'touchmove') e.preventDefault()
const rect = qrFrameRef.value.getBoundingClientRect()
const clientX = e.touches ? e.touches[0].clientX : e.clientX
const clientY = e.touches ? e.touches[0].clientY : e.clientY
let x = ((clientX - rect.left) / rect.width) * 100
let y = ((clientY - rect.top) / rect.height) * 100
x = Math.max(0, Math.min(100, x))
y = Math.max(0, Math.min(100, y))
// Snap to 5 points (Corners + Center)
// Distance threshold in percentages, roughly matching handle diameter mapping
const snapDist = 5
const snapPoints = [
{ x: 0, y: 0 }, { x: 100, y: 0 },
{ x: 0, y: 100 }, { x: 100, y: 100 },
{ x: 50, y: 50 }
]
for (const pt of snapPoints) {
if (Math.abs(x - pt.x) < snapDist && Math.abs(y - pt.y) < snapDist) {
x = pt.x
y = pt.y
break
}
}
const [type, point] = activeHandle.value.split(':')
const posRef = type === 'fg' ? fgGradPos : bgGradPos
posRef.value[`x${point}`] = Math.round(x)
posRef.value[`y${point}`] = Math.round(y)
}
const stopDrag = () => {
activeHandle.value = null
window.removeEventListener('mousemove', onDrag)
window.removeEventListener('mouseup', stopDrag)
window.removeEventListener('touchmove', onDrag)
window.removeEventListener('touchend', stopDrag)
}
const { height: previewHeight } = useFillHeight(previewRef, 40) // 40px extra margin const { height: previewHeight } = useFillHeight(previewRef, 40) // 40px extra margin
const generateQR = async () => { let worker = null
let latestJobId = 0
const generateQR = () => {
if (!text.value) { if (!text.value) {
svgContent.value = '' svgContent.value = ''
return return
} }
try {
// Generate SVG for preview (always sharp) // Create worker if not exists
svgContent.value = await QRCode.toString(text.value, { if (!worker) {
type: 'svg', worker = new Worker(new URL('../../workers/qrcode.worker.js', import.meta.url), { type: 'module' })
errorCorrectionLevel: ecc.value, worker.onmessage = (e) => {
margin: 1, const { id, svgContent: newSvg, error } = e.data
// No fixed width, allow scaling via CSS
}) // Only process the result of the most recently requested job
} catch (err) { // to avoid race conditions overriding newer results with older ones
console.error('QR Generation failed', err) if (id !== latestJobId) return
svgContent.value = ''
if (error) {
console.error('QR Generation worker failed', error)
svgContent.value = ''
} else {
svgContent.value = newSvg
}
}
} }
// Increment ID for each new Generation request
latestJobId++
worker.postMessage({
id: latestJobId,
text: text.value,
ecc: ecc.value,
isBgTransparent: isBgTransparent.value,
bgType: bgType.value,
bgColor1: bgColor1.value,
bgColor2: bgColor2.value,
bgGradPos: { ...bgGradPos.value },
fgType: fgType.value,
fgColor1: fgColor1.value,
fgColor2: fgColor2.value,
fgGradPos: { ...fgGradPos.value }
})
} }
// Debounce generation slightly to avoid lag on typing watch([text, ecc, isBgTransparent, bgType, bgColor1, bgColor2, bgGradPos, fgType, fgColor1, fgColor2, fgGradPos], () => {
let timeout generateQR()
watch([text, ecc], () => { // size is not relevant for preview }, { deep: true })
clearTimeout(timeout)
timeout = setTimeout(generateQR, 300) watch(text, (newText) => {
if (newText) {
router.replace({ name: 'QrCode', params: { payload: toBase64Url(newText) } })
} else {
router.replace({ name: 'QrCode', params: {} })
}
}) })
onMounted(() => { onMounted(() => {
if (route.params.payload) {
try {
const decodedPayload = fromBase64Url(route.params.payload)
text.value = decodedPayload
} catch (e) {
console.error('Failed to parse QR payload from URL', e)
}
} else if (text.value) {
router.replace({ name: 'QrCode', params: { payload: toBase64Url(text.value) } })
}
if (text.value) generateQR() if (text.value) generateQR()
}) })
onUnmounted(() => {
if (worker) {
worker.terminate()
}
})
const downloadFile = async () => { const downloadFile = async () => {
if (!text.value) return if (!text.value || !svgContent.value) return
const filename = `qr-code-${Date.now()}.${format.value}` const filename = `qr-code-${Date.now()}.${format.value}`
if (format.value === 'svg') { if (format.value === 'svg') {
// For SVG download, we might want to inject the size if user specifically requested it, let finalSvg = svgContent.value
// but usually raw SVG is better. if (!finalSvg.includes('width=')) {
// If we want to support the "Size" dropdown for SVG download, we can regenerate with specific width. finalSvg = finalSvg.replace('<svg ', `<svg width="${size.value}" height="${size.value}" `)
const svgWithSize = await QRCode.toString(text.value, { }
type: 'svg', const blob = new Blob([finalSvg], { type: 'image/svg+xml' })
errorCorrectionLevel: ecc.value,
margin: 1,
width: size.value
})
const blob = new Blob([svgWithSize], { type: 'image/svg+xml' })
triggerDownload(blob, filename) triggerDownload(blob, filename)
} else { } else {
// For raster formats, render to canvas first
try { try {
const canvas = document.createElement('canvas') const canvas = document.createElement('canvas')
await QRCode.toCanvas(canvas, text.value, { canvas.width = size.value
errorCorrectionLevel: ecc.value, canvas.height = size.value
margin: 1, const ctx = canvas.getContext('2d')
width: size.value
})
const mime = `image/${format.value}` const img = new Image()
canvas.toBlob((blob) => { const svgSource = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgContent.value)}`
if (blob) triggerDownload(blob, filename)
}, mime) img.onload = () => {
if (format.value === 'jpeg' && isBgTransparent.value) {
ctx.fillStyle = '#ffffff'
ctx.fillRect(0, 0, size.value, size.value)
} else if (format.value === 'jpeg' && bgType.value !== 'solid') {
// Let the Canvas render the SVG's background gradient naturally instead of filling
// Though drawing bounding rect white might still be needed behind transparent parts
ctx.fillStyle = '#ffffff'
ctx.fillRect(0, 0, size.value, size.value)
}
ctx.drawImage(img, 0, 0, size.value, size.value)
const mime = format.value === 'jpeg' ? 'image/jpeg' : `image/${format.value}`
canvas.toBlob((blob) => {
if (blob) triggerDownload(blob, filename)
}, mime, 1.0)
}
img.onerror = (e) => {
console.error('Failed to load SVG for conversion', e)
}
img.src = svgSource
} catch (err) { } catch (err) {
console.error('Download failed', err) console.error('Download failed', err)
} }
@@ -98,6 +268,7 @@ const triggerDownload = (blob, filename) => {
<div class="tool-panel"> <div class="tool-panel">
<div class="panel-header"> <div class="panel-header">
<h2 class="tool-title">QR Generator</h2> <h2 class="tool-title">QR Generator</h2>
<div class="header-actions"></div>
</div> </div>
<div class="input-section"> <div class="input-section">
@@ -121,33 +292,105 @@ const triggerDownload = (blob, filename) => {
</div> </div>
<div class="control-group"> <div class="control-group">
<label>Size (px)</label> <label>QR Style</label>
<select v-model="size" class="select-input"> <select v-model="fgType" class="select-input">
<option :value="150">150x150</option> <option value="solid">Solid Color</option>
<option :value="300">300x300</option> <option value="linear">Linear Gradient</option>
<option :value="500">500x500</option> <option value="radial">Radial Gradient</option>
<option :value="1000">1000x1000</option>
</select> </select>
</div> </div>
<div class="control-group"> <div class="control-group">
<label>Format</label> <label>QR Color(s)</label>
<select v-model="format" class="select-input"> <div class="color-picker-wrapper">
<option value="png">PNG</option> <input type="color" v-model="fgColor1" class="color-input">
<option value="jpeg">JPG</option> <input type="color" v-model="fgColor2" v-if="fgType !== 'solid'" class="color-input">
<option value="webp">WebP</option> </div>
<option value="svg">SVG</option> </div>
<div class="control-group">
<label>Background Style</label>
<select v-model="bgType" :disabled="isBgTransparent" class="select-input">
<option value="solid">Solid Color</option>
<option value="linear">Linear Gradient</option>
<option value="radial">Radial Gradient</option>
</select> </select>
</div> </div>
<div class="control-group">
<label>Background Color(s)</label>
<div class="color-picker-wrapper">
<input type="color" v-model="bgColor1" :disabled="isBgTransparent" class="color-input">
<input type="color" v-model="bgColor2" v-if="bgType !== 'solid'" :disabled="isBgTransparent" class="color-input">
<label class="checkbox-label" style="margin-left: 0.5rem">
<input type="checkbox" v-model="isBgTransparent"> Transparent
</label>
</div>
</div>
</div> </div>
<div class="preview-section" v-if="text" ref="previewRef" :style="{ height: previewHeight }"> <div class="preview-section" v-if="text" ref="previewRef" :style="{ height: previewHeight }">
<div class="qr-frame" v-html="svgContent"></div> <div class="qr-container">
<div class="qr-frame" :style="{ background: isBgTransparent ? 'white' : bgType === 'solid' ? bgColor1 : (bgType === 'linear' ? `linear-gradient(to bottom right, ${bgColor1}, ${bgColor2})` : `radial-gradient(circle, ${bgColor1}, ${bgColor2})`) }">
<div class="svg-wrapper" ref="qrFrameRef">
<div v-html="svgContent" class="svg-content-box"></div>
<div class="actions"> <template v-if="showHandles">
<button class="action-btn" @click="downloadFile"> <!-- Background Gradient Handles -->
<template v-if="!isBgTransparent && bgType !== 'solid'">
<div class="grad-handle bg-handle handle-1" :style="{ left: bgGradPos.x1 + '%', top: bgGradPos.y1 + '%' }" @mousedown="startDrag($event, 'bg:1')" @touchstart.prevent="startDrag($event, 'bg:1')"></div>
<div class="grad-handle bg-handle handle-2" :style="{ left: bgGradPos.x2 + '%', top: bgGradPos.y2 + '%' }" @mousedown="startDrag($event, 'bg:2')" @touchstart.prevent="startDrag($event, 'bg:2')"></div>
<svg class="grad-line-svg"><line :x1="bgLinePts.x1 + '%'" :y1="bgLinePts.y1 + '%'" :x2="bgLinePts.x2 + '%'" :y2="bgLinePts.y2 + '%'" class="bg-line" /></svg>
</template>
<!-- Foreground Gradient Handles -->
<template v-if="fgType !== 'solid'">
<div class="grad-handle fg-handle handle-1" :style="{ left: fgGradPos.x1 + '%', top: fgGradPos.y1 + '%' }" @mousedown="startDrag($event, 'fg:1')" @touchstart.prevent="startDrag($event, 'fg:1')"></div>
<div class="grad-handle fg-handle handle-2" :style="{ left: fgGradPos.x2 + '%', top: fgGradPos.y2 + '%' }" @mousedown="startDrag($event, 'fg:2')" @touchstart.prevent="startDrag($event, 'fg:2')"></div>
<svg class="grad-line-svg"><line :x1="fgLinePts.x1 + '%'" :y1="fgLinePts.y1 + '%'" :x2="fgLinePts.x2 + '%'" :y2="fgLinePts.y2 + '%'" class="fg-line" /></svg>
</template>
</template>
</div>
</div>
<!-- Overlay Icon Toggle -->
<button
v-if="fgType !== 'solid' || (!isBgTransparent && bgType !== 'solid')"
class="icon-btn edit-toggle-btn"
:class="{ 'active': showHandles }"
@click="showHandles = !showHandles"
v-tooltip="'Toggle edit handles'"
>
<Eye v-if="showHandles" size="20" />
<EyeOff v-else size="20" />
</button>
</div>
<div class="download-settings">
<div class="control-group">
<label>Size (px)</label>
<div class="number-control size-control">
<button class="control-btn" @click="size = Math.max(10, size - 100)" v-tooltip="'-100'" v-ripple>-100</button>
<button class="control-btn" @click="size = Math.max(10, size - 10)" v-tooltip="'-10'" v-ripple>-10</button>
<input type="number" v-model.number="size" class="number-input" />
<button class="control-btn" @click="size += 10" v-tooltip="'+10'" v-ripple>+10</button>
<button class="control-btn" @click="size += 100" v-tooltip="'+100'" v-ripple>+100</button>
</div>
</div>
<div class="control-group">
<label>Format</label>
<select v-model="format" class="select-input format-select">
<option value="png">PNG</option>
<option value="jpeg">JPG</option>
<option value="webp">WebP</option>
<option value="svg">SVG</option>
</select>
</div>
<button class="btn-neon primary" @click="downloadFile" v-ripple>
<Download size="18" /> <Download size="18" />
Download {{ format.toUpperCase() }} Download
</button> </button>
</div> </div>
</div> </div>
@@ -156,12 +399,6 @@ const triggerDownload = (blob, filename) => {
</template> </template>
<style scoped> <style scoped>
.tool-container.full-width {
height: 100%;
display: flex;
flex-direction: column;
}
.tool-panel { .tool-panel {
padding: 1rem; padding: 1rem;
display: flex; display: flex;
@@ -171,16 +408,6 @@ const triggerDownload = (blob, filename) => {
overflow: hidden; /* Prevent scrolling, force fit */ 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 { .tool-title {
margin: 0; margin: 0;
font-size: 1.5rem; font-size: 1.5rem;
@@ -195,23 +422,9 @@ const triggerDownload = (blob, filename) => {
} }
.tool-textarea { .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; 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 { .controls-section {
display: flex; display: flex;
gap: 1.5rem; gap: 1.5rem;
@@ -231,32 +444,94 @@ const triggerDownload = (blob, filename) => {
color: var(--text-secondary); color: var(--text-secondary);
} }
.select-input { .color-picker-wrapper {
padding: 0.5rem; display: flex;
align-items: center;
gap: 0.5rem;
height: 38px;
}
.color-input {
width: 38px;
height: 38px;
padding: 0;
border: 1px solid var(--panel-border);
border-radius: 6px; border-radius: 6px;
background: rgba(0, 0, 0, 0.2); cursor: pointer;
border: 1px solid var(--glass-border); background: var(--panel-bg);
color: var(--text-color); }
min-width: 120px;
.color-input:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
cursor: pointer;
color: var(--text-secondary);
user-select: none;
}
.checkbox-label input {
cursor: pointer;
width: 16px;
height: 16px;
} }
.preview-section { .preview-section {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; background: rgba(0, 0, 0, 0.05);
gap: 1rem;
justify-content: center;
background: rgba(0, 0, 0, 0.1);
border-radius: 12px; border-radius: 12px;
padding: 1rem; padding: 0.75rem 1rem 1.25rem;
overflow: hidden; /* Prevent overflow if QR is too big */ overflow: hidden;
min-height: 0;
flex: 1;
gap: 1.5rem;
}
.qr-container {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
min-height: 0; min-height: 0;
container-type: size; container-type: size;
position: relative;
}
.edit-toggle-btn {
position: absolute;
top: 0;
right: 0;
z-index: 20;
color: var(--text-secondary);
opacity: 0.6;
background: var(--panel-bg);
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.edit-toggle-btn:hover {
opacity: 1;
color: var(--text-strong);
}
.edit-toggle-btn.active {
color: var(--primary-accent);
opacity: 0.9;
}
:root[data-theme="light"] .preview-section {
background: rgba(255, 255, 255, 0.3);
} }
.qr-frame { .qr-frame {
width: calc(100cqmin - 4rem); width: min(100cqw, 100cqh);
height: calc(100cqmin - 4rem); height: min(100cqw, 100cqh);
background: white; background: white;
padding: 1rem; padding: 1rem;
border-radius: 8px; border-radius: 8px;
@@ -267,33 +542,82 @@ const triggerDownload = (blob, filename) => {
overflow: hidden; overflow: hidden;
} }
.qr-frame :deep(svg) { .svg-wrapper {
display: block; position: relative;
width: 100%; width: 100%;
height: 100%; height: 100%;
} }
.actions { .svg-content-box {
display: flex; width: 100%;
gap: 1rem; height: 100%;
display: block;
} }
.action-btn { .svg-content-box :deep(svg) {
display: flex; width: 100%;
align-items: center; height: 100%;
gap: 0.5rem; display: block;
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 { .grad-line-svg {
opacity: 0.9; position: absolute;
transform: translateY(-1px); top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 15;
} }
.grad-handle {
position: absolute;
width: 14px;
height: 14px;
transform: translate(-50%, -50%);
border-radius: 50%;
cursor: grab;
z-index: 10;
touch-action: none;
background: rgb(255, 255, 255);
box-shadow: 0 0 4px 0px rgb(0, 0, 0);
}
.grad-handle:active {
cursor: grabbing;
}
.fg-line, .bg-line {
stroke: rgb(255, 255, 255);
stroke-width: 1;
filter: drop-shadow(0px 0px 2px rgb(0, 0, 0));
}
.download-settings {
display: flex;
gap: 1.5rem;
align-items: flex-end;
flex-wrap: wrap;
justify-content: center;
width: 100%;
}
.size-control {
width: 100%;
}
@media (max-width: 480px) {
.size-control {
border-radius: 8px;
}
.size-control .control-btn {
flex: 1;
}
}
.format-select {
min-width: 100px !important;
}
</style> </style>

View File

@@ -1,30 +1,60 @@
<script setup> <script setup>
import { ref, onMounted, onUnmounted, watch, computed } from 'vue' import { ref, onMounted, onUnmounted, watch, computed } from 'vue'
import { QrcodeStream } from 'vue-qrcode-reader' import { SwitchCamera, Trash2, Copy, Download, X, QrCode } from 'lucide-vue-next'
import { SwitchCamera, Trash2, Copy, Download, X } from 'lucide-vue-next' import { useRouter } from 'vue-router'
import { toBase64Url } from '@gkucmierz/utils'
import { useCamera } from '../../composables/useCamera'
import { useQrDetection } from '../../composables/useQrDetection'
const error = ref('')
const facingMode = ref('environment')
const scannedCodes = ref([]) const scannedCodes = ref([])
const hasMultipleCameras = ref(false)
const isFullscreen = ref(false) const isFullscreen = ref(false)
const videoAspect = ref(1) const videoAspect = ref(1)
const isFront = computed(() => facingMode.value === 'user')
const wrapperRef = ref(null) const wrapperRef = ref(null)
let frontMirrorObserver = null
const bgCanvas = ref(null) const bgCanvas = ref(null)
let bgRafId = null let bgRafId = null
const videoRef = ref(null)
const router = useRouter()
const navigateToGenerateQr = (text) => {
const payload = toBase64Url(text)
router.push({ name: 'QrCode', params: { payload } })
}
const overlayCanvas = ref(null)
const {
stream,
facingMode,
hasMultipleCameras,
isMirrored,
error: cameraError,
checkCameras,
startCamera,
stopCamera,
switchCamera: baseSwitchCamera
} = useCamera(videoRef)
const {
error: detectionError,
isDetecting,
startDetection,
stopDetection
} = useQrDetection(videoRef, overlayCanvas)
const error = computed(() => cameraError.value || detectionError.value)
// Background Loop
const updateVideoAspect = () => { const updateVideoAspect = () => {
const videoEl = document.querySelector('.camera-wrapper video') if (videoRef.value && videoRef.value.videoWidth && videoRef.value.videoHeight) {
if (videoEl && videoEl.videoWidth && videoEl.videoHeight) { videoAspect.value = videoRef.value.videoWidth / videoRef.value.videoHeight
videoAspect.value = videoEl.videoWidth / videoEl.videoHeight
} }
} }
const startBackgroundLoop = () => { const startBackgroundLoop = () => {
const draw = () => { const draw = () => {
const videoEl = document.querySelector('.camera-wrapper video') const videoEl = videoRef.value
const canvas = bgCanvas.value const canvas = bgCanvas.value
if (!videoEl || !canvas) { if (!videoEl || !canvas || videoEl.paused || videoEl.ended) {
bgRafId = requestAnimationFrame(draw) bgRafId = requestAnimationFrame(draw)
return return
} }
@@ -42,7 +72,6 @@ const startBackgroundLoop = () => {
} }
const ctx = canvas.getContext('2d') const ctx = canvas.getContext('2d')
if (ctx) { if (ctx) {
// cover horizontally: scale by width, crop top/bottom
const scale = cw / vw const scale = cw / vw
const srcH = ch / scale const srcH = ch / scale
const sx = 0 const sx = 0
@@ -56,13 +85,14 @@ const startBackgroundLoop = () => {
bgRafId = requestAnimationFrame(draw) bgRafId = requestAnimationFrame(draw)
} }
// front mirror canvas removed to restore stable behavior
const stopBackgroundLoop = () => { const stopBackgroundLoop = () => {
if (bgRafId) { if (bgRafId) {
cancelAnimationFrame(bgRafId) cancelAnimationFrame(bgRafId)
bgRafId = null bgRafId = null
} }
} }
// Full screen styles
const desktopFullscreenStyle = computed(() => { const desktopFullscreenStyle = computed(() => {
if (!isFullscreen.value) return {} if (!isFullscreen.value) return {}
const isDesktop = window.innerWidth >= 768 const isDesktop = window.innerWidth >= 768
@@ -87,48 +117,37 @@ const processCodes = (codes) => {
} }
const onDetect = (detectedCodes) => { const onDetect = (detectedCodes) => {
// If fullscreen, accept all detected codes (as the user sees the full camera view mostly)
if (isFullscreen.value) { if (isFullscreen.value) {
processCodes(detectedCodes) processCodes(detectedCodes)
return return
} }
// Try to find video element to calculate visible area
const videoEl = document.querySelector('.camera-wrapper video') const videoEl = document.querySelector('.camera-wrapper video')
if (!videoEl || !videoEl.videoWidth || !videoEl.videoHeight) { if (!videoEl || !videoEl.videoWidth || !videoEl.videoHeight) {
processCodes(detectedCodes) processCodes(detectedCodes)
return return
} }
const { videoWidth, videoHeight } = videoEl const { videoWidth, videoHeight } = videoEl
// Calculate visible square area (assuming object-fit: cover and 1:1 container)
const isLandscape = videoWidth > videoHeight const isLandscape = videoWidth > videoHeight
let visibleX, visibleY, visibleW, visibleH let visibleX, visibleY, visibleW, visibleH
if (isLandscape) { if (isLandscape) {
// Landscape: sides are cropped, height is fully visible
visibleH = videoHeight visibleH = videoHeight
visibleW = videoHeight // Square visibleW = videoHeight
visibleX = (videoWidth - videoHeight) / 2 visibleX = (videoWidth - videoHeight) / 2
visibleY = 0 visibleY = 0
} else { } else {
// Portrait: top/bottom are cropped, width is fully visible
visibleW = videoWidth visibleW = videoWidth
visibleH = videoWidth // Square visibleH = videoWidth
visibleX = 0 visibleX = 0
visibleY = (videoHeight - videoWidth) / 2 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 => { const validCodes = detectedCodes.filter(code => {
if (!code.boundingBox) return true if (!code.boundingBox) return true
const { x, y, width, height } = code.boundingBox const { x, y, width, height } = code.boundingBox
const centerX = x + width / 2 const centerX = x + width / 2
const centerY = y + height / 2 const centerY = y + height / 2
return ( return (
centerX >= visibleX && centerX >= visibleX &&
centerX <= visibleX + visibleW && centerX <= visibleX + visibleW &&
@@ -140,84 +159,6 @@ const onDetect = (detectedCodes) => {
processCodes(validCodes) processCodes(validCodes)
} }
const onCameraOn = async (capabilities) => {
// Camera is ready
setTimeout(updateVideoAspect, 100)
setTimeout(startBackgroundLoop, 150)
// Flip is handled via global CSS; no JS flips needed
}
const ensureFrontMirror = () => {
// No-op: mirror is applied via CSS selectors
}
const startFrontMirrorObserver = () => {
// No-op: mirror is applied via CSS selectors
}
const stopFrontMirrorObserver = () => {
// No-op
}
watch(isFront, () => {
// CSS-based; nothing to do
})
const paintDetections = (detectedCodes, ctx) => {
try {
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height)
const styles = getComputedStyle(document.documentElement)
const accent = styles.getPropertyValue('--primary-accent').trim() || '#00f2fe'
detectedCodes.forEach(code => {
if (code.format && code.format !== 'qr_code') return
const points = code.cornerPoints || []
if (points.length < 4) return
ctx.beginPath()
ctx.moveTo(points[0].x, points[0].y)
for (let i = 1; i < points.length; i++) {
ctx.lineTo(points[i].x, points[i].y)
}
ctx.closePath()
ctx.lineWidth = 3
ctx.strokeStyle = accent
ctx.shadowColor = accent
ctx.shadowBlur = 8
ctx.stroke()
ctx.shadowBlur = 0
ctx.fillStyle = accent
points.forEach(p => {
ctx.beginPath()
ctx.arc(p.x, p.y, 2.5, 0, Math.PI * 2)
ctx.fill()
})
})
} catch (e) {
// ignore drawing errors
}
}
const onError = (err) => {
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 = () => { const loadHistory = () => {
try { try {
const saved = localStorage.getItem('qr-history') const saved = localStorage.getItem('qr-history')
@@ -233,11 +174,30 @@ watch(scannedCodes, (newVal) => {
localStorage.setItem('qr-history', JSON.stringify(newVal)) localStorage.setItem('qr-history', JSON.stringify(newVal))
}, { deep: true }) }, { deep: true })
const handleKeydown = (e) => {
if (e.key === 'Escape' && isFullscreen.value) {
toggleFullscreen()
}
}
const startScan = async () => {
try {
await startCamera()
updateVideoAspect()
startBackgroundLoop()
startDetection(onDetect)
} catch (err) {
// Error is handled by error computed property
}
}
onMounted(() => { onMounted(() => {
checkCameras() checkCameras()
loadHistory() loadHistory()
window.addEventListener('resize', updateVideoAspect) window.addEventListener('resize', updateVideoAspect)
window.addEventListener('resize', startBackgroundLoop) window.addEventListener('resize', startBackgroundLoop)
window.addEventListener('keydown', handleKeydown)
watch(isFullscreen, (fs) => { watch(isFullscreen, (fs) => {
if (fs) { if (fs) {
startBackgroundLoop() startBackgroundLoop()
@@ -245,17 +205,21 @@ onMounted(() => {
stopBackgroundLoop() stopBackgroundLoop()
} }
}, { immediate: true }) }, { immediate: true })
startScan()
}) })
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener('resize', updateVideoAspect) window.removeEventListener('resize', updateVideoAspect)
window.removeEventListener('resize', startBackgroundLoop) window.removeEventListener('resize', startBackgroundLoop)
stopBackgroundLoop() window.removeEventListener('keydown', handleKeydown)
stopDetection()
stopCamera()
}) })
const switchCamera = (event) => { const switchCamera = (event) => {
event.stopPropagation() if (event) event.stopPropagation()
facingMode.value = facingMode.value === 'environment' ? 'user' : 'environment' baseSwitchCamera()
} }
const toggleFullscreen = () => { const toggleFullscreen = () => {
@@ -316,6 +280,7 @@ const isUrl = (string) => {
<div class="tool-panel"> <div class="tool-panel">
<div class="panel-header" v-if="!isFullscreen"> <div class="panel-header" v-if="!isFullscreen">
<h2 class="tool-title">QR Scanner</h2> <h2 class="tool-title">QR Scanner</h2>
<div class="header-actions"></div>
</div> </div>
<Teleport to="body" :disabled="!isFullscreen"> <Teleport to="body" :disabled="!isFullscreen">
@@ -324,6 +289,7 @@ const isUrl = (string) => {
v-if="isFullscreen" v-if="isFullscreen"
ref="bgCanvas" ref="bgCanvas"
class="camera-bg" class="camera-bg"
:class="{ 'is-mirrored': isMirrored }"
></canvas> ></canvas>
<button v-if="isFullscreen" class="close-fullscreen-btn" @click="toggleFullscreen"> <button v-if="isFullscreen" class="close-fullscreen-btn" @click="toggleFullscreen">
<X size="24" /> <X size="24" />
@@ -331,44 +297,49 @@ const isUrl = (string) => {
<div <div
class="camera-wrapper" class="camera-wrapper"
:class="{ 'clickable': !isFullscreen, 'is-front': isFront }" :class="{ 'clickable': !isFullscreen, 'is-mirrored': isMirrored }"
:style="desktopFullscreenStyle" :style="desktopFullscreenStyle"
ref="wrapperRef" ref="wrapperRef"
@click="!isFullscreen && toggleFullscreen()" @click="!isFullscreen && toggleFullscreen()"
> >
<QrcodeStream <video
:constraints="{ facingMode }" ref="videoRef"
@detect="onDetect" class="camera-feed"
@error="onError" :class="{ 'is-mirrored': isMirrored }"
@camera-on="onCameraOn" autoplay
:track="paintDetections" playsinline
> muted
<div v-if="error" class="error-overlay"> ></video>
<p>{{ error }}</p>
</div>
<button <canvas ref="overlayCanvas" class="scan-overlay-canvas" :class="{ 'is-mirrored': isMirrored }"></canvas>
v-if="hasMultipleCameras"
class="switch-camera-btn" <div v-if="error" class="error-overlay">
@click.stop="switchCamera" <p>{{ error }}</p>
title="Switch Camera" <button @click="startScan" class="retry-btn" v-ripple>Retry</button>
> </div>
<SwitchCamera size="24" />
</button> <button
</QrcodeStream> v-if="hasMultipleCameras"
class="switch-camera-btn"
@click.stop="switchCamera"
v-tooltip="'Switch Camera'"
v-ripple
>
<SwitchCamera size="24" />
</button>
</div> </div>
<div class="results-section"> <div class="results-section">
<div class="results-header"> <div class="results-header">
<h3>Scanned Codes ({{ scannedCodes.length }})</h3> <h3>Scanned Codes ({{ scannedCodes.length }})</h3>
<div v-if="scannedCodes.length > 0" class="header-actions"> <div v-if="scannedCodes.length > 0" class="header-actions">
<button class="icon-btn" @click="copyAll" title="Copy All"> <button class="icon-btn" @click="copyAll" v-tooltip="'Copy All'" v-ripple>
<Copy size="18" /> <Copy size="18" />
</button> </button>
<button class="icon-btn" @click="downloadJson" title="Download JSON"> <button class="icon-btn" @click="downloadJson" v-tooltip="'Download JSON'" v-ripple>
<Download size="18" /> <Download size="18" />
</button> </button>
<button class="icon-btn delete-btn" @click="clearHistory" title="Clear All"> <button class="icon-btn delete-btn" @click="clearHistory" v-tooltip="'Clear All'" v-ripple>
<Trash2 size="18" /> <Trash2 size="18" />
</button> </button>
</div> </div>
@@ -387,10 +358,13 @@ const isUrl = (string) => {
</div> </div>
</div> </div>
<div class="item-actions"> <div class="item-actions">
<button class="icon-btn" @click="copyToClipboard(code.value)" title="Copy"> <button class="icon-btn" @click="copyToClipboard(code.value)" v-tooltip="'Copy'" v-ripple>
<Copy size="18" /> <Copy size="18" />
</button> </button>
<button class="icon-btn delete-btn" @click="removeCode(code.id)" title="Remove"> <button class="icon-btn" @click="navigateToGenerateQr(code.value)" v-tooltip="'Generate QR Code'" v-ripple>
<QrCode size="18" />
</button>
<button class="icon-btn delete-btn" @click="removeCode(code.id)" v-tooltip="'Remove'" v-ripple>
<Trash2 size="18" /> <Trash2 size="18" />
</button> </button>
</div> </div>
@@ -407,30 +381,6 @@ const isUrl = (string) => {
</template> </template>
<style scoped> <style scoped>
.tool-container.full-width {
max-width: 100%;
height: 100%;
display: flex;
flex-direction: column;
}
.tool-panel {
display: flex;
flex-direction: column;
height: 100%;
gap: 1.5rem;
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 { .scanner-content {
display: flex; display: flex;
@@ -447,6 +397,10 @@ const isUrl = (string) => {
gap: 0; gap: 0;
} }
:global(:root[data-theme="light"] .scanner-content.is-fullscreen) {
background: #fff;
}
.camera-wrapper { .camera-wrapper {
width: 100%; width: 100%;
max-width: 500px; max-width: 500px;
@@ -461,6 +415,10 @@ const isUrl = (string) => {
transition: all 0.3s ease; transition: all 0.3s ease;
} }
:global(:root[data-theme="light"] .camera-wrapper) {
background: #f1f5f9;
}
.camera-wrapper.clickable { .camera-wrapper.clickable {
cursor: pointer; cursor: pointer;
} }
@@ -488,7 +446,34 @@ const isUrl = (string) => {
z-index: 0; z-index: 0;
} }
/* front mirror canvas removed */ .camera-bg.is-mirrored {
transform: scaleX(-1);
}
.camera-feed {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.camera-feed.is-mirrored {
transform: scaleX(-1);
}
.scan-overlay-canvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 5;
}
.scan-overlay-canvas.is-mirrored {
transform: scaleX(-1);
}
.error-overlay { .error-overlay {
position: absolute; position: absolute;
@@ -548,18 +533,6 @@ const isUrl = (string) => {
backdrop-filter: blur(4px); 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 { .scanner-content.is-fullscreen .results-section {
position: relative; position: relative;
flex: 1; flex: 1;
@@ -570,58 +543,13 @@ const isUrl = (string) => {
background: var(--glass-bg); background: var(--glass-bg);
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
border: none; border: none;
border-top: 1px solid rgba(255, 255, 255, 0.2); border-top: 1px solid var(--glass-border);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.results-header { :global(:root[data-theme="light"] .scanner-content.is-fullscreen .results-section) {
padding: 1rem; background: rgba(255, 255, 255, 0.75);
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 { .code-value {
@@ -647,12 +575,6 @@ const isUrl = (string) => {
color: var(--text-secondary); color: var(--text-secondary);
} }
.item-actions {
display: flex;
gap: 0.5rem;
align-items: center;
}
.format-badge { .format-badge {
background: rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.1);
padding: 0 0.4rem; padding: 0 0.4rem;
@@ -664,32 +586,6 @@ const isUrl = (string) => {
background: rgba(0, 0, 0, 0.1); 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 { .empty-state {
flex: 1; flex: 1;
display: flex; display: flex;

View File

@@ -2,7 +2,7 @@
import { ref, watch, onUnmounted } from 'vue' import { ref, watch, onUnmounted } from 'vue'
import { Copy, Trash2, ExternalLink, Power, Zap, X, Settings, Download } from 'lucide-vue-next' import { Copy, Trash2, ExternalLink, Power, Zap, X, Settings, Download } from 'lucide-vue-next'
import { useExtension } from '../../composables/useExtension' import { useExtension } from '../../composables/useExtension'
import { useLocalStorage } from '../../composables/useLocalStorage' import { useUrlCleaner } from '../../composables/useUrlCleaner'
import ExtensionStatus from './common/ExtensionStatus.vue' import ExtensionStatus from './common/ExtensionStatus.vue'
import UrlCleanerExceptionsModal from './UrlCleanerExceptionsModal.vue' import UrlCleanerExceptionsModal from './UrlCleanerExceptionsModal.vue'
@@ -10,29 +10,22 @@ import UrlCleanerExceptionsModal from './UrlCleanerExceptionsModal.vue'
const { isExtensionReady, isListening, lastClipboardText, startListening, stopListening, writeClipboard } = useExtension() const { isExtensionReady, isListening, lastClipboardText, startListening, stopListening, writeClipboard } = useExtension()
const inputUrl = ref('') 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 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 {
const matchDomain = (pattern, domain) => { cleanedHistory,
// Escape regex chars except * isWatchEnabled,
const regexString = '^' + pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') + '$' exceptions,
return new RegExp(regexString, 'i').test(domain) defaultExceptions,
} processUrl: baseProcessUrl,
removeEntry,
clearHistory
} = useUrlCleaner()
// Watch for clipboard changes from extension // Watch for clipboard changes from extension
watch(lastClipboardText, (newText) => { watch(lastClipboardText, (newText) => {
if (isWatchEnabled.value && newText) { if (isWatchEnabled.value && newText) {
processUrl(newText, true) baseProcessUrl(newText, true, writeClipboard)
} }
}) })
@@ -94,114 +87,16 @@ const handleClean = () => {
if (inputUrl.value) { if (inputUrl.value) {
const urls = inputUrl.value.split(/\r?\n/).filter(line => line.trim().length > 0) const urls = inputUrl.value.split(/\r?\n/).filter(line => line.trim().length > 0)
urls.forEach(url => { urls.forEach(url => {
processUrl(url.trim(), false) baseProcessUrl(url.trim(), false, writeClipboard)
}) })
inputUrl.value = '' 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) => { const copyToClipboard = (text) => {
navigator.clipboard.writeText(text) navigator.clipboard.writeText(text)
} }
const removeEntry = (id) => {
cleanedHistory.value = cleanedHistory.value.filter(item => item.id !== id)
}
const clearHistory = () => {
cleanedHistory.value = []
}
onUnmounted(() => { onUnmounted(() => {
if (isListening.value) { if (isListening.value) {
stopListening() stopListening()
@@ -215,7 +110,7 @@ onUnmounted(() => {
<div class="panel-header"> <div class="panel-header">
<h2 class="tool-title">URL Cleaner</h2> <h2 class="tool-title">URL Cleaner</h2>
<div class="header-actions"> <div class="header-actions">
<button class="icon-btn settings-btn" @click="showExceptionsModal = true" title="Cleaning Exceptions"> <button class="icon-btn settings-btn" @click="showExceptionsModal = true" v-tooltip="'Cleaning Exceptions'" v-ripple>
<Settings size="20" /> <Settings size="20" />
</button> </button>
<ExtensionStatus :isReady="isExtensionReady" /> <ExtensionStatus :isReady="isExtensionReady" />
@@ -227,22 +122,23 @@ onUnmounted(() => {
<textarea <textarea
v-model="inputUrl" v-model="inputUrl"
placeholder="Paste URL(s) here to clean..." placeholder="Paste URL(s) here to clean..."
class="url-input" class="tool-textarea url-input"
@keydown.enter.prevent="handleClean" @keydown.enter.prevent="handleClean"
rows="1" rows="1"
></textarea> ></textarea>
<button class="btn-neon" @click="handleClean">
Clean
</button>
</div> </div>
<div class="watch-toggle"> <div class="watch-toggle">
<button class="btn-neon" @click="handleClean" v-ripple>
Clean
</button>
<button <button
class="btn-neon toggle-btn" class="btn-neon toggle-btn"
:class="{ 'active': isWatchEnabled && isExtensionReady }" :class="{ 'active': isWatchEnabled && isExtensionReady }"
@click="toggleWatch" @click="toggleWatch"
:disabled="!isExtensionReady" :disabled="!isExtensionReady"
:title="!isExtensionReady ? 'Extension required for auto-watch' : 'Automatically clean URLs from clipboard'" v-tooltip="!isExtensionReady ? 'Extension required for auto-watch' : 'Automatically clean URLs from clipboard'"
v-ripple
> >
<Power size="18" /> <Power size="18" />
<span>Watch Clipboard</span> <span>Watch Clipboard</span>
@@ -253,15 +149,15 @@ onUnmounted(() => {
<div class="history-section" v-if="cleanedHistory.length > 0"> <div class="history-section" v-if="cleanedHistory.length > 0">
<div class="history-header"> <div class="history-header">
<h3>Cleaned URLs</h3> <h3>Cleaned URLs ({{ cleanedHistory.length }})</h3>
<div class="history-actions"> <div class="history-actions">
<button class="icon-btn" @click="copyAllUrls" title="Copy all URLs"> <button class="icon-btn" @click="copyAllUrls" v-tooltip="'Copy all URLs'" v-ripple>
<Copy size="18" /> <Copy size="18" />
</button> </button>
<button class="icon-btn" @click="downloadJson" title="Download JSON"> <button class="icon-btn" @click="downloadJson" v-tooltip="'Download JSON'" v-ripple>
<Download size="18" /> <Download size="18" />
</button> </button>
<button class="icon-btn delete-btn" @click="clearHistory" title="Clear History"> <button class="icon-btn delete-btn" @click="clearHistory" v-tooltip="'Clear History'" v-ripple>
<Trash2 size="18" /> <Trash2 size="18" />
</button> </button>
</div> </div>
@@ -280,13 +176,13 @@ onUnmounted(() => {
</div> </div>
</div> </div>
<div class="item-actions"> <div class="item-actions">
<button class="icon-btn" @click="copyToClipboard(item.cleaned)" title="Copy"> <button class="icon-btn" @click="copyToClipboard(item.cleaned)" v-tooltip="'Copy'" v-ripple>
<Copy size="18" /> <Copy size="16" />
</button> </button>
<a :href="item.cleaned" target="_blank" class="icon-btn" title="Open"> <a :href="item.cleaned" target="_blank" class="icon-btn" v-tooltip="'Open'" v-ripple>
<ExternalLink size="18" /> <ExternalLink size="16" />
</a> </a>
<button class="icon-btn delete-btn" @click="removeEntry(item.id)" title="Remove"> <button class="icon-btn delete-btn" @click="removeEntry(item.id)" v-tooltip="'Remove'" v-ripple>
<X size="18" /> <X size="18" />
</button> </button>
</div> </div>
@@ -310,29 +206,6 @@ onUnmounted(() => {
</template> </template>
<style scoped> <style scoped>
.tool-container.full-width {
max-width: 100%;
height: 100%;
display: flex;
flex-direction: column;
}
.tool-panel {
display: flex;
flex-direction: column;
height: 100%;
gap: 1.5rem;
position: relative;
}
.panel-header {
display: flex;
justify-content: center;
align-items: center;
position: relative;
width: 100%;
}
.input-section { .input-section {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -374,27 +247,14 @@ onUnmounted(() => {
.url-input { .url-input {
flex: 1; flex: 1;
padding: 0.8rem 1rem; min-height: 120px;
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; 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 { .watch-toggle {
display: flex; display: flex;
justify-content: flex-end; justify-content: space-between;
gap: 0.75rem;
} }
.toggle-btn { .toggle-btn {
@@ -410,81 +270,6 @@ onUnmounted(() => {
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 { .cleaned-url {
color: var(--primary-accent); color: var(--primary-accent);
font-family: monospace; font-family: monospace;
@@ -508,44 +293,6 @@ onUnmounted(() => {
gap: 0.2rem; 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 { .empty-state {
flex: 1; flex: 1;
display: flex; display: flex;
@@ -556,16 +303,6 @@ onUnmounted(() => {
padding: 2rem; padding: 2rem;
} }
.header-actions {
display: flex;
align-items: center;
gap: 0.8rem;
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
}
.settings-btn { .settings-btn {
background: rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.1);
width: 32px; width: 32px;
@@ -583,12 +320,4 @@ onUnmounted(() => {
background: rgba(255, 255, 255, 0.2); background: rgba(255, 255, 255, 0.2);
color: var(--primary-accent); 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> </style>

View File

@@ -1,5 +1,5 @@
<script setup> <script setup>
import { ref, computed } from 'vue' import { ref, computed, watch, onUnmounted } from 'vue'
import { X, Plus, Trash2, RotateCcw } from 'lucide-vue-next' import { X, Plus, Trash2, RotateCcw } from 'lucide-vue-next'
const props = defineProps({ const props = defineProps({
@@ -10,9 +10,27 @@ const props = defineProps({
const emit = defineEmits(['close', 'update:exceptions']) const emit = defineEmits(['close', 'update:exceptions'])
const handleKeydown = (e) => {
if (e.key === 'Escape') {
emit('close')
}
}
watch(() => props.isOpen, (isOpen) => {
if (isOpen) {
window.addEventListener('keydown', handleKeydown)
} else {
window.removeEventListener('keydown', handleKeydown)
}
})
onUnmounted(() => {
window.removeEventListener('keydown', handleKeydown)
})
const newRule = ref({ const newRule = ref({
domainPattern: '', domainPattern: '',
keepParams: '', keepParams: [],
keepHash: false, keepHash: false,
keepAllParams: false keepAllParams: false
}) })
@@ -25,10 +43,12 @@ const localExceptions = computed({
const addRule = () => { const addRule = () => {
if (!newRule.value.domainPattern) return if (!newRule.value.domainPattern) return
const params = newRule.value.keepParams // Flush any pending text in the param input before adding rule
.split(',') if (pendingParamInput.value.trim()) {
.map(p => p.trim()) addPendingParam(pendingParamInput.value)
.filter(p => p) }
const params = [...newRule.value.keepParams]
const existingRuleIndex = localExceptions.value.findIndex( const existingRuleIndex = localExceptions.value.findIndex(
r => r.domainPattern === newRule.value.domainPattern r => r.domainPattern === newRule.value.domainPattern
@@ -74,19 +94,48 @@ const addRule = () => {
// Reset form // Reset form
newRule.value = { newRule.value = {
domainPattern: '', domainPattern: '',
keepParams: '', keepParams: [],
keepHash: false, keepHash: false,
keepAllParams: false keepAllParams: false
} }
pendingParamInput.value = ''
}
const pendingParamInput = ref('')
const handleParamInputKeydown = (e) => {
if (e.key === 'Enter' || e.key === ',' || e.key === ' ') {
e.preventDefault()
addPendingParam(pendingParamInput.value)
} else if (e.key === 'Backspace' && pendingParamInput.value === '') {
// Remove last param if backspace is pressed on empty input
if (newRule.value.keepParams.length > 0) {
newRule.value.keepParams.pop()
}
}
}
const addPendingParam = (val) => {
const cleanVals = val.split(/[\s,]+/).map(v => v.trim()).filter(Boolean)
if (cleanVals.length > 0) {
const updatedParams = [...new Set([...newRule.value.keepParams, ...cleanVals])]
newRule.value.keepParams = updatedParams
}
pendingParamInput.value = ''
}
const removeNewRuleParam = (paramToRemove) => {
newRule.value.keepParams = newRule.value.keepParams.filter(p => p !== paramToRemove)
} }
const editRule = (rule) => { const editRule = (rule) => {
newRule.value = { newRule.value = {
domainPattern: rule.domainPattern, domainPattern: rule.domainPattern,
keepParams: Array.isArray(rule.keepParams) ? rule.keepParams.join(', ') : '', keepParams: Array.isArray(rule.keepParams) ? [...rule.keepParams] : [],
keepHash: !!rule.keepHash, keepHash: !!rule.keepHash,
keepAllParams: !!rule.keepAllParams keepAllParams: !!rule.keepAllParams
} }
pendingParamInput.value = ''
} }
const removeRule = (id) => { const removeRule = (id) => {
@@ -154,7 +203,7 @@ const resetToDefault = (ruleId) => {
<template> <template>
<Teleport to="body"> <Teleport to="body">
<div v-if="isOpen" class="modal-overlay" @click.self="$emit('close')"> <div v-if="isOpen" class="modal-overlay" @click.self="$emit('close')">
<div class="modal-content"> <div class="modal-content glass-panel">
<div class="modal-header"> <div class="modal-header">
<h3>URL Cleaning Exceptions</h3> <h3>URL Cleaning Exceptions</h3>
<button class="close-btn" @click="$emit('close')"> <button class="close-btn" @click="$emit('close')">
@@ -177,25 +226,35 @@ const resetToDefault = (ruleId) => {
class="input-field" class="input-field"
@keyup.enter="addRule" @keyup.enter="addRule"
> >
<input <div class="token-input-field input-field" @click="$refs.paramInput?.focus()">
v-model="newRule.keepParams" <span v-for="param in newRule.keepParams" :key="param" class="token-badge">
placeholder="Keep params (comma separated, e.g. v, id)" {{ param }}
class="input-field" <button class="remove-token-btn" @click.stop="removeNewRuleParam(param)">
@keyup.enter="addRule" <X size="12" />
> </button>
</span>
<input
ref="paramInput"
v-model="pendingParamInput"
placeholder="Params (Space, Comma or Enter to add)"
class="token-raw-input"
@keydown="handleParamInputKeydown"
@blur="addPendingParam(pendingParamInput)"
>
</div>
</div> </div>
<div class="form-row checkbox-row"> <div class="form-row checkbox-row">
<div class="checkbox-group"> <div class="checkbox-group">
<label class="checkbox-label"> <label class="checkbox-label" v-ripple>
<input type="checkbox" v-model="newRule.keepHash"> <input type="checkbox" v-model="newRule.keepHash">
Keep Anchor (#) Keep Anchor (#)
</label> </label>
<label class="checkbox-label"> <label class="checkbox-label" v-ripple>
<input type="checkbox" v-model="newRule.keepAllParams"> <input type="checkbox" v-model="newRule.keepAllParams">
Keep all params Keep all params
</label> </label>
</div> </div>
<button class="btn-neon small" @click="addRule" :disabled="!newRule.domainPattern"> <button class="btn-neon small" @click="addRule" :disabled="!newRule.domainPattern" v-ripple>
<Plus size="16" /> Add Rule <Plus size="16" /> Add Rule
</button> </button>
</div> </div>
@@ -209,13 +268,13 @@ const resetToDefault = (ruleId) => {
<div v-for="rule in localExceptions" :key="rule.id" class="rule-item" :class="{ disabled: !rule.isEnabled }"> <div v-for="rule in localExceptions" :key="rule.id" class="rule-item" :class="{ disabled: !rule.isEnabled }">
<div class="rule-info"> <div class="rule-info">
<div class="rule-domain" @click="editRule(rule)" title="Click to edit">{{ rule.domainPattern }}</div> <div class="rule-domain" @click="editRule(rule)" v-tooltip="'Click to edit'">{{ rule.domainPattern }}</div>
<div class="rule-details"> <div class="rule-details">
<div class="params-list"> <div class="params-list">
<template v-if="rule.keepAllParams"> <template v-if="rule.keepAllParams">
<span class="detail-tag"> <span class="detail-tag">
Keep all params Keep all params
<button class="remove-param-btn" @click.stop="toggleKeepAllParams(rule.id)" title="Disable keep all params"> <button class="remove-param-btn" @click.stop="toggleKeepAllParams(rule.id)" v-tooltip="'Disable keep all params'">
<X size="12" /> <X size="12" />
</button> </button>
</span> </span>
@@ -223,14 +282,14 @@ const resetToDefault = (ruleId) => {
<template v-else> <template v-else>
<span v-for="param in rule.keepParams" :key="param" class="detail-tag"> <span v-for="param in rule.keepParams" :key="param" class="detail-tag">
{{ param }} {{ param }}
<button class="remove-param-btn" @click.stop="removeParam(rule.id, param)" title="Remove parameter"> <button class="remove-param-btn" @click.stop="removeParam(rule.id, param)" v-tooltip="'Remove parameter'">
<X size="12" /> <X size="12" />
</button> </button>
</span> </span>
</template> </template>
<span v-if="rule.keepHash" class="detail-tag hash-tag"> <span v-if="rule.keepHash" class="detail-tag hash-tag">
Keep # Keep #
<button class="remove-param-btn" @click.stop="toggleKeepHash(rule.id)" title="Remove hash exception"> <button class="remove-param-btn" @click.stop="toggleKeepHash(rule.id)" v-tooltip="'Remove hash exception'">
<X size="12" /> <X size="12" />
</button> </button>
</span> </span>
@@ -243,7 +302,8 @@ const resetToDefault = (ruleId) => {
<button <button
class="icon-btn" class="icon-btn"
@click="toggleRule(rule.id)" @click="toggleRule(rule.id)"
:title="rule.isEnabled ? 'Disable rule' : 'Enable rule'" v-tooltip="rule.isEnabled ? 'Disable rule' : 'Enable rule'"
v-ripple
> >
<div class="toggle-switch" :class="{ active: rule.isEnabled }"></div> <div class="toggle-switch" :class="{ active: rule.isEnabled }"></div>
</button> </button>
@@ -252,11 +312,12 @@ const resetToDefault = (ruleId) => {
v-if="!rule.isDefault" v-if="!rule.isDefault"
class="icon-btn delete-btn" class="icon-btn delete-btn"
@click="removeRule(rule.id)" @click="removeRule(rule.id)"
title="Remove rule" v-tooltip="'Remove rule'"
v-ripple
> >
<Trash2 size="18" /> <Trash2 size="18" />
</button> </button>
<button v-else class="btn-neon small default-reset" @click="resetToDefault(rule.id)" title="Restore default rule"> <button v-else class="btn-neon small default-reset" @click="resetToDefault(rule.id)" v-tooltip="'Restore default rule'" v-ripple>
<RotateCcw size="16" /> Default <RotateCcw size="16" /> Default
</button> </button>
</div> </div>
@@ -284,15 +345,7 @@ const resetToDefault = (ruleId) => {
padding: 1rem; padding: 1rem;
} }
:global(:root[data-theme="light"]) .modal-overlay {
background: rgba(0, 0, 0, 0.15);
}
.modal-content { .modal-content {
background: var(--glass-bg);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid var(--glass-border);
border-radius: 16px; border-radius: 16px;
padding: 0; padding: 0;
max-width: 800px; max-width: 800px;
@@ -300,14 +353,10 @@ const resetToDefault = (ruleId) => {
max-height: 85vh; max-height: 85vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
box-shadow: var(--glass-shadow);
color: var(--text-color); 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 { .modal-header {
padding: 1.5rem; padding: 1.5rem;
@@ -370,11 +419,6 @@ const resetToDefault = (ruleId) => {
border: 1px solid var(--glass-border); 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 { .add-rule-form h4, .rules-list h4 {
margin-top: 0; margin-top: 0;
margin-bottom: 1rem; margin-bottom: 1rem;
@@ -433,10 +477,63 @@ const resetToDefault = (ruleId) => {
outline: none; outline: none;
} }
.input-field:focus { .input-field:focus, .token-input-field:focus-within {
border-color: var(--primary-accent); border-color: var(--primary-accent);
} }
.token-input-field {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
align-items: center;
padding: 0.4rem 0.6rem;
cursor: text;
}
.token-raw-input {
flex: 1;
min-width: 150px;
background: none;
border: none;
color: var(--text-color);
outline: none;
font-size: 0.95rem;
padding: 0.2rem 0;
}
.token-raw-input:focus {
border: none !important;
box-shadow: none !important;
}
.token-badge {
display: inline-flex;
align-items: center;
gap: 0.3rem;
background: rgba(var(--primary-accent-rgb), 0.15);
border: 1px solid rgba(var(--primary-accent-rgb), 0.3);
color: var(--primary-accent);
padding: 0.15rem 0.4rem;
border-radius: 4px;
font-size: 0.85rem;
}
.remove-token-btn {
background: none;
border: none;
padding: 0;
display: flex;
align-items: center;
color: inherit;
cursor: pointer;
opacity: 0.7;
transition: opacity 0.2s;
}
.remove-token-btn:hover {
opacity: 1;
}
.checkbox-label { .checkbox-label {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -444,6 +541,31 @@ const resetToDefault = (ruleId) => {
color: var(--text-secondary); color: var(--text-secondary);
font-size: 0.9rem; font-size: 0.9rem;
cursor: pointer; cursor: pointer;
padding: 0.4rem 0.8rem;
background: var(--toggle-bg);
border: 1px solid var(--toggle-border);
border-radius: 6px;
transition: all 0.2s ease;
user-select: none;
}
.checkbox-label:hover {
border-color: var(--toggle-hover-border);
color: var(--text-color);
}
.checkbox-label:has(input:checked) {
background: rgba(var(--primary-accent-rgb), 0.2);
border-color: var(--primary-accent);
color: var(--primary-accent);
}
.checkbox-label input {
position: absolute;
opacity: 0;
cursor: pointer;
height: 0;
width: 0;
} }
.btn-neon.small { .btn-neon.small {
@@ -468,11 +590,6 @@ const resetToDefault = (ruleId) => {
transition: opacity 0.3s; 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 { .rule-item.disabled {
opacity: 0.6; opacity: 0.6;
} }
@@ -504,16 +621,7 @@ const resetToDefault = (ruleId) => {
flex-wrap: wrap; 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 { .remove-param-btn {
background: none; background: none;
@@ -594,8 +702,4 @@ const resetToDefault = (ruleId) => {
.delete-btn:hover { .delete-btn:hover {
color: #ef4444; color: #ef4444;
} }
:global(:root[data-theme="light"]) .delete-btn:hover {
color: #dc2626;
}
</style> </style>

View File

@@ -5,7 +5,7 @@ export default {
</script> </script>
<script setup> <script setup>
import { ref } from 'vue' import { ref, watch, onUnmounted } from 'vue'
import { Plug, Plus, X } from 'lucide-vue-next' import { Plug, Plus, X } from 'lucide-vue-next'
const props = defineProps({ const props = defineProps({
@@ -13,10 +13,28 @@ const props = defineProps({
}) })
const showModal = ref(false) const showModal = ref(false)
const handleKeydown = (e) => {
if (e.key === 'Escape' && showModal.value) {
showModal.value = false
}
}
watch(showModal, (isOpen) => {
if (isOpen) {
window.addEventListener('keydown', handleKeydown)
} else {
window.removeEventListener('keydown', handleKeydown)
}
})
onUnmounted(() => {
window.removeEventListener('keydown', handleKeydown)
})
</script> </script>
<template> <template>
<div class="extension-status" v-bind="$attrs" :class="{ 'is-ready': isReady }" @click="showModal = true" :title="isReady ? 'Extension Connected' : 'Extension Not Connected'"> <div class="extension-status" v-bind="$attrs" :class="{ 'is-ready': isReady }" @click="showModal = true" v-tooltip="isReady ? 'Extension Connected' : 'Extension Not Connected'">
<Plug v-if="isReady" size="18" /> <Plug v-if="isReady" size="18" />
<Plus v-else size="18" /> <Plus v-else size="18" />
</div> </div>
@@ -38,7 +56,14 @@ const showModal = ref(false)
With the extension, you can capture and process content even when you're working in other apps. With the extension, you can capture and process content even when you're working in other apps.
</p> </p>
<div class="modal-actions"> <div class="modal-actions">
<a href="#" class="btn-neon" @click.prevent>Extension Coming Soon</a> <a
href="https://chromewebstore.google.com/detail/tools-app-extension/bhcpbmfncohogehbhebiffcgjcndnneg"
target="_blank"
rel="noopener noreferrer"
class="btn-neon"
>
Install Extension
</a>
</div> </div>
</div> </div>
@@ -51,7 +76,7 @@ const showModal = ref(false)
The extension is active and ready to process your clipboard in the background. The extension is active and ready to process your clipboard in the background.
</p> </p>
<div class="modal-actions"> <div class="modal-actions">
<button class="btn-neon" @click="showModal = false">Got it!</button> <button class="btn-neon" @click="showModal = false" v-ripple>Got it!</button>
</div> </div>
</div> </div>
</div> </div>
@@ -102,19 +127,18 @@ const showModal = ref(false)
padding: 1rem; padding: 1rem;
} }
:global(:root[data-theme="light"]) .modal-overlay {
background: rgba(255, 255, 255, 0.3);
}
.modal-content { .modal-content {
background: var(--glass-bg);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid var(--glass-border);
border-radius: 16px; border-radius: 16px;
padding: 2.5rem; /* Większy padding */ padding: 2.5rem;
max-width: 500px; max-width: 500px;
width: 90%; width: 90%;
position: relative; position: relative;
box-shadow: var(--glass-shadow);
text-align: center; text-align: center;
color: var(--text-color); /* Wymuś kolor tekstu */ color: var(--text-color);
} }
.close-btn { .close-btn {

View File

@@ -0,0 +1,110 @@
import { ref, watch, onUnmounted } from 'vue'
export function useCamera(videoRef) {
const stream = ref(null)
const facingMode = ref('environment')
const hasMultipleCameras = ref(false)
const isMirrored = ref(false)
const error = ref('')
const checkCameras = async () => {
try {
if (!navigator.mediaDevices || !navigator.mediaDevices.enumerateDevices) {
return
}
const devices = await navigator.mediaDevices.enumerateDevices()
const cameras = devices.filter(d => d.kind === 'videoinput')
hasMultipleCameras.value = cameras.length > 1
} catch (e) {
console.error('Error checking cameras:', e)
}
}
const stopCamera = () => {
if (stream.value) {
stream.value.getTracks().forEach(t => t.stop())
stream.value = null
}
}
const startCamera = async () => {
stopCamera()
error.value = ''
try {
const constraints = {
video: {
facingMode: facingMode.value,
width: { ideal: 1280 },
height: { ideal: 720 }
}
}
const mediaStream = await navigator.mediaDevices.getUserMedia(constraints)
stream.value = mediaStream
// Detect actual facing mode to mirror front camera correctly
const videoTrack = mediaStream.getVideoTracks()[0]
if (videoTrack) {
const settings = videoTrack.getSettings()
if (settings.facingMode) {
isMirrored.value = settings.facingMode === 'user'
} else {
// Fallback: check label for desktop cameras or assume requested mode
const label = videoTrack.label ? videoTrack.label.toLowerCase() : ''
if (label.includes('front') || label.includes('facetime') || label.includes('macbook')) {
isMirrored.value = true
} else {
isMirrored.value = facingMode.value === 'user'
}
}
}
if (videoRef.value) {
videoRef.value.srcObject = mediaStream
return new Promise((resolve) => {
videoRef.value.onloadedmetadata = () => {
videoRef.value.play().catch(e => console.error('Play error', e))
resolve()
}
})
}
} catch (err) {
if (err.name === 'NotAllowedError') {
error.value = 'Camera permission denied'
} else if (err.name === 'NotFoundError') {
error.value = 'No camera found'
} else {
error.value = `Camera error: ${err.name}`
}
throw err // Let caller know it failed
}
}
const switchCamera = () => {
facingMode.value = facingMode.value === 'environment' ? 'user' : 'environment'
}
watch(facingMode, () => {
if (stream.value) {
// Re-start if already running
startCamera().catch(() => { })
}
})
onUnmounted(() => {
stopCamera()
})
return {
stream,
facingMode,
hasMultipleCameras,
isMirrored,
error,
checkCameras,
startCamera,
stopCamera,
switchCamera
}
}

View File

@@ -31,7 +31,7 @@ export function useExtension() {
extensionCheckInterval = setInterval(() => { extensionCheckInterval = setInterval(() => {
window.postMessage({ type: 'TOOLS_APP_PING' }, '*') window.postMessage({ type: 'TOOLS_APP_PING' }, '*')
if (Date.now() - lastPongTime > TIMEOUT_THRESHOLD) { if (Date.now() - lastPongTime > TIMEOUT_THRESHOLD) {
isExtensionReady.value = false isExtensionReady.value = false
} }
}, PING_INTERVAL) }, PING_INTERVAL)
} }

View File

@@ -1,4 +1,4 @@
import { onMounted, onUnmounted, ref, nextTick } from 'vue' import { onMounted, onUnmounted, ref, nextTick, watch } from 'vue'
import { UI_CONFIG } from '../config/ui' import { UI_CONFIG } from '../config/ui'
export function useFillHeight(elementRef, extraMargin = 0) { export function useFillHeight(elementRef, extraMargin = 0) {
@@ -29,6 +29,13 @@ export function useFillHeight(elementRef, extraMargin = 0) {
// Also update after a short delay to ensure layout is settled (e.g. sidebar transitions) // Also update after a short delay to ensure layout is settled (e.g. sidebar transitions)
setTimeout(updateHeight, 300) setTimeout(updateHeight, 300)
// Watch for element appearing (v-if) or changing
watch(elementRef, () => {
nextTick(updateHeight)
// Additional update for layout stability
setTimeout(updateHeight, 100)
})
}) })
onUnmounted(() => { onUnmounted(() => {

View File

@@ -0,0 +1,174 @@
import { ref, onMounted, onUnmounted } from 'vue'
export function useQrDetection(videoRef, overlayCanvasRef) {
let barcodeDetector = null // must be plain variable, NOT a Vue ref (Proxy breaks native private fields)
const isDetecting = ref(false)
const error = ref('')
let scanRafId = null
// Function to initialize detector
const initDetector = async () => {
if (!barcodeDetector) {
if ('BarcodeDetector' in window) {
try {
const formats = await window.BarcodeDetector.getSupportedFormats()
if (formats.includes('qr_code')) {
barcodeDetector = new window.BarcodeDetector({ formats: ['qr_code'] })
} else {
barcodeDetector = new window.BarcodeDetector()
}
} catch (e) {
barcodeDetector = new window.BarcodeDetector()
}
} else {
error.value = 'Barcode Detection API not supported on this device/browser.'
}
}
}
const paintDetections = (codes) => {
const canvas = overlayCanvasRef.value
const video = videoRef.value
if (!canvas || !video) return
const ctx = canvas.getContext('2d')
const { width, height } = canvas.getBoundingClientRect()
// Update canvas size if needed (to match CSS size)
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width
canvas.height = height
}
ctx.clearRect(0, 0, width, height)
if (!codes || codes.length === 0) return
const vw = video.videoWidth
const vh = video.videoHeight
if (!vw || !vh) return
// Calculate object-fit: cover scaling
const videoRatio = vw / vh
const canvasRatio = width / height
let drawWidth, drawHeight, startX, startY
if (canvasRatio > videoRatio) {
// Canvas is wider than video (video cropped top/bottom)
drawWidth = width
drawHeight = width / videoRatio
startX = 0
startY = (height - drawHeight) / 2
} else {
// Canvas is taller than video (video cropped left/right)
drawHeight = height
drawWidth = height * videoRatio
startY = 0
startX = (width - drawWidth) / 2
}
const scale = drawWidth / vw
// Styles
const styles = getComputedStyle(document.documentElement)
const accent = styles.getPropertyValue('--primary-accent').trim() || '#00f2fe'
ctx.lineWidth = 4
ctx.strokeStyle = accent
ctx.fillStyle = accent
codes.forEach(code => {
const points = code.cornerPoints
if (!points || points.length < 4) return
ctx.beginPath()
const transform = (p) => {
let x = p.x * scale + startX
let y = p.y * scale + startY
return { x, y }
}
const p0 = transform(points[0])
ctx.moveTo(p0.x, p0.y)
for (let i = 1; i < points.length; i++) {
const p = transform(points[i])
ctx.lineTo(p.x, p.y)
}
ctx.closePath()
ctx.stroke()
// Draw corners
points.forEach(p => {
const tp = transform(p)
ctx.beginPath()
ctx.arc(tp.x, tp.y, 4, 0, Math.PI * 2)
ctx.fill()
})
})
}
const startDetection = async (onDetectCallback) => {
error.value = ''
try {
await initDetector()
if (!barcodeDetector) {
if (!error.value) error.value = 'Barcode Detector failed to initialize'
return
}
isDetecting.value = true
const detectLoop = async () => {
const video = videoRef.value
if (!isDetecting.value) return
if (!video || video.readyState < 2) {
scanRafId = requestAnimationFrame(detectLoop)
return
}
try {
const codes = await barcodeDetector.detect(video)
paintDetections(codes)
if (codes.length > 0 && onDetectCallback) {
onDetectCallback(codes)
}
} catch (e) {
// Silent catch for intermittent detection frames failing
}
if (isDetecting.value) {
scanRafId = requestAnimationFrame(detectLoop)
}
}
detectLoop() // start loop
} catch (e) {
error.value = `Detection error: ${e.message}`
}
}
const stopDetection = () => {
isDetecting.value = false
if (scanRafId) cancelAnimationFrame(scanRafId)
// Clear canvas
if (overlayCanvasRef.value) {
const ctx = overlayCanvasRef.value.getContext('2d')
ctx.clearRect(0, 0, overlayCanvasRef.value.width, overlayCanvasRef.value.height)
}
}
onUnmounted(() => {
stopDetection()
})
return {
error,
isDetecting,
startDetection,
stopDetection
}
}

View File

@@ -0,0 +1,34 @@
import { reactive } from 'vue'
export const tooltipState = reactive({
isVisible: false,
text: '',
targetRect: null
})
export function showTooltip(el, text) {
if (!text) return;
if (!el.hasAttribute('aria-label')) {
el.setAttribute('aria-label', text);
}
const rect = el.getBoundingClientRect();
tooltipState.targetRect = {
top: rect.top,
left: rect.left,
width: rect.width,
bottom: rect.bottom
};
tooltipState.text = text;
tooltipState.isVisible = true;
}
// Hide tooltip on any scroll event to avoid floating detached tooltips
if (typeof window !== 'undefined') {
window.addEventListener('scroll', hideTooltip, { passive: true, capture: true })
}
export function hideTooltip() {
tooltipState.isVisible = false;
}

View File

@@ -0,0 +1,115 @@
import { useLocalStorage } from './useLocalStorage'
export function useUrlCleaner() {
const cleanedHistory = useLocalStorage('url-cleaner-history', [])
const isWatchEnabled = useLocalStorage('url-cleaner-watch-enabled', false)
const defaultExceptions = [
{ id: 'yt', domainPattern: '*.youtube.com', keepParams: ['v', 't'], keepHash: false, keepAllParams: false, isEnabled: true, isDefault: true },
{ id: 'yt-short', domainPattern: 'youtu.be', keepParams: ['t'], keepHash: false, keepAllParams: false, isEnabled: true, isDefault: true }
]
const exceptions = useLocalStorage('url-cleaner-exceptions', defaultExceptions)
const matchDomain = (pattern, domain) => {
// Escape regex chars except *
const regexString = '^' + pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') + '$'
return new RegExp(regexString, 'i').test(domain)
}
const processUrl = (text, autoClipboard = false, writeClipboardFn = null) => {
try {
// Basic URL validation
if (!text.match(/^https?:\/\//i)) {
if (autoClipboard) return text
}
const originalLength = text.length
let cleanedUrl = text
try {
const urlObj = new URL(text)
const hostname = urlObj.hostname
const matchedRule = exceptions.value.find(rule =>
rule.isEnabled && matchDomain(rule.domainPattern, hostname)
)
if (matchedRule) {
if (!matchedRule.keepAllParams) {
const params = new URLSearchParams(urlObj.search)
const keys = Array.from(params.keys())
for (const key of keys) {
if (!matchedRule.keepParams.includes(key)) {
params.delete(key)
}
}
urlObj.search = params.toString()
}
if (!matchedRule.keepHash) {
urlObj.hash = ''
}
} else {
if (urlObj.search || urlObj.hash) {
urlObj.search = ''
urlObj.hash = ''
}
}
cleanedUrl = urlObj.toString()
} catch (e) {
return text
}
if (cleanedUrl === text && autoClipboard) {
return text
}
const newLength = cleanedUrl.length
const savedChars = originalLength - newLength
const savedPercent = originalLength > 0 ? Math.round((savedChars / originalLength) * 100) : 0
const entry = {
id: Date.now(),
original: text,
cleaned: cleanedUrl,
savedPercent,
timestamp: new Date().toLocaleTimeString()
}
cleanedHistory.value.unshift(entry)
if (cleanedHistory.value.length > 50) {
cleanedHistory.value.pop()
}
if (autoClipboard && savedChars > 0 && writeClipboardFn) {
writeClipboardFn(cleanedUrl)
}
return cleanedUrl
} catch (e) {
console.error('Error processing URL:', e)
return text
}
}
const removeEntry = (id) => {
cleanedHistory.value = cleanedHistory.value.filter(item => item.id !== id)
}
const clearHistory = () => {
cleanedHistory.value = []
}
return {
cleanedHistory,
isWatchEnabled,
exceptions,
defaultExceptions,
processUrl,
removeEntry,
clearHistory
}
}

View File

@@ -19,7 +19,7 @@ const Ripple = {
// Allow custom color via directive value // Allow custom color via directive value
if (binding.value && typeof binding.value === 'string') { if (binding.value && typeof binding.value === 'string') {
circle.style.backgroundColor = binding.value; circle.style.backgroundColor = binding.value;
} }
el.appendChild(circle); el.appendChild(circle);

28
src/directives/tooltip.js Normal file
View File

@@ -0,0 +1,28 @@
import { showTooltip, hideTooltip, tooltipState } from '../composables/useTooltip'
export const tooltipDirective = {
mounted(el, binding) {
el._tooltipText = binding.value;
el.addEventListener('mouseenter', () => showTooltip(el, el._tooltipText));
el.addEventListener('mouseleave', hideTooltip);
el.addEventListener('focus', () => showTooltip(el, el._tooltipText));
el.addEventListener('blur', hideTooltip);
},
updated(el, binding) {
el._tooltipText = binding.value;
if (tooltipState.isVisible && tooltipState.text !== binding.value) {
if (el.matches(':hover') || document.activeElement === el) {
showTooltip(el, binding.value);
}
}
},
unmounted(el) {
el.removeEventListener('mouseenter', () => showTooltip(el, el._tooltipText));
el.removeEventListener('mouseleave', hideTooltip);
el.removeEventListener('focus', () => showTooltip(el, el._tooltipText));
el.removeEventListener('blur', hideTooltip);
hideTooltip();
}
};

View File

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

View File

@@ -34,7 +34,7 @@ const routes = [
component: QrScanner component: QrScanner
}, },
{ {
path: '/qr-code', path: '/qr-code/:payload?',
name: 'QrCode', name: 'QrCode',
component: QrCode component: QrCode
}, },

View File

@@ -1,7 +1,10 @@
/* Box sizing reset */ /* Box sizing reset */
*, *::before, *::after { *,
*::before,
*::after {
box-sizing: border-box; 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;
@@ -41,13 +44,9 @@
--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); --list-hover-bg: rgba(255, 255, 255, 0.05);
--list-border: rgba(255, 255, 255, 0.12);
--header-bg: rgba(0, 0, 0, 0.6); --header-bg: rgba(0, 0, 0, 0.6);
color: var(--text-color);
background-color: #242424; /* Fallback */
background: var(--bg-gradient);
background-attachment: fixed;
font-synthesis: none; font-synthesis: none;
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
@@ -55,37 +54,38 @@
} }
:root[data-theme="light"] { :root[data-theme="light"] {
--bg-gradient: radial-gradient(circle at center, #ffffff 0%, #e5e7eb 100%); --bg-gradient: radial-gradient(circle at center, #ffffff 0%, #ddd 100%);
--glass-bg: rgba(255, 255, 255, 0.75); --glass-bg: rgba(255, 255, 255, 0.45);
--glass-border: rgba(15, 23, 42, 0.12); --glass-border: rgba(15, 23, 42, 0.2);
--glass-shadow: 0 8px 32px 0 rgba(15, 23, 42, 0.12); --glass-shadow: 0 8px 32px 0 rgba(30, 41, 59, 0.15);
--text-color: #000000; --text-color: #0f172a;
--text-strong: #000000; --text-strong: #020617;
--text-secondary: #000000; --text-secondary: #334155;
--text-muted: rgba(0, 0, 0, 0.7); --text-muted: #64748b;
--accent-cyan: #0ea5e9; --accent-cyan: #0ea5e9;
--accent-purple: #6366f1; --accent-purple: #6366f1;
--primary-accent: #0ea5e9; --primary-accent: #0ea5e9;
--title-glow: rgba(14, 165, 233, 0.35); --title-glow: rgba(14, 165, 233, 0.35);
--toggle-bg: rgba(255, 255, 255, 0.85); --toggle-bg: rgba(255, 255, 255, 1);
--toggle-border: rgba(15, 23, 42, 0.12); --toggle-border: rgba(15, 23, 42, 0.2);
--toggle-shadow: 0 8px 20px rgba(15, 23, 42, 0.12); --toggle-shadow: 0 4px 12px rgba(15, 23, 42, 0.1);
--toggle-btn-border: rgba(15, 23, 42, 0.18); --toggle-btn-border: rgba(15, 23, 42, 0.15);
--toggle-hover-border: rgba(15, 23, 42, 0.5); --toggle-hover-border: rgba(14, 165, 233, 0.6);
--toggle-active-shadow: 0 0 12px rgba(14, 165, 233, 0.25); --toggle-active-shadow: 0 0 12px rgba(14, 165, 233, 0.25);
--panel-bg: rgba(255, 255, 255, 0.7); --panel-bg: rgba(255, 255, 255, 0.9);
--panel-border: rgba(15, 23, 42, 0.12); --panel-border: rgba(15, 23, 42, 0.12);
--panel-shadow: 0 12px 24px rgba(15, 23, 42, 0.12); --panel-shadow: 0 12px 24px rgba(15, 23, 42, 0.12);
--button-bg: rgba(255, 255, 255, 0.85); --button-bg: rgba(255, 255, 255, 0.7);
--button-border: rgba(15, 23, 42, 0.16); --button-border: rgba(255, 255, 255, 0.9);
--button-text: #0f172a; --button-text: #0f172a;
--button-hover-bg: rgba(0, 0, 0, 0.05); --button-hover-bg: rgba(15, 23, 42, 0.1);
--button-hover-shadow: 0 6px 18px rgba(15, 23, 42, 0.18); --button-hover-shadow: 0 6px 18px rgba(15, 23, 42, 0.18);
--button-active-shadow: 0 0 18px rgba(14, 165, 233, 0.25); --button-active-shadow: 0 0 18px rgba(14, 165, 233, 0.25);
--title-gradient: linear-gradient(45deg, #0ea5e9, #6366f1); --title-gradient: linear-gradient(45deg, #0ea5e9, #6366f1);
--ripple-color: rgba(0, 0, 0, 0.1); --ripple-color: rgba(0, 0, 0, 0.1);
--list-hover-bg: rgba(0, 0, 0, 0.05); --list-hover-bg: rgba(15, 23, 42, 0.05);
--header-bg: rgba(255, 255, 255, 0.9); --list-border: rgba(15, 23, 42, 0.08);
--header-bg: rgba(255, 255, 255, 0.6);
} }
body { body {
@@ -96,6 +96,12 @@ body {
overflow-x: hidden; overflow-x: hidden;
user-select: none; user-select: none;
-webkit-user-select: none; -webkit-user-select: none;
color: var(--text-color);
background-color: var(--bg-gradient);
/* fallback but works if variable contains simple color */
background: var(--bg-gradient);
background-attachment: fixed;
} }
.selectable { .selectable {
@@ -131,12 +137,16 @@ body {
display: flex; display: flex;
justify-content: center; justify-content: center;
width: 100%; width: 100%;
max-width: 800px; max-width: 100%;
margin: 0 auto; margin: 0 auto;
height: 100%; height: 100%;
padding: 0.5rem; padding: 0.5rem;
} }
.tool-container.full-width {
flex-direction: column;
}
.tool-panel { .tool-panel {
width: 100%; width: 100%;
padding: 1rem; padding: 1rem;
@@ -144,6 +154,7 @@ body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1.5rem; gap: 1.5rem;
height: 100%;
max-height: 100%; max-height: 100%;
overflow-y: auto; overflow-y: auto;
background: var(--glass-bg); background: var(--glass-bg);
@@ -174,7 +185,7 @@ body {
.tool-title { .tool-title {
margin: 0; margin: 0;
text-align: center; text-align: left;
font-size: 1.5rem; font-size: 1.5rem;
font-weight: 600; font-weight: 600;
background: var(--title-gradient); background: var(--title-gradient);
@@ -185,40 +196,51 @@ body {
filter: drop-shadow(0 0 10px var(--title-glow)); filter: drop-shadow(0 0 10px var(--title-glow));
} }
:root[data-theme="light"] .tool-title {
/* background: none !important;
-webkit-background-clip: unset !important;
background-clip: unset !important;
color: #000000 !important;
-webkit-text-fill-color: #000000 !important;
filter: none !important; */
}
.tool-textarea {
.tool-textarea,
.select-input {
width: 100%; width: 100%;
height: 100%; padding: 0.75rem 1rem;
padding: 1rem; background-color: var(--toggle-bg);
background-color: rgba(0, 0, 0, 0.2) !important;
border: 1px solid var(--toggle-border); border: 1px solid var(--toggle-border);
border-radius: 12px; border-radius: 8px;
color: #ffffff !important; /* Explicit white color for dark mode */ color: var(--text-color);
font-family: monospace; font-family: inherit;
font-size: 1rem; font-size: 0.95rem;
line-height: 1.6;
resize: none;
transition: all 0.3s ease; transition: all 0.3s ease;
box-sizing: border-box; box-sizing: border-box;
} }
:root[data-theme="light"] .tool-textarea { ::placeholder {
color: #000000 !important; color: var(--text-muted);
background-color: rgba(255, 255, 255, 0.5) !important; opacity: 1;
/* Override Firefox default opacity */
} }
.tool-textarea:focus { .tool-textarea {
font-family: monospace;
resize: none;
height: 100%;
}
.select-input {
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'%3E%3C/path%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.75rem center;
background-size: 1rem;
padding-right: 2.5rem;
height: 40px;
cursor: pointer;
line-height: 1;
}
.tool-textarea:focus,
.select-input:focus {
outline: none; outline: none;
border-color: #00f2fe !important; /* Force cyan accent */ border-color: var(--primary-accent) !important;
box-shadow: 0 0 0 1px #00f2fe !important; box-shadow: 0 0 0 1px var(--primary-accent) !important;
} }
.result-area { .result-area {
@@ -257,9 +279,10 @@ body {
background: var(--button-bg); background: var(--button-bg);
border: 1px solid var(--button-border); border: 1px solid var(--button-border);
color: var(--button-text); color: var(--button-text);
padding: 8px 16px; padding: 0 1.25rem;
height: 40px;
border-radius: 8px; border-radius: 8px;
font-weight: 600; font-weight: 500;
cursor: pointer; cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
@@ -267,7 +290,8 @@ body {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 8px; gap: 8px;
outline: none; /* Remove focus outline */ outline: none;
/* Remove focus outline */
} }
/* Global button styles */ /* Global button styles */
@@ -288,6 +312,24 @@ button:focus {
border-color: var(--accent-cyan); border-color: var(--accent-cyan);
} }
.btn-neon.primary {
background: var(--primary-accent);
color: #000;
border-color: var(--primary-accent);
font-weight: 600;
}
.btn-neon.primary:hover {
background: var(--primary-accent);
opacity: 0.9;
box-shadow: 0 0 20px rgba(0, 242, 255, 0.4);
}
:root[data-theme="light"] .btn-neon.primary:hover {
box-shadow: 0 0 18px rgba(14, 165, 233, 0.4);
}
.btn-neon:active { .btn-neon:active {
transform: translateY(1px); transform: translateY(1px);
box-shadow: var(--button-active-shadow); box-shadow: var(--button-active-shadow);
@@ -346,10 +388,320 @@ button:focus {
outline: none; outline: none;
} }
/* Custom focus ring for all form elements */ button:focus-visible,
a:focus-visible {
outline: 2px solid var(--primary-accent);
outline-offset: 2px;
border-radius: 4px;
}
input:focus, input:focus,
select:focus, select:focus,
textarea:focus { textarea:focus,
border-color: var(--primary-accent); .number-control:focus-within {
box-shadow: 0 0 0 1px var(--primary-accent); border-color: var(--primary-accent) !important;
box-shadow: 0 0 0 1px var(--primary-accent) !important;
transition: border-color 0.2s, box-shadow 0.2s;
}
/* --- Global Checkbox Styles --- */
.checkbox-label {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
user-select: none;
color: var(--text-secondary);
font-weight: 500;
white-space: nowrap;
transition: color 0.2s;
}
.checkbox-label:hover {
color: var(--text-color);
}
.checkbox-label input {
position: absolute;
opacity: 0;
cursor: pointer;
height: 0;
width: 0;
}
.checkmark {
position: relative;
height: 20px;
width: 20px;
background-color: var(--toggle-bg);
border: 1px solid var(--toggle-border);
border-radius: 4px;
transition: all 0.3s ease;
flex-shrink: 0;
}
.checkbox-label:hover .checkmark {
border-color: var(--toggle-hover-border);
}
.checkbox-label input:checked~.checkmark {
background-color: var(--primary-accent);
border-color: var(--primary-accent);
box-shadow: 0 0 10px var(--primary-accent);
}
.checkmark:after {
content: "";
position: absolute;
display: none;
}
.checkbox-label input:checked~.checkmark:after {
display: block;
}
.checkbox-label .checkmark:after {
left: 6px;
top: 2px;
width: 5px;
height: 10px;
border: solid white;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
:root[data-theme="light"] .checkmark:after {
border-color: white;
/* Keep checkmark white even in light mode if background is primary-accent */
}
/* --- Global Header/Action Patterns --- */
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
width: 100%;
min-height: 40px;
}
.header-actions {
display: flex;
align-items: center;
gap: 0.8rem;
}
/* --- Global Icon Button Styles --- */
.icon-btn {
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
padding: 0.5rem;
border-radius: 8px;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
.icon-btn:hover {
color: var(--text-color);
background: var(--button-hover-bg);
}
.icon-btn.delete-btn:hover {
color: #ef4444;
background: rgba(239, 68, 68, 0.1);
}
:root[data-theme="light"] .icon-btn.delete-btn:hover {
color: #dc2626;
background: rgba(220, 38, 38, 0.05);
}
/* --- Component Specific Theme Overrides (Consolidated) --- */
:root[data-theme="light"] .settings-btn {
background: rgba(0, 0, 0, 0.03);
}
:root[data-theme="light"] .savings {
color: #16a34a;
font-weight: 500;
}
:root[data-theme="light"] .modal-overlay {
background: rgba(255, 255, 255, 0.3);
}
:root[data-theme="light"] .add-rule-form,
:root[data-theme="light"] .rule-item {
background: rgba(0, 0, 0, 0.04);
border: 1px solid rgba(0, 0, 0, 0.06);
}
.detail-tag {
font-size: 0.8rem;
background: var(--list-hover-bg);
border: 1px solid var(--list-border);
padding: 0.2rem 0.5rem;
border-radius: 4px;
color: var(--text-secondary);
display: flex;
align-items: center;
gap: 0.4rem;
transition: all 0.2s ease;
}
/* --- Global Number Control Styles --- */
.number-control {
display: flex;
align-items: stretch;
background: var(--toggle-bg);
border: 1px solid var(--toggle-border);
border-radius: 8px;
overflow: hidden;
height: auto;
min-height: 40px;
flex-wrap: wrap;
/* allow wrapping on very small screens */
}
.control-btn {
background: none;
border: none;
color: var(--text-color);
font-size: 0.9rem;
min-width: 40px;
padding: 0 0.5rem;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
font-weight: 400;
}
.control-btn:hover {
background: var(--button-hover-bg);
color: var(--primary-accent);
}
.number-input {
background: none;
border: none;
color: var(--text-color);
flex: 1;
min-width: 40px;
text-align: center;
font-size: 1rem;
font-weight: 400;
appearance: textfield;
padding: 0.2rem;
}
.number-input::-webkit-outer-spin-button,
.number-input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* --- Global List/History Styles --- */
.results-section,
.history-section {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: 12px;
}
.history-header,
.results-header {
padding: 1rem;
border-bottom: 1px solid var(--glass-border);
display: flex;
justify-content: space-between;
align-items: center;
}
.history-header h3,
.results-header h3 {
margin: 0;
font-size: 1.1rem;
color: var(--text-strong);
}
.history-actions,
.results-actions,
.header-actions {
display: flex;
gap: 0.5rem;
align-items: center;
}
.history-list,
.codes-list {
flex: 1;
overflow-y: auto;
padding: 0;
}
.history-item,
.code-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid var(--list-border);
transition: background 0.2s;
}
.history-item:last-child,
.code-item:last-child {
border-bottom: none;
}
.history-item:hover,
.code-item:hover {
background: var(--list-hover-bg);
}
.item-info,
.code-content {
flex: 1;
overflow: hidden;
padding-right: 1rem;
}
.item-actions {
display: flex;
gap: 0.5rem;
align-items: center;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: #4ade80;
box-shadow: 0 0 8px #4ade80;
animation: pulse 2s infinite;
}
@keyframes pulse {
0% {
opacity: 1;
}
50% {
opacity: 0.5;
}
100% {
opacity: 1;
}
} }

View File

@@ -1,107 +0,0 @@
/* Shared styles for all tools */
.tool-container {
display: flex;
justify-content: center;
width: 100%;
max-width: 800px;
margin: 0 auto;
height: 100%;
padding: 1rem;
}
.tool-panel {
width: 100%;
padding: 2rem;
border-radius: 16px;
display: flex;
flex-direction: column;
gap: 1.5rem;
max-height: 100%;
overflow-y: auto;
background: var(--glass-bg);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid var(--glass-border);
box-shadow: var(--glass-shadow);
}
/* Custom scrollbar for tool panel */
.tool-panel::-webkit-scrollbar {
width: 8px;
}
.tool-panel::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.1);
border-radius: 4px;
}
.tool-panel::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
border-radius: 4px;
}
.tool-panel::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.3);
}
.tool-title {
margin: 0;
text-align: center;
font-size: 1.5rem;
font-weight: 600;
background: var(--title-gradient);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
-webkit-text-fill-color: transparent;
filter: drop-shadow(0 0 10px var(--title-glow));
}
:root[data-theme="light"] .tool-title {
background: none;
-webkit-background-clip: unset;
background-clip: unset;
color: #000000;
-webkit-text-fill-color: #000000;
filter: none;
}
.tool-textarea {
width: 100%;
height: 100%;
padding: 1rem;
background-color: rgba(0, 0, 0, 0.2);
border: 1px solid var(--toggle-border);
border-radius: 12px;
color: #ffffff; /* Explicit white color for dark mode */
font-family: monospace;
font-size: 1rem;
line-height: 1.6;
resize: none;
transition: all 0.3s ease;
box-sizing: border-box;
}
:root[data-theme="light"] .tool-textarea {
color: #000000;
}
.tool-textarea:focus {
outline: none;
border-color: #00f2fe; /* Force cyan accent */
box-shadow: 0 0 0 1px #00f2fe;
}
.result-area {
display: flex;
flex-direction: column;
flex: 1;
min-height: 200px;
}
.result-area label {
margin-bottom: 0.5rem;
font-weight: 600;
color: var(--text-secondary);
}

View File

@@ -0,0 +1,66 @@
import QRCode from 'qrcode'
self.onmessage = async (e) => {
const { id, text, ecc, isBgTransparent, bgType, bgColor1, bgColor2, bgGradPos, fgType, fgColor1, fgColor2, fgGradPos } = e.data
if (!text) {
self.postMessage({ id, svgContent: '' })
return
}
try {
let svgContent = await QRCode.toString(text, {
type: 'svg',
errorCorrectionLevel: ecc,
margin: 1,
color: {
dark: fgType === 'solid' ? fgColor1 : '#000000',
light: isBgTransparent ? '#00000000' : (bgType === 'solid' ? bgColor1 : '#00000000')
}
})
let defsHtml = ''
if (fgType !== 'solid') {
const isLinear = fgType === 'linear'
const pos = fgGradPos || { x1: 0, y1: 0, x2: 100, y2: 100 }
const r = Math.sqrt(Math.pow(pos.x2 - pos.x1, 2) + Math.pow(pos.y2 - pos.y1, 2))
defsHtml += isLinear
? `<linearGradient id="qr-fg-grad" x1="${pos.x1}%" y1="${pos.y1}%" x2="${pos.x2}%" y2="${pos.y2}%"><stop offset="0%" stop-color="${fgColor1}" /><stop offset="100%" stop-color="${fgColor2}" /></linearGradient>`
: `<radialGradient id="qr-fg-grad" cx="${pos.x1}%" cy="${pos.y1}%" r="${r}%"><stop offset="0%" stop-color="${fgColor1}" /><stop offset="100%" stop-color="${fgColor2}" /></radialGradient>`
}
if (!isBgTransparent && bgType !== 'solid') {
const isLinear = bgType === 'linear'
const pos = bgGradPos || { x1: 0, y1: 0, x2: 100, y2: 100 }
const r = Math.sqrt(Math.pow(pos.x2 - pos.x1, 2) + Math.pow(pos.y2 - pos.y1, 2))
defsHtml += isLinear
? `<linearGradient id="qr-bg-grad" x1="${pos.x1}%" y1="${pos.y1}%" x2="${pos.x2}%" y2="${pos.y2}%"><stop offset="0%" stop-color="${bgColor1}" /><stop offset="100%" stop-color="${bgColor2}" /></linearGradient>`
: `<radialGradient id="qr-bg-grad" cx="${pos.x1}%" cy="${pos.y1}%" r="${r}%"><stop offset="0%" stop-color="${bgColor1}" /><stop offset="100%" stop-color="${bgColor2}" /></radialGradient>`
}
if (defsHtml) {
svgContent = svgContent.replace('shape-rendering="crispEdges">', `shape-rendering="crispEdges"><defs>${defsHtml}</defs>`)
}
if (fgType !== 'solid') {
// qrcode outputs <path stroke="#000000"...> so it's safe to replace
svgContent = svgContent.replace(/stroke="#000000"/g, 'stroke="url(#qr-fg-grad)"')
}
if (!isBgTransparent && bgType !== 'solid') {
// Find viewBox to inject background rect
const viewBoxMatch = svgContent.match(/viewBox="0 0 (\d+) (\d+)"/)
if (viewBoxMatch) {
const w = viewBoxMatch[1]
const h = viewBoxMatch[2]
// Inject a rect immediately inside the svg
svgContent = svgContent.replace('</defs>', `</defs><rect width="${w}" height="${h}" fill="url(#qr-bg-grad)" />`)
}
}
self.postMessage({ id, svgContent })
} catch (err) {
self.postMessage({ id, error: err.message })
}
}