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; 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(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
; } function RemoteAnimation({ giftID, attribute }: { giftID: string; attribute: StarGiftCollectibleAttributeRow }) { const [data, setData] = useState(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
!
; if (!data) return
; return ; } async function parseAnimationFile(file: File): Promise { 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); const rarityLabel = (attribute: StarGiftCollectibleAttributeRow) => attribute.rarity_kind === "permille" ? `${attribute.rarity_permille}‰` : attribute.rarity_kind; export function GiftCollectiblesModal({ gift, onClose, onPublished }: { gift: StarGiftRow; onClose: () => void; onPublished: () => void }) { const { t } = useI18n(); const [active, setActive] = useState(null); const [loading, setLoading] = useState(true); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); const [preview, setPreview] = useState(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([newAnimated("model")]); const [patterns, setPatterns] = useState([newAnimated("pattern")]); const [backdrops, setBackdrops] = useState([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) => { 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: 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) => (
{t(`collectibles.${kind}`)}{t("collectibles.rarityHint")}
0 ? "good" : "neutral"}>{rarityTotals[kind]}‰
{rows.map((row, index) =>
{index + 1}
{row.animation ? : }
{row.fileError && {row.fileError}}
)}
); return createPortal(
{t("collectibles.eyebrow")}

{t("collectibles.title", { id: gift.GiftID })}

{gift.Title || `Gift #${gift.GiftID}`}

{loading ?
{t("common.loading")}
: active?.found ?
{t("collectibles.activeRevision", { revision: active.revision ?? 0 })}{active.slug_prefix} · ⭐ {active.upgrade_stars} · {active.issued} / {active.supply_total}
{t("collectibles.published")}
{[...(active.models ?? []), ...(active.patterns ?? [])].map((attribute) =>
{attribute.name}{attribute.crafted && crafted}{t(`collectibles.${attribute.kind}`)} · {rarityLabel(attribute)}
)} {(active.backdrops ?? []).map((attribute) =>
Aa
{attribute.name}{t("collectibles.backdrop")} · {rarityLabel(attribute)}
)}
:
{t("collectibles.noPool")}{t("collectibles.noPoolHint")}
}
{t("collectibles.publishNew")}{t("collectibles.immutableHint")}
TGSLottie JSON
{renderAnimatedRows("models", models, setModels)} {renderAnimatedRows("patterns", patterns, setPatterns)}
{t("collectibles.backdrops")}{t("collectibles.colorHint")}
0 ? "good" : "neutral"}>{rarityTotals.backdrops}‰
{backdrops.map((row, index) =>
{index + 1}
{(["center", "edge", "pattern", "text"] as const).map((field) => )}
Aa
)}
{error && {error}} {preview &&
{t("collectibles.validationReady")}{t("collectibles.validationHint")}
{JSON.stringify(preview.details, null, 2)}
}
, document.body); }