15 Commits

Author SHA1 Message Date
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
24 changed files with 2027 additions and 1082 deletions

6
.gitignore vendored
View File

@@ -27,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

4
.husky/pre-commit Executable file
View File

@@ -0,0 +1,4 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npm run lint

View File

@@ -27,7 +27,7 @@ Clean tracking parameters and clutter from URLs.
- **Customizable:** Manage exceptions to keep specific parameters. - **Customizable:** Manage exceptions to keep specific parameters.
- **Smart:** "Direct Clean" mode for quick single-URL cleaning. - **Smart:** "Direct Clean" mode for quick single-URL cleaning.
### QR Generator ### QR Generator
Create custom QR codes instantly. Create custom QR codes instantly.
- **Customizable:** Adjustable error correction level and size. - **Customizable:** Adjustable error correction level and size.
- **Download:** Save as PNG (raster) or SVG (vector) for high-quality printing. - **Download:** Save as PNG (raster) or SVG (vector) for high-quality printing.

49
eslint.config.js Normal file
View File

@@ -0,0 +1,49 @@
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',
}
},
{
// Global ignores
ignores: [
'dist/**',
'dev-dist/**',
'node_modules/**',
'public/**',
'scripts/pack_crx.js',
'src/app.config.mjs'
]
}
]

