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:
openhands committed 2026-10-15 16:00:00 +02:00
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>