* Add scan mode to MiniQR (#91)
* Add mobile drawer for QR code export (#95)
This commit is contained in:
Estee Tey 2025-03-12 21:29:50 -07:00 committed by GitHub
parent 543cfe3141
commit e874222374
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
54 changed files with 2758 additions and 1320 deletions

View File

@ -6,14 +6,14 @@ on:
- merged
paths:
- 'locales/en.json'
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Translate locales json
uses: lyqht/deepl-translate-github-action@v2.1.0
uses: lyqht/deepl-translate-github-action@v2.1.1
with:
target_languages: all
input_file_path: locales/en.json

View File

@ -1,8 +1,8 @@
# Mini QR Code Generator
# Mini QR
[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0)
A customizable QR code generator to create beautiful and unique QR codes.
An app to create beautiful QR codes and scan various QR code types.
<div style="display:flex; flex-direction:row; flex-wrap:wrap; justify-content:center; gap:8px;">
<a href="https://esteetey.dev"><img width="100" src="public/presets/lyqht.svg" /></a>
@ -28,6 +28,7 @@ A customizable QR code generator to create beautiful and unique QR codes.
- 🖼️ Upload custom image for logo
- 🎭 Presets: Pre-crafted QR code styles
- 🛡️ Error correction level: affects the size of the QR code and logo within. Use lower correction levels for bigger pieces of data to ensure that it can be read.
- 📱 QR Code Scanner: Scan QR codes using your camera or by uploading images, with intelligent detection for URLs, emails, phone numbers, WiFi credentials, and more
- 📦 Batch data export: Import a CSV file with multiple data strings and export QR codes for them all at once.
## Demo
@ -40,6 +41,10 @@ Batch data export is also now supported.
https://github.com/user-attachments/assets/fef17e6a-c226-4136-9501-8d3e951671e0
Scanning QR codes is also possible at MiniQR.
https://github.com/user-attachments/assets/5ad58b35-0a16-43a4-839a-e2197bfc273a
## Self-hosting with Docker 🐋
Mini-QR can easily be self-hosted. We provide a [docker-compose.yml](docker-compose.yml) file as well as our own images. We are using GitHub's `ghrc.io` Container Registry.

View File

@ -1,16 +1,20 @@
{
"$schema": "https://shadcn-vue.com/schema.json",
"style": "default",
"style": "new-york",
"typescript": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/style.css",
"css": "src/index.css",
"baseColor": "slate",
"cssVariables": true
"cssVariables": true,
"prefix": ""
},
"framework": "vite",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils"
}
"composables": "@/composables",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib"
},
"iconLibrary": "lucide"
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -96,5 +96,31 @@
"First row preview:": "First row preview:",
"Example file": "Example file",
"Have nice day!": "Have nice day!",
"Suggested": "Suggested"
"Suggested": "Suggested",
"Create": "Create",
"Scan": "Scan",
"Switch to Create Mode": "Switch to Create Mode",
"Switch to Scan Mode": "Switch to Scan Mode",
"QR Code Content": "QR Code Content",
"Data copied to clipboard": "Data copied to clipboard",
"Scan Another": "Scan Another",
"Create QR Code with this data": "Create QR Code with this data",
"Processing...": "Processing...",
"Scan a QR Code": "Scan a QR Code",
"Upload QR Code Image": "Upload QR Code Image",
"or drag and drop an image here": "or drag and drop an image here",
"Tip: For best results, use a clear image with good lighting.": "Tip: For best results, use a clear image with good lighting.",
"No QR code found in the image.": "No QR code found in the image.",
"Error reading file": "Error reading file",
"Failed to copy to clipboard": "Failed to copy to clipboard",
"Scan with Camera": "Scan with Camera",
"or": "or",
"Note: QR code scanning may not work well with front cameras or in low lighting conditions. For best results, use a high-resolution image.": "Note: QR code scanning may not work well with front cameras or in low lighting conditions. For best results, use a high-resolution image.",
"Camera access denied. Please allow camera access to use this feature.": "Camera access denied. Please allow camera access to use this feature.",
"Camera access denied. Please allow camera access in your browser settings.": "Camera access denied. Please allow camera access in your browser settings.",
"No camera found on this device": "No camera found on this device",
"Camera is already in use by another application": "Camera is already in use by another application",
"Could not start QR code scanner": "Could not start QR code scanner",
"Cancel": "Cancel",
"Error checking camera availability": "Error checking camera availability"
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,7 +1,7 @@
{
"name": "mini-qr",
"private": true,
"version": "0.9.2",
"version": "0.13.0",
"type": "module",
"scripts": {
"dev": "vite",
@ -19,22 +19,27 @@
]
},
"dependencies": {
"@floating-ui/vue": "^1.1.6",
"@vitejs/plugin-vue": "^5.2.1",
"@vitejs/plugin-vue-jsx": "^3.1.0",
"@vue/tsconfig": "^0.5.1",
"@vueuse/core": "^13.0.0",
"autoprefixer": "^10.4.20",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dom-to-image": "^2.6.0",
"dom-to-svg": "^0.12.2",
"file-saver": "^2.0.5",
"html5-qrcode": "^2.3.8",
"jszip": "^3.10.1",
"lucide-vue-next": "^0.321.0",
"qr-code-styling": "^1.9.1",
"radix-vue": "^1.9.17",
"reka-ui": "^2.0.2",
"tailwind-merge": "^2.6.0",
"tailwindcss": "^3.4.17",
"tailwindcss-animate": "^1.0.7",
"vaul-vue": "^0.4.0",
"vite": "^5.4.14",
"vue": "^3.5.13",
"vue-i18n": "^9.14.2"

118
pnpm-lock.yaml generated
View File

@ -8,6 +8,9 @@ importers:
.:
dependencies:
'@floating-ui/vue':
specifier: ^1.1.6
version: 1.1.6(vue@3.5.13(typescript@5.8.2))
'@vitejs/plugin-vue':
specifier: ^5.2.1
version: 5.2.1(vite@5.4.14(@types/node@20.17.23))(vue@3.5.13(typescript@5.8.2))
@ -17,6 +20,9 @@ importers:
'@vue/tsconfig':
specifier: ^0.5.1
version: 0.5.1
'@vueuse/core':
specifier: ^13.0.0
version: 13.0.0(vue@3.5.13(typescript@5.8.2))
autoprefixer:
specifier: ^10.4.20
version: 10.4.20(postcss@8.5.3)
@ -35,6 +41,9 @@ importers:
file-saver:
specifier: ^2.0.5
version: 2.0.5
html5-qrcode:
specifier: ^2.3.8
version: 2.3.8
jszip:
specifier: ^3.10.1
version: 3.10.1
@ -47,6 +56,9 @@ importers:
radix-vue:
specifier: ^1.9.17
version: 1.9.17(vue@3.5.13(typescript@5.8.2))
reka-ui:
specifier: ^2.0.2
version: 2.0.2(typescript@5.8.2)(vue@3.5.13(typescript@5.8.2))
tailwind-merge:
specifier: ^2.6.0
version: 2.6.0
@ -56,6 +68,9 @@ importers:
tailwindcss-animate:
specifier: ^1.0.7
version: 1.0.7(tailwindcss@3.4.17)
vaul-vue:
specifier: ^0.4.0
version: 0.4.0(reka-ui@2.0.2(typescript@5.8.2)(vue@3.5.13(typescript@5.8.2)))(vue@3.5.13(typescript@5.8.2))
vite:
specifier: ^5.4.14
version: 5.4.14(@types/node@20.17.23)
@ -600,6 +615,9 @@ packages:
'@types/web-bluetooth@0.0.20':
resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==}
'@types/web-bluetooth@0.0.21':
resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==}
'@typescript-eslint/eslint-plugin@6.21.0':
resolution: {integrity: sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA==}
engines: {node: ^16.0.0 || >=18.0.0}
@ -746,12 +764,34 @@ packages:
'@vueuse/core@10.11.1':
resolution: {integrity: sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==}
'@vueuse/core@12.8.2':
resolution: {integrity: sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==}
'@vueuse/core@13.0.0':
resolution: {integrity: sha512-rkgb4a8/0b234lMGCT29WkCjPfsX0oxrIRR7FDndRoW3FsaC9NBzefXg/9TLhAgwM11f49XnutshM4LzJBrQ5g==}
peerDependencies:
vue: ^3.5.0
'@vueuse/metadata@10.11.1':
resolution: {integrity: sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw==}
'@vueuse/metadata@12.8.2':
resolution: {integrity: sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==}
'@vueuse/metadata@13.0.0':
resolution: {integrity: sha512-TRNksqmvtvqsuHf7bbgH9OSXEV2b6+M3BSN4LR5oxWKykOFT9gV78+C2/0++Pq9KCp9KQ1OQDPvGlWNQpOb2Mw==}
'@vueuse/shared@10.11.1':
resolution: {integrity: sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==}
'@vueuse/shared@12.8.2':
resolution: {integrity: sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==}
'@vueuse/shared@13.0.0':
resolution: {integrity: sha512-9MiHhAPw+sqCF/RLo8V6HsjRqEdNEWVpDLm2WBRW2G/kSQjb8X901sozXpSCaeLG0f7TEfMrT4XNaA5m1ez7Dg==}
peerDependencies:
vue: ^3.5.0
acorn-jsx@5.3.2:
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
peerDependencies:
@ -1180,6 +1220,9 @@ packages:
resolution: {integrity: sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ==}
engines: {node: '>=8'}
html5-qrcode@2.3.8:
resolution: {integrity: sha512-jsr4vafJhwoLVEDW3n1KvPnCCXWaQfRng0/EEYk1vNcQGcG/htAdhJX0be8YyqMoSz7+hZvOZSTAepsabiuhiQ==}
human-signals@5.0.0:
resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==}
engines: {node: '>=16.17.0'}
@ -1426,6 +1469,9 @@ packages:
resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==}
engines: {node: '>= 6'}
ohash@1.1.6:
resolution: {integrity: sha512-TBu7PtV8YkAZn0tSxobKY2n2aAQva936lhRrj6957aDaCf9IEtqsKbgMzXE/F/sjqYOwmrukeORHNLe5glk7Cg==}
once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
@ -1592,6 +1638,11 @@ packages:
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
engines: {node: '>=8.10.0'}
reka-ui@2.0.2:
resolution: {integrity: sha512-pC2UF6Z+kJF96aJvIErhkSO4DJYIeq9pgvh3pntNqcZb3zFGMzw8h2uny+GnLX2CKiQV54kZNYXxecYIiPMGyg==}
peerDependencies:
vue: '>= 3.2.0'
resolve-from@4.0.0:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'}
@ -1790,6 +1841,12 @@ packages:
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
vaul-vue@0.4.0:
resolution: {integrity: sha512-qshpPYwQd61iwX9/qGe+WHeFkRtKD1r7ABICVxMkT0ryXqDcCeagyvqryJgaTXFA+tPFdQdLx9VN5A44xNrORg==}
peerDependencies:
reka-ui: ^2.0.0
vue: ^3.3.0
vite@5.4.14:
resolution: {integrity: sha512-EK5cY7Q1D8JNhSaPKVK4pwBFvaTmZxEnoKXLG/U9gmdDcihQGNzFlgIvaxezFR4glP1LsuiedwMBqCXH3wZccA==}
engines: {node: ^18.0.0 || >=20.0.0}
@ -2337,6 +2394,8 @@ snapshots:
'@types/web-bluetooth@0.0.20': {}
'@types/web-bluetooth@0.0.21': {}
'@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.8.2))(eslint@8.57.1)(typescript@5.8.2)':
dependencies:
'@eslint-community/regexpp': 4.12.1
@ -2559,8 +2618,28 @@ snapshots:
- '@vue/composition-api'
- vue
'@vueuse/core@12.8.2(typescript@5.8.2)':
dependencies:
'@types/web-bluetooth': 0.0.21
'@vueuse/metadata': 12.8.2
'@vueuse/shared': 12.8.2(typescript@5.8.2)
vue: 3.5.13(typescript@5.8.2)
transitivePeerDependencies:
- typescript
'@vueuse/core@13.0.0(vue@3.5.13(typescript@5.8.2))':
dependencies:
'@types/web-bluetooth': 0.0.21
'@vueuse/metadata': 13.0.0
'@vueuse/shared': 13.0.0(vue@3.5.13(typescript@5.8.2))
vue: 3.5.13(typescript@5.8.2)
'@vueuse/metadata@10.11.1': {}
'@vueuse/metadata@12.8.2': {}
'@vueuse/metadata@13.0.0': {}
'@vueuse/shared@10.11.1(vue@3.5.13(typescript@5.8.2))':
dependencies:
vue-demi: 0.14.10(vue@3.5.13(typescript@5.8.2))
@ -2568,6 +2647,16 @@ snapshots:
- '@vue/composition-api'
- vue
'@vueuse/shared@12.8.2(typescript@5.8.2)':
dependencies:
vue: 3.5.13(typescript@5.8.2)
transitivePeerDependencies:
- typescript
'@vueuse/shared@13.0.0(vue@3.5.13(typescript@5.8.2))':
dependencies:
vue: 3.5.13(typescript@5.8.2)
acorn-jsx@5.3.2(acorn@8.14.0):
dependencies:
acorn: 8.14.0
@ -3021,6 +3110,8 @@ snapshots:
html-tags@3.3.1: {}
html5-qrcode@2.3.8: {}
human-signals@5.0.0: {}
husky@8.0.3: {}
@ -3236,6 +3327,8 @@ snapshots:
object-hash@3.0.0: {}
ohash@1.1.6: {}
once@1.4.0:
dependencies:
wrappy: 1.0.2
@ -3392,6 +3485,23 @@ snapshots:
dependencies:
picomatch: 2.3.1
reka-ui@2.0.2(typescript@5.8.2)(vue@3.5.13(typescript@5.8.2)):
dependencies:
'@floating-ui/dom': 1.6.13
'@floating-ui/vue': 1.1.6(vue@3.5.13(typescript@5.8.2))
'@internationalized/date': 3.7.0
'@internationalized/number': 3.6.0
'@tanstack/vue-virtual': 3.13.2(vue@3.5.13(typescript@5.8.2))
'@vueuse/core': 12.8.2(typescript@5.8.2)
'@vueuse/shared': 12.8.2(typescript@5.8.2)
aria-hidden: 1.2.4
defu: 6.1.4
ohash: 1.1.6
vue: 3.5.13(typescript@5.8.2)
transitivePeerDependencies:
- '@vue/composition-api'
- typescript
resolve-from@4.0.0: {}
resolve@1.22.10:
@ -3608,6 +3718,14 @@ snapshots:
util-deprecate@1.0.2: {}
vaul-vue@0.4.0(reka-ui@2.0.2(typescript@5.8.2)(vue@3.5.13(typescript@5.8.2)))(vue@3.5.13(typescript@5.8.2)):
dependencies:
'@vueuse/core': 10.11.1(vue@3.5.13(typescript@5.8.2))
reka-ui: 2.0.2(typescript@5.8.2)(vue@3.5.13(typescript@5.8.2))
vue: 3.5.13(typescript@5.8.2)
transitivePeerDependencies:
- '@vue/composition-api'
vite@5.4.14(@types/node@20.17.23):
dependencies:
esbuild: 0.21.5

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,164 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { useFloating, offset, flip, shift, autoUpdate } from '@floating-ui/vue'
import LanguageSelector from '@/components/LanguageSelector.vue'
import { useI18n } from 'vue-i18n'
defineProps<{
isDarkMode: boolean
isDarkModePreferenceSetBySystem: boolean
}>()
const emit = defineEmits<{
(e: 'toggle-dark-mode'): void
(e: 'close'): void
}>()
const { t } = useI18n()
const isOpen = ref(false)
const reference = ref<HTMLElement | null>(null)
const floating = ref<HTMLElement | null>(null)
const { floatingStyles } = useFloating(reference, floating, {
placement: 'bottom-end',
middleware: [offset(5), flip(), shift()],
whileElementsMounted: autoUpdate
})
const toggleMenu = () => {
isOpen.value = !isOpen.value
}
const closeMenu = () => {
isOpen.value = false
emit('close')
}
const handleClickOutside = (event: MouseEvent) => {
if (
isOpen.value &&
floating.value &&
!floating.value.contains(event.target as Node) &&
reference.value &&
!reference.value.contains(event.target as Node)
) {
closeMenu()
}
}
onMounted(() => {
document.addEventListener('click', handleClickOutside)
})
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
})
</script>
<template>
<div class="transition-opacity duration-300">
<!-- Hamburger menu button -->
<button
ref="reference"
class="flex size-9 items-center justify-center rounded-md border border-zinc-300 bg-zinc-100 text-zinc-800 hover:bg-zinc-200 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-200 dark:hover:bg-zinc-700"
@click="toggleMenu"
:aria-label="t('Menu')"
aria-haspopup="true"
:aria-expanded="isOpen"
>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<path fill="currentColor" d="M3 18h18v-2H3zm0-5h18v-2H3zm0-5h18V6H3z" />
</svg>
</button>
<!-- Dropdown menu -->
<div
v-if="isOpen"
ref="floating"
:style="floatingStyles"
class="z-50 w-64 rounded-md border border-zinc-300 bg-white p-4 shadow-lg dark:border-zinc-700 dark:bg-zinc-800"
>
<div class="flex flex-col gap-4">
<!-- App title -->
<div class="flex items-center">
<h1 class="text-xl text-gray-700 dark:text-gray-100">MiniQR</h1>
</div>
<!-- GitHub link -->
<a
class="flex items-center gap-2 rounded-md px-2 py-1.5 text-zinc-800 hover:bg-zinc-100 dark:text-zinc-200 dark:hover:bg-zinc-700"
href="https://github.com/lyqht/styled-qr-code-generator"
target="_blank"
:aria-label="t('GitHub repository for this project')"
>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M12.001 2c-5.525 0-10 4.475-10 10a9.994 9.994 0 0 0 6.837 9.488c.5.087.688-.213.688-.476c0-.237-.013-1.024-.013-1.862c-2.512.463-3.162-.612-3.362-1.175c-.113-.288-.6-1.175-1.025-1.413c-.35-.187-.85-.65-.013-.662c.788-.013 1.35.725 1.538 1.025c.9 1.512 2.337 1.087 2.912.825c.088-.65.35-1.087.638-1.337c-2.225-.25-4.55-1.113-4.55-4.938c0-1.088.387-1.987 1.025-2.688c-.1-.25-.45-1.275.1-2.65c0 0 .837-.262 2.75 1.026a9.28 9.28 0 0 1 2.5-.338c.85 0 1.7.112 2.5.337c1.913-1.3 2.75-1.024 2.75-1.024c.55 1.375.2 2.4.1 2.65c.637.7 1.025 1.587 1.025 2.687c0 3.838-2.337 4.688-4.563 4.938c.363.312.676.912.676 1.85c0 1.337-.013 2.412-.013 2.75c0 .262.188.574.688.474A10.016 10.016 0 0 0 22 12c0-5.525-4.475-10-10-10Z"
/>
</svg>
<span>GitHub</span>
</a>
<!-- Dark mode toggle -->
<button
class="flex items-center gap-2 rounded-md px-2 py-1.5 text-zinc-800 hover:bg-zinc-100 dark:text-zinc-200 dark:hover:bg-zinc-700"
@click="emit('toggle-dark-mode')"
:aria-label="t('Toggle dark mode')"
>
<span v-if="isDarkModePreferenceSetBySystem">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24">
<g fill="currentColor">
<path d="M12 16a4 4 0 0 0 0-8z" />
<path
fill-rule="evenodd"
d="M12 2C6.477 2 2 6.477 2 12s4.477 10 10 10s10-4.477 10-10S17.523 2 12 2m0 2v4a4 4 0 1 0 0 8v4a8 8 0 1 0 0-16"
clip-rule="evenodd"
/>
</g>
</svg>
</span>
<span v-else-if="isDarkMode">
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
</span>
<span v-else>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
/>
</svg>
</span>
<span>{{ t('Toggle dark mode') }}</span>
</button>
<!-- Language selector -->
<div class="px-2 py-1.5">
<LanguageSelector />
</div>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,229 @@
<script setup lang="ts">
import { Html5Qrcode, Html5QrcodeScannerState } from 'html5-qrcode'
import { onMounted, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
const emit = defineEmits<{
'qr-detected': [data: string]
cancel: []
}>()
const { t } = useI18n()
const errorMessage = ref<string | null>(null)
const isLoading = ref(false)
const hasCamera = ref(false)
const scannerContainerId = 'html5-qrcode-scanner'
const html5QrCodeScanner = ref<Html5Qrcode | null>(null)
const isScanning = ref(false)
const checkCameraAvailability = async () => {
try {
const devices = await Html5Qrcode.getCameras()
hasCamera.value = devices && devices.length > 0
return hasCamera.value
} catch (err) {
console.error('Error checking camera availability:', err)
hasCamera.value = false
errorMessage.value = t('Camera access denied. Please allow camera access to use this feature.')
return false
}
}
const startScanning = async () => {
errorMessage.value = null
isLoading.value = true
try {
if (!hasCamera.value) {
const cameraAvailable = await checkCameraAvailability()
if (!cameraAvailable) {
isLoading.value = false
return
}
}
if (!html5QrCodeScanner.value) {
html5QrCodeScanner.value = new Html5Qrcode(scannerContainerId)
}
const devices = await Html5Qrcode.getCameras()
if (!devices || devices.length === 0) {
errorMessage.value = t('No camera found on this device')
isLoading.value = false
return
}
// Try to use back camera first (usually better for QR scanning)
const cameraId =
devices.find(
(device) =>
device.label.toLowerCase().includes('back') ||
device.label.toLowerCase().includes('rear') ||
device.label.toLowerCase().includes('environment')
)?.id || devices[0].id
await html5QrCodeScanner.value.start(
cameraId,
{
fps: 10,
qrbox: { width: 250, height: 250 },
aspectRatio: 1.0,
disableFlip: false
},
(decodedText) => {
// QR code detected successfully
emit('qr-detected', decodedText)
stopScanning()
},
(_errorMessage) => {
// QR code detection error (this is normal when no QR code is in view)
// We don't need to handle these errors as they occur continuously during scanning
}
)
isScanning.value = true
isLoading.value = false
} catch (err: any) {
console.error('Error starting QR scanner:', err)
if (err && err.name === 'NotAllowedError') {
errorMessage.value = t(
'Camera access denied. Please allow camera access in your browser settings.'
)
} else if (err && err.name === 'NotFoundError') {
errorMessage.value = t('No camera found on this device')
} else if (err && err.name === 'NotReadableError') {
errorMessage.value = t('Camera is already in use by another application')
} else {
errorMessage.value = t('Could not start QR code scanner')
}
isLoading.value = false
}
}
const stopScanning = async () => {
if (html5QrCodeScanner.value && isScanning.value) {
try {
// Check if scanner is in scanning state before stopping
if (html5QrCodeScanner.value.getState() === Html5QrcodeScannerState.SCANNING) {
await html5QrCodeScanner.value.stop()
}
} catch (err) {
console.error('Error stopping QR scanner:', err)
} finally {
isScanning.value = false
emit('cancel')
}
} else {
emit('cancel')
}
}
onUnmounted(() => {
stopScanning()
})
onMounted(async () => {
await checkCameraAvailability()
if (hasCamera.value) {
startScanning()
}
})
defineExpose({
hasCamera,
startScanning,
stopScanning
})
</script>
<template>
<div class="camera-scanner">
<div v-if="errorMessage" class="error-message mb-4 text-center text-red-500">
{{ errorMessage }}
</div>
<!-- Scanner container -->
<div class="scanner-container relative z-50 mb-4 overflow-hidden rounded-lg">
<div :id="scannerContainerId" class="mx-auto w-full max-w-md"></div>
<!-- Close button -->
<button
v-if="isScanning"
class="absolute right-2 top-2 rounded-full bg-white/80 p-2 text-black shadow-md dark:bg-black/80 dark:text-white"
@click="stopScanning"
>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M19 6.41L17.59 5L12 10.59L6.41 5L5 6.41L10.59 12L5 17.59L6.41 19L12 13.41L17.59 19L19 17.59L13.41 12L19 6.41z"
/>
</svg>
</button>
</div>
<button v-if="isScanning && !isLoading" class="button mt-4" @click="stopScanning">
{{ t('Cancel') }}
</button>
</div>
</template>
<style scoped>
.camera-scanner {
width: 100%;
position: relative;
}
.scanner-container {
position: relative;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
background-color: #000;
min-height: 300px;
}
/* Override some of the html5-qrcode library styles */
:deep(video) {
width: 100% !important;
height: auto !important;
border-radius: 8px;
object-fit: cover;
}
:deep(img) {
max-width: 100%;
border-radius: 8px;
}
.spinner {
width: 40px;
height: 40px;
border: 4px solid rgba(0, 0, 0, 0.1);
border-radius: 50%;
border-top-color: #3498db;
animation: spin 1s ease-in-out infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.dark .spinner {
border-color: rgba(255, 255, 255, 0.1);
border-top-color: #3498db;
}
.error-message {
max-width: 90%;
margin-left: auto;
margin-right: auto;
}
.button {
@apply rounded-lg bg-zinc-100 px-4 py-2 transition-colors hover:bg-zinc-200 dark:bg-zinc-800 dark:hover:bg-zinc-700;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,427 @@
<script setup lang="ts">
import { Html5Qrcode } from 'html5-qrcode'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import QRCodeCameraScanner from './QRCodeCameraScanner.vue'
defineEmits<{
'create-qr': [data: string]
}>()
const { t } = useI18n()
// #region Core QR Code Data
const capturedData = ref<string>('')
const errorMessage = ref<string | null>(null)
// #endregion Core QR Code Data
// #region QR Code Type Detection
const qrCodeType = computed(() => {
const data = capturedData.value
// URL detection (more comprehensive than just http)
if (/^(https?|ftp):\/\/[^\s/$.?#].[^\s]*$/i.test(data)) {
return 'url'
}
// Email detection
if (
/^mailto:(.+)$/i.test(data) ||
/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(data)
) {
return 'email'
}
// Phone number detection
if (/^tel:(.+)$/i.test(data) || /^[+]?[(]?[0-9]{1,4}[)]?[-\s./0-9]*$/.test(data)) {
return 'tel'
}
// SMS detection
if (/^sms:(.+)$/i.test(data)) {
return 'sms'
}
// WiFi detection
if (/^WIFI:(.+)$/i.test(data)) {
return 'wifi'
}
// vCard detection
if (/^BEGIN:VCARD[\s\S]*END:VCARD$/i.test(data)) {
return 'vcard'
}
// Calendar event detection
if (/^BEGIN:VEVENT[\s\S]*END:VEVENT$/i.test(data)) {
return 'calendar'
}
// Geo location detection
if (/^geo:(.+)$/i.test(data)) {
return 'geo'
}
// Default to text
return 'text'
})
const formattedData = computed(() => {
const data = capturedData.value
const type = qrCodeType.value
switch (type) {
case 'url':
return data
case 'email':
return data.startsWith('mailto:') ? data : `mailto:${data}`
case 'tel':
return data.startsWith('tel:') ? data : `tel:${data}`
case 'sms':
return data.startsWith('sms:') ? data : `sms:${data}`
case 'wifi':
// Return as is for display purposes
return data
case 'vcard':
case 'calendar':
case 'geo':
return data
default:
return data
}
})
const isActionable = computed(() => {
return ['url', 'email', 'tel', 'sms', 'geo'].includes(qrCodeType.value)
})
// #endregion QR Code Type Detection
// #region UI Display Properties
const qrCodeTypeIcon = computed(() => {
switch (qrCodeType.value) {
case 'url':
return `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24"><path fill="currentColor" d="M17 7h-4v2h4c1.65 0 3 1.35 3 3s-1.35 3-3 3h-4v2h4c2.76 0 5-2.24 5-5s-2.24-5-5-5m-6 8H7c-1.65 0-3-1.35-3-3s1.35-3 3-3h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4zm-3-4h8v2H8z"/></svg>`
case 'email':
return `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24"><path fill="currentColor" d="M20 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2m0 4l-8 5l-8-5V6l8 5l8-5z"/></svg>`
case 'tel':
return `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24"><path fill="currentColor" d="M6.62 10.79c1.44 2.83 3.76 5.14 6.59 6.59l2.2-2.2c.27-.27.67-.36 1.02-.24c1.12.37 2.33.57 3.57.57c.55 0 1 .45 1 1V20c0 .55-.45 1-1 1c-9.39 0-17-7.61-17-17c0-.55.45-1 1-1h3.5c.55 0 1 .45 1 1c0 1.25.2 2.45.57 3.57c.11.35.03.74-.25 1.02z"/></svg>`
case 'sms':
return `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24"><path fill="currentColor" d="M20 2H4c-1.1 0-1.99.9-1.99 2L2 22l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2M9 11H7V9h2zm4 0h-2V9h2zm4 0h-2V9h2z"/></svg>`
case 'wifi':
return `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24"><path fill="currentColor" d="M1 9l2 2c4.97-4.97 13.03-4.97 18 0l2-2C16.93 2.93 7.08 2.93 1 9m8 8l3 3l3-3a4.237 4.237 0 0 0-6 0m-4-4l2 2a7.074 7.074 0 0 1 10 0l2-2C15.14 9.14 8.87 9.14 5 13z"/></svg>`
case 'vcard':
return `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24"><path fill="currentColor" d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2m-7 2h2.5v12H13zm-2 12H8.5V6H11zM4 6h2.5v12H4zm16 12h-2.5V6H20z"/></svg>`
case 'calendar':
return `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24"><path fill="currentColor" d="M19 3h-1V1h-2v2H8V1H6v2H5c-1.11 0-1.99.9-1.99 2L3 19a2 2 0 0 0 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2m0 16H5V8h14zm-7-5h5v5h-5z"/></svg>`
case 'geo':
return `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24"><path fill="currentColor" d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7m0 9.5a2.5 2.5 0 0 1 0-5a2.5 2.5 0 0 1 0 5z"/></svg>`
default:
return `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24"><path fill="currentColor" d="M14.06 9.02l.92.92L5.92 19H5v-.92l9.06-9.06M17.66 3c-.25 0-.51.1-.7.29l-1.83 1.83l3.75 3.75l1.83-1.83a.996.996 0 0 0 0-1.41l-2.34-2.34c-.2-.2-.45-.29-.71-.29m-3.6 3.19L3 17.25V21h3.75L17.81 9.94l-3.75-3.75z"/></svg>`
}
})
const actionText = computed(() => {
switch (qrCodeType.value) {
case 'url':
return t('Open Link')
case 'email':
return t('Send Email')
case 'tel':
return t('Call Number')
case 'sms':
return t('Send SMS')
case 'geo':
return t('View Location')
default:
return t('Copy to clipboard')
}
})
const typeLabel = computed(() => {
switch (qrCodeType.value) {
case 'url':
return t('URL')
case 'email':
return t('Email')
case 'tel':
return t('Phone Number')
case 'sms':
return t('SMS')
case 'wifi':
return t('WiFi')
case 'vcard':
return t('Contact Card')
case 'calendar':
return t('Calendar Event')
case 'geo':
return t('Location')
default:
return t('Text')
}
})
// #endregion UI Display Properties
// #region User Actions
const copySuccess = ref(false)
const showCameraScanner = ref(false)
const copyToClipboard = async () => {
if (!capturedData.value) return
try {
await navigator.clipboard.writeText(capturedData.value)
copySuccess.value = true
// Clear the success message after 3 seconds
setTimeout(() => {
copySuccess.value = false
}, 3000)
} catch (err) {
errorMessage.value = t('Failed to copy to clipboard')
}
}
const onQRDetected = (data: string) => {
capturedData.value = data
showCameraScanner.value = false
}
const onCameraScannerCancel = () => {
showCameraScanner.value = false
}
const startCameraScanning = () => {
errorMessage.value = null
showCameraScanner.value = true
}
const resetCapture = () => {
capturedData.value = ''
errorMessage.value = null
copySuccess.value = false
showCameraScanner.value = false
}
// #endregion User Actions
// #region File Handling
const fileInput = ref<HTMLInputElement | null>(null)
const isLoading = ref(false)
const isDraggingOver = ref(false)
const handleFileUpload = (event: Event) => {
let file: File | null = null
// Handle drag and drop event
if (event.type === 'drop') {
const dt = (event as DragEvent).dataTransfer
if (dt?.files && dt.files.length > 0) {
file = dt.files[0]
}
}
// Handle file input change event
else {
const target = event.target as HTMLInputElement
if (target.files && target.files.length > 0) {
file = target.files[0]
}
}
if (!file) return
isLoading.value = true
errorMessage.value = null
const html5QrCode = new Html5Qrcode('file-qr-reader')
html5QrCode
.scanFile(file, false)
.then((decodedText) => {
capturedData.value = decodedText
isLoading.value = false
})
.catch((err) => {
console.error('Error scanning file:', err)
errorMessage.value = t('No QR code found in the image.')
isLoading.value = false
})
}
const handleDragOver = (event: DragEvent) => {
event.preventDefault()
isDraggingOver.value = true
}
const handleDragLeave = () => {
isDraggingOver.value = false
}
const handleDrop = (event: DragEvent) => {
event.preventDefault()
isDraggingOver.value = false
handleFileUpload(event)
}
// #endregion File Handling
defineExpose({
capturedData,
isLoading,
resetCapture,
copyToClipboard
})
</script>
<template>
<div class="relative mx-auto w-full max-w-[500px]">
<div v-if="capturedData" class="capture-result">
<p class="mb-4 text-xl font-semibold">{{ t('QR Code Content') }}</p>
<!-- QR Code Type Badge -->
<div class="mb-4 flex items-center justify-center">
<span
class="inline-flex items-center gap-1 rounded-full bg-gray-100 px-3 py-1 text-sm dark:bg-gray-800"
v-html="qrCodeTypeIcon + ' ' + typeLabel"
></span>
</div>
<!-- QR Code Content -->
<component
:is="isActionable ? 'a' : 'span'"
:href="isActionable ? formattedData : undefined"
:target="qrCodeType === 'url' ? '_blank' : undefined"
class="flex w-full flex-row items-center justify-center gap-1 text-center"
>
{{ capturedData }}
</component>
<div class="mt-8 flex flex-col items-center justify-center gap-4 md:mt-16">
<!-- Copy Button -->
<button
class="button flex w-full flex-row items-center justify-start gap-4"
@click="copyToClipboard"
>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<g
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
>
<path d="M8 10a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-8a2 2 0 0 1-2-2z" />
<path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2" />
</g>
</svg>
<span>{{ t('Copy to clipboard') }}</span>
</button>
<button
class="button flex w-full flex-row items-center justify-start gap-4"
@click="resetCapture"
>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M12 16q1.875 0 3.188-1.313T16.5 11.5q0-1.875-1.313-3.188T12 7q-1.875 0-3.188 1.313T7.5 11.5q0 1.875 1.313 3.188T12 16m0-1.8q-1.125 0-1.913-.788T9.3 11.5q0-1.125.788-1.913T12 8.8q1.125 0 1.913.788T14.7 11.5q0 1.125-.788 1.913T12 14.2M12 22q-2.075 0-3.9-.788t-3.175-2.137q-1.35-1.35-2.137-3.175T2 12q0-2.075.788-3.9t2.137-3.175q1.35-1.35 3.175-2.137T12 2q2.075 0 3.9.788t3.175 2.137q1.35 1.35 2.138 3.175T22 12q0 2.075-.788 3.9t-2.137 3.175q-1.35 1.35-3.175 2.138T12 22m0-2q3.35 0 5.675-2.325T20 12q0-3.35-2.325-5.675T12 4Q8.65 4 6.325 6.325T4 12q0 3.35 2.325 5.675T12 20m0-8"
/>
</svg>
<span>{{ t('Scan Another') }}</span>
</button>
<button
class="button flex w-full flex-row items-center justify-start gap-4"
@click="$emit('create-qr', capturedData)"
>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M3 11h8V3H3zm2-6h4v4H5zM3 21h8v-8H3zm2-6h4v4H5zM13 3v8h8V3zm6 6h-4V5h4zM13 13h2v2h-2zm0 4h2v2h-2zm4-4h2v2h-2zm0 4h2v2h-2z"
/>
</svg>
<span>{{ t('Create QR Code with this data') }}</span>
</button>
</div>
</div>
<div v-else-if="showCameraScanner" class="mb-4 w-full">
<QRCodeCameraScanner @qr-detected="onQRDetected" @cancel="onCameraScannerCancel" />
</div>
<div v-else class="capture-controls">
<div v-if="isLoading" class="mb-4 flex flex-col items-center justify-center">
<div
class="mb-2 size-10 animate-spin rounded-full border-4 border-solid border-gray-100 border-t-blue-500 dark:border-gray-800 dark:border-t-blue-500"
></div>
<p>{{ t('Processing...') }}</p>
</div>
<!-- Hidden div for file QR reader -->
<div id="file-qr-reader" class="hidden"></div>
<div class="flex flex-col items-center gap-4" v-if="!isLoading">
<!-- Upload QR Code Image option -->
<div class="mb-4 text-center">
<h3 class="mb-4 text-lg font-medium">{{ t('Scan a QR Code') }}</h3>
<button
:class="[
'flex w-full cursor-pointer items-center justify-center rounded-lg border-2 border-dashed p-4 py-6 text-center transition-colors',
isDraggingOver
? 'border-blue-400 bg-blue-50 dark:border-blue-600 dark:bg-blue-900/20'
: 'border-gray-300 hover:border-gray-400 hover:bg-gray-50 dark:hover:bg-zinc-800'
]"
@click="fileInput?.click()"
@keyup.enter="fileInput?.click()"
@keyup.space="fileInput?.click()"
@dragover="handleDragOver"
@dragleave="handleDragLeave"
@drop="handleDrop"
>
<div class="flex flex-col items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8zm4 18H6V4h7v5h5z"
/>
</svg>
<p>{{ t('Upload QR Code Image') }}</p>
<p class="text-sm text-gray-500">{{ t('or drag and drop an image here') }}</p>
</div>
</button>
<input
ref="fileInput"
type="file"
accept="image/*"
class="hidden"
@change="handleFileUpload"
/>
<!-- Error message -->
<p v-if="errorMessage" class="mt-4 text-red-500">
{{ errorMessage }}
</p>
<!-- Helpful tip -->
<p class="mt-2 text-sm text-gray-500">
{{ t('Tip: For best results, use a clear image with good lighting.') }}
</p>
<!-- Camera option -->
<div class="mt-4 flex flex-col items-center gap-2">
<p class="mb-2">{{ t('or') }}</p>
<button
class="z-40 flex items-center gap-2 rounded-lg bg-zinc-100 px-4 py-2 transition-colors hover:bg-zinc-200 dark:bg-zinc-800 dark:hover:bg-zinc-700"
@click="startCameraScanning"
type="button"
>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M12 9a3 3 0 1 0 0 6a3 3 0 0 0 0-6m0 8a5 5 0 1 1 0-10a5 5 0 0 1 0 10m0-12a1 1 0 0 1 1 1a1 1 0 0 1-1 1a1 1 0 0 1-1-1a1 1 0 0 1 1-1m4.5 1.5a1.5 1.5 0 0 1 1.5 1.5a1.5 1.5 0 0 1-1.5 1.5a1.5 1.5 0 0 1-1.5-1.5a1.5 1.5 0 0 1 1.5-1.5M20 4h-3.17l-1.24-1.35A1.99 1.99 0 0 0 14.12 2H9.88c-.56 0-1.1.24-1.48.65L7.17 4H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2"
/>
</svg>
{{ t('Scan with Camera') }}
</button>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,19 @@
<script lang="ts" setup>
import type { DrawerRootEmits, DrawerRootProps } from 'vaul-vue'
import { useForwardPropsEmits } from 'reka-ui'
import { DrawerRoot } from 'vaul-vue'
const props = withDefaults(defineProps<DrawerRootProps>(), {
shouldScaleBackground: true
})
const emits = defineEmits<DrawerRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<DrawerRoot v-bind="forwarded">
<slot />
</DrawerRoot>
</template>

View File

@ -0,0 +1,31 @@
<script lang="ts" setup>
import type { DialogContentEmits, DialogContentProps } from 'reka-ui'
import type { HtmlHTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
import { useForwardPropsEmits } from 'reka-ui'
import { DrawerContent, DrawerPortal } from 'vaul-vue'
import DrawerOverlay from './DrawerOverlay.vue'
const props = defineProps<DialogContentProps & { class?: HtmlHTMLAttributes['class'] }>()
const emits = defineEmits<DialogContentEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<DrawerPortal>
<DrawerOverlay />
<DrawerContent
v-bind="forwarded"
:class="
cn(
'fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background',
props.class
)
"
>
<div class="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
<slot />
</DrawerContent>
</DrawerPortal>
</template>

View File

@ -0,0 +1,23 @@
<script lang="ts" setup>
import type { DrawerDescriptionProps } from 'vaul-vue'
import { cn } from '@/lib/utils'
import { DrawerDescription } from 'vaul-vue'
import { computed, type HtmlHTMLAttributes } from 'vue'
const props = defineProps<DrawerDescriptionProps & { class?: HtmlHTMLAttributes['class'] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
</script>
<template>
<DrawerDescription
v-bind="delegatedProps"
:class="cn('text-sm text-muted-foreground', props.class)"
>
<slot />
</DrawerDescription>
</template>

View File

@ -0,0 +1,14 @@
<script lang="ts" setup>
import type { HtmlHTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HtmlHTMLAttributes['class']
}>()
</script>
<template>
<div :class="cn('mt-auto flex flex-col gap-2 p-4', props.class)">
<slot />
</div>
</template>

View File

@ -0,0 +1,14 @@
<script lang="ts" setup>
import type { HtmlHTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HtmlHTMLAttributes['class']
}>()
</script>
<template>
<div :class="cn('grid gap-1.5 p-4 text-center sm:text-left', props.class)">
<slot />
</div>
</template>

View File

@ -0,0 +1,21 @@
<script lang="ts" setup>
import type { DialogOverlayProps } from 'reka-ui'
import { cn } from '@/lib/utils'
import { DrawerOverlay } from 'vaul-vue'
import { computed, type HtmlHTMLAttributes } from 'vue'
const props = defineProps<DialogOverlayProps & { class?: HtmlHTMLAttributes['class'] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
</script>
<template>
<DrawerOverlay
v-bind="delegatedProps"
:class="cn('fixed inset-0 z-50 bg-black/80', props.class)"
/>
</template>

View File

@ -0,0 +1,23 @@
<script lang="ts" setup>
import type { DrawerTitleProps } from 'vaul-vue'
import { cn } from '@/lib/utils'
import { DrawerTitle } from 'vaul-vue'
import { computed, type HtmlHTMLAttributes } from 'vue'
const props = defineProps<DrawerTitleProps & { class?: HtmlHTMLAttributes['class'] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
</script>
<template>
<DrawerTitle
v-bind="delegatedProps"
:class="cn('text-lg font-semibold leading-none tracking-tight', props.class)"
>
<slot />
</DrawerTitle>
</template>

View File

@ -0,0 +1,8 @@
export { default as Drawer } from './Drawer.vue'
export { default as DrawerContent } from './DrawerContent.vue'
export { default as DrawerDescription } from './DrawerDescription.vue'
export { default as DrawerFooter } from './DrawerFooter.vue'
export { default as DrawerHeader } from './DrawerHeader.vue'
export { default as DrawerOverlay } from './DrawerOverlay.vue'
export { default as DrawerTitle } from './DrawerTitle.vue'
export { DrawerClose, DrawerPortal, DrawerTrigger } from 'vaul-vue'

View File

@ -34,6 +34,16 @@
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
}
:root[class~="dark"]{
@ -66,6 +76,32 @@
--ring: 212.7 26.8% 83.9%;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
}
}
@layer base {

View File

@ -1,6 +1,12 @@
import { clsx, type ClassValue } from 'clsx'
import type { Updater } from '@tanstack/vue-table'
import type { Ref } from 'vue'
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function valueUpdater<T extends Updater<any>>(updaterOrValue: T, ref: Ref) {
ref.value = typeof updaterOrValue === 'function' ? updaterOrValue(ref.value) : updaterOrValue
}

View File

@ -16,12 +16,21 @@
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
color: #0000EE;
text-decoration: underline;
cursor: pointer;
}
a:visited {
color: #551A8B;
}
a:hover {
color: #535bf2;
text-decoration: none;
}
a:active {
color: #EE0000;
}
body {
@ -46,4 +55,64 @@ label {
svg {
@apply shrink-0;
}
}
p {
@apply !font-normal;
}
p,
label,
legend {
@apply text-gray-700 dark:text-gray-100 text-lg font-semibold;
}
.text-input {
@apply bg-zinc-50 dark:bg-zinc-800 text-zinc-900 dark:text-zinc-100;
@apply shadow hover:shadow-md transition-shadow rounded-lg;
@apply outline-none focus-visible:ring-1 focus-visible:ring-zinc-700 dark:focus-visible:ring-zinc-200;
@apply resize-none appearance-none ms-1 p-4 rounded w-full;
}
input[type='color'] {
@apply outline-none focus-visible:ring-1 focus-visible:ring-zinc-700 dark:focus-visible:ring-zinc-200;
@apply bg-transparent shadow p-0 border rounded box-border text-zinc-700 dark:text-zinc-100 focus-visible:shadow;
}
input[type='radio'] {
@apply outline-none focus-visible:ring-1 focus-visible:ring-zinc-700 dark:focus-visible:ring-zinc-200;
@apply m-3;
}
.button {
@apply bg-zinc-100 dark:bg-zinc-700 text-zinc-900 dark:text-zinc-200;
@apply shadow-sm hover:shadow p-2 focus-visible:shadow-md rounded-lg;
@apply outline-none focus-visible:ring-1 focus-visible:ring-zinc-700 dark:focus-visible:ring-zinc-200;
@apply disabled:opacity-50 disabled:cursor-not-allowed;
}
.secondary-button {
@apply bg-zinc-50 dark:bg-zinc-800 text-zinc-900 dark:text-zinc-100;
@apply shadow hover:shadow-md transition-shadow rounded-lg;
@apply outline-none focus-visible:ring-1 focus-visible:ring-zinc-700 dark:focus-visible:ring-zinc-200;
@apply outline-none p-1.5;
}
.icon-button {
@apply p-1;
@apply outline-none focus-visible:ring-1 focus-visible:ring-zinc-700 dark:focus-visible:ring-zinc-200 hover:shadow rounded-sm;
@apply text-zinc-900 dark:text-zinc-100 dark:bg-zinc-800;
}
.vertical-border {
@apply h-8 bg-slate-300 dark:bg-slate-700 w-1;
}
.radiogroup {
@apply flex flex-row items-center gap-1;
}
.radiogroup > * > label,
.radiogroup > label {
@apply font-normal;
}