navigator.clipboard only works in a secure context, so copy silently failed when served over a LAN IP on http. Add a robustCopy() with a textarea+execCommand fallback (used by group-link, address and channel-link copy). The group/channel 'Link' button now toggles a visible, selectable URL row beneath the group with a working copy button. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
658 lines
27 KiB
HTML
658 lines
27 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}{{ profile.name }} — SimpleX Manager{% endblock %}
|
|
|
|
{% block head %}
|
|
<style>
|
|
.qr-wrap { text-align: center; padding: 16px; }
|
|
.qr-wrap canvas { border-radius: 8px; }
|
|
|
|
|
|
.msg-btn {
|
|
padding: 3px 10px; font-size: 12px; border-radius: 6px;
|
|
background: transparent; border: 1px solid var(--border);
|
|
color: var(--accent); cursor: pointer; font-weight: 600;
|
|
font-family: inherit; white-space: nowrap;
|
|
transition: background 0.15s, color 0.15s;
|
|
}
|
|
.msg-btn:hover { background: var(--accent); color: var(--btn-light-text); }
|
|
.msg-btn-danger { color: var(--red); border-color: var(--red); }
|
|
.msg-btn-danger:hover { background: var(--red); color: #fff; }
|
|
|
|
.addr-row { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
|
|
.addr-link { flex: 1; min-width: 0; color: var(--muted); font-family: monospace; font-size: 12px;
|
|
text-decoration: none; word-break: break-all; }
|
|
.addr-link:hover { color: var(--accent); text-decoration: underline; }
|
|
.copy-btn { flex-shrink: 0; padding: 4px 9px; font-size: 13px; line-height: 1; }
|
|
</style>
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="flex-between" style="margin-bottom: 20px;">
|
|
<div class="flex gap-8">
|
|
<a href="{{ back }}" class="muted" style="text-decoration:none;">← {{ 'Users' if back == '/users' else ('Business Groups' if back == '/businesses' else 'Bots') }}</a>
|
|
<span class="muted">/</span>
|
|
<strong>{{ profile.name }}</strong>
|
|
<span class="tag {% if profile.bot_type == 'user' %}tag-user{% endif %}">{{ profile.bot_type }}</span>
|
|
<span class="badge {% if profile.running %}badge-green{% else %}badge-red{% endif %}" id="status-badge">
|
|
{% if profile.running %}running{% else %}stopped{% endif %}
|
|
</span>
|
|
</div>
|
|
<div class="flex gap-8">
|
|
{% if profile.running %}
|
|
<button class="btn btn-danger"
|
|
hx-post="/api/profiles/{{ profile.id }}/stop"
|
|
hx-swap="none"
|
|
hx-on::after-request="location.reload()">Stop</button>
|
|
{% else %}
|
|
<button class="btn btn-success"
|
|
hx-post="/api/profiles/{{ profile.id }}/start"
|
|
hx-swap="none"
|
|
hx-on::after-request="location.reload()">Start</button>
|
|
{% endif %}
|
|
<button class="btn btn-danger" onclick="confirmDelete()">Delete</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="grid-2">
|
|
<!-- Left column -->
|
|
<div>
|
|
<!-- Profile -->
|
|
<div class="card">
|
|
<div class="flex-between" style="margin-bottom:14px;">
|
|
<h2 style="margin:0;">Profile</h2>
|
|
<button class="btn btn-ghost" style="padding:6px 14px;font-size:13px;" onclick="openEdit()">Edit</button>
|
|
</div>
|
|
<div class="flex gap-8" style="align-items:flex-start;">
|
|
{% if profile.config.avatar %}
|
|
<img src="{{ profile.config.avatar }}" alt="avatar"
|
|
style="width:64px;height:64px;border-radius:50%;object-fit:cover;border:1px solid var(--border);flex-shrink:0;">
|
|
{% else %}
|
|
<div style="width:64px;height:64px;border-radius:50%;background:var(--border);flex-shrink:0;
|
|
display:flex;align-items:center;justify-content:center;font-size:26px;font-weight:700;color:var(--muted);">
|
|
{{ profile.name[0] | upper }}
|
|
</div>
|
|
{% endif %}
|
|
<div style="min-width:0;">
|
|
<div style="font-weight:700;font-size:16px;">{{ profile.name }}</div>
|
|
{% if profile.config.full_name %}<div class="muted">{{ profile.config.full_name }}</div>{% endif %}
|
|
{% if profile.config.bio %}
|
|
<div style="margin-top:6px;font-size:14px;white-space:pre-wrap;">{{ profile.config.bio }}</div>
|
|
{% else %}
|
|
<div class="muted" style="margin-top:6px;">No bio set.</div>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Address / QR -->
|
|
<div class="card">
|
|
<h2>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>
|
|
{% else %}
|
|
<p class="muted">Start the profile to generate an address.</p>
|
|
{% endif %}
|
|
</div>
|
|
|
|
{% if profile.bot_type == 'directory' %}
|
|
{% set safe = profile.name | lower | replace(' ', '_') %}
|
|
<!-- Directory website -->
|
|
<div class="card">
|
|
<h2>Directory website</h2>
|
|
<p class="muted" style="margin-bottom:12px;">Auto-generated listing page for this directory bot.</p>
|
|
<div class="addr-row">
|
|
<button class="btn btn-ghost copy-btn" title="Copy URL"
|
|
onclick="copyAddr(this, location.origin + '/directory/{{ safe }}/index.html')"><i class="fa-solid fa-copy"></i></button>
|
|
<a class="addr-link" href="/directory/{{ safe }}/index.html" target="_blank" rel="noopener">/directory/{{ safe }}/index.html</a>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<!-- Config -->
|
|
<div class="card">
|
|
<h2>Config</h2>
|
|
<table>
|
|
<tr><th>Key</th><th>Value</th></tr>
|
|
{% for k, v in profile.config.items() if k not in ['avatar', 'bio', 'full_name'] %}
|
|
<tr><td>{{ k }}</td><td>{% if k == 'api_key' %}•••••••• (set){% else %}{{ v }}{% endif %}</td></tr>
|
|
{% else %}
|
|
<tr><td colspan="2" class="muted">No extra config set.</td></tr>
|
|
{% endfor %}
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Right column -->
|
|
<div>
|
|
<!-- Contacts -->
|
|
<div class="card">
|
|
<h2>Contacts ({{ contacts | length }})</h2>
|
|
{% if contacts %}
|
|
<table>
|
|
<tr><th>Name</th><th style="width:50px;"></th></tr>
|
|
{% for c in contacts %}
|
|
<tr>
|
|
<td><strong>{{ c.localDisplayName }}</strong></td>
|
|
<td>
|
|
<div class="flex gap-8" style="justify-content:flex-end;">
|
|
<a class="msg-btn" style="text-decoration:none;"
|
|
href="/profile/{{ profile.id }}/chat/direct/{{ c.contactId }}"><i class="fa-solid fa-comments"></i> Chat</a>
|
|
<button class="msg-btn" title="Clear conversation"
|
|
onclick="clearChat('direct', {{ c.contactId }}, '{{ c.localDisplayName | e }}')"><i class="fa-solid fa-broom"></i> Clear</button>
|
|
<button class="msg-btn msg-btn-danger" title="Delete contact"
|
|
onclick="deleteContact({{ c.contactId }}, '{{ c.localDisplayName | e }}')"><i class="fa-solid fa-trash"></i> Delete</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</table>
|
|
{% else %}
|
|
<p class="muted">No contacts yet.</p>
|
|
{% endif %}
|
|
</div>
|
|
|
|
{# Macro: one group/channel row. api_list_groups gives bare GroupInfo dicts:
|
|
g.groupId, g.groupProfile.displayName, g.groupSummary.currentMembers.
|
|
The verb is "Post" for channels (broadcast) and "Msg" for groups. #}
|
|
{% macro groupRow(g) %}
|
|
{% set name = g.groupProfile.displayName %}
|
|
{% set gid = g.groupId %}
|
|
{% set mcnt = g.groupSummary.currentMembers %}
|
|
{% set invited = (g.membership.memberStatus if g.membership else '') == 'invited' %}
|
|
{% set is_owner = (g.membership.memberRole if g.membership else '') == 'owner' %}
|
|
<tr>
|
|
<td>{{ name }}</td>
|
|
<td>
|
|
{% if invited %}
|
|
<span class="tag" title="You were invited but haven't joined yet">⏳ 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">
|
|
{% 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>
|
|
<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>
|
|
{% endif %}
|
|
{% endif %}
|
|
</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>
|
|
{% endmacro %}
|
|
|
|
<!-- Groups -->
|
|
<div class="card">
|
|
<div class="flex-between" style="margin-bottom:12px;">
|
|
<h2 style="margin:0;">Groups ({{ groups | length }})</h2>
|
|
{% if profile.running %}
|
|
<button class="btn btn-primary" style="padding:6px 14px;font-size:13px;"
|
|
onclick="openCreate('group')">+ Create Group</button>
|
|
{% endif %}
|
|
</div>
|
|
{% if groups %}
|
|
<table>
|
|
<tr><th>Name</th><th>Members</th><th style="width:130px;"></th></tr>
|
|
{% for g in groups %}{{ groupRow(g) }}{% endfor %}
|
|
</table>
|
|
{% else %}
|
|
<p class="muted">No groups yet.{% if not profile.running %} Start the profile first.{% endif %}</p>
|
|
{% endif %}
|
|
</div>
|
|
|
|
<!-- Channels -->
|
|
<div class="card">
|
|
<div class="flex-between" style="margin-bottom:12px;">
|
|
<h2 style="margin:0;">Channels ({{ channels | length }})</h2>
|
|
{% if profile.running %}
|
|
<button class="btn btn-primary" style="padding:6px 14px;font-size:13px;"
|
|
onclick="openCreate('channel')">+ Create Channel</button>
|
|
{% endif %}
|
|
</div>
|
|
{% if channels %}
|
|
<table>
|
|
<tr><th>Name</th><th>Subscribers</th><th style="width:130px;"></th></tr>
|
|
{% for g in channels %}{{ groupRow(g) }}{% endfor %}
|
|
</table>
|
|
{% else %}
|
|
<p class="muted">No channels yet.{% if not profile.running %} Start the profile first.{% endif %}</p>
|
|
{% endif %}
|
|
</div>
|
|
|
|
<!-- Event log -->
|
|
<div class="card">
|
|
<div class="flex-between" style="margin-bottom:10px;">
|
|
<h2 style="margin:0;">Event Log</h2>
|
|
<button class="btn btn-ghost" style="font-size:12px;padding:4px 10px;"
|
|
hx-get="/api/profiles/{{ profile.id }}/status"
|
|
hx-swap="none"
|
|
hx-on::after-request="refreshLog(event)">Refresh</button>
|
|
</div>
|
|
<div class="log-box" id="log-box">{% for line in log_lines %}{{ line }}
|
|
{% endfor %}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Create group/channel dialog -->
|
|
<dialog id="ch-dialog">
|
|
<h2 style="margin-bottom:16px;" id="ch-title">Create Group</h2>
|
|
<p class="muted" style="margin-bottom:16px;font-size:13px;" id="ch-desc"></p>
|
|
<div class="field">
|
|
<label id="ch-name-label">Group Name</label>
|
|
<input type="text" id="ch-name" placeholder="My Group" required>
|
|
</div>
|
|
<div id="ch-link-wrap" style="display:none;margin-bottom:12px;">
|
|
<label>Join Link</label>
|
|
<div class="flex gap-8">
|
|
<input type="text" id="ch-link-out" readonly style="font-family:monospace;font-size:12px;">
|
|
<button class="btn btn-ghost" style="white-space:nowrap;" onclick="copyChLink()">Copy</button>
|
|
</div>
|
|
</div>
|
|
<div class="flex-between mt-16">
|
|
<span id="ch-result" class="muted" style="font-size:13px;"></span>
|
|
<div class="flex gap-8">
|
|
<button class="btn btn-ghost" onclick="closeChDialog()">Close</button>
|
|
<button class="btn btn-primary" id="ch-create-btn" onclick="createGroup()">Create</button>
|
|
</div>
|
|
</div>
|
|
</dialog>
|
|
|
|
<!-- Members dialog -->
|
|
<dialog id="members-dialog">
|
|
<div class="flex-between" style="margin-bottom:16px;">
|
|
<h2 style="margin:0;">Members — <span id="members-channel-name" style="color:var(--accent);"></span></h2>
|
|
<button class="btn btn-ghost" style="padding:4px 10px;font-size:13px;"
|
|
onclick="document.getElementById('members-dialog').close()"><i class="fa-solid fa-xmark"></i></button>
|
|
</div>
|
|
<div id="members-list" style="max-height:320px;overflow-y:auto;">
|
|
<p class="muted">Loading…</p>
|
|
</div>
|
|
</dialog>
|
|
|
|
<!-- Edit profile dialog -->
|
|
<dialog id="edit-dialog">
|
|
<h2 style="margin-bottom:16px;">Edit Profile</h2>
|
|
<div class="field">
|
|
<label>Full Name <span class="muted" style="font-weight:400;">(optional)</span></label>
|
|
<input type="text" id="edit-fullname">
|
|
</div>
|
|
<div class="field">
|
|
<label>Bio / Description <span class="muted" style="font-weight:400;">(optional)</span></label>
|
|
<textarea id="edit-bio" rows="2"></textarea>
|
|
</div>
|
|
<div class="field">
|
|
<label>Avatar</label>
|
|
<div class="flex gap-8">
|
|
<img id="edit-avatar-preview" alt="" style="display:none;width:48px;height:48px;border-radius:50%;object-fit:cover;border:1px solid var(--border);">
|
|
<input type="file" accept="image/*" onchange="onEditAvatar(this)" style="flex:1;">
|
|
</div>
|
|
<button type="button" class="btn btn-ghost" style="margin-top:6px;font-size:12px;padding:4px 10px;"
|
|
onclick="removeEditAvatar()">Remove avatar</button>
|
|
</div>
|
|
<div class="flex-between mt-16">
|
|
<span id="edit-result" class="muted" style="font-size:13px;"></span>
|
|
<div class="flex gap-8">
|
|
<button class="btn btn-ghost" onclick="document.getElementById('edit-dialog').close()">Cancel</button>
|
|
<button class="btn btn-primary" onclick="saveProfile()">Save</button>
|
|
</div>
|
|
</div>
|
|
</dialog>
|
|
|
|
<!-- Send message dialog -->
|
|
<dialog id="msg-dialog">
|
|
<h2 style="margin-bottom:16px;">Message <span id="msg-target-label" style="color:var(--accent);"></span></h2>
|
|
<div class="field">
|
|
<textarea id="msg-text" rows="4" placeholder="Type your message…"
|
|
style="resize:vertical;"
|
|
onkeydown="if(event.key==='Enter'&&(event.ctrlKey||event.metaKey)){sendMsg();return false;}"></textarea>
|
|
</div>
|
|
<div class="flex-between mt-16">
|
|
<span id="msg-result" class="muted" style="font-size:13px;"></span>
|
|
<div class="flex gap-8">
|
|
<button class="btn btn-ghost" onclick="document.getElementById('msg-dialog').close()">Cancel</button>
|
|
<button class="btn btn-primary" onclick="sendMsg()">Send</button>
|
|
</div>
|
|
</div>
|
|
</dialog>
|
|
|
|
<script>
|
|
let msgTarget = '';
|
|
|
|
function openMsg(name) {
|
|
msgTarget = name;
|
|
document.getElementById('msg-target-label').textContent = name;
|
|
document.getElementById('msg-text').value = '';
|
|
document.getElementById('msg-result').textContent = '';
|
|
const dlg = document.getElementById('msg-dialog');
|
|
dlg.showModal();
|
|
setTimeout(() => document.getElementById('msg-text').focus(), 50);
|
|
}
|
|
|
|
async function sendMsg() {
|
|
const text = document.getElementById('msg-text').value.trim();
|
|
if (!text) return;
|
|
const result = document.getElementById('msg-result');
|
|
result.textContent = 'Sending…';
|
|
const token = document.cookie.match(/(?:^|;\s*)token=([^;]+)/)?.[1] || '';
|
|
const resp = await fetch('/api/profiles/{{ profile.id }}/send', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json', 'X-Token': token},
|
|
body: JSON.stringify({to: msgTarget, text})
|
|
});
|
|
const data = await resp.json();
|
|
if (data.ok) {
|
|
document.getElementById('msg-text').value = '';
|
|
result.textContent = '✓ Sent';
|
|
setTimeout(() => document.getElementById('msg-dialog').close(), 800);
|
|
} else {
|
|
result.textContent = '✗ Failed';
|
|
}
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
function copyAddr(btn, addr) {
|
|
robustCopy(addr).then(() => {
|
|
btn.innerHTML = '<i class="fa-solid fa-check"></i>';
|
|
setTimeout(() => btn.innerHTML = '<i class="fa-solid fa-copy"></i>', 1500);
|
|
});
|
|
}
|
|
|
|
// ── Contact actions ────────────────────────────────────────────────────────
|
|
function _ctoken() { return document.cookie.match(/(?:^|;\s*)token=([^;]+)/)?.[1] || ''; }
|
|
|
|
async function clearChat(type, id, name) {
|
|
if (!confirm('Clear the conversation with ' + name + '? Messages are removed; the contact stays.')) return;
|
|
const r = await fetch(`/api/profiles/{{ profile.id }}/chat/${type}/${id}/clear`, {
|
|
method: 'POST', headers: { 'X-Token': _ctoken() },
|
|
});
|
|
const d = await r.json();
|
|
if (d.ok) { alert('Conversation cleared.'); }
|
|
else { alert('Failed: ' + (d.detail || 'unknown')); }
|
|
}
|
|
|
|
async function deleteContact(id, name) {
|
|
if (!confirm('Delete contact ' + name + '? This removes them and your conversation.')) return;
|
|
const r = await fetch(`/api/profiles/{{ profile.id }}/contacts/${id}`, {
|
|
method: 'DELETE', headers: { 'X-Token': _ctoken() },
|
|
});
|
|
const d = await r.json();
|
|
if (d.ok) { location.reload(); }
|
|
else { alert('Failed: ' + (d.detail || 'unknown')); }
|
|
}
|
|
|
|
// ── Edit profile ───────────────────────────────────────────────────────────
|
|
// editAvatar: null = unchanged, '' = remove, dataURI = replace
|
|
let editAvatar = null;
|
|
|
|
function openEdit() {
|
|
editAvatar = null;
|
|
document.getElementById('edit-fullname').value = {{ (profile.config.full_name or '') | tojson }};
|
|
document.getElementById('edit-bio').value = {{ (profile.config.bio or '') | tojson }};
|
|
const prev = document.getElementById('edit-avatar-preview');
|
|
const cur = {{ (profile.config.avatar or '') | tojson }};
|
|
if (cur) { prev.src = cur; prev.style.display = 'block'; } else { prev.style.display = 'none'; prev.src = ''; }
|
|
document.getElementById('edit-result').textContent = '';
|
|
document.getElementById('edit-dialog').showModal();
|
|
}
|
|
|
|
function onEditAvatar(input) {
|
|
const file = input.files && input.files[0];
|
|
if (!file) return;
|
|
const reader = new FileReader();
|
|
reader.onload = (e) => {
|
|
const img = new Image();
|
|
img.onload = () => {
|
|
const size = 256, c = document.createElement('canvas');
|
|
c.width = size; c.height = size;
|
|
const ctx = c.getContext('2d');
|
|
const m = Math.min(img.width, img.height);
|
|
ctx.drawImage(img, (img.width - m) / 2, (img.height - m) / 2, m, m, 0, 0, size, size);
|
|
editAvatar = c.toDataURL('image/jpeg', 0.85);
|
|
const prev = document.getElementById('edit-avatar-preview');
|
|
prev.src = editAvatar; prev.style.display = 'block';
|
|
};
|
|
img.src = e.target.result;
|
|
};
|
|
reader.readAsDataURL(file);
|
|
}
|
|
|
|
function removeEditAvatar() {
|
|
editAvatar = '';
|
|
const prev = document.getElementById('edit-avatar-preview');
|
|
prev.style.display = 'none'; prev.src = '';
|
|
}
|
|
|
|
async function saveProfile() {
|
|
const body = {
|
|
full_name: document.getElementById('edit-fullname').value,
|
|
bio: document.getElementById('edit-bio').value,
|
|
};
|
|
if (editAvatar !== null) body.avatar = editAvatar; // only send if changed
|
|
document.getElementById('edit-result').textContent = 'Saving…';
|
|
const resp = await fetch('/api/profiles/{{ profile.id }}/profile', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json', 'X-Token': _token()},
|
|
body: JSON.stringify(body),
|
|
});
|
|
const data = await resp.json();
|
|
if (data.ok) { location.reload(); }
|
|
else { document.getElementById('edit-result').textContent = '✗ ' + (data.detail || 'Failed'); }
|
|
}
|
|
|
|
// ── Groups & Channels ──────────────────────────────────────────────────────
|
|
const _token = () => document.cookie.match(/(?:^|;\s*)token=([^;]+)/)?.[1] || '';
|
|
let _createKind = 'group';
|
|
|
|
function openCreate(kind) {
|
|
_createKind = kind;
|
|
const isCh = kind === 'channel';
|
|
document.getElementById('ch-title').textContent = isCh ? 'Create Channel' : 'Create Group';
|
|
document.getElementById('ch-desc').textContent = isCh
|
|
? 'Observer join link — subscribers can read broadcasts but not post. Only you broadcast.'
|
|
: 'Member join link — everyone who joins can send messages (2-way).';
|
|
document.getElementById('ch-name-label').textContent = isCh ? 'Channel Name' : 'Group Name';
|
|
document.getElementById('ch-name').placeholder = isCh ? 'My Channel' : 'My Group';
|
|
document.getElementById('ch-name').value = '';
|
|
document.getElementById('ch-link-wrap').style.display = 'none';
|
|
document.getElementById('ch-result').textContent = '';
|
|
const btn = document.getElementById('ch-create-btn');
|
|
btn.disabled = false; btn.style.display = '';
|
|
document.getElementById('ch-dialog').showModal();
|
|
}
|
|
|
|
async function createGroup() {
|
|
const name = document.getElementById('ch-name').value.trim();
|
|
if (!name) return;
|
|
const btn = document.getElementById('ch-create-btn');
|
|
btn.disabled = true;
|
|
document.getElementById('ch-result').textContent = 'Creating…';
|
|
const resp = await fetch('/api/profiles/{{ profile.id }}/groups', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json', 'X-Token': _token()},
|
|
body: JSON.stringify({name, kind: _createKind}),
|
|
});
|
|
const data = await resp.json();
|
|
if (data.ok) {
|
|
document.getElementById('ch-link-out').value = data.link;
|
|
document.getElementById('ch-link-wrap').style.display = '';
|
|
document.getElementById('ch-result').textContent = '✓ Created';
|
|
btn.style.display = 'none';
|
|
} else {
|
|
document.getElementById('ch-result').textContent = '✗ ' + (data.detail || 'Failed');
|
|
btn.disabled = false;
|
|
}
|
|
}
|
|
|
|
function copyChLink() {
|
|
const val = document.getElementById('ch-link-out').value;
|
|
robustCopy(val).then(() => {
|
|
document.getElementById('ch-result').textContent = '✓ Copied';
|
|
});
|
|
}
|
|
|
|
function closeChDialog() {
|
|
document.getElementById('ch-dialog').close();
|
|
location.reload(); // refresh group/channel lists
|
|
}
|
|
|
|
async function loadMembers(groupId, groupName) {
|
|
document.getElementById('members-channel-name').textContent = groupName;
|
|
document.getElementById('members-list').innerHTML = '<p class="muted">Loading…</p>';
|
|
document.getElementById('members-dialog').showModal();
|
|
const resp = await fetch(`/api/profiles/{{ profile.id }}/groups/${groupId}/members`, {
|
|
headers: {'X-Token': _token()},
|
|
});
|
|
const data = await resp.json();
|
|
if (!data.members || data.members.length === 0) {
|
|
document.getElementById('members-list').innerHTML =
|
|
'<p class="muted">No other members yet (you are the owner).</p>';
|
|
return;
|
|
}
|
|
const rows = data.members.map(m => `
|
|
<tr>
|
|
<td><strong>${m.name}</strong></td>
|
|
<td class="muted" style="font-size:12px;">${m.role}</td>
|
|
<td class="muted" style="font-size:12px;">${m.status}</td>
|
|
</tr>`).join('');
|
|
document.getElementById('members-list').innerHTML = `
|
|
<table>
|
|
<tr><th>Name</th><th>Role</th><th>Status</th></tr>
|
|
${rows}
|
|
</table>`;
|
|
}
|
|
|
|
async function joinGroup(groupId, btn) {
|
|
btn.textContent = 'Joining…'; btn.disabled = true;
|
|
const resp = await fetch(`/api/profiles/{{ profile.id }}/groups/${groupId}/join`, {
|
|
method: 'POST', headers: { 'X-Token': _token() },
|
|
});
|
|
const data = await resp.json();
|
|
if (data.ok) { location.reload(); }
|
|
else { btn.textContent = 'Join'; btn.disabled = false; alert('Failed to join: ' + (data.detail || 'unknown')); }
|
|
}
|
|
|
|
async function deleteGroup(groupId, name, btn) {
|
|
if (!confirm('Delete "' + name + '" for everyone? This removes the group/channel and notifies members.')) return;
|
|
btn.disabled = true; btn.textContent = 'Deleting…';
|
|
const resp = await fetch(`/api/profiles/{{ profile.id }}/groups/${groupId}`, {
|
|
method: 'DELETE', headers: { 'X-Token': _token() },
|
|
});
|
|
const data = await resp.json();
|
|
if (data.ok) { location.reload(); }
|
|
else { btn.disabled = false; btn.textContent = 'Delete'; alert('Failed to delete: ' + (data.detail || 'unknown')); }
|
|
}
|
|
|
|
async function leaveGroup(groupId, name, btn) {
|
|
if (!confirm('Leave "' + name + '"? You will stop receiving its messages.')) return;
|
|
btn.disabled = true; btn.textContent = 'Leaving…';
|
|
const resp = await fetch(`/api/profiles/{{ profile.id }}/groups/${groupId}/leave`, {
|
|
method: 'POST', headers: { 'X-Token': _token() },
|
|
});
|
|
const data = await resp.json();
|
|
if (data.ok) { location.reload(); }
|
|
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);
|
|
}
|
|
}
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
function refreshLog(event) {
|
|
try {
|
|
const data = JSON.parse(event.detail.xhr.responseText);
|
|
document.getElementById('log-box').textContent = data.log.join('\n');
|
|
document.getElementById('status-badge').textContent = data.running ? 'running' : 'stopped';
|
|
document.getElementById('status-badge').className = 'badge ' + (data.running ? 'badge-green' : 'badge-red');
|
|
} catch(e) {}
|
|
}
|
|
|
|
function confirmDelete() {
|
|
if (!confirm('Delete this profile? This cannot be undone.')) return;
|
|
const token = document.cookie.match(/(?:^|;\s*)token=([^;]+)/)?.[1] || '';
|
|
fetch('/api/profiles/{{ profile.id }}', {
|
|
method: 'DELETE',
|
|
headers: {'X-Token': token}
|
|
}).then(() => location.href = '/');
|
|
}
|
|
|
|
{% if profile.running %}
|
|
setInterval(() => {
|
|
document.querySelector('[hx-get="/api/profiles/{{ profile.id }}/status"]')?.click();
|
|
}, 10000);
|
|
{% endif %}
|
|
</script>
|
|
{% endblock %}
|