diff --git a/.env.example b/.env.example index 2e28bdab..bdd1d9e7 100644 --- a/.env.example +++ b/.env.example @@ -76,3 +76,8 @@ FLARESOLVERR_URL=http://localhost:8191 # Gated Housekeeping preview; never enabled in production HOUSEKEEPING_NEXT_PREVIEW_ENABLED=false + +# Catalog Studio export: dedicated clean clone on Beta-3 with Git push credentials. +CATALOG_GIT_CHECKOUT= +# Persistent directory shared by CMS and worker, outside the catalog clone. +CATALOG_GIT_STATE_DIR= diff --git a/CATALOG_EXPORT.md b/CATALOG_EXPORT.md new file mode 100644 index 00000000..d69639f5 --- /dev/null +++ b/CATALOG_EXPORT.md @@ -0,0 +1,73 @@ +# Catalog Studio repository export + +Studio mutations automatically queue an export to +`https://gitlab.epicnabbo.nl/remco/Epicnabbo-Catalogus-Updated-Daily.git`, branch +`Beta-3`. The jobs worker processes pending exports every minute. Streaming +imports stay active until their stream completes. Server actions for catalog +pages, offers, deletion and maintenance are covered as well. + +## Server setup + +1. Create a **dedicated clean clone** of the repository on `Beta-3`, outside the + CMS directory. Configure non-interactive Git push authentication for the + worker OS account using its credential helper. Do not put tokens in URLs. +2. Set `CATALOG_GIT_CHECKOUT` to that absolute clone directory in the CMS and + worker environments. Set `CATALOG_GIT_STATE_DIR` to a persistent, writable + directory outside the clone, shared by both processes on the same host. +3. Run `pnpm jobs:worker` alongside the CMS under your process supervisor. + Both processes must have access to the configured asset directories and DB. + The worker command enables the React server condition for server-only modules. +4. Restart the CMS after setting the environment. In **Studio → Sync**, use + **Export now / retry** for the initial export. Subsequent mutations queue + automatically. Status shows pending/active operations and the last commit. + +Leaving `CATALOG_GIT_CHECKOUT` empty disables export. No credentials are shipped. +This source change alone does not configure or deploy the production service. + +## Exported content + +| Source | Repository destination | +| --- | --- | +| Configured Nitro bundles, including furniture, figures, effects and pets | `Gamedata/bundled` | +| Configured furni icons | `Gamedata/icons` | +| Existing badge/catalog images | `Gamedata/c_images` | +| FurnitureData and supported public game-data JSON files | `Gamedata/config` | +| Existing localized FurnitureData files | `catalogue version 2 ( Final (Dev)/langs furnidata` | +| `items_base`, `catalog_pages`, `catalog_items` | `catalogue version 2 ( Final (Dev)/sqls` | +| `catalog_pages_bc`, `catalog_items_bc`, when present | Same SQL directory | + +SQL is read in one consistent, read-only InnoDB transaction. Dumps contain table +definitions and deterministic upserts with hexadecimal UTF-8 string literals. +Import `items_base.sql`, then `catalog_pages.sql`, then `catalog_items.sql`. +Existing schemas are not migrated by these dumps. Rows absent from the source +are omitted; importing an upsert dump into another existing database does not +delete that database's extra rows. No user, session or credential tables are exported. + +Only existing local assets are exported. Translation generation follows Studio's +existing setting; export does not generate missing languages or download assets. +Public JSON is explicitly allowlisted so translation caches and private runtime +files cannot enter the repository. Invalid JSON or concurrent Studio changes +prevent publication of that snapshot. + +## Failure and concurrency behavior + +- Pending events survive process restarts; events added during publication remain + pending for the next run. Partial imports are exported as their settled local + state, including successful items from a batch containing failures. +- A single filesystem lock serializes the worker. Dead local process markers are + recovered on the next run. For an unreadable marker or a marker from another + host, stop the CMS and worker before repairing the queue directory. +- A failed push retains the local commit and pending events for retry. Concurrent + upstream commits are rebased; a conflict aborts the rebase and leaves the event + pending. Resolve conflicts in the dedicated clone, then retry. +- The checkout must be clean before each run. Unrelated files are preserved and + no force push is used. Missing local files do not cause remote deletions. +- Status errors omit raw Git output to avoid exposing authentication material. + +## Verification + +Run the `catalog-git-*` service tests and `src/lib/catalog-export-api.test.ts` with +Vitest. The integration test creates a temporary bare remote and verifies push, +failed-push recovery, no-op export and preservation of unrelated files. SQL +snapshot tests mock the database; production DB import and production push need +verification in the deployed environment. diff --git a/Dockerfile b/Dockerfile index 9c4e0051..252eb243 100644 --- a/Dockerfile +++ b/Dockerfile @@ -71,6 +71,9 @@ RUN if [ "$PACKAGE_MANAGER" = "yarn" ] || { [ -z "$PACKAGE_MANAGER" ] && [ -f ya # --- Runtime stage --- FROM node:26.8.1-bookworm-slim AS runner +# Catalog Studio publishes to self-hosted Git repositories over HTTPS. +RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates && rm -rf /var/lib/apt/lists/* + # The CMS writes to bind-mounted host directories (/var/www/Gamedata is owned by # the host's www-data user, UID/GID 33). The node base image already ships a # www-data user with UID/GID 33, which matches that ownership — so we run as diff --git a/package.json b/package.json index 4df4ffdd..78825bb9 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "format": "biome format --write .", "knip": "knip --include files,dependencies,devDependencies,unlisted,binaries", "diag:permissions": "tsx scripts/diagnose-permission-page.ts", - "jobs:worker": "tsx scripts/jobs-worker.ts", + "jobs:worker": "node --conditions=react-server --import tsx scripts/jobs-worker.ts", "test": "vitest run", "test:e2e": "playwright test", "typecheck": "tsc --noEmit", diff --git a/scripts/jobs-worker.ts b/scripts/jobs-worker.ts index 97b8f377..7f3b50b2 100644 --- a/scripts/jobs-worker.ts +++ b/scripts/jobs-worker.ts @@ -11,6 +11,7 @@ import { import { logger } from "../src/lib/logger"; import { redis } from "../src/lib/redis"; import { emulatorOffline, healthDegraded } from "../src/lib/services/alert"; +import { runCatalogExport } from "../src/lib/services/catalog-git-export"; import { rcon } from "../src/lib/services/rcon"; function captureWorkerError(err: unknown, context: string): void { @@ -261,6 +262,11 @@ async function publishScheduledArticles(): Promise { } async function main() { + new Cron("* * * * *", () => { + runCatalogExport().catch((e) => + captureWorkerError(e, "Catalog export failed"), + ); + }); logger.info("Worker started", { module: "jobs" }); if (env.EMULATOR_JAR_PATH && env.EMULATOR_BACKUP_DIR) { diff --git a/src/actions/catalog-bc.ts b/src/actions/catalog-bc.ts index de924b9f..c6a862c5 100644 --- a/src/actions/catalog-bc.ts +++ b/src/actions/catalog-bc.ts @@ -5,6 +5,7 @@ import { revalidatePath } from "next/cache"; import { requirePermission } from "@/lib/admin/guard"; import { CatalogItemsBc, CatalogPagesBc, db } from "@/lib/db"; import { PERMS } from "@/lib/permissions"; +import { withCatalogExport } from "@/lib/services/catalog-git-queue"; import { rcon } from "@/lib/services/rcon"; import { logStaffActivity } from "@/lib/services/staff-activity"; @@ -52,39 +53,43 @@ export async function updateBcPage({ ...fields }: { id: number } & Record) { const staff = await requirePermission(PERMS.CATALOG_EDIT); - const data = pickAllowed(fields, BC_PAGE_FIELDS); - if (Object.keys(data).length === 0) { - return { ok: false as const, error: "No valid fields to update" }; - } - await db - .update(CatalogPagesBc) - .set(data as Partial) - .where(eq(CatalogPagesBc.id, id)); - await rcon.updateCatalog(); - await logStaffActivity({ - staffId: staff.id, - action: "bc_page_update", - description: `Updated BC catalog page #${id}`, - targetType: "catalog_page_bc", - targetId: id, + return await withCatalogExport(async () => { + const data = pickAllowed(fields, BC_PAGE_FIELDS); + if (Object.keys(data).length === 0) { + return { ok: false as const, error: "No valid fields to update" }; + } + await db + .update(CatalogPagesBc) + .set(data as Partial) + .where(eq(CatalogPagesBc.id, id)); + await rcon.updateCatalog(); + await logStaffActivity({ + staffId: staff.id, + action: "bc_page_update", + description: `Updated BC catalog page #${id}`, + targetType: "catalog_page_bc", + targetId: id, + }); + revalidatePath("/admin/catalog/builder-club"); + return { ok: true as const }; }); - revalidatePath("/admin/catalog/builder-club"); - return { ok: true as const }; } export async function deleteBcItem({ id }: { id: number }) { const staff = await requirePermission(PERMS.CATALOG_EDIT); - await db.delete(CatalogItemsBc).where(eq(CatalogItemsBc.id, id)); - await rcon.updateCatalog(); - await logStaffActivity({ - staffId: staff.id, - action: "bc_item_delete", - description: `Deleted BC catalog item #${id}`, - targetType: "catalog_item_bc", - targetId: id, + return await withCatalogExport(async () => { + await db.delete(CatalogItemsBc).where(eq(CatalogItemsBc.id, id)); + await rcon.updateCatalog(); + await logStaffActivity({ + staffId: staff.id, + action: "bc_item_delete", + description: `Deleted BC catalog item #${id}`, + targetType: "catalog_item_bc", + targetId: id, + }); + revalidatePath("/admin/catalog/builder-club"); + return { ok: true as const }; }); - revalidatePath("/admin/catalog/builder-club"); - return { ok: true as const }; } export async function updateBcItem({ @@ -98,24 +103,26 @@ export async function updateBcItem({ extradata?: string; }) { const staff = await requirePermission(PERMS.CATALOG_EDIT); - const safe = pickAllowed(data as Record, BC_ITEM_FIELDS); - if (Object.keys(safe).length === 0) { - return { ok: false as const, error: "No valid fields to update" }; - } - await db - .update(CatalogItemsBc) - .set(safe as Partial) - .where(eq(CatalogItemsBc.id, id)); - await rcon.updateCatalog(); - await logStaffActivity({ - staffId: staff.id, - action: "bc_item_update", - description: `Updated BC catalog item #${id}`, - targetType: "catalog_item_bc", - targetId: id, + return await withCatalogExport(async () => { + const safe = pickAllowed(data as Record, BC_ITEM_FIELDS); + if (Object.keys(safe).length === 0) { + return { ok: false as const, error: "No valid fields to update" }; + } + await db + .update(CatalogItemsBc) + .set(safe as Partial) + .where(eq(CatalogItemsBc.id, id)); + await rcon.updateCatalog(); + await logStaffActivity({ + staffId: staff.id, + action: "bc_item_update", + description: `Updated BC catalog item #${id}`, + targetType: "catalog_item_bc", + targetId: id, + }); + revalidatePath("/admin/catalog/builder-club"); + return { ok: true as const }; }); - revalidatePath("/admin/catalog/builder-club"); - return { ok: true as const }; } export async function createBcItem({ @@ -129,18 +136,22 @@ export async function createBcItem({ extradata: string; }) { const staff = await requirePermission(PERMS.CATALOG_EDIT); - const [result] = await db.insert(CatalogItemsBc).values({ pageId, ...data }); - const createdId = Number(result.insertId); - await rcon.updateCatalog(); - await logStaffActivity({ - staffId: staff.id, - action: "bc_item_create", - description: `Created BC catalog item #${createdId}`, - targetType: "catalog_item_bc", - targetId: createdId, + return await withCatalogExport(async () => { + const [result] = await db + .insert(CatalogItemsBc) + .values({ pageId, ...data }); + const createdId = Number(result.insertId); + await rcon.updateCatalog(); + await logStaffActivity({ + staffId: staff.id, + action: "bc_item_create", + description: `Created BC catalog item #${createdId}`, + targetType: "catalog_item_bc", + targetId: createdId, + }); + revalidatePath("/admin/catalog/builder-club"); + return { ok: true as const, data: { id: createdId } }; }); - revalidatePath("/admin/catalog/builder-club"); - return { ok: true as const, data: { id: createdId } }; } export async function toggleBcPage({ @@ -151,22 +162,24 @@ export async function toggleBcPage({ field: "enabled" | "visible"; }) { await requirePermission(PERMS.CATALOG_EDIT); - const [page] = await db - .select({ - enabled: CatalogPagesBc.enabled, - visible: CatalogPagesBc.visible, - }) - .from(CatalogPagesBc) - .where(eq(CatalogPagesBc.id, id)) - .limit(1); - if (!page) return { ok: false as const, error: "Page not found" }; - await db - .update(CatalogPagesBc) - .set({ [field]: page[field] === "1" ? "0" : "1" }) - .where(eq(CatalogPagesBc.id, id)); - await rcon.updateCatalog(); - revalidatePath("/admin/catalog/builder-club"); - return { ok: true as const }; + return await withCatalogExport(async () => { + const [page] = await db + .select({ + enabled: CatalogPagesBc.enabled, + visible: CatalogPagesBc.visible, + }) + .from(CatalogPagesBc) + .where(eq(CatalogPagesBc.id, id)) + .limit(1); + if (!page) return { ok: false as const, error: "Page not found" }; + await db + .update(CatalogPagesBc) + .set({ [field]: page[field] === "1" ? "0" : "1" }) + .where(eq(CatalogPagesBc.id, id)); + await rcon.updateCatalog(); + revalidatePath("/admin/catalog/builder-club"); + return { ok: true as const }; + }); } export async function createBcPage(input: { @@ -180,30 +193,32 @@ export async function createBcPage(input: { orderNum?: number; }) { const staff = await requirePermission(PERMS.CATALOG_EDIT); - const [result] = await db.insert(CatalogPagesBc).values({ - caption: input.caption, - parentId: input.parentId, - pageLayout: input.pageLayout ?? "default_3x3", - iconColor: input.iconColor ?? 0, - iconImage: input.iconImage ?? 0, - orderNum: input.orderNum ?? 0, - visible: input.visible ?? "1", - enabled: input.enabled ?? "1", - pageHeadline: "", - pageTeaser: "", + return await withCatalogExport(async () => { + const [result] = await db.insert(CatalogPagesBc).values({ + caption: input.caption, + parentId: input.parentId, + pageLayout: input.pageLayout ?? "default_3x3", + iconColor: input.iconColor ?? 0, + iconImage: input.iconImage ?? 0, + orderNum: input.orderNum ?? 0, + visible: input.visible ?? "1", + enabled: input.enabled ?? "1", + pageHeadline: "", + pageTeaser: "", + }); + const createdId = Number(result.insertId); + await rcon.updateCatalog(); + await logStaffActivity({ + staffId: staff.id, + action: "bc_page_create", + description: `Created BC catalog page "${input.caption}"`, + targetType: "catalog_page_bc", + targetId: createdId, + }); + revalidatePath("/admin/catalog"); + revalidatePath("/admin/catalog/builder-club"); + return { ok: true as const, data: { id: createdId } }; }); - const createdId = Number(result.insertId); - await rcon.updateCatalog(); - await logStaffActivity({ - staffId: staff.id, - action: "bc_page_create", - description: `Created BC catalog page "${input.caption}"`, - targetType: "catalog_page_bc", - targetId: createdId, - }); - revalidatePath("/admin/catalog"); - revalidatePath("/admin/catalog/builder-club"); - return { ok: true as const, data: { id: createdId } }; } async function moveBcPage(pageId: number, newParentId: number): Promise { @@ -234,24 +249,26 @@ export async function reorderBcTreePage(input: { newOrderNum: number; }) { await requirePermission(PERMS.CATALOG_EDIT); - if (input.newParentId !== undefined) { - try { - await moveBcPage(input.pageId, input.newParentId); - } catch (err) { - return { - ok: false as const, - error: err instanceof Error ? err.message : "Invalid move", - }; + return await withCatalogExport(async () => { + if (input.newParentId !== undefined) { + try { + await moveBcPage(input.pageId, input.newParentId); + } catch (err) { + return { + ok: false as const, + error: err instanceof Error ? err.message : "Invalid move", + }; + } } - } - await db - .update(CatalogPagesBc) - .set({ orderNum: input.newOrderNum }) - .where(eq(CatalogPagesBc.id, input.pageId)); - await rcon.updateCatalog(); - revalidatePath("/admin/catalog"); - revalidatePath("/admin/catalog/builder-club"); - return { ok: true as const, data: {} }; + await db + .update(CatalogPagesBc) + .set({ orderNum: input.newOrderNum }) + .where(eq(CatalogPagesBc.id, input.pageId)); + await rcon.updateCatalog(); + revalidatePath("/admin/catalog"); + revalidatePath("/admin/catalog/builder-club"); + return { ok: true as const, data: {} }; + }); } export async function deleteBcTreePage(input: { @@ -259,52 +276,54 @@ export async function deleteBcTreePage(input: { mode: "reparent" | "cascade"; }) { await requirePermission(PERMS.CATALOG_EDIT); - const [page] = await db - .select({ parentId: CatalogPagesBc.parentId }) - .from(CatalogPagesBc) - .where(eq(CatalogPagesBc.id, input.pageId)) - .limit(1); - if (!page) return { ok: false as const, error: "Page not found" }; + return await withCatalogExport(async () => { + const [page] = await db + .select({ parentId: CatalogPagesBc.parentId }) + .from(CatalogPagesBc) + .where(eq(CatalogPagesBc.id, input.pageId)) + .limit(1); + if (!page) return { ok: false as const, error: "Page not found" }; - if (input.mode === "reparent") { - await db.transaction(async (tx) => { - await tx - .update(CatalogPagesBc) - .set({ parentId: page.parentId }) - .where(eq(CatalogPagesBc.parentId, input.pageId)); - await tx - .delete(CatalogItemsBc) - .where(eq(CatalogItemsBc.pageId, input.pageId)); - await tx - .delete(CatalogPagesBc) - .where(eq(CatalogPagesBc.id, input.pageId)); - }); - } else { - const toDelete: number[] = [input.pageId]; - const queue: number[] = [input.pageId]; - while (queue.length > 0) { - const children = await db - .select({ id: CatalogPagesBc.id }) - .from(CatalogPagesBc) - .where(inArray(CatalogPagesBc.parentId, queue)); - queue.length = 0; - for (const child of children) { - toDelete.push(child.id); - queue.push(child.id); + if (input.mode === "reparent") { + await db.transaction(async (tx) => { + await tx + .update(CatalogPagesBc) + .set({ parentId: page.parentId }) + .where(eq(CatalogPagesBc.parentId, input.pageId)); + await tx + .delete(CatalogItemsBc) + .where(eq(CatalogItemsBc.pageId, input.pageId)); + await tx + .delete(CatalogPagesBc) + .where(eq(CatalogPagesBc.id, input.pageId)); + }); + } else { + const toDelete: number[] = [input.pageId]; + const queue: number[] = [input.pageId]; + while (queue.length > 0) { + const children = await db + .select({ id: CatalogPagesBc.id }) + .from(CatalogPagesBc) + .where(inArray(CatalogPagesBc.parentId, queue)); + queue.length = 0; + for (const child of children) { + toDelete.push(child.id); + queue.push(child.id); + } } + await db.transaction(async (tx) => { + await tx + .delete(CatalogItemsBc) + .where(inArray(CatalogItemsBc.pageId, toDelete)); + await tx + .delete(CatalogPagesBc) + .where(inArray(CatalogPagesBc.id, toDelete)); + }); } - await db.transaction(async (tx) => { - await tx - .delete(CatalogItemsBc) - .where(inArray(CatalogItemsBc.pageId, toDelete)); - await tx - .delete(CatalogPagesBc) - .where(inArray(CatalogPagesBc.id, toDelete)); - }); - } - await rcon.updateCatalog(); - revalidatePath("/admin/catalog"); - revalidatePath("/admin/catalog/builder-club"); - return { ok: true as const, data: {} }; + await rcon.updateCatalog(); + revalidatePath("/admin/catalog"); + revalidatePath("/admin/catalog/builder-club"); + return { ok: true as const, data: {} }; + }); } diff --git a/src/actions/catalog-items.ts b/src/actions/catalog-items.ts index b4b2c069..1340de3c 100644 --- a/src/actions/catalog-items.ts +++ b/src/actions/catalog-items.ts @@ -6,6 +6,7 @@ import { requirePermission } from "@/lib/admin/guard"; import { CatalogItems, db, ItemsBase } from "@/lib/db"; import { PERMS } from "@/lib/permissions"; import { logAudit } from "@/lib/services/audit"; +import { withCatalogExport } from "@/lib/services/catalog-git-queue"; import { allocateCatalogItemId } from "@/lib/services/furni-import"; import { rcon } from "@/lib/services/rcon"; import { logStaffActivity } from "@/lib/services/staff-activity"; @@ -123,32 +124,34 @@ export async function createCatalogItem(data: { clubOnly: "0" | "1"; }) { const staff = await requirePermission(PERMS.CATALOG_EDIT); - let catalogName = data.catalogName.trim(); - if (!catalogName) { - const firstId = Number.parseInt(data.itemIds.split(";")[0] || "", 10); - if (firstId > 0) { - const [base] = await db - .select({ - publicName: ItemsBase.publicName, - itemName: ItemsBase.itemName, - }) - .from(ItemsBase) - .where(eq(ItemsBase.id, firstId)) - .limit(1); - catalogName = base?.publicName || base?.itemName || String(firstId); + return await withCatalogExport(async () => { + let catalogName = data.catalogName.trim(); + if (!catalogName) { + const firstId = Number.parseInt(data.itemIds.split(";")[0] || "", 10); + if (firstId > 0) { + const [base] = await db + .select({ + publicName: ItemsBase.publicName, + itemName: ItemsBase.itemName, + }) + .from(ItemsBase) + .where(eq(ItemsBase.id, firstId)) + .limit(1); + catalogName = base?.publicName || base?.itemName || String(firstId); + } } - } - const id = await insertCatalogItemRow({ ...data, catalogName }); - await rcon.updateCatalog(); - await logStaffActivity({ - staffId: staff.id, - action: "catalog_item_create", - description: `Created catalog item #${id}`, - targetType: "catalog_item", - targetId: id, + const id = await insertCatalogItemRow({ ...data, catalogName }); + await rcon.updateCatalog(); + await logStaffActivity({ + staffId: staff.id, + action: "catalog_item_create", + description: `Created catalog item #${id}`, + targetType: "catalog_item", + targetId: id, + }); + revalidatePath("/admin/catalog"); + return { ok: true as const, data: { id } }; }); - revalidatePath("/admin/catalog"); - return { ok: true as const, data: { id } }; } /** Bulk create with one RCON refresh at the end. */ @@ -165,84 +168,88 @@ export async function bulkCreateCatalogItems({ }>; }) { const staff = await requirePermission(PERMS.CATALOG_EDIT); - if (rows.length === 0) { - return { ok: true as const, data: { created: 0, failed: 0 } }; - } - if (rows.length > 500) { - return { ok: false as const, error: "Max 500 items per bulk import" }; - } - - const baseIds = [...new Set(rows.map((r) => r.baseId))]; - const bases = await db - .select({ - id: ItemsBase.id, - publicName: ItemsBase.publicName, - itemName: ItemsBase.itemName, - }) - .from(ItemsBase) - .where(inArray(ItemsBase.id, baseIds)); - const baseMap = new Map(bases.map((b) => [b.id, b])); - - let created = 0; - let failed = 0; - - for (const row of rows) { - const base = baseMap.get(row.baseId); - if (!base) { - failed++; - continue; + return await withCatalogExport(async () => { + if (rows.length === 0) { + return { ok: true as const, data: { created: 0, failed: 0 } }; } - try { - await insertCatalogItemRow({ - pageId, - itemIds: String(row.baseId), - catalogName: base.publicName || base.itemName || String(row.baseId), - costCredits: row.credits ?? 0, - costPoints: row.points ?? 0, - pointsType: row.pointsType ?? 0, - amount: 1, - limitedSells: 0, - limitedStack: 0, - orderNumber: 1, - offerId: -1, - songId: 0, - haveOffer: "1", - clubOnly: "0", - extradata: "", + if (rows.length > 500) { + return { ok: false as const, error: "Max 500 items per bulk import" }; + } + + const baseIds = [...new Set(rows.map((r) => r.baseId))]; + const bases = await db + .select({ + id: ItemsBase.id, + publicName: ItemsBase.publicName, + itemName: ItemsBase.itemName, + }) + .from(ItemsBase) + .where(inArray(ItemsBase.id, baseIds)); + const baseMap = new Map(bases.map((b) => [b.id, b])); + + let created = 0; + let failed = 0; + + for (const row of rows) { + const base = baseMap.get(row.baseId); + if (!base) { + failed++; + continue; + } + try { + await insertCatalogItemRow({ + pageId, + itemIds: String(row.baseId), + catalogName: base.publicName || base.itemName || String(row.baseId), + costCredits: row.credits ?? 0, + costPoints: row.points ?? 0, + pointsType: row.pointsType ?? 0, + amount: 1, + limitedSells: 0, + limitedStack: 0, + orderNumber: 1, + offerId: -1, + songId: 0, + haveOffer: "1", + clubOnly: "0", + extradata: "", + }); + created++; + } catch { + failed++; + } + } + + if (created > 0) { + await rcon.updateCatalog(); + await logStaffActivity({ + staffId: staff.id, + action: "catalog_items_bulk_create", + description: `Bulk imported ${created} catalog item(s) on page #${pageId}`, + targetType: "catalog_page", + targetId: pageId, }); - created++; - } catch { - failed++; + revalidatePath("/admin/catalog"); } - } - if (created > 0) { - await rcon.updateCatalog(); - await logStaffActivity({ - staffId: staff.id, - action: "catalog_items_bulk_create", - description: `Bulk imported ${created} catalog item(s) on page #${pageId}`, - targetType: "catalog_page", - targetId: pageId, - }); - revalidatePath("/admin/catalog"); - } - - return { ok: true as const, data: { created, failed } }; + return { ok: true as const, data: { created, failed } }; + }); } export async function deleteCatalogItems({ ids }: { ids: number[] }) { const staff = await requirePermission(PERMS.CATALOG_EDIT); - await db.delete(CatalogItems).where(inArray(CatalogItems.id, ids)); - await rcon.updateCatalog(); - await logStaffActivity({ - staffId: staff.id, - action: "catalog_items_delete", - description: `Deleted catalog items: ${ids.join(", ")}`, - targetType: "catalog_item", + return await withCatalogExport(async () => { + await db.delete(CatalogItems).where(inArray(CatalogItems.id, ids)); + await rcon.updateCatalog(); + await logStaffActivity({ + staffId: staff.id, + action: "catalog_items_delete", + description: `Deleted catalog items: ${ids.join(", ")}`, + targetType: "catalog_item", + }); + revalidatePath("/admin/catalog"); + return { ok: true as const, data: {} }; }); - revalidatePath("/admin/catalog"); - return { ok: true as const, data: {} }; } export async function moveCatalogItems({ @@ -253,11 +260,12 @@ export async function moveCatalogItems({ targetPageId: number; }) { await requirePermission(PERMS.CATALOG_EDIT); - if (ids.length === 0) { - return { ok: true as const, data: {} }; - } - const pageIdStr = String(targetPageId); - await db.execute(sql` + return await withCatalogExport(async () => { + if (ids.length === 0) { + return { ok: true as const, data: {} }; + } + const pageIdStr = String(targetPageId); + await db.execute(sql` UPDATE catalog_items SET page_id = ${pageIdStr} WHERE id IN (${sql.join( @@ -265,9 +273,10 @@ export async function moveCatalogItems({ sql`, `, )}) `); - await rcon.updateCatalog(); - revalidatePath("/admin/catalog"); - return { ok: true as const, data: {} }; + await rcon.updateCatalog(); + revalidatePath("/admin/catalog"); + return { ok: true as const, data: {} }; + }); } export async function reorderCatalogItems({ @@ -276,15 +285,17 @@ export async function reorderCatalogItems({ orders: Array<{ id: number; orderNumber: number }>; }) { await requirePermission(PERMS.CATALOG_EDIT); - for (const { id, orderNumber } of orders) { - await db - .update(CatalogItems) - .set({ orderNumber }) - .where(eq(CatalogItems.id, id)); - } - await rcon.updateCatalog(); - revalidatePath("/admin/catalog"); - return { ok: true as const, data: {} }; + return await withCatalogExport(async () => { + for (const { id, orderNumber } of orders) { + await db + .update(CatalogItems) + .set({ orderNumber }) + .where(eq(CatalogItems.id, id)); + } + await rcon.updateCatalog(); + revalidatePath("/admin/catalog"); + return { ok: true as const, data: {} }; + }); } export async function updateCatalogItem({ @@ -297,46 +308,48 @@ export async function updateCatalogItem({ baseItem?: { id: number; fields: Record }; }) { const staff = await requirePermission(PERMS.CATALOG_EDIT); - const safeCatalog = pickAllowed(catalogFields, CATALOG_ITEM_FIELDS); - if (Object.keys(safeCatalog).length === 0 && !baseItem) { - return { ok: false as const, error: "No valid fields to update" }; - } + return await withCatalogExport(async () => { + const safeCatalog = pickAllowed(catalogFields, CATALOG_ITEM_FIELDS); + if (Object.keys(safeCatalog).length === 0 && !baseItem) { + return { ok: false as const, error: "No valid fields to update" }; + } - // page_id is often VARCHAR — update it via raw SQL when present. - const pageIdRaw = safeCatalog.pageId; - if (pageIdRaw !== undefined) { - const pageIdStr = String(pageIdRaw); - await db.execute(sql` + // page_id is often VARCHAR — update it via raw SQL when present. + const pageIdRaw = safeCatalog.pageId; + if (pageIdRaw !== undefined) { + const pageIdStr = String(pageIdRaw); + await db.execute(sql` UPDATE catalog_items SET page_id = ${pageIdStr} WHERE id = ${id} `); - delete safeCatalog.pageId; - } - - if (Object.keys(safeCatalog).length > 0) { - await db - .update(CatalogItems) - .set(safeCatalog as Partial) - .where(eq(CatalogItems.id, id)); - } - if (baseItem) { - const safeBase = pickAllowed(baseItem.fields, ITEMS_BASE_FIELDS); - if (Object.keys(safeBase).length > 0) { - await db - .update(ItemsBase) - .set(safeBase as Partial) - .where(eq(ItemsBase.id, baseItem.id)); + delete safeCatalog.pageId; } - } - await rcon.updateCatalog(); - await logStaffActivity({ - staffId: staff.id, - action: "catalog_item_update", - description: `Updated catalog item #${id}`, - targetType: "catalog_item", - targetId: id, + + if (Object.keys(safeCatalog).length > 0) { + await db + .update(CatalogItems) + .set(safeCatalog as Partial) + .where(eq(CatalogItems.id, id)); + } + if (baseItem) { + const safeBase = pickAllowed(baseItem.fields, ITEMS_BASE_FIELDS); + if (Object.keys(safeBase).length > 0) { + await db + .update(ItemsBase) + .set(safeBase as Partial) + .where(eq(ItemsBase.id, baseItem.id)); + } + } + await rcon.updateCatalog(); + await logStaffActivity({ + staffId: staff.id, + action: "catalog_item_update", + description: `Updated catalog item #${id}`, + targetType: "catalog_item", + targetId: id, + }); + revalidatePath("/admin/catalog"); + return { ok: true as const, data: {} }; }); - revalidatePath("/admin/catalog"); - return { ok: true as const, data: {} }; } export async function translateCatalogItems(input: { @@ -344,121 +357,124 @@ export async function translateCatalogItems(input: { items: Array<{ id: number; publicName: string; description?: string }>; }) { const staff = await requirePermission(PERMS.CATALOG_EDIT); - const parsed = translateItemsSchema.safeParse(input); - if (!parsed.success) { - return { - ok: false as const, - error: parsed.error.issues[0]?.message ?? "Invalid translate payload", - }; - } - const { items } = parsed.data; - const { invalidateFurniDataCache } = await import( - "@/lib/services/catalog-items-loader" - ); - const { patchFurniEntryNames } = await import("@/lib/services/furni-data"); + return await withCatalogExport(async () => { + const parsed = translateItemsSchema.safeParse(input); + if (!parsed.success) { + return { + ok: false as const, + error: parsed.error.issues[0]?.message ?? "Invalid translate payload", + }; + } + const { items } = parsed.data; + const { invalidateFurniDataCache } = await import( + "@/lib/services/catalog-items-loader" + ); + const { patchFurniEntryNames } = await import("@/lib/services/furni-data"); - let namesUpdated = 0; - let descriptionsUpdated = 0; - const furniPatches: Array<{ - classname: string; - itemType: string; - name?: string; - description?: string; - spriteId?: number; - createIfMissing?: boolean; - }> = []; + let namesUpdated = 0; + let descriptionsUpdated = 0; + const furniPatches: Array<{ + classname: string; + itemType: string; + name?: string; + description?: string; + spriteId?: number; + createIfMissing?: boolean; + }> = []; - for (const item of items) { - const [base] = await db - .select({ - id: ItemsBase.id, - publicName: ItemsBase.publicName, - itemName: ItemsBase.itemName, - type: ItemsBase.type, - spriteId: ItemsBase.spriteId, - }) - .from(ItemsBase) - .where(eq(ItemsBase.id, item.id)) - .limit(1); - if (!base) continue; - - const nextName = item.publicName?.trim() ?? ""; - const nextDesc = item.description ?? ""; - const nameChanged = nextName !== "" && nextName !== (base.publicName ?? ""); - - if (nameChanged) { - await db - .update(ItemsBase) - .set({ publicName: nextName }) - .where(eq(ItemsBase.id, base.id)); - const idStr = String(base.id); - const related = await db + for (const item of items) { + const [base] = await db .select({ - id: CatalogItems.id, - catalogName: CatalogItems.catalogName, + id: ItemsBase.id, + publicName: ItemsBase.publicName, + itemName: ItemsBase.itemName, + type: ItemsBase.type, + spriteId: ItemsBase.spriteId, }) - .from(CatalogItems) - .where( - or( - eq(CatalogItems.itemIds, idStr), - like(CatalogItems.itemIds, `${idStr};%`), - like(CatalogItems.itemIds, `%;${idStr};%`), - like(CatalogItems.itemIds, `%;${idStr}`), - ), - ); - for (const row of related) { - if (row.catalogName !== nextName) { - await db - .update(CatalogItems) - .set({ catalogName: nextName }) - .where(eq(CatalogItems.id, row.id)); + .from(ItemsBase) + .where(eq(ItemsBase.id, item.id)) + .limit(1); + if (!base) continue; + + const nextName = item.publicName?.trim() ?? ""; + const nextDesc = item.description ?? ""; + const nameChanged = + nextName !== "" && nextName !== (base.publicName ?? ""); + + if (nameChanged) { + await db + .update(ItemsBase) + .set({ publicName: nextName }) + .where(eq(ItemsBase.id, base.id)); + const idStr = String(base.id); + const related = await db + .select({ + id: CatalogItems.id, + catalogName: CatalogItems.catalogName, + }) + .from(CatalogItems) + .where( + or( + eq(CatalogItems.itemIds, idStr), + like(CatalogItems.itemIds, `${idStr};%`), + like(CatalogItems.itemIds, `%;${idStr};%`), + like(CatalogItems.itemIds, `%;${idStr}`), + ), + ); + for (const row of related) { + if (row.catalogName !== nextName) { + await db + .update(CatalogItems) + .set({ catalogName: nextName }) + .where(eq(CatalogItems.id, row.id)); + } } + namesUpdated++; + } + + if (nextDesc !== "" || nameChanged) { + descriptionsUpdated += nextDesc !== "" ? 1 : 0; + furniPatches.push({ + classname: base.itemName, + itemType: base.type || "s", + name: nextName || base.publicName || base.itemName, + description: nextDesc, + spriteId: base.spriteId, + createIfMissing: true, + }); } - namesUpdated++; } - if (nextDesc !== "" || nameChanged) { - descriptionsUpdated += nextDesc !== "" ? 1 : 0; - furniPatches.push({ - classname: base.itemName, - itemType: base.type || "s", - name: nextName || base.publicName || base.itemName, - description: nextDesc, - spriteId: base.spriteId, - createIfMissing: true, - }); + const furniResult = + furniPatches.length > 0 + ? await patchFurniEntryNames(furniPatches) + : { updated: 0, inserted: 0 }; + if (furniResult.updated > 0 || furniResult.inserted > 0) { + invalidateFurniDataCache(); } - } - const furniResult = - furniPatches.length > 0 - ? await patchFurniEntryNames(furniPatches) - : { updated: 0, inserted: 0 }; - if (furniResult.updated > 0 || furniResult.inserted > 0) { - invalidateFurniDataCache(); - } - - await rcon.updateCatalog(); - await logAudit({ - userId: staff.id, - action: "items_base_translate", - target: "ItemsBase", - after: { - namesUpdated, - descriptionsUpdated, - furniDataUpdated: furniResult.updated > 0, - furniDataInserted: furniResult.inserted, - }, + await rcon.updateCatalog(); + await logAudit({ + userId: staff.id, + action: "items_base_translate", + target: "ItemsBase", + after: { + namesUpdated, + descriptionsUpdated, + furniDataUpdated: furniResult.updated > 0, + furniDataInserted: furniResult.inserted, + }, + }); + revalidatePath("/admin/catalog"); + return { + ok: true as const, + data: { + namesUpdated, + descriptionsUpdated, + furniDataUpdated: furniResult.updated, + furniDataInserted: furniResult.inserted, + updated: items.length, + }, + }; }); - revalidatePath("/admin/catalog"); - return { - ok: true as const, - data: { - namesUpdated, - descriptionsUpdated, - furniDataUpdated: furniResult.updated, - furniDataInserted: furniResult.inserted, - updated: items.length, - }, - }; } diff --git a/src/actions/catalog.ts b/src/actions/catalog.ts index 8a4b14bd..c4511877 100644 --- a/src/actions/catalog.ts +++ b/src/actions/catalog.ts @@ -6,6 +6,7 @@ import { requirePermission } from "@/lib/admin/guard"; import { CatalogPages, db } from "@/lib/db"; import { PERMS } from "@/lib/permissions"; import type { ActionResult } from "@/lib/safe-action-shared"; +import { withCatalogExport } from "@/lib/services/catalog-git-queue"; import { deletePage, movePage } from "@/lib/services/catalog-tree"; import { rcon } from "@/lib/services/rcon"; import { logStaffActivity } from "@/lib/services/staff-activity"; @@ -48,42 +49,46 @@ export async function updateCatalogPage({ ...fields }: { id: number } & Record): Promise { const staff = await requirePermission(PERMS.CATALOG_EDIT); - const data = pickPageFields(fields); - if (Object.keys(data).length === 0) { - return { ok: false as const, error: "No valid fields to update" }; - } - if (typeof data.caption === "string" && !data.captionSave) { - data.captionSave = data.caption.slice(0, 25); - } - await db - .update(CatalogPages) - .set(data as Partial) - .where(eq(CatalogPages.id, id)); - await rcon.updateCatalog(); - await logStaffActivity({ - staffId: staff.id, - action: "catalog_page_update", - description: `Updated catalog page #${id}`, - targetType: "catalog_page", - targetId: id, + return await withCatalogExport(async () => { + const data = pickPageFields(fields); + if (Object.keys(data).length === 0) { + return { ok: false as const, error: "No valid fields to update" }; + } + if (typeof data.caption === "string" && !data.captionSave) { + data.captionSave = data.caption.slice(0, 25); + } + await db + .update(CatalogPages) + .set(data as Partial) + .where(eq(CatalogPages.id, id)); + await rcon.updateCatalog(); + await logStaffActivity({ + staffId: staff.id, + action: "catalog_page_update", + description: `Updated catalog page #${id}`, + targetType: "catalog_page", + targetId: id, + }); + revalidatePath("/admin/catalog"); + return { ok: true as const, data: {} }; }); - revalidatePath("/admin/catalog"); - return { ok: true as const, data: {} }; } export async function deleteCatalogPage({ id }: { id: number }) { const staff = await requirePermission(PERMS.CATALOG_EDIT); - await deletePage(id, "reparent"); - await rcon.updateCatalog(); - await logStaffActivity({ - staffId: staff.id, - action: "catalog_page_delete", - description: `Deleted catalog page #${id}`, - targetType: "catalog_page", - targetId: id, + return await withCatalogExport(async () => { + await deletePage(id, "reparent"); + await rcon.updateCatalog(); + await logStaffActivity({ + staffId: staff.id, + action: "catalog_page_delete", + description: `Deleted catalog page #${id}`, + targetType: "catalog_page", + targetId: id, + }); + revalidatePath("/admin/catalog"); + return { ok: true as const, data: {} }; }); - revalidatePath("/admin/catalog"); - return { ok: true as const, data: {} }; } export async function toggleCatalogPage({ @@ -94,24 +99,26 @@ export async function toggleCatalogPage({ action: "toggleEnabled" | "toggleVisible"; }) { await requirePermission(PERMS.CATALOG_EDIT); - const [page] = await db - .select({ - enabled: CatalogPages.enabled, - visible: CatalogPages.visible, - }) - .from(CatalogPages) - .where(eq(CatalogPages.id, id)) - .limit(1); - if (!page) return { ok: false as const, error: "Catalog page not found" }; - const field = action === "toggleEnabled" ? "enabled" : "visible"; - const current = action === "toggleEnabled" ? page.enabled : page.visible; - await db - .update(CatalogPages) - .set({ [field]: current === "1" ? "0" : "1" }) - .where(eq(CatalogPages.id, id)); - await rcon.updateCatalog(); - revalidatePath("/admin/catalog"); - return { ok: true as const, data: {} }; + return await withCatalogExport(async () => { + const [page] = await db + .select({ + enabled: CatalogPages.enabled, + visible: CatalogPages.visible, + }) + .from(CatalogPages) + .where(eq(CatalogPages.id, id)) + .limit(1); + if (!page) return { ok: false as const, error: "Catalog page not found" }; + const field = action === "toggleEnabled" ? "enabled" : "visible"; + const current = action === "toggleEnabled" ? page.enabled : page.visible; + await db + .update(CatalogPages) + .set({ [field]: current === "1" ? "0" : "1" }) + .where(eq(CatalogPages.id, id)); + await rcon.updateCatalog(); + revalidatePath("/admin/catalog"); + return { ok: true as const, data: {} }; + }); } export async function createCatalogPage(input: { @@ -126,34 +133,36 @@ export async function createCatalogPage(input: { orderNum?: number; }): Promise> { const staff = await requirePermission(PERMS.CATALOG_EDIT); - const [result] = await db.insert(CatalogPages).values({ - caption: input.caption, - parentId: input.parentId, - pageLayout: input.pageLayout ?? "default_3x3", - captionSave: input.caption.slice(0, 25), - iconColor: input.iconColor ?? 0, - iconImage: input.iconImage ?? 0, - minRank: input.minRank ?? 1, - orderNum: input.orderNum ?? 0, - visible: input.visible ?? "1", - enabled: input.enabled ?? "1", - clubOnly: "0", - vipOnly: "0", - pageHeadline: "", - pageTeaser: "", - includes: "", + return await withCatalogExport(async () => { + const [result] = await db.insert(CatalogPages).values({ + caption: input.caption, + parentId: input.parentId, + pageLayout: input.pageLayout ?? "default_3x3", + captionSave: input.caption.slice(0, 25), + iconColor: input.iconColor ?? 0, + iconImage: input.iconImage ?? 0, + minRank: input.minRank ?? 1, + orderNum: input.orderNum ?? 0, + visible: input.visible ?? "1", + enabled: input.enabled ?? "1", + clubOnly: "0", + vipOnly: "0", + pageHeadline: "", + pageTeaser: "", + includes: "", + }); + const createdId = Number(result.insertId); + await rcon.updateCatalog(); + await logStaffActivity({ + staffId: staff.id, + action: "catalog_page_create", + description: `Created catalog page "${input.caption}"`, + targetType: "catalog_page", + targetId: createdId, + }); + revalidatePath("/admin/catalog"); + return { ok: true as const, data: { id: createdId } }; }); - const createdId = Number(result.insertId); - await rcon.updateCatalog(); - await logStaffActivity({ - staffId: staff.id, - action: "catalog_page_create", - description: `Created catalog page "${input.caption}"`, - targetType: "catalog_page", - targetId: createdId, - }); - revalidatePath("/admin/catalog"); - return { ok: true as const, data: { id: createdId } }; } export async function reorderTreePage(input: { @@ -162,23 +171,25 @@ export async function reorderTreePage(input: { newOrderNum: number; }) { await requirePermission(PERMS.CATALOG_EDIT); - if (input.newParentId !== undefined) { - try { - await movePage(input.pageId, input.newParentId); - } catch (err) { - return { - ok: false as const, - error: err instanceof Error ? err.message : "Invalid move", - }; + return await withCatalogExport(async () => { + if (input.newParentId !== undefined) { + try { + await movePage(input.pageId, input.newParentId); + } catch (err) { + return { + ok: false as const, + error: err instanceof Error ? err.message : "Invalid move", + }; + } } - } - await db - .update(CatalogPages) - .set({ orderNum: input.newOrderNum }) - .where(eq(CatalogPages.id, input.pageId)); - await rcon.updateCatalog(); - revalidatePath("/admin/catalog"); - return { ok: true as const, data: {} }; + await db + .update(CatalogPages) + .set({ orderNum: input.newOrderNum }) + .where(eq(CatalogPages.id, input.pageId)); + await rcon.updateCatalog(); + revalidatePath("/admin/catalog"); + return { ok: true as const, data: {} }; + }); } export async function deleteTreePage(input: { @@ -186,8 +197,10 @@ export async function deleteTreePage(input: { mode: "reparent" | "cascade"; }) { await requirePermission(PERMS.CATALOG_EDIT); - await deletePage(input.pageId, input.mode); - await rcon.updateCatalog(); - revalidatePath("/admin/catalog"); - return { ok: true as const, data: {} }; + return await withCatalogExport(async () => { + await deletePage(input.pageId, input.mode); + await rcon.updateCatalog(); + revalidatePath("/admin/catalog"); + return { ok: true as const, data: {} }; + }); } diff --git a/src/actions/furni-maintenance.ts b/src/actions/furni-maintenance.ts index 2cbea7ac..382b3d1f 100644 --- a/src/actions/furni-maintenance.ts +++ b/src/actions/furni-maintenance.ts @@ -7,6 +7,7 @@ import { actionOk, handleActionError, } from "@/lib/safe-action-shared"; +import { withCatalogExport } from "@/lib/services/catalog-git-queue"; import type { AlignResult, DedupResult, @@ -38,7 +39,9 @@ export async function fixSpriteIdsAction(): Promise< > { try { await guard(); - return actionOk(await maintenance.fixSpriteIds()); + return await withCatalogExport(async () => { + return actionOk(await maintenance.fixSpriteIds()); + }); } catch (e) { return handleActionError(e); } @@ -49,7 +52,9 @@ export async function fixCatalogOffersAction(): Promise< > { try { await guard(); - return actionOk(await maintenance.fixCatalogOffers()); + return await withCatalogExport(async () => { + return actionOk(await maintenance.fixCatalogOffers()); + }); } catch (e) { return handleActionError(e); } @@ -60,7 +65,9 @@ export async function reconcileIdsAction(): Promise< > { try { await guard(); - return actionOk(await maintenance.reconcileIds()); + return await withCatalogExport(async () => { + return actionOk(await maintenance.reconcileIds()); + }); } catch (e) { return handleActionError(e); } @@ -71,7 +78,9 @@ export async function removeDuplicateItemsBaseAction(): Promise< > { try { await guard(); - return actionOk(await maintenance.removeDuplicates()); + return await withCatalogExport(async () => { + return actionOk(await maintenance.removeDuplicates()); + }); } catch (e) { return handleActionError(e); } @@ -93,7 +102,9 @@ export async function applyAlignIdsAction(): Promise< > { try { await guard(); - return actionOk(await maintenance.forceItemsBaseIdsToFurnidata(true)); + return await withCatalogExport(async () => { + return actionOk(await maintenance.forceItemsBaseIdsToFurnidata(true)); + }); } catch (e) { return handleActionError(e); } @@ -104,7 +115,9 @@ export async function fixEverythingAction(input?: { }): Promise> { try { await guard(); - return actionOk(await maintenance.fixEverything(input ?? {})); + return await withCatalogExport(async () => { + return actionOk(await maintenance.fixEverything(input ?? {})); + }); } catch (e) { return handleActionError(e); } diff --git a/src/actions/import-furni.ts b/src/actions/import-furni.ts index f41b1a28..cf84896e 100644 --- a/src/actions/import-furni.ts +++ b/src/actions/import-furni.ts @@ -5,6 +5,7 @@ import { db, WebsiteSetting } from "@/lib/db"; import { PERMS } from "@/lib/permissions"; import { adminAction } from "@/lib/safe-action"; import { actionOk } from "@/lib/safe-action-shared"; +import { withCatalogExport } from "@/lib/services/catalog-git-queue"; import { deleteImportedItem } from "@/lib/services/furni-import"; import { siteSettings } from "@/lib/services/site-settings"; @@ -14,10 +15,9 @@ export const deleteImportedFurni = adminAction( { permission: PERMS.ASSETS_IMPORT, schema: deleteSchema }, async (ctx) => actionOk( - (await deleteImportedItem(ctx.data.classname)) as unknown as Record< - string, - unknown - >, + (await withCatalogExport(() => + deleteImportedItem(ctx.data.classname), + )) as unknown as Record, ), ); diff --git a/src/app/admin/import/furni/nitro-editor-dialog.tsx b/src/app/admin/import/furni/nitro-editor-dialog.tsx index 2fefe5c2..a4f0f8e9 100644 --- a/src/app/admin/import/furni/nitro-editor-dialog.tsx +++ b/src/app/admin/import/furni/nitro-editor-dialog.tsx @@ -1,4 +1,5 @@ "use client"; +import "./nitro-editor.css"; import { Box, @@ -240,6 +241,7 @@ export function NitroEditorDialog({ setMetadata(JSON.parse(JSON.stringify(originalMetadata))); setFlags({ ...originalFlags }); setJsonText(JSON.stringify(originalMetadata, null, 2)); + setJsonError(null); toast.info("Reset to original values"); }; @@ -334,7 +336,9 @@ export function NitroEditorDialog({ const hasChanges = metadata && originalMetadata && - (JSON.stringify(metadata) !== JSON.stringify(originalMetadata) || + ((activeTab === "json" + ? jsonText !== JSON.stringify(originalMetadata, null, 2) + : JSON.stringify(metadata) !== JSON.stringify(originalMetadata)) || JSON.stringify(flags) !== JSON.stringify(originalFlags)); // Furni preview URL @@ -344,13 +348,16 @@ export function NitroEditorDialog({ return ( - + {/* Header with preview */}
{/* Furni preview */}
{/* Dimensions + Interaction in 2-column layout */} -
- {/* Dimensions card */} -
-
- -

