updated bots,channels and supergroups lists and edit screens

This commit is contained in:
onysd 2026-08-05 21:49:30 +03:00
parent 40d49d5e97
commit 0b3c861057
21 changed files with 796 additions and 198 deletions

View file

@ -58,6 +58,7 @@ func (s *server) routes() http.Handler {
mux.Handle("GET /api/accounts/{id}/avatar", s.requireAuthAPI(http.HandlerFunc(s.handleAccountAvatarAPI)))
mux.Handle("GET /api/channels", s.requireAuthAPI(http.HandlerFunc(s.handleChannelsAPI)))
mux.Handle("GET /api/channels/{id}", s.requireAuthAPI(http.HandlerFunc(s.handleChannelDetailAPI)))
mux.Handle("GET /api/channels/{id}/avatar", s.requireAuthAPI(http.HandlerFunc(s.handleChannelAvatarAPI)))
mux.Handle("GET /api/bots", s.requireAuthAPI(http.HandlerFunc(s.handleBotsAPI)))
mux.Handle("GET /api/bots/{id}", s.requireAuthAPI(http.HandlerFunc(s.handleBotDetailAPI)))
mux.Handle("GET /api/emoji", s.requireAuthAPI(http.HandlerFunc(s.handleEmojiAPI)))
@ -100,6 +101,7 @@ func (s *server) routes() http.Handler {
mux.Handle("POST /api/actions/set-account-login-email", s.requireAuthAPI(http.HandlerFunc(s.handleSetLoginEmailAPI)))
mux.Handle("POST /api/actions/set-account-color", s.requireAuthAPI(http.HandlerFunc(s.handleSetUserColorAPI)))
mux.Handle("POST /api/actions/set-account-emoji-status", s.requireAuthAPI(http.HandlerFunc(s.handleSetUserEmojiStatusAPI)))
mux.Handle("POST /api/actions/set-channel-avatar", s.requireAuthAPI(http.HandlerFunc(s.handleSetChannelAvatarAPI)))
mux.Handle("POST /api/actions/set-channel-settings", s.requireAuthAPI(http.HandlerFunc(s.handleSetChannelSettingsAPI)))
mux.Handle("POST /api/actions/set-channel-username", s.requireAuthAPI(http.HandlerFunc(s.handleSetChannelUsernameAPI)))
mux.Handle("POST /api/actions/set-channel-color", s.requireAuthAPI(http.HandlerFunc(s.handleSetChannelColorAPI)))
@ -709,6 +711,90 @@ func (s *server) handleAccountAvatarAPI(w http.ResponseWriter, r *http.Request)
_, _ = w.Write(data)
}
// handleChannelAvatarAPI streams a channel's current avatar straight through
// from the real telesrv admin API (/v1/channels/{id}/avatar), mirroring
// handleAccountAvatarAPI.
func (s *server) handleChannelAvatarAPI(w http.ResponseWriter, r *http.Request) {
channelID, err := parseInt64(r.PathValue("id"))
if err != nil || channelID <= 0 {
http.NotFound(w, r)
return
}
apiPath := fmt.Sprintf("/v1/channels/%d/avatar", channelID)
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, s.cfg.AdminAPIURL+apiPath, nil)
if err != nil {
http.NotFound(w, r)
return
}
req.Header.Set("Authorization", "Bearer "+s.cfg.AdminAPIToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
http.NotFound(w, r)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
http.NotFound(w, r)
return
}
data, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
if err != nil || len(data) == 0 {
http.NotFound(w, r)
return
}
if contentType := resp.Header.Get("Content-Type"); contentType != "" {
w.Header().Set("Content-Type", contentType)
}
w.Header().Set("Cache-Control", "private, max-age=300")
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
w.WriteHeader(http.StatusOK)
_, _ = w.Write(data)
}
type setChannelAvatarAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
ChannelID int64 `json:"channel_id"`
}
func (s *server) handleSetChannelAvatarAPI(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
r.Body = http.MaxBytesReader(w, r.Body, admin.MaxAccountAvatarBytes+(1<<20))
if err := r.ParseMultipartForm(1 << 20); err != nil {
writeAPIError(w, http.StatusBadRequest, "invalid multipart form: "+err.Error())
return
}
if r.MultipartForm != nil {
defer r.MultipartForm.RemoveAll()
}
var body setChannelAvatarAPIRequest
dec := json.NewDecoder(strings.NewReader(r.FormValue("metadata")))
dec.DisallowUnknownFields()
if err := dec.Decode(&body); err != nil {
writeAPIError(w, http.StatusBadRequest, "invalid metadata: "+err.Error())
return
}
file, header, err := r.FormFile("file")
if err != nil {
writeAPIError(w, http.StatusBadRequest, "avatar file is required")
return
}
defer file.Close()
data, err := io.ReadAll(io.LimitReader(file, admin.MaxAccountAvatarBytes+1))
if err != nil || len(data) == 0 || int64(len(data)) > admin.MaxAccountAvatarBytes {
writeAPIError(w, http.StatusBadRequest, "avatar file is empty or too large")
return
}
req := admin.SetChannelAvatarRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-channel-avatar"),
ChannelID: body.ChannelID,
FileName: header.Filename,
}
result, err := s.callAdminMultipart(r.Context(), "/v1/channels/set-avatar", req, header.Filename, data)
writeCommandResultAPI(w, result, err)
}
func (s *server) handleBotsAPI(w http.ResponseWriter, r *http.Request) {
if s.read == nil {
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -23,7 +23,7 @@
})();
</script>
<script type="module" crossorigin src="/assets/index-CKkpOZxZ.js"></script>
<script type="module" crossorigin src="/assets/index-DZsC6sWi.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BpSP1ojC.css">
</head>
<body>

View file

