Merge remote-tracking branch 'upstream/main' into merge-gramsrv-0e2fcdf9
This commit is contained in:
commit
b443ff0c73
277 changed files with 30747 additions and 1551 deletions
1
cmd/telesrv-admin/web/dist/assets/index-B998Ff2K.css
vendored
Normal file
1
cmd/telesrv-admin/web/dist/assets/index-B998Ff2K.css
vendored
Normal file
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
10
cmd/telesrv-admin/web/dist/assets/index-ilFwQ32E.js
vendored
Normal file
10
cmd/telesrv-admin/web/dist/assets/index-ilFwQ32E.js
vendored
Normal file
File diff suppressed because one or more lines are too long
22
cmd/telesrv-admin/web/dist/index.html
vendored
22
cmd/telesrv-admin/web/dist/index.html
vendored
|
|
@ -5,8 +5,26 @@
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/png" href="/logo.png" />
|
||||
<title>OwpenGram Admin</title>
|
||||
<script type="module" crossorigin src="/assets/index-CBHSiG-t.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-cWo3_wIf.css">
|
||||
<script>
|
||||
(function () {
|
||||
try {
|
||||
var stored = localStorage.getItem("telesrv.admin.theme");
|
||||
var theme =
|
||||
stored === "light" || stored === "dark"
|
||||
? stored
|
||||
: window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light";
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
document.documentElement.style.colorScheme = theme;
|
||||
} catch (e) {
|
||||
document.documentElement.setAttribute("data-theme", "light");
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
<script type="module" crossorigin src="/assets/index-ilFwQ32E.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-B998Ff2K.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,24 @@
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/png" href="/logo.png" />
|
||||
<title>OwpenGram Admin</title>
|
||||
<script>
|
||||
(function () {
|
||||
try {
|
||||
var stored = localStorage.getItem("telesrv.admin.theme");
|
||||
var theme =
|
||||
stored === "light" || stored === "dark"
|
||||
? stored
|
||||
: window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light";
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
document.documentElement.style.colorScheme = theme;
|
||||
} catch (e) {
|
||||
document.documentElement.setAttribute("data-theme", "light");
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
import type {
|
||||
AccountDetail,
|
||||
AccountListResponse,
|
||||
BotDetail,
|
||||
BotListResponse,
|
||||
ChannelDetail,
|
||||
EmojiListResponse,
|
||||
ChannelListResponse,
|
||||
CommandResult,
|
||||
GroupMessageDetail,
|
||||
|
|
@ -58,6 +61,10 @@ export const api = {
|
|||
account: (id: number) => request<AccountDetail>(`/api/accounts/${id}`),
|
||||
channels: (params: URLSearchParams) => request<ChannelListResponse>(`/api/channels?${params.toString()}`),
|
||||
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}`),
|
||||
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()}`),
|
||||
message: (ownerUserID: number, msgID: number) => {
|
||||
const params = new URLSearchParams({ owner_user_id: String(ownerUserID), msg_id: String(msgID) });
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import {
|
||||
Bot,
|
||||
ChevronDown,
|
||||
Database,
|
||||
LayoutDashboard,
|
||||
|
|
@ -7,15 +8,17 @@ import {
|
|||
Server,
|
||||
Shield,
|
||||
ShieldCheck,
|
||||
Smile,
|
||||
Users,
|
||||
Gift,
|
||||
Sticker,
|
||||
Smile
|
||||
Send
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { api } from "../api";
|
||||
import { useI18n } from "../i18n";
|
||||
import { LanguageSwitch, useI18n } from "../i18n";
|
||||
import { type Navigate, type RouteState, routeSubtitle, routeTitle } from "../routing";
|
||||
import { ThemeSwitch } from "../theme";
|
||||
import { AppLink } from "./AppLink";
|
||||
|
||||
export function BootScreen() {
|
||||
|
|
@ -77,8 +80,10 @@ export function Shell({
|
|||
<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={<Sticker size={16} />} href="/stickers" route={route} navigate={navigate}>{t("layout.stickers")}</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
|
||||
|
|
@ -127,6 +132,8 @@ export function Shell({
|
|||
<h1>{routeTitle(route.path, t)}</h1>
|
||||
</div>
|
||||
<div className="topbar-actions">
|
||||
<ThemeSwitch />
|
||||
<LanguageSwitch />
|
||||
<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")}
|
||||
|
|
|
|||
57
cmd/telesrv-admin/web/src/components/StaticLottie.tsx
Normal file
57
cmd/telesrv-admin/web/src/components/StaticLottie.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import lottie from "lottie-web/build/player/lottie_light_canvas";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
// StaticLottie renders a single (first) frame of a Lottie/TGS animation instead
|
||||
// of looping it, so a grid of many stickers/emoji does not keep the canvas
|
||||
// rendering and pinning the CPU. It plays only while hovered, then resets to the
|
||||
// static frame. Use it for list/grid previews; keep the looping player for
|
||||
// single, focused previews.
|
||||
export function StaticLottie({
|
||||
loader,
|
||||
cacheKey,
|
||||
className,
|
||||
playOnHover = true,
|
||||
onError
|
||||
}: {
|
||||
loader: () => Promise<Record<string, unknown>>;
|
||||
cacheKey: string;
|
||||
className?: string;
|
||||
playOnHover?: boolean;
|
||||
onError?: () => void;
|
||||
}) {
|
||||
const host = useRef<HTMLDivElement>(null);
|
||||
const animation = useRef<ReturnType<typeof lottie.loadAnimation> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
loader()
|
||||
.then((data) => {
|
||||
if (cancelled || !host.current) return;
|
||||
animation.current?.destroy();
|
||||
animation.current = lottie.loadAnimation({
|
||||
container: host.current,
|
||||
renderer: "canvas",
|
||||
loop: true,
|
||||
autoplay: false,
|
||||
animationData: structuredClone(data)
|
||||
});
|
||||
animation.current.goToAndStop(0, true);
|
||||
})
|
||||
.catch(() => onError?.());
|
||||
return () => {
|
||||
cancelled = true;
|
||||
animation.current?.destroy();
|
||||
animation.current = null;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [cacheKey]);
|
||||
|
||||
function play() {
|
||||
if (playOnHover) animation.current?.play();
|
||||
}
|
||||
function reset() {
|
||||
if (playOnHover) animation.current?.goToAndStop(0, true);
|
||||
}
|
||||
|
||||
return <div className={className} ref={host} onMouseEnter={play} onMouseLeave={reset} />;
|
||||
}
|
||||
186
cmd/telesrv-admin/web/src/components/attributes.tsx
Normal file
186
cmd/telesrv-admin/web/src/components/attributes.tsx
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
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";
|
||||
|
||||
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));
|
||||
|
||||
// Re-sync the toggles with the persisted state whenever the channel reloads
|
||||
// (e.g. after applying a change), so previously-applied settings stay checked.
|
||||
useEffect(() => {
|
||||
setGigagroup(channel.Gigagroup);
|
||||
setAntispam(channel.AntiSpam);
|
||||
setHidden(channel.ParticipantsHidden);
|
||||
setNoforwards(channel.NoForwards);
|
||||
setJoinToSend(channel.JoinToSend);
|
||||
setJoinRequest(channel.JoinRequest);
|
||||
setSlowmode(String(channel.SlowmodeSeconds));
|
||||
}, [channel]);
|
||||
|
||||
// Send only the fields the admin actually changed. The backend applies a
|
||||
// partial patch (nil = leave unchanged), so an unrelated setting is never
|
||||
// reset when another one is applied.
|
||||
function buildPatch() {
|
||||
const patch: Record<string, unknown> = { channel_id: channel.ID };
|
||||
if (gigagroup !== channel.Gigagroup) patch.gigagroup = gigagroup;
|
||||
if (antispam !== channel.AntiSpam) patch.antispam = antispam;
|
||||
if (hidden !== channel.ParticipantsHidden) patch.participants_hidden = hidden;
|
||||
if (noforwards !== channel.NoForwards) patch.noforwards = noforwards;
|
||||
if (joinToSend !== channel.JoinToSend) patch.join_to_send = joinToSend;
|
||||
if (joinRequest !== channel.JoinRequest) patch.join_request = joinRequest;
|
||||
if (toInt(slowmode) !== channel.SlowmodeSeconds) patch.slowmode_seconds = toInt(slowmode);
|
||||
return patch;
|
||||
}
|
||||
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={buildPatch}
|
||||
onDone={onDone}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
59
cmd/telesrv-admin/web/src/components/flags.tsx
Normal file
59
cmd/telesrv-admin/web/src/components/flags.tsx
Normal 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 toggles. scam and fake are mutually exclusive
|
||||
// (a peer is never both in Telegram), so enabling one clears the other; the
|
||||
// combined setter always receives the full desired state.
|
||||
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: !scam ? false : fake })}
|
||||
onDone={onDone}
|
||||
/>
|
||||
<ActionButton
|
||||
label={fake ? t("flags.clearFake") : t("flags.setFake")}
|
||||
icon={<ShieldX size={15} />}
|
||||
tone="danger"
|
||||
path={path}
|
||||
payload={() => ({ [idKey]: id, fake: !fake, scam: !fake ? false : scam })}
|
||||
onDone={onDone}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -2,12 +2,15 @@ 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>
|
||||
<I18nProvider>
|
||||
<App />
|
||||
</I18nProvider>
|
||||
<ThemeProvider>
|
||||
<I18nProvider>
|
||||
<App />
|
||||
</I18nProvider>
|
||||
</ThemeProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
116
cmd/telesrv-admin/web/src/pages/BotDetailPage.tsx
Normal file
116
cmd/telesrv-admin/web/src/pages/BotDetailPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
168
cmd/telesrv-admin/web/src/pages/BotsPage.tsx
Normal file
168
cmd/telesrv-admin/web/src/pages/BotsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
145
cmd/telesrv-admin/web/src/pages/EmojiPage.tsx
Normal file
145
cmd/telesrv-admin/web/src/pages/EmojiPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
220
cmd/telesrv-admin/web/src/pages/GiveGiftForm.tsx
Normal file
220
cmd/telesrv-admin/web/src/pages/GiveGiftForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
83
cmd/telesrv-admin/web/src/pages/GiveGiftsPage.tsx
Normal file
83
cmd/telesrv-admin/web/src/pages/GiveGiftsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -19,7 +19,10 @@ 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");
|
||||
|
|
@ -29,7 +32,10 @@ export function routeTitle(pathname: string, t: TFunction): string {
|
|||
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");
|
||||
|
|
|
|||
|
|
@ -6,26 +6,156 @@
|
|||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #f7f9fc;
|
||||
|
||||
/* Surfaces */
|
||||
--bg: #eef1f5;
|
||||
--bg-accent: #e7ecf1;
|
||||
--panel: #ffffff;
|
||||
--panel-subtle: #f7f9fc;
|
||||
--panel-strong: #f1f5f9;
|
||||
--line: #e2e8f0;
|
||||
--line-strong: #cbd5e1;
|
||||
--text: #0f1720;
|
||||
--muted: #64748b;
|
||||
--muted-2: #94a3b8;
|
||||
--brand: #2563eb;
|
||||
--brand-2: #38bdf8;
|
||||
--grad: linear-gradient(135deg, #38bdf8 0%, #2563eb 55%, #1e40af 100%);
|
||||
--good: #167447;
|
||||
--warn: #a15c07;
|
||||
--danger: #b42318;
|
||||
--sidebar: #08080e;
|
||||
--sidebar-soft: #12121a;
|
||||
--sidebar-line: #222228;
|
||||
--focus: rgba(37, 99, 235, 0.16);
|
||||
--shadow: 0 28px 70px -36px rgba(5, 5, 8, 0.35);
|
||||
--panel-subtle: #f5f8fb;
|
||||
--panel-strong: #eef2f6;
|
||||
--surface-soft: #f2f7f6;
|
||||
--overlay: rgba(24, 34, 47, 0.42);
|
||||
--topbar-bg: rgba(255, 255, 255, 0.86);
|
||||
|
||||
/* Lines */
|
||||
--line: #e5eaf0;
|
||||
--line-strong: #d3dce4;
|
||||
|
||||
/* Text */
|
||||
--heading: #253040;
|
||||
--text: #333f4d;
|
||||
--text-soft: #45525f;
|
||||
--muted: #6d7885;
|
||||
--muted-2: #9aa4b1;
|
||||
|
||||
/* Brand */
|
||||
--brand: #1f7d6f;
|
||||
--brand-strong: #196155;
|
||||
--brand-2: #3a6cae;
|
||||
--brand-tint: #e8f4f0;
|
||||
--brand-tint-border: #c8e2db;
|
||||
--brand-tint-text: #235d53;
|
||||
|
||||
/* Semantic */
|
||||
--good: #1f8a57;
|
||||
--good-tint: #eaf6ef;
|
||||
--good-border: #c1e1cf;
|
||||
--warn: #a86a12;
|
||||
--warn-tint: #fcf4e4;
|
||||
--warn-border: #e7d09e;
|
||||
--danger: #c0392b;
|
||||
--danger-tint: #fcefec;
|
||||
--danger-border: #eecac3;
|
||||
--danger-text: #8f2f27;
|
||||
|
||||
/* Accent (collectibles / craft) */
|
||||
--purple: #6a4fa3;
|
||||
--purple-tint: #f4effb;
|
||||
--purple-border: #dcd0f0;
|
||||
--purple-text: #5a4590;
|
||||
|
||||
/* Inputs & controls */
|
||||
--input-bg: #ffffff;
|
||||
--btn-bg: #ffffff;
|
||||
--btn-text: #29323d;
|
||||
--btn-hover: #f4f7fa;
|
||||
--switch-track: #c8d0d6;
|
||||
|
||||
/* Code / JSON blocks */
|
||||
--code-bg: #1b2733;
|
||||
--code-text: #d6e3ef;
|
||||
--code-border: #2b3a49;
|
||||
|
||||
/* Sidebar */
|
||||
--sidebar: #1c2530;
|
||||
--sidebar-soft: #26313d;
|
||||
--sidebar-line: #313c4a;
|
||||
--sidebar-row: #232d38;
|
||||
--sidebar-text: #dbe3ec;
|
||||
--sidebar-muted: #8b98a8;
|
||||
--sidebar-faint: #7c8a9a;
|
||||
--sidebar-heading: #ffffff;
|
||||
|
||||
/* Effects */
|
||||
--focus: rgba(31, 125, 111, 0.16);
|
||||
--shadow: 0 12px 34px rgba(24, 39, 56, 0.1);
|
||||
--shadow-sm: 0 2px 10px rgba(24, 39, 56, 0.05);
|
||||
--shadow-brand: 0 8px 22px rgba(31, 125, 111, 0.22);
|
||||
|
||||
/* Radii */
|
||||
--radius-xs: 8px;
|
||||
--radius-sm: 9px;
|
||||
--radius: 11px;
|
||||
--radius-lg: 14px;
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
|
||||
--bg: #0f141a;
|
||||
--bg-accent: #131a22;
|
||||
--panel: #171f28;
|
||||
--panel-subtle: #1c2530;
|
||||
--panel-strong: #212c38;
|
||||
--surface-soft: #1a232d;
|
||||
--overlay: rgba(5, 8, 12, 0.62);
|
||||
--topbar-bg: rgba(21, 28, 36, 0.86);
|
||||
|
||||
--line: #29333f;
|
||||
--line-strong: #38434f;
|
||||
|
||||
--heading: #eef3f8;
|
||||
--text: #d5dde6;
|
||||
--text-soft: #c2ccd6;
|
||||
--muted: #98a4b1;
|
||||
--muted-2: #6d7885;
|
||||
|
||||
--brand: #37a596;
|
||||
--brand-strong: #45b6a6;
|
||||
--brand-2: #6fa8e6;
|
||||
--brand-tint: #14322d;
|
||||
--brand-tint-border: #245349;
|
||||
--brand-tint-text: #7fd3c4;
|
||||
|
||||
--good: #47c281;
|
||||
--good-tint: #12301f;
|
||||
--good-border: #245639;
|
||||
--warn: #e0aa4d;
|
||||
--warn-tint: #322810;
|
||||
--warn-border: #574413;
|
||||
--danger: #e6695c;
|
||||
--danger-tint: #35201d;
|
||||
--danger-border: #5c332d;
|
||||
--danger-text: #f0a49b;
|
||||
|
||||
--purple: #ac90e2;
|
||||
--purple-tint: #221b31;
|
||||
--purple-border: #3d3357;
|
||||
--purple-text: #c9b6ef;
|
||||
|
||||
--input-bg: #131a22;
|
||||
--btn-bg: #1e2731;
|
||||
--btn-text: #dbe2ea;
|
||||
--btn-hover: #26313d;
|
||||
--switch-track: #3a454f;
|
||||
|
||||
--code-bg: #0c1218;
|
||||
--code-text: #cdd9e5;
|
||||
--code-border: #232f3b;
|
||||
|
||||
--sidebar: #10151b;
|
||||
--sidebar-soft: #1c242f;
|
||||
--sidebar-line: #262f3a;
|
||||
--sidebar-row: #161d25;
|
||||
--sidebar-text: #cbd4de;
|
||||
--sidebar-muted: #7c8794;
|
||||
--sidebar-faint: #6f7b88;
|
||||
--sidebar-heading: #f0f4f8;
|
||||
|
||||
--focus: rgba(55, 165, 150, 0.24);
|
||||
--shadow: 0 16px 40px rgba(0, 0, 0, 0.46);
|
||||
--shadow-sm: 0 2px 12px rgba(0, 0, 0, 0.38);
|
||||
--shadow-brand: 0 8px 22px rgba(55, 165, 150, 0.26);
|
||||
}
|
||||
|
||||
* {
|
||||
|
|
@ -43,6 +173,9 @@ body {
|
|||
color: var(--text);
|
||||
background: var(--bg);
|
||||
font: 13px/1.45 "Plus Jakarta Sans", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
transition: background-color 200ms ease, color 200ms ease;
|
||||
}
|
||||
|
||||
button,
|
||||
|
|
@ -71,7 +204,7 @@ a {
|
|||
gap: 16px;
|
||||
overflow-y: auto;
|
||||
padding: 18px 12px;
|
||||
color: #eef2f6;
|
||||
color: var(--sidebar-text);
|
||||
background: var(--sidebar);
|
||||
border-right: 1px solid var(--sidebar-line);
|
||||
}
|
||||
|
|
@ -88,11 +221,20 @@ a {
|
|||
justify-content: center;
|
||||
}
|
||||
|
||||
.brand-elevated .brand-mark {
|
||||
box-shadow: var(--shadow-brand);
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
display: grid;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
place-items: center;
|
||||
color: #ffffff;
|
||||
background: var(--brand);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: var(--radius-sm);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.brand-mark img {
|
||||
|
|
@ -111,16 +253,17 @@ a {
|
|||
.brand small {
|
||||
display: block;
|
||||
margin-top: 3px;
|
||||
color: #aeb8c4;
|
||||
color: var(--sidebar-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.sidebar-label {
|
||||
padding: 0 8px;
|
||||
color: #8492a6;
|
||||
color: var(--sidebar-faint);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.nav-list {
|
||||
|
|
@ -141,26 +284,27 @@ a {
|
|||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 0 10px;
|
||||
color: #8fa0b4;
|
||||
color: var(--sidebar-muted);
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 7px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
text-align: left;
|
||||
transition: color 140ms ease, background-color 140ms ease, border-color 140ms ease;
|
||||
}
|
||||
|
||||
.nav-section-toggle:hover,
|
||||
.nav-section.active .nav-section-toggle {
|
||||
color: #ffffff;
|
||||
color: var(--sidebar-heading);
|
||||
background: var(--sidebar-soft);
|
||||
border-color: #34404d;
|
||||
border-color: var(--sidebar-line);
|
||||
}
|
||||
|
||||
.nav-section-chevron {
|
||||
justify-self: end;
|
||||
color: #8fa0b4;
|
||||
color: var(--sidebar-muted);
|
||||
transition: transform 140ms ease;
|
||||
}
|
||||
|
||||
|
|
@ -181,24 +325,25 @@ a {
|
|||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 0 10px;
|
||||
color: #c6d0dc;
|
||||
color: var(--sidebar-text);
|
||||
border: 1px solid transparent;
|
||||
border-radius: 7px;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: color 140ms ease, background-color 140ms ease, border-color 140ms ease;
|
||||
}
|
||||
|
||||
.nav-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
justify-self: center;
|
||||
background: #687789;
|
||||
background: var(--sidebar-faint);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.nav-item:hover,
|
||||
.nav-item.active {
|
||||
color: #ffffff;
|
||||
color: var(--sidebar-heading);
|
||||
background: var(--sidebar-soft);
|
||||
border-color: #34404d;
|
||||
border-color: var(--sidebar-line);
|
||||
}
|
||||
|
||||
.nav-item.active .nav-dot {
|
||||
|
|
@ -218,14 +363,14 @@ a {
|
|||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 0 8px;
|
||||
color: #cbd5df;
|
||||
background: #171d25;
|
||||
border: 1px solid #27313c;
|
||||
border-radius: 7px;
|
||||
color: var(--sidebar-text);
|
||||
background: var(--sidebar-row);
|
||||
border: 1px solid var(--sidebar-line);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.runtime-row strong {
|
||||
color: #ffffff;
|
||||
color: var(--sidebar-heading);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
|
|
@ -243,13 +388,14 @@ a {
|
|||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
padding: 12px 24px;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
background: var(--topbar-bg);
|
||||
border-bottom: 1px solid var(--line);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.topbar h1 {
|
||||
margin: 2px 0 0;
|
||||
color: var(--heading);
|
||||
font-size: 20px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
|
@ -266,13 +412,69 @@ a {
|
|||
gap: 8px;
|
||||
}
|
||||
|
||||
.language-switch {
|
||||
display: inline-flex;
|
||||
min-height: 30px;
|
||||
align-items: center;
|
||||
padding: 2px;
|
||||
background: var(--panel-subtle);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.language-switch button {
|
||||
min-width: 42px;
|
||||
min-height: 24px;
|
||||
padding: 0 9px;
|
||||
color: var(--muted);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
font-weight: 800;
|
||||
transition: color 140ms ease, background-color 140ms ease;
|
||||
}
|
||||
|
||||
.language-switch button.active {
|
||||
color: #ffffff;
|
||||
background: var(--brand);
|
||||
}
|
||||
|
||||
.language-switch button:focus-visible {
|
||||
outline: 2px solid var(--brand);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.theme-toggle {
|
||||
display: inline-grid;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
place-items: center;
|
||||
color: var(--muted);
|
||||
background: var(--panel-subtle);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
transition: color 160ms ease, background-color 160ms ease, border-color 160ms ease;
|
||||
}
|
||||
|
||||
.theme-toggle:hover {
|
||||
color: var(--brand);
|
||||
border-color: var(--brand-tint-border);
|
||||
background: var(--brand-tint);
|
||||
}
|
||||
|
||||
.theme-toggle:focus-visible {
|
||||
outline: 2px solid var(--brand);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.actor-pill {
|
||||
display: inline-flex;
|
||||
min-height: 30px;
|
||||
align-items: center;
|
||||
padding: 0 10px;
|
||||
color: #344054;
|
||||
color: var(--text-soft);
|
||||
background: var(--panel-subtle);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
|
|
@ -289,4 +491,5 @@ a {
|
|||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,8 @@
|
|||
min-width: 0;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.overview-band {
|
||||
|
|
@ -25,6 +26,7 @@
|
|||
.section-head h2,
|
||||
.modal h2 {
|
||||
margin: 0;
|
||||
color: var(--heading);
|
||||
font-size: 18px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
|
@ -47,7 +49,7 @@
|
|||
padding: 10px;
|
||||
background: var(--panel-subtle);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 7px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.status-item span,
|
||||
|
|
@ -70,16 +72,16 @@
|
|||
|
||||
.status-item.good,
|
||||
.metric.good {
|
||||
border-color: #afd8bf;
|
||||
border-color: var(--good-border);
|
||||
}
|
||||
|
||||
.status-item.warn,
|
||||
.metric.warn {
|
||||
border-color: #e7c77e;
|
||||
border-color: var(--warn-border);
|
||||
}
|
||||
|
||||
.metric.danger {
|
||||
border-color: #efb4ad;
|
||||
border-color: var(--danger-border);
|
||||
}
|
||||
|
||||
.command-grid {
|
||||
|
|
@ -97,11 +99,15 @@
|
|||
padding: 14px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: border-color 160ms ease, box-shadow 160ms ease, transform 160ms ease;
|
||||
}
|
||||
|
||||
.launcher:hover {
|
||||
border-color: var(--brand);
|
||||
border-color: var(--brand-tint-border);
|
||||
box-shadow: var(--shadow);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.launcher-icon {
|
||||
|
|
@ -110,9 +116,9 @@
|
|||
height: 38px;
|
||||
place-items: center;
|
||||
color: var(--brand);
|
||||
background: #eaf2fd;
|
||||
border: 1px solid #c7dcf9;
|
||||
border-radius: 8px;
|
||||
background: var(--brand-tint);
|
||||
border: 1px solid var(--brand-tint-border);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.launcher-copy {
|
||||
|
|
@ -121,6 +127,7 @@
|
|||
}
|
||||
|
||||
.launcher-copy strong {
|
||||
color: var(--heading);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
|
|
@ -140,10 +147,10 @@
|
|||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 10px;
|
||||
color: #344054;
|
||||
color: var(--text-soft);
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.page-frame {
|
||||
|
|
@ -165,7 +172,7 @@
|
|||
padding: 10px;
|
||||
background: var(--panel-subtle);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
|
|
@ -195,9 +202,9 @@
|
|||
min-width: 0;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
background: #ffffff;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.picker-head {
|
||||
|
|
@ -206,7 +213,7 @@
|
|||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
color: #344054;
|
||||
color: var(--text-soft);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
|
|
@ -217,10 +224,10 @@
|
|||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 7px 9px;
|
||||
color: #1e3a8a;
|
||||
background: #eaf2fe;
|
||||
border: 1px solid #c3d9f7;
|
||||
border-radius: 7px;
|
||||
color: var(--brand-tint-text);
|
||||
background: var(--brand-tint);
|
||||
border: 1px solid var(--brand-tint-border);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.selected-entity strong,
|
||||
|
|
@ -237,8 +244,9 @@
|
|||
}
|
||||
|
||||
.selected-entity div span {
|
||||
color: #52606d;
|
||||
color: var(--brand-tint-text);
|
||||
font-size: 11px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.picker-search {
|
||||
|
|
@ -250,7 +258,7 @@
|
|||
padding: 0 6px 0 9px;
|
||||
background: var(--panel-subtle);
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 7px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.picker-search input {
|
||||
|
|
@ -267,7 +275,7 @@
|
|||
max-height: 236px;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 7px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.picker-row {
|
||||
|
|
@ -278,7 +286,7 @@
|
|||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
color: var(--text);
|
||||
background: #ffffff;
|
||||
background: var(--panel);
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
cursor: pointer;
|
||||
|
|
@ -291,7 +299,7 @@
|
|||
|
||||
.picker-row:hover,
|
||||
.picker-row.selected {
|
||||
background: #eef4ff;
|
||||
background: var(--surface-soft);
|
||||
}
|
||||
|
||||
.picker-row strong,
|
||||
|
|
@ -311,18 +319,24 @@
|
|||
|
||||
.picker-error {
|
||||
color: var(--danger);
|
||||
background: #fff2f0;
|
||||
border: 1px solid #efb4ad;
|
||||
border-radius: 7px;
|
||||
background: var(--danger-tint);
|
||||
border: 1px solid var(--danger-border);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
input,
|
||||
textarea {
|
||||
color: var(--text);
|
||||
background: #ffffff;
|
||||
background: var(--input-bg);
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 7px;
|
||||
border-radius: var(--radius-sm);
|
||||
outline: none;
|
||||
transition: border-color 140ms ease, box-shadow 140ms ease;
|
||||
}
|
||||
|
||||
input::placeholder,
|
||||
textarea::placeholder {
|
||||
color: var(--muted-2);
|
||||
}
|
||||
|
||||
input {
|
||||
|
|
@ -379,9 +393,10 @@ textarea:focus {
|
|||
width: min(380px, 100%);
|
||||
height: 34px;
|
||||
padding: 0 10px;
|
||||
background: #ffffff;
|
||||
color: var(--text);
|
||||
background: var(--input-bg);
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 7px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.searchbox input {
|
||||
|
|
@ -399,16 +414,17 @@ textarea:focus {
|
|||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 0 12px;
|
||||
color: #1d2939;
|
||||
background: #ffffff;
|
||||
color: var(--btn-text);
|
||||
background: var(--btn-bg);
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 7px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: background-color 140ms ease, border-color 140ms ease, color 140ms ease, box-shadow 140ms ease;
|
||||
}
|
||||
|
||||
.btn:hover:not(:disabled) {
|
||||
background: #f7f9fb;
|
||||
background: var(--btn-hover);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
|
|
@ -423,7 +439,8 @@ textarea:focus {
|
|||
}
|
||||
|
||||
.btn.primary:hover:not(:disabled) {
|
||||
background: #1d4ed8;
|
||||
background: var(--brand-strong);
|
||||
border-color: var(--brand-strong);
|
||||
}
|
||||
|
||||
.btn.ghost {
|
||||
|
|
@ -432,22 +449,24 @@ textarea:focus {
|
|||
|
||||
.btn.danger {
|
||||
color: var(--danger);
|
||||
background: #fff7f5;
|
||||
border-color: #efb4ad;
|
||||
background: var(--danger-tint);
|
||||
border-color: var(--danger-border);
|
||||
}
|
||||
|
||||
.btn.danger:hover:not(:disabled) {
|
||||
background: #ffeceb;
|
||||
background: var(--danger-tint);
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
.btn.warn {
|
||||
color: var(--warn);
|
||||
background: #fff8ec;
|
||||
border-color: #e7c77e;
|
||||
background: var(--warn-tint);
|
||||
border-color: var(--warn-border);
|
||||
}
|
||||
|
||||
.btn.warn:hover:not(:disabled) {
|
||||
background: #fff1d6;
|
||||
background: var(--warn-tint);
|
||||
border-color: var(--warn);
|
||||
}
|
||||
|
||||
.btn:disabled,
|
||||
|
|
@ -455,7 +474,7 @@ textarea:focus {
|
|||
.btn.warn:disabled,
|
||||
.btn.danger:disabled {
|
||||
color: var(--muted-2);
|
||||
background: #f3f5f7;
|
||||
background: var(--panel-strong);
|
||||
border-color: var(--line);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
|
@ -489,8 +508,9 @@ textarea:focus {
|
|||
.table-wrap {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.data-table {
|
||||
|
|
@ -513,13 +533,13 @@ textarea:focus {
|
|||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 0;
|
||||
color: #475467;
|
||||
color: var(--muted);
|
||||
background: var(--panel-strong);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.data-table tbody tr:hover {
|
||||
background: #fbfcfd;
|
||||
background: var(--panel-subtle);
|
||||
}
|
||||
|
||||
.data-table tr:last-child td {
|
||||
|
|
@ -541,32 +561,69 @@ textarea:focus {
|
|||
min-height: 22px;
|
||||
align-items: center;
|
||||
padding: 1px 8px;
|
||||
color: #4f5b68;
|
||||
background: #f3f6f8;
|
||||
border: 1px solid #d7e0e8;
|
||||
color: var(--muted);
|
||||
background: var(--panel-strong);
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 999px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badge.good {
|
||||
color: var(--good);
|
||||
background: #eef8f2;
|
||||
border-color: #b9dcc7;
|
||||
background: var(--good-tint);
|
||||
border-color: var(--good-border);
|
||||
}
|
||||
|
||||
.badge.danger {
|
||||
color: var(--danger);
|
||||
background: #fff2f0;
|
||||
border-color: #efb4ad;
|
||||
background: var(--danger-tint);
|
||||
border-color: var(--danger-border);
|
||||
}
|
||||
|
||||
.badge.warn {
|
||||
color: var(--warn);
|
||||
background: #fff8e7;
|
||||
border-color: #e7c77e;
|
||||
background: var(--warn-tint);
|
||||
border-color: var(--warn-border);
|
||||
}
|
||||
|
||||
.empty-cell {
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.bot-create-fields {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.bot-create-fields .duration-field input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.bot-create-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
margin-top: 14px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.bot-create-note {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.bot-create-fields {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.bot-create-actions {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,10 +18,11 @@
|
|||
padding: 14px;
|
||||
background: var(--panel-subtle);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.entity-title {
|
||||
color: var(--heading);
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
line-height: 1.25;
|
||||
|
|
@ -41,10 +42,10 @@
|
|||
.about-text {
|
||||
margin: 0;
|
||||
padding: 10px;
|
||||
color: #344054;
|
||||
background: #fbfcfd;
|
||||
color: var(--text-soft);
|
||||
background: var(--panel-subtle);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.section-block,
|
||||
|
|
@ -54,7 +55,8 @@
|
|||
padding: 12px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.section-head {
|
||||
|
|
@ -79,7 +81,7 @@
|
|||
|
||||
.dock-title {
|
||||
padding-bottom: 4px;
|
||||
color: #344054;
|
||||
color: var(--text-soft);
|
||||
font-weight: 800;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
|
@ -184,7 +186,7 @@
|
|||
padding: 10px;
|
||||
background: var(--panel-subtle);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.operation-title {
|
||||
|
|
@ -192,6 +194,7 @@
|
|||
width: 100%;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--heading);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
|
|
@ -212,10 +215,10 @@
|
|||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 9px 10px;
|
||||
color: #8a251d;
|
||||
background: #fff2f0;
|
||||
border: 1px solid #efb4ad;
|
||||
border-radius: 8px;
|
||||
color: var(--danger-text);
|
||||
background: var(--danger-tint);
|
||||
border: 1px solid var(--danger-border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.json-block {
|
||||
|
|
@ -223,10 +226,10 @@
|
|||
overflow: auto;
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
color: #d8e6f0;
|
||||
background: #141a22;
|
||||
border: 1px solid #2a3542;
|
||||
border-radius: 8px;
|
||||
color: var(--code-text);
|
||||
background: var(--code-bg);
|
||||
border: 1px solid var(--code-border);
|
||||
border-radius: var(--radius);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
|
|
@ -250,12 +253,12 @@
|
|||
color: var(--muted);
|
||||
background: var(--panel-subtle);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.gift-metrics .metric {
|
||||
min-height: 68px;
|
||||
padding: 12px;
|
||||
background: linear-gradient(145deg, #ffffff, #f6f9f9);
|
||||
background: var(--panel-subtle);
|
||||
}
|
||||
|
||||
.gift-metrics .metric strong { font-size: 17px; }
|
||||
|
|
@ -265,12 +268,12 @@
|
|||
flex: 0 0 auto;
|
||||
place-items: center;
|
||||
color: var(--brand);
|
||||
background: #eaf2fd;
|
||||
border: 1px solid #c7dcf9;
|
||||
background: var(--brand-tint);
|
||||
border: 1px solid var(--brand-tint-border);
|
||||
}
|
||||
|
||||
.gift-format-chips { display: flex; flex: 0 0 auto; flex-wrap: wrap; justify-content: flex-end; gap: 6px; }
|
||||
.gift-format-chips span { padding: 4px 8px; color: #1e40af; background: #eaf2fe; border: 1px solid #cbdcf7; border-radius: 999px; font-size: 10px; font-weight: 800; letter-spacing: .02em; }
|
||||
.gift-format-chips span { padding: 4px 8px; color: var(--brand-tint-text); background: var(--brand-tint); border: 1px solid var(--brand-tint-border); border-radius: 999px; font-size: 10px; font-weight: 800; letter-spacing: .02em; }
|
||||
|
||||
.gift-list-summary { margin-left: auto; color: var(--muted); font-size: 11px; font-weight: 700; }
|
||||
|
||||
|
|
@ -279,6 +282,70 @@
|
|||
.gift-bulk-import-modal .command-body { display: grid; gap: 14px; padding: 16px 18px; }
|
||||
.gift-import-modal-body { gap: 14px; }
|
||||
.gift-source-tabs { display: flex; gap: 8px; }
|
||||
|
||||
/* Give-gift flow */
|
||||
.give-gift-summary {
|
||||
display: flex; align-items: center; gap: 11px; padding: 11px 13px;
|
||||
background: var(--panel-subtle); border: 1px solid var(--line-strong); border-radius: 12px; color: var(--text-soft);
|
||||
}
|
||||
.give-gift-summary > svg { flex: 0 0 auto; color: var(--brand); }
|
||||
.give-gift-summary strong { display: block; font-size: 13px; color: var(--text); }
|
||||
.give-gift-summary .mono { font-size: 11px; color: var(--muted); }
|
||||
.give-gift-tabs { display: flex; width: 100%; gap: 4px; padding: 4px; background: var(--panel-subtle); border: 1px solid var(--line-strong); border-radius: 12px; }
|
||||
.give-gift-tabs .btn { flex: 1 1 0; justify-content: center; min-height: 36px; border: 1px solid transparent; background: transparent; box-shadow: none; color: var(--text-soft); border-radius: 9px; transition: color .15s ease, background .15s ease, border-color .15s ease, box-shadow .15s ease; }
|
||||
.give-gift-tabs .btn:not(.primary):hover { color: var(--brand); background: var(--brand-tint); }
|
||||
.give-gift-tabs .btn.primary { color: #ffffff; background: var(--brand); border-color: var(--brand); box-shadow: var(--shadow-brand); }
|
||||
.give-gift-upgrade-note { margin: 0; padding: 9px 12px; background: var(--brand-tint); border: 1px solid var(--brand-tint-border); border-radius: 10px; color: var(--text-soft); font-size: 11px; font-weight: 650; line-height: 1.45; }
|
||||
|
||||
/* Collectible attribute pickers reuse .gift-fields-grid but need equal columns
|
||||
and site-styled selects rather than the import modal's fixed template. */
|
||||
.give-gift-attrs { grid-template-columns: repeat(3, minmax(0, 1fr)); align-items: end; }
|
||||
.give-gift-attrs select,
|
||||
.give-gift-attrs input {
|
||||
width: 100%; min-width: 0; height: 38px; padding: 0 32px 0 10px;
|
||||
color: var(--text); background-color: var(--input-bg); border: 1px solid var(--line); border-radius: var(--radius-sm);
|
||||
font: inherit; font-size: 12px; font-weight: 600;
|
||||
appearance: none; -webkit-appearance: none; -moz-appearance: none; cursor: pointer;
|
||||
}
|
||||
.give-gift-attrs input { padding-right: 10px; cursor: text; text-overflow: ellipsis; }
|
||||
.give-gift-attrs select {
|
||||
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 11px center;
|
||||
}
|
||||
.give-gift-attrs select:focus,
|
||||
.give-gift-attrs input:focus { border-color: var(--brand); box-shadow: 0 0 0 3px var(--focus); outline: none; }
|
||||
|
||||
/* Give Gifts page: two-panel picker + form */
|
||||
.give-gift-layout { display: grid; grid-template-columns: minmax(220px, 280px) minmax(0, 1fr); gap: 16px; align-items: start; }
|
||||
.give-gift-picker { display: grid; gap: 10px; align-content: start; }
|
||||
.give-gift-picker-head { display: flex; align-items: center; gap: 12px; }
|
||||
.give-gift-picker-head .searchbox { flex: 1 1 auto; }
|
||||
.give-gift-picker-list {
|
||||
display: grid; gap: 8px; max-height: 640px; padding: 8px; overflow-y: auto;
|
||||
background: var(--panel-subtle); border: 1px solid var(--line); border-radius: var(--radius-lg);
|
||||
}
|
||||
.give-gift-option {
|
||||
display: grid; grid-template-columns: 46px minmax(0, 1fr) auto; gap: 11px; align-items: center; min-width: 0;
|
||||
padding: 9px 11px; text-align: left; color: var(--text); background: var(--panel);
|
||||
border: 1px solid var(--line); border-radius: var(--radius); cursor: pointer; box-shadow: var(--shadow-sm);
|
||||
transition: border-color .15s ease, box-shadow .15s ease, transform .15s ease;
|
||||
}
|
||||
.give-gift-option:hover { border-color: var(--brand-tint-border); box-shadow: var(--shadow); transform: translateY(-1px); }
|
||||
.give-gift-option.selected { border-color: var(--brand); box-shadow: 0 0 0 2px var(--focus), var(--shadow); }
|
||||
.give-gift-thumb { display: grid; place-items: center; width: 46px; height: 46px; }
|
||||
.give-gift-thumb canvas { width: 100% !important; height: 100% !important; }
|
||||
.give-gift-option-info { display: grid; gap: 3px; min-width: 0; }
|
||||
.give-gift-option-info strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
|
||||
.give-gift-option-info .mono { color: var(--muted); font-size: 10px; }
|
||||
.give-gift-option-price { justify-self: end; white-space: nowrap; }
|
||||
.give-gift-panel {
|
||||
display: grid; gap: 12px; padding: 16px; min-width: 0;
|
||||
background: var(--panel); border: 1px solid var(--line-strong); border-radius: var(--radius-lg);
|
||||
}
|
||||
.give-gift-form { display: grid; gap: 12px; min-width: 0; }
|
||||
.give-gift-form-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px; padding-top: 4px; }
|
||||
.give-gift-empty-panel { display: grid; gap: 10px; place-items: center; padding: 48px 20px; color: var(--muted); text-align: center; }
|
||||
.give-gift-empty-panel svg { color: var(--brand); opacity: .8; }
|
||||
.official-gift-picker { display: grid; min-width: 0; gap: 12px; }
|
||||
.official-gift-bulk-import { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
|
||||
.gift-bulk-import-progress { display: flex; align-items: center; gap: 8px; min-width: 180px; }
|
||||
|
|
@ -293,48 +360,48 @@
|
|||
.official-gift-categories { display: flex; flex-wrap: wrap; gap: 7px; }
|
||||
.official-gift-categories button {
|
||||
display: inline-flex; align-items: center; gap: 7px; min-height: 32px; padding: 5px 10px;
|
||||
color: #3f5a78; background: #f5f8fd; border: 1px solid #d7e2f4; border-radius: 999px;
|
||||
color: var(--text-soft); background: var(--panel-subtle); border: 1px solid var(--line-strong); border-radius: 999px;
|
||||
font: inherit; font-size: 11px; font-weight: 800; cursor: pointer;
|
||||
transition: color .15s ease, background .15s ease, border-color .15s ease, box-shadow .15s ease;
|
||||
}
|
||||
.official-gift-categories button:hover { color: var(--brand); border-color: #9dc3f5; }
|
||||
.official-gift-categories button.active { color: #ffffff; background: var(--brand); border-color: var(--brand); box-shadow: 0 4px 12px rgba(37, 99, 235, .17); }
|
||||
.official-gift-categories button:hover { color: var(--brand); border-color: var(--brand-tint-border); }
|
||||
.official-gift-categories button.active { color: #ffffff; background: var(--brand); border-color: var(--brand); box-shadow: var(--shadow-brand); }
|
||||
.official-gift-categories button span {
|
||||
display: grid; min-width: 20px; height: 20px; padding: 0 5px; place-items: center;
|
||||
color: inherit; background: rgba(255,255,255,.65); border-radius: 999px; font-size: 10px;
|
||||
color: inherit; background: rgba(125, 140, 155, .22); border-radius: 999px; font-size: 10px;
|
||||
}
|
||||
.official-gift-categories button.active span { color: var(--brand); }
|
||||
.official-gift-categories button.active span { color: var(--brand); background: rgba(255, 255, 255, .85); }
|
||||
.official-gift-list {
|
||||
display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; max-height: 314px;
|
||||
min-height: 126px; padding: 8px; overflow: auto; border: 1px solid var(--line); border-radius: 14px;
|
||||
background: #f5f8fd; scrollbar-gutter: stable;
|
||||
min-height: 126px; padding: 8px; overflow: auto; border: 1px solid var(--line); border-radius: var(--radius-lg);
|
||||
background: var(--panel-subtle); scrollbar-gutter: stable;
|
||||
}
|
||||
.official-gift-option {
|
||||
display: grid; min-width: 0; gap: 8px; padding: 11px 12px; text-align: left; color: var(--text);
|
||||
background: #ffffff; border: 1px solid #dbe6f7; border-radius: 11px; cursor: pointer;
|
||||
box-shadow: 0 1px 2px rgba(30, 41, 82, .03);
|
||||
background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); cursor: pointer;
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: border-color .15s ease, box-shadow .15s ease, transform .15s ease;
|
||||
}
|
||||
.official-gift-option:hover { border-color: #9dc3f5; box-shadow: 0 5px 14px rgba(30, 64, 175, .08); transform: translateY(-1px); }
|
||||
.official-gift-option.selected { border-color: var(--brand); box-shadow: 0 0 0 2px rgba(37, 99, 235, .12), 0 5px 14px rgba(30, 64, 175, .08); }
|
||||
.official-gift-option:hover { border-color: var(--brand-tint-border); box-shadow: var(--shadow); transform: translateY(-1px); }
|
||||
.official-gift-option.selected { border-color: var(--brand); box-shadow: 0 0 0 2px var(--focus), var(--shadow); }
|
||||
.official-gift-option-head { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; align-items: baseline; }
|
||||
.official-gift-option-head strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
|
||||
.official-gift-option-head .mono { color: var(--muted); font-size: 9px; }
|
||||
.official-gift-option-meta { display: flex; flex-wrap: wrap; gap: 10px; color: #5b6b85; font-size: 10px; font-weight: 700; }
|
||||
.official-gift-option-meta { display: flex; flex-wrap: wrap; gap: 10px; color: var(--muted); font-size: 10px; font-weight: 700; }
|
||||
.official-gift-capabilities { display: flex; flex-wrap: wrap; gap: 5px; }
|
||||
.official-gift-capabilities > span {
|
||||
padding: 3px 7px; border: 1px solid transparent; border-radius: 999px; font-size: 9px; font-weight: 850; letter-spacing: .01em;
|
||||
}
|
||||
.official-gift-capabilities > span.yes { color: #136b4d; background: #e9f8f0; border-color: #bde6cf; }
|
||||
.official-gift-capabilities > span.craft { color: #6e3ca0; background: #f3ebfb; border-color: #d9c5ef; }
|
||||
.official-gift-capabilities > span.no { color: #78837f; background: #f1f3f2; border-color: #dde2e0; }
|
||||
.official-gift-capabilities > span.yes { color: var(--good); background: var(--good-tint); border-color: var(--good-border); }
|
||||
.official-gift-capabilities > span.craft { color: var(--purple); background: var(--purple-tint); border-color: var(--purple-border); }
|
||||
.official-gift-capabilities > span.no { color: var(--muted); background: var(--panel-strong); border-color: var(--line-strong); }
|
||||
.official-gift-empty {
|
||||
display: grid; grid-column: 1 / -1; min-height: 108px; place-items: center; padding: 20px;
|
||||
color: var(--muted); text-align: center; font-size: 12px;
|
||||
}
|
||||
.official-gift-selected {
|
||||
display: grid; grid-template-columns: 108px minmax(0, 1fr); gap: 14px; align-items: center;
|
||||
padding: 12px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface-soft);
|
||||
padding: 12px; border: 1px solid var(--line); border-radius: var(--radius-lg); background: var(--surface-soft);
|
||||
}
|
||||
.official-gift-selected .gift-animation-shell { width: 96px; height: 96px; min-height: 96px; overflow: hidden; border-radius: 12px; }
|
||||
.official-gift-selected .gift-animation { width: 96px; height: 96px; }
|
||||
|
|
@ -351,23 +418,23 @@
|
|||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
color: var(--text);
|
||||
background: #ffffff;
|
||||
border: 1px dashed #b7c9e8;
|
||||
border-radius: 10px;
|
||||
background: var(--panel);
|
||||
border: 1px dashed var(--line-strong);
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
transition: border-color .16s ease, background .16s ease, box-shadow .16s ease;
|
||||
}
|
||||
|
||||
.gift-file-picker:hover,
|
||||
.gift-file-picker.has-file { background: #f5f9ff; border-color: var(--brand); box-shadow: 0 0 0 2px rgba(37, 99, 235, .05); }
|
||||
.gift-file-picker.has-file { background: var(--brand-tint); border-color: var(--brand); box-shadow: 0 0 0 2px var(--focus); }
|
||||
.gift-file-picker.compact { grid-template-columns: minmax(0, 1fr); min-height: 44px; padding: 8px 12px; }
|
||||
.gift-file-picker input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
|
||||
.gift-file-icon { width: 40px; height: 40px; border-radius: 9px; }
|
||||
.gift-file-icon { width: 40px; height: 40px; border-radius: var(--radius-sm); }
|
||||
.gift-file-copy { display: grid; min-width: 0; gap: 2px; }
|
||||
.gift-field-label { color: var(--muted); font-size: 10px; font-weight: 800; text-transform: uppercase; letter-spacing: .04em; }
|
||||
.gift-file-copy strong { overflow: hidden; font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.gift-file-copy strong { overflow: hidden; color: var(--heading); font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.gift-file-copy small { color: var(--muted); font-size: 11px; font-weight: 500; }
|
||||
.gift-file-action { padding: 7px 10px; color: var(--brand); background: #eef4ff; border: 1px solid #c7dcf9; border-radius: 7px; font-size: 11px; font-weight: 800; }
|
||||
.gift-file-action { padding: 7px 10px; color: var(--brand); background: var(--brand-tint); border: 1px solid var(--brand-tint-border); border-radius: var(--radius-sm); font-size: 11px; font-weight: 800; }
|
||||
|
||||
.gift-fields-grid {
|
||||
display: grid;
|
||||
|
|
@ -391,26 +458,26 @@
|
|||
height: 38px;
|
||||
padding: 0 10px;
|
||||
color: var(--text);
|
||||
background: #fff;
|
||||
background: var(--input-bg);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 7px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.gift-fields-grid input:focus,
|
||||
.gift-reason-field input:focus { border-color: #7bb4f0; box-shadow: 0 0 0 3px rgba(37, 99, 235, .08); outline: none; }
|
||||
.gift-reason-field input:focus { border-color: var(--brand); box-shadow: 0 0 0 3px var(--focus); outline: none; }
|
||||
|
||||
.gift-switch { display: inline-flex; align-items: center; gap: 9px; color: #344054; font-size: 12px; font-weight: 700; cursor: pointer; }
|
||||
.gift-switch { display: inline-flex; align-items: center; gap: 9px; color: var(--text-soft); font-size: 12px; font-weight: 700; cursor: pointer; }
|
||||
.gift-switch input { position: absolute; width: 1px; height: 1px; opacity: 0; }
|
||||
.gift-switch-track { display: flex; width: 34px; height: 19px; align-items: center; padding: 2px; background: #c8d0d5; border-radius: 999px; transition: background .16s ease; }
|
||||
.gift-switch-track { display: flex; width: 34px; height: 19px; align-items: center; padding: 2px; background: var(--switch-track); border-radius: 999px; transition: background .16s ease; }
|
||||
.gift-switch-track span { width: 15px; height: 15px; background: #ffffff; border-radius: 50%; box-shadow: 0 1px 3px rgba(16, 24, 40, .22); transition: transform .16s ease; }
|
||||
.gift-switch input:checked + .gift-switch-track { background: var(--brand); }
|
||||
.gift-switch input:checked + .gift-switch-track span { transform: translateX(15px); }
|
||||
.gift-switch input:focus-visible + .gift-switch-track { outline: 3px solid rgba(37, 99, 235, .16); outline-offset: 2px; }
|
||||
.gift-validation { overflow: hidden; color: #d5fff5; background: #173631; border: 1px solid #24564e; border-radius: 9px; }
|
||||
.gift-validation-head { display: flex; align-items: center; gap: 9px; padding: 10px 12px; color: #e3fff9; background: rgba(255, 255, 255, .035); border-bottom: 1px solid rgba(255, 255, 255, .09); }
|
||||
.gift-switch input:focus-visible + .gift-switch-track { outline: 3px solid var(--focus); outline-offset: 2px; }
|
||||
.gift-validation { overflow: hidden; color: var(--code-text); background: var(--code-bg); border: 1px solid var(--code-border); border-radius: var(--radius-sm); }
|
||||
.gift-validation-head { display: flex; align-items: center; gap: 9px; padding: 10px 12px; color: var(--code-text); background: rgba(255, 255, 255, .035); border-bottom: 1px solid rgba(255, 255, 255, .09); }
|
||||
.gift-validation-head div { display: grid; gap: 2px; }
|
||||
.gift-validation-head span { color: #99cfc4; font-size: 10px; }
|
||||
.gift-validation pre { max-height: 180px; overflow: auto; margin: 0; padding: 11px 12px; color: #d5fff5; font-size: 11px; }
|
||||
.gift-validation-head span { color: var(--brand); font-size: 10px; }
|
||||
.gift-validation pre { max-height: 180px; overflow: auto; margin: 0; padding: 11px 12px; color: var(--code-text); font-size: 11px; }
|
||||
|
||||
.sticker-preview-modal { width: min(760px, 100%); }
|
||||
.sticker-doc-grid {
|
||||
|
|
@ -444,12 +511,12 @@
|
|||
.sticker-add-form-error { flex-basis: 100%; color: var(--danger); font-size: 12px; }
|
||||
.sticker-doc-error { position: absolute; inset: 0; display: grid; place-items: center; padding: 4px; color: var(--danger); font-size: 9px; text-align: center; }
|
||||
|
||||
.gift-animation-shell { position: relative; display: grid; min-height: 210px; place-items: center; background: radial-gradient(circle, #f9f3ff, #eaf2fe); }
|
||||
.gift-animation-shell { position: relative; display: grid; min-height: 210px; place-items: center; background: var(--surface-soft); }
|
||||
.gift-animation { width: 200px; height: 200px; }
|
||||
.gift-animation canvas { width: 100% !important; height: 100% !important; }
|
||||
.gift-play { position: absolute; right: 8px; bottom: 8px; display: grid; width: 30px; height: 30px; place-items: center; color: var(--text); background: rgba(255,255,255,.9); border: 1px solid var(--line); border-radius: 50%; }
|
||||
.gift-play { position: absolute; right: 8px; bottom: 8px; display: grid; width: 30px; height: 30px; place-items: center; color: var(--text); background: var(--panel); border: 1px solid var(--line); border-radius: 50%; }
|
||||
|
||||
.gift-table-wrap { background: #ffffff; }
|
||||
.gift-table-wrap { background: var(--panel); }
|
||||
.gift-table { min-width: 1080px; }
|
||||
.gift-table th:nth-child(2) { width: 74px; }
|
||||
.gift-table td { vertical-align: middle; }
|
||||
|
|
@ -485,7 +552,7 @@
|
|||
.gift-bulk-count { color: var(--text); font-size: 12px; font-weight: 700; white-space: nowrap; }
|
||||
.gift-bulk-reason { flex: 1; min-width: 160px; }
|
||||
.gift-bulk-reason input { height: 34px; }
|
||||
.gift-bulk-error { color: #b42318; font-size: 11px; font-weight: 700; }
|
||||
.gift-bulk-error { color: var(--danger); font-size: 11px; font-weight: 700; }
|
||||
|
||||
.gift-page-size {
|
||||
display: inline-flex;
|
||||
|
|
@ -500,9 +567,9 @@
|
|||
height: 30px;
|
||||
padding: 0 8px;
|
||||
color: var(--text);
|
||||
background: #ffffff;
|
||||
background: var(--input-bg);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 7px;
|
||||
border-radius: var(--radius-sm);
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
|
@ -518,7 +585,7 @@
|
|||
.gift-pager-range { color: var(--muted); font-size: 11px; font-weight: 700; }
|
||||
.gift-pager-controls { display: flex; align-items: center; gap: 10px; }
|
||||
.gift-pager-page { color: var(--text); font-size: 12px; font-weight: 700; white-space: nowrap; }
|
||||
.gift-animation-shell.compact { width: 56px; min-height: 56px; overflow: hidden; border: 1px solid var(--line); border-radius: 9px; }
|
||||
.gift-animation-shell.compact { width: 56px; min-height: 56px; overflow: hidden; border: 1px solid var(--line); border-radius: var(--radius-sm); }
|
||||
.gift-animation-shell.compact .gift-animation { width: 54px; height: 54px; }
|
||||
.gift-animation-shell.compact .gift-play { right: 3px; bottom: 3px; width: 20px; height: 20px; }
|
||||
.gift-row-disabled { opacity: .68; }
|
||||
|
|
@ -530,65 +597,67 @@
|
|||
.gift-sort-order,
|
||||
.gift-source-size,
|
||||
.gift-convert-price { margin-top: 3px; color: var(--muted); font-size: 10px; }
|
||||
.gift-table-price { color: #755b00; }
|
||||
.gift-table-price { color: var(--warn); }
|
||||
.gift-table-actions { display: flex; align-items: center; gap: 6px; }
|
||||
.collectible-button { color: #6548a8; background: #f7f3ff; border-color: #ddd2f5; }
|
||||
.collectible-button:hover { background: #efe8ff; border-color: #cbbaf0; }
|
||||
.collectible-button { color: var(--purple); background: var(--purple-tint); border-color: var(--purple-border); }
|
||||
.collectible-button:hover { background: var(--purple-tint); border-color: var(--purple); }
|
||||
|
||||
.collectible-modal { width: min(1180px, 100%); max-height: min(92vh, 980px); }
|
||||
.collectible-modal .modal-head p { margin: 4px 0 0; color: var(--muted); font-size: 11px; }
|
||||
.collectible-modal-body { gap: 16px; overflow: auto; padding: 16px 18px 22px; background: #f5f7fa; }
|
||||
.collectible-modal-body { gap: 16px; overflow: auto; padding: 16px 18px 22px; background: var(--bg); }
|
||||
.collectible-loading { display: flex; min-height: 90px; align-items: center; justify-content: center; gap: 8px; color: var(--muted); }
|
||||
.collectible-empty { display: flex; align-items: center; gap: 12px; padding: 16px; color: #66568c; background: linear-gradient(135deg, #fbf9ff, #f2f7ff); border: 1px dashed #cfc3e9; border-radius: 12px; }
|
||||
.collectible-empty { display: flex; align-items: center; gap: 12px; padding: 16px; color: var(--purple-text); background: var(--purple-tint); border: 1px dashed var(--purple-border); border-radius: var(--radius); }
|
||||
.collectible-empty div,
|
||||
.collectible-definition-head > div:first-child,
|
||||
.collectible-section-head > div:first-child { display: grid; gap: 3px; }
|
||||
.collectible-empty span,
|
||||
.collectible-definition-head span,
|
||||
.collectible-section-head span { color: var(--muted); font-size: 10px; font-weight: 500; }
|
||||
.collectible-active { overflow: hidden; background: #ffffff; border: 1px solid #ddd6ee; border-radius: 12px; box-shadow: 0 5px 16px rgba(66, 46, 110, .05); }
|
||||
.collectible-active-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 14px; background: linear-gradient(100deg, #fbf9ff, #f4f9ff); border-bottom: 1px solid #e9e4f3; }
|
||||
.collectible-active-head > div { display: flex; align-items: center; gap: 9px; color: #60458f; }
|
||||
.collectible-active { overflow: hidden; background: var(--panel); border: 1px solid var(--purple-border); border-radius: var(--radius); box-shadow: var(--shadow-sm); }
|
||||
.collectible-active-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 14px; background: var(--purple-tint); border-bottom: 1px solid var(--purple-border); }
|
||||
.collectible-active-head > div { display: flex; align-items: center; gap: 9px; color: var(--purple-text); }
|
||||
.collectible-active-head > div > div { display: grid; gap: 2px; }
|
||||
.collectible-active-head span { color: var(--muted); font-size: 10px; }
|
||||
.collectible-active-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(145px, 1fr)); gap: 1px; background: var(--line); }
|
||||
.collectible-active-grid article { display: flex; min-width: 0; align-items: center; gap: 9px; padding: 9px 11px; background: #ffffff; }
|
||||
.collectible-active-grid article { display: flex; min-width: 0; align-items: center; gap: 9px; padding: 9px 11px; background: var(--panel); }
|
||||
.collectible-active-grid article > div:last-child { display: grid; min-width: 0; gap: 2px; }
|
||||
.collectible-active-grid article strong { overflow: hidden; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.collectible-active-grid article span { color: var(--muted); font-size: 9px; }
|
||||
.collectible-definition { overflow: hidden; background: #ffffff; border: 1px solid var(--line); border-radius: 12px; box-shadow: 0 8px 24px rgba(16, 24, 40, .04); }
|
||||
.collectible-definition-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 16px; background: linear-gradient(110deg, #f8fbfa, #fbf9ff); border-bottom: 1px solid var(--line); }
|
||||
.collectible-main-fields { padding: 14px 16px; background: #fbfcfd; border-bottom: 1px solid var(--line); }
|
||||
.collectible-definition { overflow: hidden; background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow-sm); }
|
||||
.collectible-definition-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 16px; background: var(--panel-subtle); border-bottom: 1px solid var(--line); }
|
||||
.collectible-main-fields { padding: 14px 16px; background: var(--panel-subtle); border-bottom: 1px solid var(--line); }
|
||||
.collectible-section { padding: 14px 16px; border-bottom: 1px solid var(--line); }
|
||||
.collectible-section:last-child { border-bottom: 0; }
|
||||
.collectible-section-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 10px; }
|
||||
.collectible-section-tools { display: flex; align-items: center; gap: 7px; }
|
||||
.collectible-rows { display: grid; gap: 7px; }
|
||||
.collectible-row { position: relative; display: grid; align-items: end; gap: 7px; padding: 9px 9px 9px 36px; background: #fafbfc; border: 1px solid #e1e6eb; border-radius: 9px; }
|
||||
.collectible-row:hover { background: #ffffff; border-color: #cbd7dd; box-shadow: 0 3px 10px rgba(16, 24, 40, .035); }
|
||||
.collectible-row { position: relative; display: grid; align-items: end; gap: 7px; padding: 9px 9px 9px 36px; background: var(--panel-subtle); border: 1px solid var(--line); border-radius: var(--radius-sm); }
|
||||
.collectible-row:hover { background: var(--panel); border-color: var(--line-strong); box-shadow: var(--shadow-sm); }
|
||||
.collectible-row.animated { grid-template-columns: minmax(120px, 1.2fr) 90px 78px minmax(160px, 1.4fr) 48px 30px; }
|
||||
.collectible-row.backdrop { grid-template-columns: minmax(110px, 1.2fr) 70px 80px 70px repeat(4, 52px) 48px 30px; }
|
||||
.collectible-row-index { position: absolute; top: 0; bottom: 0; left: 0; display: grid; width: 27px; place-items: center; color: #71668c; background: #f0edf7; border-right: 1px solid #e0d9ed; border-radius: 8px 0 0 8px; font-size: 10px; font-weight: 800; }
|
||||
.collectible-row-index { position: absolute; top: 0; bottom: 0; left: 0; display: grid; width: 27px; place-items: center; color: var(--purple-text); background: var(--purple-tint); border-right: 1px solid var(--purple-border); border-radius: var(--radius-xs) 0 0 var(--radius-xs); font-size: 10px; font-weight: 800; }
|
||||
.collectible-row label { display: grid; min-width: 0; gap: 4px; }
|
||||
.collectible-row label > span { color: var(--muted); font-size: 9px; font-weight: 800; text-transform: uppercase; letter-spacing: .025em; }
|
||||
.collectible-row input:not([type="file"]) { width: 100%; min-width: 0; height: 32px; padding: 0 8px; color: var(--text); background: #ffffff; border: 1px solid #d5dde3; border-radius: 7px; font: inherit; font-size: 11px; }
|
||||
.collectible-row input:focus { border-color: #8d7aba; box-shadow: 0 0 0 3px rgba(111, 91, 174, .08); outline: none; }
|
||||
.collectible-row input:not([type="file"]) { width: 100%; min-width: 0; height: 32px; padding: 0 8px; color: var(--text); background: var(--input-bg); border: 1px solid var(--line-strong); border-radius: var(--radius-sm); font: inherit; font-size: 11px; }
|
||||
.collectible-row input:focus { border-color: var(--purple); box-shadow: 0 0 0 3px var(--purple-tint); outline: none; }
|
||||
.collectible-file input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
|
||||
.collectible-file em { display: flex; min-width: 0; height: 32px; align-items: center; gap: 5px; overflow: hidden; padding: 0 8px; color: #625080; background: #f7f4fd; border: 1px dashed #cfc4e1; border-radius: 7px; font-size: 10px; font-style: normal; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; cursor: pointer; }
|
||||
.collectible-inline-preview { display: grid; width: 42px; height: 42px; place-items: center; overflow: hidden; color: #8c7cae; background: radial-gradient(circle, #ffffff, #eee8f8); border: 1px solid #ded5ed; border-radius: 8px; }
|
||||
.collectible-file em { display: flex; min-width: 0; height: 32px; align-items: center; gap: 5px; overflow: hidden; padding: 0 8px; color: var(--purple-text); background: var(--purple-tint); border: 1px dashed var(--purple-border); border-radius: var(--radius-sm); font-size: 10px; font-style: normal; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; cursor: pointer; }
|
||||
.collectible-inline-preview { display: grid; width: 42px; height: 42px; place-items: center; overflow: hidden; color: var(--purple); background: var(--purple-tint); border: 1px solid var(--purple-border); border-radius: var(--radius-sm); }
|
||||
.collectible-animation { width: 100%; height: 100%; overflow: hidden; }
|
||||
.collectible-animation.compact { display: grid; width: 42px; height: 42px; flex: 0 0 42px; place-items: center; background: radial-gradient(circle, #ffffff, #f0ebfa); border: 1px solid #e0d9ec; border-radius: 8px; }
|
||||
.collectible-animation.compact { display: grid; width: 42px; height: 42px; flex: 0 0 42px; place-items: center; background: var(--purple-tint); border: 1px solid var(--purple-border); border-radius: var(--radius-sm); }
|
||||
.collectible-animation canvas { width: 100% !important; height: 100% !important; }
|
||||
.collectible-animation.failed { color: #b42318; background: #fff4f2; }
|
||||
.collectible-animation.loading { color: #807397; }
|
||||
.collectible-file-error { grid-column: 1 / -1; color: #b42318; font-size: 10px; }
|
||||
.collectible-animation.failed { color: var(--danger); background: var(--danger-tint); }
|
||||
.collectible-animation.loading { color: var(--purple-text); }
|
||||
.collectible-file-error { grid-column: 1 / -1; color: var(--danger); font-size: 10px; }
|
||||
.collectible-color input { height: 32px !important; padding: 3px !important; cursor: pointer; }
|
||||
.collectible-backdrop-preview { display: grid; width: 42px; height: 42px; flex: 0 0 42px; place-items: center; border: 1px solid rgba(42, 31, 71, .18); border-radius: 8px; box-shadow: inset 0 0 0 1px rgba(255,255,255,.2); font-size: 11px; font-weight: 900; }
|
||||
.collectible-backdrop-preview { display: grid; width: 42px; height: 42px; flex: 0 0 42px; place-items: center; border: 1px solid rgba(42, 31, 71, .18); border-radius: var(--radius-sm); box-shadow: inset 0 0 0 1px rgba(255,255,255,.2); font-size: 11px; font-weight: 900; }
|
||||
.collectible-row .icon-btn { align-self: center; }
|
||||
.collectible-row .icon-btn:disabled { opacity: .28; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.gift-fields-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.give-gift-layout { grid-template-columns: 1fr; }
|
||||
.give-gift-picker-list { max-height: 320px; }
|
||||
.collectible-row.animated,
|
||||
.collectible-row.backdrop { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.collectible-inline-preview,
|
||||
|
|
@ -614,3 +683,113 @@
|
|||
.collectible-row.backdrop { grid-template-columns: 1fr; }
|
||||
.collectible-active-grid { grid-template-columns: 1fr 1fr; }
|
||||
}
|
||||
|
||||
.attr-block {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
background: var(--panel-subtle);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.attr-block .duration-field input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.attr-block .btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.emoji-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.emoji-card {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.emoji-preview {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
height: 88px;
|
||||
background: var(--surface-soft);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.emoji-anim {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
}
|
||||
|
||||
.emoji-anim canvas {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
.emoji-glyph {
|
||||
font-size: 46px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.emoji-meta {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.emoji-alt {
|
||||
font-size: 18px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.emoji-id {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
color: var(--text);
|
||||
background: var(--panel-subtle);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.emoji-id .mono {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.emoji-id svg {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.emoji-id:hover {
|
||||
border-color: var(--brand-tint-border);
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
.emoji-sub {
|
||||
overflow: hidden;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@
|
|||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background: rgba(17, 24, 39, 0.52);
|
||||
background: var(--overlay);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.modal {
|
||||
|
|
@ -13,9 +14,9 @@
|
|||
max-height: min(820px, calc(100vh - 48px));
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
background: #ffffff;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
|
|
@ -43,10 +44,17 @@
|
|||
width: 30px;
|
||||
height: 30px;
|
||||
place-items: center;
|
||||
color: var(--text-soft);
|
||||
background: var(--panel-subtle);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 7px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: background-color 140ms ease, border-color 140ms ease, color 140ms ease;
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
background: var(--btn-hover);
|
||||
border-color: var(--line-strong);
|
||||
}
|
||||
|
||||
.command-steps {
|
||||
|
|
@ -73,7 +81,7 @@
|
|||
color: var(--muted);
|
||||
background: var(--panel-subtle);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.command-step span {
|
||||
|
|
@ -81,7 +89,7 @@
|
|||
width: 20px;
|
||||
height: 20px;
|
||||
place-items: center;
|
||||
background: #ffffff;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
|
|
@ -90,12 +98,12 @@
|
|||
|
||||
.command-step.active {
|
||||
color: var(--brand);
|
||||
border-color: #a9c8f7;
|
||||
border-color: var(--brand-tint-border);
|
||||
}
|
||||
|
||||
.command-step.done {
|
||||
color: var(--good);
|
||||
border-color: #b9dcc7;
|
||||
border-color: var(--good-border);
|
||||
}
|
||||
|
||||
.form-field {
|
||||
|
|
@ -105,10 +113,16 @@
|
|||
|
||||
.form-field span,
|
||||
.form-stack span {
|
||||
color: #4b5563;
|
||||
color: var(--text-soft);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.form-field input:disabled,
|
||||
.form-field textarea:disabled {
|
||||
opacity: .6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.command-preview {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
|
|
@ -123,7 +137,7 @@
|
|||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
color: #344054;
|
||||
color: var(--text-soft);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
|
|
@ -131,9 +145,9 @@
|
|||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
background: #fbfcfd;
|
||||
background: var(--panel-subtle);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.result-line {
|
||||
|
|
@ -151,13 +165,13 @@
|
|||
}
|
||||
|
||||
.result-message {
|
||||
color: #344054;
|
||||
color: var(--text-soft);
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
justify-content: flex-end;
|
||||
padding: 12px 18px;
|
||||
background: #ffffff;
|
||||
background: var(--panel);
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
|
|
@ -174,9 +188,9 @@
|
|||
width: min(420px, 100%);
|
||||
gap: 18px;
|
||||
padding: 22px;
|
||||
background: #ffffff;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
|
|
@ -201,14 +215,15 @@
|
|||
align-items: center;
|
||||
padding: 0 8px;
|
||||
color: var(--brand);
|
||||
background: #eaf2fd;
|
||||
border: 1px solid #c7dcf9;
|
||||
background: var(--brand-tint);
|
||||
border: 1px solid var(--brand-tint-border);
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.login-copy h1 {
|
||||
margin: 0;
|
||||
color: var(--heading);
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
|
|
@ -237,13 +252,14 @@
|
|||
place-items: center;
|
||||
align-content: center;
|
||||
gap: 18px;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.loader-bar {
|
||||
width: 180px;
|
||||
height: 4px;
|
||||
overflow: hidden;
|
||||
background: #d7dde4;
|
||||
background: var(--line-strong);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
|
|
|
|||
106
cmd/telesrv-admin/web/src/theme.tsx
Normal file
106
cmd/telesrv-admin/web/src/theme.tsx
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
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";
|
||||
|
||||
const storageKey = "telesrv.admin.theme";
|
||||
|
||||
type ThemeContextValue = {
|
||||
theme: Theme;
|
||||
setTheme: (theme: Theme) => void;
|
||||
toggleTheme: () => void;
|
||||
};
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||
|
||||
export function applyTheme(theme: Theme) {
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
document.documentElement.style.colorScheme = theme;
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [theme, setThemeState] = useState<Theme>(() => initialTheme());
|
||||
|
||||
useEffect(() => {
|
||||
applyTheme(theme);
|
||||
try {
|
||||
localStorage.setItem(storageKey, theme);
|
||||
} catch {
|
||||
// Theme persistence is best-effort.
|
||||
}
|
||||
}, [theme]);
|
||||
|
||||
// Follow the OS preference until the user makes an explicit choice.
|
||||
useEffect(() => {
|
||||
if (!window.matchMedia) {
|
||||
return;
|
||||
}
|
||||
const media = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const onChange = (event: MediaQueryListEvent) => {
|
||||
let stored: string | null = null;
|
||||
try {
|
||||
stored = localStorage.getItem(storageKey);
|
||||
} catch {
|
||||
stored = null;
|
||||
}
|
||||
if (stored !== "light" && stored !== "dark") {
|
||||
setThemeState(event.matches ? "dark" : "light");
|
||||
}
|
||||
};
|
||||
media.addEventListener("change", onChange);
|
||||
return () => media.removeEventListener("change", onChange);
|
||||
}, []);
|
||||
|
||||
const setTheme = useCallback((next: Theme) => setThemeState(next), []);
|
||||
const toggleTheme = useCallback(() => setThemeState((current) => (current === "dark" ? "light" : "dark")), []);
|
||||
|
||||
const value = useMemo<ThemeContextValue>(() => ({ theme, setTheme, toggleTheme }), [theme, setTheme, toggleTheme]);
|
||||
|
||||
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
||||
}
|
||||
|
||||
export function useTheme(): ThemeContextValue {
|
||||
const value = useContext(ThemeContext);
|
||||
if (!value) {
|
||||
throw new Error("useTheme must be used inside ThemeProvider");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function ThemeSwitch() {
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const { t } = useI18n();
|
||||
const nextIsDark = theme === "light";
|
||||
const label = t(nextIsDark ? "theme.switchToDark" : "theme.switchToLight");
|
||||
return (
|
||||
<button
|
||||
className="theme-toggle"
|
||||
type="button"
|
||||
onClick={toggleTheme}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
>
|
||||
{theme === "dark" ? <Sun size={16} /> : <Moon size={16} />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function initialTheme(): Theme {
|
||||
try {
|
||||
const stored = localStorage.getItem(storageKey);
|
||||
if (stored === "light" || stored === "dark") {
|
||||
return stored;
|
||||
}
|
||||
} catch {
|
||||
// Storage is optional.
|
||||
}
|
||||
try {
|
||||
if (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches) {
|
||||
return "dark";
|
||||
}
|
||||
} catch {
|
||||
// matchMedia can be unavailable in unusual embedded contexts.
|
||||
}
|
||||
return "light";
|
||||
}
|
||||
|
|
@ -9,6 +9,8 @@ export type AccountRow = {
|
|||
Frozen: boolean;
|
||||
Reason: string;
|
||||
Verified: boolean;
|
||||
Scam: boolean;
|
||||
Fake: boolean;
|
||||
PremiumUntil: number;
|
||||
LastActiveAt: string;
|
||||
DeviceCount: number;
|
||||
|
|
@ -59,6 +61,8 @@ export type AccountDetail = {
|
|||
About: string;
|
||||
LastSeenAt: number;
|
||||
Verified: boolean;
|
||||
Scam: boolean;
|
||||
Fake: boolean;
|
||||
Support: boolean;
|
||||
Bot: boolean;
|
||||
StarsBalance: number;
|
||||
|
|
@ -81,7 +85,16 @@ export type ChannelRow = {
|
|||
Forum: boolean;
|
||||
Monoforum: boolean;
|
||||
Verified: boolean;
|
||||
Scam: boolean;
|
||||
Fake: boolean;
|
||||
Gigagroup: boolean;
|
||||
Deleted: boolean;
|
||||
AntiSpam: boolean;
|
||||
ParticipantsHidden: boolean;
|
||||
NoForwards: boolean;
|
||||
JoinToSend: boolean;
|
||||
JoinRequest: boolean;
|
||||
SlowmodeSeconds: number;
|
||||
ParticipantsCount: number;
|
||||
AdminsCount: number;
|
||||
KickedCount: number;
|
||||
|
|
@ -100,6 +113,27 @@ export type ChannelDetail = {
|
|||
AuditLogs: AuditLogRow[];
|
||||
};
|
||||
|
||||
export type BotRow = {
|
||||
ID: number;
|
||||
Username: string;
|
||||
FirstName: string;
|
||||
Verified: boolean;
|
||||
Scam: boolean;
|
||||
Fake: boolean;
|
||||
System: boolean;
|
||||
OwnerUserID: number;
|
||||
CreatedAt: string;
|
||||
UpdatedAt: string;
|
||||
};
|
||||
|
||||
export type BotDetail = {
|
||||
Bot: BotRow;
|
||||
About: string;
|
||||
Description: string;
|
||||
OwnerUsername: string;
|
||||
AuditLogs: AuditLogRow[];
|
||||
};
|
||||
|
||||
export type MessageRow = {
|
||||
OwnerUserID: number;
|
||||
BoxID: number;
|
||||
|
|
@ -330,6 +364,32 @@ export type ChannelListResponse = {
|
|||
listing: boolean;
|
||||
};
|
||||
|
||||
export type BotListResponse = {
|
||||
query: string;
|
||||
limit: number;
|
||||
rows: BotRow[];
|
||||
has_more: boolean;
|
||||
next_before_id: number;
|
||||
listing: boolean;
|
||||
};
|
||||
|
||||
export type EmojiRow = {
|
||||
DocumentID: string;
|
||||
Alt: string;
|
||||
MimeType: string;
|
||||
Size: number;
|
||||
SetTitle: string;
|
||||
CreatedAt: string;
|
||||
};
|
||||
|
||||
export type EmojiListResponse = {
|
||||
query: string;
|
||||
rows: EmojiRow[];
|
||||
has_more: boolean;
|
||||
next_before_id: number;
|
||||
listing: boolean;
|
||||
};
|
||||
|
||||
export type MessageListResponse = {
|
||||
owner_user_id: number;
|
||||
peer_id: number;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue