feat: sync collectible star gifts

This commit is contained in:
A 2026-07-16 12:38:53 +08:00
parent 47fcf0ea41
commit 5ecf4e912d
64 changed files with 7559 additions and 403 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>telesrv admin</title>
<script type="module" crossorigin src="/assets/index-DRWO_DgE.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-W1-UPxwc.css">
<script type="module" crossorigin src="/assets/index-Q8RNNOYL.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CSxFgF7v.css">
</head>
<body>
<div id="root"></div>

View file

@ -8,6 +8,7 @@
"name": "telesrv-admin-ui",
"version": "0.1.0",
"dependencies": {
"lottie-web": "^5.13.0",
"lucide-react": "^0.468.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
@ -741,6 +742,12 @@
"loose-envify": "cli.js"
}
},
"node_modules/lottie-web": {
"version": "5.13.0",
"resolved": "https://registry.npmjs.org/lottie-web/-/lottie-web-5.13.0.tgz",
"integrity": "sha512-+gfBXl6sxXMPe8tKQm7qzLnUy5DUPJPKIyRHwtpCpyUEYjHYRJC/5gjUvdkuO2c3JllrPtHXH5UJJK8LRYl5yQ==",
"license": "MIT"
},
"node_modules/lucide-react": {
"version": "0.468.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.468.0.tgz",

View file

@ -9,6 +9,7 @@
"preview": "vite preview"
},
"dependencies": {
"lottie-web": "^5.13.0",
"lucide-react": "^0.468.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"

View file

@ -7,7 +7,9 @@ import type {
GroupMessageDetail,
GroupMessageListResponse,
MessageDetail,
MessageListResponse
MessageListResponse,
StarGiftCollectiblePreview,
StarGiftListResponse
} from "./types";
export class APIError extends Error {
@ -20,12 +22,10 @@ export class APIError extends Error {
}
async function request<T>(url: string, init: RequestInit = {}): Promise<T> {
const isForm = typeof FormData !== "undefined" && init.body instanceof FormData;
const response = await fetch(url, {
credentials: "same-origin",
headers: {
"Content-Type": "application/json",
...(init.headers ?? {})
},
headers: isForm ? init.headers : { "Content-Type": "application/json", ...(init.headers ?? {}) },
...init
});
const text = await response.text();
@ -65,6 +65,12 @@ export const api = {
const params = new URLSearchParams({ channel_id: String(channelID), msg_id: String(msgID) });
return request<GroupMessageDetail>(`/api/messages/groups/detail?${params.toString()}`);
},
gifts: () => request<StarGiftListResponse>("/api/gifts"),
giftAnimation: (id: number) => request<Record<string, unknown>>(`/api/gifts/${id}/animation`),
giftCollectibles: (id: number) => request<StarGiftCollectiblePreview>(`/api/gifts/${id}/collectibles`),
giftCollectibleAnimation: (giftID: number, kind: "model" | "pattern", attributeID: number) => request<Record<string, unknown>>(`/api/gifts/${giftID}/collectibles/${kind}/${attributeID}/animation`),
importGift: (form: FormData) => request<CommandResult>("/api/actions/import-gift", { method: "POST", body: form }),
publishGiftCollectibles: (giftID: number, form: FormData) => request<CommandResult>(`/api/actions/publish-gift-collectibles?gift_id=${giftID}`, { method: "POST", body: form }),
action: (path: string, payload: Record<string, unknown>) => request<CommandResult>(path, {
method: "POST",
body: JSON.stringify(payload)

View file

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

View file

@ -61,12 +61,15 @@ const translations: Record<Language, Record<string, string>> = {
"route.dashboardSubtitle": "Console / Overview",
"route.messages": "Message Audit",
"route.messagesSubtitle": "Console / Messages",
"route.gifts": "Star Gifts",
"route.giftsSubtitle": "Console / Star Gifts",
"layout.navigation": "Navigation",
"layout.primaryNav": "Primary navigation",
"layout.dashboard": "Overview",
"layout.accounts": "Accounts",
"layout.channels": "Supergroups / Channels",
"layout.messages": "Messages",
"layout.gifts": "Star Gifts",
"layout.privateMessages": "Private",
"layout.groupMessages": "Groups",
"layout.runtime": "Runtime",
@ -241,6 +244,81 @@ const translations: Record<Language, Record<string, string>> = {
"messages.channelGroup": "Channel / Group",
"messages.pinned": "Pinned",
"messages.channelPost": "Channel post",
"gifts.pageTitle": "Star Gift Catalog",
"gifts.eyebrow": "Catalog, immutable revisions and animation assets",
"gifts.total": "Catalog entries",
"gifts.enabled": "Enabled",
"gifts.received": "Received gifts",
"gifts.formats": "Accepted formats",
"gifts.add": "Add gift",
"gifts.searchPlaceholder": "Search gift ID, title or format",
"gifts.listSummary": "Showing {shown} of {total}",
"gifts.idRevision": "ID / Revision",
"gifts.price": "Price / Conversion",
"gifts.importTitle": "Import a Star Gift",
"gifts.importEyebrow": "Gift catalog operation",
"gifts.newRevision": "Create revision for gift #{id}",
"gifts.importHint": "Upload TGS or plain Lottie JSON. Lottie is normalized and compressed to TGS.",
"gifts.animation": "Animation file",
"gifts.filePrompt": "Drop or choose a TGS / Lottie file",
"gifts.fileHint": "TGS, JSON or Lottie · validated before import",
"gifts.chooseFile": "Choose file",
"gifts.changeFile": "Change file",
"gifts.title": "Display title",
"gifts.titlePlaceholder": "e.g. Celebration Star",
"gifts.stars": "Price in Stars",
"gifts.convertStars": "Conversion Stars",
"gifts.sortOrder": "Sort order",
"gifts.reason": "Audit reason",
"gifts.reasonPlaceholder": "Briefly describe why this gift is being imported",
"gifts.enableAfterImport": "Enable after import",
"gifts.validate": "Dry-run validation",
"gifts.confirmImport": "Confirm import",
"gifts.stepDetails": "File and details",
"gifts.stepValidate": "Dry-run validation",
"gifts.stepImport": "Confirm import",
"gifts.fileRequired": "Choose a TGS or Lottie file first",
"gifts.source": "Source",
"gifts.replace": "New revision",
"gifts.disable": "Disable",
"gifts.enable": "Enable",
"gifts.empty": "No Star Gifts have been imported.",
"gifts.emptyHint": "Import the first animation above to build the gift catalog.",
"gifts.validationReady": "Validation passed",
"gifts.validationHint": "Review the normalized metadata, then confirm the import.",
"gifts.confirmState": "Apply the validated state change to gift #{id}?",
"collectibles.manage": "Attribute pool",
"collectibles.title": "Collectible pool · Gift #{id}",
"collectibles.eyebrow": "Unique gift attributes",
"collectibles.activeRevision": "Published revision {revision}",
"collectibles.published": "Published",
"collectibles.noPool": "No collectible pool published",
"collectibles.noPoolHint": "Publish models, patterns and backdrops to enable upgrades.",
"collectibles.publishNew": "Publish a new immutable revision",
"collectibles.immutableHint": "Dry-run checks every file and rarity total before the revision becomes active.",
"collectibles.upgradeStars": "Upgrade price in Stars",
"collectibles.supply": "Unique supply",
"collectibles.slug": "Public slug prefix",
"collectibles.models": "Models",
"collectibles.patterns": "Patterns",
"collectibles.backdrops": "Backdrops",
"collectibles.model": "Model",
"collectibles.pattern": "Pattern",
"collectibles.backdrop": "Backdrop",
"collectibles.rarity": "Rarity ‰",
"collectibles.rarityHint": "Every section must total exactly 1000‰.",
"collectibles.colorHint": "Colors are stored as Telegram 24-bit RGB values.",
"collectibles.addAttribute": "Add",
"collectibles.remove": "Remove attribute",
"collectibles.fileRequired": "Every model and pattern needs a TGS or Lottie file.",
"collectibles.backdropID": "Backdrop ID",
"collectibles.color.center": "Center",
"collectibles.color.edge": "Edge",
"collectibles.color.pattern": "Pattern",
"collectibles.color.text": "Text",
"collectibles.validationReady": "Attribute pool is valid",
"collectibles.validationHint": "Review the normalized assets, then publish this immutable revision.",
"collectibles.publish": "Publish revision",
"messages.msgIDsInvalid": "Message IDs are invalid",
"auth.device": "Device",
"auth.platform": "Platform",
@ -332,12 +410,15 @@ const translations: Record<Language, Record<string, string>> = {
"route.dashboardSubtitle": "控制台 / 总览",
"route.messages": "消息审计",
"route.messagesSubtitle": "控制台 / 消息",
"route.gifts": "星星礼物",
"route.giftsSubtitle": "控制台 / 星星礼物",
"layout.navigation": "导航",
"layout.primaryNav": "主导航",
"layout.dashboard": "总览",
"layout.accounts": "账号",
"layout.channels": "超级群/频道",
"layout.messages": "消息",
"layout.gifts": "礼物目录",
"layout.privateMessages": "私聊",
"layout.groupMessages": "群聊",
"layout.runtime": "运行状态",
@ -512,6 +593,81 @@ const translations: Record<Language, Record<string, string>> = {
"messages.channelGroup": "频道 / 群",
"messages.pinned": "置顶",
"messages.channelPost": "频道帖子",
"gifts.pageTitle": "星星礼物目录",
"gifts.eyebrow": "目录、不可变版本与动画资源",
"gifts.total": "目录条目",
"gifts.enabled": "已启用",
"gifts.received": "已领取礼物",
"gifts.formats": "支持格式",
"gifts.add": "添加礼物",
"gifts.searchPlaceholder": "搜索礼物 ID、标题或格式",
"gifts.listSummary": "显示 {shown} / {total} 项",
"gifts.idRevision": "ID / 版本",
"gifts.price": "售价 / 兑换",
"gifts.importTitle": "导入星星礼物",
"gifts.importEyebrow": "礼物目录操作",
"gifts.newRevision": "为礼物 #{id} 创建新版本",
"gifts.importHint": "支持 TGS 或纯 Lottie JSONLottie 会规范化并压缩成 TGS。",
"gifts.animation": "动画文件",
"gifts.filePrompt": "拖放或选择 TGS / Lottie 文件",
"gifts.fileHint": "支持 TGS、JSON、Lottie导入前会先进行校验",
"gifts.chooseFile": "选择文件",
"gifts.changeFile": "更换文件",
"gifts.title": "显示标题",
"gifts.titlePlaceholder": "例如:庆典星星",
"gifts.stars": "售价 Stars",
"gifts.convertStars": "可兑换 Stars",
"gifts.sortOrder": "排序值",
"gifts.reason": "审计原因",
"gifts.reasonPlaceholder": "简要说明本次导入礼物的原因",
"gifts.enableAfterImport": "导入后启用",
"gifts.validate": "Dry-run 校验",
"gifts.confirmImport": "确认导入",
"gifts.stepDetails": "文件与信息",
"gifts.stepValidate": "Dry-run 校验",
"gifts.stepImport": "确认导入",
"gifts.fileRequired": "请先选择 TGS 或 Lottie 文件",
"gifts.source": "来源",
"gifts.replace": "创建新版本",
"gifts.disable": "停用",
"gifts.enable": "启用",
"gifts.empty": "尚未导入星星礼物。",
"gifts.emptyHint": "从上方导入第一个动画,开始搭建礼物目录。",
"gifts.validationReady": "校验已通过",
"gifts.validationHint": "确认规范化后的元数据无误,再执行正式导入。",
"gifts.confirmState": "确认执行礼物 #{id} 的状态变更吗?",
"collectibles.manage": "属性池",
"collectibles.title": "Collectibles 属性池 · 礼物 #{id}",
"collectibles.eyebrow": "唯一礼物属性管理",
"collectibles.activeRevision": "已发布版本 {revision}",
"collectibles.published": "已发布",
"collectibles.noPool": "尚未发布 Collectibles 属性池",
"collectibles.noPoolHint": "发布模型、图案与背景后,客户端即可升级为唯一礼物。",
"collectibles.publishNew": "发布新的不可变版本",
"collectibles.immutableHint": "Dry-run 会校验全部文件和稀有度总和,通过后才切换为当前版本。",
"collectibles.upgradeStars": "升级价格 Stars",
"collectibles.supply": "唯一礼物总量",
"collectibles.slug": "公开 Slug 前缀",
"collectibles.models": "模型",
"collectibles.patterns": "图案",
"collectibles.backdrops": "背景",
"collectibles.model": "模型",
"collectibles.pattern": "图案",
"collectibles.backdrop": "背景",
"collectibles.rarity": "稀有度 ‰",
"collectibles.rarityHint": "每一类的稀有度总和必须正好为 1000‰。",
"collectibles.colorHint": "颜色会按 Telegram 24 位 RGB 数值保存。",
"collectibles.addAttribute": "添加",
"collectibles.remove": "删除属性",
"collectibles.fileRequired": "每个模型和图案都必须选择 TGS 或 Lottie 文件。",
"collectibles.backdropID": "背景 ID",
"collectibles.color.center": "中心色",
"collectibles.color.edge": "边缘色",
"collectibles.color.pattern": "图案色",
"collectibles.color.text": "文字色",
"collectibles.validationReady": "属性池校验通过",
"collectibles.validationHint": "确认规范化资源无误后,即可发布这个不可变版本。",
"collectibles.publish": "发布版本",
"messages.msgIDsInvalid": "消息 ID 无效",
"auth.device": "设备",
"auth.platform": "平台",

View file

@ -0,0 +1,4 @@
declare module "lottie-web/build/player/lottie_light_canvas" {
import lottie from "lottie-web";
export default lottie;
}

View file

@ -0,0 +1,236 @@
import { CheckCircle2, FileJson2, Gem, Loader2, Plus, ShieldCheck, Sparkles, Trash2, Upload, X } from "lucide-react";
import lottie from "lottie-web/build/player/lottie_light_canvas";
import { useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { api, errorMessage } from "../api";
import { Alert, Badge } from "../components/ui";
import { useI18n } from "../i18n";
import type { CommandResult, StarGiftCollectibleAttributeRow, StarGiftCollectiblePreview, StarGiftRow } from "../types";
type AnimationData = Record<string, unknown>;
type AnimatedDraft = {
key: string;
name: string;
rarity: string;
sortOrder: string;
file: File | null;
animation: AnimationData | null;
fileError: string;
};
type BackdropDraft = {
key: string;
name: string;
backdropID: string;
rarity: string;
sortOrder: string;
center: string;
edge: string;
pattern: string;
text: string;
};
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" });
function AnimationPreview({ data, compact = false }: { data: AnimationData; compact?: boolean }) {
const host = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!host.current) return;
const player = lottie.loadAnimation({ container: host.current, renderer: "canvas", loop: true, autoplay: true, animationData: structuredClone(data) });
return () => player.destroy();
}, [data]);
return <div className={`collectible-animation ${compact ? "compact" : ""}`} ref={host} />;
}
function RemoteAnimation({ giftID, attribute }: { giftID: number; attribute: StarGiftCollectibleAttributeRow }) {
const [data, setData] = useState<AnimationData | null>(null);
const [failed, setFailed] = useState(false);
useEffect(() => {
let cancelled = false;
setFailed(false);
api.giftCollectibleAnimation(giftID, attribute.kind as "model" | "pattern", attribute.id)
.then((value) => { if (!cancelled) setData(value); })
.catch(() => { if (!cancelled) setFailed(true); });
return () => { cancelled = true; };
}, [giftID, attribute.id, attribute.kind]);
if (failed) return <div className="collectible-animation compact failed">!</div>;
if (!data) return <div className="collectible-animation compact loading"><Loader2 className="spin" size={15} /></div>;
return <AnimationPreview data={data} compact />;
}
async function parseAnimationFile(file: File): Promise<AnimationData> {
const bytes = new Uint8Array(await file.arrayBuffer());
let raw: Uint8Array = bytes;
if (bytes.length >= 2 && bytes[0] === 0x1f && bytes[1] === 0x8b) {
if (!("DecompressionStream" in window)) throw new Error("This browser cannot preview TGS files");
const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("gzip"));
raw = new Uint8Array(await new Response(stream).arrayBuffer());
}
const parsed: unknown = JSON.parse(new TextDecoder().decode(raw));
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("Invalid Lottie JSON");
return parsed as AnimationData;
}
const colorNumber = (value: string) => Number.parseInt(value.replace("#", ""), 16);
export function GiftCollectiblesModal({ gift, onClose, onPublished }: { gift: StarGiftRow; onClose: () => void; onPublished: () => void }) {
const { t } = useI18n();
const [active, setActive] = useState<StarGiftCollectiblePreview | null>(null);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [preview, setPreview] = useState<CommandResult | null>(null);
const [upgradeStars, setUpgradeStars] = useState("100");
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()]);
useEffect(() => {
let cancelled = false;
api.giftCollectibles(gift.GiftID).then((value) => {
if (cancelled) return;
setActive(value);
if (value.found) {
setUpgradeStars(String(value.upgrade_stars ?? 100));
setSupplyTotal(String(value.supply_total ?? 1000));
setSlugPrefix(value.slug_prefix ?? `gift-${gift.GiftID}`);
}
}).catch((err) => setError(errorMessage(err))).finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [gift.GiftID]);
const rarityTotals = useMemo(() => ({
models: models.reduce((sum, value) => sum + Number(value.rarity || 0), 0),
patterns: patterns.reduce((sum, value) => sum + Number(value.rarity || 0), 0),
backdrops: backdrops.reduce((sum, value) => sum + Number(value.rarity || 0), 0)
}), [models, patterns, backdrops]);
const invalidate = () => setPreview(null);
const updateAnimated = (kind: "models" | "patterns", key: string, patch: Partial<AnimatedDraft>) => {
const setter = kind === "models" ? setModels : setPatterns;
setter((rows) => rows.map((row) => row.key === key ? { ...row, ...patch } : row));
invalidate();
};
async function chooseFile(kind: "models" | "patterns", row: AnimatedDraft, file: File | null) {
updateAnimated(kind, row.key, { file, animation: null, fileError: "" });
if (!file) return;
try {
const animation = await parseAnimationFile(file);
updateAnimated(kind, row.key, { animation, fileError: "" });
} catch (err) {
updateAnimated(kind, row.key, { animation: null, fileError: errorMessage(err) });
}
}
function buildForm(confirm: boolean, commandID = "") {
if (!reason.trim()) throw new Error(t("action.reasonRequired"));
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 }));
form.set("metadata", JSON.stringify({
command_id: commandID, reason: reason.trim(), confirm,
upgrade_stars: Number(upgradeStars), supply_total: Number(supplyTotal), slug_prefix: slugPrefix.trim().toLowerCase(),
models: animatedMetadata(models), patterns: animatedMetadata(patterns),
backdrops: backdrops.map((row) => ({
name: row.name.trim(), backdrop_id: Number(row.backdropID), rarity_permille: Number(row.rarity), sort_order: Number(row.sortOrder),
center_color: colorNumber(row.center), edge_color: colorNumber(row.edge), pattern_color: colorNumber(row.pattern), text_color: colorNumber(row.text)
}))
}));
for (const row of [...models, ...patterns]) form.set(row.key, row.file as File, (row.file as File).name);
return form;
}
async function validate() {
setBusy(true); setError(""); setPreview(null);
try { setPreview(await api.publishGiftCollectibles(gift.GiftID, buildForm(false))); }
catch (err) { setError(errorMessage(err)); }
finally { setBusy(false); }
}
async function publish() {
if (!preview) return;
setBusy(true); setError("");
try {
await api.publishGiftCollectibles(gift.GiftID, buildForm(true, preview.command_id));
onPublished(); onClose();
} catch (err) { setError(errorMessage(err)); }
finally { setBusy(false); }
}
const renderAnimatedRows = (kind: "models" | "patterns", rows: AnimatedDraft[], setRows: (rows: AnimatedDraft[]) => void) => (
<section className="collectible-section">
<div className="collectible-section-head">
<div><strong>{t(`collectibles.${kind}`)}</strong><span>{t("collectibles.rarityHint")}</span></div>
<div className="collectible-section-tools"><Badge tone={rarityTotals[kind] === 1000 ? "good" : "neutral"}>{rarityTotals[kind]} / 1000</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>
<div className="collectible-rows">
{rows.map((row, index) => <div className="collectible-row animated" key={row.key}>
<div className="collectible-row-index">{index + 1}</div>
<label><span>{t("common.name")}</span><input value={row.name} maxLength={128} onChange={(e) => updateAnimated(kind, row.key, { name: e.target.value })} /></label>
<label><span>{t("collectibles.rarity")}</span><input type="number" min="1" max="1000" value={row.rarity} onChange={(e) => updateAnimated(kind, row.key, { rarity: e.target.value })} /></label>
<label><span>{t("gifts.sortOrder")}</span><input type="number" value={row.sortOrder} onChange={(e) => updateAnimated(kind, row.key, { sortOrder: e.target.value })} /></label>
<label className="collectible-file"><span>{t("gifts.animation")}</span><input type="file" accept=".tgs,.json,.lottie,application/json,application/x-tgsticker" onChange={(e) => void chooseFile(kind, row, e.target.files?.[0] ?? null)} /><em><FileJson2 size={13} />{row.file?.name ?? t("gifts.chooseFile")}</em></label>
<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>
{row.fileError && <span className="collectible-file-error">{row.fileError}</span>}
</div>)}
</div>
</section>
);
return createPortal(<div className="modal-backdrop" role="presentation">
<section className="modal command-modal collectible-modal" role="dialog" aria-modal="true" aria-label={t("collectibles.title", { id: gift.GiftID })}>
<div className="modal-head">
<div><div className="eyebrow">{t("collectibles.eyebrow")}</div><h2>{t("collectibles.title", { id: gift.GiftID })}</h2><p>{gift.Title || `Gift #${gift.GiftID}`}</p></div>
<button className="icon-btn" type="button" onClick={onClose} disabled={busy} aria-label={t("action.close")}><X size={15} /></button>
</div>
<div className="command-body collectible-modal-body">
{loading ? <div className="collectible-loading"><Loader2 className="spin" />{t("common.loading")}</div> : active?.found ? <section className="collectible-active">
<div className="collectible-active-head"><div><Gem size={18} /><div><strong>{t("collectibles.activeRevision", { revision: active.revision ?? 0 })}</strong><span>{active.slug_prefix} · {active.upgrade_stars} · {active.issued} / {active.supply_total}</span></div></div><Badge tone="good">{t("collectibles.published")}</Badge></div>
<div className="collectible-active-grid">
{[...(active.models ?? []), ...(active.patterns ?? [])].map((attribute) => <article key={`${attribute.kind}-${attribute.id}`}><RemoteAnimation giftID={gift.GiftID} attribute={attribute} /><div><strong>{attribute.name}</strong><span>{t(`collectibles.${attribute.kind}`)} · {attribute.rarity_permille}</span></div></article>)}
{(active.backdrops ?? []).map((attribute) => <article key={`backdrop-${attribute.id}`}><div className="collectible-backdrop-preview" style={{ background: `radial-gradient(circle, #${(attribute.center_color ?? 0).toString(16).padStart(6, "0")}, #${(attribute.edge_color ?? 0).toString(16).padStart(6, "0")})`, color: `#${(attribute.text_color ?? 0xffffff).toString(16).padStart(6, "0")}` }}>Aa</div><div><strong>{attribute.name}</strong><span>{t("collectibles.backdrop")} · {attribute.rarity_permille}</span></div></article>)}
</div>
</section> : <div className="collectible-empty"><Gem size={22} /><div><strong>{t("collectibles.noPool")}</strong><span>{t("collectibles.noPoolHint")}</span></div></div>}
<section className="collectible-definition">
<div className="collectible-definition-head"><div><strong>{t("collectibles.publishNew")}</strong><span>{t("collectibles.immutableHint")}</span></div><div className="gift-format-chips"><span>TGS</span><span>Lottie JSON</span></div></div>
<div className="gift-fields-grid collectible-main-fields">
<label><span>{t("collectibles.upgradeStars")}</span><input type="number" min="1" value={upgradeStars} onChange={(e) => { setUpgradeStars(e.target.value); invalidate(); }} /></label>
<label><span>{t("collectibles.supply")}</span><input type="number" min="1" value={supplyTotal} onChange={(e) => { setSupplyTotal(e.target.value); invalidate(); }} /></label>
<label><span>{t("collectibles.slug")}</span><input value={slugPrefix} maxLength={48} onChange={(e) => { setSlugPrefix(e.target.value.toLowerCase()); invalidate(); }} /></label>
<label><span>{t("gifts.reason")}</span><input value={reason} maxLength={1000} placeholder={t("gifts.reasonPlaceholder")} onChange={(e) => setReason(e.target.value)} /></label>
</div>
{renderAnimatedRows("models", models, setModels)}
{renderAnimatedRows("patterns", patterns, setPatterns)}
<section className="collectible-section">
<div className="collectible-section-head"><div><strong>{t("collectibles.backdrops")}</strong><span>{t("collectibles.colorHint")}</span></div><div className="collectible-section-tools"><Badge tone={rarityTotals.backdrops === 1000 ? "good" : "neutral"}>{rarityTotals.backdrops} / 1000</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-rows">{backdrops.map((row, index) => <div className="collectible-row backdrop" key={row.key}>
<div className="collectible-row-index">{index + 1}</div>
<label><span>{t("common.name")}</span><input value={row.name} maxLength={128} onChange={(e) => { setBackdrops(backdrops.map((value) => value.key === row.key ? { ...value, name: e.target.value } : value)); invalidate(); }} /></label>
<label><span>{t("collectibles.backdropID")}</span><input type="number" min="1" value={row.backdropID} onChange={(e) => { setBackdrops(backdrops.map((value) => value.key === row.key ? { ...value, backdropID: e.target.value } : value)); invalidate(); }} /></label>
<label><span>{t("collectibles.rarity")}</span><input type="number" min="1" max="1000" value={row.rarity} onChange={(e) => { setBackdrops(backdrops.map((value) => value.key === row.key ? { ...value, rarity: e.target.value } : value)); invalidate(); }} /></label>
<label><span>{t("gifts.sortOrder")}</span><input type="number" value={row.sortOrder} onChange={(e) => { setBackdrops(backdrops.map((value) => value.key === row.key ? { ...value, sortOrder: e.target.value } : value)); invalidate(); }} /></label>
{(["center", "edge", "pattern", "text"] as const).map((field) => <label className="collectible-color" key={field}><span>{t(`collectibles.color.${field}`)}</span><input type="color" value={row[field]} onChange={(e) => { setBackdrops(backdrops.map((value) => value.key === row.key ? { ...value, [field]: e.target.value } : value)); invalidate(); }} /></label>)}
<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>
</div>)}</div>
</section>
</section>
{error && <Alert>{error}</Alert>}
{preview && <div className="gift-validation"><div className="gift-validation-head"><CheckCircle2 size={17} /><div><strong>{t("collectibles.validationReady")}</strong><span>{t("collectibles.validationHint")}</span></div></div><pre>{JSON.stringify(preview.details, null, 2)}</pre></div>}
</div>
<div className="modal-actions">
<button className="btn" type="button" onClick={onClose} disabled={busy}>{t("common.close")}</button>
<button className="btn" type="button" onClick={validate} disabled={busy}>{busy ? <Loader2 className="spin" size={15} /> : <ShieldCheck size={15} />}{t("gifts.validate")}</button>
<button className="btn primary" type="button" onClick={publish} disabled={busy || !preview}><Upload size={15} />{t("collectibles.publish")}</button>
</div>
</section>
</div>, document.body);
}

