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
+2
View File
@@ -9,6 +9,8 @@
<ul id="nav-list">
<li><a href="{{ url_for('side_main.index') }}">NanoShare</a></li>
<li><a href="{{ url_for('link_composer.link_composer_page') }}">Links</a></li>
{% if user %}
<li><a href="{{ url_for('side_main.index') }}">Upload</a></li>
<li><a href="{{ url_for('side_main.files_list') }}">Files</a></li>
@@ -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 %}
+44 -3
View File
@@ -48,9 +48,14 @@
}
/* Globale Stile */
html, body {
height: -webkit-fill-available;
height: 100%;
html {
min-height: 100%;
background: var(--bg);
}
body {
min-height: 100vh;
min-height: 100dvh;
display: flex;
flex-direction: column;
font-family: Arial, sans-serif;
@@ -300,6 +305,8 @@ body{
radial-gradient(1200px 600px at 10% -10%, rgba(124,158,255,.18), transparent 60%),
radial-gradient(900px 500px at 110% 10%, rgba(142,246,255,.18), transparent 60%),
var(--bg);
background-attachment: fixed;
background-repeat: no-repeat;
font:500 14px/1.5 system-ui,-apple-system,Segoe UI,Inter,Roboto,"Helvetica Neue",Arial,"Noto Sans","Apple Color Emoji","Segoe UI Emoji";
}
@@ -528,6 +535,40 @@ input[type="file"]{display:none}
.toast.show{display:flex;animation:pop .18s ease-out}
@keyframes pop{from{transform:translateY(8px);opacity:0}to{transform:translateY(0);opacity:1}}
/* ====== Link Composer ====== */
.link-composer{max-width:1400px;width:min(1400px, calc(100% - 32px));padding:clamp(18px,2.6vw,34px)}
.link-composer .hero-card{margin-bottom:22px;padding:clamp(24px,3vw,36px)}
.link-composer .composer-card,.link-composer .result-panel{padding:clamp(24px,3vw,36px)}
.composer-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:24px;margin-bottom:24px}
.composer-grid.is-single{grid-template-columns:1fr}
@media (max-width:860px){.composer-grid{grid-template-columns:1fr}}
.composer-card textarea{
width:100%;height:190px;min-height:190px;resize:vertical;border-radius:14px;border:1px solid var(--border);
background:color-mix(in srgb,var(--panel) 88%, transparent);color:var(--text);padding:16px 18px;
}
.composer-card textarea:focus{outline:none;border-color:color-mix(in srgb,var(--accent) 50%, var(--border));box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 25%, transparent)}
.composer-card textarea:disabled,.composer-card button:disabled{opacity:.55;cursor:not-allowed}
.card-heading{display:flex;justify-content:space-between;gap:12px;align-items:center;margin-bottom:14px}
.card-heading h2{margin:0;font-size:1.25rem}
.badge-warning{color:#ffd37a;background:rgba(255,211,122,.12);border-color:rgba(255,211,122,.35)}
.notice{display:grid;gap:4px;margin:10px 0 16px;padding:12px;border:1px solid rgba(255,211,122,.35);border-radius:12px;background:rgba(255,211,122,.08);color:var(--muted)}
.switch-row{display:flex;align-items:center;gap:10px;margin:12px 0;color:var(--muted);font-weight:700}
.switch-row-spacer{visibility:hidden;pointer-events:none}
.switch-row input{accent-color:var(--accent)}
.actions-row{display:flex;gap:12px;flex-wrap:wrap;margin-top:18px}.actions-row.centered{justify-content:center}
.form-error{display:none;margin-top:12px;padding:12px;border-radius:12px;border:1px solid rgba(255,122,122,.45);background:rgba(255,122,122,.10);color:#ffd5d5}.form-error.visible{display:block}
.result-panel{display:none}.result-panel.visible{display:block}
.result-grid{display:grid;grid-template-columns:minmax(0,1fr) 320px;gap:24px;align-items:stretch}
@media (max-width:820px){.result-grid{grid-template-columns:1fr}}
.output-block{margin-top:12px}.output-label{display:block;color:var(--muted);font-size:.78rem;font-weight:800;text-transform:uppercase;letter-spacing:.08em;margin-bottom:7px}
.copy-field{display:flex;gap:10px;align-items:center;justify-content:space-between;border:1px solid var(--border);border-radius:12px;background:rgba(0,0,0,.16);padding:10px 12px;overflow-wrap:anywhere}
.copy-field code{font:700 .93rem ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;color:var(--text)}
.btn.mini{padding:.4rem .6rem;font-size:.82rem;flex:0 0 auto}
.stats-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px;margin-top:14px}
@media (max-width:620px){.stats-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}
.stat-card{border:1px solid var(--border);border-radius:12px;background:rgba(0,0,0,.16);padding:12px}.stat-card strong{display:block;font-size:1.15rem}.stat-card span{color:var(--muted);font-size:.82rem}
.qr-card{display:flex;flex-direction:column;gap:12px;justify-content:space-between;align-items:center;border:1px solid var(--border);border-radius:14px;background:rgba(0,0,0,.12);padding:14px}.qr-card img{max-width:230px;width:100%;background:#fff;border-radius:12px;padding:10px}
/* ====== Utilities ====== */
.sr-only{
position:absolute !important;width:1px;height:1px;padding:0;margin:-1px;