Compare commits

1 Commits

Author SHA1 Message Date
Jon
ecce417f6d Add chat rooms, channels, sidebar nav, themes, and UI polish
Backend (profiles.py / main.py):
- Fix bot startup crash: create active user before start_chat
- Fix address-clobbering bug on restart (UserContactLink vs CreatedConnLink)
- Add user profile type alongside bots
- Channels: create groups with observer links, classify via acceptMemberRole
- Chat rooms: get_chat_history + send_to_chat, history/messages/send routes

UI:
- Chat room view with message bubbles and live polling
- Convert top nav to collapsible left sidebar (mobile-friendly off-canvas)
- Three-way Users/Bots split; clickable cards; copy-address buttons + links
- Add Matrix theme alongside Original Light/Dark
- File upload sidebar link; site footer on all pages incl. login
- Bot-type descriptions on the Bots page; QR caption on profiles

Remove orphaned index.html (superseded by list.html).
Ignore downloaded libs/, exploration DBs, and local ai.sh.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 14:48:24 +01:00
12 changed files with 1371 additions and 272 deletions

7
.gitignore vendored
View File

@@ -10,3 +10,10 @@ __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

7
manager/download_sdk.sh Executable file
View File

@@ -0,0 +1,7 @@
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

@@ -27,12 +27,10 @@ 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"): # had an address = was running before
if profile.get("address"):
await pm.start_bot(profile, _save_address)
yield
# Graceful shutdown
for pid in list(pm._running):
await pm.stop_bot(pid)
@@ -52,7 +50,6 @@ 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")
@@ -62,6 +59,13 @@ 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
# ── Auth ──────────────────────────────────────────────────────────────────────
@app.get("/login", response_class=HTMLResponse)
@@ -89,18 +93,38 @@ async def logout():
@app.get("/", response_class=HTMLResponse)
async def index(request: Request):
return RedirectResponse("/users", status_code=302)
@app.get("/users", response_class=HTMLResponse)
async def users_page(request: Request):
if redir := _redirect_if_unauth(request):
return redir
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,
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",
})
@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("/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"})
@app.get("/profile/{profile_id}", response_class=HTMLResponse)
async def profile_page(request: Request, profile_id: int):
if redir := _redirect_if_unauth(request):
@@ -114,16 +138,90 @@ async def profile_page(request: Request, profile_id: int):
contacts = bot.contacts if bot else []
groups = bot.groups if bot else []
log_lines = bot.log_lines[-50:] if bot else []
is_user = profile["bot_type"] in pm.USER_TYPES
# 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": groups,
"groups": plain_groups,
"channels": channels,
"log_lines": log_lines,
"bot_types": pm.BOT_TYPES,
"back": "/users" if is_user else "/bots",
"nav_active": "users" if is_user else "bots",
})
# ── API ───────────────────────────────────────────────────────────────────────
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
is_user = profile["bot_type"] in pm.USER_TYPES
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": "users" if is_user else "bots",
})
@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")
ok = await pm.send_to_chat(profile_id, chat_type, chat_id, text)
return JSONResponse({"ok": ok})
# ── Profile API ───────────────────────────────────────────────────────────────
@app.post("/api/profiles")
async def create_profile(request: Request):
@@ -134,8 +232,8 @@ async def create_profile(request: Request):
config = data.get("config", {})
if not name:
raise HTTPException(400, "name required")
if bot_type not in pm.BOT_TYPES:
raise HTTPException(400, f"bot_type must be one of {pm.BOT_TYPES}")
if bot_type not in pm.ALL_TYPES:
raise HTTPException(400, f"bot_type must be one of {pm.ALL_TYPES}")
try:
profile = db.create_profile(name, bot_type, config)
except Exception as e:
@@ -196,6 +294,50 @@ 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.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)

View File

