1 Commits

Author SHA1 Message Date
Jon
dab2685498 some changes in startup 2026-06-07 20:24:03 +01:00
28 changed files with 332 additions and 5069 deletions

12
.gitignore vendored
View File

@@ -10,15 +10,3 @@ __pycache__/
*.pyc
.venv/
*.egg-info/
# Downloaded libsimplex artifacts (regenerate via manager/download_sdk.sh)
manager/libs/
# Throwaway exploration databases
manager/data/explore/
# Local Claude session resume helper (machine-specific)
manager/ai.sh
# Runtime bot state (databases + directory registries)
manager/data/bots/
# Generated directory-bot websites (web/index.html is the master template)
/web/*/
!/web/data/

View File

@@ -1,144 +0,0 @@
"""End-to-end test of the broadcast bot (Pattern 3, in-process FFI).
Runs the real bot via profiles.start_bot, connects a publisher ("pub") and a
non-publisher ("sub") to it, then checks:
- a publisher's message is broadcast to all contacts (sub receives it)
- a non-publisher's message gets the prohibited reply and is deleted
Uses three libsimplex controllers in one process (bot + pub + sub) — exactly the
multi-controller model the manager relies on. Needs network (SMP).
Run: .venv/bin/python broadcast_test.py
"""
import asyncio
import json
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import profiles as pm # noqa: E402
from simplex_chat import ChatApi, SqliteDb # noqa: E402
DATA = Path("data")
BOT_PREFIX = str(DATA / "bctest_bot")
PUB_PREFIX = str(DATA / "bctest_pub")
SUB_PREFIX = str(DATA / "bctest_sub")
BOT_PID = 99001
def cleanup():
for pat in ("bctest_bot_*", "bctest_pub_*", "bctest_sub_*"):
for p in DATA.glob(pat):
p.unlink()
async def make_account(prefix: str, display: str) -> ChatApi:
chat = await ChatApi.init(SqliteDb(file_prefix=prefix))
user = await chat.api_get_active_user()
if not user:
await chat.api_create_active_user({"displayName": display, "fullName": ""})
await chat.start_chat()
return chat
async def wait_until(fn, timeout=120, every=1):
start = time.time()
while time.time() - start < timeout:
v = await fn()
if v:
return v
await asyncio.sleep(every)
return None
async def first_contact_id(chat: ChatApi) -> int | None:
u = await chat.api_get_active_user()
cs = await chat.api_list_contacts(u["userId"])
return cs[0]["contactId"] if cs else None
async def incoming_texts(chat: ChatApi, contact_id: int) -> list[str]:
c = await chat.api_get_chat("direct", contact_id, 50)
out = []
for ci in c.get("chatItems", []):
d = ci.get("chatDir", {}).get("type", "")
if d.endswith("Rcv"):
out.append(ci.get("content", {}).get("msgContent", {}).get("text", ""))
return out
async def main() -> int:
cleanup()
addr_box = {}
async def on_address(pid, addr):
addr_box["addr"] = addr
profile = {
"id": BOT_PID, "name": "bctestbot", "bot_type": "broadcast",
"db_prefix": BOT_PREFIX, "config": json.dumps({"publishers": ["pub"]}),
}
pub = sub = None
ok = True
try:
await pm.start_bot(profile, on_address)
addr = await wait_until(lambda: asyncio.sleep(0, addr_box.get("addr")), timeout=90)
print("bot address:", bool(addr))
assert addr, "bot never published an address"
pub = await make_account(PUB_PREFIX, "pub")
sub = await make_account(SUB_PREFIX, "sub")
await pub.send_chat_cmd(f"/connect {addr}")
await sub.send_chat_cmd(f"/connect {addr}")
pub_cid = await wait_until(lambda: first_contact_id(pub))
sub_cid = await wait_until(lambda: first_contact_id(sub))
print("pub connected:", bool(pub_cid), "| sub connected:", bool(sub_cid))
assert pub_cid and sub_cid, "publisher/subscriber did not connect"
# wait until the BOT itself has both contacts, else /feed would miss sub
both = await wait_until(
lambda: asyncio.sleep(0, len(pm.get_running(BOT_PID).contacts) >= 2), timeout=60
)
print("bot sees both contacts:", bool(both))
# 1) publisher broadcasts → sub should receive it
await pub.api_send_text_message({"chatType": "direct", "chatId": pub_cid}, "hello all")
got_bcast = await wait_until(
lambda: _contains(incoming_texts(sub, sub_cid), "hello all"), timeout=60, every=2
)
print("broadcast delivered to sub:", bool(got_bcast))
ok = ok and bool(got_bcast)
# 2) non-publisher (sub) sends → should get prohibited reply
await sub.api_send_text_message({"chatType": "direct", "chatId": sub_cid}, "spam please")
got_prohibited = await wait_until(
lambda: _contains(incoming_texts(sub, sub_cid), "deleted"), timeout=60, every=2
)
print("non-publisher got prohibited reply:", bool(got_prohibited))
ok = ok and bool(got_prohibited)
except AssertionError as e:
ok = False
print("ASSERT FAIL:", e)
finally:
await pm.stop_bot(BOT_PID)
for c in (pub, sub):
if c:
try:
await c.close()
except Exception:
pass
cleanup()
print("\nRESULT:", "PASS" if ok else "FAIL")
return 0 if ok else 1
async def _contains(coro, needle):
texts = await coro
return any(needle.lower() in (t or "").lower() for t in texts)
if __name__ == "__main__":
raise SystemExit(asyncio.run(main()))

View File

@@ -1,100 +0,0 @@
"""End-to-end test of a 'business' profile (Pattern 3, in-process FFI).
A business profile sets businessAddress=True, so a connecting customer gets their
own GROUP chat (business chat) rather than a plain direct contact. This starts a
business profile via profiles.start_bot, connects a customer, and asserts the
customer ends up in a group (the distinguishing business-address behavior).
Run: .venv/bin/python business_test.py
"""
import asyncio
import json
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import profiles as pm # noqa: E402
from simplex_chat import ChatApi, SqliteDb # noqa: E402
DATA = Path("data")
BIZ_PREFIX = str(DATA / "biztest_biz")
CUST_PREFIX = str(DATA / "biztest_cust")
BIZ_PID = 99002
def cleanup():
for pat in ("biztest_biz_*", "biztest_cust_*"):
for p in DATA.glob(pat):
p.unlink()
async def wait_until(fn, timeout=120, every=1):
start = time.time()
while time.time() - start < timeout:
v = await fn()
if v:
return v
await asyncio.sleep(every)
return None
async def main() -> int:
cleanup()
addr_box = {}
async def on_address(pid, addr):
addr_box["addr"] = addr
profile = {
"id": BIZ_PID, "name": "biztestco", "bot_type": "business",
"db_prefix": BIZ_PREFIX,
"config": json.dumps({"welcome_message": "Thanks for contacting us!"}),
}
cust = None
ok = True
try:
await pm.start_bot(profile, on_address)
addr = await wait_until(lambda: asyncio.sleep(0, addr_box.get("addr")), timeout=90)
print("business address:", bool(addr))
assert addr, "business profile never published an address"
cust = await ChatApi.init(SqliteDb(file_prefix=CUST_PREFIX))
if not await cust.api_get_active_user():
await cust.api_create_active_user({"displayName": "customer", "fullName": ""})
await cust.start_chat()
await cust.send_chat_cmd(f"/connect {addr}")
u = await cust.api_get_active_user()
uid = u["userId"]
async def customer_groups():
return await cust.api_list_groups(uid)
groups = await wait_until(lambda: customer_groups(), timeout=90, every=2)
contacts = await cust.api_list_contacts(uid)
print("customer groups:", [g.get("groupProfile", {}).get("displayName") for g in (groups or [])])
print("customer direct contacts:", [c.get("localDisplayName") for c in contacts])
# business address ⇒ a business GROUP chat, not a plain direct contact
assert groups, "customer did not land in a business group (businessAddress not in effect?)"
ok = bool(groups)
except AssertionError as e:
ok = False
print("ASSERT FAIL:", e)
finally:
await pm.stop_bot(BIZ_PID)
if cust:
try:
await cust.close()
except Exception:
pass
cleanup()
print("\nRESULT:", "PASS — business profile creates per-customer group chats" if ok else "FAIL")
return 0 if ok else 1
if __name__ == "__main__":
raise SystemExit(asyncio.run(main()))

View File

@@ -1,116 +0,0 @@
"""End-to-end test of the crypto price bot (Pattern 3, in-process FFI).
Serves a mock CoinGecko simple/price endpoint, starts a crypto bot, and checks it
creates a channel and posts a price snapshot of the selected coins/currencies.
Run: .venv/bin/python crypto_test.py
"""
import asyncio
import json
import sys
import threading
import time
import urllib.request
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import profiles as pm # noqa: E402
DATA = Path("data")
BOT_PREFIX = str(DATA / "cryptotest_bot")
BOT_PID = 99005
PRICES = {"bitcoin": {"usd": 65000, "gbp": 51000}, "ethereum": {"usd": 3200, "gbp": 2500}}
class CGHandler(BaseHTTPRequestHandler):
def do_GET(self):
body = json.dumps(PRICES).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(body)
def log_message(self, *a):
pass
def cleanup():
for p in DATA.glob("cryptotest_bot_*"):
p.unlink()
async def wait_until(fn, timeout=120, every=1):
start = time.time()
while time.time() - start < timeout:
v = await fn()
if v:
return v
await asyncio.sleep(every)
return None
async def channel_texts(chat, gid):
c = await chat.api_get_chat("group", gid, 50)
return [ci.get("content", {}).get("msgContent", {}).get("text", "") for ci in c.get("chatItems", [])]
async def main() -> int:
cleanup()
srv = HTTPServer(("127.0.0.1", 0), CGHandler)
port = srv.server_address[1]
threading.Thread(target=srv.serve_forever, daemon=True).start()
# point the bot's fetcher at the mock server
base = f"http://127.0.0.1:{port}/"
orig_fetch = pm._fetch_crypto
def mock_fetch(ids, vs):
with urllib.request.urlopen(base, timeout=10) as r:
return json.loads(r.read())
pm._fetch_crypto = mock_fetch
print("mock CoinGecko on", base)
profile = {
"id": BOT_PID, "name": "cryptotestbot", "bot_type": "crypto",
"db_prefix": BOT_PREFIX,
"config": json.dumps({"coins": ["bitcoin", "ethereum"],
"currencies": ["usd", "gbp"], "poll_seconds": 60}),
}
ok = True
try:
await pm.start_bot(profile, lambda pid, addr: asyncio.sleep(0))
b = pm.get_running(BOT_PID)
gid = await wait_until(lambda: asyncio.sleep(0, b.channel_gid), timeout=90)
print("channel created:", bool(gid), "gid", gid)
assert gid, "crypto bot did not create a channel"
got = await wait_until(
lambda: _has_price(channel_texts(b.chat, gid)), timeout=30, every=2
)
print("price snapshot posted:", got)
assert got and "Bitcoin" in got and "$" in got, "no valid price snapshot posted"
except AssertionError as e:
ok = False
print("ASSERT FAIL:", e)
finally:
pm._fetch_crypto = orig_fetch
await pm.stop_bot(BOT_PID)
srv.shutdown()
cleanup()
print("\nRESULT:", "PASS — crypto bot posts price snapshots" if ok else "FAIL")
return 0 if ok else 1
async def _has_price(coro):
for t in await coro:
if "Crypto prices" in (t or ""):
return t
return None
if __name__ == "__main__":
raise SystemExit(asyncio.run(main()))

View File

@@ -61,13 +61,6 @@ def update_address(profile_id: int, address: str) -> None:
conn.execute("UPDATE profiles SET address=? WHERE id=?", (address, profile_id))
def update_config(profile_id: int, config: dict) -> None:
with get_conn() as conn:
conn.execute(
"UPDATE profiles SET config=? WHERE id=?", (json.dumps(config), profile_id)
)
def delete_profile(profile_id: int) -> None:
with get_conn() as conn:
conn.execute("DELETE FROM profiles WHERE id=?", (profile_id,))

View File

@@ -1,114 +0,0 @@
"""Directory bot registry: submitted groups/channels with super-user approval.
Mirrors the core of SimpleX's directory service: groups are registered (pending),
a super-user approves/rejects, and approved entries are published to the bot's
website (web/<safe>/data/listing.json) in the format index.html expects.
State is a per-bot JSON file so it survives restarts: data/bots/<safe>_directory.json
"""
import json
from datetime import datetime, timezone
from pathlib import Path
BASE = Path(__file__).parent
DATA_DIR = BASE / "data" / "bots"
WEB_DIR = BASE.parent / "web"
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
def _state_path(safe: str) -> Path:
return DATA_DIR / f"{safe}_directory.json"
def load_state(safe: str) -> dict:
p = _state_path(safe)
if p.exists():
try:
return json.loads(p.read_text(encoding="utf-8"))
except Exception:
pass
return {"seq": 0, "entries": []}
def save_state(safe: str, state: dict) -> None:
DATA_DIR.mkdir(parents=True, exist_ok=True)
_state_path(safe).write_text(json.dumps(state, indent=2), encoding="utf-8")
def find_by_group(state: dict, group_id: int) -> dict | None:
for e in state["entries"]:
if e["group_id"] == group_id:
return e
return None
def add_pending(
safe: str, group_id: int, display_name: str, link: str,
is_channel: bool, summary: dict, short_descr: str | None, submitted_by: str,
) -> tuple[dict, bool]:
"""Register a group as pending. Returns (entry, is_new)."""
state = load_state(safe)
existing = find_by_group(state, group_id)
if existing:
return existing, False
state["seq"] += 1
entry = {
"id": state["seq"], "group_id": group_id, "status": "pending",
"displayName": display_name, "link": link, "is_channel": bool(is_channel),
"summary": summary or {}, "shortDescr": short_descr,
"submitted_by": submitted_by, "createdAt": _now(), "activeAt": _now(),
}
state["entries"].append(entry)
save_state(safe, state)
return entry, True
def set_status(safe: str, entry_id: int, status: str) -> dict | None:
state = load_state(safe)
for e in state["entries"]:
if e["id"] == entry_id:
e["status"] = status
save_state(safe, state)
return e
return None
def entries_by_status(safe: str, status: str) -> list[dict]:
return [e for e in load_state(safe)["entries"] if e["status"] == status]
def search(safe: str, query: str) -> list[dict]:
q = query.lower().strip()
out = []
for e in entries_by_status(safe, "approved"):
if q in e["displayName"].lower() or (e.get("shortDescr") or "").lower().find(q) >= 0:
out.append(e)
return out
def publish(safe: str) -> int:
"""Write approved entries to web/<safe>/data/listing.json (website schema). Returns count."""
entries = []
for e in entries_by_status(safe, "approved"):
entry_type = {
"type": "channel" if e["is_channel"] else "group",
"summary": e.get("summary") or {},
}
if e["is_channel"]:
entry_type["groupType"] = "channel"
entries.append({
"entryType": entry_type,
"displayName": e["displayName"],
"groupLink": {"connShortLink": e["link"]} if e.get("link") else {},
"shortDescr": e.get("shortDescr"),
"createdAt": e["createdAt"],
"activeAt": e["activeAt"],
})
out = WEB_DIR / safe / "data" / "listing.json"
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(json.dumps({"entries": entries}, indent=2), encoding="utf-8")
return len(entries)

View File

@@ -1,7 +0,0 @@
curl -L --progress-bar \
"https://github.com/simplex-chat/simplex-chat-libs/releases/download/v6.5.2/simplex-chat-libs-linux-x86_64.zip" \
-o /tmp/simplex-libs.zip && \
unzip /tmp/simplex-libs.zip -d /tmp/simplex-libs-extracted && \
mkdir -p ~/.cache/simplex-chat/v6.5.2/sqlite && \
mv /tmp/simplex-libs-extracted/libs/* ~/.cache/simplex-chat/v6.5.2/sqlite/ && \
echo "Done: $(ls ~/.cache/simplex-chat/v6.5.2/sqlite/)"

View File

@@ -1,150 +0,0 @@
"""End-to-end test of the 'llm' bot (Pattern 3, in-process FFI).
Stands up a tiny OpenAI-compatible mock server (so no Ollama needed), starts an
llm bot pointed at it with a known context, connects a customer, sends a message,
and verifies the bot's reply reflects both the configured context and the message.
Run: .venv/bin/python llm_test.py
"""
import asyncio
import json
import sys
import threading
import time
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import profiles as pm # noqa: E402
from simplex_chat import ChatApi, SqliteDb # noqa: E402
DATA = Path("data")
BOT_PREFIX = str(DATA / "llmtest_bot")
CUST_PREFIX = str(DATA / "llmtest_cust")
BOT_PID = 99003
CONTEXT = "TESTCTX"
def cleanup():
for pat in ("llmtest_bot_*", "llmtest_cust_*"):
for p in DATA.glob(pat):
p.unlink()
class MockLLM(BaseHTTPRequestHandler):
def do_POST(self):
n = int(self.headers.get("Content-Length", 0))
body = json.loads(self.rfile.read(n) or b"{}")
msgs = body.get("messages", [])
system = next((m["content"] for m in msgs if m["role"] == "system"), "")
last_user = next((m["content"] for m in reversed(msgs) if m["role"] == "user"), "")
content = f"ctx:{system}|got:{last_user}"
out = json.dumps({"choices": [{"message": {"role": "assistant", "content": content}}]}).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(out)
def log_message(self, *a):
pass
async def wait_until(fn, timeout=120, every=1):
start = time.time()
while time.time() - start < timeout:
v = await fn()
if v:
return v
await asyncio.sleep(every)
return None
async def incoming_texts(chat, contact_id):
c = await chat.api_get_chat("direct", contact_id, 50)
return [
ci.get("content", {}).get("msgContent", {}).get("text", "")
for ci in c.get("chatItems", [])
if ci.get("chatDir", {}).get("type", "").endswith("Rcv")
]
async def main() -> int:
cleanup()
srv = HTTPServer(("127.0.0.1", 0), MockLLM)
port = srv.server_address[1]
threading.Thread(target=srv.serve_forever, daemon=True).start()
print("mock LLM on port", port)
addr_box = {}
async def on_address(pid, addr):
addr_box["addr"] = addr
profile = {
"id": BOT_PID, "name": "llmtestbot", "bot_type": "llm",
"db_prefix": BOT_PREFIX,
"config": json.dumps({
"api_base": f"http://127.0.0.1:{port}/v1",
"model": "test-model", "api_key": "x", "system_prompt": CONTEXT,
}),
}
cust = None
ok = True
try:
await pm.start_bot(profile, on_address)
addr = await wait_until(lambda: asyncio.sleep(0, addr_box.get("addr")), timeout=90)
print("bot address:", bool(addr))
assert addr, "llm bot never published an address"
cust = await ChatApi.init(SqliteDb(file_prefix=CUST_PREFIX))
if not await cust.api_get_active_user():
await cust.api_create_active_user({"displayName": "customer", "fullName": ""})
await cust.start_chat()
await cust.send_chat_cmd(f"/connect {addr}")
u = await cust.api_get_active_user()
cid = await wait_until(
lambda: _first_contact(cust, u["userId"]), timeout=90, every=2
)
assert cid, "customer did not connect"
await asyncio.sleep(2)
await cust.api_send_text_message({"chatType": "direct", "chatId": cid}, "ping")
reply = await wait_until(
lambda: _find_reply(cust, cid), timeout=60, every=2
)
print("bot reply:", reply)
assert reply and "ctx:TESTCTX" in reply and "got:ping" in reply, \
"reply did not reflect context + message"
except AssertionError as e:
ok = False
print("ASSERT FAIL:", e)
finally:
await pm.stop_bot(BOT_PID)
if cust:
try:
await cust.close()
except Exception:
pass
srv.shutdown()
cleanup()
print("\nRESULT:", "PASS — llm bot replies using its context" if ok else "FAIL")
return 0 if ok else 1
async def _first_contact(chat, uid):
cs = await chat.api_list_contacts(uid)
return cs[0]["contactId"] if cs else None
async def _find_reply(chat, cid):
for t in await incoming_texts(chat, cid):
if t.startswith("ctx:"):
return t
return None
if __name__ == "__main__":
raise SystemExit(asyncio.run(main()))

View File

@@ -7,10 +7,8 @@ import os
from contextlib import asynccontextmanager
from pathlib import Path
import mimetypes
from fastapi import FastAPI, Form, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
@@ -29,20 +27,18 @@ AUTH_TOKEN = os.environ.get("MANAGER_TOKEN", "changeme")
@asynccontextmanager
async def lifespan(app: FastAPI):
db.init_db()
# Auto-restart any previously running bots on startup
for profile in db.list_profiles():
if profile.get("address"):
if profile.get("address"): # had an address = was running before
await pm.start_bot(profile, _save_address)
yield
# Graceful shutdown
for pid in list(pm._running):
await pm.stop_bot(pid)
app = FastAPI(title="SimpleX Manager", lifespan=lifespan)
app.mount("/static", StaticFiles(directory=str(BASE / "static")), name="static")
# Generated directory-bot websites (one folder per directory bot) — preview/host.
WEB_DIR = BASE.parent / "web"
WEB_DIR.mkdir(parents=True, exist_ok=True)
app.mount("/directory", StaticFiles(directory=str(WEB_DIR), html=True), name="directory")
async def _save_address(profile_id: int, address: str) -> None:
@@ -56,6 +52,7 @@ def _check_auth(request: Request) -> bool:
def _require_auth(request: Request) -> None:
if not _check_auth(request):
from fastapi.responses import RedirectResponse
raise HTTPException(status_code=401, detail="Unauthorized")
@@ -65,24 +62,6 @@ def _redirect_if_unauth(request: Request):
return None
def _enrich(profiles: list[dict]) -> list[dict]:
for p in profiles:
p["running"] = pm.is_running(p["id"])
p["config"] = json.loads(p.get("config") or "{}")
return profiles
def _category(bot_type: str) -> str:
"""Which sidebar category a profile belongs to: users / businesses / rss-bots / bots."""
if bot_type in pm.BUSINESS_TYPES:
return "businesses"
if bot_type in pm.RSS_TYPES:
return "rss-bots"
if bot_type in pm.USER_TYPES:
return "users"
return "bots"
# ── Auth ──────────────────────────────────────────────────────────────────────
@app.get("/login", response_class=HTMLResponse)
@@ -112,119 +91,16 @@ async def logout():
async def index(request: Request):
if redir := _redirect_if_unauth(request):
return redir
return TEMPLATES.TemplateResponse(request, "home.html", {"nav_active": "home"})
@app.get("/users", response_class=HTMLResponse)
async def users_page(request: Request):
if redir := _redirect_if_unauth(request):
return redir
items = _enrich([p for p in db.list_profiles() if p["bot_type"] in pm.USER_TYPES])
return TEMPLATES.TemplateResponse(request, "list.html", {
"tab": "users", "items": items, "create_types": pm.USER_TYPES,
"nav_active": "users",
all_profiles = db.list_profiles()
for p in all_profiles:
p["running"] = pm.is_running(p["id"])
p["config"] = json.loads(p.get("config") or "{}")
return TEMPLATES.TemplateResponse(request, "index.html", {
"profiles": all_profiles,
"bot_types": pm.BOT_TYPES,
})
@app.get("/businesses", response_class=HTMLResponse)
async def businesses_page(request: Request):
if redir := _redirect_if_unauth(request):
return redir
items = _enrich([p for p in db.list_profiles() if p["bot_type"] in pm.BUSINESS_TYPES])
return TEMPLATES.TemplateResponse(request, "list.html", {
"tab": "businesses", "items": items, "create_types": pm.BUSINESS_TYPES,
"nav_active": "businesses",
})
@app.get("/bots", response_class=HTMLResponse)
async def bots_page(request: Request):
if redir := _redirect_if_unauth(request):
return redir
items = _enrich([p for p in db.list_profiles() if p["bot_type"] in pm.BOT_TYPES])
return TEMPLATES.TemplateResponse(request, "list.html", {
"tab": "bots", "items": items, "create_types": pm.BOT_TYPES,
"nav_active": "bots",
})
@app.get("/rss-bots", response_class=HTMLResponse)
async def rss_bots_page(request: Request):
if redir := _redirect_if_unauth(request):
return redir
items = _enrich([p for p in db.list_profiles() if p["bot_type"] in pm.RSS_TYPES])
return TEMPLATES.TemplateResponse(request, "list.html", {
"tab": "rss-bots", "items": items, "create_types": pm.RSS_TYPES,
"nav_active": "rss-bots",
})
RELAY_KINDS = {"chat": "Chat Relay", "file": "File Relay", "message": "Message Relay"}
@app.get("/relays/{kind}", response_class=HTMLResponse)
async def relay_page(request: Request, kind: str):
if redir := _redirect_if_unauth(request):
return redir
if kind not in RELAY_KINDS:
raise HTTPException(status_code=404, detail="Unknown relay")
return TEMPLATES.TemplateResponse(request, "relay.html", {
"nav_active": "relays", "kind": kind, "title": RELAY_KINDS[kind],
})
@app.get("/settings", response_class=HTMLResponse)
async def settings_page(request: Request):
if redir := _redirect_if_unauth(request):
return redir
return TEMPLATES.TemplateResponse(request, "settings.html", {
"nav_active": "settings",
"network": await pm.get_network_config(),
})
@app.get("/network", response_class=HTMLResponse)
async def network_page(request: Request):
if redir := _redirect_if_unauth(request):
return redir
return TEMPLATES.TemplateResponse(request, "network.html", {
"detail": await pm.get_servers_detail(),
"nav_active": "network",
})
@app.get("/notifications", response_class=HTMLResponse)
async def notifications_page(request: Request):
if redir := _redirect_if_unauth(request):
return redir
return TEMPLATES.TemplateResponse(request, "notifications.html", {
"items": pm.get_notifications(100),
"nav_active": "notifications",
})
@app.get("/api/status")
async def api_status(request: Request):
_require_auth(request)
status = await pm.global_status()
status["profiles_total"] = len(db.list_profiles())
status["online"] = status["profiles_running"] > 0
return JSONResponse(status)
@app.get("/api/notifications")
async def api_notifications(request: Request):
_require_auth(request)
return JSONResponse({"unread": pm.unread_count(), "items": pm.get_notifications(50)})
@app.post("/api/notifications/read")
async def api_notifications_read(request: Request):
_require_auth(request)
pm.mark_all_read()
return JSONResponse({"ok": True})
@app.get("/profile/{profile_id}", response_class=HTMLResponse)
async def profile_page(request: Request, profile_id: int):
if redir := _redirect_if_unauth(request):
@@ -234,166 +110,20 @@ async def profile_page(request: Request, profile_id: int):
raise HTTPException(404, "Profile not found")
profile["config"] = json.loads(profile.get("config") or "{}")
profile["running"] = pm.is_running(profile_id)
# Refresh cached lists so member counts / contacts are current on view
await pm.refresh_lists(profile_id)
bot = pm.get_running(profile_id)
contacts = bot.contacts if bot else []
groups = bot.groups if bot else []
log_lines = bot.log_lines[-50:] if bot else []
cat = _category(profile["bot_type"])
# Split groups by their link role: channels (observer) vs regular groups (member).
# The is_channel flag is set during the bot's group refresh (see _classify_group).
channels = [g for g in groups if g.get("is_channel")]
plain_groups = [g for g in groups if not g.get("is_channel")]
return TEMPLATES.TemplateResponse(request, "profile.html", {
"profile": profile,
"contacts": contacts,
"groups": plain_groups,
"channels": channels,
"groups": groups,
"log_lines": log_lines,
"back": "/" + cat,
"nav_active": cat,
"bot_types": pm.BOT_TYPES,
})
def _find_chat_name(bot, chat_type: str, chat_id: int) -> str:
"""Resolve a chat's display name from the running bot's cached lists."""
if not bot:
return ""
if chat_type == "direct":
for c in bot.contacts:
if c["contactId"] == chat_id:
return c["localDisplayName"]
elif chat_type == "group":
for g in bot.groups:
if pm.group_id(g) == chat_id:
return pm.group_name(g)
return ""
@app.get("/profile/{profile_id}/chat/{chat_type}/{chat_id}", response_class=HTMLResponse)
async def chat_room(request: Request, profile_id: int, chat_type: str, chat_id: int):
if redir := _redirect_if_unauth(request):
return redir
profile = db.get_profile(profile_id)
if not profile:
raise HTTPException(404, "Profile not found")
if chat_type not in ("direct", "group"):
raise HTTPException(400, "chat_type must be 'direct' or 'group'")
bot = pm.get_running(profile_id)
name = _find_chat_name(bot, chat_type, chat_id) or f"#{chat_id}"
# Is this group a channel? (affects send affordance: broadcast vs reply)
is_channel = False
if chat_type == "group" and bot:
for g in bot.groups:
if pm.group_id(g) == chat_id:
is_channel = bool(g.get("is_channel"))
break
return TEMPLATES.TemplateResponse(request, "chat.html", {
"profile": profile,
"running": pm.is_running(profile_id),
"chat_type": chat_type,
"chat_id": chat_id,
"chat_name": name,
"is_channel": is_channel,
"nav_active": _category(profile["bot_type"]),
})
@app.get("/api/profiles/{profile_id}/chat/{chat_type}/{chat_id}/messages")
async def chat_messages(request: Request, profile_id: int, chat_type: str, chat_id: int):
_require_auth(request)
count = int(request.query_params.get("count", 50))
try:
messages = await pm.get_chat_history(profile_id, chat_type, chat_id, count)
except Exception as e:
raise HTTPException(400, str(e))
return JSONResponse({"messages": messages})
@app.post("/api/profiles/{profile_id}/chat/{chat_type}/{chat_id}/send")
async def chat_send(request: Request, profile_id: int, chat_type: str, chat_id: int):
_require_auth(request)
data = await request.json()
text = data.get("text", "").strip()
if not text:
raise HTTPException(400, "text required")
reply_to_id = data.get("reply_to_id")
try:
await pm.send_to_chat(profile_id, chat_type, chat_id, text, reply_to_id=reply_to_id)
except Exception as e:
log.error("chat send failed (profile=%s %s/%s): %s", profile_id, chat_type, chat_id, e)
return JSONResponse({"ok": False, "error": str(e)})
return JSONResponse({"ok": True})
@app.post("/api/profiles/{profile_id}/chat/{chat_type}/{chat_id}/react")
async def chat_react(request: Request, profile_id: int, chat_type: str, chat_id: int):
_require_auth(request)
data = await request.json()
item_id = data.get("item_id")
emoji = data.get("emoji", "")
add = data.get("add", True)
if not item_id or not emoji:
raise HTTPException(400, "item_id and emoji required")
try:
await pm.send_reaction(profile_id, chat_type, chat_id, item_id, emoji, add)
except Exception as e:
log.error("react failed: %s", e)
return JSONResponse({"ok": False, "error": str(e)})
return JSONResponse({"ok": True})
@app.post("/api/profiles/{profile_id}/file/{file_id}/receive")
async def file_receive(request: Request, profile_id: int, file_id: int):
_require_auth(request)
try:
await pm.accept_file(profile_id, file_id)
except Exception as e:
log.error("file receive failed: %s", e)
return JSONResponse({"ok": False, "error": str(e)})
return JSONResponse({"ok": True})
@app.get("/api/profiles/{profile_id}/file/{file_id}/download")
async def file_download(request: Request, profile_id: int, file_id: int):
_require_auth(request)
try:
data, filename = await pm.read_file_bytes(profile_id, file_id)
except FileNotFoundError as e:
raise HTTPException(404, str(e))
except Exception as e:
log.error("file download failed: %s", e)
raise HTTPException(500, str(e))
mime = mimetypes.guess_type(filename)[0] or "application/octet-stream"
return Response(
content=data,
media_type=mime,
headers={"Content-Disposition": f'inline; filename="{filename}"'},
)
@app.post("/api/profiles/{profile_id}/chat/{chat_type}/{chat_id}/clear")
async def chat_clear(request: Request, profile_id: int, chat_type: str, chat_id: int):
_require_auth(request)
try:
ok = await pm.clear_chat(profile_id, chat_type, chat_id)
except Exception as e:
raise HTTPException(400, str(e))
return JSONResponse({"ok": ok})
@app.delete("/api/profiles/{profile_id}/contacts/{contact_id}")
async def contact_delete(request: Request, profile_id: int, contact_id: int):
_require_auth(request)
try:
ok = await pm.delete_contact(profile_id, contact_id)
except Exception as e:
raise HTTPException(400, str(e))
return JSONResponse({"ok": ok})
# ── Profile API ───────────────────────────────────────────────────────────────
# ── API ───────────────────────────────────────────────────────────────────────
@app.post("/api/profiles")
async def create_profile(request: Request):
@@ -404,38 +134,15 @@ async def create_profile(request: Request):
config = data.get("config", {})
if not name:
raise HTTPException(400, "name required")
if bot_type not in pm.ALL_TYPES:
raise HTTPException(400, f"bot_type must be one of {pm.ALL_TYPES}")
if bot_type not in pm.BOT_TYPES:
raise HTTPException(400, f"bot_type must be one of {pm.BOT_TYPES}")
try:
profile = db.create_profile(name, bot_type, config)
except Exception as e:
raise HTTPException(400, str(e))
# Directory bots get their own auto-generated listing website
if bot_type == "directory":
try:
profile["site"] = pm.generate_directory_site(name)
except Exception as e:
log.error("directory site generation failed: %s", e)
return JSONResponse(profile, status_code=201)
@app.post("/api/profiles/{profile_id}/profile")
async def edit_profile(request: Request, profile_id: int):
_require_auth(request)
if not db.get_profile(profile_id):
raise HTTPException(404, "Profile not found")
data = await request.json()
# Keys absent from the body are left unchanged; avatar="" removes the avatar.
full_name = data.get("full_name")
bio = data.get("bio")
avatar = data.get("avatar") # None = unchanged, "" = remove, str = replace
try:
config = await pm.update_profile(profile_id, full_name, bio, avatar)
except Exception as e:
raise HTTPException(400, str(e))
return JSONResponse({"ok": True, "config": config})
@app.delete("/api/profiles/{profile_id}")
async def delete_profile(request: Request, profile_id: int):
_require_auth(request)
@@ -474,7 +181,6 @@ async def profile_status(request: Request, profile_id: int):
"contacts": len(bot.contacts) if bot else 0,
"groups": len(bot.groups) if bot else 0,
"log": bot.log_lines[-20:] if bot else [],
"poll_next": bot.poll_next if bot else 0,
})
@@ -490,80 +196,6 @@ async def send_message(request: Request, profile_id: int):
return JSONResponse({"ok": ok})
# ── Group / Channel API ───────────────────────────────────────────────────────
@app.post("/api/profiles/{profile_id}/groups")
async def create_group(request: Request, profile_id: int):
"""Create a group (kind='group', 2-way) or channel (kind='channel', broadcast)."""
_require_auth(request)
profile = db.get_profile(profile_id)
if not profile:
raise HTTPException(404, "Profile not found")
data = await request.json()
name = data.get("name", "").strip()
kind = data.get("kind", "group")
if not name:
raise HTTPException(400, "name required")
try:
if kind == "channel":
link = await pm.create_channel(profile_id, name)
else:
link = await pm.create_group(profile_id, name)
except Exception as e:
raise HTTPException(400, str(e))
return JSONResponse({"ok": True, "link": link})
@app.post("/api/profiles/{profile_id}/groups/{group_id}/join")
async def group_join(request: Request, profile_id: int, group_id: int):
_require_auth(request)
try:
ok = await pm.join_group(profile_id, group_id)
except Exception as e:
raise HTTPException(400, str(e))
return JSONResponse({"ok": ok})
@app.post("/api/profiles/{profile_id}/groups/{group_id}/leave")
async def group_leave(request: Request, profile_id: int, group_id: int):
_require_auth(request)
try:
ok = await pm.leave_group(profile_id, group_id)
except Exception as e:
raise HTTPException(400, str(e))
return JSONResponse({"ok": ok})
@app.delete("/api/profiles/{profile_id}/groups/{group_id}")
async def group_delete(request: Request, profile_id: int, group_id: int):
_require_auth(request)
try:
ok = await pm.delete_group(profile_id, group_id)
except Exception as e:
raise HTTPException(400, str(e))
return JSONResponse({"ok": ok})
@app.get("/api/profiles/{profile_id}/groups/{group_id}/members")
async def group_members(request: Request, profile_id: int, group_id: int):
_require_auth(request)
try:
members = await pm.get_group_members(profile_id, group_id)
except Exception as e:
raise HTTPException(400, str(e))
return JSONResponse({"members": members})
@app.get("/api/profiles/{profile_id}/groups/{group_id}/link")
async def group_link(request: Request, profile_id: int, group_id: int):
_require_auth(request)
try:
link = await pm.get_group_link(profile_id, group_id)
except Exception as e:
raise HTTPException(400, str(e))
return JSONResponse({"link": link})
if __name__ == "__main__":
import uvicorn
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)

File diff suppressed because it is too large Load Diff

View File

@@ -1,134 +0,0 @@
"""End-to-end test of the RSS bot (Pattern 3, in-process FFI).
Serves a mock RSS feed locally, starts an rss bot pointed at it, and checks:
- the bot creates a broadcast channel (observer group)
- the initial feed item is seeded (not re-posted)
- a newly-added feed item is broadcast to the channel
Verifies via the bot's own view of the channel chat (no subscriber needed).
Run: .venv/bin/python rss_test.py
"""
import asyncio
import json
import sys
import threading
import time
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import profiles as pm # noqa: E402
DATA = Path("data")
BOT_PREFIX = str(DATA / "rsstest_bot")
BOT_PID = 99004
# mutable feed state the mock server serves
FEED = {"items": [("First post", "https://example.com/1")]}
def feed_xml():
items = "".join(
f"<item><title>{t}</title><link>{l}</link><guid>{l}</guid></item>"
for t, l in FEED["items"]
)
return (f'<?xml version="1.0"?><rss version="2.0"><channel>'
f'<title>Test Feed</title>{items}</channel></rss>').encode()
class FeedHandler(BaseHTTPRequestHandler):
def do_GET(self):
body = feed_xml()
self.send_response(200)
self.send_header("Content-Type", "application/rss+xml")
self.end_headers()
self.wfile.write(body)
def log_message(self, *a):
pass
def cleanup():
for p in DATA.glob("rsstest_bot_*"):
p.unlink()
bf = DATA / "manager.db" # leave the manager db alone; we don't use it here
async def wait_until(fn, timeout=120, every=1):
start = time.time()
while time.time() - start < timeout:
v = await fn()
if v:
return v
await asyncio.sleep(every)
return None
async def channel_texts(chat, gid):
c = await chat.api_get_chat("group", gid, 50)
out = []
for ci in c.get("chatItems", []):
out.append(ci.get("content", {}).get("msgContent", {}).get("text", ""))
return out
async def main() -> int:
cleanup()
srv = HTTPServer(("127.0.0.1", 0), FeedHandler)
port = srv.server_address[1]
threading.Thread(target=srv.serve_forever, daemon=True).start()
url = f"http://127.0.0.1:{port}/feed.xml"
print("mock feed at", url)
async def on_address(pid, addr):
pass
profile = {
"id": BOT_PID, "name": "rsstestbot", "bot_type": "rss",
"db_prefix": BOT_PREFIX,
"config": json.dumps({"feed_url": url, "poll_seconds": 5}),
}
ok = True
try:
await pm.start_bot(profile, on_address)
b = pm.get_running(BOT_PID)
# 1) channel created
gid = await wait_until(lambda: asyncio.sleep(0, b.channel_gid), timeout=90)
print("channel created:", bool(gid), "gid", gid)
assert gid, "rss bot did not create a channel"
# 2) first run populates the channel with the existing item(s)
got_initial = await wait_until(
lambda: _contains(channel_texts(b.chat, gid), "First post"), timeout=20, every=2
)
print("initial item populated to channel:", bool(got_initial))
assert got_initial, "channel was not populated on first run"
# 3) add a new item → it should be broadcast on the next poll
FEED["items"].insert(0, ("Breaking news", "https://example.com/2"))
got = await wait_until(
lambda: _contains(channel_texts(b.chat, gid), "Breaking news"), timeout=30, every=2
)
print("new item broadcast to channel:", bool(got))
ok = bool(got)
except AssertionError as e:
ok = False
print("ASSERT FAIL:", e)
finally:
await pm.stop_bot(BOT_PID)
srv.shutdown()
cleanup()
print("\nRESULT:", "PASS — rss bot broadcasts new feed posts" if ok else "FAIL")
return 0 if ok else 1
async def _contains(coro, needle):
return any(needle in (t or "") for t in await coro)
if __name__ == "__main__":
raise SystemExit(asyncio.run(main()))

View File

@@ -3,16 +3,20 @@ set -e
cd "$(dirname "$0")"
# Bootstrap virtualenv on first run
if [ ! -d ".venv" ]; then
echo "Creating virtualenv..."
# Bootstrap virtualenv — check for uvicorn specifically so partial installs are retried
if [ ! -f ".venv/bin/uvicorn" ]; then
echo "Setting up virtualenv..."
rm -rf .venv
python3 -m venv .venv
.venv/bin/pip install -q --upgrade pip
.venv/bin/pip install -q -r requirements.txt
.venv/bin/pip install --upgrade pip
.venv/bin/pip install -r requirements.txt
fi
mkdir -p data/bots
mkdir -p static
mkdir -p data/bots static
# Pre-download the libsimplex native binary so first Start doesn't stall
echo "Checking simplex-chat binary..."
.venv/bin/python -m simplex_chat install 2>/dev/null && echo " simplex binary ready." || echo " Binary not downloaded yet — use the Init button in Settings."
# Set token — override via: MANAGER_TOKEN=mysecret ./start.sh
export MANAGER_TOKEN="${MANAGER_TOKEN:-changeme}"
@@ -23,4 +27,4 @@ echo " URL: http://0.0.0.0:8000"
echo " Token: $MANAGER_TOKEN"
echo ""
exec .venv/bin/uvicorn main:app --host 0.0.0.0 --port 8000 --reload
exec .venv/bin/uvicorn main:app --host 0.0.0.0 --port 8000

View File

@@ -1,23 +0,0 @@
{# Reusable SimpleX link box: a "Link" and a "QR" toggle button, both hidden by default.
linkbtns(id) — just the two toggle buttons (for table action cells)
linkpanels(link,id) — the hidden link + QR containers (place where they can span)
linkbox(link,id) — buttons + panels together (for block contexts)
`id` must be unique on the page. JS lives in base.html (sxToggleLink/sxToggleQr/sxCopy). #}
{% macro linkbtns(id) %}
<button class="lb-btn" onclick="sxToggleLink('{{ id }}', this)"><i class="fa-solid fa-link"></i> Link</button>
<button class="lb-btn" onclick="sxToggleQr('{{ id }}', this)"><i class="fa-solid fa-qrcode"></i> QR</button>
{% endmacro %}
{% macro linkpanels(link, id) %}
<div id="lb-link-{{ id }}" class="lb-link" style="display:none;">
<button class="btn btn-ghost copy-btn" title="Copy" onclick="sxCopy('{{ id }}', this)"><i class="fa-solid fa-copy"></i></button>
<a class="addr-link" id="lb-url-{{ id }}" href="{{ link }}" target="_blank" rel="noopener">{{ link }}</a>
</div>
<div id="lb-qr-{{ id }}" class="lb-qr" style="display:none;"><canvas id="lb-qrc-{{ id }}"></canvas></div>
{% endmacro %}
{% macro linkbox(link, id) %}
<div class="flex gap-8">{{ linkbtns(id) }}</div>
{{ linkpanels(link, id) }}
{% endmacro %}

View File

@@ -1,23 +1,12 @@
<!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>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap" rel="stylesheet">
<script src="https://unpkg.com/htmx.org@1.9.12/dist/htmx.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/qrcode/build/qrcode.min.js"></script>
<script>
// 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];
@@ -26,8 +15,7 @@
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
/* ── Original Light ─────────────────────────────────────────────── */
[data-theme="original-light"] {
:root {
--bg: #f5f5f7;
--card: #ffffff;
--text: #1d1d1f;
@@ -37,180 +25,33 @@
--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;
}
/* ── Original Dark ──────────────────────────────────────────────── */
[data-theme="original-dark"] {
@media (prefers-color-scheme: dark) {
:root {
--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: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif;
background: var(--bg); color: var(--text); min-height: 100vh; }
/* Material-ish elevation: cards lift slightly on hover */
.card { transition: box-shadow 0.18s ease, transform 0.05s ease; }
.card:hover { box-shadow: 0 4px 18px rgba(0,0,0,0.14); }
[data-theme="matrix"] .card:hover { box-shadow: 0 0 18px rgba(0,255,65,0.22); }
.btn { box-shadow: 0 1px 3px rgba(0,0,0,0.18); letter-spacing: 0.2px; }
.btn-ghost { box-shadow: none; }
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;
overflow-y: auto; /* scroll within the sidebar if it's taller than the screen */
transition: width 0.2s ease, transform 0.2s ease;
z-index: 50;
}
html.collapsed .sidebar { width: 64px; }
.side-head { display: flex; align-items: center; border-bottom: 1px solid var(--border); }
.collapse-toggle {
flex-shrink: 0; background: none; border: none; cursor: pointer;
color: var(--muted); font-size: 17px; padding: 18px;
}
.collapse-toggle:hover { color: var(--text); }
.nav-brand {
display: flex; align-items: center; gap: 10px;
padding: 18px 18px 18px 0; font-size: 16px; font-weight: 700;
color: var(--accent); text-decoration: none;
white-space: nowrap; overflow: hidden;
}
.nav-brand .brand-icon { font-size: 18px; flex-shrink: 0; }
html.collapsed .nav-brand { display: none; }
html.collapsed .side-head { justify-content: center; }
.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 .side-nav .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-clock { padding: 12px 18px 10px; border-bottom: 1px solid var(--border); text-align: center; }
.side-clock .clk-time { font-size: 20px; font-weight: 700; color: var(--text);
font-variant-numeric: tabular-nums; letter-spacing: 0.5px; }
.side-clock .clk-date { font-size: 11px; color: var(--muted); margin-top: 2px; }
html.collapsed .side-clock { padding: 10px 0; }
html.collapsed .side-clock .clk-time { font-size: 12px; }
html.collapsed .side-clock .clk-date { display: none; }
.side-status { padding: 10px 18px 12px; font-size: 12px; color: var(--muted);
border-bottom: 1px solid var(--border); }
.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); }
/* collapsed: keep only the status dot, centered */
html.collapsed .side-status { padding: 12px 0; }
html.collapsed .side-status .ss-text { display: none; }
html.collapsed .side-status .ss-row { justify-content: center; margin-top: 0; }
html.collapsed .side-status .ss-row.ss-text { display: none; }
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; width: 240px;
height: 100vh; height: 100dvh; /* dvh tracks the visible area incl. browser toolbar */
overflow-y: auto; -webkit-overflow-scrolling: touch;
transform: translateX(-100%);
}
html.collapsed .sidebar { width: 240px; } /* ignore collapse on mobile */
html.collapsed .lbl, html.collapsed .brand-text { display: inline; }
html.collapsed .nav-brand { display: flex; } /* keep brand visible on mobile */
html.collapsed .side-head { justify-content: flex-start; }
body.sidebar-open .sidebar { transform: translateX(0); }
body.sidebar-open .backdrop { display: block; }
.mobile-menu-btn { display: flex; }
.collapse-toggle { display: none; } /* mobile uses the drawer toggle instead */
.container { padding-top: 64px; }
}
.container { max-width: 960px; margin: 0 auto; padding: 32px 20px; }
h1 { font-size: 28px; font-weight: 700; margin-bottom: 24px; }
h2 { font-size: 20px; font-weight: 600; margin-bottom: 16px; }
@@ -223,15 +64,25 @@
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: var(--btn-light-text); }
.btn-primary { background: var(--accent); color: #fff; }
.btn-danger { background: var(--red); color: #fff; }
.btn-success { background: var(--green); color: var(--btn-light-text); }
.btn-success { background: var(--green); color: #fff; }
.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: var(--badge-green-bg); color: var(--badge-green-text); }
.badge-red { background: var(--badge-red-bg); color: var(--badge-red-text); }
.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; }
}
input, select, textarea {
width: 100%; padding: 9px 12px; font-size: 14px; font-family: inherit;
@@ -257,9 +108,6 @@
.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; }
@@ -280,176 +128,19 @@
dialog { background: var(--card); color: var(--text); border: 1px solid var(--border);
border-radius: 12px; padding: 28px; max-width: 480px; width: 90%; }
dialog::backdrop { background: rgba(0,0,0,0.5); }
/* reusable SimpleX link box: Link + QR toggles, both hidden by default */
.lb-btn { display: inline-flex; align-items: center; gap: 6px;
padding: 4px 10px; font-size: 12px; font-weight: 600; border-radius: 6px;
background: transparent; border: 1px solid var(--border); color: var(--accent);
cursor: pointer; font-family: inherit; }
.lb-btn:hover { background: var(--bg); }
.lb-btn.on { background: var(--accent); color: var(--btn-light-text); border-color: var(--accent); }
.lb-link { display: flex; align-items: center; gap: 8px; margin-top: 8px; }
.lb-link .addr-link { flex: 1; min-width: 0; color: var(--muted); font-family: monospace;
font-size: 12px; text-decoration: none; word-break: break-all; }
.lb-link .addr-link:hover { color: var(--accent); text-decoration: underline; }
.lb-qr { margin-top: 10px; }
.lb-qr canvas { background: #fff; border-radius: 8px; padding: 8px; }
</style>
{% block head %}{% endblock %}
</head>
<body>
<button class="mobile-menu-btn" onclick="toggleSidebar()" aria-label="Menu"><i class="fa-solid fa-bars"></i></button>
<div class="app">
<aside class="sidebar" id="sidebar">
<div class="side-head">
<button class="collapse-toggle" onclick="toggleCollapse()" title="Collapse sidebar" aria-label="Collapse sidebar"><i class="fa-solid fa-bars"></i></button>
<a class="nav-brand" href="/">
<span class="brand-text">SimpleX Manager</span>
</a>
<nav>
<a class="nav-brand" href="/">SimpleX Manager</a>
<div class="nav-links">
<a href="/">Profiles</a>
<a href="/logout">Logout</a>
</div>
<nav class="side-nav">
<!-- Group 1: accounts -->
<a href="/users" {% if nav_active == 'users' %}class="active"{% endif %}><span class="ico"><i class="fa-solid fa-user"></i></span><span class="lbl">Users</span></a>
<a href="/bots" {% if nav_active == 'bots' %}class="active"{% endif %}><span class="ico"><i class="fa-solid fa-robot"></i></span><span class="lbl">Bots</span></a>
<a href="/businesses" {% if nav_active == 'businesses' %}class="active"{% endif %}><span class="ico"><i class="fa-solid fa-briefcase"></i></span><span class="lbl">Business Groups</span></a>
<a href="https://simplex.chat/file/" target="_blank" rel="noopener"><span class="ico"><i class="fa-solid fa-upload"></i></span><span class="lbl">File Upload</span></a>
<a href="/rss-bots" {% if nav_active == 'rss-bots' %}class="active"{% endif %}><span class="ico"><i class="fa-solid fa-rss"></i></span><span class="lbl">RSS</span></a>
<!-- Group 2: relays -->
<a href="/relays/chat" class="nav-sep {% if nav_active == 'relays' and kind == 'chat' %}active{% endif %}"><span class="ico"><i class="fa-solid fa-comments"></i></span><span class="lbl">Chat Relay</span></a>
<a href="/relays/file" {% if nav_active == 'relays' and kind == 'file' %}class="active"{% endif %}><span class="ico"><i class="fa-solid fa-file-export"></i></span><span class="lbl">File Relay</span></a>
<a href="/relays/message" {% if nav_active == 'relays' and kind == 'message' %}class="active"{% endif %}><span class="ico"><i class="fa-solid fa-envelope"></i></span><span class="lbl">Message Relay</span></a>
<!-- Group 3: system -->
<a href="/network" class="nav-sep {% if nav_active == 'network' %}active{% endif %}"><span class="ico"><i class="fa-solid fa-tower-broadcast"></i></span><span class="lbl">Network</span></a>
<a href="/notifications" {% if nav_active == 'notifications' %}class="active"{% endif %}><span class="ico"><i class="fa-solid fa-bell"></i></span><span class="lbl">Notifications</span><span class="notif-badge" id="notif-badge" style="display:none;"></span></a>
<a href="/settings" {% if nav_active == 'settings' %}class="active"{% endif %}><span class="ico"><i class="fa-solid fa-gear"></i></span><span class="lbl">Settings</span></a>
<!-- Group 4: external -->
<a href="https://simplex.chat/downloads/" target="_blank" rel="noopener" class="nav-sep"><span class="ico"><i class="fa-solid fa-download"></i></span><span class="lbl">Get App</span></a>
</nav>
<div class="side-foot">
<a href="/network" class="side-status" id="side-status" title="View SimpleX network &amp; servers"
style="display:block;text-decoration:none;{% if nav_active == 'network' %}background:var(--bg);{% endif %}">
<div class="ss-row"><span class="ss-dot" id="ss-dot"></span><span class="ss-text"><span id="ss-running">/</span>&nbsp;running</span></div>
<div class="ss-row ss-text"><i class="fa-solid fa-server" style="width:14px;text-align:center;"></i>&nbsp;<span id="ss-servers"></span></div>
<div class="ss-row ss-text" id="ss-ops" style="opacity:0.8;"></div>
</a>
<div class="side-clock" id="side-clock">
<div class="clk-time" id="clk-time">--:--</div>
<div class="clk-date ss-text" id="clk-date"></div>
</div>
<nav class="side-nav">
<a href="/logout"><span class="ico"><i class="fa-solid fa-right-from-bracket"></i></span><span class="lbl">Logout</span></a>
</nav>
</div>
</aside>
<div class="backdrop" id="backdrop" onclick="closeSidebar()"></div>
<main class="main">
<div class="container">
</nav>
<div class="container">
{% block content %}{% endblock %}
</div>
<footer class="site-footer">
© <a href="https://bournemouthtechnology.co.uk" target="_blank" rel="noopener">Bournemouth Technology Ltd</a>
<span class="sep">·</span>
built on © <a href="https://simplex.chat" target="_blank" rel="noopener">SimpleX Network</a>
<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' : '');
}
// Clipboard that also works over plain-HTTP LAN (navigator.clipboard needs a secure context).
function robustCopy(text) {
if (navigator.clipboard && window.isSecureContext) {
return navigator.clipboard.writeText(text).catch(() => fallbackCopy(text));
}
return Promise.resolve(fallbackCopy(text));
}
function fallbackCopy(text) {
const ta = document.createElement('textarea');
ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0';
document.body.appendChild(ta); ta.focus(); ta.select();
try { document.execCommand('copy'); } catch (e) {}
document.body.removeChild(ta);
}
function flashCheck(btn) {
const o = btn.innerHTML;
btn.innerHTML = '<i class="fa-solid fa-check"></i>';
setTimeout(() => btn.innerHTML = o, 1500);
}
// Reusable SimpleX link box: Link + QR toggles (both hidden by default).
function _sxUrl(id) { const a = document.getElementById('lb-url-' + id); return a ? a.getAttribute('href') : ''; }
function sxToggleLink(id, btn) {
const el = document.getElementById('lb-link-' + id); if (!el) return;
const show = el.style.display === 'none';
el.style.display = show ? '' : 'none';
btn.classList.toggle('on', show);
}
function sxToggleQr(id, btn) {
const w = document.getElementById('lb-qr-' + id); if (!w) return;
const show = w.style.display === 'none';
w.style.display = show ? '' : 'none';
btn.classList.toggle('on', show);
if (show && !w.dataset.r && window.QRCode) {
QRCode.toCanvas(document.getElementById('lb-qrc-' + id), _sxUrl(id), {width: 180}, () => {});
w.dataset.r = '1';
}
}
function sxCopy(id, btn) { robustCopy(_sxUrl(id)).then(() => flashCheck(btn)); }
// Sidebar clock: 24h time + day-of-week date
function tickClock() {
const now = new Date();
const te = document.getElementById('clk-time');
const de = document.getElementById('clk-date');
if (te) te.textContent = now.toLocaleTimeString('en-GB', {hour: '2-digit', minute: '2-digit', hour12: false});
if (de) de.textContent = now.toLocaleDateString('en-GB', {weekday: 'short', day: '2-digit', month: 'short', year: 'numeric'});
}
tickClock();
setInterval(tickClock, 1000);
// 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 label = d.unread > 99 ? '99+' : d.unread;
// update every badge (sidebar nav + homepage card) from the one source
document.querySelectorAll('.notif-badge').forEach(b => {
if (d.unread > 0) { b.textContent = label; 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>

View File

@@ -1,399 +0,0 @@
{% 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: 6px;
}
/* ── Bubble row (holds avatar space + bubble + actions) ── */
.msg-row {
display: flex; align-items: flex-end; gap: 6px;
position: relative;
}
.msg-row.out { flex-direction: row-reverse; }
/* ── Bubble ── */
.bubble {
max-width: 72%; padding: 8px 12px; border-radius: 14px;
font-size: 14px; line-height: 1.4; word-wrap: break-word;
position: relative;
}
.bubble .who { font-size: 11px; font-weight: 700; opacity: 0.7; margin-bottom: 2px; }
.bubble .ts { font-size: 10px; opacity: 0.55; margin-top: 4px; 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; }
/* ── Quote block (replied-to message) ── */
.quote-block {
border-left: 3px solid currentColor;
padding: 4px 8px; margin-bottom: 6px; border-radius: 4px;
font-size: 12px; opacity: 0.75;
background: rgba(0,0,0,0.06);
max-height: 60px; overflow: hidden;
}
.quote-block .q-who { font-weight: 700; margin-bottom: 1px; }
.quote-block .q-text { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* ── Inline image ── */
.msg-image {
max-width: 100%; max-height: 300px; border-radius: 8px;
display: block; margin-bottom: 4px; cursor: pointer;
}
/* ── File attachment ── */
.file-block {
display: flex; align-items: center; gap: 8px;
padding: 6px 8px; margin-bottom: 4px;
background: rgba(0,0,0,0.08); border-radius: 8px;
font-size: 12px;
}
.file-block .f-ico { font-size: 18px; flex-shrink: 0; }
.file-block .f-meta { flex: 1; min-width: 0; }
.file-block .f-name { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.file-block .f-size { opacity: 0.6; font-size: 11px; }
.file-block .f-action button, .file-block .f-action a {
font-size: 11px; padding: 3px 8px; border-radius: 6px;
border: 1px solid currentColor; background: transparent; cursor: pointer;
color: inherit; text-decoration: none; display: inline-block;
}
.file-block .f-action button:hover, .file-block .f-action a:hover { background: rgba(255,255,255,0.15); }
/* ── Reactions ── */
.reactions {
display: flex; flex-wrap: wrap; gap: 4px; margin-top: 5px;
}
.rxn {
display: inline-flex; align-items: center; gap: 3px;
font-size: 13px; padding: 1px 6px;
border-radius: 10px; border: 1px solid var(--border);
background: var(--bg); cursor: pointer;
transition: background 0.1s;
}
.rxn.me { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 15%, transparent); }
.rxn:hover { background: var(--border); }
.rxn .rxn-count { font-size: 11px; opacity: 0.8; }
/* ── Hover action buttons ── */
.msg-actions {
display: none; flex-direction: column; gap: 3px;
align-self: center;
}
.msg-row:hover .msg-actions { display: flex; }
.msg-act-btn {
background: var(--card); border: 1px solid var(--border);
border-radius: 6px; padding: 3px 7px; cursor: pointer;
font-size: 12px; color: var(--text); white-space: nowrap;
}
.msg-act-btn:hover { border-color: var(--accent); color: var(--accent); }
/* ── Emoji picker strip ── */
.emoji-strip {
display: none; position: absolute; z-index: 10;
background: var(--card); border: 1px solid var(--border);
border-radius: 10px; padding: 5px 8px; gap: 4px;
box-shadow: var(--shadow);
}
.msg-row:hover .emoji-strip { display: flex; }
.msg-row.out .emoji-strip { right: 100%; margin-right: 6px; }
.msg-row:not(.out) .emoji-strip { left: 100%; margin-left: 6px; }
.e-btn { font-size: 18px; cursor: pointer; border: none; background: transparent;
padding: 2px; border-radius: 4px; }
.e-btn:hover { background: var(--bg); }
/* ── Reply preview above compose ── */
.reply-preview {
display: flex; align-items: center; justify-content: space-between;
padding: 6px 12px; background: var(--bg); border-top: 1px solid var(--border);
font-size: 12px; gap: 8px;
}
.reply-preview .rp-bar {
width: 3px; align-self: stretch; border-radius: 2px;
background: var(--accent); flex-shrink: 0;
}
.reply-preview .rp-body { flex: 1; min-width: 0; }
.reply-preview .rp-who { font-weight: 700; color: var(--accent); }
.reply-preview .rp-text { opacity: 0.7; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.reply-preview .rp-cancel { cursor: pointer; opacity: 0.5; font-size: 16px; flex-shrink: 0; }
.reply-preview .rp-cancel:hover { opacity: 1; }
.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)"><i class="fa-solid fa-rotate-right"></i> Refresh</button>
</div>
{% if is_channel %}
<div class="chat-banner"><i class="fa-solid fa-bullhorn"></i> 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 id="reply-preview" class="reply-preview" style="display:none;">
<div class="rp-bar"></div>
<div class="rp-body">
<div class="rp-who" id="rp-who"></div>
<div class="rp-text" id="rp-text"></div>
</div>
<span class="rp-cancel" onclick="cancelReply()">&#x2715;</span>
</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 = '';
let replyTo = null; // {id, sender, text}
const QUICK_EMOJIS = ['👍','❤️','😂','😮','😢','🙏'];
function fmtSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1048576) return (bytes/1024).toFixed(1) + ' KB';
return (bytes/1048576).toFixed(1) + ' MB';
}
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 escapeHtml(s) {
return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
function renderQuote(q) {
if (!q) return '';
const who = q.sender ? `<div class="q-who">${escapeHtml(q.sender)}</div>` : '';
const txt = q.text ? `<div class="q-text">${escapeHtml(q.text)}</div>` : '<div class="q-text"><em>attachment</em></div>';
return `<div class="quote-block">${who}${txt}</div>`;
}
function renderImage(m) {
if (!m.image_preview) return '';
// image_preview is a base64 data URI from the message content
const escaped = escapeHtml(m.image_preview);
// If file is downloaded, clicking opens the full-res version
const fullUrl = m.file && m.file.status === 'rcvComplete'
? `/api/profiles/${PROFILE_ID}/file/${m.file.id}/download`
: null;
const img = `<img class="msg-image" src="${escaped}" alt="image"
${fullUrl ? `onclick="window.open('${fullUrl}','_blank')" title="Click to open full size"` : ''}>`;
return img;
}
function renderFile(f) {
if (!f) return '';
const isComplete = f.status === 'rcvComplete' || f.status === 'sndComplete' || f.status === 'sndStored';
const ico = '<i class="fa-solid fa-paperclip"></i>';
const name = escapeHtml(f.name || 'file');
const size = fmtSize(f.size || 0);
let action = '';
if (f.status === 'rcvInvitation') {
action = `<div class="f-action"><button onclick="acceptFile(${f.id},this)"><i class="fa-solid fa-download"></i> Accept</button></div>`;
} else if (isComplete) {
const dlUrl = `/api/profiles/${PROFILE_ID}/file/${f.id}/download`;
action = `<div class="f-action"><a href="${dlUrl}" target="_blank"><i class="fa-solid fa-arrow-down"></i> Download</a></div>`;
} else if (f.status && f.status.startsWith('rcvTransfer')) {
action = `<div class="f-action"><span style="opacity:0.6;font-size:11px;"><i class="fa-solid fa-spinner fa-spin"></i> Downloading…</span></div>`;
}
return `<div class="file-block">
<div class="f-ico">${ico}</div>
<div class="f-meta">
<div class="f-name" title="${name}">${name}</div>
<div class="f-size">${size}</div>
</div>
${action}
</div>`;
}
function renderReactions(reactions, itemId) {
if (!reactions || !reactions.length) return '';
const pills = reactions.map(r => {
const me = r.me ? ' me' : '';
return `<span class="rxn${me}" title="${r.me?'You reacted':''}" onclick="toggleReaction(${itemId},'${escapeHtml(r.emoji)}',${r.me})">${escapeHtml(r.emoji)}<span class="rxn-count">${r.count}</span></span>`;
}).join('');
return `<div class="reactions">${pills}</div>`;
}
function renderEmojiStrip(itemId) {
const btns = QUICK_EMOJIS.map(e =>
`<button class="e-btn" title="React ${e}" onclick="sendReaction(${itemId},'${e}',true,event)">${e}</button>`
).join('');
return `<div class="emoji-strip">${btns}</div>`;
}
function render(messages) {
const log = document.getElementById('chat-log');
const sig = messages.map(m => m.id + ':' + (m.reactions||[]).map(r=>r.emoji+r.count).join('')).join(',');
if (sig === lastIds) return;
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 out = m.outgoing;
const dir = out ? 'out' : 'in';
const bubbleCls = `bubble ${dir}${m.deleted ? ' deleted' : ''}`;
const who = (!out && m.sender) ? `<div class="who">${escapeHtml(m.sender)}</div>` : '';
const txt = m.deleted ? '(deleted)' : (m.text ? escapeHtml(m.text) : '');
const quoteHtml = renderQuote(m.quote);
const imageHtml = renderImage(m);
const fileHtml = (!imageHtml || m.file) ? renderFile(m.file) : '';
const reactHtml = renderReactions(m.reactions, m.id);
const emojiStrip = m.deleted ? '' : renderEmojiStrip(m.id);
const replyBtn = m.deleted ? '' :
`<button class="msg-act-btn" onclick="setReply(${m.id},${JSON.stringify(escapeHtml(m.sender||'You'))},${JSON.stringify(escapeHtml((m.text||'').slice(0,80)))})" title="Reply"><i class="fa-solid fa-reply"></i></button>`;
const actionsHtml = `<div class="msg-actions">${replyBtn}</div>`;
const inner = `${quoteHtml}${imageHtml}${fileHtml}${who}${txt}${reactHtml}<div class="ts">${fmtTs(m.ts)}</div>`;
return `<div class="msg-row ${dir}" data-id="${m.id}">
${emojiStrip}
${out ? actionsHtml : ''}
<div class="${bubbleCls}">${inner}</div>
${!out ? actionsHtml : ''}
</div>`;
}).join('');
if (atBottom) log.scrollTop = log.scrollHeight;
}
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) {}
}
function setReply(id, sender, text) {
replyTo = {id, sender, text};
document.getElementById('rp-who').textContent = sender || 'Unknown';
document.getElementById('rp-text').textContent = text || '(attachment)';
document.getElementById('reply-preview').style.display = 'flex';
document.getElementById('msg-input').focus();
}
function cancelReply() {
replyTo = null;
document.getElementById('reply-preview').style.display = 'none';
}
async function sendMsg() {
const input = document.getElementById('msg-input');
const text = input.value.trim();
if (!text) return;
input.value = '';
const body = {text};
if (replyTo) body.reply_to_id = replyTo.id;
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(body),
});
cancelReply();
const data = await resp.json();
if (!data.ok) {
input.value = text;
alert('Failed to send: ' + (data.error || data.detail || 'unknown error'));
return;
}
setTimeout(() => loadMessages(true), 250);
}
async function sendReaction(itemId, emoji, add, ev) {
if (ev) ev.stopPropagation();
await fetch(`/api/profiles/${PROFILE_ID}/chat/${CHAT_TYPE}/${CHAT_ID}/react`, {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-Token': _token()},
body: JSON.stringify({item_id: itemId, emoji, add}),
});
setTimeout(() => loadMessages(true), 300);
}
async function toggleReaction(itemId, emoji, currently_reacted) {
await sendReaction(itemId, emoji, !currently_reacted, null);
}
async function acceptFile(fileId, btn) {
btn.disabled = true;
btn.textContent = '…';
const resp = await fetch(`/api/profiles/${PROFILE_ID}/file/${fileId}/receive`, {
method: 'POST', headers: {'X-Token': _token()},
});
const data = await resp.json();
if (!data.ok) { btn.disabled = false; btn.textContent = 'Retry'; return; }
setTimeout(() => loadMessages(true), 500);
}
if (RUNNING) {
loadMessages(true);
setInterval(loadMessages, 3000);
}
</script>
{% endblock %}

