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

This commit is contained in:
onysd 2026-07-24 17:15:53 +03:00
commit b443ff0c73
277 changed files with 30747 additions and 1551 deletions

View file

@ -4,6 +4,8 @@ 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 { 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";
@ -67,6 +69,7 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
<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>}
<ScamFakeBadges scam={detail.Scam} fake={detail.Fake} />
{account.Frozen ? <Badge tone="danger">{t("account.accountFrozen")}</Badge> : <Badge>{t("account.accountActive")}</Badge>}
</div>
</section>
@ -194,6 +197,12 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
onDone={load}
/>
</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>
<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} />
<EmojiStatusAction idKey="user_id" id={account.ID} path="/api/actions/set-account-emoji-status" onDone={load} />
</section>
}
/>

View file

@ -3,6 +3,7 @@ import { useEffect, useState } from "react";
import { api, errorMessage } from "../api";
import { Avatar } from "../components/Avatar";
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
import { ScamFakeBadges } from "../components/flags";
import { useI18n } from "../i18n";
import { displayName, displayPhone, displayUsername, formatDate, formatUnix } from "../lib/format";
import { accountMetrics } from "../lib/metrics";
@ -116,7 +117,7 @@ export function AccountsPage({ navigate }: { navigate: Navigate }) {
<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>}</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>{formatDate(row.UpdatedAt)}</td>
<td><button className="row-link" onClick={() => navigate(`/accounts/${row.ID}`)}>{t("common.detail")} <ChevronRight size={14} /></button></td>

View file

@ -0,0 +1,116 @@
import { ArrowLeft, BadgeCheck, Trash2 } from "lucide-react";
import { useEffect, useState } from "react";
import { api, errorMessage } from "../api";
import { ActionButton } from "../components/ActionButton";
import { Alert, AuditTable, Badge, LoadingSurface, PageFrame, SectionHead, SplitLayout, Summary } from "../components/ui";
import { 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);
async function load() {
setBusy(true);
setError("");
try {
setDetail(await api.bot(id));
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
useEffect(() => {
void load();
}, [id]);
if (error) {
return <Alert>{error}</Alert>;
}
if (!detail) {
return <LoadingSurface label={busy ? t("bots.loadingDetail") : t("account.waitingData")} />;
}
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>}
>
<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>
<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>}
<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) || "-"} />
</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")} />
<AuditTable rows={detail.AuditLogs} />
</section>
</div>
}
side={
<section className="action-dock">
<div className="dock-title">{t("bots.actionDock")}</div>
<div className="action-stack">
<ActionButton
label={bot.Verified ? t("account.clearVerified") : t("account.setVerified")}
icon={<BadgeCheck size={15} />}
tone="neutral"
path="/api/actions/set-verified"
payload={() => ({ user_id: bot.ID, verified: !bot.Verified })}
onDone={load}
/>
</div>
<ScamFakeActions idKey="user_id" id={bot.ID} path="/api/actions/set-account-flags" scam={bot.Scam} fake={bot.Fake} onDone={load} />
<div className="dock-title">{t("attr.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>
) : (
<div className="danger-zone">
<ActionButton
label={t("bots.delete")}
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>
</div>
)}
</section>
}
/>
</PageFrame>
);
}

View file

