fix(admin): resolve moderation, gift, and admin-panel bugs from QA
- scam/fake: enforce mutual exclusivity (scam precedence) at TL conversion, admin command, and UI toggles; both flags could render as neither - scam/fake flicker: persist scam/fake in the Redis user base cache so cache hits no longer drop the flags - getSavedStarGifts: hidden (unsaved) gifts are now force-excluded for non-owner viewers; same guard for get-by-ref - convertStarGift: map already-upgraded/owner-invalid/unavailable to a clean client error instead of 500 - channel force-settings: send only changed fields (partial patch) and re-sync toggles after reload, so one setting no longer resets another - give gifts: removed custom collectible numbers (auto sequential only); locked sender to the system account 777000 - give gifts UI: fixed picker card spacing/right gap; static (hover-play) Lottie previews on emoji + picker to stop render lag; fixed emoji ID field overflow
This commit is contained in:
parent
313624eab2
commit
90792cdfab
22 changed files with 214 additions and 135 deletions
|
|
@ -1371,7 +1371,6 @@ type giveGiftAPIRequest struct {
|
|||
ModelAttributeID int64 `json:"model_attribute_id,string"`
|
||||
PatternAttributeID int64 `json:"pattern_attribute_id,string"`
|
||||
BackdropAttributeID int64 `json:"backdrop_attribute_id,string"`
|
||||
Num int `json:"num"`
|
||||
}
|
||||
|
||||
func (s *server) handleGiveGiftAPI(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
@ -1391,7 +1390,6 @@ func (s *server) handleGiveGiftAPI(w http.ResponseWriter, r *http.Request) {
|
|||
ModelAttributeID: body.ModelAttributeID,
|
||||
PatternAttributeID: body.PatternAttributeID,
|
||||
BackdropAttributeID: body.BackdropAttributeID,
|
||||
Num: body.Num,
|
||||
}
|
||||
result, err := s.callAdminAPI(r.Context(), "/v1/gifts/give", req)
|
||||
writeCommandResultAPI(w, result, err)
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
9
cmd/telesrv-admin/web/dist/assets/index-C0WDPjsF.js
vendored
Normal file
9
cmd/telesrv-admin/web/dist/assets/index-C0WDPjsF.js
vendored
Normal file
File diff suppressed because one or more lines are too long
4
cmd/telesrv-admin/web/dist/index.html
vendored
4
cmd/telesrv-admin/web/dist/index.html
vendored
|
|
@ -21,8 +21,8 @@
|
|||
}
|
||||
})();
|
||||
</script>
|
||||
<script type="module" crossorigin src="/assets/index-Bx7A77x9.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-XV_IEG5m.css">
|
||||
<script type="module" crossorigin src="/assets/index-C0WDPjsF.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BwxAoLbQ.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
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} />;
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { AtSign, LifeBuoy, Palette, Settings2, Smile } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { ActionButton } from "./ActionButton";
|
||||
import { useI18n } from "../i18n";
|
||||
import { toInt } from "../lib/format";
|
||||
|
|
@ -134,6 +134,33 @@ export function ChannelSettingsAction({ channel, onDone }: { channel: ChannelRow
|
|||
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>
|
||||
|
|
@ -151,16 +178,7 @@ export function ChannelSettingsAction({ channel, onDone }: { channel: ChannelRow
|
|||
icon={<Settings2 size={15} />}
|
||||
tone="warn"
|
||||
path="/api/actions/set-channel-settings"
|
||||
payload={() => ({
|
||||
channel_id: channel.ID,
|
||||
gigagroup,
|
||||
antispam,
|
||||
participants_hidden: hidden,
|
||||
noforwards,
|
||||
join_to_send: joinToSend,
|
||||
join_request: joinRequest,
|
||||
slowmode_seconds: toInt(slowmode)
|
||||
})}
|
||||
payload={buildPatch}
|
||||
onDone={onDone}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -17,9 +17,9 @@ export function ScamFakeBadges({ scam, fake }: { scam: boolean; fake: boolean })
|
|||
);
|
||||
}
|
||||
|
||||
// ScamFakeActions renders the two independent toggles. The combined setter
|
||||
// receives the full desired state, so each button flips one flag and keeps the
|
||||
// other unchanged.
|
||||
// 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,
|
||||
|
|
@ -43,7 +43,7 @@ export function ScamFakeActions({
|
|||
icon={<ShieldAlert size={15} />}
|
||||
tone="danger"
|
||||
path={path}
|
||||
payload={() => ({ [idKey]: id, scam: !scam, fake })}
|
||||
payload={() => ({ [idKey]: id, scam: !scam, fake: !scam ? false : fake })}
|
||||
onDone={onDone}
|
||||
/>
|
||||
<ActionButton
|
||||
|
|
@ -51,7 +51,7 @@ export function ScamFakeActions({
|
|||
icon={<ShieldX size={15} />}
|
||||
tone="danger"
|
||||
path={path}
|
||||
payload={() => ({ [idKey]: id, scam, fake: !fake })}
|
||||
payload={() => ({ [idKey]: id, fake: !fake, scam: !fake ? false : scam })}
|
||||
onDone={onDone}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -337,17 +337,15 @@ const translations: Record<Language, Record<string, string>> = {
|
|||
"giveGift.pickChannel": "Recipient channel",
|
||||
"giveGift.recipientRequired": "Select a recipient first",
|
||||
"giveGift.sender": "Sender account ID",
|
||||
"giveGift.senderHint": "Defaults to the system account 777000 (Telesrv).",
|
||||
"giveGift.senderHint": "Gifts are always sent from the system account 777000 (Telesrv).",
|
||||
"giveGift.message": "Attached message (optional)",
|
||||
"giveGift.messagePlaceholder": "Shown with the gift",
|
||||
"giveGift.hideName": "Hide sender name from recipient",
|
||||
"giveGift.upgrade": "Deliver as upgraded collectible",
|
||||
"giveGift.upgradeNote": "The gift is minted as a unique collectible. Pick specific attributes and a number below, or leave them on Random to draw from the published pool. Requires a published collectible upgrade with remaining supply.",
|
||||
"giveGift.upgradeNote": "The gift is minted as a unique collectible. Pick specific attributes below, or leave them on Random to draw from the published pool. The collectible number is assigned automatically. Requires a published collectible upgrade with remaining supply.",
|
||||
"giveGift.model": "Model",
|
||||
"giveGift.pattern": "Pattern",
|
||||
"giveGift.backdrop": "Backdrop",
|
||||
"giveGift.number": "Number",
|
||||
"giveGift.numberAuto": "Auto",
|
||||
"giveGift.random": "Random",
|
||||
"giveGift.confirm": "Give gift",
|
||||
"giveGifts.pageTitle": "Give Gifts",
|
||||
|
|
@ -821,17 +819,15 @@ const translations: Record<Language, Record<string, string>> = {
|
|||
"giveGift.pickChannel": "接收频道",
|
||||
"giveGift.recipientRequired": "请先选择接收方",
|
||||
"giveGift.sender": "发送方账号 ID",
|
||||
"giveGift.senderHint": "默认使用系统账号 777000(Telesrv)。",
|
||||
"giveGift.senderHint": "礼物始终由系统账号 777000(Telesrv)发送。",
|
||||
"giveGift.message": "附加留言(可选)",
|
||||
"giveGift.messagePlaceholder": "随礼物一起显示",
|
||||
"giveGift.hideName": "对接收方隐藏发送方名称",
|
||||
"giveGift.upgrade": "作为升级收藏品发放",
|
||||
"giveGift.upgradeNote": "礼物将铸造为唯一收藏品。可在下方指定具体属性和编号,或保持“随机”从已发布的属性池中抽取。需要存在有剩余供应量的已发布收藏品升级。",
|
||||
"giveGift.upgradeNote": "礼物将铸造为唯一收藏品。可在下方指定具体属性,或保持“随机”从已发布的属性池中抽取。编号自动分配。需要存在有剩余供应量的已发布收藏品升级。",
|
||||
"giveGift.model": "模型",
|
||||
"giveGift.pattern": "图案",
|
||||
"giveGift.backdrop": "背景",
|
||||
"giveGift.number": "编号",
|
||||
"giveGift.numberAuto": "自动",
|
||||
"giveGift.random": "随机",
|
||||
"giveGift.confirm": "赠送礼物",
|
||||
"giveGifts.pageTitle": "赠送礼物",
|
||||
|
|
@ -1305,17 +1301,15 @@ const translations: Record<Language, Record<string, string>> = {
|
|||
"giveGift.pickChannel": "Получатель (канал)",
|
||||
"giveGift.recipientRequired": "Сначала выберите получателя",
|
||||
"giveGift.sender": "ID аккаунта-отправителя",
|
||||
"giveGift.senderHint": "По умолчанию системный аккаунт 777000 (Telesrv).",
|
||||
"giveGift.senderHint": "Подарки всегда отправляются от системного аккаунта 777000 (Telesrv).",
|
||||
"giveGift.message": "Сообщение к подарку (необязательно)",
|
||||
"giveGift.messagePlaceholder": "Показывается вместе с подарком",
|
||||
"giveGift.hideName": "Скрыть имя отправителя от получателя",
|
||||
"giveGift.upgrade": "Выдать как улучшенный коллекционный",
|
||||
"giveGift.upgradeNote": "Подарок будет отчеканен как уникальный коллекционный. Ниже можно выбрать конкретные атрибуты и номер или оставить «Случайно» для выбора из опубликованного пула. Требуется опубликованное коллекционное улучшение с остатком тиража.",
|
||||
"giveGift.upgradeNote": "Подарок будет отчеканен как уникальный коллекционный. Ниже можно выбрать конкретные атрибуты или оставить «Случайно» для выбора из опубликованного пула. Номер присваивается автоматически. Требуется опубликованное коллекционное улучшение с остатком тиража.",
|
||||
"giveGift.model": "Модель",
|
||||
"giveGift.pattern": "Узор",
|
||||
"giveGift.backdrop": "Фон",
|
||||
"giveGift.number": "Номер",
|
||||
"giveGift.numberAuto": "Авто",
|
||||
"giveGift.random": "Случайно",
|
||||
"giveGift.confirm": "Выдать подарок",
|
||||
"giveGifts.pageTitle": "Выдача подарков",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import lottie from "lottie-web/build/player/lottie_light_canvas";
|
||||
import { Check, ChevronRight, Copy, Loader2, RefreshCw, Search } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "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";
|
||||
|
|
@ -18,38 +18,25 @@ function isAnimated(mime: string): boolean {
|
|||
}
|
||||
|
||||
function EmojiPreview({ row }: { row: EmojiRow }) {
|
||||
const host = useRef<HTMLDivElement>(null);
|
||||
const animation = useRef<ReturnType<typeof lottie.loadAnimation> | null>(null);
|
||||
const [failed, setFailed] = useState(!isAnimated(row.MimeType));
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAnimated(row.MimeType)) {
|
||||
setFailed(true);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
api.emojiAnimation(row.DocumentID).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(() => setFailed(true));
|
||||
return () => {
|
||||
cancelled = true;
|
||||
animation.current?.destroy();
|
||||
animation.current = null;
|
||||
};
|
||||
setFailed(!isAnimated(row.MimeType));
|
||||
}, [row.DocumentID, row.MimeType]);
|
||||
|
||||
if (failed) {
|
||||
return <div className="emoji-glyph">{row.Alt || "🙂"}</div>;
|
||||
}
|
||||
return <div className="emoji-anim" ref={host} />;
|
||||
// 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 }) {
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ export function GiveGiftForm({ gift, onDone }: { gift: StarGiftRow; onDone?: ()
|
|||
const [kind, setKind] = useState<RecipientKind>("user");
|
||||
const [user, setUser] = useState<AccountRow | null>(null);
|
||||
const [channel, setChannel] = useState<ChannelRow | null>(null);
|
||||
const [sender, setSender] = useState(SYSTEM_SENDER);
|
||||
const [message, setMessage] = useState("");
|
||||
const [hideName, setHideName] = useState(false);
|
||||
const [upgrade, setUpgrade] = useState(false);
|
||||
|
|
@ -29,7 +28,6 @@ export function GiveGiftForm({ gift, onDone }: { gift: StarGiftRow; onDone?: ()
|
|||
const [modelID, setModelID] = useState("0");
|
||||
const [patternID, setPatternID] = useState("0");
|
||||
const [backdropID, setBackdropID] = useState("0");
|
||||
const [num, setNum] = useState("");
|
||||
const [reason, setReason] = useState("");
|
||||
const [result, setResult] = useState<CommandResult | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
|
|
@ -47,7 +45,6 @@ export function GiveGiftForm({ gift, onDone }: { gift: StarGiftRow; onDone?: ()
|
|||
setModelID("0");
|
||||
setPatternID("0");
|
||||
setBackdropID("0");
|
||||
setNum("");
|
||||
setResult(null);
|
||||
setError("");
|
||||
}, [gift.GiftID]);
|
||||
|
|
@ -63,11 +60,10 @@ export function GiveGiftForm({ gift, onDone }: { gift: StarGiftRow; onDone?: ()
|
|||
}, [upgradable, preview, gift.GiftID]);
|
||||
|
||||
function buildPayload(confirm: boolean): Record<string, unknown> {
|
||||
const senderID = Number.parseInt(sender.trim() || SYSTEM_SENDER, 10);
|
||||
const parsedNum = Number.parseInt(num.trim(), 10);
|
||||
return {
|
||||
gift_id: gift.GiftID,
|
||||
sender_user_id: Number.isFinite(senderID) ? senderID : 0,
|
||||
// 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,
|
||||
|
|
@ -76,13 +72,12 @@ export function GiveGiftForm({ gift, onDone }: { gift: StarGiftRow; onDone?: ()
|
|||
model_attribute_id: upgradable ? modelID : "0",
|
||||
pattern_attribute_id: upgradable ? patternID : "0",
|
||||
backdrop_attribute_id: upgradable ? backdropID : "0",
|
||||
num: upgradable && Number.isFinite(parsedNum) && parsedNum > 0 ? parsedNum : 0,
|
||||
reason: reason.trim(),
|
||||
confirm
|
||||
};
|
||||
}
|
||||
|
||||
const previewPayload = useMemo(() => buildPayload(false), [gift.GiftID, kind, recipientID, sender, message, hideName, upgrade, modelID, patternID, backdropID, num, reason]);
|
||||
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) {
|
||||
|
|
@ -134,7 +129,7 @@ export function GiveGiftForm({ gift, onDone }: { gift: StarGiftRow; onDone?: ()
|
|||
|
||||
<label className="form-field">
|
||||
<span>{t("giveGift.sender")}</span>
|
||||
<input value={sender} inputMode="numeric" onChange={(event) => { setSender(event.target.value.replace(/[^0-9]/g, "")); setResult(null); }} placeholder={SYSTEM_SENDER} />
|
||||
<input value={SYSTEM_SENDER} disabled readOnly />
|
||||
<small className="field-hint">{t("giveGift.senderHint")}</small>
|
||||
</label>
|
||||
|
||||
|
|
@ -152,7 +147,7 @@ export function GiveGiftForm({ gift, onDone }: { gift: StarGiftRow; onDone?: ()
|
|||
{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"); setNum(""); } setResult(null); }} />
|
||||
<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>
|
||||
|
|
@ -181,10 +176,6 @@ export function GiveGiftForm({ gift, onDone }: { gift: StarGiftRow; onDone?: ()
|
|||
{(preview.backdrops ?? []).map((attr) => <option key={attr.id} value={attr.id}>{attrLabel(attr)}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>{t("giveGift.number")}</span>
|
||||
<input type="number" min="1" max={preview.supply_total ?? undefined} value={num} placeholder={t("giveGift.numberAuto")} onChange={(event) => { setNum(event.target.value.replace(/[^0-9]/g, "")); setResult(null); }} />
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
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 { LottiePreview } from "./GiftsPage";
|
||||
import { GiveGiftForm } from "./GiveGiftForm";
|
||||
|
||||
export function GiveGiftsPage() {
|
||||
|
|
@ -58,13 +58,13 @@ export function GiveGiftsPage() {
|
|||
<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)}>
|
||||
<LottiePreview giftID={gift.GiftID} revision={gift.Revision} compact />
|
||||
<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 className="give-gift-option-meta">
|
||||
{gift.Enabled ? <Badge>⭐ {gift.Stars}</Badge> : <Badge tone="neutral">{t("common.disabled")}</Badge>}
|
||||
</span>
|
||||
</span>
|
||||
<span className="give-gift-option-price">
|
||||
{gift.Enabled ? <Badge>⭐ {gift.Stars}</Badge> : <Badge tone="neutral">{t("common.disabled")}</Badge>}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -297,7 +297,7 @@
|
|||
|
||||
/* 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(4, minmax(0, 1fr)); align-items: end; }
|
||||
.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;
|
||||
|
|
@ -314,27 +314,28 @@
|
|||
.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(300px, 380px) minmax(0, 1fr); gap: 16px; align-items: start; }
|
||||
.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: auto;
|
||||
background: var(--panel-subtle); border: 1px solid var(--line); border-radius: var(--radius-lg); scrollbar-gutter: stable;
|
||||
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: 56px minmax(0, 1fr); gap: 11px; align-items: center; min-width: 0;
|
||||
padding: 9px; text-align: left; color: var(--text); background: var(--panel);
|
||||
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-option .gift-animation-shell.compact { pointer-events: none; }
|
||||
.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-meta { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 2px; }
|
||||
.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);
|
||||
|
|
@ -645,7 +646,9 @@
|
|||
}
|
||||
|
||||
.emoji-id {
|
||||
display: inline-flex;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
|
|
@ -659,11 +662,17 @@
|
|||
}
|
||||
|
||||
.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);
|
||||
|
|
|
|||
|
|
@ -117,6 +117,12 @@
|
|||
font-weight: 800;
|
||||
}
|
||||
|
||||
.form-field input:disabled,
|
||||
.form-field textarea:disabled {
|
||||
opacity: .6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.command-preview {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue