added pagination for admin panel at star gifts list

This commit is contained in:
onysd 2026-07-22 01:22:24 +03:00
parent deae6c4a4a
commit f358d5a64a
7 changed files with 93 additions and 19 deletions

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,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/png" href="/logo.png" /> <link rel="icon" type="image/png" href="/logo.png" />
<title>OwpenGram Admin</title> <title>OwpenGram Admin</title>
<script type="module" crossorigin src="/assets/index-CpvYOqis.js"></script> <script type="module" crossorigin src="/assets/index-CRrNXtUN.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Da0KbwLb.css"> <link rel="stylesheet" crossorigin href="/assets/index-C7HtHaDP.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View file

@ -303,6 +303,12 @@ const translations: Record<string, string> = {
"gifts.bulkEnable": "Enable selected", "gifts.bulkEnable": "Enable selected",
"gifts.bulkDisable": "Disable selected", "gifts.bulkDisable": "Disable selected",
"gifts.bulkStatusFailed": "{failed} of {total} failed", "gifts.bulkStatusFailed": "{failed} of {total} failed",
"gifts.perPage": "Per page",
"gifts.perPageAll": "All",
"gifts.pageRange": "Showing {start}-{end} of {total}",
"gifts.pagePrev": "Previous",
"gifts.pageNext": "Next",
"gifts.pageOf": "Page {page} of {total}",
"gifts.empty": "No Star Gifts have been imported.", "gifts.empty": "No Star Gifts have been imported.",
"gifts.emptyHint": "Import the first animation above to build the gift catalog.", "gifts.emptyHint": "Import the first animation above to build the gift catalog.",
"gifts.validationReady": "Validation passed", "gifts.validationReady": "Validation passed",

View file

@ -1,4 +1,4 @@
import { CheckCircle2, FileJson2, Gem, Loader2, Pause, Play, Plus, RefreshCw, Search, ShieldCheck, Upload, X } from "lucide-react"; 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 lottie from "lottie-web/build/player/lottie_light_canvas";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
@ -11,6 +11,7 @@ import type { CommandResult, OfficialStarGiftRow, StarGiftRow } from "../types";
import { GiftCollectiblesModal } from "./GiftCollectiblesModal"; import { GiftCollectiblesModal } from "./GiftCollectiblesModal";
type OfficialGiftCategory = "all" | "upgrade" | "craft" | "basic"; type OfficialGiftCategory = "all" | "upgrade" | "craft" | "basic";
type GiftPageSize = 10 | 20 | 50 | 100 | "all";
function officialGiftAttributeCount(gift: OfficialStarGiftRow) { function officialGiftAttributeCount(gift: OfficialStarGiftRow) {
return gift.model_count + gift.pattern_count + gift.backdrop_count; return gift.model_count + gift.pattern_count + gift.backdrop_count;
@ -111,6 +112,8 @@ export function GiftsPage() {
const [bulkReason, setBulkReason] = useState(""); const [bulkReason, setBulkReason] = useState("");
const [bulkBusy, setBulkBusy] = useState(false); const [bulkBusy, setBulkBusy] = useState(false);
const [bulkError, setBulkError] = useState(""); const [bulkError, setBulkError] = useState("");
const [pageSize, setPageSize] = useState<GiftPageSize>(10);
const [page, setPage] = useState(1);
async function load() { async function load() {
setError(""); setError("");
@ -156,7 +159,19 @@ export function GiftsPage() {
); );
}, [gifts, query]); }, [gifts, query]);
const allVisibleSelected = visibleGifts.length > 0 && visibleGifts.every((gift) => selected.has(gift.GiftID)); 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) { function toggleSelected(giftID: string) {
setSelected((prev) => { setSelected((prev) => {
@ -171,11 +186,11 @@ export function GiftsPage() {
setSelected((prev) => { setSelected((prev) => {
if (allVisibleSelected) { if (allVisibleSelected) {
const next = new Set(prev); const next = new Set(prev);
for (const gift of visibleGifts) next.delete(gift.GiftID); for (const gift of pagedGifts) next.delete(gift.GiftID);
return next; return next;
} }
const next = new Set(prev); const next = new Set(prev);
for (const gift of visibleGifts) next.add(gift.GiftID); for (const gift of pagedGifts) next.add(gift.GiftID);
return next; return next;
}); });
} }
@ -305,6 +320,15 @@ export function GiftsPage() {
<QueryPanel> <QueryPanel>
<div className="toolbar"> <div className="toolbar">
<label className="searchbox"><Search size={15} /><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder={t("gifts.searchPlaceholder")} /></label> <label className="searchbox"><Search size={15} /><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder={t("gifts.searchPlaceholder")} /></label>
<label className="gift-page-size"><span>{t("gifts.perPage")}</span>
<select value={String(pageSize)} onChange={(event) => setPageSize(event.target.value === "all" ? "all" : (Number(event.target.value) as GiftPageSize))}>
<option value="10">10</option>
<option value="20">20</option>
<option value="50">50</option>
<option value="100">100</option>
<option value="all">{t("gifts.perPageAll")}</option>
</select>
</label>
<span className="gift-list-summary">{t("gifts.listSummary", { shown: visibleGifts.length, total: gifts.length })}</span> <span className="gift-list-summary">{t("gifts.listSummary", { shown: visibleGifts.length, total: gifts.length })}</span>
</div> </div>
</QueryPanel> </QueryPanel>
@ -324,7 +348,7 @@ export function GiftsPage() {
<table className="data-table gift-table"> <table className="data-table gift-table">
<thead><tr><th className="gift-select-col"><input type="checkbox" checked={allVisibleSelected} onChange={toggleSelectAllVisible} aria-label={t("gifts.bulkSelectAll")} /></th><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> <thead><tr><th className="gift-select-col"><input type="checkbox" checked={allVisibleSelected} onChange={toggleSelectAllVisible} aria-label={t("gifts.bulkSelectAll")} /></th><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> <tbody>
{visibleGifts.map((gift) => ( {pagedGifts.map((gift) => (
<tr className={gift.Enabled ? "" : "gift-row-disabled"} key={gift.GiftID}> <tr className={gift.Enabled ? "" : "gift-row-disabled"} key={gift.GiftID}>
<td className="gift-select-col"><input type="checkbox" checked={selected.has(gift.GiftID)} onChange={() => toggleSelected(gift.GiftID)} aria-label={t("gifts.bulkSelectOne", { id: gift.GiftID })} /></td> <td className="gift-select-col"><input type="checkbox" checked={selected.has(gift.GiftID)} onChange={() => toggleSelected(gift.GiftID)} aria-label={t("gifts.bulkSelectOne", { id: gift.GiftID })} /></td>
<td><LottiePreview giftID={gift.GiftID} revision={gift.Revision} compact /></td> <td><LottiePreview giftID={gift.GiftID} revision={gift.Revision} compact /></td>
@ -338,10 +362,22 @@ export function GiftsPage() {
<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> <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> </tr>
))} ))}
{visibleGifts.length === 0 && <EmptyRow colSpan={10} />} {pagedGifts.length === 0 && <EmptyRow colSpan={10} />}
</tbody> </tbody>
</table> </table>
</div> </div>
{pageSize !== "all" && visibleGifts.length > 0 && <div className="gift-pager">
<span className="gift-pager-range">{t("gifts.pageRange", { start: pageRangeStart, end: pageRangeEnd, total: visibleGifts.length })}</span>
<div className="gift-pager-controls">
<button className="btn compact-btn" type="button" onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={currentPage <= 1}>
<ChevronLeft size={14} /> {t("gifts.pagePrev")}
</button>
<span className="gift-pager-page">{t("gifts.pageOf", { page: currentPage, total: totalPages })}</span>
<button className="btn compact-btn" type="button" onClick={() => setPage((p) => Math.min(totalPages, p + 1))} disabled={currentPage >= totalPages}>
{t("gifts.pageNext")} <ChevronRight size={14} />
</button>
</div>
</div>}
{importOpen && createPortal( {importOpen && createPortal(
<div className="modal-backdrop" role="presentation"> <div className="modal-backdrop" role="presentation">

View file

@ -419,7 +419,7 @@
gap: 10px; gap: 10px;
padding: 9px 12px; padding: 9px 12px;
margin-bottom: 10px; margin-bottom: 10px;
background: #f3f8f7; background: var(--panel-strong);
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 9px; border-radius: 9px;
} }
@ -427,6 +427,38 @@
.gift-bulk-reason { flex: 1; min-width: 160px; } .gift-bulk-reason { flex: 1; min-width: 160px; }
.gift-bulk-reason input { height: 34px; } .gift-bulk-reason input { height: 34px; }
.gift-bulk-error { color: #b42318; font-size: 11px; font-weight: 700; } .gift-bulk-error { color: #b42318; font-size: 11px; font-weight: 700; }
.gift-page-size {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--muted);
font-size: 11px;
font-weight: 700;
white-space: nowrap;
}
.gift-page-size select {
height: 30px;
padding: 0 8px;
color: var(--text);
background: #ffffff;
border: 1px solid var(--line);
border-radius: 7px;
font: inherit;
font-weight: 700;
}
.gift-pager {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 10px;
flex-wrap: wrap;
}
.gift-pager-range { color: var(--muted); font-size: 11px; font-weight: 700; }
.gift-pager-controls { display: flex; align-items: center; gap: 10px; }
.gift-pager-page { color: var(--text); font-size: 12px; font-weight: 700; white-space: nowrap; }
.gift-animation-shell.compact { width: 56px; min-height: 56px; overflow: hidden; border: 1px solid var(--line); border-radius: 9px; } .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-animation { width: 54px; height: 54px; }
.gift-animation-shell.compact .gift-play { right: 3px; bottom: 3px; width: 20px; height: 20px; } .gift-animation-shell.compact .gift-play { right: 3px; bottom: 3px; width: 20px; height: 20px; }