5 Commits

Author SHA1 Message Date
b2e8f23d60 0.6.1
All checks were successful
Deploy to Production / deploy (push) Successful in 13s
2026-02-27 19:43:31 +00:00
d2ea9e3fc7 chore: UI spacing tweaks, desktop scrolling fix, QR title alignment 2026-02-27 19:43:12 +00:00
1765742574 0.6.0
All checks were successful
Deploy to Production / deploy (push) Successful in 13s
2026-02-27 19:03:09 +00:00
5b1a50f148 chore: prepare release; reintroduce front camera CSS mirror, stabilize QR Scanner 2026-02-27 19:02:49 +00:00
613604f3c4 feat(qr-scanner): remove frame, add shape detection overlay, improve fullscreen desktop layout 2026-02-27 17:52:36 +00:00
7 changed files with 209 additions and 46 deletions

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "tools-app",
"version": "0.5.0",
"version": "0.6.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "tools-app",
"version": "0.5.0",
"version": "0.6.1",
"dependencies": {
"lucide-vue-next": "^0.575.0",
"vue": "^3.5.25",

View File

@@ -1,7 +1,7 @@
{
"name": "tools-app",
"private": true,
"version": "0.5.0",
"version": "0.6.1",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -67,29 +67,29 @@ onUnmounted(() => {
.main-content {
flex: 1;
padding: 2rem;
padding: 1rem;
width: 100%;
max-width: 100%;
/* Space for fixed footer on mobile + extra margin (match top padding 2rem + footer height ~40px) */
padding-bottom: calc(2rem + 40px + env(safe-area-inset-bottom));
padding-bottom: calc(1rem + 40px + env(safe-area-inset-bottom));
}
@media (max-width: 640px) {
.main-content {
padding: 1rem;
padding-bottom: calc(1rem + 40px + env(safe-area-inset-bottom));
padding: 0.5rem;
padding-bottom: calc(0.5rem + 40px + env(safe-area-inset-bottom));
}
}
@media (min-width: 768px) {
.app-body {
overflow: hidden;
overflow: visible;
}
.main-content {
overflow-y: auto;
height: 100%;
padding-bottom: 2rem;
overflow: visible;
height: auto;
padding-bottom: 1rem;
}
}

View File

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

View File

@@ -16,7 +16,7 @@ const {
stopListening
} = useExtension()
const { height: textareaHeight } = useFillHeight(textareaRef, 40)
const { height: textareaHeight } = useFillHeight(textareaRef, 120)
// Watch for clipboard updates from extension
watch(lastClipboardText, (newText) => {
@@ -112,6 +112,7 @@ const clearText = () => {
justify-content: center;
gap: 1rem;
flex-wrap: wrap;
flex-shrink: 0;
}
.tool-container.full-width {
@@ -126,6 +127,9 @@ const clearText = () => {
flex-direction: column;
height: 100%;
gap: 1.5rem;
/* Override shared tool-panel scroll for this tool */
max-height: none;
overflow: hidden;
}
.tool-textarea {
@@ -154,4 +158,9 @@ const clearText = () => {
transform: translateY(-50%);
display: flex;
}
.result-area {
flex: 1;
display: flex;
}
</style>

View File

@@ -1,5 +1,5 @@
<script setup>
import { ref, onMounted, watch } from 'vue'
import { ref, onMounted, onUnmounted, watch, computed } from 'vue'
import { QrcodeStream } from 'vue-qrcode-reader'
import { SwitchCamera, Trash2, Copy, Download, X } from 'lucide-vue-next'
@@ -8,6 +8,69 @@ 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 => {
@@ -79,8 +142,59 @@ const onDetect = (detectedCodes) => {
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'
@@ -122,6 +236,21 @@ watch(scannedCodes, (newVal) => {
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) => {
@@ -191,16 +320,28 @@ const isUrl = (string) => {
<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 }" @click="!isFullscreen && toggleFullscreen()">
<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>
@@ -214,10 +355,6 @@ const isUrl = (string) => {
>
<SwitchCamera size="24" />
</button>
<div class="scan-overlay">
<div class="scan-frame"></div>
</div>
</QrcodeStream>
</div>
@@ -290,6 +427,9 @@ const isUrl = (string) => {
justify-content: center;
align-items: center;
margin-bottom: 0.5rem;
margin-top: 0;
position: relative;
width: 100%;
}
.scanner-content {
@@ -334,9 +474,22 @@ const isUrl = (string) => {
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;
@@ -395,25 +548,7 @@ const isUrl = (string) => {
backdrop-filter: blur(4px);
}
.scan-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
display: flex;
align-items: center;
justify-content: center;
}
.scan-frame {
width: 70%;
height: 70%;
border: 2px solid rgba(255, 255, 255, 0.5);
border-radius: 12px;
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.3);
}
/* Removed legacy scan frame overlay - using shape detection rendering via track instead */
.results-section {
flex: 1;
@@ -581,4 +716,21 @@ const isUrl = (string) => {
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>

View File

@@ -2,8 +2,6 @@
*, *::before, *::after {
box-sizing: border-box;
}
@import 'tailwindcss';
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
@@ -43,6 +41,7 @@
--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 */
@@ -86,6 +85,7 @@
--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 {
@@ -113,16 +113,18 @@ body {
@media (min-width: 768px) {
body {
height: 100vh;
overflow: hidden;
min-height: 100vh;
overflow: auto;
}
#app {
height: 100vh;
overflow: hidden;
min-height: 100vh;
overflow: auto;
}
}
/* Removed global front camera mirror to restore stability */
/* --- Shared styles for all tools (moved from tools.css) --- */
.tool-container {
@@ -132,12 +134,12 @@ body {
max-width: 800px;
margin: 0 auto;
height: 100%;
padding: 1rem;
padding: 0.5rem;
}
.tool-panel {
width: 100%;
padding: 2rem;
padding: 1rem;
border-radius: 16px;
display: flex;
flex-direction: column;