Add chat rooms, channels, sidebar nav, themes, and UI polish
Backend (profiles.py / main.py): - Fix bot startup crash: create active user before start_chat - Fix address-clobbering bug on restart (UserContactLink vs CreatedConnLink) - Add user profile type alongside bots - Channels: create groups with observer links, classify via acceptMemberRole - Chat rooms: get_chat_history + send_to_chat, history/messages/send routes UI: - Chat room view with message bubbles and live polling - Convert top nav to collapsible left sidebar (mobile-friendly off-canvas) - Three-way Users/Bots split; clickable cards; copy-address buttons + links - Add Matrix theme alongside Original Light/Dark - File upload sidebar link; site footer on all pages incl. login - Bot-type descriptions on the Bots page; QR caption on profiles Remove orphaned index.html (superseded by list.html). Ignore downloaded libs/, exploration DBs, and local ai.sh. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -5,17 +5,34 @@
|
||||
<style>
|
||||
.qr-wrap { text-align: center; padding: 16px; }
|
||||
.qr-wrap canvas { border-radius: 8px; }
|
||||
.contact-row td:first-child { font-weight: 600; }
|
||||
|
||||
.row-action { opacity: 0; transition: opacity 0.15s; }
|
||||
tr:hover .row-action { opacity: 1; }
|
||||
|
||||
.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); }
|
||||
|
||||
.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="/" class="muted" style="text-decoration:none;">← Profiles</a>
|
||||
<a href="{{ back }}" class="muted" style="text-decoration:none;">← {{ 'Users' if back == '/users' else 'Bots' }}</a>
|
||||
<span class="muted">/</span>
|
||||
<strong>{{ profile.name }}</strong>
|
||||
<span class="tag">{{ profile.bot_type }}</span>
|
||||
<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>
|
||||
@@ -43,16 +60,21 @@
|
||||
<div class="card">
|
||||
<h2>Address</h2>
|
||||
{% if profile.address %}
|
||||
<div class="monospace muted" style="word-break:break-all; margin-bottom:12px;">{{ profile.address }}</div>
|
||||
<div class="addr-row">
|
||||
<button class="btn btn-ghost copy-btn" title="Copy address"
|
||||
onclick="copyAddr(this, '{{ profile.address | e }}')">📋</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 bot to generate an address.</p>
|
||||
<p class="muted">Start the profile to generate an address.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -72,37 +94,19 @@
|
||||
|
||||
<!-- Right column -->
|
||||
<div>
|
||||
<!-- Send message -->
|
||||
<div class="card">
|
||||
<h2>Send Message</h2>
|
||||
<form id="send-form">
|
||||
<div class="field">
|
||||
<label>To (contact or group name)</label>
|
||||
<input type="text" name="to" placeholder="Alice" list="contact-list">
|
||||
<datalist id="contact-list">
|
||||
{% for c in contacts %}<option value="{{ c.localDisplayName }}">{% endfor %}
|
||||
{% for g in groups %}<option value="{{ g.groupInfo.groupProfile.displayName }}">{% endfor %}
|
||||
</datalist>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Message</label>
|
||||
<textarea name="text" rows="3" placeholder="Hello…"></textarea>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Send</button>
|
||||
<span id="send-result" class="muted" style="margin-left:10px;"></span>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Contacts -->
|
||||
<div class="card">
|
||||
<h2>Contacts ({{ contacts | length }})</h2>
|
||||
{% if contacts %}
|
||||
<table>
|
||||
<tr><th>Name</th><th>ID</th></tr>
|
||||
<tr><th>Name</th><th style="width:50px;"></th></tr>
|
||||
{% for c in contacts %}
|
||||
<tr class="contact-row">
|
||||
<td>{{ c.localDisplayName }}</td>
|
||||
<td class="muted monospace">{{ c.contactId }}</td>
|
||||
<tr>
|
||||
<td><strong>{{ c.localDisplayName }}</strong></td>
|
||||
<td>
|
||||
<a class="msg-btn row-action" style="text-decoration:none;"
|
||||
href="/profile/{{ profile.id }}/chat/direct/{{ c.contactId }}">💬 Chat</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
@@ -111,21 +115,64 @@
|
||||
{% 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 %}
|
||||
<tr>
|
||||
<td>{{ name }}</td>
|
||||
<td>
|
||||
<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>
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex gap-8">
|
||||
<a class="msg-btn row-action" style="text-decoration:none;"
|
||||
href="/profile/{{ profile.id }}/chat/group/{{ gid }}">💬 {{ 'Broadcast' if g.is_channel else 'Chat' }}</a>
|
||||
<button class="msg-btn row-action" onclick="getGroupLink({{ gid }}, this)">Link</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endmacro %}
|
||||
|
||||
<!-- Groups -->
|
||||
<div class="card">
|
||||
<h2>Groups ({{ groups | length }})</h2>
|
||||
<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></tr>
|
||||
{% for g in groups %}
|
||||
<tr>
|
||||
<td>{{ g.groupInfo.groupProfile.displayName }}</td>
|
||||
<td class="muted">{{ g.members | length }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
<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.</p>
|
||||
<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>
|
||||
|
||||
@@ -144,46 +191,221 @@
|
||||
</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()">✕</button>
|
||||
</div>
|
||||
<div id="members-list" style="max-height:320px;overflow-y:auto;">
|
||||
<p class="muted">Loading…</p>
|
||||
</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>
|
||||
document.getElementById('send-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault()
|
||||
const fd = new FormData(e.target)
|
||||
const result = document.getElementById('send-result')
|
||||
result.textContent = 'Sending…'
|
||||
const token = document.cookie.match(/(?:^|;\s*)token=([^;]+)/)?.[1] || ''
|
||||
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: fd.get('to'), text: fd.get('text')})
|
||||
})
|
||||
const data = await resp.json()
|
||||
result.textContent = data.ok ? '✓ Sent' : '✗ Failed'
|
||||
setTimeout(() => result.textContent = '', 3000)
|
||||
})
|
||||
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';
|
||||
}
|
||||
}
|
||||
|
||||
function copyAddr(btn, addr) {
|
||||
navigator.clipboard.writeText(addr).then(() => {
|
||||
btn.textContent = '✓';
|
||||
setTimeout(() => btn.textContent = '📋', 1500);
|
||||
});
|
||||
}
|
||||
|
||||
// ── 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;
|
||||
navigator.clipboard.writeText(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 getGroupLink(groupId, btn) {
|
||||
const orig = btn.textContent;
|
||||
btn.textContent = '…';
|
||||
const resp = await fetch(`/api/profiles/{{ profile.id }}/groups/${groupId}/link`, {
|
||||
headers: {'X-Token': _token()},
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.link) {
|
||||
await navigator.clipboard.writeText(data.link);
|
||||
btn.textContent = '✓ Copied';
|
||||
} else {
|
||||
btn.textContent = 'No link';
|
||||
}
|
||||
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')
|
||||
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] || ''
|
||||
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 = '/')
|
||||
}).then(() => location.href = '/');
|
||||
}
|
||||
|
||||
// Auto-refresh log every 10s if running
|
||||
{% if profile.running %}
|
||||
setInterval(() => {
|
||||
document.querySelector('[hx-get="/api/profiles/{{ profile.id }}/status"]')?.click()
|
||||
}, 10000)
|
||||
document.querySelector('[hx-get="/api/profiles/{{ profile.id }}/status"]')?.click();
|
||||
}, 10000);
|
||||
{% endif %}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user