View File

@@ -1,99 +0,0 @@
{% extends "base.html" %}
{% block title %}Home — SimpleX Manager{% endblock %}
{% block head %}
<style>
.home-head { margin-bottom: 28px; text-align: center; }
.home-head h1 { margin-bottom: 6px; color: var(--accent); }
.tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 14px; }
/* faded bar between areas, like the sidebar separators */
.tiles + .tiles { margin-top: 28px; padding-top: 28px; border-top: 1px solid var(--border); }
.tile {
display: flex; flex-direction: row; align-items: center; gap: 12px;
background: var(--card); border: 1px solid var(--border); border-radius: 12px;
padding: 16px 18px; text-decoration: none; color: var(--text);
box-shadow: var(--shadow);
transition: border-color 0.15s, transform 0.05s;
}
.tile:hover { border-color: var(--accent); }
.tile:active { transform: translateY(1px); }
.tile .t-ico { font-size: 22px; line-height: 1; flex-shrink: 0; }
.tile .t-title { font-size: 15px; font-weight: 700; }
/* closing bar at the bottom */
.home-end { margin-top: 28px; border-top: 1px solid var(--border); }
</style>
{% endblock %}
{% block content %}
<div class="home-head">
<h1>SimpleX Manager</h1>
</div>
<!-- Area 1: your accounts -->
<div class="tiles">
<a class="tile" href="/users">
<span class="t-ico"><i class="fa-solid fa-user"></i></span>
<span class="t-title">Users</span>
</a>
<a class="tile" href="/bots">
<span class="t-ico"><i class="fa-solid fa-robot"></i></span>
<span class="t-title">Bots</span>
</a>
<a class="tile" href="/businesses">
<span class="t-ico"><i class="fa-solid fa-briefcase"></i></span>
<span class="t-title">Business Groups</span>
</a>
<a class="tile" href="https://simplex.chat/file/" target="_blank" rel="noopener">
<span class="t-ico"><i class="fa-solid fa-upload"></i></span>
<span class="t-title">File Upload</span>
</a>
<a class="tile" href="/rss-bots">
<span class="t-ico"><i class="fa-solid fa-rss"></i></span>
<span class="t-title">RSS</span>
</a>
</div>
<!-- Area: relays -->
<div class="tiles">
<a class="tile" href="/relays/chat">
<span class="t-ico"><i class="fa-solid fa-comments"></i></span>
<span class="t-title">Chat Relay</span>
</a>
<a class="tile" href="/relays/file">
<span class="t-ico"><i class="fa-solid fa-file-export"></i></span>
<span class="t-title">File Relay</span>
</a>
<a class="tile" href="/relays/message">
<span class="t-ico"><i class="fa-solid fa-envelope"></i></span>
<span class="t-title">Message Relay</span>
</a>
</div>
<!-- Area 3: system -->
<div class="tiles">
<a class="tile" href="/network">
<span class="t-ico"><i class="fa-solid fa-tower-broadcast"></i></span>
<span class="t-title">Network</span>
</a>
<a class="tile" href="/notifications">
<span class="t-ico"><i class="fa-solid fa-bell"></i></span>
<span class="t-title">Notifications</span>
<span class="notif-badge" style="display:none;"></span>
</a>
<a class="tile" href="/settings">
<span class="t-ico"><i class="fa-solid fa-gear"></i></span>
<span class="t-title">Settings</span>
</a>
</div>
<!-- Area 3: SimpleX (external) -->
<div class="tiles">
<a class="tile" href="https://simplex.chat/downloads/" target="_blank" rel="noopener">
<span class="t-ico"><i class="fa-solid fa-download"></i></span>
<span class="t-title">Get SimpleX App</span>
</a>
</div>
<div class="home-end"></div>
{% endblock %}

