v0.13.0 (#94)
* Add scan mode to MiniQR (#91) * Add mobile drawer for QR code export (#95)
This commit is contained in:
parent
543cfe3141
commit
e874222374
4
.github/workflows/translate.yml
vendored
4
.github/workflows/translate.yml
vendored
@ -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
|
||||
|
@ -1,8 +1,8 @@
|
||||
# Mini QR Code Generator
|
||||
# Mini QR
|
||||
|
||||
[](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.
|
||||
|
@ -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
@ -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
@ -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
118
pnpm-lock.yaml
generated
@ -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
|
||||
|
1536
src/App.vue
1536
src/App.vue
File diff suppressed because it is too large
Load Diff
164
src/components/MobileMenu.vue
Normal file
164
src/components/MobileMenu.vue
Normal 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>
|
229
src/components/QRCodeCameraScanner.vue
Normal file
229
src/components/QRCodeCameraScanner.vue
Normal 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>
|
1200
src/components/QRCodeCreate.vue
Normal file
1200
src/components/QRCodeCreate.vue
Normal file
File diff suppressed because it is too large
Load Diff
427
src/components/QRCodeScan.vue
Normal file
427
src/components/QRCodeScan.vue
Normal 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>
|
19
src/components/ui/drawer/Drawer.vue
Normal file
19
src/components/ui/drawer/Drawer.vue
Normal 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>
|
31
src/components/ui/drawer/DrawerContent.vue
Normal file
31
src/components/ui/drawer/DrawerContent.vue
Normal 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>
|
23
src/components/ui/drawer/DrawerDescription.vue
Normal file
23
src/components/ui/drawer/DrawerDescription.vue
Normal 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>
|
14
src/components/ui/drawer/DrawerFooter.vue
Normal file
14
src/components/ui/drawer/DrawerFooter.vue
Normal 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>
|
14
src/components/ui/drawer/DrawerHeader.vue
Normal file
14
src/components/ui/drawer/DrawerHeader.vue
Normal 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>
|
21
src/components/ui/drawer/DrawerOverlay.vue
Normal file
21
src/components/ui/drawer/DrawerOverlay.vue
Normal 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>
|
23
src/components/ui/drawer/DrawerTitle.vue
Normal file
23
src/components/ui/drawer/DrawerTitle.vue
Normal 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>
|
8
src/components/ui/drawer/index.ts
Normal file
8
src/components/ui/drawer/index.ts
Normal 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'
|
@ -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 {
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user