import { CheckCircle2, ChevronLeft, ChevronRight, 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, APIError, errorMessage } from "../api"; import { ActionButton } from "../components/ActionButton"; import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui"; import { formatDate } from "../lib/format"; 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"; const officialCategoryLabels: Record = { all: "All", upgrade: "Upgradable", craft: "Craftable", basic: "Not upgradable" }; // The demo pool only has 3 placeholder gifts left after pruning to one per // capability tier (Spark/Star/Coin); hide the tab until real custom designs // replace them. Flip back to true to re-enable. const SHOW_DEFAULT_GIFTS_TAB = false; 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`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } export function LottiePreview({ giftID, revision, compact = false }: { giftID: string; revision: number; compact?: boolean }) { const host = useRef(null); const animation = useRef | 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 (
{error && {error}}
); } function DefaultLottiePreview({ id }: { id: number }) { const host = useRef(null); useEffect(() => { let cancelled = false; let player: ReturnType | null = null; api.defaultGiftAnimation(id).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(); }; }, [id]); return
; } function OfficialLottiePreview({ sourceGiftID }: { sourceGiftID: string }) { const host = useRef(null); useEffect(() => { let cancelled = false; let player: ReturnType | 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
; } export function GiftsPage() { const [gifts, setGifts] = useState([]); const [query, setQuery] = useState(""); const [importOpen, setImportOpen] = useState(false); const [collectibleGift, setCollectibleGift] = useState(null); const [file, setFile] = useState(null); const [importSource, setImportSource] = useState<"default" | "official" | "file">(SHOW_DEFAULT_GIFTS_TAB ? "default" : "official"); const [defaultGifts, setDefaultGifts] = useState([]); const [selectedDefaultID, setSelectedDefaultID] = useState(0); const [officialGifts, setOfficialGifts] = useState([]); const [officialQuery, setOfficialQuery] = useState(""); const [officialCategory, setOfficialCategory] = useState("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"); const [convertStars, setConvertStars] = useState("50"); const [sortOrder, setSortOrder] = useState("0"); const [enabled, setEnabled] = useState(true); const [reason, setReason] = useState(""); const [preview, setPreview] = useState(null); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); const [importError, setImportError] = useState(""); const [bulkImportOpen, setBulkImportOpen] = useState<"default" | "official" | null>(null); const [bulkImportItems, setBulkImportItems] = useState>([]); const [bulkImportEnabled, setBulkImportEnabled] = useState(true); const [bulkImportReason, setBulkImportReason] = useState(""); const [bulkImportBusy, setBulkImportBusy] = useState(false); const [bulkImportProgress, setBulkImportProgress] = useState({ done: 0, total: 0 }); const [bulkImportError, setBulkImportError] = useState(""); const [bulkImportResult, setBulkImportResult] = useState<{ imported: number; skipped: number; failed: number; errors: string[] } | null>(null); const [selected, setSelected] = useState>(new Set()); const [bulkReason, setBulkReason] = useState(""); const [bulkBusy, setBulkBusy] = useState(false); const [bulkError, setBulkError] = useState(""); const [pageSize, setPageSize] = useState(10); const [page, setPage] = useState(1); async function load() { setError(""); try { setGifts((await api.gifts()).Gifts ?? []); } catch (err) { setError(errorMessage(err)); } } useEffect(() => { void load(); }, []); useEffect(() => { if (!importOpen || importSource !== "default" || defaultGifts.length > 0) return; 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(); 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]); useEffect(() => { setPage(1); }, [query, pageSize]); const totalPages = pageSize === "all" ? 1 : Math.max(1, Math.ceil(visibleGifts.length / pageSize)); const currentPage = Math.min(page, totalPages); const pagedGifts = useMemo(() => { if (pageSize === "all") return visibleGifts; const start = (currentPage - 1) * pageSize; return visibleGifts.slice(start, start + pageSize); }, [visibleGifts, currentPage, pageSize]); const pageRangeStart = pagedGifts.length === 0 ? 0 : pageSize === "all" ? 1 : (currentPage - 1) * pageSize + 1; const pageRangeEnd = pageRangeStart === 0 ? 0 : pageRangeStart + pagedGifts.length - 1; const allVisibleSelected = pagedGifts.length > 0 && pagedGifts.every((gift) => selected.has(gift.GiftID)); function toggleSelected(giftID: string) { setSelected((prev) => { const next = new Set(prev); if (next.has(giftID)) next.delete(giftID); else next.add(giftID); return next; }); } function toggleSelectAllVisible() { setSelected((prev) => { if (allVisibleSelected) { const next = new Set(prev); for (const gift of pagedGifts) next.delete(gift.GiftID); return next; } const next = new Set(prev); for (const gift of pagedGifts) next.add(gift.GiftID); return next; }); } async function bulkSetEnabled(nextEnabled: boolean) { if (!bulkReason.trim()) { setBulkError("Please enter an operation reason"); return; } setBulkBusy(true); setBulkError(""); const ids = Array.from(selected); let failed = 0; for (const id of ids) { try { await api.action("/api/actions/set-gift-enabled", { gift_id: id, enabled: nextEnabled, reason: bulkReason.trim(), confirm: true }); } catch { failed++; } } setBulkBusy(false); if (failed > 0) { setBulkError(`${failed} of ${ids.length} failed`); } else { setSelected(new Set()); setBulkReason(""); } await load(); } function uploadForm(confirm: boolean, commandID = "") { if (!file) throw new Error("Choose a TGS or Lottie file first"); if (!reason.trim()) throw new Error("Please enter an operation reason"); const form = new FormData(); form.set("metadata", JSON.stringify({ command_id: commandID, reason: reason.trim(), confirm, gift_id: giftID, title: title.trim(), stars, convert_stars: convertStars, enabled, sort_order: Number(sortOrder) })); form.set("file", file, file.name); return form; } function defaultPayload(confirm: boolean, commandID = "") { if (!selectedDefaultID) throw new Error("Choose a default gift first"); if (!reason.trim()) throw new Error("Please enter an operation reason"); return { command_id: commandID, reason: reason.trim(), confirm, id: selectedDefaultID }; } function officialPayload(confirm: boolean, commandID = "") { if (!sourceGiftID) throw new Error("Choose an official gift first"); if (!reason.trim()) throw new Error("Please enter an operation reason"); 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 || `Unnamed official gift #${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 openBulkImport(source: "default" | "official") { setBulkImportOpen(source); setBulkImportItems([]); setBulkImportEnabled(true); setBulkImportReason(""); setBulkImportBusy(false); setBulkImportProgress({ done: 0, total: 0 }); setBulkImportError(""); setBulkImportResult(null); try { if (source === "default") { const list = defaultGifts.length > 0 ? defaultGifts : (await api.defaultGifts()).gifts ?? []; setBulkImportItems(list); } else { const list = officialGifts.length > 0 ? officialGifts : (await api.officialGifts()).gifts ?? []; setBulkImportItems(list); } } catch (err) { setBulkImportError(errorMessage(err)); } } function closeBulkImport() { if (bulkImportBusy) return; setBulkImportOpen(null); } async function runBulkImport() { if (!bulkImportOpen) return; if (!bulkImportReason.trim()) { setBulkImportError("Please enter an operation reason"); return; } const source = bulkImportOpen; setBulkImportBusy(true); setBulkImportError(""); setBulkImportResult(null); setBulkImportProgress({ done: 0, total: bulkImportItems.length }); let imported = 0, skipped = 0, failed = 0; const errors: string[] = []; for (const item of bulkImportItems) { const label = source === "default" ? (item as DefaultGiftRow).title : ((item as OfficialStarGiftRow).title || `#${(item as OfficialStarGiftRow).source_gift_id}`); try { // Stable per-gift command_id (mirrors the old server-side bulk // endpoint) so a gift already imported by a prior run is recognized // as a replay instead of creating a duplicate catalog entry - // CreateCatalogBundle has no unique constraint on title/source id to // fall back on. If this run's Enabled value differs from the run // that first created it, the server reports COMMAND_ID_CONFLICT // instead of silently re-importing; treat that as "skipped" too. const result = source === "default" ? await api.importDefaultGift({ command_id: `bulk-default-gift-${(item as DefaultGiftRow).id}`, reason: bulkImportReason.trim(), confirm: true, id: (item as DefaultGiftRow).id, enabled: bulkImportEnabled }) : await api.importOfficialGift({ command_id: `bulk-official-gift-${(item as OfficialStarGiftRow).source_gift_id}`, reason: bulkImportReason.trim(), confirm: true, source_gift_id: (item as OfficialStarGiftRow).source_gift_id, include_collectible: (item as OfficialStarGiftRow).can_upgrade, enabled: bulkImportEnabled }); if (result.already_executed || result.details?.skipped) skipped++; else imported++; } catch (err) { if (err instanceof APIError && err.message === "COMMAND_ID_CONFLICT") { skipped++; } else { failed++; errors.push(`${label}: ${errorMessage(err)}`); } } setBulkImportProgress((prev) => ({ ...prev, done: prev.done + 1 })); } setBulkImportBusy(false); setBulkImportResult({ imported, skipped, failed, errors }); await load(); } async function validateImport() { setBusy(true); setImportError(""); setPreview(null); try { 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); } } async function confirmImport() { if (!preview) return; 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); setSourceGiftID(""); 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(""); setImportSource(SHOW_DEFAULT_GIFTS_TAB ? "default" : "official"); setSelectedDefaultID(0); setSourceGiftID(""); setOfficialQuery(""); setOfficialCategory("all"); setBulkImportBusy(false); setBulkImportProgress({ done: 0, total: 0 }); setBulkImportError(""); 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); setSourceGiftID(""); setImportOpen(true); } const step1Done = importSource === "default" ? selectedDefaultID > 0 : importSource === "official" ? Boolean(sourceGiftID) : Boolean(file); return ( }> {error && {error}}
gift.Enabled).length)} tone="good" /> sum + BigInt(gift.ReceivedCount), 0n).toString()} />
{`Showing ${visibleGifts.length} of ${gifts.length}`}
{selected.size > 0 &&
{`${selected.size} selected`} {bulkError && {bulkError}}
}
{pagedGifts.map((gift) => ( ))} {pagedGifts.length === 0 && }
{"Animation file"}{"ID / Revision"}{"Display title"}{"Price / Conversion"}{"Source"}{"Received gifts"}{"Status"}{"Updated"}{"Actions"}
toggleSelected(gift.GiftID)} aria-label={`Select gift ${gift.GiftID}`} /> {gift.GiftID} / {gift.Revision} {gift.Title || `Gift #${gift.GiftID}`}{"Sort order"}: {gift.SortOrder} ⭐ {gift.Stars}→ {gift.ConvertStars} {gift.SourceFormat}{formatBytes(gift.AnimationSize)} {gift.ReceivedCount} {gift.Enabled ? "Enabled" : "Disabled"} {formatDate(gift.UpdatedAt)}
({ gift_id: gift.GiftID, enabled: !gift.Enabled })} onDone={() => void load()} />
{pageSize !== "all" && visibleGifts.length > 0 &&
{`Showing ${pageRangeStart}-${pageRangeEnd} of ${visibleGifts.length}`}
{`Page ${currentPage} of ${totalPages}`}
} {importOpen && createPortal(
{"Gift catalog operation"}

{giftID !== "0" ? `Create revision for gift #${giftID}` : "Import a Star Gift"}

1{"File and details"}
2{"Dry-run validation"}
3{"Confirm import"}
{giftID === "0" &&
{SHOW_DEFAULT_GIFTS_TAB && }
} {importSource === "default" && giftID === "0" && SHOW_DEFAULT_GIFTS_TAB ?
{"Import our built-in original OwpenGram gifts. Complete collectible pools (upgrade + craft) are imported atomically."}
{defaultGifts.length}OwpenGram
{defaultGifts.map((gift) => { const isSelected = gift.id === selectedDefaultID; return ; })} {defaultGifts.length === 0 &&
{"No default gifts are available."}
}
{selectedDefault &&
{selectedDefault.title}⭐ {selectedDefault.stars} → {selectedDefault.convert_stars}{selectedDefault.model_count} {"Models"} · {selectedDefault.pattern_count} {"Patterns"} · {selectedDefault.backdrop_count} {"Backdrops"}{selectedDefault.upgradeable ? "Can upgrade" : "Cannot upgrade"}{selectedDefault.craftable ? "Can Craft" : "Cannot Craft"}
}
: importSource === "official" && giftID === "0" ?
{"Choose a verified gift from data/official-gifts. Complete collectible pools are imported atomically."}
{officialGifts.length}SHA-256
{`Showing ${visibleOfficial.length} of ${officialGifts.length}`}
{(["all", "upgrade", "craft", "basic"] as const).map((category) => ( ))}
{visibleOfficial.map((gift) => { const isSelected = gift.source_gift_id === sourceGiftID; return ; })} {visibleOfficial.length === 0 &&
{"No official gifts match this category and search."}
}
{selectedOfficial &&
{selectedOfficial.title || `Unnamed official gift #${selectedOfficial.source_gift_id}`}{selectedOfficial.source_gift_id}{selectedOfficial.model_count} {"Models"} · {selectedOfficial.pattern_count} {"Patterns"} · {selectedOfficial.backdrop_count} {"Backdrops"}{selectedOfficial.can_upgrade ? "Can upgrade" : "Cannot upgrade"}{selectedOfficial.can_craft ? "Can Craft" : "Cannot Craft"}
} {selectedOfficial?.can_upgrade && <> {includeCollectible &&
} }
: <>
{"Upload TGS or plain Lottie JSON. Lottie is normalized and compressed to TGS."}
TGSLottie JSON
} {importError && {importError}} {preview &&
{"Validation passed"}{"Review the normalized metadata, then confirm the import."}
{JSON.stringify(preview.details, null, 2)}
}
, document.body )} {bulkImportOpen && createPortal(
{"Gift catalog operation"}

{bulkImportOpen === "default" ? "Import all default gifts" : "Import all official gifts"}

{`${bulkImportItems.length} gifts available to import`}
{bulkImportBusy &&
{`Importing ${bulkImportProgress.done} of ${bulkImportProgress.total}`}
} {bulkImportError && {bulkImportError}} {bulkImportResult &&
{"Import complete"}{`Imported ${bulkImportResult.imported}, skipped ${bulkImportResult.skipped}, failed ${bulkImportResult.failed}`}
{bulkImportResult.errors.length > 0 &&
{bulkImportResult.errors.join("\n")}
}
}
, document.body )} {collectibleGift && setCollectibleGift(null)} onPublished={() => void load()} />}
); }