Add chat rooms, channels, sidebar nav, themes, and UI polish
Backend (profiles.py / main.py): - Fix bot startup crash: create active user before start_chat - Fix address-clobbering bug on restart (UserContactLink vs CreatedConnLink) - Add user profile type alongside bots - Channels: create groups with observer links, classify via acceptMemberRole - Chat rooms: get_chat_history + send_to_chat, history/messages/send routes UI: - Chat room view with message bubbles and live polling - Convert top nav to collapsible left sidebar (mobile-friendly off-canvas) - Three-way Users/Bots split; clickable cards; copy-address buttons + links - Add Matrix theme alongside Original Light/Dark - File upload sidebar link; site footer on all pages incl. login - Bot-type descriptions on the Bots page; QR caption on profiles Remove orphaned index.html (superseded by list.html). Ignore downloaded libs/, exploration DBs, and local ai.sh. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
7
.gitignore
vendored
7
.gitignore
vendored
@@ -10,3 +10,10 @@ __pycache__/
|
|||||||
*.pyc
|
*.pyc
|
||||||
.venv/
|
.venv/
|
||||||
*.egg-info/
|
*.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
7
manager/download_sdk.sh
Executable 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/)"
|
||||||
174
manager/main.py
174
manager/main.py
@@ -27,12 +27,10 @@ AUTH_TOKEN = os.environ.get("MANAGER_TOKEN", "changeme")
|
|||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
db.init_db()
|
db.init_db()
|
||||||
# Auto-restart any previously running bots on startup
|
|
||||||
for profile in db.list_profiles():
|
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)
|
await pm.start_bot(profile, _save_address)
|
||||||
yield
|
yield
|
||||||
# Graceful shutdown
|
|
||||||
for pid in list(pm._running):
|
for pid in list(pm._running):
|
||||||
await pm.stop_bot(pid)
|
await pm.stop_bot(pid)
|
||||||
|
|
||||||
@@ -52,7 +50,6 @@ def _check_auth(request: Request) -> bool:
|
|||||||
|
|
||||||
def _require_auth(request: Request) -> None:
|
def _require_auth(request: Request) -> None:
|
||||||
if not _check_auth(request):
|
if not _check_auth(request):
|
||||||
from fastapi.responses import RedirectResponse
|
|
||||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||||
|
|
||||||
|
|
||||||
@@ -62,6 +59,13 @@ def _redirect_if_unauth(request: Request):
|
|||||||
return None
|
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 ──────────────────────────────────────────────────────────────────────
|
# ── Auth ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@app.get("/login", response_class=HTMLResponse)
|
@app.get("/login", response_class=HTMLResponse)
|
||||||
@@ -89,18 +93,38 @@ async def logout():
|
|||||||
|
|
||||||
@app.get("/", response_class=HTMLResponse)
|
@app.get("/", response_class=HTMLResponse)
|
||||||
async def index(request: Request):
|
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):
|
if redir := _redirect_if_unauth(request):
|
||||||
return redir
|
return redir
|
||||||
all_profiles = db.list_profiles()
|
items = _enrich([p for p in db.list_profiles() if p["bot_type"] in pm.USER_TYPES])
|
||||||
for p in all_profiles:
|
return TEMPLATES.TemplateResponse(request, "list.html", {
|
||||||
p["running"] = pm.is_running(p["id"])
|
"tab": "users", "items": items, "create_types": pm.USER_TYPES,
|
||||||
p["config"] = json.loads(p.get("config") or "{}")
|
"nav_active": "users",
|
||||||
return TEMPLATES.TemplateResponse(request, "index.html", {
|
|
||||||
"profiles": all_profiles,
|
|
||||||
"bot_types": pm.BOT_TYPES,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@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)
|
@app.get("/profile/{profile_id}", response_class=HTMLResponse)
|
||||||
async def profile_page(request: Request, profile_id: int):
|
async def profile_page(request: Request, profile_id: int):
|
||||||
if redir := _redirect_if_unauth(request):
|
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 []
|
contacts = bot.contacts if bot else []
|
||||||
groups = bot.groups if bot else []
|
groups = bot.groups if bot else []
|
||||||
log_lines = bot.log_lines[-50:] 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", {
|
return TEMPLATES.TemplateResponse(request, "profile.html", {
|
||||||
"profile": profile,
|
"profile": profile,
|
||||||
"contacts": contacts,
|
"contacts": contacts,
|
||||||
"groups": groups,
|
"groups": plain_groups,
|
||||||
|
"channels": channels,
|
||||||
"log_lines": log_lines,
|
"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")
|
@app.post("/api/profiles")
|
||||||
async def create_profile(request: Request):
|
async def create_profile(request: Request):
|
||||||
@@ -134,8 +232,8 @@ async def create_profile(request: Request):
|
|||||||
config = data.get("config", {})
|
config = data.get("config", {})
|
||||||
if not name:
|
if not name:
|
||||||
raise HTTPException(400, "name required")
|
raise HTTPException(400, "name required")
|
||||||
if bot_type not in pm.BOT_TYPES:
|
if bot_type not in pm.ALL_TYPES:
|
||||||
raise HTTPException(400, f"bot_type must be one of {pm.BOT_TYPES}")
|
raise HTTPException(400, f"bot_type must be one of {pm.ALL_TYPES}")
|
||||||
try:
|
try:
|
||||||
profile = db.create_profile(name, bot_type, config)
|
profile = db.create_profile(name, bot_type, config)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -196,6 +294,50 @@ async def send_message(request: Request, profile_id: int):
|
|||||||
return JSONResponse({"ok": ok})
|
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__":
|
if __name__ == "__main__":
|
||||||
import uvicorn
|
import uvicorn
|
||||||
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
|
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
|
||||||
|
|||||||
@@ -8,7 +8,32 @@ from typing import Any
|
|||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# 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"]
|
BOT_TYPES = ["echo", "broadcast", "support", "directory", "deadmans"]
|
||||||
|
USER_TYPES = ["user"]
|
||||||
|
ALL_TYPES = BOT_TYPES + USER_TYPES
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@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:
|
if not b or not b.chat:
|
||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
contacts = await b.chat.api_list_contacts(1)
|
for c in b.contacts:
|
||||||
for c in contacts:
|
|
||||||
if c["localDisplayName"] == contact_or_group:
|
if c["localDisplayName"] == contact_or_group:
|
||||||
await b.chat.api_send_text_message(
|
await b.chat.api_send_text_message(
|
||||||
{"chatType": "direct", "chatId": c["contactId"]}, text
|
{"chatType": "direct", "chatId": c["contactId"]}, text
|
||||||
)
|
)
|
||||||
return True
|
return True
|
||||||
groups = await b.chat.api_list_groups(1)
|
for g in b.groups:
|
||||||
for g in groups:
|
if group_name(g) == contact_or_group:
|
||||||
if g["groupInfo"]["groupProfile"]["displayName"] == contact_or_group:
|
|
||||||
await b.chat.api_send_text_message(
|
await b.chat.api_send_text_message(
|
||||||
{"chatType": "group", "chatId": g["groupInfo"]["groupId"]}, text
|
{"chatType": "group", "chatId": group_id(g)}, text
|
||||||
)
|
)
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -103,6 +126,53 @@ async def send_message(profile_id: int, contact_or_group: str, text: str) -> boo
|
|||||||
return False
|
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(
|
async def _run_bot(
|
||||||
profile_id: int,
|
profile_id: int,
|
||||||
name: str,
|
name: str,
|
||||||
@@ -123,27 +193,34 @@ async def _run_bot(
|
|||||||
try:
|
try:
|
||||||
chat = await ChatApi.init(SqliteDb(file_prefix=db_prefix))
|
chat = await ChatApi.init(SqliteDb(file_prefix=db_prefix))
|
||||||
b.chat = chat
|
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()
|
user = await chat.api_get_active_user()
|
||||||
if not user:
|
if not user:
|
||||||
user = await chat.api_create_active_user(
|
user = await chat.api_create_active_user(
|
||||||
{"displayName": name, "fullName": ""}
|
{"displayName": name, "fullName": ""}
|
||||||
)
|
)
|
||||||
|
|
||||||
user_id = user["userId"]
|
await chat.start_chat()
|
||||||
addr = await chat.api_get_user_address(user_id)
|
|
||||||
if not addr:
|
|
||||||
addr = await chat.api_create_user_address(user_id)
|
|
||||||
|
|
||||||
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
|
b.address = address
|
||||||
await on_address(profile_id, 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}}
|
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
|
settings["businessAddress"] = True
|
||||||
welcome = config.get("welcome_message", f"Welcome to {name} support.")
|
welcome = config.get("welcome_message", f"Welcome to {name} support.")
|
||||||
settings["autoReply"] = {"type": "text", "text": welcome}
|
settings["autoReply"] = {"type": "text", "text": welcome}
|
||||||
@@ -157,9 +234,13 @@ async def _run_bot(
|
|||||||
async def refresh() -> None:
|
async def refresh() -> None:
|
||||||
try:
|
try:
|
||||||
b.contacts = await chat.api_list_contacts(user_id)
|
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:
|
except Exception:
|
||||||
pass
|
log.exception("refresh failed for bot %d", profile_id)
|
||||||
|
|
||||||
await refresh()
|
await refresh()
|
||||||
|
|
||||||
@@ -241,3 +322,84 @@ def _append_log(b: RunningBot, line: str) -> None:
|
|||||||
b.log_lines.append(line)
|
b.log_lines.append(line)
|
||||||
if len(b.log_lines) > 200:
|
if len(b.log_lines) > 200:
|
||||||
b.log_lines = 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 ""
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ if [ ! -d ".venv" ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
mkdir -p data/bots
|
mkdir -p data/bots
|
||||||
|
mkdir -p static
|
||||||
|
|
||||||
# Set token — override via: MANAGER_TOKEN=mysecret ./start.sh
|
# Set token — override via: MANAGER_TOKEN=mysecret ./start.sh
|
||||||
export MANAGER_TOKEN="${MANAGER_TOKEN:-changeme}"
|
export MANAGER_TOKEN="${MANAGER_TOKEN:-changeme}"
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<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 charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{% block title %}SimpleX Manager{% endblock %}</title>
|
<title>{% block title %}SimpleX Manager{% endblock %}</title>
|
||||||
<script src="https://unpkg.com/htmx.org@1.9.12/dist/htmx.min.js"></script>
|
<script src="https://unpkg.com/htmx.org@1.9.12/dist/htmx.min.js"></script>
|
||||||
<script>
|
<script>
|
||||||
// Inject auth token into every HTMX request automatically
|
|
||||||
document.addEventListener('htmx:configRequest', function(evt) {
|
document.addEventListener('htmx:configRequest', function(evt) {
|
||||||
const m = document.cookie.match(/(?:^|;\s*)token=([^;]+)/);
|
const m = document.cookie.match(/(?:^|;\s*)token=([^;]+)/);
|
||||||
if (m) evt.detail.headers['X-Token'] = m[1];
|
if (m) evt.detail.headers['X-Token'] = m[1];
|
||||||
@@ -15,7 +22,8 @@
|
|||||||
<style>
|
<style>
|
||||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
:root {
|
/* ── Original Light ─────────────────────────────────────────────── */
|
||||||
|
[data-theme="original-light"] {
|
||||||
--bg: #f5f5f7;
|
--bg: #f5f5f7;
|
||||||
--card: #ffffff;
|
--card: #ffffff;
|
||||||
--text: #1d1d1f;
|
--text: #1d1d1f;
|
||||||
@@ -25,33 +33,136 @@
|
|||||||
--red: #DD0000;
|
--red: #DD0000;
|
||||||
--border: #e0e0e5;
|
--border: #e0e0e5;
|
||||||
--shadow: 0 2px 12px rgba(0,0,0,0.08);
|
--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) {
|
/* ── Original Dark ──────────────────────────────────────────────── */
|
||||||
:root {
|
[data-theme="original-dark"] {
|
||||||
--bg: #111827;
|
--bg: #111827;
|
||||||
--card: #0B2A59;
|
--card: #0B2A59;
|
||||||
--text: #f5f5f7;
|
--text: #f5f5f7;
|
||||||
--muted: #9ca3af;
|
--muted: #9ca3af;
|
||||||
--accent: #70F0F9;
|
--accent: #70F0F9;
|
||||||
|
--green: #20BD3D;
|
||||||
|
--red: #DD0000;
|
||||||
--border: #1e3a5f;
|
--border: #1e3a5f;
|
||||||
--shadow: none;
|
--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;
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif;
|
||||||
background: var(--bg); color: var(--text); min-height: 100vh; }
|
background: var(--bg); color: var(--text); min-height: 100vh; }
|
||||||
|
|
||||||
nav { background: var(--card); border-bottom: 1px solid var(--border);
|
[data-theme="matrix"] body {
|
||||||
padding: 12px 24px; display: flex; align-items: center; justify-content: space-between;
|
font-family: 'SF Mono', 'Consolas', 'Courier New', monospace;
|
||||||
position: sticky; top: 0; z-index: 10; }
|
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; }
|
.sidebar {
|
||||||
.nav-links a:hover { color: var(--accent); }
|
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; }
|
h1 { font-size: 28px; font-weight: 700; margin-bottom: 24px; }
|
||||||
h2 { font-size: 20px; font-weight: 600; margin-bottom: 16px; }
|
h2 { font-size: 20px; font-weight: 600; margin-bottom: 16px; }
|
||||||
@@ -64,25 +175,15 @@
|
|||||||
font-family: inherit; cursor: pointer; border: none; text-decoration: none;
|
font-family: inherit; cursor: pointer; border: none; text-decoration: none;
|
||||||
transition: opacity 0.15s; }
|
transition: opacity 0.15s; }
|
||||||
.btn:hover { opacity: 0.85; }
|
.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-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); }
|
.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;
|
.badge { display: inline-block; padding: 2px 8px; border-radius: 10px;
|
||||||
font-size: 12px; font-weight: 600; }
|
font-size: 12px; font-weight: 600; }
|
||||||
.badge-green { background: #d1fae5; color: #065f46; }
|
.badge-green { background: var(--badge-green-bg); color: var(--badge-green-text); }
|
||||||
.badge-red { background: #fee2e2; color: #991b1b; }
|
.badge-red { background: var(--badge-red-bg); color: var(--badge-red-text); }
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
.badge-green { background: #064e3b; color: #6ee7b7; }
|
|
||||||
.badge-red { background: #7f1d1d; color: #fca5a5; }
|
|
||||||
}
|
|
||||||
|
|
||||||
input, select, textarea {
|
input, select, textarea {
|
||||||
width: 100%; padding: 9px 12px; font-size: 14px; font-family: inherit;
|
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;
|
.tag { display: inline-block; padding: 2px 8px; border-radius: 6px;
|
||||||
font-size: 12px; background: var(--border); color: var(--muted); }
|
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 { display: flex; align-items: center; gap: 10px; }
|
||||||
.flex-between { display: flex; align-items: center; justify-content: space-between; }
|
.flex-between { display: flex; align-items: center; justify-content: space-between; }
|
||||||
@@ -132,15 +236,55 @@
|
|||||||
{% block head %}{% endblock %}
|
{% block head %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<nav>
|
<button class="mobile-menu-btn" onclick="toggleSidebar()" aria-label="Menu">☰</button>
|
||||||
<a class="nav-brand" href="/">SimpleX Manager</a>
|
<div class="app">
|
||||||
<div class="nav-links">
|
<aside class="sidebar" id="sidebar">
|
||||||
<a href="/">Profiles</a>
|
<a class="nav-brand" href="/users">
|
||||||
<a href="/logout">Logout</a>
|
<span class="brand-icon">◆</span><span class="brand-text">SimpleX Manager</span>
|
||||||
</div>
|
</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>
|
</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">
|
<div class="container">
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</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>
|
||||||
|
</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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
154
manager/templates/chat.html
Normal file
154
manager/templates/chat.html
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}{{ chat_name }} — SimpleX Manager{% endblock %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
<style>
|
||||||
|
.chat-wrap {
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
height: calc(100vh - 140px); min-height: 400px;
|
||||||
|
background: var(--card); border-radius: 10px; box-shadow: var(--shadow);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.chat-head {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 14px 18px; border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.chat-head .title { font-weight: 700; font-size: 16px; }
|
||||||
|
|
||||||
|
.chat-log {
|
||||||
|
flex: 1; overflow-y: auto; padding: 18px;
|
||||||
|
display: flex; flex-direction: column; gap: 8px;
|
||||||
|
}
|
||||||
|
.bubble {
|
||||||
|
max-width: 72%; padding: 8px 12px; border-radius: 14px;
|
||||||
|
font-size: 14px; line-height: 1.4; word-wrap: break-word; white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
.bubble .who { font-size: 11px; font-weight: 700; opacity: 0.7; margin-bottom: 2px; }
|
||||||
|
.bubble .ts { font-size: 10px; opacity: 0.55; margin-top: 3px; text-align: right; }
|
||||||
|
.bubble.in { align-self: flex-start; background: var(--bg); border: 1px solid var(--border); }
|
||||||
|
.bubble.out { align-self: flex-end; background: var(--accent); color: var(--btn-light-text); }
|
||||||
|
.bubble.deleted { font-style: italic; opacity: 0.5; }
|
||||||
|
|
||||||
|
.chat-compose {
|
||||||
|
display: flex; gap: 8px; padding: 12px; border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.chat-compose textarea { resize: none; height: 42px; }
|
||||||
|
.chat-empty { text-align: center; color: var(--muted); margin: auto; font-size: 14px; }
|
||||||
|
.chat-banner { padding: 8px 18px; font-size: 12px; color: var(--muted);
|
||||||
|
background: var(--bg); border-bottom: 1px solid var(--border); }
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="flex gap-8" style="margin-bottom:16px;">
|
||||||
|
<a href="/profile/{{ profile.id }}" class="muted" style="text-decoration:none;">← {{ profile.name }}</a>
|
||||||
|
<span class="muted">/</span>
|
||||||
|
<strong>{{ chat_name }}</strong>
|
||||||
|
<span class="tag">{{ 'channel' if is_channel else chat_type }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="chat-wrap">
|
||||||
|
<div class="chat-head">
|
||||||
|
<span class="title">{{ chat_name }}</span>
|
||||||
|
<button class="btn btn-ghost" style="padding:4px 12px;font-size:12px;"
|
||||||
|
onclick="loadMessages(true)">↻ Refresh</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if is_channel %}
|
||||||
|
<div class="chat-banner">📢 Channel — messages you send here broadcast to all subscribers.</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="chat-log" id="chat-log">
|
||||||
|
{% if not running %}
|
||||||
|
<div class="chat-empty">Profile is stopped. Start it to load messages.</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="chat-empty" id="chat-loading">Loading messages…</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="chat-compose">
|
||||||
|
<textarea id="msg-input" placeholder="{{ 'Broadcast a message…' if is_channel else 'Type a message…' }}"
|
||||||
|
{% if not running %}disabled{% endif %}
|
||||||
|
onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();sendMsg();}"></textarea>
|
||||||
|
<button class="btn btn-primary" onclick="sendMsg()" {% if not running %}disabled{% endif %}>Send</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const PROFILE_ID = {{ profile.id }};
|
||||||
|
const CHAT_TYPE = '{{ chat_type }}';
|
||||||
|
const CHAT_ID = {{ chat_id }};
|
||||||
|
const RUNNING = {{ 'true' if running else 'false' }};
|
||||||
|
const _token = () => document.cookie.match(/(?:^|;\s*)token=([^;]+)/)?.[1] || '';
|
||||||
|
|
||||||
|
let lastIds = ''; // signature of rendered messages, to skip needless re-renders
|
||||||
|
|
||||||
|
function fmtTs(iso) {
|
||||||
|
if (!iso) return '';
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (isNaN(d)) return '';
|
||||||
|
return d.toLocaleString([], {month:'short', day:'numeric', hour:'2-digit', minute:'2-digit'});
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(messages) {
|
||||||
|
const log = document.getElementById('chat-log');
|
||||||
|
const sig = messages.map(m => m.id).join(',');
|
||||||
|
if (sig === lastIds) return; // nothing new
|
||||||
|
const atBottom = log.scrollHeight - log.scrollTop - log.clientHeight < 60;
|
||||||
|
lastIds = sig;
|
||||||
|
|
||||||
|
if (!messages.length) {
|
||||||
|
log.innerHTML = '<div class="chat-empty">No messages yet.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
log.innerHTML = messages.map(m => {
|
||||||
|
const cls = 'bubble ' + (m.outgoing ? 'out' : 'in') + (m.deleted ? ' deleted' : '');
|
||||||
|
const who = (!m.outgoing && m.sender) ? `<div class="who">${escapeHtml(m.sender)}</div>` : '';
|
||||||
|
const txt = m.deleted ? '(deleted)' : escapeHtml(m.text || '');
|
||||||
|
return `<div class="${cls}">${who}${txt}<div class="ts">${fmtTs(m.ts)}</div></div>`;
|
||||||
|
}).join('');
|
||||||
|
if (atBottom) log.scrollTop = log.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return s.replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMessages(force) {
|
||||||
|
if (!RUNNING) return;
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/profiles/${PROFILE_ID}/chat/${CHAT_TYPE}/${CHAT_ID}/messages?count=80`, {
|
||||||
|
headers: {'X-Token': _token()},
|
||||||
|
});
|
||||||
|
if (!resp.ok) return;
|
||||||
|
const data = await resp.json();
|
||||||
|
if (force) lastIds = '';
|
||||||
|
render(data.messages || []);
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendMsg() {
|
||||||
|
const input = document.getElementById('msg-input');
|
||||||
|
const text = input.value.trim();
|
||||||
|
if (!text) return;
|
||||||
|
input.value = '';
|
||||||
|
const resp = await fetch(`/api/profiles/${PROFILE_ID}/chat/${CHAT_TYPE}/${CHAT_ID}/send`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json', 'X-Token': _token()},
|
||||||
|
body: JSON.stringify({text}),
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (!data.ok) {
|
||||||
|
input.value = text; // restore on failure
|
||||||
|
alert('Failed to send');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTimeout(() => loadMessages(true), 250); // reflect the sent message quickly
|
||||||
|
}
|
||||||
|
|
||||||
|
if (RUNNING) {
|
||||||
|
loadMessages(true);
|
||||||
|
setInterval(loadMessages, 3000); // live updates via polling
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
{% block title %}Profiles — SimpleX Manager{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="flex-between" style="margin-bottom: 24px;">
|
|
||||||
<h1 style="margin:0">Bot Profiles</h1>
|
|
||||||
<button class="btn btn-primary" onclick="document.getElementById('create-dialog').showModal()">+ New Profile</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if profiles %}
|
|
||||||
<div id="profile-list">
|
|
||||||
{% for p in profiles %}
|
|
||||||
<div class="card" id="profile-{{ p.id }}">
|
|
||||||
<div class="flex-between">
|
|
||||||
<div class="flex gap-8">
|
|
||||||
<strong>{{ p.name }}</strong>
|
|
||||||
<span class="tag">{{ p.bot_type }}</span>
|
|
||||||
<span class="badge {% if p.running %}badge-green{% else %}badge-red{% endif %}"
|
|
||||||
id="status-{{ p.id }}"
|
|
||||||
hx-get="/api/profiles/{{ p.id }}/status"
|
|
||||||
hx-trigger="every 5s"
|
|
||||||
hx-swap="none"
|
|
||||||
hx-on::after-request="updateStatus({{ p.id }}, event)">
|
|
||||||
{% if p.running %}running{% else %}stopped{% endif %}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-8">
|
|
||||||
<a href="/profile/{{ p.id }}" class="btn btn-ghost" style="padding: 6px 14px; font-size: 13px;">View</a>
|
|
||||||
<button class="btn btn-success" style="padding: 6px 14px; font-size: 13px;"
|
|
||||||
hx-post="/api/profiles/{{ p.id }}/start"
|
|
||||||
hx-swap="none"
|
|
||||||
onclick="this.textContent='Starting…'">Start</button>
|
|
||||||
<button class="btn btn-danger" style="padding: 6px 14px; font-size: 13px;"
|
|
||||||
hx-post="/api/profiles/{{ p.id }}/stop"
|
|
||||||
hx-swap="none"
|
|
||||||
onclick="this.textContent='Stopping…'">Stop</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% if p.address %}
|
|
||||||
<div class="muted mt-8 monospace" style="word-break:break-all;">{{ p.address }}</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<div class="card" style="text-align:center; padding: 48px; color: var(--muted);">
|
|
||||||
No profiles yet. Create one to get started.
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<!-- Create dialog -->
|
|
||||||
<dialog id="create-dialog">
|
|
||||||
<h2 style="margin-bottom:20px;">New Bot Profile</h2>
|
|
||||||
<form id="create-form">
|
|
||||||
<div class="field">
|
|
||||||
<label>Name</label>
|
|
||||||
<input type="text" name="name" placeholder="My Support Bot" required>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label>Bot Type</label>
|
|
||||||
<select name="bot_type">
|
|
||||||
{% for t in bot_types %}
|
|
||||||
<option value="{{ t }}">{{ t }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label>Welcome Message</label>
|
|
||||||
<input type="text" name="welcome_message" placeholder="Welcome! How can I help?">
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-8 mt-16" style="justify-content: flex-end;">
|
|
||||||
<button type="button" class="btn btn-ghost" onclick="document.getElementById('create-dialog').close()">Cancel</button>
|
|
||||||
<button type="submit" class="btn btn-primary">Create</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</dialog>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
function updateStatus(id, event) {
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(event.detail.xhr.responseText)
|
|
||||||
const badge = document.getElementById('status-' + id)
|
|
||||||
if (!badge) return
|
|
||||||
badge.textContent = data.running ? 'running' : 'stopped'
|
|
||||||
badge.className = 'badge ' + (data.running ? 'badge-green' : 'badge-red')
|
|
||||||
} catch(e) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('create-form').addEventListener('submit', async (e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
const fd = new FormData(e.target)
|
|
||||||
const config = {}
|
|
||||||
const welcome = fd.get('welcome_message')
|
|
||||||
if (welcome) config.welcome_message = welcome
|
|
||||||
|
|
||||||
const token = document.cookie.match(/(?:^|;\s*)token=([^;]+)/)?.[1] || ''
|
|
||||||
const resp = await fetch('/api/profiles', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {'Content-Type': 'application/json', 'X-Token': token},
|
|
||||||
body: JSON.stringify({name: fd.get('name'), bot_type: fd.get('bot_type'), config})
|
|
||||||
})
|
|
||||||
if (resp.ok) {
|
|
||||||
document.getElementById('create-dialog').close()
|
|
||||||
location.reload()
|
|
||||||
} else {
|
|
||||||
const err = await resp.json()
|
|
||||||
alert('Error: ' + (err.detail || 'unknown'))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
183
manager/templates/list.html
Normal file
183
manager/templates/list.html
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}{{ tab | title }} — SimpleX Manager{% endblock %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
<style>
|
||||||
|
.empty-state { text-align: center; padding: 56px 24px; color: var(--muted); }
|
||||||
|
.empty-state p { margin-top: 8px; font-size: 13px; }
|
||||||
|
|
||||||
|
.profile-card { cursor: pointer; transition: border-color 0.15s, box-shadow 0.15s, transform 0.05s;
|
||||||
|
border: 1px solid transparent; }
|
||||||
|
.profile-card:hover { border-color: var(--accent); }
|
||||||
|
.profile-card:active { transform: translateY(1px); }
|
||||||
|
|
||||||
|
.addr-row { display: flex; align-items: center; gap: 8px; margin-top: 10px; }
|
||||||
|
.addr-link { flex: 1; min-width: 0; color: var(--muted); font-family: monospace; font-size: 12px;
|
||||||
|
text-decoration: none; word-break: break-all; }
|
||||||
|
.addr-link:hover { color: var(--accent); text-decoration: underline; }
|
||||||
|
.copy-btn { flex-shrink: 0; padding: 4px 9px; font-size: 13px; line-height: 1; }
|
||||||
|
|
||||||
|
.bot-types-card table td { vertical-align: top; }
|
||||||
|
.bot-types-card .tag { white-space: nowrap; }
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="flex-between" style="margin-bottom: 24px;">
|
||||||
|
<h1 style="margin:0;">{{ tab | title }}</h1>
|
||||||
|
<button class="btn btn-primary" onclick="openCreate()">
|
||||||
|
+ New {{ 'User' if tab == 'users' else 'Bot' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if tab == 'bots' %}
|
||||||
|
<div class="card bot-types-card" style="margin-bottom:24px;">
|
||||||
|
<h2 style="font-size:15px;margin-bottom:12px;">Available bot types</h2>
|
||||||
|
<table>
|
||||||
|
<tr><td><span class="tag">echo</span></td><td class="muted">Repeats every message back to the sender — handy for testing a connection end to end.</td></tr>
|
||||||
|
<tr><td><span class="tag">broadcast</span></td><td class="muted">Relays messages from authorized publishers out to all of the bot's contacts.</td></tr>
|
||||||
|
<tr><td><span class="tag">support</span></td><td class="muted">Business inbox — auto-replies with a welcome message and collects incoming inquiries.</td></tr>
|
||||||
|
<tr><td><span class="tag">directory</span></td><td class="muted">Directory service for discovering and listing groups or contacts.</td></tr>
|
||||||
|
<tr><td><span class="tag">deadmans</span></td><td class="muted">Dead man's switch — triggers an action if expected check-ins stop arriving.</td></tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if items %}
|
||||||
|
{% for p in items %}
|
||||||
|
<div class="card profile-card" id="profile-{{ p.id }}"
|
||||||
|
onclick="location.href='/profile/{{ p.id }}'">
|
||||||
|
<div class="flex-between">
|
||||||
|
<div class="flex gap-8">
|
||||||
|
<strong>{{ p.name }}</strong>
|
||||||
|
<span class="tag {% if p.bot_type == 'user' %}tag-user{% endif %}">{{ p.bot_type }}</span>
|
||||||
|
<span class="badge {% if p.running %}badge-green{% else %}badge-red{% endif %}"
|
||||||
|
id="status-{{ p.id }}"
|
||||||
|
hx-get="/api/profiles/{{ p.id }}/status"
|
||||||
|
hx-trigger="every 5s"
|
||||||
|
hx-swap="none"
|
||||||
|
hx-on::after-request="updateStatus({{ p.id }}, event)">
|
||||||
|
{% if p.running %}running{% else %}stopped{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-8" onclick="event.stopPropagation()">
|
||||||
|
<button class="btn btn-success" style="padding:6px 14px;font-size:13px;"
|
||||||
|
hx-post="/api/profiles/{{ p.id }}/start" hx-swap="none"
|
||||||
|
onclick="this.textContent='Starting…'">Start</button>
|
||||||
|
<button class="btn btn-danger" style="padding:6px 14px;font-size:13px;"
|
||||||
|
hx-post="/api/profiles/{{ p.id }}/stop" hx-swap="none"
|
||||||
|
onclick="this.textContent='Stopping…'">Stop</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if p.address %}
|
||||||
|
<div class="addr-row" onclick="event.stopPropagation()">
|
||||||
|
<button class="btn btn-ghost copy-btn" title="Copy address"
|
||||||
|
onclick="copyAddr(event, this, '{{ p.address | e }}')">📋</button>
|
||||||
|
<a class="addr-link" href="{{ p.address }}" target="_blank" rel="noopener">{{ p.address }}</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state card">
|
||||||
|
{% if tab == 'users' %}
|
||||||
|
<strong>No users yet</strong>
|
||||||
|
<p>Create a SimpleX user account to manage contacts and channels.</p>
|
||||||
|
{% else %}
|
||||||
|
<strong>No bots yet</strong>
|
||||||
|
<p>Bots can echo messages, broadcast to subscribers, or run automated tasks.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Create dialog -->
|
||||||
|
<dialog id="create-dialog">
|
||||||
|
<h2 style="margin-bottom:20px;">New {{ 'User' if tab == 'users' else 'Bot' }}</h2>
|
||||||
|
<form id="create-form">
|
||||||
|
<div class="field">
|
||||||
|
<label>Name</label>
|
||||||
|
<input type="text" name="name" placeholder="{{ 'Alice' if tab == 'users' else 'My Bot' }}" required>
|
||||||
|
</div>
|
||||||
|
{% if tab == 'bots' %}
|
||||||
|
<div class="field">
|
||||||
|
<label>Bot Type</label>
|
||||||
|
<select name="profile_type" id="type-select" onchange="onTypeChange()">
|
||||||
|
{% for t in create_types %}
|
||||||
|
<option value="{{ t }}">{{ t }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field" id="welcome-field">
|
||||||
|
<label>Welcome Message</label>
|
||||||
|
<input type="text" name="welcome_message" placeholder="Welcome! How can I help?">
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="flex gap-8 mt-16" style="justify-content:flex-end;">
|
||||||
|
<button type="button" class="btn btn-ghost"
|
||||||
|
onclick="document.getElementById('create-dialog').close()">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Create</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function updateStatus(id, event) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.detail.xhr.responseText);
|
||||||
|
const badge = document.getElementById('status-' + id);
|
||||||
|
if (!badge) return;
|
||||||
|
badge.textContent = data.running ? 'running' : 'stopped';
|
||||||
|
badge.className = 'badge ' + (data.running ? 'badge-green' : 'badge-red');
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
document.getElementById('create-form').reset();
|
||||||
|
{% if tab == 'bots' %}onTypeChange();{% endif %}
|
||||||
|
document.getElementById('create-dialog').showModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyAddr(ev, btn, addr) {
|
||||||
|
ev.stopPropagation();
|
||||||
|
navigator.clipboard.writeText(addr).then(() => {
|
||||||
|
btn.textContent = '✓';
|
||||||
|
setTimeout(() => btn.textContent = '📋', 1500);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
{% if tab == 'bots' %}
|
||||||
|
function onTypeChange() {
|
||||||
|
const val = document.getElementById('type-select').value;
|
||||||
|
const hide = ['echo'].includes(val); // echo has no welcome msg
|
||||||
|
document.getElementById('welcome-field').style.display = hide ? 'none' : '';
|
||||||
|
}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
document.getElementById('create-form').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const fd = new FormData(e.target);
|
||||||
|
{% if tab == 'users' %}
|
||||||
|
const botType = 'user';
|
||||||
|
const config = {};
|
||||||
|
{% else %}
|
||||||
|
const botType = fd.get('profile_type') || '{{ create_types[0] }}';
|
||||||
|
const config = {};
|
||||||
|
const welcome = fd.get('welcome_message');
|
||||||
|
if (welcome) config.welcome_message = welcome;
|
||||||
|
{% endif %}
|
||||||
|
const token = document.cookie.match(/(?:^|;\s*)token=([^;]+)/)?.[1] || '';
|
||||||
|
const resp = await fetch('/api/profiles', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json', 'X-Token': token},
|
||||||
|
body: JSON.stringify({name: fd.get('name'), bot_type: botType, config}),
|
||||||
|
});
|
||||||
|
if (resp.ok) {
|
||||||
|
document.getElementById('create-dialog').close();
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
const err = await resp.json();
|
||||||
|
alert('Error: ' + (err.detail || 'unknown'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,18 +1,39 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<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 charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>SimpleX Manager — Login</title>
|
<title>SimpleX Manager — Login</title>
|
||||||
<style>
|
<style>
|
||||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
*, *::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) {
|
[data-theme="original-light"] {
|
||||||
:root { --bg: #111827; --card: #0B2A59; --text: #f5f5f7; --accent: #70F0F9; --border: #1e3a5f; }
|
--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;
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;
|
||||||
background: var(--bg); color: var(--text); min-height: 100vh;
|
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;
|
.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); }
|
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; }
|
h1 { font-size: 22px; font-weight: 700; color: var(--accent); margin-bottom: 24px; text-align: center; }
|
||||||
@@ -20,13 +41,19 @@
|
|||||||
input { width: 100%; padding: 10px 12px; font-size: 15px; border: 1px solid var(--border);
|
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; }
|
border-radius: 8px; background: var(--bg); color: var(--text); outline: none; margin-bottom: 16px; }
|
||||||
input:focus { border-color: var(--accent); }
|
input:focus { border-color: var(--accent); }
|
||||||
button { width: 100%; padding: 10px; background: var(--accent); color: #fff; border: none;
|
button { width: 100%; padding: 10px; background: var(--accent); color: var(--btn-light-text);
|
||||||
border-radius: 8px; font-size: 15px; font-weight: 600; cursor: pointer; }
|
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; }
|
.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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<div class="login-main">
|
||||||
<div class="box">
|
<div class="box">
|
||||||
<h1>SimpleX Manager</h1>
|
<h1>SimpleX Manager</h1>
|
||||||
{% if error %}<div class="error">{{ error }}</div>{% endif %}
|
{% if error %}<div class="error">{{ error }}</div>{% endif %}
|
||||||
@@ -36,5 +63,13 @@
|
|||||||
<button type="submit">Sign in</button>
|
<button type="submit">Sign in</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -5,17 +5,34 @@
|
|||||||
<style>
|
<style>
|
||||||
.qr-wrap { text-align: center; padding: 16px; }
|
.qr-wrap { text-align: center; padding: 16px; }
|
||||||
.qr-wrap canvas { border-radius: 8px; }
|
.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>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="flex-between" style="margin-bottom: 20px;">
|
<div class="flex-between" style="margin-bottom: 20px;">
|
||||||
<div class="flex gap-8">
|
<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>
|
<span class="muted">/</span>
|
||||||
<strong>{{ profile.name }}</strong>
|
<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">
|
<span class="badge {% if profile.running %}badge-green{% else %}badge-red{% endif %}" id="status-badge">
|
||||||
{% if profile.running %}running{% else %}stopped{% endif %}
|
{% if profile.running %}running{% else %}stopped{% endif %}
|
||||||
</span>
|
</span>
|
||||||
@@ -43,16 +60,21 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Address</h2>
|
<h2>Address</h2>
|
||||||
{% if profile.address %}
|
{% 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">
|
<div class="qr-wrap">
|
||||||
<canvas id="qr-canvas"></canvas>
|
<canvas id="qr-canvas"></canvas>
|
||||||
|
<p class="muted" style="margin-top:10px;">Scan QR code from mobile app to start a chat</p>
|
||||||
</div>
|
</div>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/qrcode/build/qrcode.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/qrcode/build/qrcode.min.js"></script>
|
||||||
<script>
|
<script>
|
||||||
QRCode.toCanvas(document.getElementById('qr-canvas'), {{ profile.address | tojson }}, {width: 200}, () => {})
|
QRCode.toCanvas(document.getElementById('qr-canvas'), {{ profile.address | tojson }}, {width: 200}, () => {})
|
||||||
</script>
|
</script>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="muted">Start the bot to generate an address.</p>
|
<p class="muted">Start the profile to generate an address.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -72,37 +94,19 @@
|
|||||||
|
|
||||||
<!-- Right column -->
|
<!-- Right column -->
|
||||||
<div>
|
<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 -->
|
<!-- Contacts -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Contacts ({{ contacts | length }})</h2>
|
<h2>Contacts ({{ contacts | length }})</h2>
|
||||||
{% if contacts %}
|
{% if contacts %}
|
||||||
<table>
|
<table>
|
||||||
<tr><th>Name</th><th>ID</th></tr>
|
<tr><th>Name</th><th style="width:50px;"></th></tr>
|
||||||
{% for c in contacts %}
|
{% for c in contacts %}
|
||||||
<tr class="contact-row">
|
<tr>
|
||||||
<td>{{ c.localDisplayName }}</td>
|
<td><strong>{{ c.localDisplayName }}</strong></td>
|
||||||
<td class="muted monospace">{{ c.contactId }}</td>
|
<td>
|
||||||
|
<a class="msg-btn row-action" style="text-decoration:none;"
|
||||||
|
href="/profile/{{ profile.id }}/chat/direct/{{ c.contactId }}">💬 Chat</a>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</table>
|
</table>
|
||||||
@@ -111,21 +115,64 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</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 -->
|
<!-- Groups -->
|
||||||
<div class="card">
|
<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 %}
|
{% if groups %}
|
||||||
<table>
|
<table>
|
||||||
<tr><th>Name</th><th>Members</th></tr>
|
<tr><th>Name</th><th>Members</th><th style="width:130px;"></th></tr>
|
||||||
{% for g in groups %}
|
{% for g in groups %}{{ groupRow(g) }}{% endfor %}
|
||||||
<tr>
|
|
||||||
<td>{{ g.groupInfo.groupProfile.displayName }}</td>
|
|
||||||
<td class="muted">{{ g.members | length }}</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</table>
|
</table>
|
||||||
{% else %}
|
{% 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 %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -144,46 +191,221 @@
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
<script>
|
||||||
document.getElementById('send-form').addEventListener('submit', async (e) => {
|
let msgTarget = '';
|
||||||
e.preventDefault()
|
|
||||||
const fd = new FormData(e.target)
|
function openMsg(name) {
|
||||||
const result = document.getElementById('send-result')
|
msgTarget = name;
|
||||||
result.textContent = 'Sending…'
|
document.getElementById('msg-target-label').textContent = name;
|
||||||
const token = document.cookie.match(/(?:^|;\s*)token=([^;]+)/)?.[1] || ''
|
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', {
|
const resp = await fetch('/api/profiles/{{ profile.id }}/send', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {'Content-Type': 'application/json', 'X-Token': token},
|
headers: {'Content-Type': 'application/json', 'X-Token': token},
|
||||||
body: JSON.stringify({to: fd.get('to'), text: fd.get('text')})
|
body: JSON.stringify({to: msgTarget, text})
|
||||||
})
|
});
|
||||||
const data = await resp.json()
|
const data = await resp.json();
|
||||||
result.textContent = data.ok ? '✓ Sent' : '✗ Failed'
|
if (data.ok) {
|
||||||
setTimeout(() => result.textContent = '', 3000)
|
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) {
|
function refreshLog(event) {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(event.detail.xhr.responseText)
|
const data = JSON.parse(event.detail.xhr.responseText);
|
||||||
document.getElementById('log-box').textContent = data.log.join('\n')
|
document.getElementById('log-box').textContent = data.log.join('\n');
|
||||||
document.getElementById('status-badge').textContent = data.running ? 'running' : 'stopped'
|
document.getElementById('status-badge').textContent = data.running ? 'running' : 'stopped';
|
||||||
document.getElementById('status-badge').className = 'badge ' + (data.running ? 'badge-green' : 'badge-red')
|
document.getElementById('status-badge').className = 'badge ' + (data.running ? 'badge-green' : 'badge-red');
|
||||||
} catch(e) {}
|
} catch(e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
function confirmDelete() {
|
function confirmDelete() {
|
||||||
if (!confirm('Delete this profile? This cannot be undone.')) return
|
if (!confirm('Delete this profile? This cannot be undone.')) return;
|
||||||
const token = document.cookie.match(/(?:^|;\s*)token=([^;]+)/)?.[1] || ''
|
const token = document.cookie.match(/(?:^|;\s*)token=([^;]+)/)?.[1] || '';
|
||||||
fetch('/api/profiles/{{ profile.id }}', {
|
fetch('/api/profiles/{{ profile.id }}', {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: {'X-Token': token}
|
headers: {'X-Token': token}
|
||||||
}).then(() => location.href = '/')
|
}).then(() => location.href = '/');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-refresh log every 10s if running
|
|
||||||
{% if profile.running %}
|
{% if profile.running %}
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
document.querySelector('[hx-get="/api/profiles/{{ profile.id }}/status"]')?.click()
|
document.querySelector('[hx-get="/api/profiles/{{ profile.id }}/status"]')?.click();
|
||||||
}, 10000)
|
}, 10000);
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
153
manager/templates/settings.html
Normal file
153
manager/templates/settings.html
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Settings — SimpleX Manager{% endblock %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
<style>
|
||||||
|
.settings-section { margin-bottom: 32px; }
|
||||||
|
.settings-section h2 { margin-bottom: 16px; }
|
||||||
|
|
||||||
|
.theme-grid { display: flex; gap: 16px; flex-wrap: wrap; }
|
||||||
|
|
||||||
|
.theme-card {
|
||||||
|
flex: 0 0 180px;
|
||||||
|
border: 2px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.2s, box-shadow 0.2s;
|
||||||
|
background: var(--card);
|
||||||
|
}
|
||||||
|
.theme-card:hover { border-color: var(--accent); }
|
||||||
|
.theme-card.selected {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px rgba(0,83,208,0.18);
|
||||||
|
}
|
||||||
|
[data-theme="original-dark"] .theme-card.selected {
|
||||||
|
box-shadow: 0 0 0 3px rgba(112,240,249,0.2);
|
||||||
|
}
|
||||||
|
[data-theme="matrix"] .theme-card.selected {
|
||||||
|
box-shadow: 0 0 0 3px rgba(0,255,65,0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-preview {
|
||||||
|
height: 96px;
|
||||||
|
padding: 10px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.preview-bar { border-radius: 4px; height: 14px; }
|
||||||
|
.preview-bar-sm { border-radius: 4px; height: 9px; width: 60%; }
|
||||||
|
.preview-dot { width: 20px; height: 20px; border-radius: 50%; margin-top: auto; }
|
||||||
|
|
||||||
|
.theme-label {
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-top: 1px solid;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.checkmark {
|
||||||
|
width: 18px; height: 18px; border-radius: 50%;
|
||||||
|
background: var(--accent);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--btn-light-text);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
.theme-card.selected .checkmark { opacity: 1; }
|
||||||
|
|
||||||
|
/* Original Light preview colors (hardcoded so visible regardless of current theme) */
|
||||||
|
.preview-light { background: #f5f5f7; }
|
||||||
|
.preview-light .preview-bar { background: #ffffff; }
|
||||||
|
.preview-light .preview-bar-sm { background: #e0e0e5; }
|
||||||
|
.preview-light .preview-dot { background: #0053D0; }
|
||||||
|
.preview-light + .theme-label { border-color: #e0e0e5; color: #1d1d1f; background: #fff; }
|
||||||
|
|
||||||
|
/* Original Dark preview colors */
|
||||||
|
.preview-dark { background: #111827; }
|
||||||
|
.preview-dark .preview-bar { background: #0B2A59; }
|
||||||
|
.preview-dark .preview-bar-sm { background: #1e3a5f; }
|
||||||
|
.preview-dark .preview-dot { background: #70F0F9; }
|
||||||
|
.preview-dark + .theme-label { border-color: #1e3a5f; color: #f5f5f7; background: #0B2A59; }
|
||||||
|
|
||||||
|
/* Matrix preview colors */
|
||||||
|
.preview-matrix { background: #000000; }
|
||||||
|
.preview-matrix .preview-bar { background: #062006; }
|
||||||
|
.preview-matrix .preview-bar-sm { background: #0f3d0f; }
|
||||||
|
.preview-matrix .preview-dot { background: #00ff41; box-shadow: 0 0 8px #00ff41; }
|
||||||
|
.preview-matrix + .theme-label { border-color: #0f3d0f; color: #00ff41; background: #050d05;
|
||||||
|
font-family: 'Consolas', monospace; }
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>Settings</h1>
|
||||||
|
|
||||||
|
<div class="card settings-section">
|
||||||
|
<h2>Theme</h2>
|
||||||
|
<div class="theme-grid">
|
||||||
|
|
||||||
|
<div class="theme-card" id="card-original-light" onclick="setTheme('original-light')">
|
||||||
|
<div class="theme-preview preview-light">
|
||||||
|
<div class="preview-bar"></div>
|
||||||
|
<div class="preview-bar-sm"></div>
|
||||||
|
<div class="preview-bar-sm"></div>
|
||||||
|
<div class="preview-dot"></div>
|
||||||
|
</div>
|
||||||
|
<div class="theme-label">
|
||||||
|
<span>Original Light</span>
|
||||||
|
<span class="checkmark">✓</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="theme-card" id="card-original-dark" onclick="setTheme('original-dark')">
|
||||||
|
<div class="theme-preview preview-dark">
|
||||||
|
<div class="preview-bar"></div>
|
||||||
|
<div class="preview-bar-sm"></div>
|
||||||
|
<div class="preview-bar-sm"></div>
|
||||||
|
<div class="preview-dot"></div>
|
||||||
|
</div>
|
||||||
|
<div class="theme-label">
|
||||||
|
<span>Original Dark</span>
|
||||||
|
<span class="checkmark">✓</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="theme-card" id="card-matrix" onclick="setTheme('matrix')">
|
||||||
|
<div class="theme-preview preview-matrix">
|
||||||
|
<div class="preview-bar"></div>
|
||||||
|
<div class="preview-bar-sm"></div>
|
||||||
|
<div class="preview-bar-sm"></div>
|
||||||
|
<div class="preview-dot"></div>
|
||||||
|
</div>
|
||||||
|
<div class="theme-label">
|
||||||
|
<span>Matrix</span>
|
||||||
|
<span class="checkmark">✓</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function currentTheme() {
|
||||||
|
return localStorage.getItem('theme') ||
|
||||||
|
(window.matchMedia('(prefers-color-scheme:dark)').matches ? 'original-dark' : 'original-light');
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTheme(t) {
|
||||||
|
localStorage.setItem('theme', t);
|
||||||
|
document.documentElement.setAttribute('data-theme', t);
|
||||||
|
document.querySelectorAll('.theme-card').forEach(c => c.classList.remove('selected'));
|
||||||
|
const card = document.getElementById('card-' + t);
|
||||||
|
if (card) card.classList.add('selected');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark current selection on load
|
||||||
|
document.getElementById('card-' + currentTheme())?.classList.add('selected');
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user