added default gifts import back

This commit is contained in:
onysd 2026-07-23 23:03:51 +03:00
parent 66e849d980
commit c436c7c773
17 changed files with 765 additions and 40 deletions

View file

@ -61,6 +61,8 @@ func (s *server) routes() http.Handler {
mux.Handle("GET /api/gifts", s.requireAuthAPI(http.HandlerFunc(s.handleStarGiftsAPI)))
mux.Handle("GET /api/default-gifts", s.requireAuthAPI(http.HandlerFunc(s.handleDefaultStarGiftsAPI)))
mux.Handle("GET /api/default-gifts/{id}/animation", s.requireAuthAPI(http.HandlerFunc(s.handleDefaultStarGiftAnimationAPI)))
mux.Handle("GET /api/official-gifts", s.requireAuthAPI(http.HandlerFunc(s.handleOfficialStarGiftsAPI)))
mux.Handle("GET /api/official-gifts/{id}/animation", s.requireAuthAPI(http.HandlerFunc(s.handleOfficialStarGiftAnimationAPI)))
mux.Handle("GET /api/gifts/{id}/animation", s.requireAuthAPI(http.HandlerFunc(s.handleStarGiftAnimationAPI)))
mux.Handle("GET /api/gifts/{id}/collectibles", s.requireAuthAPI(http.HandlerFunc(s.handleStarGiftCollectiblesAPI)))
mux.Handle("GET /api/gifts/{id}/collectibles/{kind}/{attribute_id}/animation", s.requireAuthAPI(http.HandlerFunc(s.handleStarGiftCollectibleAnimationAPI)))
@ -75,6 +77,8 @@ func (s *server) routes() http.Handler {
mux.Handle("POST /api/actions/import-gift", s.requireAuthAPI(http.HandlerFunc(s.handleImportStarGiftAPI)))
mux.Handle("POST /api/actions/import-default-gift", s.requireAuthAPI(http.HandlerFunc(s.handleImportDefaultStarGiftAPI)))
mux.Handle("POST /api/actions/import-all-default-gifts", s.requireAuthAPI(http.HandlerFunc(s.handleImportAllDefaultStarGiftsAPI)))
mux.Handle("POST /api/actions/import-official-gift", s.requireAuthAPI(http.HandlerFunc(s.handleImportOfficialStarGiftAPI)))
mux.Handle("POST /api/actions/import-all-official-gifts", s.requireAuthAPI(http.HandlerFunc(s.handleImportAllOfficialStarGiftsAPI)))
mux.Handle("POST /api/actions/publish-gift-collectibles", s.requireAuthAPI(http.HandlerFunc(s.handlePublishStarGiftCollectiblesAPI)))
mux.Handle("POST /api/actions/set-gift-enabled", s.requireAuthAPI(http.HandlerFunc(s.handleSetStarGiftEnabledAPI)))
mux.Handle("POST /api/actions/set-gift-sort-order", s.requireAuthAPI(http.HandlerFunc(s.handleSetStarGiftSortOrderAPI)))
@ -256,6 +260,19 @@ func (s *server) handleDefaultStarGiftAnimationAPI(w http.ResponseWriter, r *htt
s.proxyAdminJSON(w, r, "/v1/default-gifts/"+id+"/animation", 4<<20)
}
func (s *server) handleOfficialStarGiftsAPI(w http.ResponseWriter, r *http.Request) {
s.proxyAdminJSON(w, r, "/v1/official-gifts", 4<<20)
}
func (s *server) handleOfficialStarGiftAnimationAPI(w http.ResponseWriter, r *http.Request) {
id := strings.TrimSpace(r.PathValue("id"))
if _, err := strconv.ParseInt(id, 10, 64); err != nil {
writeAPIError(w, http.StatusBadRequest, "invalid official gift id")
return
}
s.proxyAdminJSON(w, r, "/v1/official-gifts/"+id+"/animation", 4<<20)
}
func (s *server) handleStarGiftCollectibleAnimationAPI(w http.ResponseWriter, r *http.Request) {
giftID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
attributeID, attrErr := strconv.ParseInt(r.PathValue("attribute_id"), 10, 64)
@ -857,6 +874,61 @@ func (s *server) handleImportAllDefaultStarGiftsAPI(w http.ResponseWriter, r *ht
writeCommandResultAPI(w, result, err)
}
type importOfficialStarGiftAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
SourceGiftID string `json:"source_gift_id"`
GiftID int64 `json:"gift_id,string"`
Title string `json:"title"`
Stars int64 `json:"stars,string"`
ConvertStars int64 `json:"convert_stars,string"`
Enabled bool `json:"enabled"`
SortOrder int `json:"sort_order"`
IncludeCollectible bool `json:"include_collectible"`
UpgradeStars int64 `json:"upgrade_stars,string"`
SupplyTotal int `json:"supply_total"`
SlugPrefix string `json:"slug_prefix"`
}
func (s *server) handleImportOfficialStarGiftAPI(w http.ResponseWriter, r *http.Request) {
var body importOfficialStarGiftAPIRequest
if !decodeAction(w, r, &body) {
return
}
if _, err := strconv.ParseInt(strings.TrimSpace(body.SourceGiftID), 10, 64); err != nil {
writeAPIError(w, http.StatusBadRequest, "invalid official gift id")
return
}
req := admin.ImportOfficialStarGiftRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "import-official-gift"),
SourceGiftID: body.SourceGiftID, GiftID: body.GiftID, Title: body.Title,
Stars: body.Stars, ConvertStars: body.ConvertStars, Enabled: body.Enabled, SortOrder: body.SortOrder,
IncludeCollectible: body.IncludeCollectible, UpgradeStars: body.UpgradeStars,
SupplyTotal: body.SupplyTotal, SlugPrefix: body.SlugPrefix,
}
result, err := s.callAdminAPI(r.Context(), "/v1/official-gifts/import", req)
writeCommandResultAPI(w, result, err)
}
type importAllOfficialStarGiftsAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
}
func (s *server) handleImportAllOfficialStarGiftsAPI(w http.ResponseWriter, r *http.Request) {
var body importAllOfficialStarGiftsAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.ImportAllOfficialStarGiftsRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "import-all-official-gifts"),
}
result, err := s.callAdminAPI(r.Context(), "/v1/official-gifts/import-all", req)
writeCommandResultAPI(w, result, err)
}
type publishStarGiftCollectiblesAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`

View file

@ -121,6 +121,24 @@ func TestDefaultGiftImportActionDecodes(t *testing.T) {
}
}
func TestOfficialGiftActionDecimalStringDecodingPreservesInt64(t *testing.T) {
const maxInt64 = int64(9223372036854775807)
req := httptest.NewRequest(http.MethodPost, "/api/actions/import-official-gift", strings.NewReader(`{
"source_gift_id":"5895603153683874485",
"gift_id":"9223372036854775807",
"stars":"9223372036854775807",
"convert_stars":"9223372036854775807",
"upgrade_stars":"9223372036854775807"
}`))
var got importOfficialStarGiftAPIRequest
if err := decodeJSON(req, &got); err != nil {
t.Fatalf("decode official gift action: %v", err)
}
if got.GiftID != maxInt64 || got.Stars != maxInt64 || got.ConvertStars != maxInt64 || got.UpgradeStars != maxInt64 {
t.Fatalf("decoded official gift action = %+v", got)
}
}
func TestSetStarGiftEnabledBFFForwardsExactInt64(t *testing.T) {
const maxInt64 = int64(9223372036854775807)
var got admin.SetStarGiftEnabledRequest

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/png" href="/logo.png" />
<title>OwpenGram Admin</title>
<script type="module" crossorigin src="/assets/index-CO25dkAS.js"></script>
<script type="module" crossorigin src="/assets/index-6aaLOsAb.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D2rrhA4q.css">
</head>
<body>

View file

@ -9,6 +9,7 @@ import type {
MessageDetail,
MessageListResponse,
DefaultGiftListResponse,
OfficialStarGiftListResponse,
StarGiftCollectiblePreview,
StarGiftListResponse,
StickerSetListResponse
@ -75,11 +76,14 @@ export const api = {
addStickerToSet: (form: FormData) => request<CommandResult>("/api/actions/add-sticker-to-set", { method: "POST", body: form }),
defaultGifts: () => request<DefaultGiftListResponse>("/api/default-gifts"),
defaultGiftAnimation: (id: number) => request<Record<string, unknown>>(`/api/default-gifts/${id}/animation`),
officialGifts: () => request<OfficialStarGiftListResponse>("/api/official-gifts"),
officialGiftAnimation: (id: string) => request<Record<string, unknown>>(`/api/official-gifts/${encodeURIComponent(id)}/animation`),
giftAnimation: (id: string) => request<Record<string, unknown>>(`/api/gifts/${encodeURIComponent(id)}/animation`),
giftCollectibles: (id: string) => request<StarGiftCollectiblePreview>(`/api/gifts/${encodeURIComponent(id)}/collectibles`),
giftCollectibleAnimation: (giftID: string, kind: "model" | "pattern", attributeID: string) => request<Record<string, unknown>>(`/api/gifts/${encodeURIComponent(giftID)}/collectibles/${kind}/${encodeURIComponent(attributeID)}/animation`),
importGift: (form: FormData) => request<CommandResult>("/api/actions/import-gift", { method: "POST", body: form }),
importDefaultGift: (payload: Record<string, unknown>) => request<CommandResult>("/api/actions/import-default-gift", { method: "POST", body: JSON.stringify(payload) }),
importOfficialGift: (payload: Record<string, unknown>) => request<CommandResult>("/api/actions/import-official-gift", { method: "POST", body: JSON.stringify(payload) }),
publishGiftCollectibles: (giftID: string, form: FormData) => request<CommandResult>(`/api/actions/publish-gift-collectibles?gift_id=${encodeURIComponent(giftID)}`, { method: "POST", body: form }),
action: (path: string, payload: Record<string, unknown>) => request<CommandResult>(path, {
method: "POST",

View file

@ -258,6 +258,21 @@ const translations: Record<string, string> = {
"gifts.defaultSelect": "Choose a default gift",
"gifts.defaultRequired": "Choose a default gift first",
"gifts.defaultEmpty": "No default gifts are available.",
"gifts.importAllOfficial": "Import all official gifts",
"gifts.officialSource": "Official snapshot",
"gifts.officialHint": "Choose a verified gift from data/official-gifts. Complete collectible pools are imported atomically.",
"gifts.officialSearch": "Search official gift ID or title",
"gifts.officialSelect": "Choose an official gift",
"gifts.officialRequired": "Choose an official gift first",
"gifts.officialResults": "Showing {shown} of {total}",
"gifts.officialCategoryLabel": "Official gift capability category",
"gifts.officialCategory.all": "All",
"gifts.officialCategory.upgrade": "Upgradable",
"gifts.officialCategory.craft": "Craftable",
"gifts.officialCategory.basic": "Not upgradable",
"gifts.officialUnnamed": "Unnamed official gift #{id}",
"gifts.officialEmpty": "No official gifts match this category and search.",
"gifts.includeCollectible": "Import the complete collectible pool, including crafted models",
"gifts.limited": "Limited · {total}",
"gifts.premium": "Premium only",
"gifts.searchPlaceholder": "Search gift ID, title or format",

View file

@ -7,15 +7,20 @@ 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, DefaultGiftRow, StarGiftRow } from "../types";
import type { CommandResult, DefaultGiftRow, OfficialStarGiftRow, StarGiftRow } from "../types";
import { GiftCollectiblesModal } from "./GiftCollectiblesModal";
type OfficialGiftCategory = "all" | "upgrade" | "craft" | "basic";
type GiftPageSize = 10 | 20 | 50 | 100 | "all";
function defaultGiftAttributeCount(gift: DefaultGiftRow) {
return gift.model_count + gift.pattern_count + gift.backdrop_count;
}
function officialGiftAttributeCount(gift: OfficialStarGiftRow) {
return gift.model_count + gift.pattern_count + gift.backdrop_count;
}
function formatBytes(value: number | string) {
const bytes = Number(value);
if (bytes < 1024) return `${bytes} B`;
@ -80,6 +85,20 @@ function DefaultLottiePreview({ id }: { id: number }) {
return <div className="gift-animation-shell"><div className="gift-animation" ref={host} /></div>;
}
function OfficialLottiePreview({ sourceGiftID }: { sourceGiftID: string }) {
const host = useRef<HTMLDivElement>(null);
useEffect(() => {
let cancelled = false;
let player: ReturnType<typeof lottie.loadAnimation> | null = null;
api.officialGiftAnimation(sourceGiftID).then((data) => {
if (cancelled || !host.current) return;
player = lottie.loadAnimation({ container: host.current, renderer: "canvas", loop: true, autoplay: true, animationData: structuredClone(data) });
}).catch(() => undefined);
return () => { cancelled = true; player?.destroy(); };
}, [sourceGiftID]);
return <div className="gift-animation-shell"><div className="gift-animation" ref={host} /></div>;
}
export function GiftsPage() {
const { t } = useI18n();
const [gifts, setGifts] = useState<StarGiftRow[]>([]);
@ -87,9 +106,17 @@ export function GiftsPage() {
const [importOpen, setImportOpen] = useState(false);
const [collectibleGift, setCollectibleGift] = useState<StarGiftRow | null>(null);
const [file, setFile] = useState<File | null>(null);
const [importSource, setImportSource] = useState<"default" | "file">("default");
const [importSource, setImportSource] = useState<"default" | "official" | "file">("default");
const [defaultGifts, setDefaultGifts] = useState<DefaultGiftRow[]>([]);
const [selectedDefaultID, setSelectedDefaultID] = useState(0);
const [officialGifts, setOfficialGifts] = useState<OfficialStarGiftRow[]>([]);
const [officialQuery, setOfficialQuery] = useState("");
const [officialCategory, setOfficialCategory] = useState<OfficialGiftCategory>("all");
const [sourceGiftID, setSourceGiftID] = useState("");
const [includeCollectible, setIncludeCollectible] = useState(true);
const [upgradeStars, setUpgradeStars] = useState("0");
const [supplyTotal, setSupplyTotal] = useState("0");
const [slugPrefix, setSlugPrefix] = useState("");
const [giftID, setGiftID] = useState("0");
const [title, setTitle] = useState("");
const [stars, setStars] = useState("50");
@ -124,7 +151,29 @@ export function GiftsPage() {
api.defaultGifts().then((value) => setDefaultGifts(value.gifts ?? [])).catch((err) => setImportError(errorMessage(err)));
}, [importOpen, importSource, defaultGifts.length]);
useEffect(() => {
if (!importOpen || importSource !== "official" || officialGifts.length > 0) return;
api.officialGifts().then((value) => setOfficialGifts(value.gifts ?? [])).catch((err) => setImportError(errorMessage(err)));
}, [importOpen, importSource, officialGifts.length]);
const selectedDefault = useMemo(() => defaultGifts.find((gift) => gift.id === selectedDefaultID) ?? null, [defaultGifts, selectedDefaultID]);
const selectedOfficial = useMemo(() => officialGifts.find((gift) => gift.source_gift_id === sourceGiftID) ?? null, [officialGifts, sourceGiftID]);
const officialCategoryCounts = useMemo(() => ({
all: officialGifts.length,
upgrade: officialGifts.filter((gift) => gift.can_upgrade).length,
craft: officialGifts.filter((gift) => gift.can_craft).length,
basic: officialGifts.filter((gift) => !gift.can_upgrade).length
}), [officialGifts]);
const visibleOfficial = useMemo(() => {
const normalized = officialQuery.trim().toLowerCase();
return officialGifts.filter((gift) => {
const categoryMatches = officialCategory === "all" ||
(officialCategory === "upgrade" && gift.can_upgrade) ||
(officialCategory === "craft" && gift.can_craft) ||
(officialCategory === "basic" && !gift.can_upgrade);
return categoryMatches && (!normalized || gift.source_gift_id.includes(normalized) || gift.title.toLowerCase().includes(normalized));
});
}, [officialGifts, officialQuery, officialCategory]);
const visibleGifts = useMemo(() => {
const normalized = query.trim().toLowerCase();
@ -228,10 +277,37 @@ export function GiftsPage() {
return { command_id: commandID, reason: reason.trim(), confirm, id: selectedDefaultID };
}
function officialPayload(confirm: boolean, commandID = "") {
if (!sourceGiftID) throw new Error(t("gifts.officialRequired"));
if (!reason.trim()) throw new Error(t("action.reasonRequired"));
return {
command_id: commandID, reason: reason.trim(), confirm,
source_gift_id: sourceGiftID, gift_id: giftID, title: title.trim(),
stars, convert_stars: convertStars, enabled, sort_order: Number(sortOrder),
include_collectible: includeCollectible, upgrade_stars: upgradeStars,
supply_total: Number(supplyTotal), slug_prefix: slugPrefix.trim().toLowerCase()
};
}
function chooseOfficial(gift: OfficialStarGiftRow) {
setSourceGiftID(gift.source_gift_id);
setTitle(gift.title || t("gifts.officialUnnamed", { id: gift.source_gift_id }));
setStars(String(gift.stars));
setConvertStars(String(gift.convert_stars));
setIncludeCollectible(gift.can_upgrade);
setUpgradeStars(gift.upgrade_stars);
setSupplyTotal(String(gift.availability_total || 1));
setSlugPrefix(`official-${gift.source_gift_id}`);
setPreview(null);
}
async function validateImport() {
setBusy(true); setImportError(""); setPreview(null);
try {
setPreview(importSource === "default" ? await api.importDefaultGift(defaultPayload(false)) : await api.importGift(uploadForm(false)));
const result = importSource === "default" ? await api.importDefaultGift(defaultPayload(false))
: importSource === "official" ? await api.importOfficialGift(officialPayload(false))
: await api.importGift(uploadForm(false));
setPreview(result);
} catch (err) {
setImportError(errorMessage(err));
} finally { setBusy(false); }
@ -242,8 +318,9 @@ export function GiftsPage() {
setBusy(true); setImportError("");
try {
if (importSource === "default") await api.importDefaultGift(defaultPayload(true, preview.command_id));
else if (importSource === "official") await api.importOfficialGift(officialPayload(true, preview.command_id));
else await api.importGift(uploadForm(true, preview.command_id));
setPreview(null); setFile(null); setGiftID("0"); setTitle(""); setSelectedDefaultID(0);
setPreview(null); setFile(null); setGiftID("0"); setTitle(""); setSelectedDefaultID(0); setSourceGiftID("");
await load();
setImportOpen(false);
} catch (err) {
@ -254,17 +331,20 @@ export function GiftsPage() {
function startImport() {
setGiftID("0"); setTitle(""); setStars("50"); setConvertStars("50"); setSortOrder("0");
setEnabled(true); setReason(""); setFile(null); setPreview(null); setImportError("");
setImportSource("default"); setSelectedDefaultID(0); setImportOpen(true);
setImportSource("default"); setSelectedDefaultID(0);
setSourceGiftID(""); setOfficialQuery(""); setOfficialCategory("all"); 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("");
setImportSource("file"); setSelectedDefaultID(0); setImportOpen(true);
setImportSource("file"); setSelectedDefaultID(0); setSourceGiftID(""); setImportOpen(true);
}
const step1Done = importSource === "default" ? selectedDefaultID > 0 : Boolean(file);
const step1Done = importSource === "default" ? selectedDefaultID > 0
: importSource === "official" ? Boolean(sourceGiftID)
: Boolean(file);
return (
<PageFrame title={t("gifts.pageTitle")} eyebrow={t("gifts.eyebrow")} actions={<>
@ -355,6 +435,7 @@ export function GiftsPage() {
</div>
{giftID === "0" && <div className="gift-source-tabs">
<button className={`btn ${importSource === "default" ? "primary" : ""}`} type="button" onClick={() => { setImportSource("default"); setPreview(null); }}>{t("gifts.defaultSource")}</button>
<button className={`btn ${importSource === "official" ? "primary" : ""}`} type="button" onClick={() => { setImportSource("official"); setPreview(null); }}>{t("gifts.officialSource")}</button>
<button className={`btn ${importSource === "file" ? "primary" : ""}`} type="button" onClick={() => { setImportSource("file"); setPreview(null); }}>{t("gifts.fileSource")}</button>
</div>}
{importSource === "default" && giftID === "0" ? <section className="official-gift-picker">
@ -395,6 +476,70 @@ export function GiftsPage() {
<DefaultLottiePreview id={selectedDefault.id} />
<div><strong>{selectedDefault.title}</strong><span className="mono"> {selectedDefault.stars} {selectedDefault.convert_stars}</span><small>{selectedDefault.model_count} {t("collectibles.models")} · {selectedDefault.pattern_count} {t("collectibles.patterns")} · {selectedDefault.backdrop_count} {t("collectibles.backdrops")}</small><span className="official-gift-capabilities"><span className={selectedDefault.upgradeable ? "yes" : "no"}>{selectedDefault.upgradeable ? t("gifts.canUpgrade") : t("gifts.cannotUpgrade")}</span><span className={selectedDefault.craftable ? "craft" : "no"}>{selectedDefault.craftable ? t("gifts.canCraft") : t("gifts.cannotCraft")}</span></span></div>
</div>}
</section> : importSource === "official" && giftID === "0" ? <section className="official-gift-picker">
<div className="gift-import-note"><span>{t("gifts.officialHint")}</span><div className="gift-format-chips"><span>{officialGifts.length}</span><span>SHA-256</span></div></div>
<div className="official-gift-bulk-import">
<ActionButton
label={t("gifts.importAllOfficial")}
path="/api/actions/import-all-official-gifts"
payload={() => ({})}
tone="neutral"
icon={<Upload size={14} />}
onDone={() => void load()}
/>
</div>
<div className="official-gift-tools">
<label className="searchbox"><Search size={15} /><input value={officialQuery} onChange={(e) => setOfficialQuery(e.target.value)} placeholder={t("gifts.officialSearch")} /></label>
<span>{t("gifts.officialResults", { shown: visibleOfficial.length, total: officialGifts.length })}</span>
</div>
<div className="official-gift-categories" role="group" aria-label={t("gifts.officialCategoryLabel")}>
{(["all", "upgrade", "craft", "basic"] as const).map((category) => (
<button key={category} className={officialCategory === category ? "active" : ""} type="button"
aria-pressed={officialCategory === category} onClick={() => setOfficialCategory(category)}>
{t(`gifts.officialCategory.${category}`)}<span>{officialCategoryCounts[category]}</span>
</button>
))}
</div>
<div className="official-gift-list" role="listbox" aria-label={t("gifts.officialSelect")}>
{visibleOfficial.map((gift) => {
const isSelected = gift.source_gift_id === sourceGiftID;
return <button key={gift.source_gift_id} className={`official-gift-option ${isSelected ? "selected" : ""}`}
type="button" role="option" aria-selected={isSelected} onClick={() => chooseOfficial(gift)}>
<span className="official-gift-option-head">
<strong>{gift.title || t("gifts.officialUnnamed", { id: gift.source_gift_id })}</strong>
<span className="mono">#{gift.source_gift_id}</span>
</span>
<span className="official-gift-option-meta">
<span> {gift.stars}</span>
<span>{t("gifts.officialAttributes", { count: officialGiftAttributeCount(gift) })}</span>
</span>
<span className="official-gift-capabilities">
<span className={gift.can_upgrade ? "yes" : "no"}>{gift.can_upgrade ? t("gifts.canUpgrade") : t("gifts.cannotUpgrade")}</span>
<span className={gift.can_craft ? "craft" : "no"}>{gift.can_craft ? t("gifts.canCraft") : t("gifts.cannotCraft")}</span>
</span>
</button>;
})}
{visibleOfficial.length === 0 && <div className="official-gift-empty">{t("gifts.officialEmpty")}</div>}
</div>
{selectedOfficial && <div className="official-gift-selected">
<OfficialLottiePreview sourceGiftID={selectedOfficial.source_gift_id} />
<div><strong>{selectedOfficial.title || t("gifts.officialUnnamed", { id: selectedOfficial.source_gift_id })}</strong><span className="mono">{selectedOfficial.source_gift_id}</span><small>{selectedOfficial.model_count} {t("collectibles.models")} · {selectedOfficial.pattern_count} {t("collectibles.patterns")} · {selectedOfficial.backdrop_count} {t("collectibles.backdrops")}</small><span className="official-gift-capabilities"><span className={selectedOfficial.can_upgrade ? "yes" : "no"}>{selectedOfficial.can_upgrade ? t("gifts.canUpgrade") : t("gifts.cannotUpgrade")}</span><span className={selectedOfficial.can_craft ? "craft" : "no"}>{selectedOfficial.can_craft ? t("gifts.canCraft") : t("gifts.cannotCraft")}</span></span></div>
</div>}
{selectedOfficial?.can_upgrade && <>
<label className="gift-switch"><input type="checkbox" checked={includeCollectible} onChange={(e) => { setIncludeCollectible(e.target.checked); setPreview(null); }} /><span className="gift-switch-track" aria-hidden="true"><span /></span><span>{t("gifts.includeCollectible")}</span></label>
{includeCollectible && <div className="gift-fields-grid">
<label><span>{t("collectibles.upgradeStars")}</span><input type="number" min="1" value={upgradeStars} onChange={(e) => { setUpgradeStars(e.target.value); setPreview(null); }} /></label>
<label><span>{t("collectibles.supply")}</span><input type="number" min="1" value={supplyTotal} onChange={(e) => { setSupplyTotal(e.target.value); setPreview(null); }} /></label>
<label><span>{t("collectibles.slug")}</span><input value={slugPrefix} maxLength={48} onChange={(e) => { setSlugPrefix(e.target.value.toLowerCase()); setPreview(null); }} /></label>
</div>}
</>}
<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-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>
</section> : <>
<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" : ""}`}>

View file

@ -206,6 +206,29 @@ export type DefaultGiftRow = {
export type DefaultGiftListResponse = { gifts: DefaultGiftRow[] };
// A verified official Star Gift snapshot entry (see cmd/giftfetch). Numeric
// ids/counters that can approach int64 range are decimal strings.
export type OfficialStarGiftRow = {
source_gift_id: string;
title: string;
stars: string;
convert_stars: string;
upgrade_stars: string;
availability_total: number;
limited: boolean;
sold_out: boolean;
model_count: number;
pattern_count: number;
backdrop_count: number;
crafted_model_count: number;
can_upgrade: boolean;
can_craft: boolean;
document_id: string;
animation_validated: boolean;
};
export type OfficialStarGiftListResponse = { gifts: OfficialStarGiftRow[] };
export type StarGiftCollectibleAttributeRow = {
id: string;
kind: "model" | "pattern" | "backdrop";