View File

@@ -0,0 +1,111 @@
{% 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 %}

View File

@@ -1,481 +0,0 @@
{% extends "base.html" %}
{% import "_macros.html" as ui %}
{% block title %}{{ 'Business Groups' if tab == 'businesses' else ('RSS' if tab == 'rss-bots' else 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; }
.chk-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 6px 12px; }
.chk { display: flex; align-items: center; gap: 7px; font-size: 13px; font-weight: 500;
color: var(--text); cursor: pointer; }
.chk input { width: auto; }
</style>
{% endblock %}
{% block content %}
{% set new_label = 'User' if tab == 'users' else ('Business Group' if tab == 'businesses' else ('RSS Bot' if tab == 'rss-bots' else 'Bot')) %}
{% set page_title = 'Business Groups' if tab == 'businesses' else ('RSS' if tab == 'rss-bots' else tab | title) %}
<div class="flex-between" style="margin-bottom: 24px;">
<h1 style="margin:0;">{{ page_title }}</h1>
<button class="btn btn-primary" onclick="openCreate()">
+ New {{ new_label }}
</button>
</div>
{% if tab == 'businesses' %}
<div class="card bot-types-card" style="margin-bottom:24px;">
<h2 style="font-size:15px;margin-bottom:8px;">Business Groups</h2>
<p class="muted" style="font-size:13px;">
A business group uses a <strong>business address</strong>: each customer who connects gets
their own group chat (so teammates can be added). You handle those conversations here, the same
way you chat in a group. Set an optional welcome message to auto-greet new customers.
</p>
</div>
{% endif %}
{% if tab == 'rss-bots' %}
<div class="card bot-types-card" style="margin-bottom:24px;">
<h2 style="font-size:15px;margin-bottom:8px;">RSS</h2>
<p class="muted" style="font-size:13px;">
RSS bots read from an RSS/Atom feed and post new items to a channel. To receive a feed,
share the bot's <strong>channel</strong> link with subscribers — open the bot and copy its
<strong>channel</strong> link, <strong>not</strong> the user address.
</p>
</div>
{% endif %}
{% 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">llm</span></td><td class="muted">Chat with a local or remote LLM (OpenAI-compatible, e.g. Ollama). Give it context, it replies to your messages.</td></tr>
<tr><td><span class="tag">crypto</span></td><td class="muted">Streams selected crypto prices (CoinGecko) to a channel on an interval. Pick coins &amp; currencies below.</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">
{% 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 %}"
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 tab == 'rss-bots' %}
<div style="margin-top:8px;font-size:12px;color:var(--muted);">
<i class="fa-solid fa-clock"></i>
<span id="poll-{{ p.id }}">{{ '—' if not p.running else 'Loading…' }}</span>
</div>
{% endif %}
{% if p.address %}
<div onclick="event.stopPropagation()" style="margin-top:10px;">{{ ui.linkbox(p.address, 'p' ~ p.id) }}</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>
{% elif tab == 'businesses' %}
<strong>No business groups yet</strong>
<p>Create a business group; each customer who connects gets their own group chat.</p>
{% elif tab == 'rss-bots' %}
<strong>No RSS bots yet</strong>
<p>Create an RSS bot to post a feed to a channel.</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 {{ new_label }}</h2>
<form id="create-form">
<div class="field">
<label>Name</label>
<input type="text" name="name" placeholder="{{ 'Alice' if tab == 'users' else ('Acme Inc' if tab == 'businesses' 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 == 'businesses' %}
<div class="field">
<label>Welcome Message <span class="muted" style="font-weight:400;">(optional auto-reply to new customers)</span></label>
<input type="text" name="welcome_message" placeholder="Thanks for reaching out! How can we help?">
</div>
{% endif %}
{% if tab == 'rss-bots' %}
<div class="field">
<label>Feed URL</label>
<input type="text" name="feed_url" placeholder="https://example.com/feed.xml">
</div>
<div class="field">
<label>How often to check the feed</label>
<div class="chk-grid">
<label class="chk"><input type="radio" name="rss_poll" value="3600" checked> Per hour</label>
<label class="chk"><input type="radio" name="rss_poll" value="86400"> Per day</label>
<label class="chk"><input type="radio" name="rss_poll" value="604800"> Per week</label>
</div>
</div>
{% endif %}
{% 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>
<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 — works with a local Ollama via <code>ollama serve</code>,
OpenAI, Grok…). The LLM bot needs the URL; support bots may leave it blank for a
welcome-only inbox.
</p>
</div>
<div class="field">
<label>API Base URL</label>
<input type="text" name="api_base" placeholder="http://localhost:11434/v1 (Ollama) · https://api.x.ai/v1">
</div>
<div class="field">
<label>API Key <span class="muted" style="font-weight:400;">(any value for Ollama)</span></label>
<input type="password" name="api_key" placeholder="ollama · xai-…">
</div>
<div class="field">
<label>Model</label>
<input type="text" name="model" placeholder="llama3.2 (Ollama) · grok-2">
</div>
<div class="field">
<label>Context <span class="muted" style="font-weight:400;">(system prompt given on start-up)</span></label>
<textarea name="system_prompt" rows="3" placeholder="You are a helpful 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>
<div id="broadcast-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;">
Only listed publishers can broadcast; their text/links are relayed to every contact.
Anyone else gets the prohibited reply and their message is deleted.
</p>
</div>
<div class="field">
<label>Publishers <span class="muted" style="font-weight:400;">(comma-separated; "Name" or "ID:Name")</span></label>
<input type="text" name="publishers" placeholder="Alice, 2:Bob">
</div>
<div class="field">
<label>Prohibited reply <span class="muted" style="font-weight:400;">(blank = default listing publishers)</span></label>
<input type="text" name="prohibited_message" placeholder="Only publishers can broadcast. Your message is deleted.">
</div>
</div>
<div id="crypto-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;">
Posts a price snapshot of the selected coins to a channel every interval (via CoinGecko).
</p>
</div>
<div class="field">
<label>Coins</label>
<div class="chk-grid">
<label class="chk"><input type="checkbox" name="coin" value="bitcoin" checked> Bitcoin</label>
<label class="chk"><input type="checkbox" name="coin" value="ethereum" checked> Ethereum</label>
<label class="chk"><input type="checkbox" name="coin" value="solana"> Solana</label>
<label class="chk"><input type="checkbox" name="coin" value="ripple"> XRP</label>
<label class="chk"><input type="checkbox" name="coin" value="cardano"> Cardano</label>
<label class="chk"><input type="checkbox" name="coin" value="dogecoin"> Dogecoin</label>
<label class="chk"><input type="checkbox" name="coin" value="binancecoin"> BNB</label>
<label class="chk"><input type="checkbox" name="coin" value="polkadot"> Polkadot</label>
<label class="chk"><input type="checkbox" name="coin" value="litecoin"> Litecoin</label>
<label class="chk"><input type="checkbox" name="coin" value="tron"> TRON</label>
<label class="chk"><input type="checkbox" name="coin" value="chainlink"> Chainlink</label>
<label class="chk"><input type="checkbox" name="coin" value="tether"> Tether</label>
</div>
</div>
<div class="field">
<label>Currencies</label>
<div class="chk-grid">
<label class="chk"><input type="checkbox" name="cur" value="usd" checked> USD</label>
<label class="chk"><input type="checkbox" name="cur" value="gbp" checked> GBP</label>
<label class="chk"><input type="checkbox" name="cur" value="eur"> EUR</label>
<label class="chk"><input type="checkbox" name="cur" value="jpy"> JPY</label>
<label class="chk"><input type="checkbox" name="cur" value="aud"> AUD</label>
<label class="chk"><input type="checkbox" name="cur" value="cad"> CAD</label>
</div>
</div>
<div class="field">
<label>Poll interval <span class="muted" style="font-weight:400;">(seconds)</span></label>
<input type="number" name="crypto_poll_seconds" min="60" value="300">
</div>
</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>
const _pollNextMap = {}; // profile_id → poll_next epoch (seconds)
function fmtCountdown(secs) {
if (secs <= 0) return 'polling now…';
const h = Math.floor(secs / 3600);
const m = Math.floor((secs % 3600) / 60);
const s = Math.floor(secs % 60);
if (h > 0) return `in ${h}h ${m}m`;
if (m > 0) return `in ${m}m ${s}s`;
return `in ${s}s`;
}
function tickPolls() {
const now = Date.now() / 1000;
for (const [id, pollNext] of Object.entries(_pollNextMap)) {
const el = document.getElementById('poll-' + id);
if (!el) continue;
el.textContent = pollNext > 0 ? fmtCountdown(pollNext - now) : '—';
}
}
setInterval(tickPolls, 1000);
function updateStatus(id, event) {
try {
const data = JSON.parse(event.detail.xhr.responseText);
const badge = document.getElementById('status-' + id);
if (badge) {
badge.textContent = data.running ? 'running' : 'stopped';
badge.className = 'badge ' + (data.running ? 'badge-green' : 'badge-red');
}
if (data.poll_next !== undefined) {
_pollNextMap[id] = data.running ? data.poll_next : 0;
const el = document.getElementById('poll-' + id);
if (el) el.textContent = data.running && data.poll_next > 0
? fmtCountdown(data.poll_next - Date.now() / 1000) : '—';
}
} 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);
}
// robustCopy and the SimpleX link box (sx*) live in base.html (shared).
{% if tab == 'bots' %}
function onTypeChange() {
const val = document.getElementById('type-select').value;
document.getElementById('welcome-field').style.display = (val === 'echo') ? 'none' : '';
document.getElementById('support-fields').style.display = (val === 'support' || val === 'llm') ? 'block' : 'none';
document.getElementById('deadmans-fields').style.display = (val === 'deadmans') ? 'block' : 'none';
document.getElementById('directory-fields').style.display = (val === 'directory') ? 'block' : 'none';
document.getElementById('broadcast-fields').style.display = (val === 'broadcast') ? 'block' : 'none';
document.getElementById('crypto-fields').style.display = (val === 'crypto') ? 'block' : '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;
if (botType === 'support' || botType === 'llm') {
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;
}
if (botType === 'broadcast') {
const pubs = (fd.get('publishers') || '').split(',').map(s => s.trim()).filter(Boolean);
if (pubs.length) config.publishers = pubs;
const prohibited = (fd.get('prohibited_message') || '').trim();
if (prohibited) config.prohibited_message = prohibited;
}
if (botType === 'rss') {
const url = (fd.get('feed_url') || '').trim();
if (!url) { alert('Feed URL is required for an RSS bot'); return; }
config.feed_url = url;
const ps = parseInt(fd.get('rss_poll'), 10);
if (!isNaN(ps)) config.poll_seconds = ps;
}
if (botType === 'crypto') {
const coins = Array.from(document.querySelectorAll('#crypto-fields input[name=coin]:checked')).map(c => c.value);
const curs = Array.from(document.querySelectorAll('#crypto-fields input[name=cur]:checked')).map(c => c.value);
if (!coins.length) { alert('Pick at least one coin'); return; }
if (!curs.length) { alert('Pick at least one currency'); return; }
config.coins = coins;
config.currencies = curs;
const ps = parseInt(fd.get('crypto_poll_seconds'), 10);
if (!isNaN(ps) && ps >= 60) config.poll_seconds = ps;
}
{% 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',
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 %}

View File

@@ -1,39 +1,18 @@
<!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; }
[data-theme="original-light"] {
--bg: #f5f5f7; --card: #fff; --text: #1d1d1f;
--accent: #0053D0; --border: #e0e0e5;
--btn-light-text: #fff;
: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-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; flex-direction: column; }
.login-main { flex: 1; display: flex; align-items: center; justify-content: center; padding: 24px; }
display: flex; align-items: center; justify-content: center; }
.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; }
@@ -41,19 +20,13 @@
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: var(--btn-light-text);
border: none; border-radius: 8px; font-size: 15px; font-weight: 600; cursor: pointer; }
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; } }
.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="login-main">
<div class="box">
<h1>SimpleX Manager</h1>
{% if error %}<div class="error">{{ error }}</div>{% endif %}
@@ -63,13 +36,5 @@
<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>