@ -0,0 +1,168 @@
import { BadgeCheck, Bot, ChevronRight, Loader2, Plus, RefreshCw, Search } from "lucide-react";
import { useEffect, useState } from "react";
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);
const [cursor, setCursor] = useState(0);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [ownerID, setOwnerID] = useState("");
const [botName, setBotName] = useState("");
const [botUsername, setBotUsername] = useState("");
async function load(next = false) {
setBusy(true);
setError("");
const params = new URLSearchParams({ limit });
if (q.trim()) {
params.set("q", q.trim());
} else if (next) {
params.set("before_id", String(cursor));
}
try {
const result = await api.bots(params);
setData(result);
setCursor(result.next_before_id);
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
useEffect(() => {
void load(false);
}, []);
const rows = data?.rows ?? [];
const verified = rows.filter((row) => row.Verified).length;
const systemCount = rows.filter((row) => row.System).length;
return (
<PageFrame
title={t("bots.pageTitle")}
eyebrow={data?.listing === false ? t("bots.queryResults") : t("bots.recent")}
actions={
<button className="btn" type="button" onClick={() => load(false)} disabled={busy}>
<RefreshCw size={15} /> {t("common.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)} />
</div>
<section className="section-block">
<div className="section-head">
<div>
<h2>{t("bots.createTitle")}</h2>
<p>{t("bots.createHint")}</p>
</div>
</div>
<div className="bot-create-fields">
<label className="duration-field">
<span>{t("bots.ownerUserID")}</span>
<input
value={ownerID}
onChange={(event) => setOwnerID(event.target.value)}
type="number"
min="1"
placeholder="123456789"
/>
</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} />
</label>
<label className="duration-field">
<span>{t("bots.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>
<ActionButton
label={t("bots.create")}
icon={<Plus size={15} />}
tone="neutral"
path="/api/actions/create-bot"
payload={() => ({
owner_user_id: toInt(ownerID),
name: botName.trim(),
username: botUsername.trim().replace(/^@/, "")
})}
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={t("bots.searchPlaceholder")} />
</label>
<label className="field-inline">
<span>{t("common.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")}
</button>
{data?.listing && data.has_more && (
<button className="btn icon-text" type="button" onClick={() => load(true)} disabled={busy}>
<ChevronRight size={15} /> {t("messages.nextPage")}
</button>
)}
</form>
</QueryPanel>
<div className="table-wrap">
<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></th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.ID}>
<td className="mono">{row.ID}</td>
<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>{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>
</tr>
))}
{rows.length === 0 && <EmptyRow colSpan={8} />}
</tbody>
</table>
</div>
</PageFrame>
);
}

View file

@ -4,6 +4,8 @@ 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";
import type { Navigate } from "../routing";
import type { ChannelDetail } from "../types";
@ -51,6 +53,7 @@ export function ChannelDetailPage({ id, navigate }: { id: number; navigate: Navi
<div className="entity-badges">
<Badge>{channelKind(ch, t)}</Badge>
{ch.Verified ? <Badge tone="good">{t("common.verified")}</Badge> : <Badge>{t("account.notVerified")}</Badge>}
<ScamFakeBadges scam={ch.Scam} fake={ch.Fake} />
{ch.Deleted ? <Badge tone="danger">{t("common.deleted")}</Badge> : <Badge>{t("common.valid")}</Badge>}
</div>
</section>
@ -86,6 +89,13 @@ export function ChannelDetailPage({ id, navigate }: { id: number; navigate: Navi
payload={() => ({ channel_id: ch.ID, verified: !ch.Verified })}
onDone={load}
/>
<ScamFakeActions idKey="channel_id" id={ch.ID} path="/api/actions/set-channel-flags" scam={ch.Scam} fake={ch.Fake} onDone={load} />
<div className="dock-title">{t("attr.settings")}</div>
<ChannelSettingsAction channel={ch} onDone={load} />
<div className="dock-title">{t("attr.attributes")}</div>
<UsernameAction idKey="channel_id" id={ch.ID} path="/api/actions/set-channel-username" current={ch.Username} onDone={load} />
<ColorAction idKey="channel_id" id={ch.ID} path="/api/actions/set-channel-color" onDone={load} />
<EmojiStatusAction idKey="channel_id" id={ch.ID} path="/api/actions/set-channel-emoji-status" onDone={load} />
</section>
}
/>

View file

@ -2,6 +2,7 @@ import { ChevronRight, Loader2, RefreshCw, Search } from "lucide-react";
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";
@ -110,7 +111,7 @@ export function ChannelsPage({ navigate }: { navigate: Navigate }) {
<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>}</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>{formatDate(row.UpdatedAt)}</td>
<td><button className="row-link" onClick={() => navigate(`/channels/${row.ID}`)}>{t("common.detail")} <ChevronRight size={14} /></button></td>
</tr>

View file

@ -0,0 +1,145 @@
import { Check, ChevronRight, Copy, Loader2, RefreshCw, Search } from "lucide-react";
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 {
if (value < 1024) return `${value} B`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
return `${(value / (1024 * 1024)).toFixed(1)} MB`;
}
function isAnimated(mime: string): boolean {
const m = mime.toLowerCase();
return m.includes("tgsticker") || m.includes("lottie") || m.includes("json");
}
function EmojiPreview({ row }: { row: EmojiRow }) {
const [failed, setFailed] = useState(!isAnimated(row.MimeType));
useEffect(() => {
setFailed(!isAnimated(row.MimeType));
}, [row.DocumentID, row.MimeType]);
if (failed) {
return <div className="emoji-glyph">{row.Alt || "🙂"}</div>;
}
// Render a static first frame (plays only on hover) so a full grid of emoji
// does not keep every Lottie canvas animating and lag the page.
return (
<StaticLottie
className="emoji-anim"
cacheKey={row.DocumentID}
loader={() => api.emojiAnimation(row.DocumentID)}
onError={() => setFailed(true)}
/>
);
}
function EmojiCard({ row }: { row: EmojiRow }) {
const { t } = useI18n();
const [copied, setCopied] = useState(false);
async function copy() {
try {
await navigator.clipboard.writeText(row.DocumentID);
setCopied(true);
setTimeout(() => setCopied(false), 1200);
} catch {
// Clipboard is best-effort.
}
}
return (
<div className="emoji-card">
<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")}>
<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>
</div>
</div>
);
}
export function EmojiPage() {
const { t } = useI18n();
const [q, setQ] = useState("");
const [data, setData] = useState<EmojiListResponse | null>(null);
const [cursor, setCursor] = useState(0);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
async function load(next = false) {
setBusy(true);
setError("");
const params = new URLSearchParams();
if (q.trim()) {
params.set("q", q.trim());
} else if (next) {
params.set("before_id", String(cursor));
}
try {
const result = await api.emoji(params);
setData(result);
setCursor(result.next_before_id);
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
useEffect(() => {
void load(false);
}, []);
const rows = data?.rows ?? [];
return (
<PageFrame
title={t("emoji.pageTitle")}
eyebrow={data?.listing === false ? t("emoji.queryResults") : t("emoji.recent")}
actions={
<button className="btn" type="button" onClick={() => load(false)} disabled={busy}>
<RefreshCw size={15} /> {t("common.refresh")}
</button>
}
>
{error && <Alert>{error}</Alert>}
<div className="metric-row">
<Metric label={t("emoji.currentPage")} 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")} />
</label>
<button className="btn primary icon-text" type="submit" disabled={busy}>
{busy ? <Loader2 size={15} className="spin" /> : <Search size={15} />} {t("common.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")}
</button>
)}
</form>
</QueryPanel>
<p className="about-text">{t("emoji.hint")}</p>
{rows.length === 0 ? (
<div className="empty-panel">{t("common.noResults")}</div>
) : (
<div className="emoji-grid">
{rows.map((row) => <EmojiCard key={row.DocumentID} row={row} />)}
</div>
)}
</PageFrame>
);
}

View file

@ -31,8 +31,38 @@ type BackdropDraft = {
let draftSequence = 0;
const nextKey = (kind: string) => `${kind}-${++draftSequence}`;
const newAnimated = (kind: string): AnimatedDraft => ({ key: nextKey(kind), name: "", rarity: "1000", sortOrder: "0", file: null, animation: null, fileError: "" });
const newBackdrop = (): BackdropDraft => ({ key: nextKey("backdrop"), name: "", backdropID: "1", rarity: "1000", sortOrder: "0", center: "#6f5bea", edge: "#34278f", pattern: "#a89df5", text: "#ffffff" });
const backdropPalettes = [
{ center: "#6f5bea", edge: "#34278f", pattern: "#a89df5", text: "#ffffff" },
{ center: "#32a86b", edge: "#17613e", pattern: "#8ee0b3", text: "#ffffff" },
{ center: "#df8d2f", edge: "#8c421e", pattern: "#ffd08a", text: "#ffffff" },
{ center: "#d95878", edge: "#7b2944", pattern: "#f5a1b6", text: "#ffffff" }
];
function rebalanceRarity<T extends { rarity: string }>(rows: T[]): T[] {
if (!rows.length) return rows;
const base = Math.floor(1000 / rows.length);
const remainder = 1000 % rows.length;
return rows.map((row, index) => ({ ...row, rarity: String(base + (index < remainder ? 1 : 0)) }));
}
const newAnimated = (kind: string, sortOrder: number): AnimatedDraft => ({
key: nextKey(kind), name: "", rarity: "1", sortOrder: String(sortOrder), file: null, animation: null, fileError: ""
});
function newBackdrop(rows: BackdropDraft[]): BackdropDraft {
const backdropID = rows.reduce((maximum, row) => {
const value = Number(row.backdropID);
return Number.isInteger(value) ? Math.max(maximum, value) : maximum;
}, 0) + 1;
const colors = backdropPalettes[rows.length % backdropPalettes.length];
return { key: nextKey("backdrop"), name: "", backdropID: String(backdropID), rarity: "1", sortOrder: String(rows.length), ...colors };
}
const initialAnimated = (kind: string) => rebalanceRarity([newAnimated(kind, 0), newAnimated(kind, 1)]);
const initialBackdrops = () => {
const first = newBackdrop([]);
return rebalanceRarity([first, newBackdrop([first])]);
};
function AnimationPreview({ data, compact = false }: { data: AnimationData; compact?: boolean }) {
const host = useRef<HTMLDivElement>(null);
@ -87,9 +117,9 @@ export function GiftCollectiblesModal({ gift, onClose, onPublished }: { gift: St
const [supplyTotal, setSupplyTotal] = useState("1000");
const [slugPrefix, setSlugPrefix] = useState(`gift-${gift.GiftID}`);
const [reason, setReason] = useState("");
const [models, setModels] = useState<AnimatedDraft[]>([newAnimated("model")]);
const [patterns, setPatterns] = useState<AnimatedDraft[]>([newAnimated("pattern")]);
const [backdrops, setBackdrops] = useState<BackdropDraft[]>([newBackdrop()]);
const [models, setModels] = useState<AnimatedDraft[]>(() => initialAnimated("model"));
const [patterns, setPatterns] = useState<AnimatedDraft[]>(() => initialAnimated("pattern"));
const [backdrops, setBackdrops] = useState<BackdropDraft[]>(initialBackdrops);
useEffect(() => {
let cancelled = false;
@ -131,6 +161,9 @@ 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"));
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"));
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 }));
@ -168,7 +201,7 @@ export function GiftCollectiblesModal({ gift, onClose, onPublished }: { gift: St
<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([...rows, newAnimated(kind === "models" ? "model" : "pattern")]); invalidate(); }}><Plus size={13} />{t("collectibles.addAttribute")}</button></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>
<div className="collectible-rows">
{rows.map((row, index) => <div className="collectible-row animated" key={row.key}>
@ -178,7 +211,7 @@ export function GiftCollectiblesModal({ gift, onClose, onPublished }: { gift: St
<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>
<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 === 1} onClick={() => { setRows(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={t("collectibles.remove")}><Trash2 size={14} /></button>
{row.fileError && <span className="collectible-file-error">{row.fileError}</span>}
</div>)}
</div>
@ -211,7 +244,7 @@ export function GiftCollectiblesModal({ gift, onClose, onPublished }: { gift: St
{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([...backdrops, newBackdrop()]); invalidate(); }}><Plus size={13} />{t("collectibles.addAttribute")}</button></div></div>
<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-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>
@ -220,7 +253,7 @@ export function GiftCollectiblesModal({ gift, onClose, onPublished }: { gift: St
<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>)}
<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 === 1} onClick={() => { setBackdrops(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={t("collectibles.remove")}><Trash2 size={14} /></button>
</div>)}</div>
</section>
</section>

View file

@ -33,7 +33,7 @@ function formatBytes(value: number | string) {
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function LottiePreview({ giftID, revision, compact = false }: { giftID: string; revision: number; compact?: boolean }) {
export function LottiePreview({ giftID, revision, compact = false }: { giftID: string; revision: number; compact?: boolean }) {
const host = useRef<HTMLDivElement>(null);
const animation = useRef<ReturnType<typeof lottie.loadAnimation> | null>(null);
const [playing, setPlaying] = useState(true);

View file

@ -0,0 +1,220 @@
import { CheckCircle2, CircleAlert, Gift, Loader2, Play, User, Users } from "lucide-react";
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";
type RecipientKind = "user" | "channel";
function attrLabel(attr: StarGiftCollectibleAttributeRow): string {
const rarity = attr.rarity_permille > 0 ? ` · ${(attr.rarity_permille / 10).toFixed(1)}%` : "";
return `${attr.name || `#${attr.id}`}${rarity}`;
}
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);
const [message, setMessage] = useState("");
const [hideName, setHideName] = useState(false);
const [upgrade, setUpgrade] = useState(false);
const [preview, setPreview] = useState<StarGiftCollectiblePreview | null>(null);
const [previewError, setPreviewError] = useState("");
const [modelID, setModelID] = useState("0");
const [patternID, setPatternID] = useState("0");
const [backdropID, setBackdropID] = useState("0");
const [reason, setReason] = useState("");
const [result, setResult] = useState<CommandResult | null>(null);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const recipientID = kind === "user" ? user?.ID ?? 0 : channel?.ID ?? 0;
const upgradable = kind === "user" && upgrade;
// Reset the collectible selection whenever the chosen gift changes; the
// recipient/sender/message are intentionally preserved for fast re-issuing.
useEffect(() => {
setUpgrade(false);
setPreview(null);
setPreviewError("");
setModelID("0");
setPatternID("0");
setBackdropID("0");
setResult(null);
setError("");
}, [gift.GiftID]);
useEffect(() => {
if (!upgradable || preview) return;
let cancelled = false;
setPreviewError("");
api.giftCollectibles(gift.GiftID)
.then((data) => { if (!cancelled) setPreview(data); })
.catch((err) => { if (!cancelled) setPreviewError(errorMessage(err)); });
return () => { cancelled = true; };
}, [upgradable, preview, gift.GiftID]);
function buildPayload(confirm: boolean): Record<string, unknown> {
return {
gift_id: gift.GiftID,
// Gifts are always sent from the official system account (777000).
sender_user_id: Number(SYSTEM_SENDER),
user_id: kind === "user" ? recipientID : 0,
channel_id: kind === "channel" ? recipientID : 0,
hide_name: hideName,
message: message.trim(),
upgrade: upgradable,
model_attribute_id: upgradable ? modelID : "0",
pattern_attribute_id: upgradable ? patternID : "0",
backdrop_attribute_id: upgradable ? backdropID : "0",
reason: reason.trim(),
confirm
};
}
const previewPayload = useMemo(() => buildPayload(false), [gift.GiftID, kind, recipientID, message, hideName, upgrade, modelID, patternID, backdropID, reason]);
const canConfirm = result?.dry_run && !result.error;
async function run(confirm: boolean) {
if (recipientID <= 0) {
setError(t("giveGift.recipientRequired"));
return;
}
if (!reason.trim()) {
setError(t("action.reasonRequired"));
return;
}
setBusy(true);
setError("");
try {
const commandResult = await api.action("/api/actions/give-gift", buildPayload(confirm));
setResult(commandResult);
if (confirm && !commandResult.error) {
onDone?.();
}
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
return (
<div className="give-gift-form">
<div className="give-gift-summary">
<Gift size={16} />
<div>
<strong>{gift.Title || `Gift #${gift.GiftID}`}</strong>
<span className="mono">#{gift.GiftID} · {gift.Stars}</span>
</div>
</div>
<div className="give-gift-tabs" role="group" aria-label={t("giveGift.recipientKind")}>
<button type="button" className={`btn ${kind === "user" ? "primary" : ""}`} onClick={() => { setKind("user"); setResult(null); }}>
<User size={15} /> {t("giveGift.recipientUser")}
</button>
<button type="button" className={`btn ${kind === "channel" ? "primary" : ""}`} onClick={() => { setKind("channel"); setUpgrade(false); setResult(null); }}>
<Users size={15} /> {t("giveGift.recipientChannel")}
</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); }} />}
<label className="form-field">
<span>{t("giveGift.sender")}</span>
<input value={SYSTEM_SENDER} disabled readOnly />
<small className="field-hint">{t("giveGift.senderHint")}</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")} />
</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>
</label>
{kind === "user" && (
<>
<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>
</label>
{upgrade && <p className="give-gift-upgrade-note">{t("giveGift.upgradeNote")}</p>}
{upgrade && previewError && <Alert>{previewError}</Alert>}
{upgrade && preview && (
<div className="gift-fields-grid give-gift-attrs">
<label>
<span>{t("giveGift.model")}</span>
<select value={modelID} onChange={(event) => { setModelID(event.target.value); setResult(null); }}>
<option value="0">{t("giveGift.random")}</option>
{(preview.models ?? []).map((attr) => <option key={attr.id} value={attr.id}>{attrLabel(attr)}</option>)}
</select>
</label>
<label>
<span>{t("giveGift.pattern")}</span>
<select value={patternID} onChange={(event) => { setPatternID(event.target.value); setResult(null); }}>
<option value="0">{t("giveGift.random")}</option>
{(preview.patterns ?? []).map((attr) => <option key={attr.id} value={attr.id}>{attrLabel(attr)}</option>)}
</select>
</label>
<label>
<span>{t("giveGift.backdrop")}</span>
<select value={backdropID} onChange={(event) => { setBackdropID(event.target.value); setResult(null); }}>
<option value="0">{t("giveGift.random")}</option>
{(preview.backdrops ?? []).map((attr) => <option key={attr.id} value={attr.id}>{attrLabel(attr)}</option>)}
</select>
</label>
</div>
)}
</>
)}
<label className="form-field">
<span>{t("action.reason")}</span>
<textarea value={reason} rows={2} onChange={(event) => setReason(event.target.value)} placeholder={t("action.reasonPlaceholder")} />
</label>
<div className="command-preview">
<div className="preview-head">{t("action.requestPreview")}</div>
<JsonBlock value={JSON.stringify(previewPayload, null, 2)} />
</div>
{error && <Alert>{error}</Alert>}
{result && (
<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>
</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>
{result.details && <JsonBlock value={JSON.stringify(result.details, null, 2)} />}
</div>
)}
<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")}
</button>
<button className="btn primary icon-text" type="button" onClick={() => run(true)} disabled={busy || !canConfirm}>
<Gift size={15} />
{t("giveGift.confirm")}
</button>
</div>
</div>
);
}

View file

@ -0,0 +1,83 @@
import { Gift, RefreshCw, Search } from "lucide-react";
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);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
async function load() {
setBusy(true);
setError("");
try {
const rows = (await api.gifts()).Gifts ?? [];
setGifts(rows);
setSelected((current) => current ?? rows[0] ?? null);
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
useEffect(() => { void load(); }, []);
const visible = useMemo(() => {
const normalized = query.trim().toLowerCase();
if (!normalized) return gifts;
return gifts.filter((gift) =>
String(gift.GiftID).includes(normalized) || gift.Title.toLowerCase().includes(normalized)
);
}, [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>
}>
{error && <Alert>{error}</Alert>}
<p className="give-gift-upgrade-note">{t("giveGifts.hint")}</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>
</div>
<div className="give-gift-picker-list" role="listbox" aria-label={t("giveGifts.pickGift")}>
{visible.map((gift) => {
const active = selected?.GiftID === gift.GiftID;
return (
<button key={gift.GiftID} type="button" role="option" aria-selected={active}
className={`give-gift-option ${active ? "selected" : ""} ${gift.Enabled ? "" : "gift-row-disabled"}`}
onClick={() => setSelected(gift)}>
<StaticLottie className="give-gift-thumb" cacheKey={`${gift.GiftID}:${gift.Revision}`} loader={() => api.giftAnimation(gift.GiftID)} />
<span className="give-gift-option-info">
<strong>{gift.Title || `Gift #${gift.GiftID}`}</strong>
<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>}
</span>
</button>
);
})}
{visible.length === 0 && !busy && <div className="official-gift-empty">{t("common.noResults")}</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>}
</section>
</div>
</PageFrame>
);
}

View file

@ -2,7 +2,8 @@ import type { FormEvent } from "react";
import { useState } from "react";
import { api, errorMessage } from "../api";
import { Alert } from "../components/ui";
import { useI18n } from "../i18n";
import { LanguageSwitch, useI18n } from "../i18n";
import { ThemeSwitch } from "../theme";
export function LoginPage({ onLogin }: { onLogin: (actor: string) => void }) {
const { t } = useI18n();
@ -36,6 +37,8 @@ export function LoginPage({ onLogin }: { onLogin: (actor: string) => void }) {
</span>
</div>
<div className="login-head-actions">
<ThemeSwitch />
<LanguageSwitch />
<span className="login-chip">{t("app.localAccess")}</span>
</div>
</div>

View file

@ -3,6 +3,9 @@ import { AccountDetailPage } from "./AccountDetailPage";
import { AccountsPage } from "./AccountsPage";
import { ChannelDetailPage } from "./ChannelDetailPage";
import { ChannelsPage } from "./ChannelsPage";
import { BotDetailPage } from "./BotDetailPage";
import { BotsPage } from "./BotsPage";
import { EmojiPage } from "./EmojiPage";
import { Dashboard } from "./Dashboard";
import { GroupMessageDetailPage } from "./GroupMessageDetailPage";
import { GroupMessagesPage } from "./GroupMessagesPage";
@ -10,21 +13,32 @@ import { MessageDetailPage } from "./MessageDetailPage";
import { MessagesPage } from "./MessagesPage";
import { GiftsPage } from "./GiftsPage";
import { StickerSetsPage } from "./StickerSetsPage";
import { GiveGiftsPage } from "./GiveGiftsPage";
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];
if (accountID) {
return <AccountDetailPage id={Number(accountID)} navigate={navigate} />;
}
if (channelID) {
return <ChannelDetailPage id={Number(channelID)} navigate={navigate} />;
}
if (botID) {
return <BotDetailPage id={Number(botID)} navigate={navigate} />;
}
if (route.path === "/accounts") {
return <AccountsPage navigate={navigate} />;
}
if (route.path === "/channels") {
return <ChannelsPage navigate={navigate} />;
}
if (route.path === "/bots") {
return <BotsPage navigate={navigate} />;
}
if (route.path === "/emoji") {
return <EmojiPage />;
}
if (route.path === "/gifts") {
return <GiftsPage />;
@ -32,8 +46,8 @@ export function Routes({ route, navigate }: { route: RouteState; navigate: Navig
if (route.path === "/stickers") {
return <StickerSetsPage kind="stickers" />;
}
if (route.path === "/emoji") {
return <StickerSetsPage kind="emoji" />;
if (route.path === "/give-gifts") {
return <GiveGiftsPage />;
}
if (route.path === "/messages/detail" || route.path === "/messages/private/detail") {
return (