Merge remote-tracking branch 'upstream/main' into merge-gramsrv-9106877

This commit is contained in:
onysd 2026-08-03 23:29:20 +03:00
commit ac6a50c5ff
697 changed files with 100880 additions and 8052 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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,8 +23,8 @@
})();
</script>
<script type="module" crossorigin src="/assets/index-kK52bvQu.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DjPaQXsn.css">
<script type="module" crossorigin src="/assets/index-65rEwtSD.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CKoIcj6p.css">
</head>
<body>
<div id="root"></div>

View file

@ -758,9 +758,9 @@
}
},
"node_modules/nanoid": {
"version": "3.3.15",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
"integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
"version": "3.3.16",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
"dev": true,
"funding": [
{
@ -797,9 +797,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.16",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
"integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==",
"version": "8.5.23",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
"integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==",
"dev": true,
"funding": [
{
@ -817,7 +817,7 @@
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.12",
"nanoid": "^3.3.16",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},

View file

@ -1,12 +1,16 @@
import { useEffect, useState } from "react";
import { api, APIError } from "./api";
import { api } from "./api";
import { BootScreen, Shell } from "./components/Layout";
import { LoginPage } from "./pages/LoginPage";
import { PermissionsProvider } from "./permissions";
import { Routes } from "./pages/Routes";
import { currentRoute, type RouteState } from "./routing";
import type { AdminSession } from "./types";
export function App() {
const [actor, setActor] = useState<string | null | undefined>(undefined);
// One GET /api/session at boot carries both the actor and the permission set the
// signed session was issued with.
const [session, setSession] = useState<AdminSession | null | undefined>(undefined);
const [route, setRoute] = useState<RouteState>(() => currentRoute());
useEffect(() => {
@ -17,14 +21,10 @@ export function App() {
useEffect(() => {
api.session()
.then((session) => setActor(session.actor))
.catch((error) => {
if (error instanceof APIError && error.status === 401) {
setActor(null);
return;
}
setActor(null);
});
.then((next) => setSession(next))
// A 401 and an unreachable backend both end at the login screen; there is
// nothing the panel can render without a session.
.catch(() => setSession(null));
}, []);
const navigate = (href: string) => {
@ -32,17 +32,19 @@ export function App() {
setRoute(currentRoute());
};
if (actor === undefined) {
if (session === undefined) {
return <BootScreen />;
}
if (actor === null) {
return <LoginPage onLogin={setActor} />;
if (session === null) {
return <LoginPage onLogin={setSession} />;
}
return (
<Shell actor={actor} route={route} navigate={navigate} onLogout={() => setActor(null)}>
<Routes route={route} navigate={navigate} />
</Shell>
<PermissionsProvider permissions={session.permissions ?? []}>
<Shell actor={session.actor} route={route} navigate={navigate} onLogout={() => setSession(null)}>
<Routes route={route} navigate={navigate} />
</Shell>
</PermissionsProvider>
);
}

View file

@ -1,22 +1,40 @@
import type {
AccountDetail,
AccountListResponse,
AccountRatingDetail,
AccountRatingListResponse,
AccountStatsResponse,
AdminLoginResult,
AdminSession,
BotDetail,
BotListResponse,
BotVerificationCountsResponse,
BotVerifierListResponse,
ChannelDetail,
CustomVerificationListResponse,
CustomVerificationRequestDetail,
CustomVerificationRequestListResponse,
VerificationIconListResponse,
EmojiListResponse,
ChannelListResponse,
CollectibleUsernameDetail,
CollectibleUsernameListResponse,
CommandResult,
GroupMessageDetail,
GroupMessageListResponse,
MessageDetail,
MessageListResponse,
DefaultGiftListResponse,
ModerationCaseDetail,
ModerationCaseRow,
ModerationReport,
OfficialStarGiftListResponse,
StarGiftCollectiblePreview,
StarGiftListResponse,
StickerSetListResponse
StickerSetListResponse,
VerificationApplicationDetail,
VerificationApplicationListResponse,
VerificationCountsResponse
} from "./types";
export class APIError extends Error {
@ -28,12 +46,81 @@ export class APIError extends Error {
}
}
// The backend publishes the CSRF token in a deliberately readable cookie and
// refuses every mutating request whose X-CSRF-Token header does not repeat it
// (cmd/telesrv-admin/security.go). Echoing it here — inside request<T> — is what
// keeps a new endpoint from silently shipping without the header.
const csrfCookieName = "telesrv_admin_csrf";
const csrfHeaderName = "X-CSRF-Token";
// Login answers with the token in the body as well as in Set-Cookie. Keeping the
// body value is the fallback for the window where the browser has not applied
// the cookie yet, or where the cookie is not readable back to the script.
let issuedCSRFToken = "";
export function rememberCSRFToken(token: string | undefined): void {
issuedCSRFToken = (token ?? "").trim();
}
function readCSRFCookie(): string {
if (typeof document === "undefined") return "";
for (const chunk of document.cookie.split(";")) {
const entry = chunk.trim();
const separator = entry.indexOf("=");
if (separator <= 0 || entry.slice(0, separator) !== csrfCookieName) continue;
try {
return decodeURIComponent(entry.slice(separator + 1));
} catch {
return entry.slice(separator + 1);
}
}
return "";
}
// The cookie wins: it is the value the server compares against, and it survives
// a page reload that the in-memory copy does not.
export function csrfToken(): string {
return readCSRFCookie() || issuedCSRFToken;
}
// Same classification the backend uses: GET/HEAD/OPTIONS are safe, everything
// else carries a token.
function mutatingMethod(method: string | undefined): boolean {
const verb = (method ?? "GET").toUpperCase();
return verb !== "GET" && verb !== "HEAD" && verb !== "OPTIONS";
}
function plainHeaders(source: HeadersInit | undefined): Record<string, string> {
if (!source) return {};
if (source instanceof Headers) {
const out: Record<string, string> = {};
source.forEach((value, key) => {
out[key] = value;
});
return out;
}
if (Array.isArray(source)) {
return Object.fromEntries(source);
}
return { ...source };
}
async function request<T>(url: string, init: RequestInit = {}): Promise<T> {
const isForm = typeof FormData !== "undefined" && init.body instanceof FormData;
// A multipart body must keep the boundary the browser generates, so its
// Content-Type is left alone; the CSRF header is added either way.
const headers: Record<string, string> = isForm ? {} : { "Content-Type": "application/json" };
Object.assign(headers, plainHeaders(init.headers));
if (mutatingMethod(init.method)) {
const token = csrfToken();
if (token) {
headers[csrfHeaderName] = token;
}
}
const response = await fetch(url, {
credentials: "same-origin",
headers: isForm ? init.headers : { "Content-Type": "application/json", ...(init.headers ?? {}) },
...init
...init,
headers
});
const text = await response.text();
const data = text ? JSON.parse(text) : null;
@ -52,11 +139,16 @@ export function errorMessage(error: unknown): string {
}
export const api = {
session: () => request<{ actor: string }>("/api/session"),
login: (secret: string) => request<{ actor: string }>("/api/login", {
method: "POST",
body: JSON.stringify({ secret })
}),
session: () => request<AdminSession>("/api/session"),
login: async (secret: string) => {
const result = await request<AdminLoginResult>("/api/login", {
method: "POST",
body: JSON.stringify({ secret })
});
// Stashed here rather than in the caller so no login path can forget it.
rememberCSRFToken(result.csrf_token);
return result;
},
logout: () => request<{ ok: boolean }>("/api/logout", { method: "POST", body: "{}" }),
accounts: (params: URLSearchParams) => request<AccountListResponse>(`/api/accounts?${params.toString()}`),
accountStats: () => request<AccountStatsResponse>("/api/accounts/stats"),
@ -65,6 +157,36 @@ export const api = {
channel: (id: number) => request<ChannelDetail>(`/api/channels/${id}`),
bots: (params: URLSearchParams) => request<BotListResponse>(`/api/bots?${params.toString()}`),
bot: (id: number) => request<BotDetail>(`/api/bots/${id}`),
collectibleUsernames: (params: URLSearchParams) =>
request<CollectibleUsernameListResponse>(`/api/collectible-usernames?${params.toString()}`),
collectibleUsername: (id: string) =>
request<CollectibleUsernameDetail>(`/api/collectible-usernames/${encodeURIComponent(id)}`),
accountRatings: (params: URLSearchParams) =>
request<AccountRatingListResponse>(`/api/account-ratings?${params.toString()}`),
accountRating: (userID: string) =>
request<AccountRatingDetail>(`/api/account-ratings/${encodeURIComponent(userID)}`),
verificationApplications: (params: URLSearchParams) =>
request<VerificationApplicationListResponse>(`/api/verification/applications?${params.toString()}`),
// The application id is an int64 decimal string end to end, so it is never
// parsed into a number on the way to the URL.
verificationApplication: (id: string) =>
request<VerificationApplicationDetail>(`/api/verification/applications/${encodeURIComponent(id)}`),
verificationCounts: () => request<VerificationCountsResponse>("/api/verification/counts"),
// Third-party verification lives under its own prefix: the two mechanisms share
// no state, so they share no route either.
botVerifiers: (params: URLSearchParams) =>
request<BotVerifierListResponse>(`/api/botverification/verifiers?${params.toString()}`),
verificationIcons: (params: URLSearchParams) =>
request<VerificationIconListResponse>(`/api/botverification/icons?${params.toString()}`),
customVerifications: (params: URLSearchParams) =>
request<CustomVerificationListResponse>(`/api/botverification/marks?${params.toString()}`),
customVerificationRequests: (params: URLSearchParams) =>
request<CustomVerificationRequestListResponse>(`/api/botverification/requests?${params.toString()}`),
// The application id is an int64 decimal string end to end, so it is never parsed
// into a number on the way to the URL.
customVerificationRequest: (id: string) =>
request<CustomVerificationRequestDetail>(`/api/botverification/requests/${encodeURIComponent(id)}`),
botVerificationCounts: () => request<BotVerificationCountsResponse>("/api/botverification/counts"),
emoji: (params: URLSearchParams) => request<EmojiListResponse>(`/api/emoji?${params.toString()}`),
emojiAnimation: (documentID: string) => request<Record<string, unknown>>(`/api/emoji/${encodeURIComponent(documentID)}/animation`),
messages: (params: URLSearchParams) => request<MessageListResponse>(`/api/messages?${params.toString()}`),
@ -77,6 +199,27 @@ export const api = {
const params = new URLSearchParams({ channel_id: String(channelID), msg_id: String(msgID) });
return request<GroupMessageDetail>(`/api/messages/groups/detail?${params.toString()}`);
},
moderationCases: (params: URLSearchParams) =>
request<{ cases: ModerationCaseRow[] }>(`/api/moderation/cases?${params.toString()}`),
moderationCase: (id: number) =>
request<ModerationCaseDetail>(`/api/moderation/cases/${id}`),
moderationReport: (id: number) =>
request<ModerationReport>(`/api/moderation/reports/${id}`),
claimModerationCase: (id: number, expectedVersion: number) =>
request<ModerationCaseRow>(`/api/moderation/cases/${id}/claim`, {
method: "POST",
body: JSON.stringify({ expected_version: expectedVersion })
}),
decideModerationCase: (id: number, payload: Record<string, unknown>) =>
request<{ created: boolean; case: ModerationCaseDetail }>(`/api/moderation/cases/${id}/decide`, {
method: "POST",
body: JSON.stringify(payload)
}),
reviewModerationAppeal: (caseID: number, appealID: number, payload: Record<string, unknown>) =>
request<{ created: boolean; case: ModerationCaseDetail }>(`/api/moderation/cases/${caseID}/appeals/${appealID}/review`, {
method: "POST",
body: JSON.stringify(payload)
}),
gifts: () => request<StarGiftListResponse>("/api/gifts"),
stickerSets: (kind: string) => request<StickerSetListResponse>(`/api/stickers?kind=${encodeURIComponent(kind)}`),
stickerSetDocuments: (setID: string) => request<{ document_ids: string[] }>(`/api/stickers/${encodeURIComponent(setID)}/documents`),

View file

@ -3,7 +3,6 @@ import type { ReactNode } from "react";
import { useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { api, errorMessage } from "../api";
import { useI18n } from "../i18n";
import type { CommandResult } from "../types";
import { Alert, JsonBlock } from "./ui";
@ -16,7 +15,9 @@ export function ActionButton({
icon,
compact = false,
tone = "danger",
onDone
disabled = false,
onDone,
onError
}: {
label: string;
path: string;
@ -24,9 +25,16 @@ export function ActionButton({
icon?: ReactNode;
compact?: boolean;
tone?: ActionTone;
// disabled keeps a form from opening the confirm flow at all while its own
// validation is unhappy, so the operator fixes the field instead of reading a
// backend rejection.
disabled?: boolean;
onDone?: () => void;
// onError lets a page react to a failure the operator cannot fix by editing the
// form — an optimistic-locking 409, say — and replace the raw backend text with
// an explanation by returning it.
onError?: (error: unknown) => string | undefined;
}) {
const { t } = useI18n();
const [open, setOpen] = useState(false);
const [reason, setReason] = useState("");
const [result, setResult] = useState<CommandResult | null>(null);
@ -41,7 +49,7 @@ export function ActionButton({
async function run(confirm: boolean) {
if (!reason.trim()) {
setError(t("action.reasonRequired"));
setError("Please enter an operation reason");
return;
}
setBusy(true);
@ -54,7 +62,7 @@ export function ActionButton({
onDone?.();
}
} catch (err) {
setError(errorMessage(err));
setError(onError?.(err) || errorMessage(err));
} finally {
setBusy(false);
}
@ -75,6 +83,7 @@ export function ActionButton({
<button
className={triggerClass}
type="button"
disabled={disabled}
onClick={() => {
reset();
setOpen(true);
@ -88,29 +97,29 @@ export function ActionButton({
<section className="modal command-modal" role="dialog" aria-modal="true" aria-label={label}>
<div className="modal-head">
<div>
<div className="eyebrow">{t("action.flow")}</div>
<div className="eyebrow">{"Action Flow"}</div>
<h2>{label}</h2>
</div>
<button className="icon-btn" type="button" onClick={() => setOpen(false)} aria-label={t("action.close")}><X size={15} /></button>
<button className="icon-btn" type="button" onClick={() => setOpen(false)} aria-label={"Close"}><X size={15} /></button>
</div>
<div className="command-body">
<div className="command-steps">
<div className={`command-step ${reason.trim() ? "done" : "active"}`}>
<span>1</span><strong>{t("action.stepReason")}</strong>
<span>1</span><strong>{"Enter reason"}</strong>
</div>
<div className={`command-step ${result?.dry_run ? "done" : reason.trim() ? "active" : ""}`}>
<span>2</span><strong>{t("action.stepDryRun")}</strong>
<span>2</span><strong>{"Dry-run check"}</strong>
</div>
<div className={`command-step ${result && !result.dry_run && !result.error ? "done" : canConfirm ? "active" : ""}`}>
<span>3</span><strong>{t("action.stepConfirm")}</strong>
<span>3</span><strong>{"Confirm execution"}</strong>
</div>
</div>
<label className="form-field">
<span>{t("action.reason")}</span>
<textarea value={reason} onChange={(event) => setReason(event.target.value)} rows={3} placeholder={t("action.reasonPlaceholder")} />
<span>{"Operation reason"}</span>
<textarea value={reason} onChange={(event) => setReason(event.target.value)} rows={3} placeholder={"Describe why this operation is being performed"} />
</label>
<div className="command-preview">
<div className="preview-head"><FileJson size={14} /> {t("action.requestPreview")}</div>
<div className="preview-head"><FileJson size={14} /> {"Request preview"}</div>
<JsonBlock value={JSON.stringify(previewPayload, null, 2)} />
</div>
{error && <Alert>{error}</Alert>}
@ -118,25 +127,25 @@ export function ActionButton({
<div className="result-box">
<div className="result-title">
{result.error ? <CircleAlert size={16} /> : <CheckCircle2 size={16} />}
<strong>{result.message || result.error || t("action.result")}</strong>
<strong>{result.message || result.error || "Action result"}</strong>
</div>
<div className="result-line"><span>{t("action.commandID")}</span><strong>{result.command_id}</strong></div>
<div className="result-line"><span>{t("action.status")}</span><strong>{result.status}</strong></div>
<div className="result-line"><span>{t("action.dryRun")}</span><strong>{result.dry_run ? t("common.yes") : t("common.no")}</strong></div>
<div className="result-line"><span>{"Command ID"}</span><strong>{result.command_id}</strong></div>
<div className="result-line"><span>{"Status"}</span><strong>{result.status}</strong></div>
<div className="result-line"><span>{"Dry-run"}</span><strong>{result.dry_run ? "Yes" : "No"}</strong></div>
<div className="result-message">{result.message || result.error}</div>
{result.details && <JsonBlock value={JSON.stringify(result.details, null, 2)} />}
</div>
)}
</div>
<div className="modal-actions">
<button className="btn" type="button" onClick={() => setOpen(false)}>{t("common.close")}</button>
<button className="btn" type="button" onClick={() => setOpen(false)}>{"Close"}</button>
<button className="btn icon-text" type="button" onClick={() => run(false)} disabled={busy}>
{busy ? <Loader2 size={15} className="spin" /> : <Play size={15} />}
{result ? t("action.runAgain") : t("action.runDry")}
{result ? "Run dry-run again" : "Run dry-run first"}
</button>
<button className="btn danger icon-text" type="button" onClick={() => run(true)} disabled={busy || !canConfirm}>
<CheckCircle2 size={15} />
{t("action.confirm")}
{"Confirm execution"}
</button>
</div>
</section>

View file

@ -1,13 +1,11 @@
import { Cable, LogOut, ShieldCheck } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { formatDate } from "../lib/format";
import { useI18n } from "../i18n";
import type { AuthorizationRow } from "../types";
import { ActionButton } from "./ActionButton";
import { EmptyRow } from "./ui";
export function AuthorizationTable({ rows, userID, onDone }: { rows: AuthorizationRow[]; userID: number; onDone: () => void }) {
const { t } = useI18n();
const [removedHashes, setRemovedHashes] = useState<Set<number>>(() => new Set());
useEffect(() => {
@ -30,11 +28,11 @@ export function AuthorizationTable({ rows, userID, onDone }: { rows: Authorizati
<table className="data-table authorization-table">
<thead>
<tr>
<th>{t("auth.device")}</th>
<th>{t("auth.platform")}</th>
<th>{t("auth.ip")}</th>
<th>{t("auth.lastActive")}</th>
<th className="device-actions-head">{t("common.actions")}</th>
<th>{"Device"}</th>
<th>{"Platform"}</th>
<th>{"IP"}</th>
<th>{"Last active"}</th>
<th className="device-actions-head">{"Actions"}</th>
</tr>
</thead>
<tbody>
@ -47,7 +45,7 @@ export function AuthorizationTable({ rows, userID, onDone }: { rows: Authorizati
<td className="device-actions-cell">
<div className="device-actions">
<ActionButton
label={t("auth.revokeCurrent")}
label={"Revoke current"}
icon={<LogOut size={13} />}
compact
path="/api/actions/revoke-sessions"
@ -55,7 +53,7 @@ export function AuthorizationTable({ rows, userID, onDone }: { rows: Authorizati
onDone={() => afterRevoke((previous) => new Set([...previous, row.Hash]))}
/>
<ActionButton
label={t("auth.keepCurrent")}
label={"Keep current"}
icon={<ShieldCheck size={13} />}
compact
path="/api/actions/revoke-sessions"
@ -72,7 +70,7 @@ export function AuthorizationTable({ rows, userID, onDone }: { rows: Authorizati
</div>
<div className="danger-zone">
<ActionButton
label={t("auth.revokeAll")}
label={"Revoke all devices"}
icon={<Cable size={15} />}
path="/api/actions/revoke-sessions"
payload={() => ({ user_id: userID, revoke_all: true })}

View file

@ -1,9 +1,8 @@
import { Check, Loader2, Search, X } from "lucide-react";
import { useEffect, useState } from "react";
import { api, errorMessage } from "../api";
import { useI18n } from "../i18n";
import { channelKind, displayName, displayPhone, displayUsername } from "../lib/format";
import type { AccountRow, ChannelRow } from "../types";
import type { AccountRow, BotRow, ChannelRow } from "../types";
import { Badge } from "./ui";
export function UserPicker({
@ -15,7 +14,6 @@ export function UserPicker({
value: AccountRow | null;
onChange: (row: AccountRow | null) => void;
}) {
const { t } = useI18n();
const [query, setQuery] = useState("");
const [rows, setRows] = useState<AccountRow[]>([]);
const [busy, setBusy] = useState(false);
@ -48,7 +46,7 @@ export function UserPicker({
<span>{label}</span>
{value ? (
<button className="link-button" type="button" onClick={() => onChange(null)}>
<X size={13} /> {t("common.clear")}
<X size={13} /> {"Clear"}
</button>
) : null}
</div>
@ -73,10 +71,10 @@ export function UserPicker({
void search();
}
}}
placeholder={t("picker.userPlaceholder")}
placeholder={"Search user_id / phone / username"}
/>
<button className="btn compact-btn" type="button" onClick={search} disabled={busy}>
{busy ? <Loader2 size={14} className="spin" /> : t("common.search")}
{busy ? <Loader2 size={14} className="spin" /> : "Search"}
</button>
</div>
{error && <div className="picker-error">{error}</div>}
@ -91,10 +89,106 @@ export function UserPicker({
<span className="mono">{row.ID}</span>
<strong>{displayName(row)}</strong>
<span>{displayUsername(row.Username) || displayPhone(row.Phone) || "-"}</span>
{row.Verified ? <Badge tone="good">{t("picker.verified")}</Badge> : <Badge>{t("picker.regular")}</Badge>}
{row.Verified ? <Badge tone="good">{"Verified"}</Badge> : <Badge>{"Regular"}</Badge>}
</button>
))}
{rows.length === 0 && !busy ? <div className="picker-empty">{t("common.noResults")}</div> : null}
{rows.length === 0 && !busy ? <div className="picker-empty">{"No results"}</div> : null}
</div>
</div>
);
}
// BotPicker is the same widget over /api/bots. Verifier status is granted to a bot
// account, and an operator knows the handle rather than the id, so the grant form
// resolves it here instead of asking for a raw number.
export function BotPicker({
label,
value,
onChange
}: {
label: string;
value: BotRow | null;
onChange: (row: BotRow | null) => void;
}) {
const [query, setQuery] = useState("");
const [rows, setRows] = useState<BotRow[]>([]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
async function search() {
setBusy(true);
setError("");
const params = new URLSearchParams({ limit: "20" });
if (query.trim()) {
params.set("q", query.trim().replace(/^@/, ""));
}
try {
const result = await api.bots(params);
setRows(result.rows ?? []);
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
useEffect(() => {
void search();
}, []);
return (
<div className="entity-picker">
<div className="picker-head">
<span>{label}</span>
{value ? (
<button className="link-button" type="button" onClick={() => onChange(null)}>
<X size={13} /> {"Clear"}
</button>
) : null}
</div>
{value ? (
<div className="selected-entity">
<Check size={15} />
<div>
<strong>{value.FirstName || "-"}</strong>
<span className="mono">{value.ID}</span>
</div>
<span>{displayUsername(value.Username) || "-"}</span>
</div>
) : null}
<div className="picker-search">
<Search size={15} />
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
void search();
}
}}
placeholder={"Bot username or id"}
/>
<button className="btn compact-btn" type="button" onClick={search} disabled={busy}>
{busy ? <Loader2 size={14} className="spin" /> : "Search"}
</button>
</div>
{error && <div className="picker-error">{error}</div>}
<div className="picker-results">
{rows.map((row) => (
<button
key={row.ID}
className={`picker-row ${value?.ID === row.ID ? "selected" : ""}`}
type="button"
onClick={() => onChange(row)}
>
<span className="mono">{row.ID}</span>
<strong>{row.FirstName || "-"}</strong>
<span>{displayUsername(row.Username) || "-"}</span>
{row.System ? <Badge tone="warn">{"System"}</Badge> : <Badge>{"Regular"}</Badge>}
</button>
))}
{rows.length === 0 && !busy ? <div className="picker-empty">{"No results"}</div> : null}
</div>
</div>
);
@ -109,7 +203,6 @@ export function ChannelPicker({
value: ChannelRow | null;
onChange: (row: ChannelRow | null) => void;
}) {
const { t } = useI18n();
const [query, setQuery] = useState("");
const [rows, setRows] = useState<ChannelRow[]>([]);
const [busy, setBusy] = useState(false);
@ -142,7 +235,7 @@ export function ChannelPicker({
<span>{label}</span>
{value ? (
<button className="link-button" type="button" onClick={() => onChange(null)}>
<X size={13} /> {t("common.clear")}
<X size={13} /> {"Clear"}
</button>
) : null}
</div>
@ -153,7 +246,7 @@ export function ChannelPicker({
<strong>{value.Title || "-"}</strong>
<span className="mono">{value.ID}</span>
</div>
<span>{displayUsername(value.Username) || channelKind(value, t)}</span>
<span>{displayUsername(value.Username) || channelKind(value)}</span>
</div>
) : null}
<div className="picker-search">
@ -167,10 +260,10 @@ export function ChannelPicker({
void search();
}
}}
placeholder={t("picker.channelPlaceholder")}
placeholder={"Search channel_id / username / title"}
/>
<button className="btn compact-btn" type="button" onClick={search} disabled={busy}>
{busy ? <Loader2 size={14} className="spin" /> : t("common.search")}
{busy ? <Loader2 size={14} className="spin" /> : "Search"}
</button>
</div>
{error && <div className="picker-error">{error}</div>}
@ -184,11 +277,11 @@ export function ChannelPicker({
>
<span className="mono">{row.ID}</span>
<strong>{row.Title || "-"}</strong>
<span>{displayUsername(row.Username) || channelKind(row, t)}</span>
{row.Verified ? <Badge tone="good">{t("picker.verified")}</Badge> : <Badge>{channelKind(row, t)}</Badge>}
<span>{displayUsername(row.Username) || channelKind(row)}</span>
{row.Verified ? <Badge tone="good">{"Verified"}</Badge> : <Badge>{channelKind(row)}</Badge>}
</button>
))}
{rows.length === 0 && !busy ? <div className="picker-empty">{t("common.noResults")}</div> : null}
{rows.length === 0 && !busy ? <div className="picker-empty">{"No results"}</div> : null}
</div>
</div>
);

View file

@ -1,4 +1,6 @@
import {
AtSign,
BadgeCheck,
Bot,
ChevronDown,
Database,
@ -7,8 +9,11 @@ import {
MessageSquareText,
Server,
Shield,
ShieldAlert,
ShieldCheck,
Smile,
Stamp,
Trophy,
Users,
Gift,
Sticker,
@ -16,20 +21,19 @@ import {
} from "lucide-react";
import { useEffect, useState, type ReactNode } from "react";
import { api } from "../api";
import { useI18n } from "../i18n";
import { permissionBotVerificationReview, permissionVerificationReview, useCan } from "../permissions";
import { type Navigate, type RouteState, routeSubtitle, routeTitle } from "../routing";
import { ThemeSwitch } from "../theme";
import { AppLink } from "./AppLink";
export function BootScreen() {
const { t } = useI18n();
return (
<div className="boot-screen">
<div className="brand compact brand-elevated">
<span className="brand-mark"><img src="/logo.png" alt="OwpenGram" /></span>
<span>
<strong>OwpenGram</strong>
<small>{t("app.adminConsole")}</small>
<small>{"Admin Console"}</small>
</span>
</div>
<div className="loader-bar" />
@ -50,7 +54,12 @@ export function Shell({
onLogout: () => void;
children: ReactNode;
}) {
const { t } = useI18n();
// The verification queue is hidden for a session without verification.review:
// the entry would only lead to a 403 (and the route itself is gated as well).
const canReviewVerification = useCan(permissionVerificationReview);
// Same reasoning for the third-party queue, which has its own right: the two
// sections are granted independently, so one entry can be visible without the other.
const canReviewBotVerification = useCan(permissionBotVerificationReview);
const messagesActive = route.path.startsWith("/messages");
const [messagesOpen, setMessagesOpen] = useState(messagesActive);
@ -72,19 +81,28 @@ export function Shell({
<span className="brand-mark"><img src="/logo.png" alt="OwpenGram" /></span>
<span>
<strong>OwpenGram</strong>
<small>{t("app.adminConsole")}</small>
<small>{"Admin Console"}</small>
</span>
</AppLink>
<div className="sidebar-label">{t("layout.navigation")}</div>
<nav className="nav-list" aria-label={t("layout.primaryNav")}>
<NavLink icon={<LayoutDashboard size={16} />} href="/" route={route} navigate={navigate}>{t("layout.dashboard")}</NavLink>
<NavLink icon={<Users size={16} />} href="/accounts" route={route} navigate={navigate}>{t("layout.accounts")}</NavLink>
<NavLink icon={<ShieldCheck size={16} />} href="/channels" route={route} navigate={navigate}>{t("layout.channels")}</NavLink>
<NavLink icon={<Bot size={16} />} href="/bots" route={route} navigate={navigate}>{t("layout.bots")}</NavLink>
<NavLink icon={<Gift size={16} />} href="/gifts" route={route} navigate={navigate}>{t("layout.gifts")}</NavLink>
<NavLink icon={<Send size={16} />} href="/give-gifts" route={route} navigate={navigate}>{t("layout.giveGifts")}</NavLink>
<NavLink icon={<Sticker size={16} />} href="/stickers" route={route} navigate={navigate}>{t("layout.stickers")}</NavLink>
<NavLink icon={<Smile size={16} />} href="/emoji" route={route} navigate={navigate}>{t("layout.emoji")}</NavLink>
<div className="sidebar-label">{"Navigation"}</div>
<nav className="nav-list" aria-label={"Primary navigation"}>
<NavLink icon={<LayoutDashboard size={16} />} href="/" route={route} navigate={navigate}>{"Overview"}</NavLink>
<NavLink icon={<Users size={16} />} href="/accounts" route={route} navigate={navigate}>{"Accounts"}</NavLink>
<NavLink icon={<ShieldCheck size={16} />} href="/channels" route={route} navigate={navigate}>{"Supergroups / Channels"}</NavLink>
<NavLink icon={<Bot size={16} />} href="/bots" route={route} navigate={navigate}>{"Bots"}</NavLink>
<NavLink icon={<ShieldAlert size={16} />} href="/moderation" route={route} navigate={navigate}>{"Reports / Moderation"}</NavLink>
{canReviewVerification && (
<NavLink icon={<BadgeCheck size={16} />} href="/verification" route={route} navigate={navigate}>{"Verification"}</NavLink>
)}
{canReviewBotVerification && (
<NavLink icon={<Stamp size={16} />} href="/bot-verification" route={route} navigate={navigate}>{"Third-party marks"}</NavLink>
)}
<NavLink icon={<AtSign size={16} />} href="/collectible-usernames" route={route} navigate={navigate}>{"NFT Usernames"}</NavLink>
<NavLink icon={<Trophy size={16} />} href="/account-ratings" route={route} navigate={navigate}>{"Account Rating"}</NavLink>
<NavLink icon={<Gift size={16} />} href="/gifts" route={route} navigate={navigate}>{"Star Gifts"}</NavLink>
<NavLink icon={<Send size={16} />} href="/give-gifts" route={route} navigate={navigate}>{"Give Gifts"}</NavLink>
<NavLink icon={<Sticker size={16} />} href="/stickers" route={route} navigate={navigate}>{"Stickers"}</NavLink>
<NavLink icon={<Smile size={16} />} href="/emoji" route={route} navigate={navigate}>{"Emoji"}</NavLink>
<div className={`nav-section ${messagesActive ? "active" : ""} ${messagesOpen ? "open" : ""}`}>
<button
className="nav-section-toggle"
@ -93,7 +111,7 @@ export function Shell({
onClick={() => setMessagesOpen((open) => !open)}
>
<MessageSquareText size={16} />
<span>{t("layout.messages")}</span>
<span>{"Messages"}</span>
<ChevronDown className="nav-section-chevron" size={15} />
</button>
{messagesOpen && (
@ -104,7 +122,7 @@ export function Shell({
navigate={navigate}
activeWhen={(path) => path === "/messages" || path === "/messages/detail" || path.startsWith("/messages/private")}
>
{t("layout.privateMessages")}
{"Private"}
</NavLink>
<NavLink
href="/messages/groups"
@ -112,30 +130,30 @@ export function Shell({
navigate={navigate}
activeWhen={(path) => path.startsWith("/messages/groups")}
>
{t("layout.groupMessages")}
{"Groups"}
</NavLink>
</div>
)}
</div>
</nav>
<div className="sidebar-status">
<div className="sidebar-label">{t("layout.runtime")}</div>
<div className="runtime-row"><Server size={14} /><span>{t("layout.adminBackend")}</span><strong>{t("layout.ready")}</strong></div>
<div className="runtime-row"><Database size={14} /><span>{t("layout.pgRead")}</span><strong>{t("layout.readOnly")}</strong></div>
<div className="runtime-row"><Shield size={14} /><span>{t("layout.writeOps")}</span><strong>{t("layout.dryRun")}</strong></div>
<div className="sidebar-label">{"Runtime"}</div>
<div className="runtime-row"><Server size={14} /><span>{"Admin backend"}</span><strong>{"Ready"}</strong></div>
<div className="runtime-row"><Database size={14} /><span>{"PG read"}</span><strong>{"Read-only"}</strong></div>
<div className="runtime-row"><Shield size={14} /><span>{"Write operations"}</span><strong>{"Dry-run"}</strong></div>
</div>
</aside>
<div className="workspace">
<header className="topbar">
<div>
<div className="eyebrow">{routeSubtitle(route.path, t)}</div>
<h1>{routeTitle(route.path, t)}</h1>
<div className="eyebrow">{routeSubtitle(route.path)}</div>
<h1>{routeTitle(route.path)}</h1>
</div>
<div className="topbar-actions">
<ThemeSwitch />
<span className="actor-pill">{t("layout.actor", { actor })}</span>
<button className="btn ghost icon-text" type="button" onClick={logout} title={t("layout.logout")}>
<LogOut size={16} /> {t("layout.logout")}
<span className="actor-pill">{`Actor: ${actor}`}</span>
<button className="btn ghost icon-text" type="button" onClick={logout} title={"Log out"}>
<LogOut size={16} /> {"Log out"}
</button>
</div>
</header>

View file

@ -1,7 +1,6 @@
import { AtSign, LifeBuoy, Palette, Settings2, Smile } from "lucide-react";
import { useEffect, useState } from "react";
import { ActionButton } from "./ActionButton";
import { useI18n } from "../i18n";
import { toInt } from "../lib/format";
import type { ChannelRow } from "../types";
@ -9,10 +8,9 @@ type IDKey = "user_id" | "channel_id";
// SupportAction toggles the official-support flag (users/bots only).
export function SupportAction({ id, support, onDone }: { id: number; support: boolean; onDone: () => void }) {
const { t } = useI18n();
return (
<ActionButton
label={support ? t("attr.clearSupport") : t("attr.setSupport")}
label={support ? "Clear support" : "Mark as support"}
icon={<LifeBuoy size={15} />}
tone="neutral"
path="/api/actions/set-support"
@ -30,16 +28,15 @@ export function UsernameAction({ idKey, id, path, current, onDone }: {
current: string;
onDone: () => void;
}) {
const { t } = useI18n();
const [username, setUsername] = useState(current.replace(/^@/, ""));
return (
<div className="attr-block">
<label className="duration-field">
<span>{t("attr.username")}</span>
<span>{"Username"}</span>
<input value={username} onChange={(e) => setUsername(e.target.value)} placeholder="username" />
</label>
<ActionButton
label={t("attr.setUsername")}
label={"Set username"}
icon={<AtSign size={15} />}
tone="neutral"
path={path}
@ -57,25 +54,24 @@ export function ColorAction({ idKey, id, path, onDone }: {
path: string;
onDone: () => void;
}) {
const { t } = useI18n();
const [forProfile, setForProfile] = useState(false);
const [hasColor, setHasColor] = useState(true);
const [color, setColor] = useState("0");
const [bgEmoji, setBgEmoji] = useState("");
return (
<div className="attr-block">
<label className="checkline"><input type="checkbox" checked={forProfile} onChange={(e) => setForProfile(e.target.checked)} /> {t("attr.forProfile")}</label>
<label className="checkline"><input type="checkbox" checked={hasColor} onChange={(e) => setHasColor(e.target.checked)} /> {t("attr.hasColor")}</label>
<label className="checkline"><input type="checkbox" checked={forProfile} onChange={(e) => setForProfile(e.target.checked)} /> {"Profile color"}</label>
<label className="checkline"><input type="checkbox" checked={hasColor} onChange={(e) => setHasColor(e.target.checked)} /> {"Enable color"}</label>
<label className="duration-field">
<span>{t("attr.colorIndex")}</span>
<span>{"Color index"}</span>
<input type="number" min="0" max="20" value={color} onChange={(e) => setColor(e.target.value)} />
</label>
<label className="duration-field">
<span>{t("attr.bgEmojiID")}</span>
<span>{"Background emoji ID"}</span>
<input value={bgEmoji} onChange={(e) => setBgEmoji(e.target.value)} placeholder="0" />
</label>
<ActionButton
label={t("attr.setColor")}
label={"Set color"}
icon={<Palette size={15} />}
tone="neutral"
path={path}
@ -99,21 +95,20 @@ export function EmojiStatusAction({ idKey, id, path, onDone }: {
path: string;
onDone: () => void;
}) {
const { t } = useI18n();
const [documentID, setDocumentID] = useState("");
const [until, setUntil] = useState("0");
return (
<div className="attr-block">
<label className="duration-field">
<span>{t("attr.emojiDocID")}</span>
<span>{"Emoji document ID"}</span>
<input value={documentID} onChange={(e) => setDocumentID(e.target.value)} placeholder="0 = clear" />
</label>
<label className="duration-field">
<span>{t("attr.emojiUntil")}</span>
<span>{"Until (unix, 0 = permanent)"}</span>
<input type="number" min="0" value={until} onChange={(e) => setUntil(e.target.value)} />
</label>
<ActionButton
label={t("attr.setEmojiStatus")}
label={"Set emoji status"}
icon={<Smile size={15} />}
tone="neutral"
path={path}
@ -126,7 +121,6 @@ export function EmojiStatusAction({ idKey, id, path, onDone }: {
// ChannelSettingsAction force-applies moderation settings to a channel/supergroup.
export function ChannelSettingsAction({ channel, onDone }: { channel: ChannelRow; onDone: () => void }) {
const { t } = useI18n();
const [gigagroup, setGigagroup] = useState(channel.Gigagroup);
const [antispam, setAntispam] = useState(channel.AntiSpam);
const [hidden, setHidden] = useState(channel.ParticipantsHidden);
@ -163,18 +157,18 @@ export function ChannelSettingsAction({ channel, onDone }: { channel: ChannelRow
}
return (
<div className="attr-block">
<label className="checkline"><input type="checkbox" checked={gigagroup} onChange={(e) => setGigagroup(e.target.checked)} /> {t("attr.gigagroup")}</label>
<label className="checkline"><input type="checkbox" checked={antispam} onChange={(e) => setAntispam(e.target.checked)} /> {t("attr.antispam")}</label>
<label className="checkline"><input type="checkbox" checked={hidden} onChange={(e) => setHidden(e.target.checked)} /> {t("attr.participantsHidden")}</label>
<label className="checkline"><input type="checkbox" checked={noforwards} onChange={(e) => setNoforwards(e.target.checked)} /> {t("attr.noforwards")}</label>
<label className="checkline"><input type="checkbox" checked={joinToSend} onChange={(e) => setJoinToSend(e.target.checked)} /> {t("attr.joinToSend")}</label>
<label className="checkline"><input type="checkbox" checked={joinRequest} onChange={(e) => setJoinRequest(e.target.checked)} /> {t("attr.joinRequest")}</label>
<label className="checkline"><input type="checkbox" checked={gigagroup} onChange={(e) => setGigagroup(e.target.checked)} /> {"Gigagroup"}</label>
<label className="checkline"><input type="checkbox" checked={antispam} onChange={(e) => setAntispam(e.target.checked)} /> {"Aggressive anti-spam"}</label>
<label className="checkline"><input type="checkbox" checked={hidden} onChange={(e) => setHidden(e.target.checked)} /> {"Hide members"}</label>
<label className="checkline"><input type="checkbox" checked={noforwards} onChange={(e) => setNoforwards(e.target.checked)} /> {"Restrict forwarding"}</label>
<label className="checkline"><input type="checkbox" checked={joinToSend} onChange={(e) => setJoinToSend(e.target.checked)} /> {"Join to send messages"}</label>
<label className="checkline"><input type="checkbox" checked={joinRequest} onChange={(e) => setJoinRequest(e.target.checked)} /> {"Join by request"}</label>
<label className="duration-field">
<span>{t("attr.slowmode")}</span>
<span>{"Slowmode (seconds)"}</span>
<input type="number" min="0" max="86400" value={slowmode} onChange={(e) => setSlowmode(e.target.value)} />
</label>
<ActionButton
label={t("attr.applySettings")}
label={"Apply settings"}
icon={<Settings2 size={15} />}
tone="warn"
path="/api/actions/set-channel-settings"

View file

@ -1,18 +1,16 @@
import { ShieldAlert, ShieldX } from "lucide-react";
import { useI18n } from "../i18n";
import { ActionButton } from "./ActionButton";
import { Badge } from "./ui";
// ScamFakeBadges renders the SCAM/FAKE moderation labels when set.
export function ScamFakeBadges({ scam, fake }: { scam: boolean; fake: boolean }) {
const { t } = useI18n();
if (!scam && !fake) {
return null;
}
return (
<>
{scam && <Badge tone="danger">{t("flags.scam")}</Badge>}
{fake && <Badge tone="danger">{t("flags.fake")}</Badge>}
{scam && <Badge tone="danger">{"SCAM"}</Badge>}
{fake && <Badge tone="danger">{"FAKE"}</Badge>}
</>
);
}
@ -35,11 +33,10 @@ export function ScamFakeActions({
fake: boolean;
onDone: () => void;
}) {
const { t } = useI18n();
return (
<div className="action-stack">
<ActionButton
label={scam ? t("flags.clearScam") : t("flags.setScam")}
label={scam ? "Clear SCAM" : "Mark as SCAM"}
icon={<ShieldAlert size={15} />}
tone="danger"
path={path}
@ -47,7 +44,7 @@ export function ScamFakeActions({
onDone={onDone}
/>
<ActionButton
label={fake ? t("flags.clearFake") : t("flags.setFake")}
label={fake ? "Clear FAKE" : "Mark as FAKE"}
icon={<ShieldX size={15} />}
tone="danger"
path={path}

View file

@ -1,8 +1,7 @@
import { CircleAlert } from "lucide-react";
import type { ReactNode } from "react";
import { useI18n } from "../i18n";
import { formatDate } from "../lib/format";
import type { AuditLogRow } from "../types";
import { displayUsername, formatDate } from "../lib/format";
import type { AccountUsername, AuditLogRow } from "../types";
type Tone = "neutral" | "good" | "danger" | "warn";
@ -92,11 +91,10 @@ export function Summary({ label, value, mono = false }: { label: string; value:
}
export function AuditTable({ rows }: { rows: AuditLogRow[] }) {
const { t } = useI18n();
return (
<div className="table-wrap">
<table className="data-table">
<thead><tr><th>{t("audit.id")}</th><th>{t("audit.commandID")}</th><th>{t("audit.action")}</th><th>{t("audit.actor")}</th><th>{t("audit.status")}</th><th>{t("audit.dryRun")}</th><th>{t("audit.reason")}</th><th>{t("audit.time")}</th></tr></thead>
<thead><tr><th>{"ID"}</th><th>{"Command ID"}</th><th>{"Action"}</th><th>{"Actor"}</th><th>{"Status"}</th><th>{"Dry-run"}</th><th>{"Reason"}</th><th>{"Time"}</th></tr></thead>
<tbody>
{rows.map((row) => (
<tr key={row.ID}>
@ -105,7 +103,7 @@ export function AuditTable({ rows }: { rows: AuditLogRow[] }) {
<td>{row.Action}</td>
<td>{row.Actor}</td>
<td>{row.Status}</td>
<td>{row.DryRun ? t("common.yes") : t("common.no")}</td>
<td>{row.DryRun ? "Yes" : "No"}</td>
<td className="truncate">{row.Reason}</td>
<td>{formatDate(row.CreatedAt)}</td>
</tr>
@ -118,8 +116,7 @@ export function AuditTable({ rows }: { rows: AuditLogRow[] }) {
}
export function EmptyRow({ colSpan }: { colSpan: number }) {
const { t } = useI18n();
return <tr><td colSpan={colSpan} className="empty-cell">{t("common.noResults")}</td></tr>;
return <tr><td colSpan={colSpan} className="empty-cell">{"No results"}</td></tr>;
}
export function LoadingSurface({ label }: { label: string }) {
@ -129,3 +126,32 @@ export function LoadingSurface({ label }: { label: string }) {
export function JsonBlock({ value }: { value: string }) {
return <pre className="json-block">{value || "{}"}</pre>;
}
// UsernameCell renders a peer's editable username with its collectible usernames
// branching off underneath, in the order clients project them.
//
// An inactive collectible is shown rather than hidden: the peer still owns it, it
// just does not resolve publicly, and an operator looking for "where did that name
// go" needs to see it. It is marked instead of dropped.
// Pass an empty username to render the branch on its own, which is what the
// detail header does: it already shows the editable slot on the line above.
export function UsernameCell({ username, collectibles }: { username?: string; collectibles?: AccountUsername[] | null }) {
const main = displayUsername(username ?? "");
const branch = collectibles ?? [];
if (branch.length === 0) {
return <>{main || "-"}</>;
}
return (
<>
{main}
<ul className="username-branch">
{branch.map((item) => (
<li key={item.Username} className={item.Active ? "" : "inactive"}>
<span>{displayUsername(item.Username)}</span>
{!item.Active && <em>{"inactive"}</em>}
</li>
))}
</ul>
</>
);
}

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,4 @@
import type { AccountRow, ChannelRow } from "../types";
import type { TFunction } from "../i18n";
export function displayPhone(value: string): string {
const phone = value.trim();
@ -17,17 +16,11 @@ export function displayName(row: Pick<AccountRow, "FirstName" | "LastName">): st
return `${row.FirstName || ""} ${row.LastName || ""}`.trim() || "-";
}
export function channelKind(ch: ChannelRow, t?: TFunction): string {
const translate = t ?? ((key: string) => ({
"channel.kind.broadcast": "Channel",
"channel.kind.forum": "Supergroup / Forum",
"channel.kind.megagroup": "Supergroup",
"channel.kind.generic": "Channel / Group"
})[key] ?? key);
if (ch.Broadcast && !ch.Megagroup) return translate("channel.kind.broadcast");
if (ch.Megagroup && ch.Forum) return translate("channel.kind.forum");
if (ch.Megagroup) return translate("channel.kind.megagroup");
return translate("channel.kind.generic");
export function channelKind(ch: ChannelRow): string {
if (ch.Broadcast && !ch.Megagroup) return "Channel";
if (ch.Megagroup && ch.Forum) return "Supergroup / Forum";
if (ch.Megagroup) return "Supergroup";
return "Channel / Group";
}
export function formatDate(value: string): string {
@ -44,12 +37,131 @@ export function formatUnix(value: number): string {
return date.toLocaleString();
}
// safeHttpURL vets a link an applicant typed. Only http(s) is turned into an
// anchor: a submitted string may just as well be javascript:, data: or a bare
// word, and must stay inert text in that case. The parsed href is returned so a
// malformed authority cannot slip through the prefix test.
export function safeHttpURL(value: string): string {
const raw = (value ?? "").trim();
if (!/^https?:\/\//i.test(raw)) return "";
try {
const parsed = new URL(raw);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return "";
return parsed.href;
} catch {
return "";
}
}
export function toInt(value: string): number {
if (!value.trim()) return 0;
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) ? parsed : 0;
}
// int64 values arrive as JSON strings; keep parsing tolerant so an unexpected
// empty string or "null" never renders as NaN.
export function toNumeric(value: string): number {
const raw = (value ?? "").trim();
if (!raw) return 0;
const parsed = Number(raw);
return Number.isFinite(parsed) ? parsed : 0;
}
export function formatQuantity(value: string): string {
const raw = (value ?? "").trim();
if (!raw) return "0";
const parsed = Number(raw);
return Number.isFinite(parsed) ? parsed.toLocaleString() : raw;
}
// Currency scaling for fragment.collectibleInfo.
//
// The wire format is integer smallest units: core.telegram.org says amount is
// "Total price in the smallest units of the currency (integer, not
// float/double)" -- $1.45 is 145 -- and crypto_amount likewise, so TON is
// nanotons (1 TON = 1e9). Clients divide by that exponent before drawing the
// price, which is why a panel that both stores and shows the raw integer makes an
// operator type 900 for "900 TON" and Telegram Desktop then renders 0.0000009.
//
// Everything the operator reads or types in the panel is therefore in whole
// currency units, and these helpers are the only conversion boundary.
const currencyExponents: Record<string, number> = {
// Stars have no subunit: an XTR amount is a count of stars.
XTR: 0,
// Nanotons.
TON: 9,
// Fiat minor units.
USD: 2,
EUR: 2,
RUB: 2
};
export function currencyExponent(currency: string): number {
const key = (currency ?? "").trim().toUpperCase();
// Two decimals is the ISO 4217 default, and it is what an unknown fiat code
// most likely is; guessing 0 would silently multiply a price by 100.
return key in currencyExponents ? currencyExponents[key] : 2;
}
// formatCurrencyAmount renders smallest units as whole currency units. It works
// on the decimal string rather than a JS number so a nanoton amount beyond
// Number.MAX_SAFE_INTEGER is not rounded on the way to the screen.
export function formatCurrencyAmount(value: string, currency: string): string {
const raw = (value ?? "").trim();
if (!raw) return "0";
if (!/^-?\d+$/.test(raw)) return raw;
const exponent = currencyExponent(currency);
const negative = raw.startsWith("-");
const digits = (negative ? raw.slice(1) : raw).replace(/^0+(?=\d)/, "");
const padded = digits.padStart(exponent + 1, "0");
const whole = padded.slice(0, padded.length - exponent) || "0";
let fraction = exponent > 0 ? padded.slice(padded.length - exponent) : "";
// Fiat keeps its two decimals the way a client draws them ($10.00); a
// nine-decimal crypto amount would just be a wall of zeros, so trim those.
if (exponent > 2) fraction = fraction.replace(/0+$/, "");
const sign = negative ? "-" : "";
return fraction ? `${sign}${groupDigits(whole)}.${fraction}` : `${sign}${groupDigits(whole)}`;
}
// groupDigits inserts thousands separators without going through a JS number, so
// a value past Number.MAX_SAFE_INTEGER keeps every digit.
function groupDigits(digits: string): string {
return digits.replace(/\B(?=(\d{3})+(?!\d))/g, " ");
}
// formatCurrency is formatCurrencyAmount with the code appended, which is the
// shape every price cell in the panel wants.
export function formatCurrency(value: string, currency: string): string {
const code = (currency ?? "").trim().toUpperCase();
const amount = formatCurrencyAmount(value, code);
return code ? `${amount} ${code}` : amount;
}
// toSmallestUnits turns what the operator typed -- whole currency units, with an
// optional fraction -- into the integer decimal string the API expects. It
// returns null for anything that is not a plain non-negative amount, or that
// carries more decimals than the currency has, so the form can refuse instead of
// silently truncating a price.
export function toSmallestUnits(value: string, currency: string): string | null {
const raw = (value ?? "").trim().replace(/\s+/g, "").replace(",", ".");
if (!raw) return "0";
if (!/^\d*(\.\d*)?$/.test(raw) || raw === "." ) return null;
const exponent = currencyExponent(currency);
const [wholePart, fractionPart = ""] = raw.split(".");
if (fractionPart.length > exponent) return null;
const digits = `${wholePart || "0"}${fractionPart.padEnd(exponent, "0")}`.replace(/^0+(?=\d)/, "");
return digits === "" ? "0" : digits;
}
export function formatSigned(value: string): string {
const raw = (value ?? "").trim();
if (!raw) return "0";
const parsed = Number(raw);
if (!Number.isFinite(parsed)) return raw;
return parsed > 0 ? `+${parsed.toLocaleString()}` : parsed.toLocaleString();
}
export function parseIDs(value: string, invalidMessage = "msg ids invalid"): number[] {
const ids = value
.split(/[\s,]+/)

View file

@ -1,16 +1,13 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { App } from "./App";
import { I18nProvider } from "./i18n";
import { ThemeProvider } from "./theme";
import "./styles.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<ThemeProvider>
<I18nProvider>
<App />
</I18nProvider>
<App />
</ThemeProvider>
</React.StrictMode>
);

View file

@ -3,16 +3,14 @@ import { useEffect, useState } from "react";
import { api, errorMessage } from "../api";
import { ActionButton } from "../components/ActionButton";
import { AuthorizationTable } from "../components/AuthorizationTable";
import { Alert, AuditTable, Badge, LoadingSurface, PageFrame, SectionHead, SplitLayout, Summary } from "../components/ui";
import { Alert, AuditTable, Badge, LoadingSurface, PageFrame, SectionHead, SplitLayout, Summary, UsernameCell } from "../components/ui";
import { ScamFakeActions, ScamFakeBadges } from "../components/flags";
import { ColorAction, EmojiStatusAction, SupportAction, UsernameAction } from "../components/attributes";
import { useI18n } from "../i18n";
import { displayName, displayPhone, displayUsername, formatDate, formatUnix, toInt } from "../lib/format";
import type { Navigate } from "../routing";
import type { AccountDetail } from "../types";
export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navigate }) {
const { t } = useI18n();
const [detail, setDetail] = useState<AccountDetail | null>(null);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
@ -48,15 +46,15 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
return <Alert>{error}</Alert>;
}
if (!detail) {
return <LoadingSurface label={busy ? t("account.loadingDetail") : t("account.waitingData")} />;
return <LoadingSurface label={busy ? "Loading account detail" : "Waiting for data"} />;
}
const account = detail.Account;
return (
<PageFrame
title={t("account.detailTitle", { id: account.ID })}
eyebrow={t("account.profile")}
actions={<button className="btn icon-text" onClick={() => navigate("/accounts")}><ArrowLeft size={15} /> {t("common.backToList")}</button>}
title={`Account #${account.ID}`}
eyebrow={"Account Profile"}
actions={<button className="btn icon-text" onClick={() => navigate("/accounts")}><ArrowLeft size={15} /> {"Back to list"}</button>}
>
<SplitLayout
main={
@ -64,56 +62,61 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
<section className="entity-head">
<div>
<div className="entity-title">{displayName(account)}</div>
<div className="entity-subtitle">{displayUsername(account.Username) || t("account.noUsername")} · {displayPhone(account.Phone) || t("account.noPhone")}</div>
<div className="entity-subtitle">{displayUsername(account.Username) || "No username"} · {displayPhone(account.Phone) || "No phone"}</div>
{account.Collectibles?.length > 0 && (
<div className="entity-subtitle">
<UsernameCell username="" collectibles={account.Collectibles} />
</div>
)}
</div>
<div className="entity-badges">
{account.PremiumUntil > 0 ? <Badge tone="good">{t("account.premium")}</Badge> : <Badge>{t("account.notPremium")}</Badge>}
{detail.Verified ? <Badge tone="good">{t("common.verified")}</Badge> : <Badge>{t("account.notVerified")}</Badge>}
{account.PremiumUntil > 0 ? <Badge tone="good">{"Premium"}</Badge> : <Badge>{"Not premium"}</Badge>}
{detail.Verified ? <Badge tone="good">{"Verified"}</Badge> : <Badge>{"Not verified"}</Badge>}
<ScamFakeBadges scam={detail.Scam} fake={detail.Fake} />
{account.Frozen ? <Badge tone="danger">{t("account.accountFrozen")}</Badge> : <Badge>{t("account.accountActive")}</Badge>}
{account.Frozen ? <Badge tone="danger">{"Account frozen"}</Badge> : <Badge>{"Account active"}</Badge>}
</div>
</section>
<div className="summary-grid">
<Summary label={t("account.userID")} value={String(account.ID)} mono />
<Summary label={t("account.lastActive")} value={formatUnix(detail.LastSeenAt) || "-"} />
<Summary label={t("account.premiumUntil")} value={account.PremiumUntil > 0 ? formatUnix(account.PremiumUntil) : t("common.none")} />
<Summary label={t("account.starsBalance")} value={`${detail.StarsBalance} / ${detail.StarsGranted ? t("account.startingGrantApplied") : t("account.startingGrantPending")}`} />
<Summary label={t("common.updatedAt")} value={formatDate(account.UpdatedAt) || "-"} />
<Summary label={t("account.activeSessions")} value={String(detail.Authorizations.length)} />
<Summary label={t("account.accountFlags")} value={`support=${detail.Support} bot=${detail.Bot}`} />
<Summary label={t("account.restriction")} value={detail.HasRestriction ? detail.Restriction.Reason || t("account.restricted") : t("common.none")} />
<Summary label={t("account.freezeSince")} value={detail.Restriction.Since ? formatDate(detail.Restriction.Since) : t("common.none")} />
<Summary label={t("account.freezeUntil")} value={detail.Restriction.Until ? formatDate(detail.Restriction.Until) : t("common.none")} />
<Summary label={t("account.freezeAppealURL")} value={detail.Restriction.AppealURL || t("common.none")} />
<Summary label={t("account.createdAt")} value={formatDate(account.CreatedAt) || "-"} />
<Summary label={"User ID"} value={String(account.ID)} mono />
<Summary label={"Last active"} value={formatUnix(detail.LastSeenAt) || "-"} />
<Summary label={"Premium expires"} value={account.PremiumUntil > 0 ? formatUnix(account.PremiumUntil) : "None"} />
<Summary label={"Stars balance"} value={`${detail.StarsBalance} / ${detail.StarsGranted ? "initial grant applied" : "initial grant pending"}`} />
<Summary label={"Updated"} value={formatDate(account.UpdatedAt) || "-"} />
<Summary label={"Authorized devices"} value={String(detail.Authorizations.length)} />
<Summary label={"Account flags"} value={`support=${detail.Support} bot=${detail.Bot}`} />
<Summary label={"Restriction"} value={detail.HasRestriction ? detail.Restriction.Reason || "Restricted" : "None"} />
<Summary label={"Frozen since"} value={detail.Restriction.Since ? formatDate(detail.Restriction.Since) : "None"} />
<Summary label={"Appeal deadline"} value={detail.Restriction.Until ? formatDate(detail.Restriction.Until) : "None"} />
<Summary label={"Appeal URL"} value={detail.Restriction.AppealURL || "None"} />
<Summary label={"Created"} value={formatDate(account.CreatedAt) || "-"} />
</div>
{detail.About && <p className="about-text">{detail.About}</p>}
<section className="section-block">
<SectionHead title={t("account.authorizationsTitle")} text={t("account.authorizationsCount", { count: detail.Authorizations.length })} />
<SectionHead title={"Authorized Devices"} text={`${detail.Authorizations.length} authorizations`} />
<AuthorizationTable rows={detail.Authorizations} userID={account.ID} onDone={load} />
</section>
<section className="section-block">
<SectionHead title={t("account.recentAdminOps")} text={t("account.recent30Audit")} />
<SectionHead title={"Recent Admin Actions"} text={"Last 30 audit rows"} />
<AuditTable rows={detail.AuditLogs} />
</section>
</div>
}
side={
<section className="action-dock">
<div className="dock-title">{t("account.actionDock")}</div>
<div className="dock-title">{"Account Actions"}</div>
<label className="duration-field">
<span>{t("account.freezeUntil")}</span>
<span>{"Appeal deadline"}</span>
<input
aria-label={t("account.freezeUntilAria")}
aria-label={"Freeze appeal deadline"}
value={freezeUntil}
onChange={(event) => setFreezeUntil(event.target.value)}
type="datetime-local"
/>
</label>
<label className="duration-field">
<span>{t("account.freezeAppealURL")}</span>
<span>{"Appeal URL"}</span>
<input
aria-label={t("account.freezeAppealURLAria")}
aria-label={"Freeze appeal URL"}
value={freezeAppealURL}
onChange={(event) => setFreezeAppealURL(event.target.value)}
type="url"
@ -121,7 +124,7 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
/>
</label>
<ActionButton
label={account.Frozen ? t("account.updateFreeze") : t("account.freezeAccount")}
label={account.Frozen ? "Update freeze" : "Freeze account"}
icon={<CircleAlert size={15} />}
path="/api/actions/set-frozen"
payload={() => ({
@ -134,7 +137,7 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
/>
{account.Frozen && (
<ActionButton
label={t("account.unfreezeAccount")}
label={"Unfreeze account"}
icon={<CircleAlert size={15} />}
path="/api/actions/set-frozen"
payload={() => ({ user_id: account.ID, frozen: false })}
@ -142,9 +145,9 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
/>
)}
<label className="duration-field">
<span>{t("account.premiumMonths")}</span>
<span>{"Premium duration (months)"}</span>
<input
aria-label={t("account.premiumMonthsAria")}
aria-label={"Set premium duration in months"}
value={months}
onChange={(event) => setMonths(event.target.value)}
type="number"
@ -154,7 +157,7 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
</label>
<div className="action-stack">
<ActionButton
label={t("account.setPremium")}
label={"Set premium"}
icon={<Sparkles size={15} />}
tone="warn"
path="/api/actions/grant-premium"
@ -162,7 +165,7 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
onDone={load}
/>
<ActionButton
label={t("account.clearPremium")}
label={"Clear premium"}
icon={<Sparkles size={15} />}
tone="warn"
path="/api/actions/grant-premium"
@ -170,9 +173,9 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
onDone={load}
/>
<label className="duration-field">
<span>{t("account.starsAmount")}</span>
<span>{"Stars to grant"}</span>
<input
aria-label={t("account.starsAmountAria")}
aria-label={"Set Stars amount to grant"}
value={starsAmount}
onChange={(event) => setStarsAmount(event.target.value)}
type="number"
@ -181,7 +184,7 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
/>
</label>
<ActionButton
label={t("account.grantStars")}
label={"Grant Stars"}
icon={<Star size={15} />}
tone="warn"
path="/api/actions/grant-stars"
@ -189,7 +192,7 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
onDone={load}
/>
<ActionButton
label={detail.Verified ? t("account.clearVerified") : t("account.setVerified")}
label={detail.Verified ? "Clear verified" : "Set verified"}
icon={<BadgeCheck size={15} />}
tone="warn"
path="/api/actions/set-verified"
@ -198,7 +201,7 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
/>
</div>
<ScamFakeActions idKey="user_id" id={account.ID} path="/api/actions/set-account-flags" scam={detail.Scam} fake={detail.Fake} onDone={load} />
<div className="dock-title">{t("attr.attributes")}</div>
<div className="dock-title">{"Attributes"}</div>
<SupportAction id={account.ID} support={detail.Support} onDone={load} />
<UsernameAction idKey="user_id" id={account.ID} path="/api/actions/set-account-username" current={account.Username} onDone={load} />
<ColorAction idKey="user_id" id={account.ID} path="/api/actions/set-account-color" onDone={load} />

View file

@ -0,0 +1,265 @@
import { ArrowLeft, Calculator, RefreshCw, SlidersHorizontal, User } from "lucide-react";
import { useEffect, useState } from "react";
import { api, errorMessage } from "../api";
import { ActionButton } from "../components/ActionButton";
import { Alert, Badge, EmptyRow, LoadingSurface, Metric, PageFrame, SectionHead, SplitLayout, Summary } from "../components/ui";
import { displayUsername, formatDate, formatQuantity, formatSigned, toNumeric } from "../lib/format";
import type { Navigate } from "../routing";
import type { AccountRatingDetail, AccountRatingEventKind, AccountRatingRow } from "../types";
import { LevelBadge, RatingProgress, levelProgress } from "./AccountRatingsPage";
export function AccountRatingDetailPage({ userID, navigate }: { userID: string; navigate: Navigate }) {
const [detail, setDetail] = useState<AccountRatingDetail | null>(null);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const [adjustment, setAdjustment] = useState("");
async function load() {
setBusy(true);
setError("");
try {
setDetail(await api.accountRating(userID));
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
useEffect(() => {
void load();
}, [userID]);
if (error && !detail) {
return <Alert>{error}</Alert>;
}
if (!detail) {
return <LoadingSurface label={busy ? "Loading account rating…" : "Waiting for data"} />;
}
const rating = detail.rating;
const events = detail.events ?? [];
const pending = toNumeric(rating.PendingStars);
const progress = levelProgress(rating);
// user_id / amount are `,string` int64 fields on the backend, so they stay
// decimal strings and never pass through a float.
const payloadUserID = rating.UserID || userID;
return (
<PageFrame
title={`Rating of ${displayUsername(rating.Username) || rating.FirstName || rating.UserID}`}
eyebrow={"Rating / Component breakdown"}
actions={
<>
<button className="btn icon-text" type="button" onClick={() => navigate("/account-ratings")}>
<ArrowLeft size={15} /> {"Back to list"}
</button>
<button className="btn icon-text" type="button" onClick={load} disabled={busy}>
<RefreshCw size={15} className={busy ? "spin" : ""} /> {"Refresh"}
</button>
</>
}
>
{error && <Alert>{error}</Alert>}
<SplitLayout
main={
<div className="stacked-sections">
<section className="entity-head">
<div>
<div className="entity-title">{displayUsername(rating.Username) || rating.FirstName || "Unnamed bot"}</div>
<div className="entity-subtitle">{"User ID"}: {rating.UserID}</div>
</div>
<div className="entity-badges">
<LevelBadge level={rating.Level} />
{pending !== 0 && <Badge tone="warn">{`Pending ${formatSigned(rating.PendingStars)}`}</Badge>}
</div>
</section>
<div className="metric-row">
<Metric label={"Points"} value={formatQuantity(rating.Stars)} mono />
<Metric label={"Level"} value={String(rating.Level)} tone="good" />
<Metric
label={"Next level threshold"}
value={rating.HasNextLevel ? formatQuantity(rating.NextLevelStars) : "Max level reached"}
mono={rating.HasNextLevel}
/>
<Metric
label={"Points to next level"}
value={rating.HasNextLevel ? formatQuantity(String(progress.remaining)) : "-"}
mono
tone={rating.HasNextLevel && progress.percent >= 80 ? "good" : "neutral"}
/>
</div>
<section className="section-block">
<SectionHead title={"How the rating adds up"} text={"Contribution of every source: stars, activity, moderation penalties and manual corrections."} />
<Breakdown rating={rating} />
<div className="summary-grid">
<Summary label={"Current level threshold"} value={formatQuantity(rating.CurrentLevelStars)} mono />
<Summary
label={"Next level threshold"}
value={rating.HasNextLevel ? formatQuantity(rating.NextLevelStars) : "Max level reached"}
mono={rating.HasNextLevel}
/>
<Summary label={"Computed"} value={formatDate(rating.ComputedAt) || "-"} />
<Summary label={"Updated"} value={formatDate(rating.UpdatedAt) || "-"} />
</div>
<div className="progress-wide">
<RatingProgress row={rating} />
</div>
</section>
{pending !== 0 && (
<section className="section-block">
<SectionHead title={"Pending points"} text={"Already earned, but counted towards the rating only on the date below."} />
<div className="summary-grid">
<Summary label={"Pending"} value={formatSigned(rating.PendingStars)} mono />
<Summary label={"Applied on"} value={formatDate(rating.PendingDate) || "-"} />
</div>
</section>
)}
<section className="section-block">
<SectionHead title={"Rating events"} text={"Every rating change with its source, actor and reason."} />
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th>{"ID"}</th>
<th>{"Source"}</th>
<th>{"Change"}</th>
<th>{"Reason"}</th>
<th>{"Actor"}</th>
<th>{"Time"}</th>
</tr>
</thead>
<tbody>
{events.map((row) => (
<tr key={row.ID}>
<td className="mono">{row.ID}</td>
<td><EventKind kind={row.Kind} /></td>
<td className="mono">{formatSigned(row.Amount)}</td>
<td className="truncate">{row.Reason || "-"}</td>
<td>{row.Actor || "-"}</td>
<td>{formatDate(row.CreatedAt) || "-"}</td>
</tr>
))}
{events.length === 0 && <EmptyRow colSpan={6} />}
</tbody>
</table>
</div>
</section>
</div>
}
side={
<section className="action-dock">
<div className="dock-title">{"Rating operations"}</div>
<button className="btn icon-text" type="button" onClick={() => navigate(`/accounts/${rating.UserID}`)}>
<User size={15} /> {"Open account"}
</button>
<div className="action-stack">
<ActionButton
label={"Recompute"}
icon={<Calculator size={15} />}
tone="neutral"
path="/api/actions/recompute-account-rating"
payload={() => ({ user_id: payloadUserID })}
onDone={load}
/>
</div>
<p className="bot-create-note">{"Rebuilds the rating from stars, activity, penalties and manual corrections."}</p>
<div className="dock-title">{"Manual correction"}</div>
<label className="duration-field">
<span>{"Value (negative allowed)"}</span>
<input
value={adjustment}
onChange={(event) => setAdjustment(event.target.value)}
type="number"
step="1"
placeholder="-500"
/>
</label>
<div className="action-stack">
<ActionButton
label={"Apply correction"}
icon={<SlidersHorizontal size={15} />}
tone="warn"
path="/api/actions/adjust-account-rating"
payload={() => ({
user_id: payloadUserID,
amount: String(Number.parseInt(adjustment.trim() || "0", 10) || 0)
})}
onDone={() => {
setAdjustment("");
void load();
}}
/>
</div>
<p className="bot-create-note">{"The value is added to the manual component; a negative number lowers the rating."}</p>
</section>
}
/>
</PageFrame>
);
}
function Breakdown({ rating }: { rating: AccountRatingRow }) {
// PenaltyComponent is stored as a positive magnitude and subtracted by the
// scorer, so it is shown (and summed) as a negative contribution.
const components = [
{ key: "stars", label: "Stars", hint: "Purchased and received stars", value: toNumeric(rating.StarsComponent) },
{ key: "activity", label: "Activity", hint: "Messages, sessions and long-term engagement", value: toNumeric(rating.ActivityComponent) },
{ key: "penalty", label: "Penalties", hint: "Moderation decisions and restrictions", value: -toNumeric(rating.PenaltyComponent) },
{ key: "manual", label: "Manual corrections", hint: "Adjustments made by admins", value: toNumeric(rating.ManualComponent) }
];
const scale = Math.max(1, ...components.map((item) => Math.abs(item.value)));
// The score is clamped at zero, and a delayed increase sits in PendingStars
// instead of the score, so both cases are expected rather than drift.
const sum = Math.max(0, components.reduce((total, item) => total + item.value, 0));
const total = toNumeric(rating.Stars);
const pending = toNumeric(rating.PendingStars);
return (
<>
<div className="breakdown-list">
{components.map((item) => {
const percent = Math.min(100, (Math.abs(item.value) / scale) * 100);
const tone = item.value < 0 ? "danger" : item.value > 0 ? "good" : "";
return (
<div className="breakdown-row" key={item.key}>
<div className="breakdown-label">
<strong>{item.label}</strong>
<small>{item.hint}</small>
</div>
<div className={`progress-bar ${tone}`} role="img" aria-label={String(item.value)}>
<span style={{ width: `${percent}%` }} />
</div>
<div className={`breakdown-value mono ${tone}`}>{formatSigned(String(item.value))}</div>
</div>
);
})}
<div className="breakdown-row total">
<div className="breakdown-label"><strong>{"Total rating"}</strong></div>
<div className="breakdown-value mono">{formatQuantity(rating.Stars)}</div>
</div>
</div>
{pending === 0 && sum !== total && (
<Alert>{`Components add up to ${formatQuantity(String(sum))} while the stored rating is ${formatQuantity(rating.Stars)}. Recompute to resolve the drift.`}</Alert>
)}
{pending !== 0 && <p className="bot-create-note">{`Components already include ${formatSigned(rating.PendingStars)} that reaches the score only on the date below.`}</p>}
</>
);
}
const ratingKindLabels: Record<AccountRatingEventKind, string> = {
stars: "Stars",
activity: "Activity",
moderation: "Moderation",
manual: "Manual",
recompute: "Recompute"
};
function EventKind({ kind }: { kind: AccountRatingEventKind }) {
const tone = kind === "moderation" ? "danger" : kind === "manual" ? "warn" : kind === "recompute" ? "neutral" : "good";
return <Badge tone={tone}>{ratingKindLabels[kind]}</Badge>;
}

View file

@ -0,0 +1,163 @@
import { ChevronDown, ChevronRight, Loader2, RefreshCw, Search, Trophy } from "lucide-react";
import { useEffect, useState } from "react";
import { api, errorMessage } from "../api";
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
import { displayUsername, formatDate, formatQuantity, toNumeric } from "../lib/format";
import type { Navigate } from "../routing";
import type { AccountRatingRow } from "../types";
export function AccountRatingsPage({ navigate }: { navigate: Navigate }) {
const [minLevel, setMinLevel] = useState("");
const [search, setSearch] = useState("");
const [limit, setLimit] = useState("50");
const [rows, setRows] = useState<AccountRatingRow[]>([]);
const [hasMore, setHasMore] = useState(false);
const [cursor, setCursor] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
async function load(next = false) {
// One free-text field: the backend matches a username prefix (editable or
// collectible), a first/last name prefix, and a bare number as the user id.
const wanted = search.trim();
setBusy(true);
setError("");
const params = new URLSearchParams({ limit });
if (minLevel.trim()) params.set("min_level", minLevel.trim());
if (wanted) params.set("q", wanted);
if (next && cursor) params.set("before_id", cursor);
try {
const result = await api.accountRatings(params);
const page = result.rows ?? [];
setRows((current) => (next ? [...current, ...page] : page));
setCursor(result.next_before_id ?? "");
setHasMore(Boolean(result.has_more));
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
useEffect(() => {
void load(false);
}, []);
const topLevel = rows.reduce((max, row) => Math.max(max, row.Level), 0);
const pendingCount = rows.filter((row) => toNumeric(row.PendingStars) !== 0).length;
const avgLevel = rows.length > 0
? (rows.reduce((sum, row) => sum + row.Level, 0) / rows.length).toFixed(1)
: "0";
return (
<PageFrame
title={"Account rating leaderboard"}
eyebrow={"Rating / Leaderboard"}
actions={
<button className="btn icon-text" type="button" onClick={() => load(false)} disabled={busy}>
<RefreshCw size={15} className={busy ? "spin" : ""} /> {"Refresh"}
</button>
}
>
{error && <Alert>{error}</Alert>}
<div className="metric-row">
<Metric label={"Loaded rows"} value={String(rows.length)} />
<Metric label={"Top level"} value={String(topLevel)} tone="good" />
<Metric label={"Average level"} value={avgLevel} />
<Metric label={"With pending points"} value={String(pendingCount)} tone={pendingCount ? "warn" : "neutral"} />
</div>
<QueryPanel>
<form className="toolbar" onSubmit={(event) => { event.preventDefault(); void load(false); }}>
<label className="searchbox">
<Search size={15} />
<input value={search} onChange={(event) => setSearch(event.target.value)} placeholder={"Search by username, name or user ID"} />
</label>
<label className="field-inline">
<span>{"Min level"}</span>
<input className="small-input" value={minLevel} onChange={(event) => setMinLevel(event.target.value)} type="number" min="0" placeholder="0" />
</label>
<label className="field-inline">
<span>{"Limit"}</span>
<input className="small-input" value={limit} onChange={(event) => setLimit(event.target.value)} type="number" min="1" max="200" />
</label>
<button className="btn primary icon-text" type="submit" disabled={busy}>
{busy ? <Loader2 size={15} className="spin" /> : <Search size={15} />} {"Search"}
</button>
</form>
</QueryPanel>
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th>{"User ID"}</th>
<th>{"Username"}</th>
<th>{"Level"}</th>
<th>{"Points"}</th>
<th>{"Progress to next level"}</th>
<th>{"Pending"}</th>
<th>{"Computed"}</th>
<th></th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.UserID}>
<td className="mono">{row.UserID}</td>
<td>{displayUsername(row.Username) || row.FirstName || "-"}</td>
<td><LevelBadge level={row.Level} /></td>
<td className="mono">{formatQuantity(row.Stars)}</td>
<td><RatingProgress row={row} /></td>
<td className="mono">{toNumeric(row.PendingStars) !== 0 ? formatQuantity(row.PendingStars) : "-"}</td>
<td>{formatDate(row.ComputedAt) || "-"}</td>
<td>
<button className="row-link" type="button" onClick={() => navigate(`/account-ratings/${row.UserID}`)}>
<Trophy size={14} /> {"Details"} <ChevronRight size={14} />
</button>
</td>
</tr>
))}
{rows.length === 0 && <EmptyRow colSpan={8} />}
</tbody>
</table>
</div>
{hasMore && (
<div className="toolbar">
<button className="btn icon-text" type="button" onClick={() => load(true)} disabled={busy}>
{busy ? <Loader2 size={15} className="spin" /> : <ChevronDown size={15} />} {"Load more"}
</button>
</div>
)}
</PageFrame>
);
}
export function LevelBadge({ level }: { level: number }) {
const tone = level >= 10 ? "good" : level >= 5 ? "warn" : "neutral";
return <Badge tone={tone}>{`Level ${level}`}</Badge>;
}
export function levelProgress(row: AccountRatingRow): { percent: number; remaining: number; target: number; stars: number } {
const stars = toNumeric(row.Stars);
const current = toNumeric(row.CurrentLevelStars);
const target = toNumeric(row.NextLevelStars);
const span = target - current;
const percent = span > 0 ? Math.min(100, Math.max(0, ((stars - current) / span) * 100)) : 0;
return { percent, remaining: Math.max(0, target - stars), target, stars };
}
export function RatingProgress({ row }: { row: AccountRatingRow }) {
if (!row.HasNextLevel) {
return <span className="progress-note">{"Max level reached"}</span>;
}
const { percent, remaining, target } = levelProgress(row);
return (
<div className="progress-cell">
<div className="progress-bar" role="img" aria-label={`${Math.round(percent)}%`}>
<span style={{ width: `${percent}%` }} />
</div>
<small>{`${formatQuantity(String(remaining))} left to reach ${formatQuantity(String(target))}`}</small>
</div>
);
}

View file

@ -2,10 +2,9 @@ import { ChevronLeft, ChevronRight, Loader2, RefreshCw, Search } from "lucide-re
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 { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel, UsernameCell } from "../components/ui";
import { ScamFakeBadges } from "../components/flags";
import { useI18n } from "../i18n";
import { displayName, displayPhone, displayUsername, formatDate, formatUnix } from "../lib/format";
import { displayName, displayPhone, formatDate, formatUnix } from "../lib/format";
import { accountMetrics } from "../lib/metrics";
import type { Navigate } from "../routing";
import type { AccountListResponse, AccountStatsResponse } from "../types";
@ -16,7 +15,6 @@ type AccountPageSize = 10 | 20 | 50 | 100;
const zeroCursor: Cursor = { beforeID: 0, beforeActiveUS: 0 };
export function AccountsPage({ navigate }: { navigate: Navigate }) {
const { t } = useI18n();
const [q, setQ] = useState("");
const [limit, setLimit] = useState<AccountPageSize>(50);
const [data, setData] = useState<AccountListResponse | null>(null);
@ -98,8 +96,8 @@ export function AccountsPage({ navigate }: { navigate: Navigate }) {
return (
<PageFrame
title={t("account.pageTitle")}
eyebrow={data?.listing === false ? t("account.queryResults") : t("account.recentActive")}
title={"Accounts"}
eyebrow={data?.listing === false ? "Search results" : "Recently active accounts"}
actions={
<button
className="btn"
@ -110,24 +108,24 @@ export function AccountsPage({ navigate }: { navigate: Navigate }) {
}}
disabled={busy}
>
<RefreshCw size={15} /> {t("common.refresh")}
<RefreshCw size={15} /> {"Refresh"}
</button>
}
>
{error && <Alert>{error}</Alert>}
<div className="metric-row">
<Metric label={t("account.totalUsers")} value={stats ? String(stats.total) : "…"} />
<Metric label={t("account.onlineNow")} value={stats ? String(stats.online) : "…"} tone="good" />
<Metric label={t("account.onlineDevices")} value={String(metrics.devices)} />
<Metric label={"Total users"} value={stats ? String(stats.total) : "…"} />
<Metric label={"Online now"} value={stats ? String(stats.online) : "…"} tone="good" />
<Metric label={"Online device records"} value={String(metrics.devices)} />
</div>
<QueryPanel>
<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={t("account.searchPlaceholder")} />
<input value={q} onChange={(event) => setQ(event.target.value)} placeholder={"User ID / phone / username"} />
</label>
<label className="gift-page-size">
<span>{t("common.limit")}</span>
<span>{"Limit"}</span>
<select value={String(limit)} onChange={(event) => setLimit(Number(event.target.value) as AccountPageSize)}>
<option value="10">10</option>
<option value="20">20</option>
@ -136,13 +134,13 @@ export function AccountsPage({ navigate }: { navigate: Navigate }) {
</select>
</label>
<button className="btn primary icon-text" type="submit" disabled={busy}>
{busy ? <Loader2 size={15} className="spin" /> : <Search size={15} />} {t("common.search")}
{busy ? <Loader2 size={15} className="spin" /> : <Search size={15} />} {"Search"}
</button>
<button className="btn icon-text" type="button" onClick={() => void loadPrev()} disabled={!canGoPrev}>
<ChevronLeft size={15} /> {t("common.previous")}
<ChevronLeft size={15} /> {"Previous page"}
</button>
<button className="btn icon-text" type="button" onClick={() => void loadNext()} disabled={!canGoNext}>
<ChevronRight size={15} /> {t("common.next")}
<ChevronRight size={15} /> {"Next page"}
</button>
</form>
</QueryPanel>
@ -151,17 +149,17 @@ export function AccountsPage({ navigate }: { navigate: Navigate }) {
<thead>
<tr>
<th className="avatar-col"></th>
<th>{t("account.userID")}</th>
<th>{t("account.phone")}</th>
<th>{t("common.username")}</th>
<th>{t("common.name")}</th>
<th>{t("account.loginEmail")}</th>
<th>{t("common.device")}</th>
<th>{t("account.lastActive")}</th>
<th>{t("account.premium")}</th>
<th>{t("common.verified")}</th>
<th>{t("account.frozen")}</th>
<th>{t("common.updatedAt")}</th>
<th>{"User ID"}</th>
<th>{"Phone"}</th>
<th>{"Username"}</th>
<th>{"Name"}</th>
<th>{"Login email"}</th>
<th>{"Device"}</th>
<th>{"Last active"}</th>
<th>{"Premium"}</th>
<th>{"Verified"}</th>
<th>{"Frozen"}</th>
<th>{"Updated"}</th>
<th></th>
</tr>
</thead>
@ -171,16 +169,16 @@ export function AccountsPage({ navigate }: { navigate: Navigate }) {
<td className="avatar-col"><Avatar userID={row.ID} firstName={row.FirstName} lastName={row.LastName} username={row.Username} /></td>
<td className="mono">{row.ID}</td>
<td>{displayPhone(row.Phone)}</td>
<td>{displayUsername(row.Username)}</td>
<td><UsernameCell username={row.Username} collectibles={row.Collectibles} /></td>
<td>{displayName(row)}</td>
<td>{row.LoginEmail || <span className="muted-cell">{t("common.none")}</span>}</td>
<td>{row.LoginEmail || <span className="muted-cell">{"None"}</span>}</td>
<td>{row.DeviceCount}</td>
<td>{formatDate(row.LastActiveAt)}</td>
<td>{row.PremiumUntil > 0 ? <Badge tone="good">{t("account.premium")} {formatUnix(row.PremiumUntil)}</Badge> : <Badge>{t("common.none")}</Badge>}</td>
<td>{row.Verified ? <Badge tone="good">{t("common.verified")}</Badge> : <Badge>{t("account.notVerified")}</Badge>} <ScamFakeBadges scam={row.Scam} fake={row.Fake} /></td>
<td>{row.Frozen ? <Badge tone="danger">{t("account.frozen")}</Badge> : <Badge>{t("common.normal")}</Badge>}</td>
<td>{row.PremiumUntil > 0 ? <Badge tone="good">{"Premium"} {formatUnix(row.PremiumUntil)}</Badge> : <Badge>{"None"}</Badge>}</td>
<td>{row.Verified ? <Badge tone="good">{"Verified"}</Badge> : <Badge>{"Not verified"}</Badge>} <ScamFakeBadges scam={row.Scam} fake={row.Fake} /></td>
<td>{row.Frozen ? <Badge tone="danger">{"Frozen"}</Badge> : <Badge>{"Normal"}</Badge>}</td>
<td>{formatDate(row.UpdatedAt)}</td>
<td><button className="row-link" onClick={() => navigate(`/accounts/${row.ID}`)}>{t("common.detail")} <ChevronRight size={14} /></button></td>
<td><button className="row-link" onClick={() => navigate(`/accounts/${row.ID}`)}>{"Details"} <ChevronRight size={14} /></button></td>
</tr>
))}
{(!data || data.rows.length === 0) && <EmptyRow colSpan={12} />}

View file

@ -5,13 +5,11 @@ import { ActionButton } from "../components/ActionButton";
import { Alert, AuditTable, Badge, LoadingSurface, PageFrame, SectionHead, SplitLayout, Summary } from "../components/ui";
import { ScamFakeActions, ScamFakeBadges } from "../components/flags";
import { ColorAction, EmojiStatusAction, UsernameAction } from "../components/attributes";
import { useI18n } from "../i18n";
import { displayUsername, formatDate } from "../lib/format";
import type { Navigate } from "../routing";
import type { BotDetail } from "../types";
export function BotDetailPage({ id, navigate }: { id: number; navigate: Navigate }) {
const { t } = useI18n();
const [detail, setDetail] = useState<BotDetail | null>(null);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
@ -36,51 +34,51 @@ export function BotDetailPage({ id, navigate }: { id: number; navigate: Navigate
return <Alert>{error}</Alert>;
}
if (!detail) {
return <LoadingSurface label={busy ? t("bots.loadingDetail") : t("account.waitingData")} />;
return <LoadingSurface label={busy ? "Loading bot detail" : "Waiting for data"} />;
}
const bot = detail.Bot;
return (
<PageFrame
title={t("bots.detailTitle", { id: bot.ID })}
eyebrow={t("bots.profile")}
actions={<button className="btn icon-text" onClick={() => navigate("/bots")}><ArrowLeft size={15} /> {t("common.backToList")}</button>}
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 || t("bots.unnamed")}</div>
<div className="entity-subtitle">{displayUsername(bot.Username) || t("account.noUsername")}</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 ? t("bots.system") : t("bots.user")}</Badge>
{bot.Verified ? <Badge tone="good">{t("common.verified")}</Badge> : <Badge>{t("account.notVerified")}</Badge>}
<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={t("bots.botID")} value={String(bot.ID)} mono />
<Summary label={t("bots.owner")} value={bot.OwnerUserID > 0 ? `${bot.OwnerUserID} ${displayUsername(detail.OwnerUsername)}`.trim() : t("common.none")} />
<Summary label={t("bots.type")} value={bot.System ? t("bots.system") : t("bots.user")} />
<Summary label={t("common.updatedAt")} value={formatDate(bot.UpdatedAt) || "-"} />
<Summary label={t("account.createdAt")} value={formatDate(bot.CreatedAt) || "-"} />
<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="section-block">
<SectionHead title={t("account.recentAdminOps")} text={t("account.recent30Audit")} />
<SectionHead title={"Recent Admin Actions"} text={"Last 30 audit rows"} />
<AuditTable rows={detail.AuditLogs} />
</section>
</div>
}
side={
<section className="action-dock">
<div className="dock-title">{t("bots.actionDock")}</div>
<div className="dock-title">{"Bot Actions"}</div>
<div className="action-stack">
<ActionButton
label={bot.Verified ? t("account.clearVerified") : t("account.setVerified")}
label={bot.Verified ? "Clear verified" : "Set verified"}
icon={<BadgeCheck size={15} />}
tone="neutral"
path="/api/actions/set-verified"
@ -89,23 +87,23 @@ export function BotDetailPage({ id, navigate }: { id: number; navigate: Navigate
/>
</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">{t("attr.attributes")}</div>
<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">{t("bots.systemHint")}</p>
<p className="bot-create-note">{"System bots are built in and cannot be deleted."}</p>
) : (
<div className="danger-zone">
<ActionButton
label={t("bots.delete")}
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">{t("bots.deleteHint")}</p>
<p className="bot-create-note">{"Permanently deletes this user-created bot and invalidates its token. This cannot be undone."}</p>
</div>
)}
</section>

View file

@ -0,0 +1,966 @@
import {
Ban,
BadgeCheck,
Building2,
ChevronDown,
ChevronRight,
ExternalLink,
Loader2,
Plus,
Power,
PowerOff,
RefreshCw,
Search,
Stamp,
Sticker,
Trash2
} from "lucide-react";
import { useEffect, useState, type ReactNode } from "react";
import { api, APIError, errorMessage } from "../api";
import { ActionButton } from "../components/ActionButton";
import { BotPicker } from "../components/EntityPicker";
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel, SectionHead } from "../components/ui";
import { displayUsername, formatDate } from "../lib/format";
import {
permissionBotVerificationManage,
permissionVerificationReview,
usePermissions
} from "../permissions";
import type { Navigate } from "../routing";
import type {
BotRow,
BotVerificationPeerType,
BotVerifierRow,
CustomVerificationRequestRow,
CustomVerificationRequestStatus,
CustomVerificationRow,
VerificationIconRow
} from "../types";
type Tab = "requests" | "verifiers" | "icons" | "marks";
type StatusFilter = "all" | CustomVerificationRequestStatus;
type PeerTypeFilter = "all" | BotVerificationPeerType;
const statuses: CustomVerificationRequestStatus[] = ["pending", "approved", "rejected", "revoked"];
const peerTypes: BotVerificationPeerType[] = ["user", "channel"];
export const statusLabels: Record<CustomVerificationRequestStatus, string> = {
pending: "Pending",
approved: "Approved",
rejected: "Rejected",
revoked: "Mark revoked"
};
export const peerTypeLabels: Record<BotVerificationPeerType, string> = {
user: "Account",
channel: "Channel"
};
// The section owns four different objects — applications, verifiers, the icon
// catalogue and the granted marks — and mixing them into one table would hide which
// row an action addresses. They are separate tabs over one shared verifier/icon
// load: the roster feeds three of the four filters, so it is fetched once here
// rather than per tab.
export function BotVerificationPage({ navigate }: { navigate: Navigate }) {
const { can } = usePermissions();
const canManage = can(permissionBotVerificationManage);
const canSeeOfficial = can(permissionVerificationReview);
const [tab, setTab] = useState<Tab>("requests");
const [verifiers, setVerifiers] = useState<BotVerifierRow[]>([]);
const [icons, setIcons] = useState<VerificationIconRow[]>([]);
const [error, setError] = useState("");
const [rosterDenied, setRosterDenied] = useState(false);
async function loadRoster() {
setError("");
setRosterDenied(false);
try {
const [verifierResult, iconResult] = await Promise.all([
api.botVerifiers(new URLSearchParams({ limit: "200" })),
api.verificationIcons(new URLSearchParams({ limit: "200" }))
]);
setVerifiers(verifierResult.rows ?? []);
setIcons(iconResult.rows ?? []);
} catch (err) {
// A 403 here is not a fault to alarm about: it means the session may review
// applications but not see the roster. Saying so beats an empty table that
// reads as "no verifiers configured".
if (err instanceof APIError && err.status === 403) {
setVerifiers([]);
setIcons([]);
setRosterDenied(true);
return;
}
setError(errorMessage(err));
}
}
useEffect(() => {
void loadRoster();
}, []);
const tabs: Array<{ key: Tab; label: string; icon: ReactNode }> = [
{ key: "requests", label: "Applications", icon: <Stamp size={15} /> },
{ key: "verifiers", label: "Verifiers", icon: <Building2 size={15} /> },
{ key: "icons", label: "Icon catalogue", icon: <Sticker size={15} /> },
{ key: "marks", label: "Granted marks", icon: <BadgeCheck size={15} /> }
];
return (
<PageFrame
title={"Third-party verification"}
eyebrow={"Third-party verification / Verifiers, icons, marks"}
actions={
canSeeOfficial ? (
<button className="btn icon-text" type="button" onClick={() => navigate("/verification")}>
<ExternalLink size={15} /> {"Official verification"}
</button>
) : undefined
}
>
{error && <Alert>{error}</Alert>}
{rosterDenied && <Alert>{"The server refused the verifier roster and the icon catalogue for this session (403), so both lists are empty here — applications can still be reviewed."}</Alert>}
{/* The one thing an operator has to understand before touching anything here:
this is a verifier company's own icon, not the platform checkmark. */}
<section className="section-block">
<SectionHead title={"A verifier company's icon — not the official checkmark"} text={"A third-party mark is a verifier bot's own icon, drawn right BEFORE the name of an account, a bot or a channel, plus one line of description in the profile. It says “this verifier vouches for this peer”, and nothing more."} />
<p className="bot-create-note">{"The icon is a custom emoji document. The client fetches it through messages.getCustomEmojiDocuments, so a document id that resolves to nothing renders as no badge at all — which is why marks are granted from the catalogue below rather than from a typed number."}</p>
<p className="bot-create-note">{"The official checkmark is a different mechanism, granted by the platform in the Verification section. The two are stored, shown and taken away separately, and neither one implies the other."}</p>
{!canManage && <p className="bot-create-note">{"This session can read the section and decide applications, but not change verifiers or the icon catalogue — that needs the botverification.manage permission."}</p>}
</section>
<div className="toolbar" role="group" aria-label={"Third-party verification"}>
{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 === "requests" && <RequestsBlock navigate={navigate} verifiers={verifiers} />}
{tab === "verifiers" && (
<VerifiersBlock
verifiers={verifiers}
icons={icons}
canManage={canManage}
onChanged={loadRoster}
navigate={navigate}
/>
)}
{tab === "icons" && (
<IconsBlock icons={icons} verifiers={verifiers} canManage={canManage} onChanged={loadRoster} />
)}
{tab === "marks" && <MarksBlock verifiers={verifiers} canManage={canManage} navigate={navigate} />}
</PageFrame>
);
}
// ---------------------------------------------------------------------------
// Applications
// ---------------------------------------------------------------------------
function RequestsBlock({ navigate, verifiers }: { navigate: Navigate; verifiers: BotVerifierRow[] }) {
const [status, setStatus] = useState<StatusFilter>("pending");
const [verifierBotID, setVerifierBotID] = useState("");
const [peerType, setPeerType] = useState<PeerTypeFilter>("all");
const [q, setQ] = useState("");
const [limit, setLimit] = useState("50");
const [rows, setRows] = useState<CustomVerificationRequestRow[]>([]);
const [counts, setCounts] = useState<Record<string, string>>({});
const [hasMore, setHasMore] = useState(false);
const [cursor, setCursor] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
// One free-text field: the backend matches the application id, the peer id and a
// username (applicant or peer), so "@durov", "42" and a peer id all work without a
// mode switch.
async function load(next = false) {
setBusy(true);
setError("");
const params = new URLSearchParams({ limit });
if (status !== "all") params.set("status", status);
if (verifierBotID) params.set("verifier_bot_id", verifierBotID);
if (peerType !== "all") params.set("peer_type", peerType);
if (q.trim()) params.set("q", q.trim().replace(/^@/, ""));
if (next && cursor) params.set("before_id", cursor);
try {
const result = await api.customVerificationRequests(params);
const page = result.rows ?? [];
setRows((current) => (next ? [...current, ...page] : page));
setCursor(result.next_before_id ?? "");
setHasMore(Boolean(result.has_more));
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
// The counts describe the whole queue, not the current page, so they are fetched
// separately from the keyset listing.
async function loadCounts() {
try {
const result = await api.botVerificationCounts();
setCounts(result.counts ?? {});
} catch (err) {
setError(errorMessage(err));
}
}
useEffect(() => {
void load(false);
void loadCounts();
}, []);
function refresh() {
void load(false);
void loadCounts();
}
return (
<>
<section className="section-block">
<SectionHead
title={"Application queue"}
text={"Applications filed with a verifier bot by the owner of the peer. The counters cover the whole queue, not the page below."}
action={
<button className="btn icon-text" type="button" onClick={refresh} disabled={busy}>
<RefreshCw size={15} className={busy ? "spin" : ""} /> {"Refresh"}
</button>
}
/>
{error && <Alert>{error}</Alert>}
<div className="metric-row">
{statuses.map((item) => (
<Metric
key={item}
label={statusLabels[item]}
value={counts[item] ?? "0"}
mono
tone={countTone(item, counts[item] ?? "0")}
/>
))}
</div>
</section>
<QueryPanel>
<form className="toolbar" onSubmit={(event) => { event.preventDefault(); void load(false); }}>
<label className="searchbox">
<Search size={15} />
<input value={q} onChange={(event) => setQ(event.target.value)} placeholder={"Application id, peer id, username or title"} />
</label>
<label className="field-inline">
<span>{"Status"}</span>
<select value={status} onChange={(event) => setStatus(event.target.value as StatusFilter)}>
<option value="all">{"All statuses"}</option>
{statuses.map((item) => (
<option key={item} value={item}>{statusLabels[item]}</option>
))}
</select>
</label>
<label className="field-inline">
<span>{"Verifier"}</span>
<VerifierOptions value={verifierBotID} verifiers={verifiers} onChange={setVerifierBotID} />
</label>
<label className="field-inline">
<span>{"Peer type"}</span>
<select value={peerType} onChange={(event) => setPeerType(event.target.value as PeerTypeFilter)}>
<option value="all">{"All types"}</option>
{peerTypes.map((item) => (
<option key={item} value={item}>{peerTypeLabels[item]}</option>
))}
</select>
</label>
<label className="field-inline">
<span>{"Limit"}</span>
<input className="small-input" value={limit} onChange={(event) => setLimit(event.target.value)} type="number" min="1" max="200" />
</label>
<button className="btn primary icon-text" type="submit" disabled={busy}>
{busy ? <Loader2 size={15} className="spin" /> : <Search size={15} />} {"Search"}
</button>
</form>
</QueryPanel>
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th>{"ID"}</th>
<th>{"Verifier"}</th>
<th>{"Peer"}</th>
<th>{"Applicant"}</th>
<th>{"Stated reason"}</th>
<th>{"Status"}</th>
<th>{"Filed"}</th>
<th></th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.ID}>
<td className="mono">
<button className="row-link" type="button" onClick={() => navigate(`/bot-verification/${row.ID}`)}>
#{row.ID}
</button>
</td>
<td>
<strong>{displayUsername(row.VerifierBotUsername) || row.VerifierBotID}</strong>
<div className="entity-subtitle mono">{row.VerifierBotID}</div>
</td>
<td>
<strong>{peerLabel(row)}</strong>
<div className="entity-subtitle mono">
{peerTypeLabels[row.PeerType]} · {row.PeerID}
</div>
</td>
<td>
{displayUsername(row.ApplicantUsername) || "-"}
<div className="entity-subtitle mono">{row.ApplicantUserID}</div>
</td>
<td className="truncate">{row.Reason || "-"}</td>
<td><RequestStatusBadge status={row.Status} /></td>
<td>{formatDate(row.CreatedAt) || "-"}</td>
<td>
<button className="row-link" type="button" onClick={() => navigate(`/bot-verification/${row.ID}`)}>
<Stamp size={14} /> {"Details"} <ChevronRight size={14} />
</button>
</td>
</tr>
))}
{rows.length === 0 && <EmptyRow colSpan={8} />}
</tbody>
</table>
</div>
{hasMore && (
<div className="toolbar">
<button className="btn icon-text" type="button" onClick={() => load(true)} disabled={busy}>
{busy ? <Loader2 size={15} className="spin" /> : <ChevronDown size={15} />} {"Load more"}
</button>
</div>
)}
</>
);
}
// ---------------------------------------------------------------------------
// Verifiers
// ---------------------------------------------------------------------------
function VerifiersBlock({
verifiers,
icons,
canManage,
onChanged,
navigate
}: {
verifiers: BotVerifierRow[];
icons: VerificationIconRow[];
canManage: boolean;
onChanged: () => void;
navigate: Navigate;
}) {
const [bot, setBot] = useState<BotRow | null>(null);
// editing carries the bot id of the row being updated: the grant endpoint is an
// upsert, and version is the optimistic lock of the row it overwrites. A fresh
// grant sends "0", which is what "there is no row yet" means.
const [editing, setEditing] = useState<BotVerifierRow | null>(null);
const [iconDocumentID, setIconDocumentID] = useState("");
const [company, setCompany] = useState("");
const [defaultDescription, setDefaultDescription] = useState("");
const [canModify, setCanModify] = useState(false);
const activeIcons = icons.filter((icon) => icon.Active);
// A verifier can hold an icon the operator has since retired. Editing that row must
// not silently swap the icon just because the select has no matching option, so the
// current document is kept in the list and labelled instead.
const iconOptions: Array<{ value: string; label: string }> = activeIcons.map((icon) => ({
value: icon.DocumentID,
label: `${icon.Name} · ${icon.DocumentID}`
}));
if (iconDocumentID && !iconOptions.some((option) => option.value === iconDocumentID)) {
const retired = icons.find((icon) => icon.DocumentID === iconDocumentID);
iconOptions.unshift({
value: iconDocumentID,
label: `${retired?.Name ?? iconDocumentID} · ${iconDocumentID} (${"Retired"})`
});
}
function startEdit(row: BotVerifierRow) {
setEditing(row);
setBot(null);
setIconDocumentID(row.IconDocumentID);
setCompany(row.CompanyName);
setDefaultDescription(row.DefaultDescription);
setCanModify(row.CanModifyCustomDescription);
}
function resetForm() {
setEditing(null);
setBot(null);
setIconDocumentID("");
setCompany("");
setDefaultDescription("");
setCanModify(false);
}
// int64 fields go out as decimal strings (the backend tags them `,string`), which
// is also the shape they arrived in, so nothing is re-parsed on the way back.
function grantPayload(): Record<string, unknown> {
const botID = editing ? editing.BotID : bot ? String(bot.ID) : "0";
return {
bot_id: botID,
icon_document_id: iconDocumentID || "0",
company_name: company.trim(),
default_description: defaultDescription.trim(),
can_modify_custom_description: canModify,
version: editing ? editing.Version : "0"
};
}
return (
<>
{canManage && (
<section className="section-block">
<SectionHead
title={editing ? "Update verifier" : "Grant verifier status"}
text={"The bot gets an icon from the catalogue and a company name to vouch under. The same call updates an existing verifier, which is why it carries a version."}
action={
editing ? (
<button className="btn icon-text" type="button" onClick={resetForm}>
{"Cancel update"}
</button>
) : undefined
}
/>
{editing ? (
<p className="bot-create-note">
{`Updating ${displayUsername(editing.BotUsername) || editing.BotID} — version ${editing.Version} is sent as the optimistic lock, so a row somebody else changed meanwhile is refused instead of overwritten.`}
</p>
) : (
<BotPicker label={"Bot"} value={bot} onChange={setBot} />
)}
<div className="bot-create-fields">
<label className="duration-field">
<span>{"Icon from the catalogue"}</span>
<select value={iconDocumentID} onChange={(event) => setIconDocumentID(event.target.value)}>
<option value="">{"Pick an icon"}</option>
{iconOptions.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</label>
<label className="duration-field">
<span>{"Company"}</span>
<input
value={company}
onChange={(event) => setCompany(event.target.value)}
placeholder={"Acme Verification Ltd"}
/>
</label>
<label className="duration-field">
<span>{"Default description"}</span>
<input
value={defaultDescription}
onChange={(event) => setDefaultDescription(event.target.value)}
placeholder={"Verified by Acme"}
/>
</label>
</div>
<label className="checkline">
<input type="checkbox" checked={canModify} onChange={(event) => setCanModify(event.target.checked)} />
{"The verifier may replace the description per peer"}
</label>
<p className="bot-create-note">{"This is botVerifierSettings.can_modify_custom_description: with it off, every mark this verifier grants carries the default description above, whatever the applicant asked for."}</p>
{activeIcons.length === 0 && <Alert>{"The catalogue has no active icon, so there is nothing to grant. Add one in the icon catalogue first."}</Alert>}
<div className="bot-create-actions">
<span className="bot-create-note">{"The bot can mark peers as soon as the row exists and is enabled."}</span>
<ActionButton
label={editing ? "Update verifier" : "Grant verifier status"}
icon={<Plus size={15} />}
tone="neutral"
path="/api/actions/grant-bot-verifier"
payload={grantPayload}
onDone={() => {
resetForm();
onChanged();
}}
/>
</div>
</section>
)}
<section className="section-block">
<SectionHead
title={"Verifier bots"}
text={"Bots allowed to hand out their own mark. Verifier status is granted per deployment, so every row here is a badge printer an operator switched on by hand."}
action={
<button className="btn icon-text" type="button" onClick={onChanged}>
<RefreshCw size={15} /> {"Refresh"}
</button>
}
/>
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th>{"Bot"}</th>
<th>{"Company"}</th>
<th>{"Icon"}</th>
<th>{"Own description"}</th>
<th>{"Status"}</th>
<th>{"Marks"}</th>
<th>{"Granted by"}</th>
<th>{"Updated"}</th>
{canManage && <th></th>}
</tr>
</thead>
<tbody>
{verifiers.map((row) => (
<tr key={row.BotID}>
<td>
<button className="row-link" type="button" onClick={() => navigate(`/bots/${row.BotID}`)}>
<strong>{displayUsername(row.BotUsername) || row.BotName || row.BotID}</strong>
</button>
<div className="entity-subtitle mono">{row.BotID}</div>
</td>
<td>
<strong>{row.CompanyName || "-"}</strong>
<div className="entity-subtitle truncate">{row.DefaultDescription || "Not set"}</div>
</td>
<td>
{row.IconName || "-"}
<div className="entity-subtitle mono">{row.IconDocumentID}</div>
</td>
<td>{row.CanModifyCustomDescription ? "Yes" : "No"}</td>
<td>
{row.Enabled
? <Badge tone="good">{"Enabled"}</Badge>
: <Badge tone="warn">{"disabled"}</Badge>}
</td>
<td className="mono">{String(row.MarkCount ?? "0")}</td>
<td>
{row.GrantedBy || "-"}
<div className="entity-subtitle truncate">{row.GrantReason || "-"}</div>
</td>
<td>{formatDate(row.UpdatedAt) || "-"}</td>
{canManage && (
<td>
<div className="row-actions">
<button className="btn compact-btn" type="button" onClick={() => startEdit(row)}>
{"Edit"}
</button>
<ActionButton
label={row.Enabled ? "Disable" : "Enable"}
icon={row.Enabled ? <PowerOff size={14} /> : <Power size={14} />}
tone={row.Enabled ? "warn" : "neutral"}
compact
path="/api/actions/set-bot-verifier-enabled"
payload={() => ({ bot_id: row.BotID, enabled: !row.Enabled })}
onDone={onChanged}
/>
<ActionButton
label={"Revoke status"}
icon={<Trash2 size={14} />}
tone="danger"
compact
path="/api/actions/revoke-bot-verifier"
payload={() => ({ bot_id: row.BotID })}
onDone={onChanged}
/>
</div>
</td>
)}
</tr>
))}
{verifiers.length === 0 && <EmptyRow colSpan={canManage ? 9 : 8} />}
</tbody>
</table>
</div>
<p className="bot-create-note">{"Disabling is the per-verifier kill switch: the marks already granted keep rendering, but the bot can no longer mark anything new and its settings stop being projected into botInfo."}</p>
<p className="bot-create-note">{"Revoking verifier status removes the row and every mark this verifier granted — the icon disappears from all of its peers at once."}</p>
</section>
</>
);
}
// ---------------------------------------------------------------------------
// Icon catalogue
// ---------------------------------------------------------------------------
function IconsBlock({
icons,
verifiers,
canManage,
onChanged
}: {
icons: VerificationIconRow[];
verifiers: BotVerifierRow[];
canManage: boolean;
onChanged: () => void;
}) {
const [documentID, setDocumentID] = useState("");
const [name, setName] = useState("");
const [ownerBotID, setOwnerBotID] = useState("");
// owner_bot_id is omitted entirely for a shared entry rather than sent as "" —
// `,string,omitempty` cannot decode an empty string.
function iconPayload(): Record<string, unknown> {
const payload: Record<string, unknown> = {
document_id: documentID.trim() || "0",
name: name.trim()
};
if (ownerBotID) payload.owner_bot_id = ownerBotID;
return payload;
}
return (
<>
{canManage && (
<section className="section-block">
<SectionHead title={"Add or rename an icon"} text={"The document id has to name a real custom emoji document on this deployment; the Emoji section lists them with their ids. Adding an id that already exists renames it instead of duplicating it."} />
<div className="bot-create-fields">
<label className="duration-field">
<span>{"Document ID"}</span>
<input
value={documentID}
onChange={(event) => setDocumentID(event.target.value)}
inputMode="numeric"
placeholder="5361371319611781774"
/>
</label>
<label className="duration-field">
<span>{"Name"}</span>
<input
value={name}
onChange={(event) => setName(event.target.value)}
placeholder={"Acme blue tick"}
/>
</label>
<label className="duration-field">
<span>{"Owner"}</span>
<select value={ownerBotID} onChange={(event) => setOwnerBotID(event.target.value)}>
<option value="">{"Shared"}</option>
{verifiers.map((row) => (
<option key={row.BotID} value={row.BotID}>
{`${row.CompanyName || row.BotID} · ${displayUsername(row.BotUsername) || row.BotID}`}
</option>
))}
</select>
</label>
</div>
<p className="bot-create-note">{"A document id that resolves to nothing produces an invisible badge: the peer is marked in the database and the client draws nothing."}</p>
<p className="bot-create-note">{"A shared icon may be granted to any verifier; picking an owner reserves it for that one bot."}</p>
<div className="bot-create-actions">
<span className="bot-create-note">{"Adding an icon grants nothing by itself — it only makes the document available to grant."}</span>
<ActionButton
label={"Save icon"}
icon={<Plus size={15} />}
tone="neutral"
path="/api/actions/upsert-verification-icon"
payload={iconPayload}
onDone={() => {
setDocumentID("");
setName("");
setOwnerBotID("");
onChanged();
}}
/>
</div>
</section>
)}
<section className="section-block">
<SectionHead
title={"Icon catalogue"}
text={"The custom emoji documents a verifier may mark with. Nothing else can be used as an icon, so the catalogue is where a wrong badge is prevented rather than fixed."}
action={
<button className="btn icon-text" type="button" onClick={onChanged}>
<RefreshCw size={15} /> {"Refresh"}
</button>
}
/>
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th>{"Document ID"}</th>
<th>{"Name"}</th>
<th>{"Owner"}</th>
<th>{"Status"}</th>
<th>{"Verifiers using it"}</th>
<th>{"Filed"}</th>
{canManage && <th></th>}
</tr>
</thead>
<tbody>
{icons.map((row) => (
<tr key={row.ID}>
<td className="mono">{row.DocumentID}</td>
<td><strong>{row.Name || "-"}</strong></td>
<td>
{row.OwnerBotID && row.OwnerBotID !== "0"
? <>
{displayUsername(row.OwnerBotUsername) || row.OwnerBotID}
<div className="entity-subtitle mono">{row.OwnerBotID}</div>
</>
: <Badge>{"Shared"}</Badge>}
</td>
<td>
{row.Active
? <Badge tone="good">{"Active"}</Badge>
: <Badge tone="warn">{"Retired"}</Badge>}
</td>
<td className="mono">{String(row.UsedByVerifiers ?? "0")}</td>
<td>{formatDate(row.CreatedAt) || "-"}</td>
{canManage && (
<td>
<div className="row-actions">
<ActionButton
label={row.Active ? "Retire" : "Activate"}
icon={row.Active ? <PowerOff size={14} /> : <Power size={14} />}
tone={row.Active ? "warn" : "neutral"}
compact
path="/api/actions/set-verification-icon-active"
payload={() => ({ icon_id: row.ID, active: !row.Active })}
onDone={onChanged}
/>
</div>
</td>
)}
</tr>
))}
{icons.length === 0 && <EmptyRow colSpan={canManage ? 7 : 6} />}
</tbody>
</table>
</div>
<p className="bot-create-note">{"Retiring an icon stops it from being granted to anybody new. Marks already carrying it keep it: the icon is copied onto the mark when it is granted."}</p>
</section>
</>
);
}
// ---------------------------------------------------------------------------
// Granted marks
// ---------------------------------------------------------------------------
function MarksBlock({
verifiers,
canManage,
navigate
}: {
verifiers: BotVerifierRow[];
canManage: boolean;
navigate: Navigate;
}) {
const [verifierBotID, setVerifierBotID] = useState("");
const [peerType, setPeerType] = useState<PeerTypeFilter>("all");
const [q, setQ] = useState("");
const [limit, setLimit] = useState("50");
const [rows, setRows] = useState<CustomVerificationRow[]>([]);
const [hasMore, setHasMore] = useState(false);
const [cursor, setCursor] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
async function load(next = false) {
setBusy(true);
setError("");
const params = new URLSearchParams({ limit });
if (verifierBotID) params.set("verifier_bot_id", verifierBotID);
if (peerType !== "all") params.set("peer_type", peerType);
if (q.trim()) params.set("q", q.trim().replace(/^@/, ""));
if (next && cursor) params.set("before_id", cursor);
try {
const result = await api.customVerifications(params);
const page = result.rows ?? [];
setRows((current) => (next ? [...current, ...page] : page));
setCursor(result.next_before_id ?? "");
setHasMore(Boolean(result.has_more));
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
useEffect(() => {
void load(false);
}, []);
return (
<>
<section className="section-block">
<SectionHead
title={"Granted marks"}
text={"Every peer currently carrying a third-party mark, whoever granted it — an operator decision, the verifier bot itself, or the peer's owner through bots.setCustomVerification."}
action={
<button className="btn icon-text" type="button" onClick={() => load(false)} disabled={busy}>
<RefreshCw size={15} className={busy ? "spin" : ""} /> {"Refresh"}
</button>
}
/>
{error && <Alert>{error}</Alert>}
</section>
<QueryPanel>
<form className="toolbar" onSubmit={(event) => { event.preventDefault(); void load(false); }}>
<label className="searchbox">
<Search size={15} />
<input value={q} onChange={(event) => setQ(event.target.value)} placeholder={"Peer id, username or title"} />
</label>
<label className="field-inline">
<span>{"Verifier"}</span>
<VerifierOptions value={verifierBotID} verifiers={verifiers} onChange={setVerifierBotID} />
</label>
<label className="field-inline">
<span>{"Peer type"}</span>
<select value={peerType} onChange={(event) => setPeerType(event.target.value as PeerTypeFilter)}>
<option value="all">{"All types"}</option>
{peerTypes.map((item) => (
<option key={item} value={item}>{peerTypeLabels[item]}</option>
))}
</select>
</label>
<label className="field-inline">
<span>{"Limit"}</span>
<input className="small-input" value={limit} onChange={(event) => setLimit(event.target.value)} type="number" min="1" max="200" />
</label>
<button className="btn primary icon-text" type="submit" disabled={busy}>
{busy ? <Loader2 size={15} className="spin" /> : <Search size={15} />} {"Search"}
</button>
</form>
</QueryPanel>
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th>{"ID"}</th>
<th>{"Verifier"}</th>
<th>{"Peer"}</th>
<th>{"Description"}</th>
<th>{"Icon"}</th>
<th>{"Filed"}</th>
{canManage && <th></th>}
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.ID}>
<td className="mono">#{row.ID}</td>
<td>
<strong>{row.CompanyName || displayUsername(row.VerifierBotUsername) || row.VerifierBotID}</strong>
<div className="entity-subtitle mono">
{displayUsername(row.VerifierBotUsername) || row.VerifierBotID}
</div>
</td>
<td>
<button className="row-link" type="button" onClick={() => navigate(peerHref(row.PeerType, row.PeerID))}>
<strong>{peerLabel(row)}</strong>
</button>
<div className="entity-subtitle mono">
{peerTypeLabels[row.PeerType]} · {row.PeerID}
</div>
</td>
<td className="truncate">{row.Description || "Not set"}</td>
<td className="mono">{row.IconDocumentID}</td>
<td>{formatDate(row.CreatedAt) || "-"}</td>
{canManage && (
<td>
<div className="row-actions">
<ActionButton
label={"Remove mark"}
icon={<Ban size={14} />}
tone="danger"
compact
path="/api/actions/revoke-custom-verification"
payload={() => ({
verifier_bot_id: row.VerifierBotID,
peer_type: row.PeerType,
peer_id: row.PeerID
})}
onDone={() => load(false)}
/>
</div>
</td>
)}
</tr>
))}
{rows.length === 0 && <EmptyRow colSpan={canManage ? 7 : 6} />}
</tbody>
</table>
</div>
<p className="bot-create-note">{"Removing a mark clears the icon and the description from the peer. The application it came from keeps its history."}</p>
{hasMore && (
<div className="toolbar">
<button className="btn icon-text" type="button" onClick={() => load(true)} disabled={busy}>
{busy ? <Loader2 size={15} className="spin" /> : <ChevronDown size={15} />} {"Load more"}
</button>
</div>
)}
</>
);
}
// ---------------------------------------------------------------------------
// Shared bits
// ---------------------------------------------------------------------------
// The verifier filter lists the roster rather than asking for a bot id: a company
// name is what an operator reads in the queue, and a disabled verifier still owns
// rows worth filtering by, so it stays in the list and is labelled instead.
function VerifierOptions({
value,
verifiers,
onChange
}: {
value: string;
verifiers: BotVerifierRow[];
onChange: (value: string) => void;
}) {
return (
<select value={value} onChange={(event) => onChange(event.target.value)}>
<option value="">{"All verifiers"}</option>
{verifiers.map((row) => (
<option key={row.BotID} value={row.BotID}>
{`${row.CompanyName || row.BotID} · ${displayUsername(row.BotUsername) || row.BotID}`
+ (row.Enabled ? "" : ` (${"disabled"})`)}
</option>
))}
</select>
);
}
export function RequestStatusBadge({ status }: { status: CustomVerificationRequestStatus }) {
return <Badge tone={statusTone(status)}>{statusLabels[status]}</Badge>;
}
export function statusTone(status: CustomVerificationRequestStatus): "neutral" | "good" | "warn" | "danger" {
if (status === "approved") return "good";
if (status === "pending") return "warn";
if (status === "rejected") return "danger";
return "neutral";
}
// pending is the only status that waits for somebody, so it is the only one
// highlighted — and only while something actually sits in it.
function countTone(status: CustomVerificationRequestStatus, count: string): "neutral" | "good" | "warn" {
if (status === "pending") return count !== "0" && count !== "" ? "warn" : "neutral";
return status === "approved" ? "good" : "neutral";
}
export function peerLabel(row: { PeerUsername: string; PeerTitle: string; PeerID: string }): string {
return displayUsername(row.PeerUsername) || row.PeerTitle || `#${row.PeerID}`;
}
// The panel page that owns the peer type. A third-party mark can sit on an ordinary
// account or on a bot — both are user rows, so both open the account page.
export function peerHref(peerType: BotVerificationPeerType, peerID: string): string {
return peerType === "channel" ? `/channels/${peerID}` : `/accounts/${peerID}`;
}

View file

@ -0,0 +1,358 @@
import {
ArrowLeft,
BadgeCheck,
Ban,
Building2,
CheckCircle2,
ExternalLink,
RefreshCw,
ShieldOff,
Stamp,
User,
XCircle
} from "lucide-react";
import { useEffect, useState, type ReactNode } from "react";
import { api, APIError, errorMessage } from "../api";
import { ActionButton } from "../components/ActionButton";
import { Alert, Badge, LoadingSurface, PageFrame, SectionHead, SplitLayout, Summary } from "../components/ui";
import { displayUsername, formatDate } from "../lib/format";
import type { Navigate } from "../routing";
import type { BotVerifierRow, CustomVerificationRequestDetail } from "../types";
import { RequestStatusBadge, peerHref, peerLabel, peerTypeLabels, statusLabels } from "./BotVerificationPage";
export function BotVerificationRequestPage({ id, navigate }: { id: string; navigate: Navigate }) {
const [detail, setDetail] = useState<CustomVerificationRequestDetail | null>(null);
const [note, setNote] = useState("");
const [conflict, setConflict] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
async function load() {
setBusy(true);
setError("");
try {
setDetail(await api.customVerificationRequest(id));
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
function refresh() {
setConflict(false);
void load();
}
useEffect(() => {
void load();
}, [id]);
// 409 is the one failure the operator cannot fix by editing the form: another
// admin decided against the version this page read. The panel says so in plain
// words and reloads, so the next attempt carries the current version.
function handleActionError(err: unknown): string | undefined {
if (err instanceof APIError && err.status === 409) {
setConflict(true);
void load();
return "Another admin has already changed this application. The data has been reloaded — check the status before deciding again.";
}
return undefined;
}
if (error && !detail) {
return <Alert>{error}</Alert>;
}
if (!detail) {
return <LoadingSurface label={"Loading the application…"} />;
}
const request = detail.request;
const verifier = liveVerifier(detail.verifier);
const markActive = detail.mark_active;
const canDecide = request.Status === "pending";
const canRevoke = request.Status === "approved";
const trimmedNote = note.trim();
// What the mark would actually say: the applicant's wording only when this
// verifier is allowed to override its own default, otherwise the default. Same
// rule the backend applies (BotVerifierSettings.DescriptionFor), shown here so a
// reviewer is not surprised by the text that ends up in the profile.
const requestedDescription = request.RequestedDescription.trim();
const descriptionAllowed = Boolean(verifier?.CanModifyCustomDescription) && requestedDescription !== "";
const effectiveDescription = descriptionAllowed
? requestedDescription
: (verifier?.DefaultDescription ?? "").trim();
// version is the optimistic-locking token: it goes with every decision, as the
// decimal string it arrived as, so a stale page cannot overwrite a fresh one.
function decisionPayload(): Record<string, unknown> {
const payload: Record<string, unknown> = { version: request.Version };
if (trimmedNote) payload.internal_note = trimmedNote;
return payload;
}
function afterDecision() {
setNote("");
setConflict(false);
void load();
}
return (
<PageFrame
title={`Application #${request.ID}`}
eyebrow={"Third-party verification / Review"}
actions={
<>
<button className="btn icon-text" type="button" onClick={() => navigate("/bot-verification")}>
<ArrowLeft size={15} /> {"Back to list"}
</button>
<button className="btn icon-text" type="button" onClick={refresh} disabled={busy}>
<RefreshCw size={15} className={busy ? "spin" : ""} /> {"Refresh"}
</button>
</>
}
>
{error && <Alert>{error}</Alert>}
{conflict && <Alert>{"Another admin has already changed this application. The data has been reloaded — check the status before deciding again."}</Alert>}
<SplitLayout
main={
<div className="stacked-sections">
<section className="entity-head">
<div>
<div className="entity-title">{peerLabel(request)}</div>
<div className="entity-subtitle mono">
#{request.ID} · {peerTypeLabels[request.PeerType]}:{request.PeerID} · v{request.Version}
</div>
</div>
<div className="entity-badges">
<RequestStatusBadge status={request.Status} />
{markActive
? <Badge tone="good"><BadgeCheck size={12} /> {"Mark is live"}</Badge>
: <Badge tone="neutral">{"No mark on the peer"}</Badge>}
</div>
</section>
{/* Repeated on the detail page on purpose: the decision an operator is
about to take grants a company's icon, not the platform badge. */}
<section className="section-block">
<SectionHead title={"A verifier company's icon — not the official checkmark"} text={"A third-party mark is a verifier bot's own icon, drawn right BEFORE the name of an account, a bot or a channel, plus one line of description in the profile. It says “this verifier vouches for this peer”, and nothing more."} />
<p className="bot-create-note">{"The icon is a custom emoji document. The client fetches it through messages.getCustomEmojiDocuments, so a document id that resolves to nothing renders as no badge at all — which is why marks are granted from the catalogue below rather than from a typed number."}</p>
</section>
<section className="section-block">
<SectionHead
title={"Verifier"}
text={"The company whose icon the peer would carry, as its row stands right now."}
action={
<button className="btn icon-text" type="button" onClick={() => navigate(`/bots/${request.VerifierBotID}`)}>
<Building2 size={15} /> {"Open verifier bot"}
</button>
}
/>
<div className="summary-grid">
<Summary label={"Company"} value={verifier?.CompanyName || "-"} />
<Summary label={"Bot"} value={displayUsername(request.VerifierBotUsername) || "-"} />
<Summary label={"Verifier bot ID"} value={request.VerifierBotID} mono />
<Summary label={"Document ID"} value={verifier?.IconDocumentID || "-"} mono />
<Summary label={"Name"} value={verifier?.IconName || "-"} />
<Summary
label={"Own description"}
value={verifier?.CanModifyCustomDescription ? "Yes" : "No"}
/>
</div>
<FieldBlock label={"Default description"}>
{verifier?.DefaultDescription
? <p className="about-text">{verifier.DefaultDescription}</p>
: <p className="bot-create-note">{"Not set"}</p>}
</FieldBlock>
{!verifier && <Alert>{"The verifier row is gone: its status was revoked after this application was filed. There is no icon to grant, so the application can only be rejected."}</Alert>}
{verifier && !verifier.Enabled && <Alert>{"This verifier is disabled. It cannot mark anything new until an operator enables it again."}</Alert>}
</section>
<section className="section-block">
<SectionHead
title={"Peer"}
text={"The account, bot or channel the icon would be attached to."}
action={
<button
className="btn icon-text"
type="button"
onClick={() => navigate(peerHref(request.PeerType, request.PeerID))}
>
<ExternalLink size={15} /> {"Open peer"}
</button>
}
/>
<div className="summary-grid">
<Summary label={"Type"} value={peerTypeLabels[request.PeerType]} />
<Summary label={"Username"} value={displayUsername(request.PeerUsername) || "-"} />
<Summary label={"Title"} value={request.PeerTitle || "-"} />
<Summary label={"Peer ID"} value={request.PeerID} mono />
</div>
</section>
<section className="section-block">
<SectionHead
title={"Applicant"}
text={"Who filed the application with the verifier bot."}
action={
<button
className="btn icon-text"
type="button"
onClick={() => navigate(`/accounts/${request.ApplicantUserID}`)}
>
<User size={15} /> {"Open account"}
</button>
}
/>
<div className="summary-grid">
<Summary label={"Username"} value={displayUsername(request.ApplicantUsername) || "-"} />
<Summary label={"User ID"} value={request.ApplicantUserID} mono />
<Summary label={"Filed"} value={formatDate(request.CreatedAt) || "-"} />
<Summary label={"Updated"} value={formatDate(request.UpdatedAt) || "-"} />
</div>
</section>
<section className="section-block">
<SectionHead title={"Application"} text={"What the applicant wrote, rendered as plain text."} />
<div className="stacked-sections">
<div className="summary-grid">
<Summary label={"Correlation ID"} value={request.CorrelationID || "-"} mono />
<Summary label={"Status"} value={statusLabels[request.Status]} />
</div>
<FieldBlock label={"Stated reason"}>
{request.Reason
? <p className="about-text">{request.Reason}</p>
: <p className="bot-create-note">{"Not set"}</p>}
</FieldBlock>
<FieldBlock label={"Requested description"}>
{requestedDescription
? <p className="about-text">{requestedDescription}</p>
: <p className="bot-create-note">{"Not set"}</p>}
</FieldBlock>
<FieldBlock label={"Description the mark would carry"}>
{effectiveDescription
? <p className="about-text">{effectiveDescription}</p>
: <p className="bot-create-note">{"Not set"}</p>}
</FieldBlock>
<p className="bot-create-note">{"Resolved the same way the backend resolves it: the applicant's wording only when this verifier may set its own description, otherwise the verifier's default."}</p>
{requestedDescription !== "" && !descriptionAllowed && (
<p className="bot-create-note">{"This verifier may not set a per-peer description, so the requested wording is ignored and the default is applied."}</p>
)}
</div>
</section>
<section className="section-block">
<SectionHead title={"Decision"} text={"What was decided, by whom, and with which wording."} />
<div className="stacked-sections">
<div className="summary-grid">
<Summary label={"Decided by"} value={request.DecidedBy || "-"} />
<Summary label={"Approved"} value={formatDate(request.ApprovedAt) || "-"} />
<Summary label={"Rejected"} value={formatDate(request.RejectedAt) || "-"} />
<Summary label={"Version (optimistic lock)"} value={request.Version} mono />
</div>
<FieldBlock label={"Decision reason"}>
{request.DecisionReason
? <p className="about-text">{request.DecisionReason}</p>
: <p className="bot-create-note">{"No decision yet"}</p>}
</FieldBlock>
{/* The internal note is the operator handover text and is labelled as
admin-only wherever it appears. */}
<FieldBlock label={`${"Internal note"} · ${"admins only"}`}>
{request.InternalNote
? <p className="about-text">{request.InternalNote}</p>
: <p className="bot-create-note">{"Not set"}</p>}
</FieldBlock>
</div>
</section>
</div>
}
side={
<section className="action-dock">
<div className="dock-title"><Stamp size={14} /> {"Decision"}</div>
{!canDecide && !canRevoke && <p className="bot-create-note">{"This status has no available actions."}</p>}
{(canDecide || canRevoke) && (
<>
<label className="duration-field">
<span>{"Internal note"}</span>
<textarea
value={note}
onChange={(event) => setNote(event.target.value)}
rows={3}
placeholder={"Handover note for other admins"}
/>
</label>
<p className="bot-create-note">{"Optional. Stored with the decision and visible to admins only — never sent to the applicant."}</p>
</>
)}
{canDecide && (
<>
{!verifier && <Alert>{"The verifier row is gone: its status was revoked after this application was filed. There is no icon to grant, so the application can only be rejected."}</Alert>}
{verifier && !verifier.Enabled && <Alert>{"This verifier is disabled. It cannot mark anything new until an operator enables it again."}</Alert>}
{markActive && <p className="bot-create-note">{"This peer already carries this verifier's mark; approving refreshes the description and records the decision."}</p>}
<div className="action-stack">
<ActionButton
label={"Approve"}
icon={<CheckCircle2 size={15} />}
tone="neutral"
path={`/api/botverification/requests/${request.ID}/approve`}
payload={decisionPayload}
onDone={afterDecision}
onError={handleActionError}
/>
<ActionButton
label={"Reject"}
icon={<XCircle size={15} />}
tone="warn"
path={`/api/botverification/requests/${request.ID}/reject`}
payload={decisionPayload}
onDone={afterDecision}
onError={handleActionError}
/>
</div>
<p className="bot-create-note">{"Puts the verifier's icon before the peer's name and its description in the profile, and messages the applicant."}</p>
<p className="bot-create-note">{"The reason is mandatory: it is the wording the applicant is told, so write what exactly was missing."}</p>
</>
)}
{canRevoke && (
<>
<div className="dock-title"><ShieldOff size={14} /> {"Danger zone"}</div>
<div className="danger-zone">
<ActionButton
label={"Revoke mark"}
icon={<Ban size={15} />}
tone="danger"
path={`/api/botverification/requests/${request.ID}/revoke`}
payload={decisionPayload}
onDone={afterDecision}
onError={handleActionError}
/>
<p className="bot-create-note">{"Takes the icon and the description off the peer and closes the application as revoked. The official checkmark, if the peer has one, is untouched."}</p>
{!markActive && <p className="bot-create-note">{"The peer carries no mark right now — revoking only closes the application."}</p>}
</div>
</>
)}
</section>
}
/>
</PageFrame>
);
}
function FieldBlock({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="duration-field">
<span>{label}</span>
{children}
</div>
);
}
// A verifier whose row was revoked after the application was filed can come back as
// null or as a zeroed record, depending on how the backend renders "gone". Both mean
// the same thing to a reviewer, so they collapse into one absent value here.
function liveVerifier(row: BotVerifierRow | null): BotVerifierRow | null {
if (!row) return null;
if (!row.BotID || row.BotID === "0") return null;
return row;
}

View file

@ -4,13 +4,11 @@ import { api, errorMessage } from "../api";
import { ActionButton } from "../components/ActionButton";
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
import { ScamFakeBadges } from "../components/flags";
import { useI18n } from "../i18n";
import { displayUsername, formatDate, toInt } from "../lib/format";
import type { Navigate } from "../routing";
import type { BotListResponse } from "../types";
export function BotsPage({ navigate }: { navigate: Navigate }) {
const { t } = useI18n();
const [q, setQ] = useState("");
const [limit, setLimit] = useState("50");
const [data, setData] = useState<BotListResponse | null>(null);
@ -52,31 +50,31 @@ export function BotsPage({ navigate }: { navigate: Navigate }) {
return (
<PageFrame
title={t("bots.pageTitle")}
eyebrow={data?.listing === false ? t("bots.queryResults") : t("bots.recent")}
title={"Bots"}
eyebrow={data?.listing === false ? "Search results" : "Recently created bots"}
actions={
<button className="btn" type="button" onClick={() => load(false)} disabled={busy}>
<RefreshCw size={15} /> {t("common.refresh")}
<RefreshCw size={15} /> {"Refresh"}
</button>
}
>
{error && <Alert>{error}</Alert>}
<div className="metric-row">
<Metric label={t("bots.currentPage")} value={String(rows.length)} />
<Metric label={t("common.verified")} value={String(verified)} tone="good" />
<Metric label={t("bots.system")} value={String(systemCount)} />
<Metric label={"Bots on page"} value={String(rows.length)} />
<Metric label={"Verified"} value={String(verified)} tone="good" />
<Metric label={"System"} value={String(systemCount)} />
</div>
<section className="section-block">
<div className="section-head">
<div>
<h2>{t("bots.createTitle")}</h2>
<p>{t("bots.createHint")}</p>
<h2>{"Create a system bot"}</h2>
<p>{"Provision a bot account owned by the given user. The token is shown once after confirmation."}</p>
</div>
</div>
<div className="bot-create-fields">
<label className="duration-field">
<span>{t("bots.ownerUserID")}</span>
<span>{"Owner user ID"}</span>
<input
value={ownerID}
onChange={(event) => setOwnerID(event.target.value)}
@ -86,18 +84,18 @@ export function BotsPage({ navigate }: { navigate: Navigate }) {
/>
</label>
<label className="duration-field">
<span>{t("bots.name")}</span>
<input value={botName} onChange={(event) => setBotName(event.target.value)} placeholder={t("bots.namePlaceholder")} maxLength={64} />
<span>{"Display name"}</span>
<input value={botName} onChange={(event) => setBotName(event.target.value)} placeholder={"e.g. Service Bot"} maxLength={64} />
</label>
<label className="duration-field">
<span>{t("bots.username")}</span>
<span>{"Username"}</span>
<input value={botUsername} onChange={(event) => setBotUsername(event.target.value)} placeholder="my_service_bot" />
</label>
</div>
<div className="bot-create-actions">
<span className="bot-create-note">{t("bots.usernameHint")}</span>
<span className="bot-create-note">{"Username must be 5-32 characters and end with 'bot'."}</span>
<ActionButton
label={t("bots.create")}
label={"Create bot"}
icon={<Plus size={15} />}
tone="neutral"
path="/api/actions/create-bot"
@ -115,18 +113,18 @@ export function BotsPage({ navigate }: { navigate: Navigate }) {
<form className="toolbar" onSubmit={(event) => { event.preventDefault(); void load(false); }}>
<label className="searchbox">
<Search size={15} />
<input value={q} onChange={(event) => setQ(event.target.value)} placeholder={t("bots.searchPlaceholder")} />
<input value={q} onChange={(event) => setQ(event.target.value)} placeholder={"Bot ID / username"} />
</label>
<label className="field-inline">
<span>{t("common.limit")}</span>
<span>{"Limit"}</span>
<input className="small-input" value={limit} onChange={(event) => setLimit(event.target.value)} type="number" min="1" max="100" />
</label>
<button className="btn primary icon-text" type="submit" disabled={busy}>
{busy ? <Loader2 size={15} className="spin" /> : <Search size={15} />} {t("common.search")}
{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} /> {t("messages.nextPage")}
<ChevronRight size={15} /> {"Next page"}
</button>
)}
</form>
@ -136,13 +134,13 @@ export function BotsPage({ navigate }: { navigate: Navigate }) {
<table className="data-table">
<thead>
<tr>
<th>{t("bots.botID")}</th>
<th>{t("common.username")}</th>
<th>{t("common.name")}</th>
<th>{t("bots.owner")}</th>
<th>{t("common.verified")}</th>
<th>{t("bots.type")}</th>
<th>{t("account.createdAt")}</th>
<th>{"Bot ID"}</th>
<th>{"Username"}</th>
<th>{"Name"}</th>
<th>{"Owner"}</th>
<th>{"Verified"}</th>
<th>{"Type"}</th>
<th>{"Created"}</th>
<th></th>
</tr>
</thead>
@ -153,10 +151,10 @@ export function BotsPage({ navigate }: { navigate: Navigate }) {
<td>{displayUsername(row.Username) || "-"}</td>
<td>{row.FirstName || "-"}</td>
<td className="mono">{row.OwnerUserID > 0 ? row.OwnerUserID : "-"}</td>
<td>{row.Verified ? <Badge tone="good"><BadgeCheck size={12} /> {t("common.verified")}</Badge> : <Badge>{t("account.notVerified")}</Badge>} <ScamFakeBadges scam={row.Scam} fake={row.Fake} /></td>
<td>{row.System ? <Badge tone="warn">{t("bots.system")}</Badge> : <Badge>{t("bots.user")}</Badge>}</td>
<td>{row.Verified ? <Badge tone="good"><BadgeCheck size={12} /> {"Verified"}</Badge> : <Badge>{"Not verified"}</Badge>} <ScamFakeBadges scam={row.Scam} fake={row.Fake} /></td>
<td>{row.System ? <Badge tone="warn">{"System"}</Badge> : <Badge>{"User"}</Badge>}</td>
<td>{formatDate(row.CreatedAt)}</td>
<td><button className="row-link" onClick={() => navigate(`/bots/${row.ID}`)}><Bot size={14} /> {t("common.detail")} <ChevronRight size={14} /></button></td>
<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} />}

View file

@ -3,7 +3,6 @@ import { useEffect, useState } 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 { useI18n } from "../i18n";
import { ScamFakeActions, ScamFakeBadges } from "../components/flags";
import { ChannelSettingsAction, ColorAction, EmojiStatusAction, UsernameAction } from "../components/attributes";
import { channelKind, displayUsername, formatDate, formatUnix } from "../lib/format";
@ -11,7 +10,6 @@ import type { Navigate } from "../routing";
import type { ChannelDetail } from "../types";
export function ChannelDetailPage({ id, navigate }: { id: number; navigate: Navigate }) {
const { t } = useI18n();
const [detail, setDetail] = useState<ChannelDetail | null>(null);
const [error, setError] = useState("");
@ -32,15 +30,15 @@ export function ChannelDetailPage({ id, navigate }: { id: number; navigate: Navi
return <Alert>{error}</Alert>;
}
if (!detail) {
return <LoadingSurface label={t("channel.loadingDetail")} />;
return <LoadingSurface label={"Loading channel detail"} />;
}
const ch = detail.Channel;
return (
<PageFrame
title={`${channelKind(ch, t)} #${ch.ID}`}
eyebrow={t("channel.detailProfile")}
actions={<button className="btn icon-text" onClick={() => navigate("/channels")}><ArrowLeft size={15} /> {t("common.backToList")}</button>}
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={
@ -48,41 +46,41 @@ export function ChannelDetailPage({ id, navigate }: { id: number; navigate: Navi
<section className="entity-head">
<div>
<div className="entity-title">{ch.Title || "-"}</div>
<div className="entity-subtitle">{displayUsername(ch.Username) || t("account.noUsername")} · {t("channel.creator", { id: ch.CreatorUserID })}</div>
<div className="entity-subtitle">{displayUsername(ch.Username) || "No username"} · {`Creator ${ch.CreatorUserID}`}</div>
</div>
<div className="entity-badges">
<Badge>{channelKind(ch, t)}</Badge>
{ch.Verified ? <Badge tone="good">{t("common.verified")}</Badge> : <Badge>{t("account.notVerified")}</Badge>}
<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">{t("common.deleted")}</Badge> : <Badge>{t("common.valid")}</Badge>}
{ch.Deleted ? <Badge tone="danger">{"Deleted"}</Badge> : <Badge>{"Valid"}</Badge>}
</div>
</section>
<div className="summary-grid">
<Summary label={t("channel.channelID")} value={String(ch.ID)} mono />
<Summary label={"Channel ID"} value={String(ch.ID)} mono />
<Summary label="access_hash" value={String(ch.AccessHash)} mono />
<Summary label={t("common.members")} value={`${ch.ParticipantsCount} / ${t("common.admins")} ${ch.AdminsCount}`} />
<Summary label={t("channel.governance")} value={t("channel.governanceValue", { banned: ch.BannedCount, kicked: ch.KickedCount })} />
<Summary label={t("channel.flags")} value={`broadcast=${ch.Broadcast} megagroup=${ch.Megagroup} forum=${ch.Forum}`} />
<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={t("account.createdAt")} value={formatUnix(ch.Date) || "-"} />
<Summary label={t("common.updatedAt")} value={formatDate(ch.UpdatedAt) || "-"} />
<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={t("account.recentAdminOps")} text={t("account.recent30Audit")} />
<SectionHead title={"Recent Admin Actions"} text={"Last 30 audit rows"} />
<AuditTable rows={detail.AuditLogs} />
</section>
<section className="section-block">
<SectionHead title={t("channel.rawRow")} text={t("channel.rawRowText")} />
<SectionHead title={"Channel Raw Row"} text={"Database read-only snapshot"} />
<JsonBlock value={detail.ChannelJSON} />
</section>
</div>
}
side={
<section className="action-dock">
<div className="dock-title">{t("channel.actionDock")}</div>
<div className="dock-title">{"Channel Actions"}</div>
<ActionButton
label={ch.Verified ? t("channel.clearVerified") : t("channel.setVerified")}
label={ch.Verified ? "Clear verified" : "Set verified"}
icon={<BadgeCheck size={15} />}
tone="warn"
path="/api/actions/set-channel-verified"
@ -90,9 +88,9 @@ export function ChannelDetailPage({ id, navigate }: { id: number; navigate: Navi
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">{t("attr.settings")}</div>
<div className="dock-title">{"Settings"}</div>
<ChannelSettingsAction channel={ch} onDone={load} />
<div className="dock-title">{t("attr.attributes")}</div>
<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} />

View file

@ -3,14 +3,12 @@ import { useEffect, useState } from "react";
import { api, errorMessage } from "../api";
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
import { ScamFakeBadges } from "../components/flags";
import { useI18n } from "../i18n";
import { channelKind, displayUsername, formatDate } from "../lib/format";
import { channelMetrics } from "../lib/metrics";
import type { Navigate } from "../routing";
import type { ChannelListResponse } from "../types";
export function ChannelsPage({ navigate }: { navigate: Navigate }) {
const { t } = useI18n();
const [q, setQ] = useState("");
const [limit, setLimit] = useState("50");
const [data, setData] = useState<ChannelListResponse | null>(null);
@ -50,37 +48,37 @@ export function ChannelsPage({ navigate }: { navigate: Navigate }) {
return (
<PageFrame
title={t("channel.pageTitle")}
eyebrow={data?.listing === false ? t("account.queryResults") : t("channel.recentUpdated")}
title={"Supergroups and Channels"}
eyebrow={data?.listing === false ? "Search results" : "Recently updated"}
actions={
<button className="btn" type="button" onClick={() => load(false)} disabled={busy}>
<RefreshCw size={15} /> {t("common.refresh")}
<RefreshCw size={15} /> {"Refresh"}
</button>
}
>
{error && <Alert>{error}</Alert>}
<div className="metric-row">
<Metric label={t("channel.currentPage")} value={String(data?.rows.length ?? 0)} />
<Metric label={t("channel.megagroups")} value={String(metrics.megagroups)} />
<Metric label={t("channel.broadcasts")} value={String(metrics.broadcasts)} />
<Metric label={t("channel.verifiedCount")} value={String(metrics.verified)} tone="good" />
<Metric label={"Entities on page"} value={String(data?.rows.length ?? 0)} />
<Metric label={"Supergroups"} value={String(metrics.megagroups)} />
<Metric label={"Channels"} value={String(metrics.broadcasts)} />
<Metric label={"Verified"} value={String(metrics.verified)} tone="good" />
</div>
<QueryPanel>
<form className="toolbar" onSubmit={(event) => { event.preventDefault(); void load(false); }}>
<label className="searchbox">
<Search size={15} />
<input value={q} onChange={(event) => setQ(event.target.value)} placeholder={t("channel.searchPlaceholder")} />
<input value={q} onChange={(event) => setQ(event.target.value)} placeholder={"Channel ID / username / title"} />
</label>
<label className="field-inline">
<span>{t("common.limit")}</span>
<span>{"Limit"}</span>
<input className="small-input" value={limit} onChange={(event) => setLimit(event.target.value)} type="number" min="1" max="100" />
</label>
<button className="btn primary icon-text" type="submit" disabled={busy}>
{busy ? <Loader2 size={15} className="spin" /> : <Search size={15} />} {t("common.search")}
{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} /> {t("messages.nextPage")}
<ChevronRight size={15} /> {"Next page"}
</button>
)}
</form>
@ -89,15 +87,15 @@ export function ChannelsPage({ navigate }: { navigate: Navigate }) {
<table className="data-table">
<thead>
<tr>
<th>{t("channel.channelID")}</th>
<th>{t("channel.kind")}</th>
<th>{t("common.username")}</th>
<th>{t("channel.title")}</th>
<th>{t("common.members")}</th>
<th>{t("common.admins")}</th>
<th>{"Channel ID"}</th>
<th>{"Kind"}</th>
<th>{"Username"}</th>
<th>{"Title"}</th>
<th>{"Members"}</th>
<th>{"Admins"}</th>
<th>PTS</th>
<th>{t("common.verified")}</th>
<th>{t("common.updatedAt")}</th>
<th>{"Verified"}</th>
<th>{"Updated"}</th>
<th></th>
</tr>
</thead>
@ -105,15 +103,15 @@ export function ChannelsPage({ navigate }: { navigate: Navigate }) {
{data?.rows.map((row) => (
<tr key={row.ID}>
<td className="mono">{row.ID}</td>
<td>{channelKind(row, t)}</td>
<td>{channelKind(row)}</td>
<td>{displayUsername(row.Username)}</td>
<td>{row.Title}</td>
<td>{row.ParticipantsCount}</td>
<td>{row.AdminsCount}</td>
<td>{row.PTS}</td>
<td>{row.Verified ? <Badge tone="good">{t("common.verified")}</Badge> : <Badge>{t("account.notVerified")}</Badge>} <ScamFakeBadges scam={row.Scam} fake={row.Fake} /></td>
<td>{row.Verified ? <Badge tone="good">{"Verified"}</Badge> : <Badge>{"Not verified"}</Badge>} <ScamFakeBadges scam={row.Scam} fake={row.Fake} /></td>
<td>{formatDate(row.UpdatedAt)}</td>
<td><button className="row-link" onClick={() => navigate(`/channels/${row.ID}`)}>{t("common.detail")} <ChevronRight size={14} /></button></td>
<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} />}

View file

@ -0,0 +1,257 @@
import { ArrowLeft, ArrowLeftRight, ExternalLink, Flame, Trash2, RefreshCw, Undo2 } from "lucide-react";
import { useEffect, useState } from "react";
import { api, errorMessage } from "../api";
import { ActionButton } from "../components/ActionButton";
import { ChannelPicker, UserPicker } from "../components/EntityPicker";
import { Alert, Badge, EmptyRow, LoadingSurface, PageFrame, SectionHead, SplitLayout, Summary } from "../components/ui";
import { displayUsername, formatCurrency, formatDate } from "../lib/format";
import type { Navigate } from "../routing";
import type {
AccountRow,
ChannelRow,
CollectibleUsernameDetail,
CollectibleUsernameTransferKind
} from "../types";
import { UsernameStatus, ownerLabel, priceLabel } from "./CollectibleUsernamesPage";
type RecipientKind = "user" | "channel";
export function CollectibleUsernameDetailPage({ id, navigate }: { id: string; navigate: Navigate }) {
const [detail, setDetail] = useState<CollectibleUsernameDetail | null>(null);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const [recipientKind, setRecipientKind] = useState<RecipientKind>("user");
const [recipientUser, setRecipientUser] = useState<AccountRow | null>(null);
const [recipientChannel, setRecipientChannel] = useState<ChannelRow | null>(null);
async function load() {
setBusy(true);
setError("");
try {
setDetail(await api.collectibleUsername(id));
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
useEffect(() => {
void load();
}, [id]);
if (error && !detail) {
return <Alert>{error}</Alert>;
}
if (!detail) {
return <LoadingSurface label={busy ? "Loading collectible username…" : "Waiting for data"} />;
}
const asset = detail.asset;
const transfers = detail.transfers ?? [];
const vaultLabel = "Vault";
const hasOwner = Boolean(asset.OwnerPeerType) && asset.OwnerPeerID !== "" && asset.OwnerPeerID !== "0";
const burned = asset.Status === "burned";
function openOwner() {
if (!hasOwner) return;
navigate(asset.OwnerPeerType === "channel" ? `/channels/${asset.OwnerPeerID}` : `/accounts/${asset.OwnerPeerID}`);
}
// Peer ids travel as decimal strings to match the backend `,string` tags.
function transferPayload(): Record<string, unknown> {
const payload: Record<string, unknown> = { username: asset.Username };
if (recipientKind === "user" && recipientUser) payload.to_user_id = String(recipientUser.ID);
if (recipientKind === "channel" && recipientChannel) payload.to_channel_id = String(recipientChannel.ID);
return payload;
}
return (
<PageFrame
title={`Collectible ${displayUsername(asset.Username)}`}
eyebrow={"NFT usernames / Asset"}
actions={
<>
<button className="btn icon-text" type="button" onClick={() => navigate("/collectible-usernames")}>
<ArrowLeft size={15} /> {"Back to list"}
</button>
<button className="btn icon-text" type="button" onClick={load} disabled={busy}>
<RefreshCw size={15} className={busy ? "spin" : ""} /> {"Refresh"}
</button>
</>
}
>
{error && <Alert>{error}</Alert>}
<SplitLayout
main={
<div className="stacked-sections">
<section className="entity-head">
<div>
<div className="entity-title">{displayUsername(asset.Username)}</div>
<div className="entity-subtitle">{`Asset #${asset.ID}`}</div>
</div>
<div className="entity-badges">
<UsernameStatus status={asset.Status} />
<Badge tone={asset.TransferCount > 0 ? "warn" : "neutral"}>
{`${asset.TransferCount} transfers`}
</Badge>
{asset.Status === "owned" && (
<Badge tone={asset.RegistryActive ? "good" : "warn"}>
{asset.RegistryActive ? "Active in profile" : "Hidden in profile"}
</Badge>
)}
</div>
</section>
<div className="summary-grid">
<Summary label={"Owner"} value={ownerLabel(asset, vaultLabel)} />
<Summary label={"Price"} value={priceLabel(asset)} mono />
<Summary label={"Purchase date (UTC)"} value={formatDate(asset.PurchaseDate) || "-"} />
<Summary
label={"Original owner"}
value={peerLabel(asset.OriginalOwnerPeerType, asset.OriginalOwnerPeerID, vaultLabel, asset.OriginalOwnerUsername)}
/>
<Summary label={"Transfers"} value={String(asset.TransferCount)} mono />
<Summary label={"Created"} value={formatDate(asset.CreatedAt) || "-"} />
<Summary label={"Updated"} value={formatDate(asset.UpdatedAt) || "-"} />
</div>
<div className="toolbar">
{hasOwner && (
<button className="row-link" type="button" onClick={openOwner}>
{asset.OwnerPeerType === "channel" ? "Open owner channel" : "Open owner account"}
</button>
)}
{asset.URL && (
<a className="row-link" href={asset.URL} target="_blank" rel="noreferrer noopener">
<ExternalLink size={14} /> {"Open marketplace page"}
</a>
)}
</div>
{!burned && (
<section className="section-block">
<SectionHead title={"Transfer ownership"} text={"Pick the recipient; the transfer is appended to the provenance history."} />
<div className="toolbar" role="group" aria-label={"Recipient type"}>
<button type="button" className={`btn ${recipientKind === "user" ? "primary" : ""}`} onClick={() => setRecipientKind("user")}>
{"To user"}
</button>
<button type="button" className={`btn ${recipientKind === "channel" ? "primary" : ""}`} onClick={() => setRecipientKind("channel")}>
{"To channel"}
</button>
</div>
{recipientKind === "user"
? <UserPicker label={"To user"} value={recipientUser} onChange={setRecipientUser} />
: <ChannelPicker label={"To channel"} value={recipientChannel} onChange={setRecipientChannel} />}
<div className="bot-create-actions">
<span className="bot-create-note">{"The current owner loses the username immediately after confirmation."}</span>
<ActionButton
label={"Transfer"}
icon={<ArrowLeftRight size={15} />}
tone="warn"
path="/api/actions/transfer-collectible-username"
payload={transferPayload}
onDone={load}
/>
</div>
</section>
)}
<section className="section-block">
<SectionHead title={"Provenance history"} text={"Mint, transfer, revoke and burn events in chronological order."} />
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th>{"ID"}</th>
<th>{"Event"}</th>
<th>{"From"}</th>
<th>{"To"}</th>
<th>{"Price"}</th>
<th>{"Actor"}</th>
<th>{"Reason"}</th>
<th>{"Time"}</th>
</tr>
</thead>
<tbody>
{transfers.map((row) => (
<tr key={row.ID}>
<td className="mono">{row.ID}</td>
<td><TransferKind kind={row.Kind} /></td>
<td className="mono">{peerLabel(row.FromPeerType, row.FromPeerID, vaultLabel, row.FromUsername)}</td>
<td className="mono">{peerLabel(row.ToPeerType, row.ToPeerID, vaultLabel, row.ToUsername)}</td>
<td className="mono">{row.Amount && row.Amount !== "0" ? formatCurrency(row.Amount, row.Currency) : "-"}</td>
<td>{row.Actor || "-"}</td>
<td className="truncate">{row.Reason || "-"}</td>
<td>{formatDate(row.CreatedAt) || "-"}</td>
</tr>
))}
{transfers.length === 0 && <EmptyRow colSpan={8} />}
</tbody>
</table>
</div>
</section>
</div>
}
side={
<section className="action-dock">
<div className="dock-title">{"Asset operations"}</div>
{burned ? (
<p className="bot-create-note">{"This username is burned — no further operations are possible."}</p>
) : (
<>
<div className="action-stack">
<ActionButton
label={"Revoke to vault"}
icon={<Undo2 size={15} />}
tone="warn"
path="/api/actions/revoke-collectible-username"
payload={() => ({ username: asset.Username, burn: false })}
onDone={load}
/>
</div>
<p className="bot-create-note">{"Takes the username away from its owner and returns it to the vault; it can be issued again later."}</p>
<div className="danger-zone">
<ActionButton
label={"Burn permanently"}
icon={<Flame size={15} />}
tone="danger"
path="/api/actions/revoke-collectible-username"
payload={() => ({ username: asset.Username, burn: true })}
onDone={load}
/>
<p className="bot-create-note">{"Irreversible: the username is destroyed and can never be issued again."}</p>
<ActionButton
label={"Delete record"}
icon={<Trash2 size={15} />}
tone="danger"
path="/api/actions/delete-collectible-username"
payload={() => ({ username: asset.Username })}
onDone={() => navigate("/collectible-usernames")}
/>
<p className="bot-create-note">{"Erases the asset and its ownership history, and frees the username for a fresh issue. Use this for a username issued by mistake; a burn keeps the history instead."}</p>
</div>
</>
)}
</section>
}
/>
</PageFrame>
);
}
const usernameKindLabels: Record<CollectibleUsernameTransferKind, string> = {
mint: "Mint",
transfer: "Transfer",
burn: "Burn",
revoke: "Revoke"
};
function TransferKind({ kind }: { kind: CollectibleUsernameTransferKind }) {
const tone = kind === "burn" ? "danger" : kind === "revoke" ? "warn" : kind === "mint" ? "good" : "neutral";
return <Badge tone={tone}>{usernameKindLabels[kind]}</Badge>;
}
function peerLabel(type: string, peerID: string, vaultLabel: string, username = ""): string {
if (!type || peerID === "" || peerID === "0") return vaultLabel;
const handle = displayUsername(username);
return handle ? `${handle} · ${type}:${peerID}` : `${type}:${peerID}`;
}

View file

@ -0,0 +1,302 @@
import { AtSign, ChevronDown, ChevronRight, Flame, Loader2, Plus, RefreshCw, Search, Vault } from "lucide-react";
import { useEffect, useState } from "react";
import { api, errorMessage } from "../api";
import { ActionButton } from "../components/ActionButton";
import { ChannelPicker, UserPicker } from "../components/EntityPicker";
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel, SectionHead } from "../components/ui";
import { currencyExponent, displayUsername, formatCurrency, formatDate, toSmallestUnits } from "../lib/format";
import type { Navigate } from "../routing";
import type {
AccountRow,
ChannelRow,
CollectibleCurrency,
CollectibleUsernameRow,
CollectibleUsernameStatus
} from "../types";
type StatusFilter = "all" | CollectibleUsernameStatus;
type OwnerKind = "vault" | "user" | "channel";
export function CollectibleUsernamesPage({ navigate }: { navigate: Navigate }) {
const [status, setStatus] = useState<StatusFilter>("all");
const [q, setQ] = useState("");
const [limit, setLimit] = useState("50");
const [rows, setRows] = useState<CollectibleUsernameRow[]>([]);
const [hasMore, setHasMore] = useState(false);
const [cursor, setCursor] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
// Mint form state.
const [ownerKind, setOwnerKind] = useState<OwnerKind>("vault");
const [owner, setOwner] = useState<AccountRow | null>(null);
const [ownerChannel, setOwnerChannel] = useState<ChannelRow | null>(null);
const [mintUsername, setMintUsername] = useState("");
const [currency, setCurrency] = useState<CollectibleCurrency>("XTR");
const [amount, setAmount] = useState("");
const [cryptoCurrency, setCryptoCurrency] = useState("");
const [cryptoAmount, setCryptoAmount] = useState("");
const [url, setUrl] = useState("");
const [purchaseDate, setPurchaseDate] = useState("");
const [purchaseTime, setPurchaseTime] = useState("");
async function load(next = false) {
setBusy(true);
setError("");
const params = new URLSearchParams({ limit });
if (status !== "all") params.set("status", status);
if (q.trim()) params.set("q", q.trim().replace(/^@/, ""));
if (next && cursor) params.set("before_id", cursor);
try {
const result = await api.collectibleUsernames(params);
const page = result.rows ?? [];
setRows((current) => (next ? [...current, ...page] : page));
setCursor(result.next_before_id ?? "");
setHasMore(Boolean(result.has_more));
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
useEffect(() => {
void load(false);
}, []);
const vaultCount = rows.filter((row) => row.Status === "vault").length;
const ownedCount = rows.filter((row) => row.Status === "owned").length;
const burnedCount = rows.filter((row) => row.Status === "burned").length;
// int64 request fields are sent as decimal strings (the backend tags them
// `,string`); purchase_date is Unix seconds. Optional owner keys are omitted
// entirely rather than sent empty, because `,string,omitempty` cannot decode "".
// Both amounts are typed in whole currency units and converted here: the API
// and fragment.collectibleInfo carry smallest units, so 900 TON has to leave
// the panel as 900000000000 nanotons or clients render 0.0000009.
const minorAmount = toSmallestUnits(amount, currency);
const minorCryptoAmount = cryptoCurrency ? toSmallestUnits(cryptoAmount, cryptoCurrency) : "0";
const amountInvalid = minorAmount === null;
const cryptoAmountInvalid = minorCryptoAmount === null;
function mintPayload(): Record<string, unknown> {
const payload: Record<string, unknown> = {
username: mintUsername.trim().replace(/^@/, ""),
currency,
amount: minorAmount ?? "0"
};
if (ownerKind === "user" && owner) payload.owner_user_id = String(owner.ID);
if (ownerKind === "channel" && ownerChannel) payload.owner_channel_id = String(ownerChannel.ID);
// The backend accepts either no crypto leg at all, or TON with a positive
// nanoton amount — never a currency without an amount.
if (cryptoCurrency) {
payload.crypto_currency = cryptoCurrency;
payload.crypto_amount = minorCryptoAmount ?? "0";
}
if (url.trim()) payload.url = url.trim();
if (purchaseDate) {
// fragment.collectibleInfo.purchase_date is a unix timestamp, and the date has
// always been read as UTC here. The time follows the same clock rather than the
// operator's local one, so adding it cannot silently shift what a date-only
// entry used to mean; the field label says UTC.
const parsed = Date.parse(`${purchaseDate}T${purchaseTime || "00:00"}:00Z`);
if (Number.isFinite(parsed)) payload.purchase_date = Math.floor(parsed / 1000);
}
return payload;
}
return (
<PageFrame
title={"Collectible usernames"}
eyebrow={"NFT usernames / Registry"}
actions={
<button className="btn icon-text" type="button" onClick={() => load(false)} disabled={busy}>
<RefreshCw size={15} className={busy ? "spin" : ""} /> {"Refresh"}
</button>
}
>
{error && <Alert>{error}</Alert>}
<div className="metric-row">
<Metric label={"Loaded rows"} value={String(rows.length)} />
<Metric label={"In vault"} value={String(vaultCount)} />
<Metric label={"Held by owners"} value={String(ownedCount)} tone="good" />
<Metric label={"Burned"} value={String(burnedCount)} tone={burnedCount ? "danger" : "neutral"} />
</div>
<section className="section-block">
<SectionHead title={"Mint a collectible username"} text={"Creates the asset together with its purchase record. Keep the owner as vault to mint it unassigned."} />
<div className="toolbar" role="group" aria-label={"Owner type"}>
<button type="button" className={`btn ${ownerKind === "vault" ? "primary" : ""}`} onClick={() => setOwnerKind("vault")}>
<Vault size={15} /> {"Vault (no owner)"}
</button>
<button type="button" className={`btn ${ownerKind === "user" ? "primary" : ""}`} onClick={() => setOwnerKind("user")}>
{"User owner"}
</button>
<button type="button" className={`btn ${ownerKind === "channel" ? "primary" : ""}`} onClick={() => setOwnerKind("channel")}>
{"Channel owner"}
</button>
</div>
{ownerKind === "user" && <UserPicker label={"User owner"} value={owner} onChange={setOwner} />}
{ownerKind === "channel" && <ChannelPicker label={"Channel owner"} value={ownerChannel} onChange={setOwnerChannel} />}
<div className="bot-create-fields">
<label className="duration-field">
<span>{"Username"}</span>
<input value={mintUsername} onChange={(event) => setMintUsername(event.target.value)} placeholder="durov" />
</label>
<label className="duration-field">
<span>{"Currency"}</span>
<select value={currency} onChange={(event) => setCurrency(event.target.value as CollectibleCurrency)}>
<option value="XTR">XTR</option>
<option value="TON">TON</option>
<option value="USD">USD</option>
</select>
</label>
<label className="duration-field">
<span>{`Amount (${currency})`}</span>
<input value={amount} onChange={(event) => setAmount(event.target.value)} inputMode="decimal" placeholder="1000" />
</label>
<label className="duration-field">
<span>{"Crypto currency"}</span>
<select value={cryptoCurrency} onChange={(event) => setCryptoCurrency(event.target.value)}>
<option value="">{"None"}</option>
<option value="TON">TON</option>
</select>
</label>
{cryptoCurrency !== "" && (
<label className="duration-field">
<span>{`Crypto amount (${cryptoCurrency})`}</span>
<input value={cryptoAmount} onChange={(event) => setCryptoAmount(event.target.value)} inputMode="decimal" placeholder="12.5" />
</label>
)}
<label className="duration-field">
<span>{"Marketplace URL"}</span>
<input value={url} onChange={(event) => setUrl(event.target.value)} placeholder="https://fragment.com/username/durov" />
</label>
<label className="duration-field">
<span>{"Purchase date (UTC)"}</span>
<input value={purchaseDate} onChange={(event) => setPurchaseDate(event.target.value)} type="date" />
</label>
<label className="duration-field">
<span>{"Purchase time (UTC)"}</span>
<input
value={purchaseTime}
onChange={(event) => setPurchaseTime(event.target.value)}
type="time"
step={60}
disabled={!purchaseDate}
/>
</label>
</div>
<p className="bot-create-note">
{`Amounts are typed in whole ${currency} and stored as the smallest units the API and fragment.collectibleInfo carry, so clients render the price you meant. Up to ${String(currencyExponent(currency))} decimal places. Clients will show: ${formatCurrency(minorAmount ?? "0", currency)}.`}
</p>
{amountInvalid && <Alert>{`That is not a valid ${currency} amount: digits only, with at most ${String(currencyExponent(currency))} decimal places.`}</Alert>}
{cryptoCurrency !== "" && cryptoAmountInvalid && (
<Alert>{`That is not a valid ${cryptoCurrency} amount: digits only, with at most ${String(currencyExponent(cryptoCurrency))} decimal places.`}</Alert>
)}
<div className="bot-create-actions">
<span className="bot-create-note">{"Username, currency and amount are required; the dry-run checks availability first."}</span>
<ActionButton
disabled={amountInvalid || cryptoAmountInvalid}
label={"Mint username"}
icon={<Plus size={15} />}
tone="neutral"
path="/api/actions/mint-collectible-username"
payload={mintPayload}
onDone={() => load(false)}
/>
</div>
</section>
<QueryPanel>
<form className="toolbar" onSubmit={(event) => { event.preventDefault(); void load(false); }}>
<label className="searchbox">
<Search size={15} />
<input value={q} onChange={(event) => setQ(event.target.value)} placeholder={"Search by username"} />
</label>
<label className="field-inline">
<span>{"Status"}</span>
<select value={status} onChange={(event) => setStatus(event.target.value as StatusFilter)}>
<option value="all">{"All statuses"}</option>
<option value="vault">{"Vault"}</option>
<option value="owned">{"Owned"}</option>
<option value="burned">{"Burned"}</option>
</select>
</label>
<label className="field-inline">
<span>{"Limit"}</span>
<input className="small-input" value={limit} onChange={(event) => setLimit(event.target.value)} type="number" min="1" max="200" />
</label>
<button className="btn primary icon-text" type="submit" disabled={busy}>
{busy ? <Loader2 size={15} className="spin" /> : <Search size={15} />} {"Search"}
</button>
</form>
</QueryPanel>
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th>{"Username"}</th>
<th>{"Status"}</th>
<th>{"Owner"}</th>
<th>{"Price"}</th>
<th>{"Purchase date (UTC)"}</th>
<th>{"Transfers"}</th>
<th>{"Updated"}</th>
<th></th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.ID}>
<td><strong>{displayUsername(row.Username)}</strong></td>
<td><UsernameStatus status={row.Status} /></td>
<td>{ownerLabel(row, "Vault")}</td>
<td className="mono">{priceLabel(row)}</td>
<td>{formatDate(row.PurchaseDate) || "-"}</td>
<td className="mono">{row.TransferCount}</td>
<td>{formatDate(row.UpdatedAt) || "-"}</td>
<td>
<button className="row-link" type="button" onClick={() => navigate(`/collectible-usernames/${row.ID}`)}>
<AtSign size={14} /> {"Details"} <ChevronRight size={14} />
</button>
</td>
</tr>
))}
{rows.length === 0 && <EmptyRow colSpan={8} />}
</tbody>
</table>
</div>
{hasMore && (
<div className="toolbar">
<button className="btn icon-text" type="button" onClick={() => load(true)} disabled={busy}>
{busy ? <Loader2 size={15} className="spin" /> : <ChevronDown size={15} />} {"Load more"}
</button>
</div>
)}
</PageFrame>
);
}
export function UsernameStatus({ status }: { status: CollectibleUsernameStatus }) {
if (status === "owned") return <Badge tone="good">{"Owned"}</Badge>;
if (status === "burned") return <Badge tone="danger"><Flame size={12} /> {"Burned"}</Badge>;
return <Badge><Vault size={12} /> {"Vault"}</Badge>;
}
export function ownerLabel(row: CollectibleUsernameRow, vaultLabel: string): string {
if (!row.OwnerPeerType || row.OwnerPeerID === "" || row.OwnerPeerID === "0") return vaultLabel;
const name = displayUsername(row.OwnerUsername) || row.OwnerName || row.OwnerPeerID;
return `${name} · ${row.OwnerPeerType}:${row.OwnerPeerID}`;
}
// priceLabel renders what a Telegram client will draw, not the stored integer:
// both legs are smallest units on the wire (see formatCurrency).
export function priceLabel(row: CollectibleUsernameRow): string {
const base = formatCurrency(row.Amount, row.Currency);
if (row.CryptoCurrency && row.CryptoAmount && row.CryptoAmount !== "0") {
return `${base} (${formatCurrency(row.CryptoAmount, row.CryptoCurrency)})`;
}
return base;
}

View file

@ -3,14 +3,11 @@ import { useState } from "react";
import { createPortal } from "react-dom";
import { api, errorMessage } from "../api";
import { Alert } from "../components/ui";
import { useI18n } from "../i18n";
// Real Telegram sticker/emoji packs are created with at least one item, so
// this form collects the title/short name plus a single starting file —
// exactly like CreateStickerSet's domain-level requirement. More stickers
// get added afterward from the pack's own preview modal.
export function CreateStickerSetModal({ kind, onClose, onCreated }: { kind: "stickers" | "emoji"; onClose: () => void; onCreated: () => void }) {
const { t } = useI18n();
const noun = kind === "emoji" ? "emoji" : "sticker";
const [title, setTitle] = useState("");
const [shortName, setShortName] = useState("");
@ -22,11 +19,11 @@ export function CreateStickerSetModal({ kind, onClose, onCreated }: { kind: "sti
async function submit() {
if (!title.trim() || !shortName.trim() || !emoji.trim() || !file) {
setError(t("stickers.createFieldsRequired", { noun }));
setError(`Title, short name, emoji and a first ${noun} file are required.`);
return;
}
if (!reason.trim()) {
setError(t("action.reasonRequired"));
setError("Please enter an operation reason");
return;
}
setBusy(true);
@ -50,33 +47,33 @@ export function CreateStickerSetModal({ kind, onClose, onCreated }: { kind: "sti
return createPortal(
<div className="modal-backdrop" role="presentation">
<section className="modal command-modal" role="dialog" aria-modal="true" aria-label={t("stickers.createTitle", { noun })}>
<section className="modal command-modal" role="dialog" aria-modal="true" aria-label={`Create a new ${noun} pack`}>
<div className="modal-head">
<div>
<div className="eyebrow">{t("stickers.createEyebrow")}</div>
<h2>{t("stickers.createTitle", { noun })}</h2>
<div className="eyebrow">{"New set"}</div>
<h2>{`Create a new ${noun} pack`}</h2>
</div>
<button className="icon-btn" type="button" onClick={onClose} disabled={busy} aria-label={t("action.close")}><X size={15} /></button>
<button className="icon-btn" type="button" onClick={onClose} disabled={busy} aria-label={"Close"}><X size={15} /></button>
</div>
<div className="command-body">
<div className="gift-fields-grid">
<label><span>{t("stickers.title")}</span><input value={title} maxLength={64} onChange={(event) => setTitle(event.target.value)} /></label>
<label><span>{t("stickers.shortName")}</span><input value={shortName} maxLength={32} onChange={(event) => setShortName(event.target.value)} placeholder={t("stickers.shortNamePlaceholder")} /></label>
<label><span>{t("stickers.emoji")}</span><input value={emoji} onChange={(event) => setEmoji(event.target.value)} placeholder={t("stickers.emojiPlaceholder")} /></label>
<label><span>{"Title"}</span><input value={title} maxLength={64} onChange={(event) => setTitle(event.target.value)} /></label>
<label><span>{"Short name"}</span><input value={shortName} maxLength={32} onChange={(event) => setShortName(event.target.value)} placeholder={"lowercase_short_name"} /></label>
<label><span>{"Emoji"}</span><input value={emoji} onChange={(event) => setEmoji(event.target.value)} placeholder={"e.g. 😀"} /></label>
</div>
<label className={`gift-file-picker ${file ? "has-file" : ""}`}>
<input type="file" accept=".tgs,.json,.webp,application/json,application/x-tgsticker,image/webp" onChange={(event) => setFile(event.target.files?.[0] ?? null)} />
<span className="gift-file-copy"><span className="gift-field-label">{t("stickers.firstSticker", { noun })}</span><strong>{file ? file.name : t("stickers.filePrompt")}</strong></span>
<span className="gift-file-action">{file ? t("gifts.changeFile") : t("gifts.chooseFile")}</span>
<span className="gift-file-copy"><span className="gift-field-label">{`First ${noun}`}</span><strong>{file ? file.name : "Choose a TGS, Lottie JSON, or WebP file"}</strong></span>
<span className="gift-file-action">{file ? "Change file" : "Choose file"}</span>
</label>
<label className="gift-reason-field"><span>{t("gifts.reason")}</span><input value={reason} placeholder={t("gifts.reasonPlaceholder")} onChange={(event) => setReason(event.target.value)} /></label>
<label className="gift-reason-field"><span>{"Audit reason"}</span><input value={reason} placeholder={"Briefly describe why this gift is being imported"} onChange={(event) => setReason(event.target.value)} /></label>
{error && <Alert>{error}</Alert>}
</div>
<div className="modal-actions">
<button className="btn" type="button" onClick={onClose} disabled={busy}>{t("common.close")}</button>
<button className="btn" type="button" onClick={onClose} disabled={busy}>{"Close"}</button>
<button className="btn primary" type="button" onClick={submit} disabled={busy}>
{busy ? <Loader2 className="spin" size={15} /> : <Upload size={15} />}
{t("stickers.create", { noun })}
{`Create ${noun} pack`}
</button>
</div>
</section>

View file

@ -2,34 +2,32 @@ import { CheckCircle2, ChevronRight, Clock3, FileJson, KeyRound, MessageSquareTe
import type { ReactNode } from "react";
import { AppLink } from "../components/AppLink";
import { StatusItem } from "../components/ui";
import { useI18n } from "../i18n";
import type { Navigate } from "../routing";
export function Dashboard({ navigate }: { navigate: Navigate }) {
const { t } = useI18n();
return (
<div className="dashboard-layout">
<section className="overview-band">
<div>
<div className="eyebrow">{t("dashboard.eyebrow")}</div>
<h2>{t("dashboard.title")}</h2>
<div className="eyebrow">{"Runtime Overview"}</div>
<h2>{"Console Overview"}</h2>
</div>
<div className="overview-metrics">
<StatusItem label={t("dashboard.readPath")} value={t("dashboard.readPathValue")} tone="neutral" />
<StatusItem label={t("dashboard.writePath")} value="Admin API" tone="good" />
<StatusItem label={t("dashboard.executionPolicy")} value={t("dashboard.dryRunFirst")} tone="warn" />
<StatusItem label={"Read path"} value={"PG read-only"} tone="neutral" />
<StatusItem label={"Write path"} value="Admin API" tone="good" />
<StatusItem label={"Execution policy"} value={"Dry-run first"} tone="warn" />
</div>
</section>
<div className="command-grid">
<Launcher icon={<Users />} title={t("route.accounts")} text={t("dashboard.accountsText")} href="/accounts" navigate={navigate} />
<Launcher icon={<ShieldCheck />} title={t("route.channels")} text={t("dashboard.channelsText")} href="/channels" navigate={navigate} />
<Launcher icon={<MessageSquareText />} title={t("route.messages")} text={t("dashboard.messagesText")} href="/messages" navigate={navigate} />
<Launcher icon={<Users />} title={"Accounts"} text={"Account status, premium, verification, sessions."} href="/accounts" navigate={navigate} />
<Launcher icon={<ShieldCheck />} title={"Supergroups and Channels"} text={"Public entities, member counts, verification state."} href="/channels" navigate={navigate} />
<Launcher icon={<MessageSquareText />} title={"Message Audit"} text={"Message boxes, updates, outbox state."} href="/messages" navigate={navigate} />
</div>
<section className="work-strip">
<div className="strip-item"><CheckCircle2 size={16} /><span>{t("dashboard.strip.dryRun")}</span></div>
<div className="strip-item"><KeyRound size={16} /><span>{t("dashboard.strip.token")}</span></div>
<div className="strip-item"><Clock3 size={16} /><span>{t("dashboard.strip.pagination")}</span></div>
<div className="strip-item"><FileJson size={16} /><span>{t("dashboard.strip.snapshot")}</span></div>
<div className="strip-item"><CheckCircle2 size={16} /><span>{"All dangerous actions start with dry-run"}</span></div>
<div className="strip-item"><KeyRound size={16} /><span>{"Browser never stores internal tokens"}</span></div>
<div className="strip-item"><Clock3 size={16} /><span>{"Lists use cursor pagination"}</span></div>
<div className="strip-item"><FileJson size={16} /><span>{"Detail pages retain raw state snapshots"}</span></div>
</section>
</div>
);

View file

@ -3,7 +3,6 @@ import { useEffect, useState } from "react";
import { api, errorMessage } from "../api";
import { StaticLottie } from "../components/StaticLottie";
import { Alert, Metric, PageFrame, QueryPanel } from "../components/ui";
import { useI18n } from "../i18n";
import type { EmojiListResponse, EmojiRow } from "../types";
function formatBytes(value: number): string {
@ -40,7 +39,6 @@ function EmojiPreview({ row }: { row: EmojiRow }) {
}
function EmojiCard({ row }: { row: EmojiRow }) {
const { t } = useI18n();
const [copied, setCopied] = useState(false);
async function copy() {
@ -58,18 +56,17 @@ function EmojiCard({ row }: { row: EmojiRow }) {
<div className="emoji-preview"><EmojiPreview row={row} /></div>
<div className="emoji-meta">
<span className="emoji-alt">{row.Alt || "—"}</span>
<button className="emoji-id" type="button" onClick={copy} title={t("emoji.copyID")}>
<button className="emoji-id" type="button" onClick={copy} title={"Copy document ID"}>
<span className="mono">{row.DocumentID}</span>
{copied ? <Check size={12} /> : <Copy size={12} />}
</button>
<span className="emoji-sub">{row.SetTitle || t("emoji.noSet")} · {formatBytes(row.Size)}</span>
<span className="emoji-sub">{row.SetTitle || "No set"} · {formatBytes(row.Size)}</span>
</div>
</div>
);
}
export function EmojiPage() {
const { t } = useI18n();
const [q, setQ] = useState("");
const [data, setData] = useState<EmojiListResponse | null>(null);
const [cursor, setCursor] = useState(0);
@ -104,37 +101,37 @@ export function EmojiPage() {
return (
<PageFrame
title={t("emoji.pageTitle")}
eyebrow={data?.listing === false ? t("emoji.queryResults") : t("emoji.recent")}
title={"Custom Emoji"}
eyebrow={data?.listing === false ? "Search results" : "Custom emoji catalog"}
actions={
<button className="btn" type="button" onClick={() => load(false)} disabled={busy}>
<RefreshCw size={15} /> {t("common.refresh")}
<RefreshCw size={15} /> {"Refresh"}
</button>
}
>
{error && <Alert>{error}</Alert>}
<div className="metric-row">
<Metric label={t("emoji.currentPage")} value={String(rows.length)} />
<Metric label={"Emoji on page"} value={String(rows.length)} />
</div>
<QueryPanel>
<form className="toolbar" onSubmit={(event) => { event.preventDefault(); void load(false); }}>
<label className="searchbox">
<Search size={15} />
<input value={q} onChange={(event) => setQ(event.target.value)} placeholder={t("emoji.searchPlaceholder")} />
<input value={q} onChange={(event) => setQ(event.target.value)} placeholder={"Document ID or emoji"} />
</label>
<button className="btn primary icon-text" type="submit" disabled={busy}>
{busy ? <Loader2 size={15} className="spin" /> : <Search size={15} />} {t("common.search")}
{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} /> {t("messages.nextPage")}
<ChevronRight size={15} /> {"Next page"}
</button>
)}
</form>
</QueryPanel>
<p className="about-text">{t("emoji.hint")}</p>
<p className="about-text">{"Document IDs here can be pasted into the Emoji status field on account, bot and channel profiles."}</p>
{rows.length === 0 ? (
<div className="empty-panel">{t("common.noResults")}</div>
<div className="empty-panel">{"No results"}</div>
) : (
<div className="emoji-grid">
{rows.map((row) => <EmojiCard key={row.DocumentID} row={row} />)}

View file

@ -4,7 +4,6 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { api, errorMessage } from "../api";
import { Alert, Badge } from "../components/ui";
import { useI18n } from "../i18n";
import type { CommandResult, StarGiftCollectibleAttributeRow, StarGiftCollectiblePreview, StarGiftRow } from "../types";
type AnimationData = Record<string, unknown>;
@ -106,8 +105,25 @@ async function parseAnimationFile(file: File): Promise<AnimationData> {
const colorNumber = (value: string) => Number.parseInt(value.replace("#", ""), 16);
const rarityLabel = (attribute: StarGiftCollectibleAttributeRow) => attribute.rarity_kind === "permille" ? `${attribute.rarity_permille}‰` : attribute.rarity_kind;
const collectibleGroupLabels: Record<"models" | "patterns", string> = {
models: "Models",
patterns: "Patterns"
};
const collectibleAttributeLabels: Record<"model" | "pattern" | "backdrop", string> = {
model: "Model",
pattern: "Pattern",
backdrop: "Backdrop"
};
const collectibleColorLabels: Record<"center" | "edge" | "pattern" | "text", string> = {
center: "Center",
edge: "Edge",
pattern: "Pattern",
text: "Text"
};
export function GiftCollectiblesModal({ gift, onClose, onPublished }: { gift: StarGiftRow; onClose: () => void; onPublished: () => void }) {
const { t } = useI18n();
const [active, setActive] = useState<StarGiftCollectiblePreview | null>(null);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
@ -160,11 +176,11 @@ export function GiftCollectiblesModal({ gift, onClose, onPublished }: { gift: St
}
function buildForm(confirm: boolean, commandID = "") {
if (!reason.trim()) throw new Error(t("action.reasonRequired"));
if (models.length < 2 || patterns.length < 2 || backdrops.length < 2) throw new Error(t("collectibles.minimumAttributes"));
if (!reason.trim()) throw new Error("Please enter an operation reason");
if (models.length < 2 || patterns.length < 2 || backdrops.length < 2) throw new Error("Models, patterns, and backdrops must each contain at least two attributes.");
const backdropIDs = backdrops.map((row) => Number(row.backdropID));
if (new Set(backdropIDs).size !== backdropIDs.length) throw new Error(t("collectibles.duplicateBackdropID"));
for (const row of [...models, ...patterns]) if (!row.file) throw new Error(t("collectibles.fileRequired"));
if (new Set(backdropIDs).size !== backdropIDs.length) throw new Error("Backdrop IDs must be unique within the pool.");
for (const row of [...models, ...patterns]) if (!row.file) throw new Error("Every model and pattern needs a TGS or Lottie file.");
const form = new FormData();
const animatedMetadata = (rows: AnimatedDraft[]) => rows.map((row) => ({ name: row.name.trim(), rarity_permille: Number(row.rarity), sort_order: Number(row.sortOrder), file_key: row.key }));
form.set("metadata", JSON.stringify({
@ -200,18 +216,18 @@ export function GiftCollectiblesModal({ gift, onClose, onPublished }: { gift: St
const renderAnimatedRows = (kind: "models" | "patterns", rows: AnimatedDraft[], setRows: (rows: AnimatedDraft[]) => void) => (
<section className="collectible-section">
<div className="collectible-section-head">
<div><strong>{t(`collectibles.${kind}`)}</strong><span>{t("collectibles.rarityHint")}</span></div>
<div className="collectible-section-tools"><Badge tone={rarityTotals[kind] > 0 ? "good" : "neutral"}>{rarityTotals[kind]}‰</Badge><button className="btn compact-btn" type="button" onClick={() => { setRows(rebalanceRarity([...rows, newAnimated(kind === "models" ? "model" : "pattern", rows.length)])); invalidate(); }}><Plus size={13} />{t("collectibles.addAttribute")}</button></div>
<div><strong>{collectibleGroupLabels[kind]}</strong><span>{"Permille values are relative regular-upgrade weights; their total does not need to equal 1000."}</span></div>
<div className="collectible-section-tools"><Badge tone={rarityTotals[kind] > 0 ? "good" : "neutral"}>{rarityTotals[kind]}‰</Badge><button className="btn compact-btn" type="button" onClick={() => { setRows(rebalanceRarity([...rows, newAnimated(kind === "models" ? "model" : "pattern", rows.length)])); invalidate(); }}><Plus size={13} />{"Add"}</button></div>
</div>
<div className="collectible-rows">
{rows.map((row, index) => <div className="collectible-row animated" key={row.key}>
<div className="collectible-row-index">{index + 1}</div>
<label><span>{t("common.name")}</span><input value={row.name} maxLength={128} onChange={(e) => updateAnimated(kind, row.key, { name: e.target.value })} /></label>
<label><span>{t("collectibles.rarity")}</span><input type="number" min="1" max="1000" value={row.rarity} onChange={(e) => updateAnimated(kind, row.key, { rarity: e.target.value })} /></label>
<label><span>{t("gifts.sortOrder")}</span><input type="number" value={row.sortOrder} onChange={(e) => updateAnimated(kind, row.key, { sortOrder: e.target.value })} /></label>
<label className="collectible-file"><span>{t("gifts.animation")}</span><input type="file" accept=".tgs,.json,.lottie,application/json,application/x-tgsticker" onChange={(e) => void chooseFile(kind, row, e.target.files?.[0] ?? null)} /><em><FileJson2 size={13} />{row.file?.name ?? t("gifts.chooseFile")}</em></label>
<label><span>{"Name"}</span><input value={row.name} maxLength={128} onChange={(e) => updateAnimated(kind, row.key, { name: e.target.value })} /></label>
<label><span>{"Rarity ‰"}</span><input type="number" min="1" max="1000" value={row.rarity} onChange={(e) => updateAnimated(kind, row.key, { rarity: e.target.value })} /></label>
<label><span>{"Sort order"}</span><input type="number" value={row.sortOrder} onChange={(e) => updateAnimated(kind, row.key, { sortOrder: e.target.value })} /></label>
<label className="collectible-file"><span>{"Animation file"}</span><input type="file" accept=".tgs,.json,.lottie,application/json,application/x-tgsticker" onChange={(e) => void chooseFile(kind, row, e.target.files?.[0] ?? null)} /><em><FileJson2 size={13} />{row.file?.name ?? "Choose file"}</em></label>
<div className="collectible-inline-preview">{row.animation ? <AnimationPreview data={row.animation} compact /> : <Sparkles size={16} />}</div>
<button className="icon-btn danger" type="button" disabled={rows.length <= 2} onClick={() => { setRows(rebalanceRarity(rows.filter((value) => value.key !== row.key))); invalidate(); }} aria-label={t("collectibles.remove")}><Trash2 size={14} /></button>
<button className="icon-btn danger" type="button" disabled={rows.length <= 2} onClick={() => { setRows(rebalanceRarity(rows.filter((value) => value.key !== row.key))); invalidate(); }} aria-label={"Remove attribute"}><Trash2 size={14} /></button>
{row.fileError && <span className="collectible-file-error">{row.fileError}</span>}
</div>)}
</div>
@ -219,51 +235,51 @@ export function GiftCollectiblesModal({ gift, onClose, onPublished }: { gift: St
);
return createPortal(<div className="modal-backdrop" role="presentation">
<section className="modal command-modal collectible-modal" role="dialog" aria-modal="true" aria-label={t("collectibles.title", { id: gift.GiftID })}>
<section className="modal command-modal collectible-modal" role="dialog" aria-modal="true" aria-label={`Collectible pool · Gift #${gift.GiftID}`}>
<div className="modal-head">
<div><div className="eyebrow">{t("collectibles.eyebrow")}</div><h2>{t("collectibles.title", { id: gift.GiftID })}</h2><p>{gift.Title || `Gift #${gift.GiftID}`}</p></div>
<button className="icon-btn" type="button" onClick={onClose} disabled={busy} aria-label={t("action.close")}><X size={15} /></button>
<div><div className="eyebrow">{"Unique gift attributes"}</div><h2>{`Collectible pool · Gift #${gift.GiftID}`}</h2><p>{gift.Title || `Gift #${gift.GiftID}`}</p></div>
<button className="icon-btn" type="button" onClick={onClose} disabled={busy} aria-label={"Close"}><X size={15} /></button>
</div>
<div className="command-body collectible-modal-body">
{loading ? <div className="collectible-loading"><Loader2 className="spin" />{t("common.loading")}</div> : active?.found ? <section className="collectible-active">
<div className="collectible-active-head"><div><Gem size={18} /><div><strong>{t("collectibles.activeRevision", { revision: active.revision ?? 0 })}</strong><span>{active.slug_prefix} · ⭐ {active.upgrade_stars} · {active.issued} / {active.supply_total}</span></div></div><Badge tone="good">{t("collectibles.published")}</Badge></div>
{loading ? <div className="collectible-loading"><Loader2 className="spin" />{"Loading"}</div> : active?.found ? <section className="collectible-active">
<div className="collectible-active-head"><div><Gem size={18} /><div><strong>{`Published revision ${active.revision ?? 0}`}</strong><span>{active.slug_prefix} · ⭐ {active.upgrade_stars} · {active.issued} / {active.supply_total}</span></div></div><Badge tone="good">{"Published"}</Badge></div>
<div className="collectible-active-grid">
{[...(active.models ?? []), ...(active.patterns ?? [])].map((attribute) => <article key={`${attribute.kind}-${attribute.id}`}><RemoteAnimation giftID={gift.GiftID} attribute={attribute} /><div><strong>{attribute.name}{attribute.crafted && <Badge>crafted</Badge>}</strong><span>{t(`collectibles.${attribute.kind}`)} · {rarityLabel(attribute)}</span></div></article>)}
{(active.backdrops ?? []).map((attribute) => <article key={`backdrop-${attribute.id}`}><div className="collectible-backdrop-preview" style={{ background: `radial-gradient(circle, #${(attribute.center_color ?? 0).toString(16).padStart(6, "0")}, #${(attribute.edge_color ?? 0).toString(16).padStart(6, "0")})`, color: `#${(attribute.text_color ?? 0xffffff).toString(16).padStart(6, "0")}` }}>Aa</div><div><strong>{attribute.name}</strong><span>{t("collectibles.backdrop")} · {rarityLabel(attribute)}</span></div></article>)}
{[...(active.models ?? []), ...(active.patterns ?? [])].map((attribute) => <article key={`${attribute.kind}-${attribute.id}`}><RemoteAnimation giftID={gift.GiftID} attribute={attribute} /><div><strong>{attribute.name}{attribute.crafted && <Badge>crafted</Badge>}</strong><span>{collectibleAttributeLabels[attribute.kind]} · {rarityLabel(attribute)}</span></div></article>)}
{(active.backdrops ?? []).map((attribute) => <article key={`backdrop-${attribute.id}`}><div className="collectible-backdrop-preview" style={{ background: `radial-gradient(circle, #${(attribute.center_color ?? 0).toString(16).padStart(6, "0")}, #${(attribute.edge_color ?? 0).toString(16).padStart(6, "0")})`, color: `#${(attribute.text_color ?? 0xffffff).toString(16).padStart(6, "0")}` }}>Aa</div><div><strong>{attribute.name}</strong><span>{"Backdrop"} · {rarityLabel(attribute)}</span></div></article>)}
</div>
</section> : <div className="collectible-empty"><Gem size={22} /><div><strong>{t("collectibles.noPool")}</strong><span>{t("collectibles.noPoolHint")}</span></div></div>}
</section> : <div className="collectible-empty"><Gem size={22} /><div><strong>{"No collectible pool published"}</strong><span>{"Publish models, patterns and backdrops to enable upgrades."}</span></div></div>}
<section className="collectible-definition">
<div className="collectible-definition-head"><div><strong>{t("collectibles.publishNew")}</strong><span>{t("collectibles.immutableHint")}</span></div><div className="gift-format-chips"><span>TGS</span><span>Lottie JSON</span></div></div>
<div className="collectible-definition-head"><div><strong>{"Publish a new immutable revision"}</strong><span>{"Dry-run checks every file and rarity total before the revision becomes active."}</span></div><div className="gift-format-chips"><span>TGS</span><span>Lottie JSON</span></div></div>
<div className="gift-fields-grid collectible-main-fields">
<label><span>{t("collectibles.upgradeStars")}</span><input type="number" min="1" value={upgradeStars} onChange={(e) => { setUpgradeStars(e.target.value); invalidate(); }} /></label>
<label><span>{t("collectibles.supply")}</span><input type="number" min="1" value={supplyTotal} onChange={(e) => { setSupplyTotal(e.target.value); invalidate(); }} /></label>
<label><span>{t("collectibles.slug")}</span><input value={slugPrefix} maxLength={48} onChange={(e) => { setSlugPrefix(e.target.value.toLowerCase()); invalidate(); }} /></label>
<label><span>{t("gifts.reason")}</span><input value={reason} maxLength={1000} placeholder={t("gifts.reasonPlaceholder")} onChange={(e) => setReason(e.target.value)} /></label>
<label><span>{"Upgrade price in Stars"}</span><input type="number" min="1" value={upgradeStars} onChange={(e) => { setUpgradeStars(e.target.value); invalidate(); }} /></label>
<label><span>{"Unique supply"}</span><input type="number" min="1" value={supplyTotal} onChange={(e) => { setSupplyTotal(e.target.value); invalidate(); }} /></label>
<label><span>{"Public slug prefix"}</span><input value={slugPrefix} maxLength={48} onChange={(e) => { setSlugPrefix(e.target.value.toLowerCase()); invalidate(); }} /></label>
<label><span>{"Audit reason"}</span><input value={reason} maxLength={1000} placeholder={"Briefly describe why this gift is being imported"} onChange={(e) => setReason(e.target.value)} /></label>
</div>
{renderAnimatedRows("models", models, setModels)}
{renderAnimatedRows("patterns", patterns, setPatterns)}
<section className="collectible-section">
<div className="collectible-section-head"><div><strong>{t("collectibles.backdrops")}</strong><span>{t("collectibles.colorHint")}</span></div><div className="collectible-section-tools"><Badge tone={rarityTotals.backdrops > 0 ? "good" : "neutral"}>{rarityTotals.backdrops}‰</Badge><button className="btn compact-btn" type="button" onClick={() => { setBackdrops(rebalanceRarity([...backdrops, newBackdrop(backdrops)])); invalidate(); }}><Plus size={13} />{t("collectibles.addAttribute")}</button></div></div>
<div className="collectible-section-head"><div><strong>{"Backdrops"}</strong><span>{"Colors are stored as 24-bit RGB values."}</span></div><div className="collectible-section-tools"><Badge tone={rarityTotals.backdrops > 0 ? "good" : "neutral"}>{rarityTotals.backdrops}‰</Badge><button className="btn compact-btn" type="button" onClick={() => { setBackdrops(rebalanceRarity([...backdrops, newBackdrop(backdrops)])); invalidate(); }}><Plus size={13} />{"Add"}</button></div></div>
<div className="collectible-rows">{backdrops.map((row, index) => <div className="collectible-row backdrop" key={row.key}>
<div className="collectible-row-index">{index + 1}</div>
<label><span>{t("common.name")}</span><input value={row.name} maxLength={128} onChange={(e) => { setBackdrops(backdrops.map((value) => value.key === row.key ? { ...value, name: e.target.value } : value)); invalidate(); }} /></label>
<label><span>{t("collectibles.backdropID")}</span><input type="number" min="0" value={row.backdropID} onChange={(e) => { setBackdrops(backdrops.map((value) => value.key === row.key ? { ...value, backdropID: e.target.value } : value)); invalidate(); }} /></label>
<label><span>{t("collectibles.rarity")}</span><input type="number" min="1" max="1000" value={row.rarity} onChange={(e) => { setBackdrops(backdrops.map((value) => value.key === row.key ? { ...value, rarity: e.target.value } : value)); invalidate(); }} /></label>
<label><span>{t("gifts.sortOrder")}</span><input type="number" value={row.sortOrder} onChange={(e) => { setBackdrops(backdrops.map((value) => value.key === row.key ? { ...value, sortOrder: e.target.value } : value)); invalidate(); }} /></label>
{(["center", "edge", "pattern", "text"] as const).map((field) => <label className="collectible-color" key={field}><span>{t(`collectibles.color.${field}`)}</span><input type="color" value={row[field]} onChange={(e) => { setBackdrops(backdrops.map((value) => value.key === row.key ? { ...value, [field]: e.target.value } : value)); invalidate(); }} /></label>)}
<label><span>{"Name"}</span><input value={row.name} maxLength={128} onChange={(e) => { setBackdrops(backdrops.map((value) => value.key === row.key ? { ...value, name: e.target.value } : value)); invalidate(); }} /></label>
<label><span>{"Backdrop ID"}</span><input type="number" min="0" value={row.backdropID} onChange={(e) => { setBackdrops(backdrops.map((value) => value.key === row.key ? { ...value, backdropID: e.target.value } : value)); invalidate(); }} /></label>
<label><span>{"Rarity ‰"}</span><input type="number" min="1" max="1000" value={row.rarity} onChange={(e) => { setBackdrops(backdrops.map((value) => value.key === row.key ? { ...value, rarity: e.target.value } : value)); invalidate(); }} /></label>
<label><span>{"Sort order"}</span><input type="number" value={row.sortOrder} onChange={(e) => { setBackdrops(backdrops.map((value) => value.key === row.key ? { ...value, sortOrder: e.target.value } : value)); invalidate(); }} /></label>
{(["center", "edge", "pattern", "text"] as const).map((field) => <label className="collectible-color" key={field}><span>{collectibleColorLabels[field]}</span><input type="color" value={row[field]} onChange={(e) => { setBackdrops(backdrops.map((value) => value.key === row.key ? { ...value, [field]: e.target.value } : value)); invalidate(); }} /></label>)}
<div className="collectible-backdrop-preview" style={{ background: `radial-gradient(circle, ${row.center}, ${row.edge})`, color: row.text }}>Aa</div>
<button className="icon-btn danger" type="button" disabled={backdrops.length <= 2} onClick={() => { setBackdrops(rebalanceRarity(backdrops.filter((value) => value.key !== row.key))); invalidate(); }} aria-label={t("collectibles.remove")}><Trash2 size={14} /></button>
<button className="icon-btn danger" type="button" disabled={backdrops.length <= 2} onClick={() => { setBackdrops(rebalanceRarity(backdrops.filter((value) => value.key !== row.key))); invalidate(); }} aria-label={"Remove attribute"}><Trash2 size={14} /></button>
</div>)}</div>
</section>
</section>
{error && <Alert>{error}</Alert>}
{preview && <div className="gift-validation"><div className="gift-validation-head"><CheckCircle2 size={17} /><div><strong>{t("collectibles.validationReady")}</strong><span>{t("collectibles.validationHint")}</span></div></div><pre>{JSON.stringify(preview.details, null, 2)}</pre></div>}
{preview && <div className="gift-validation"><div className="gift-validation-head"><CheckCircle2 size={17} /><div><strong>{"Attribute pool is valid"}</strong><span>{"Review the normalized assets, then publish this immutable revision."}</span></div></div><pre>{JSON.stringify(preview.details, null, 2)}</pre></div>}
</div>
<div className="modal-actions">
<button className="btn" type="button" onClick={onClose} disabled={busy}>{t("common.close")}</button>
<button className="btn" type="button" onClick={validate} disabled={busy}>{busy ? <Loader2 className="spin" size={15} /> : <ShieldCheck size={15} />}{t("gifts.validate")}</button>
<button className="btn primary" type="button" onClick={publish} disabled={busy || !preview}><Upload size={15} />{t("collectibles.publish")}</button>
<button className="btn" type="button" onClick={onClose} disabled={busy}>{"Close"}</button>
<button className="btn" type="button" onClick={validate} disabled={busy}>{busy ? <Loader2 className="spin" size={15} /> : <ShieldCheck size={15} />}{"Dry-run validation"}</button>
<button className="btn primary" type="button" onClick={publish} disabled={busy || !preview}><Upload size={15} />{"Publish revision"}</button>
</div>
</section>
</div>, document.body);

View file

@ -5,7 +5,6 @@ import { createPortal } from "react-dom";
import { api, APIError, errorMessage } from "../api";
import { ActionButton } from "../components/ActionButton";
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
import { useI18n } from "../i18n";
import { formatDate } from "../lib/format";
import type { CommandResult, DefaultGiftRow, OfficialStarGiftRow, StarGiftRow } from "../types";
import { GiftCollectiblesModal } from "./GiftCollectiblesModal";
@ -13,6 +12,13 @@ import { GiftCollectiblesModal } from "./GiftCollectiblesModal";
type OfficialGiftCategory = "all" | "upgrade" | "craft" | "basic";
type GiftPageSize = 10 | 20 | 50 | 100 | "all";
const officialCategoryLabels: Record<OfficialGiftCategory, string> = {
all: "All",
upgrade: "Upgradable",
craft: "Craftable",
basic: "Not upgradable"
};
// The demo pool only has 3 placeholder gifts left after pruning to one per
// capability tier (Spark/Star/Coin); hide the tab until real custom designs
// replace them. Flip back to true to re-enable.
@ -105,7 +111,6 @@ function OfficialLottiePreview({ sourceGiftID }: { sourceGiftID: string }) {
}
export function GiftsPage() {
const { t } = useI18n();
const [gifts, setGifts] = useState<StarGiftRow[]>([]);
const [query, setQuery] = useState("");
const [importOpen, setImportOpen] = useState(false);
@ -236,7 +241,7 @@ export function GiftsPage() {
async function bulkSetEnabled(nextEnabled: boolean) {
if (!bulkReason.trim()) {
setBulkError(t("action.reasonRequired"));
setBulkError("Please enter an operation reason");
return;
}
setBulkBusy(true);
@ -257,7 +262,7 @@ export function GiftsPage() {
}
setBulkBusy(false);
if (failed > 0) {
setBulkError(t("gifts.bulkStatusFailed", { failed, total: ids.length }));
setBulkError(`${failed} of ${ids.length} failed`);
} else {
setSelected(new Set());
setBulkReason("");
@ -266,8 +271,8 @@ export function GiftsPage() {
}
function uploadForm(confirm: boolean, commandID = "") {
if (!file) throw new Error(t("gifts.fileRequired"));
if (!reason.trim()) throw new Error(t("action.reasonRequired"));
if (!file) throw new Error("Choose a TGS or Lottie file first");
if (!reason.trim()) throw new Error("Please enter an operation reason");
const form = new FormData();
form.set("metadata", JSON.stringify({
command_id: commandID,
@ -285,14 +290,14 @@ export function GiftsPage() {
}
function defaultPayload(confirm: boolean, commandID = "") {
if (!selectedDefaultID) throw new Error(t("gifts.defaultRequired"));
if (!reason.trim()) throw new Error(t("action.reasonRequired"));
if (!selectedDefaultID) throw new Error("Choose a default gift first");
if (!reason.trim()) throw new Error("Please enter an operation reason");
return { command_id: commandID, reason: reason.trim(), confirm, id: selectedDefaultID };
}
function officialPayload(confirm: boolean, commandID = "") {
if (!sourceGiftID) throw new Error(t("gifts.officialRequired"));
if (!reason.trim()) throw new Error(t("action.reasonRequired"));
if (!sourceGiftID) throw new Error("Choose an official gift first");
if (!reason.trim()) throw new Error("Please enter an operation reason");
return {
command_id: commandID, reason: reason.trim(), confirm,
source_gift_id: sourceGiftID, gift_id: giftID, title: title.trim(),
@ -304,7 +309,7 @@ export function GiftsPage() {
function chooseOfficial(gift: OfficialStarGiftRow) {
setSourceGiftID(gift.source_gift_id);
setTitle(gift.title || t("gifts.officialUnnamed", { id: gift.source_gift_id }));
setTitle(gift.title || `Unnamed official gift #${gift.source_gift_id}`);
setStars(String(gift.stars));
setConvertStars(String(gift.convert_stars));
setIncludeCollectible(gift.can_upgrade);
@ -343,7 +348,7 @@ export function GiftsPage() {
async function runBulkImport() {
if (!bulkImportOpen) return;
if (!bulkImportReason.trim()) { setBulkImportError(t("action.reasonRequired")); return; }
if (!bulkImportReason.trim()) { setBulkImportError("Please enter an operation reason"); return; }
const source = bulkImportOpen;
setBulkImportBusy(true); setBulkImportError(""); setBulkImportResult(null);
setBulkImportProgress({ done: 0, total: bulkImportItems.length });
@ -440,60 +445,60 @@ export function GiftsPage() {
: Boolean(file);
return (
<PageFrame title={t("gifts.pageTitle")} eyebrow={t("gifts.eyebrow")} actions={<>
<button className="btn" type="button" onClick={() => load()} disabled={busy}><RefreshCw size={15} /> {t("common.refresh")}</button>
<button className="btn primary" type="button" onClick={startImport}><Plus size={15} /> {t("gifts.add")}</button>
<PageFrame title={"Star Gift Catalog"} eyebrow={"Catalog, immutable revisions and animation assets"} actions={<>
<button className="btn" type="button" onClick={() => load()} disabled={busy}><RefreshCw size={15} /> {"Refresh"}</button>
<button className="btn primary" type="button" onClick={startImport}><Plus size={15} /> {"Add gift"}</button>
</>}>
{error && <Alert>{error}</Alert>}
<div className="metric-row gift-metrics">
<Metric label={t("gifts.total")} value={String(gifts.length)} />
<Metric label={t("gifts.enabled")} value={String(gifts.filter((gift) => gift.Enabled).length)} tone="good" />
<Metric label={t("gifts.received")} value={gifts.reduce((sum, gift) => sum + BigInt(gift.ReceivedCount), 0n).toString()} />
<Metric label={t("gifts.formats")} value="TGS / Lottie" />
<Metric label={"Catalog entries"} value={String(gifts.length)} />
<Metric label={"Enabled"} value={String(gifts.filter((gift) => gift.Enabled).length)} tone="good" />
<Metric label={"Received gifts"} value={gifts.reduce((sum, gift) => sum + BigInt(gift.ReceivedCount), 0n).toString()} />
<Metric label={"Accepted formats"} value="TGS / Lottie" />
</div>
<QueryPanel>
<div className="toolbar">
<label className="searchbox"><Search size={15} /><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder={t("gifts.searchPlaceholder")} /></label>
<label className="gift-page-size"><span>{t("gifts.perPage")}</span>
<label className="searchbox"><Search size={15} /><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder={"Search gift ID, title or format"} /></label>
<label className="gift-page-size"><span>{"Per page"}</span>
<select value={String(pageSize)} onChange={(event) => setPageSize(event.target.value === "all" ? "all" : (Number(event.target.value) as GiftPageSize))}>
<option value="10">10</option>
<option value="20">20</option>
<option value="50">50</option>
<option value="100">100</option>
<option value="all">{t("gifts.perPageAll")}</option>
<option value="all">{"All"}</option>
</select>
</label>
<span className="gift-list-summary">{t("gifts.listSummary", { shown: visibleGifts.length, total: gifts.length })}</span>
<span className="gift-list-summary">{`Showing ${visibleGifts.length} of ${gifts.length}`}</span>
</div>
</QueryPanel>
{selected.size > 0 && <div className="gift-bulk-toolbar">
<span className="gift-bulk-count">{t("gifts.bulkSelected", { count: selected.size })}</span>
<label className="gift-reason-field gift-bulk-reason"><span>{t("gifts.reason")}</span><input value={bulkReason} placeholder={t("gifts.reasonPlaceholder")} onChange={(e) => setBulkReason(e.target.value)} /></label>
<span className="gift-bulk-count">{`${selected.size} selected`}</span>
<label className="gift-reason-field gift-bulk-reason"><span>{"Audit reason"}</span><input value={bulkReason} placeholder={"Briefly describe why this gift is being imported"} onChange={(e) => setBulkReason(e.target.value)} /></label>
<button className="btn" type="button" onClick={() => bulkSetEnabled(true)} disabled={bulkBusy}>
{bulkBusy ? <Loader2 className="spin" size={14} /> : <CheckCircle2 size={14} />} {t("gifts.bulkEnable")}
{bulkBusy ? <Loader2 className="spin" size={14} /> : <CheckCircle2 size={14} />} {"Enable selected"}
</button>
<button className="btn" type="button" onClick={() => bulkSetEnabled(false)} disabled={bulkBusy}>
{bulkBusy ? <Loader2 className="spin" size={14} /> : <Pause size={14} />} {t("gifts.bulkDisable")}
{bulkBusy ? <Loader2 className="spin" size={14} /> : <Pause size={14} />} {"Disable selected"}
</button>
<button className="btn" type="button" onClick={() => { setSelected(new Set()); setBulkError(""); }} disabled={bulkBusy}>{t("common.close")}</button>
<button className="btn" type="button" onClick={() => { setSelected(new Set()); setBulkError(""); }} disabled={bulkBusy}>{"Close"}</button>
{bulkError && <span className="gift-bulk-error">{bulkError}</span>}
</div>}
<div className="table-wrap gift-table-wrap">
<table className="data-table gift-table">
<thead><tr><th className="gift-select-col"><input type="checkbox" checked={allVisibleSelected} onChange={toggleSelectAllVisible} aria-label={t("gifts.bulkSelectAll")} /></th><th>{t("gifts.animation")}</th><th>{t("gifts.idRevision")}</th><th>{t("gifts.title")}</th><th>{t("gifts.price")}</th><th>{t("gifts.source")}</th><th>{t("gifts.received")}</th><th>{t("common.status")}</th><th>{t("common.updatedAt")}</th><th>{t("common.actions")}</th></tr></thead>
<thead><tr><th className="gift-select-col"><input type="checkbox" checked={allVisibleSelected} onChange={toggleSelectAllVisible} aria-label={"Select all visible gifts"} /></th><th>{"Animation file"}</th><th>{"ID / Revision"}</th><th>{"Display title"}</th><th>{"Price / Conversion"}</th><th>{"Source"}</th><th>{"Received gifts"}</th><th>{"Status"}</th><th>{"Updated"}</th><th>{"Actions"}</th></tr></thead>
<tbody>
{pagedGifts.map((gift) => (
<tr className={gift.Enabled ? "" : "gift-row-disabled"} key={gift.GiftID}>
<td className="gift-select-col"><input type="checkbox" checked={selected.has(gift.GiftID)} onChange={() => toggleSelected(gift.GiftID)} aria-label={t("gifts.bulkSelectOne", { id: gift.GiftID })} /></td>
<td className="gift-select-col"><input type="checkbox" checked={selected.has(gift.GiftID)} onChange={() => toggleSelected(gift.GiftID)} aria-label={`Select gift ${gift.GiftID}`} /></td>
<td><LottiePreview giftID={gift.GiftID} revision={gift.Revision} compact /></td>
<td className="mono">{gift.GiftID} / {gift.Revision}</td>
<td><strong className="gift-table-title">{gift.Title || `Gift #${gift.GiftID}`}</strong><span className="gift-sort-order">{t("gifts.sortOrder")}: {gift.SortOrder}</span></td>
<td><strong className="gift-table-title">{gift.Title || `Gift #${gift.GiftID}`}</strong><span className="gift-sort-order">{"Sort order"}: {gift.SortOrder}</span></td>
<td><strong className="gift-table-price">⭐ {gift.Stars}</strong><span className="gift-convert-price">→ {gift.ConvertStars}</span></td>
<td><Badge>{gift.SourceFormat}</Badge><span className="gift-source-size">{formatBytes(gift.AnimationSize)}</span></td>
<td>{gift.ReceivedCount}</td>
<td><Badge tone={gift.Enabled ? "good" : "neutral"}>{gift.Enabled ? t("common.enabled") : t("common.disabled")}</Badge></td>
<td><Badge tone={gift.Enabled ? "good" : "neutral"}>{gift.Enabled ? "Enabled" : "Disabled"}</Badge></td>
<td>{formatDate(gift.UpdatedAt)}</td>
<td><div className="gift-table-actions"><button className="btn compact-btn collectible-button" type="button" onClick={() => setCollectibleGift(gift)}><Gem size={13} />{t("collectibles.manage")}</button><button className="btn compact-btn" type="button" onClick={() => startRevision(gift)}>{t("gifts.replace")}</button><ActionButton compact tone="neutral" label={gift.Enabled ? t("gifts.disable") : t("gifts.enable")} path="/api/actions/set-gift-enabled" payload={() => ({ gift_id: gift.GiftID, enabled: !gift.Enabled })} onDone={() => void load()} /></div></td>
<td><div className="gift-table-actions"><button className="btn compact-btn collectible-button" type="button" onClick={() => setCollectibleGift(gift)}><Gem size={13} />{"Attribute pool"}</button><button className="btn compact-btn" type="button" onClick={() => startRevision(gift)}>{"New revision"}</button><ActionButton compact tone="neutral" label={gift.Enabled ? "Disable" : "Enable"} path="/api/actions/set-gift-enabled" payload={() => ({ gift_id: gift.GiftID, enabled: !gift.Enabled })} onDone={() => void load()} /></div></td>
</tr>
))}
{pagedGifts.length === 0 && <EmptyRow colSpan={10} />}
@ -501,45 +506,45 @@ export function GiftsPage() {
</table>
</div>
{pageSize !== "all" && visibleGifts.length > 0 && <div className="gift-pager">
<span className="gift-pager-range">{t("gifts.pageRange", { start: pageRangeStart, end: pageRangeEnd, total: visibleGifts.length })}</span>
<span className="gift-pager-range">{`Showing ${pageRangeStart}-${pageRangeEnd} of ${visibleGifts.length}`}</span>
<div className="gift-pager-controls">
<button className="btn compact-btn" type="button" onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={currentPage <= 1}>
<ChevronLeft size={14} /> {t("gifts.pagePrev")}
<ChevronLeft size={14} /> {"Previous"}
</button>
<span className="gift-pager-page">{t("gifts.pageOf", { page: currentPage, total: totalPages })}</span>
<span className="gift-pager-page">{`Page ${currentPage} of ${totalPages}`}</span>
<button className="btn compact-btn" type="button" onClick={() => setPage((p) => Math.min(totalPages, p + 1))} disabled={currentPage >= totalPages}>
{t("gifts.pageNext")} <ChevronRight size={14} />
{"Next"} <ChevronRight size={14} />
</button>
</div>
</div>}
{importOpen && createPortal(
<div className="modal-backdrop" role="presentation">
<section className="modal command-modal gift-import-modal" role="dialog" aria-modal="true" aria-label={giftID !== "0" ? t("gifts.newRevision", { id: giftID }) : t("gifts.importTitle")}>
<section className="modal command-modal gift-import-modal" role="dialog" aria-modal="true" aria-label={giftID !== "0" ? `Create revision for gift #${giftID}` : "Import a Star Gift"}>
<div className="modal-head">
<div><div className="eyebrow">{t("gifts.importEyebrow")}</div><h2>{giftID !== "0" ? t("gifts.newRevision", { id: giftID }) : t("gifts.importTitle")}</h2></div>
<button className="icon-btn" type="button" onClick={() => setImportOpen(false)} disabled={busy} aria-label={t("action.close")}><X size={15} /></button>
<div><div className="eyebrow">{"Gift catalog operation"}</div><h2>{giftID !== "0" ? `Create revision for gift #${giftID}` : "Import a Star Gift"}</h2></div>
<button className="icon-btn" type="button" onClick={() => setImportOpen(false)} disabled={busy} aria-label={"Close"}><X size={15} /></button>
</div>
<div className="command-body gift-import-modal-body">
<div className="command-steps">
<div className={`command-step ${step1Done ? "done" : "active"}`}><span>1</span><strong>{t("gifts.stepDetails")}</strong></div>
<div className={`command-step ${preview ? "done" : step1Done ? "active" : ""}`}><span>2</span><strong>{t("gifts.stepValidate")}</strong></div>
<div className={`command-step ${preview ? "active" : ""}`}><span>3</span><strong>{t("gifts.stepImport")}</strong></div>
<div className={`command-step ${step1Done ? "done" : "active"}`}><span>1</span><strong>{"File and details"}</strong></div>
<div className={`command-step ${preview ? "done" : step1Done ? "active" : ""}`}><span>2</span><strong>{"Dry-run validation"}</strong></div>
<div className={`command-step ${preview ? "active" : ""}`}><span>3</span><strong>{"Confirm import"}</strong></div>
</div>
{giftID === "0" && <div className="gift-source-tabs">
{SHOW_DEFAULT_GIFTS_TAB && <button className={`btn ${importSource === "default" ? "primary" : ""}`} type="button" onClick={() => { setImportSource("default"); setPreview(null); }}>{t("gifts.defaultSource")}</button>}
<button className={`btn ${importSource === "official" ? "primary" : ""}`} type="button" onClick={() => { setImportSource("official"); setPreview(null); }}>{t("gifts.officialSource")}</button>
<button className={`btn ${importSource === "file" ? "primary" : ""}`} type="button" onClick={() => { setImportSource("file"); setPreview(null); }}>{t("gifts.fileSource")}</button>
{SHOW_DEFAULT_GIFTS_TAB && <button className={`btn ${importSource === "default" ? "primary" : ""}`} type="button" onClick={() => { setImportSource("default"); setPreview(null); }}>{"Default gifts"}</button>}
<button className={`btn ${importSource === "official" ? "primary" : ""}`} type="button" onClick={() => { setImportSource("official"); setPreview(null); }}>{"Official snapshot"}</button>
<button className={`btn ${importSource === "file" ? "primary" : ""}`} type="button" onClick={() => { setImportSource("file"); setPreview(null); }}>{"Upload file"}</button>
</div>}
{importSource === "default" && giftID === "0" && SHOW_DEFAULT_GIFTS_TAB ? <section className="official-gift-picker">
<div className="gift-import-note"><span>{t("gifts.defaultHint")}</span><div className="gift-format-chips"><span>{defaultGifts.length}</span><span>OwpenGram</span></div></div>
<div className="gift-import-note"><span>{"Import our built-in original OwpenGram gifts. Complete collectible pools (upgrade + craft) are imported atomically."}</span><div className="gift-format-chips"><span>{defaultGifts.length}</span><span>OwpenGram</span></div></div>
<div className="official-gift-bulk-import">
<button className="btn" type="button" onClick={() => openBulkImport("default")}>
<Upload size={14} /> {t("gifts.importAllDefault")}
<Upload size={14} /> {"Import all default gifts"}
</button>
</div>
<label className="gift-switch"><input type="checkbox" checked={enabled} onChange={(e) => { setEnabled(e.target.checked); setPreview(null); }} /><span className="gift-switch-track" aria-hidden="true"><span /></span><span>{t("gifts.enableAfterImport")}</span></label>
<div className="official-gift-list" role="listbox" aria-label={t("gifts.defaultSelect")}>
<label className="gift-switch"><input type="checkbox" checked={enabled} onChange={(e) => { setEnabled(e.target.checked); setPreview(null); }} /><span className="gift-switch-track" aria-hidden="true"><span /></span><span>{"Enable after import"}</span></label>
<div className="official-gift-list" role="listbox" aria-label={"Choose a default gift"}>
{defaultGifts.map((gift) => {
const isSelected = gift.id === selectedDefaultID;
return <button key={gift.id} className={`official-gift-option ${isSelected ? "selected" : ""}`}
@ -549,105 +554,105 @@ export function GiftsPage() {
<span className="mono">⭐ {gift.stars}</span>
</span>
<span className="official-gift-option-meta">
<span>{t("gifts.officialAttributes", { count: defaultGiftAttributeCount(gift) })}</span>
{gift.limited && <span>{t("gifts.limited", { total: gift.availability })}</span>}
{gift.require_premium && <span>{t("gifts.premium")}</span>}
<span>{`${defaultGiftAttributeCount(gift)} attributes`}</span>
{gift.limited && <span>{`Limited · ${gift.availability}`}</span>}
{gift.require_premium && <span>{"Premium only"}</span>}
</span>
<span className="official-gift-capabilities">
<span className={gift.upgradeable ? "yes" : "no"}>{gift.upgradeable ? t("gifts.canUpgrade") : t("gifts.cannotUpgrade")}</span>
<span className={gift.craftable ? "craft" : "no"}>{gift.craftable ? t("gifts.canCraft") : t("gifts.cannotCraft")}</span>
<span className={gift.upgradeable ? "yes" : "no"}>{gift.upgradeable ? "Can upgrade" : "Cannot upgrade"}</span>
<span className={gift.craftable ? "craft" : "no"}>{gift.craftable ? "Can Craft" : "Cannot Craft"}</span>
</span>
</button>;
})}
{defaultGifts.length === 0 && <div className="official-gift-empty">{t("gifts.defaultEmpty")}</div>}
{defaultGifts.length === 0 && <div className="official-gift-empty">{"No default gifts are available."}</div>}
</div>
{selectedDefault && <div className="official-gift-selected">
<DefaultLottiePreview id={selectedDefault.id} />
<div><strong>{selectedDefault.title}</strong><span className="mono">⭐ {selectedDefault.stars} → {selectedDefault.convert_stars}</span><small>{selectedDefault.model_count} {t("collectibles.models")} · {selectedDefault.pattern_count} {t("collectibles.patterns")} · {selectedDefault.backdrop_count} {t("collectibles.backdrops")}</small><span className="official-gift-capabilities"><span className={selectedDefault.upgradeable ? "yes" : "no"}>{selectedDefault.upgradeable ? t("gifts.canUpgrade") : t("gifts.cannotUpgrade")}</span><span className={selectedDefault.craftable ? "craft" : "no"}>{selectedDefault.craftable ? t("gifts.canCraft") : t("gifts.cannotCraft")}</span></span></div>
<div><strong>{selectedDefault.title}</strong><span className="mono">⭐ {selectedDefault.stars} → {selectedDefault.convert_stars}</span><small>{selectedDefault.model_count} {"Models"} · {selectedDefault.pattern_count} {"Patterns"} · {selectedDefault.backdrop_count} {"Backdrops"}</small><span className="official-gift-capabilities"><span className={selectedDefault.upgradeable ? "yes" : "no"}>{selectedDefault.upgradeable ? "Can upgrade" : "Cannot upgrade"}</span><span className={selectedDefault.craftable ? "craft" : "no"}>{selectedDefault.craftable ? "Can Craft" : "Cannot Craft"}</span></span></div>
</div>}
</section> : importSource === "official" && giftID === "0" ? <section className="official-gift-picker">
<div className="gift-import-note"><span>{t("gifts.officialHint")}</span><div className="gift-format-chips"><span>{officialGifts.length}</span><span>SHA-256</span></div></div>
<div className="gift-import-note"><span>{"Choose a verified gift from data/official-gifts. Complete collectible pools are imported atomically."}</span><div className="gift-format-chips"><span>{officialGifts.length}</span><span>SHA-256</span></div></div>
<div className="official-gift-bulk-import">
<button className="btn" type="button" onClick={() => openBulkImport("official")}>
<Upload size={14} /> {t("gifts.importAllOfficial")}
<Upload size={14} /> {"Import all official gifts"}
</button>
</div>
<div className="official-gift-tools">
<label className="searchbox"><Search size={15} /><input value={officialQuery} onChange={(e) => setOfficialQuery(e.target.value)} placeholder={t("gifts.officialSearch")} /></label>
<span>{t("gifts.officialResults", { shown: visibleOfficial.length, total: officialGifts.length })}</span>
<label className="searchbox"><Search size={15} /><input value={officialQuery} onChange={(e) => setOfficialQuery(e.target.value)} placeholder={"Search official gift ID or title"} /></label>
<span>{`Showing ${visibleOfficial.length} of ${officialGifts.length}`}</span>
</div>
<div className="official-gift-categories" role="group" aria-label={t("gifts.officialCategoryLabel")}>
<div className="official-gift-categories" role="group" aria-label={"Official gift capability category"}>
{(["all", "upgrade", "craft", "basic"] as const).map((category) => (
<button key={category} className={officialCategory === category ? "active" : ""} type="button"
aria-pressed={officialCategory === category} onClick={() => setOfficialCategory(category)}>
{t(`gifts.officialCategory.${category}`)}<span>{officialCategoryCounts[category]}</span>
{officialCategoryLabels[category]}<span>{officialCategoryCounts[category]}</span>
</button>
))}
</div>
<div className="official-gift-list" role="listbox" aria-label={t("gifts.officialSelect")}>
<div className="official-gift-list" role="listbox" aria-label={"Choose an official gift"}>
{visibleOfficial.map((gift) => {
const isSelected = gift.source_gift_id === sourceGiftID;
return <button key={gift.source_gift_id} className={`official-gift-option ${isSelected ? "selected" : ""}`}
type="button" role="option" aria-selected={isSelected} onClick={() => chooseOfficial(gift)}>
<span className="official-gift-option-head">
<strong>{gift.title || t("gifts.officialUnnamed", { id: gift.source_gift_id })}</strong>
<strong>{gift.title || `Unnamed official gift #${gift.source_gift_id}`}</strong>
<span className="mono">#{gift.source_gift_id}</span>
</span>
<span className="official-gift-option-meta">
<span>⭐ {gift.stars}</span>
<span>{t("gifts.officialAttributes", { count: officialGiftAttributeCount(gift) })}</span>
<span>{`${officialGiftAttributeCount(gift)} attributes`}</span>
</span>
<span className="official-gift-capabilities">
<span className={gift.can_upgrade ? "yes" : "no"}>{gift.can_upgrade ? t("gifts.canUpgrade") : t("gifts.cannotUpgrade")}</span>
<span className={gift.can_craft ? "craft" : "no"}>{gift.can_craft ? t("gifts.canCraft") : t("gifts.cannotCraft")}</span>
<span className={gift.can_upgrade ? "yes" : "no"}>{gift.can_upgrade ? "Can upgrade" : "Cannot upgrade"}</span>
<span className={gift.can_craft ? "craft" : "no"}>{gift.can_craft ? "Can Craft" : "Cannot Craft"}</span>
</span>
</button>;
})}
{visibleOfficial.length === 0 && <div className="official-gift-empty">{t("gifts.officialEmpty")}</div>}
{visibleOfficial.length === 0 && <div className="official-gift-empty">{"No official gifts match this category and search."}</div>}
</div>
{selectedOfficial && <div className="official-gift-selected">
<OfficialLottiePreview sourceGiftID={selectedOfficial.source_gift_id} />
<div><strong>{selectedOfficial.title || t("gifts.officialUnnamed", { id: selectedOfficial.source_gift_id })}</strong><span className="mono">{selectedOfficial.source_gift_id}</span><small>{selectedOfficial.model_count} {t("collectibles.models")} · {selectedOfficial.pattern_count} {t("collectibles.patterns")} · {selectedOfficial.backdrop_count} {t("collectibles.backdrops")}</small><span className="official-gift-capabilities"><span className={selectedOfficial.can_upgrade ? "yes" : "no"}>{selectedOfficial.can_upgrade ? t("gifts.canUpgrade") : t("gifts.cannotUpgrade")}</span><span className={selectedOfficial.can_craft ? "craft" : "no"}>{selectedOfficial.can_craft ? t("gifts.canCraft") : t("gifts.cannotCraft")}</span></span></div>
<div><strong>{selectedOfficial.title || `Unnamed official gift #${selectedOfficial.source_gift_id}`}</strong><span className="mono">{selectedOfficial.source_gift_id}</span><small>{selectedOfficial.model_count} {"Models"} · {selectedOfficial.pattern_count} {"Patterns"} · {selectedOfficial.backdrop_count} {"Backdrops"}</small><span className="official-gift-capabilities"><span className={selectedOfficial.can_upgrade ? "yes" : "no"}>{selectedOfficial.can_upgrade ? "Can upgrade" : "Cannot upgrade"}</span><span className={selectedOfficial.can_craft ? "craft" : "no"}>{selectedOfficial.can_craft ? "Can Craft" : "Cannot Craft"}</span></span></div>
</div>}
{selectedOfficial?.can_upgrade && <>
<label className="gift-switch"><input type="checkbox" checked={includeCollectible} onChange={(e) => { setIncludeCollectible(e.target.checked); setPreview(null); }} /><span className="gift-switch-track" aria-hidden="true"><span /></span><span>{t("gifts.includeCollectible")}</span></label>
<label className="gift-switch"><input type="checkbox" checked={includeCollectible} onChange={(e) => { setIncludeCollectible(e.target.checked); setPreview(null); }} /><span className="gift-switch-track" aria-hidden="true"><span /></span><span>{"Import the complete collectible pool, including crafted models"}</span></label>
{includeCollectible && <div className="gift-fields-grid">
<label><span>{t("collectibles.upgradeStars")}</span><input type="number" min="1" value={upgradeStars} onChange={(e) => { setUpgradeStars(e.target.value); setPreview(null); }} /></label>
<label><span>{t("collectibles.supply")}</span><input type="number" min="1" value={supplyTotal} onChange={(e) => { setSupplyTotal(e.target.value); setPreview(null); }} /></label>
<label><span>{t("collectibles.slug")}</span><input value={slugPrefix} maxLength={48} onChange={(e) => { setSlugPrefix(e.target.value.toLowerCase()); setPreview(null); }} /></label>
<label><span>{"Upgrade price in Stars"}</span><input type="number" min="1" value={upgradeStars} onChange={(e) => { setUpgradeStars(e.target.value); setPreview(null); }} /></label>
<label><span>{"Unique supply"}</span><input type="number" min="1" value={supplyTotal} onChange={(e) => { setSupplyTotal(e.target.value); setPreview(null); }} /></label>
<label><span>{"Public slug prefix"}</span><input value={slugPrefix} maxLength={48} onChange={(e) => { setSlugPrefix(e.target.value.toLowerCase()); setPreview(null); }} /></label>
</div>}
</>}
<div className="gift-fields-grid">
<label><span>{t("gifts.title")}</span><input value={title} maxLength={128} placeholder={t("gifts.titlePlaceholder")} onChange={(e) => { setTitle(e.target.value); setPreview(null); }} /></label>
<label><span>{t("gifts.stars")}</span><input type="number" min="1" value={stars} onChange={(e) => { setStars(e.target.value); setPreview(null); }} /></label>
<label><span>{t("gifts.convertStars")}</span><input type="number" min="0" value={convertStars} onChange={(e) => { setConvertStars(e.target.value); setPreview(null); }} /></label>
<label><span>{t("gifts.sortOrder")}</span><input type="number" value={sortOrder} onChange={(e) => { setSortOrder(e.target.value); setPreview(null); }} /></label>
<label><span>{"Display title"}</span><input value={title} maxLength={128} placeholder={"e.g. Celebration Star"} onChange={(e) => { setTitle(e.target.value); setPreview(null); }} /></label>
<label><span>{"Price in Stars"}</span><input type="number" min="1" value={stars} onChange={(e) => { setStars(e.target.value); setPreview(null); }} /></label>
<label><span>{"Conversion Stars"}</span><input type="number" min="0" value={convertStars} onChange={(e) => { setConvertStars(e.target.value); setPreview(null); }} /></label>
<label><span>{"Sort order"}</span><input type="number" value={sortOrder} onChange={(e) => { setSortOrder(e.target.value); setPreview(null); }} /></label>
</div>
<label className="gift-switch"><input type="checkbox" checked={enabled} onChange={(e) => { setEnabled(e.target.checked); setPreview(null); }} /><span className="gift-switch-track" aria-hidden="true"><span /></span><span>{t("gifts.enableAfterImport")}</span></label>
<label className="gift-switch"><input type="checkbox" checked={enabled} onChange={(e) => { setEnabled(e.target.checked); setPreview(null); }} /><span className="gift-switch-track" aria-hidden="true"><span /></span><span>{"Enable after import"}</span></label>
</section> : <>
<div className="gift-import-note"><span>{t("gifts.importHint")}</span><div className="gift-format-chips" aria-label={t("gifts.formats")}><span>TGS</span><span>Lottie JSON</span></div></div>
<div className="gift-import-note"><span>{"Upload TGS or plain Lottie JSON. Lottie is normalized and compressed to TGS."}</span><div className="gift-format-chips" aria-label={"Accepted formats"}><span>TGS</span><span>Lottie JSON</span></div></div>
<label className={`gift-file-picker ${file ? "has-file" : ""}`}>
<input type="file" accept=".tgs,.json,.lottie,application/json,application/x-tgsticker" onChange={(e) => { setFile(e.target.files?.[0] ?? null); setPreview(null); }} />
<span className="gift-file-icon"><FileJson2 size={22} /></span>
<span className="gift-file-copy"><span className="gift-field-label">{t("gifts.animation")}</span><strong>{file ? file.name : t("gifts.filePrompt")}</strong><small>{file ? formatBytes(file.size) : t("gifts.fileHint")}</small></span>
<span className="gift-file-action">{file ? t("gifts.changeFile") : t("gifts.chooseFile")}</span>
<span className="gift-file-copy"><span className="gift-field-label">{"Animation file"}</span><strong>{file ? file.name : "Drop or choose a TGS / Lottie file"}</strong><small>{file ? formatBytes(file.size) : "TGS, JSON or Lottie · validated before import"}</small></span>
<span className="gift-file-action">{file ? "Change file" : "Choose file"}</span>
</label>
<div className="gift-fields-grid">
<label><span>{t("gifts.title")}</span><input value={title} maxLength={128} placeholder={t("gifts.titlePlaceholder")} onChange={(e) => { setTitle(e.target.value); setPreview(null); }} /></label>
<label><span>{t("gifts.stars")}</span><input type="number" min="1" value={stars} onChange={(e) => { setStars(e.target.value); setPreview(null); }} /></label>
<label><span>{t("gifts.convertStars")}</span><input type="number" min="0" value={convertStars} onChange={(e) => { setConvertStars(e.target.value); setPreview(null); }} /></label>
<label><span>{t("gifts.sortOrder")}</span><input type="number" value={sortOrder} onChange={(e) => { setSortOrder(e.target.value); setPreview(null); }} /></label>
<label><span>{"Display title"}</span><input value={title} maxLength={128} placeholder={"e.g. Celebration Star"} onChange={(e) => { setTitle(e.target.value); setPreview(null); }} /></label>
<label><span>{"Price in Stars"}</span><input type="number" min="1" value={stars} onChange={(e) => { setStars(e.target.value); setPreview(null); }} /></label>
<label><span>{"Conversion Stars"}</span><input type="number" min="0" value={convertStars} onChange={(e) => { setConvertStars(e.target.value); setPreview(null); }} /></label>
<label><span>{"Sort order"}</span><input type="number" value={sortOrder} onChange={(e) => { setSortOrder(e.target.value); setPreview(null); }} /></label>
</div>
<label className="gift-switch"><input type="checkbox" checked={enabled} onChange={(e) => { setEnabled(e.target.checked); setPreview(null); }} /><span className="gift-switch-track" aria-hidden="true"><span /></span><span>{t("gifts.enableAfterImport")}</span></label>
<label className="gift-switch"><input type="checkbox" checked={enabled} onChange={(e) => { setEnabled(e.target.checked); setPreview(null); }} /><span className="gift-switch-track" aria-hidden="true"><span /></span><span>{"Enable after import"}</span></label>
</>}
<label className="gift-reason-field"><span>{t("gifts.reason")}</span><input value={reason} placeholder={t("gifts.reasonPlaceholder")} onChange={(e) => setReason(e.target.value)} /></label>
<label className="gift-reason-field"><span>{"Audit reason"}</span><input value={reason} placeholder={"Briefly describe why this gift is being imported"} onChange={(e) => setReason(e.target.value)} /></label>
{importError && <Alert>{importError}</Alert>}
{preview && <div className="gift-validation"><div className="gift-validation-head"><CheckCircle2 size={17} /><div><strong>{t("gifts.validationReady")}</strong><span>{t("gifts.validationHint")}</span></div></div><pre>{JSON.stringify(preview.details, null, 2)}</pre></div>}
{preview && <div className="gift-validation"><div className="gift-validation-head"><CheckCircle2 size={17} /><div><strong>{"Validation passed"}</strong><span>{"Review the normalized metadata, then confirm the import."}</span></div></div><pre>{JSON.stringify(preview.details, null, 2)}</pre></div>}
</div>
<div className="modal-actions">
<button className="btn" type="button" onClick={() => setImportOpen(false)} disabled={busy}>{t("common.close")}</button>
<button className="btn" type="button" onClick={validateImport} disabled={busy}>{busy ? <Loader2 className="spin" size={15} /> : <ShieldCheck size={15} />}{t("gifts.validate")}</button>
<button className="btn primary" type="button" onClick={confirmImport} disabled={busy || !preview}><Upload size={15} />{t("gifts.confirmImport")}</button>
<button className="btn" type="button" onClick={() => setImportOpen(false)} disabled={busy}>{"Close"}</button>
<button className="btn" type="button" onClick={validateImport} disabled={busy}>{busy ? <Loader2 className="spin" size={15} /> : <ShieldCheck size={15} />}{"Dry-run validation"}</button>
<button className="btn primary" type="button" onClick={confirmImport} disabled={busy || !preview}><Upload size={15} />{"Confirm import"}</button>
</div>
</section>
</div>,
@ -656,29 +661,29 @@ export function GiftsPage() {
{bulkImportOpen && createPortal(
<div className="modal-backdrop" role="presentation">
<section className="modal command-modal gift-bulk-import-modal" role="dialog" aria-modal="true"
aria-label={bulkImportOpen === "default" ? t("gifts.importAllDefault") : t("gifts.importAllOfficial")}>
aria-label={bulkImportOpen === "default" ? "Import all default gifts" : "Import all official gifts"}>
<div className="modal-head">
<div><div className="eyebrow">{t("gifts.importEyebrow")}</div><h2>{bulkImportOpen === "default" ? t("gifts.importAllDefault") : t("gifts.importAllOfficial")}</h2></div>
<button className="icon-btn" type="button" onClick={closeBulkImport} disabled={bulkImportBusy} aria-label={t("action.close")}><X size={15} /></button>
<div><div className="eyebrow">{"Gift catalog operation"}</div><h2>{bulkImportOpen === "default" ? "Import all default gifts" : "Import all official gifts"}</h2></div>
<button className="icon-btn" type="button" onClick={closeBulkImport} disabled={bulkImportBusy} aria-label={"Close"}><X size={15} /></button>
</div>
<div className="command-body">
<div className="gift-import-note"><span>{t("gifts.bulkImportCount", { count: bulkImportItems.length })}</span></div>
<label className="gift-switch"><input type="checkbox" checked={bulkImportEnabled} disabled={bulkImportBusy} onChange={(e) => setBulkImportEnabled(e.target.checked)} /><span className="gift-switch-track" aria-hidden="true"><span /></span><span>{t("gifts.enableAfterImport")}</span></label>
<label className="gift-reason-field"><span>{t("gifts.reason")}</span><input value={bulkImportReason} placeholder={t("gifts.reasonPlaceholder")} disabled={bulkImportBusy} onChange={(e) => setBulkImportReason(e.target.value)} /></label>
<div className="gift-import-note"><span>{`${bulkImportItems.length} gifts available to import`}</span></div>
<label className="gift-switch"><input type="checkbox" checked={bulkImportEnabled} disabled={bulkImportBusy} onChange={(e) => setBulkImportEnabled(e.target.checked)} /><span className="gift-switch-track" aria-hidden="true"><span /></span><span>{"Enable after import"}</span></label>
<label className="gift-reason-field"><span>{"Audit reason"}</span><input value={bulkImportReason} placeholder={"Briefly describe why this gift is being imported"} disabled={bulkImportBusy} onChange={(e) => setBulkImportReason(e.target.value)} /></label>
{bulkImportBusy && <div className="gift-bulk-import-progress">
<div className="gift-bulk-import-progress-bar"><div style={{ width: `${bulkImportProgress.total ? Math.round((bulkImportProgress.done / bulkImportProgress.total) * 100) : 0}%` }} /></div>
<span>{t("gifts.importingProgress", { done: bulkImportProgress.done, total: bulkImportProgress.total })}</span>
<span>{`Importing ${bulkImportProgress.done} of ${bulkImportProgress.total}`}</span>
</div>}
{bulkImportError && <Alert>{bulkImportError}</Alert>}
{bulkImportResult && <div className="gift-validation">
<div className="gift-validation-head"><CheckCircle2 size={17} /><div><strong>{t("gifts.bulkImportDone")}</strong><span>{t("gifts.bulkImportSummary", { imported: bulkImportResult.imported, skipped: bulkImportResult.skipped, failed: bulkImportResult.failed })}</span></div></div>
<div className="gift-validation-head"><CheckCircle2 size={17} /><div><strong>{"Import complete"}</strong><span>{`Imported ${bulkImportResult.imported}, skipped ${bulkImportResult.skipped}, failed ${bulkImportResult.failed}`}</span></div></div>
{bulkImportResult.errors.length > 0 && <pre>{bulkImportResult.errors.join("\n")}</pre>}
</div>}
</div>
<div className="modal-actions">
<button className="btn" type="button" onClick={closeBulkImport} disabled={bulkImportBusy}>{t("common.close")}</button>
<button className="btn" type="button" onClick={closeBulkImport} disabled={bulkImportBusy}>{"Close"}</button>
<button className="btn primary" type="button" onClick={runBulkImport} disabled={bulkImportBusy || bulkImportItems.length === 0}>
{bulkImportBusy ? <Loader2 className="spin" size={15} /> : <Upload size={15} />} {t("gifts.startBulkImport")}
{bulkImportBusy ? <Loader2 className="spin" size={15} /> : <Upload size={15} />} {"Start import"}
</button>
</div>
</section>

View file

@ -3,7 +3,6 @@ import { useEffect, useMemo, useState } from "react";
import { api, errorMessage } from "../api";
import { ChannelPicker, UserPicker } from "../components/EntityPicker";
import { Alert, JsonBlock } from "../components/ui";
import { useI18n } from "../i18n";
import type { AccountRow, ChannelRow, CommandResult, StarGiftCollectibleAttributeRow, StarGiftCollectiblePreview, StarGiftRow } from "../types";
const SYSTEM_SENDER = "777000";
@ -16,7 +15,6 @@ function attrLabel(attr: StarGiftCollectibleAttributeRow): string {
}
export function GiveGiftForm({ gift, onDone }: { gift: StarGiftRow; onDone?: () => void }) {
const { t } = useI18n();
const [kind, setKind] = useState<RecipientKind>("user");
const [user, setUser] = useState<AccountRow | null>(null);
const [channel, setChannel] = useState<ChannelRow | null>(null);
@ -82,11 +80,11 @@ export function GiveGiftForm({ gift, onDone }: { gift: StarGiftRow; onDone?: ()
async function run(confirm: boolean) {
if (recipientID <= 0) {
setError(t("giveGift.recipientRequired"));
setError("Select a recipient first");
return;
}
if (!reason.trim()) {
setError(t("action.reasonRequired"));
setError("Please enter an operation reason");
return;
}
setBusy(true);
@ -114,34 +112,34 @@ export function GiveGiftForm({ gift, onDone }: { gift: StarGiftRow; onDone?: ()
</div>
</div>
<div className="give-gift-tabs" role="group" aria-label={t("giveGift.recipientKind")}>
<div className="give-gift-tabs" role="group" aria-label={"Recipient type"}>
<button type="button" className={`btn ${kind === "user" ? "primary" : ""}`} onClick={() => { setKind("user"); setResult(null); }}>
<User size={15} /> {t("giveGift.recipientUser")}
<User size={15} /> {"User"}
</button>
<button type="button" className={`btn ${kind === "channel" ? "primary" : ""}`} onClick={() => { setKind("channel"); setUpgrade(false); setResult(null); }}>
<Users size={15} /> {t("giveGift.recipientChannel")}
<Users size={15} /> {"Channel"}
</button>
</div>
{kind === "user"
? <UserPicker label={t("giveGift.pickUser")} value={user} onChange={(row) => { setUser(row); setResult(null); }} />
: <ChannelPicker label={t("giveGift.pickChannel")} value={channel} onChange={(row) => { setChannel(row); setResult(null); }} />}
? <UserPicker label={"Recipient user"} value={user} onChange={(row) => { setUser(row); setResult(null); }} />
: <ChannelPicker label={"Recipient channel"} value={channel} onChange={(row) => { setChannel(row); setResult(null); }} />}
<label className="form-field">
<span>{t("giveGift.sender")}</span>
<span>{"Sender account ID"}</span>
<input value={SYSTEM_SENDER} disabled readOnly />
<small className="field-hint">{t("giveGift.senderHint")}</small>
<small className="field-hint">{"Gifts are always sent from the system account 777000 (Telesrv)."}</small>
</label>
<label className="form-field">
<span>{t("giveGift.message")}</span>
<textarea value={message} rows={2} maxLength={128} onChange={(event) => { setMessage(event.target.value); setResult(null); }} placeholder={t("giveGift.messagePlaceholder")} />
<span>{"Attached message (optional)"}</span>
<textarea value={message} rows={2} maxLength={128} onChange={(event) => { setMessage(event.target.value); setResult(null); }} placeholder={"Shown with the gift"} />
</label>
<label className="gift-switch">
<input type="checkbox" checked={hideName} onChange={(event) => { setHideName(event.target.checked); setResult(null); }} />
<span className="gift-switch-track" aria-hidden="true"><span /></span>
<span>{t("giveGift.hideName")}</span>
<span>{"Hide sender name from recipient"}</span>
</label>
{kind === "user" && (
@ -149,30 +147,30 @@ export function GiveGiftForm({ gift, onDone }: { gift: StarGiftRow; onDone?: ()
<label className="gift-switch">
<input type="checkbox" checked={upgrade} onChange={(event) => { setUpgrade(event.target.checked); if (!event.target.checked) { setModelID("0"); setPatternID("0"); setBackdropID("0"); } setResult(null); }} />
<span className="gift-switch-track" aria-hidden="true"><span /></span>
<span>{t("giveGift.upgrade")}</span>
<span>{"Deliver as upgraded collectible"}</span>
</label>
{upgrade && <p className="give-gift-upgrade-note">{t("giveGift.upgradeNote")}</p>}
{upgrade && <p className="give-gift-upgrade-note">{"The gift is minted as a unique collectible. Pick specific attributes below, or leave them on Random to draw from the published pool. The collectible number is assigned automatically. Requires a published collectible upgrade with remaining supply."}</p>}
{upgrade && previewError && <Alert>{previewError}</Alert>}
{upgrade && preview && (
<div className="gift-fields-grid give-gift-attrs">
<label>
<span>{t("giveGift.model")}</span>
<span>{"Model"}</span>
<select value={modelID} onChange={(event) => { setModelID(event.target.value); setResult(null); }}>
<option value="0">{t("giveGift.random")}</option>
<option value="0">{"Random"}</option>
{(preview.models ?? []).map((attr) => <option key={attr.id} value={attr.id}>{attrLabel(attr)}</option>)}
</select>
</label>
<label>
<span>{t("giveGift.pattern")}</span>
<span>{"Pattern"}</span>
<select value={patternID} onChange={(event) => { setPatternID(event.target.value); setResult(null); }}>
<option value="0">{t("giveGift.random")}</option>
<option value="0">{"Random"}</option>
{(preview.patterns ?? []).map((attr) => <option key={attr.id} value={attr.id}>{attrLabel(attr)}</option>)}
</select>
</label>
<label>
<span>{t("giveGift.backdrop")}</span>
<span>{"Backdrop"}</span>
<select value={backdropID} onChange={(event) => { setBackdropID(event.target.value); setResult(null); }}>
<option value="0">{t("giveGift.random")}</option>
<option value="0">{"Random"}</option>
{(preview.backdrops ?? []).map((attr) => <option key={attr.id} value={attr.id}>{attrLabel(attr)}</option>)}
</select>
</label>
@ -182,12 +180,12 @@ export function GiveGiftForm({ gift, onDone }: { gift: StarGiftRow; onDone?: ()
)}
<label className="form-field">
<span>{t("action.reason")}</span>
<textarea value={reason} rows={2} onChange={(event) => setReason(event.target.value)} placeholder={t("action.reasonPlaceholder")} />
<span>{"Operation reason"}</span>
<textarea value={reason} rows={2} onChange={(event) => setReason(event.target.value)} placeholder={"Describe why this operation is being performed"} />
</label>
<div className="command-preview">
<div className="preview-head">{t("action.requestPreview")}</div>
<div className="preview-head">{"Request preview"}</div>
<JsonBlock value={JSON.stringify(previewPayload, null, 2)} />
</div>
@ -196,11 +194,11 @@ export function GiveGiftForm({ gift, onDone }: { gift: StarGiftRow; onDone?: ()
<div className="result-box">
<div className="result-title">
{result.error ? <CircleAlert size={16} /> : <CheckCircle2 size={16} />}
<strong>{result.message || result.error || t("action.result")}</strong>
<strong>{result.message || result.error || "Action result"}</strong>
</div>
<div className="result-line"><span>{t("action.commandID")}</span><strong>{result.command_id}</strong></div>
<div className="result-line"><span>{t("action.status")}</span><strong>{result.status}</strong></div>
<div className="result-line"><span>{t("action.dryRun")}</span><strong>{result.dry_run ? t("common.yes") : t("common.no")}</strong></div>
<div className="result-line"><span>{"Command ID"}</span><strong>{result.command_id}</strong></div>
<div className="result-line"><span>{"Status"}</span><strong>{result.status}</strong></div>
<div className="result-line"><span>{"Dry-run"}</span><strong>{result.dry_run ? "Yes" : "No"}</strong></div>
{result.details && <JsonBlock value={JSON.stringify(result.details, null, 2)} />}
</div>
)}
@ -208,11 +206,11 @@ export function GiveGiftForm({ gift, onDone }: { gift: StarGiftRow; onDone?: ()
<div className="give-gift-form-actions">
<button className="btn icon-text" type="button" onClick={() => run(false)} disabled={busy}>
{busy ? <Loader2 size={15} className="spin" /> : <Play size={15} />}
{result ? t("action.runAgain") : t("action.runDry")}
{result ? "Run dry-run again" : "Run dry-run first"}
</button>
<button className="btn primary icon-text" type="button" onClick={() => run(true)} disabled={busy || !canConfirm}>
<Gift size={15} />
{t("giveGift.confirm")}
{"Give gift"}
</button>
</div>
</div>

View file

@ -3,12 +3,10 @@ import { useEffect, useMemo, useState } from "react";
import { api, errorMessage } from "../api";
import { StaticLottie } from "../components/StaticLottie";
import { Alert, Badge, PageFrame } from "../components/ui";
import { useI18n } from "../i18n";
import type { StarGiftRow } from "../types";
import { GiveGiftForm } from "./GiveGiftForm";
export function GiveGiftsPage() {
const { t } = useI18n();
const [gifts, setGifts] = useState<StarGiftRow[]>([]);
const [query, setQuery] = useState("");
const [selected, setSelected] = useState<StarGiftRow | null>(null);
@ -40,18 +38,18 @@ export function GiveGiftsPage() {
}, [gifts, query]);
return (
<PageFrame title={t("giveGifts.pageTitle")} eyebrow={t("giveGifts.eyebrow")} actions={
<button className="btn" type="button" onClick={() => load()} disabled={busy}><RefreshCw size={15} /> {t("common.refresh")}</button>
<PageFrame title={"Give Gifts"} eyebrow={"Grant catalog gifts to any user or channel"} actions={
<button className="btn" type="button" onClick={() => load()} disabled={busy}><RefreshCw size={15} /> {"Refresh"}</button>
}>
{error && <Alert>{error}</Alert>}
<p className="give-gift-upgrade-note">{t("giveGifts.hint")}</p>
<p className="give-gift-upgrade-note">{"Pick a gift to grant. Delivery is free of charge and sent from the system account 777000 (Telesrv) by default."}</p>
<div className="give-gift-layout">
<section className="give-gift-picker">
<div className="give-gift-picker-head">
<label className="searchbox"><Search size={15} /><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder={t("giveGifts.searchPlaceholder")} /></label>
<span className="gift-list-summary">{t("gifts.listSummary", { shown: visible.length, total: gifts.length })}</span>
<label className="searchbox"><Search size={15} /><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder={"Search by title or gift ID"} /></label>
<span className="gift-list-summary">{`Showing ${visible.length} of ${gifts.length}`}</span>
</div>
<div className="give-gift-picker-list" role="listbox" aria-label={t("giveGifts.pickGift")}>
<div className="give-gift-picker-list" role="listbox" aria-label={"Select a gift"}>
{visible.map((gift) => {
const active = selected?.GiftID === gift.GiftID;
return (
@ -64,18 +62,18 @@ export function GiveGiftsPage() {
<span className="mono">#{gift.GiftID}</span>
</span>
<span className="give-gift-option-price">
{gift.Enabled ? <Badge>⭐ {gift.Stars}</Badge> : <Badge tone="neutral">{t("common.disabled")}</Badge>}
{gift.Enabled ? <Badge>⭐ {gift.Stars}</Badge> : <Badge tone="neutral">{"Disabled"}</Badge>}
</span>
</button>
);
})}
{visible.length === 0 && !busy && <div className="official-gift-empty">{t("common.noResults")}</div>}
{visible.length === 0 && !busy && <div className="official-gift-empty">{"No results"}</div>}
</div>
</section>
<section className="give-gift-panel">
{selected
? <GiveGiftForm key={selected.GiftID} gift={selected} onDone={() => void load()} />
: <div className="give-gift-empty-panel"><Gift size={26} /><p>{t("giveGifts.selectPrompt")}</p></div>}
: <div className="give-gift-empty-panel"><Gift size={26} /><p>{"Select a gift from the list to start."}</p></div>}
</section>
</div>
</PageFrame>

View file

@ -2,13 +2,11 @@ import { ArrowLeft } from "lucide-react";
import { useEffect, useState } from "react";
import { api, errorMessage } from "../api";
import { Alert, Badge, EmptyRow, JsonBlock, LoadingSurface, PageFrame, SectionHead, Summary } from "../components/ui";
import { useI18n } from "../i18n";
import { formatUnix } from "../lib/format";
import type { Navigate } from "../routing";
import type { GroupMessageDetail } from "../types";
export function GroupMessageDetailPage({ channelID, msgID, navigate }: { channelID: number; msgID: number; navigate: Navigate }) {
const { t } = useI18n();
const [detail, setDetail] = useState<GroupMessageDetail | null>(null);
const [error, setError] = useState("");
@ -29,48 +27,48 @@ export function GroupMessageDetailPage({ channelID, msgID, navigate }: { channel
return <Alert>{error}</Alert>;
}
if (!detail) {
return <LoadingSurface label={t("common.loading")} />;
return <LoadingSurface label={"Loading"} />;
}
const msg = detail.Message;
return (
<PageFrame
title={t("messages.groupDetailTitle", { id: msg.ID })}
eyebrow={t("messages.detailEyebrow")}
actions={<button className="btn icon-text" onClick={() => navigate("/messages/groups")}><ArrowLeft size={15} /> {t("messages.backGroup")}</button>}
title={`Group Message #${msg.ID}`}
eyebrow={"Message Detail"}
actions={<button className="btn icon-text" onClick={() => navigate("/messages/groups")}><ArrowLeft size={15} /> {"Back to group messages"}</button>}
>
<div className="stacked-sections">
<section className="entity-head">
<div>
<div className="entity-title">{t("messages.channelGroupTitle", { id: msg.ChannelID })}</div>
<div className="entity-subtitle">{t("messages.senderSubtitle", { sender: msg.SenderUserID, date: formatUnix(msg.Date) })}</div>
<div className="entity-title">{`Channel / Group ${msg.ChannelID}`}</div>
<div className="entity-subtitle">{`Sender ${msg.SenderUserID} · ${formatUnix(msg.Date)}`}</div>
</div>
<div className="entity-badges">
{msg.Deleted ? <Badge tone="danger">{t("common.deleted")}</Badge> : <Badge>{t("common.survived")}</Badge>}
{msg.Pinned && <Badge tone="warn">{t("messages.pinned")}</Badge>}
{msg.Post && <Badge>{t("messages.channelPost")}</Badge>}
{msg.Deleted ? <Badge tone="danger">{"Deleted"}</Badge> : <Badge>{"Live"}</Badge>}
{msg.Pinned && <Badge tone="warn">{"Pinned"}</Badge>}
{msg.Post && <Badge>{"Channel post"}</Badge>}
<Badge>pts {msg.PTS}</Badge>
</div>
</section>
<div className="summary-grid">
<Summary label={t("common.messageId")} value={String(msg.ID)} mono />
<Summary label={t("messages.channelGroup")} value={String(msg.ChannelID)} mono />
<Summary label={"Message ID"} value={String(msg.ID)} mono />
<Summary label={"Channel / Group"} value={String(msg.ChannelID)} mono />
<Summary label="From Peer" value={`${msg.FromPeerType}:${msg.FromPeerID}`} mono />
<Summary label={t("common.views")} value={String(msg.ViewsCount)} />
<Summary label={"Views"} value={String(msg.ViewsCount)} />
</div>
<section className="section-block">
<SectionHead title={t("messages.channelMessageRow")} text={t("messages.channelMessagesSnapshot")} />
<SectionHead title={"Channel Message Row"} text={"channel_messages read-only snapshot"} />
<JsonBlock value={detail.MessageJSON} />
</section>
<section className="section-block">
<SectionHead title={t("messages.channelRow")} text={t("messages.channelSnapshot")} />
<SectionHead title={"Channel Row"} text={"channels read-only snapshot"} />
<JsonBlock value={detail.ChannelJSON} />
</section>
<section className="section-block">
<SectionHead title={t("messages.channelUpdateEvents")} text={t("messages.channelEventsSource")} />
<SectionHead title={"Channel Update Events"} text={"durable channel_update_events"} />
<div className="table-wrap">
<table className="data-table">
<thead><tr><th>PTS</th><th>{t("common.count")}</th><th>{t("common.type")}</th><th>{t("common.messageId")}</th><th>{t("common.sender")}</th><th>{t("common.time")}</th></tr></thead>
<thead><tr><th>PTS</th><th>{"Count"}</th><th>{"Type"}</th><th>{"Message ID"}</th><th>{"Sender"}</th><th>{"Time"}</th></tr></thead>
<tbody>
{detail.UpdateEvents.map((row) => (
<tr key={`${row.PTS}-${row.Type}-${row.MessageID}`}>
@ -88,12 +86,12 @@ export function GroupMessageDetailPage({ channelID, msgID, navigate }: { channel
</div>
</section>
<section className="section-block">
<SectionHead title={t("messages.eventJson")} />
<SectionHead title={"Event JSON"} />
<div className="raw-grid">
{detail.UpdateEvents.map((row) => (
<JsonBlock key={`${row.PTS}-${row.Type}-json`} value={row.JSON} />
))}
{detail.UpdateEvents.length === 0 && <div className="empty-panel">{t("common.noResults")}</div>}
{detail.UpdateEvents.length === 0 && <div className="empty-panel">{"No results"}</div>}
</div>
</section>
</div>

View file

@ -3,13 +3,11 @@ import { useState } from "react";
import { api, errorMessage } from "../api";
import { ChannelPicker } from "../components/EntityPicker";
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
import { useI18n } from "../i18n";
import { channelKind, formatUnix } from "../lib/format";
import type { Navigate } from "../routing";
import type { ChannelRow, GroupMessageListResponse } from "../types";
export function GroupMessagesPage({ navigate }: { navigate: Navigate }) {
const { t } = useI18n();
const [channel, setChannel] = useState<ChannelRow | null>(null);
const [beforeDate, setBeforeDate] = useState("");
const [beforeID, setBeforeID] = useState("");
@ -20,7 +18,7 @@ export function GroupMessagesPage({ navigate }: { navigate: Navigate }) {
async function load(next = false) {
setError("");
if (!channel) {
setError(t("messages.selectChannel"));
setError("Search and select a supergroup or channel first");
return;
}
const params = new URLSearchParams({
@ -54,38 +52,38 @@ export function GroupMessagesPage({ navigate }: { navigate: Navigate }) {
const rows = data?.rows ?? [];
return (
<PageFrame title={t("messages.groupTitle")} eyebrow={t("messages.groupEyebrow")}>
<PageFrame title={"Group Messages"} eyebrow={"Supergroup / channel messages"}>
{error && <Alert>{error}</Alert>}
<QueryPanel>
<div className="message-selector-grid single">
<ChannelPicker label={t("messages.channelGroup")} value={channel} onChange={changeChannel} />
<ChannelPicker label={"Channel / Group"} value={channel} onChange={changeChannel} />
</div>
<form className="toolbar message-query" onSubmit={(event) => { event.preventDefault(); void load(false); }}>
<input value={beforeDate} onChange={(event) => setBeforeDate(event.target.value)} placeholder={t("messages.beforeDatePlaceholder")} />
<input value={beforeID} onChange={(event) => setBeforeID(event.target.value)} placeholder={t("messages.beforeIDPlaceholder")} />
<input className="small-input" value={limit} onChange={(event) => setLimit(event.target.value)} placeholder={t("messages.limitPlaceholder")} />
<button className="btn primary icon-text" type="submit"><Search size={15} /> {t("messages.searchMessages")}</button>
{rows.length ? <button className="btn icon-text" type="button" onClick={() => load(true)}><ChevronRight size={15} /> {t("messages.nextPage")}</button> : null}
<input value={beforeDate} onChange={(event) => setBeforeDate(event.target.value)} placeholder={"before_date cursor"} />
<input value={beforeID} onChange={(event) => setBeforeID(event.target.value)} placeholder={"before_msg_id cursor"} />
<input className="small-input" value={limit} onChange={(event) => setLimit(event.target.value)} placeholder={"limit <= 100"} />
<button className="btn primary icon-text" type="submit"><Search size={15} /> {"Search messages"}</button>
{rows.length ? <button className="btn icon-text" type="button" onClick={() => load(true)}><ChevronRight size={15} /> {"Next page"}</button> : null}
</form>
</QueryPanel>
<div className="metric-row">
<Metric label={t("messages.currentPage")} value={String(rows.length)} />
<Metric label={t("messages.mediaCount")} value={String(rows.filter((row) => row.Media && row.Media !== "{}").length)} />
<Metric label={t("messages.channelPosts")} value={String(rows.filter((row) => row.Post).length)} />
<Metric label={t("messages.channelGroup")} value={channel ? `${channel.Title || channelKind(channel, t)} (${channel.ID})` : "-"} />
<Metric label={"Messages on page"} value={String(rows.length)} />
<Metric label={"With media"} value={String(rows.filter((row) => row.Media && row.Media !== "{}").length)} />
<Metric label={"Channel posts"} value={String(rows.filter((row) => row.Post).length)} />
<Metric label={"Channel / Group"} value={channel ? `${channel.Title || channelKind(channel)} (${channel.ID})` : "-"} />
</div>
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th>{t("common.messageId")}</th>
<th>{t("common.time")}</th>
<th>{t("common.sender")}</th>
<th>{"Message ID"}</th>
<th>{"Time"}</th>
<th>{"Sender"}</th>
<th>From Peer</th>
<th>PTS</th>
<th>{t("common.views")}</th>
<th>{t("common.status")}</th>
<th>{t("messages.body")}</th>
<th>{"Views"}</th>
<th>{"Status"}</th>
<th>{"Body"}</th>
<th></th>
</tr>
</thead>
@ -99,7 +97,7 @@ export function GroupMessagesPage({ navigate }: { navigate: Navigate }) {
<td>{row.PTS}</td>
<td>{row.ViewsCount}</td>
<td>
{row.Deleted ? <Badge tone="danger">{t("common.deleted")}</Badge> : row.Pinned ? <Badge tone="warn">{t("messages.pinned")}</Badge> : <Badge>{t("common.survived")}</Badge>}
{row.Deleted ? <Badge tone="danger">{"Deleted"}</Badge> : row.Pinned ? <Badge tone="warn">{"Pinned"}</Badge> : <Badge>{"Live"}</Badge>}
</td>
<td className="truncate">{row.Body}</td>
<td>
@ -107,7 +105,7 @@ export function GroupMessagesPage({ navigate }: { navigate: Navigate }) {
className="row-link"
onClick={() => navigate(`/messages/groups/detail?channel_id=${row.ChannelID}&msg_id=${row.ID}`)}
>
{t("common.detail")} <ChevronRight size={14} />
{"Details"} <ChevronRight size={14} />
</button>
</td>
</tr>

View file

@ -2,11 +2,10 @@ import type { FormEvent } from "react";
import { useState } from "react";
import { api, errorMessage } from "../api";
import { Alert } from "../components/ui";
import { useI18n } from "../i18n";
import { ThemeSwitch } from "../theme";
import type { AdminSession } from "../types";
export function LoginPage({ onLogin }: { onLogin: (actor: string) => void }) {
const { t } = useI18n();
export function LoginPage({ onLogin }: { onLogin: (session: AdminSession) => void }) {
const [secret, setSecret] = useState("");
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
@ -16,8 +15,10 @@ export function LoginPage({ onLogin }: { onLogin: (actor: string) => void }) {
setBusy(true);
setError("");
try {
// The login answer carries the permission set and the CSRF token; api.login
// remembers the token, the session state keeps the rights.
const result = await api.login(secret);
onLogin(result.actor);
onLogin({ actor: result.actor, permissions: result.permissions ?? [] });
} catch (err) {
setError(errorMessage(err));
} finally {
@ -38,22 +39,22 @@ export function LoginPage({ onLogin }: { onLogin: (actor: string) => void }) {
<span className="brand-mark"><img src="/logo.png" alt="OwpenGram" /></span>
<span>
<strong>OwpenGram</strong>
<small>{t("app.adminConsole")}</small>
<small>{"Admin Console"}</small>
</span>
</div>
<div className="login-head-actions">
<ThemeSwitch />
<span className="login-chip">{t("app.localAccess")}</span>
<span className="login-chip">{"Local access"}</span>
</div>
</div>
<div className="login-copy">
<h1>{t("login.heading")}</h1>
<p>{t("login.body")}</p>
<h1>{"Operations Admin"}</h1>
<p>{"Enter credentials to open the console."}</p>
</div>
{error && <Alert>{error}</Alert>}
<form className="form-stack" onSubmit={submit}>
<label>
<span>{t("login.secret")}</span>
<span>{"Admin password or token"}</span>
<input
autoFocus
type="password"
@ -63,7 +64,7 @@ export function LoginPage({ onLogin }: { onLogin: (actor: string) => void }) {
/>
</label>
<button className="btn primary full" type="submit" disabled={busy}>
{busy ? t("login.submitting") : t("login.submit")}
{busy ? "Logging in" : "Log in"}
</button>
</form>
</section>

View file

@ -3,13 +3,11 @@ import { useEffect, useState } from "react";
import { api, errorMessage } from "../api";
import { ActionButton } from "../components/ActionButton";
import { Alert, Badge, EmptyRow, JsonBlock, LoadingSurface, PageFrame, SectionHead, SplitLayout, Summary } from "../components/ui";
import { useI18n } from "../i18n";
import { formatDate, formatUnix } from "../lib/format";
import type { Navigate } from "../routing";
import type { MessageDetail } from "../types";
export function MessageDetailPage({ ownerUserID, msgID, navigate }: { ownerUserID: number; msgID: number; navigate: Navigate }) {
const { t } = useI18n();
const [detail, setDetail] = useState<MessageDetail | null>(null);
const [error, setError] = useState("");
@ -30,55 +28,55 @@ export function MessageDetailPage({ ownerUserID, msgID, navigate }: { ownerUserI
return <Alert>{error}</Alert>;
}
if (!detail) {
return <LoadingSurface label={t("common.loading")} />;
return <LoadingSurface label={"Loading"} />;
}
const msg = detail.Message;
return (
<PageFrame
title={t("messages.privateDetailTitle", { id: msg.BoxID })}
eyebrow={t("messages.detailEyebrow")}
actions={<button className="btn icon-text" onClick={() => navigate("/messages/private")}><ArrowLeft size={15} /> {t("messages.backPrivate")}</button>}
title={`Message #${msg.BoxID}`}
eyebrow={"Message Detail"}
actions={<button className="btn icon-text" onClick={() => navigate("/messages/private")}><ArrowLeft size={15} /> {"Back to private messages"}</button>}
>
<SplitLayout
main={
<div className="stacked-sections">
<section className="entity-head">
<div>
<div className="entity-title">{t("messages.ownerPeerTitle", { owner: msg.OwnerUserID, peer: msg.PeerID })}</div>
<div className="entity-subtitle">{t("messages.senderSubtitle", { sender: msg.FromUserID, date: formatUnix(msg.Date) })}</div>
<div className="entity-title">{`Owner ${msg.OwnerUserID} · Peer ${msg.PeerID}`}</div>
<div className="entity-subtitle">{`Sender ${msg.FromUserID} · ${formatUnix(msg.Date)}`}</div>
</div>
<div className="entity-badges">
{msg.Deleted ? <Badge tone="danger">{t("common.deleted")}</Badge> : <Badge>{t("common.survived")}</Badge>}
{msg.Deleted ? <Badge tone="danger">{"Deleted"}</Badge> : <Badge>{"Live"}</Badge>}
<Badge>pts {msg.PTS}</Badge>
<Badge>{msg.Outgoing ? t("messages.outgoing") : t("messages.incoming")}</Badge>
<Badge>{msg.Outgoing ? "Outgoing" : "Incoming"}</Badge>
</div>
</section>
<div className="summary-grid">
<Summary label={t("messages.boxID")} value={String(msg.BoxID)} mono />
<Summary label={t("messages.privateMessageID")} value={String(msg.PrivateMessageID)} mono />
<Summary label={t("messages.messageSender")} value={String(msg.MessageSenderID)} mono />
<Summary label={t("common.time")} value={formatUnix(msg.Date)} />
<Summary label={"Message box ID"} value={String(msg.BoxID)} mono />
<Summary label={"Private message ID"} value={String(msg.PrivateMessageID)} mono />
<Summary label={"Message sender"} value={String(msg.MessageSenderID)} mono />
<Summary label={"Time"} value={formatUnix(msg.Date)} />
</div>
<section className="section-block">
<SectionHead title={t("messages.messageBox")} text={t("messages.messageBoxesSnapshot")} />
<SectionHead title={"Message Box"} text={"message_boxes read-only snapshot"} />
<JsonBlock value={detail.MessageJSON} />
</section>
<div className="raw-grid">
<section className="section-block">
<SectionHead title={t("messages.dialogRow")} text={t("messages.dialogSnapshot")} />
<SectionHead title={"Dialog Row"} text={"dialogs read-only snapshot"} />
<JsonBlock value={detail.DialogJSON} />
</section>
<section className="section-block">
<SectionHead title={t("messages.privateRow")} text={t("messages.privateSnapshot")} />
<SectionHead title={"Private Message Row"} text={"private_messages read-only snapshot"} />
<JsonBlock value={detail.PrivateJSON} />
</section>
</div>
<section className="section-block">
<SectionHead title={t("messages.userUpdateEvents")} text={t("messages.userEventsSource")} />
<SectionHead title={"Update Events"} text={"durable user_update_events"} />
<div className="table-wrap">
<table className="data-table">
<thead><tr><th>PTS</th><th>{t("common.count")}</th><th>{t("common.type")}</th><th>{t("common.time")}</th></tr></thead>
<thead><tr><th>PTS</th><th>{"Count"}</th><th>{"Type"}</th><th>{"Time"}</th></tr></thead>
<tbody>
{detail.UpdateEvents.map((row) => <tr key={`${row.PTS}-${row.Type}`}><td>{row.PTS}</td><td>{row.PTSCount}</td><td>{row.Type}</td><td>{formatUnix(row.Date)}</td></tr>)}
{detail.UpdateEvents.length === 0 && <EmptyRow colSpan={4} />}
@ -87,10 +85,10 @@ export function MessageDetailPage({ ownerUserID, msgID, navigate }: { ownerUserI
</div>
</section>
<section className="section-block">
<SectionHead title={t("messages.dispatchOutbox")} text={t("messages.outboxSource")} />
<SectionHead title={"Dispatch Queue"} text={"online/offline dispatch_outbox"} />
<div className="table-wrap">
<table className="data-table">
<thead><tr><th>ID</th><th>{t("account.userID")}</th><th>PTS</th><th>{t("common.type")}</th><th>{t("common.status")}</th><th>{t("messages.attempts")}</th><th>{t("common.updatedAt")}</th></tr></thead>
<thead><tr><th>ID</th><th>{"User ID"}</th><th>PTS</th><th>{"Type"}</th><th>{"Status"}</th><th>{"Attempts"}</th><th>{"Updated"}</th></tr></thead>
<tbody>
{detail.Outbox.map((row) => <tr key={row.ID}><td>{row.ID}</td><td>{row.TargetUserID}</td><td>{row.PTS}</td><td>{row.EventType}</td><td>{row.Status}</td><td>{row.Attempts}</td><td>{formatDate(row.UpdatedAt)}</td></tr>)}
{detail.Outbox.length === 0 && <EmptyRow colSpan={7} />}
@ -102,9 +100,9 @@ export function MessageDetailPage({ ownerUserID, msgID, navigate }: { ownerUserI
}
side={
<section className="action-dock">
<div className="dock-title">{t("common.operations")}</div>
<div className="dock-title">{"Operations"}</div>
<ActionButton
label={t("messages.deleteThis")}
label={"Delete this message"}
icon={<Trash2 size={15} />}
path="/api/actions/delete-messages"
payload={() => ({ owner_user_id: msg.OwnerUserID, peer_id: msg.PeerID, ids: [msg.BoxID], revoke: true })}

View file

@ -4,13 +4,11 @@ import { api, errorMessage } from "../api";
import { ActionButton } from "../components/ActionButton";
import { UserPicker } from "../components/EntityPicker";
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
import { useI18n } from "../i18n";
import { displayName, formatUnix, parseIDs, toInt } from "../lib/format";
import type { Navigate } from "../routing";
import type { AccountRow, MessageListResponse } from "../types";
export function MessagesPage({ navigate }: { navigate: Navigate }) {
const { t } = useI18n();
const [owner, setOwner] = useState<AccountRow | null>(null);
const [peer, setPeer] = useState<AccountRow | null>(null);
const [beforeDate, setBeforeDate] = useState("");
@ -27,7 +25,7 @@ export function MessagesPage({ navigate }: { navigate: Navigate }) {
async function load(next = false) {
setError("");
if (!owner || !peer) {
setError(t("messages.selectPrivatePeers"));
setError("Search and select the owner user and peer user first");
return;
}
const params = new URLSearchParams({
@ -67,46 +65,46 @@ export function MessagesPage({ navigate }: { navigate: Navigate }) {
}
return (
<PageFrame title={t("messages.privateTitle")} eyebrow={t("messages.privateEyebrow")}>
<PageFrame title={"Private Messages"} eyebrow={"Private message boxes"}>
{error && <Alert>{error}</Alert>}
<QueryPanel>
<div className="message-selector-grid">
<UserPicker label={t("messages.ownerUser")} value={owner} onChange={changeOwner} />
<UserPicker label={t("messages.peerUser")} value={peer} onChange={changePeer} />
<UserPicker label={"Owner user"} value={owner} onChange={changeOwner} />
<UserPicker label={"Peer user"} value={peer} onChange={changePeer} />
</div>
<form className="toolbar message-query" onSubmit={(event) => { event.preventDefault(); void load(false); }}>
<input value={beforeDate} onChange={(event) => setBeforeDate(event.target.value)} placeholder={t("messages.beforeDatePlaceholder")} />
<input value={beforeID} onChange={(event) => setBeforeID(event.target.value)} placeholder={t("messages.beforeIDPlaceholder")} />
<input className="small-input" value={limit} onChange={(event) => setLimit(event.target.value)} placeholder={t("messages.limitPlaceholder")} />
<button className="btn primary icon-text" type="submit"><Search size={15} /> {t("messages.searchMessages")}</button>
{data?.rows.length ? <button className="btn icon-text" type="button" onClick={() => load(true)}><ChevronRight size={15} /> {t("messages.nextPage")}</button> : null}
<input value={beforeDate} onChange={(event) => setBeforeDate(event.target.value)} placeholder={"before_date cursor"} />
<input value={beforeID} onChange={(event) => setBeforeID(event.target.value)} placeholder={"before_msg_id cursor"} />
<input className="small-input" value={limit} onChange={(event) => setLimit(event.target.value)} placeholder={"limit <= 100"} />
<button className="btn primary icon-text" type="submit"><Search size={15} /> {"Search messages"}</button>
{data?.rows.length ? <button className="btn icon-text" type="button" onClick={() => load(true)}><ChevronRight size={15} /> {"Next page"}</button> : null}
</form>
</QueryPanel>
<div className="metric-row">
<Metric label={t("messages.currentPage")} value={String(data?.rows.length ?? 0)} />
<Metric label={t("messages.deleted")} value={String((data?.rows ?? []).filter((row) => row.Deleted).length)} tone="danger" />
<Metric label={t("messages.outgoing")} value={String((data?.rows ?? []).filter((row) => row.Outgoing).length)} />
<Metric label={t("messages.ownerPeer")} value={owner && peer ? `${displayName(owner)} / ${displayName(peer)}` : "-"} />
<Metric label={"Messages on page"} value={String(data?.rows.length ?? 0)} />
<Metric label={"Deleted"} value={String((data?.rows ?? []).filter((row) => row.Deleted).length)} tone="danger" />
<Metric label={"Outgoing"} value={String((data?.rows ?? []).filter((row) => row.Outgoing).length)} />
<Metric label={"Owner / Peer"} value={owner && peer ? `${displayName(owner)} / ${displayName(peer)}` : "-"} />
</div>
<div className="operation-row">
<div className="operation-box">
<div className="operation-title"><Trash2 size={15} /> {t("messages.deleteSelected")}</div>
<input value={ids} onChange={(event) => setIDs(event.target.value)} placeholder={t("messages.idsPlaceholder")} />
<label className="checkline"><input type="checkbox" checked={revoke} onChange={(event) => setRevoke(event.target.checked)} /> {t("messages.revoke")}</label>
<ActionButton path="/api/actions/delete-messages" label={t("messages.previewDelete")} payload={() => ({
<div className="operation-title"><Trash2 size={15} /> {"Delete selected messages"}</div>
<input value={ids} onChange={(event) => setIDs(event.target.value)} placeholder={"Message IDs, comma separated"} />
<label className="checkline"><input type="checkbox" checked={revoke} onChange={(event) => setRevoke(event.target.checked)} /> {"Revoke for both sides"}</label>
<ActionButton path="/api/actions/delete-messages" label={"Dry-run delete"} payload={() => ({
owner_user_id: owner?.ID ?? 0,
peer_id: peer?.ID ?? 0,
ids: parseIDs(ids, t("messages.msgIDsInvalid")),
ids: parseIDs(ids, "Message IDs are invalid"),
revoke
})} />
</div>
<div className="operation-box">
<div className="operation-title"><History size={15} /> {t("messages.clearHistory")}</div>
<input value={maxID} onChange={(event) => setMaxID(event.target.value)} placeholder={t("messages.maxIDPlaceholder")} />
<input value={maxBatches} onChange={(event) => setMaxBatches(event.target.value)} placeholder={t("messages.maxBatchesPlaceholder")} />
<label className="checkline"><input type="checkbox" checked={revoke} onChange={(event) => setRevoke(event.target.checked)} /> {t("messages.revoke")}</label>
<label className="checkline"><input type="checkbox" checked={justClear} onChange={(event) => setJustClear(event.target.checked)} /> {t("messages.justClear")}</label>
<ActionButton path="/api/actions/delete-history" label={t("messages.previewClearHistory")} payload={() => ({
<div className="operation-title"><History size={15} /> {"Clear private history"}</div>
<input value={maxID} onChange={(event) => setMaxID(event.target.value)} placeholder={"max_id cutoff"} />
<input value={maxBatches} onChange={(event) => setMaxBatches(event.target.value)} placeholder={"max_batches"} />
<label className="checkline"><input type="checkbox" checked={revoke} onChange={(event) => setRevoke(event.target.checked)} /> {"Revoke for both sides"}</label>
<label className="checkline"><input type="checkbox" checked={justClear} onChange={(event) => setJustClear(event.target.checked)} /> {"Clear only this side"}</label>
<ActionButton path="/api/actions/delete-history" label={"Dry-run clear history"} payload={() => ({
owner_user_id: owner?.ID ?? 0,
peer_id: peer?.ID ?? 0,
max_id: toInt(maxID),
@ -120,13 +118,13 @@ export function MessagesPage({ navigate }: { navigate: Navigate }) {
<table className="data-table">
<thead>
<tr>
<th>{t("common.messageId")}</th>
<th>{t("common.time")}</th>
<th>{t("common.sender")}</th>
<th>{t("messages.direction")}</th>
<th>{"Message ID"}</th>
<th>{"Time"}</th>
<th>{"Sender"}</th>
<th>{"Direction"}</th>
<th>PTS</th>
<th>{t("common.status")}</th>
<th>{t("messages.body")}</th>
<th>{"Status"}</th>
<th>{"Body"}</th>
<th></th>
</tr>
</thead>
@ -136,16 +134,16 @@ export function MessagesPage({ navigate }: { navigate: Navigate }) {
<td className="mono">{row.BoxID}</td>
<td>{formatUnix(row.Date)}</td>
<td className="mono">{row.FromUserID}</td>
<td>{row.Outgoing ? t("messages.outgoing") : t("messages.incoming")}</td>
<td>{row.Outgoing ? "Outgoing" : "Incoming"}</td>
<td>{row.PTS}</td>
<td>{row.Deleted ? <Badge tone="danger">{t("common.deleted")}</Badge> : <Badge>{t("common.survived")}</Badge>}</td>
<td>{row.Deleted ? <Badge tone="danger">{"Deleted"}</Badge> : <Badge>{"Live"}</Badge>}</td>
<td className="truncate">{row.Body}</td>
<td>
<button
className="row-link"
onClick={() => navigate(`/messages/private/detail?owner_user_id=${row.OwnerUserID}&msg_id=${row.BoxID}`)}
>
{t("common.detail")} <ChevronRight size={14} />
{"Details"} <ChevronRight size={14} />
</button>
</td>
</tr>

View file

@ -0,0 +1,402 @@
import { ArrowLeft, CheckCircle2, RefreshCw, ShieldCheck } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { api, errorMessage } from "../api";
import { Alert, JsonBlock, LoadingSurface, PageFrame, SectionHead, SplitLayout, Summary } from "../components/ui";
import { formatDate } from "../lib/format";
import type { Navigate } from "../routing";
import type { ModerationCaseDetail, ModerationReport } from "../types";
import {
CaseSeverity,
CaseStatus,
moderationEnumLabel,
moderationTargetLabel
} from "./ModerationCasesPage";
type DecisionPreset = "no_violation" | "scam" | "fake" | "freeze" | "scam_freeze" | "fake_freeze" | "delete_messages" | "delete_account";
export function ModerationCaseDetailPage({ id, navigate }: { id: number; navigate: Navigate }) {
const [detail, setDetail] = useState<ModerationCaseDetail | null>(null);
const [report, setReport] = useState<ModerationReport | null>(null);
const [reason, setReason] = useState("");
const [preset, setPreset] = useState<DecisionPreset>("no_violation");
const [messageIDs, setMessageIDs] = useState("");
const [ownerUserID, setOwnerUserID] = useState("");
const [revokeMessages, setRevokeMessages] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
function selectReport(next: ModerationReport | null) {
setReport(next);
if (!next) return;
const ids = next.Items
.filter((item) => item.Kind === "message")
.map((item) => Number(item.ItemID))
.filter((value) => Number.isSafeInteger(value) && value > 0);
setMessageIDs(ids.join(", "));
setOwnerUserID(String(next.ReporterUserID));
}
async function load() {
setError("");
try {
const next = await api.moderationCase(id);
setDetail(next);
const reportID = next.ReportIDs[0];
selectReport(reportID ? await api.moderationReport(reportID) : null);
} catch (err) {
setError(errorMessage(err));
}
}
useEffect(() => {
void load();
}, [id]);
const selectedActions = useMemo(
() => actionsForPreset(
preset,
detail?.Case.Target.Type,
parseMessageIDs(messageIDs),
Number(ownerUserID),
revokeMessages
),
[preset, detail?.Case.Target.Type, messageIDs, ownerUserID, revokeMessages]
);
const appealRemedy = useMemo(
() => detail ? requiredAppealRemedy(detail) : { actions: [], label: "None", blocked: false },
[detail]
);
async function claim() {
if (!detail) return;
setBusy(true);
setError("");
try {
await api.claimModerationCase(id, detail.Case.Version);
await load();
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
async function decide() {
if (!detail || !reason.trim()) {
setError("A review reason is required.");
return;
}
if (preset === "delete_messages" && selectedActions.length === 0) {
setError(detail.Case.Target.Type === "user"
? "Private-message deletion requires valid evidence message IDs and the reporter's owner_user_id."
: "Channel-message deletion requires at least one valid evidence message ID.");
return;
}
if (!window.confirm(`Submit the “${decisionPresetLabel(preset)}” decision? The action will run through the durable action queue.`)) return;
setBusy(true);
setError("");
try {
const result = await api.decideModerationCase(id, {
expected_version: detail.Case.Version,
reason: reason.trim(),
kind: preset === "no_violation" ? "no_violation" : "violation",
actions: selectedActions
});
setDetail(result.case);
setReason("");
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
async function reviewAppeal(appealID: number, granted: boolean) {
if (!detail || !reason.trim()) {
setError("An appeal review reason is required.");
return;
}
if (!window.confirm(granted ? "Grant this appeal?" : "Deny this appeal?")) return;
setBusy(true);
try {
const result = await api.reviewModerationAppeal(id, appealID, {
expected_version: detail.Case.Version,
reason: reason.trim(),
granted,
actions: granted ? appealRemedy.actions : []
});
setDetail(result.case);
setReason("");
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
if (error && !detail) return <Alert>{error}</Alert>;
if (!detail) return <LoadingSurface label={"Loading moderation case…"} />;
const item = detail.Case;
const canClaim = item.Status === "open" || item.Status === "in_review" || item.Status === "appeal_review";
const canDecide = (item.Status === "in_review" || item.Status === "action_failed") && Boolean(item.AssignedTo);
const canSubmitDecision = canDecide && (item.Status !== "action_failed" || preset !== "no_violation");
const pendingAppeal = detail.Appeals.find((appeal) => appeal.Status === "pending");
return (
<PageFrame
title={`Review case #${item.ID}`}
eyebrow={"Moderation / Case detail"}
actions={
<>
<button className="btn icon-text" onClick={() => navigate("/moderation")}>
<ArrowLeft size={15} /> {"Back to queue"}
</button>
<button className="btn icon-text" onClick={load}>
<RefreshCw size={15} /> {"Refresh"}
</button>
</>
}
>
{error && <Alert>{error}</Alert>}
<SplitLayout
main={
<div className="stacked-sections">
<section className="entity-head">
<div>
<div className="entity-title">{moderationTargetLabel(item.Target.Type, item.Target.ID)}</div>
<div className="entity-subtitle">
{`Version ${item.Version} · Updated ${formatDate(item.UpdatedAt)}`}
</div>
</div>
<div className="entity-badges">
<CaseStatus status={item.Status} />
<CaseSeverity value={item.Severity} />
</div>
</section>
<div className="summary-grid">
<Summary label={"Target"} value={moderationTargetLabel(item.Target.Type, item.Target.ID)} mono />
<Summary
label={"Reports"}
value={`${item.ReportCount} reports from ${item.DistinctReporterCount} reporters`}
/>
<Summary label={"Reviewer"} value={item.AssignedTo || "-"} />
<Summary
label={"First / latest report"}
value={`${formatDate(item.FirstReportAt)} / ${formatDate(item.LastReportAt)}`}
/>
</div>
<section className="section-block">
<SectionHead title={"Report evidence"} text={"Shows up to the latest 100 reports; snapshots are frozen when reports are admitted."} />
<div className="toolbar">
{detail.ReportIDs.map((reportID) => (
<button className="btn" key={reportID} onClick={async () => selectReport(await api.moderationReport(reportID))}>
#{reportID}
</button>
))}
</div>
{report && (
<>
<div className="summary-grid">
<Summary
label={"Source / Reason"}
value={`${moderationEnumLabel("source", report.Source)} / ${moderationEnumLabel("reason", report.Reason)}`}
/>
<Summary label={"Reporter"} value={String(report.ReporterUserID)} mono />
<Summary label={"Option"} value={report.Option} mono />
<Summary label={"Time"} value={formatDate(report.CreatedAt)} />
</div>
{report.Comment && <p className="about-text">{report.Comment}</p>}
<JsonBlock value={JSON.stringify(report, null, 2)} />
</>
)}
</section>
<section className="section-block">
<SectionHead title={"Decision and action audit"} text={"Actions run idempotently through a lease worker; failures retain their error and attempt count."} />
<JsonBlock value={JSON.stringify({ decisions: detail.Decisions, actions: detail.Actions }, null, 2)} />
</section>
{detail.Appeals.length > 0 && (
<section className="section-block">
<SectionHead title={"Appeals"} />
<JsonBlock value={JSON.stringify(detail.Appeals, null, 2)} />
</section>
)}
</div>
}
side={
<section className="action-dock">
<div className="dock-title">{"Case actions"}</div>
{canClaim && (
<button className="btn primary icon-text" disabled={busy} onClick={claim}>
<ShieldCheck size={15} /> {item.AssignedTo ? "Renew claim" : "Claim case"}
</button>
)}
<label className="field">
<span>{"Review reason"}</span>
<textarea value={reason} onChange={(event) => setReason(event.target.value)} rows={5} />
</label>
<label className="field">
<span>{"Decision template"}</span>
<select value={preset} onChange={(event) => setPreset(event.target.value as DecisionPreset)}>
<option value="no_violation">{"No violation (dismiss report)"}</option>
<option value="scam">{"Mark as SCAM"}</option>
<option value="fake">{"Mark as FAKE"}</option>
<option value="freeze">{"Freeze account"}</option>
<option value="scam_freeze">{"SCAM + freeze"}</option>
<option value="fake_freeze">{"FAKE + freeze"}</option>
<option value="delete_messages">{"Delete messages covered by evidence"}</option>
<option value="delete_account">{"Delete account"}</option>
</select>
</label>
{preset === "delete_messages" && (
<>
<label className="field">
<span>{"Evidence message IDs (comma-separated)"}</span>
<input value={messageIDs} onChange={(event) => setMessageIDs(event.target.value)} placeholder="101, 102" />
</label>
{item.Target.Type === "user" && (
<>
<label className="field">
<span>{"Private-chat owner_user_id"}</span>
<input value={ownerUserID} onChange={(event) => setOwnerUserID(event.target.value)} inputMode="numeric" />
</label>
<label className="field checkbox-field">
<input type="checkbox" checked={revokeMessages} onChange={(event) => setRevokeMessages(event.target.checked)} />
<span>{"Revoke for both sides"}</span>
</label>
</>
)}
<Alert>{"The server will verify again that every message ID exists in this case's immutable report evidence."}</Alert>
</>
)}
{item.Status === "action_failed" && preset === "no_violation" && (
<Alert>{"The action was partially executed and cannot be changed directly to no violation. Select a new action to retry while retaining the previous failure audit."}</Alert>
)}
{canDecide && (
<button className="btn danger icon-text" disabled={busy || !canSubmitDecision} onClick={decide}>
<CheckCircle2 size={15} /> {item.Status === "action_failed"
? "Retry action"
: "Submit decision"}
</button>
)}
{pendingAppeal && item.AssignedTo && (
<>
<div className="dock-title">{`Appeal review #${pendingAppeal.ID}`}</div>
<Summary label={"Automatic remedy after approval"} value={appealRemedy.label} />
{appealRemedy.blocked && (
<Alert>{"The case contains a completed irreversible deletion. It cannot be marked as approved and restored; deny it or escalate for manual handling."}</Alert>
)}
<button className="btn" disabled={busy} onClick={() => reviewAppeal(pendingAppeal.ID, false)}>
{"Deny appeal"}
</button>
<button className="btn primary" disabled={busy || appealRemedy.blocked} onClick={() => reviewAppeal(pendingAppeal.ID, true)}>
{"Grant appeal"}
</button>
</>
)}
</section>
}
/>
</PageFrame>
);
}
function actionsForPreset(
preset: DecisionPreset,
targetType: string | undefined,
messageIDs: number[],
ownerUserID: number,
revoke: boolean
): Array<{ kind: string; payload: Record<string, unknown> }> {
switch (preset) {
case "scam":
return [{ kind: "mark_scam", payload: {} }];
case "fake":
return [{ kind: "mark_fake", payload: {} }];
case "freeze":
return [{ kind: "freeze_account", payload: {} }];
case "scam_freeze":
return [{ kind: "mark_scam", payload: {} }, { kind: "freeze_account", payload: {} }];
case "fake_freeze":
return [{ kind: "mark_fake", payload: {} }, { kind: "freeze_account", payload: {} }];
case "delete_messages":
if (messageIDs.length === 0) return [];
if (targetType === "channel") {
return [{ kind: "delete_channel_message", payload: { ids: messageIDs } }];
}
if (targetType === "user" && Number.isSafeInteger(ownerUserID) && ownerUserID > 0) {
return [{ kind: "delete_private_message", payload: { owner_user_id: ownerUserID, ids: messageIDs, revoke } }];
}
return [];
case "delete_account":
return [{ kind: "delete_account", payload: {} }];
default:
return [];
}
}
function parseMessageIDs(raw: string): number[] {
const values = raw
.split(/[,\s]+/)
.filter(Boolean)
.map(Number);
if (values.length === 0 || values.some((value) => !Number.isSafeInteger(value) || value <= 0)) return [];
return [...new Set(values)];
}
function requiredAppealRemedy(detail: ModerationCaseDetail): {
actions: Array<{ kind: string; payload: Record<string, unknown> }>;
label: string;
blocked: boolean;
} {
let flagsActive = false;
let freezeActive = false;
let irreversible = false;
for (const action of [...detail.Actions].sort((left, right) => left.ID - right.ID)) {
if (action.Status !== "succeeded") continue;
switch (action.Kind) {
case "mark_scam":
case "mark_fake":
flagsActive = true;
break;
case "clear_peer_flags":
flagsActive = false;
break;
case "freeze_account":
freezeActive = true;
break;
case "unfreeze_account":
freezeActive = false;
break;
case "delete_private_message":
case "delete_channel_message":
case "delete_account":
irreversible = true;
break;
}
}
const actions: Array<{ kind: string; payload: Record<string, unknown> }> = [];
const labels: string[] = [];
if (flagsActive) {
actions.push({ kind: "clear_peer_flags", payload: {} });
labels.push("Clear SCAM / FAKE");
}
if (freezeActive) {
actions.push({ kind: "unfreeze_account", payload: {} });
labels.push("Unfreeze account");
}
return { actions, label: labels.join(" + ") || "No recovery action needed", blocked: irreversible };
}
function decisionPresetLabel(preset: DecisionPreset): string {
const labels: Record<DecisionPreset, string> = {
no_violation: "No violation (dismiss report)",
scam: "Mark as SCAM",
fake: "Mark as FAKE",
freeze: "Freeze account",
scam_freeze: "SCAM + freeze",
fake_freeze: "FAKE + freeze",
delete_messages: "Delete messages covered by evidence",
delete_account: "Delete account"
};
return labels[preset];
}

View file

@ -0,0 +1,209 @@
import { ChevronRight, RefreshCw, ShieldAlert } from "lucide-react";
import { useEffect, useState } from "react";
import { api, errorMessage } from "../api";
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
import { formatDate } from "../lib/format";
import type { Navigate } from "../routing";
import type { ModerationCaseRow } from "../types";
const defaultStatuses = "open,in_review,action_pending,action_failed,appeal_review";
const allStatuses = "open,in_review,action_pending,action_failed,resolved,dismissed,appeal_review";
const statusFilterOptions = [
{ value: defaultStatuses, label: "Active queue" },
{ value: allStatuses, label: "All statuses" },
{ value: "open", label: "Open" },
{ value: "in_review", label: "In review" },
{ value: "action_pending", label: "Action pending" },
{ value: "action_failed", label: "Action failed" },
{ value: "appeal_review", label: "Appeal review" },
{ value: "resolved", label: "Resolved" },
{ value: "dismissed", label: "Dismissed" }
];
export function ModerationCasesPage({ navigate }: { navigate: Navigate }) {
const [statuses, setStatuses] = useState(defaultStatuses);
const [assignedTo, setAssignedTo] = useState("");
const [rows, setRows] = useState<ModerationCaseRow[]>([]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
async function load() {
setBusy(true);
setError("");
try {
const params = new URLSearchParams({ statuses, limit: "100" });
if (assignedTo.trim()) params.set("assigned_to", assignedTo.trim());
setRows((await api.moderationCases(params)).cases);
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
useEffect(() => {
void load();
}, []);
const pendingActions = rows.filter((row) => row.Status === "action_pending" || row.Status === "action_failed").length;
const critical = rows.filter((row) => row.Severity === 4).length;
return (
<PageFrame
title={"Reports and Moderation"}
eyebrow={"Moderation / Cases"}
actions={
<button className="btn icon-text" type="button" onClick={load} disabled={busy}>
<RefreshCw size={15} className={busy ? "spin" : ""} /> {"Refresh"}
</button>
}
>
{error && <Alert>{error}</Alert>}
<div className="metric-row">
<Metric label={"Current queue"} value={String(rows.length)} />
<Metric label={"Critical cases"} value={String(critical)} tone={critical ? "danger" : "neutral"} />
<Metric label={"Pending / failed actions"} value={String(pendingActions)} tone={pendingActions ? "warn" : "good"} />
</div>
<QueryPanel>
<form className="toolbar" onSubmit={(event) => { event.preventDefault(); void load(); }}>
<label className="field-inline">
<span>{"Status"}</span>
<select
aria-label={"Case status filter"}
value={statuses}
onChange={(event) => setStatuses(event.target.value)}
>
{statusFilterOptions.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</label>
<label className="field-inline">
<span>{"Reviewer"}</span>
<input
value={assignedTo}
onChange={(event) => setAssignedTo(event.target.value)}
placeholder={"Leave blank for all"}
/>
</label>
<button className="btn primary icon-text" type="submit" disabled={busy}>
<ShieldAlert size={15} /> {"Search"}
</button>
</form>
</QueryPanel>
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th>{"Case"}</th>
<th>{"Target"}</th>
<th>{"Status"}</th>
<th>{"Severity"}</th>
<th>{"Reports / Reporters"}</th>
<th>{"Reviewer"}</th>
<th>{"Latest report"}</th>
<th></th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.ID}>
<td className="mono">#{row.ID}</td>
<td className="mono">{moderationTargetLabel(row.Target.Type, row.Target.ID)}</td>
<td><CaseStatus status={row.Status} /></td>
<td><CaseSeverity value={row.Severity} /></td>
<td>{row.ReportCount} / {row.DistinctReporterCount}</td>
<td>{row.AssignedTo || "-"}</td>
<td>{formatDate(row.LastReportAt)}</td>
<td>
<button className="row-link" onClick={() => navigate(`/moderation/${row.ID}`)}>
{"Review"} <ChevronRight size={14} />
</button>
</td>
</tr>
))}
{rows.length === 0 && <EmptyRow colSpan={8} />}
</tbody>
</table>
</div>
</PageFrame>
);
}
export function CaseStatus({ status }: { status: string }) {
const tone = status === "resolved" || status === "dismissed"
? "good"
: status === "action_failed"
? "danger"
: status === "action_pending"
? "warn"
: "neutral";
return <Badge tone={tone}>{moderationEnumLabel("status", status)}</Badge>;
}
const severityLabels: Record<string, string> = {
low: "Low",
medium: "Medium",
high: "High",
critical: "Critical"
};
export function CaseSeverity({ value }: { value: number }) {
const keys = ["", "low", "medium", "high", "critical"];
const key = keys[value];
return (
<Badge tone={value >= 4 ? "danger" : value >= 3 ? "warn" : "neutral"}>
{key ? severityLabels[key] : value}
</Badge>
);
}
const moderationLabels: Record<string, Record<string, string>> = {
status: {
open: "Open",
in_review: "In review",
action_pending: "Action pending",
action_failed: "Action failed",
appeal_review: "Appeal review",
resolved: "Resolved",
dismissed: "Dismissed"
},
targetType: {
channel: "Channel",
chat: "Group",
user: "Account"
},
source: {
account_peer: "Account / peer",
antispam_false_positive: "Anti-spam false positive",
channel_spam: "Channel spam",
encrypted_spam: "Encrypted-chat spam",
ephemeral: "Ephemeral media",
messages: "Messages",
messages_spam: "Message spam",
profile_photo: "Profile photo",
reaction: "Reaction",
sponsored: "Sponsored message",
story: "Story"
},
reason: {
child_abuse: "Child abuse",
copyright: "Copyright",
fake: "Fake",
geo_irrelevant: "Location-irrelevant",
illegal_drugs: "Illegal drugs",
other: "Other",
personal_details: "Personal details",
pornography: "Pornography",
spam: "Spam",
violence: "Violence"
}
};
export function moderationEnumLabel(group: string, value: string): string {
return moderationLabels[group]?.[value] ?? value;
}
export function moderationTargetLabel(type: string, id: number): string {
return `${moderationEnumLabel("targetType", type)} #${id}`;
}

View file

@ -1,6 +1,10 @@
import { type Navigate, type RouteState } from "../routing";
import { AccountDetailPage } from "./AccountDetailPage";
import { AccountRatingDetailPage } from "./AccountRatingDetailPage";
import { AccountRatingsPage } from "./AccountRatingsPage";
import { AccountsPage } from "./AccountsPage";
import { CollectibleUsernameDetailPage } from "./CollectibleUsernameDetailPage";
import { CollectibleUsernamesPage } from "./CollectibleUsernamesPage";
import { ChannelDetailPage } from "./ChannelDetailPage";
import { ChannelsPage } from "./ChannelsPage";
import { BotDetailPage } from "./BotDetailPage";
@ -13,11 +17,73 @@ import { MessagesPage } from "./MessagesPage";
import { GiftsPage } from "./GiftsPage";
import { StickerSetsPage } from "./StickerSetsPage";
import { GiveGiftsPage } from "./GiveGiftsPage";
import { ModerationCaseDetailPage } from "./ModerationCaseDetailPage";
import { ModerationCasesPage } from "./ModerationCasesPage";
import { BotVerificationPage } from "./BotVerificationPage";
import { BotVerificationRequestPage } from "./BotVerificationRequestPage";
import { VerificationDetailPage } from "./VerificationDetailPage";
import { VerificationPage } from "./VerificationPage";
import {
PermissionGate,
permissionBotVerificationReview,
permissionVerificationReview
} from "../permissions";
export function Routes({ route, navigate }: { route: RouteState; navigate: Navigate }) {
const accountID = route.path.match(/^\/accounts\/(\d+)$/)?.[1];
const channelID = route.path.match(/^\/channels\/(\d+)$/)?.[1];
const botID = route.path.match(/^\/bots\/(\d+)$/)?.[1];
const moderationCaseID = route.path.match(/^\/moderation\/(\d+)$/)?.[1];
// int64 ids stay strings so large values never lose precision.
const collectibleUsernameID = route.path.match(/^\/collectible-usernames\/(\d+)$/)?.[1];
const ratingUserID = route.path.match(/^\/account-ratings\/(\d+)$/)?.[1];
const verificationID = route.path.match(/^\/verification\/(\d+)$/)?.[1];
// Third-party verification: a separate section with its own rights, matched before
// the official one so neither prefix can shadow the other.
const botVerificationRequestID = route.path.match(/^\/bot-verification\/(\d+)$/)?.[1];
if (botVerificationRequestID) {
return (
<PermissionGate permission={permissionBotVerificationReview}>
<BotVerificationRequestPage id={botVerificationRequestID} navigate={navigate} />
</PermissionGate>
);
}
if (route.path === "/bot-verification") {
return (
<PermissionGate permission={permissionBotVerificationReview}>
<BotVerificationPage navigate={navigate} />
</PermissionGate>
);
}
// The detail match has to be tested before the exact "/verification" branch, and
// the whole section is wrapped in the permission gate so a direct URL explains
// itself instead of rendering an empty queue.
if (verificationID) {
return (
<PermissionGate permission={permissionVerificationReview}>
<VerificationDetailPage id={verificationID} navigate={navigate} />
</PermissionGate>
);
}
if (route.path === "/verification") {
return (
<PermissionGate permission={permissionVerificationReview}>
<VerificationPage navigate={navigate} />
</PermissionGate>
);
}
if (collectibleUsernameID) {
return <CollectibleUsernameDetailPage id={collectibleUsernameID} navigate={navigate} />;
}
if (ratingUserID) {
return <AccountRatingDetailPage userID={ratingUserID} navigate={navigate} />;
}
if (route.path === "/collectible-usernames") {
return <CollectibleUsernamesPage navigate={navigate} />;
}
if (route.path === "/account-ratings") {
return <AccountRatingsPage navigate={navigate} />;
}
if (accountID) {
return <AccountDetailPage id={Number(accountID)} navigate={navigate} />;
}
@ -27,6 +93,9 @@ export function Routes({ route, navigate }: { route: RouteState; navigate: Navig
if (botID) {
return <BotDetailPage id={Number(botID)} navigate={navigate} />;
}
if (moderationCaseID) {
return <ModerationCaseDetailPage id={Number(moderationCaseID)} navigate={navigate} />;
}
if (route.path === "/accounts") {
return <AccountsPage navigate={navigate} />;
}
@ -36,6 +105,9 @@ export function Routes({ route, navigate }: { route: RouteState; navigate: Navig
if (route.path === "/bots") {
return <BotsPage navigate={navigate} />;
}
if (route.path === "/moderation") {
return <ModerationCasesPage navigate={navigate} />;
}
if (route.path === "/emoji") {
return <StickerSetsPage kind="emoji" />;
}

View file

@ -5,7 +5,6 @@ import { api, errorMessage } from "../api";
import { ActionButton } from "../components/ActionButton";
import { StickerDocumentPreview } from "../components/StickerDocumentPreview";
import { Alert } from "../components/ui";
import { useI18n } from "../i18n";
import type { StickerSetRow } from "../types";
// Cells per modal page. Each page fully replaces the previous one (rather
@ -15,7 +14,6 @@ import type { StickerSetRow } from "../types";
const PAGE_SIZE = 24;
export function StickerSetPreviewModal({ set, onClose }: { set: StickerSetRow; onClose: () => void }) {
const { t } = useI18n();
const noun = set.Kind === "emoji" ? "emoji" : "sticker";
const [documentIDs, setDocumentIDs] = useState<string[] | null>(null);
const [error, setError] = useState("");
@ -52,19 +50,19 @@ export function StickerSetPreviewModal({ set, onClose }: { set: StickerSetRow; o
<section className="modal command-modal sticker-preview-modal" role="dialog" aria-modal="true" aria-label={set.Title || `#${set.ID}`}>
<div className="modal-head">
<div>
<div className="eyebrow">{t("stickers.previewEyebrow")}</div>
<div className="eyebrow">{"Set contents"}</div>
<h2>{set.Title || `#${set.ID}`}</h2>
</div>
<button className="icon-btn" type="button" onClick={onClose} aria-label={t("action.close")}><X size={15} /></button>
<button className="icon-btn" type="button" onClick={onClose} aria-label={"Close"}><X size={15} /></button>
</div>
<div className="command-body">
<AddStickerForm setID={set.ID} noun={noun} onAdded={load} />
{error && <Alert>{error}</Alert>}
{!error && documentIDs === null && (
<div className="loading-line"><Loader2 className="spin" size={18} /> {t("common.loading")}</div>
<div className="loading-line"><Loader2 className="spin" size={18} /> {"Loading"}</div>
)}
{documentIDs !== null && total === 0 && !error && (
<div className="empty-panel">{t("stickers.previewEmpty")}</div>
<div className="empty-panel">{"This set has no documents."}</div>
)}
{pageItems.length > 0 && (
<div className="sticker-doc-grid" key={currentPage}>
@ -74,7 +72,7 @@ export function StickerSetPreviewModal({ set, onClose }: { set: StickerSetRow; o
<ActionButton
compact
tone="danger"
label={t("stickers.removeSticker", { noun })}
label={"Remove"}
icon={<Trash2 size={12} />}
path="/api/actions/remove-sticker-from-set"
payload={() => ({ set_id: set.ID, document_id: documentID })}
@ -86,14 +84,14 @@ export function StickerSetPreviewModal({ set, onClose }: { set: StickerSetRow; o
)}
{total > PAGE_SIZE && (
<div className="gift-pager">
<span className="gift-pager-range">{t("gifts.pageRange", { start: rangeStart, end: rangeEnd, total })}</span>
<span className="gift-pager-range">{`Showing ${rangeStart}-${rangeEnd} of ${total}`}</span>
<div className="gift-pager-controls">
<button className="btn compact-btn" type="button" onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={currentPage <= 1}>
<ChevronLeft size={14} /> {t("gifts.pagePrev")}
<ChevronLeft size={14} /> {"Previous"}
</button>
<span className="gift-pager-page">{t("gifts.pageOf", { page: currentPage, total: totalPages })}</span>
<span className="gift-pager-page">{`Page ${currentPage} of ${totalPages}`}</span>
<button className="btn compact-btn" type="button" onClick={() => setPage((p) => Math.min(totalPages, p + 1))} disabled={currentPage >= totalPages}>
{t("gifts.pageNext")} <ChevronRight size={14} />
{"Next"} <ChevronRight size={14} />
</button>
</div>
</div>
@ -110,7 +108,6 @@ export function StickerSetPreviewModal({ set, onClose }: { set: StickerSetRow; o
// step): unlike destructive actions, materializing one sticker document is
// low-risk and reversible via the per-cell Remove button.
function AddStickerForm({ setID, noun, onAdded }: { setID: string; noun: string; onAdded: () => void }) {
const { t } = useI18n();
const [file, setFile] = useState<File | null>(null);
const [emoji, setEmoji] = useState("");
const [reason, setReason] = useState("");
@ -119,15 +116,15 @@ function AddStickerForm({ setID, noun, onAdded }: { setID: string; noun: string;
async function submit() {
if (!file) {
setError(t("stickers.fileRequired", { noun }));
setError(`Choose a ${noun} file first`);
return;
}
if (!emoji.trim()) {
setError(t("stickers.emojiRequired"));
setError("An emoji is required.");
return;
}
if (!reason.trim()) {
setError(t("action.reasonRequired"));
setError("Please enter an operation reason");
return;
}
setBusy(true);
@ -152,12 +149,12 @@ function AddStickerForm({ setID, noun, onAdded }: { setID: string; noun: string;
<div className="sticker-add-form">
<label className={`gift-file-picker compact ${file ? "has-file" : ""}`}>
<input type="file" accept=".tgs,.json,.webp,application/json,application/x-tgsticker,image/webp" onChange={(event) => setFile(event.target.files?.[0] ?? null)} />
<span className="gift-file-copy"><strong>{file ? file.name : t("stickers.filePrompt")}</strong></span>
<span className="gift-file-copy"><strong>{file ? file.name : "Choose a TGS, Lottie JSON, or WebP file"}</strong></span>
</label>
<input className="small-input" value={emoji} onChange={(event) => setEmoji(event.target.value)} placeholder={t("stickers.emojiPlaceholder")} />
<input className="small-input" value={reason} onChange={(event) => setReason(event.target.value)} placeholder={t("action.reasonPlaceholder")} />
<input className="small-input" value={emoji} onChange={(event) => setEmoji(event.target.value)} placeholder={"e.g. 😀"} />
<input className="small-input" value={reason} onChange={(event) => setReason(event.target.value)} placeholder={"Describe why this operation is being performed"} />
<button className="btn primary compact-btn" type="button" onClick={submit} disabled={busy}>
{busy ? <Loader2 className="spin" size={14} /> : <Plus size={14} />} {t("stickers.addSticker", { noun })}
{busy ? <Loader2 className="spin" size={14} /> : <Plus size={14} />} {`Add ${noun}`}
</button>
{error && <span className="sticker-add-form-error">{error}</span>}
</div>

View file

@ -4,7 +4,6 @@ import { api, errorMessage } from "../api";
import { ActionButton } from "../components/ActionButton";
import { StickerDocumentPreview } from "../components/StickerDocumentPreview";
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
import { useI18n } from "../i18n";
import type { StickerSetRow } from "../types";
import { CreateStickerSetModal } from "./CreateStickerSetModal";
import { StickerSetPreviewModal } from "./StickerSetPreviewModal";
@ -16,7 +15,6 @@ type StickerPageSize = 10 | 20 | 50 | 100 | "all";
// filtered out server-side and never reach this page; they aren't meant to be
// hand-edited.
export function StickerSetsPage({ kind }: { kind: "stickers" | "emoji" }) {
const { t } = useI18n();
const [sets, setSets] = useState<StickerSetRow[]>([]);
const [query, setQuery] = useState("");
const [busy, setBusy] = useState(false);
@ -28,8 +26,10 @@ export function StickerSetsPage({ kind }: { kind: "stickers" | "emoji" }) {
const [previewSet, setPreviewSet] = useState<StickerSetRow | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const pageTitleKey = kind === "emoji" ? "stickers.emojiPageTitle" : "stickers.pageTitle";
const eyebrowKey = kind === "emoji" ? "stickers.emojiEyebrow" : "stickers.eyebrow";
const pageTitle = kind === "emoji" ? "Emoji" : "Stickers";
const eyebrow = kind === "emoji"
? "Custom-emoji packs — system packs aren't shown here, they're not hand-edited"
: "Sticker packs — system packs (dice, animated emoji, gifts) aren't shown here, they're not hand-edited";
const noun = kind === "emoji" ? "emoji" : "sticker";
async function load() {
@ -76,33 +76,33 @@ export function StickerSetsPage({ kind }: { kind: "stickers" | "emoji" }) {
return (
<PageFrame
title={t(pageTitleKey)}
eyebrow={t(eyebrowKey)}
title={pageTitle}
eyebrow={eyebrow}
actions={
<>
<button className="btn" type="button" onClick={() => load()} disabled={busy}>
<RefreshCw size={15} /> {t("common.refresh")}
<RefreshCw size={15} /> {"Refresh"}
</button>
<button className="btn primary" type="button" onClick={() => setCreateOpen(true)}>
<Plus size={15} /> {t("stickers.create", { noun })}
<Plus size={15} /> {`Create ${noun} pack`}
</button>
</>
}
>
{error && <Alert>{error}</Alert>}
<div className="metric-row">
<Metric label={t("stickers.total")} value={String(counts.total)} />
<Metric label={t("stickers.official")} value={String(counts.official)} tone="good" />
<Metric label={t("stickers.archived")} value={String(counts.archived)} tone={counts.archived > 0 ? "warn" : "neutral"} />
<Metric label={"Total sets"} value={String(counts.total)} />
<Metric label={"Official"} value={String(counts.official)} tone="good" />
<Metric label={"Archived"} value={String(counts.archived)} tone={counts.archived > 0 ? "warn" : "neutral"} />
</div>
<QueryPanel>
<div className="toolbar">
<label className="searchbox">
<Search size={15} />
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder={t("stickers.searchPlaceholder")} />
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder={"Search set ID, short name or title"} />
</label>
<label className="gift-page-size">
<span>{t("gifts.perPage")}</span>
<span>{"Per page"}</span>
<select
value={String(pageSize)}
onChange={(event) => setPageSize(event.target.value === "all" ? "all" : (Number(event.target.value) as StickerPageSize))}
@ -111,25 +111,25 @@ export function StickerSetsPage({ kind }: { kind: "stickers" | "emoji" }) {
<option value="20">20</option>
<option value="50">50</option>
<option value="100">100</option>
<option value="all">{t("gifts.perPageAll")}</option>
<option value="all">{"All"}</option>
</select>
</label>
<span className="gift-list-summary">{t("stickers.listSummary", { shown: visible.length, total: sets.length })}</span>
<span className="gift-list-summary">{`Showing ${visible.length} of ${sets.length}`}</span>
</div>
</QueryPanel>
<div className="table-wrap gift-table-wrap">
<table className="data-table">
<thead>
<tr>
<th>{t("stickers.logo")}</th>
<th>{t("stickers.id")}</th>
<th>{t("stickers.shortName")}</th>
<th>{t("stickers.title")}</th>
<th>{t("stickers.count")}</th>
<th>{t("stickers.official")}</th>
<th>{t("common.status")}</th>
<th>{t("stickers.sortOrder")}</th>
<th>{t("common.actions")}</th>
<th>{"Logo"}</th>
<th>{"ID"}</th>
<th>{"Short name"}</th>
<th>{"Title"}</th>
<th>{"Documents"}</th>
<th>{"Official"}</th>
<th>{"Status"}</th>
<th>{"Sort order"}</th>
<th>{"Actions"}</th>
</tr>
</thead>
<tbody>
@ -143,7 +143,7 @@ export function StickerSetsPage({ kind }: { kind: "stickers" | "emoji" }) {
)}
</td>
<td className="mono">{set.ID}</td>
<td className="mono">{set.ShortName || <span className="muted-cell">{t("common.none")}</span>}</td>
<td className="mono">{set.ShortName || <span className="muted-cell">{"None"}</span>}</td>
<td>
<div className="sort-order-editor">
<input
@ -154,7 +154,7 @@ export function StickerSetsPage({ kind }: { kind: "stickers" | "emoji" }) {
<ActionButton
compact
tone="neutral"
label={t("stickers.saveTitle")}
label={"Save"}
path="/api/actions/rename-sticker-set"
payload={() => ({ set_id: set.ID, title: (titleDrafts[set.ID] ?? set.Title).trim() })}
onDone={() => void load()}
@ -162,8 +162,8 @@ export function StickerSetsPage({ kind }: { kind: "stickers" | "emoji" }) {
</div>
</td>
<td>{set.Count}</td>
<td>{set.Official ? <Badge tone="good">{t("common.yes")}</Badge> : <Badge>{t("common.no")}</Badge>}</td>
<td>{set.Archived ? <Badge tone="danger">{t("stickers.archived")}</Badge> : <Badge tone="good">{t("common.enabled")}</Badge>}</td>
<td>{set.Official ? <Badge tone="good">{"Yes"}</Badge> : <Badge>{"No"}</Badge>}</td>
<td>{set.Archived ? <Badge tone="danger">{"Archived"}</Badge> : <Badge tone="good">{"Enabled"}</Badge>}</td>
<td>
<div className="sort-order-editor">
<input
@ -175,7 +175,7 @@ export function StickerSetsPage({ kind }: { kind: "stickers" | "emoji" }) {
<ActionButton
compact
tone="neutral"
label={t("stickers.saveOrder")}
label={"Save"}
path="/api/actions/set-sticker-set-sort-order"
payload={() => ({ set_id: set.ID, sort_order: Number(orderDrafts[set.ID] ?? set.SortOrder) })}
onDone={() => void load()}
@ -185,12 +185,12 @@ export function StickerSetsPage({ kind }: { kind: "stickers" | "emoji" }) {
<td>
<div className="gift-table-actions">
<button className="btn compact-btn" type="button" onClick={() => setPreviewSet(set)}>
<Eye size={13} /> {t("stickers.view")}
<Eye size={13} /> {"View"}
</button>
<ActionButton
compact
tone="neutral"
label={set.Archived ? t("stickers.unarchive") : t("stickers.archive")}
label={set.Archived ? "Unarchive" : "Archive"}
path="/api/actions/set-sticker-set-archived"
payload={() => ({ set_id: set.ID, archived: !set.Archived })}
onDone={() => void load()}
@ -198,7 +198,7 @@ export function StickerSetsPage({ kind }: { kind: "stickers" | "emoji" }) {
<ActionButton
compact
tone="danger"
label={t("stickers.delete")}
label={"Delete"}
path="/api/actions/delete-sticker-set"
payload={() => ({ set_id: set.ID })}
onDone={() => void load()}
@ -213,14 +213,14 @@ export function StickerSetsPage({ kind }: { kind: "stickers" | "emoji" }) {
</div>
{pageSize !== "all" && visible.length > 0 && (
<div className="gift-pager">
<span className="gift-pager-range">{t("gifts.pageRange", { start: rangeStart, end: rangeEnd, total: visible.length })}</span>
<span className="gift-pager-range">{`Showing ${rangeStart}-${rangeEnd} of ${visible.length}`}</span>
<div className="gift-pager-controls">
<button className="btn compact-btn" type="button" onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={currentPage <= 1}>
<ChevronLeft size={14} /> {t("gifts.pagePrev")}
<ChevronLeft size={14} /> {"Previous"}
</button>
<span className="gift-pager-page">{t("gifts.pageOf", { page: currentPage, total: totalPages })}</span>
<span className="gift-pager-page">{`Page ${currentPage} of ${totalPages}`}</span>
<button className="btn compact-btn" type="button" onClick={() => setPage((p) => Math.min(totalPages, p + 1))} disabled={currentPage >= totalPages}>
{t("gifts.pageNext")} <ChevronRight size={14} />
{"Next"} <ChevronRight size={14} />
</button>
</div>
</div>

View file

@ -0,0 +1,426 @@
import {
ArrowLeft,
BadgeCheck,
Ban,
CheckCircle2,
ExternalLink,
Handshake,
RefreshCw,
ShieldOff,
User,
XCircle
} from "lucide-react";
import { useEffect, useState, type ReactNode } from "react";
import { api, APIError, errorMessage } from "../api";
import { ActionButton } from "../components/ActionButton";
import { Alert, Badge, EmptyRow, LoadingSurface, PageFrame, SectionHead, SplitLayout, Summary } from "../components/ui";
import { displayUsername, formatDate, safeHttpURL } from "../lib/format";
import { permissionVerificationRevoke, usePermissions } from "../permissions";
import type { Navigate } from "../routing";
import type { VerificationApplicationDetail, VerificationEventKind } from "../types";
import {
VerificationStatusBadge,
targetHref,
targetLabel,
verificationStatusLabels,
verificationTargetTypeLabels
} from "./VerificationPage";
const verificationEventKindLabels: Record<VerificationEventKind, string> = {
created: "Created",
updated: "Updated",
submitted: "Submitted",
claimed: "Claimed",
approved: "Approved",
rejected: "Rejected",
cancelled: "Cancelled",
revoked: "Badge revoked",
notified: "Applicant notified"
};
export function VerificationDetailPage({ id, navigate }: { id: string; navigate: Navigate }) {
const { can } = usePermissions();
const [detail, setDetail] = useState<VerificationApplicationDetail | null>(null);
const [note, setNote] = useState("");
const [conflict, setConflict] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
async function load() {
setBusy(true);
setError("");
try {
setDetail(await api.verificationApplication(id));
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
function refresh() {
setConflict(false);
void load();
}
useEffect(() => {
void load();
}, [id]);
// 409 is the one failure the operator cannot fix by editing the form: another
// reviewer decided against the version this page read. The panel says so in
// plain words and reloads, so the next attempt carries the current version.
function handleActionError(err: unknown): string | undefined {
if (err instanceof APIError && err.status === 409) {
setConflict(true);
void load();
return "Another admin has already changed this application. The data has been reloaded — check the status before deciding again.";
}
return undefined;
}
if (error && !detail) {
return <Alert>{error}</Alert>;
}
if (!detail) {
return <LoadingSurface label={"Loading the application…"} />;
}
const app = detail.application;
const events = detail.events ?? [];
const controls = detail.applicant_controls_target;
const verified = detail.target_verified;
const canClaim = app.Status === "submitted";
const canDecide = app.Status === "submitted" || app.Status === "in_review";
const canRevoke = app.Status === "approved" && can(permissionVerificationRevoke);
const trimmedNote = note.trim();
// version is the optimistic-locking token: it goes with every decision, as the
// decimal string it arrived as, so a stale page cannot overwrite a fresh one.
function decisionPayload(): Record<string, unknown> {
const payload: Record<string, unknown> = { version: app.Version };
if (trimmedNote) payload.internal_note = trimmedNote;
return payload;
}
function afterDecision() {
setNote("");
setConflict(false);
void load();
}
return (
<PageFrame
title={`Application #${app.ID}`}
eyebrow={"Verification / Review"}
actions={
<>
<button className="btn icon-text" type="button" onClick={() => navigate("/verification")}>
<ArrowLeft size={15} /> {"Back to list"}
</button>
<button className="btn icon-text" type="button" onClick={refresh} disabled={busy}>
<RefreshCw size={15} className={busy ? "spin" : ""} /> {"Refresh"}
</button>
</>
}
>
{error && <Alert>{error}</Alert>}
{conflict && <Alert>{"Another admin has already changed this application. The data has been reloaded — check the status before deciding again."}</Alert>}
<SplitLayout
main={
<div className="stacked-sections">
<section className="entity-head">
<div>
<div className="entity-title">{targetLabel(app)}</div>
<div className="entity-subtitle mono">
#{app.ID} · {verificationTargetTypeLabels[app.TargetType]}:{app.TargetID} · v{app.Version}
</div>
</div>
<div className="entity-badges">
<VerificationStatusBadge status={app.Status} />
{verified && <Badge tone="good"><BadgeCheck size={12} /> {"Badge already on"}</Badge>}
<Badge tone={controls ? "good" : "danger"}>
{controls ? "Control confirmed" : "No control over the target"}
</Badge>
</div>
</section>
<section className="section-block">
<SectionHead
title={"Target"}
text={"The peer the badge would be attached to, as it exists right now."}
action={
<button className="btn icon-text" type="button" onClick={() => navigate(targetHref(app))}>
<ExternalLink size={15} /> {"Open target"}
</button>
}
/>
<div className="summary-grid">
<Summary label={"Type"} value={verificationTargetTypeLabels[app.TargetType]} />
<Summary label={"Username"} value={displayUsername(app.TargetUsername) || "-"} />
<Summary label={"Title"} value={app.TargetTitle || "-"} />
<Summary label={"Peer ID"} value={app.TargetID} mono />
</div>
</section>
<section className="section-block">
<SectionHead
title={"Applicant"}
text={"Who filed the application and whether they still hold rights on the target."}
action={
<button className="btn icon-text" type="button" onClick={() => navigate(`/accounts/${app.ApplicantUserID}`)}>
<User size={15} /> {"Open account"}
</button>
}
/>
<div className="summary-grid">
<Summary label={"Username"} value={displayUsername(app.ApplicantUsername) || "-"} />
<Summary label={"Name"} value={app.ApplicantName || "-"} />
<Summary label={"User ID"} value={app.ApplicantUserID} mono />
<Summary label={"Submitted"} value={formatDate(app.SubmittedAt) || "-"} />
</div>
{controls
? <p className="bot-create-note">{"The applicant controls the target right now — checked against the live records, not against the submission snapshot."}</p>
: <Alert>{"The applicant no longer controls the target. Approving would hand the badge to someone who does not hold the peer — normally a reason to reject."}</Alert>}
</section>
<section className="section-block">
<SectionHead title={"Application"} text={"Everything the applicant submitted, rendered as plain text."} />
<div className="stacked-sections">
<div className="summary-grid">
<Summary label={"Category"} value={app.Category || "-"} />
<Summary label={"Correlation ID"} value={app.CorrelationID || "-"} mono />
<Summary label={"Created"} value={formatDate(app.CreatedAt) || "-"} />
<Summary label={"Updated"} value={formatDate(app.UpdatedAt) || "-"} />
</div>
<FieldBlock label={"Description"}>
{app.Description
? <p className="about-text">{app.Description}</p>
: <p className="bot-create-note">{"Not provided"}</p>}
</FieldBlock>
<FieldBlock label={"Official website"}>
{app.OfficialWebsite
? <div className="about-text"><SafeLink value={app.OfficialWebsite} /></div>
: <p className="bot-create-note">{"Not provided"}</p>}
</FieldBlock>
<FieldBlock label={"Social links"}>
<LinkList values={app.SocialLinks} />
</FieldBlock>
<FieldBlock label={"Press coverage"}>
<LinkList values={app.PressLinks} />
</FieldBlock>
<FieldBlock label={"Applicant comment"}>
{app.AdditionalNote
? <p className="about-text">{app.AdditionalNote}</p>
: <p className="bot-create-note">{"Not provided"}</p>}
</FieldBlock>
<p className="bot-create-note">{"Only http:// and https:// links are clickable and open in a new tab; anything else is shown as text."}</p>
</div>
</section>
<section className="section-block">
<SectionHead title={"Decision"} text={"What was decided, by whom, and with which wording."} />
<div className="stacked-sections">
<div className="summary-grid">
<Summary label={"Reviewer"} value={app.ReviewerAdminID || "-"} />
<Summary label={"Decided"} value={formatDate(app.ReviewedAt) || "-"} />
<Summary label={"Status"} value={verificationStatusLabels[app.Status]} />
<Summary label={"Version (optimistic lock)"} value={app.Version} mono />
</div>
<FieldBlock label={"Decision reason"}>
{app.DecisionReason
? <p className="about-text">{app.DecisionReason}</p>
: <p className="bot-create-note">{"No decision yet"}</p>}
</FieldBlock>
{/* The internal note is the reviewer handover text and is labelled
as admin-only wherever it appears. */}
<FieldBlock label={`${"Internal note"} · ${"admins only"}`}>
{app.InternalNote
? <p className="about-text">{app.InternalNote}</p>
: <p className="bot-create-note">{"Not provided"}</p>}
</FieldBlock>
</div>
</section>
<section className="section-block">
<SectionHead title={"History"} text={"Immutable trail of every status transition, with actor and reason."} />
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th>{"Event"}</th>
<th>{"From → to"}</th>
<th>{"Actor"}</th>
<th>{"Reason"}</th>
<th>{"Internal note"}</th>
<th>{"Time"}</th>
</tr>
</thead>
<tbody>
{events.map((row) => (
<tr key={row.ID}>
<td><EventKind kind={row.Kind} /></td>
<td className="mono">
{row.FromStatus || "-"} → {row.ToStatus || "-"}
</td>
<td>{row.Actor || "-"}</td>
<td className="truncate">{row.Reason || "-"}</td>
<td className="truncate">{row.Note || "-"}</td>
<td>{formatDate(row.CreatedAt) || "-"}</td>
</tr>
))}
{events.length === 0 && <EmptyRow colSpan={6} />}
</tbody>
</table>
</div>
</section>
</div>
}
side={
<section className="action-dock">
<div className="dock-title">{"Review actions"}</div>
{!canClaim && !canDecide && !canRevoke && (
<p className="bot-create-note">{"This status has no available actions."}</p>
)}
{canClaim && (
<>
<div className="action-stack">
<ActionButton
label={"Take into review"}
icon={<Handshake size={15} />}
tone="neutral"
path={`/api/verification/applications/${app.ID}/claim`}
payload={() => ({ version: app.Version })}
onDone={afterDecision}
onError={handleActionError}
/>
</div>
<p className="bot-create-note">{"Assigns the application to you and moves it to in review, so two reviewers never work on the same one."}</p>
</>
)}
{/* One optional note field feeds every decision on this page,
including a revoke. */}
{(canDecide || canRevoke) && (
<>
<label className="duration-field">
<span>{"Internal note"}</span>
<textarea
value={note}
onChange={(event) => setNote(event.target.value)}
rows={3}
placeholder={"Handover note for other reviewers"}
/>
</label>
<p className="bot-create-note">{"Optional. Stored with the decision and visible to admins only — never sent to the applicant."}</p>
</>
)}
{canDecide && (
<>
{!controls && <Alert>{"The applicant no longer controls the target. Approving would hand the badge to someone who does not hold the peer — normally a reason to reject."}</Alert>}
{verified && <p className="bot-create-note">{"The target already carries the badge; approving only records the decision."}</p>}
<div className="action-stack">
<ActionButton
label={"Approve"}
icon={<CheckCircle2 size={15} />}
tone="neutral"
path={`/api/verification/applications/${app.ID}/approve`}
payload={decisionPayload}
onDone={afterDecision}
onError={handleActionError}
/>
<ActionButton
label={"Reject"}
icon={<XCircle size={15} />}
tone="warn"
path={`/api/verification/applications/${app.ID}/reject`}
payload={decisionPayload}
onDone={afterDecision}
onError={handleActionError}
/>
</div>
<p className="bot-create-note">{"Grants the official badge to the target and closes the application."}</p>
<p className="bot-create-note">{"The reason is mandatory: it is the wording the applicant is told, so write what exactly was missing."}</p>
</>
)}
{canRevoke && (
<>
<div className="dock-title"><ShieldOff size={14} /> {"Danger zone"}</div>
<div className="danger-zone">
<ActionButton
label={"Revoke verification"}
icon={<Ban size={15} />}
tone="danger"
path="/api/actions/revoke-verification"
payload={() => {
// Revoke addresses the peer, not the application: the
// approved application stays approved as history.
const payload: Record<string, unknown> = {
target_type: app.TargetType,
target_id: app.TargetID
};
if (trimmedNote) payload.internal_note = trimmedNote;
return payload;
}}
onDone={afterDecision}
onError={handleActionError}
/>
<p className="bot-create-note">{"Clears the badge from the target. The approved application stays in history."}</p>
{!verified && <p className="bot-create-note">{"The target carries no badge right now — there is nothing to revoke."}</p>}
</div>
</>
)}
</section>
}
/>
</PageFrame>
);
}
function FieldBlock({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="duration-field">
<span>{label}</span>
{children}
</div>
);
}
// Applicant-supplied text is rendered as ordinary React children (escaped by
// React) and only ever linked when it is an http(s) URL. No markup from a
// submission reaches the DOM.
function SafeLink({ value }: { value: string }) {
const href = safeHttpURL(value);
if (!href) {
return <span className="mono">{value}</span>;
}
return (
<a className="row-link" href={href} target="_blank" rel="noopener noreferrer">
{value} <ExternalLink size={13} />
</a>
);
}
function LinkList({ values }: { values: string[] | null }) {
const links = (values ?? []).filter((item) => item.trim() !== "");
if (links.length === 0) {
return <p className="bot-create-note">{"Not provided"}</p>;
}
return (
<div className="about-text">
{links.map((item, index) => (
<div key={`${index}-${item}`}><SafeLink value={item} /></div>
))}
</div>
);
}
function EventKind({ kind }: { kind: VerificationEventKind }) {
const tone = kind === "approved"
? "good"
: kind === "rejected" || kind === "revoked" || kind === "cancelled"
? "danger"
: kind === "submitted" || kind === "claimed"
? "warn"
: "neutral";
return <Badge tone={tone}>{verificationEventKindLabels[kind]}</Badge>;
}

View file

@ -0,0 +1,245 @@
import { BadgeCheck, ChevronDown, ChevronRight, Loader2, RefreshCw, Search, ShieldCheck } from "lucide-react";
import { useEffect, useState } from "react";
import { api, errorMessage } from "../api";
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
import { displayUsername, formatDate } from "../lib/format";
import type { Navigate } from "../routing";
import type {
VerificationApplicationRow,
VerificationStatus,
VerificationTargetType
} from "../types";
type StatusFilter = "all" | VerificationStatus;
type TargetFilter = "all" | VerificationTargetType;
const statuses: VerificationStatus[] = ["draft", "submitted", "in_review", "approved", "rejected", "cancelled"];
const targetTypes: VerificationTargetType[] = ["bot", "channel", "supergroup", "user"];
export const verificationStatusLabels: Record<VerificationStatus, string> = {
draft: "Draft",
submitted: "Submitted",
in_review: "In review",
approved: "Approved",
rejected: "Rejected",
cancelled: "Cancelled"
};
export const verificationTargetTypeLabels: Record<VerificationTargetType, string> = {
bot: "Bot",
channel: "Channel",
supergroup: "Supergroup",
user: "User"
};
export function VerificationPage({ navigate }: { navigate: Navigate }) {
const [status, setStatus] = useState<StatusFilter>("all");
const [targetType, setTargetType] = useState<TargetFilter>("all");
const [reviewer, setReviewer] = useState("");
const [q, setQ] = useState("");
const [limit, setLimit] = useState("50");
const [rows, setRows] = useState<VerificationApplicationRow[]>([]);
const [counts, setCounts] = useState<Record<string, string>>({});
const [hasMore, setHasMore] = useState(false);
const [cursor, setCursor] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
// One free-text field: the backend matches the application id, the target peer
// id and a username (applicant or target), so "@durov", "42" and a peer id all
// work without a mode switch.
async function load(next = false) {
setBusy(true);
setError("");
const params = new URLSearchParams({ limit });
if (status !== "all") params.set("status", status);
if (targetType !== "all") params.set("target_type", targetType);
if (reviewer.trim()) params.set("reviewer", reviewer.trim());
if (q.trim()) params.set("q", q.trim().replace(/^@/, ""));
if (next && cursor) params.set("before_id", cursor);
try {
const result = await api.verificationApplications(params);
const page = result.rows ?? [];
setRows((current) => (next ? [...current, ...page] : page));
setCursor(result.next_before_id ?? "");
setHasMore(Boolean(result.has_more));
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
// The counts are the whole queue, not the current page, so they are fetched
// separately from the keyset listing.
async function loadCounts() {
try {
const result = await api.verificationCounts();
setCounts(result.counts ?? {});
} catch (err) {
setError(errorMessage(err));
}
}
useEffect(() => {
void load(false);
void loadCounts();
}, []);
function refresh() {
void load(false);
void loadCounts();
}
return (
<PageFrame
title={"Verification queue"}
eyebrow={"Verification / Queue"}
actions={
<button className="btn icon-text" type="button" onClick={refresh} disabled={busy}>
<RefreshCw size={15} className={busy ? "spin" : ""} /> {"Refresh"}
</button>
}
>
{error && <Alert>{error}</Alert>}
<div className="metric-row">
{statuses.map((item) => (
<Metric
key={item}
label={verificationStatusLabels[item]}
value={counts[item] ?? "0"}
mono
tone={statusMetricTone(item, counts[item] ?? "0")}
/>
))}
</div>
<QueryPanel>
<form className="toolbar" onSubmit={(event) => { event.preventDefault(); void load(false); }}>
<label className="searchbox">
<Search size={15} />
<input value={q} onChange={(event) => setQ(event.target.value)} placeholder={"Application id, peer id, username or title"} />
</label>
<label className="field-inline">
<span>{"Status"}</span>
<select value={status} onChange={(event) => setStatus(event.target.value as StatusFilter)}>
<option value="all">{"All statuses"}</option>
{statuses.map((item) => (
<option key={item} value={item}>{verificationStatusLabels[item]}</option>
))}
</select>
</label>
<label className="field-inline">
<span>{"Target type"}</span>
<select value={targetType} onChange={(event) => setTargetType(event.target.value as TargetFilter)}>
<option value="all">{"All types"}</option>
{targetTypes.map((item) => (
<option key={item} value={item}>{verificationTargetTypeLabels[item]}</option>
))}
</select>
</label>
<label className="field-inline">
<span>{"Reviewer"}</span>
<input value={reviewer} onChange={(event) => setReviewer(event.target.value)} placeholder={"Any reviewer"} />
</label>
<label className="field-inline">
<span>{"Limit"}</span>
<input className="small-input" value={limit} onChange={(event) => setLimit(event.target.value)} type="number" min="1" max="200" />
</label>
<button className="btn primary icon-text" type="submit" disabled={busy}>
{busy ? <Loader2 size={15} className="spin" /> : <Search size={15} />} {"Search"}
</button>
</form>
</QueryPanel>
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th>{"ID"}</th>
<th>{"Target"}</th>
<th>{"Applicant"}</th>
<th>{"Category"}</th>
<th>{"Status"}</th>
<th>{"Submitted"}</th>
<th>{"Reviewer"}</th>
<th></th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.ID}>
<td className="mono">
<button className="row-link" type="button" onClick={() => navigate(`/verification/${row.ID}`)}>
#{row.ID}
</button>
</td>
<td>
<strong>{targetLabel(row)}</strong>
<div className="entity-subtitle mono">
{verificationTargetTypeLabels[row.TargetType]} · {row.TargetID}
</div>
{row.TargetVerified && (
<Badge tone="good"><BadgeCheck size={12} /> {"Badge already on"}</Badge>
)}
</td>
<td>
{displayUsername(row.ApplicantUsername) || row.ApplicantName || "-"}
<div className="entity-subtitle mono">{row.ApplicantUserID}</div>
</td>
<td>{row.Category || "-"}</td>
<td><VerificationStatusBadge status={row.Status} /></td>
<td>{formatDate(row.SubmittedAt) || "-"}</td>
<td>{row.ReviewerAdminID || "-"}</td>
<td>
<button className="row-link" type="button" onClick={() => navigate(`/verification/${row.ID}`)}>
<ShieldCheck size={14} /> {"Details"} <ChevronRight size={14} />
</button>
</td>
</tr>
))}
{rows.length === 0 && <EmptyRow colSpan={8} />}
</tbody>
</table>
</div>
{hasMore && (
<div className="toolbar">
<button className="btn icon-text" type="button" onClick={() => load(true)} disabled={busy}>
{busy ? <Loader2 size={15} className="spin" /> : <ChevronDown size={15} />} {"Load more"}
</button>
</div>
)}
</PageFrame>
);
}
export function VerificationStatusBadge({ status }: { status: VerificationStatus }) {
return <Badge tone={statusTone(status)}>{verificationStatusLabels[status]}</Badge>;
}
export function statusTone(status: VerificationStatus): "neutral" | "good" | "warn" | "danger" {
if (status === "approved") return "good";
if (status === "submitted" || status === "in_review") return "warn";
if (status === "rejected") return "danger";
return "neutral";
}
// submitted and in_review are the two statuses that need a reviewer; they are
// highlighted only while something actually sits in them.
function statusMetricTone(status: VerificationStatus, count: string): "neutral" | "good" | "warn" {
const waiting = status === "submitted" || status === "in_review";
if (!waiting) return status === "approved" ? "good" : "neutral";
return count !== "0" && count !== "" ? "warn" : "neutral";
}
export function targetLabel(row: VerificationApplicationRow): string {
return displayUsername(row.TargetUsername) || row.TargetTitle || `#${row.TargetID}`;
}
// The panel page that owns the target peer type, so a reviewer can inspect the
// live record rather than only the submission snapshot.
export function targetHref(row: VerificationApplicationRow): string {
if (row.TargetType === "bot") return `/bots/${row.TargetID}`;
if (row.TargetType === "user") return `/accounts/${row.TargetID}`;
return `/channels/${row.TargetID}`;
}

View file

@ -0,0 +1,72 @@
import { ShieldOff } from "lucide-react";
import { createContext, useContext, useMemo, type ReactNode } from "react";
import { Alert, PageFrame } from "./components/ui";
// Permission names exactly as the backend spells them
// (cmd/telesrv-admin/security.go). "*" is the wildcard an operator configures for
// a full-access session.
export const permissionAll = "*";
export const permissionVerificationReview = "verification.review";
export const permissionVerificationRevoke = "verification.revoke";
// Third-party verification is a separate mechanism and therefore a separate pair of
// rights: review reads the section and decides applications, manage owns the
// verifier roster, the icon catalogue and taking a granted mark away.
export const permissionBotVerificationReview = "botverification.review";
export const permissionBotVerificationManage = "botverification.manage";
// GET /api/session is read once at boot; the panel keeps the answer here so a
// section the session may not use is hidden instead of rendered into a 403. This
// is a convenience for the operator, not a security boundary: every route is
// checked again server-side.
const PermissionsContext = createContext<readonly string[]>([]);
export function PermissionsProvider({
permissions,
children
}: {
permissions: readonly string[];
children: ReactNode;
}) {
return <PermissionsContext.Provider value={permissions}>{children}</PermissionsContext.Provider>;
}
export function usePermissions(): { permissions: readonly string[]; can: (permission: string) => boolean } {
const permissions = useContext(PermissionsContext);
return useMemo(
() => ({
permissions,
can: (permission: string) => permissions.includes(permissionAll) || permissions.includes(permission)
}),
[permissions]
);
}
export function useCan(permission: string): boolean {
return usePermissions().can(permission);
}
// PermissionGate is what a direct URL hits: without the right the operator gets
// an explanation naming the missing permission, not an empty table that looks
// like "no data".
export function PermissionGate({ permission, children }: { permission: string; children: ReactNode }) {
const { can } = usePermissions();
if (can(permission)) {
return <>{children}</>;
}
return <PermissionDenied permission={permission} />;
}
export function PermissionDenied({ permission }: { permission: string }) {
return (
<PageFrame title={"Not enough rights"} eyebrow={"Console / Access"}>
<Alert>{`This session was not granted the ${permission} permission, so the section stays closed.`}</Alert>
<section className="section-block">
<div className="entity-head">
<div>
<div className="entity-title"><ShieldOff size={16} /> {"Section unavailable"}</div>
<div className="entity-subtitle">{"Ask an operator to add the permission to TELESRV_ADMIN_UI_PERMISSIONS and sign in again."}</div>
</div>
</div>
</section>
</PageFrame>
);
}

View file

@ -1,5 +1,3 @@
import type { TFunction } from "./i18n";
export type Navigate = (href: string) => void;
export type RouteState = {
@ -16,28 +14,40 @@ export function currentRoute(): RouteState {
};
}
export function routeTitle(pathname: string, t: TFunction): string {
if (pathname.startsWith("/accounts")) return t("route.accounts");
if (pathname.startsWith("/channels")) return t("route.channels");
if (pathname.startsWith("/bots")) return t("route.bots");
if (pathname.startsWith("/emoji")) return t("route.emoji");
if (pathname.startsWith("/messages")) return t("route.messages");
if (pathname.startsWith("/give-gifts")) return t("route.giveGifts");
if (pathname.startsWith("/gifts")) return t("route.gifts");
if (pathname.startsWith("/stickers")) return t("route.stickers");
if (pathname.startsWith("/emoji")) return t("route.emoji");
return t("route.dashboard");
export function routeTitle(pathname: string): string {
// Third-party verification is tested before the official section and before
// "/bots": three different prefixes that all read as "verification of a bot".
if (pathname.startsWith("/bot-verification")) return "Third-party verification";
if (pathname.startsWith("/verification")) return "Official Verification";
if (pathname.startsWith("/collectible-usernames")) return "Collectible Usernames";
if (pathname.startsWith("/account-ratings")) return "Account Rating";
if (pathname.startsWith("/accounts")) return "Accounts";
if (pathname.startsWith("/channels")) return "Supergroups and Channels";
if (pathname.startsWith("/bots")) return "Bots";
if (pathname.startsWith("/moderation")) return "Reports and Moderation";
if (pathname.startsWith("/emoji")) return "Emoji";
if (pathname.startsWith("/messages")) return "Message Audit";
if (pathname.startsWith("/give-gifts")) return "Give Gifts";
if (pathname.startsWith("/gifts")) return "Star Gifts";
if (pathname.startsWith("/stickers")) return "Stickers";
if (pathname.startsWith("/emoji")) return "Emoji";
return "Operations Console";
}
export function routeSubtitle(pathname: string, t: TFunction): string {
if (pathname.startsWith("/accounts")) return t("route.accountsSubtitle");
if (pathname.startsWith("/channels")) return t("route.channelsSubtitle");
if (pathname.startsWith("/bots")) return t("route.botsSubtitle");
if (pathname.startsWith("/emoji")) return t("route.emojiSubtitle");
if (pathname.startsWith("/messages")) return t("route.messagesSubtitle");
if (pathname.startsWith("/give-gifts")) return t("route.giveGiftsSubtitle");
if (pathname.startsWith("/gifts")) return t("route.giftsSubtitle");
if (pathname.startsWith("/stickers")) return t("route.stickersSubtitle");
if (pathname.startsWith("/emoji")) return t("route.emojiSubtitle");
return t("route.dashboardSubtitle");
export function routeSubtitle(pathname: string): string {
if (pathname.startsWith("/bot-verification")) return "Console / Third-party verification";
if (pathname.startsWith("/verification")) return "Console / Verification";
if (pathname.startsWith("/collectible-usernames")) return "Console / Collectible usernames";
if (pathname.startsWith("/account-ratings")) return "Console / Account rating";
if (pathname.startsWith("/accounts")) return "Console / Accounts";
if (pathname.startsWith("/channels")) return "Console / Channels";
if (pathname.startsWith("/bots")) return "Console / Bots";
if (pathname.startsWith("/moderation")) return "Console / Moderation";
if (pathname.startsWith("/emoji")) return "Console / Emoji";
if (pathname.startsWith("/messages")) return "Console / Messages";
if (pathname.startsWith("/give-gifts")) return "Console / Give Gifts";
if (pathname.startsWith("/gifts")) return "Console / Star Gifts";
if (pathname.startsWith("/stickers")) return "Console / Stickers";
if (pathname.startsWith("/emoji")) return "Console / Emoji";
return "Console / Overview";
}

View file

@ -185,6 +185,7 @@ body {
button,
input,
select,
textarea {
font: inherit;
}

View file

@ -325,6 +325,7 @@
}
input,
select,
textarea {
color: var(--text);
background: var(--input-bg);
@ -339,12 +340,33 @@ textarea::placeholder {
color: var(--muted-2);
}
input {
input,
select {
width: 190px;
height: 34px;
padding: 0 10px;
}
select {
min-width: 220px;
height: 34px;
padding: 0 30px 0 10px;
font: inherit;
font-weight: 600;
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
cursor: pointer;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%239aa4b2' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 10px center;
}
select:disabled {
color: var(--muted-2);
cursor: not-allowed;
}
textarea {
width: 100%;
padding: 9px 10px;
@ -352,6 +374,7 @@ textarea {
}
input:focus,
select:focus,
textarea:focus {
border-color: var(--brand);
box-shadow: 0 0 0 3px var(--focus);
@ -627,3 +650,43 @@ textarea:focus {
align-items: stretch;
}
}
/* Level progress bars (account rating leaderboard and detail). */
.progress-cell {
display: grid;
gap: 4px;
min-width: 130px;
}
.progress-cell small,
.progress-note {
color: var(--muted);
font-size: 11px;
}
.progress-bar {
overflow: hidden;
width: 100%;
height: 6px;
background: var(--panel-subtle);
border: 1px solid var(--line);
border-radius: 999px;
}
.progress-bar > span {
display: block;
height: 100%;
background: var(--brand-2);
}
.progress-bar.good > span {
background: var(--good);
}
.progress-bar.danger > span {
background: var(--danger);
}
.progress-wide .progress-cell {
min-width: 0;
}

View file

@ -103,7 +103,8 @@
font-weight: 800;
}
.duration-field input {
.duration-field input,
.duration-field select {
width: 100%;
}
@ -126,6 +127,16 @@
border-top: 1px solid var(--line);
}
/* A .dock-title already draws the rule under itself, so a .danger-zone placed
directly after one must not draw a second: the verification and bot-verification
detail docks label the zone with a dock-title and rendered two lines 10px apart
above the revoke button. */
.dock-title + .danger-zone {
margin-top: 0;
padding-top: 0;
border-top: 0;
}
.authorization-block {
display: grid;
gap: 10px;
@ -693,7 +704,8 @@
border-radius: var(--radius-sm);
}
.attr-block .duration-field input {
.attr-block .duration-field input,
.duration-field select {
width: 100%;
}
@ -793,3 +805,113 @@
text-overflow: ellipsis;
white-space: nowrap;
}
/* Account rating component breakdown. */
.breakdown-list {
display: grid;
gap: 8px;
margin-bottom: 10px;
}
.breakdown-row {
display: grid;
grid-template-columns: minmax(140px, 260px) 1fr minmax(80px, auto);
gap: 12px;
align-items: center;
padding: 9px 10px;
background: var(--panel-subtle);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
}
.breakdown-row.total {
grid-template-columns: 1fr minmax(80px, auto);
background: transparent;
}
.breakdown-label {
display: grid;
gap: 2px;
min-width: 0;
}
.breakdown-label strong {
color: var(--text);
font-weight: 800;
}
.breakdown-label small {
color: var(--muted);
font-size: 11px;
line-height: 1.35;
}
.breakdown-value {
color: var(--text);
font-weight: 800;
text-align: right;
}
.breakdown-value.good {
color: var(--good);
}
.breakdown-value.danger {
color: var(--danger-text);
}
@media (max-width: 760px) {
.breakdown-row,
.breakdown-row.total {
grid-template-columns: 1fr;
}
.breakdown-value {
text-align: left;
}
}
/* Collectible usernames branching off the peer's editable one. The guide is drawn
with borders rather than a "↳" character so it lines up at any font size and is
not read out by a screen reader as punctuation. */
.username-branch {
margin: 2px 0 0;
padding: 0;
list-style: none;
}
.username-branch li {
position: relative;
padding-left: 14px;
color: var(--text-soft);
font-size: 12px;
line-height: 1.7;
}
.username-branch li::before {
position: absolute;
top: 0;
left: 3px;
width: 6px;
height: 11px;
border-left: 1px solid var(--line-strong, var(--line));
border-bottom: 1px solid var(--line-strong, var(--line));
content: "";
}
.username-branch li.inactive {
color: var(--muted);
}
.username-branch li.inactive span {
text-decoration: line-through;
}
.username-branch li em {
margin-left: 6px;
font-size: 10px;
font-style: normal;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.04em;
}

View file

@ -1,6 +1,5 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
import { Moon, Sun } from "lucide-react";
import { useI18n } from "./i18n";
export type Theme = "light" | "dark";
@ -70,9 +69,8 @@ export function useTheme(): ThemeContextValue {
export function ThemeSwitch() {
const { theme, toggleTheme } = useTheme();
const { t } = useI18n();
const nextIsDark = theme === "light";
const label = t(nextIsDark ? "theme.switchToDark" : "theme.switchToLight");
const label = nextIsDark ? "Switch to dark theme" : "Switch to light theme";
return (
<button
className="theme-toggle"

View file

@ -1,9 +1,19 @@
// AccountUsername is one collectible username the peer holds. Active mirrors the
// username#b4073647 flag: an inactive collectible is owned but does not resolve.
export type AccountUsername = {
Username: string;
Active: boolean;
};
export type AccountRow = {
ID: number;
Phone: string;
Username: string;
FirstName: string;
LastName: string;
// Collectible usernames in projection order; never includes the editable slot
// above. Always an array, so it can be iterated unconditionally.
Collectibles: AccountUsername[];
CreatedAt: string;
UpdatedAt: string;
Frozen: boolean;
@ -263,6 +273,85 @@ export type OfficialStarGiftRow = {
export type OfficialStarGiftListResponse = { gifts: OfficialStarGiftRow[] };
export type ModerationPeer = {
Type: "user" | "channel";
ID: number;
};
export type ModerationCaseRow = {
ID: number;
Target: ModerationPeer;
Status: string;
Severity: number;
AssignedTo: string;
Version: number;
ReportCount: number;
DistinctReporterCount: number;
FirstReportAt: string;
LastReportAt: string;
CreatedAt: string;
UpdatedAt: string;
};
export type ModerationDecision = {
ID: number;
CaseID: number;
AppealID: number;
Kind: string;
Actor: string;
Reason: string;
CommandID: string;
CreatedAt: string;
};
export type ModerationAction = {
ID: number;
CaseID: number;
DecisionID: number;
Kind: string;
Payload: Record<string, unknown>;
Status: string;
Attempts: number;
LastError: string;
CommandID: string;
CreatedAt: string;
UpdatedAt: string;
};
export type ModerationAppeal = {
ID: number;
CaseID: number;
AppellantUserID: number;
Text: string;
Status: string;
PreviousCaseStatus: string;
Reviewer: string;
ReviewReason: string;
CreatedAt: string;
ReviewedAt: string;
};
export type ModerationCaseDetail = {
Case: ModerationCaseRow;
ReportIDs: number[];
Decisions: ModerationDecision[];
Actions: ModerationAction[];
Appeals: ModerationAppeal[];
};
export type ModerationReport = {
ID: number;
ReporterUserID: number;
Source: string;
Target: ModerationPeer;
Reason: string;
Option: string;
Comment: string;
Items: Array<Record<string, unknown>>;
MediaHolds: Array<Record<string, unknown>>;
CreatedAt: string;
};
export type StarGiftCollectibleAttributeRow = {
id: string;
kind: "model" | "pattern" | "backdrop";
@ -294,6 +383,347 @@ export type StarGiftCollectiblePreview = {
backdrops?: StarGiftCollectibleAttributeRow[];
};
export type CollectibleUsernameStatus = "vault" | "owned" | "burned";
export type CollectiblePeerType = "" | "user" | "channel";
export type CollectibleCurrency = "XTR" | "TON" | "USD";
// int64 columns arrive as JSON strings to survive the 2^53 boundary.
export type CollectibleUsernameRow = {
ID: string;
Username: string;
Status: CollectibleUsernameStatus;
OwnerPeerType: CollectiblePeerType;
OwnerPeerID: string;
OwnerUsername: string;
OwnerName: string;
PurchaseDate: string;
Currency: CollectibleCurrency;
Amount: string;
CryptoCurrency: string;
CryptoAmount: string;
URL: string;
OriginalOwnerPeerType: string;
OriginalOwnerPeerID: string;
OriginalOwnerUsername: string;
TransferCount: number;
Version: string;
// Mirrors the holder's username-registry row: an owned asset can still be
// hidden from the profile.
RegistryActive: boolean;
RegistrySortOrder: number;
CreatedAt: string;
UpdatedAt: string;
};
export type CollectibleUsernameTransferKind = "mint" | "transfer" | "revoke" | "burn";
export type CollectibleUsernameTransferRow = {
ID: string;
CollectibleID: string;
Kind: CollectibleUsernameTransferKind;
FromPeerType: string;
FromPeerID: string;
FromUsername: string;
ToPeerType: string;
ToPeerID: string;
ToUsername: string;
Currency: string;
Amount: string;
Actor: string;
Reason: string;
CommandKey: string;
CreatedAt: string;
};
export type CollectibleUsernameListResponse = {
rows: CollectibleUsernameRow[] | null;
has_more: boolean;
next_before_id: string;
};
export type CollectibleUsernameDetail = {
asset: CollectibleUsernameRow;
transfers: CollectibleUsernameTransferRow[] | null;
};
export type AccountRatingRow = {
UserID: string;
Username: string;
FirstName: string;
Level: number;
Stars: string;
CurrentLevelStars: string;
NextLevelStars: string;
HasNextLevel: boolean;
StarsComponent: string;
ActivityComponent: string;
PenaltyComponent: string;
ManualComponent: string;
PendingStars: string;
PendingDate: string;
ComputedAt: string;
UpdatedAt: string;
Version: string;
};
export type AccountRatingEventKind = "stars" | "activity" | "moderation" | "manual" | "recompute";
export type AccountRatingEventRow = {
ID: string;
UserID: string;
Kind: AccountRatingEventKind;
Amount: string;
Reason: string;
Actor: string;
CommandKey: string;
CreatedAt: string;
};
export type AccountRatingListResponse = {
rows: AccountRatingRow[] | null;
has_more: boolean;
next_before_id: string;
};
export type AccountRatingDetail = {
rating: AccountRatingRow;
events: AccountRatingEventRow[] | null;
};
// Official platform verification. Every int64 the backend tags `,string` stays a
// decimal string here: application ids, peer ids and the optimistic-locking
// version all outgrow the exact range of a JSON number, and a rounded version
// would send a decision against the wrong revision of the row.
export type VerificationTargetType = "bot" | "channel" | "supergroup" | "user";
export type VerificationStatus =
| "draft"
| "submitted"
| "in_review"
| "approved"
| "rejected"
| "cancelled";
export type VerificationEventKind =
| "created"
| "updated"
| "submitted"
| "claimed"
| "approved"
| "rejected"
| "cancelled"
| "revoked"
| "notified";
export type VerificationApplicationRow = {
ID: string;
ApplicantUserID: string;
ApplicantUsername: string;
ApplicantName: string;
TargetType: VerificationTargetType;
TargetID: string;
TargetTitle: string;
TargetUsername: string;
TargetVerified: boolean;
Category: string;
Description: string;
OfficialWebsite: string;
// Go marshals an empty slice as null, so both shapes have to be tolerated.
SocialLinks: string[] | null;
PressLinks: string[] | null;
AdditionalNote: string;
Status: VerificationStatus;
ReviewerAdminID: string;
DecisionReason: string;
// InternalNote is the reviewer handover note: operator-only, never shown to the
// applicant.
InternalNote: string;
CorrelationID: string;
CreatedAt: string;
UpdatedAt: string;
SubmittedAt: string;
ReviewedAt: string;
Version: string;
};
export type VerificationEventRow = {
ID: string;
Kind: VerificationEventKind;
FromStatus: string;
ToStatus: string;
Actor: string;
Reason: string;
Note: string;
CreatedAt: string;
};
export type VerificationApplicationListResponse = {
rows: VerificationApplicationRow[] | null;
has_more: boolean;
next_before_id: string;
};
export type VerificationApplicationDetail = {
application: VerificationApplicationRow;
events: VerificationEventRow[] | null;
// Both flags describe the target as it is now, not as it was at submission.
applicant_controls_target: boolean;
target_verified: boolean;
};
// Counts are decimal strings for the same exactness reason as the ids; the
// backend always sends all six statuses.
export type VerificationCountsResponse = {
counts: Record<string, string> | null;
};
// Third-party bot verification (core.telegram.org/api/bots/verification): a
// verifier bot marks a peer with its OWN icon and description, rendered before the
// name. It is a different mechanism from the official checkmark above — the two
// never read each other's state — so it gets its own row types rather than reusing
// VerificationApplicationRow.
//
// Every int64 the backend tags `,string` stays a decimal string here: bot ids, peer
// ids, custom emoji document ids and the optimistic-locking version all outgrow the
// exact range of a JSON number.
export type BotVerificationPeerType = "user" | "channel";
export type CustomVerificationRequestStatus = "pending" | "approved" | "rejected" | "revoked";
// MarkCount is tagged `,string` like the ids (it is the count that would cascade
// away with a revocation, read as int64), while VerificationIconRow.UsedByVerifiers
// is a plain number — it counts verifier rows and cannot approach the exactness
// limit. Both are rendered through String(), so neither shape can surprise a cell.
export type BotVerifierRow = {
BotID: string;
BotUsername: string;
BotName: string;
// IconDocumentID is the custom emoji document the verifier marks with. Clients
// resolve it through messages.getCustomEmojiDocuments, so an id naming no
// fetchable document renders as no badge at all.
IconDocumentID: string;
IconName: string;
CompanyName: string;
DefaultDescription: string;
// CanModifyCustomDescription mirrors botVerifierSettings flags.1: when false the
// verifier may only apply DefaultDescription.
CanModifyCustomDescription: boolean;
// Enabled is the operator kill switch: a disabled verifier keeps its granted
// marks but can no longer mark anything new.
Enabled: boolean;
GrantedBy: string;
GrantReason: string;
MarkCount: string;
CreatedAt: string;
UpdatedAt: string;
Version: string;
};
export type VerificationIconRow = {
ID: string;
DocumentID: string;
// OwnerBotID is "0" for a catalogue entry any verifier may use, and a bot id
// when the operator reserved the icon for one verifier.
OwnerBotID: string;
OwnerBotUsername: string;
Name: string;
Active: boolean;
UsedByVerifiers: number;
CreatedAt: string;
UpdatedAt: string;
};
export type CustomVerificationRow = {
ID: string;
VerifierBotID: string;
VerifierBotUsername: string;
CompanyName: string;
PeerType: BotVerificationPeerType;
PeerID: string;
PeerTitle: string;
PeerUsername: string;
// Denormalised at grant time, so a mark keeps the icon it was granted with even
// after the verifier changes its own.
IconDocumentID: string;
Description: string;
CreatedAt: string;
UpdatedAt: string;
Version: string;
};
export type CustomVerificationRequestRow = {
ID: string;
VerifierBotID: string;
VerifierBotUsername: string;
ApplicantUserID: string;
ApplicantUsername: string;
PeerType: BotVerificationPeerType;
PeerID: string;
PeerTitle: string;
PeerUsername: string;
Reason: string;
RequestedDescription: string;
Status: CustomVerificationRequestStatus;
DecidedBy: string;
DecisionReason: string;
// InternalNote is the operator handover note: never shown to the applicant.
InternalNote: string;
CorrelationID: string;
CreatedAt: string;
UpdatedAt: string;
ApprovedAt: string;
RejectedAt: string;
Version: string;
};
export type BotVerifierListResponse = {
rows: BotVerifierRow[] | null;
};
export type VerificationIconListResponse = {
rows: VerificationIconRow[] | null;
};
export type CustomVerificationListResponse = {
rows: CustomVerificationRow[] | null;
has_more: boolean;
next_before_id: string;
};
export type CustomVerificationRequestListResponse = {
rows: CustomVerificationRequestRow[] | null;
has_more: boolean;
next_before_id: string;
};
export type CustomVerificationRequestDetail = {
request: CustomVerificationRequestRow;
// The verifier row as it is now: it can be disabled, or revoked entirely, after
// the application was filed.
verifier: BotVerifierRow | null;
// mark_active describes the peer right now, not the application status: an
// approved application whose mark a verifier later withdrew reads false.
mark_active: boolean;
};
// Counts are decimal strings for the same exactness reason as the ids; the backend
// always sends all four statuses.
export type BotVerificationCountsResponse = {
counts: Record<string, string> | null;
};
export type AdminSession = {
actor: string;
// The right set the signed session was issued with; ["*"] means everything.
permissions?: string[] | null;
};
export type AdminLoginResult = AdminSession & {
csrf_token: string;
};
export type MessageDetail = {
Message: MessageRow;
MessageJSON: string;