feat: QR export as SVG and PDF with selectable error correction

All QR endpoints (/api/qr/:code, /api/qr/custom GET+POST) accept
format=png|svg|pdf, ec=L|M|Q|H, and download=1 for attachment responses,
unified behind a shared qr_response() renderer. SVG is generated as a true
vector (one path of modules, crispEdges) with an optional logo embedded as
a data URI over a cleared centre tile; PDF wraps the raster render for
print. A logo always forces error correction H. Dot styles remain
raster-only.

QR Studio: format and error-correction selectors, PDF previews as PNG,
format-aware download button with contextual hints.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jason Stedwell
2026-07-03 01:39:50 -05:00
parent 14d6e8c50e
commit a1cb6ec5f2
2 changed files with 194 additions and 45 deletions
+73 -22
View File
@@ -697,6 +697,26 @@ input[type="range"]::-webkit-slider-thumb { -webkit-appearance:none; width:16px;
<button class="style-btn" data-style="horizontal">▬ H-Bars</button>
</div>
</div>
<div class="color-row" style="margin-bottom:18px">
<div>
<label class="field-label">FORMAT</label>
<select class="field-input" id="qr-format">
<option value="png" selected>PNG — raster</option>
<option value="svg">SVG — vector (print)</option>
<option value="pdf">PDF — print-ready</option>
</select>
<div style="font-size:10px;color:var(--muted);font-family:var(--font-mono);margin-top:4px">Dot styles apply to PNG &amp; PDF only</div>
</div>
<div>
<label class="field-label">ERROR CORRECTION</label>
<select class="field-input" id="qr-ec">
<option value="L">L — 7% (densest)</option>
<option value="M" selected>M — 15% (default)</option>
<option value="Q">Q — 25%</option>
<option value="H">H — 30% (logos)</option>
</select>
</div>
</div>
<div style="margin-bottom:18px">
<label class="field-label">LOGO / ICON <span style="font-weight:400;color:var(--muted)">(optional — centered overlay)</span></label>
<div class="logo-row">
@@ -713,7 +733,7 @@ input[type="range"]::-webkit-slider-thumb { -webkit-appearance:none; width:16px;
<div id="qr-preview-wrap"><div class="qr-placeholder">Enter a URL and click Generate</div></div>
<div id="qr-dl-row" style="display:none;flex-direction:column;align-items:center;gap:8px;width:100%">
<a class="btn-primary" id="qr-download-link" download="qrcode.png" style="text-decoration:none;display:inline-block;text-align:center">↓ Download PNG</a>
<div style="font-size:11px;color:var(--muted);font-family:var(--font-mono)">Right-click to save</div>
<div style="font-size:11px;color:var(--muted);font-family:var(--font-mono)" id="qr-dl-hint">Right-click to save</div>
</div>
</div>
</div>
@@ -722,6 +742,22 @@ input[type="range"]::-webkit-slider-thumb { -webkit-appearance:none; width:16px;
<!-- ── ADMIN ── -->
<div class="view" id="view-admin">
<!-- Platform overview -->
<div class="section-head" style="margin-bottom:18px">
<div><div class="section-title">Platform Overview</div><div style="color:var(--muted);font-size:13px;margin-top:3px">All users, links, and clicks across the instance</div></div>
<button class="btn-sm" onclick="loadAdminOverview()">↻ Refresh</button>
</div>
<div class="stats-strip" id="admin-stats">
<div class="stat-card"><div class="stat-value" id="astat-users"></div><div class="stat-label">USERS</div></div>
<div class="stat-card"><div class="stat-value" id="astat-orgs"></div><div class="stat-label">ORGS</div></div>
<div class="stat-card"><div class="stat-value" id="astat-links"></div><div class="stat-label">ACTIVE LINKS</div></div>
<div class="stat-card"><div class="stat-value" id="astat-clicks30"></div><div class="stat-label">CLICKS / 30D</div></div>
</div>
<div class="panel" style="margin-bottom:32px">
<h3>QUOTA USAGE</h3>
<div id="admin-usage"><div style="color:var(--muted);font-size:12px;font-family:var(--font-mono)">Loading…</div></div>
</div>
<!-- Users -->
<div class="section-head" style="margin-bottom:24px">
<div><div class="section-title">User Management</div><div style="color:var(--muted);font-size:13px;margin-top:3px">Create and manage user accounts</div></div>
@@ -1831,37 +1867,52 @@ function clearQrLogo() {
document.getElementById('qr-logo-clear').style.display = 'none';
}
async function postQrBlob(body) {
const resp = await authFetch(`${BASE}/api/qr/custom`, {method:'POST', body:JSON.stringify(body)});
if (!resp.ok) throw new Error('QR generation failed');
return await resp.blob();
}
document.getElementById('qr-generate-btn').addEventListener('click', async () => {
const url = document.getElementById('qr-url-input').value.trim();
if (!url) { toast('Please enter a URL','error'); return; }
const fg = qrFgH.value.replace('#','') || '000000';
const bg = qrBgH.value.replace('#','') || 'ffffff';
const size = parseInt(qrSz.value);
const fmt = document.getElementById('qr-format').value;
const ec = document.getElementById('qr-ec').value;
const btn = document.getElementById('qr-generate-btn');
const dlLink = document.getElementById('qr-download-link');
// PDFs can't render in an <img> — preview those as PNG, download as PDF
const previewFmt = fmt === 'pdf' ? 'png' : fmt;
if (qrLogoBase64 || qrStyle !== 'square') {
btn.disabled = true; btn.textContent = 'Generating...';
try {
const body = { url, fg, bg, size, style: qrStyle };
if (qrLogoBase64) body.logo = qrLogoBase64;
const resp = await authFetch(`${BASE}/api/qr/custom`, {method:'POST', body:JSON.stringify(body)});
if (!resp.ok) { toast('Failed to generate QR','error'); return; }
const blob = await resp.blob();
const blobUrl = URL.createObjectURL(blob);
document.getElementById('qr-preview-wrap').innerHTML =
`<div class="qr-preview-img"><img src="${blobUrl}" width="${Math.min(size,300)}" height="${Math.min(size,300)}" alt="QR"></div>`;
document.getElementById('qr-dl-row').style.display = 'flex';
document.getElementById('qr-download-link').href = blobUrl;
} catch(e) { toast('Network error','error'); }
finally { btn.disabled = false; btn.textContent = 'Generate QR Code'; }
} else {
const qrUrl = `${BASE}/api/qr/custom?url=${encodeURIComponent(url)}&fg=${fg}&bg=${bg}&size=${size}&t=${Date.now()}`;
btn.disabled = true; btn.textContent = 'Generating...';
try {
let previewUrl, dlUrl;
if (qrLogoBase64) {
const params = { url, fg, bg, size, style: qrStyle, ec, logo: qrLogoBase64 };
const previewBlob = await postQrBlob({...params, format: previewFmt});
previewUrl = URL.createObjectURL(previewBlob);
dlUrl = fmt === 'pdf'
? URL.createObjectURL(await postQrBlob({...params, format: 'pdf'}))
: previewUrl;
} else {
const qs = `url=${encodeURIComponent(url)}&fg=${fg}&bg=${bg}&size=${size}&style=${qrStyle}&ec=${ec}`;
previewUrl = `${BASE}/api/qr/custom?${qs}&format=${previewFmt}&t=${Date.now()}`;
dlUrl = `${BASE}/api/qr/custom?${qs}&format=${fmt}&download=1`;
}
document.getElementById('qr-preview-wrap').innerHTML =
`<div class="qr-preview-img"><img src="${qrUrl}" width="${Math.min(size,300)}" height="${Math.min(size,300)}" alt="QR"></div>`;
`<div class="qr-preview-img"><img src="${previewUrl}" width="${Math.min(size,300)}" height="${Math.min(size,300)}" alt="QR"></div>`;
document.getElementById('qr-dl-row').style.display = 'flex';
document.getElementById('qr-download-link').href = qrUrl;
}
toast('QR generated!');
dlLink.href = dlUrl;
dlLink.download = `qrcode.${fmt}`;
dlLink.textContent = `↓ Download ${fmt.toUpperCase()}`;
document.getElementById('qr-dl-hint').textContent =
fmt === 'svg' ? 'Vector — scales losslessly for print' :
fmt === 'pdf' ? 'Print-ready single-page PDF' : 'Right-click to save';
toast('QR generated!');
} catch(e) { toast('Failed to generate QR','error'); }
finally { btn.disabled = false; btn.textContent = 'Generate QR Code'; }
});
// ── Select Mode & Bulk Ops ─────────────────────────