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:
+73
-22
@@ -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 & 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 ─────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user