feat: INITIAL COMMIT

This commit is contained in:
2026-05-19 15:32:05 +02:00
commit 537b594e0d
13 changed files with 1446 additions and 0 deletions

313
public/app.js Normal file
View File

@ -0,0 +1,313 @@
'use strict';
const els = {
form: document.getElementById('controls'),
type: document.getElementById('type'),
typeList: document.getElementById('type-list'),
input: document.getElementById('input'),
data: document.getElementById('data'),
size: document.getElementById('size'),
margin: document.getElementById('margin'),
scale: document.getElementById('scale'),
rotate: document.getElementById('rotate'),
fg: document.getElementById('fg'),
bg: document.getElementById('bg'),
includetext: document.getElementById('includetext'),
textalign: document.getElementById('textalign'),
heightmm: document.getElementById('heightmm'),
eclevel: document.getElementById('eclevel'),
parsefnc: document.getElementById('parsefnc'),
parse: document.getElementById('parse'),
sample: document.getElementById('sample'),
copyPage: document.getElementById('copy-page'),
copyImage: document.getElementById('copy-image'),
download: document.getElementById('download'),
canvas: document.getElementById('canvas'),
status: document.getElementById('status'),
meta: document.getElementById('meta'),
imageUrl: document.getElementById('image-url')
};
const DEFAULTS = {
type: 'qrcode',
input: 'text',
data: 'https://example.com/?hello=world',
size: '256',
margin: '0',
scale: '4',
rotate: 'N',
fg: '#000000',
bg: '#ffffff',
includetext: 'false',
textalign: 'center'
};
let renderGeneration = 0;
let popularTypes = [];
let debounceTimer = null;
main().catch((error) => {
setStatus(`Startup error: ${error.message || error}`, true);
});
async function main() {
await loadTypes();
hydrateFromQuery();
bindEvents();
await render();
}
async function loadTypes() {
const response = await fetch('/api/types');
if (!response.ok) {
throw new Error('Could not load supported code types.');
}
const payload = await response.json();
popularTypes = payload.popular || [];
els.typeList.innerHTML = '';
for (const item of popularTypes) {
const option = document.createElement('option');
option.value = item.id;
option.label = `${item.label} · ${item.kind}`;
els.typeList.appendChild(option);
}
}
function bindEvents() {
els.form.addEventListener('input', scheduleRender);
els.form.addEventListener('change', scheduleRender);
els.sample.addEventListener('click', async () => {
const type = els.type.value.trim() || DEFAULTS.type;
try {
const response = await fetch(`/api/sample/${encodeURIComponent(type)}`);
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.error || 'No sample available.');
}
els.data.value = payload.data;
scheduleRender();
} catch (error) {
setStatus(error.message || String(error), true);
}
});
els.copyPage.addEventListener('click', async () => {
const url = buildPageUrl();
await copyText(url, 'Page URL copied');
});
els.copyImage.addEventListener('click', async () => {
const url = buildAbsoluteImageUrl();
await copyText(url, 'Image URL copied');
});
els.download.addEventListener('click', () => {
const filename = filenameForCurrentCode();
els.canvas.toBlob((blob) => {
if (!blob) {
setStatus('Could not create PNG blob.', true);
return;
}
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
setStatus('PNG downloaded');
}, 'image/png');
});
}
function hydrateFromQuery() {
const params = new URLSearchParams(window.location.search);
setValue(els.type, params.get('type') || params.get('bcid') || DEFAULTS.type);
setValue(els.input, params.get('input') || params.get('format') || DEFAULTS.input);
setValue(els.data, params.get('data') ?? params.get('text') ?? DEFAULTS.data);
setValue(els.size, params.get('size') || params.get('codeSize') || DEFAULTS.size);
setValue(els.margin, params.get('margin') || DEFAULTS.margin);
setValue(els.scale, params.get('scale') || DEFAULTS.scale);
setValue(els.rotate, params.get('rotate') || DEFAULTS.rotate);
setValue(els.fg, normalizeColorForInput(params.get('fg') || params.get('foreground') || DEFAULTS.fg));
setValue(els.bg, normalizeColorForInput(params.get('bg') || params.get('background') || DEFAULTS.bg));
setValue(els.includetext, params.get('includetext') || DEFAULTS.includetext);
setValue(els.textalign, params.get('textalign') || DEFAULTS.textalign);
setValue(els.heightmm, params.get('heightmm') || params.get('height') || '');
setValue(els.eclevel, params.get('eclevel') || '');
setValue(els.parsefnc, params.get('parsefnc') || '');
setValue(els.parse, params.get('parse') || '');
}
function setValue(element, value) {
element.value = value;
}
function normalizeColorForInput(value) {
const stripped = String(value || '').trim().replace(/^#/, '');
return /^[0-9a-fA-F]{6}$/.test(stripped) ? `#${stripped}` : value;
}
function scheduleRender() {
window.clearTimeout(debounceTimer);
debounceTimer = window.setTimeout(() => {
render().catch((error) => setStatus(error.message || String(error), true));
}, 120);
}
async function render() {
const generation = ++renderGeneration;
const params = buildParams();
const imagePath = `/api/code.png?${params.toString()}`;
const imageUrl = `${window.location.origin}${imagePath}`;
els.imageUrl.value = imageUrl;
updateMeta();
window.history.replaceState(null, '', buildPageUrl());
setStatus('Rendering…');
const response = await fetch(imagePath, { headers: { Accept: 'image/png, application/json' } });
if (generation !== renderGeneration) {
return;
}
if (!response.ok) {
const message = await readError(response);
setStatus(message, true);
return;
}
const blob = await response.blob();
const image = await blobToImage(blob);
if (generation !== renderGeneration) {
URL.revokeObjectURL(image.src);
return;
}
drawImageToCanvas(image);
URL.revokeObjectURL(image.src);
setStatus('Ready');
}
function buildParams() {
const params = new URLSearchParams();
params.set('type', els.type.value.trim() || DEFAULTS.type);
params.set('input', els.input.value || DEFAULTS.input);
params.set('data', els.data.value);
params.set('size', els.size.value || DEFAULTS.size);
params.set('margin', els.margin.value || DEFAULTS.margin);
params.set('scale', els.scale.value || DEFAULTS.scale);
params.set('rotate', els.rotate.value || DEFAULTS.rotate);
params.set('fg', stripHash(els.fg.value || DEFAULTS.fg));
params.set('bg', stripHash(els.bg.value || DEFAULTS.bg));
params.set('includetext', els.includetext.value || DEFAULTS.includetext);
params.set('textalign', els.textalign.value || DEFAULTS.textalign);
setOptional(params, 'heightmm', els.heightmm.value);
setOptional(params, 'eclevel', els.eclevel.value);
setOptional(params, 'parsefnc', els.parsefnc.value);
setOptional(params, 'parse', els.parse.value);
return params;
}
function setOptional(params, key, value) {
if (value !== undefined && String(value).trim() !== '') {
params.set(key, String(value).trim());
}
}
function buildPageUrl() {
return `${window.location.origin}${window.location.pathname}?${buildParams().toString()}`;
}
function buildAbsoluteImageUrl() {
return `${window.location.origin}/api/code.png?${buildParams().toString()}`;
}
function stripHash(color) {
return String(color || '').replace(/^#/, '');
}
function updateMeta() {
const size = Math.max(0, Number.parseInt(els.size.value || DEFAULTS.size, 10));
const margin = Math.max(0, Number.parseInt(els.margin.value || DEFAULTS.margin, 10));
const output = size + margin * 2;
els.meta.textContent = `Output: ${output} × ${output} px`;
}
function drawImageToCanvas(image) {
const canvas = els.canvas;
canvas.width = image.naturalWidth;
canvas.height = image.naturalHeight;
const ctx = canvas.getContext('2d');
ctx.imageSmoothingEnabled = false;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(image, 0, 0);
}
function blobToImage(blob) {
return new Promise((resolve, reject) => {
const url = URL.createObjectURL(blob);
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error('Could not load generated image.'));
};
img.src = url;
});
}
async function readError(response) {
const contentType = response.headers.get('content-type') || '';
if (contentType.includes('application/json')) {
const payload = await response.json();
return payload.error || `Render failed with HTTP ${response.status}`;
}
return response.text();
}
async function copyText(text, successMessage) {
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
} else {
fallbackCopy(text);
}
setStatus(successMessage);
} catch (error) {
setStatus(`Copy failed: ${error.message || error}`, true);
}
}
function fallbackCopy(text) {
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.setAttribute('readonly', '');
textarea.style.position = 'fixed';
textarea.style.top = '-1000px';
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
textarea.remove();
}
function filenameForCurrentCode() {
const safeType = (els.type.value || DEFAULTS.type).replace(/[^a-z0-9_-]+/gi, '-').slice(0, 64);
const size = els.size.value || DEFAULTS.size;
const margin = els.margin.value || DEFAULTS.margin;
return `${safeType}-${size}px-m${margin}.png`;
}
function setStatus(message, error = false) {
els.status.textContent = message;
els.status.dataset.error = error ? 'true' : 'false';
}