View File

@@ -1,86 +0,0 @@
{% 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 %}

View File

@@ -1,63 +0,0 @@
{% 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 %}

View File

@@ -1,39 +1,21 @@
{% extends "base.html" %}
{% import "_macros.html" as ui %}
{% block title %}{{ profile.name }} — SimpleX Manager{% endblock %}
{% block head %}
<style>
.qr-wrap { text-align: center; padding: 16px; }
.qr-wrap canvas { border-radius: 8px; }
.msg-btn {
padding: 3px 10px; font-size: 12px; border-radius: 6px;
background: transparent; border: 1px solid var(--border);
color: var(--accent); cursor: pointer; font-weight: 600;
font-family: inherit; white-space: nowrap;
transition: background 0.15s, color 0.15s;
}
.msg-btn:hover { background: var(--accent); color: var(--btn-light-text); }
.msg-btn-danger { color: var(--red); border-color: var(--red); }
.msg-btn-danger:hover { background: var(--red); color: #fff; }
.addr-row { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
.addr-link { flex: 1; min-width: 0; color: var(--muted); font-family: monospace; font-size: 12px;
text-decoration: none; word-break: break-all; }
.addr-link:hover { color: var(--accent); text-decoration: underline; }
.copy-btn { flex-shrink: 0; padding: 4px 9px; font-size: 13px; line-height: 1; }
.contact-row td:first-child { font-weight: 600; }
</style>
{% endblock %}
{% block content %}
<div class="flex-between" style="margin-bottom: 20px;">
<div class="flex gap-8">
<a href="{{ back }}" class="muted" style="text-decoration:none;">{{ 'Users' if back == '/users' else ('Business Groups' if back == '/businesses' else 'Bots') }}</a>
<a href="/" class="muted" style="text-decoration:none;">Profiles</a>
<span class="muted">/</span>
<strong>{{ profile.name }}</strong>
<span class="tag {% if profile.bot_type == 'user' %}tag-user{% endif %}">{{ profile.bot_type }}</span>
<span class="tag">{{ 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>
@@ -57,67 +39,32 @@
<div class="grid-2">
<!-- Left column -->
<div>
<!-- Profile -->
<!-- Address / QR -->
<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 -->
<div class="card">
<h2 style="margin-bottom:12px;">Address</h2>
<h2>Address</h2>
{% if profile.address %}
{{ ui.linkbox(profile.address, 'addr') }}
<div class="monospace muted" style="word-break:break-all; margin-bottom:12px;">{{ profile.address }}</div>
<div class="qr-wrap">
<canvas id="qr-canvas"></canvas>
</div>
<script src="https://cdn.jsdelivr.net/npm/qrcode/build/qrcode.min.js"></script>
<script>
QRCode.toCanvas(document.getElementById('qr-canvas'), {{ profile.address | tojson }}, {width: 200}, () => {})
</script>
{% else %}
<p class="muted">Start the profile to generate an address.</p>
<p class="muted">Start the bot to generate an address.</p>
{% endif %}
</div>
{% if profile.bot_type == 'directory' %}
{% set safe = profile.name | lower | replace(' ', '_') %}
<!-- Directory website -->
<div class="card">
<h2>Directory website</h2>
<p class="muted" style="margin-bottom:12px;">Auto-generated listing page for this directory bot.</p>
<div class="addr-row">
<button class="btn btn-ghost copy-btn" title="Copy URL"
onclick="copyAddr(this, location.origin + '/directory/{{ safe }}/index.html')"><i class="fa-solid fa-copy"></i></button>
<a class="addr-link" href="/directory/{{ safe }}/index.html" target="_blank" rel="noopener">/directory/{{ safe }}/index.html</a>
</div>
</div>
{% endif %}
<!-- Config -->
<div class="card">
<h2>Config</h2>
<table>
<tr><th>Key</th><th>Value</th></tr>
{% for k, v in profile.config.items() if k not in ['avatar', 'bio', 'full_name'] %}
<tr><td>{{ k }}</td><td>{% if k == 'api_key' %}•••••••• (set){% else %}{{ v }}{% endif %}</td></tr>
{% for k, v in profile.config.items() %}
<tr><td>{{ k }}</td><td>{{ v }}</td></tr>
{% else %}
<tr><td colspan="2" class="muted">No extra config set.</td></tr>
<tr><td colspan="2" class="muted">No config set.</td></tr>
{% endfor %}
</table>
</div>
@@ -125,25 +72,37 @@
<!-- 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 style="width:50px;"></th></tr>
<tr><th>Name</th><th>ID</th></tr>
{% for c in contacts %}
<tr>
<td><strong>{{ c.localDisplayName }}</strong></td>
<td>
<div class="flex gap-8" style="justify-content:flex-end;">
<a class="msg-btn" style="text-decoration:none;"
href="/profile/{{ profile.id }}/chat/direct/{{ c.contactId }}"><i class="fa-solid fa-comments"></i> Chat</a>
<button class="msg-btn" title="Clear conversation"
onclick="clearChat('direct', {{ c.contactId }}, '{{ c.localDisplayName | e }}')"><i class="fa-solid fa-broom"></i> Clear</button>
<button class="msg-btn msg-btn-danger" title="Delete contact"
onclick="deleteContact({{ c.contactId }}, '{{ c.localDisplayName | e }}')"><i class="fa-solid fa-trash"></i> Delete</button>
</div>
</td>
<tr class="contact-row">
<td>{{ c.localDisplayName }}</td>
<td class="muted monospace">{{ c.contactId }}</td>
</tr>
{% endfor %}
</table>
@@ -152,81 +111,21 @@
{% endif %}
</div>
{# Macro: one group/channel row. api_list_groups gives bare GroupInfo dicts:
g.groupId, g.groupProfile.displayName, g.groupSummary.currentMembers.
The verb is "Post" for channels (broadcast) and "Msg" for groups. #}
{% macro groupRow(g) %}
{% set name = g.groupProfile.displayName %}
{% set gid = g.groupId %}
{% set mcnt = g.groupSummary.currentMembers %}
{% set invited = (g.membership.memberStatus if g.membership else '') == 'invited' %}
{% set is_owner = (g.membership.memberRole if g.membership else '') == 'owner' %}
<tr>
<td>{{ name }}</td>
<td>
{% if invited %}
<span class="tag" title="You were invited but haven't joined yet"><i class="fa-solid fa-hourglass-half"></i> 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" style="flex-wrap:wrap;">
{% if invited %}
<button class="msg-btn" onclick="joinGroup({{ gid }}, this)">Join</button>
{% else %}
<a class="msg-btn" style="text-decoration:none;"
href="/profile/{{ profile.id }}/chat/group/{{ gid }}"><i class="fa-solid fa-comments"></i> {{ 'Broadcast' if g.is_channel else 'Chat' }}</a>
{% if g.link %}{{ ui.linkbtns('g' ~ gid) }}{% endif %}
<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>
{% if g.link %}
<tr><td colspan="3" style="border:none;padding:0 12px 4px;">{{ ui.linkpanels(g.link, 'g' ~ gid) }}</td></tr>
{% endif %}
{% endmacro %}
<!-- Groups -->
<div class="card">
<div class="flex-between" style="margin-bottom:12px;">
<h2 style="margin:0;">Groups ({{ groups | length }})</h2>
{% if profile.running %}
<button class="btn btn-primary" style="padding:6px 14px;font-size:13px;"
onclick="openCreate('group')">+ Create Group</button>
{% endif %}
</div>
<h2>Groups ({{ groups | length }})</h2>
{% if groups %}
<table>
<tr><th>Name</th><th>Members</th><th style="width:130px;"></th></tr>
{% for g in groups %}{{ groupRow(g) }}{% endfor %}
<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 %}
</table>
{% else %}
<p class="muted">No groups yet.{% if not profile.running %} Start the profile first.{% endif %}</p>
{% endif %}
</div>
<!-- Channels -->
<div class="card">
<div class="flex-between" style="margin-bottom:12px;">
<h2 style="margin:0;">Channels ({{ channels | length }})</h2>
{% if profile.running %}
<button class="btn btn-primary" style="padding:6px 14px;font-size:13px;"
onclick="openCreate('channel')">+ Create Channel</button>
{% endif %}
</div>
{% if channels %}
<table>
<tr><th>Name</th><th>Subscribers</th><th style="width:130px;"></th></tr>
{% for g in channels %}{{ groupRow(g) }}{% endfor %}
</table>
{% else %}
<p class="muted">No channels yet.{% if not profile.running %} Start the profile first.{% endif %}</p>
<p class="muted">No groups yet.</p>
{% endif %}
</div>
@@ -245,351 +144,46 @@
</div>
</div>
<!-- Create group/channel dialog -->
<dialog id="ch-dialog">
<h2 style="margin-bottom:16px;" id="ch-title">Create Group</h2>
<p class="muted" style="margin-bottom:16px;font-size:13px;" id="ch-desc"></p>
<div class="field">
<label id="ch-name-label">Group Name</label>
<input type="text" id="ch-name" placeholder="My Group" required>
</div>
<div id="ch-link-wrap" style="display:none;margin-bottom:12px;">
<label>Join Link</label>
<div class="flex gap-8">
<input type="text" id="ch-link-out" readonly style="font-family:monospace;font-size:12px;">
<button class="btn btn-ghost" style="white-space:nowrap;" onclick="copyChLink()">Copy</button>
</div>
</div>
<div class="flex-between mt-16">
<span id="ch-result" class="muted" style="font-size:13px;"></span>
<div class="flex gap-8">
<button class="btn btn-ghost" onclick="closeChDialog()">Close</button>
<button class="btn btn-primary" id="ch-create-btn" onclick="createGroup()">Create</button>
</div>
</div>
</dialog>
<!-- Members dialog -->
<dialog id="members-dialog">
<div class="flex-between" style="margin-bottom:16px;">
<h2 style="margin:0;">Members — <span id="members-channel-name" style="color:var(--accent);"></span></h2>
<button class="btn btn-ghost" style="padding:4px 10px;font-size:13px;"
onclick="document.getElementById('members-dialog').close()"><i class="fa-solid fa-xmark"></i></button>
</div>
<div id="members-list" style="max-height:320px;overflow-y:auto;">
<p class="muted">Loading…</p>
</div>
</dialog>
<!-- Edit profile dialog -->
<dialog id="edit-dialog">
<h2 style="margin-bottom:16px;">Edit Profile</h2>
<div class="field">
<label>Full Name <span class="muted" style="font-weight:400;">(optional)</span></label>
<input type="text" id="edit-fullname">
</div>
<div class="field">
<label>Bio / Description <span class="muted" style="font-weight:400;">(optional)</span></label>
<textarea id="edit-bio" rows="2"></textarea>
</div>
<div class="field">
<label>Avatar</label>
<div class="flex gap-8">
<img id="edit-avatar-preview" alt="" style="display:none;width:48px;height:48px;border-radius:50%;object-fit:cover;border:1px solid var(--border);">
<input type="file" accept="image/*" onchange="onEditAvatar(this)" style="flex:1;">
</div>
<button type="button" class="btn btn-ghost" style="margin-top:6px;font-size:12px;padding:4px 10px;"
onclick="removeEditAvatar()">Remove avatar</button>
</div>
<div class="flex-between mt-16">
<span id="edit-result" class="muted" style="font-size:13px;"></span>
<div class="flex gap-8">
<button class="btn btn-ghost" onclick="document.getElementById('edit-dialog').close()">Cancel</button>
<button class="btn btn-primary" onclick="saveProfile()">Save</button>
</div>
</div>
</dialog>
<!-- Send message dialog -->
<dialog id="msg-dialog">
<h2 style="margin-bottom:16px;">Message <span id="msg-target-label" style="color:var(--accent);"></span></h2>
<div class="field">
<textarea id="msg-text" rows="4" placeholder="Type your message…"
style="resize:vertical;"
onkeydown="if(event.key==='Enter'&&(event.ctrlKey||event.metaKey)){sendMsg();return false;}"></textarea>
</div>
<div class="flex-between mt-16">
<span id="msg-result" class="muted" style="font-size:13px;"></span>
<div class="flex gap-8">
<button class="btn btn-ghost" onclick="document.getElementById('msg-dialog').close()">Cancel</button>
<button class="btn btn-primary" onclick="sendMsg()">Send</button>
</div>
</div>
</dialog>
<script>
let msgTarget = '';
function openMsg(name) {
msgTarget = name;
document.getElementById('msg-target-label').textContent = name;
document.getElementById('msg-text').value = '';
document.getElementById('msg-result').textContent = '';
const dlg = document.getElementById('msg-dialog');
dlg.showModal();
setTimeout(() => document.getElementById('msg-text').focus(), 50);
}
async function sendMsg() {
const text = document.getElementById('msg-text').value.trim();
if (!text) return;
const result = document.getElementById('msg-result');
result.textContent = 'Sending…';
const token = document.cookie.match(/(?:^|;\s*)token=([^;]+)/)?.[1] || '';
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] || ''
const resp = await fetch('/api/profiles/{{ profile.id }}/send', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-Token': token},
body: JSON.stringify({to: msgTarget, text})
});
const data = await resp.json();
if (data.ok) {
document.getElementById('msg-text').value = '';
result.textContent = '✓ Sent';
setTimeout(() => document.getElementById('msg-dialog').close(), 800);
} else {
result.textContent = '✗ Failed';
}
}
// robustCopy/fallbackCopy live in base.html (shared). Directory-website URL copy:
function copyAddr(btn, addr) {
robustCopy(addr).then(() => {
btn.innerHTML = '<i class="fa-solid fa-check"></i>';
setTimeout(() => btn.innerHTML = '<i class="fa-solid fa-copy"></i>', 1500);
});
}
// ── Contact actions ────────────────────────────────────────────────────────
function _ctoken() { return document.cookie.match(/(?:^|;\s*)token=([^;]+)/)?.[1] || ''; }
async function clearChat(type, id, name) {
if (!confirm('Clear the conversation with ' + name + '? Messages are removed; the contact stays.')) return;
const r = await fetch(`/api/profiles/{{ profile.id }}/chat/${type}/${id}/clear`, {
method: 'POST', headers: { 'X-Token': _ctoken() },
});
const d = await r.json();
if (d.ok) { alert('Conversation cleared.'); }
else { alert('Failed: ' + (d.detail || 'unknown')); }
}
async function deleteContact(id, name) {
if (!confirm('Delete contact ' + name + '? This removes them and your conversation.')) return;
const r = await fetch(`/api/profiles/{{ profile.id }}/contacts/${id}`, {
method: 'DELETE', headers: { 'X-Token': _ctoken() },
});
const d = await r.json();
if (d.ok) { location.reload(); }
else { alert('Failed: ' + (d.detail || 'unknown')); }
}
// ── Edit profile ───────────────────────────────────────────────────────────
// editAvatar: null = unchanged, '' = remove, dataURI = replace
let editAvatar = null;
function openEdit() {
editAvatar = null;
document.getElementById('edit-fullname').value = {{ (profile.config.full_name or '') | tojson }};
document.getElementById('edit-bio').value = {{ (profile.config.bio or '') | tojson }};
const prev = document.getElementById('edit-avatar-preview');
const cur = {{ (profile.config.avatar or '') | tojson }};
if (cur) { prev.src = cur; prev.style.display = 'block'; } else { prev.style.display = 'none'; prev.src = ''; }
document.getElementById('edit-result').textContent = '';
document.getElementById('edit-dialog').showModal();
}
function onEditAvatar(input) {
const file = input.files && input.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
const img = new Image();
img.onload = () => {
const size = 256, c = document.createElement('canvas');
c.width = size; c.height = size;
const ctx = c.getContext('2d');
const m = Math.min(img.width, img.height);
ctx.drawImage(img, (img.width - m) / 2, (img.height - m) / 2, m, m, 0, 0, size, size);
editAvatar = c.toDataURL('image/jpeg', 0.85);
const prev = document.getElementById('edit-avatar-preview');
prev.src = editAvatar; prev.style.display = 'block';
};
img.src = e.target.result;
};
reader.readAsDataURL(file);
}
function removeEditAvatar() {
editAvatar = '';
const prev = document.getElementById('edit-avatar-preview');
prev.style.display = 'none'; prev.src = '';
}
async function saveProfile() {
const body = {
full_name: document.getElementById('edit-fullname').value,
bio: document.getElementById('edit-bio').value,
};
if (editAvatar !== null) body.avatar = editAvatar; // only send if changed
document.getElementById('edit-result').textContent = 'Saving…';
const resp = await fetch('/api/profiles/{{ profile.id }}/profile', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-Token': _token()},
body: JSON.stringify(body),
});
const data = await resp.json();
if (data.ok) { location.reload(); }
else { document.getElementById('edit-result').textContent = '✗ ' + (data.detail || 'Failed'); }
}
// ── Groups & Channels ──────────────────────────────────────────────────────
const _token = () => document.cookie.match(/(?:^|;\s*)token=([^;]+)/)?.[1] || '';
let _createKind = 'group';
function openCreate(kind) {
_createKind = kind;
const isCh = kind === 'channel';
document.getElementById('ch-title').textContent = isCh ? 'Create Channel' : 'Create Group';
document.getElementById('ch-desc').textContent = isCh
? 'Observer join link — subscribers can read broadcasts but not post. Only you broadcast.'
: 'Member join link — everyone who joins can send messages (2-way).';
document.getElementById('ch-name-label').textContent = isCh ? 'Channel Name' : 'Group Name';
document.getElementById('ch-name').placeholder = isCh ? 'My Channel' : 'My Group';
document.getElementById('ch-name').value = '';
document.getElementById('ch-link-wrap').style.display = 'none';
document.getElementById('ch-result').textContent = '';
const btn = document.getElementById('ch-create-btn');
btn.disabled = false; btn.style.display = '';
document.getElementById('ch-dialog').showModal();
}
async function createGroup() {
const name = document.getElementById('ch-name').value.trim();
if (!name) return;
const btn = document.getElementById('ch-create-btn');
btn.disabled = true;
document.getElementById('ch-result').textContent = 'Creating…';
const resp = await fetch('/api/profiles/{{ profile.id }}/groups', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-Token': _token()},
body: JSON.stringify({name, kind: _createKind}),
});
const data = await resp.json();
if (data.ok) {
document.getElementById('ch-link-out').value = data.link;
document.getElementById('ch-link-wrap').style.display = '';
document.getElementById('ch-result').textContent = '✓ Created';
btn.style.display = 'none';
} else {
document.getElementById('ch-result').textContent = '✗ ' + (data.detail || 'Failed');
btn.disabled = false;
}
}
function copyChLink() {
const val = document.getElementById('ch-link-out').value;
robustCopy(val).then(() => {
document.getElementById('ch-result').textContent = '✓ Copied';
});
}
function closeChDialog() {
document.getElementById('ch-dialog').close();
location.reload(); // refresh group/channel lists
}
async function loadMembers(groupId, groupName) {
document.getElementById('members-channel-name').textContent = groupName;
document.getElementById('members-list').innerHTML = '<p class="muted">Loading…</p>';
document.getElementById('members-dialog').showModal();
const resp = await fetch(`/api/profiles/{{ profile.id }}/groups/${groupId}/members`, {
headers: {'X-Token': _token()},
});
const data = await resp.json();
if (!data.members || data.members.length === 0) {
document.getElementById('members-list').innerHTML =
'<p class="muted">No other members yet (you are the owner).</p>';
return;
}
const rows = data.members.map(m => `
<tr>
<td><strong>${m.name}</strong></td>
<td class="muted" style="font-size:12px;">${m.role}</td>
<td class="muted" style="font-size:12px;">${m.status}</td>
</tr>`).join('');
document.getElementById('members-list').innerHTML = `
<table>
<tr><th>Name</th><th>Role</th><th>Status</th></tr>
${rows}
</table>`;
}
async function joinGroup(groupId, btn) {
btn.textContent = 'Joining…'; btn.disabled = true;
const resp = await fetch(`/api/profiles/{{ profile.id }}/groups/${groupId}/join`, {
method: 'POST', headers: { 'X-Token': _token() },
});
const data = await resp.json();
if (data.ok) { location.reload(); }
else { btn.textContent = 'Join'; btn.disabled = false; alert('Failed to join: ' + (data.detail || 'unknown')); }
}
async function deleteGroup(groupId, name, btn) {
if (!confirm('Delete "' + name + '" for everyone? This removes the group/channel and notifies members.')) return;
btn.disabled = true; btn.textContent = 'Deleting…';
const resp = await fetch(`/api/profiles/{{ profile.id }}/groups/${groupId}`, {
method: 'DELETE', headers: { 'X-Token': _token() },
});
const data = await resp.json();
if (data.ok) { location.reload(); }
else { btn.disabled = false; btn.textContent = 'Delete'; alert('Failed to delete: ' + (data.detail || 'unknown')); }
}
async function leaveGroup(groupId, name, btn) {
if (!confirm('Leave "' + name + '"? You will stop receiving its messages.')) return;
btn.disabled = true; btn.textContent = 'Leaving…';
const resp = await fetch(`/api/profiles/{{ profile.id }}/groups/${groupId}/leave`, {
method: 'POST', headers: { 'X-Token': _token() },
});
const data = await resp.json();
if (data.ok) { location.reload(); }
else { btn.disabled = false; btn.textContent = 'Leave'; alert('Failed to leave: ' + (data.detail || 'unknown')); }
}
// Group/channel links use the shared link box (sxToggleLink/sxToggleQr in base.html).
// ─────────────────────────────────────────────────────────────────────────────
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)
})
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 %}

