Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
1765742574
|
|||
|
5b1a50f148
|
|||
|
613604f3c4
|
|||
|
a699b432d7
|
|||
|
839a98a658
|
|||
|
fd23860bcf
|
|||
|
74c0251535
|
|||
|
06b2815dd9
|
|||
|
1346de684c
|
68
package-lock.json
generated
68
package-lock.json
generated
@@ -1,15 +1,16 @@
|
||||
{
|
||||
"name": "tools-app",
|
||||
"version": "0.4.4",
|
||||
"version": "0.6.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "tools-app",
|
||||
"version": "0.4.4",
|
||||
"version": "0.6.0",
|
||||
"dependencies": {
|
||||
"lucide-vue-next": "^0.575.0",
|
||||
"vue": "^3.5.25",
|
||||
"vue-qrcode-reader": "^5.7.3",
|
||||
"vue-router": "^5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -2514,6 +2515,18 @@
|
||||
"sourcemap-codec": "^1.4.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/dom-webcodecs": {
|
||||
"version": "0.1.18",
|
||||
"resolved": "https://registry.npmjs.org/@types/dom-webcodecs/-/dom-webcodecs-0.1.18.tgz",
|
||||
"integrity": "sha512-vAvE8C9DGWR+tkb19xyjk1TSUlJ7RUzzp4a9Anu7mwBT+fpyePWK1UxmH14tMO5zHmrnrRIMg5NutnnDztLxgg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/emscripten": {
|
||||
"version": "1.41.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.5.tgz",
|
||||
"integrity": "sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
@@ -2907,6 +2920,16 @@
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/barcode-detector": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/barcode-detector/-/barcode-detector-2.2.2.tgz",
|
||||
"integrity": "sha512-JcSekql+EV93evfzF9zBr+Y6aRfkR+QFvgyzbwQ0dbymZXoAI9+WgT7H1E429f+3RKNncHz2CW98VQtaaKpmfQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/dom-webcodecs": "^0.1.11",
|
||||
"zxing-wasm": "1.1.3"
|
||||
}
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.10.0",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
|
||||
@@ -5235,6 +5258,12 @@
|
||||
"integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sdp": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/sdp/-/sdp-3.2.1.tgz",
|
||||
"integrity": "sha512-lwsAIzOPlH8/7IIjjz3K0zYBk7aBVVcvjMwt3M4fLxpjMYyy7i3I97SLHebgn4YBjirkzfp3RvRDWSKsh/+WFw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||
@@ -6094,6 +6123,19 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vue-qrcode-reader": {
|
||||
"version": "5.7.3",
|
||||
"resolved": "https://registry.npmjs.org/vue-qrcode-reader/-/vue-qrcode-reader-5.7.3.tgz",
|
||||
"integrity": "sha512-iSGko42FsEvdHyizBMBs/X+HMO9Z5ONDxjW+mQdoraOR5emRNedmjC5SEJdYzGz8ZP5ME3lwB4iHy3S7MOt5Qw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"barcode-detector": "2.2.2",
|
||||
"webrtc-adapter": "8.2.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vue-router": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-5.0.3.tgz",
|
||||
@@ -6152,6 +6194,19 @@
|
||||
"integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/webrtc-adapter": {
|
||||
"version": "8.2.3",
|
||||
"resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-8.2.3.tgz",
|
||||
"integrity": "sha512-gnmRz++suzmvxtp3ehQts6s2JtAGPuDPjA1F3a9ckNpG1kYdYuHWYpazoAnL9FS5/B21tKlhkorbdCXat0+4xQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"sdp": "^3.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0",
|
||||
"npm": ">=3.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-url": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz",
|
||||
@@ -6619,6 +6674,15 @@
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/eemeli"
|
||||
}
|
||||
},
|
||||
"node_modules/zxing-wasm": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/zxing-wasm/-/zxing-wasm-1.1.3.tgz",
|
||||
"integrity": "sha512-MYm9k/5YVs4ZOTIFwlRjfFKD0crhefgbnt1+6TEpmKUDFp3E2uwqGSKwQOd2hOIsta/7Usq4hnpNRYTLoljnfA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/emscripten": "^1.39.10"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "tools-app",
|
||||
"private": true,
|
||||
"version": "0.4.4",
|
||||
"version": "0.6.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -11,6 +11,7 @@
|
||||
"dependencies": {
|
||||
"lucide-vue-next": "^0.575.0",
|
||||
"vue": "^3.5.25",
|
||||
"vue-qrcode-reader": "^5.7.3",
|
||||
"vue-router": "^5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -83,12 +83,12 @@ onUnmounted(() => {
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.app-body {
|
||||
overflow: hidden;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
overflow: visible;
|
||||
height: auto;
|
||||
padding-bottom: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ onMounted(() => {
|
||||
<style scoped>
|
||||
.app-header {
|
||||
/* Remove hardcoded colors and use theme variables */
|
||||
background: var(--glass-bg);
|
||||
background: var(--header-bg);
|
||||
color: var(--text-color);
|
||||
padding: 1rem;
|
||||
/* box-shadow handled by glass-panel class */
|
||||
|
||||
@@ -13,6 +13,7 @@ defineProps({
|
||||
<router-link to="/passwords" class="nav-item" v-ripple>Passwords</router-link>
|
||||
<router-link to="/clipboard-sniffer" class="nav-item" v-ripple>Clipboard Sniffer</router-link>
|
||||
<router-link to="/url-cleaner" class="nav-item" v-ripple>URL Cleaner</router-link>
|
||||
<router-link to="/qr-scanner" class="nav-item" v-ripple>QR Scanner</router-link>
|
||||
</nav>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
733
src/components/tools/QrScanner.vue
Normal file
733
src/components/tools/QrScanner.vue
Normal file
@@ -0,0 +1,733 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, watch, computed } from 'vue'
|
||||
import { QrcodeStream } from 'vue-qrcode-reader'
|
||||
import { SwitchCamera, Trash2, Copy, Download, X } from 'lucide-vue-next'
|
||||
|
||||
const error = ref('')
|
||||
const facingMode = ref('environment')
|
||||
const scannedCodes = ref([])
|
||||
const hasMultipleCameras = ref(false)
|
||||
const isFullscreen = ref(false)
|
||||
const videoAspect = ref(1)
|
||||
const isFront = computed(() => facingMode.value === 'user')
|
||||
const wrapperRef = ref(null)
|
||||
let frontMirrorObserver = null
|
||||
const bgCanvas = ref(null)
|
||||
let bgRafId = null
|
||||
const updateVideoAspect = () => {
|
||||
const videoEl = document.querySelector('.camera-wrapper video')
|
||||
if (videoEl && videoEl.videoWidth && videoEl.videoHeight) {
|
||||
videoAspect.value = videoEl.videoWidth / videoEl.videoHeight
|
||||
}
|
||||
}
|
||||
const startBackgroundLoop = () => {
|
||||
const draw = () => {
|
||||
const videoEl = document.querySelector('.camera-wrapper video')
|
||||
const canvas = bgCanvas.value
|
||||
if (!videoEl || !canvas) {
|
||||
bgRafId = requestAnimationFrame(draw)
|
||||
return
|
||||
}
|
||||
const vw = videoEl.videoWidth || 0
|
||||
const vh = videoEl.videoHeight || 0
|
||||
if (!vw || !vh) {
|
||||
bgRafId = requestAnimationFrame(draw)
|
||||
return
|
||||
}
|
||||
const cw = Math.floor(window.innerWidth)
|
||||
const ch = Math.floor(window.innerHeight * 0.5)
|
||||
if (canvas.width !== cw || canvas.height !== ch) {
|
||||
canvas.width = cw
|
||||
canvas.height = ch
|
||||
}
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (ctx) {
|
||||
// cover horizontally: scale by width, crop top/bottom
|
||||
const scale = cw / vw
|
||||
const srcH = ch / scale
|
||||
const sx = 0
|
||||
const sy = Math.max(0, (vh - srcH) / 2)
|
||||
ctx.clearRect(0, 0, cw, ch)
|
||||
ctx.drawImage(videoEl, sx, sy, vw, srcH, 0, 0, cw, ch)
|
||||
}
|
||||
bgRafId = requestAnimationFrame(draw)
|
||||
}
|
||||
if (bgRafId) cancelAnimationFrame(bgRafId)
|
||||
bgRafId = requestAnimationFrame(draw)
|
||||
}
|
||||
|
||||
// front mirror canvas removed to restore stable behavior
|
||||
const stopBackgroundLoop = () => {
|
||||
if (bgRafId) {
|
||||
cancelAnimationFrame(bgRafId)
|
||||
bgRafId = null
|
||||
}
|
||||
}
|
||||
const desktopFullscreenStyle = computed(() => {
|
||||
if (!isFullscreen.value) return {}
|
||||
const isDesktop = window.innerWidth >= 768
|
||||
if (!isDesktop) return {}
|
||||
const halfHeight = Math.floor(window.innerHeight * 0.5)
|
||||
const widthPx = Math.min(window.innerWidth, Math.floor(halfHeight * videoAspect.value))
|
||||
return { height: `${halfHeight}px`, width: `${widthPx}px`, margin: '0 auto' }
|
||||
})
|
||||
|
||||
const processCodes = (codes) => {
|
||||
codes.forEach(code => {
|
||||
const value = code.rawValue
|
||||
if (value && !scannedCodes.value.some(c => c.value === value)) {
|
||||
scannedCodes.value.unshift({
|
||||
id: Date.now() + Math.random(),
|
||||
value,
|
||||
format: code.format,
|
||||
timestamp: new Date().toLocaleTimeString()
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const onDetect = (detectedCodes) => {
|
||||
// If fullscreen, accept all detected codes (as the user sees the full camera view mostly)
|
||||
if (isFullscreen.value) {
|
||||
processCodes(detectedCodes)
|
||||
return
|
||||
}
|
||||
|
||||
// Try to find video element to calculate visible area
|
||||
const videoEl = document.querySelector('.camera-wrapper video')
|
||||
|
||||
if (!videoEl || !videoEl.videoWidth || !videoEl.videoHeight) {
|
||||
processCodes(detectedCodes)
|
||||
return
|
||||
}
|
||||
|
||||
const { videoWidth, videoHeight } = videoEl
|
||||
|
||||
// Calculate visible square area (assuming object-fit: cover and 1:1 container)
|
||||
const isLandscape = videoWidth > videoHeight
|
||||
let visibleX, visibleY, visibleW, visibleH
|
||||
|
||||
if (isLandscape) {
|
||||
// Landscape: sides are cropped, height is fully visible
|
||||
visibleH = videoHeight
|
||||
visibleW = videoHeight // Square
|
||||
visibleX = (videoWidth - videoHeight) / 2
|
||||
visibleY = 0
|
||||
} else {
|
||||
// Portrait: top/bottom are cropped, width is fully visible
|
||||
visibleW = videoWidth
|
||||
visibleH = videoWidth // Square
|
||||
visibleX = 0
|
||||
visibleY = (videoHeight - videoWidth) / 2
|
||||
}
|
||||
|
||||
// Add margin to be safe (code center must be within visible area)
|
||||
// We allow codes slightly outside if their center is inside
|
||||
const validCodes = detectedCodes.filter(code => {
|
||||
if (!code.boundingBox) return true
|
||||
const { x, y, width, height } = code.boundingBox
|
||||
const centerX = x + width / 2
|
||||
const centerY = y + height / 2
|
||||
|
||||
return (
|
||||
centerX >= visibleX &&
|
||||
centerX <= visibleX + visibleW &&
|
||||
centerY >= visibleY &&
|
||||
centerY <= visibleY + visibleH
|
||||
)
|
||||
})
|
||||
|
||||
processCodes(validCodes)
|
||||
}
|
||||
|
||||
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 = () => {
|
||||
try {
|
||||
const saved = localStorage.getItem('qr-history')
|
||||
if (saved) {
|
||||
scannedCodes.value = JSON.parse(saved)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load QR history', e)
|
||||
}
|
||||
}
|
||||
|
||||
watch(scannedCodes, (newVal) => {
|
||||
localStorage.setItem('qr-history', JSON.stringify(newVal))
|
||||
}, { deep: true })
|
||||
|
||||
onMounted(() => {
|
||||
checkCameras()
|
||||
loadHistory()
|
||||
window.addEventListener('resize', updateVideoAspect)
|
||||
window.addEventListener('resize', startBackgroundLoop)
|
||||
watch(isFullscreen, (fs) => {
|
||||
if (fs) {
|
||||
startBackgroundLoop()
|
||||
} else {
|
||||
stopBackgroundLoop()
|
||||
}
|
||||
}, { immediate: true })
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', updateVideoAspect)
|
||||
window.removeEventListener('resize', startBackgroundLoop)
|
||||
stopBackgroundLoop()
|
||||
})
|
||||
|
||||
const switchCamera = (event) => {
|
||||
event.stopPropagation()
|
||||
facingMode.value = facingMode.value === 'environment' ? 'user' : 'environment'
|
||||
}
|
||||
|
||||
const toggleFullscreen = () => {
|
||||
isFullscreen.value = !isFullscreen.value
|
||||
}
|
||||
|
||||
const clearHistory = () => {
|
||||
scannedCodes.value = []
|
||||
}
|
||||
|
||||
const removeCode = (id) => {
|
||||
scannedCodes.value = scannedCodes.value.filter(c => c.id !== id)
|
||||
}
|
||||
|
||||
const copyAll = async () => {
|
||||
if (scannedCodes.value.length === 0) return
|
||||
const text = scannedCodes.value.map(c => c.value).join('\n')
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
} catch (err) {
|
||||
console.error('Failed to copy', err)
|
||||
}
|
||||
}
|
||||
|
||||
const downloadJson = () => {
|
||||
if (scannedCodes.value.length === 0) return
|
||||
const data = JSON.stringify(scannedCodes.value, null, 2)
|
||||
const blob = new Blob([data], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `qr-scan-history-${new Date().toISOString().slice(0, 10)}.json`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const copyToClipboard = async (text) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
} catch (err) {
|
||||
console.error('Failed to copy', err)
|
||||
}
|
||||
}
|
||||
|
||||
const isUrl = (string) => {
|
||||
try {
|
||||
return Boolean(new URL(string))
|
||||
} catch (_) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tool-container full-width">
|
||||
<div class="tool-panel">
|
||||
<div class="panel-header" v-if="!isFullscreen">
|
||||
<h2 class="tool-title">QR Scanner</h2>
|
||||
</div>
|
||||
|
||||
<Teleport to="body" :disabled="!isFullscreen">
|
||||
<div class="scanner-content" :class="{ 'is-fullscreen': isFullscreen }">
|
||||
<canvas
|
||||
v-if="isFullscreen"
|
||||
ref="bgCanvas"
|
||||
class="camera-bg"
|
||||
></canvas>
|
||||
<button v-if="isFullscreen" class="close-fullscreen-btn" @click="toggleFullscreen">
|
||||
<X size="24" />
|
||||
</button>
|
||||
|
||||
<div
|
||||
class="camera-wrapper"
|
||||
:class="{ 'clickable': !isFullscreen, 'is-front': isFront }"
|
||||
:style="desktopFullscreenStyle"
|
||||
ref="wrapperRef"
|
||||
@click="!isFullscreen && toggleFullscreen()"
|
||||
>
|
||||
<QrcodeStream
|
||||
:constraints="{ facingMode }"
|
||||
@detect="onDetect"
|
||||
@error="onError"
|
||||
@camera-on="onCameraOn"
|
||||
:track="paintDetections"
|
||||
>
|
||||
<div v-if="error" class="error-overlay">
|
||||
<p>{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="hasMultipleCameras"
|
||||
class="switch-camera-btn"
|
||||
@click.stop="switchCamera"
|
||||
title="Switch Camera"
|
||||
>
|
||||
<SwitchCamera size="24" />
|
||||
</button>
|
||||
</QrcodeStream>
|
||||
</div>
|
||||
|
||||
<div class="results-section">
|
||||
<div class="results-header">
|
||||
<h3>Scanned Codes ({{ scannedCodes.length }})</h3>
|
||||
<div v-if="scannedCodes.length > 0" class="header-actions">
|
||||
<button class="icon-btn" @click="copyAll" title="Copy All">
|
||||
<Copy size="18" />
|
||||
</button>
|
||||
<button class="icon-btn" @click="downloadJson" title="Download JSON">
|
||||
<Download size="18" />
|
||||
</button>
|
||||
<button class="icon-btn delete-btn" @click="clearHistory" title="Clear All">
|
||||
<Trash2 size="18" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="scannedCodes.length > 0" class="codes-list">
|
||||
<div v-for="code in scannedCodes" :key="code.id" class="code-item">
|
||||
<div class="code-content">
|
||||
<div class="code-value">
|
||||
<a v-if="isUrl(code.value)" :href="code.value" target="_blank" rel="noopener noreferrer">{{ code.value }}</a>
|
||||
<span v-else>{{ code.value }}</span>
|
||||
</div>
|
||||
<div class="code-meta">
|
||||
<span class="timestamp">{{ code.timestamp }}</span>
|
||||
<span class="format-badge">{{ code.format }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="item-actions">
|
||||
<button class="icon-btn" @click="copyToClipboard(code.value)" title="Copy">
|
||||
<Copy size="18" />
|
||||
</button>
|
||||
<button class="icon-btn delete-btn" @click="removeCode(code.id)" title="Remove">
|
||||
<Trash2 size="18" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-state">
|
||||
Point camera at a QR code to scan
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tool-container.full-width {
|
||||
max-width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tool-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
gap: 1.5rem;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.scanner-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.scanner-content.is-fullscreen {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
background: #000;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.camera-wrapper {
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
aspect-ratio: 1;
|
||||
margin: 0 auto;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
background: #000;
|
||||
border: 1px solid var(--glass-border);
|
||||
box-shadow: var(--glass-shadow);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.camera-wrapper.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.scanner-content.is-fullscreen .camera-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
max-width: none;
|
||||
height: auto;
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
margin: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.camera-bg {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100vw;
|
||||
height: 50vh;
|
||||
filter: blur(16px) saturate(110%);
|
||||
opacity: 0.9;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* front mirror canvas removed */
|
||||
|
||||
.error-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: #ff4444;
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.switch-camera-btn {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
color: #fff;
|
||||
border-radius: 50%;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
z-index: 20;
|
||||
backdrop-filter: blur(4px);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.switch-camera-btn:hover {
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.close-fullscreen-btn {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
left: 1rem;
|
||||
z-index: 20;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
color: #fff;
|
||||
border-radius: 50%;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
/* Removed legacy scan frame overlay - using shape detection rendering via track instead */
|
||||
|
||||
.results-section {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.scanner-content.is-fullscreen .results-section {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
z-index: 2;
|
||||
border-radius: 0;
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(10px);
|
||||
border: none;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.results-header {
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid var(--glass-border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.results-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.codes-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.code-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid var(--glass-border);
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.code-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.code-item:hover {
|
||||
background: var(--list-hover-bg);
|
||||
}
|
||||
|
||||
.code-content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
|
||||
.code-value {
|
||||
color: var(--primary-accent);
|
||||
font-family: monospace;
|
||||
word-break: break-all;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.code-value a {
|
||||
color: var(--primary-accent);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.code-value a:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.code-meta {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.item-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.format-badge {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
padding: 0 0.4rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
:global(:root[data-theme="light"]) .format-badge {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
padding: 0.4rem;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
color: var(--text-color);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
:global(:root[data-theme="light"]) .icon-btn:hover {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.delete-btn:hover {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: var(--text-secondary);
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
:deep(video) {
|
||||
object-fit: cover !important;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
position: absolute !important;
|
||||
left: 0 !important;
|
||||
top: 0 !important;
|
||||
}
|
||||
|
||||
:deep(.qrcode-stream-wrapper),
|
||||
:deep(.qrcode-stream-overlay) {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
position: absolute !important;
|
||||
inset: 0 !important;
|
||||
}
|
||||
|
||||
/* Front camera mirror (CSS-only) */
|
||||
.camera-wrapper.is-front :deep(video) {
|
||||
transform: scaleX(-1);
|
||||
transform-origin: center;
|
||||
}
|
||||
.camera-wrapper.is-front :deep(#qrcode-stream-pause-frame),
|
||||
.camera-wrapper.is-front :deep(#qrcode-stream-overlay) {
|
||||
transform: scaleX(-1);
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
:deep(.scanner-content.is-fullscreen .camera-wrapper video) {
|
||||
object-fit: contain !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
import { ref, watch, onUnmounted } from 'vue'
|
||||
import { Copy, Trash2, ExternalLink, Power, Zap, X, Settings } from 'lucide-vue-next'
|
||||
import { Copy, Trash2, ExternalLink, Power, Zap, X, Settings, Download } from 'lucide-vue-next'
|
||||
import { useExtension } from '../../composables/useExtension'
|
||||
import { useLocalStorage } from '../../composables/useLocalStorage'
|
||||
import ExtensionStatus from './common/ExtensionStatus.vue'
|
||||
@@ -57,10 +57,45 @@ const toggleWatch = () => {
|
||||
isWatchEnabled.value = !isWatchEnabled.value
|
||||
}
|
||||
|
||||
const copyAllUrls = async () => {
|
||||
if (cleanedHistory.value.length === 0) return
|
||||
const text = cleanedHistory.value.map(item => item.cleaned).join('\n')
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
} catch (err) {
|
||||
console.error('Failed to copy URLs', err)
|
||||
}
|
||||
}
|
||||
|
||||
const downloadJson = () => {
|
||||
if (cleanedHistory.value.length === 0) return
|
||||
|
||||
const exportData = cleanedHistory.value.map(item => ({
|
||||
url: item.cleaned,
|
||||
original: item.original,
|
||||
timestamp: item.timestamp
|
||||
}))
|
||||
|
||||
const data = JSON.stringify(exportData, null, 2)
|
||||
const blob = new Blob([data], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `cleaned-urls-${new Date().toISOString().slice(0, 10)}.json`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
// Manual clean
|
||||
const handleClean = () => {
|
||||
if (inputUrl.value) {
|
||||
processUrl(inputUrl.value, false)
|
||||
const urls = inputUrl.value.split(/\r?\n/).filter(line => line.trim().length > 0)
|
||||
urls.forEach(url => {
|
||||
processUrl(url.trim(), false)
|
||||
})
|
||||
inputUrl.value = ''
|
||||
}
|
||||
}
|
||||
@@ -189,13 +224,13 @@ onUnmounted(() => {
|
||||
|
||||
<div class="input-section">
|
||||
<div class="input-wrapper">
|
||||
<input
|
||||
<textarea
|
||||
v-model="inputUrl"
|
||||
type="text"
|
||||
placeholder="Paste URL here to clean..."
|
||||
placeholder="Paste URL(s) here to clean..."
|
||||
class="url-input"
|
||||
@keyup.enter="handleClean"
|
||||
>
|
||||
@keydown.enter.prevent="handleClean"
|
||||
rows="1"
|
||||
></textarea>
|
||||
<button class="btn-neon" @click="handleClean">
|
||||
Clean
|
||||
</button>
|
||||
@@ -219,9 +254,17 @@ onUnmounted(() => {
|
||||
<div class="history-section" v-if="cleanedHistory.length > 0">
|
||||
<div class="history-header">
|
||||
<h3>Cleaned URLs</h3>
|
||||
<button class="icon-btn" @click="clearHistory" title="Clear History">
|
||||
<Trash2 size="18" />
|
||||
</button>
|
||||
<div class="history-actions">
|
||||
<button class="icon-btn" @click="copyAllUrls" title="Copy all URLs">
|
||||
<Copy size="18" />
|
||||
</button>
|
||||
<button class="icon-btn" @click="downloadJson" title="Download JSON">
|
||||
<Download size="18" />
|
||||
</button>
|
||||
<button class="icon-btn delete-btn" @click="clearHistory" title="Clear History">
|
||||
<Trash2 size="18" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="history-list">
|
||||
@@ -339,6 +382,9 @@ onUnmounted(() => {
|
||||
font-size: 1rem;
|
||||
outline: none;
|
||||
transition: all 0.2s;
|
||||
resize: vertical;
|
||||
min-height: 46px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.url-input:focus {
|
||||
@@ -404,10 +450,16 @@ onUnmounted(() => {
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.history-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.history-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.history-item {
|
||||
@@ -424,7 +476,7 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.history-item:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
background: var(--list-hover-bg);
|
||||
}
|
||||
|
||||
.item-info {
|
||||
|
||||
@@ -80,6 +80,15 @@ const addRule = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const editRule = (rule) => {
|
||||
newRule.value = {
|
||||
domainPattern: rule.domainPattern,
|
||||
keepParams: Array.isArray(rule.keepParams) ? rule.keepParams.join(', ') : '',
|
||||
keepHash: !!rule.keepHash,
|
||||
keepAllParams: !!rule.keepAllParams
|
||||
}
|
||||
}
|
||||
|
||||
const removeRule = (id) => {
|
||||
localExceptions.value = localExceptions.value.filter(r => r.id !== id)
|
||||
}
|
||||
@@ -145,7 +154,7 @@ const resetToDefault = (ruleId) => {
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="isOpen" class="modal-overlay" @click.self="$emit('close')">
|
||||
<div class="modal-content glass-panel">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>URL Cleaning Exceptions</h3>
|
||||
<button class="close-btn" @click="$emit('close')">
|
||||
@@ -200,7 +209,7 @@ const resetToDefault = (ruleId) => {
|
||||
|
||||
<div v-for="rule in localExceptions" :key="rule.id" class="rule-item" :class="{ disabled: !rule.isEnabled }">
|
||||
<div class="rule-info">
|
||||
<div class="rule-domain">{{ rule.domainPattern }}</div>
|
||||
<div class="rule-domain" @click="editRule(rule)" title="Click to edit">{{ rule.domainPattern }}</div>
|
||||
<div class="rule-details">
|
||||
<div class="params-list">
|
||||
<template v-if="rule.keepAllParams">
|
||||
@@ -275,6 +284,10 @@ const resetToDefault = (ruleId) => {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
:global(:root[data-theme="light"]) .modal-overlay {
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(16px);
|
||||
@@ -291,6 +304,11 @@ const resetToDefault = (ruleId) => {
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
:global(:root[data-theme="light"]) .modal-content {
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 1.5rem;
|
||||
border-bottom: 1px solid var(--glass-border);
|
||||
@@ -352,6 +370,11 @@ const resetToDefault = (ruleId) => {
|
||||
border: 1px solid var(--glass-border);
|
||||
}
|
||||
|
||||
:global(:root[data-theme="light"]) .add-rule-form {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.add-rule-form h4, .rules-list h4 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
@@ -445,6 +468,11 @@ const resetToDefault = (ruleId) => {
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
:global(:root[data-theme="light"]) .rule-item {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.rule-item.disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
@@ -457,6 +485,13 @@ const resetToDefault = (ruleId) => {
|
||||
font-weight: 600;
|
||||
color: var(--primary-accent);
|
||||
margin-bottom: 0.3rem;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.rule-domain:hover {
|
||||
opacity: 0.8;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.rule-details {
|
||||
|
||||
@@ -3,6 +3,7 @@ import Main from '../components/Main.vue'
|
||||
import Passwords from '../components/tools/Passwords.vue'
|
||||
import ClipboardSniffer from '../components/tools/ClipboardSniffer.vue'
|
||||
import UrlCleaner from '../components/tools/UrlCleaner.vue'
|
||||
import QrScanner from '../components/tools/QrScanner.vue'
|
||||
import PrivacyPolicy from '../views/PrivacyPolicy.vue'
|
||||
|
||||
const routes = [
|
||||
@@ -26,6 +27,11 @@ const routes = [
|
||||
name: 'UrlCleaner',
|
||||
component: UrlCleaner
|
||||
},
|
||||
{
|
||||
path: '/qr-scanner',
|
||||
name: 'QrScanner',
|
||||
component: QrScanner
|
||||
},
|
||||
{
|
||||
path: '/extension-privacy-policy',
|
||||
name: 'PrivacyPolicy',
|
||||
|
||||
@@ -42,6 +42,8 @@
|
||||
--title-gradient: linear-gradient(45deg, #00f2fe, #4facfe);
|
||||
--ripple-color: rgba(255, 255, 255, 0.3);
|
||||
--nav-item-weight: 400;
|
||||
--list-hover-bg: rgba(255, 255, 255, 0.05);
|
||||
--header-bg: rgba(0, 0, 0, 0.6);
|
||||
|
||||
color: var(--text-color);
|
||||
background-color: #242424; /* Fallback */
|
||||
@@ -84,6 +86,8 @@
|
||||
--button-active-shadow: 0 0 18px rgba(14, 165, 233, 0.25);
|
||||
--title-gradient: linear-gradient(45deg, #0ea5e9, #6366f1);
|
||||
--ripple-color: rgba(0, 0, 0, 0.1);
|
||||
--list-hover-bg: rgba(0, 0, 0, 0.05);
|
||||
--header-bg: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -121,6 +125,8 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
/* Removed global front camera mirror to restore stability */
|
||||
|
||||
/* --- Shared styles for all tools (moved from tools.css) --- */
|
||||
|
||||
.tool-container {
|
||||
|
||||
@@ -43,5 +43,11 @@ export default defineConfig({
|
||||
],
|
||||
define: {
|
||||
'__APP_VERSION__': JSON.stringify(packageJson.version)
|
||||
},
|
||||
server: {
|
||||
host: true,
|
||||
allowedHosts: [
|
||||
'.trycloudflare.com'
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user