Dimensions

-
-
-
- - setDim("x", e.target.value)} - className="mt-1" - /> +
+
+ {/* Dimensions card */} +
+
+ +

Dimensions

-
- - setDim("y", e.target.value)} - className="mt-1" - /> +
+
+ + setDim("x", e.target.value)} + className="mt-1" + /> +
+
+ + setDim("y", e.target.value)} + className="mt-1" + /> +
+
+ + setDim("z", e.target.value)} + className="mt-1" + /> +
-
- - setDim("z", e.target.value)} - className="mt-1" - /> -
-
-
+
+ {/* Available rotations */} +
+
+ +

Directions

+ + {directions.length} / 8 active + +
+
+ {ALL_DIRECTIONS.map((dir) => ( + + ))} +
+

+ Choose the available rotations. Keep at least one + direction active. +

+
+
{/* Interaction card */}
@@ -506,6 +544,7 @@ export function NitroEditorDialog({
Can Stand On setFlags((f) => ({ ...f, canstandon: !!v })) @@ -515,6 +554,7 @@ export function NitroEditorDialog({
Can Sit On setFlags((f) => ({ ...f, cansiton: !!v })) @@ -524,6 +564,7 @@ export function NitroEditorDialog({
Can Lay On setFlags((f) => ({ ...f, canlayon: !!v })) @@ -581,58 +622,6 @@ export function NitroEditorDialog({
- {/* Directions - visual compass */} -
-
- -

Directions

- - {directions.length} active - -
-
-
- {/* Compass circle */} -
-
- {/* Direction buttons arranged in circle */} - {ALL_DIRECTIONS.map((dir) => { - const angle = (dir * 45 - 90) * (Math.PI / 180); - const r = 80; - const x = 96 + r * Math.cos(angle) - 18; - const y = 96 + r * Math.sin(angle) - 18; - const isActive = directions.includes(dir); - return ( - - ); - })} - {/* Center label */} -
- - {directions.length}/8 - -
-
-
-
- {/* Types */}
@@ -858,7 +847,8 @@ export function NitroEditorDialog({ >