View File

@@ -1,15 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ title }} — SimpleX Manager{% endblock %}
{% block content %}
<div class="flex-between" style="margin-bottom:24px;">
<h1 style="margin:0;">{{ title }}</h1>
<a href="/" class="muted" style="text-decoration:none;">← Home</a>
</div>
<div class="card" style="text-align:center;padding:48px 24px;">
<div style="font-size:40px;line-height:1;margin-bottom:12px;"><i class="fa-solid fa-shuffle"></i></div>
<strong>{{ title }} — coming soon</strong>
<p class="muted" style="margin-top:8px;">This relay isnt implemented yet.</p>
</div>
{% endblock %}

View File

@@ -1,168 +0,0 @@
{% 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"><i class="fa-solid fa-check"></i></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"><i class="fa-solid fa-check"></i></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"><i class="fa-solid fa-check"></i></span>
</div>
</div>
</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') ||
(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 %}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

View File

@@ -1,348 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>About — Speakers' Corner Online Directory</title>
<meta name="description" content="Why Speakers' Corner Online exists — private, uncensored conversation for UK residents facing the Online Safety Act, digital ID, and growing surveillance of speech.">
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Crect width='100' height='100' fill='%230053D0'/%3E%3Cg transform='translate(50,50) rotate(45)'%3E%3Crect x='-34' y='-9' width='68' height='18' fill='%2302C0FF'/%3E%3Crect x='-9' y='-34' width='18' height='68' fill='%2302C0FF'/%3E%3Crect x='-20' y='-5' width='40' height='10' fill='%230053D0'/%3E%3Crect x='-5' y='-20' width='10' height='40' fill='%230053D0'/%3E%3C/g%3E%3C/svg%3E">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #f5f5f7;
--card-bg: #ffffff;
--text: #1d1d1f;
--muted: #6e6e73;
--accent: #0053D0;
--border: #e0e0e5;
--shadow: 0px 20px 30px rgba(0,0,0,0.12);
--warn: #b91c1c;
--warn-bg: #fef2f2;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #111827;
--card-bg: #0B2A59;
--text: #f5f5f7;
--muted: #9ca3af;
--accent: #70F0F9;
--border: #1e3a5f;
--shadow: none;
--warn: #fca5a5;
--warn-bg: rgba(185,28,28,0.18);
}
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
}
header {
background: var(--card-bg);
border-bottom: 1px solid var(--border);
padding: 14px 24px;
position: sticky; top: 0; z-index: 10;
}
.header-inner {
max-width: 860px; margin: 0 auto;
display: flex; align-items: center;
justify-content: space-between; gap: 10px;
}
.logo-text {
font-size: 18px; font-weight: 700;
color: var(--accent); letter-spacing: -0.5px;
}
.container {
max-width: 760px; margin: 0 auto;
padding: 48px 20px 80px;
}
h1 {
font-size: clamp(26px, 5vw, 36px);
font-weight: 700; color: var(--accent);
margin-bottom: 8px;
}
.page-lead {
font-size: 16px; color: var(--muted);
line-height: 1.7; margin-bottom: 40px;
max-width: 640px;
}
.page-lead a { color: var(--accent); text-decoration: none; }
/* Stat banner */
.stat-banner {
background: var(--warn-bg);
border: 1.5px solid var(--warn);
border-radius: 14px;
padding: 22px 28px;
margin-bottom: 32px;
display: flex; gap: 18px; align-items: flex-start;
}
.stat-number {
font-size: clamp(36px, 8vw, 52px);
font-weight: 800; color: var(--warn);
line-height: 1; flex-shrink: 0;
}
.stat-body { flex: 1; }
.stat-body strong {
display: block; font-size: 15px;
color: var(--text); margin-bottom: 5px;
}
.stat-body p { font-size: 13px; color: var(--muted); line-height: 1.6; }
.stat-body a { color: var(--warn); text-decoration: none; font-size: 12px; }
.stat-body a:hover { text-decoration: underline; }
/* Section cards */
.section {
background: var(--card-bg); border-radius: 16px;
box-shadow: var(--shadow); padding: 28px 32px;
margin-bottom: 24px;
}
.section-icon { font-size: 26px; margin-bottom: 10px; display: block; }
.section h2 { font-size: 19px; font-weight: 700; margin-bottom: 14px; color: var(--text); }
.section p {
font-size: 14px; line-height: 1.75; color: var(--muted);
margin-bottom: 10px;
}
.section p:last-child { margin-bottom: 0; }
.section a { color: var(--accent); text-decoration: none; }
.section a:hover { text-decoration: underline; }
.section ol, .section ul { padding-left: 20px; margin-top: 8px; }
.section li { font-size: 14px; line-height: 1.7; color: var(--muted); margin-bottom: 6px; }
.section li:last-child { margin-bottom: 0; }
/* Rule rows */
.rule-list { list-style: none; padding: 0; margin-top: 8px; }
.rule-list li {
display: flex; gap: 12px; align-items: flex-start;
padding: 10px 0; border-bottom: 1px solid var(--border);
}
.rule-list li:last-child { border-bottom: none; }
.rule-dot {
width: 8px; height: 8px; border-radius: 50%;
background: var(--accent); flex-shrink: 0; margin-top: 6px;
}
.rule-body strong { display: block; font-size: 14px; color: var(--text); margin-bottom: 2px; }
.rule-body span { font-size: 13px; color: var(--muted); line-height: 1.55; }
/* Callouts */
.callout {
border-left: 4px solid var(--accent);
background: color-mix(in srgb, var(--accent) 8%, transparent);
border-radius: 0 10px 10px 0;
padding: 12px 16px; margin-top: 14px;
font-size: 13px; color: var(--muted); line-height: 1.65;
}
.callout.warn {
border-left-color: var(--warn);
background: var(--warn-bg);
}
footer {
text-align: center; padding: 28px 20px;
font-size: 12px; color: var(--muted);
border-top: 1px solid var(--border);
}
footer a { color: var(--accent); text-decoration: none; }
@media (max-width: 640px) {
.section { padding: 20px; }
.stat-banner { flex-direction: column; gap: 8px; padding: 18px 20px; }
}
</style>
</head>
<body>
<header>
<div class="header-inner">
<a href="./index.html" style="text-decoration:none;">
<span class="logo-text">Speakers' Corner Online</span>
</a>
<a href="./index.html" style="color:var(--accent);font-size:13px;font-weight:600;text-decoration:none;">← Directory</a>
</div>
</header>
<div class="container">
<h1>Why This Exists</h1>
<p class="page-lead">
The United Kingdom has quietly become one of the most aggressive surveilleurs of online
speech in the democratic world. Speakers' Corner Online was built as a response — a place
where residents can talk freely, privately, and without fear, using infrastructure that
cannot be monitored or compelled to hand over your conversations.
</p>
<!-- Stat banner -->
<div class="stat-banner">
<div class="stat-number">65,000+</div>
<div class="stat-body">
<strong>arrests in the UK for social media posts since 2017</strong>
<p>
British police forces have made over 65,000 arrests for online speech in less than a decade —
averaging more than 20 arrests every single day. Offences range from "grossly offensive"
messages under the Communications Act to alleged "stirring up" of hatred, applied
increasingly broadly and with little consistency.
</p>
<a href="https://archive.ph/bdEqK" target="_blank">Source: archived report ↗</a>
</div>
</div>
<!-- Online Safety Act -->
<div class="section">
<span class="section-icon">&#128274;</span>
<h2>The Online Safety Act</h2>
<p>
The Online Safety Act 2023 granted Ofcom sweeping powers to demand that platforms
scan private messages for illegal content — a power that, by technical necessity,
requires breaking end-to-end encryption. Put simply: if a platform must be able to
read your messages to check them, your messages are no longer private.
</p>
<p>
The Act also imposes broad "duty of care" obligations that incentivise platforms to
over-censor legal speech to avoid regulatory liability. The practical result is that
mainstream platforms increasingly remove or restrict content that is perfectly lawful —
not because it breaks any law, but because it is cheaper than arguing with a regulator.
</p>
<div class="callout warn">
Signal, WhatsApp and others have threatened to leave the UK market rather than comply
with backdoor requirements. SimpleX's architecture makes compliance technically
impossible — there is no central server holding your keys and no company that can be
served a disclosure order for your messages.
</div>
</div>
<!-- Digital ID -->
<div class="section">
<span class="section-icon">&#127820;</span>
<h2>Digital Identity &amp; the Coming Infrastructure</h2>
<p>
The UK government is rolling out a voluntary Digital Identity and Attributes Trust
Framework that creates the infrastructure for verified digital IDs accepted across
government and private services. "Voluntary" has a habit of becoming mandatory once
the infrastructure exists — access to banking, benefits, travel and eventually online
platforms may increasingly depend on presenting a verified digital identity.
</p>
<p>
Combined with age-verification mandates in the Online Safety Act, the direction of
travel is clear: anonymous online participation is being engineered out of existence.
Once every account is tied to a real identity, the chilling effect on speech becomes
total — people will self-censor on anything that could attract official attention.
</p>
<div class="callout">
SimpleX requires no phone number, no email address, and no government ID.
Your identity on this network is a cryptographic key that exists only on your device.
There is nothing to subpoena and no account to suspend.
</div>
</div>
<!-- Why SimpleX -->
<div class="section">
<span class="section-icon">&#128640;</span>
<h2>Why SimpleX</h2>
<p>
Most "private" messaging apps still require a phone number — which ties every account
to a real identity via your mobile carrier. SimpleX was designed from the ground up to
have no user identifiers at all. Each conversation uses a fresh pair of message queues;
even the platform operator cannot link two conversations to the same person.
</p>
<ul>
<li><strong>No phone number or email required</strong> — connect via a QR code or link.</li>
<li><strong>No central user database</strong> — there is nothing to leak, sell, or hand to police.</li>
<li><strong>End-to-end encrypted</strong> — messages are decrypted only on your device.</li>
<li><strong>Open source</strong> — the code can be audited by anyone.</li>
<li><strong>Self-hostable</strong> — you can run your own relay servers and remain fully independent.</li>
</ul>
<p style="margin-top:12px;">
<a href="https://simplex.chat/downloads/" target="_blank">Download the SimpleX app →</a>
</p>
</div>
<!-- Community rules -->
<div class="section">
<span class="section-icon">&#128220;</span>
<h2>Community Rules</h2>
<p>
We defend the right to hold and express unpopular opinions. The narrow set of rules
below are about direct harm — not offence, not controversy, not dissent.
</p>
<ul class="rule-list">
<li>
<div class="rule-dot"></div>
<div class="rule-body">
<strong>No illegal content</strong>
<span>Content that is illegal in the UK — CSAM, credible incitement to imminent violence, etc. — will result in immediate removal and referral to authorities.</span>
</div>
</li>
<li>
<div class="rule-dot"></div>
<div class="rule-body">
<strong>No targeted harassment campaigns</strong>
<span>Groups whose sole purpose is to coordinate abuse toward a specific individual are not permitted.</span>
</div>
</li>
<li>
<div class="rule-dot"></div>
<div class="rule-body">
<strong>No doxing</strong>
<span>Publishing someone's private personal information (home address, workplace, phone number) without consent is not allowed.</span>
</div>
</li>
<li>
<div class="rule-dot"></div>
<div class="rule-body">
<strong>Label adult content</strong>
<span>Communities containing explicit material must say so clearly in their description.</span>
</div>
</li>
<li>
<div class="rule-dot"></div>
<div class="rule-body">
<strong>Honest descriptions</strong>
<span>Your group's name and description must accurately represent what it is. No spam, no bait-and-switch.</span>
</div>
</li>
</ul>
<div class="callout">
Controversial, offensive, politically extreme, or unpopular speech is explicitly
permitted here. The entire point of this platform is that you do not need our approval
to speak — only your own conscience.
</div>
</div>
<!-- Reporting -->
<div class="section">
<span class="section-icon">&#128226;</span>
<h2>Reporting &amp; Joining</h2>
<p>
To report a listed community that breaches the rules above, connect to the directory bot
via the QR code on the <a href="./index.html">main page</a> and send a brief message
with the community name and the nature of the breach. Reports are reviewed manually.
</p>
<p>
To submit your own group or channel, join the directory bot and follow the prompts.
Listing is free.
</p>
</div>
</div>
<footer>
<p>Speakers' Corner Online &mdash; private communities on the <a href="https://simplex.chat" target="_blank">SimpleX Network</a> &mdash; <a href="./index.html">← Back to Directory</a></p>
</footer>
</body>
</html>

