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:
23
manager/templates/_macros.html
Normal file
23
manager/templates/_macros.html
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{# Reusable SimpleX link box: a "Link" and a "QR" toggle button, both hidden by default.
|
||||||
|
linkbtns(id) — just the two toggle buttons (for table action cells)
|
||||||
|
linkpanels(link,id) — the hidden link + QR containers (place where they can span)
|
||||||
|
linkbox(link,id) — buttons + panels together (for block contexts)
|
||||||
|
`id` must be unique on the page. JS lives in base.html (sxToggleLink/sxToggleQr/sxCopy). #}
|
||||||
|
|
||||||
|
{% macro linkbtns(id) %}
|
||||||
|
<button class="lb-btn" onclick="sxToggleLink('{{ id }}', this)"><i class="fa-solid fa-link"></i> Link</button>
|
||||||
|
<button class="lb-btn" onclick="sxToggleQr('{{ id }}', this)"><i class="fa-solid fa-qrcode"></i> QR</button>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% macro linkpanels(link, id) %}
|
||||||
|
<div id="lb-link-{{ id }}" class="lb-link" style="display:none;">
|
||||||
|
<button class="btn btn-ghost copy-btn" title="Copy" onclick="sxCopy('{{ id }}', this)"><i class="fa-solid fa-copy"></i></button>
|
||||||
|
<a class="addr-link" id="lb-url-{{ id }}" href="{{ link }}" target="_blank" rel="noopener">{{ link }}</a>
|
||||||
|
</div>
|
||||||
|
<div id="lb-qr-{{ id }}" class="lb-qr" style="display:none;"><canvas id="lb-qrc-{{ id }}"></canvas></div>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% macro linkbox(link, id) %}
|
||||||
|
<div class="flex gap-8">{{ linkbtns(id) }}</div>
|
||||||
|
{{ linkpanels(link, id) }}
|
||||||
|
{% endmacro %}
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap" rel="stylesheet">
|
||||||
<script src="https://unpkg.com/htmx.org@1.9.12/dist/htmx.min.js"></script>
|
<script src="https://unpkg.com/htmx.org@1.9.12/dist/htmx.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/qrcode/build/qrcode.min.js"></script>
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('htmx:configRequest', function(evt) {
|
document.addEventListener('htmx:configRequest', function(evt) {
|
||||||
const m = document.cookie.match(/(?:^|;\s*)token=([^;]+)/);
|
const m = document.cookie.match(/(?:^|;\s*)token=([^;]+)/);
|
||||||
@@ -279,6 +280,20 @@
|
|||||||
dialog { background: var(--card); color: var(--text); border: 1px solid var(--border);
|
dialog { background: var(--card); color: var(--text); border: 1px solid var(--border);
|
||||||
border-radius: 12px; padding: 28px; max-width: 480px; width: 90%; }
|
border-radius: 12px; padding: 28px; max-width: 480px; width: 90%; }
|
||||||
dialog::backdrop { background: rgba(0,0,0,0.5); }
|
dialog::backdrop { background: rgba(0,0,0,0.5); }
|
||||||
|
|
||||||
|
/* reusable SimpleX link box: Link + QR toggles, both hidden by default */
|
||||||
|
.lb-btn { display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
padding: 4px 10px; font-size: 12px; font-weight: 600; border-radius: 6px;
|
||||||
|
background: transparent; border: 1px solid var(--border); color: var(--accent);
|
||||||
|
cursor: pointer; font-family: inherit; }
|
||||||
|
.lb-btn:hover { background: var(--bg); }
|
||||||
|
.lb-btn.on { background: var(--accent); color: var(--btn-light-text); border-color: var(--accent); }
|
||||||
|
.lb-link { display: flex; align-items: center; gap: 8px; margin-top: 8px; }
|
||||||
|
.lb-link .addr-link { flex: 1; min-width: 0; color: var(--muted); font-family: monospace;
|
||||||
|
font-size: 12px; text-decoration: none; word-break: break-all; }
|
||||||
|
.lb-link .addr-link:hover { color: var(--accent); text-decoration: underline; }
|
||||||
|
.lb-qr { margin-top: 10px; }
|
||||||
|
.lb-qr canvas { background: #fff; border-radius: 8px; padding: 8px; }
|
||||||
</style>
|
</style>
|
||||||
{% block head %}{% endblock %}
|
{% block head %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
@@ -347,6 +362,46 @@
|
|||||||
localStorage.setItem('sidebar-collapsed', collapsed ? '1' : '');
|
localStorage.setItem('sidebar-collapsed', collapsed ? '1' : '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clipboard that also works over plain-HTTP LAN (navigator.clipboard needs a secure context).
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
function flashCheck(btn) {
|
||||||
|
const o = btn.innerHTML;
|
||||||
|
btn.innerHTML = '<i class="fa-solid fa-check"></i>';
|
||||||
|
setTimeout(() => btn.innerHTML = o, 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reusable SimpleX link box: Link + QR toggles (both hidden by default).
|
||||||
|
function _sxUrl(id) { const a = document.getElementById('lb-url-' + id); return a ? a.getAttribute('href') : ''; }
|
||||||
|
function sxToggleLink(id, btn) {
|
||||||
|
const el = document.getElementById('lb-link-' + id); if (!el) return;
|
||||||
|
const show = el.style.display === 'none';
|
||||||
|
el.style.display = show ? '' : 'none';
|
||||||
|
btn.classList.toggle('on', show);
|
||||||
|
}
|
||||||
|
function sxToggleQr(id, btn) {
|
||||||
|
const w = document.getElementById('lb-qr-' + id); if (!w) return;
|
||||||
|
const show = w.style.display === 'none';
|
||||||
|
w.style.display = show ? '' : 'none';
|
||||||
|
btn.classList.toggle('on', show);
|
||||||
|
if (show && !w.dataset.r && window.QRCode) {
|
||||||
|
QRCode.toCanvas(document.getElementById('lb-qrc-' + id), _sxUrl(id), {width: 180}, () => {});
|
||||||
|
w.dataset.r = '1';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function sxCopy(id, btn) { robustCopy(_sxUrl(id)).then(() => flashCheck(btn)); }
|
||||||
|
|
||||||
// Sidebar clock: 24h time + day-of-week date
|
// Sidebar clock: 24h time + day-of-week date
|
||||||
function tickClock() {
|
function tickClock() {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
{% import "_macros.html" as ui %}
|
||||||
{% block title %}{{ 'Business Groups' if tab == 'businesses' else tab | title }} — SimpleX Manager{% endblock %}
|
{% block title %}{{ 'Business Groups' if tab == 'businesses' else tab | title }} — SimpleX Manager{% endblock %}
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
@@ -95,11 +96,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% if p.address %}
|
{% if p.address %}
|
||||||
<div class="addr-row" onclick="event.stopPropagation()">
|
<div onclick="event.stopPropagation()" style="margin-top:10px;">{{ ui.linkbox(p.address, 'p' ~ p.id) }}</div>
|
||||||
<button class="btn btn-ghost copy-btn" title="Copy address"
|
|
||||||
onclick="copyAddr(event, this, '{{ p.address | e }}')"><i class="fa-solid fa-copy"></i></button>
|
|
||||||
<a class="addr-link" href="{{ p.address }}" target="_blank" rel="noopener">{{ p.address }}</a>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -344,27 +341,7 @@ function onAvatarChange(input) {
|
|||||||
reader.readAsDataURL(file);
|
reader.readAsDataURL(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clipboard that also works over plain-HTTP LAN (navigator.clipboard needs a secure context).
|
// robustCopy and the SimpleX link box (sx*) live in base.html (shared).
|
||||||
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);
|
|
||||||
}
|
|
||||||
function copyAddr(ev, btn, addr) {
|
|
||||||
ev.stopPropagation();
|
|
||||||
robustCopy(addr).then(() => {
|
|
||||||
btn.innerHTML = '<i class="fa-solid fa-check"></i>';
|
|
||||||
setTimeout(() => btn.innerHTML = '<i class="fa-solid fa-copy"></i>', 1500);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
{% if tab == 'bots' %}
|
{% if tab == 'bots' %}
|
||||||
function onTypeChange() {
|
function onTypeChange() {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
{% import "_macros.html" as ui %}
|
||||||
{% block title %}{{ profile.name }} — SimpleX Manager{% endblock %}
|
{% block title %}{{ profile.name }} — SimpleX Manager{% endblock %}
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
@@ -84,23 +85,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Address / QR -->
|
<!-- Address -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Address</h2>
|
<h2 style="margin-bottom:12px;">Address</h2>
|
||||||
{% if profile.address %}
|
{% if profile.address %}
|
||||||
<div class="addr-row">
|
{{ ui.linkbox(profile.address, 'addr') }}
|
||||||
<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>
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="muted">Start the profile to generate an address.</p>
|
<p class="muted">Start the profile to generate an address.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -176,20 +165,20 @@
|
|||||||
<td>{{ name }}</td>
|
<td>{{ name }}</td>
|
||||||
<td>
|
<td>
|
||||||
{% if invited %}
|
{% 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 %}
|
{% else %}
|
||||||
<button class="msg-btn" style="border:none;padding:0;background:none;color:var(--accent);font-weight:600;font-size:13px;cursor:pointer;"
|
<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>
|
onclick="loadMembers({{ gid }}, '{{ name | e }}')">{{ mcnt }}</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="flex gap-8">
|
<div class="flex gap-8" style="flex-wrap:wrap;">
|
||||||
{% if invited %}
|
{% if invited %}
|
||||||
<button class="msg-btn" onclick="joinGroup({{ gid }}, this)">Join</button>
|
<button class="msg-btn" onclick="joinGroup({{ gid }}, this)">Join</button>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a class="msg-btn" style="text-decoration:none;"
|
<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>
|
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>
|
<button class="msg-btn msg-btn-danger" onclick="leaveGroup({{ gid }}, '{{ name | e }}', this)">Leave</button>
|
||||||
{% if is_owner %}
|
{% if is_owner %}
|
||||||
<button class="msg-btn msg-btn-danger" onclick="deleteGroup({{ gid }}, '{{ name | e }}', this)">Delete</button>
|
<button class="msg-btn msg-btn-danger" onclick="deleteGroup({{ gid }}, '{{ name | e }}', this)">Delete</button>
|
||||||
@@ -198,15 +187,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr id="link-row-{{ gid }}" style="display:none;">
|
{% if g.link %}
|
||||||
<td colspan="3" style="padding-top:0;">
|
<tr><td colspan="3" style="border:none;padding:0 12px 4px;">{{ ui.linkpanels(g.link, 'g' ~ gid) }}</td></tr>
|
||||||
<div class="addr-row" style="margin:0;">
|
{% endif %}
|
||||||
<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>
|
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
<!-- Groups -->
|
<!-- Groups -->
|
||||||
@@ -378,21 +361,7 @@ async function sendMsg() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clipboard that also works over plain-HTTP LAN (navigator.clipboard needs a
|
// robustCopy/fallbackCopy live in base.html (shared). Directory-website URL copy:
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
function copyAddr(btn, addr) {
|
function copyAddr(btn, addr) {
|
||||||
robustCopy(addr).then(() => {
|
robustCopy(addr).then(() => {
|
||||||
btn.innerHTML = '<i class="fa-solid fa-check"></i>';
|
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')); }
|
else { btn.disabled = false; btn.textContent = 'Leave'; alert('Failed to leave: ' + (data.detail || 'unknown')); }
|
||||||
}
|
}
|
||||||
|
|
||||||
function copyGroupLinkBtn(gid, btn) {
|
// Group/channel links use the shared link box (sxToggleLink/sxToggleQr in base.html).
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function refreshLog(event) {
|
function refreshLog(event) {
|
||||||
|
|||||||
Reference in New Issue
Block a user