From 39f763614b48b626eca97c7e695573e980723573 Mon Sep 17 00:00:00 2001 From: Julien Calixte Date: Thu, 28 May 2026 11:47:39 +0200 Subject: [PATCH] feat: add styled QR code generator with logo and edge customization Generates QR codes from a URL with center logo upload, adjustable logo padding and size, dot/corner-square/corner-dot style pickers, color controls, and PNG/SVG export. Uses error correction level H so the logo can occlude up to ~30% of modules without breaking scannability. --- src/App.vue | 154 ++++++++++++++++++++++ src/composables/useQrCode.ts | 66 ++++++++++ src/style.css | 242 +++++++++++++++++++++++++++++++++++ 3 files changed, 462 insertions(+) create mode 100644 src/App.vue create mode 100644 src/composables/useQrCode.ts create mode 100644 src/style.css diff --git a/src/App.vue b/src/App.vue new file mode 100644 index 0000000..2826291 --- /dev/null +++ b/src/App.vue @@ -0,0 +1,154 @@ + + + diff --git a/src/composables/useQrCode.ts b/src/composables/useQrCode.ts new file mode 100644 index 0000000..eb73a05 --- /dev/null +++ b/src/composables/useQrCode.ts @@ -0,0 +1,66 @@ +import { onMounted, shallowRef, watch, type Ref } from 'vue' +import QRCodeStyling, { + type DotType, + type CornerSquareType, + type CornerDotType, + type FileExtension, +} from 'qr-code-styling' + +export type QrOptions = { + data: string + size: number + dotsType: DotType + dotsColor: string + cornersSquareType: CornerSquareType + cornersSquareColor: string + cornersDotType: CornerDotType + cornersDotColor: string + backgroundColor: string + image: string | null + imageMargin: number + imageSize: number + hideBackgroundDots: boolean +} + +export function useQrCode(container: Ref, options: QrOptions) { + const qr = shallowRef(null) + + onMounted(() => { + qr.value = new QRCodeStyling(toLibOptions(options)) + if (container.value) qr.value.append(container.value) + }) + + watch( + () => ({ ...options }), + () => qr.value?.update(toLibOptions(options)), + { deep: true }, + ) + + const download = (extension: FileExtension) => { + qr.value?.download({ name: 'qrcode', extension }) + } + + return { download } +} + +function toLibOptions(o: QrOptions) { + return { + width: o.size, + height: o.size, + type: 'svg' as const, + data: o.data || ' ', + image: o.image ?? undefined, + margin: 0, + qrOptions: { errorCorrectionLevel: 'H' as const }, + dotsOptions: { color: o.dotsColor, type: o.dotsType }, + backgroundOptions: { color: o.backgroundColor }, + cornersSquareOptions: { color: o.cornersSquareColor, type: o.cornersSquareType }, + cornersDotOptions: { color: o.cornersDotColor, type: o.cornersDotType }, + imageOptions: { + crossOrigin: 'anonymous', + margin: o.imageMargin, + imageSize: o.imageSize, + hideBackgroundDots: o.hideBackgroundDots, + }, + } +} diff --git a/src/style.css b/src/style.css new file mode 100644 index 0000000..2da65b9 --- /dev/null +++ b/src/style.css @@ -0,0 +1,242 @@ +:root { + font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + color-scheme: light; + color: #0f172a; + background: #f1f5f9; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; +} + +#app { + min-height: 100vh; +} + +.app { + max-width: 1100px; + margin: 0 auto; + padding: 32px 24px 64px; +} + +header { + margin-bottom: 28px; +} + +h1 { + margin: 0 0 6px; + font-size: 28px; + font-weight: 700; + letter-spacing: -0.01em; +} + +.subtitle { + margin: 0; + color: #64748b; + font-size: 15px; +} + +main { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 380px); + gap: 28px; + align-items: start; +} + +@media (max-width: 880px) { + main { + grid-template-columns: 1fr; + } +} + +.controls, +.preview-card { + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 14px; + padding: 20px; + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04); +} + +.controls { + display: flex; + flex-direction: column; + gap: 18px; +} + +fieldset { + border: 1px solid #e2e8f0; + border-radius: 10px; + padding: 14px 16px 16px; + margin: 0; + display: flex; + flex-direction: column; + gap: 12px; +} + +legend { + padding: 0 6px; + font-size: 13px; + font-weight: 600; + color: #475569; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.field { + display: flex; + flex-direction: column; + gap: 6px; +} + +.field label { + font-size: 13px; + font-weight: 500; + color: #334155; + display: flex; + justify-content: space-between; + gap: 8px; +} + +.value { + color: #64748b; + font-variant-numeric: tabular-nums; + font-weight: 400; +} + +input[type="text"], +select { + width: 100%; + padding: 9px 11px; + border: 1px solid #cbd5e1; + border-radius: 8px; + font: inherit; + background: #fff; + transition: border-color 0.12s, box-shadow 0.12s; +} + +input[type="text"]:focus, +select:focus { + outline: none; + border-color: #6366f1; + box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.18); +} + +input[type="color"] { + width: 100%; + height: 38px; + padding: 2px; + border: 1px solid #cbd5e1; + border-radius: 8px; + background: #fff; + cursor: pointer; +} + +input[type="range"] { + width: 100%; + accent-color: #6366f1; +} + +input[type="file"] { + font: inherit; + font-size: 13px; +} + +.checkbox { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: #334155; + cursor: pointer; +} + +.logo-actions { + display: flex; + align-items: center; + gap: 12px; +} + +.logo-thumb { + width: 44px; + height: 44px; + object-fit: contain; + border: 1px solid #e2e8f0; + border-radius: 8px; + background: #f8fafc; + padding: 4px; +} + +.preview-pane { + display: flex; + flex-direction: column; + gap: 14px; + position: sticky; + top: 24px; +} + +.preview-card { + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + min-height: 360px; +} + +.preview { + display: flex; +} + +.preview svg, +.preview canvas { + display: block; + max-width: 100%; + height: auto; +} + +.download { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; +} + +button { + font: inherit; + font-weight: 600; + font-size: 14px; + padding: 10px 14px; + border-radius: 10px; + border: 1px solid transparent; + background: #4f46e5; + color: #fff; + cursor: pointer; + transition: background 0.12s, transform 0.05s; +} + +button:hover { + background: #4338ca; +} + +button:active { + transform: translateY(1px); +} + +button.ghost { + background: transparent; + color: #475569; + border-color: #cbd5e1; + padding: 6px 10px; + font-size: 13px; +} + +button.ghost:hover { + background: #f1f5f9; + color: #1e293b; +}