857
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,16 @@
{ {
"name": "tools-app", "name": "tools-app",
"private": true, "private": true,
"version": "0.6.12", "version": "0.6.19",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"postinstall": "mkdir -p public/wasm && cp node_modules/zxing-wasm/dist/reader/zxing_reader.wasm public/wasm/" "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": {
"barcode-detector": "^3.1.0", "barcode-detector": "^3.1.0",
@@ -18,7 +21,12 @@
"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"
} }

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

@@ -76,6 +76,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 +96,7 @@ onUnmounted(() => {
.main-content { .main-content {
overflow: visible; overflow: visible;
height: auto; flex: 1;
padding-bottom: 1rem; padding-bottom: 1rem;
} }
} }

View File

@@ -44,7 +44,7 @@ onMounted(() => {
<line x1="3" y1="18" x2="21" y2="18"></line> <line x1="3" y1="18" x2="21" y2="18"></line>
</svg> </svg>
</button> </button>
<h1 class="app-title">Tools App</h1> <router-link to="/" class="app-title">Tools App</router-link>
</div> </div>
<button <button
@@ -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

@@ -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

@@ -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

@@ -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>
@@ -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>
@@ -149,32 +149,12 @@ const generatePasswords = () => {
</template> </template>
<style scoped> <style scoped>
.tool-container.full-width { .tool-container {
max-width: 100%;
height: 100%; height: 100%;
display: flex; display: flex;
flex-direction: column; 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 +188,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 +200,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 +207,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

@@ -98,6 +98,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">
@@ -119,35 +120,38 @@ const triggerDownload = (blob, filename) => {
<option value="H">High (30%)</option> <option value="H">High (30%)</option>
</select> </select>
</div> </div>
<div class="control-group">
<label>Size (px)</label>
<select v-model="size" class="select-input">
<option :value="150">150x150</option>
<option :value="300">300x300</option>
<option :value="500">500x500</option>
<option :value="1000">1000x1000</option>
</select>
</div>
<div class="control-group">
<label>Format</label>
<select v-model="format" class="select-input">
<option value="png">PNG</option>
<option value="jpeg">JPG</option>
<option value="webp">WebP</option>
<option value="svg">SVG</option>
</select>
</div>
</div> </div>
<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" v-html="svgContent"></div>
</div>
<div class="actions"> <div class="download-settings">
<button class="action-btn" @click="downloadFile"> <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)" title="-100">-100</button>
<button class="control-btn" @click="size = Math.max(10, size - 10)" title="-10">-10</button>
<input type="number" v-model.number="size" class="number-input" />
<button class="control-btn" @click="size += 10" title="+10">+10</button>
<button class="control-btn" @click="size += 100" title="+100">+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="action-btn download-btn" @click="downloadFile">
<Download size="18" /> <Download size="18" />
Download {{ format.toUpperCase() }} Download
</button> </button>
</div> </div>
</div> </div>
@@ -171,16 +175,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 +189,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 +211,36 @@ const triggerDownload = (blob, filename) => {
color: var(--text-secondary); color: var(--text-secondary);
} }
.select-input {
padding: 0.5rem;
border-radius: 6px;
background: rgba(0, 0, 0, 0.2);
border: 1px solid var(--glass-border);
color: var(--text-color);
min-width: 120px;
}
.preview-section { .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;
} }
: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 +251,41 @@ const triggerDownload = (blob, filename) => {
overflow: hidden; overflow: hidden;
} }
.qr-frame :deep(svg) { .download-settings {
display: block; display: flex;
gap: 1.5rem;
align-items: flex-end;
flex-wrap: wrap;
justify-content: center;
width: 100%; width: 100%;
height: 100%;
} }
.actions { .size-control {
display: flex; min-width: 280px;
gap: 1rem;
} }
.action-btn { .format-select {
display: flex; min-width: 100px !important;
align-items: center; }
gap: 0.5rem;
padding: 0.6rem 1.2rem; .download-btn {
border-radius: 6px; height: 40px;
padding: 0 1.5rem;
background: var(--primary-accent); background: var(--primary-accent);
color: #000; color: #000;
border: none; border: none;
font-weight: 500; border-radius: 8px;
font-weight: 600;
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
} }
.action-btn:hover { .download-btn:hover {
opacity: 0.9; opacity: 0.9;
transform: translateY(-1px); transform: translateY(-1px);
} }
</style> </style>

View File

@@ -1,113 +1,41 @@
<script setup> <script setup>
import { ref, onMounted, onUnmounted, watch, computed } from 'vue' import { ref, onMounted, onUnmounted, watch, computed } from 'vue'
import { SwitchCamera, Trash2, Copy, Download, X, Maximize2, Minimize2 } from 'lucide-vue-next' import { SwitchCamera, Trash2, Copy, Download, X } from 'lucide-vue-next'
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 isMirrored = ref(false)
const wrapperRef = ref(null) const wrapperRef = ref(null)
const bgCanvas = ref(null) const bgCanvas = ref(null)
let bgRafId = null let bgRafId = null
// Native scanner state
const videoRef = ref(null) const videoRef = ref(null)
let stream = null
let scanRafId = null
let barcodeDetector = null
const overlayCanvas = ref(null) const overlayCanvas = ref(null)
const paintDetections = (codes) => { const {
const canvas = overlayCanvas.value stream,
const video = videoRef.value facingMode,
hasMultipleCameras,
isMirrored,
error: cameraError,
checkCameras,
startCamera,
stopCamera,
switchCamera: baseSwitchCamera
} = useCamera(videoRef)
if (!canvas || !video) return const {
error: detectionError,
isDetecting,
startDetection,
stopDetection
} = useQrDetection(videoRef, overlayCanvas)
const ctx = canvas.getContext('2d') const error = computed(() => cameraError.value || detectionError.value)
const { width, height } = canvas.getBoundingClientRect()
// Update canvas size if needed (to match CSS size)
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width
canvas.height = height
}
ctx.clearRect(0, 0, width, height)
if (!codes || codes.length === 0) return
const vw = video.videoWidth
const vh = video.videoHeight
if (!vw || !vh) return
// Calculate object-fit: cover scaling
const videoRatio = vw / vh
const canvasRatio = width / height
let drawWidth, drawHeight, startX, startY
if (canvasRatio > videoRatio) {
// Canvas is wider than video (video cropped top/bottom)
drawWidth = width
drawHeight = width / videoRatio
startX = 0
startY = (height - drawHeight) / 2
} else {
// Canvas is taller than video (video cropped left/right)
drawHeight = height
drawWidth = height * videoRatio
startY = 0
startX = (width - drawWidth) / 2
}
const scale = drawWidth / vw
// Canvas is mirrored via CSS if isMirrored is true, so no manual coordinate mirroring needed
// Styles
const styles = getComputedStyle(document.documentElement)
const accent = styles.getPropertyValue('--primary-accent').trim() || '#00f2fe'
ctx.lineWidth = 4
ctx.strokeStyle = accent
ctx.fillStyle = accent
codes.forEach(code => {
const points = code.cornerPoints
if (!points || points.length < 4) return
ctx.beginPath()
const transform = (p) => {
let x = p.x * scale + startX
let y = p.y * scale + startY
return { x, y }
}
const p0 = transform(points[0])
ctx.moveTo(p0.x, p0.y)
for (let i = 1; i < points.length; i++) {
const p = transform(points[i])
ctx.lineTo(p.x, p.y)
}
ctx.closePath()
ctx.stroke()
// Draw corners
points.forEach(p => {
const tp = transform(p)
ctx.beginPath()
ctx.arc(tp.x, tp.y, 4, 0, Math.PI * 2)
ctx.fill()
})
})
}
// Background Loop
const updateVideoAspect = () => { const updateVideoAspect = () => {
if (videoRef.value && videoRef.value.videoWidth && videoRef.value.videoHeight) { if (videoRef.value && videoRef.value.videoWidth && videoRef.value.videoHeight) {
videoAspect.value = videoRef.value.videoWidth / videoRef.value.videoHeight videoAspect.value = videoRef.value.videoWidth / videoRef.value.videoHeight
@@ -136,7 +64,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
@@ -157,107 +84,7 @@ const stopBackgroundLoop = () => {
} }
} }
const initDetector = async () => { // Full screen styles
if (!barcodeDetector) {
if ('BarcodeDetector' in window) {
try {
// Formats are optional, but specifying qr_code might be faster
const formats = await window.BarcodeDetector.getSupportedFormats()
if (formats.includes('qr_code')) {
barcodeDetector = new window.BarcodeDetector({ formats: ['qr_code'] })
} else {
barcodeDetector = new window.BarcodeDetector()
}
} catch (e) {
// Fallback
barcodeDetector = new window.BarcodeDetector()
}
} else {
error.value = 'Barcode Detection API not supported'
}
}
}
const startScan = async () => {
stopScan()
error.value = ''
try {
await initDetector()
if (!barcodeDetector) {
error.value = 'Barcode Detector failed to initialize'
return
}
const constraints = {
video: {
facingMode: facingMode.value,
width: { ideal: 1280 },
height: { ideal: 720 }
}
}
stream = await navigator.mediaDevices.getUserMedia(constraints)
// Detect actual facing mode to mirror front camera correctly
const videoTrack = stream.getVideoTracks()[0]
if (videoTrack) {
const settings = videoTrack.getSettings()
if (settings.facingMode) {
isMirrored.value = settings.facingMode === 'user'
} else {
// Fallback: check label for desktop cameras or assume requested mode
const label = videoTrack.label ? videoTrack.label.toLowerCase() : ''
if (label.includes('front') || label.includes('facetime') || label.includes('macbook')) {
isMirrored.value = true
} else {
isMirrored.value = facingMode.value === 'user'
}
}
}
if (videoRef.value) {
videoRef.value.srcObject = stream
// Wait for metadata to play
videoRef.value.onloadedmetadata = () => {
videoRef.value.play().catch(e => console.error('Play error', e))
updateVideoAspect()
detectLoop()
startBackgroundLoop()
}
}
} catch (err) {
onError(err)
}
}
const stopScan = () => {
if (scanRafId) cancelAnimationFrame(scanRafId)
if (stream) {
stream.getTracks().forEach(t => t.stop())
stream = null
}
stopBackgroundLoop()
}
const detectLoop = async () => {
if (!videoRef.value || videoRef.value.paused || videoRef.value.ended) {
scanRafId = requestAnimationFrame(detectLoop)
return
}
try {
const codes = await barcodeDetector.detect(videoRef.value)
paintDetections(codes)
if (codes.length > 0) {
onDetect(codes)
}
} catch (e) {
// console.error('Detection error', e)
}
scanRafId = requestAnimationFrame(detectLoop)
}
const desktopFullscreenStyle = computed(() => { const desktopFullscreenStyle = computed(() => {
if (!isFullscreen.value) return {} if (!isFullscreen.value) return {}
const isDesktop = window.innerWidth >= 768 const isDesktop = window.innerWidth >= 768
@@ -282,48 +109,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 &&
@@ -335,33 +151,6 @@ const onDetect = (detectedCodes) => {
processCodes(validCodes) processCodes(validCodes)
} }
watch(facingMode, () => {
startScan()
})
const onError = (err) => {
if (err.name === 'NotAllowedError') {
error.value = 'Camera permission denied'
} else if (err.name === 'NotFoundError') {
error.value = 'No camera found'
} else {
error.value = `Camera error: ${err.name}`
}
}
const checkCameras = async () => {
try {
if (!navigator.mediaDevices || !navigator.mediaDevices.enumerateDevices) {
return
}
const devices = await navigator.mediaDevices.enumerateDevices()
const cameras = devices.filter(d => d.kind === 'videoinput')
hasMultipleCameras.value = cameras.length > 1
} catch (e) {
console.error('Error checking cameras:', e)
}
}
const loadHistory = () => { const loadHistory = () => {
try { try {
const saved = localStorage.getItem('qr-history') const saved = localStorage.getItem('qr-history')
@@ -377,11 +166,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()
@@ -396,12 +204,14 @@ onMounted(() => {
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener('resize', updateVideoAspect) window.removeEventListener('resize', updateVideoAspect)
window.removeEventListener('resize', startBackgroundLoop) window.removeEventListener('resize', startBackgroundLoop)
stopScan() 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 = () => {
@@ -462,6 +272,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">
@@ -565,24 +376,6 @@ const isUrl = (string) => {
flex-direction: column; 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;
flex-direction: column; flex-direction: column;
@@ -598,6 +391,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;
@@ -612,6 +409,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;
} }
@@ -668,8 +469,6 @@ const isUrl = (string) => {
transform: scaleX(-1); transform: scaleX(-1);
} }
/* front mirror canvas removed */
.error-overlay { .error-overlay {
position: absolute; position: absolute;
top: 0; top: 0;
@@ -728,18 +527,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;
@@ -750,58 +537,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 {
@@ -827,12 +569,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;
@@ -844,32 +580,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()
@@ -227,16 +122,16 @@ 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">
Clean
</button>
<button <button
class="btn-neon toggle-btn" class="btn-neon toggle-btn"
:class="{ 'active': isWatchEnabled && isExtensionReady }" :class="{ 'active': isWatchEnabled && isExtensionReady }"
@@ -317,22 +212,6 @@ onUnmounted(() => {
flex-direction: column; 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 +253,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 +276,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 +299,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 +309,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 +326,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,6 +10,24 @@ const props = defineProps({
const emit = defineEmits(['close', 'update:exceptions']) const emit = defineEmits(['close', 'update:exceptions'])
const handleKeydown = (e) => {
if (e.key === 'Escape') {
emit('close')
}
}
watch(() => props.isOpen, (isOpen) => {
if (isOpen) {
window.addEventListener('keydown', handleKeydown)
} else {
window.removeEventListener('keydown', handleKeydown)
}
})
onUnmounted(() => {
window.removeEventListener('keydown', handleKeydown)
})
const newRule = ref({ const newRule = ref({
domainPattern: '', domainPattern: '',
keepParams: '', keepParams: '',
@@ -284,10 +302,6 @@ 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); background: var(--glass-bg);
backdrop-filter: blur(16px); backdrop-filter: blur(16px);
@@ -304,10 +318,7 @@ const resetToDefault = (ruleId) => {
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 +381,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;
@@ -468,11 +474,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;
} }
@@ -594,8 +595,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,6 +13,24 @@ const props = defineProps({
}) })
const showModal = ref(false) const showModal = ref(false)
const handleKeydown = (e) => {
if (e.key === 'Escape' && showModal.value) {
showModal.value = false
}
}
watch(showModal, (isOpen) => {
if (isOpen) {
window.addEventListener('keydown', handleKeydown)
} else {
window.removeEventListener('keydown', handleKeydown)
}
})
onUnmounted(() => {
window.removeEventListener('keydown', handleKeydown)
})
</script> </script>
<template> <template>
@@ -38,7 +56,14 @@ const showModal = ref(false)
With the extension, you can capture and process content even when you're working in other apps. With the extension, you can capture and process content even when you're working in other apps.
</p> </p>
<div class="modal-actions"> <div class="modal-actions">
<a href="#" class="btn-neon" @click.prevent>Extension Coming Soon</a> <a
href="https://chromewebstore.google.com/detail/tools-app-extension/bhcpbmfncohogehbhebiffcgjcndnneg"
target="_blank"
rel="noopener noreferrer"
class="btn-neon"
>
Install Extension
</a>
</div> </div>
</div> </div>
@@ -102,19 +127,23 @@ 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); background: var(--glass-bg);
backdrop-filter: blur(16px); backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);
border: 1px solid var(--glass-border); 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); 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

@@ -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,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

@@ -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,10 +44,12 @@
--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); color: var(--text-color);
background-color: #242424; /* Fallback */ background-color: #242424;
/* Fallback */
background: var(--bg-gradient); background: var(--bg-gradient);
background-attachment: fixed; background-attachment: fixed;
@@ -55,37 +60,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, #f8fafc 0%, #e2e8f0 100%);
--glass-bg: rgba(255, 255, 255, 0.75); --glass-bg: rgba(255, 255, 255, 0.85);
--glass-border: rgba(15, 23, 42, 0.12); --glass-border: rgba(15, 23, 42, 0.12);
--glass-shadow: 0 8px 32px 0 rgba(15, 23, 42, 0.12); --glass-shadow: 0 8px 32px 0 rgba(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 {
@@ -131,7 +137,7 @@ 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;
@@ -144,6 +150,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 +181,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 +192,45 @@ 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 { .tool-textarea {
color: #000000 !important; font-family: monospace;
background-color: rgba(255, 255, 255, 0.5) !important; resize: none;
height: 100%;
} }
.tool-textarea:focus { .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 +269,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 +280,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 */
@@ -346,10 +360,295 @@ button:focus {
outline: none; outline: none;
} }
/* Custom focus ring for all form elements */
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;
}
/* --- 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);
}
/* --- 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: 40px;
}
.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: 60px;
text-align: center;
font-size: 1rem;
font-weight: 400;
appearance: textfield;
}
.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);
}