171
public/index.html Normal file
View File

@ -0,0 +1,171 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Code Canvas Generator</title>
<link rel="stylesheet" href="/style.css">
<script src="/app.js" defer></script>
</head>
<body>
<main class="layout">
<section class="panel controls" aria-labelledby="title">
<div class="header-block">
<h1 id="title">Code Canvas Generator</h1>
<p>Self-hosted barcode and QR generator. All state is encoded as query parameters.</p>
</div>
<form id="controls" autocomplete="off">
<label>
Code type / bwip-js id
<input id="type" name="type" list="type-list" value="qrcode" spellcheck="false">
<datalist id="type-list"></datalist>
</label>
<div class="row two">
<label>
Input format
<select id="input" name="input">
<option value="text">Text / UTF-8</option>
<option value="ascii">ASCII</option>
<option value="latin1">Latin-1 binary string</option>
<option value="base64">Base64 bytes</option>
<option value="base64url">Base64URL bytes</option>
<option value="hex">Hex dump</option>
<option value="binary">Binary bits</option>
<option value="urlencoded">URL-encoded text</option>
<option value="json">JSON string/value</option>
</select>
</label>
<label>
Rotation
<select id="rotate" name="rotate">
<option value="N">Normal</option>
<option value="R">Right 90°</option>
<option value="L">Left 90°</option>
<option value="I">Invert 180°</option>
</select>
</label>
</div>
<label>
Data
<textarea id="data" name="data" rows="7" spellcheck="false">https://example.com/?hello=world</textarea>
</label>
<div class="row three">
<label>
Code size px
<input id="size" name="size" type="number" min="16" max="4096" step="1" value="256">
</label>
<label>
Margin px
<input id="margin" name="margin" type="number" min="0" max="2000" step="1" value="0">
</label>
<label>
Engine scale
<input id="scale" name="scale" type="number" min="1" max="20" step="1" value="4">
</label>
</div>
<div class="row two">
<label>
Foreground
<input id="fg" name="fg" type="color" value="#000000">
</label>
<label>
Background
<input id="bg" name="bg" type="color" value="#ffffff">
</label>
</div>
<details>
<summary>Advanced options</summary>
<div class="row three advanced-grid">
<label>
Human text
<select id="includetext" name="includetext">
<option value="false">Off</option>
<option value="true">On</option>
</select>
</label>
<label>
Text align
<select id="textalign" name="textalign">
<option value="center">Center</option>
<option value="left">Left</option>
<option value="right">Right</option>
<option value="justify">Justify</option>
</select>
</label>
<label>
Linear height mm
<input id="heightmm" name="heightmm" type="number" min="1" max="200" step="1" placeholder="optional">
</label>
</div>
<div class="row three advanced-grid">
<label>
QR EC level
<select id="eclevel" name="eclevel">
<option value="">Default</option>
<option value="L">L</option>
<option value="M">M</option>
<option value="Q">Q</option>
<option value="H">H</option>
</select>
</label>
<label>
parsefnc
<select id="parsefnc" name="parsefnc">
<option value="">Default</option>
<option value="true">true</option>
<option value="false">false</option>
</select>
</label>
<label>
parse
<select id="parse" name="parse">
<option value="">Default</option>
<option value="true">true</option>
<option value="false">false</option>
</select>
</label>
</div>
</details>
<div class="actions">
<button type="button" id="sample">Use sample for type</button>
<button type="button" id="copy-page">Copy page URL</button>
<button type="button" id="copy-image">Copy image URL</button>
<button type="button" id="download">Download PNG</button>
</div>
</form>
</section>
<section class="panel preview" aria-labelledby="preview-title">
<div class="preview-header">
<div>
<h2 id="preview-title">Live preview</h2>
<p id="meta">Output: 256 × 256 px</p>
</div>
<span id="status" role="status">Ready</span>
</div>
<div class="canvas-shell">
<canvas id="canvas" width="256" height="256" aria-label="Generated code preview"></canvas>
</div>
<label>
Generated image URL
<input id="image-url" readonly spellcheck="false">
</label>
<p class="note">
Margin is explicit white padding around the generated symbol. With margin 200 and size 256, the final PNG is 656 × 656 px.
</p>
</section>
</main>
</body>
</html>

264
public/style.css Normal file
View File

@ -0,0 +1,264 @@
:root {
color-scheme: light dark;
--bg: #0d1117;
--panel: #161b22;
--panel-2: #0f141b;
--text: #e6edf3;
--muted: #8b949e;
--border: #30363d;
--accent: #58a6ff;
--danger: #ff7b72;
--button: #238636;
--button-hover: #2ea043;
--input: #0d1117;
}
* {
box-sizing: border-box;
}
html,
body {
min-height: 100%;
}
body {
margin: 0;
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
line-height: 1.5;
background: radial-gradient(circle at top left, #1f2a44, var(--bg) 45%);
color: var(--text);
}
.layout {
display: grid;
grid-template-columns: minmax(340px, 560px) minmax(320px, 1fr);
gap: 24px;
width: min(1440px, 100%);
margin: 0 auto;
padding: 24px;
}
.panel {
border: 1px solid var(--border);
border-radius: 18px;
background: color-mix(in srgb, var(--panel) 92%, transparent);
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.25);
}
.controls,
.preview {
padding: 22px;
}
.header-block h1,
.preview h2 {
margin: 0;
line-height: 1.1;
}
.header-block p,
.preview p,
.note {
margin: 8px 0 0;
color: var(--muted);
}
form {
display: grid;
gap: 16px;
margin-top: 20px;
}
label {
display: grid;
gap: 7px;
font-size: 0.9rem;
color: var(--muted);
}
input,
select,
textarea,
button {
font: inherit;
}
input,
select,
textarea {
width: 100%;
border: 1px solid var(--border);
border-radius: 10px;
padding: 10px 11px;
background: var(--input);
color: var(--text);
outline: none;
}
input:focus,
select:focus,
textarea:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 18%, transparent);
}
input[type="color"] {
min-height: 42px;
padding: 4px;
}
textarea {
resize: vertical;
min-height: 120px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
}
.row {
display: grid;
gap: 14px;
}
.row.two {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.row.three {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
details {
border: 1px solid var(--border);
border-radius: 12px;
padding: 12px;
background: var(--panel-2);
}
summary {
cursor: pointer;
color: var(--text);
}
.advanced-grid {
margin-top: 12px;
}
.actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
button {
border: 1px solid color-mix(in srgb, var(--button) 65%, white);
border-radius: 10px;
padding: 10px 12px;
cursor: pointer;
background: var(--button);
color: white;
font-weight: 650;
}
button:hover {
background: var(--button-hover);
}
.preview {
display: grid;
align-content: start;
gap: 18px;
}
.preview-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
#status {
display: inline-flex;
align-items: center;
max-width: 50%;
min-height: 32px;
border: 1px solid var(--border);
border-radius: 999px;
padding: 5px 10px;
color: var(--muted);
background: var(--panel-2);
overflow-wrap: anywhere;
}
#status[data-error="true"] {
color: var(--danger);
border-color: color-mix(in srgb, var(--danger) 45%, var(--border));
}
.canvas-shell {
display: grid;
place-items: center;
min-height: 420px;
border: 1px dashed var(--border);
border-radius: 16px;
padding: 24px;
background:
linear-gradient(45deg, rgba(255,255,255,0.035) 25%, transparent 25%),
linear-gradient(-45deg, rgba(255,255,255,0.035) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, rgba(255,255,255,0.035) 75%),
linear-gradient(-45deg, transparent 75%, rgba(255,255,255,0.035) 75%);
background-size: 20px 20px;
background-position: 0 0, 0 10px, 10px -10px, -10px 0;
}
canvas {
display: block;
max-width: 100%;
max-height: 70vh;
image-rendering: pixelated;
background: #fff;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.3);
}
#image-url {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
}
.note {
font-size: 0.9rem;
}
@media (max-width: 980px) {
.layout {
grid-template-columns: 1fr;
}
}
@media (max-width: 620px) {
.layout {
padding: 12px;
}
.controls,
.preview {
padding: 16px;
}
.row.two,
.row.three,
.actions {
grid-template-columns: 1fr;
}
.preview-header {
display: grid;
}
#status {
max-width: 100%;
}
.canvas-shell {
min-height: 300px;
padding: 12px;
}
}