View File

@@ -3,8 +3,8 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Speakers' Corner Online Directory</title>
<meta name="description" content="Find communities on the Speakers' Corner Online network">
<title>SimpleXXX Directory</title>
<meta name="description" content="Find communities on the SimpleXXX network">
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Crect width='100' height='100' fill='%230053D0'/%3E%3Cg transform='translate(50,50) rotate(45)'%3E%3Crect x='-34' y='-9' width='68' height='18' fill='%2302C0FF'/%3E%3Crect x='-9' y='-34' width='18' height='68' fill='%2302C0FF'/%3E%3Crect x='-20' y='-5' width='40' height='10' fill='%230053D0'/%3E%3Crect x='-5' y='-20' width='10' height='40' fill='%230053D0'/%3E%3C/g%3E%3C/svg%3E">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
@@ -52,7 +52,6 @@
margin: 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
@@ -322,72 +321,12 @@
.pagination button:disabled { opacity: 0.5; cursor: not-allowed; }
/* ── Hero banner ── */
.hero {
width: 100%;
max-height: 260px;
overflow: hidden;
border-radius: 16px;
margin-bottom: 28px;
position: relative;
}
.hero img {
width: 100%;
height: 260px;
object-fit: cover;
object-position: center 40%;
display: block;
}
.dir-summary {
text-align: center;
color: var(--muted);
font-size: 15px;
line-height: 1.6;
max-width: 600px;
margin: 0 auto 28px;
}
/* ── QR popout ── */
.qr-btn {
display: inline-flex; align-items: center; gap: 6px;
padding: 7px 16px; border-radius: 20px;
border: 1.5px solid var(--accent); background: transparent;
color: var(--accent); font-size: 13px; font-weight: 600;
font-family: inherit; cursor: pointer;
transition: background 0.15s;
}
.qr-btn:hover { background: color-mix(in srgb, var(--accent) 10%, transparent); }
.qr-popout {
display: none;
position: fixed; inset: 0; z-index: 100;
background: rgba(0,0,0,0.55);
align-items: center; justify-content: center;
}
.qr-popout.open { display: flex; }
.qr-box {
background: var(--card-bg); border-radius: 20px;
padding: 28px; max-width: 320px; width: 90%;
text-align: center; box-shadow: 0 24px 60px rgba(0,0,0,0.35);
position: relative;
}
.qr-box img { width: 100%; border-radius: 10px; display: block; }
.qr-box p { margin-top: 14px; font-size: 13px; color: var(--muted); line-height: 1.5; }
.qr-close {
position: absolute; top: 12px; right: 16px;
background: none; border: none; cursor: pointer;
font-size: 20px; color: var(--muted); line-height: 1;
}
.qr-close:hover { color: var(--text); }
@media (max-width: 640px) {
#directory .entry { flex-direction: column; }
#directory .entry a.img-link { margin-right: 0; }
#directory .entry a.img-link img { width: 72px; height: 72px; min-width: 72px; min-height: 72px; border-radius: 16px; }
.search-container { flex-direction: column; align-items: stretch; }
.sort-tabs { justify-content: center; }
.hero img { height: 160px; }
}
</style>
</head>
@@ -395,43 +334,12 @@
<header>
<div class="header-inner">
<a href="https://smp6.simplex.im/a#Puih5QVZOvfdnMamqJ_KBcj86dwqOJjy3sYZxKMpJnc"
target="_blank" style="text-decoration:none;">
<span class="logo-text">Speakers' Corner Online Directory</span>
</a>
<div style="display:flex;align-items:center;gap:10px;">
<a href="./about.html" style="color:var(--accent);font-size:13px;font-weight:600;text-decoration:none;">About</a>
<button class="qr-btn" onclick="document.getElementById('qr-popout').classList.add('open')">
&#9638; Scan QR
</button>
</div>
<span class="logo-text">SimpleXXX Directory</span>
</div>
</header>
<!-- QR popout overlay -->
<div id="qr-popout" class="qr-popout" onclick="if(event.target===this)this.classList.remove('open')">
<div class="qr-box">
<button class="qr-close" onclick="document.getElementById('qr-popout').classList.remove('open')">&times;</button>
<img src="./SC-QR.png" alt="Speakers' Corner Online Directory QR code">
<p>Scan with the SimpleX app to connect to the directory, or <a href="https://smp6.simplex.im/a#Puih5QVZOvfdnMamqJ_KBcj86dwqOJjy3sYZxKMpJnc" target="_blank" style="color:var(--accent);">tap here</a>.</p>
</div>
</div>
<div class="container">
<h1>
<a href="https://smp6.simplex.im/a#Puih5QVZOvfdnMamqJ_KBcj86dwqOJjy3sYZxKMpJnc"
target="_blank" style="color:inherit;text-decoration:none;">Speakers' Corner Online Directory</a>
</h1>
<div class="hero">
<img src="./thedigitalartist-flag-4628030_1920.jpg" alt="Speakers' Corner Online Directory">
</div>
<p class="dir-summary">
A community-run directory of groups and channels on the
<a href="https://simplex.chat" target="_blank" style="color:var(--accent);text-decoration:none;">SimpleX Network</a>.
Browse by activity, discover new communities, and join with one tap — no phone number or account required.
</p>
<h1>SimpleXXX Directory</h1>
<!-- Groups / Channels tabs -->
<div class="section-tabs">

Binary file not shown.

Before

Width:  |  Height:  |  Size: 994 KiB