View file

@ -0,0 +1,237 @@
import { CheckCircle2, FileJson2, Gem, Loader2, Pause, Play, Plus, RefreshCw, Search, ShieldCheck, Upload, X } from "lucide-react";
import lottie from "lottie-web/build/player/lottie_light_canvas";
import { useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { api, errorMessage } from "../api";
import { ActionButton } from "../components/ActionButton";
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
import { useI18n } from "../i18n";
import { formatDate } from "../lib/format";
import type { CommandResult, StarGiftRow } from "../types";
import { GiftCollectiblesModal } from "./GiftCollectiblesModal";
function formatBytes(bytes: number) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function LottiePreview({ giftID, revision, compact = false }: { giftID: number; revision: number; compact?: boolean }) {
const host = useRef<HTMLDivElement>(null);
const animation = useRef<ReturnType<typeof lottie.loadAnimation> | null>(null);
const [playing, setPlaying] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
let cancelled = false;
api.giftAnimation(giftID).then((data) => {
if (cancelled || !host.current) return;
animation.current?.destroy();
animation.current = lottie.loadAnimation({
container: host.current,
renderer: "canvas",
loop: true,
autoplay: true,
animationData: structuredClone(data)
});
}).catch((err) => setError(errorMessage(err)));
return () => {
cancelled = true;
animation.current?.destroy();
animation.current = null;
};
}, [giftID, revision]);
function toggle() {
if (!animation.current) return;
if (playing) animation.current.pause();
else animation.current.play();
setPlaying(!playing);
}
return (
<div className={`gift-animation-shell ${compact ? "compact" : ""}`}>
<div className="gift-animation" ref={host}>{error && <span>{error}</span>}</div>
<button className="gift-play" type="button" onClick={toggle} aria-label={playing ? "Pause" : "Play"}>
{playing ? <Pause size={14} /> : <Play size={14} />}
</button>
</div>
);
}
export function GiftsPage() {
const { t } = useI18n();
const [gifts, setGifts] = useState<StarGiftRow[]>([]);
const [query, setQuery] = useState("");
const [importOpen, setImportOpen] = useState(false);
const [collectibleGift, setCollectibleGift] = useState<StarGiftRow | null>(null);
const [file, setFile] = useState<File | null>(null);
const [giftID, setGiftID] = useState(0);
const [title, setTitle] = useState("");
const [stars, setStars] = useState("50");
const [convertStars, setConvertStars] = useState("50");
const [sortOrder, setSortOrder] = useState("0");
const [enabled, setEnabled] = useState(true);
const [reason, setReason] = useState("");
const [preview, setPreview] = useState<CommandResult | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [importError, setImportError] = useState("");
async function load() {
setError("");
try {
setGifts((await api.gifts()).Gifts ?? []);
} catch (err) {
setError(errorMessage(err));
}
}
useEffect(() => { void load(); }, []);
const visibleGifts = useMemo(() => {
const normalized = query.trim().toLowerCase();
if (!normalized) return gifts;
return gifts.filter((gift) =>
String(gift.GiftID).includes(normalized) ||
gift.Title.toLowerCase().includes(normalized) ||
gift.SourceFormat.toLowerCase().includes(normalized)
);
}, [gifts, query]);
function uploadForm(confirm: boolean, commandID = "") {
if (!file) throw new Error(t("gifts.fileRequired"));
if (!reason.trim()) throw new Error(t("action.reasonRequired"));
const form = new FormData();
form.set("metadata", JSON.stringify({
command_id: commandID,
reason: reason.trim(),
confirm,
gift_id: giftID,
title: title.trim(),
stars: Number(stars),
convert_stars: Number(convertStars),
enabled,
sort_order: Number(sortOrder)
}));
form.set("file", file, file.name);
return form;
}
async function validateImport() {
setBusy(true); setImportError(""); setPreview(null);
try {
setPreview(await api.importGift(uploadForm(false)));
} catch (err) {
setImportError(errorMessage(err));
} finally { setBusy(false); }
}
async function confirmImport() {
if (!preview) return;
setBusy(true); setImportError("");
try {
await api.importGift(uploadForm(true, preview.command_id));
setPreview(null); setFile(null); setGiftID(0); setTitle("");
await load();
setImportOpen(false);
} catch (err) {
setImportError(errorMessage(err));
} finally { setBusy(false); }
}
function startImport() {
setGiftID(0); setTitle(""); setStars("50"); setConvertStars("50"); setSortOrder("0");
setEnabled(true); setReason(""); setFile(null); setPreview(null); setImportError(""); setImportOpen(true);
}
function startRevision(gift: StarGiftRow) {
setGiftID(gift.GiftID); setTitle(gift.Title); setStars(String(gift.Stars));
setConvertStars(String(gift.ConvertStars)); setSortOrder(String(gift.SortOrder)); setEnabled(gift.Enabled);
setReason(""); setFile(null); setPreview(null); setImportError(""); setImportOpen(true);
}
return (
<PageFrame title={t("gifts.pageTitle")} eyebrow={t("gifts.eyebrow")} actions={<>
<button className="btn" type="button" onClick={() => load()} disabled={busy}><RefreshCw size={15} /> {t("common.refresh")}</button>
<button className="btn primary" type="button" onClick={startImport}><Plus size={15} /> {t("gifts.add")}</button>
</>}>
{error && <Alert>{error}</Alert>}
<div className="metric-row gift-metrics">
<Metric label={t("gifts.total")} value={String(gifts.length)} />
<Metric label={t("gifts.enabled")} value={String(gifts.filter((gift) => gift.Enabled).length)} tone="good" />
<Metric label={t("gifts.received")} value={String(gifts.reduce((sum, gift) => sum + gift.ReceivedCount, 0))} />
<Metric label={t("gifts.formats")} value="TGS / Lottie" />
</div>
<QueryPanel>
<div className="toolbar">
<label className="searchbox"><Search size={15} /><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder={t("gifts.searchPlaceholder")} /></label>
<span className="gift-list-summary">{t("gifts.listSummary", { shown: visibleGifts.length, total: gifts.length })}</span>
</div>
</QueryPanel>
<div className="table-wrap gift-table-wrap">
<table className="data-table gift-table">
<thead><tr><th>{t("gifts.animation")}</th><th>{t("gifts.idRevision")}</th><th>{t("gifts.title")}</th><th>{t("gifts.price")}</th><th>{t("gifts.source")}</th><th>{t("gifts.received")}</th><th>{t("common.status")}</th><th>{t("common.updatedAt")}</th><th>{t("common.actions")}</th></tr></thead>
<tbody>
{visibleGifts.map((gift) => (
<tr className={gift.Enabled ? "" : "gift-row-disabled"} key={gift.GiftID}>
<td><LottiePreview giftID={gift.GiftID} revision={gift.Revision} compact /></td>
<td className="mono">{gift.GiftID} / {gift.Revision}</td>
<td><strong className="gift-table-title">{gift.Title || `Gift #${gift.GiftID}`}</strong><span className="gift-sort-order">{t("gifts.sortOrder")}: {gift.SortOrder}</span></td>
<td><strong className="gift-table-price"> {gift.Stars}</strong><span className="gift-convert-price"> {gift.ConvertStars}</span></td>
<td><Badge>{gift.SourceFormat}</Badge><span className="gift-source-size">{formatBytes(gift.AnimationSize)}</span></td>
<td>{gift.ReceivedCount}</td>
<td><Badge tone={gift.Enabled ? "good" : "neutral"}>{gift.Enabled ? t("common.enabled") : t("common.disabled")}</Badge></td>
<td>{formatDate(gift.UpdatedAt)}</td>
<td><div className="gift-table-actions"><button className="btn compact-btn collectible-button" type="button" onClick={() => setCollectibleGift(gift)}><Gem size={13} />{t("collectibles.manage")}</button><button className="btn compact-btn" type="button" onClick={() => startRevision(gift)}>{t("gifts.replace")}</button><ActionButton compact tone="neutral" label={gift.Enabled ? t("gifts.disable") : t("gifts.enable")} path="/api/actions/set-gift-enabled" payload={() => ({ gift_id: gift.GiftID, enabled: !gift.Enabled })} onDone={() => void load()} /></div></td>
</tr>
))}
{visibleGifts.length === 0 && <EmptyRow colSpan={9} />}
</tbody>
</table>
</div>
{importOpen && createPortal(
<div className="modal-backdrop" role="presentation">
<section className="modal command-modal gift-import-modal" role="dialog" aria-modal="true" aria-label={giftID ? t("gifts.newRevision", { id: giftID }) : t("gifts.importTitle")}>
<div className="modal-head">
<div><div className="eyebrow">{t("gifts.importEyebrow")}</div><h2>{giftID ? t("gifts.newRevision", { id: giftID }) : t("gifts.importTitle")}</h2></div>
<button className="icon-btn" type="button" onClick={() => setImportOpen(false)} disabled={busy} aria-label={t("action.close")}><X size={15} /></button>
</div>
<div className="command-body gift-import-modal-body">
<div className="command-steps">
<div className={`command-step ${file ? "done" : "active"}`}><span>1</span><strong>{t("gifts.stepDetails")}</strong></div>
<div className={`command-step ${preview ? "done" : file ? "active" : ""}`}><span>2</span><strong>{t("gifts.stepValidate")}</strong></div>
<div className={`command-step ${preview ? "active" : ""}`}><span>3</span><strong>{t("gifts.stepImport")}</strong></div>
</div>
<div className="gift-import-note"><span>{t("gifts.importHint")}</span><div className="gift-format-chips" aria-label={t("gifts.formats")}><span>TGS</span><span>Lottie JSON</span></div></div>
<label className={`gift-file-picker ${file ? "has-file" : ""}`}>
<input type="file" accept=".tgs,.json,.lottie,application/json,application/x-tgsticker" onChange={(e) => { setFile(e.target.files?.[0] ?? null); setPreview(null); }} />
<span className="gift-file-icon"><FileJson2 size={22} /></span>
<span className="gift-file-copy"><span className="gift-field-label">{t("gifts.animation")}</span><strong>{file ? file.name : t("gifts.filePrompt")}</strong><small>{file ? formatBytes(file.size) : t("gifts.fileHint")}</small></span>
<span className="gift-file-action">{file ? t("gifts.changeFile") : t("gifts.chooseFile")}</span>
</label>
<div className="gift-fields-grid">
<label><span>{t("gifts.title")}</span><input value={title} maxLength={128} placeholder={t("gifts.titlePlaceholder")} onChange={(e) => { setTitle(e.target.value); setPreview(null); }} /></label>
<label><span>{t("gifts.stars")}</span><input type="number" min="1" value={stars} onChange={(e) => { setStars(e.target.value); setPreview(null); }} /></label>
<label><span>{t("gifts.convertStars")}</span><input type="number" min="0" value={convertStars} onChange={(e) => { setConvertStars(e.target.value); setPreview(null); }} /></label>
<label><span>{t("gifts.sortOrder")}</span><input type="number" value={sortOrder} onChange={(e) => { setSortOrder(e.target.value); setPreview(null); }} /></label>
</div>
<label className="gift-reason-field"><span>{t("gifts.reason")}</span><input value={reason} placeholder={t("gifts.reasonPlaceholder")} onChange={(e) => setReason(e.target.value)} /></label>
<label className="gift-switch"><input type="checkbox" checked={enabled} onChange={(e) => { setEnabled(e.target.checked); setPreview(null); }} /><span className="gift-switch-track" aria-hidden="true"><span /></span><span>{t("gifts.enableAfterImport")}</span></label>
{importError && <Alert>{importError}</Alert>}
{preview && <div className="gift-validation"><div className="gift-validation-head"><CheckCircle2 size={17} /><div><strong>{t("gifts.validationReady")}</strong><span>{t("gifts.validationHint")}</span></div></div><pre>{JSON.stringify(preview.details, null, 2)}</pre></div>}
</div>
<div className="modal-actions">
<button className="btn" type="button" onClick={() => setImportOpen(false)} disabled={busy}>{t("common.close")}</button>
<button className="btn" type="button" onClick={validateImport} disabled={busy}>{busy ? <Loader2 className="spin" size={15} /> : <ShieldCheck size={15} />}{t("gifts.validate")}</button>
<button className="btn primary" type="button" onClick={confirmImport} disabled={busy || !preview}><Upload size={15} />{t("gifts.confirmImport")}</button>
</div>
</section>
</div>,
document.body
)}
{collectibleGift && <GiftCollectiblesModal gift={collectibleGift} onClose={() => setCollectibleGift(null)} onPublished={() => void load()} />}
</PageFrame>
);
}

View file

@ -8,6 +8,7 @@ import { GroupMessageDetailPage } from "./GroupMessageDetailPage";
import { GroupMessagesPage } from "./GroupMessagesPage";
import { MessageDetailPage } from "./MessageDetailPage";
import { MessagesPage } from "./MessagesPage";
import { GiftsPage } from "./GiftsPage";
export function Routes({ route, navigate }: { route: RouteState; navigate: Navigate }) {
const accountID = route.path.match(/^\/accounts\/(\d+)$/)?.[1];
@ -24,6 +25,9 @@ export function Routes({ route, navigate }: { route: RouteState; navigate: Navig
if (route.path === "/channels") {
return <ChannelsPage navigate={navigate} />;
}
if (route.path === "/gifts") {
return <GiftsPage />;
}
if (route.path === "/messages/detail" || route.path === "/messages/private/detail") {
return (
<MessageDetailPage

View file

@ -20,6 +20,7 @@ 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("/messages")) return t("route.messages");
if (pathname.startsWith("/gifts")) return t("route.gifts");
return t("route.dashboard");
}
@ -27,5 +28,6 @@ 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("/messages")) return t("route.messagesSubtitle");
if (pathname.startsWith("/gifts")) return t("route.giftsSubtitle");
return t("route.dashboardSubtitle");
}

View file

@ -252,3 +252,198 @@
border: 1px solid var(--line);
border-radius: 8px;
}
.gift-metrics .metric {
min-height: 68px;
padding: 12px;
background: linear-gradient(145deg, #ffffff, #f6f9f9);
}
.gift-metrics .metric strong { font-size: 17px; }
.gift-file-icon {
display: grid;
flex: 0 0 auto;
place-items: center;
color: var(--brand);
background: #eaf6f3;
border: 1px solid #c7e3dc;
}
.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: #33645d; background: #eef8f5; border: 1px solid #cfe5df; 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; }
.gift-import-modal { width: min(860px, 100%); }
.gift-import-modal-body { gap: 14px; }
.gift-import-note { display: flex; align-items: center; justify-content: space-between; gap: 12px; color: var(--muted); line-height: 1.45; }
.gift-file-picker {
position: relative;
display: grid;
grid-template-columns: 42px minmax(0, 1fr) auto;
min-height: 78px;
align-items: center;
gap: 12px;
padding: 12px 14px;
color: var(--text);
background: #ffffff;
border: 1px dashed #b7ccc8;
border-radius: 10px;
cursor: pointer;
transition: border-color .16s ease, background .16s ease, box-shadow .16s ease;
}
.gift-file-picker:hover,
.gift-file-picker.has-file { background: #f8fcfb; border-color: var(--brand); box-shadow: 0 0 0 2px rgba(23, 109, 97, .05); }
.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-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 small { color: var(--muted); font-size: 11px; font-weight: 500; }
.gift-file-action { padding: 7px 10px; color: var(--brand); background: #f0f8f6; border: 1px solid #c7e3dc; border-radius: 7px; font-size: 11px; font-weight: 800; }
.gift-fields-grid {
display: grid;
grid-template-columns: minmax(200px, 1.5fr) repeat(3, minmax(120px, 1fr));
gap: 10px;
}
.gift-fields-grid label,
.gift-reason-field {
display: grid;
gap: 6px;
color: var(--muted);
font-size: 11px;
font-weight: 700;
}
.gift-fields-grid input,
.gift-reason-field input {
min-width: 0;
height: 38px;
padding: 0 10px;
color: var(--text);
background: #fff;
border: 1px solid var(--line);
border-radius: 7px;
}
.gift-fields-grid input:focus,
.gift-reason-field input:focus { border-color: #77b6aa; box-shadow: 0 0 0 3px rgba(23, 109, 97, .08); outline: none; }
.gift-switch { display: inline-flex; align-items: center; gap: 9px; color: #344054; 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 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(23, 109, 97, .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-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-animation-shell { position: relative; display: grid; min-height: 210px; place-items: center; background: radial-gradient(circle, #f9f3ff, #eef8f5); }
.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-table-wrap { background: #ffffff; }
.gift-table { min-width: 1080px; }
.gift-table th:first-child { width: 74px; }
.gift-table td { vertical-align: middle; }
.gift-animation-shell.compact { width: 56px; min-height: 56px; overflow: hidden; border: 1px solid var(--line); border-radius: 9px; }
.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; }
.gift-table-title,
.gift-sort-order,
.gift-source-size,
.gift-convert-price { display: block; }
.gift-table-title { max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.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-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-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-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 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-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 > 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-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.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 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-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-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 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-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-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)); }
.collectible-row.animated,
.collectible-row.backdrop { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.collectible-inline-preview,
.collectible-backdrop-preview,
.collectible-row .icon-btn { align-self: center; justify-self: start; }
}
@media (max-width: 620px) {
.gift-import-note { align-items: flex-start; flex-direction: column; }
.gift-format-chips { justify-content: flex-start; }
.gift-file-picker { grid-template-columns: 40px minmax(0, 1fr); }
.gift-file-action { display: none; }
.gift-fields-grid { grid-template-columns: 1fr; }
.gift-list-summary { width: 100%; margin-left: 0; }
.collectible-modal-body { padding: 10px; }
.collectible-definition-head,
.collectible-section-head { align-items: flex-start; flex-direction: column; }
.collectible-row.animated,
.collectible-row.backdrop { grid-template-columns: 1fr; }
.collectible-active-grid { grid-template-columns: 1fr 1fr; }
}

View file

@ -160,6 +160,58 @@ export type OutboxRow = {
UpdatedAt: string;
};
export type StarGiftRow = {
GiftID: number;
RevisionID: number;
Revision: number;
Title: string;
Stars: number;
ConvertStars: number;
Enabled: boolean;
SortOrder: number;
DocumentID: number;
SourceName: string;
SourceFormat: "tgs" | "lottie";
AnimationSHA: string;
AnimationSize: number;
Width: number;
Height: number;
FrameRate: number;
ReceivedCount: number;
CreatedBy: string;
UpdatedAt: string;
};
export type StarGiftListResponse = { Gifts: StarGiftRow[] };
export type StarGiftCollectibleAttributeRow = {
id: number;
kind: "model" | "pattern" | "backdrop";
name: string;
rarity_permille: number;
sort_order: number;
source_name?: string;
source_format?: "tgs" | "lottie";
backdrop_id?: number;
center_color?: number;
edge_color?: number;
pattern_color?: number;
text_color?: number;
};
export type StarGiftCollectiblePreview = {
found: boolean;
gift_id: number;
revision?: number;
upgrade_stars?: number;
supply_total?: number;
issued?: number;
slug_prefix?: string;
models?: StarGiftCollectibleAttributeRow[];
patterns?: StarGiftCollectibleAttributeRow[];
backdrops?: StarGiftCollectibleAttributeRow[];
};
export type MessageDetail = {
Message: MessageRow;
MessageJSON: string;