perf(app): batch catalog updates, tune caches, add captcha env fallback
- catalog: apply bulk offer edits in a single transaction with one RCON refresh instead of N round-trips, virtualize the items table past 150 rows and cap the shop preview at 200 tiles - cache: lift the public cache TTLs and the boot warm-up into env-tunable constants (src/lib/perf-ttls.ts; CMS_*_TTL_SEC, CMS_WARMUP_ENABLED) - auth: resolve the captcha provider, site key and secret from CAPTCHA_PROVIDER, TURNSTILE_SITE_KEY and TURNSTILE_SECRET_KEY when the admin settings are unset; an explicit DB value (including "none") still wins, so Turnstile can be enabled per-deploy without touching the DB.
This commit is contained in:
1 parent
05c2706eaf
commit
9f024dc6ac
15 files changed
+529
-83
No files matched your search
@@ -17,6 +17,7 @@ import {
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import {
|
||||
ArrowRightLeft,
|
||||
Copy,
|
||||
@@ -34,7 +35,7 @@ import {
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
createCatalogItem,
|
||||
@@ -43,6 +44,7 @@ import {
|
||||
reorderCatalogItems,
|
||||
restoreDeletedCatalogItems,
|
||||
updateCatalogItem,
|
||||
updateCatalogItemsBulk,
|
||||
} from "@/actions/catalog-items";
|
||||
import { BulkImportItems } from "@/components/admin/catalog/bulk-import-items";
|
||||
import { ItemsShopPreview } from "@/components/admin/catalog/items-shop-preview";
|
||||
@@ -115,10 +117,15 @@ export type { BaseItemData, CatalogItemData };
|
||||
function SortableRow({
|
||||
id,
|
||||
className,
|
||||
dataIndex,
|
||||
measureRef,
|
||||
children,
|
||||
}: {
|
||||
id: number;
|
||||
className?: string;
|
||||
/** Virtualizer index; forwarded as data-index so measureElement can map it. */
|
||||
dataIndex?: number;
|
||||
measureRef?: (el: HTMLTableRowElement | null) => void;
|
||||
children: (props: {
|
||||
dragHandleProps: React.HTMLAttributes<HTMLDivElement>;
|
||||
}) => React.ReactNode;
|
||||
@@ -133,6 +140,13 @@ function SortableRow({
|
||||
} = useSortable({
|
||||
id,
|
||||
});
|
||||
const setRefs = useCallback(
|
||||
(node: HTMLTableRowElement | null) => {
|
||||
setNodeRef(node);
|
||||
measureRef?.(node);
|
||||
},
|
||||
[setNodeRef, measureRef],
|
||||
);
|
||||
const style: React.CSSProperties = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
@@ -144,7 +158,8 @@ function SortableRow({
|
||||
} as unknown as React.HTMLAttributes<HTMLDivElement>;
|
||||
return (
|
||||
<TableRow
|
||||
ref={setNodeRef as unknown as React.Ref<HTMLTableRowElement>}
|
||||
ref={setRefs}
|
||||
data-index={dataIndex}
|
||||
style={style}
|
||||
className={className}
|
||||
>
|
||||
@@ -280,6 +295,23 @@ export function CatalogItemsTable({
|
||||
.sort((a, b) => (orderMap.get(a.id) ?? 0) - (orderMap.get(b.id) ?? 0));
|
||||
}, [filteredItems, itemOrder, searchQuery, filters]);
|
||||
|
||||
// ── Virtualization (table view) ─────────────────────────────────
|
||||
// A page with hundreds of offers used to mount a full <tr> per row, each
|
||||
// with several inputs and icons — the single biggest source of lag in the
|
||||
// catalog editor. Above the threshold only the visible window (plus a small
|
||||
// overscan) is mounted; spacer rows keep the scrollbar honest.
|
||||
const VIRTUALIZE_THRESHOLD = 150;
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const isVirtualized = orderedFilteredItems.length > VIRTUALIZE_THRESHOLD;
|
||||
const virtualizer = useVirtualizer({
|
||||
count: orderedFilteredItems.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => 52,
|
||||
overscan: 15,
|
||||
enabled: isVirtualized,
|
||||
});
|
||||
const virtualRows = isVirtualized ? virtualizer.getVirtualItems() : [];
|
||||
|
||||
// ── Selection ────────────────────────────────────────────────────
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||
|
||||
@@ -504,55 +536,36 @@ export function CatalogItemsTable({
|
||||
async function handleSaveAllModified() {
|
||||
if (modifiedItems.length === 0) return;
|
||||
|
||||
run(
|
||||
async () => {
|
||||
let savedCount = 0;
|
||||
let failedCount = 0;
|
||||
const updates = modifiedItems.flatMap((item) => {
|
||||
const edit = inlineEdits[item.id];
|
||||
if (!edit) return [];
|
||||
return [
|
||||
{
|
||||
id: item.id,
|
||||
catalogFields: {
|
||||
catalogName: item.catalogName,
|
||||
itemIds: item.itemIds,
|
||||
costCredits: edit.costCredits,
|
||||
costPoints: edit.costPoints,
|
||||
pointsType: edit.pointsType,
|
||||
amount: edit.amount,
|
||||
limitedSells: item.limitedSells,
|
||||
limitedStack: item.limitedStack,
|
||||
orderNumber: item.orderNumber,
|
||||
offerId: item.offerId,
|
||||
haveOffer: item.haveOffer,
|
||||
clubOnly: item.clubOnly,
|
||||
extradata: item.extradata,
|
||||
},
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
for (const item of modifiedItems) {
|
||||
const edit = inlineEdits[item.id];
|
||||
if (!edit) continue;
|
||||
|
||||
const result = await updateCatalogItem({
|
||||
id: item.id,
|
||||
catalog,
|
||||
catalogFields: {
|
||||
catalogName: item.catalogName,
|
||||
itemIds: item.itemIds,
|
||||
costCredits: edit.costCredits,
|
||||
costPoints: edit.costPoints,
|
||||
pointsType: edit.pointsType,
|
||||
amount: edit.amount,
|
||||
limitedSells: item.limitedSells,
|
||||
limitedStack: item.limitedStack,
|
||||
orderNumber: item.orderNumber,
|
||||
offerId: item.offerId,
|
||||
haveOffer: item.haveOffer,
|
||||
clubOnly: item.clubOnly,
|
||||
extradata: item.extradata,
|
||||
},
|
||||
});
|
||||
|
||||
if (result.ok) savedCount++;
|
||||
else failedCount++;
|
||||
}
|
||||
|
||||
if (savedCount > 0) {
|
||||
onRefresh();
|
||||
}
|
||||
if (failedCount > 0) {
|
||||
return {
|
||||
ok: false as const,
|
||||
error: `Saved ${savedCount}, failed ${failedCount}.`,
|
||||
};
|
||||
}
|
||||
return { ok: true as const, data: { savedCount } };
|
||||
},
|
||||
{
|
||||
successMessage: `Saved ${modifiedItems.length} item(s).`,
|
||||
errorMessage: "Failed to save items.",
|
||||
},
|
||||
);
|
||||
run(() => updateCatalogItemsBulk({ updates, catalog }), {
|
||||
successMessage: `Saved ${updates.length} item(s).`,
|
||||
errorMessage: "Failed to save items.",
|
||||
onSuccess: () => onRefresh(),
|
||||
});
|
||||
}
|
||||
|
||||
// ── Bulk delete selected ─────────────────────────────────────────
|
||||
@@ -1243,7 +1256,10 @@ export function CatalogItemsTable({
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={handleDragCancel}
|
||||
>
|
||||
<div className="rounded-md border overflow-auto max-h-[600px]">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="rounded-md border overflow-auto max-h-[600px]"
|
||||
>
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10 bg-background">
|
||||
<TableRow>
|
||||
@@ -1278,7 +1294,29 @@ export function CatalogItemsTable({
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<TableBody>
|
||||
{orderedFilteredItems.map((item) => {
|
||||
{isVirtualized && virtualRows.length > 0 && (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={10}
|
||||
style={{
|
||||
height: virtualRows[0]?.start ?? 0,
|
||||
padding: 0,
|
||||
border: 0,
|
||||
}}
|
||||
/>
|
||||
</tr>
|
||||
)}
|
||||
{(isVirtualized
|
||||
? virtualRows.map((vr) => ({
|
||||
item: orderedFilteredItems[vr.index],
|
||||
index: vr.index,
|
||||
}))
|
||||
: orderedFilteredItems.map((item, index) => ({
|
||||
item,
|
||||
index,
|
||||
}))
|
||||
).map(({ item, index }) => {
|
||||
if (!item) return null;
|
||||
const pts = getPointsLabel(item.pointsType);
|
||||
const edit = inlineEdits[item.id];
|
||||
const modified = isItemModified(item);
|
||||
@@ -1289,6 +1327,14 @@ export function CatalogItemsTable({
|
||||
<SortableRow
|
||||
key={item.id}
|
||||
id={item.id}
|
||||
dataIndex={index}
|
||||
measureRef={
|
||||
isVirtualized
|
||||
? (el) => {
|
||||
if (el) virtualizer.measureElement(el);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
className={cn(
|
||||
modified &&
|
||||
"bg-[var(--admin-warning-subtle)] dark:bg-[var(--admin-warning-subtle)]",
|
||||
@@ -1561,6 +1607,25 @@ export function CatalogItemsTable({
|
||||
</SortableRow>
|
||||
);
|
||||
})}
|
||||
{isVirtualized &&
|
||||
virtualRows.length > 0 &&
|
||||
(() => {
|
||||
const last = virtualRows[virtualRows.length - 1];
|
||||
const total = virtualizer.getTotalSize();
|
||||
if (!last || last.end >= total) return null;
|
||||
return (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={10}
|
||||
style={{
|
||||
height: total - last.end,
|
||||
padding: 0,
|
||||
border: 0,
|
||||
}}
|
||||
/>
|
||||
</tr>
|
||||
);
|
||||
})()}
|
||||
</TableBody>
|
||||
</SortableContext>
|
||||
</Table>
|
||||
|
||||
Reference in new issue
Block a user