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,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