feat: add self-contained link composer
Build and Push Docker Container / build-and-push (push) Successful in 1m36s

- Add structured URL codec with compact alphabets and dictionary support.
- Add public decode, redirect, and QR endpoints for composed links.
- Add authenticated encode API and link composer UI.
- Generate PNG QR codes via qrcode with Pillow support.
- Register the new blueprint and navigation entry.
- Cover public decode, auth gating, redirect URL parsing, and QR output.
- Bump NanoShare to 1.27.0 and lock new dependencies.
This commit is contained in:
2026-08-14 08:56:42 +02:00
parent d3e5e11f8d
commit ed62837f6d
18 changed files with 1139 additions and 163 deletions
@@ -0,0 +1,218 @@
{% extends "base.htm" %}
{% block title %}NanoShare - Link Composer{% endblock %}
{% block meta %}
<meta name="description" content="Encode, decode and QR-share self-contained NanoShare links.">
<meta name="robots" content="noindex, nofollow" />
{% endblock %}
{% block content %}
<main class="wrap link-composer">
<section class="card hero-card">
<span class="hero-badge"><i class="fa-solid fa-link"></i> Link Composer</span>
<h1 class="title">Encode, decode and QR links</h1>
<p class="subtitle">Decode is public. Log in to create compressed links and QR codes.</p>
</section>
<section class="composer-grid {% if not can_encode %}is-single{% endif %}">
{% if can_encode %}
<section class="card composer-card">
<div class="card-heading">
<h2>Encode</h2>
</div>
<label for="compose-url">URL</label>
<textarea id="compose-url" placeholder="https://example.com/docs?page=1"></textarea>
<label class="switch-row">
<input id="qr-mode" type="checkbox">
QR alphabet mode
</label>
<div class="actions-row">
<button id="encode-btn" class="btn" type="button">Encode URL</button>
<button id="clear-btn" class="btn btn-ghost" type="button">Clear</button>
</div>
<div id="encode-error" class="form-error"></div>
</section>
{% endif %}
<section class="card composer-card">
<div class="card-heading">
<h2>Decode</h2>
</div>
<label for="compose-code">Code or redirect link</label>
<textarea id="compose-code" placeholder="u... or https://example.com/l/u..."></textarea>
{% if can_encode %}
<div class="switch-row switch-row-spacer" aria-hidden="true">&nbsp;</div>
{% endif %}
<div class="actions-row">
<button id="decode-btn" class="btn" type="button">Decode Code</button>
{% if not can_encode %}
<a class="btn btn-ghost" href="{{ url_for('auth_login.login') }}">Login to encode</a>
{% endif %}
</div>
<div id="decode-error" class="form-error"></div>
</section>
</section>
<section id="result-panel" class="card result-panel">
<div class="card-heading">
<h2>Result</h2>
<span id="mode-badge" class="badge">structured</span>
</div>
<div class="result-grid">
<div class="result-main">
<div class="output-block">
<span class="output-label">Compressed code</span>
<div class="copy-field">
<code id="out-code"></code>
<button class="btn btn-ghost mini" type="button" data-copy="out-code">Copy</button>
</div>
</div>
<div class="output-block">
<span class="output-label">Redirect link</span>
<div class="copy-field">
<code id="out-redirect"></code>
<button class="btn btn-ghost mini" type="button" data-copy="out-redirect">Copy</button>
</div>
</div>
<div class="output-block">
<span class="output-label">Decoded URL</span>
<div class="copy-field">
<code id="out-url"></code>
<button class="btn btn-ghost mini" type="button" data-copy="out-url">Copy</button>
</div>
</div>
<div class="stats-grid">
<div class="stat-card"><strong id="stat-original"></strong><span>Original</span></div>
<div class="stat-card"><strong id="stat-code"></strong><span>Code</span></div>
<div class="stat-card"><strong id="stat-ratio"></strong><span>Ratio</span></div>
<div class="stat-card"><strong id="stat-bits"></strong><span>Bits</span></div>
</div>
</div>
<div class="qr-card">
<img id="qr-image" alt="Generated QR code">
<div class="actions-row centered">
<a id="qr-open" class="btn btn-ghost" target="_blank" rel="noreferrer">Open PNG</a>
<a id="qr-download" class="btn" download="nanoshare-link-qr.png">Download PNG</a>
</div>
</div>
</div>
</section>
</main>
{% endblock %}
{% block script %}
<script>
const canEncode = {{ 'true' if can_encode else 'false' }};
const encodeApiUrl = {{ encode_api_url | tojson }};
const decodeApiUrl = {{ decode_api_url | tojson }};
const redirectPrefix = {{ redirect_prefix | tojson }};
const qrPrefix = {{ qr_prefix | tojson }};
const $ = (id) => document.getElementById(id);
function setError(id, message) {
const el = $(id);
el.textContent = message || '';
el.classList.toggle('visible', Boolean(message));
}
function setText(id, value) {
$(id).textContent = value || '';
}
async function postJson(path, body) {
const response = await fetch(path, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
const data = await response.json();
if (!response.ok || data.ok === false) throw new Error(data.error || data.detail || 'Request failed');
return data;
}
function showResult(data) {
setText('out-code', data.code || $('compose-code').value.trim());
setText('out-url', data.url);
setText('out-redirect', data.redirect_url || `${location.origin}${redirectPrefix}${encodeURIComponent(data.code || $('compose-code').value.trim())}`);
setText('stat-original', data.original_length ?? '');
setText('stat-code', data.code_length ?? ((data.code || '').length || ''));
setText('stat-ratio', data.ratio ?? '');
setText('stat-bits', data.bit_length ?? '');
setText('mode-badge', data.mode || 'decoded');
const code = data.code || $('compose-code').value.trim();
if (code) {
const qrUrl = data.qr_url || `${location.origin}${qrPrefix}${encodeURIComponent(code)}`;
$('qr-image').src = qrUrl;
$('qr-open').href = qrUrl;
$('qr-download').href = qrUrl;
}
$('result-panel').classList.add('visible');
}
$('encode-btn')?.addEventListener('click', async () => {
if (!canEncode) return;
setError('encode-error', '');
try {
const data = await postJson(encodeApiUrl, { url: $('compose-url').value, qr: $('qr-mode').checked });
$('compose-code').value = data.code;
showResult(data);
} catch (error) {
setError('encode-error', error.message);
}
});
function extractCode(value) {
const trimmed = value.trim();
try {
const url = new URL(trimmed);
const fullPath = url.pathname + url.search + url.hash;
if (fullPath.startsWith(redirectPrefix)) return decodeURIComponent(fullPath.slice(redirectPrefix.length));
if (fullPath.startsWith(qrPrefix)) return decodeURIComponent(fullPath.slice(qrPrefix.length));
} catch (_) {}
return trimmed;
}
$('decode-btn').addEventListener('click', async () => {
setError('decode-error', '');
try {
const input = $('compose-code').value.trim();
const code = extractCode(input);
const data = await postJson(decodeApiUrl, { code: input });
showResult({
...data,
code,
redirect_url: `${location.origin}${redirectPrefix}${encodeURIComponent(code)}`,
qr_url: `${location.origin}${qrPrefix}${encodeURIComponent(code)}`,
mode: 'decoded',
});
} catch (error) {
setError('decode-error', error.message);
}
});
$('clear-btn')?.addEventListener('click', () => {
$('compose-url').value = '';
$('compose-code').value = '';
$('result-panel').classList.remove('visible');
setError('encode-error', '');
setError('decode-error', '');
});
document.addEventListener('click', async (event) => {
const button = event.target.closest('[data-copy]');
if (!button) return;
await navigator.clipboard.writeText($(button.dataset.copy).textContent);
const old = button.textContent;
button.textContent = 'Copied';
setTimeout(() => button.textContent = old, 900);
});
</script>
{% endblock %}