Add chat actions, channels/groups mgmt, directory + deadmans bots, network status
Profiles/bots (profiles.py, main.py): - Surface real send errors; fix group send and member-count staleness (refresh on view) - Group/channel actions: join, leave, owner delete; consistent member counts - Contacts: always-visible Chat, plus Clear chat and Delete contact - Support bot: OpenAI-compatible LLM backend (Grok/Ollama/OpenAI) per-bot config - Deadmans bot: check-in window, trigger message, recipients, owner - Directory bot: add-to-group registration, super-user /approve /reject /list, search, publishes listing.json in the website schema (directory.py registry module) - Profile edit (name/bio/avatar) + avatars on list pages - Global status + /network page (operators, SMP/XFTP servers) and Settings network info UI (templates): - Chat rooms; collapsible left sidebar with notifications + network widget - Per-directory-bot website generated on creation (name substituted) - Matrix theme; copy/hyperlink addresses; site footer Ignore runtime bot state and generated directory sites. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -107,17 +107,39 @@
|
||||
|
||||
.side-nav { display: flex; flex-direction: column; padding: 8px 0; }
|
||||
.side-nav a {
|
||||
position: relative;
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 11px 18px; color: var(--muted); text-decoration: none;
|
||||
font-size: 14px; font-weight: 600; white-space: nowrap; overflow: hidden;
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
.notif-badge {
|
||||
margin-left: auto; min-width: 18px; height: 18px; padding: 0 5px;
|
||||
border-radius: 9px; background: var(--red); color: #fff;
|
||||
font-size: 11px; font-weight: 700;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
}
|
||||
html.collapsed .notif-badge {
|
||||
position: absolute; top: 5px; right: 8px; margin: 0;
|
||||
min-width: 16px; height: 16px; font-size: 10px; padding: 0 4px;
|
||||
}
|
||||
.side-nav a:hover { color: var(--text); background: var(--bg); }
|
||||
.side-nav a.active { color: var(--accent); border-left-color: var(--accent); }
|
||||
.side-nav .ico { width: 20px; text-align: center; font-size: 16px; flex-shrink: 0; }
|
||||
.side-nav a.nav-sep { margin-top: 10px; padding-top: 17px; border-top: 1px solid var(--border); }
|
||||
|
||||
.side-foot { margin-top: auto; padding: 8px 0; border-top: 1px solid var(--border); }
|
||||
|
||||
.side-status { padding: 10px 18px 12px; font-size: 12px; color: var(--muted);
|
||||
border-bottom: 1px solid var(--border); }
|
||||
.side-status .ss-title { font-weight: 700; text-transform: uppercase; font-size: 10px;
|
||||
letter-spacing: 0.5px; margin-bottom: 7px; opacity: 0.7; }
|
||||
.side-status .ss-row { display: flex; align-items: center; gap: 6px; margin-top: 3px;
|
||||
white-space: nowrap; overflow: hidden; }
|
||||
.ss-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); flex-shrink: 0; }
|
||||
.ss-dot.online { background: var(--green); box-shadow: 0 0 5px var(--green); }
|
||||
.ss-dot.offline { background: var(--red); }
|
||||
html.collapsed .side-status { display: none; }
|
||||
.collapse-btn {
|
||||
display: flex; align-items: center; gap: 12px; width: 100%;
|
||||
padding: 11px 18px; background: none; border: none; cursor: pointer;
|
||||
@@ -246,9 +268,17 @@
|
||||
<a href="/users" {% if nav_active == 'users' %}class="active"{% endif %}><span class="ico">👤</span><span class="lbl">Users</span></a>
|
||||
<a href="/bots" {% if nav_active == 'bots' %}class="active"{% endif %}><span class="ico">🤖</span><span class="lbl">Bots</span></a>
|
||||
<a href="https://simplex.chat/file/" target="_blank" rel="noopener"><span class="ico">📁</span><span class="lbl">File upload</span></a>
|
||||
<a href="/notifications" class="nav-sep {% if nav_active == 'notifications' %}active{% endif %}"><span class="ico">🔔</span><span class="lbl">Notifications</span><span class="notif-badge" id="notif-badge" style="display:none;"></span></a>
|
||||
<a href="/settings" class="nav-sep {% if nav_active == 'settings' %}active{% endif %}"><span class="ico">⚙️</span><span class="lbl">Settings</span></a>
|
||||
</nav>
|
||||
<div class="side-foot">
|
||||
<a href="/network" class="side-status" id="side-status" title="View SimpleX network & servers"
|
||||
style="display:block;text-decoration:none;{% if nav_active == 'network' %}background:var(--bg);{% endif %}">
|
||||
<div class="ss-title">Network ›</div>
|
||||
<div class="ss-row"><span class="ss-dot" id="ss-dot"></span><span id="ss-running">–/–</span> running</div>
|
||||
<div class="ss-row" id="ss-servers">📡 –</div>
|
||||
<div class="ss-row" id="ss-ops" style="opacity:0.8;"></div>
|
||||
</a>
|
||||
<button class="collapse-btn" onclick="toggleCollapse()" title="Collapse sidebar" aria-label="Collapse sidebar">
|
||||
<span class="ico" id="collapse-ico">‹</span>
|
||||
</button>
|
||||
@@ -285,6 +315,41 @@
|
||||
const ico = document.getElementById('collapse-ico');
|
||||
if (ico && document.documentElement.classList.contains('collapsed')) ico.textContent = '›';
|
||||
})();
|
||||
|
||||
// Poll for unread notifications and update the sidebar badge
|
||||
async function pollNotifications() {
|
||||
try {
|
||||
const t = document.cookie.match(/(?:^|;\s*)token=([^;]+)/)?.[1] || '';
|
||||
const r = await fetch('/api/notifications', { headers: { 'X-Token': t } });
|
||||
if (!r.ok) return;
|
||||
const d = await r.json();
|
||||
const b = document.getElementById('notif-badge');
|
||||
if (!b) return;
|
||||
if (d.unread > 0) { b.textContent = d.unread > 99 ? '99+' : d.unread; b.style.display = 'inline-flex'; }
|
||||
else { b.style.display = 'none'; }
|
||||
} catch (e) {}
|
||||
}
|
||||
pollNotifications();
|
||||
setInterval(pollNotifications, 5000);
|
||||
|
||||
// Poll global SimpleX/network status for the sidebar widget
|
||||
async function pollStatus() {
|
||||
try {
|
||||
const t = document.cookie.match(/(?:^|;\s*)token=([^;]+)/)?.[1] || '';
|
||||
const r = await fetch('/api/status', { headers: { 'X-Token': t } });
|
||||
if (!r.ok) return;
|
||||
const d = await r.json();
|
||||
document.getElementById('ss-running').textContent = d.profiles_running + '/' + d.profiles_total;
|
||||
const dot = document.getElementById('ss-dot');
|
||||
dot.className = 'ss-dot ' + (d.online ? 'online' : 'offline');
|
||||
document.getElementById('ss-servers').textContent =
|
||||
d.online ? `📡 ${d.smp_servers} SMP · ${d.xftp_servers} XFTP` : 'no profile running';
|
||||
document.getElementById('ss-ops').textContent =
|
||||
(d.operators && d.operators.length) ? d.operators.join(', ') : '';
|
||||
} catch (e) {}
|
||||
}
|
||||
pollStatus();
|
||||
setInterval(pollStatus, 15000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -140,7 +140,7 @@ async function sendMsg() {
|
||||
const data = await resp.json();
|
||||
if (!data.ok) {
|
||||
input.value = text; // restore on failure
|
||||
alert('Failed to send');
|
||||
alert('Failed to send: ' + (data.error || data.detail || 'unknown error'));
|
||||
return;
|
||||
}
|
||||
setTimeout(() => loadMessages(true), 250); // reflect the sent message quickly
|
||||
|
||||
@@ -49,6 +49,10 @@
|
||||
onclick="location.href='/profile/{{ p.id }}'">
|
||||
<div class="flex-between">
|
||||
<div class="flex gap-8">
|
||||
{% if p.config.avatar %}
|
||||
<img src="{{ p.config.avatar }}" alt=""
|
||||
style="width:32px;height:32px;border-radius:50%;object-fit:cover;border:1px solid var(--border);flex-shrink:0;">
|
||||
{% endif %}
|
||||
<strong>{{ p.name }}</strong>
|
||||
<span class="tag {% if p.bot_type == 'user' %}tag-user{% endif %}">{{ p.bot_type }}</span>
|
||||
<span class="badge {% if p.running %}badge-green{% else %}badge-red{% endif %}"
|
||||
@@ -98,6 +102,17 @@
|
||||
<label>Name</label>
|
||||
<input type="text" name="name" placeholder="{{ 'Alice' if tab == 'users' else 'My Bot' }}" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Bio / Description <span class="muted" style="font-weight:400;">(optional)</span></label>
|
||||
<textarea name="bio" rows="2" placeholder="A short description shown on the profile"></textarea>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Avatar <span class="muted" style="font-weight:400;">(optional image)</span></label>
|
||||
<div class="flex gap-8">
|
||||
<img id="avatar-preview" alt="" style="display:none;width:48px;height:48px;border-radius:50%;object-fit:cover;border:1px solid var(--border);">
|
||||
<input type="file" name="avatar_file" accept="image/*" onchange="onAvatarChange(this)" style="flex:1;">
|
||||
</div>
|
||||
</div>
|
||||
{% if tab == 'bots' %}
|
||||
<div class="field">
|
||||
<label>Bot Type</label>
|
||||
@@ -111,6 +126,63 @@
|
||||
<label>Welcome Message</label>
|
||||
<input type="text" name="welcome_message" placeholder="Welcome! How can I help?">
|
||||
</div>
|
||||
<div id="support-fields" style="display:none;">
|
||||
<div style="border-top:1px solid var(--border);margin:4px 0 14px;padding-top:14px;">
|
||||
<p class="muted" style="margin-bottom:12px;">
|
||||
LLM backend (OpenAI-compatible). Leave the URL blank for a static welcome-only bot.
|
||||
</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>API Base URL</label>
|
||||
<input type="text" name="api_base" placeholder="https://api.x.ai/v1 (Ollama: http://localhost:11434/v1)">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>API Key</label>
|
||||
<input type="password" name="api_key" placeholder="xai-… (any value for Ollama)">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Model</label>
|
||||
<input type="text" name="model" placeholder="grok-2 (Ollama: llama3.2)">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>System Prompt</label>
|
||||
<textarea name="system_prompt" rows="3" placeholder="You are a helpful customer-support assistant…"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div id="deadmans-fields" style="display:none;">
|
||||
<div style="border-top:1px solid var(--border);margin:4px 0 14px;padding-top:14px;">
|
||||
<p class="muted" style="margin-bottom:12px;">
|
||||
Fires a message to recipients if no check-in arrives in time. Check in by messaging the bot.
|
||||
</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Check-in window (hours)</label>
|
||||
<input type="number" name="checkin_hours" min="0.1" step="0.1" value="24">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Trigger message</label>
|
||||
<textarea name="dms_message" rows="2" placeholder="If you receive this, I haven't checked in…"></textarea>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Recipients <span class="muted" style="font-weight:400;">(comma-separated names; blank = all contacts)</span></label>
|
||||
<input type="text" name="recipients" placeholder="Alice, Bob">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Owner <span class="muted" style="font-weight:400;">(only this contact's messages count as check-in; blank = anyone)</span></label>
|
||||
<input type="text" name="owner" placeholder="Alice">
|
||||
</div>
|
||||
</div>
|
||||
<div id="directory-fields" style="display:none;">
|
||||
<div style="border-top:1px solid var(--border);margin:4px 0 14px;padding-top:14px;">
|
||||
<p class="muted" style="margin-bottom:12px;">
|
||||
Group owners register by adding this bot to their group. Listings stay pending until a super-user approves.
|
||||
</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Super-users <span class="muted" style="font-weight:400;">(comma-separated contact names who can /approve)</span></label>
|
||||
<input type="text" name="superusers" placeholder="Alice, Bob">
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="flex gap-8 mt-16" style="justify-content:flex-end;">
|
||||
<button type="button" class="btn btn-ghost"
|
||||
@@ -131,12 +203,43 @@ function updateStatus(id, event) {
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
let avatarDataUri = '';
|
||||
|
||||
function openCreate() {
|
||||
document.getElementById('create-form').reset();
|
||||
avatarDataUri = '';
|
||||
const prev = document.getElementById('avatar-preview');
|
||||
prev.style.display = 'none'; prev.src = '';
|
||||
{% if tab == 'bots' %}onTypeChange();{% endif %}
|
||||
document.getElementById('create-dialog').showModal();
|
||||
}
|
||||
|
||||
// Read an image file, downscale it to a small square data URI (avatars are sent
|
||||
// over the wire to every contact, so keep them tiny). Stores result in avatarDataUri.
|
||||
function onAvatarChange(input) {
|
||||
const file = input.files && input.files[0];
|
||||
if (!file) { avatarDataUri = ''; return; }
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const size = 256;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = size; canvas.height = size;
|
||||
const ctx = canvas.getContext('2d');
|
||||
// center-crop to square
|
||||
const m = Math.min(img.width, img.height);
|
||||
const sx = (img.width - m) / 2, sy = (img.height - m) / 2;
|
||||
ctx.drawImage(img, sx, sy, m, m, 0, 0, size, size);
|
||||
avatarDataUri = canvas.toDataURL('image/jpeg', 0.85);
|
||||
const prev = document.getElementById('avatar-preview');
|
||||
prev.src = avatarDataUri; prev.style.display = 'block';
|
||||
};
|
||||
img.src = e.target.result;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
function copyAddr(ev, btn, addr) {
|
||||
ev.stopPropagation();
|
||||
navigator.clipboard.writeText(addr).then(() => {
|
||||
@@ -148,8 +251,10 @@ function copyAddr(ev, btn, addr) {
|
||||
{% if tab == 'bots' %}
|
||||
function onTypeChange() {
|
||||
const val = document.getElementById('type-select').value;
|
||||
const hide = ['echo'].includes(val); // echo has no welcome msg
|
||||
document.getElementById('welcome-field').style.display = hide ? 'none' : '';
|
||||
document.getElementById('welcome-field').style.display = (val === 'echo') ? 'none' : '';
|
||||
document.getElementById('support-fields').style.display = (val === 'support') ? 'block' : 'none';
|
||||
document.getElementById('deadmans-fields').style.display = (val === 'deadmans') ? 'block' : 'none';
|
||||
document.getElementById('directory-fields').style.display = (val === 'directory') ? 'block' : 'none';
|
||||
}
|
||||
{% endif %}
|
||||
|
||||
@@ -164,7 +269,35 @@ document.getElementById('create-form').addEventListener('submit', async (e) => {
|
||||
const config = {};
|
||||
const welcome = fd.get('welcome_message');
|
||||
if (welcome) config.welcome_message = welcome;
|
||||
if (botType === 'support') {
|
||||
const apiBase = (fd.get('api_base') || '').trim();
|
||||
if (apiBase) config.api_base = apiBase;
|
||||
const apiKey = (fd.get('api_key') || '').trim();
|
||||
if (apiKey) config.api_key = apiKey;
|
||||
const model = (fd.get('model') || '').trim();
|
||||
if (model) config.model = model;
|
||||
const sysPrompt = (fd.get('system_prompt') || '').trim();
|
||||
if (sysPrompt) config.system_prompt = sysPrompt;
|
||||
}
|
||||
if (botType === 'deadmans') {
|
||||
const hrs = parseFloat(fd.get('checkin_hours'));
|
||||
if (!isNaN(hrs) && hrs > 0) config.checkin_hours = hrs;
|
||||
const dmsMsg = (fd.get('dms_message') || '').trim();
|
||||
if (dmsMsg) config.message = dmsMsg;
|
||||
const recips = (fd.get('recipients') || '').split(',').map(s => s.trim()).filter(Boolean);
|
||||
if (recips.length) config.recipients = recips;
|
||||
const owner = (fd.get('owner') || '').trim();
|
||||
if (owner) config.owner = owner;
|
||||
}
|
||||
if (botType === 'directory') {
|
||||
const su = (fd.get('superusers') || '').split(',').map(s => s.trim()).filter(Boolean);
|
||||
if (su.length) config.superusers = su;
|
||||
}
|
||||
{% endif %}
|
||||
// Shared profile fields (users and bots)
|
||||
const bio = (fd.get('bio') || '').trim();
|
||||
if (bio) config.bio = bio;
|
||||
if (avatarDataUri) config.avatar = avatarDataUri;
|
||||
const token = document.cookie.match(/(?:^|;\s*)token=([^;]+)/)?.[1] || '';
|
||||
const resp = await fetch('/api/profiles', {
|
||||
method: 'POST',
|
||||
|
||||
86
manager/templates/network.html
Normal file
86
manager/templates/network.html
Normal file
@@ -0,0 +1,86 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Network — SimpleX Manager{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<style>
|
||||
.srv-table td { padding: 6px 12px; }
|
||||
.srv-host { font-family: monospace; font-size: 12px; }
|
||||
.srv-off { opacity: 0.5; }
|
||||
.op-sub { font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.4px;
|
||||
color: var(--muted); margin: 14px 0 6px; }
|
||||
.net-table td:first-child { color: var(--muted); width: 45%; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Network</h1>
|
||||
|
||||
{% if not detail.profile_name %}
|
||||
<div class="card" style="text-align:center;padding:48px;color:var(--muted);">
|
||||
No running profile. Start a profile to view SMP/XFTP servers and network status.
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="muted" style="margin-bottom:16px;">
|
||||
Servers and network config for <strong>{{ detail.profile_name }}</strong> (SimpleX presets are shared across profiles).
|
||||
</p>
|
||||
|
||||
{% if detail.network %}
|
||||
<div class="card">
|
||||
<h2>Network configuration</h2>
|
||||
<table class="net-table">
|
||||
<tr><td>SMP proxy mode</td><td>{{ detail.network.smpProxyMode | default('—', true) }}</td></tr>
|
||||
<tr><td>SMP proxy fallback</td><td>{{ detail.network.smpProxyFallback | default('—', true) }}</td></tr>
|
||||
<tr><td>Host mode</td><td>{{ detail.network.hostMode | default('—', true) }}</td></tr>
|
||||
<tr><td>Required host mode</td><td>{{ detail.network.requiredHostMode | default('—', true) }}</td></tr>
|
||||
<tr><td>Session mode</td><td>{{ detail.network.sessionMode | default('—', true) }}</td></tr>
|
||||
<tr><td>TCP connect timeout</td><td>{{ detail.network.tcpConnectTimeout | default('—', true) }}</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% for op in detail.operators %}
|
||||
<div class="card">
|
||||
<div class="flex-between" style="margin-bottom:6px;">
|
||||
<h2 style="margin:0;">{{ op.name }}</h2>
|
||||
<span class="badge {% if op.enabled %}badge-green{% else %}badge-red{% endif %}">
|
||||
{{ 'enabled' if op.enabled else 'disabled' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="op-sub">SMP — messaging ({{ op.smp | length }})</div>
|
||||
{% if op.smp %}
|
||||
<table class="srv-table">
|
||||
{% for s in op.smp %}
|
||||
<tr class="{% if not s.enabled or s.deleted %}srv-off{% endif %}">
|
||||
<td class="srv-host">{{ s.host }}</td>
|
||||
<td style="text-align:right;">
|
||||
{% if s.preset %}<span class="tag">preset</span>{% endif %}
|
||||
{% if s.deleted %}<span class="badge badge-red">deleted</span>
|
||||
{% elif s.enabled %}<span class="badge badge-green">on</span>
|
||||
{% else %}<span class="badge badge-red">off</span>{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% else %}<p class="muted">None.</p>{% endif %}
|
||||
|
||||
<div class="op-sub">XFTP — files ({{ op.xftp | length }})</div>
|
||||
{% if op.xftp %}
|
||||
<table class="srv-table">
|
||||
{% for s in op.xftp %}
|
||||
<tr class="{% if not s.enabled or s.deleted %}srv-off{% endif %}">
|
||||
<td class="srv-host">{{ s.host }}</td>
|
||||
<td style="text-align:right;">
|
||||
{% if s.preset %}<span class="tag">preset</span>{% endif %}
|
||||
{% if s.deleted %}<span class="badge badge-red">deleted</span>
|
||||
{% elif s.enabled %}<span class="badge badge-green">on</span>
|
||||
{% else %}<span class="badge badge-red">off</span>{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% else %}<p class="muted">None.</p>{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
63
manager/templates/notifications.html
Normal file
63
manager/templates/notifications.html
Normal file
@@ -0,0 +1,63 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Notifications — SimpleX Manager{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<style>
|
||||
.notif-item { display: block; padding: 14px 18px; border-bottom: 1px solid var(--border);
|
||||
text-decoration: none; color: var(--text); border-left: 3px solid transparent; }
|
||||
.notif-item:last-child { border-bottom: none; }
|
||||
.notif-item:hover { background: var(--bg); }
|
||||
.notif-item.unread { border-left-color: var(--accent); }
|
||||
.notif-text { margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 540px; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex-between" style="margin-bottom: 24px;">
|
||||
<h1 style="margin:0;">Notifications</h1>
|
||||
{% if items %}
|
||||
<button class="btn btn-ghost" onclick="markAllRead()">Mark all read</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if items %}
|
||||
<div class="card" style="padding:0;">
|
||||
{% for n in items %}
|
||||
<a class="notif-item {% if not n.read %}unread{% endif %}"
|
||||
href="/profile/{{ n.profile_id }}/chat/{{ n.chat_type }}/{{ n.chat_id }}">
|
||||
<div class="flex-between">
|
||||
<div style="min-width:0;">
|
||||
<div><strong>{{ n.sender or 'Someone' }}</strong> <span class="muted">→ {{ n.profile_name }}</span></div>
|
||||
<div class="muted notif-text">{{ n.text }}</div>
|
||||
</div>
|
||||
<span class="muted notif-time" data-ts="{{ n.ts }}" style="flex-shrink:0;margin-left:12px;"></span>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card" style="text-align:center;padding:48px;color:var(--muted);">
|
||||
No notifications yet. Incoming messages across all accounts will appear here.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
function _ntoken(){ return document.cookie.match(/(?:^|;\s*)token=([^;]+)/)?.[1] || ''; }
|
||||
|
||||
async function markAllRead() {
|
||||
await fetch('/api/notifications/read', { method: 'POST', headers: { 'X-Token': _ntoken() } });
|
||||
location.reload();
|
||||
}
|
||||
|
||||
// Localize timestamps
|
||||
document.querySelectorAll('.notif-time').forEach(el => {
|
||||
const d = new Date(el.dataset.ts);
|
||||
if (!isNaN(d)) el.textContent = d.toLocaleString([], {month:'short', day:'numeric', hour:'2-digit', minute:'2-digit'});
|
||||
});
|
||||
|
||||
// Mark read shortly after viewing so the badge clears (keeps this view's highlights)
|
||||
setTimeout(() => {
|
||||
fetch('/api/notifications/read', { method: 'POST', headers: { 'X-Token': _ntoken() } });
|
||||
}, 1200);
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -6,8 +6,6 @@
|
||||
.qr-wrap { text-align: center; padding: 16px; }
|
||||
.qr-wrap canvas { border-radius: 8px; }
|
||||
|
||||
.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;
|
||||
@@ -17,6 +15,8 @@
|
||||
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;
|
||||
@@ -56,6 +56,34 @@
|
||||
<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>
|
||||
@@ -78,15 +106,29 @@
|
||||
{% 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')">📋</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() %}
|
||||
<tr><td>{{ k }}</td><td>{{ v }}</td></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 config set.</td></tr>
|
||||
<tr><td colspan="2" class="muted">No extra config set.</td></tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
@@ -104,8 +146,14 @@
|
||||
<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>
|
||||
<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 }}">💬 Chat</a>
|
||||
<button class="msg-btn" title="Clear conversation"
|
||||
onclick="clearChat('direct', {{ c.contactId }}, '{{ c.localDisplayName | e }}')">🧹 Clear</button>
|
||||
<button class="msg-btn msg-btn-danger" title="Delete contact"
|
||||
onclick="deleteContact({{ c.contactId }}, '{{ c.localDisplayName | e }}')">🗑 Delete</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
@@ -122,17 +170,31 @@
|
||||
{% 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">
|
||||
<a class="msg-btn row-action" style="text-decoration:none;"
|
||||
{% 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 }}">💬 {{ 'Broadcast' if g.is_channel else 'Chat' }}</a>
|
||||
<button class="msg-btn row-action" onclick="getGroupLink({{ gid }}, this)">Link</button>
|
||||
<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>
|
||||
@@ -227,6 +289,35 @@
|
||||
</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>
|
||||
@@ -285,6 +376,88 @@ function copyAddr(btn, addr) {
|
||||
});
|
||||
}
|
||||
|
||||
// ── 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';
|
||||
@@ -367,6 +540,38 @@ async function loadMembers(groupId, groupName) {
|
||||
</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')); }
|
||||
}
|
||||
|
||||
async function getGroupLink(groupId, btn) {
|
||||
const orig = btn.textContent;
|
||||
btn.textContent = '…';
|
||||
|
||||
@@ -133,6 +133,21 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card settings-section">
|
||||
<h2>Network</h2>
|
||||
{% if network %}
|
||||
<table>
|
||||
<tr><td style="color:var(--muted);width:45%;">SMP proxy mode</td><td>{{ network.smpProxyMode | default('—', true) }}</td></tr>
|
||||
<tr><td style="color:var(--muted);">SMP proxy fallback</td><td>{{ network.smpProxyFallback | default('—', true) }}</td></tr>
|
||||
<tr><td style="color:var(--muted);">Host mode</td><td>{{ network.hostMode | default('—', true) }}</td></tr>
|
||||
<tr><td style="color:var(--muted);">Session mode</td><td>{{ network.sessionMode | default('—', true) }}</td></tr>
|
||||
</table>
|
||||
<p class="muted" style="margin-top:12px;">Read-only here. <a href="/network" style="color:var(--accent);">View full server list →</a></p>
|
||||
{% else %}
|
||||
<p class="muted">Start a profile to view network configuration.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function currentTheme() {
|
||||
return localStorage.getItem('theme') ||
|
||||
|
||||
Reference in New Issue
Block a user