@@ -8,7 +8,32 @@ from typing import Any
log = logging.getLogger(__name__)
BOT_TYPES = ["echo", "broadcast", "support", "directory", "deadmans"]
# api_list_groups returns BARE GroupInfo dicts (verified against the live API):
# g["groupId"], g["groupProfile"]["displayName"],
# g["groupSummary"]["currentMembers"], g["membership"]["memberRole"]
# There is no "groupInfo" wrapper and no "members" list in this response.
#
# A "channel" is a group whose join link has acceptMemberRole == "observer"
# (joiners are read-only; only the owner broadcasts). A regular group's link
# has role "member" (2-way). This is the only thing that distinguishes them.
def group_name(g: dict) -> str:
return g["groupProfile"]["displayName"]
def group_id(g: dict) -> int:
return g["groupId"]
def group_member_count(g: dict) -> int:
return g.get("groupSummary", {}).get("currentMembers", 0)
BOT_TYPES = ["echo", "broadcast", "support", "directory", "deadmans"]
USER_TYPES = ["user"]
ALL_TYPES = BOT_TYPES + USER_TYPES
@dataclass
@@ -84,18 +109,16 @@ async def send_message(profile_id: int, contact_or_group: str, text: str) -> boo
if not b or not b.chat:
return False
try:
contacts = await b.chat.api_list_contacts(1)
for c in contacts:
for c in b.contacts:
if c["localDisplayName"] == contact_or_group:
await b.chat.api_send_text_message(
{"chatType": "direct", "chatId": c["contactId"]}, text
)
return True
groups = await b.chat.api_list_groups(1)
for g in groups:
if g["groupInfo"]["groupProfile"]["displayName"] == contact_or_group:
for g in b.groups:
if group_name(g) == contact_or_group:
await b.chat.api_send_text_message(
{"chatType": "group", "chatId": g["groupInfo"]["groupId"]}, text
{"chatType": "group", "chatId": group_id(g)}, text
)
return True
except Exception as e:
@@ -103,6 +126,53 @@ async def send_message(profile_id: int, contact_or_group: str, text: str) -> boo
return False
async def send_to_chat(profile_id: int, chat_type: str, chat_id: int, text: str) -> bool:
"""Send a message directly to a chat by its (type, id) ref. Returns True on success."""
b = get_running(profile_id)
if not b or not b.chat:
return False
try:
await b.chat.api_send_text_message({"chatType": chat_type, "chatId": chat_id}, text)
return True
except Exception as e:
log.error("send_to_chat error: %s", e)
return False
def _normalize_item(ci: dict) -> dict:
"""Flatten a ChatItem into {id, ts, text, outgoing, sender} for the UI."""
meta = ci.get("meta", {})
chat_dir = ci.get("chatDir", {})
dir_type = chat_dir.get("type", "")
outgoing = dir_type.endswith("Snd")
# Prefer meta.itemText; fall back to content.msgContent.text
text = meta.get("itemText") or ci.get("content", {}).get("msgContent", {}).get("text", "")
# Sender name: group messages carry the member; direct/own use a generic label
sender = ""
if dir_type == "groupRcv":
sender = chat_dir.get("groupMember", {}).get("localDisplayName", "")
return {
"id": meta.get("itemId"),
"ts": meta.get("itemTs", ""),
"text": text,
"outgoing": outgoing,
"sender": sender,
"deleted": "itemDeleted" in meta,
}
async def get_chat_history(
profile_id: int, chat_type: str, chat_id: int, count: int = 50
) -> list[dict]:
"""Return the last `count` messages of a chat, oldest-first, normalized for the UI."""
b = get_running(profile_id)
if not b or not b.chat:
raise RuntimeError("Profile is not running")
chat = await b.chat.api_get_chat(chat_type, chat_id, count)
items = chat.get("chatItems", [])
return [_normalize_item(ci) for ci in items]
async def _run_bot(
profile_id: int,
name: str,
@@ -123,27 +193,34 @@ async def _run_bot(
try:
chat = await ChatApi.init(SqliteDb(file_prefix=db_prefix))
b.chat = chat
await chat.start_chat()
# Create or fetch address
# libsimplex /_start requires an active user to exist first
user = await chat.api_get_active_user()
if not user:
user = await chat.api_create_active_user(
{"displayName": name, "fullName": ""}
)
user_id = user["userId"]
addr = await chat.api_get_user_address(user_id)
if not addr:
addr = await chat.api_create_user_address(user_id)
await chat.start_chat()
address = addr.get("connShortLink") or addr.get("connFullLink", "")
user_id = user["userId"]
existing = await chat.api_get_user_address(user_id)
if existing:
# api_get_user_address returns UserContactLink; link is nested under connLinkContact
link = existing["connLinkContact"]
else:
# api_create_user_address returns CreatedConnLink directly
link = await chat.api_create_user_address(user_id)
address = link.get("connShortLink") or link.get("connFullLink", "")
b.address = address
await on_address(profile_id, address)
# Configure address settings based on bot type
# Configure address settings based on profile type
settings: dict = {"businessAddress": False, "autoAccept": {"acceptIncognito": False}}
if bot_type == "support":
if bot_type == "user":
pass # plain user: auto-accept on, no auto-reply
elif bot_type == "support":
settings["businessAddress"] = True
welcome = config.get("welcome_message", f"Welcome to {name} support.")
settings["autoReply"] = {"type": "text", "text": welcome}
@@ -157,9 +234,13 @@ async def _run_bot(
async def refresh() -> None:
try:
b.contacts = await chat.api_list_contacts(user_id)
b.groups = await chat.api_list_groups(user_id)
groups = await chat.api_list_groups(user_id)
# Classify each group as channel (observer link) vs group (member link)
for g in groups:
await _classify_group(chat, g)
b.groups = groups
except Exception:
pass
log.exception("refresh failed for bot %d", profile_id)
await refresh()
@@ -241,3 +322,84 @@ def _append_log(b: RunningBot, line: str) -> None:
b.log_lines.append(line)
if len(b.log_lines) > 200:
b.log_lines = b.log_lines[-200:]
async def _classify_group(chat: Any, g: dict) -> None:
"""Annotate a GroupInfo in place with link info: is_channel, link.
A channel is a group whose join-link role is "observer". Groups with a
"member"+ link (or no link at all) are regular 2-way groups.
"""
g["is_channel"] = False
g["link"] = ""
try:
link_obj = await chat.api_get_group_link(g["groupId"])
except Exception:
return # no link → plain private group
g["is_channel"] = link_obj.get("acceptMemberRole") == "observer"
conn = link_obj.get("connLinkContact", {})
g["link"] = conn.get("connShortLink") or conn.get("connFullLink", "")
async def create_channel(profile_id: int, name: str) -> str:
"""Create a group with an observer join link (a SimpleX channel). Returns the link."""
b = get_running(profile_id)
if not b or not b.chat:
raise RuntimeError("Profile is not running")
user = await b.chat.api_get_active_user()
if not user:
raise RuntimeError("No active user for this profile")
info = await b.chat.api_new_group(user["userId"], {"displayName": name, "fullName": ""})
link = await b.chat.api_create_group_link(info["groupId"], "observer")
# Refresh cached group list (re-classifies all groups including the new channel)
try:
groups = await b.chat.api_list_groups(user["userId"])
for g in groups:
await _classify_group(b.chat, g)
b.groups = groups
except Exception:
log.exception("group refresh after create_channel failed")
return link
async def create_group(profile_id: int, name: str) -> str:
"""Create a regular 2-way group with a member join link. Returns the link."""
b = get_running(profile_id)
if not b or not b.chat:
raise RuntimeError("Profile is not running")
user = await b.chat.api_get_active_user()
if not user:
raise RuntimeError("No active user for this profile")
info = await b.chat.api_new_group(user["userId"], {"displayName": name, "fullName": ""})
link = await b.chat.api_create_group_link(info["groupId"], "member")
try:
groups = await b.chat.api_list_groups(user["userId"])
for g in groups:
await _classify_group(b.chat, g)
b.groups = groups
except Exception:
log.exception("group refresh after create_group failed")
return link
async def get_group_members(profile_id: int, gid: int) -> list[dict]:
"""Return the member list for a group/channel (excludes the owner themselves)."""
b = get_running(profile_id)
if not b or not b.chat:
raise RuntimeError("Profile is not running")
members = await b.chat.api_list_members(gid)
return [
{"name": m["localDisplayName"], "role": m["memberRole"], "status": m["memberStatus"]}
for m in members
]
async def get_group_link(profile_id: int, gid: int) -> str:
"""Return the existing join link for a group/channel (empty if none)."""
b = get_running(profile_id)
if not b or not b.chat:
raise RuntimeError("Profile is not running")
try:
return await b.chat.api_get_group_link_str(gid)
except Exception:
return ""

View File

@@ -12,6 +12,7 @@ if [ ! -d ".venv" ]; then
fi
mkdir -p data/bots
mkdir -p static
# Set token — override via: MANAGER_TOKEN=mysecret ./start.sh
export MANAGER_TOKEN="${MANAGER_TOKEN:-changeme}"

View File

@@ -1,12 +1,19 @@
<!DOCTYPE html>
<html lang="en">
<head>
<script>
(function(){
var t=localStorage.getItem('theme');
if(!t){t=window.matchMedia('(prefers-color-scheme:dark)').matches?'original-dark':'original-light';}
document.documentElement.setAttribute('data-theme',t);
if(localStorage.getItem('sidebar-collapsed')) document.documentElement.classList.add('collapsed');
})();
</script>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}SimpleX Manager{% endblock %}</title>
<script src="https://unpkg.com/htmx.org@1.9.12/dist/htmx.min.js"></script>
<script>
// Inject auth token into every HTMX request automatically
document.addEventListener('htmx:configRequest', function(evt) {
const m = document.cookie.match(/(?:^|;\s*)token=([^;]+)/);
if (m) evt.detail.headers['X-Token'] = m[1];
@@ -15,43 +22,147 @@
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #f5f5f7;
--card: #ffffff;
--text: #1d1d1f;
--muted: #6e6e73;
--accent: #0053D0;
--green: #20BD3D;
--red: #DD0000;
--border: #e0e0e5;
--shadow: 0 2px 12px rgba(0,0,0,0.08);
/* ── Original Light ─────────────────────────────────────────────── */
[data-theme="original-light"] {
--bg: #f5f5f7;
--card: #ffffff;
--text: #1d1d1f;
--muted: #6e6e73;
--accent: #0053D0;
--green: #20BD3D;
--red: #DD0000;
--border: #e0e0e5;
--shadow: 0 2px 12px rgba(0,0,0,0.08);
--btn-light-text: #fff;
--badge-green-bg: #d1fae5;
--badge-green-text: #065f46;
--badge-red-bg: #fee2e2;
--badge-red-text: #991b1b;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #111827;
--card: #0B2A59;
--text: #f5f5f7;
--muted: #9ca3af;
--accent: #70F0F9;
--border: #1e3a5f;
--shadow: none;
}
/* ── Original Dark ──────────────────────────────────────────────── */
[data-theme="original-dark"] {
--bg: #111827;
--card: #0B2A59;
--text: #f5f5f7;
--muted: #9ca3af;
--accent: #70F0F9;
--green: #20BD3D;
--red: #DD0000;
--border: #1e3a5f;
--shadow: none;
--btn-light-text: #000;
--badge-green-bg: #064e3b;
--badge-green-text: #6ee7b7;
--badge-red-bg: #7f1d1d;
--badge-red-text: #fca5a5;
}
/* ── Matrix ─────────────────────────────────────────────────────── */
[data-theme="matrix"] {
--bg: #000000;
--card: #050d05;
--text: #00ff41;
--muted: #2e8b57;
--accent: #00ff41;
--green: #00ff41;
--red: #ff3b3b;
--border: #0f3d0f;
--shadow: 0 0 14px rgba(0,255,65,0.12);
--btn-light-text: #000000;
--badge-green-bg: #002200;
--badge-green-text: #00ff41;
--badge-red-bg: #220000;
--badge-red-text: #ff6b6b;
}
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif;
background: var(--bg); color: var(--text); min-height: 100vh; }
nav { background: var(--card); border-bottom: 1px solid var(--border);
padding: 12px 24px; display: flex; align-items: center; justify-content: space-between;
position: sticky; top: 0; z-index: 10; }
[data-theme="matrix"] body {
font-family: 'SF Mono', 'Consolas', 'Courier New', monospace;
text-shadow: 0 0 2px rgba(0,255,65,0.4);
}
.nav-brand { font-size: 17px; font-weight: 700; color: var(--accent); text-decoration: none; }
/* ── Layout: sidebar + main ─────────────────────────────────────── */
.app { display: flex; min-height: 100vh; }
.nav-links a { color: var(--muted); text-decoration: none; font-size: 14px; margin-left: 16px; }
.nav-links a:hover { color: var(--accent); }
.sidebar {
width: 220px; flex-shrink: 0;
background: var(--card); border-right: 1px solid var(--border);
display: flex; flex-direction: column;
position: sticky; top: 0; height: 100vh;
transition: width 0.2s ease, transform 0.2s ease;
z-index: 50;
}
html.collapsed .sidebar { width: 64px; }
.container { max-width: 960px; margin: 0 auto; padding: 32px 20px; }
.nav-brand {
display: flex; align-items: center; gap: 10px;
padding: 18px; font-size: 16px; font-weight: 700;
color: var(--accent); text-decoration: none;
border-bottom: 1px solid var(--border); white-space: nowrap; overflow: hidden;
}
.nav-brand .brand-icon { font-size: 18px; flex-shrink: 0; }
.side-nav { display: flex; flex-direction: column; padding: 8px 0; }
.side-nav a {
display: flex; align-items: center; gap: 12px;
padding: 11px 18px; color: var(--muted); text-decoration: none;
font-size: 14px; font-weight: 600; white-space: nowrap; overflow: hidden;
border-left: 3px solid transparent;
}
.side-nav a:hover { color: var(--text); background: var(--bg); }
.side-nav a.active { color: var(--accent); border-left-color: var(--accent); }
.side-nav .ico { width: 20px; text-align: center; font-size: 16px; flex-shrink: 0; }
.side-nav a.nav-sep { margin-top: 10px; padding-top: 17px; border-top: 1px solid var(--border); }
.side-foot { margin-top: auto; padding: 8px 0; border-top: 1px solid var(--border); }
.collapse-btn {
display: flex; align-items: center; gap: 12px; width: 100%;
padding: 11px 18px; background: none; border: none; cursor: pointer;
color: var(--muted); font-family: inherit; font-size: 13px; font-weight: 600;
white-space: nowrap; overflow: hidden;
}
.collapse-btn:hover { color: var(--text); }
.collapse-btn .ico { width: 20px; text-align: center; flex-shrink: 0; }
html.collapsed .lbl, html.collapsed .brand-text { display: none; }
.main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.container { max-width: 960px; margin: 0 auto; padding: 32px 20px; width: 100%; flex: 1 0 auto; }
.site-footer {
flex-shrink: 0; text-align: center;
padding: 18px 20px; border-top: 1px solid var(--border);
color: var(--muted); font-size: 12px; line-height: 1.6;
}
.site-footer a { color: var(--accent); text-decoration: none; font-weight: 600; }
.site-footer a:hover { text-decoration: underline; }
.site-footer .sep { margin: 0 8px; opacity: 0.5; }
.mobile-menu-btn {
display: none; position: fixed; top: 12px; left: 12px; z-index: 40;
width: 40px; height: 40px; border-radius: 8px; border: 1px solid var(--border);
background: var(--card); color: var(--text); font-size: 18px; cursor: pointer;
align-items: center; justify-content: center;
}
.backdrop { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 45; }
/* ── Mobile: off-canvas sidebar ─────────────────────────────────── */
@media (max-width: 768px) {
.sidebar {
position: fixed; left: 0; top: 0; height: 100vh; width: 240px;
transform: translateX(-100%);
}
html.collapsed .sidebar { width: 240px; } /* ignore collapse on mobile */
html.collapsed .lbl, html.collapsed .brand-text { display: inline; }
body.sidebar-open .sidebar { transform: translateX(0); }
body.sidebar-open .backdrop { display: block; }
.mobile-menu-btn { display: flex; }
.collapse-btn { display: none; }
.container { padding-top: 64px; }
}
h1 { font-size: 28px; font-weight: 700; margin-bottom: 24px; }
h2 { font-size: 20px; font-weight: 600; margin-bottom: 16px; }
@@ -64,25 +175,15 @@
font-family: inherit; cursor: pointer; border: none; text-decoration: none;
transition: opacity 0.15s; }
.btn:hover { opacity: 0.85; }
.btn-primary { background: var(--accent); color: #fff; }
.btn-primary { background: var(--accent); color: var(--btn-light-text); }
.btn-danger { background: var(--red); color: #fff; }
.btn-success { background: var(--green); color: #fff; }
.btn-success { background: var(--green); color: var(--btn-light-text); }
.btn-ghost { background: transparent; border: 1px solid var(--border); color: var(--text); }
@media (prefers-color-scheme: dark) {
.btn-primary { color: #000; }
.btn-success { color: #000; }
}
.badge { display: inline-block; padding: 2px 8px; border-radius: 10px;
font-size: 12px; font-weight: 600; }
.badge-green { background: #d1fae5; color: #065f46; }
.badge-red { background: #fee2e2; color: #991b1b; }
@media (prefers-color-scheme: dark) {
.badge-green { background: #064e3b; color: #6ee7b7; }
.badge-red { background: #7f1d1d; color: #fca5a5; }
}
.badge-green { background: var(--badge-green-bg); color: var(--badge-green-text); }
.badge-red { background: var(--badge-red-bg); color: var(--badge-red-text); }
input, select, textarea {
width: 100%; padding: 9px 12px; font-size: 14px; font-family: inherit;
@@ -108,6 +209,9 @@
.tag { display: inline-block; padding: 2px 8px; border-radius: 6px;
font-size: 12px; background: var(--border); color: var(--muted); }
.tag-user { background: rgba(0,83,208,0.12); color: var(--accent); }
[data-theme="original-dark"] .tag-user { background: rgba(112,240,249,0.12); color: var(--accent); }
[data-theme="matrix"] .tag-user { background: rgba(0,255,65,0.12); color: var(--accent); }
.flex { display: flex; align-items: center; gap: 10px; }
.flex-between { display: flex; align-items: center; justify-content: space-between; }
@@ -132,15 +236,55 @@
{% block head %}{% endblock %}
</head>
<body>
<nav>
<a class="nav-brand" href="/">SimpleX Manager</a>
<div class="nav-links">
<a href="/">Profiles</a>
<a href="/logout">Logout</a>
</div>
</nav>
<div class="container">
{% block content %}{% endblock %}
<button class="mobile-menu-btn" onclick="toggleSidebar()" aria-label="Menu"></button>
<div class="app">
<aside class="sidebar" id="sidebar">
<a class="nav-brand" href="/users">
<span class="brand-icon"></span><span class="brand-text">SimpleX Manager</span>
</a>
<nav class="side-nav">
<a href="/users" {% if nav_active == 'users' %}class="active"{% endif %}><span class="ico">👤</span><span class="lbl">Users</span></a>
<a href="/bots" {% if nav_active == 'bots' %}class="active"{% endif %}><span class="ico">🤖</span><span class="lbl">Bots</span></a>
<a href="https://simplex.chat/file/" target="_blank" rel="noopener"><span class="ico">📁</span><span class="lbl">File upload</span></a>
<a href="/settings" class="nav-sep {% if nav_active == 'settings' %}active{% endif %}"><span class="ico">⚙️</span><span class="lbl">Settings</span></a>
</nav>
<div class="side-foot">
<button class="collapse-btn" onclick="toggleCollapse()" title="Collapse sidebar" aria-label="Collapse sidebar">
<span class="ico" id="collapse-ico"></span>
</button>
<nav class="side-nav">
<a href="/logout"><span class="ico"></span><span class="lbl">Logout</span></a>
</nav>
</div>
</aside>
<div class="backdrop" id="backdrop" onclick="closeSidebar()"></div>
<main class="main">
<div class="container">
{% block content %}{% endblock %}
</div>
<footer class="site-footer">
© Bournemouth Technology Ltd
<span class="sep">·</span>
built on © SimpleX Network
<span class="sep">·</span>
<a href="https://simplex.chat/downloads/" target="_blank" rel="noopener">Get SimpleX App</a>
</footer>
</main>
</div>
<script>
function toggleSidebar() { document.body.classList.toggle('sidebar-open'); }
function closeSidebar() { document.body.classList.remove('sidebar-open'); }
function toggleCollapse() {
const collapsed = document.documentElement.classList.toggle('collapsed');
localStorage.setItem('sidebar-collapsed', collapsed ? '1' : '');
const ico = document.getElementById('collapse-ico');
if (ico) ico.textContent = collapsed ? '' : '';
}
// Sync collapse icon with restored state on load
(function(){
const ico = document.getElementById('collapse-ico');
if (ico && document.documentElement.classList.contains('collapsed')) ico.textContent = '';
})();
</script>
</body>
</html>

154
manager/templates/chat.html Normal file
View File

@@ -0,0 +1,154 @@
{% extends "base.html" %}
{% block title %}{{ chat_name }} — SimpleX Manager{% endblock %}
{% block head %}
<style>
.chat-wrap {
display: flex; flex-direction: column;
height: calc(100vh - 140px); min-height: 400px;
background: var(--card); border-radius: 10px; box-shadow: var(--shadow);
overflow: hidden;
}
.chat-head {
display: flex; align-items: center; justify-content: space-between;
padding: 14px 18px; border-bottom: 1px solid var(--border);
}
.chat-head .title { font-weight: 700; font-size: 16px; }
.chat-log {
flex: 1; overflow-y: auto; padding: 18px;
display: flex; flex-direction: column; gap: 8px;
}
.bubble {
max-width: 72%; padding: 8px 12px; border-radius: 14px;
font-size: 14px; line-height: 1.4; word-wrap: break-word; white-space: pre-wrap;
}
.bubble .who { font-size: 11px; font-weight: 700; opacity: 0.7; margin-bottom: 2px; }
.bubble .ts { font-size: 10px; opacity: 0.55; margin-top: 3px; text-align: right; }
.bubble.in { align-self: flex-start; background: var(--bg); border: 1px solid var(--border); }
.bubble.out { align-self: flex-end; background: var(--accent); color: var(--btn-light-text); }
.bubble.deleted { font-style: italic; opacity: 0.5; }
.chat-compose {
display: flex; gap: 8px; padding: 12px; border-top: 1px solid var(--border);
}
.chat-compose textarea { resize: none; height: 42px; }
.chat-empty { text-align: center; color: var(--muted); margin: auto; font-size: 14px; }
.chat-banner { padding: 8px 18px; font-size: 12px; color: var(--muted);
background: var(--bg); border-bottom: 1px solid var(--border); }
</style>
{% endblock %}
{% block content %}
<div class="flex gap-8" style="margin-bottom:16px;">
<a href="/profile/{{ profile.id }}" class="muted" style="text-decoration:none;">← {{ profile.name }}</a>
<span class="muted">/</span>
<strong>{{ chat_name }}</strong>
<span class="tag">{{ 'channel' if is_channel else chat_type }}</span>
</div>
<div class="chat-wrap">
<div class="chat-head">
<span class="title">{{ chat_name }}</span>
<button class="btn btn-ghost" style="padding:4px 12px;font-size:12px;"
onclick="loadMessages(true)">↻ Refresh</button>
</div>
{% if is_channel %}
<div class="chat-banner">📢 Channel — messages you send here broadcast to all subscribers.</div>
{% endif %}
<div class="chat-log" id="chat-log">
{% if not running %}
<div class="chat-empty">Profile is stopped. Start it to load messages.</div>
{% else %}
<div class="chat-empty" id="chat-loading">Loading messages…</div>
{% endif %}
</div>
<div class="chat-compose">
<textarea id="msg-input" placeholder="{{ 'Broadcast a message…' if is_channel else 'Type a message…' }}"
{% if not running %}disabled{% endif %}
onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();sendMsg();}"></textarea>
<button class="btn btn-primary" onclick="sendMsg()" {% if not running %}disabled{% endif %}>Send</button>
</div>
</div>
<script>
const PROFILE_ID = {{ profile.id }};
const CHAT_TYPE = '{{ chat_type }}';
const CHAT_ID = {{ chat_id }};
const RUNNING = {{ 'true' if running else 'false' }};
const _token = () => document.cookie.match(/(?:^|;\s*)token=([^;]+)/)?.[1] || '';
let lastIds = ''; // signature of rendered messages, to skip needless re-renders
function fmtTs(iso) {
if (!iso) return '';
const d = new Date(iso);
if (isNaN(d)) return '';
return d.toLocaleString([], {month:'short', day:'numeric', hour:'2-digit', minute:'2-digit'});
}
function render(messages) {
const log = document.getElementById('chat-log');
const sig = messages.map(m => m.id).join(',');
if (sig === lastIds) return; // nothing new
const atBottom = log.scrollHeight - log.scrollTop - log.clientHeight < 60;
lastIds = sig;
if (!messages.length) {
log.innerHTML = '<div class="chat-empty">No messages yet.</div>';
return;
}
log.innerHTML = messages.map(m => {
const cls = 'bubble ' + (m.outgoing ? 'out' : 'in') + (m.deleted ? ' deleted' : '');
const who = (!m.outgoing && m.sender) ? `<div class="who">${escapeHtml(m.sender)}</div>` : '';
const txt = m.deleted ? '(deleted)' : escapeHtml(m.text || '');
return `<div class="${cls}">${who}${txt}<div class="ts">${fmtTs(m.ts)}</div></div>`;
}).join('');
if (atBottom) log.scrollTop = log.scrollHeight;
}
function escapeHtml(s) {
return s.replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
async function loadMessages(force) {
if (!RUNNING) return;
try {
const resp = await fetch(`/api/profiles/${PROFILE_ID}/chat/${CHAT_TYPE}/${CHAT_ID}/messages?count=80`, {
headers: {'X-Token': _token()},
});
if (!resp.ok) return;
const data = await resp.json();
if (force) lastIds = '';
render(data.messages || []);
} catch(e) {}
}
async function sendMsg() {
const input = document.getElementById('msg-input');
const text = input.value.trim();
if (!text) return;
input.value = '';
const resp = await fetch(`/api/profiles/${PROFILE_ID}/chat/${CHAT_TYPE}/${CHAT_ID}/send`, {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-Token': _token()},
body: JSON.stringify({text}),
});
const data = await resp.json();
if (!data.ok) {
input.value = text; // restore on failure
alert('Failed to send');
return;
}
setTimeout(() => loadMessages(true), 250); // reflect the sent message quickly
}
if (RUNNING) {
loadMessages(true);
setInterval(loadMessages, 3000); // live updates via polling
}
</script>
{% endblock %}

View File

@@ -1,111 +0,0 @@
{% extends "base.html" %}
{% block title %}Profiles — SimpleX Manager{% endblock %}
{% block content %}
<div class="flex-between" style="margin-bottom: 24px;">
<h1 style="margin:0">Bot Profiles</h1>
<button class="btn btn-primary" onclick="document.getElementById('create-dialog').showModal()">+ New Profile</button>
</div>
{% if profiles %}
<div id="profile-list">
{% for p in profiles %}
<div class="card" id="profile-{{ p.id }}">
<div class="flex-between">
<div class="flex gap-8">
<strong>{{ p.name }}</strong>
<span class="tag">{{ p.bot_type }}</span>
<span class="badge {% if p.running %}badge-green{% else %}badge-red{% endif %}"
id="status-{{ p.id }}"
hx-get="/api/profiles/{{ p.id }}/status"
hx-trigger="every 5s"
hx-swap="none"
hx-on::after-request="updateStatus({{ p.id }}, event)">
{% if p.running %}running{% else %}stopped{% endif %}
</span>
</div>
<div class="flex gap-8">
<a href="/profile/{{ p.id }}" class="btn btn-ghost" style="padding: 6px 14px; font-size: 13px;">View</a>
<button class="btn btn-success" style="padding: 6px 14px; font-size: 13px;"
hx-post="/api/profiles/{{ p.id }}/start"
hx-swap="none"
onclick="this.textContent='Starting…'">Start</button>
<button class="btn btn-danger" style="padding: 6px 14px; font-size: 13px;"
hx-post="/api/profiles/{{ p.id }}/stop"
hx-swap="none"
onclick="this.textContent='Stopping…'">Stop</button>
</div>
</div>
{% if p.address %}
<div class="muted mt-8 monospace" style="word-break:break-all;">{{ p.address }}</div>
{% endif %}
</div>
{% endfor %}
</div>
{% else %}
<div class="card" style="text-align:center; padding: 48px; color: var(--muted);">
No profiles yet. Create one to get started.
</div>
{% endif %}
<!-- Create dialog -->
<dialog id="create-dialog">
<h2 style="margin-bottom:20px;">New Bot Profile</h2>
<form id="create-form">
<div class="field">
<label>Name</label>
<input type="text" name="name" placeholder="My Support Bot" required>
</div>
<div class="field">
<label>Bot Type</label>
<select name="bot_type">
{% for t in bot_types %}
<option value="{{ t }}">{{ t }}</option>
{% endfor %}
</select>
</div>
<div class="field">
<label>Welcome Message</label>
<input type="text" name="welcome_message" placeholder="Welcome! How can I help?">
</div>
<div class="flex gap-8 mt-16" style="justify-content: flex-end;">
<button type="button" class="btn btn-ghost" onclick="document.getElementById('create-dialog').close()">Cancel</button>
<button type="submit" class="btn btn-primary">Create</button>
</div>
</form>
</dialog>
<script>
function updateStatus(id, event) {
try {
const data = JSON.parse(event.detail.xhr.responseText)
const badge = document.getElementById('status-' + id)
if (!badge) return
badge.textContent = data.running ? 'running' : 'stopped'
badge.className = 'badge ' + (data.running ? 'badge-green' : 'badge-red')
} catch(e) {}
}
document.getElementById('create-form').addEventListener('submit', async (e) => {
e.preventDefault()
const fd = new FormData(e.target)
const config = {}
const welcome = fd.get('welcome_message')
if (welcome) config.welcome_message = welcome
const token = document.cookie.match(/(?:^|;\s*)token=([^;]+)/)?.[1] || ''
const resp = await fetch('/api/profiles', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-Token': token},
body: JSON.stringify({name: fd.get('name'), bot_type: fd.get('bot_type'), config})
})
if (resp.ok) {
document.getElementById('create-dialog').close()
location.reload()
} else {
const err = await resp.json()
alert('Error: ' + (err.detail || 'unknown'))
}
})
</script>
{% endblock %}

183
manager/templates/list.html Normal file
View File

@@ -0,0 +1,183 @@
{% extends "base.html" %}
{% block title %}{{ tab | title }} — SimpleX Manager{% endblock %}
{% block head %}
<style>
.empty-state { text-align: center; padding: 56px 24px; color: var(--muted); }
.empty-state p { margin-top: 8px; font-size: 13px; }
.profile-card { cursor: pointer; transition: border-color 0.15s, box-shadow 0.15s, transform 0.05s;
border: 1px solid transparent; }
.profile-card:hover { border-color: var(--accent); }
.profile-card:active { transform: translateY(1px); }
.addr-row { display: flex; align-items: center; gap: 8px; margin-top: 10px; }
.addr-link { flex: 1; min-width: 0; color: var(--muted); font-family: monospace; font-size: 12px;
text-decoration: none; word-break: break-all; }
.addr-link:hover { color: var(--accent); text-decoration: underline; }
.copy-btn { flex-shrink: 0; padding: 4px 9px; font-size: 13px; line-height: 1; }
.bot-types-card table td { vertical-align: top; }
.bot-types-card .tag { white-space: nowrap; }
</style>
{% endblock %}
{% block content %}
<div class="flex-between" style="margin-bottom: 24px;">
<h1 style="margin:0;">{{ tab | title }}</h1>
<button class="btn btn-primary" onclick="openCreate()">
+ New {{ 'User' if tab == 'users' else 'Bot' }}
</button>
</div>
{% if tab == 'bots' %}
<div class="card bot-types-card" style="margin-bottom:24px;">
<h2 style="font-size:15px;margin-bottom:12px;">Available bot types</h2>
<table>
<tr><td><span class="tag">echo</span></td><td class="muted">Repeats every message back to the sender — handy for testing a connection end to end.</td></tr>
<tr><td><span class="tag">broadcast</span></td><td class="muted">Relays messages from authorized publishers out to all of the bot's contacts.</td></tr>
<tr><td><span class="tag">support</span></td><td class="muted">Business inbox — auto-replies with a welcome message and collects incoming inquiries.</td></tr>
<tr><td><span class="tag">directory</span></td><td class="muted">Directory service for discovering and listing groups or contacts.</td></tr>
<tr><td><span class="tag">deadmans</span></td><td class="muted">Dead man's switch — triggers an action if expected check-ins stop arriving.</td></tr>
</table>
</div>
{% endif %}
{% if items %}
{% for p in items %}
<div class="card profile-card" id="profile-{{ p.id }}"
onclick="location.href='/profile/{{ p.id }}'">
<div class="flex-between">
<div class="flex gap-8">
<strong>{{ p.name }}</strong>
<span class="tag {% if p.bot_type == 'user' %}tag-user{% endif %}">{{ p.bot_type }}</span>
<span class="badge {% if p.running %}badge-green{% else %}badge-red{% endif %}"
id="status-{{ p.id }}"
hx-get="/api/profiles/{{ p.id }}/status"
hx-trigger="every 5s"
hx-swap="none"
hx-on::after-request="updateStatus({{ p.id }}, event)">
{% if p.running %}running{% else %}stopped{% endif %}
</span>
</div>
<div class="flex gap-8" onclick="event.stopPropagation()">
<button class="btn btn-success" style="padding:6px 14px;font-size:13px;"
hx-post="/api/profiles/{{ p.id }}/start" hx-swap="none"
onclick="this.textContent='Starting…'">Start</button>
<button class="btn btn-danger" style="padding:6px 14px;font-size:13px;"
hx-post="/api/profiles/{{ p.id }}/stop" hx-swap="none"
onclick="this.textContent='Stopping…'">Stop</button>
</div>
</div>
{% if p.address %}
<div class="addr-row" onclick="event.stopPropagation()">
<button class="btn btn-ghost copy-btn" title="Copy address"
onclick="copyAddr(event, this, '{{ p.address | e }}')">📋</button>
<a class="addr-link" href="{{ p.address }}" target="_blank" rel="noopener">{{ p.address }}</a>
</div>
{% endif %}
</div>
{% endfor %}
{% else %}
<div class="empty-state card">
{% if tab == 'users' %}
<strong>No users yet</strong>
<p>Create a SimpleX user account to manage contacts and channels.</p>
{% else %}
<strong>No bots yet</strong>
<p>Bots can echo messages, broadcast to subscribers, or run automated tasks.</p>
{% endif %}
</div>
{% endif %}
<!-- Create dialog -->
<dialog id="create-dialog">
<h2 style="margin-bottom:20px;">New {{ 'User' if tab == 'users' else 'Bot' }}</h2>
<form id="create-form">
<div class="field">
<label>Name</label>
<input type="text" name="name" placeholder="{{ 'Alice' if tab == 'users' else 'My Bot' }}" required>
</div>
{% if tab == 'bots' %}
<div class="field">
<label>Bot Type</label>
<select name="profile_type" id="type-select" onchange="onTypeChange()">
{% for t in create_types %}
<option value="{{ t }}">{{ t }}</option>
{% endfor %}
</select>
</div>
<div class="field" id="welcome-field">
<label>Welcome Message</label>
<input type="text" name="welcome_message" placeholder="Welcome! How can I help?">
</div>
{% endif %}
<div class="flex gap-8 mt-16" style="justify-content:flex-end;">
<button type="button" class="btn btn-ghost"
onclick="document.getElementById('create-dialog').close()">Cancel</button>
<button type="submit" class="btn btn-primary">Create</button>
</div>
</form>
</dialog>
<script>
function updateStatus(id, event) {
try {
const data = JSON.parse(event.detail.xhr.responseText);
const badge = document.getElementById('status-' + id);
if (!badge) return;
badge.textContent = data.running ? 'running' : 'stopped';
badge.className = 'badge ' + (data.running ? 'badge-green' : 'badge-red');
} catch(e) {}
}
function openCreate() {
document.getElementById('create-form').reset();
{% if tab == 'bots' %}onTypeChange();{% endif %}
document.getElementById('create-dialog').showModal();
}
function copyAddr(ev, btn, addr) {
ev.stopPropagation();
navigator.clipboard.writeText(addr).then(() => {
btn.textContent = '✓';
setTimeout(() => btn.textContent = '📋', 1500);
});
}
{% if tab == 'bots' %}
function onTypeChange() {
const val = document.getElementById('type-select').value;
const hide = ['echo'].includes(val); // echo has no welcome msg
document.getElementById('welcome-field').style.display = hide ? 'none' : '';
}
{% endif %}
document.getElementById('create-form').addEventListener('submit', async (e) => {
e.preventDefault();
const fd = new FormData(e.target);
{% if tab == 'users' %}
const botType = 'user';
const config = {};
{% else %}
const botType = fd.get('profile_type') || '{{ create_types[0] }}';
const config = {};
const welcome = fd.get('welcome_message');
if (welcome) config.welcome_message = welcome;
{% endif %}
const token = document.cookie.match(/(?:^|;\s*)token=([^;]+)/)?.[1] || '';
const resp = await fetch('/api/profiles', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-Token': token},
body: JSON.stringify({name: fd.get('name'), bot_type: botType, config}),
});
if (resp.ok) {
document.getElementById('create-dialog').close();
location.reload();
} else {
const err = await resp.json();
alert('Error: ' + (err.detail || 'unknown'));
}
});
</script>
{% endblock %}

View File

@@ -1,18 +1,39 @@
<!DOCTYPE html>
<html lang="en">
<head>
<script>
(function(){
var t=localStorage.getItem('theme');
if(!t){t=window.matchMedia('(prefers-color-scheme:dark)').matches?'original-dark':'original-light';}
document.documentElement.setAttribute('data-theme',t);
})();
</script>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SimpleX Manager — Login</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root { --bg: #f5f5f7; --card: #fff; --text: #1d1d1f; --accent: #0053D0; --border: #e0e0e5; }
@media (prefers-color-scheme: dark) {
:root { --bg: #111827; --card: #0B2A59; --text: #f5f5f7; --accent: #70F0F9; --border: #1e3a5f; }
[data-theme="original-light"] {
--bg: #f5f5f7; --card: #fff; --text: #1d1d1f;
--accent: #0053D0; --border: #e0e0e5;
--btn-light-text: #fff;
}
[data-theme="original-dark"] {
--bg: #111827; --card: #0B2A59; --text: #f5f5f7;
--accent: #70F0F9; --border: #1e3a5f;
--btn-light-text: #000;
}
[data-theme="matrix"] {
--bg: #000; --card: #050d05; --text: #00ff41;
--accent: #00ff41; --border: #0f3d0f;
--btn-light-text: #000; --muted: #2e8b57;
}
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;
background: var(--bg); color: var(--text); min-height: 100vh;
display: flex; align-items: center; justify-content: center; }
display: flex; flex-direction: column; }
.login-main { flex: 1; display: flex; align-items: center; justify-content: center; padding: 24px; }
.box { background: var(--card); border-radius: 12px; padding: 36px 32px;
width: 100%; max-width: 360px; box-shadow: 0 4px 24px rgba(0,0,0,0.1); }
h1 { font-size: 22px; font-weight: 700; color: var(--accent); margin-bottom: 24px; text-align: center; }
@@ -20,21 +41,35 @@
input { width: 100%; padding: 10px 12px; font-size: 15px; border: 1px solid var(--border);
border-radius: 8px; background: var(--bg); color: var(--text); outline: none; margin-bottom: 16px; }
input:focus { border-color: var(--accent); }
button { width: 100%; padding: 10px; background: var(--accent); color: #fff; border: none;
border-radius: 8px; font-size: 15px; font-weight: 600; cursor: pointer; }
@media (prefers-color-scheme: dark) { button { color: #000; } }
button { width: 100%; padding: 10px; background: var(--accent); color: var(--btn-light-text);
border: none; border-radius: 8px; font-size: 15px; font-weight: 600; cursor: pointer; }
.error { color: #DD0000; font-size: 13px; margin-bottom: 12px; text-align: center; }
.site-footer { flex-shrink: 0; text-align: center; padding: 18px 20px;
border-top: 1px solid var(--border); color: var(--muted, #6e6e73);
font-size: 12px; line-height: 1.6; }
.site-footer a { color: var(--accent); text-decoration: none; font-weight: 600; }
.site-footer a:hover { text-decoration: underline; }
.site-footer .sep { margin: 0 8px; opacity: 0.5; }
</style>
</head>
<body>
<div class="box">
<h1>SimpleX Manager</h1>
{% if error %}<div class="error">{{ error }}</div>{% endif %}
<form method="post" action="/login">
<label for="token">Access Token</label>
<input type="password" id="token" name="token" placeholder="Enter token…" autofocus>
<button type="submit">Sign in</button>
</form>
<div class="login-main">
<div class="box">
<h1>SimpleX Manager</h1>
{% if error %}<div class="error">{{ error }}</div>{% endif %}
<form method="post" action="/login">
<label for="token">Access Token</label>
<input type="password" id="token" name="token" placeholder="Enter token…" autofocus>
<button type="submit">Sign in</button>
</form>
</div>
</div>
<footer class="site-footer">
© Bournemouth Technology Ltd
<span class="sep">·</span>
built on © SimpleX Network
<span class="sep">·</span>
<a href="https://simplex.chat/downloads/" target="_blank" rel="noopener">Get SimpleX App</a>
</footer>
</body>
</html>

View File

@@ -5,17 +5,34 @@
<style>
.qr-wrap { text-align: center; padding: 16px; }
.qr-wrap canvas { border-radius: 8px; }
.contact-row td:first-child { font-weight: 600; }
.row-action { opacity: 0; transition: opacity 0.15s; }
tr:hover .row-action { opacity: 1; }
.msg-btn {
padding: 3px 10px; font-size: 12px; border-radius: 6px;
background: transparent; border: 1px solid var(--border);
color: var(--accent); cursor: pointer; font-weight: 600;
font-family: inherit; white-space: nowrap;
transition: background 0.15s, color 0.15s;
}
.msg-btn:hover { background: var(--accent); color: var(--btn-light-text); }
.addr-row { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
.addr-link { flex: 1; min-width: 0; color: var(--muted); font-family: monospace; font-size: 12px;
text-decoration: none; word-break: break-all; }
.addr-link:hover { color: var(--accent); text-decoration: underline; }
.copy-btn { flex-shrink: 0; padding: 4px 9px; font-size: 13px; line-height: 1; }
</style>
{% endblock %}
{% block content %}
<div class="flex-between" style="margin-bottom: 20px;">
<div class="flex gap-8">
<a href="/" class="muted" style="text-decoration:none;">Profiles</a>
<a href="{{ back }}" class="muted" style="text-decoration:none;">{{ 'Users' if back == '/users' else 'Bots' }}</a>
<span class="muted">/</span>
<strong>{{ profile.name }}</strong>
<span class="tag">{{ profile.bot_type }}</span>
<span class="tag {% if profile.bot_type == 'user' %}tag-user{% endif %}">{{ profile.bot_type }}</span>
<span class="badge {% if profile.running %}badge-green{% else %}badge-red{% endif %}" id="status-badge">
{% if profile.running %}running{% else %}stopped{% endif %}
</span>
@@ -43,16 +60,21 @@
<div class="card">
<h2>Address</h2>
{% if profile.address %}
<div class="monospace muted" style="word-break:break-all; margin-bottom:12px;">{{ profile.address }}</div>
<div class="addr-row">
<button class="btn btn-ghost copy-btn" title="Copy address"
onclick="copyAddr(this, '{{ profile.address | e }}')">📋</button>
<a class="addr-link" href="{{ profile.address }}" target="_blank" rel="noopener" id="address-text">{{ profile.address }}</a>
</div>
<div class="qr-wrap">
<canvas id="qr-canvas"></canvas>
<p class="muted" style="margin-top:10px;">Scan QR code from mobile app to start a chat</p>
</div>
<script src="https://cdn.jsdelivr.net/npm/qrcode/build/qrcode.min.js"></script>
<script>
QRCode.toCanvas(document.getElementById('qr-canvas'), {{ profile.address | tojson }}, {width: 200}, () => {})
</script>
{% else %}
<p class="muted">Start the bot to generate an address.</p>
<p class="muted">Start the profile to generate an address.</p>
{% endif %}
</div>
@@ -72,37 +94,19 @@
<!-- Right column -->
<div>
<!-- Send message -->
<div class="card">
<h2>Send Message</h2>
<form id="send-form">
<div class="field">
<label>To (contact or group name)</label>
<input type="text" name="to" placeholder="Alice" list="contact-list">
<datalist id="contact-list">
{% for c in contacts %}<option value="{{ c.localDisplayName }}">{% endfor %}
{% for g in groups %}<option value="{{ g.groupInfo.groupProfile.displayName }}">{% endfor %}
</datalist>
</div>
<div class="field">
<label>Message</label>
<textarea name="text" rows="3" placeholder="Hello…"></textarea>
</div>
<button type="submit" class="btn btn-primary">Send</button>
<span id="send-result" class="muted" style="margin-left:10px;"></span>
</form>
</div>
<!-- Contacts -->
<div class="card">
<h2>Contacts ({{ contacts | length }})</h2>
{% if contacts %}
<table>
<tr><th>Name</th><th>ID</th></tr>
<tr><th>Name</th><th style="width:50px;"></th></tr>
{% for c in contacts %}
<tr class="contact-row">
<td>{{ c.localDisplayName }}</td>
<td class="muted monospace">{{ c.contactId }}</td>
<tr>
<td><strong>{{ c.localDisplayName }}</strong></td>
<td>
<a class="msg-btn row-action" style="text-decoration:none;"
href="/profile/{{ profile.id }}/chat/direct/{{ c.contactId }}">💬 Chat</a>
</td>
</tr>
{% endfor %}
</table>
@@ -111,21 +115,64 @@
{% endif %}
</div>
{# Macro: one group/channel row. api_list_groups gives bare GroupInfo dicts:
g.groupId, g.groupProfile.displayName, g.groupSummary.currentMembers.
The verb is "Post" for channels (broadcast) and "Msg" for groups. #}
{% macro groupRow(g) %}
{% set name = g.groupProfile.displayName %}
{% set gid = g.groupId %}
{% set mcnt = g.groupSummary.currentMembers %}
<tr>
<td>{{ name }}</td>
<td>
<button class="msg-btn" style="border:none;padding:0;background:none;color:var(--accent);font-weight:600;font-size:13px;cursor:pointer;"
onclick="loadMembers({{ gid }}, '{{ name | e }}')">{{ mcnt }}</button>
</td>
<td>
<div class="flex gap-8">
<a class="msg-btn row-action" style="text-decoration:none;"
href="/profile/{{ profile.id }}/chat/group/{{ gid }}">💬 {{ 'Broadcast' if g.is_channel else 'Chat' }}</a>
<button class="msg-btn row-action" onclick="getGroupLink({{ gid }}, this)">Link</button>
</div>
</td>
</tr>
{% endmacro %}
<!-- Groups -->
<div class="card">
<h2>Groups ({{ groups | length }})</h2>
<div class="flex-between" style="margin-bottom:12px;">
<h2 style="margin:0;">Groups ({{ groups | length }})</h2>
{% if profile.running %}
<button class="btn btn-primary" style="padding:6px 14px;font-size:13px;"
onclick="openCreate('group')">+ Create Group</button>
{% endif %}
</div>
{% if groups %}
<table>
<tr><th>Name</th><th>Members</th></tr>
{% for g in groups %}
<tr>
<td>{{ g.groupInfo.groupProfile.displayName }}</td>
<td class="muted">{{ g.members | length }}</td>
</tr>
{% endfor %}
<tr><th>Name</th><th>Members</th><th style="width:130px;"></th></tr>
{% for g in groups %}{{ groupRow(g) }}{% endfor %}
</table>
{% else %}
<p class="muted">No groups yet.</p>
<p class="muted">No groups yet.{% if not profile.running %} Start the profile first.{% endif %}</p>
{% endif %}
</div>
<!-- Channels -->
<div class="card">
<div class="flex-between" style="margin-bottom:12px;">
<h2 style="margin:0;">Channels ({{ channels | length }})</h2>
{% if profile.running %}
<button class="btn btn-primary" style="padding:6px 14px;font-size:13px;"
onclick="openCreate('channel')">+ Create Channel</button>
{% endif %}
</div>
{% if channels %}
<table>
<tr><th>Name</th><th>Subscribers</th><th style="width:130px;"></th></tr>
{% for g in channels %}{{ groupRow(g) }}{% endfor %}
</table>
{% else %}
<p class="muted">No channels yet.{% if not profile.running %} Start the profile first.{% endif %}</p>
{% endif %}
</div>
@@ -144,46 +191,221 @@
</div>
</div>
<!-- Create group/channel dialog -->
<dialog id="ch-dialog">
<h2 style="margin-bottom:16px;" id="ch-title">Create Group</h2>
<p class="muted" style="margin-bottom:16px;font-size:13px;" id="ch-desc"></p>
<div class="field">
<label id="ch-name-label">Group Name</label>
<input type="text" id="ch-name" placeholder="My Group" required>
</div>
<div id="ch-link-wrap" style="display:none;margin-bottom:12px;">
<label>Join Link</label>
<div class="flex gap-8">
<input type="text" id="ch-link-out" readonly style="font-family:monospace;font-size:12px;">
<button class="btn btn-ghost" style="white-space:nowrap;" onclick="copyChLink()">Copy</button>
</div>
</div>
<div class="flex-between mt-16">
<span id="ch-result" class="muted" style="font-size:13px;"></span>
<div class="flex gap-8">
<button class="btn btn-ghost" onclick="closeChDialog()">Close</button>
<button class="btn btn-primary" id="ch-create-btn" onclick="createGroup()">Create</button>
</div>
</div>
</dialog>
<!-- Members dialog -->
<dialog id="members-dialog">
<div class="flex-between" style="margin-bottom:16px;">
<h2 style="margin:0;">Members — <span id="members-channel-name" style="color:var(--accent);"></span></h2>
<button class="btn btn-ghost" style="padding:4px 10px;font-size:13px;"
onclick="document.getElementById('members-dialog').close()"></button>
</div>
<div id="members-list" style="max-height:320px;overflow-y:auto;">
<p class="muted">Loading…</p>
</div>
</dialog>
<!-- Send message dialog -->
<dialog id="msg-dialog">
<h2 style="margin-bottom:16px;">Message <span id="msg-target-label" style="color:var(--accent);"></span></h2>
<div class="field">
<textarea id="msg-text" rows="4" placeholder="Type your message…"
style="resize:vertical;"
onkeydown="if(event.key==='Enter'&&(event.ctrlKey||event.metaKey)){sendMsg();return false;}"></textarea>
</div>
<div class="flex-between mt-16">
<span id="msg-result" class="muted" style="font-size:13px;"></span>
<div class="flex gap-8">
<button class="btn btn-ghost" onclick="document.getElementById('msg-dialog').close()">Cancel</button>
<button class="btn btn-primary" onclick="sendMsg()">Send</button>
</div>
</div>
</dialog>
<script>
document.getElementById('send-form').addEventListener('submit', async (e) => {
e.preventDefault()
const fd = new FormData(e.target)
const result = document.getElementById('send-result')
result.textContent = 'Sending…'
const token = document.cookie.match(/(?:^|;\s*)token=([^;]+)/)?.[1] || ''
let msgTarget = '';
function openMsg(name) {
msgTarget = name;
document.getElementById('msg-target-label').textContent = name;
document.getElementById('msg-text').value = '';
document.getElementById('msg-result').textContent = '';
const dlg = document.getElementById('msg-dialog');
dlg.showModal();
setTimeout(() => document.getElementById('msg-text').focus(), 50);
}
async function sendMsg() {
const text = document.getElementById('msg-text').value.trim();
if (!text) return;
const result = document.getElementById('msg-result');
result.textContent = 'Sending…';
const token = document.cookie.match(/(?:^|;\s*)token=([^;]+)/)?.[1] || '';
const resp = await fetch('/api/profiles/{{ profile.id }}/send', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-Token': token},
body: JSON.stringify({to: fd.get('to'), text: fd.get('text')})
})
const data = await resp.json()
result.textContent = data.ok ? '✓ Sent' : '✗ Failed'
setTimeout(() => result.textContent = '', 3000)
})
body: JSON.stringify({to: msgTarget, text})
});
const data = await resp.json();
if (data.ok) {
document.getElementById('msg-text').value = '';
result.textContent = '✓ Sent';
setTimeout(() => document.getElementById('msg-dialog').close(), 800);
} else {
result.textContent = '✗ Failed';
}
}
function copyAddr(btn, addr) {
navigator.clipboard.writeText(addr).then(() => {
btn.textContent = '✓';
setTimeout(() => btn.textContent = '📋', 1500);
});
}
// ── Groups & Channels ──────────────────────────────────────────────────────
const _token = () => document.cookie.match(/(?:^|;\s*)token=([^;]+)/)?.[1] || '';
let _createKind = 'group';
function openCreate(kind) {
_createKind = kind;
const isCh = kind === 'channel';
document.getElementById('ch-title').textContent = isCh ? 'Create Channel' : 'Create Group';
document.getElementById('ch-desc').textContent = isCh
? 'Observer join link — subscribers can read broadcasts but not post. Only you broadcast.'
: 'Member join link — everyone who joins can send messages (2-way).';
document.getElementById('ch-name-label').textContent = isCh ? 'Channel Name' : 'Group Name';
document.getElementById('ch-name').placeholder = isCh ? 'My Channel' : 'My Group';
document.getElementById('ch-name').value = '';
document.getElementById('ch-link-wrap').style.display = 'none';
document.getElementById('ch-result').textContent = '';
const btn = document.getElementById('ch-create-btn');
btn.disabled = false; btn.style.display = '';
document.getElementById('ch-dialog').showModal();
}
async function createGroup() {
const name = document.getElementById('ch-name').value.trim();
if (!name) return;
const btn = document.getElementById('ch-create-btn');
btn.disabled = true;
document.getElementById('ch-result').textContent = 'Creating…';
const resp = await fetch('/api/profiles/{{ profile.id }}/groups', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-Token': _token()},
body: JSON.stringify({name, kind: _createKind}),
});
const data = await resp.json();
if (data.ok) {
document.getElementById('ch-link-out').value = data.link;
document.getElementById('ch-link-wrap').style.display = '';
document.getElementById('ch-result').textContent = '✓ Created';
btn.style.display = 'none';
} else {
document.getElementById('ch-result').textContent = '✗ ' + (data.detail || 'Failed');
btn.disabled = false;
}
}
function copyChLink() {
const val = document.getElementById('ch-link-out').value;
navigator.clipboard.writeText(val).then(() => {
document.getElementById('ch-result').textContent = '✓ Copied';
});
}
function closeChDialog() {
document.getElementById('ch-dialog').close();
location.reload(); // refresh group/channel lists
}
async function loadMembers(groupId, groupName) {
document.getElementById('members-channel-name').textContent = groupName;
document.getElementById('members-list').innerHTML = '<p class="muted">Loading…</p>';
document.getElementById('members-dialog').showModal();
const resp = await fetch(`/api/profiles/{{ profile.id }}/groups/${groupId}/members`, {
headers: {'X-Token': _token()},
});
const data = await resp.json();
if (!data.members || data.members.length === 0) {
document.getElementById('members-list').innerHTML =
'<p class="muted">No other members yet (you are the owner).</p>';
return;
}
const rows = data.members.map(m => `
<tr>
<td><strong>${m.name}</strong></td>
<td class="muted" style="font-size:12px;">${m.role}</td>
<td class="muted" style="font-size:12px;">${m.status}</td>
</tr>`).join('');
document.getElementById('members-list').innerHTML = `
<table>
<tr><th>Name</th><th>Role</th><th>Status</th></tr>
${rows}
</table>`;
}
async function getGroupLink(groupId, btn) {
const orig = btn.textContent;
btn.textContent = '…';
const resp = await fetch(`/api/profiles/{{ profile.id }}/groups/${groupId}/link`, {
headers: {'X-Token': _token()},
});
const data = await resp.json();
if (data.link) {
await navigator.clipboard.writeText(data.link);
btn.textContent = '✓ Copied';
} else {
btn.textContent = 'No link';
}
setTimeout(() => btn.textContent = orig, 2000);
}
// ─────────────────────────────────────────────────────────────────────────────
function refreshLog(event) {
try {
const data = JSON.parse(event.detail.xhr.responseText)
document.getElementById('log-box').textContent = data.log.join('\n')
document.getElementById('status-badge').textContent = data.running ? 'running' : 'stopped'
document.getElementById('status-badge').className = 'badge ' + (data.running ? 'badge-green' : 'badge-red')
const data = JSON.parse(event.detail.xhr.responseText);
document.getElementById('log-box').textContent = data.log.join('\n');
document.getElementById('status-badge').textContent = data.running ? 'running' : 'stopped';
document.getElementById('status-badge').className = 'badge ' + (data.running ? 'badge-green' : 'badge-red');
} catch(e) {}
}
function confirmDelete() {
if (!confirm('Delete this profile? This cannot be undone.')) return
const token = document.cookie.match(/(?:^|;\s*)token=([^;]+)/)?.[1] || ''
if (!confirm('Delete this profile? This cannot be undone.')) return;
const token = document.cookie.match(/(?:^|;\s*)token=([^;]+)/)?.[1] || '';
fetch('/api/profiles/{{ profile.id }}', {
method: 'DELETE',
headers: {'X-Token': token}
}).then(() => location.href = '/')
}).then(() => location.href = '/');
}
// Auto-refresh log every 10s if running
{% if profile.running %}
setInterval(() => {
document.querySelector('[hx-get="/api/profiles/{{ profile.id }}/status"]')?.click()
}, 10000)
document.querySelector('[hx-get="/api/profiles/{{ profile.id }}/status"]')?.click();
}, 10000);
{% endif %}
</script>
{% endblock %}

View File

@@ -0,0 +1,153 @@
{% extends "base.html" %}
{% block title %}Settings — SimpleX Manager{% endblock %}
{% block head %}
<style>
.settings-section { margin-bottom: 32px; }
.settings-section h2 { margin-bottom: 16px; }
.theme-grid { display: flex; gap: 16px; flex-wrap: wrap; }
.theme-card {
flex: 0 0 180px;
border: 2px solid var(--border);
border-radius: 12px;
overflow: hidden;
cursor: pointer;
transition: border-color 0.2s, box-shadow 0.2s;
background: var(--card);
}
.theme-card:hover { border-color: var(--accent); }
.theme-card.selected {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(0,83,208,0.18);
}
[data-theme="original-dark"] .theme-card.selected {
box-shadow: 0 0 0 3px rgba(112,240,249,0.2);
}
[data-theme="matrix"] .theme-card.selected {
box-shadow: 0 0 0 3px rgba(0,255,65,0.25);
}
.theme-preview {
height: 96px;
padding: 10px;
display: flex;
flex-direction: column;
gap: 6px;
}
.preview-bar { border-radius: 4px; height: 14px; }
.preview-bar-sm { border-radius: 4px; height: 9px; width: 60%; }
.preview-dot { width: 20px; height: 20px; border-radius: 50%; margin-top: auto; }
.theme-label {
padding: 10px 14px;
border-top: 1px solid;
display: flex;
align-items: center;
justify-content: space-between;
font-size: 13px;
font-weight: 600;
}
.checkmark {
width: 18px; height: 18px; border-radius: 50%;
background: var(--accent);
display: flex; align-items: center; justify-content: center;
font-size: 11px;
color: var(--btn-light-text);
opacity: 0;
transition: opacity 0.15s;
}
.theme-card.selected .checkmark { opacity: 1; }
/* Original Light preview colors (hardcoded so visible regardless of current theme) */
.preview-light { background: #f5f5f7; }
.preview-light .preview-bar { background: #ffffff; }
.preview-light .preview-bar-sm { background: #e0e0e5; }
.preview-light .preview-dot { background: #0053D0; }
.preview-light + .theme-label { border-color: #e0e0e5; color: #1d1d1f; background: #fff; }
/* Original Dark preview colors */
.preview-dark { background: #111827; }
.preview-dark .preview-bar { background: #0B2A59; }
.preview-dark .preview-bar-sm { background: #1e3a5f; }
.preview-dark .preview-dot { background: #70F0F9; }
.preview-dark + .theme-label { border-color: #1e3a5f; color: #f5f5f7; background: #0B2A59; }
/* Matrix preview colors */
.preview-matrix { background: #000000; }
.preview-matrix .preview-bar { background: #062006; }
.preview-matrix .preview-bar-sm { background: #0f3d0f; }
.preview-matrix .preview-dot { background: #00ff41; box-shadow: 0 0 8px #00ff41; }
.preview-matrix + .theme-label { border-color: #0f3d0f; color: #00ff41; background: #050d05;
font-family: 'Consolas', monospace; }
</style>
{% endblock %}
{% block content %}
<h1>Settings</h1>
<div class="card settings-section">
<h2>Theme</h2>
<div class="theme-grid">
<div class="theme-card" id="card-original-light" onclick="setTheme('original-light')">
<div class="theme-preview preview-light">
<div class="preview-bar"></div>
<div class="preview-bar-sm"></div>
<div class="preview-bar-sm"></div>
<div class="preview-dot"></div>
</div>
<div class="theme-label">
<span>Original Light</span>
<span class="checkmark"></span>
</div>
</div>
<div class="theme-card" id="card-original-dark" onclick="setTheme('original-dark')">
<div class="theme-preview preview-dark">
<div class="preview-bar"></div>
<div class="preview-bar-sm"></div>
<div class="preview-bar-sm"></div>
<div class="preview-dot"></div>
</div>
<div class="theme-label">
<span>Original Dark</span>
<span class="checkmark"></span>
</div>
</div>
<div class="theme-card" id="card-matrix" onclick="setTheme('matrix')">
<div class="theme-preview preview-matrix">
<div class="preview-bar"></div>
<div class="preview-bar-sm"></div>
<div class="preview-bar-sm"></div>
<div class="preview-dot"></div>
</div>
<div class="theme-label">
<span>Matrix</span>
<span class="checkmark"></span>
</div>
</div>
</div>
</div>
<script>
function currentTheme() {
return localStorage.getItem('theme') ||
(window.matchMedia('(prefers-color-scheme:dark)').matches ? 'original-dark' : 'original-light');
}
function setTheme(t) {
localStorage.setItem('theme', t);
document.documentElement.setAttribute('data-theme', t);
document.querySelectorAll('.theme-card').forEach(c => c.classList.remove('selected'));
const card = document.getElementById('card-' + t);
if (card) card.classList.add('selected');
}
// Mark current selection on load
document.getElementById('card-' + currentTheme())?.classList.add('selected');
</script>
{% endblock %}