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:
@@ -1,12 +1,19 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<script>
|
||||
(function(){
|
||||
var t=localStorage.getItem('theme');
|
||||
if(!t){t=window.matchMedia('(prefers-color-scheme:dark)').matches?'original-dark':'original-light';}
|
||||
document.documentElement.setAttribute('data-theme',t);
|
||||
if(localStorage.getItem('sidebar-collapsed')) document.documentElement.classList.add('collapsed');
|
||||
})();
|
||||
</script>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}SimpleX Manager{% endblock %}</title>
|
||||
<script src="https://unpkg.com/htmx.org@1.9.12/dist/htmx.min.js"></script>
|
||||
<script>
|
||||
// Inject auth token into every HTMX request automatically
|
||||
document.addEventListener('htmx:configRequest', function(evt) {
|
||||
const m = document.cookie.match(/(?:^|;\s*)token=([^;]+)/);
|
||||
if (m) evt.detail.headers['X-Token'] = m[1];
|
||||
@@ -15,43 +22,147 @@
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--bg: #f5f5f7;
|
||||
--card: #ffffff;
|
||||
--text: #1d1d1f;
|
||||
--muted: #6e6e73;
|
||||
--accent: #0053D0;
|
||||
--green: #20BD3D;
|
||||
--red: #DD0000;
|
||||
--border: #e0e0e5;
|
||||
--shadow: 0 2px 12px rgba(0,0,0,0.08);
|
||||
/* ── Original Light ─────────────────────────────────────────────── */
|
||||
[data-theme="original-light"] {
|
||||
--bg: #f5f5f7;
|
||||
--card: #ffffff;
|
||||
--text: #1d1d1f;
|
||||
--muted: #6e6e73;
|
||||
--accent: #0053D0;
|
||||
--green: #20BD3D;
|
||||
--red: #DD0000;
|
||||
--border: #e0e0e5;
|
||||
--shadow: 0 2px 12px rgba(0,0,0,0.08);
|
||||
--btn-light-text: #fff;
|
||||
--badge-green-bg: #d1fae5;
|
||||
--badge-green-text: #065f46;
|
||||
--badge-red-bg: #fee2e2;
|
||||
--badge-red-text: #991b1b;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #111827;
|
||||
--card: #0B2A59;
|
||||
--text: #f5f5f7;
|
||||
--muted: #9ca3af;
|
||||
--accent: #70F0F9;
|
||||
--border: #1e3a5f;
|
||||
--shadow: none;
|
||||
}
|
||||
/* ── Original Dark ──────────────────────────────────────────────── */
|
||||
[data-theme="original-dark"] {
|
||||
--bg: #111827;
|
||||
--card: #0B2A59;
|
||||
--text: #f5f5f7;
|
||||
--muted: #9ca3af;
|
||||
--accent: #70F0F9;
|
||||
--green: #20BD3D;
|
||||
--red: #DD0000;
|
||||
--border: #1e3a5f;
|
||||
--shadow: none;
|
||||
--btn-light-text: #000;
|
||||
--badge-green-bg: #064e3b;
|
||||
--badge-green-text: #6ee7b7;
|
||||
--badge-red-bg: #7f1d1d;
|
||||
--badge-red-text: #fca5a5;
|
||||
}
|
||||
|
||||
/* ── Matrix ─────────────────────────────────────────────────────── */
|
||||
[data-theme="matrix"] {
|
||||
--bg: #000000;
|
||||
--card: #050d05;
|
||||
--text: #00ff41;
|
||||
--muted: #2e8b57;
|
||||
--accent: #00ff41;
|
||||
--green: #00ff41;
|
||||
--red: #ff3b3b;
|
||||
--border: #0f3d0f;
|
||||
--shadow: 0 0 14px rgba(0,255,65,0.12);
|
||||
--btn-light-text: #000000;
|
||||
--badge-green-bg: #002200;
|
||||
--badge-green-text: #00ff41;
|
||||
--badge-red-bg: #220000;
|
||||
--badge-red-text: #ff6b6b;
|
||||
}
|
||||
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif;
|
||||
background: var(--bg); color: var(--text); min-height: 100vh; }
|
||||
|
||||
nav { background: var(--card); border-bottom: 1px solid var(--border);
|
||||
padding: 12px 24px; display: flex; align-items: center; justify-content: space-between;
|
||||
position: sticky; top: 0; z-index: 10; }
|
||||
[data-theme="matrix"] body {
|
||||
font-family: 'SF Mono', 'Consolas', 'Courier New', monospace;
|
||||
text-shadow: 0 0 2px rgba(0,255,65,0.4);
|
||||
}
|
||||
|
||||
.nav-brand { font-size: 17px; font-weight: 700; color: var(--accent); text-decoration: none; }
|
||||
/* ── Layout: sidebar + main ─────────────────────────────────────── */
|
||||
.app { display: flex; min-height: 100vh; }
|
||||
|
||||
.nav-links a { color: var(--muted); text-decoration: none; font-size: 14px; margin-left: 16px; }
|
||||
.nav-links a:hover { color: var(--accent); }
|
||||
.sidebar {
|
||||
width: 220px; flex-shrink: 0;
|
||||
background: var(--card); border-right: 1px solid var(--border);
|
||||
display: flex; flex-direction: column;
|
||||
position: sticky; top: 0; height: 100vh;
|
||||
transition: width 0.2s ease, transform 0.2s ease;
|
||||
z-index: 50;
|
||||
}
|
||||
html.collapsed .sidebar { width: 64px; }
|
||||
|
||||
.container { max-width: 960px; margin: 0 auto; padding: 32px 20px; }
|
||||
.nav-brand {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 18px; font-size: 16px; font-weight: 700;
|
||||
color: var(--accent); text-decoration: none;
|
||||
border-bottom: 1px solid var(--border); white-space: nowrap; overflow: hidden;
|
||||
}
|
||||
.nav-brand .brand-icon { font-size: 18px; flex-shrink: 0; }
|
||||
|
||||
.side-nav { display: flex; flex-direction: column; padding: 8px 0; }
|
||||
.side-nav a {
|
||||
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;
|
||||
}
|
||||
.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); }
|
||||
.collapse-btn {
|
||||
display: flex; align-items: center; gap: 12px; width: 100%;
|
||||
padding: 11px 18px; background: none; border: none; cursor: pointer;
|
||||
color: var(--muted); font-family: inherit; font-size: 13px; font-weight: 600;
|
||||
white-space: nowrap; overflow: hidden;
|
||||
}
|
||||
.collapse-btn:hover { color: var(--text); }
|
||||
.collapse-btn .ico { width: 20px; text-align: center; flex-shrink: 0; }
|
||||
|
||||
html.collapsed .lbl, html.collapsed .brand-text { display: none; }
|
||||
|
||||
.main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
||||
.container { max-width: 960px; margin: 0 auto; padding: 32px 20px; width: 100%; flex: 1 0 auto; }
|
||||
|
||||
.site-footer {
|
||||
flex-shrink: 0; text-align: center;
|
||||
padding: 18px 20px; border-top: 1px solid var(--border);
|
||||
color: var(--muted); font-size: 12px; line-height: 1.6;
|
||||
}
|
||||
.site-footer a { color: var(--accent); text-decoration: none; font-weight: 600; }
|
||||
.site-footer a:hover { text-decoration: underline; }
|
||||
.site-footer .sep { margin: 0 8px; opacity: 0.5; }
|
||||
|
||||
.mobile-menu-btn {
|
||||
display: none; position: fixed; top: 12px; left: 12px; z-index: 40;
|
||||
width: 40px; height: 40px; border-radius: 8px; border: 1px solid var(--border);
|
||||
background: var(--card); color: var(--text); font-size: 18px; cursor: pointer;
|
||||
align-items: center; justify-content: center;
|
||||
}
|
||||
.backdrop { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 45; }
|
||||
|
||||
/* ── Mobile: off-canvas sidebar ─────────────────────────────────── */
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
position: fixed; left: 0; top: 0; height: 100vh; width: 240px;
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
html.collapsed .sidebar { width: 240px; } /* ignore collapse on mobile */
|
||||
html.collapsed .lbl, html.collapsed .brand-text { display: inline; }
|
||||
body.sidebar-open .sidebar { transform: translateX(0); }
|
||||
body.sidebar-open .backdrop { display: block; }
|
||||
.mobile-menu-btn { display: flex; }
|
||||
.collapse-btn { display: none; }
|
||||
.container { padding-top: 64px; }
|
||||
}
|
||||
|
||||
h1 { font-size: 28px; font-weight: 700; margin-bottom: 24px; }
|
||||
h2 { font-size: 20px; font-weight: 600; margin-bottom: 16px; }
|
||||
@@ -64,25 +175,15 @@
|
||||
font-family: inherit; cursor: pointer; border: none; text-decoration: none;
|
||||
transition: opacity 0.15s; }
|
||||
.btn:hover { opacity: 0.85; }
|
||||
.btn-primary { background: var(--accent); color: #fff; }
|
||||
.btn-primary { background: var(--accent); color: var(--btn-light-text); }
|
||||
.btn-danger { background: var(--red); color: #fff; }
|
||||
.btn-success { background: var(--green); color: #fff; }
|
||||
.btn-success { background: var(--green); color: var(--btn-light-text); }
|
||||
.btn-ghost { background: transparent; border: 1px solid var(--border); color: var(--text); }
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.btn-primary { color: #000; }
|
||||
.btn-success { color: #000; }
|
||||
}
|
||||
|
||||
.badge { display: inline-block; padding: 2px 8px; border-radius: 10px;
|
||||
font-size: 12px; font-weight: 600; }
|
||||
.badge-green { background: #d1fae5; color: #065f46; }
|
||||
.badge-red { background: #fee2e2; color: #991b1b; }
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.badge-green { background: #064e3b; color: #6ee7b7; }
|
||||
.badge-red { background: #7f1d1d; color: #fca5a5; }
|
||||
}
|
||||
.badge-green { background: var(--badge-green-bg); color: var(--badge-green-text); }
|
||||
.badge-red { background: var(--badge-red-bg); color: var(--badge-red-text); }
|
||||
|
||||
input, select, textarea {
|
||||
width: 100%; padding: 9px 12px; font-size: 14px; font-family: inherit;
|
||||
@@ -108,6 +209,9 @@
|
||||
|
||||
.tag { display: inline-block; padding: 2px 8px; border-radius: 6px;
|
||||
font-size: 12px; background: var(--border); color: var(--muted); }
|
||||
.tag-user { background: rgba(0,83,208,0.12); color: var(--accent); }
|
||||
[data-theme="original-dark"] .tag-user { background: rgba(112,240,249,0.12); color: var(--accent); }
|
||||
[data-theme="matrix"] .tag-user { background: rgba(0,255,65,0.12); color: var(--accent); }
|
||||
|
||||
.flex { display: flex; align-items: center; gap: 10px; }
|
||||
.flex-between { display: flex; align-items: center; justify-content: space-between; }
|
||||
@@ -132,15 +236,55 @@
|
||||
{% block head %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<nav>
|
||||
<a class="nav-brand" href="/">SimpleX Manager</a>
|
||||
<div class="nav-links">
|
||||
<a href="/">Profiles</a>
|
||||
<a href="/logout">Logout</a>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="container">
|
||||
{% block content %}{% endblock %}
|
||||
<button class="mobile-menu-btn" onclick="toggleSidebar()" aria-label="Menu">☰</button>
|
||||
<div class="app">
|
||||
<aside class="sidebar" id="sidebar">
|
||||
<a class="nav-brand" href="/users">
|
||||
<span class="brand-icon">◆</span><span class="brand-text">SimpleX Manager</span>
|
||||
</a>
|
||||
<nav class="side-nav">
|
||||
<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="/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">
|
||||
<button class="collapse-btn" onclick="toggleCollapse()" title="Collapse sidebar" aria-label="Collapse sidebar">
|
||||
<span class="ico" id="collapse-ico">‹</span>
|
||||
</button>
|
||||
<nav class="side-nav">
|
||||
<a href="/logout"><span class="ico">⏻</span><span class="lbl">Logout</span></a>
|
||||
</nav>
|
||||
</div>
|
||||
</aside>
|
||||
<div class="backdrop" id="backdrop" onclick="closeSidebar()"></div>
|
||||
<main class="main">
|
||||
<div class="container">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
<footer class="site-footer">
|
||||
© Bournemouth Technology Ltd
|
||||
<span class="sep">·</span>
|
||||
built on © SimpleX Network
|
||||
<span class="sep">·</span>
|
||||
<a href="https://simplex.chat/downloads/" target="_blank" rel="noopener">Get SimpleX App</a>
|
||||
</footer>
|
||||
</main>
|
||||
</div>
|
||||
<script>
|
||||
function toggleSidebar() { document.body.classList.toggle('sidebar-open'); }
|
||||
function closeSidebar() { document.body.classList.remove('sidebar-open'); }
|
||||
function toggleCollapse() {
|
||||
const collapsed = document.documentElement.classList.toggle('collapsed');
|
||||
localStorage.setItem('sidebar-collapsed', collapsed ? '1' : '');
|
||||
const ico = document.getElementById('collapse-ico');
|
||||
if (ico) ico.textContent = collapsed ? '›' : '‹';
|
||||
}
|
||||
// Sync collapse icon with restored state on load
|
||||
(function(){
|
||||
const ico = document.getElementById('collapse-ico');
|
||||
if (ico && document.documentElement.classList.contains('collapsed')) ico.textContent = '›';
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
154
manager/templates/chat.html
Normal file
154
manager/templates/chat.html
Normal file
@@ -0,0 +1,154 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ chat_name }} — SimpleX Manager{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<style>
|
||||
.chat-wrap {
|
||||
display: flex; flex-direction: column;
|
||||
height: calc(100vh - 140px); min-height: 400px;
|
||||
background: var(--card); border-radius: 10px; box-shadow: var(--shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
.chat-head {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 14px 18px; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.chat-head .title { font-weight: 700; font-size: 16px; }
|
||||
|
||||
.chat-log {
|
||||
flex: 1; overflow-y: auto; padding: 18px;
|
||||
display: flex; flex-direction: column; gap: 8px;
|
||||
}
|
||||
.bubble {
|
||||
max-width: 72%; padding: 8px 12px; border-radius: 14px;
|
||||
font-size: 14px; line-height: 1.4; word-wrap: break-word; white-space: pre-wrap;
|
||||
}
|
||||
.bubble .who { font-size: 11px; font-weight: 700; opacity: 0.7; margin-bottom: 2px; }
|
||||
.bubble .ts { font-size: 10px; opacity: 0.55; margin-top: 3px; text-align: right; }
|
||||
.bubble.in { align-self: flex-start; background: var(--bg); border: 1px solid var(--border); }
|
||||
.bubble.out { align-self: flex-end; background: var(--accent); color: var(--btn-light-text); }
|
||||
.bubble.deleted { font-style: italic; opacity: 0.5; }
|
||||
|
||||
.chat-compose {
|
||||
display: flex; gap: 8px; padding: 12px; border-top: 1px solid var(--border);
|
||||
}
|
||||
.chat-compose textarea { resize: none; height: 42px; }
|
||||
.chat-empty { text-align: center; color: var(--muted); margin: auto; font-size: 14px; }
|
||||
.chat-banner { padding: 8px 18px; font-size: 12px; color: var(--muted);
|
||||
background: var(--bg); border-bottom: 1px solid var(--border); }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex gap-8" style="margin-bottom:16px;">
|
||||
<a href="/profile/{{ profile.id }}" class="muted" style="text-decoration:none;">← {{ profile.name }}</a>
|
||||
<span class="muted">/</span>
|
||||
<strong>{{ chat_name }}</strong>
|
||||
<span class="tag">{{ 'channel' if is_channel else chat_type }}</span>
|
||||
</div>
|
||||
|
||||
<div class="chat-wrap">
|
||||
<div class="chat-head">
|
||||
<span class="title">{{ chat_name }}</span>
|
||||
<button class="btn btn-ghost" style="padding:4px 12px;font-size:12px;"
|
||||
onclick="loadMessages(true)">↻ Refresh</button>
|
||||
</div>
|
||||
|
||||
{% if is_channel %}
|
||||
<div class="chat-banner">📢 Channel — messages you send here broadcast to all subscribers.</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="chat-log" id="chat-log">
|
||||
{% if not running %}
|
||||
<div class="chat-empty">Profile is stopped. Start it to load messages.</div>
|
||||
{% else %}
|
||||
<div class="chat-empty" id="chat-loading">Loading messages…</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="chat-compose">
|
||||
<textarea id="msg-input" placeholder="{{ 'Broadcast a message…' if is_channel else 'Type a message…' }}"
|
||||
{% if not running %}disabled{% endif %}
|
||||
onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();sendMsg();}"></textarea>
|
||||
<button class="btn btn-primary" onclick="sendMsg()" {% if not running %}disabled{% endif %}>Send</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const PROFILE_ID = {{ profile.id }};
|
||||
const CHAT_TYPE = '{{ chat_type }}';
|
||||
const CHAT_ID = {{ chat_id }};
|
||||
const RUNNING = {{ 'true' if running else 'false' }};
|
||||
const _token = () => document.cookie.match(/(?:^|;\s*)token=([^;]+)/)?.[1] || '';
|
||||
|
||||
let lastIds = ''; // signature of rendered messages, to skip needless re-renders
|
||||
|
||||
function fmtTs(iso) {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d)) return '';
|
||||
return d.toLocaleString([], {month:'short', day:'numeric', hour:'2-digit', minute:'2-digit'});
|
||||
}
|
||||
|
||||
function render(messages) {
|
||||
const log = document.getElementById('chat-log');
|
||||
const sig = messages.map(m => m.id).join(',');
|
||||
if (sig === lastIds) return; // nothing new
|
||||
const atBottom = log.scrollHeight - log.scrollTop - log.clientHeight < 60;
|
||||
lastIds = sig;
|
||||
|
||||
if (!messages.length) {
|
||||
log.innerHTML = '<div class="chat-empty">No messages yet.</div>';
|
||||
return;
|
||||
}
|
||||
log.innerHTML = messages.map(m => {
|
||||
const cls = 'bubble ' + (m.outgoing ? 'out' : 'in') + (m.deleted ? ' deleted' : '');
|
||||
const who = (!m.outgoing && m.sender) ? `<div class="who">${escapeHtml(m.sender)}</div>` : '';
|
||||
const txt = m.deleted ? '(deleted)' : escapeHtml(m.text || '');
|
||||
return `<div class="${cls}">${who}${txt}<div class="ts">${fmtTs(m.ts)}</div></div>`;
|
||||
}).join('');
|
||||
if (atBottom) log.scrollTop = log.scrollHeight;
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return s.replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
}
|
||||
|
||||
async function loadMessages(force) {
|
||||
if (!RUNNING) return;
|
||||
try {
|
||||
const resp = await fetch(`/api/profiles/${PROFILE_ID}/chat/${CHAT_TYPE}/${CHAT_ID}/messages?count=80`, {
|
||||
headers: {'X-Token': _token()},
|
||||
});
|
||||
if (!resp.ok) return;
|
||||
const data = await resp.json();
|
||||
if (force) lastIds = '';
|
||||
render(data.messages || []);
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
async function sendMsg() {
|
||||
const input = document.getElementById('msg-input');
|
||||
const text = input.value.trim();
|
||||
if (!text) return;
|
||||
input.value = '';
|
||||
const resp = await fetch(`/api/profiles/${PROFILE_ID}/chat/${CHAT_TYPE}/${CHAT_ID}/send`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json', 'X-Token': _token()},
|
||||
body: JSON.stringify({text}),
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (!data.ok) {
|
||||
input.value = text; // restore on failure
|
||||
alert('Failed to send');
|
||||
return;
|
||||
}
|
||||
setTimeout(() => loadMessages(true), 250); // reflect the sent message quickly
|
||||
}
|
||||
|
||||
if (RUNNING) {
|
||||
loadMessages(true);
|
||||
setInterval(loadMessages, 3000); // live updates via polling
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,111 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Profiles — SimpleX Manager{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex-between" style="margin-bottom: 24px;">
|
||||
<h1 style="margin:0">Bot Profiles</h1>
|
||||
<button class="btn btn-primary" onclick="document.getElementById('create-dialog').showModal()">+ New Profile</button>
|
||||
</div>
|
||||
|
||||
{% if profiles %}
|
||||
<div id="profile-list">
|
||||
{% for p in profiles %}
|
||||
<div class="card" id="profile-{{ p.id }}">
|
||||
<div class="flex-between">
|
||||
<div class="flex gap-8">
|
||||
<strong>{{ p.name }}</strong>
|
||||
<span class="tag">{{ p.bot_type }}</span>
|
||||
<span class="badge {% if p.running %}badge-green{% else %}badge-red{% endif %}"
|
||||
id="status-{{ p.id }}"
|
||||
hx-get="/api/profiles/{{ p.id }}/status"
|
||||
hx-trigger="every 5s"
|
||||
hx-swap="none"
|
||||
hx-on::after-request="updateStatus({{ p.id }}, event)">
|
||||
{% if p.running %}running{% else %}stopped{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex gap-8">
|
||||
<a href="/profile/{{ p.id }}" class="btn btn-ghost" style="padding: 6px 14px; font-size: 13px;">View</a>
|
||||
<button class="btn btn-success" style="padding: 6px 14px; font-size: 13px;"
|
||||
hx-post="/api/profiles/{{ p.id }}/start"
|
||||
hx-swap="none"
|
||||
onclick="this.textContent='Starting…'">Start</button>
|
||||
<button class="btn btn-danger" style="padding: 6px 14px; font-size: 13px;"
|
||||
hx-post="/api/profiles/{{ p.id }}/stop"
|
||||
hx-swap="none"
|
||||
onclick="this.textContent='Stopping…'">Stop</button>
|
||||
</div>
|
||||
</div>
|
||||
{% if p.address %}
|
||||
<div class="muted mt-8 monospace" style="word-break:break-all;">{{ p.address }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card" style="text-align:center; padding: 48px; color: var(--muted);">
|
||||
No profiles yet. Create one to get started.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Create dialog -->
|
||||
<dialog id="create-dialog">
|
||||
<h2 style="margin-bottom:20px;">New Bot Profile</h2>
|
||||
<form id="create-form">
|
||||
<div class="field">
|
||||
<label>Name</label>
|
||||
<input type="text" name="name" placeholder="My Support Bot" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Bot Type</label>
|
||||
<select name="bot_type">
|
||||
{% for t in bot_types %}
|
||||
<option value="{{ t }}">{{ t }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Welcome Message</label>
|
||||
<input type="text" name="welcome_message" placeholder="Welcome! How can I help?">
|
||||
</div>
|
||||
<div class="flex gap-8 mt-16" style="justify-content: flex-end;">
|
||||
<button type="button" class="btn btn-ghost" onclick="document.getElementById('create-dialog').close()">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Create</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<script>
|
||||
function updateStatus(id, event) {
|
||||
try {
|
||||
const data = JSON.parse(event.detail.xhr.responseText)
|
||||
const badge = document.getElementById('status-' + id)
|
||||
if (!badge) return
|
||||
badge.textContent = data.running ? 'running' : 'stopped'
|
||||
badge.className = 'badge ' + (data.running ? 'badge-green' : 'badge-red')
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
document.getElementById('create-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault()
|
||||
const fd = new FormData(e.target)
|
||||
const config = {}
|
||||
const welcome = fd.get('welcome_message')
|
||||
if (welcome) config.welcome_message = welcome
|
||||
|
||||
const token = document.cookie.match(/(?:^|;\s*)token=([^;]+)/)?.[1] || ''
|
||||
const resp = await fetch('/api/profiles', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json', 'X-Token': token},
|
||||
body: JSON.stringify({name: fd.get('name'), bot_type: fd.get('bot_type'), config})
|
||||
})
|
||||
if (resp.ok) {
|
||||
document.getElementById('create-dialog').close()
|
||||
location.reload()
|
||||
} else {
|
||||
const err = await resp.json()
|
||||
alert('Error: ' + (err.detail || 'unknown'))
|
||||
}
|
||||
})
|
||||
</script>
|
||||
{% endblock %}
|
||||
183
manager/templates/list.html
Normal file
183
manager/templates/list.html
Normal file
@@ -0,0 +1,183 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ tab | title }} — SimpleX Manager{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<style>
|
||||
.empty-state { text-align: center; padding: 56px 24px; color: var(--muted); }
|
||||
.empty-state p { margin-top: 8px; font-size: 13px; }
|
||||
|
||||
.profile-card { cursor: pointer; transition: border-color 0.15s, box-shadow 0.15s, transform 0.05s;
|
||||
border: 1px solid transparent; }
|
||||
.profile-card:hover { border-color: var(--accent); }
|
||||
.profile-card:active { transform: translateY(1px); }
|
||||
|
||||
.addr-row { display: flex; align-items: center; gap: 8px; margin-top: 10px; }
|
||||
.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; }
|
||||
|
||||
.bot-types-card table td { vertical-align: top; }
|
||||
.bot-types-card .tag { white-space: nowrap; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex-between" style="margin-bottom: 24px;">
|
||||
<h1 style="margin:0;">{{ tab | title }}</h1>
|
||||
<button class="btn btn-primary" onclick="openCreate()">
|
||||
+ New {{ 'User' if tab == 'users' else 'Bot' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{% if tab == 'bots' %}
|
||||
<div class="card bot-types-card" style="margin-bottom:24px;">
|
||||
<h2 style="font-size:15px;margin-bottom:12px;">Available bot types</h2>
|
||||
<table>
|
||||
<tr><td><span class="tag">echo</span></td><td class="muted">Repeats every message back to the sender — handy for testing a connection end to end.</td></tr>
|
||||
<tr><td><span class="tag">broadcast</span></td><td class="muted">Relays messages from authorized publishers out to all of the bot's contacts.</td></tr>
|
||||
<tr><td><span class="tag">support</span></td><td class="muted">Business inbox — auto-replies with a welcome message and collects incoming inquiries.</td></tr>
|
||||
<tr><td><span class="tag">directory</span></td><td class="muted">Directory service for discovering and listing groups or contacts.</td></tr>
|
||||
<tr><td><span class="tag">deadmans</span></td><td class="muted">Dead man's switch — triggers an action if expected check-ins stop arriving.</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if items %}
|
||||
{% for p in items %}
|
||||
<div class="card profile-card" id="profile-{{ p.id }}"
|
||||
onclick="location.href='/profile/{{ p.id }}'">
|
||||
<div class="flex-between">
|
||||
<div class="flex gap-8">
|
||||
<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 %}"
|
||||
id="status-{{ p.id }}"
|
||||
hx-get="/api/profiles/{{ p.id }}/status"
|
||||
hx-trigger="every 5s"
|
||||
hx-swap="none"
|
||||
hx-on::after-request="updateStatus({{ p.id }}, event)">
|
||||
{% if p.running %}running{% else %}stopped{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex gap-8" onclick="event.stopPropagation()">
|
||||
<button class="btn btn-success" style="padding:6px 14px;font-size:13px;"
|
||||
hx-post="/api/profiles/{{ p.id }}/start" hx-swap="none"
|
||||
onclick="this.textContent='Starting…'">Start</button>
|
||||
<button class="btn btn-danger" style="padding:6px 14px;font-size:13px;"
|
||||
hx-post="/api/profiles/{{ p.id }}/stop" hx-swap="none"
|
||||
onclick="this.textContent='Stopping…'">Stop</button>
|
||||
</div>
|
||||
</div>
|
||||
{% if p.address %}
|
||||
<div class="addr-row" onclick="event.stopPropagation()">
|
||||
<button class="btn btn-ghost copy-btn" title="Copy address"
|
||||
onclick="copyAddr(event, this, '{{ p.address | e }}')">📋</button>
|
||||
<a class="addr-link" href="{{ p.address }}" target="_blank" rel="noopener">{{ p.address }}</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="empty-state card">
|
||||
{% if tab == 'users' %}
|
||||
<strong>No users yet</strong>
|
||||
<p>Create a SimpleX user account to manage contacts and channels.</p>
|
||||
{% else %}
|
||||
<strong>No bots yet</strong>
|
||||
<p>Bots can echo messages, broadcast to subscribers, or run automated tasks.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Create dialog -->
|
||||
<dialog id="create-dialog">
|
||||
<h2 style="margin-bottom:20px;">New {{ 'User' if tab == 'users' else 'Bot' }}</h2>
|
||||
<form id="create-form">
|
||||
<div class="field">
|
||||
<label>Name</label>
|
||||
<input type="text" name="name" placeholder="{{ 'Alice' if tab == 'users' else 'My Bot' }}" required>
|
||||
</div>
|
||||
{% if tab == 'bots' %}
|
||||
<div class="field">
|
||||
<label>Bot Type</label>
|
||||
<select name="profile_type" id="type-select" onchange="onTypeChange()">
|
||||
{% for t in create_types %}
|
||||
<option value="{{ t }}">{{ t }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field" id="welcome-field">
|
||||
<label>Welcome Message</label>
|
||||
<input type="text" name="welcome_message" placeholder="Welcome! How can I help?">
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="flex gap-8 mt-16" style="justify-content:flex-end;">
|
||||
<button type="button" class="btn btn-ghost"
|
||||
onclick="document.getElementById('create-dialog').close()">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Create</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<script>
|
||||
function updateStatus(id, event) {
|
||||
try {
|
||||
const data = JSON.parse(event.detail.xhr.responseText);
|
||||
const badge = document.getElementById('status-' + id);
|
||||
if (!badge) return;
|
||||
badge.textContent = data.running ? 'running' : 'stopped';
|
||||
badge.className = 'badge ' + (data.running ? 'badge-green' : 'badge-red');
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
document.getElementById('create-form').reset();
|
||||
{% if tab == 'bots' %}onTypeChange();{% endif %}
|
||||
document.getElementById('create-dialog').showModal();
|
||||
}
|
||||
|
||||
function copyAddr(ev, btn, addr) {
|
||||
ev.stopPropagation();
|
||||
navigator.clipboard.writeText(addr).then(() => {
|
||||
btn.textContent = '✓';
|
||||
setTimeout(() => btn.textContent = '📋', 1500);
|
||||
});
|
||||
}
|
||||
|
||||
{% 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' : '';
|
||||
}
|
||||
{% endif %}
|
||||
|
||||
document.getElementById('create-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const fd = new FormData(e.target);
|
||||
{% if tab == 'users' %}
|
||||
const botType = 'user';
|
||||
const config = {};
|
||||
{% else %}
|
||||
const botType = fd.get('profile_type') || '{{ create_types[0] }}';
|
||||
const config = {};
|
||||
const welcome = fd.get('welcome_message');
|
||||
if (welcome) config.welcome_message = welcome;
|
||||
{% endif %}
|
||||
const token = document.cookie.match(/(?:^|;\s*)token=([^;]+)/)?.[1] || '';
|
||||
const resp = await fetch('/api/profiles', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json', 'X-Token': token},
|
||||
body: JSON.stringify({name: fd.get('name'), bot_type: botType, config}),
|
||||
});
|
||||
if (resp.ok) {
|
||||
document.getElementById('create-dialog').close();
|
||||
location.reload();
|
||||
} else {
|
||||
const err = await resp.json();
|
||||
alert('Error: ' + (err.detail || 'unknown'));
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,18 +1,39 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<script>
|
||||
(function(){
|
||||
var t=localStorage.getItem('theme');
|
||||
if(!t){t=window.matchMedia('(prefers-color-scheme:dark)').matches?'original-dark':'original-light';}
|
||||
document.documentElement.setAttribute('data-theme',t);
|
||||
})();
|
||||
</script>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SimpleX Manager — Login</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root { --bg: #f5f5f7; --card: #fff; --text: #1d1d1f; --accent: #0053D0; --border: #e0e0e5; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root { --bg: #111827; --card: #0B2A59; --text: #f5f5f7; --accent: #70F0F9; --border: #1e3a5f; }
|
||||
|
||||
[data-theme="original-light"] {
|
||||
--bg: #f5f5f7; --card: #fff; --text: #1d1d1f;
|
||||
--accent: #0053D0; --border: #e0e0e5;
|
||||
--btn-light-text: #fff;
|
||||
}
|
||||
[data-theme="original-dark"] {
|
||||
--bg: #111827; --card: #0B2A59; --text: #f5f5f7;
|
||||
--accent: #70F0F9; --border: #1e3a5f;
|
||||
--btn-light-text: #000;
|
||||
}
|
||||
[data-theme="matrix"] {
|
||||
--bg: #000; --card: #050d05; --text: #00ff41;
|
||||
--accent: #00ff41; --border: #0f3d0f;
|
||||
--btn-light-text: #000; --muted: #2e8b57;
|
||||
}
|
||||
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;
|
||||
background: var(--bg); color: var(--text); min-height: 100vh;
|
||||
display: flex; align-items: center; justify-content: center; }
|
||||
display: flex; flex-direction: column; }
|
||||
.login-main { flex: 1; display: flex; align-items: center; justify-content: center; padding: 24px; }
|
||||
.box { background: var(--card); border-radius: 12px; padding: 36px 32px;
|
||||
width: 100%; max-width: 360px; box-shadow: 0 4px 24px rgba(0,0,0,0.1); }
|
||||
h1 { font-size: 22px; font-weight: 700; color: var(--accent); margin-bottom: 24px; text-align: center; }
|
||||
@@ -20,21 +41,35 @@
|
||||
input { width: 100%; padding: 10px 12px; font-size: 15px; border: 1px solid var(--border);
|
||||
border-radius: 8px; background: var(--bg); color: var(--text); outline: none; margin-bottom: 16px; }
|
||||
input:focus { border-color: var(--accent); }
|
||||
button { width: 100%; padding: 10px; background: var(--accent); color: #fff; border: none;
|
||||
border-radius: 8px; font-size: 15px; font-weight: 600; cursor: pointer; }
|
||||
@media (prefers-color-scheme: dark) { button { color: #000; } }
|
||||
button { width: 100%; padding: 10px; background: var(--accent); color: var(--btn-light-text);
|
||||
border: none; border-radius: 8px; font-size: 15px; font-weight: 600; cursor: pointer; }
|
||||
.error { color: #DD0000; font-size: 13px; margin-bottom: 12px; text-align: center; }
|
||||
.site-footer { flex-shrink: 0; text-align: center; padding: 18px 20px;
|
||||
border-top: 1px solid var(--border); color: var(--muted, #6e6e73);
|
||||
font-size: 12px; line-height: 1.6; }
|
||||
.site-footer a { color: var(--accent); text-decoration: none; font-weight: 600; }
|
||||
.site-footer a:hover { text-decoration: underline; }
|
||||
.site-footer .sep { margin: 0 8px; opacity: 0.5; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="box">
|
||||
<h1>SimpleX Manager</h1>
|
||||
{% if error %}<div class="error">{{ error }}</div>{% endif %}
|
||||
<form method="post" action="/login">
|
||||
<label for="token">Access Token</label>
|
||||
<input type="password" id="token" name="token" placeholder="Enter token…" autofocus>
|
||||
<button type="submit">Sign in</button>
|
||||
</form>
|
||||
<div class="login-main">
|
||||
<div class="box">
|
||||
<h1>SimpleX Manager</h1>
|
||||
{% if error %}<div class="error">{{ error }}</div>{% endif %}
|
||||
<form method="post" action="/login">
|
||||
<label for="token">Access Token</label>
|
||||
<input type="password" id="token" name="token" placeholder="Enter token…" autofocus>
|
||||
<button type="submit">Sign in</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<footer class="site-footer">
|
||||
© Bournemouth Technology Ltd
|
||||
<span class="sep">·</span>
|
||||
built on © SimpleX Network
|
||||
<span class="sep">·</span>
|
||||
<a href="https://simplex.chat/downloads/" target="_blank" rel="noopener">Get SimpleX App</a>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
153
manager/templates/settings.html
Normal file
153
manager/templates/settings.html
Normal file
@@ -0,0 +1,153 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Settings — SimpleX Manager{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<style>
|
||||
.settings-section { margin-bottom: 32px; }
|
||||
.settings-section h2 { margin-bottom: 16px; }
|
||||
|
||||
.theme-grid { display: flex; gap: 16px; flex-wrap: wrap; }
|
||||
|
||||
.theme-card {
|
||||
flex: 0 0 180px;
|
||||
border: 2px solid var(--border);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
background: var(--card);
|
||||
}
|
||||
.theme-card:hover { border-color: var(--accent); }
|
||||
.theme-card.selected {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(0,83,208,0.18);
|
||||
}
|
||||
[data-theme="original-dark"] .theme-card.selected {
|
||||
box-shadow: 0 0 0 3px rgba(112,240,249,0.2);
|
||||
}
|
||||
[data-theme="matrix"] .theme-card.selected {
|
||||
box-shadow: 0 0 0 3px rgba(0,255,65,0.25);
|
||||
}
|
||||
|
||||
.theme-preview {
|
||||
height: 96px;
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.preview-bar { border-radius: 4px; height: 14px; }
|
||||
.preview-bar-sm { border-radius: 4px; height: 9px; width: 60%; }
|
||||
.preview-dot { width: 20px; height: 20px; border-radius: 50%; margin-top: auto; }
|
||||
|
||||
.theme-label {
|
||||
padding: 10px 14px;
|
||||
border-top: 1px solid;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.checkmark {
|
||||
width: 18px; height: 18px; border-radius: 50%;
|
||||
background: var(--accent);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 11px;
|
||||
color: var(--btn-light-text);
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.theme-card.selected .checkmark { opacity: 1; }
|
||||
|
||||
/* Original Light preview colors (hardcoded so visible regardless of current theme) */
|
||||
.preview-light { background: #f5f5f7; }
|
||||
.preview-light .preview-bar { background: #ffffff; }
|
||||
.preview-light .preview-bar-sm { background: #e0e0e5; }
|
||||
.preview-light .preview-dot { background: #0053D0; }
|
||||
.preview-light + .theme-label { border-color: #e0e0e5; color: #1d1d1f; background: #fff; }
|
||||
|
||||
/* Original Dark preview colors */
|
||||
.preview-dark { background: #111827; }
|
||||
.preview-dark .preview-bar { background: #0B2A59; }
|
||||
.preview-dark .preview-bar-sm { background: #1e3a5f; }
|
||||
.preview-dark .preview-dot { background: #70F0F9; }
|
||||
.preview-dark + .theme-label { border-color: #1e3a5f; color: #f5f5f7; background: #0B2A59; }
|
||||
|
||||
/* Matrix preview colors */
|
||||
.preview-matrix { background: #000000; }
|
||||
.preview-matrix .preview-bar { background: #062006; }
|
||||
.preview-matrix .preview-bar-sm { background: #0f3d0f; }
|
||||
.preview-matrix .preview-dot { background: #00ff41; box-shadow: 0 0 8px #00ff41; }
|
||||
.preview-matrix + .theme-label { border-color: #0f3d0f; color: #00ff41; background: #050d05;
|
||||
font-family: 'Consolas', monospace; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Settings</h1>
|
||||
|
||||
<div class="card settings-section">
|
||||
<h2>Theme</h2>
|
||||
<div class="theme-grid">
|
||||
|
||||
<div class="theme-card" id="card-original-light" onclick="setTheme('original-light')">
|
||||
<div class="theme-preview preview-light">
|
||||
<div class="preview-bar"></div>
|
||||
<div class="preview-bar-sm"></div>
|
||||
<div class="preview-bar-sm"></div>
|
||||
<div class="preview-dot"></div>
|
||||
</div>
|
||||
<div class="theme-label">
|
||||
<span>Original Light</span>
|
||||
<span class="checkmark">✓</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="theme-card" id="card-original-dark" onclick="setTheme('original-dark')">
|
||||
<div class="theme-preview preview-dark">
|
||||
<div class="preview-bar"></div>
|
||||
<div class="preview-bar-sm"></div>
|
||||
<div class="preview-bar-sm"></div>
|
||||
<div class="preview-dot"></div>
|
||||
</div>
|
||||
<div class="theme-label">
|
||||
<span>Original Dark</span>
|
||||
<span class="checkmark">✓</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="theme-card" id="card-matrix" onclick="setTheme('matrix')">
|
||||
<div class="theme-preview preview-matrix">
|
||||
<div class="preview-bar"></div>
|
||||
<div class="preview-bar-sm"></div>
|
||||
<div class="preview-bar-sm"></div>
|
||||
<div class="preview-dot"></div>
|
||||
</div>
|
||||
<div class="theme-label">
|
||||
<span>Matrix</span>
|
||||
<span class="checkmark">✓</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function currentTheme() {
|
||||
return localStorage.getItem('theme') ||
|
||||
(window.matchMedia('(prefers-color-scheme:dark)').matches ? 'original-dark' : 'original-light');
|
||||
}
|
||||
|
||||
function setTheme(t) {
|
||||
localStorage.setItem('theme', t);
|
||||
document.documentElement.setAttribute('data-theme', t);
|
||||
document.querySelectorAll('.theme-card').forEach(c => c.classList.remove('selected'));
|
||||
const card = document.getElementById('card-' + t);
|
||||
if (card) card.classList.add('selected');
|
||||
}
|
||||
|
||||
// Mark current selection on load
|
||||
document.getElementById('card-' + currentTheme())?.classList.add('selected');
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user