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.
219 lines
8.1 KiB
HTML
219 lines
8.1 KiB
HTML
{% 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 %}
|