@ -231,6 +231,7 @@ export const api = {
stickerDocumentAnimationURL: (documentID: string) => `/api/stickers/documents/${encodeURIComponent(documentID)}/animation`,
createStickerSet: (form: FormData) => request<CommandResult>("/api/actions/create-sticker-set", { method: "POST", body: form }),
setAccountAvatar: (form: FormData) => request<CommandResult>("/api/actions/set-account-avatar", { method: "POST", body: form }),
setChannelAvatar: (form: FormData) => request<CommandResult>("/api/actions/set-channel-avatar", { method: "POST", body: form }),
addStickerToSet: (form: FormData) => request<CommandResult>("/api/actions/add-sticker-to-set", { method: "POST", body: form }),
defaultGifts: () => request<DefaultGiftListResponse>("/api/default-gifts"),
defaultGiftAnimation: (id: number) => request<Record<string, unknown>>(`/api/default-gifts/${id}/animation`),

View file

@ -34,18 +34,29 @@ function avatarInitials(firstName: string, lastName: string, username: string):
return out.toUpperCase();
}
type AvatarKind = "user" | "channel";
// Avatar renders a user's or channel's current photo, reading through the
// admin console's proxy (/api/accounts/{id}/avatar or /api/channels/{id}/avatar).
// It falls back to a gradient-tinted initials tile — identical to the public
// preview cards — when there's no photo or the fetch fails.
export function Avatar({
userID,
firstName,
lastName,
id,
kind = "user",
firstName = "",
lastName = "",
username = "",
title = "",
size = 34,
refreshKey
}: {
userID: number;
firstName: string;
lastName: string;
id: number;
kind?: AvatarKind;
firstName?: string;
lastName?: string;
username?: string;
// title is the channel/supergroup name, used for initials when kind="channel".
title?: string;
size?: number;
// refreshKey busts the browser's cached image (Cache-Control: max-age=300)
// right after an admin-driven avatar change, so the new photo shows up
@ -56,24 +67,25 @@ export function Avatar({
useEffect(() => {
setFailed(false);
}, [userID, refreshKey]);
}, [id, kind, refreshKey]);
if (failed) {
const [from, to] = avatarGradient(userID);
const [from, to] = avatarGradient(id);
return (
<div
className="avatar-fallback"
style={{ width: size, height: size, background: `linear-gradient(135deg, ${from}, ${to})`, fontSize: Math.round(size * 0.42) }}
>
{avatarInitials(firstName, lastName, username)}
{kind === "channel" ? avatarInitials(title, "", username) : avatarInitials(firstName, lastName, username)}
</div>
);
}
const basePath = kind === "channel" ? `/api/channels/${id}/avatar` : `/api/accounts/${id}/avatar`;
return (
<img
className="avatar-photo-img"
src={`/api/accounts/${userID}/avatar${refreshKey !== undefined ? `?v=${encodeURIComponent(String(refreshKey))}` : ""}`}
src={`${basePath}${refreshKey !== undefined ? `?v=${encodeURIComponent(String(refreshKey))}` : ""}`}
alt=""
loading="lazy"
style={{ width: size, height: size }}

View file

@ -4,11 +4,13 @@ import { createPortal } from "react-dom";
import { api, errorMessage } from "../api";
import { Alert } from "./ui";
// AccountAvatarModal uploads a new profile photo for an account. It follows
// CreateStickerSetModal's shape (a single reason field + direct execute,
// rather than ActionButton's JSON dry-run/confirm flow) since the payload is
// a multipart file upload, not a plain JSON body.
export function AccountAvatarModal({ userID, onClose, onDone }: { userID: number; onClose: () => void; onDone: () => void }) {
type AvatarModalKind = "user" | "channel";
// AvatarModal uploads a new photo for an account or a channel/supergroup. It
// follows CreateStickerSetModal's shape (a single reason field + direct
// execute, rather than ActionButton's JSON dry-run/confirm flow) since the
// payload is a multipart file upload, not a plain JSON body.
export function AvatarModal({ kind, id, onClose, onDone }: { kind: AvatarModalKind; id: number; onClose: () => void; onDone: () => void }) {
const [file, setFile] = useState<File | null>(null);
const [previewURL, setPreviewURL] = useState("");
const [reason, setReason] = useState("");
@ -37,10 +39,11 @@ export function AccountAvatarModal({ userID, onClose, onDone }: { userID: number
setBusy(true);
setError("");
try {
const idField = kind === "channel" ? "channel_id" : "user_id";
const form = new FormData();
form.set("metadata", JSON.stringify({ command_id: "", reason: reason.trim(), confirm: true, user_id: userID }));
form.set("metadata", JSON.stringify({ command_id: "", reason: reason.trim(), confirm: true, [idField]: id }));
form.set("file", file, file.name);
const result = await api.setAccountAvatar(form);
const result = kind === "channel" ? await api.setChannelAvatar(form) : await api.setAccountAvatar(form);
if (result.error) {
setError(result.error);
return;
@ -54,12 +57,14 @@ export function AccountAvatarModal({ userID, onClose, onDone }: { userID: number
}
}
const noun = kind === "channel" ? "Channel" : "Account";
return createPortal(
<div className="modal-backdrop" role="presentation">
<section className="modal command-modal" role="dialog" aria-modal="true" aria-label={"Change avatar"}>
<div className="modal-head">
<div>
<div className="eyebrow">{"Account"}</div>
<div className="eyebrow">{noun}</div>
<h2>{"Change avatar"}</h2>
</div>
<button className="icon-btn" type="button" onClick={onClose} disabled={busy} aria-label={"Close"}><X size={15} /></button>

View file

@ -1,7 +1,7 @@
import { ArrowLeft, BadgeCheck, CircleAlert, ImagePlus, MonitorSmartphone, ScrollText, Settings2, Sparkles, Star, UserRound } from "lucide-react";
import { useEffect, useState, type ReactNode } from "react";
import { api, errorMessage } from "../api";
import { AccountAvatarModal } from "../components/AccountAvatarModal";
import { AvatarModal } from "../components/AvatarModal";
import { ActionButton } from "../components/ActionButton";
import { Avatar } from "../components/Avatar";
import { AuthorizationTable } from "../components/AuthorizationTable";
@ -74,7 +74,7 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
<section className="entity-head">
<div className="entity-head-main">
<div className="avatar-edit-slot">
<Avatar userID={account.ID} firstName={account.FirstName} lastName={account.LastName} username={account.Username} size={64} refreshKey={avatarVersion || undefined} />
<Avatar id={account.ID} firstName={account.FirstName} lastName={account.LastName} username={account.Username} size={64} refreshKey={avatarVersion || undefined} />
<button
className="icon-btn avatar-edit-btn"
type="button"
@ -305,8 +305,9 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
)}
{avatarModalOpen && (
<AccountAvatarModal
userID={account.ID}
<AvatarModal
kind="user"
id={account.ID}
onClose={() => setAvatarModalOpen(false)}
onDone={() => {
setAvatarVersion((v) => v + 1);

View file

@ -166,7 +166,7 @@ export function AccountsPage({ navigate }: { navigate: Navigate }) {
<tbody>
{data?.rows.map((row) => (
<tr key={row.ID}>
<td className="avatar-col"><Avatar userID={row.ID} firstName={row.FirstName} lastName={row.LastName} username={row.Username} /></td>
<td className="avatar-col"><Avatar id={row.ID} firstName={row.FirstName} lastName={row.LastName} username={row.Username} /></td>
<td className="mono">{row.ID}</td>
<td>{displayPhone(row.Phone)}</td>
<td><UsernameCell username={row.Username} collectibles={row.Collectibles} /></td>

View file

@ -1,18 +1,25 @@
import { ArrowLeft, BadgeCheck, Trash2 } from "lucide-react";
import { useEffect, useState } from "react";
import { ArrowLeft, BadgeCheck, ImagePlus, ScrollText, Settings2, Trash2, UserRound } from "lucide-react";
import { useEffect, useState, type ReactNode } from "react";
import { api, errorMessage } from "../api";
import { ActionButton } from "../components/ActionButton";
import { Alert, AuditTable, Badge, LoadingSurface, PageFrame, SectionHead, SplitLayout, Summary } from "../components/ui";
import { Avatar } from "../components/Avatar";
import { AvatarModal } from "../components/AvatarModal";
import { Alert, AuditTable, Badge, LoadingSurface, PageFrame, SectionHead, Summary } from "../components/ui";
import { ScamFakeActions, ScamFakeBadges } from "../components/flags";
import { ColorAction, EmojiStatusAction, UsernameAction } from "../components/attributes";
import { displayUsername, formatDate } from "../lib/format";
import type { Navigate } from "../routing";
import type { BotDetail } from "../types";
type Tab = "profile" | "actions";
export function BotDetailPage({ id, navigate }: { id: number; navigate: Navigate }) {
const [detail, setDetail] = useState<BotDetail | null>(null);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const [tab, setTab] = useState<Tab>("profile");
const [avatarModalOpen, setAvatarModalOpen] = useState(false);
const [avatarVersion, setAvatarVersion] = useState(0);
async function load() {
setBusy(true);
@ -28,6 +35,8 @@ export function BotDetailPage({ id, navigate }: { id: number; navigate: Navigate
useEffect(() => {
void load();
setTab("profile");
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id]);
if (error) {
@ -38,77 +47,142 @@ export function BotDetailPage({ id, navigate }: { id: number; navigate: Navigate
}
const bot = detail.Bot;
const tabs: Array<{ key: Tab; label: string; icon: ReactNode }> = [
{ key: "profile", label: "Profile & Status", icon: <UserRound size={15} /> },
{ key: "actions", label: "Actions & Management", icon: <Settings2 size={15} /> }
];
return (
<PageFrame
title={`Bot #${bot.ID}`}
eyebrow={"Bot Profile"}
actions={<button className="btn icon-text" onClick={() => navigate("/bots")}><ArrowLeft size={15} /> {"Back to list"}</button>}
>
<SplitLayout
main={
<div className="stacked-sections">
<section className="entity-head">
<div>
<div className="entity-title">{bot.FirstName || "Unnamed bot"}</div>
<div className="entity-subtitle">{displayUsername(bot.Username) || "No username"}</div>
</div>
<div className="entity-badges">
<Badge tone={bot.System ? "warn" : "neutral"}>{bot.System ? "System" : "User"}</Badge>
{bot.Verified ? <Badge tone="good">{"Verified"}</Badge> : <Badge>{"Not verified"}</Badge>}
<ScamFakeBadges scam={bot.Scam} fake={bot.Fake} />
</div>
</section>
<div className="summary-grid">
<Summary label={"Bot ID"} value={String(bot.ID)} mono />
<Summary label={"Owner"} value={bot.OwnerUserID > 0 ? `${bot.OwnerUserID} ${displayUsername(detail.OwnerUsername)}`.trim() : "None"} />
<Summary label={"Type"} value={bot.System ? "System" : "User"} />
<Summary label={"Updated"} value={formatDate(bot.UpdatedAt) || "-"} />
<Summary label={"Created"} value={formatDate(bot.CreatedAt) || "-"} />
</div>
{detail.About && <p className="about-text">{detail.About}</p>}
{detail.Description && detail.Description.trim() !== detail.About.trim() && <p className="about-text">{detail.Description}</p>}
<section className="entity-head">
<div className="entity-head-main">
<div className="avatar-edit-slot">
<Avatar id={bot.ID} firstName={bot.FirstName} username={bot.Username} size={64} refreshKey={avatarVersion || undefined} />
<button
className="icon-btn avatar-edit-btn"
type="button"
aria-label={"Change avatar"}
title={"Change avatar"}
onClick={() => setAvatarModalOpen(true)}
>
<ImagePlus size={13} />
</button>
</div>
<div>
<div className="entity-title">{bot.FirstName || "Unnamed bot"}</div>
<div className="entity-subtitle">{displayUsername(bot.Username) || "No username"}</div>
</div>
</div>
<div className="entity-badges">
<Badge tone={bot.System ? "warn" : "neutral"}>{bot.System ? "System" : "User"}</Badge>
{bot.Verified ? <Badge tone="good">{"Verified"}</Badge> : <Badge>{"Not verified"}</Badge>}
<ScamFakeBadges scam={bot.Scam} fake={bot.Fake} />
</div>
</section>
<div className="toolbar" role="group" aria-label={"Bot sections"}>
{tabs.map((item) => (
<button
key={item.key}
className={`btn icon-text ${tab === item.key ? "primary" : ""}`}
type="button"
aria-pressed={tab === item.key}
onClick={() => setTab(item.key)}
>
{item.icon} {item.label}
</button>
))}
</div>
{tab === "profile" && (
<div className="stacked-sections">
<div className="summary-grid">
<Summary label={"Bot ID"} value={String(bot.ID)} mono />
<Summary label={"Owner"} value={bot.OwnerUserID > 0 ? `${bot.OwnerUserID} ${displayUsername(detail.OwnerUsername)}`.trim() : "None"} />
<Summary label={"Type"} value={bot.System ? "System" : "User"} />
<Summary label={"Updated"} value={formatDate(bot.UpdatedAt) || "-"} />
<Summary label={"Created"} value={formatDate(bot.CreatedAt) || "-"} />
</div>
{detail.About && <p className="about-text">{detail.About}</p>}
{detail.Description && detail.Description.trim() !== detail.About.trim() && <p className="about-text">{detail.Description}</p>}
</div>
)}
{tab === "actions" && (
<div className="stacked-sections">
<div className="action-groups">
<section className="section-block">
<SectionHead title={"Recent Admin Actions"} text={"Last 30 audit rows"} />
<AuditTable rows={detail.AuditLogs} />
<SectionHead title={"Verification & Moderation Flags"} />
<div className="action-stack">
<ActionButton
label={bot.Verified ? "Clear verified" : "Set verified"}
icon={<BadgeCheck size={15} />}
tone="neutral"
path="/api/actions/set-verified"
payload={() => ({ user_id: bot.ID, verified: !bot.Verified })}
onDone={load}
/>
</div>
<ScamFakeActions idKey="user_id" id={bot.ID} path="/api/actions/set-account-flags" scam={bot.Scam} fake={bot.Fake} onDone={load} />
</section>
<section className="section-block">
<SectionHead title={"Username"} />
<UsernameAction idKey="user_id" id={bot.ID} path="/api/actions/set-account-username" current={bot.Username} onDone={load} />
</section>
<section className="section-block">
<SectionHead title={"Profile Color"} />
<ColorAction idKey="user_id" id={bot.ID} path="/api/actions/set-account-color" onDone={load} />
</section>
<section className="section-block">
<SectionHead title={"Emoji Status"} />
<EmojiStatusAction idKey="user_id" id={bot.ID} path="/api/actions/set-account-emoji-status" onDone={load} />
</section>
<section className="section-block">
<SectionHead title={"Danger Zone"} />
{bot.System ? (
<p className="bot-create-note">{"System bots are built in and cannot be deleted."}</p>
) : (
<div className="danger-zone">
<ActionButton
label={"Delete bot"}
icon={<Trash2 size={15} />}
tone="danger"
path="/api/actions/delete-bot"
payload={() => ({ bot_user_id: bot.ID })}
onDone={() => navigate("/bots")}
/>
<p className="bot-create-note">{"Permanently deletes this user-created bot and invalidates its token. This cannot be undone."}</p>
</div>
)}
</section>
</div>
}
side={
<section className="action-dock">
<div className="dock-title">{"Bot Actions"}</div>
<div className="action-stack">
<ActionButton
label={bot.Verified ? "Clear verified" : "Set verified"}
icon={<BadgeCheck size={15} />}
tone="neutral"
path="/api/actions/set-verified"
payload={() => ({ user_id: bot.ID, verified: !bot.Verified })}
onDone={load}
/>
</div>
<ScamFakeActions idKey="user_id" id={bot.ID} path="/api/actions/set-account-flags" scam={bot.Scam} fake={bot.Fake} onDone={load} />
<div className="dock-title">{"Attributes"}</div>
<UsernameAction idKey="user_id" id={bot.ID} path="/api/actions/set-account-username" current={bot.Username} onDone={load} />
<ColorAction idKey="user_id" id={bot.ID} path="/api/actions/set-account-color" onDone={load} />
<EmojiStatusAction idKey="user_id" id={bot.ID} path="/api/actions/set-account-emoji-status" onDone={load} />
{bot.System ? (
<p className="bot-create-note">{"System bots are built in and cannot be deleted."}</p>
) : (
<div className="danger-zone">
<ActionButton
label={"Delete bot"}
icon={<Trash2 size={15} />}
tone="danger"
path="/api/actions/delete-bot"
payload={() => ({ bot_user_id: bot.ID })}
onDone={() => navigate("/bots")}
/>
<p className="bot-create-note">{"Permanently deletes this user-created bot and invalidates its token. This cannot be undone."}</p>
</div>
)}
<section className="section-block">
<SectionHead title={"Recent Admin Actions"} text={"Last 30 audit rows"} action={<ScrollText size={16} />} />
<AuditTable rows={detail.AuditLogs} />
</section>
}
/>
</div>
)}
{avatarModalOpen && (
<AvatarModal
kind="user"
id={bot.ID}
onClose={() => setAvatarModalOpen(false)}
onDone={() => {
setAvatarVersion((v) => v + 1);
void load();
}}
/>
)}
</PageFrame>
);
}

View file

@ -1,18 +1,28 @@
import { BadgeCheck, Bot, ChevronRight, Loader2, Plus, RefreshCw, Search } from "lucide-react";
import { BadgeCheck, Bot, ChevronLeft, ChevronRight, Loader2, Plus, RefreshCw, Search } from "lucide-react";
import { useEffect, useState } from "react";
import { api, errorMessage } from "../api";
import { ActionButton } from "../components/ActionButton";
import { Avatar } from "../components/Avatar";
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
import { ScamFakeBadges } from "../components/flags";
import { displayUsername, formatDate, toInt } from "../lib/format";
import type { Navigate } from "../routing";
import type { BotListResponse } from "../types";
type Cursor = { beforeID: number };
type BotPageSize = 10 | 20 | 50 | 100;
const zeroCursor: Cursor = { beforeID: 0 };
export function BotsPage({ navigate }: { navigate: Navigate }) {
const [q, setQ] = useState("");
const [limit, setLimit] = useState("50");
const [limit, setLimit] = useState<BotPageSize>(50);
const [data, setData] = useState<BotListResponse | null>(null);
const [cursor, setCursor] = useState(0);
// history holds the cursor used to reach every page before the current
// one, so "Previous" can pop back without re-deriving offsets -- keyset
// pagination has no notion of "page N" to jump back to otherwise.
const [history, setHistory] = useState<Cursor[]>([]);
const [cursor, setCursor] = useState<Cursor>(zeroCursor);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
@ -20,40 +30,71 @@ export function BotsPage({ navigate }: { navigate: Navigate }) {
const [botName, setBotName] = useState("");
const [botUsername, setBotUsername] = useState("");
async function load(next = false) {
async function fetchPage(query: string, at: Cursor) {
setBusy(true);
setError("");
const params = new URLSearchParams({ limit });
if (q.trim()) {
params.set("q", q.trim());
} else if (next) {
params.set("before_id", String(cursor));
const params = new URLSearchParams({ limit: String(limit) });
if (query.trim()) {
params.set("q", query.trim());
}
if (at.beforeID) {
params.set("before_id", String(at.beforeID));
}
try {
const result = await api.bots(params);
setData(result);
setCursor(result.next_before_id);
return result;
} catch (err) {
setError(errorMessage(err));
return null;
} finally {
setBusy(false);
}
}
async function loadFresh() {
setHistory([]);
setCursor(zeroCursor);
await fetchPage(q, zeroCursor);
}
async function loadNext() {
if (!data?.has_more) return;
const at = { beforeID: data.next_before_id };
const result = await fetchPage(q, at);
if (result) {
setHistory((prev) => [...prev, cursor]);
setCursor(at);
}
}
async function loadPrev() {
if (history.length === 0) return;
const at = history[history.length - 1];
const result = await fetchPage(q, at);
if (result) {
setHistory((prev) => prev.slice(0, -1));
setCursor(at);
}
}
useEffect(() => {
void load(false);
void loadFresh();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const rows = data?.rows ?? [];
const verified = rows.filter((row) => row.Verified).length;
const systemCount = rows.filter((row) => row.System).length;
const canGoPrev = history.length > 0 && !busy;
const canGoNext = Boolean(data?.has_more) && !busy;
return (
<PageFrame
title={"Bots"}
eyebrow={data?.listing === false ? "Search results" : "Recently created bots"}
actions={
<button className="btn" type="button" onClick={() => load(false)} disabled={busy}>
<button className="btn" type="button" onClick={() => void loadFresh()} disabled={busy}>
<RefreshCw size={15} /> {"Refresh"}
</button>
}
@ -104,29 +145,35 @@ export function BotsPage({ navigate }: { navigate: Navigate }) {
name: botName.trim(),
username: botUsername.trim().replace(/^@/, "")
})}
onDone={() => load(false)}
onDone={() => void loadFresh()}
/>
</div>
</section>
<QueryPanel>
<form className="toolbar" onSubmit={(event) => { event.preventDefault(); void load(false); }}>
<form className="toolbar" onSubmit={(event) => { event.preventDefault(); void loadFresh(); }}>
<label className="searchbox">
<Search size={15} />
<input value={q} onChange={(event) => setQ(event.target.value)} placeholder={"Bot ID / username"} />
</label>
<label className="field-inline">
<label className="gift-page-size">
<span>{"Limit"}</span>
<input className="small-input" value={limit} onChange={(event) => setLimit(event.target.value)} type="number" min="1" max="100" />
<select value={String(limit)} onChange={(event) => setLimit(Number(event.target.value) as BotPageSize)}>
<option value="10">10</option>
<option value="20">20</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</label>
<button className="btn primary icon-text" type="submit" disabled={busy}>
{busy ? <Loader2 size={15} className="spin" /> : <Search size={15} />} {"Search"}
</button>
{data?.listing && data.has_more && (
<button className="btn icon-text" type="button" onClick={() => load(true)} disabled={busy}>
<ChevronRight size={15} /> {"Next page"}
</button>
)}
<button className="btn icon-text" type="button" onClick={() => void loadPrev()} disabled={!canGoPrev}>
<ChevronLeft size={15} /> {"Previous page"}
</button>
<button className="btn icon-text" type="button" onClick={() => void loadNext()} disabled={!canGoNext}>
<ChevronRight size={15} /> {"Next page"}
</button>
</form>
</QueryPanel>
@ -134,6 +181,7 @@ export function BotsPage({ navigate }: { navigate: Navigate }) {
<table className="data-table">
<thead>
<tr>
<th className="avatar-col"></th>
<th>{"Bot ID"}</th>
<th>{"Username"}</th>
<th>{"Name"}</th>
@ -147,6 +195,7 @@ export function BotsPage({ navigate }: { navigate: Navigate }) {
<tbody>
{rows.map((row) => (
<tr key={row.ID}>
<td className="avatar-col"><Avatar id={row.ID} firstName={row.FirstName} username={row.Username} /></td>
<td className="mono">{row.ID}</td>
<td>{displayUsername(row.Username) || "-"}</td>
<td>{row.FirstName || "-"}</td>
@ -157,7 +206,7 @@ export function BotsPage({ navigate }: { navigate: Navigate }) {
<td><button className="row-link" onClick={() => navigate(`/bots/${row.ID}`)}><Bot size={14} /> {"Details"} <ChevronRight size={14} /></button></td>
</tr>
))}
{rows.length === 0 && <EmptyRow colSpan={8} />}
{rows.length === 0 && <EmptyRow colSpan={9} />}
</tbody>
</table>
</div>

View file

@ -1,102 +1,181 @@
import { ArrowLeft, BadgeCheck } from "lucide-react";
import { useEffect, useState } from "react";
import { ArrowLeft, BadgeCheck, ImagePlus, ScrollText, Settings2, UserRound } from "lucide-react";
import { useEffect, useState, type ReactNode } from "react";
import { api, errorMessage } from "../api";
import { ActionButton } from "../components/ActionButton";
import { Alert, AuditTable, Badge, JsonBlock, LoadingSurface, PageFrame, SectionHead, SplitLayout, Summary } from "../components/ui";
import { Avatar } from "../components/Avatar";
import { AvatarModal } from "../components/AvatarModal";
import { Alert, AuditTable, Badge, JsonBlock, LoadingSurface, PageFrame, SectionHead, Summary } from "../components/ui";
import { ScamFakeActions, ScamFakeBadges } from "../components/flags";
import { ChannelSettingsAction, ColorAction, EmojiStatusAction, UsernameAction } from "../components/attributes";
import { channelKind, displayUsername, formatDate, formatUnix } from "../lib/format";
import type { Navigate } from "../routing";
import type { ChannelDetail } from "../types";
type Tab = "profile" | "actions";
export function ChannelDetailPage({ id, navigate }: { id: number; navigate: Navigate }) {
const [detail, setDetail] = useState<ChannelDetail | null>(null);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const [tab, setTab] = useState<Tab>("profile");
const [avatarModalOpen, setAvatarModalOpen] = useState(false);
const [avatarVersion, setAvatarVersion] = useState(0);
async function load() {
setBusy(true);
setError("");
try {
setDetail(await api.channel(id));
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
useEffect(() => {
void load();
setTab("profile");
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id]);
if (error) {
return <Alert>{error}</Alert>;
}
if (!detail) {
return <LoadingSurface label={"Loading channel detail"} />;
return <LoadingSurface label={busy ? "Loading channel detail" : "Waiting for data"} />;
}
const ch = detail.Channel;
const tabs: Array<{ key: Tab; label: string; icon: ReactNode }> = [
{ key: "profile", label: "Profile & Status", icon: <UserRound size={15} /> },
{ key: "actions", label: "Actions & Management", icon: <Settings2 size={15} /> }
];
return (
<PageFrame
title={`${channelKind(ch)} #${ch.ID}`}
eyebrow={"Channel Profile"}
actions={<button className="btn icon-text" onClick={() => navigate("/channels")}><ArrowLeft size={15} /> {"Back to list"}</button>}
>
<SplitLayout
main={
<div className="stacked-sections">
<section className="entity-head">
<div>
<div className="entity-title">{ch.Title || "-"}</div>
<div className="entity-subtitle">{displayUsername(ch.Username) || "No username"} · {`Creator ${ch.CreatorUserID}`}</div>
</div>
<div className="entity-badges">
<Badge>{channelKind(ch)}</Badge>
{ch.Verified ? <Badge tone="good">{"Verified"}</Badge> : <Badge>{"Not verified"}</Badge>}
<ScamFakeBadges scam={ch.Scam} fake={ch.Fake} />
{ch.Deleted ? <Badge tone="danger">{"Deleted"}</Badge> : <Badge>{"Valid"}</Badge>}
<section className="entity-head">
<div className="entity-head-main">
<div className="avatar-edit-slot">
<Avatar id={ch.ID} kind="channel" title={ch.Title} size={64} refreshKey={avatarVersion || undefined} />
<button
className="icon-btn avatar-edit-btn"
type="button"
aria-label={"Change avatar"}
title={"Change avatar"}
onClick={() => setAvatarModalOpen(true)}
>
<ImagePlus size={13} />
</button>
</div>
<div>
<div className="entity-title">{ch.Title || "-"}</div>
<div className="entity-subtitle">{displayUsername(ch.Username) || "No username"} · {`Creator ${ch.CreatorUserID}`}</div>
</div>
</div>
<div className="entity-badges">
<Badge>{channelKind(ch)}</Badge>
{ch.Verified ? <Badge tone="good">{"Verified"}</Badge> : <Badge>{"Not verified"}</Badge>}
<ScamFakeBadges scam={ch.Scam} fake={ch.Fake} />
{ch.Deleted ? <Badge tone="danger">{"Deleted"}</Badge> : <Badge>{"Valid"}</Badge>}
</div>
</section>
<div className="toolbar" role="group" aria-label={"Channel sections"}>
{tabs.map((item) => (
<button
key={item.key}
className={`btn icon-text ${tab === item.key ? "primary" : ""}`}
type="button"
aria-pressed={tab === item.key}
onClick={() => setTab(item.key)}
>
{item.icon} {item.label}
</button>
))}
</div>
{tab === "profile" && (
<div className="stacked-sections">
<div className="summary-grid">
<Summary label={"Channel ID"} value={String(ch.ID)} mono />
<Summary label="access_hash" value={String(ch.AccessHash)} mono />
<Summary label={"Members"} value={`${ch.ParticipantsCount} / ${"Admins"} ${ch.AdminsCount}`} />
<Summary label={"Moderation"} value={`Banned ${ch.BannedCount} / Kicked ${ch.KickedCount}`} />
<Summary label={"Channel flags"} value={`broadcast=${ch.Broadcast} megagroup=${ch.Megagroup} forum=${ch.Forum}`} />
<Summary label="top / pinned / PTS" value={`${ch.TopMessageID} / ${ch.PinnedMessageID} / ${ch.PTS}`} />
<Summary label={"Created"} value={formatUnix(ch.Date) || "-"} />
<Summary label={"Updated"} value={formatDate(ch.UpdatedAt) || "-"} />
</div>
{ch.About && <p className="about-text">{ch.About}</p>}
<section className="section-block">
<SectionHead title={"Channel Raw Row"} text={"Database read-only snapshot"} />
<JsonBlock value={detail.ChannelJSON} />
</section>
</div>
)}
{tab === "actions" && (
<div className="stacked-sections">
<div className="action-groups">
<section className="section-block">
<SectionHead title={"Verification & Moderation Flags"} />
<div className="action-stack">
<ActionButton
label={ch.Verified ? "Clear verified" : "Set verified"}
icon={<BadgeCheck size={15} />}
tone="warn"
path="/api/actions/set-channel-verified"
payload={() => ({ channel_id: ch.ID, verified: !ch.Verified })}
onDone={load}
/>
<ScamFakeActions idKey="channel_id" id={ch.ID} path="/api/actions/set-channel-flags" scam={ch.Scam} fake={ch.Fake} onDone={load} />
</div>
</section>
<div className="summary-grid">
<Summary label={"Channel ID"} value={String(ch.ID)} mono />
<Summary label="access_hash" value={String(ch.AccessHash)} mono />
<Summary label={"Members"} value={`${ch.ParticipantsCount} / ${"Admins"} ${ch.AdminsCount}`} />
<Summary label={"Moderation"} value={`Banned ${ch.BannedCount} / Kicked ${ch.KickedCount}`} />
<Summary label={"Channel flags"} value={`broadcast=${ch.Broadcast} megagroup=${ch.Megagroup} forum=${ch.Forum}`} />
<Summary label="top / pinned / PTS" value={`${ch.TopMessageID} / ${ch.PinnedMessageID} / ${ch.PTS}`} />
<Summary label={"Created"} value={formatUnix(ch.Date) || "-"} />
<Summary label={"Updated"} value={formatDate(ch.UpdatedAt) || "-"} />
</div>
{ch.About && <p className="about-text">{ch.About}</p>}
<section className="section-block">
<SectionHead title={"Recent Admin Actions"} text={"Last 30 audit rows"} />
<AuditTable rows={detail.AuditLogs} />
<SectionHead title={"Settings"} />
<ChannelSettingsAction channel={ch} onDone={load} />
</section>
<section className="section-block">
<SectionHead title={"Channel Raw Row"} text={"Database read-only snapshot"} />
<JsonBlock value={detail.ChannelJSON} />
<SectionHead title={"Username"} />
<UsernameAction idKey="channel_id" id={ch.ID} path="/api/actions/set-channel-username" current={ch.Username} onDone={load} />
</section>
<section className="section-block">
<SectionHead title={"Profile Color"} />
<ColorAction idKey="channel_id" id={ch.ID} path="/api/actions/set-channel-color" onDone={load} />
</section>
<section className="section-block">
<SectionHead title={"Emoji Status"} />
<EmojiStatusAction idKey="channel_id" id={ch.ID} path="/api/actions/set-channel-emoji-status" onDone={load} />
</section>
</div>
}
side={
<section className="action-dock">
<div className="dock-title">{"Channel Actions"}</div>
<ActionButton
label={ch.Verified ? "Clear verified" : "Set verified"}
icon={<BadgeCheck size={15} />}
tone="warn"
path="/api/actions/set-channel-verified"
payload={() => ({ channel_id: ch.ID, verified: !ch.Verified })}
onDone={load}
/>
<ScamFakeActions idKey="channel_id" id={ch.ID} path="/api/actions/set-channel-flags" scam={ch.Scam} fake={ch.Fake} onDone={load} />
<div className="dock-title">{"Settings"}</div>
<ChannelSettingsAction channel={ch} onDone={load} />
<div className="dock-title">{"Attributes"}</div>
<UsernameAction idKey="channel_id" id={ch.ID} path="/api/actions/set-channel-username" current={ch.Username} onDone={load} />
<ColorAction idKey="channel_id" id={ch.ID} path="/api/actions/set-channel-color" onDone={load} />
<EmojiStatusAction idKey="channel_id" id={ch.ID} path="/api/actions/set-channel-emoji-status" onDone={load} />
<section className="section-block">
<SectionHead title={"Recent Admin Actions"} text={"Last 30 audit rows"} action={<ScrollText size={16} />} />
<AuditTable rows={detail.AuditLogs} />
</section>
}
/>
</div>
)}
{avatarModalOpen && (
<AvatarModal
kind="channel"
id={ch.ID}
onClose={() => setAvatarModalOpen(false)}
onDone={() => {
setAvatarVersion((v) => v + 1);
void load();
}}
/>
)}
</PageFrame>
);
}

View file

@ -1,6 +1,7 @@
import { ChevronRight, Loader2, RefreshCw, Search } from "lucide-react";
import { ChevronLeft, ChevronRight, Loader2, RefreshCw, Search } from "lucide-react";
import { useEffect, useState } from "react";
import { api, errorMessage } from "../api";
import { Avatar } from "../components/Avatar";
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
import { ScamFakeBadges } from "../components/flags";
import { channelKind, displayUsername, formatDate } from "../lib/format";
@ -8,50 +9,87 @@ import { channelMetrics } from "../lib/metrics";
import type { Navigate } from "../routing";
import type { ChannelListResponse } from "../types";
type Cursor = { beforeID: number; beforeUpdatedUS: number };
type ChannelPageSize = 10 | 20 | 50 | 100;
const zeroCursor: Cursor = { beforeID: 0, beforeUpdatedUS: 0 };
export function ChannelsPage({ navigate }: { navigate: Navigate }) {
const [q, setQ] = useState("");
const [limit, setLimit] = useState("50");
const [limit, setLimit] = useState<ChannelPageSize>(50);
const [data, setData] = useState<ChannelListResponse | null>(null);
const [cursor, setCursor] = useState({ beforeID: 0, beforeUpdatedUS: 0 });
// history holds the cursor used to reach every page before the current
// one, so "Previous" can pop back without re-deriving offsets -- keyset
// pagination has no notion of "page N" to jump back to otherwise.
const [history, setHistory] = useState<Cursor[]>([]);
const [cursor, setCursor] = useState<Cursor>(zeroCursor);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
async function load(next = false) {
async function fetchPage(query: string, at: Cursor) {
setBusy(true);
setError("");
const params = new URLSearchParams({ limit });
if (q.trim()) {
params.set("q", q.trim());
} else if (next) {
params.set("before_id", String(cursor.beforeID));
params.set("before_updated_us", String(cursor.beforeUpdatedUS));
const params = new URLSearchParams({ limit: String(limit) });
if (query.trim()) {
params.set("q", query.trim());
}
if (at.beforeID || at.beforeUpdatedUS) {
params.set("before_id", String(at.beforeID));
params.set("before_updated_us", String(at.beforeUpdatedUS));
}
try {
const result = await api.channels(params);
setData(result);
setCursor({
beforeID: result.next_before_id,
beforeUpdatedUS: result.next_before_updated_us
});
return result;
} catch (err) {
setError(errorMessage(err));
return null;
} finally {
setBusy(false);
}
}
async function loadFresh() {
setHistory([]);
setCursor(zeroCursor);
await fetchPage(q, zeroCursor);
}
async function loadNext() {
if (!data?.has_more) return;
const at = { beforeID: data.next_before_id, beforeUpdatedUS: data.next_before_updated_us };
const result = await fetchPage(q, at);
if (result) {
setHistory((prev) => [...prev, cursor]);
setCursor(at);
}
}
async function loadPrev() {
if (history.length === 0) return;
const at = history[history.length - 1];
const result = await fetchPage(q, at);
if (result) {
setHistory((prev) => prev.slice(0, -1));
setCursor(at);
}
}
useEffect(() => {
void load(false);
void loadFresh();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const metrics = channelMetrics(data?.rows ?? []);
const canGoPrev = history.length > 0 && !busy;
const canGoNext = Boolean(data?.has_more) && !busy;
return (
<PageFrame
title={"Supergroups and Channels"}
eyebrow={data?.listing === false ? "Search results" : "Recently updated"}
actions={
<button className="btn" type="button" onClick={() => load(false)} disabled={busy}>
<button className="btn" type="button" onClick={() => void loadFresh()} disabled={busy}>
<RefreshCw size={15} /> {"Refresh"}
</button>
}
@ -64,29 +102,36 @@ export function ChannelsPage({ navigate }: { navigate: Navigate }) {
<Metric label={"Verified"} value={String(metrics.verified)} tone="good" />
</div>
<QueryPanel>
<form className="toolbar" onSubmit={(event) => { event.preventDefault(); void load(false); }}>
<form className="toolbar" onSubmit={(event) => { event.preventDefault(); void loadFresh(); }}>
<label className="searchbox">
<Search size={15} />
<input value={q} onChange={(event) => setQ(event.target.value)} placeholder={"Channel ID / username / title"} />
</label>
<label className="field-inline">
<label className="gift-page-size">
<span>{"Limit"}</span>
<input className="small-input" value={limit} onChange={(event) => setLimit(event.target.value)} type="number" min="1" max="100" />
<select value={String(limit)} onChange={(event) => setLimit(Number(event.target.value) as ChannelPageSize)}>
<option value="10">10</option>
<option value="20">20</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</label>
<button className="btn primary icon-text" type="submit" disabled={busy}>
{busy ? <Loader2 size={15} className="spin" /> : <Search size={15} />} {"Search"}
</button>
{data?.listing && data.has_more && (
<button className="btn icon-text" type="button" onClick={() => load(true)} disabled={busy}>
<ChevronRight size={15} /> {"Next page"}
</button>
)}
<button className="btn icon-text" type="button" onClick={() => void loadPrev()} disabled={!canGoPrev}>
<ChevronLeft size={15} /> {"Previous page"}
</button>
<button className="btn icon-text" type="button" onClick={() => void loadNext()} disabled={!canGoNext}>
<ChevronRight size={15} /> {"Next page"}
</button>
</form>
</QueryPanel>
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th className="avatar-col"></th>
<th>{"Channel ID"}</th>
<th>{"Kind"}</th>
<th>{"Username"}</th>
@ -102,6 +147,7 @@ export function ChannelsPage({ navigate }: { navigate: Navigate }) {
<tbody>
{data?.rows.map((row) => (
<tr key={row.ID}>
<td className="avatar-col"><Avatar id={row.ID} kind="channel" title={row.Title} /></td>
<td className="mono">{row.ID}</td>
<td>{channelKind(row)}</td>
<td>{displayUsername(row.Username)}</td>
@ -114,7 +160,7 @@ export function ChannelsPage({ navigate }: { navigate: Navigate }) {
<td><button className="row-link" onClick={() => navigate(`/channels/${row.ID}`)}>{"Details"} <ChevronRight size={14} /></button></td>
</tr>
))}
{(!data || data.rows.length === 0) && <EmptyRow colSpan={10} />}
{(!data || data.rows.length === 0) && <EmptyRow colSpan={11} />}
</tbody>
</table>
</div>