Add show/hide Link + QR toggles for every SimpleX link (default hidden)

Reusable link box (Jinja macro in _macros.html + shared JS/CSS/QR lib in
base.html): a 'Link' button toggles the URL (with copy) and a 'QR' button toggles
a lazily-rendered QR of the same link — both hidden by default. Applied to the
profile address, profile groups & channels, and the profile cards on the list
pages. Centralize robustCopy in base.html; drop the per-page duplicates and the
old async group-link fetch (groups now use their known link).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Jon
2026-06-05 22:46:26 +01:00
parent 3456ed9411
commit 332b4a1801
4 changed files with 93 additions and 100 deletions

View File

@@ -1,4 +1,5 @@
{% extends "base.html" %}
{% import "_macros.html" as ui %}
{% block title %}{{ profile.name }} — SimpleX Manager{% endblock %}
{% block head %}
@@ -84,23 +85,11 @@
</div>
</div>
<!-- Address / QR -->
<!-- Address -->
<div class="card">
<h2>Address</h2>
<h2 style="margin-bottom:12px;">Address</h2>
{% if profile.address %}
<div class="addr-row">
<button class="btn btn-ghost copy-btn" title="Copy address"
onclick="copyAddr(this, '{{ profile.address | e }}')"><i class="fa-solid fa-copy"></i></button>
<a class="addr-link" href="{{ profile.address }}" target="_blank" rel="noopener" id="address-text">{{ profile.address }}</a>
</div>
<div class="qr-wrap">
<canvas id="qr-canvas"></canvas>
<p class="muted" style="margin-top:10px;">Scan QR code from mobile app to start a chat</p>
</div>
<script src="https://cdn.jsdelivr.net/npm/qrcode/build/qrcode.min.js"></script>
<script>
QRCode.toCanvas(document.getElementById('qr-canvas'), {{ profile.address | tojson }}, {width: 200}, () => {})
</script>
{{ ui.linkbox(profile.address, 'addr') }}
{% else %}
<p class="muted">Start the profile to generate an address.</p>
{% endif %}
@@ -176,20 +165,20 @@
<td>{{ name }}</td>
<td>
{% if invited %}
<span class="tag" title="You were invited but haven't joined yet"> invited</span>
<span class="tag" title="You were invited but haven't joined yet"><i class="fa-solid fa-hourglass-half"></i> invited</span>
{% else %}
<button class="msg-btn" style="border:none;padding:0;background:none;color:var(--accent);font-weight:600;font-size:13px;cursor:pointer;"
onclick="loadMembers({{ gid }}, '{{ name | e }}')">{{ mcnt }}</button>
{% endif %}
</td>
<td>
<div class="flex gap-8">
<div class="flex gap-8" style="flex-wrap:wrap;">
{% if invited %}
<button class="msg-btn" onclick="joinGroup({{ gid }}, this)">Join</button>
{% else %}
<a class="msg-btn" style="text-decoration:none;"
href="/profile/{{ profile.id }}/chat/group/{{ gid }}"><i class="fa-solid fa-comments"></i> {{ 'Broadcast' if g.is_channel else 'Chat' }}</a>
<button class="msg-btn" onclick="getGroupLink({{ gid }}, this)">Link</button>
{% if g.link %}{{ ui.linkbtns('g' ~ gid) }}{% endif %}
<button class="msg-btn msg-btn-danger" onclick="leaveGroup({{ gid }}, '{{ name | e }}', this)">Leave</button>
{% if is_owner %}
<button class="msg-btn msg-btn-danger" onclick="deleteGroup({{ gid }}, '{{ name | e }}', this)">Delete</button>
@@ -198,15 +187,9 @@
</div>
</td>
</tr>
<tr id="link-row-{{ gid }}" style="display:none;">
<td colspan="3" style="padding-top:0;">
<div class="addr-row" style="margin:0;">
<button class="btn btn-ghost copy-btn" title="Copy link"
onclick="copyGroupLinkBtn({{ gid }}, this)"><i class="fa-solid fa-copy"></i></button>
<a class="addr-link" id="link-url-{{ gid }}" href="#" target="_blank" rel="noopener"></a>
</div>
</td>
</tr>
{% if g.link %}
<tr><td colspan="3" style="border:none;padding:0 12px 4px;">{{ ui.linkpanels(g.link, 'g' ~ gid) }}</td></tr>
{% endif %}
{% endmacro %}
<!-- Groups -->
@@ -378,21 +361,7 @@ async function sendMsg() {
}
}
// Clipboard that also works over plain-HTTP LAN (navigator.clipboard needs a
// secure context). Falls back to a hidden textarea + execCommand.
function robustCopy(text) {
if (navigator.clipboard && window.isSecureContext) {
return navigator.clipboard.writeText(text).catch(() => fallbackCopy(text));
}
return Promise.resolve(fallbackCopy(text));
}
function fallbackCopy(text) {
const ta = document.createElement('textarea');
ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0';
document.body.appendChild(ta); ta.focus(); ta.select();
try { document.execCommand('copy'); } catch (e) {}
document.body.removeChild(ta);
}
// robustCopy/fallbackCopy live in base.html (shared). Directory-website URL copy:
function copyAddr(btn, addr) {
robustCopy(addr).then(() => {
btn.innerHTML = '<i class="fa-solid fa-check"></i>';
@@ -596,38 +565,7 @@ async function leaveGroup(groupId, name, btn) {
else { btn.disabled = false; btn.textContent = 'Leave'; alert('Failed to leave: ' + (data.detail || 'unknown')); }
}
function copyGroupLinkBtn(gid, btn) {
const url = document.getElementById('link-url-' + gid).textContent;
robustCopy(url).then(() => {
btn.innerHTML = '<i class="fa-solid fa-check"></i>';
setTimeout(() => btn.innerHTML = '<i class="fa-solid fa-copy"></i>', 1500);
});
}
async function getGroupLink(groupId, btn) {
const row = document.getElementById('link-row-' + groupId);
if (row && row.style.display !== 'none') { row.style.display = 'none'; return; } // toggle off
const orig = btn.textContent;
btn.textContent = '…';
try {
const resp = await fetch(`/api/profiles/{{ profile.id }}/groups/${groupId}/link`, {
headers: {'X-Token': _token()},
});
const data = await resp.json();
btn.textContent = orig;
if (data.link) {
const a = document.getElementById('link-url-' + groupId);
a.textContent = data.link;
a.href = data.link;
row.style.display = '';
} else {
btn.textContent = 'No link';
setTimeout(() => btn.textContent = orig, 2000);
}
} catch (e) {
btn.textContent = 'Error';
setTimeout(() => btn.textContent = orig, 2000);
}
}
// Group/channel links use the shared link box (sxToggleLink/sxToggleQr in base.html).
// ─────────────────────────────────────────────────────────────────────────────
function refreshLog(event) {