feat: add self-contained link composer
Build and Push Docker Container / build-and-push (push) Successful in 1m36s
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:
@@ -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"> </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 %}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user