admin: gift granting, collectible attribute/number control, and Layer 228 moderation tools

Admin console additions (Layer 228):
- Give Gifts: dedicated tab with sorted Lottie/TGS gift picker + inline form; grant any catalog gift to a user/channel from 777000 (no charge)
- Upgraded/collectible delivery: mint a unique gift with admin-selected model/pattern/backdrop and custom number, or random/auto (DB FK + UNIQUE(gift_id,num) enforce invariants)
- SCAM/FAKE flags for users/channels (migration 0136) with configurable profile warning (TELESRV_SCAM_WARNING/TELESRV_FAKE_WARNING)
- Support toggle, force channel settings incl. gigagroup (migration 0137), username management, cosmetic color/emoji-status
- Emoji admin tab (custom emoji list + document IDs + Lottie/TGS preview)
- Bot management; soft UI / dark theme
Wired through Router -> admin.Service -> adminapi -> BFF -> React panel (en/zh/ru).
This commit is contained in:
epilepticseizureee 2026-07-23 04:00:29 +03:00
parent 9e45da69ef
commit 313624eab2
63 changed files with 3650 additions and 71 deletions

View file

@ -8,8 +8,10 @@ import {
Server,
Shield,
ShieldCheck,
Smile,
Users,
Gift
Gift,
Send
} from "lucide-react";
import { useEffect, useState, type ReactNode } from "react";
import { api } from "../api";
@ -79,6 +81,8 @@ export function Shell({
<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={<Smile size={16} />} href="/emoji" route={route} navigate={navigate}>{t("layout.emoji")}</NavLink>
<div className={`nav-section ${messagesActive ? "active" : ""} ${messagesOpen ? "open" : ""}`}>
<button
className="nav-section-toggle"

View file

@ -0,0 +1,168 @@
import { AtSign, LifeBuoy, Palette, Settings2, Smile } from "lucide-react";
import { useState } from "react";
import { ActionButton } from "./ActionButton";
import { useI18n } from "../i18n";
import { toInt } from "../lib/format";
import type { ChannelRow } from "../types";
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")}
icon={<LifeBuoy size={15} />}
tone="neutral"
path="/api/actions/set-support"
payload={() => ({ user_id: id, support: !support })}
onDone={onDone}
/>
);
}
// UsernameAction sets or clears (empty) a username.
export function UsernameAction({ idKey, id, path, current, onDone }: {
idKey: IDKey;
id: number;
path: string;
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>
<input value={username} onChange={(e) => setUsername(e.target.value)} placeholder="username" />
</label>
<ActionButton
label={t("attr.setUsername")}
icon={<AtSign size={15} />}
tone="neutral"
path={path}
payload={() => ({ [idKey]: id, username: username.trim().replace(/^@/, "") })}
onDone={onDone}
/>
</div>
);
}
// ColorAction sets or clears a name/profile color (Layer 228 peer color).
export function ColorAction({ idKey, id, path, onDone }: {
idKey: IDKey;
id: number;
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="duration-field">
<span>{t("attr.colorIndex")}</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>
<input value={bgEmoji} onChange={(e) => setBgEmoji(e.target.value)} placeholder="0" />
</label>
<ActionButton
label={t("attr.setColor")}
icon={<Palette size={15} />}
tone="neutral"
path={path}
payload={() => ({
[idKey]: id,
for_profile: forProfile,
has_color: hasColor,
color: toInt(color),
background_emoji_id: (bgEmoji.trim() || "0")
})}
onDone={onDone}
/>
</div>
);
}
// EmojiStatusAction sets (document id) or clears (empty) an emoji status.
export function EmojiStatusAction({ idKey, id, path, onDone }: {
idKey: IDKey;
id: number;
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>
<input value={documentID} onChange={(e) => setDocumentID(e.target.value)} placeholder="0 = clear" />
</label>
<label className="duration-field">
<span>{t("attr.emojiUntil")}</span>
<input type="number" min="0" value={until} onChange={(e) => setUntil(e.target.value)} />
</label>
<ActionButton
label={t("attr.setEmojiStatus")}
icon={<Smile size={15} />}
tone="neutral"
path={path}
payload={() => ({ [idKey]: id, document_id: (documentID.trim() || "0"), until: toInt(until) })}
onDone={onDone}
/>
</div>
);
}
// 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);
const [noforwards, setNoforwards] = useState(channel.NoForwards);
const [joinToSend, setJoinToSend] = useState(channel.JoinToSend);
const [joinRequest, setJoinRequest] = useState(channel.JoinRequest);
const [slowmode, setSlowmode] = useState(String(channel.SlowmodeSeconds));
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="duration-field">
<span>{t("attr.slowmode")}</span>
<input type="number" min="0" max="86400" value={slowmode} onChange={(e) => setSlowmode(e.target.value)} />
</label>
<ActionButton
label={t("attr.applySettings")}
icon={<Settings2 size={15} />}
tone="warn"
path="/api/actions/set-channel-settings"
payload={() => ({
channel_id: channel.ID,
gigagroup,
antispam,
participants_hidden: hidden,
noforwards,
join_to_send: joinToSend,
join_request: joinRequest,
slowmode_seconds: toInt(slowmode)
})}
onDone={onDone}
/>
</div>
);
}

View file

@ -0,0 +1,59 @@
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>}
</>
);
}
// ScamFakeActions renders the two independent toggles. The combined setter
// receives the full desired state, so each button flips one flag and keeps the
// other unchanged.
export function ScamFakeActions({
idKey,
id,
path,
scam,
fake,
onDone
}: {
idKey: "user_id" | "channel_id";
id: number;
path: string;
scam: boolean;
fake: boolean;
onDone: () => void;
}) {
const { t } = useI18n();
return (
<div className="action-stack">
<ActionButton
label={scam ? t("flags.clearScam") : t("flags.setScam")}
icon={<ShieldAlert size={15} />}
tone="danger"
path={path}
payload={() => ({ [idKey]: id, scam: !scam, fake })}
onDone={onDone}
/>
<ActionButton
label={fake ? t("flags.clearFake") : t("flags.setFake")}
icon={<ShieldX size={15} />}
tone="danger"
path={path}
payload={() => ({ [idKey]: id, scam, fake: !fake })}
onDone={onDone}
/>
</div>
);
}